引用《深入理解Java虚拟机》书里的一句话:
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
概述
上一篇文章深入理解Java虚拟机(一)java运行时数据区域中,讲到程序计数器、虚拟机栈、本地方法栈的生灭都随线程的生命周期,也就是内存的分配和回收都有确定性。而Java堆和方法区都为线程共享,具有不确定性,这两个区域如何回收也是垃圾收集器所关注的。
对象已死?
在进行垃圾收集之前,首先要判断哪些对象是存活的,哪些对象需要回收。
引用计数算法
在对象中添加一个引用计数器,当有地方引用它时,计数器就加一,当引用失效时,计数器就减一。如果计数器为零,代表对象不可能再被使用了。这种算法的缺点是,很难解决循环引用的问题,Java虚拟机不使用这种算法。
可达性分析算法
可达性分析算法的基本思路是,以一系列称为GC Roots的根对象开始,根据引用关系向下搜索,搜索走过的路径称为引用链。如果某个对象到GC Roots没有引用链相连接,也就是说从GC Roots到这个对象不可达,说明对象不可能再被使用了。固定可作为GC Roots的对象包含以下几种:
- 在虚拟机栈(栈帧中的本地变量表)引用的对象,例如各个线程被调用的方法堆栈中使用的参数、局部变量、临时变量等
- 在方法区中类静态属性引用的对象,例如Java类的引用类型静态变量
- 在方法区中常量引用的对象,例如字符串常量引用的对象
- 本地方法栈中JNI(也就是Native方法)引用的对象
- 所有被同步锁(Synchronized关键字)引用的对象
- Java虚拟机的内部引用,如基本数据类型对于的Class对象、常驻的异常对象、系统类加载器
- 映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
垃圾收集算法
标记-复制算法
将可用内存平均划分为两块,每次只使用其中一块。当这一块内存用完时,就将存活的对象复制到另一块,再清理它。
这种算法的优点是简单高效,但如果多数对象都是存活的,那么会产生大量的复制开销,而且由于内存空间等分为两块,对空间的使用率不高。
标记-清除算法
首先标记出需要回收的对象,标记完成后,对所有标记的对象统一回收。或者标记存活对象,再统一回收未被标记的对象。
这种算法的缺点是执行效率不稳定,如果有大量对象是要被标记的,那么标记和清除的效率都会因对象数量的增长而降低。其次,它会造成内存空间的碎片化,不利于后续大对象的分配。
标记-整理算法
标记-整理算法的标记过程,和标记-清除算法一样,但是在进行回收时,先将所有存活对象向内存的一端移动,再进行垃圾收集,这样就保证了内存空间使用的连续性。它的缺点便是移动对象比较耗时,需要暂停用户应用程序。
因此,移动对象与否具有两面性,不移动则会带来内存空间碎片化,但也可以在因碎片导致的对象无法分配时,再进行整理;而移动对象虽然会降低垃圾回收效率,但涉及到分配大对象时更划算。
分代收集理论
虚拟机通常将Java堆分为新生代和老年代。
新生代
新生代的目标是尽可能快速的回收生命周期比较短的对象,通常来说,新创建的对象会在新生代中。通常新生代还会细分为Eden区、Survivor 0区、Survivor 1区(8:1:1的比例)。当Eden区空间不足时,会发生一次Minor GC。此时会将Eden区的存活对象复制到Survivor 0区,再清空Eden区。当Eden区再次填满时,同时回收Eden区和Survivor 0区,将存活对象放入Survivor 1区,再清理另外两个区域。也就是说,必定会有一个Survivor区保持为空,供复制时使用。但如果某个Survivor区被填满,且有对象未复制完成,或者经历N次垃圾回收(-XX:MaxTenuringThreshold参数设置)后依然存活的对象,会被放入老年代中。如果设置了 - XX:PretenureSizeThreshold 参数,超过这个大小的对象会直接进入老年代,为的是避免大对象在新生代中来回复制。
老年代
老年代一般存放生命周期较长的对象,通常在新生代经历N次垃圾回收后依然存活的对象,会被放到老年代中。老年代的收集称为Major GC。
其它回收方式
- Mixed GC:只有G1收集器有这种方式,回收整个新生代以及部分老年代。
- Full GC:对整个堆和方法区进行垃圾回收
HotSpot的算法实现细节
根节点枚举
现在Java应用越来越庞大,类、常量等数量就像恒河沙数,从GC Roots集合高效查找引用链并非易事。
虽然可达性分析算法耗时最长的查找引用链的过程,已经可以做到与用户线程一起并发。
但迄今为止的所有收集器在根节点枚举时,都必须暂停用户线程,也就是"Stop The World"。根节点枚举是导致垃圾收集过程必须停顿所有用户线程的一个重要原因。
目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文(例如栈帧中的本地变量表)和全局引用(例如常量、类静态属性)的位置,有专门的地方来存放对象引用的。
在HotSpot中,使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成,HotSpot就会把对象内什么偏移量上是什么类型的数据计算处理,在即时编译过程中,也会在特定位置记录下栈里和寄存器里哪些位置是引用。
安全点
如果每条指令都生成对应的OopMap,那么将需要大量额外的存储空间。HotSpot虚拟机也没有这么做,只是在特定位置记录这些信息,这些位置称为“安全点”。安全点的设定强制要求代码指令流必须执行到安全点后才能暂停开始垃圾收集,而非在任意位置都能停顿下来。
总结来说,安全点是用来解决如何停顿用户线程,让虚拟机进入垃圾回收状态。
在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来,有两种方案:
- 抢先式中断:在垃圾收集发生时,先把所有用户线程全部中断,如果有线程没有到达安全点,则让它继续执行到安全点上。但是虚拟机一般不用这种方式。
- 主动式中断:当垃圾收集发生时,不直接对线程操作,而是设定一个标志位也就是安全点,各个线程执行过程中不断主动轮询,一旦发现中断标志为真,就主动挂起。
安全区域
当用户线程处于Sleep状态或者Blocked状态,将无法响应虚拟机的中断请求,也不无法自己执行到安全点。这种情况使用安全区域来处理。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此这块区域内任意地方开始垃圾回收都是安全的。
记忆集与卡表
为解决跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。(整个老年代加入扫描将严重影响性能)
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。底层用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
有三种记录精度供选择:
- 字长精度:每个记录精确到一个机器字长
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
- 卡精度:每个记录精确到一个内存区域,该区域内含有的对象含有跨代指针
目前最常用第三种方式,也就是用“卡表”(Card Table)来实现记忆集。
写屏障
写屏障用来维护卡表元素的状态,也就是卡页对应的内存区域中是否存在跨代引用。
写屏障的实现原理是在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,赋值时会产生一个环形(Around)通知,供程序执行额外动作。
经典的垃圾收集器
图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
JDK9标识的线条,表示在JDK9时取消了搭配使用。
下图对垃圾收集器做了归类和总结,本文仅介绍CMS和G1收集器的算法原理。
CMS收集器
CMS(Concurrent Mark Sweep)收集器以获取最短停顿时间为目标,它基于标记-清除算法,整个过程分为四个步骤:
- 初始标记:仅标记GC Roots能够直接关联到的对象,速度很快,需要停止用户线程
- 并发标记:就是从GC Roots直接关联的对象开始遍历整个对象树,不需要停止用户线程
- 重新标记:修正在并发标记期间,由于用户线程继续运作而导致标记产生变动的那部分对象的标记记录,需要停止用户线程
- 并发清除:清理标记阶段判断为已死亡的对象,由于不需要移动存活对象,因此不需要停止用户线程
说明
并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。
G1收集器
G1收集器开创了面向局部收集的设计思路和基于Region的内存布局形式,它把连续的Java堆划分为大小相等的独立区域(Region),每一个Region都可以根据需要扮演新生代的Eden空间,Survivor空间,或者老年代空间。还有一类特殊的Humongous区域,专门用来存储大对象。G1为每个Region分别两个名为TAMS的指针,因为回收过程与用户线程会并发执行,期间可能有新对象创建,新分别的对象地址必须在两个指针位置以上。
G1收集器整体上是用标记-整理算法来实现的,但从局部来看(两个Region之间)是基于标记-复制算法来实现的,具体分为四个步骤:
- 初始标记:仅标记GC Roots能直接关联的对象,并移动TAMS指针,保证下一阶段用户线程执行时能正确创建对象。此阶段用户线程需要停顿,但耗时很短
- 并发标记:从GC Roots开始递归扫描对象图,找到要回收的对象,这个过程耗时比较长,但可以与用户线程并发执行
- 最终标记:短暂暂停用户线程,处理上一步中引用发生改变的对象的SATB记录
- 筛选回收:更新Region的统计数据,对Region的回收价值和成本进行排序,再根据用户配置的停顿时间,将决定回收的Region中的存活对象,复制到空的Region中。由于此过程涉及对象的移动,因此需要暂停用户线程。由此也可以看出它并非只关注低延迟,还关注吞吐量。
本文主要为阅读《深入理解Java虚拟机 第三版》所做的笔记总结,如有错漏欢迎支持,谢谢