0%

Java - GC概述

在C语言中,使用malloc()分配对象之后,最终需要调用free()进行对象回收,否则会出现内存泄露。在C++11以前,也是同样的操作,C++11之后推出了智能指针,通过引用计数的方式,使得代码编写者可以不用主动关心对象回收的问题。

在Java语言中,同样也不用关心主动回收对象的问题,Java提供了一套完整的垃圾回收(GC,Garbage Collection)机制,自动辅助程序进行内存回收,当然如果代码编写不规范,或者参数配置有误的话,也会加重GC的负担,对性能产生比较大的影响。

在食堂吃完饭,端餐盘去回收区的是C程序员,不端盘子直接走的是Java程序员。

回收区域

JVM - 运行时数据区简述一文中,我们提到JVM运行时数据区的模型为:

1

其中,蓝色的三个部分,是每个线程独享的,随着线程的销毁而回收。而栈中的栈帧,是随着方法的调用和返回,对应入栈和出栈,当栈帧出栈时,表示生命周期结束,栈帧中的局部变量表等会自动被回收。

局部变量表中的引用被回收,但是指向的堆中内存不会随着声明周期的结束而回收,堆中的数据会交给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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Begin the process of bringing the system to a safepoint.
// Java threads can be in several different states and are
// stopped by different mechanisms:
//
// 1. Running interpreted
// The interpeter dispatch table is changed to force it to
// check for a safepoint condition between bytecodes.
// 2. Running in native code
// When returning from the native code, a Java thread must check
// the safepoint _state to see if we must block. If the
// VM thread sees a Java thread in native, it does
// not wait for this thread to block. The order of the memory
// writes and reads of both the safepoint state and the Java
// threads state is critical. In order to guarantee that the
// memory writes are serialized with respect to each other,
// the VM thread issues a memory barrier instruction
// (on MP systems). In order to avoid the overhead of issuing
// a memory barrier for each Java thread making native calls, each Java
// thread performs a write to a single memory page after changing
// the thread state. The VM thread performs a sequence of
// mprotect OS calls which forces all previous writes from all
// Java threads to be serialized. This is done in the
// os::serialize_thread_states() call. This has proven to be
// much more efficient than executing a membar instruction
// on every call to native code.
// 3. Running compiled Code
// Compiled code reads a global (Safepoint Polling) page that
// is set to fault if we are trying to get to a safepoint.
// 4. Blocked
// A thread which is blocked will not be allowed to return from the
// block condition until the safepoint operation is complete.
// 5. In VM or Transitioning between states
// If a Java thread is currently running in the VM or transitioning
// between states, the safepointing code will wait for the thread to
// block itself when it attempts transitions to a new state.

Running interpreted(指令解释执行)

线程在解释执行Java代码编译的指令时,需要一个dispatch table,来记录方法地址进行跳转。JVM中一共有3个dispatch table:

1
2
3
4
5
6
7
8
9
class TemplateInterpreter: public AbstractInterpreter {
protected:
// the active dispatch table (used by the interpreter for dispatch)
static DispatchTable _active_table;
// the normal dispatch table (used to set the active table in normal mode)
static DispatchTable _normal_table;
// the safepoint dispatch table (used to set the active table for safepoints)
static DispatchTable _safept_table;
}

需要STW的时候,解释器会将_active_table替换为_safept_table,解释器会把指令跳转到检查safe point的状态。

在begin()函数中会调用Interpreter::notice_safepoints()来执行上述操作:

