深入理解Java垃圾回收机制

卫星图像展现出的美洲海洋流 (© Karsten Schneider/Science Photo Library)

如果你想要从太空观察地球,卫星技术就能够做到这一点。图中的海洋流是卫星地图展现的,紫色和粉红色的漩涡代表更暖的洋流,而蓝色和绿色是较冷的洋流。卫星地图为天气预报提供有利的证据,我们能够准确的知道气温以及对海洋健康做长期分析监控。

垃圾回收机制的意义

Java语言中一个显著的特点就是引入了垃圾回收机制,使c++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。

ps:内存泄露是指该内存空间使用完毕之后未回收,在不涉及复杂数据结构的一般情况下,Java 的内存泄露表现为一个内存对象的生命周期超出了程序需要它的时间长度,我们有时也将其称为“对象游离”。

垃圾回收机制中的算法

Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:
 (1)发现无用信息对象;
 (2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。

Jvm(Java虚拟机)内存模型

在了解垃圾回收算法之前先简单了解一下jvm内存模型.
从Jvm内存模型中入手对于理解GC会有很大的帮助,不过这里只需要了解一个大概,说多了反而混淆视线。

Jvm(Java虚拟机)主要管理两种类型内存:堆和非堆。

  • 堆是运行时数据区域,所有类实例和数组的内存均从此处分配。
  • 非堆是JVM留给自己用的,包含方法区、JVM内部处理或优化所需的内存(如 JIT Compiler,Just-in-time Compiler,即时编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码。

简言之,Java程序内存主要(这里强调主要二字)分两部分,堆和非堆。大家一般new的对象和数组都是在堆中的,而GC主要回收的内存也是这块堆内存。

配一张示意图总结一下:
[图片上传失败...(image-4de587-1517713417487)]

堆内存(Heap Memory): 存放Java对象
非堆内存(Non-Heap Memory): 存放类加载信息和其它meta-data
其它(Other): 存放JVM 自身代码等

重点是堆内存,我们就再看看堆的内存模型。

  • 堆内存由垃圾回收器的自动内存管理系统回收。
  • 堆内存分为两大部分:新生代和老年代。比例为1:2。
  • 老年代主要存放应用程序中生命周期长的存活对象。
  • 新生代又分为三个部分:一个Eden区和两个Survivor区,比例为8:1:1。
  • Eden区存放新生的对象。
  • Survivor存放每次垃圾回收后存活的对象。

[图片上传失败...(image-c20973-1517713417487)]

其实只需要关注这几个问题:

  • 为什么要分新生代和老年代?
  • 新生代为什么分一个Eden区和两个Survivor区?
  • 一个Eden区和两个Survivor区的比例为什么是8:1:1?

现在还不能解释为什么,但这几个问题都是垃圾回收机制所采用的算法决定的。
所以问题转化为,是何种算法?为什么要采用此种算法?

可回收对象的判定

讲算法之前,我们先要搞清楚一个问题,什么样的对象是垃圾(无用对象),需要被回收?
目前市面上有两种算法用来判定一个对象是否为垃圾。

  • 引用计数算法
  • 可达性分析算法(根搜索算法)

引用计数法(Reference Counting Collector)

算法分析

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

[图片上传失败...(image-40d1e0-1517713417487)]

优缺点

  • 优点: 简单,高效
    引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
  • 缺点: 无法检测出循环引用
    如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.
public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
         
        object1.object = object2;
        object2.object = object1;
         
        object1 = null;
        object2 = null;
    }
}

最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

可达性分析算法(根搜索算法)

算法分析

根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

java中可作为GC Root的对象有:

  • 虚拟机栈中引用的对象(本地变量表)
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象(Native对象)

优缺点

  • 优点:
    解决循环引用问题
  • 缺点:

Stop The World

在学习GC前,你应该知道一个技术名词:这个词是“stop-the-world。“ 无论你选择哪种GC算法,Stop-the-world都会发生。Stop-the-world意味着JVM停止应用程序,而去进行垃圾回收。当stop-the-world发生时,除了进行垃圾回收的线程,其他所有线程都将停止运行。被中断的任务将在GC任务完成后恢复执行。GC调优往往意味着减少stop-the-world的时间.

垃圾回收的时候,需要整个的引用状态保持不变,否则判定是判定垃圾,等我稍后回收的时候它又被引用了,这就全乱套了。所以,GC的时候,其他所有的程序执行处于暂停状态,卡住了。

幸运的是,这个卡顿是非常短(尤其是新生代),对程序的影响微乎其微 (关于其他GC比如并发GC之类的,在此不讨论)。
所以GC的卡顿问题由此而来,也是情有可原,暂时无可避免。

垃圾回收算法

标记清除算法 (Mark-Sweep)

原理

分为两个阶段: 标记阶段(Mark) 和清除阶段(Sweep):

  • 标记阶段:
    collector从mutator根对象开始进行遍历,对从mutator根对象可以访问到的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象。
  • 清除阶段:
    collector对堆内存(heap memory)从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象-通过读取对象的header信息,则就将其回收。

