Java垃圾回收机制

前言

最近闲来无事,开始学习一些比较基本的技术来巩固自己的知识面。因为我从事Android开发工作,所以想了解一下Java虚拟机的工作原理,阅读了《深入理解Java虚拟机:JVM高级特性与最佳实践》这本书,为了使自己加深印象,特此记录一下。

Java虚拟机最广为人知并且最具特点的一面就是它的垃圾回收机制。这个概念其实很笼统,我比较认同它的另一种叫法自动内存管理机制,因为他包含了内存分配垃圾回收两部分。

Java内存区域

运行时内存区域

谈及内存分配,就先需要了解Java虚拟机的运行时内存结构,才能知道为什么要这样分配。运行时数据区域分为5块:

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区

还包括一块非虚拟机运行时数据区的直接内存,其中只有方法区和堆是所有线程共享的,程序计数器、虚拟机栈、本地方法栈是线程私有的。

程序计数器

程序计数器是用来标记线程所执行的字节码的行号指示器,虚拟机通过改变这个计数器的值来选取下一条该执行的指令,可以实现分支、循环、跳转、异常处理、线程恢复等功能。因为虚拟机存在多线程操作,所以为了使线程切换后能恢复到正确的位置,每条线程都拥有一个独立的程序计数器。

虚拟机栈

虚拟机栈储存的是Java方法的执行情况,方法都是线程来执行的,因此它的生命周期与线程相同,线程创建的同时也会创建一个虚拟机栈。虚拟机栈里面储存的是一个个栈帧,每个方法执行的时候都会创建一个栈帧并且入栈,当方法执行结束后则会出栈

栈帧中储存了局部变量表、操作数栈、动态链接、方法出口等信息。我们最关心的就是局部变量表这部分,也就是人们常说的栈内存。局部变量表中存放了8种基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址),在编译期就会完全确定好需要分配的内存空间大小并且在运行时也不会改变。

本地方法栈

和虚拟机栈的作用非常相似,只不过虚拟机栈为Java方法服务,本地方法栈为Native方法服务。

Java堆是虚拟机内存中最大的一块,在虚拟机启动的时候就会创建,它能被所有线程访问。堆用来存放对象实例,也就是人们常说的堆内存,是垃圾收集器管理的主要区域。

方法区

方法区和堆类似,是线程共享的内存区域,用来储存已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

运行时常量池是方法区的一部分,当Class文件被加载后,Class文件中的常量池里的各种字面量和符号引用都将进入方法区的运行时常量池,运行期间产生的新的常量也会放入池中。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,但是也能被虚拟机引用到。在JDK 1.4中加入了NIO类,引入了一种基于通道(Channel)与缓冲区的I/O方式,它使用Native函数来分配堆外内存,然后在堆内创建一个DirectByteBuffer对象作为这块堆外内存的引用,并利用堆内DirectByteBuffer对象进行操作,这种方式能在一些场景中显著地提高性能。

虚拟机对象的探索

以常用的虚拟机HotSpot为例。

对象的创建

在使用new关键字创建一个对象时,虚拟机会先在常量池中查找是否有该类的符号引用并且该类已经被加载、解析、初始化过,如果没有,则必须先加载该类。一个对象所需要的内存空间大小在类加载完成后就可以完全确定了,因此为对象分配内存就是在堆内存中划分一块大小确定的空间。分配的方式有两种:

  1. 指针碰撞:当堆内存非常规整时,以一个指针为中间点,一边是用过的内存,一边是空闲的内存,这时就只需要把指针往空闲内存方向移动所需大小的距离就可以了。
  2. 空闲列表:当空闲内存和已用内存互相交错时,虚拟机只能维护一个列表,这个列表记录着内存的分布情况,当需要分配内存时从表中找一块足够大的空间划分给新的对象。

具体使用哪一种方式给对象分配内存,由虚拟机的垃圾收集器是否带压缩整理功能来决定的。

