在C语言中,使用malloc()分配对象之后,最终需要调用free()进行对象回收,否则会出现内存泄露。在C++11以前,也是同样的操作,C++11之后推出了智能指针,通过引用计数的方式,使得代码编写者可以不用主动关心对象回收的问题。
在Java语言中,同样也不用关心主动回收对象的问题,Java提供了一套完整的垃圾回收(GC,Garbage Collection)机制,自动辅助程序进行内存回收,当然如果代码编写不规范,或者参数配置有误的话,也会加重GC的负担,对性能产生比较大的影响。
在食堂吃完饭,端餐盘去回收区的是C程序员,不端盘子直接走的是Java程序员。
回收区域
在JVM - 运行时数据区简述一文中,我们提到JVM运行时数据区的模型为:
其中,蓝色的三个部分,是每个线程独享的,随着线程的销毁而回收。而栈中的栈帧,是随着方法的调用和返回,对应入栈和出栈,当栈帧出栈时,表示生命周期结束,栈帧中的局部变量表等会自动被回收。
局部变量表中的引用被回收,但是指向的堆中内存不会随着声明周期的结束而回收,堆中的数据会交给GC来处理。
因此,垃圾回收主要针对的是堆和方法区。
Stop The Word(STW)
在介绍GC之前,先介绍两个很重要的知识:STW,safe point。
在JVM中,有一个特殊的线程VM Threads,专门用来执行一些特殊的操作(VM Operation),例如Java - synchronized一文中提到的偏向锁的释放,比如Thread dump,GC等等(完整列表可以参考JVM的vm_operations.hpp),执行这些操作的时候,需要正在执行Java Code的线程全部都停下来(这个也称之为STW),此时会进入一个稳定的状态,然后再开始执行操作。
如何能让正在执行的线程快速暂停呢?最容易想到的办法就是在代码中的一些特殊位置,插入一些检查是否需要STW的代码,当线程执行到这里时,如果需要STW,则将线程会被挂起。这里提到的“代码中的一些特殊位置”,便可以理解为safe point。
阻塞线程
根据Java线程当前运行状态不同,safe point的实现方式也不同,源码在SafepointSynchronize::begin()函数中,相关注释为:
1 | // Begin the process of bringing the system to a safepoint. |
Running interpreted(指令解释执行)
线程在解释执行Java代码编译的指令时,需要一个dispatch table,来记录方法地址进行跳转。JVM中一共有3个dispatch table:
1 | class TemplateInterpreter: public AbstractInterpreter { |
需要STW的时候,解释器会将_active_table
替换为_safept_table
,解释器会把指令跳转到检查safe point的状态。
在begin()函数中会调用Interpreter::notice_safepoints()
来执行上述操作:
1 | void TemplateInterpreter::notice_safepoints() { |
Running in native code(正在执行C/C++代码)
当一个Java线程正在执行native code时,JVM并不会去阻塞正在执行的Java线程,不过当线程执行完native code返回时,必须检查safe point状态,看是否需要进行阻塞。
注意,当执行native code的线程返回时,需要去做线程状态的同步,源码注释中描述了大段关于如何做线程同步,以及基于性能方面的考虑,这里不针对这个问题深究。
Running compiled Code(JIT编译执行)
JIT会将一些热门代码编译成机器码,这样可以直接执行而不需要解释执行,以提高热门代码的执行效率。在编译的过程中,会在如下一些特定的位置,加入检测safe point的代码:
- 循环的末尾;
- 方法返回前;
- 调用方法的call之后;
- 抛出异常的位置。
线程运行到这些位置的时候,会去访问内存中的一个poling page(poling page是在jvm初始化启动的时候会初始化的一个单独的内存页面,这个页面是让运行的编译过的代码的线程进入停止状态的关键)。
当需要STW时,begin()函数中会调用os::make_polling_page_unreadable()
,将这个内存页设置为不可读,线程执行到JIT添加的代码时,会去访问这个不可读的内存页,就会在系统级别产生一个错误信号SIGSEGV,在safepoint.cpp#SafepointSynchronize::handle_polling_page_exception()
中会处理这个信号,最终通过调用SafepointSynchronize::block(thread())
来block当前线程。
Blocked
一个处于BLOCKED,WAITING状态的线程,无法执行代码,因此上面的safe point机制无法对这部分线程生效。
但是被挂起的线程,也没有机会操作数据,修改对象之间的引用关系,所以我们不需要干预一个正在被阻塞的线程,但是我们需要干预在STW期间,挂起的线程被唤醒之后的行为,这里需要用到safe region。
safe region是指一块区域,这块区域中对象的引用关系不会发生变化,比如线程被阻塞了,那么它的线程堆栈中的引用是不会被修改的,JVM可以安全地进行与这个线程有关的GC操作。线程进入到safe region的时候先标识自己进入了safe region,等它被唤醒准备离开safe region的时候,会检查safe point operation(例如GC操作)是否已经完成,如果已经完成,则可以离开;否则会一直呆在safe region中。
In VM or Transitioning between states
线程正在转换状态,会去检查safepoint状态,如果需要阻塞,就把自己挂起。
恢复线程
在SafepointSynchronize::end()
函数中,会唤醒所有挂起的线程:
- 调用
os::make_polling_page_readable()
,将内存页设置为可读; - 调用
TemplateInterpreter::ignore_safepoints()
,设置解释器为ignore safepoints; - 遍历线程,唤醒挂起的线程。
监控
由于STW会导致所有线程暂停,因此STW耗时太长会严重影响业务。JVM提供了一些参数用于打印STW相关的信息:
-XX:+PrintGCApplicationStoppedTime
此参数会打印日志:
Total time for which application threads were stopped: X seconds, Stopping threads took: Y seconds
。日志通俗易懂,表示线程一共被暂停了X秒,其中等待所有线程挂起一共耗时Y秒。不过由于很多VM Operations都需要STW,从上述日志我们无法得知是由于什么原因导致的STW。
-XX:+PrintSafepointStatistics –XX:PrintSafepointStatisticsCount=1
此参数会打印日志:
5.141: RevokeBias [13 0 2] [0 0 0 0 0] 0
,从左至右,参数含义依次是:- 5.141:JVM启动之后所经历的毫秒数;
- RevokeBias:触发这次STW的操作;
- RevokeBias:偏向锁的撤销,高并发的应用一般会干脆在启动参数里加一句
-XX:-UseBiasedLocking
取消掉它; - no vm operation:这是一个“保证安全点”,JVM默认每秒都会进入一次安全点(如果这秒已经GC过就不用了),给一些需要在安全点里进行,又非紧急的操作使用(比如一些采样型的Profiler工具),可用
-DGuaranteedSafepointInterval
来调整。
- RevokeBias:偏向锁的撤销,高并发的应用一般会干脆在启动参数里加一句
- [13 0 2]:
- total:停在安全点的线程总数;
- initially_running: 安全点时开始时正在运行状态的线程数;
- wait_to_block: 在VM Operation开始前需要等待其暂停的线程数;
- [0 0 0 0 0]:
- spin: 等待线程响应safe point号召的时间;
- block: 暂停所有线程所用的时间;
- sync: 等于spin+block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时;
- cleanup: 清理所用时间;
- vmop: 真正执行VM Operation的时间;
- 0:执行操作所花的时间。
GC算法
全局线程都暂停之后,就可以开始GC了,简单来说,GC算法可以理解成完成三件事情:
- 标记哪些对象是无用的对象;
- 将无用的对象内存回收掉;
- 处理回收之后的内存碎片的问题(非必须)。
标记算法
标记是GC算法中的第一步,不管用什么GC算法,都要首先把没有被使用的对象标记出来,然后在进行统一的内存回收。标记对象一般有两种方式:
引用计数
引用计数是一种历史悠久的算法,被广泛应用于各种语言中,其原理比较简单,给每个对象增加一个引用计数器,当对象新增一个引用时,计数器便加1;引用被释放时,计数器便减1。当计数器减到0时,便回收此对象。
但是引用计数算法本身无法解决循环引用的问题,比如上图中红色的三个对象,他们之间是相互引用的关系,所以引用计数都是1,但是实际上没有任何其他对象引用他们,理论上这三个对象应该也是要被回收的。
C++11中的shared_ptr也是采用的引用计数的方式,来实现的对象自动销毁,C++11中,还同时引入了weak_ptr,来解决循环引用的问题。weak_ptr是一种弱引用,不增加引用计数,不过需要代码编写者手动处理什么地方使用shared_ptr,什么地方使用weak_ptr。
可达性分析
绝大部分Java虚拟机都是采用的可达性分析的算法,算法首先会定义如下类型的对象为GC Roots:
虚拟机栈中引用的对象;
方法区中类静态属性实体引用的对象;
方法区中常量(final)引用的对象;
本地方法栈中JNI引用的对象;
分代收集算法中,非收集部分指向收集部分的引用。
最后一点,见R大在知乎上的回答。
然后,从GC Roots开始,一层一层不断搜索引用的对象,如下图:
上图中,蓝色的对象都是被GC Roots对象直接或者间接引用;其他对象不能与GC Roots对象挂钩;右侧的三个红色对象为循环引用,且并没有与GC Roots对象直接或者间接引用。通过GC Roots开始遍历,可以将所有有效对象都标记出来,不会受到循环引用的影响。
没有被标记的对象,则表示没有被使用了,这些对象会存放在一个“即将被回收”的集合里面,但是这些对象还有一次“自救”的机会。一个对象真正被确定为需要回收,需要两次标记,第一次标记即是从GC Roots开始的可达性分析,第二次是finalize()方法的执行判断:
首先判断是否有必要调用对象的finalize()方法。如果一个对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则表示没有必要再调用finalize()方法了,则自救失败;
需要调用finalize()方法的对象,会被放入一个F-Queue队列中,稍后会被一个Finalizer线程去执行对象的finalize()方法;
- finalize()方法是对象自救的最后机会,如果在方法中,对象与GC Roots的引用树上的任何一个对象建立关联(比如把自己赋值给某个类变量或者对象的成员变量),那在第二次标记时它将被移除出“即将回收的集合;否则,对象就自救失败。
测试代码如下:
1 | public class TestApp { |
程序依次输出:
1 | call System.gc 1 |
TestApp obj
是一个类静态属性,所以在GC中会被作为GC Roots之一。
- 第一次调用gc之前,在堆中分配一个TestApp对象,并将obj指向它;
- 第一次调用gc时,由于obj指向TestApp对象,因此堆中的TestApp对象不会被回收;
- 第二次调用gc之前,将obj指向null,使得TestApp对象没有被任何GC Root直接或者间接引用;
- 第二次调用gc时,堆中的TestApp对象会被放入“即将被回收”的集合里面;
- 紧接着,一个名为Finalizer的线程调用了对象的finalize()方法,而方法中又将TestApp对象赋值给了obj,自救成功,堆中的TestApp对象活了下来,没有被回收;
- 第三次调用gc之前,再次将obj指向null,使得TestApp对象没有被任何GC Root直接或者间接引用;
- 第三次调用gc时,因为finalize()方法已经被调用过了,不会再次调用,所以堆中的TestApp对象没有自救的机会,被成功回收。
回收算法
标记-清除算法
步骤如下:
- 标记正在使用的对象,从而找到没有被使用的对象;
- 将没有被使用的对象占用的内存回收掉。
这个算法不会对回收之后的内存分布做处理,这样会导致两个问题:
- JVM不得不维护一个空闲的内存列表,记录下空闲内存的地址;
- 会增加分配对象的耗时,因为需要利用空闲内存列表找到适合的内存空间。
标记-整理算法
步骤如下:
- 标记正在使用的对象;
- 按照内存地址次序,依次移动正在使用的对象并紧密排列;
- 将末端内存地址以后的内存全部回收。
“标记-整理”算法,能够解决“标记-清除”算法导致的内存碎片的问题,但是由于多了对象的移动,因此会增加GC耗时。
复制算法
复制算法最大的特点,是需要将内存一分为二,分为A/B两块区域,所有内存分配都在其中一块进行,这一块称之为活动区间,另外一块称之为空闲区间,GC步骤如下:
- 在活动区间中,标记正在使用的对象;
- 将标记为正在使用的对象移动到空闲区间;
- 回收整个活动区间,完成之后交换空闲区间与活动区间的指针。
算法的缺点为:
- 内存利用率只有50%;
- 假设对象存活率很高,例如极端情况下的100%,那么所有对象都要复制一遍。
分代内存收集
由于GC过程需要STW,所以如果能不影响GC频率的前提下,缩短每次GC的工作时长,那么就能提高GC的效率,而堆中对象的多少,直接影响了STW的耗时,所以是否有可能减少每次GC扫描的对象范围?
假设某个对象,在连续多次的GC中都活了下来,那么是否可以假设这个对象在接下来的几次GC中仍然会存活?我们认为这些对象是生命周期长的对象,针对这些对象,可以降低GC频率,比如经历过N次GC仍然存活的对象,以后每M次(M>N)GC才扫描一次。
在实际代码中,绝大部分变量(比如局部变量)都是生命周期很短的对象,变量作用域一过,对象就可以被回收掉。我们认为这些对象是生命周期短的对象。
基于上面两种情况,我们将内存划分为两块,分别存放生命周期短和生命周期长的对象,即是年轻代和老年代。
针对年轻代来说,由于部分对象放在了老年代,因此减少了每次年轻代GC时的扫描对象范围;针对老年代来说,由于只保存了生命周期长的对象,因此内存占用变动不频繁,从而降低了GC的频率。这种根据对象生命周期划分内存范围的思想,便是分代收集算法。
下图是一个常规的分代算法的内存布局,不同的GC回收器可能会有不同的内存布局实现,比如G1中的Region划分机制。
young(年轻代)
年轻代中,由于绝大部分对象的生命周期都很短,熬不过一次GC,因此年轻代采用了复制算法的思想,这样每次只需要找到存活的对象就行了,不需要扫描整个年轻代。而基于常规的复制算法,又做了一些优化,将内存分为3部分,Eden(伊甸区),S0(From区),S1(To区),三者的比例默认为8:1:1,S0和S1又被称为Survivor区。
JVM中绝大部分对象都会分配在Eden中(部分大对象会直接进入老年代),当Eden区内存不足以分配对象时,会触发一次Young GC,大致步骤如下:
- 扫描Eden区和From区,标记存活的对象;
- 将存活的对象的年龄加1,并根据对象的年龄,移动对象
- 如果达到进入老年代的阈值,则会被移动到老年代;
- 如果没有达到进入老年代的阈值,则会被移动到To区;
- 清空Eden区和From区,然后互换From区和To区的角色。
若To区的剩余容量不足以存放此次存活的对象,则会触发对象提前晋升到老年代。
old(老年代)
老年代的内存空间一般比年轻代大很多,主要用于存放在年轻代中经过多次GC仍然存活的对象,不过有些对象(比如大对象,大的数组等等)也会直接分配到老年区。
不同的GC回收器,对老年代的GC触发条件不一样,比如有的是定时监控老年区的大小,超过阈值之后便触发GC;有的是当老年代无法分配内存之后才触发GC。
除了年轻代和老年代中存放的对象之外,还有一部分对象,基本上是不太会被销毁的,比如运行时的方法区中的对象,在Hotspot JDK6,7,8三个版本中,都不断做了调整,可以参考知乎的这个回答。
非收集部分的GC Roots
在分代收集算法中,GC一般都是采用的部分收集的方式,在执行部分收集时,从GC堆的非收集部分指向收集部分的引用,也必须作为GC roots的一部分。举个例子,在执行年轻代GC的时候,如果有个老年代的对象A指向年轻代的对象B,那么在这次Young GC中,对象B不应该被回收,对象A也应该作为GC Roots的其中一员。
但是如果每次做分代GC时,都扫描非收集部分的内存区域的话,效率太低,为了高性能的解决这个的问题,衍生出一种空间换时间的方式:在程序运行过程中,将不同分代之间的对象引用关系记录下来,这样在Young GC的时候,只需要扫描年轻代的GC Roots和关系记录中的内容就可以了,避免扫描整个老年代。
不过不同的GC有不同的实现方式,有的是points-into,有的是points-out。
分代收集算法下的GC
这部分内容,可参考文末R大的文章,绝大部分都是根据R大的回复内容整理得来。
经过HotSpot VM这么多年的发展,各种名词的解读已经比较混乱了,我们经常能看到各种GC相关的概念,Young GC,Minor GC,Old GC,Major GC,Full GC等等。
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大类:
Partial GC:并不收集整个GC堆的模式;
- Young GC:只收集年轻代;
- Old GC:只收集老年代。只有CMS GC的concurrent collection是这个模式;
- Mixed GC:收集整个年轻代以及部分老年代,是G1 GC特有的模式;
Full GC:收集整个堆。
Minor GC通常等价于Young GC,而Major GC需要确定清楚沟通中指的是Full GC还是Old GC,没有一个清楚的定义。
后续的文章中,会尽量避免Minor GC,Major GC,主要使用Young GC,Old GC,Full GC三个概念。
Full GC是收集整个堆,以标记-整理算法为例,在标记步骤是整个堆一起做的,然后整理的时候,先整理老年代,再整理年轻代。整理年轻代的时候,如果老年代有剩余空间,就会把活着的对象压缩到老年代,否则还是留在年轻代。
GC组合
这部分内容中涉及到GC细节的地方不深入讨论,各种GC细节可以参考后续的文章。
在绝大部分文章中,都会提到用什么参数,开启某个年轻代GC和老年代GC的组合,例如使用-XX:+UseSerialGC
参数,使得年轻代使用SerialGC,老年代使用SerialOldGC,不过我觉得这种说法有点误导,因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集老年代的GC都会同时收集整个GC堆,其实是利用了老年代GC来做Full GC,所以我觉得使用Young GC + Full GC的组合更贴切,例如:
CMS
只有CMS有真正的只负责收集老年代的GC,在CMS算法中,会使用ParNew GC + CMS GC + Serial Old GC的组合,其中ParNew GC负责收集年轻代,CMS GC负责收集老年代(收集老年代之前根据参数配置,可能会触发一次Young GC),当CMS发生concurrent mode failure时,会降级成Serial Old GC来做Full GC。
其他
常规的收集器,其组合更应该是Young GC + Full GC,例如使用
-XX:+UseSerialGC
参数,实际上表示使用SerialGC来执行Young GC,Serial Old GC来执行整个堆的Full GC。敲黑板:Serial Old GC实际上是整个堆范围的GC,不光只收集老年代。不过G1 GC特殊一点,G1包括一个Young GC负责做年轻代的GC,Mix GC收集所有年轻代和部分老年代,当回收速度赶不上的时候,会使用Serial Old GC来做Full GC。
常见的组合有:
Young | Old | Full | 开启参数 |
---|---|---|---|
Serial GC | Serial Old GC | -XX:+UseSerialGC | |
Parallel Scavenge GC | Parallel Old GC | -XX:+UseParallelOldGC | |
Parallel New GC | CMS GC | Serial Old GC | -XX:+UseConcMarkSweepGC |
Young GC | Mix GC / Serial Old GC | -XX:+UseG1GC |
触发GC
根据选择的收集器不同,触发GC的方式也不同:
Young GC
Young GC的触发条件相对统一一点,当Eden区分配满了之后,便会触发Young GC。Young GC之后,有部分对象可能会达到晋升年龄,从而进入老年代。
Full GC
大部分通用的触发条件
- 准备要触发Young GC时,如果发现统计数据说之前Young GC的平均晋升大小比目前老年代剩余的空间大,则放弃Young GC而是转为触发Full GC(Full GC也会收集年轻代,所以不需要事先触发一次单独的Young GC);
- System.gc()、Heap Dump等命令。
收集器各自的触发条件
不同的收集器,根据各自的策略会有不同的触发条件,比如G1是检查老年代占整个堆的百分比,一旦超过之后,便会触发Mix GC。
Old GC
目前只有CMS有单独的老年代收集器,CMS算法中,会定时检查老年代的使用比例,一旦超过了设置的触发比例,就会启动一次CMS GC。
参考
- JVM源码分析之安全点safepoint
- JVM的Stop The World,安全点,黑暗的地底世界
- RednaxelaFX - java的gc为什么要分代?
- RednaxelaFX - Major GC和Full GC的区别是什么?触发条件呢?
- RednaxelaFX - ParNew 和 PSYoungGen 和 DefNew 是一个东西么?