【读书笔记】JVM垃圾收集与内存分配策略
Tip:内容为对《深入理解Java虚拟机》(周志明 著)第三章内容的总结和笔记。这是第一次拜读时读到的一些重点,做个分享,也为后面再次阅读和实践做保障。
3.1 概述
程序计数器、虚拟机栈、本地方法栈三个区域跟随线程的生命周期,栈中的栈帧随方法的进出而有序的进行出栈和入栈,每一个栈帧分配多少内存基本上是在类节后确定下来时就已知的。
Java堆和方法区只有在程序运行时才能确定内存的使用情况,垃圾回收器所关注的主要就是这部分内存。
在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
3.2 如何判断对象是否存活
3.2.1 引用计数算法
给对象添加一个引用计数器,每当有地方引用它时,计数器就加一;当引用失效时,计数器就减一;任何时刻计数器为0的对象就是不可能被再被使用的。
引用计数算法实现简单,判定效率也很高;但是它很难解决对象间相互循环引用的问题。JVM 没有选用这种方法管理内存。
3.2.2 可达性分析法
算法的基本思路就是通过一系列的称为“ GC ROOT ”的对象作为起始点,从这些节点开始向下搜索。从图论的角度看,当一个对象到 GC ROOT 不可达的时候这个对象就是不可用的;反之则是可用的。
在 Java 中,可以作为 GC ROOT 的对象包括以下4种:
虚拟机栈(栈帧中的本地变量表)中引用的对象;
方法区中类静态属性引用的对象;
方法区中常量引用的对象;
本地方法栈中 JNI(即 Native 方法)引用的对象。
3.2.3 Java 的四种引用
在 JDK1.2 后,Java 对引用概念扩充,分为强引用、软引用、弱引用、虚引用。强度渐弱。
强引用
就是值在程序代码之中普遍存在的,类似Object obj = new Object()
这类的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。软引用
它关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围内进行第二次回收。提供 SoftReference 类来实现软引用。弱引用
强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。提供 WeakReference 类来实现软引用。虚引用
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来去的一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。提供 PhantomReference 类来实现软引用。
3.2.4 对象生存还是死亡
即使在可达性分析算法中不可达的对象,也并不是立即就会被销毁,它至少要经历两次标记过程:
一个对象在分析后发现是不可达的,它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被调用过,虚拟机将认为它没必要再执行。即任何一个对象的 finalize() 方法都只会被系统自动调用一次。
如果对象判定认为有必要执行 finalize() 方法,那么这个对象会进入 F-Queue 队列中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalize 线程去执行它。这里的“执行”是指虚拟机会触发这个方法,但是不一定会等它结束(防止阻塞)。然后 GC 会将 F-Queue 中的对象进行第二次标记,如果在 finalize 方法中与 GC ROOT 建立关联,那么它将被移除“即将回收”的集合。
3.2.5 方法区回收
永久代中的垃圾收集主要回收两部分的内容:废弃常量和无用的类。
废弃常量的回收和堆中对象的回首方法类似。
类需要满足下面3个条件才算是“无用的类”。满足这些条件只是可以被回收,是否肯定回收还有虚拟机的一些参数控制。
该类的所有实例都已经被回收;
加载该类的 ClassLoader 已经被回收;
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 垃圾收集算法
3.3.1 标记-清除算法
思想:算法分为“标记”和“清除”两个阶段。
首先标记出所有需要回收的对象;
标记完后同意回收所有被标记的对象。
这种算法有两个不足:
一是效率问题,标记和清除两个过程效率都不太高;
二是空间问题,空间会碎片化。
3.3.2 复制算法
复制算法是为了解决效率问题,也解决了内存碎片的问题。
思想:
它将可用的内存分为相等的两块;
每次只用一块;
GC 时将还存活的对象复制到另一块上面,然后全面清理使用过的内存。
代价是将内存缩小为原来的一半。
3.3.3 标记-整理算法
思想:
标记过程跟“标记-清除”算法一样;
然后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
3.3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法。
思想:
根据对象存活周期的不同将内存划分为几块。(一般是把 Java 堆分为新生代和老年代)
新生代中,采用复制算法回收。因为新生代中对象98%是很短暂的。所以不必按照1:1划分内存,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一个 Survivor 。回收时将存活的对象复制到另一块 Survivor 中,然后清理 Eden 和使用过的 Survivor 空间。HotSpot 中 Eden 和 Survivor 比例为8:1。当 Survivior 空间不够时,需要依赖其它内存(老年代)进行分配担保。
老年代中因为对象存活率高、没有额外的空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法进行回收。
3.4 HotSpot 的算法实现
3.4.1 枚举根节点
可作为 GC ROOT 的节点主要在全局性的引用(eg.常量、类静态属性)和执行上下文(eg.栈帧中的本地变量表)。
在可达性分析时,执行系统需要在一个一致性的快照中(一致性的意思就是说在分析期间执行系统中对象引用关系不可以还在不断变化,否则分析准确性无法保证)。这是 GC 进行时必须停顿所有 JAVA 执行线程的一个重要原因。
准确式 GC:虚拟机可以知道内存中某个位置的数据具体是什么类型。这样在 GC 的时候虚拟机能准确的判断堆上的数据是否还可能被使用。
在 HotSpot 中一组 OopMap 的数据结构来记录哪些地方存放着对象引用。(普通对象指针 Ordinary Object Pointer)
3.4.2 安全点
可能导致引用关系变化(Oop 内容变化)的指令很多,为每条指令生成 OopMap 是不现实的。
HotSpot 只有在安全点才能暂停。
安全点的选定基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。
安全点的另一个要处理的问题是:如何在 GC 发生时让所有的线程都到最近的安全点停下来。这里有两种方案:
抢断式中断:不需要线程主动配合,GC 发生时,先中断所有线程,发现有线程不在安全点上再恢复它,让它运行至安全点。
主动式中断:当需要 GC 时,仅仅在安全点设置一个标志,各线程执行时主动去轮询这个标志,发现为真就中断挂起。
3.4.3 安全区域
当线程处于 Sleep 状态或者 Blocked 状态时,它无法响应 JVM 的中断请求,此时需要安全区域来解决。
安全区域就是指在一段代码片段中,引用关系不会发生变化,在这个区域中的任何地方开始 GC 都是安全的。
当线程要离开安全区域时,她要检查系统是否已经完成了根节点枚举,完成才能离开。
3.5 垃圾收集器
3.5.1 HotSpot 虚拟机的垃圾收集器
3.5.2 Serial 收集器
收集器采用复制算法;
这个收集器是一个单线程的收集器;
它只会使用一个 CPU一个线程去完成垃圾收集工作;
它在进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束!
3.5.3 ParNew 收集器
收集器采用复制算法;
ParNew 收集器就是 Serial 收集器的多线程版本;
目前只有它能与 CMS 收集器配合工作;
HotSpot 虚拟机中第一款真正意义上的并发收集器;
3.5.4 Parallel Scavenge(清除) 收集器
Parallel Scavenge 收集器是一个新生代收集器,采用复制算法,也是一个并行的多线程收集器;
它不同于其它收集器的特点是:
跟其它收集器关注点不一样,CMS 等收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间;PS 收集器的目标是吞吐量优先(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间));
GC 自适应调整策略。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整 GC 比率参数以提供最合适的停顿时间或最大的吞吐量。
3.5.5 Serial Old 收集器
Serial Old 是 Serial 的老年代版本;
它是一个单线程收集器;
采用“标记-整理”算法;
在 Server 模式下,它还有两大用途:
在 JDK1.5 之前与 Parallel Scavenge 收集器配合使用;
作为 CMS 收集器的后备预案,在并发收集发生 CMF(Concurrent Mode Failure)时使用。
3.5.6 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge收集器的老年代版本;
是一个多线程收集器;
采用“标记-整理”算法;
在注重吞吐量以及 CPU 资源敏感的场合,可以考虑 Parallel Scavenge 加 Parallel Old 收集器。
3.5.7 CMS 收集器
CMS 收集器的目标是获取最短回收停顿时间,适合重视服务的响应速度的场合。
采用“标记-清除”算法;
CMS 收集器的运行过程;
初始标记(需要 Stop The World):标记 GC ROOT 直接关联到的对象。
并发标记:进行 GC ROOT 的 Tracing 过程,即可达性分析。
重新标记(需要 Stop The World):修正并发标记阶段因用户继续运行导致的标记的变动。时间一般比初始标记长,比并发标记短。
并发清除:根据标记清除内存。
整个过程中耗时最长的并发标记和并发清除过程收集线程都可以和用户线程一起工作。
CMS 收集器也有三个明显的缺点:
CMS 收集器对 CPU 资源非常敏感。
虽然不会导致用户线程停顿,但是会占用一部分 CPU 资源而导致程序变慢。回收线程数是 (CPU 数量 + 3) / 4。CMS 收集器无法处理浮动垃圾(并发清理阶段产生的垃圾)。
浮动垃圾要留到下次 GC 时再进行清理。因为并行清理时用户线程还在运行,所以要预留一部分老年代空间提供线程使用(JDK1.6 为92%时开始)。如果预留无法满足要求,会出现“Concurrent Mode Failure”错误,此时需要临时启用 Serial Old 收集器来进行老年代垃圾收集。CMS 收集器会产生大量空间碎片。
3.5.8 G1 收集器
G1 收集器是一款面向服务端应用的垃圾收集器;
与其它收集器相比有以下特点:
并行与并发
能充分利用多 CPU、多核环境下的硬件优势。分代收集
G1 收集器独立管理整个 GC 堆,但依然有分代的概念,用不同的方式处理新老对象。空间整理
G1 整体上是基于“标记-整理”算法的,从局部(两个 Region 之间)看是基于“复制”算法的,都不会产生内存碎片。可预测的停顿
G1 可以建立可预测的停顿时间模型。
实现思想:
它将整个 JAVA 内存堆划分为多个大小相等的独立区域(Region);
G1 跟踪各个 Region 里面的垃圾堆积的价值大小,优先回收价值最大的 Region。
在 G1 收集器的 Region 之间的对象引用以及其它收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set .
运行过程:
初始标记:标记 GC ROOT 直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,以便下次用户程序运行时,能在正确的可用的 Region 中创建对象。
并发标记:进行可达性分析。
最终标记:修正并发标记阶段因用户继续运行导致的标记的变动。更新 Remembered Set。
筛选回收:对各个 Region 回收价值排序,然后根据用户期望的 GC 停顿时间来制定回收计划。
3.6 内存分配与回收策略
3.6.1 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(新生代 GC)。
3.6.2 大对象直接进入老年代
典型的大对象就是那种很长的字符串以及数组。
虚拟机提供一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。
3.6.3 长期存活的对象将进入老年代
虚拟机给每个对象设置一个对象年龄(AGE)计数器;
每经过一次 Minor GC,年龄增加一岁;当到一定岁数(默认15)后进入老年代。
3.6.4 动态对象年龄判断
为了更好地适应不同程序的内存状况,如果在Survivor 空间中相同年龄所有对象大小综合大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
3.6.5 空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。
如果条件不成立,虚拟机会查看是否允许担保失败。如果允许,会检查老年代最大可用的连续空间是否大于历次晋升带老年代对象的平均大小,大于则会进行一次 Minor GC;小于或者不允许蛋白失败则改为进行一次 Full GC。
JDK1.6 Update 24后强制允许担保失败,HandlePromotionFailure 参数不起作用了。