还需要考虑的一个问题是内存分配给对象的线程安全,虚拟机采用了两种方式解决这个问题:

  1. CAS配上失败重试:把分配内存的动作进行同步,具体请看CAS的内容。
  2. 本地线程分配缓冲(TLAB):为每个线程预先分配一小块内存,当TLAB用完需要分配新的TLAB时才做同步锁定处理。

内存分配完成后,虚拟机会将内存空间都初始化为零值,这一步操作保证了对象实例的字段不用初始化赋值就可以直接使用。接下来虚拟机还会对对象进行必要的设置,具体位置在对象头中,然后再根据程序员的意愿进行初始化,这样一个对象才完全创建出来。

对象的内存布局

对于常用的虚拟机Hotspot来说,对象在内存中的布局分为3个区域:

  1. 对象头:包含两部分信息,一部分是哈希码、GC分代年龄、锁状态标志、线程持有的锁等对象运行时数据;另一部分是类型指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  2. 实例数据:对象真正储存的有效信息。
  3. 对齐填充:占位作用,因为虚拟机要求对象起始地址必须是8字节的整数倍。

对象的访问

我们可以通过两种方式来访问对象:

  1. 句柄:如果是句柄方式,虚拟机会划出一块内存来作为句柄池,句柄中包含了指向对象实例数据和类型数据的指针,reference则指向该句柄。
  2. 直接指针:对象内放置了类型的指针,reference则指向该对象。

两种方式各有优势,通过句柄来访问最大的好处是reference中存储稳定的句柄地址,通过直接指针访问最大的好处就是速度快,具体使用哪一种方式是根据虚拟机来决定的。注:我也不太理解这两种访问方式到底有什么不同,后续还会查找资料,如果有同行知道可以告诉我,-_-

垃圾回收

在介绍内存分配之前,先了解一下垃圾回收,因为有很多内存分配策略是依据垃圾收集器的特性来进行分配的。

对象是否需要回收

在进行垃圾回收之前,垃圾收集器需要知道哪些对象需要被回收。

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,任何时候计数器值为0时,该对象则不能再被引用。

这个算法的实现简单高效,但是当两个对象存在互相引用时,却不能通过任何一个reference访问,这时他们的计数都不为0,导致无法被回收,因此Java虚拟机没有采用这种算法。

可达性分析算法

通过一些“GC Root”对象作为起点,向下搜索走过的路径成为引用链,当一个对象到GC Root没有任何引用链相连时,则说明该对象是不可用的。

可作为GC Root的对象包括以下4种:

  1. 虚拟机栈中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象

引用

在JDK 1.2中,Java定义了4中引用:

  1. 强引用:类似“Object obj = new Object()”的引用,只要存在强引用,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用:描述一些还有用但非必须的对象,如果在发生内存溢出之前清除掉软引用还是不够,才会抛出异常。
  3. 弱引用:只能存活到下一次垃圾收集发生之前
  4. 虚引用:不能通过虚引用访问对象,虚引用也不会对对象的生存时间有影响,虚引用只是为了在对象被回收时收到系统通知。

对象并不是非死不可

宣告一个对象可以被回收,需要经历两次标记过程,如果对象在第一次被标记时能与引用链上的任何一个对象建立关联就可“救活”自己,但是这个“救活”自己的机会只有一次

例如一个对象在进行可达性分析时发现没有与任何引用链有关联,会被第一次标记,并且筛选是否可以进行“救活”自己的操作,(如果对象没有复写finalize()方法或者对象finalize()方法已经被虚拟机调用过,则不能再进行“救活”自己的操作)。如果对象有机会“自救”,那么这个对象会被放置在一个叫作F-Queue的队列之中,并由一个Finalizer线程(这个线程优先级很低)去执行对象的finalize(),对象可以在finalize()中将自己与引用链上的任何一个对象建立关联,在第二次标记时被移出这个集合。如果对象在finalize()中没能与任何引用链建立关联,则下次标记后将被回收。

这个方法只是为了让C/C++的程序员更容易接收Java所做的妥协,编写代码时不建议使用这个方法。

方法区的回收