collector指的就是垃圾收集器,而mutator是指除了垃圾收集器之外的部分,比如说我们应用程序本身。mutator的职责一般是NEW(分配内存),READ(从内存中读取内容),WRITE(将内容写入内存),而collector则就是回收不再使用的内存来供mutator进行NEW操作的使用。
mutator根对象一般指的是分配在堆内存之外,可以直接被mutator直接访问到的对象,一般是指静态/全局变量以及Thread-Local变量
可达对象的定义,从mutator根对象开始进行遍历,可以被访问到的对象都称为是可达对象。这些对象也是mutator(你的应用程序)正在使用的对象。

[图片上传失败...(image-4e7592-1517713417487)]

优缺点

  • 优点:简单,容易实现
  • 缺点:容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

复制算法 (Copying)

原理

复制算法将内存划分为两个区间,活动区和空闲区,在任意时间点,所有动态分配的对象都只能分配在活动区
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。

清理前:


清理后:


通俗的讲,就是:
复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

优缺点

  • 优点:实现简单,运行高效且不容易产生内存碎片
  • 缺点:浪费内存

标记整理算法 (Mark-Compact)

原理

分为两个阶段: 标记阶段(Mark) 和整理阶段(Compact)

  • 标记阶段: 与标记/清除算法是一模一样
  • 整理阶段: 移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

优缺点

  • 优点: 标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价
  • 缺点: 低效

分代回收算法(Generational Collector)

分代回收算法其实不算一种新的算法,而是根据复制算法和标记整理算法的的特点综合而成。这种综合是考虑到java的语言特性的。
这里重复一下两种老算法的适用场景:

  • 复制算法:适用于存活对象很少。回收对象多
  • 适用用于存活对象多,回收对象少

复习下面这个图:
[图片上传失败...(image-1560a8-1517713417487)]

解析:

  • 堆内存分为两大部分:新生代和老年代。比例为1:2。
  • 新生代又分为三个部分:一个Eden区和两个Survivor区,比例为8:1:1。
  • Eden区存放新生的对象。new
  • Survivor存放每次垃圾回收后存活的对象。
  • 老年代主要存放应用程序中生命周期长的存活对象。

原理(过程)

所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

  • 分配对象时,保存在Eden区
  • Eden区满,触发GC(Minor GC), 将Eden区存活对象复制到一个survivor0区,然后清空Eden区
  • 当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区
  • 此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复
  • 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收

年轻代 年老代 持久代

  • 年轻代(Young Generation):
    所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
    新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)
  • 年老代(Old Generation):
    年老代中存放的都是一些生命周期较长的对象。
    老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
  • 持久代(Permanent Generation):
    用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

深入理解分代回收算法

为什么不是一块Survivor空间而是两块?

这里涉及到一个新生代和老年代的存活周期的问题,比如一个对象在新生代经历15次(仅供参考)GC,就可以移到老年代了。问题来了,当我们第一次GC的时候,我们可以把Eden区的存活对象放到Survivor A空间,但是第二次GC的时候,Survivor A空间的存活对象也需要再次用Copying算法,放到Survivor B空间上,而把刚刚的Survivor A空间和Eden空间清除。第三次GC时,又把Survivor B空间的存活对象复制到Survivor A空间,如此反复。
所以,这里就需要两块Survivor空间来回倒腾

为什么Eden空间这么大而Survivor空间要分的少一点?

新创建的对象都是放在Eden空间,这是很频繁的,尤其是大量的局部变量产生的临时对象,这些对象绝大部分都应该马上被回收,能存活下来被转移到survivor空间的往往不多。所以,设置较大的Eden空间和较小的Survivor空间是合理的,大大提高了内存的使用率,缓解了Copying算法的缺点。
我看8:1:1就挺好的,当然这个比例是可以调整的,包括上面的新生代和老年代的1:2的比例也是可以调整的。
新的问题又来了,从Eden空间往Survivor空间转移的时候Survivor空间不够了怎么办?直接放到老年代去

垃圾收集器

  • 新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge
  • 老年代收集器使用的收集器:Serial Old、Parallel Old、CMS

Serial收集器(复制算法)

新生代单线程收集器,标记和清理都是单线程,优点是简单高效。

Serial Old收集器(标记-整理算法)

老年代单线程收集器,Serial收集器的老年代版本。

ParNew收集器(停止-复制算法)

新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
Parallel Scavenge收集器(停止-复制算法)
并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。

Parallel Old收集器(停止-复制算法)

Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先

CMS(Concurrent Mark Sweep)收集器(标记-清理算法)

高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择

GC的执行机制

Scavenge GC(Minor GC)

一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC:

  • 年老代(Tenured)被写满
  • 持久代(Perm)被写满
  • System.gc()被显示调用
  • 上一次GC之后Heap的各域分配策略动态变化

参考:

深入理解java垃圾回收机制
理解Java垃圾回收机制

推荐阅读更多精彩内容