垃圾回收机制
想要了解垃圾收集策略,需要先了解 Java内存区域
说起垃圾收集(Garbage Collection,GC
),经过半个多世纪的发展,目前的内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了 “ 自动化 ” 时代,那为什么还要去了解GC
和内存分配呢?
答案很简单:需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,就需要对这些 “ 自动化 ” 的技术实施必要的监控和调节。
上篇文章介绍了在内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭,不需要过多考虑回收问题,因为方法结束或者线程结束时,内存自然就回收了,这里主要讨论的是 Java 堆和方法区,本章后续讨论中的 “ 内存 ”分配和回收也仅指着一部分内存。
GC
完成需要思考的三件事:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
对象判断机制
引用计数法
引用计数法(Reference Counting
):给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;计数器为 0 时,对象就是不可能再被使用的。简单高效,缺点是无法解决对象之间相互循环引用的问题。
举个简单的例子:
1 | public class ReferenceTest { |
很显然,在这种情况下,引用计数法是无法解决的,而虚拟机并没有因为这两个对象互相引用就不回收它们,这也从说明虚拟机并不是通过引用计数法来判断对象是否存活的。
可达性分析算法
可达性分析(Reachability Analysis
),通过一系列的称为 “GC Roots
”,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain
),当一个对象到 GC Roots
没有任何引用链相连时,则证明此对象是不可用的。
在 Java 语言中,可作为 GC Roots
的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中
JNI
(Native
方法)引用的对象
作为 GC Roots
的节点主要在全局性的引用与执行上下文中。要明确的是,Tracing GC
必须以当前存活的对象集为 Roots,因此必须选取确定存活的引用类型对象。
GC
管理的区域是 Java 堆,虚拟机栈、方法区和本地方法栈不被 GC
所管理,因此选用这些区域内引用的对象作为 GC Roots
,是不会被 GC
所回收的。
其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是 GC roots
的一部分。
再谈引用
JDK1.2
以前,一个对象只有被引用和没有被引用两种状态。
后来,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference
)、软引用(Soft Reference
)、弱引用(Weak Reference
)、虚引用(Phantom Reference
)4 种,这 4 种引用强度依次逐渐减弱。
- 强引用:指在程序代码之中普遍存在的,类似 “
Object obj=new Object()
” 这类的引用,垃圾收集器永远不会回收存活的强引用对象。- 软引用:还有用但并非必需的对象。在系统将要发生内存溢出异常之前 ,将会把这些对象列进回收范围之中进行第二次回收,主要用于缓存内存。
- 弱引用:也是用来描述非必需对象的,被弱引用关联的对象 只能生存到下一次垃圾收集发生之前 。当垃圾收集器工作时,无论内存是否足够,都会回收掉只被弱引用关联的对象,有个著名的ThreadLocal线程泄露问题。
- 虚引用:是最弱的一种引用关系。 无法通过虚引用来取得一个对象实例 。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
java中的ThreadLocal
中的set方法就是使用了弱引用:
1 | public void set(T value) { |
可以看到其set
方法将获取当前线程中的ThreadLocalMap
属性,然后把自身this
,也就是这个ThreadLocal
对象当做key
,设置到ThreadLocalMap
属性中,而ThreadLocalMap
类的set
方法中,会把Key
和Value
封装成一个Entry
对象存放在数组里。
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
该Entry
继承了WeakReference
,并在构造方法时调用了其super
方法,也就是说,调用了ThreadLocal
后,会有一个虚引用指向ThreadLocal
。
问题:为什么这里要用虚引用呢?
答案:为了让ThreadLocal对象的强引用失效后,GC可以回收,如果这里用了强引用,则ThreadLocal无法被回收。
问题:那ThreadLocal的内存泄漏原因是什么呢?
答案:由于ThreadLocal被作为一个Entry的
key
,而当ThreadLocal被回收了之后,其key
变为NULL值,但是其Value
的关联关系依旧存在,无法从ThreadLocalMap中移出,会导致内存泄漏。问题:那ThreadLocal如何避免内存泄漏?
答案:当要释放ThreadLocal时,需要手动调用
ThreadLocal.remove()
方法,手动释放设置的属性内容。
如java的直接缓冲区DirectByteBuffer
,它关联了对外内存,JVM通过初始化DirectByteBuffer
对象时设置一个虚引用,当DirectByteBuffer
对象被回收时,将会把DirectByteBuffer
关联的堆外内存信息存放到一个Queue
队列中,GC通过监控这个队列能够将DirectByteBuffer
对象关联的堆外内存给回收。
生存与死亡
不可达的对象将暂时处于“ 缓刑 ”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与
GC Roots
相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()
方法。当对象没有覆盖
finalize()
方法,或者finalize()
方法已经被虚拟机调用过,虚拟机将这两种情况都视为 “没有必要执行”,直接进行第二次标记。
如果这个对象被判定为有必要执行 finalize()
方法,那么这个对象将会放置在一个叫做 F-Queue
的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer
线程去执行它。
这里所谓的 “ 执行 ” 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,因为如果一个对象在 finalize()
方法中执行缓慢,将很可能会一直阻塞 F-Queue
队列,甚至导致整个内存回收系统崩溃。
来看一段代码:
1 | public class FinalizerTest { |
输出结果:
1 | method finalize is executed |
如果不重写finalize()方法
,输出将会是:
1 | I'm dead |
值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()
方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()
方法不会被再次执行。
应该尽量避免使用finalize()方法拯救对象,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,finalize()
能做的所有工作,使用try-finally
或者其他方法能都可以做的更好、更及时。
回收方法区
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类
回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串 “abc
” 已经进入了常量池中,但是当前系统没有任何一个 String
对象引用它,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个 “abc
” 常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
类需要满足下面3个条件才能算是无用的类:
- 该类所有实例都已经被回收,也就是说 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class
对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是 “ 可以 ” ,而并不是和对象一样,不使用了就必然会回收。
在大量使用反射、动态代理、CGLib
等 ByteCode
框架、动态生成 JSP
以及 OSGi
这类频繁自定义 ClassLoader
的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
垃圾收集算法
标记-清除算法
最基础的收集算法是 标记-清除(Mark-Sweep
)算法,算法分为 “ 标记 ” 和 “ 清除 ”两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它主要不足有两个:
- 效率问题 : 标记和清除两个过程都不高
- 空间问题 : 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记清除算法的执行过程如图所示:
复制算法
为了解决效率问题,一种称为 “ 复制 ”(Copying
)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只是用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次性清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要一动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
复制算法的执行过程如图所示:
现在的商业虚拟机都采用这种算法来回收新生代,IBM 研究指出新生代中的对象 98% 是 “朝生夕死” 的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden
空间和两块较小的 Survivor
空间,每次使用 Eden
和其中一块 Survivor
。
当回收时,将 Eden
和 Survivor
中还存活着的对象一次性地复制到另外一块 Survivor
空间上,最后清理掉 Eden
和刚才用过的 Survivor
空间。HotSpot
虚拟机默认Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor
空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion
)。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。
内存的分配担保也一样,如果另外一块 Survivor
空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章稍后在讲解垃圾收集器执行规则时还会再详细讲解。
标记-整理算法
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种 “ 标记-整理 ”(Mark-Compact
)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法的执行过程如图所示:
第一个过程和标记清除算法的第一个过程一样。然后是整理,最后在清除。
标记整理算法的优缺点:
- 优点:解决内存碎片问题。
- 缺点:不仅要标记所有存活对象,还要移动所有存活对象的地址并更新被移动的对象相关的引用。从效率上来说,要低于复制算法。
分代收集策略
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection
)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。
一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
HotSpot的算法实现
枚举根节点
以可达性分析中从 GC Roots
节点找引用链这个操作为例,可作为 GC Roots
的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在 GC
停顿上,因为这项分析工作必须在一个能确保 一致性 的快照中进行。这里的 一致性 指的是整个分析期间整个执行系统看起来就像被冻结在某个时间点上,
不可以出现分析过程中对象引用关系还在不断变化的情况,否则分析结果准确性就无法得到保证。
这点是导致 GC
进行时必须停顿所有 Java 执行线程(Sun将这件事情称为”Stop The World,STW
“)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS
收集器中,枚举根节点时也是必须要停顿的。
因此,目前的主流 Java 虚拟机使用的都是准确式 GC(即虚拟机可以知道内存中某个位置的数据具体是什么类型。),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。
在 HotSpot
的实现中,是使用一组称为 OopMap
的数据结构来达到枚举 GC Roots
的目的,在类加载完成的时候,HotSpot
就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT
编译过程中,也会在特定的位置记录栈和寄存器中哪些位置是引用。这样, GC
在扫描时就可以直接得知这些信息了。
OopMap
垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference
类型。如果发现某个位置确实存的是 Reference
类型,就意味着它所引用的对象这一次不能被回收。
但问题是,栈上的本地变量表里面只有一部分数据是 Reference
类型的(它们是 GC
所需要的),那些非 Reference
类型的数据对 GC
而言毫无用处,但 GC
还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。
一个很自然的想法是,能不能用空间换时间,在某个时候把栈上代表引用的位置全部记录下来,这样到真正 GC
的时候就可以直接读取,而不用再一点一点的扫描了。事实上,大部分主流的虚拟机也正是这么做的,比如 HotSpot
,它使用一种叫做 OopMap
的数据结构来记录这类信息。
OopMap
记录了栈上本地变量到堆上对象的引用关系。枚举根节点时,递归遍历每个栈帧的 OopMap
,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots
)。
Card Table
Card Table
由于做YGC时,无法确定年轻代的对象是否被老年代的对象引用,所以需要扫描整个OLD区,效率非常低,所以JVM设计了CardTable, 如果一个OLD区CardTable中有对象指向Y区,就将这个OLD区的对象设为Dirty,下次扫描时,只需要扫描Dirty Card上的对象,而不需要扫描整个OLD区。
在结构上,Card Table用BitMap来实现。
Collection Set
G1垃圾收集器的概念:一组可被回收的分区Region
的集合。
在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个对空间的1%大小。
RememberedSet
RememberedSet
主要用来处理频繁的新生代 GC,执行新生代 GC而不执行老年代 GC。
背景:
一般来说,GC
的过程是先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。这里由于我们只想回收新生代(换句话说,不想回收老年代),所以没有必要对位于老年代的 GC Roots
做全面的可达性分析。
问题:可能存在位于老年代的某个 GC Root
,它引用了新生代的某个对象,这个对象你是不能清除的。那怎么办呢?
通过空间换时间的办法。事实上,对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。对应上面所举的例子,“ 老年代对象引用新生代对象 ” 这种关系,会在引用关系发生时,在新生代所属的Region上专门开辟一块空间记录下来,这就是 RememberedSet
。
它和Card Table的区别在于Card Table是一张结构表,需要通过扫描对象才能知道对象是否可以清除,而G1可以通过RememberedSet
上的信息直接判断当前对象是否可以清除,Rset中的数据来源于Card Table。
达到了规避了扫描Card Table,直接通过Rset就能判断对象清除状态的作用。
由于RSet的存在,那么每次给对象赋引用的时候,就得做一些额外的操作,这些额外的操作在GC中被称作写屏障,这个写屏障不等于内存屏障。
所以新生代的 GC Roots + RememberedSet 存储的内容,才是新生代收集时真正的 GC Roots
。然后就可以以此为据,在新生代上做可达性分析,进行垃圾回收。
Collection Set和 RemenberedSet 是G1垃圾收集器引入的两个新概念。
ZGC是基于G1的思想上做了改进,没有了RemenberedSet,而是通过颜色指针,在对象头中存储了三位的信息,作为标识。
安全点
在 OopMap
的协助下,HotSpot
可以快速且准确地完成 GC Roots
枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap
内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap
,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。
实际上,HotSpot
也的确没有为每条指令都生成 OopMap
,前面已经提到,只是在 “ 特定的位置 ” 记录了这些信息,这些位置称为安全点(SafePoint
),即程序执行时并非在所有地方都能停顿下来开始 GC
,只有在到达安全点时才能暂停。
Safepoint
的选定既不能太少以致于 GC
过少,也不能过于频繁以致于过分增大运行时的负荷。
对于 Safepoint
,另一个需要考虑的问题是如何在 GC
发生时让所有线程都 “跑” 到最近的安全点上再停顿下来。这里有两种方案可供选择:
- 抢先式中断(
Preemptive Suspension
) - 主动式中断(
Voluntary Suspension
)。
其中抢先式中断不需要线程的执行代码主动去配合,在 GC
发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它 “跑” 到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC
事件。
而主动式中断的思想是当 GC
需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 GC
发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap
,记下栈上哪些位置代表着引用。
安全区域
使用 Safepoint
似乎已经完美地解决了如何进入 GC
的问题,但实际情况却并不一定。Safepoint
机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC
的Safepoint
。但是,程序“不执行”的时候呢?
所谓的程序不执行就是没有分配 CPU
时间,典型的例子就是线程处于 Sleep
状态或者 Blocked
状态,这时候线程无法响应 JVM
的中断请求,“走”到安全的地方去中断挂起,JVM
也显然不太可能等待线程重新被分配 CPU
时间。对于这种情况,就需要安全区域(Safe Region
)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。
在这个区域中的任意地方开始 GC
都是安全的。我们也可以把 Safe Region
看做是被扩展了的 Safepoint
。在线程执行到 Safe Region
中的代码时,首先标识自己已经进入了 Safe Region
,那样,当在这段时间里 JVM
要发起 GC
时,就不用管标识自己为 Safe Region
状态的线程了。
在线程要离开 Safe Region
时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC
过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region
的信号为止。
“本篇文章主要摘自《深入理解Java虚拟机_JVM高级特性与最佳实践 第2版》”
最后更新: 2020年12月06日 23:49
原始链接: https://midkuro.gitee.io/2020/05/21/garbage-collection/