JVM 面试精选 20 题(续)
目录
- 1. 什么是 Java 内存模型(JMM)?
- 2. volatile 关键字的作用是什么?
- 3. synchronized 关键字的作用是什么?它的实现原理是?
- 4. 什么是偏向锁、轻量级锁和重量级锁?
- 5. JVM 为什么要使用 CAS(Compare-and-Swap)?
- 6. JVM 中,方法区和永久代(PermGen)是什么关系?
- 7. JVM 为什么要将新生代分为 Eden、From Survivor 和 To Survivor 区?
- 8. 什么是 JVM 类加载器的层次结构?
- 9. 什么是 Java 中的常量池?
- 10. 如何判断一个对象是否可以被回收?
- 11. G1 垃圾回收器的工作原理是什么?
- 12. JVM 中的 AOT 编译是什么?
- 13. JVM 中的本地方法接口(JNI)是什么?
- 14. JVM 调优的步骤和思路是什么?
- 15. JVM 的即时编译(JIT)和解释器有什么区别?
- 16. 说说 Minor GC 的过程。
- 17. 什么是 JVM 的分代收集?为什么分代?
- 18. 如何判断 GC Roots?
- 19. JVM 的类加载器可以自定义吗?为什么?
- 20. 什么是 JVM 的 safepoint?
JVM 面试精选 20 题
1. 什么是 Java 内存模型(JMM)?
解答:
Java 内存模型(Java Memory Model,JMM) 是 Java 虚拟机规范中定义的,用于抽象化计算机硬件内存模型,以解决多线程环境下对共享变量的访问问题。它定义了线程和主内存之间的关系,以及各种操作的可见性、有序性和原子性。
核心概念:
- 主内存(Main Memory):所有线程共享的内存区域,包含了所有对象的实例数据。
- 工作内存(Working Memory):每个线程私有的内存区域,保存了该线程使用到的变量在主内存中的副本。
JMM 规定了以下操作:
lock
和unlock
:同步操作,确保同一时刻只有一个线程可以持有某个锁。read
和load
:将主内存中的变量值读取到线程的工作内存中。store
和write
:将工作内存中的变量值写回到主内存中。
JMM 通过定义一系列规则,确保在多线程环境下,对共享变量的读写操作是可预测的,避免了可见性、有序性等问题。
2. volatile 关键字的作用是什么?
解答:
volatile
关键字是 Java 虚拟机提供的最轻量级的同步机制。它主要有两个作用:
- 保证可见性:当一个线程修改了
volatile
变量的值时,新值会立即同步到主内存中,并且其他线程的工作内存中的旧值会失效。因此,其他线程再次读取该变量时,会从主内存中获取最新值。 - 保证有序性:
volatile
禁止了指令重排序。它通过插入内存屏障(Memory Barrier)来确保在其之前的读写操作都已执行完成,并且在其之后的读写操作不会被提前执行。
需要注意的是:volatile
只能保证可见性和有序性,不能保证原子性。例如,i++
操作并不是原子的,它包含“读取-修改-写入”三个步骤,如果多个线程同时操作,仍然会出错。要保证原子性,需要使用 synchronized
或 java.util.concurrent.atomic
包下的类。
3. synchronized 关键字的作用是什么?它的实现原理是?
解答:
synchronized
是 Java 中的一个同步关键字,它可以修饰方法或代码块,用于实现线程间的同步。它主要保证了三个特性:
- 原子性:被
synchronized
包裹的代码块是不可分割的,要么执行完成,要么不执行。 - 可见性:当一个线程执行完
synchronized
代码块后,对共享变量的修改会立即同步到主内存中,其他线程可以看到最新的值。 - 有序性:
synchronized
代码块内的指令不会发生重排序。
实现原理:
synchronized
的实现基于 Monitor(管程)。
- 修饰方法:JVM 通过在方法字节码中添加
ACC_SYNCHRONIZED
标志位来实现。当线程进入该方法时,会获取 Monitor 锁;当方法执行完成(无论正常退出还是异常退出)时,会释放 Monitor 锁。 - 修饰代码块:JVM 通过
monitorenter
和monitorexit
字节码指令来实现。monitorenter
指令在进入同步代码块时执行,获取 Monitor 锁;monitorexit
指令在退出同步代码块时执行,释放 Monitor 锁。
4. 什么是偏向锁、轻量级锁和重量级锁?
解答:
这是 HotSpot JVM 为了提高 synchronized
的性能而采用的锁升级策略,锁的状态从无锁逐步升级,直到重量级锁。
-
偏向锁(Biased Locking):
- 适用场景:一个线程多次获取同一个锁。
- 原理:当一个线程第一次获取锁时,JVM 会在对象头中记录该线程 ID,此后该线程进入同步代码块时,无需再次加锁,直接执行。
- 升级:当另一个线程尝试获取该锁时,偏向锁会升级为轻量级锁。
-
轻量级锁(Lightweight Locking):
- 适用场景:在短时间内,多个线程交替获取锁,但没有竞争。
- 原理:线程在自己的栈帧中创建锁记录(Lock Record),并将对象头的 Mark Word 复制到锁记录中。然后,通过 CAS(Compare-and-Swap)操作尝试将 Mark Word 替换为指向锁记录的指针。如果成功,则获取锁。
- 升级:如果 CAS 失败(说明有多个线程在竞争),轻量级锁会升级为重量级锁。
-
重量级锁(Heavyweight Locking):
- 适用场景:多个线程在激烈竞争同一个锁。
- 原理:依赖操作系统底层的 Mutex(互斥量)来实现。线程会阻塞并进入内核态,释放 CPU 资源。上下文切换的开销较大。
5. JVM 为什么要使用 CAS(Compare-and-Swap)?
解答:
CAS(Compare-and-Swap) 是一种乐观锁机制,它是一种无锁的、非阻塞的算法。
核心思想:它包含三个操作数:
- V(需要更新的变量值)
- A(预期的旧值)
- B(要更新的新值)
当且仅当 V
的值等于 A
时,才会将 V
的值更新为 B
,否则不进行任何操作。
JVM 使用 CAS 的目的:
- 提高性能:CAS 是一种原子操作,由 CPU 硬件指令支持。它不需要像重量级锁那样,让线程阻塞、进入内核态,减少了上下文切换的开销。
- 实现无锁并发:通过 CAS,可以在不使用重量级锁的情况下,实现线程安全。例如,
java.util.concurrent.atomic
包下的原子类,就是通过 CAS 来保证操作的原子性。
6. JVM 中,方法区和永久代(PermGen)是什么关系?
解答:
方法区(Method Area) 是《Java 虚拟机规范》中定义的一块运行时数据区,用于存放类信息、常量、静态变量等。
永久代(Permanent Generation,PermGen) 是 HotSpot JVM 在 JDK 1.8 之前,对方法区的一种具体实现。它将方法区的数据放在了 Java 堆中。由于永久代有固定大小,因此容易发生 OutOfMemoryError: PermGen space
。
JDK 1.8 后的变化:
- 在 JDK 1.8 中,永久代被彻底移除,取而代之的是 元空间(Metaspace)。
- 元空间 将方法区的数据存放到了本地内存中,而不是 JVM 堆中。
- 这样做的好处是,元空间的大小只受本地内存大小的限制,避免了永久代固定的内存大小带来的 OOM 问题。
7. JVM 为什么要将新生代分为 Eden、From Survivor 和 To Survivor 区?
解答:
为了提高复制算法的效率和内存利用率。
- 如果只用两个区(复制算法),内存利用率只有 50%。
- 如果分成 Eden、From Survivor 和 To Survivor 三个区:
- Eden 区:大部分新创建的对象都分配在这里。
- Survivor 区:用于存放经过一次 Minor GC 后仍然存活的对象。
GC 流程:
- 新对象在 Eden 区创建。
- 当 Eden 区满了,触发 Minor GC。
- 存活的对象被复制到 To Survivor 区,并且年龄加 1。
- From Survivor 区中存活的对象,如果年龄达到晋升老年代的阈值,则进入老年代;否则也复制到 To Survivor 区,年龄加 1。
- 清空 Eden 区和 From Survivor 区。
- 交换 From 和 To 的角色,保证每次 GC 都只使用一块 Survivor 区。
这种方式将复制算法的内存利用率提升到了 90%(Eden 区占 8/10,两个 Survivor 各占 1/10)。
8. 什么是 JVM 类加载器的层次结构?
解答:
Java 虚拟机默认有三个类加载器,它们之间是父子关系,遵循双亲委派模型:
-
启动类加载器(Bootstrap ClassLoader):
- 负责加载
<JAVA_HOME>/lib
目录下的核心类库,如rt.jar
。 - 它不是
java.lang.ClassLoader
的子类,是用 C++ 实现的,所以无法在 Java 代码中直接获取它的引用。
- 负责加载
-
扩展类加载器(Extension ClassLoader):
- 负责加载
<JAVA_HOME>/lib/ext
目录下的扩展类库。 - 它的父类加载器是启动类加载器。
- 负责加载
-
应用程序类加载器(Application ClassLoader):
- 负责加载用户类路径(
CLASSPATH
)上的所有类。 - 它的父类加载器是扩展类加载器。
- 负责加载用户类路径(
9. 什么是 Java 中的常量池?
解答:
常量池 是 .class
文件中的一部分,用于存放编译期生成的各种字面量和符号引用。
分类:
- 静态常量池:
.class
文件中的常量池。 - 运行时常量池:当类加载到内存中后,静态常量池中的内容会被加载到方法区(JDK 1.8 后是元空间)的运行时常量池中。
主要内容:
- 字面量:字符串字面量(
"hello"
)、final
变量等。 - 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。
运行时常量池具有动态性,可以在运行时将新的常量放入池中,例如 String.intern()
方法。
10. 如何判断一个对象是否可以被回收?
解答:
主要有两种方式:
-
引用计数算法(Reference Counting):
- 给每个对象添加一个引用计数器。当有一个地方引用它时,计数器加 1;引用失效时,计数器减 1。当计数器为 0 时,说明该对象可以被回收。
- 缺点:无法解决对象之间循环引用的问题,导致内存泄漏。因此,JVM 不采用这种算法。
-
可达性分析算法(Reachability Analysis):
- 从一系列被称为 GC Roots 的对象作为起点,向下搜索,形成一个引用链。
- 如果一个对象无法从任何 GC Roots 节点到达,那么它就是不可达的,可以被回收。
- GC Roots 包括:
- 虚拟机栈中的引用对象。
- 方法区中的静态变量和常量。
- 本地方法栈中的引用。
- 正在运行的线程对象。
11. G1 垃圾回收器的工作原理是什么?
解答:
G1(Garbage First) 是一款面向大内存服务器的垃圾回收器。其核心思想是将整个 Java 堆划分为多个大小相等的独立区域(Region),每个区域可以是 Eden、Survivor 或者 Old。
工作流程:
- 初始标记(Initial Mark):标记所有 GC Roots 能直接访问到的对象。会产生短暂的 STW。
- 并发标记(Concurrent Mark):从 GC Roots 开始,对堆中的对象进行可达性分析,此阶段 GC 线程与应用线程并发执行。
- 最终标记(Final Mark):修正并发标记期间因应用线程活动导致的对象引用变化。会产生短暂的 STW。
- 筛选回收(Mixed GC):G1 会根据回收效率(垃圾最多)和预设的停顿时间,选择部分区域进行回收。它采用复制算法,将存活对象复制到空的区域,同时完成垃圾回收。
G1 的优点:
- 可预测的停顿时间:可以设置期望的停顿时间(
MaxGCPauseMillis
),G1 会根据这个值来选择要回收的区域数量。 - 并行与并发:充分利用多核 CPU,减少 STW。
- 分代收集:G1 也是一种分代收集器,但它将新生代和老年代区域化,可以同时回收新生代和老年代的垃圾(Mixed GC)。
- 无内存碎片:G1 主要采用复制算法,因此不会产生内存碎片。
12. JVM 中的 AOT 编译是什么?
解答:
AOT(Ahead-of-Time)编译,即预先编译。它是在程序运行之前,将 Java 字节码直接编译为本地机器码。这与 JIT 编译(运行时编译)是相对的。
AOT 的优点:
- 启动速度快:由于代码在运行前已经编译好,省去了 JIT 编译和解释执行的开销,程序启动非常迅速。
- 峰值性能高:由于在编译时可以进行更充分的优化,理论上可以达到更高的执行效率。
AOT 的缺点:
- 牺牲跨平台性:生成的本地机器码依赖于特定的操作系统和 CPU 架构。
- 动态性限制:无法在运行时进行一些动态优化,比如根据运行时信息调整代码执行路径。
13. JVM 中的本地方法接口(JNI)是什么?
解答:
JNI(Java Native Interface) 是 Java 平台标准的一部分,允许 Java 代码与运行在 JVM 上的其他语言(如 C、C++)代码进行交互。
主要作用:
- 调用本地方法:Java 程序可以通过 JNI 调用用其他语言编写的函数。
- 访问系统资源:Java 无法直接操作操作系统底层,可以通过 JNI 调用本地方法来访问操作系统资源,如文件系统、硬件设备等。
14. JVM 调优的步骤和思路是什么?
解答:
JVM 调优是一个系统性的过程,通常遵循以下步骤:
-
监控与分析:
- 使用
jstat
、jstack
、jmap
、VisualVM
等工具监控 JVM 状态,收集 GC 日志、堆栈信息等。 - 分析应用程序的性能瓶颈,是 CPU 密集型还是 IO 密集型?是内存泄漏还是 GC 频繁?
- 使用
-
确定目标:
- 调优的目的是什么?是减少 Full GC 次数?减少 GC 停顿时间?还是提高吞吐量?
-
参数调整:
- 根据分析结果,调整 JVM 启动参数,如
-Xms
、-Xmx
、-Xmn
、-XX:NewRatio
等。 - 针对性地选择合适的垃圾回收器,例如:
- 如果追求低延迟,选择 CMS 或 G1。
- 如果追求高吞吐量,选择 Parallel Scavenge。
- 根据分析结果,调整 JVM 启动参数,如
-
代码优化:
- 调优参数只是治标,根本原因在代码。
- 检查代码是否存在内存泄漏,如未关闭的资源、未从集合中移除的对象。
- 减少大对象的创建,尤其是频繁创建的临时大对象。
- 优化算法和数据结构,减少不必要的对象创建。
-
持续观察:
- 每次调整后,重新进行监控,观察效果,不要一次调整太多参数。
15. JVM 的即时编译(JIT)和解释器有什么区别?
解答:
特性 | 解释器 | JIT 编译器 |
---|---|---|
执行方式 | 逐行翻译字节码并执行 | 将热点代码编译成机器码后执行 |
启动速度 | 快,无需等待编译 | 慢,需要时间编译 |
执行效率 | 慢,每次执行都需翻译 | 快,直接执行本地机器码 |
适用场景 | 程序启动初期,或不频繁执行的代码 | 频繁执行的热点代码 |
工作模式 | 解释执行 | 编译执行 |
现代 JVM 采用解释器和 JIT 编译器混合工作的模式,以平衡启动速度和运行效率。
16. 说说 Minor GC 的过程。
解答:
Minor GC 发生在新生代,主要回收 Eden 区和 Survivor 区的垃圾。
- 新对象分配:大多数新对象在 Eden 区中创建。
- Eden 区满:当 Eden 区空间不足时,触发 Minor GC。
- 标记存活对象:从 GC Roots 开始,标记所有 Eden 区和 From Survivor 区中存活的对象。
- 复制:将所有存活的对象复制到 To Survivor 区。
- 年龄递增:每经过一次 Minor GC 仍然存活的对象,其年龄(age)会加 1。
- 晋升老年代:当对象的年龄达到某个阈值(默认 15)时,它们会被移到老年代。
- 清空:清空 Eden 区和 From Survivor 区。
- 角色交换:将 From Survivor 和 To Survivor 的角色互换。
17. 什么是 JVM 的分代收集?为什么分代?
解答:
分代收集 是基于**弱分代假说(Weak Generational Hypothesis)**的垃圾回收策略。
- 假说内容:绝大多数对象都是“朝生夕灭”的,存活时间很短。
- 为什么分代:根据这个假说,可以将堆分为两个或多个区域,每个区域根据其对象的特点采用不同的 GC 策略。
- 新生代:存放新对象,对象存活率低,适合使用复制算法,效率高。
- 老年代:存放存活时间长的对象,对象存活率高,适合使用标记-整理或标记-清除算法,减少移动开销。
这种分代策略使得 JVM 可以更高效地管理内存,显著减少 GC 停顿时间。
18. 如何判断 GC Roots?
解答:
GC Roots 是垃圾回收器进行可达性分析的起点。一个对象只要能通过引用链从任何一个 GC Roots 对象到达,它就是存活的。
常见的 GC Roots 对象包括:
- 虚拟机栈中引用的对象:如栈帧中的局部变量表。
- 本地方法栈中 JNI 引用的对象:即本地方法中使用的对象。
- 方法区中类静态属性引用的对象:如
static
变量。 - 方法区中常量引用的对象:如
String
常量池中的引用。 - 所有处于活动状态的线程对象。
19. JVM 的类加载器可以自定义吗?为什么?
解答:
可以自定义。
原因:
- 动态加载:比如我们编写的 Web 服务器,需要动态加载和卸载应用,这时就需要自定义类加载器。
- 隔离性:不同的应用程序可能依赖不同版本的同一个类库,自定义类加载器可以实现类之间的隔离,防止冲突。例如,Tomcat 服务器就是通过自定义类加载器来隔离不同 Web 应用的。
- 加密:为了防止反编译,可以将
.class
文件进行加密,然后编写自定义类加载器,在加载时对文件进行解密。
自定义类加载器只需继承 java.lang.ClassLoader
类,并重写 findClass()
方法。
20. 什么是 JVM 的 safepoint?
解答:
safepoint
(安全点) 是 JVM 垃圾回收时的一个重要概念。它是程序执行中的一个特定位置,在这个位置上,所有线程的状态都是已知的,并且可以安全地进行 GC。
为什么需要 safepoint
?
- GC 线程需要一个稳定的环境来执行,不能在应用线程随意执行时进行。
- 如果 GC 线程在应用线程执行一半时进行,可能会导致引用关系混乱,无法正确判断对象存活状态。
当 GC 发生时,JVM 会等待所有线程都运行到 safepoint
,然后暂停所有线程(STW),进行 GC 操作。线程进入 safepoint
的方式有两种:
- 抢占式中断:GC 线程先中断所有线程,然后检查它们是否在
safepoint
上。如果不在,就恢复它们执行,直到它们运行到safepoint
再暂停。 - 主动式中断:GC 线程在
safepoint
检查点上设置一个标志,每个线程在执行时,都会主动检查这个标志,如果被设置了,就主动挂起自己。HotSpot JVM 采用的是这种方式。