1
2
3
4
5
6
7
void TemplateInterpreter::notice_safepoints() {
if (!_notice_safepoints) {
// switch to safepoint dispatch table
_notice_safepoints = true;
copy_table((address*)&_safept_table, (address*)&_active_table, sizeof(_active_table) / sizeof(address));
}
}

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来调整。
    • [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时,便回收此对象。

2

但是引用计数算法本身无法解决循环引用的问题,比如上图中红色的三个对象,他们之间是相互引用的关系,所以引用计数都是1,但是实际上没有任何其他对象引用他们,理论上这三个对象应该也是要被回收的。

C++11中的shared_ptr也是采用的引用计数的方式,来实现的对象自动销毁,C++11中,还同时引入了weak_ptr,来解决循环引用的问题。weak_ptr是一种弱引用,不增加引用计数,不过需要代码编写者手动处理什么地方使用shared_ptr,什么地方使用weak_ptr。

可达性分析

绝大部分Java虚拟机都是采用的可达性分析的算法,算法首先会定义如下类型的对象为GC Roots:

  • 虚拟机栈中引用的对象;

  • 方法区中类静态属性实体引用的对象;

  • 方法区中常量(final)引用的对象;

  • 本地方法栈中JNI引用的对象;

  • 分代收集算法中,非收集部分指向收集部分的引用。

    最后一点,见R大在知乎上的回答

然后,从GC Roots开始,一层一层不断搜索引用的对象,如下图:

3

上图中,蓝色的对象都是被GC Roots对象直接或者间接引用;其他对象不能与GC Roots对象挂钩;右侧的三个红色对象为循环引用,且并没有与GC Roots对象直接或者间接引用。通过GC Roots开始遍历,可以将所有有效对象都标记出来,不会受到循环引用的影响。

没有被标记的对象,则表示没有被使用了,这些对象会存放在一个“即将被回收”的集合里面,但是这些对象还有一次“自救”的机会。一个对象真正被确定为需要回收,需要两次标记,第一次标记即是从GC Roots开始的可达性分析,第二次是finalize()方法的执行判断:

  • 首先判断是否有必要调用对象的finalize()方法。如果一个对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,则表示没有必要再调用finalize()方法了,则自救失败;

  • 需要调用finalize()方法的对象,会被放入一个F-Queue队列中,稍后会被一个Finalizer线程去执行对象的finalize()方法;

  • finalize()方法是对象自救的最后机会,如果在方法中,对象与GC Roots的引用树上的任何一个对象建立关联(比如把自己赋值给某个类变量或者对象的成员变量),那在第二次标记时它将被移除出“即将回收的集合;否则,对象就自救失败。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TestApp {
private static TestApp obj = null;

public static void main(String[] args) throws InterruptedException {
obj = new TestApp();
gc(1);

obj = null;
gc(2);

obj = null;
gc(3);
}

private static void gc(int index) throws InterruptedException {
System.gc();
System.out.println("call System.gc " + index);
Thread.sleep(2000);

if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is alive");
}

System.out.println("==============");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
obj = this;
System.out.println("call finalize() with thread:" + Thread.currentThread().getName());
}
}

程序依次输出:

1
2
3
4
5
6
7
8
9
10
11
12
call System.gc 1
obj is alive
==============
call System.gc 2
call finalize() with thread:Finalizer
obj is alive
==============
call System.gc 3
obj is dead
==============

Process finished with exit code 0

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对象没有自救的机会,被成功回收。

回收算法

标记-清除算法

4

步骤如下:

  • 标记正在使用的对象,从而找到没有被使用的对象;
  • 将没有被使用的对象占用的内存回收掉。

这个算法不会对回收之后的内存分布做处理,这样会导致两个问题:

  • JVM不得不维护一个空闲的内存列表,记录下空闲内存的地址;
  • 会增加分配对象的耗时,因为需要利用空闲内存列表找到适合的内存空间。

标记-整理算法

5

步骤如下:

  • 标记正在使用的对象;
  • 按照内存地址次序,依次移动正在使用的对象并紧密排列;
  • 将末端内存地址以后的内存全部回收。

“标记-整理”算法,能够解决“标记-清除”算法导致的内存碎片的问题,但是由于多了对象的移动,因此会增加GC耗时。

复制算法

6

复制算法最大的特点,是需要将内存一分为二,分为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划分机制。

7

  • 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。

参考



-=全文完=-