方法区(也有人成为永久代),也存在垃圾收集,主要收集两部分内容:

  1. 废弃常量:如果常量池中的数据没有被任何一个对象引用,如果有必要则会被清除。
  2. 无用的类:堆中不存在该类的实例、加载该类的ClassLoader已经被回收、该类对应的Class对象没有被引用,达到以上三点则可以被回收,但是需要对虚拟机进行参数设置。

垃圾收集算法

标记-清除算法

先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。这种算法有两点不足,一是标记和清除的效率都不高,二是清除后会产生大量不连续的内存空间,在之后给大对象分配内存时,又会触发一次垃圾回收动作

复制算法

复制算法主要运用于回收新生代,简单的复制算法是将内存分为大小相等的两块,每次只使用其中一块,当这一块“满了”,就将还存活的对象复制到另一块内存区域中,再将原来的那一块内存空间完整清除掉,这样就避免了出现内存碎片的问题,分配内存时只用移动堆顶指针即可。但是这样一次只能使用完整内存大小的一半,代价太高,因此有了一些改进。

因为一半以上甚至98%的对象都是很快死亡的,所以可以将内存空间分为一块较大的Eden和两块较小的Survivor空间。每次分配内存只使用Eden和其中一块Survivor空间,当空间不足时,就将还存活的对象复制到另一块Survivor中,最后清除掉Eden和原先使用过的Survivor,再接着使用Eden和另一块Survivor空间分配对象,重复以上操作。

在常用的Hotspot虚拟机中,Eden和Survivor的大小比例是 8 : 1,意味着我们每次能只用90%的内存空间分配对象,但是我们不能保证每次回收后存活的对象小于等于10%。因此,当Survivor的空间不够时,需要依赖老年代进行分配担保。分配担保就是当另一块Survivor空间不足以存放上一次新生代垃圾收集存活的对象时,这些对象将直接进入老年代。

标记-整理算法

标记-整理算法主要运用于老年代,老年代不使用复制算法是因为老年代的对象存活率较高,过多的复制操作会降低效率,并且也没有其他区域来为老年代进行分配担保。标记-整理算法也需要标记可回收的对象,但不是直接清除对象,而是将存活的对象向内存一端移动,然后直接清理掉边界以外的内存空间。

分代收集算法

分代收集就是将内存空间分为新生代老年代两块,新生代每次垃圾回收都会有大量的对象死去,因此采用改良的复制算法,而老年代里都是存活率高的对象,可以使用标记-清除或者标记-整理算法进行垃圾回收。

垃圾收集的实现

上面交代了几种垃圾回收的思路,接下来讲述如何实现它们。

枚举根节点

因为一个应用的方法区中可能会有数百兆,所以逐个检查这里面的引用会显得不那么明智。Java虚拟机使用OopMap来记录对象内有哪些数据类型、在什么位置,栈和寄存器中哪些位置是引用,当GC时扫描OopMap就可以直接得知这些信息。

安全点

虽然OopMap可以快速准确地完成GC Root的枚举,但是不能为每一条指令都生成对应的OopMap,这样会消耗掉很大的内存空间。因此虚拟机只会在“特定的位置”记录这些信息,这些位置就是安全点。虚拟机不会在任何地方都能停顿下来开始GC,只有执行到安全点时才能暂停。安全点是为了让程序能够长时间执行,只有例如方法调用、循环、异常等指令序列复用的指令才会产生安全点。

对于安全点,还有一个问题就是如何在GC发生时让所有线程都跑到安全点再停顿下来。一种方法是抢先式中断,在GC发生时让所有线程都停下来,如果线程没有中断在安全点就让它跑到最近的安全点再停下来,现在几乎没有虚拟机采用这种方法。另一种方法是主动式中断,当需要GC时设置一个标志,每个线程执行时都会去轮询这个标志。轮询标志的位置和安全点重合,创建对象分配内存时也需要轮询这个标志,发现标志位真时则自己中断挂起。

安全区域

