GC算法
1.垃圾检测算法
若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。
1.1 引用计数法
在对象头维护着一个 counter 计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题:
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。 正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
1.2 可达性分析算法
以GCROOT对象为起始点进行搜索,如果有对象不可达的话,即是垃圾对象。
GC 遍历(traverses)内存中整体的对象关系图(object graph),从 GC 根元素开始扫描,到直接引用,以及其他对象(通过对象的属性域)。所有 GC 访问到的对象都被标记(marked) 为存活对象。
存活对象在上图中用蓝色表示。标记阶段完成后,所有存活对象都被标记了。而其他对象(上图中灰色的数据结构)就是从 GC 根元素不可达的,也就是说程序不能再使用这些不可达的对象(unreachable object)。这样的对象被认为是垃圾,GC 会在接下来的阶段中清除他们。
在标记阶段有几个需要注意的地方:在标记阶段,需要暂停所有应用线程,以遍历所有对象的引用关系。因为不暂停就没法跟踪一直在变化的引用关系图。这种情景叫做 Stop The World pause(全线停顿),而可以安全地暂停线程的点叫做安全点(safe point),然后,JVM 就可以专心执行清理工作。安全点可能有多种因素触发,当前,GC 是触发安全点最常见的原因。
此阶段暂停的时间,与堆内存大小,对象的总数没有直接关系,而是由存活对象(alive objects)的数量来决定。所以增加堆内存的大小并不会直接影响标记阶段占用的时间。
可作为 GC Roots 的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象
GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。
2.引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java
具有四种强度不同的引用类型。主要体现的是对象不同的可达性状态reachable
和垃圾收集的影响。分为以下四种:
2.1 强引用(Strong Reference)
类似 "Object obj = new Object()
"
这类的引用,就是强引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。但是,如果我们错误地保持了强引用,比如:赋值给了
static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。
1 |
|
2.2 软引用(Soft Reference)
软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
1 |
|
2.3 弱引用(Weak Reference)
弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。
1 |
|
2.4 虚引用(Phantom Reference)
虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。它仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机制。
1 |
|
很多开发者忽略了下一段内容(这才是重点):
Unlike soft and weak references,phantom references are not automatically cleared by the garbage collector as they are enqueued. An object that is reachable via phantom references will remain so until all such references are cleared or themselves become unreachable.
与软引用和弱引用不同,虚引用不会被 GC 自动清除,因为他们被存放到队列中。通过虚引用可达的对象会继续留在内存中,直到调用此引用的 clear 方法,或者引用自身变为不可达。
1 |
|
3.垃圾回收算法
3.1 标记-清除(Mark-And-Sweep)
JVM 使用标记—清除算法(Mark and Sweep algorithm),来跟踪所有的可达对象(即存活对象),确保所有不可达对象(non-reachable objects)占用的内存都能被重用。如它的名字一样,算法分为“标记”和“清除”两个阶段:
- Marking(标记):遍历所有的可达对象,并在本地内存(native)中分门别类记下。
- Sweeping(清除):这一步保证了,不可达对象所占用的内存,在之后进行内存分配时可以重用。
JVM 中包含了多种 GC 算法,如 Parallel Scavenge(并行清除),Parallel Mark+Copy(并行标记 + 复制)以及 CMS,他们在实现上略有不同,但理论上都采用了以上两个步骤。
标记清除算法最重要的优势,就是不再因为循环引用而导致内存泄露:
标记—清除(Mark and Sweep)是最经典的垃圾收集算法。将理论用于生产实践时,会有很多需要优化调整的地方,以适应具体环境。后面我们会通过一个简单的例子,看看如何才能保证 JVM 能安全持续地分配对象。
而这种处理方式不好的地方在于:垃圾收集过程中,需要暂停应用程序的所有线程。假如不暂停,则对象间的引用关系会一直不停地发生变化,那样就没法进行统计了。这种情况叫做 STW 停顿(Stop The World pause,全线暂停),让应用程序暂时停止,让 JVM 进行内存清理工作。如果把 JVM 里的环境看做一个世界,就好像我们经常在电影里看到的全世界时间静止了一样。有很多原因会触发 STW 停顿,其中垃圾收集是最主要的原因。
它的主要缺点有两个:
- 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
- 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.2 复制(Copying)
复制算法也叫标记-复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。它把内存空间划为两个相等的区域,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保(Handle Promotion),也就是借用老年代的空间存储放不下的对象。
3.3 标记- 整理(Mark-Compact)
针对于标记—清除,我们会发现:每次执行清除(Sweeping),JVM 都必须保证不可达对象占用的内存能被回收重用。这时候,就像是摆满棋子的围棋盘上,一部分位置上棋子被拿掉而产生了一些零散的空位置。但这(最终)有可能会产生内存碎片(类似于磁盘碎片),进而引发两个问题。
- 写入操作越来越耗时,因为寻找一块足够大的空闲内存会变得困难(棋盘上没有一整片的空地方);
- 在创建新对象时,JVM 在连续的块中分配内存。如果碎片问题很严重,直至没有空闲片段能存放下新创建的对象,就会发生内存分配错误(allocation error)。
JVM 必须确保碎片问题不失控。因此在垃圾收集过程中,不仅仅是标记和清除,还需要执行“内存碎片整理”过程。我们上面所说标记-复制算法已经解决了这个问题,但是它有局限性:
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果
不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存
活的极端情况,所以在老年代一般不能直接选用这种算法。****
针对于标记-复制算法,在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,后来又提出了标记-整理算法。其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内,这个过程让所有可达对象(reachable objects)依次排列,以消除(或减少)碎片。就像是我们把棋盘上剩余的棋子都聚集到一起,留出来足够大的空余区域。这就是我们的标记-整理算法。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动 式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”,所以修改对象引用是一个安全的行为。但要更新所有的引用,可能会影响应用程序的性能。
3.4 分代收集
分代收集是一种理论,前面提到过,执行垃圾收集需要停止整个应用。很明显,对象越多则收集所有垃圾消耗的时间就越长。但可不可以只处理一个较小的内存区域呢?为了探究这种可能性,研究人员发现,程序中的大多数可回收的内存可归为两类:
大部分对象很快就不再使用,生命周期较短;
还有一部分不会立即无用,但也不会持续太长时间。
根据对象存活周期的不同,把对象进行分类,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。
- 新生代:复制算法
- 老年代:标记-清除算法、标记-整理算法
天下没有免费的午餐,所以这种方法也不是没有任何问题。例如,在不同分代中的对象可能会互相引用,在收集某一个分代时就会成为“事实上的”GC root。
4.内存池划分
堆内存中的内存池划分也是类似的,不太容易理解的地方在于各个内存池中的垃圾收集是如何运行的。
4.1 新生代(Eden Space)
Eden Space,也叫伊甸区,是内存中的一个区域,用来分配新创建的对象。通常会有多个线程同时创建多个对象,所以 Eden 区被划分为多个 线程本地分配缓冲区(Thread Local Allocation Buffer,简称 TLAB)。通过这种缓冲区划分,大部分对象直接由 JVM 在对应线程的 TLAB 中分配,避免与其他线程的同步操作。
如果 TLAB 中没有足够的内存空间,就会在共享 Eden 区(shared Eden space)之中分配。如果共享 Eden 区也没有足够的空间,就会触发一次 年轻代 GC 来释放内存空间。如果 GC 之后 Eden 区依然没有足够的空闲内存区域,则对象就会被分配到老年代空间(Old Generation)。
当 Eden 区进行垃圾收集时,GC 将所有从 root 可达的对象过一遍,并标记为存活对象。
我们曾指出,对象间可能会有跨代的引用,所以需要一种方法来标记从其他分代中指向 Eden 的所有引用。这样做又会遭遇各个分代之间一遍又一遍的引用。JVM 在实现时采用了一些绝招:卡片标记(card-marking)。从本质上讲,JVM 只需要记住 Eden 区中“脏”对象的粗略位置,可能有老年代的对象引用指向这部分区间。更多细节请参考:Nitsan 的博客。
标记阶段完成后,Eden 区中所有存活的对象都会被复制到存活区(Survivor spaces)里面。整个 Eden 区就可以被认为是空的,然后就能用来分配新对象。这种方法称为“标记—复制”(Mark and Copy):存活的对象被标记,然后复制到一个存活区(注意,是复制,而不是移动)。
4.2 存活区(Survivor Spaces)
Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。
空的那个存活区用于在下一次年轻代 GC 时存放收集的对象。年轻代中所有的存活对象(包括 Eden 区和非空的那个“from”存活区)都会被复制到 ”to“ 存活区。GC 过程完成后,“to”区有对象,而“from”区里没有对象。两者的角色进行正好切换,from 变成 to,to 变成 from。
存活的对象会在两个存活区之间复制多次,直到某些对象的存活 时间达到一定的阀值。分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间。
这类“年老”的对象因此被提升(promoted)到老年代。提升的时候,存活区的对象不再是复制到另一个存活区,而是迁移到老年代,并在老年代一直驻留,直到变为不可达对象。
为了确定一个对象是否“足够老”,可以被提升(Promotion)到老年代,GC 模块跟踪记录每个存活区对象存活的次数。每次分代 GC 完成后,存活对象的年龄就会增长。当年龄超过提升阈值(tenuring threshold),就会被提升到老年代区域。
具体的提升阈值由 JVM 动态调整,但也可以用参数
-XX:+MaxTenuringThreshold
来指定上限。如果设置
-XX:+MaxTenuringThreshold=0
,则 GC
时存活对象不在存活区之间复制,直接提升到老年代。现代 JVM
中这个阈值默认设置为 15 个 GC 周期。这也是 HotSpot JVM
中允许的最大值。
如果存活区空间不够存放年轻代中的存活对象,提升(Promotion)也可能更早地进行。
4.3 老年代(Old Gen)
老年代的 GC 实现要复杂得多。老年代内存空间通常会更大,里面的对象是垃圾的概率也更小。
老年代 GC 发生的频率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。老年代空间的清理算法通常是建立在不同的基础上的。原则上,会执行以下这些步骤:
- 通过标志位(marked bit),标记所有通过 GC roots 可达的对象;
- 删除所有不可达对象;
- 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放。
通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。
4.4 永久代(Perm Gen)
在 Java 8 之前有一个特殊的空间,称为“永久代”(Permanent Generation)。这是存储元数据(metadata)的地方,比如 class 信息等。此外,这个区域中也保存有其他的数据和信息,包括内部化的字符串(internalized strings)等等。
实际上这给 Java
开发者造成了很多麻烦,因为很难去计算这块区域到底需要占用多少内存空间。预测失败导致的结果就是产生
java.lang.OutOfMemoryError: Permgen space
这种形式的错误。除非 OutOfMemoryError
确实是内存泄漏导致的,否则就只能增加 permgen
的大小,例如下面的示例,就是设置 perm gen 最大空间为 256 MB:
1 |
|
4.5 元数据区(Metaspace)
既然估算元数据所需空间那么复杂,Java 8 直接删除了永久代(Permanent Generation),改用 Metaspace。从此以后,Java 中很多杂七杂八的东西都放置到普通的堆内存里。
当然,像类定义(class definitions)之类的信息会被加载到 Metaspace
中。元数据区位于本地内存(native memory),不再影响到普通的 Java
对象。默认情况下,Metaspace 的大小只受限于 Java
进程可用的本地内存。这样程序就不再因为多加载了几个类/JAR 包就导致
java.lang.OutOfMemoryError: Permgen space.
。注意,这种不受限制的空间也不是没有代价的
—— 如果 Metaspace
失控,则可能会导致严重影响程序性能的内存交换(swapping),或者导致本地内存分配失败。
如果需要避免这种最坏情况,那么可以通过下面这样的方式来限制 Metaspace 的大小,如 256 MB:
1 |
|
5.问题
5.1 为什么新生代使用标记-复制算法?
结合前面所讲的,我们可知:新生代将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,也就是我们新生代的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效。
5.2 为什么老年代使用标记-整理算法?
我们知道,在老年代中,对象大多都是存货的对象,针对于这种老年代对象的存亡特征,就不能直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,而且老年代也不需要复制算法,老年代的对象大多数都是存货的对象,像复制算法将大量存活的对象复制到另一个区域,效率与内存使用都比使用标记-整理算法差。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策.
5.3 标记-复制算法为什么不是标记-移动算法?
JVM 中的引用是一个抽象的概念,如果 GC 移动某个对象,就会修改(栈和堆中)所有指向该对象的引用。移动/拷贝/提升/压缩一般来说是一个 STW 的过程,所以修改对象引用是一个安全的行为。但要更新所有的引用,可能会影响应用程序的性能。
如果移动存活对象,尤其是在新生代这种每次回收都有对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,因为如果不程暂停用户应用程序,则对象的引用关系一直在变,则对象间的引用关系会一直不停地发生变化,那样就没法进行统计了。
反之使用复制算法,最后再更新对象的引用关系。
6.趣谈
这里有个比喻很形象:
假设你是一个普通的 Java 对象,你出生在 Eden 区,在 Eden 区有许多和你差不多的小兄弟、小姐妹,可以把 Eden 区当成幼儿园,在这个幼儿园里大家玩了很长时间。Eden 区不能无休止地放你们在里面,所以当年纪稍大,你就要被送到学校去上学,这里假设从小学到高中都称为 Survivor 区。开始的时候你在 Survivor 区里面划分出来的的“From”区,读到高年级了,就进了 Survivor 区的“To”区,中间由于学习成绩不稳定,还经常来回折腾。直到你 18 岁的时候,高中毕业了,该去社会上闯闯了。于是你就去了年老代,年老代里面人也很多。在年老代里,你生活了 20 年 (每次 GC 加一岁),最后寿终正寝,被 GC 回收。有一点没有提,你在年老代遇到了一个同学,他的名字叫爱德华 (慕光之城里的帅哥吸血鬼),他以及他的家族永远不会死,那么他们就生活在永生代。
博客说明
文章所涉及的资料来自互联网整理和个人总结,意在于个人学习和经验汇总,不用于任何的商业用途。如有侵权,请联系本人删除。谢谢!