在 C/C++
程序中,开发者需要自己手动管理程序的内存。也就是说当某个对象不再需要被使用,我们必须手动将其置为 null
。这虽然为开发者提供了极大的自由度,但同时也导致了很多的问题。常用的问题有两类:
- 某个对象释放内存时,多删除了一次,如果有一个其他对象刚刚申请到这块内存,突然被这个对象释放内存时删除了,就会引发一些奇怪的
bug
,并且这样的bug
很难追根溯源。 - 某个对象使用后忘记释放内存,导致内存泄漏。
所以内存管理一直是 C/C++
开发者非常头疼的问题。但在 Java 中就不会存在这样的事情,这得益于 Java
中出色的 GC(Garbage Collector)
机制,GC
会帮助我们自动回收不需要的对象。
本篇文章我们就来一起学习一下 Java
的 GC
算法。
一、什么是垃圾?
在 Java
程序中,每 new
一个对象,就会在栈或堆中分配一块内存,比如这一行代码:
Object o = new Object();
变量 o
保存了这个对象的内存地址,我们称之为 o
持有这个 new Object()
的引用,当 o
被置为 null
时:
o = null;
栈或堆中,为这个 new Object()
分配的内存不再被任何变量引用,这块内存现在孤苦伶仃,没人知道它的存在,也没有人能够再访问到它,它就成为了一个垃圾。
垃圾:程序中的一块内存没有被任何变量持有引用,导致这块内存无法被这个程序再次访问时,这块内存被称为垃圾。
二、怎么找到垃圾?
2.1 引用计数法(Reference Count)
上文说到,没有被任何引用指向的对象称之为垃圾。所以我们可以想到一种算法:在某个对象被引用指向时,将其引用数量计数。每多一个引用指向这个对象,计数 ,每少一个引用指向这个对象,计数 ,当计数为 时,表示这个对象成为了一个垃圾,将其回收掉。Python
语言的 GC
机制就是采用的此算法,它被称之为引用计数法。
循环引用的问题
引用计数法无法解决一个问题:循环引用。
看这样一个例子:
public class Client {
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.o = b;
b.o = a;
}
}
class Test {
Object o;
}
在这种情况下,a
引用了 b
,b
又引用了 a
。如果使用引用计数法,则它们的计数都为 。当 main
方法执行完后,a
和 b
都不再被使用。但由于它们的引用计数不为 ,所以它们将无法被 GC
回收掉。如果采用引用计数法来寻找垃圾,必须小心这种循环引用的问题。所以 Java
中并没有采用引用计数法来进行内存回收。
2.2 可达性分析算法(Root Searching)
可达性分析算法又被称为根搜索算法,GC
定义了一些根(roots)
,从根开始不断搜索,能够被引用到的对象就不是垃圾,不能被引用到的对象就是垃圾。
可达性分析算法解决了循环引用的问题,即使有两个或多个对象循环引用,只要根访问不到它们,它们就是一对垃圾或一堆垃圾。
GC roots
包括:虚拟机栈(局部变量表)中引用的对象、本地方法栈中 JNI 引用的对象、方法区中静态引用的对象、存活的线程对象等等。
三、怎么清除垃圾 —— 垃圾回收算法(GC Algorithms)
垃圾回收算法一共有三种:
- 标记清除
(Mark-Sweep)
- 拷贝
(Copying)
- 标记压缩
(Mark-Compact)
3.1 标记清除(Mark-Sweep)
标记清除算法的思想是:先扫描一遍内存中的所有对象,将找到的垃圾做一个标记;回收时,再扫描一遍所有对象,将带有标记的垃圾清除。
优点:
- 算法简单,容易理解
- 在垃圾较少时,效率较高
缺点:
- 需要扫描两次
- 容易产生内存碎片,可能导致最后无法找到一块连续的内存存放大对象
3.2 拷贝(Copying)
拷贝算法的思想是:将内存空间一分为二,只在一半的内存上分配对象,GC
时,将正在使用的一半内存中,所有存活的对象拷贝到另一半中,然后将正在使用的这一半内存整个回收掉。
优点:
- 只扫描一次,效率很高,尤其是垃圾较多的情况
- 不会有内存碎片
缺点:
- 浪费空间,可用内存减少
- 移动时,需要复制对象,并调整对象引用
3.3 标记压缩(Mark-Compact)
标记压缩算法的思想是:先扫描一遍内存中的所有对象,将垃圾做一个标记;回收时,先清除垃圾,然后将存活的对象移动到被回收的位置。
优点:
- 不会有内存碎片
- 不会使内存减少
缺点:
- 需要扫描两次
- 移动时,需要复制对象,并调整对象引用
四、内存分代模型
分代模型并不是一种垃圾回收算法,而是一种内存管理模型。它将 Java
中的内存分为不同的区域,在 GC
时,不同的区域采取不同的算法,可以提高回收效率。
内存分代模型将内存中的区域分成两部分:新生代(new/young)
、老年代(old/tenuring)
。两块区域的比例默认是 ,我们也可以自己设置这个比例(通过 -Xms
初始化堆的大小,通过 -Xmx
设置堆最大分配的内存大小,通过 -Xmn
设置新生代的内存大小)。
顾名思义,对象存活的时间较短,则属于新生代,存活时间较长,则属于老年代。那么如何去衡量对象存活的时间呢?JVM
的做法是:每经过一次 GC
,没被回收掉的对象年龄 ,大约 15
岁之后,新生代的对象到达老年代。
新生代又分为一个伊甸区(eden)
,两个存活区(survivor)
。当对象刚 new
出来时,通常分配在伊甸区,伊甸区的对象大多数生命周期都比较短,据不完全统计,每次 GC
时,伊甸区存活对象只占 ,由于存活对象较少,所以伊甸区的 GC
采用的是拷贝算法,但这里的拷贝算法并不是将内存一分为二,因为伊甸区存活的对象数量较少,所以存活区只需要较小的内存(伊甸区和存活区的默认比例是 ,通过-XX:SurvivorRatio
可以自定义此比例)。
新生代的 GC
被称之为 YGC
(Young Garbage Collector
,年轻代垃圾回收)或者 MinorGC
(Minor Garbage Collector
,次要垃圾回收),整个回收过程类似这样:
- 对象在伊甸区中被创建出来 →
- 伊甸区经过一次
GC
之后,存活的对象到达存活 区,清空伊甸区 → - 伊甸区和存活 区的对象经历第二次
GC
,存活的对象到达存活 区,清空伊甸区和存活 区 → - 伊甸区和存活 区经历第三次回收,存活的对象到达存活 区,清空伊甸区和存活 区 →
- 循环往复... →
- 每经过一次
GC
,没被回收掉的对象年龄 。当存活的对象到达一定年龄之后,新生代的对象到达老年代。
新生代转移到老年代的年龄根据垃圾回收器的类型而有所不同,CMS(Concurrent Mark Sweep,一种垃圾回收器) 设置的默认年龄是 ,其他的垃圾回收器默认年龄都是 。这个年龄我们可以自己设置(通过参数 -XX:MaxTenuringThreshold
配置),但不可超过 ,因为对象头中用于记录对象分代年龄的空间只有四位。
老年代的 GC
采用的是标记清除或者标记整理,因为老年代的空间较大,所以老年代的 GC
并不像新生代那样频繁。
整个内存回收称之为 FGC
(Full Garbage Collector
,完整垃圾回收),或者 MajorGC
(Major Garbage Collector
,重要垃圾回收)。YGC/MinorGC
在新生代空间耗尽时触发。FGC/MajorGC
在老年代空间耗尽时触发,FGC/MajorGC
触发时,新生代和老年代会同时进行 GC
。在 Java
程序中,也可以通过 System.gc()
来手动调用 FGC
。
小结
整个内存回收过程如下图所示:
当对象刚创建时,优先考虑在栈上分配内存。因为栈上分配内存效率很高,当栈帧从虚拟机栈 pop
出去时,对象就被回收了。但在栈上分配内存时,必须保证此对象不会被其他栈帧所引用,否则此栈帧被 pop
出去时,就会出现对象逃逸,产生 bug
。
如果此对象不能在栈上分配内存,则判断此对象是否是大对象,如果对象过大,则直接分配到老年代(具体多大这个阈值可以通过-XX:PretenureSizeThreshold
参数设置)。
否则考虑在 TLAB
(Thread Local Allocation Buffer
,线程本地分配缓冲区)上分配内存,这块内存是伊甸区为每个线程分配的一块区域,它的大小是伊甸区的 (可以通过-XX:TLABWasteTargetPercent
设置),作用是减少线程间互相争抢伊甸区空间,以减少同步操作。
伊甸区的对象经过 GC
,存活的对象在 Survivor 1
区和 Survivor 2
区不断拷贝,到达一定年龄后到达老年代。
老年代的垃圾在 FGC
时被回收。
这就是 Java 中的整个 GC
过程。
五、Garbage Collectors
随着 Java
的不断发展,垃圾回收器也在不断地更新。在 JDK 5
及之前,主要采用 Serial/Serial Old
进行垃圾回收,它们分别回收新生代/老年代,从名字就可以看出,两者都是单线程的。
在 JDK 6
中,引入了 Parallel Scavenge/Parallel Old
,简称 PS/PO
,分别用于回收新生代/老年代,在 JDK 6
到 JDK 8
中,采用 PS/PO
进行垃圾回收,它们都是多线程的。
在 JDK8
之后,出现过一个承上启下的垃圾回收器 CMS
,它开启了并发回收的先河,主要用于老年代的垃圾回收,与其搭配使用的新生代垃圾回收器名为 ParNew
。
之前的 PS/PO
虽然也使用了多线程,但多线程回收和并发回收的区别在于:多线程回收是指多个线程同时执行垃圾回收,而并发回收的意思是垃圾回收线程和工作线程同时执行。可惜的是,CMS
使用起来有一个非常大的问题,但它开启了 GC
的新思路,之后的并发垃圾回收器,如 G1(Garbage-First)
、ZGC( Z Garbage Collector)
、Shenandoah
等都是由它启发出来的。
JDK11
引入了 ZGC
,JDK12
引入了 Shenandoah
。但在 JDK 9
之后,默认都是采用 G1
进行垃圾回收,G1
是一个非常高效的并发垃圾回收器。
5.1 Serial/Serial Old
Serial: a stop-the-world, copying collector which uses a single GC thread.
Stop-the-world
简称 STW
,意思是 GC
操作中,所有的线程必须停止所有工作,等待 GC
完成后再继续工作,STW
会造成界面的卡顿。
从定义中可以看出,Serial
采用的是拷贝算法,并且是单线程运行。
Serial Old:a stop-the-world, mark-sweep-compact collector that uses a single GC thread.
和 Serial
类似,但它主要用于老年代垃圾回收,采用的是标记压缩算法,也是单线程运行。
这两个最早的垃圾回收器现在已经不实用了, 因为它们的效率实在太低。并且随着程序内存越来越大,STW
的时间也会越来越长,最终导致界面卡死的时间越来越长。
5.2 Parallel Scavenge/Parallel Old
Parallel Scavenge: a stop-the-world, copying collector which uses multiple GC threads.
从定义中可以看出,Parallel Scavenge
采用拷贝算法,多线程运行。
Parallel Old: a compacting collector that uses multiple GC threads.
Parallel Old
采用标记压缩算法,多线程运行。
5.3 CMS/ParNew
CMS(Concurrent Mark Sweep):a mostly concurrent, low-pause collector.
CMS
采用的是标记清除算法,并且是并发执行的。
并发虽好,但使用不当也会带来很多问题。核心问题有两类:
- 某个对象将要被当成垃圾回收时,工作线程中突然有一个引用准备指向它,导致标记了不该回收的对象。举个例子:Object 类中有一个
finalize
方法,当一个对象被标记为垃圾之后,垃圾回收器会调用该对象的finalize
方法,然后再将其回收。如果开发者重写了finalize
方法,在这个方法中使GC roots
再次持有该对象的引用,这时它已经不再是一个垃圾了,所以就产生了错标。 - 某个对象在
GC
扫描时没有被当成垃圾,扫描过后又变成了垃圾,导致没有标记到应该回收的对象。
这两个问题是并发垃圾回收器需要解决的关键问题。以 CMS
为例,我们来看下它是怎么解决这两类问题的。
CMS
主要分为四个阶段:初始标记(initial mark)
, 并发标记(concurrent mark)
, 重新标记(remark)
,并发清理(concurrent sweep)
。
初始标记阶段:通过 GC roots
将根上的对象找到,这时会触发 STW
,但由于根上的对象相对较少,这里的 STW
时间不会很长。
并发标记阶段:从 GC roots
开始,通过可达性分析算法找到所有的垃圾,这个阶段是最耗时的,但由于并发执行,所以不会触发 STW
。这里会用到黑白灰三色扫描算法。
黑色:自己已经标记,且
fields
已经标记完成
灰色:自己标记完成,但fields
还没标记
白色:没有遍历到的节点
并发标记是最困难的一步,难点在于标记对象的过程中,对象的引用关系正在发生改变,白色对象可能会被错误回收。
重新标记阶段:这个阶段主要用于纠错,也就是修复上文提到的标记了不该回收的对象和没有标记到应该回收的对象这两个错误,这时会触发 STW
,但时间也不会很长,因为出错的对象毕竟是少数。
并发清理阶段:清除所有的垃圾,不会触发 STW
。
由于 CMS
采用的是标记清除算法,所以不可避免地会产生较多的内存碎片。当老年代中内存碎片过多,导致无法为大对象分配内存时,CMS
会使用 Serial Old
对老年代进行垃圾回收。这会出现一次非常长时间的 STW
,这也是前文说到的使用 CMS
最大的一个问题。所以,``没有任何一个 JDK
版本采用 CMS
作为默认垃圾回收器。
ParNew:a stop-the-world, copying collector which uses multiple GC threads. It differs from "Parallel Scavenge" in that it has enhancements that make it usable with CMS.
从定义中可以看出,ParNew
是 PS
的一个变种,采用拷贝算法,多线程运行,主要是为了配合 CMS
。
5.4 G1、ZGC、Shenandoah
三者都是比较高效的并发垃圾回收器。在 CMS
的 Remark
阶段,为了修复并发标记过程中的错误标记,CMS
采用了一种 Increment Update
的算法,但这种算法在并发时可能会产生漏标。在 G1
中,此阶段采用的方案是 SATB(Snapshot At The Begining)
,ZGC
和 Shenandoah
采用的方案是 Colored Pointers
。这几种算法都比较复杂,感兴趣的读者可以自行查阅资料了解这些算法的具体实现。
六、引用
聊完了 Java
内存回收,再来看看 Java
中的四种引用类型。引用类型由强到弱分别为:
- 强引用:
Object obj = new Object();
这样new
出来的对象就属于强引用类型。GC
不会回收强引用对象。 - 软引用:
SoftReference<Object> softObj = new SoftReference();
当内存实在不足时,GC
就会回收软引用对象。 - 弱引用:
WeakReference<Object> weakObj = new WeakReference();
在 GC 回收时,遇到弱引用对象就会将其回收。 - 虚引用:不会被使用
七、总结
本文介绍了 Java
内存回收算法的知识体系,包括什么是垃圾,如何找到垃圾以及如何回收垃圾。介绍了回收垃圾时用到的三种回收算法:标记清除、拷贝、标记整理。然后介绍了历史上的几种垃圾回收器,以及 Java
中的四种引用类型。
Java
内存回收机制在面试中经常出现,但能够将其叙述清楚的开发者实在不多。当然,学习内存回收算法的意义不仅在于应付面试,在实际工作中,掌握内存回收算法可以帮助我们更好的理解对象的生命周期,防止出现内存泄漏。
Java
中的内存泄漏:某个生命周期长的对象持有了生命周期短的对象的引用,导致生命周期短的对象无法被及时回收。
所以,并不是说有了 GC
机制我们就完全不用操心内存回收问题了。在有的情况下,当某个强引用对象不再需要被使用时,我们应该手动将其置为 null
,使 GC
能够识别出这段内存已经成为了垃圾。
并且,由前文可知,方法区中静态引用的对象属于 GC roots
,所以使用静态变量和静态方法时需要小心,这些对象一旦创建,就会一直存在于内存中,直到程序退出或者变量被手动置为 null
之后,这段内存才能被回收掉。
Stay Hungry, Stay Foolish
。在日常工作中,不要只满足于完成业务。多了解程序背后的原理和运行机制,对我们自身能力的提升大有裨益。