内存动态分配和垃圾收集这些自动化技术使得Java语言比C++语言更加简单易用,经过几十年的发展,这些技术也日趋成熟。虽然这些技术处于底层且较为成熟,但我们仍有必要学习并掌握它,因为当GC成为系统高并发的瓶颈时,我们就需要进行GC调优。本文不涉及GC调优的相关内容,仅仅是作为当前Java虚拟机GC技术的一个简单入门介绍。
总的来说,垃圾收集技术紧紧围绕两个核心:
- 哪些对象需要回收?(如何判断对象是否存活)
- 如何回收?(常见的垃圾回收算法简介)
下面我们针对这两个核心对GC进行简单介绍,并讲解一下当下常用的垃圾收集器的基本过程。
如何判断对象是否存活
判断对象是否存活的常用算法有引用计数法和可达性分析法,Java虚拟机一般使用可达性分析法。
引用计数法的思想十分简单,它使用一个变量来记录对象被引用的次数,当变量值为0时则该对象可被回收。但引用计数法无法解决循环引用的问题,例如A引用B,B引用A,但没有其它对象引用A和B,理论上讲此时A和B都已经是不可能再被访问到应当被回收,但实际上A和B的引用计数都为1,引用计数算法也就无法回收它们。
可达性分析算法的基本思想是从一些称为“GC Roots”的对象出发,根据引用关系向下搜索,可以被访问到的对象是存活的,不能被访问到的对象则是可被回收的。例如,下图中的Object 5,6,7就是应当被回收的对象。JVM中可以作为GC Roots的对象包括类中静态属性引用的对象等。
常见的垃圾回收算法简介
分代假说
当下的垃圾回收器大多基于“分代假说”设计,分代假说是对程序运行情况的两条经验总结:
- 绝大多数对象都是朝生夕死的
- 熬过垃圾收集次数越多的对象越难以消亡
根据分代假说,大多数垃圾收集器的设计原则为:将对象根据其年龄(熬过垃圾收集的次数)存放在不同的内存区域,并针对该内存区域中对象的存亡特征执行相应的垃圾回收策略。如果一个区域内的对象大多数都是朝生夕死的,那么垃圾回收时就可以只关注少部分存活对象即可,从而用较低代价回收到大量空间;如果一个区域内的对象大多数是难以消亡的,那么虚拟机就可以减少对该区域的垃圾回收频率,从而兼顾了垃圾回收的时间开销的回收效率。当下虚拟机的具体实现中多把内存区域分为新生代和老年代,并相应的设计了标记-清除算法、标记-复制算法和标记-整理算法。
标记-清除算法
标记-清除算法的基本思想为:首先使用可达性分析法标记出所有需要被回收的对象,标记完成后统一回收被标记的对象。该算法缺点有二:第一,当需要被回收的对象较多时,其效率较低;第二,会造成内存碎片化问题。算法示意图如下:
标记-复制算法
标记-清除算法的基本思想为:将内存区域一分为二,每次只使用其中之一,垃圾回收时将这一块内存上的存活对象复制到另一块未使用的内存区域中去,然后将这一块内存一次性整体回收。这种算法解决了标记-清除算法存在内存碎片的问题,但缺陷是存在空间浪费。算法示意图如下:
标记-整理算法
标记-整理算法的基本思想为:将所有存活对象向内存空间的一端移动,然后直接清理掉边界以外的内存。这种算法解决了空间浪费的问题。算法示意图如下:
三种算法总结
新生代中存活对象较少,需要被回收的对象较多;老年代中存活对象较多,需要被回收的对象较少。三种算法的适应场景如下:
- 标记-清除算法:该算法标记完成后统一回收被标记的对象,所以在被回收对象较少存活对象较多的老年代中效率更高
- 标记-复制算法:该算法将存活对象复制到保留内存中去,所以在存活对象较少被回收对象较多的新生代中效率更高
- 标记-整理算法:该算法将存活对象向一边靠拢,效率比标记-复制算法低,一般用在老年代中
标记-复制算法的具体实现并不会简单粗暴的按照1:1的比例来划分新生代的内存空间,有研究表明98%的新生代对象都熬不过第一轮垃圾收集,因此可以采用一种Apple式回收的策略:将新生代划分为一块Eden区和两块Survivor区,Eden:Survivor = 8:1,每次使用Eden区和一块Survivor区,回收时将其存活对象复制到另一块Survivor中,所以只有10%的新生代空间是被浪费的。
标记-清除算法和标记-整理算法都用于老年代的回收,两种算法的根本区别是是否需要移动存活对象。移动存活对象,内存回收时更加复杂,而且移动过程需要STW(Stop The World, 即暂停用户线程,因为移动对象会改变内存地址,需要更新相关对象的引用);而不移动,内存分配时更加复杂,还产生空间碎片。因此,从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
CMS的垃圾收集过程
CMS(Concurrent Mark Sweep)收集器专注于负责老年代的垃圾收集,以获取最短回收停顿时间为目标,所以基于标记-清除算法实现。整个过程分为四步:
- 初始标记:需要STW,仅标记和GC Roots直接关联的对象,速度快
- 并发标记:不需要STW,从GC Roots直接关联的对象出发并发标记,和用户线程并发运行
- 重新标记:需要STW,修正并发标记过程中因用户线程并发运行导致的变动(主要是垃圾变为非垃圾的修正)
- 并发清除:不需要STW,清理掉死亡对象,因为使用标记-清除算法不需移动对象,所以可以和用户线程并发运行
CMS运行示意图如下:
CMS的缺点有三:
- 吞吐量低,并发过程占用CPU
- 无法处理并发过程中新产生的浮动垃圾
- 标记-清除算法导致的内存碎片化问题
G1的垃圾收集过程
G1(Garbage First)收集器是全功能的垃圾收集器,从JDK 9开始称为服务端模式下的默认收集器。G1不再单单针对新生代或者老年代,而是面向整个堆:G1将堆划分为多个大小相等的独立区域,每个区域都可以根据需要扮演Eden、Survivor或者老年代,优先针对垃圾最多回收价值最大的区域进行回收。G1的设计目标是在延迟可控的情况下尽可能追求高的吞吐量。
参考文献
【1】《深入理解Java虚拟机(第3版)》周志明著,机械工业出版社。
每日学习笔记,写于2020-04-17 星期五