安全区域是指在这个区域中的任何位置开始GC都是安全的。当线程执行到安全区域时,会标记自己已经进入了安全区域,当虚拟机需要GC时就不会再管这个线程,直接执行GC。在线程离开安全区域前,要检查系统是否完成了根节点枚举或者整个GC过程,如果完成了,线程就继续执行,否则必须等待直到收到可以安全离开的信息为止。

垃圾收集器

虚拟机中通常不只有一种GC收集器,不同的厂商、不同版本一般都会提供参数让用户自己选择组合。接下来讨论的辣鸡收集器是基于JDK 1.7之后的,它所包含的所有收集器如下图:

Java垃圾回收机制

(上图最右侧的问号代表G1 收集器)上图展示了7种不同分代的收集器,两者的连线表示它们可以搭配使用。直到目前为止还没有一款最好的收集器,我们只能根据需求和应用场景选择最适合的。

Serial 收集器

Serial 收集器是最基本、发展历史最悠久的新生代收集器。这是一个单线程收集器,它在进行垃圾收集时必须暂停其他所有工作线程直到它收集结束。随着开发团队的不断努力,用户线程的停顿时间一直在不断缩短,但是无法完全消除。由于 Serial 收集器不用担心线程交互可以专心做垃圾收集,所以可以获得最高的单线程收集效率,大致控制在几十毫秒最多一百毫秒,在用户桌面应用场景中已经足够。

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,在一定条件下性能更好,是运行在服务器模式下的虚拟机首选的新生代收集器。使用 ParNew 收集器的一个重要原因是只有 Serial 和 ParNew 收集器可以与CMS收集器配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个使用复制算法的并行多线程的新生代收集器,它的不同之处在于达到一个可控制的吞吐量。所谓吞吐量就是运行代码的时间与CPU总消耗时间的比值,总消耗时间中包含了运行代码时间与垃圾回收时间。这款收集器的目的是为了尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 收集器拥有自适应调节策略,能自动完成具体细节参数的调节工作。

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,是一个单线程收集器,使用标记-整理算法。主要用于Client模式下的虚拟机,如果用于Server模式下,是作为CMS的后备预案。

Parallel Scavenge 收集器只能与 Serial Old 收集器搭配使用,因为 Parallel Scavenge 收集器本身就包含 PS MarkSweep 收集器来进行老年代收集,而 PS MarkSweep 收集器与 Serial Old 收集器的实现非常接近,所以可以说是两者搭配.

Parallel Old 收集器

Paraleel Old 收集器是Parallel Scavenge 收集器的老年代版本,因为之前 Parallel Scavenge 收集器只能与 Serial Old 收集器搭配使用,由于 Serial Old 收集器在服务器端应用性能上太差,所以诞生了 Parallel Old 收集器与 Parallel Scavenge 收集器的搭配。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,是基于标记-清除算法实现的,整个回收过程可以分为4步:

  1. 初始标记:需要暂停工作线程,但仅仅是标记一下GC Roots能直接关联到的对象,速度很快。
  2. 并发标记:就是进行GC Roots追踪的过程,不需要暂停工作线程。
  3. 重新标记:是为了修正并发标记阶段因用户程序继续运行而导致的部分对象标记记录变动,需要暂停工作线程,这个阶段的停顿时间比初始标记阶段长,远比并发标记阶段短。
  4. 并发清理:和用户程序并发的运行,清理掉被标记的对象。

CMS是一款优秀的垃圾收集器,但是还有不足之处:

  1. 并发阶段占用CPU资源导致程序变慢
  2. 无法处理标记过程中产生的新的垃圾,所以不能在老年代内存不足时再启动垃圾收集,虽然虚拟机提供一个参数设置,达到这个值时就启动垃圾回收,但是如果预留的内存还是不足以满足程序需要,就会启动应急预案:Serial Old 收集器,这样会导致停顿时间过长,因此这个值的设定不能设置得太高。
  3. 基于标记-清除算法会产生大量内存碎片,因此虚拟机提供了一个参数用来在Full GC之前进行碎片整理,还提供了一个参数用于设置执行多少次不带压缩的Full GC后跟着来一次带压缩的。

