JVM常见面试题分享
(一)内存区域划分
1、程序计数器
程序计数器是一块较小的内存空间,可以看作当前线程执行字节码的行号指示器,是唯一没有内存溢出的区域。字节码解释器工作时通过改变计数器的值选取下一条执行指令。分支、循环、跳转、线程恢复等功能都需要依赖计数器。
2、Java 虚拟机栈
Java 虚拟机栈描述 Java 方法的内存模型。当有新线程创建时会分配一个栈空间,栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈和方法出口等信息。每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程。
线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError;如果 JVM 栈允许动态扩展,栈扩展无法申请足够内存抛出 OutOfMemoryError(HotSpot 不可动态扩展)。
3、本地方法栈
本地方法栈与虚拟机栈作用相似,不同的是虚拟机栈为 Java 方法服务,本地方法栈为本地方法服务。虚拟机规范对本地方法栈中方法的语言与数据结构无强制规定,例如 HotSpot 将虚拟机栈和本地方法栈合二为一。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError,例如一个递归方法不断调用自己。该异常有明确错误堆栈可供分析,容易定位问题。
4、堆
堆是虚拟机管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。堆用来存放对象实例,Java 里几乎所有对象都在堆分配内存。堆可以处于物理上不连续的内存空间,但对于数组这样的大对象,多数虚拟机出于简单高效的考虑会要求连续的内存空间。
堆既可以被实现成固定大小,也可以是可扩展的,通过 -Xms 和 -Xmx 设置堆的最小和最大容量,主流 JVM 都按照可扩展实现。
堆用于存储对象实例,只要不断创建对象并保证 GC Roots 可达避免垃圾回收,当总容量触及最大堆容量后就会产生 OOM。
5、方法区
方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
JDK8 前使用永久代实现方法区,容易内存溢出,因为永久代有 -XX:MaxPermSize 上限,即使不设置也有默认大小。JDK7 把放在永久代的字符串常量池、静态变量等移出,JDK8 时永久代完全废弃,改用在本地内存中实现的元空间代替,把 JDK7 中永久代剩余内容(主要是类型信息)全部移到元空间。
虚拟机规范对方法区的约束宽松,除和堆一样不需要连续内存、可选择固定大小、可扩展外,还可以不实现垃圾回收。垃圾回收在方法区出现较少,主要针对常量池和类型卸载。
方法区主要存放类型信息,只要不断在运行时产生大量类,方法区就会溢出。例如使用反射或 CGLib 在运行时生成大量的类。
JDK8 使用元空间取代永久代,HotSpot 提供了一些参数作为元空间防御措施,例如 -XX:MetaspaceSize 指定元空间初始大小,达到该值会触发 GC 进行类型卸载,同时收集器会对该值进行调整,如果释放大量空间就适当降低该值,如果释放很少空间就适当提高。
6、运行时常量池
运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容在类加载后存放到运行时常量池。一般除了保存 Class 文件中描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池。
运行时常量池相对于 Class 文件常量池的一个重要特征是动态性,Java 不要求常量只有编译期才能产生,例如 String 的 intern 方法。
在 JDK6 及之前常量池分配在永久代,因此可以通过 -XX:PermSize 和 -XX:MaxPermSize 限制永久代大小,间接限制常量池。在 while 死循环中调用 intern 方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中。
(二)内存溢出和内存泄漏
内存溢出 OutOfMemory,指程序在申请内存时,没有足够的内存空间供其使用。
内存泄露 Memory Leak,指程序在申请内存后,无法释放已申请的内存空间,内存泄漏最终将导致内存溢出。
(三)创建对象的过程
① 当 JVM 遇到字节码 new 指令时,首先检查能否在常量池中定位到一个类的符号引用,并检查该类是否已被加载。
② 在类加载检查通过后虚拟机将为新生对象分配内存。
③ 内存分配完成后虚拟机将成员变量设为零值,保证对象的实例字段可以不赋初值就使用。
④ 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
⑤ 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
(四)分配内存
分配内存相当于把一块确定大小的内存块从 Java 堆划分出来。
指针碰撞: 假设 Java 堆内存规整,利用一个指针将内存分为两部分,分配内存就是把指针向空闲方向挪动一段与对象大小相等的距离。
空间列表: 如果 Java 堆内存不规整,虚拟机维护一个列表记录可用内存,分配时从列表中找到一块足够的空间划分给对象并更新列表。
选择哪种分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否有空间压缩能力决定。使用 Serial、ParNew 等收集器时,系统采用指针碰撞;使用 CMS 时,采用空间列表。
修改指针位置是线程不安全的,存在正给对象分配内存,指针还没来得及修改,其它对象又使用指针分配内存的情况。解决方法:① CAS 加失败重试。② 把内存分配按线程划分在不同空间,叫做本地线程分配缓冲 TLAB,哪个线程要分配内存就在对应的 TLAB 分配。
(五)内存布局
对象头
占 12B,包括对象标记和类型指针。对象标记存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁标志、偏向线程 ID 等,这部分占 8B,称为 Mark Word。Mark Word 被设计为动态数据结构,以便在极小的空间存储更多数据,根据对象状态复用存储空间。类型指针是对象指向它的类型元数据的指针,占 4B,JVM 通过该指针来确定对象是哪个类的实例。
实例数据
对象真正存储的有效信息,即本类对象的实例成员变量和所有可见的父类成员变量。存储顺序会受到虚拟机分配策略参数和字段在源码中定义顺序的影响。
对齐填充
仅起占位符作用。虚拟机的内存管理系统要求任何对象的大小必须是 8B 的倍数,如果没有对齐需要对齐填充补全。
(六)垃圾回收
1、引用类型
JDK1.2 后对引用进行了扩充,按强度分为四种。
强引用: 最常见的引用,例如使用 new 创建对象。只要对象有强引用指向且 GC Roots 可达,即使濒临内存耗尽也不会回收。
软引用: 弱于强引用,描述非必需对象。系统发生内存溢出前,会把软引用关联的对象加入回收范围。
弱引用: 弱于软引用,描述非必需对象。弱引用关联的对象只能生存到下次 YGC 前,GC 时无论内存是否足够都会回收。
虚引用: 最弱的引用,无法通过引用获取对象。唯一目的是在对象被回收时收到一个系统通知,必须与引用队列配合。
2、GC 算法
标记清除
分为标记和清除阶段,首先从每个 GC Roots 出发依次标记有引用关系的对象,最后清除没有标记的对象。如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,效率低。
缺点:存在内存空间碎片化问题,分配大对象时容易触发 Full GC。
标记复制
为解决内存碎片,将可用内存按容量划分为大小相等的两块,每次只使用其中一块,主要用于新生代。
缺点:对象存活率高时要进行较多复制操作,效率低。如果不想浪费空间就需要有额外空间分配担保,老年代一般不使用此算法。
HotSpot 把新生代划分为一块较大的 Eden 和两块较小的 Survivor,每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1,每次新生代中可用空间为整个新生代的 90%。
标记整理
老年代使用标记整理算法,标记过程与标记清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。
标记清除与标记整理的区别:前者是一种非移动式算法,后者是移动式的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,开销很大,而且移动必须暂停用户线程;如果不移动会导致空间碎片问题。
3、垃圾收集器
(1)Serial
最基础的收集器,使用复制算法、单线程工作,进行垃圾收集时必须暂停其他线程。
Serial 是客户端模式的默认新生代收集器,对于处理器核心较少的环境,由于没有线程开销,可获得最高的单线程收集效率。
(2)ParNew
Serial 的多线程版本,ParNew 是虚拟机在服务端模式的默认新生代收集器,一个重要原因是除了 Serial 外只有它能与 CMS 配合。从 JDK9 开始,ParNew 加 CMS 不再是官方推荐的解决方案,官方希望它被 G1 取代。
(3)Parallel Scavenge
基于复制算法、多线程工作的新生代收集器。
它的目标是高吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器消耗总时间的比值。
(4)Serial Old
Serial 的老年代版本,使用整理算法。
Serial Old 是客户端模式的默认老年代收集器,用于服务端有两种用途:① JDK5 前与 Parallel Scavenge 搭配。② 作为 CMS 失败预案。
(5)Parellel Old
Parallel Scavenge 的老年代版本,支持多线程,基于整理算法。JDK6 提供,注重吞吐量可考虑 Parallel Scavenge 加 Parallel Old 组合。
(6)CMS
以获取最短回收停顿时间为目标,基于清除算法,过程分为四个步骤:
-
初始标记:标记 GC Roots 能直接关联的对象,速度很快。
-
并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长但不需要停顿用户线程。
-
重新标记:修正并发标记期间因用户程序运作而导致标记产生变动的记录。
-
并发清除:清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发。
缺点:① 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量。② 无法处理浮动垃圾,有可能出现并发失败而导致 Full GC。③ 基于清除算***产生空间碎片。
(7)G1
开创了面向局部收集的设计思路和基于 Region 的内存布局,主要面向服务端,最初设计目标是替换 CMS。
G1 可面向堆任何部分来组成回收集进行回收,衡量标准不再是分代,而是哪块内存中垃圾的价值最大。价值即回收所获空间大小以及回收所需时间的经验值,G1 在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的 Region。
G1 运作过程:
-
初始标记:标记 GC Roots 能直接关联到的对象,让下一阶段用户线程并发运行时能正确地在可用 Region 中分配新对象。需要 STW 但耗时很短,在 Minor GC 时同步完成。
-
并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图。耗时长但可与用户线程并发,扫描完成后要重新处理 SATB 记录的在并发时有变动的对象。
-
最终标记:对用户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB 记录。
-
筛选回收:对各 Region 的回收价值排序,根据用户期望停顿时间制定回收计划。必须暂停用户线程,由多条收集线程并行完成。
(8)ZGC
JDK11 中加入的具有实验性质的低延迟垃圾收集器,目标是尽可能在不影响吞吐量的前提下,实现在任意堆内存大小都可以把停顿时间限制在 10ms 以内的低延迟。
基于 Region 内存布局,不设分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记整理。ZGC 的 Region 具有动态性,是动态创建和销毁的,并且容量大小也是动态变化的。
(七)内存分配与回收策略
对象优先在 Eden 区分配
大多数情况下对象在新生代 Eden 区分配,当 Eden 没有足够空间时将发起一次 Minor GC。
大对象直接进入老年代
大对象指需要大量连续内存空间的对象,例如很长的字符串或大数组,容易导致内存还有不少空间就提前触发 GC 以获得足够连续空间。
HotSpot 提供了 -XX:PretenureSizeThreshold 参数,大于该值的对象直接在老年代分配,避免在 Eden 和 Survivor 间来回复制。
长期存活对象进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头。如果经历过第一次 Minor GC 仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过一次 Minor GC 年龄就加 1 ,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过 -XX:MaxTenuringThreshold 设置。
动态对象年龄判定
为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代。
空间分配担保
MinorGC 前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足则说明这次 Minor GC 确定安全。
如果不满足,虚拟机会查看 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC。
冒险是因为新生代使用复制算法,只用一个 Survivor,大量对象在 Minor GC 后仍然存活时需要老年代接收 Survivor 无法容纳的对象。