Java 内存回收算法

C/C++ 程序中,开发者需要自己手动管理程序的内存。也就是说当某个对象不再需要被使用,我们必须手动将其置为 null。这虽然为开发者提供了极大的自由度,但同时也导致了很多的问题。常用的问题有两类:

  • 某个对象释放内存时,多删除了一次,如果有一个其他对象刚刚申请到这块内存,突然被这个对象释放内存时删除了,就会引发一些奇怪的 bug,并且这样的 bug 很难追根溯源。
  • 某个对象使用后忘记释放内存,导致内存泄漏。

所以内存管理一直是 C/C++ 开发者非常头疼的问题。但在 Java 中就不会存在这样的事情,这得益于 Java 中出色的 GC(Garbage Collector) 机制,GC 会帮助我们自动回收不需要的对象。

本篇文章我们就来一起学习一下 JavaGC 算法。

一、什么是垃圾?

Java 程序中,每 new 一个对象,就会在栈或堆中分配一块内存,比如这一行代码:

Object o = new Object();

变量 o 保存了这个对象的内存地址,我们称之为 o 持有这个 new Object() 的引用,当 o 被置为 null 时:

o = null;

栈或堆中,为这个 new Object() 分配的内存不再被任何变量引用,这块内存现在孤苦伶仃,没人知道它的存在,也没有人能够再访问到它,它就成为了一个垃圾。

垃圾:程序中的一块内存没有被任何变量持有引用,导致这块内存无法被这个程序再次访问时,这块内存被称为垃圾。

二、怎么找到垃圾?

2.1 引用计数法(Reference Count)

上文说到,没有被任何引用指向的对象称之为垃圾。所以我们可以想到一种算法:在某个对象被引用指向时,将其引用数量计数。每多一个引用指向这个对象,计数 +1,每少一个引用指向这个对象,计数 -1,当计数为 0 时,表示这个对象成为了一个垃圾,将其回收掉。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 引用了 bb 又引用了 a。如果使用引用计数法,则它们的计数都为 1。当 main 方法执行完后,ab 都不再被使用。但由于它们的引用计数不为 0,所以它们将无法被 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)。两块区域的比例默认是 1:2,我们也可以自己设置这个比例(通过 -Xms 初始化堆的大小,通过 -Xmx 设置堆最大分配的内存大小,通过 -Xmn 设置新生代的内存大小)。

顾名思义,对象存活的时间较短,则属于新生代,存活时间较长,则属于老年代。那么如何去衡量对象存活的时间呢?JVM 的做法是:每经过一次 GC,没被回收掉的对象年龄 +1,大约 15 岁之后,新生代的对象到达老年代。

新生代又分为一个伊甸区(eden),两个存活区(survivor)。当对象刚 new 出来时,通常分配在伊甸区,伊甸区的对象大多数生命周期都比较短,据不完全统计,每次 GC 时,伊甸区存活对象只占 5\%~10\%,由于存活对象较少,所以伊甸区的 GC 采用的是拷贝算法,但这里的拷贝算法并不是将内存一分为二,因为伊甸区存活的对象数量较少,所以存活区只需要较小的内存(伊甸区和存活区的默认比例是 8:1:1,通过-XX:SurvivorRatio可以自定义此比例)。

新生代的 GC 被称之为 YGCYoung Garbage Collector,年轻代垃圾回收)或者 MinorGCMinor Garbage Collector,次要垃圾回收),整个回收过程类似这样:

  • 对象在伊甸区中被创建出来 →
  • 伊甸区经过一次 GC 之后,存活的对象到达存活 1 区,清空伊甸区 →
  • 伊甸区和存活 1 区的对象经历第二次 GC,存活的对象到达存活 2 区,清空伊甸区和存活 1 区 →
  • 伊甸区和存活 2 区经历第三次回收,存活的对象到达存活 1 区,清空伊甸区和存活 2 区 →
  • 循环往复... →
  • 每经过一次 GC,没被回收掉的对象年龄 +1。当存活的对象到达一定年龄之后,新生代的对象到达老年代。

新生代转移到老年代的年龄根据垃圾回收器的类型而有所不同,CMS(Concurrent Mark Sweep,一种垃圾回收器) 设置的默认年龄是 6,其他的垃圾回收器默认年龄都是 15。这个年龄我们可以自己设置(通过参数 -XX:MaxTenuringThreshold 配置),但不可超过 15,因为对象头中用于记录对象分代年龄的空间只有四位。

老年代的 GC 采用的是标记清除或者标记整理,因为老年代的空间较大,所以老年代的 GC 并不像新生代那样频繁。

整个内存回收称之为 FGCFull Garbage Collector,完整垃圾回收),或者 MajorGCMajor Garbage Collector,重要垃圾回收)。YGC/MinorGC 在新生代空间耗尽时触发。FGC/MajorGC 在老年代空间耗尽时触发,FGC/MajorGC 触发时,新生代和老年代会同时进行 GC。在 Java 程序中,也可以通过 System.gc() 来手动调用 FGC

小结

整个内存回收过程如下图所示:


GC 流程

当对象刚创建时,优先考虑在栈上分配内存。因为栈上分配内存效率很高,当栈帧从虚拟机栈 pop 出去时,对象就被回收了。但在栈上分配内存时,必须保证此对象不会被其他栈帧所引用,否则此栈帧被 pop 出去时,就会出现对象逃逸,产生 bug

如果此对象不能在栈上分配内存,则判断此对象是否是大对象,如果对象过大,则直接分配到老年代(具体多大这个阈值可以通过-XX:PretenureSizeThreshold参数设置)。

否则考虑在 TLABThread Local Allocation Buffer,线程本地分配缓冲区)上分配内存,这块内存是伊甸区为每个线程分配的一块区域,它的大小是伊甸区的 1\%(可以通过-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 6JDK 8 中,采用 PS/PO 进行垃圾回收,它们都是多线程的。

JDK8 之后,出现过一个承上启下的垃圾回收器 CMS,它开启了并发回收的先河,主要用于老年代的垃圾回收,与其搭配使用的新生代垃圾回收器名为 ParNew

之前的 PS/PO 虽然也使用了多线程,但多线程回收和并发回收的区别在于:多线程回收是指多个线程同时执行垃圾回收,而并发回收的意思是垃圾回收线程和工作线程同时执行。可惜的是,CMS 使用起来有一个非常大的问题,但它开启了 GC 的新思路,之后的并发垃圾回收器,如 G1(Garbage-First)ZGC( Z Garbage Collector)Shenandoah 等都是由它启发出来的。

JDK11 引入了 ZGCJDK12 引入了 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.

从定义中可以看出,ParNewPS 的一个变种,采用拷贝算法,多线程运行,主要是为了配合 CMS

5.4 G1、ZGC、Shenandoah

三者都是比较高效的并发垃圾回收器。在 CMSRemark 阶段,为了修复并发标记过程中的错误标记,CMS 采用了一种 Increment Update 的算法,但这种算法在并发时可能会产生漏标。在 G1 中,此阶段采用的方案是 SATB(Snapshot At The Begining)ZGCShenandoah 采用的方案是 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。在日常工作中,不要只满足于完成业务。多了解程序背后的原理和运行机制,对我们自身能力的提升大有裨益。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容