G1 收集器

G1 收集器是一款面向服务端的垃圾收集器,它的使命是在未来可以替换掉 CMS 收集器。G1 收集器具有以下特点:

  1. 并行与并发:使用多个CPU优势缩短Stop-The-World停顿时间,并发的方式让Java程序继续执行。
  2. 分代收集:不需要与其他收集器配合就能独自管理整个GC堆。
  3. 空间整合:整体上基于标记-整理算法,局部上基于复制算法,避免产生内存碎片。
  4. 可预测的停顿:除了追求低停顿外,还能建立可预测的停顿时间模型,在M长度的时间内垃圾收集的时间不超过N。

G1 收集器不再将Java堆分为新生代和老年代,而是多个大小相等的独立区域。G1之所以能建立可预测的停顿模型,是因为能跟踪各个区域的垃圾堆积的价值大小(回收所获得的空间、所需时间的经验),在后台维护一个优先队列,每次根据允许的收集时间优先收集价值最大的区域。

G1 收集器想法很棒,但是实现起来却不容易。例如在垃圾回收时,并不能真的只回收某个区域,因为可能存在不同区域里对象的相互引用。这个问题在其他收集器中也会出现,例如老年代引用了新生代的对象。为了解决这个问题,虚拟机使用了Remembered Set来避免全堆扫描。在 G1 中每个区域都对应着一个Remembered Set,当需要对引用进行写操作(可以理解为将引用指向一个对象)会产生一个中断,检查引用的对象是否处于不同的区域之中(在分代的例子中就是检查老年代是否引用了新生代中的对象),如果是,就把相关引用的信息记录在被引用对象所属的区域的Remembered Set中。当GC进行回收时,GC Roots加上Remembered Set就可以避免全堆扫描。

G1 收集器运行过程可分为4个步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

筛选回收可以做到并发执行,但是因为时间较短,为了提高垃圾收集效率,开发团队选择暂停用户线程让回收线程并行执行。

内存分配

在垃圾回收篇幅中描述了垃圾收集算法、实现和7种收集器,为的就是更好地理解虚拟机是如何自动回收垃圾对象的,好让我们清楚虚拟机如何给对象分配内存。虚拟机的内存分配规则并不是百分之百确定的,具取决于当前使用哪一种垃圾收集器组合还有虚拟机中与内存相关的设置。

在Eden分配

大多数对象都是优先在新生代的Eden区中分配内存的,如果Eden区中没有足够的内存进行分配,则会发起一次Minor GC。

大对象直接进入老年代

最典型的大对象就是那种很长的字符串以及数组,当经常需要为一个大对象分配内存时,容易导致内存还有不少空间,但是不足以存下这个大对象而触发Minor GC来获取连续的内存空间来存放它。因此虚拟机提供一个参数用来设置大于这个值的对象直接分配到老年代中。

长期存活的对象将进入老年代

在新生代的复制算法中,对象每熬过一次Minor GC年龄都会加1,当达到15岁时则会进入老年代。这是“岁数”的值是可以设置的,当超过这个值对象将进入老年代。

动态对象年龄判定

并不是所有的对象都要熬过设置的年龄才能进入老年代,当Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

之前讲过在新生代的复制算法中,老年代为新生代提供分配担保,但是老年代不一定也有足够的空间来存放这些对象,因此如果虚拟机允许冒险(一个参数设置是否允许冒险),则当老年代最大可用连续空间大于历次回收到老年代对象的平均大小则进行一次Minor GC,如果冒险失败则进行Full GC.如果虚拟机不允许冒险,或者说老年代最大可用连续空间小于平均值则直接进行Full GC。

总结

不知不觉写了那么多,虽然内容不是很详细,但是目的只是为了整理自己的思路,顺便巩固一下知识,如果有朋友想更详细地了解Java垃圾回收机制,还是推荐阅读《深入理解Java虚拟机:JVM高级特性与最佳实践》这本书,提供了很多例子解释了枯燥的概念,非常棒。