垃圾回收

jdk14 CMS取消 ZGC迟早有一天替换掉G1

什么是垃圾

  • 垃圾收集,不是Java语言的伴生产物.早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生

  • 关于垃圾收集有三个经典问题

    • 哪些内存需要回收
    • 什么时候回收
    • 如何回收
    • 垃圾收集机制是Java的招牌能力,极大地提高了开发效率.如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进,不同大小设备,不同特征的应用场景,对垃圾收集提出了新的挑战
  • 什么是垃圾呢?

    • 垃圾是指在运行程序中没有任何指针指向的对象 这个对象就是需要被回收的垃圾
  • 如果不及时对内存中的垃圾进行清理 那么这些垃圾对象所占用的内存空间就一直保留到应用程序结束 被保留的空间无法被其他对象使用 甚至可能导致内存溢出

为什么需要GC?

  • 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样
  • 除了释放没用的对象,垃圾回收也可以清除内存里的纪录碎片.碎片整理将占用的堆内存存到堆的一端,以便JVM将整理出的内存分配给新的对象
  • 随着应用程序所应付的业务越来越庞大,复杂,用户越来越多,没有GC就不能保证应用程序的正常进行.而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试GC进行优化

早期垃圾回收

  • 在早期的C/C++时代,垃圾回收基本上是手工进行的.开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放.

    1
    2
    3
    4
    MibBridge *pBridge= new cmBaseGroupBridge();

    if(pBridge->Register(kDestory)!=NO_ERROR)
    delete pBridge;

    这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担.倘若有一处内存区域由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃

Java垃圾回收机制

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
    • 没有垃圾回收器,java也会跟cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来, 可以更专心地专注于业务开发

对于java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于”自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力

此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题

当需要排查各种内存溢出,内存泄漏问题的时,当垃圾收集成为系统达到更高并发量的瓶颈时,就必须对这些”自动化”的技术实施必要的监控和调节

GC的作用区域 方法区 堆

  • 垃圾回收器可以对年轻代进行回收,也可以对老年代回收,甚至是全堆和方法区的回收.
    • 其中Java堆是垃圾收集器的工作重点
  • 从次数上讲:
    • 频繁收集young区
    • 较少收集Old区
    • 基本不动Perm区

垃圾回收相关算法

垃圾标记阶段的算法值引用计数算法

垃圾标记阶段:对象存活判断

  • 在堆里存放着几乎所有的java对象实例 在GC执行垃圾回收之前 首先需要区分出内存中哪些是 存活对象 哪些是已经死亡的对象 只有标记为已经死亡的对象 GC才会在执行垃圾回收时候释放掉其所占用的内存空间 因此这个过程我们可以称为 垃圾标记阶段

  • 那么在JVM中究竟是如何标记一个死亡对象的呢?简单来说 当一个对象已经不再被任何存活对象继续引用时 就可以宣判为已经死亡

  • 判断对象存活一般有两种方式:

    • 引用计数算法
    • 可达性分析算法

方式一:引用计数算法(Java不用)

  • 引用计数算法比较简单 对每个对象保存一个整型的引用计数器属性用于记录对象被引用的情况

  • 对于一个对象A 只要有任何一个对象引用了A 则A的引用计数器就加1 当引用失效时 引用计数器就减1,只要对象A的引用计数器的值为0 即表示对象A不可能再被使用 可进行回收

  • 优点: 实现简单 垃圾对象容易辨识 判定效率高,回收没有延迟性

  • 缺点:

    • 它需要单独的字段存储计数器 这样的做法增加了存储空间的开销

    • 每次赋值需要更新计数器 伴随加法减法操作 这增加了时间开销

    • 引用计数器有一个严重的问题 即无法处理循环引用的情况

      这时一条致命缺陷 导致在java的垃圾回收器中没有使用这类算法

image-20200623120536479

image-20200623120820609

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
/**
* -XX:+PrintGCDetails
* 证明:java使用的不是引用计数算法
* @author shkstart
* @create 2020 下午 2:38
*/
public class RefCountGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

Object reference = null;

public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();

obj1.reference = obj2;
obj2.reference = obj1;

obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为
//这里发生GC,obj1和obj2能否被回收?
System.gc();

try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用技术和垃圾收集机制
  • 具体哪种最优需要看场景,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试
  • Python如果解决循环引用?
    • 手动接触: 很好理解,就是在合适的时机,解除引用关系
    • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用

方式二:可达性分析算法(根搜索算法 追踪性垃圾收集)

  • 相较于引用计数算法而言 可达性分析算法不仅同样具备实现简单和执行高效特点 更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题 防止内存泄漏的发生
  • 相较于引用计数算法 这里的可达性分析就是java C#选择的 这种类型的垃圾收集通常也叫作追踪性垃圾收集

基本思路

GC Roots 就是一组必须活跃的引用

  • 可达性分析算法是以根对象集合为起始点 按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后 内存中的存活对象都会被根对象集合直接或者间接连接着 搜索走过的路径称为引用链(Reference chain)
  • 如果目标对象没有任何引用链相连 则是不可达的 就意味着该对象已经死亡 可以标记为垃圾对象
  • 在可达性分析算法中 只有能够被根对象集合直接或者间接连接的对象才是存活对象

image-20200623122402743

GC Roots 包括以下几类元素

  • 虚拟机中引用的对象
    • 比如 各个线程中被调用的方法中使用到的参数 局部变量等
  • 本地方法栈内JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象
    • 比如 java类的引用类型静态变量
  • 方法区中 常量引用的对象
    • 比如 字符串常量池里的引用
  • 所有被同步锁 synchronized持有的对象
  • Java虚拟机内部的引用
    • 基本数据类型对应的Class对象 一下常驻的异常对象 (如:NullPointerException OutOfMemoryError),系统类加载器
  • 反应java虚拟机内部情况的JMXBean JVMTI中注册的回调本地代码缓存等
  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象”临时性”地加入 共同构成完整GC Roots集合.比如分代收集和局部回收(Partial GC)

    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中区考虑,才能保证可达性分析的准确性
  • 小技巧:

    由于Root栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就是一个Root

  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行.这点不满足的话分析结果的准确性就无法保证
  • 这点也是导致GC进行时必须”Stop The World”的一个重要原因
    • 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

CMS第一个低延迟的垃圾回收器

对象的finalization

  • Java语言提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义处理逻辑

  • 当垃圾回收器发现没有引用指向一个对象,即: 垃圾回收此对象之前,总会先调用这个对象的finalize()方法

  • finalize()方法允许在子类中被重写.用于在对象被回收时进行资源释放,通常这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库连接

  • 应该交给垃圾回收机制调用,理由包括下面三点:永远不要主动调用某个对象的finalize()方法

    • 在finalize() 时可能会导致对象复活
    • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下若不发生GC,则finalize()方法将没有执行机会
    • 一个糟糕的finalize()会严重影响GC的性能
  • 从功能上说,finalize()方法与C++中的析构函数比较相似,但是java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于c++中的析构函数

  • 由于finalize()方法的存在,虚拟机中的对象一般都处于三种可能的状态

  • 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了.一般来说,此对象需要被回收.但事实上,也并非是非死不可的.这时候它们暂时处于缓刑阶段.一个无法触及的对象有可能在某一个条件下”复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态

    • 可触及的: 从根节点开始,可以到达这个对象
    • 可复活的: 对象的所有引用都被释放,但是对象有可能在finalize()中复活
    • 不可触及的: 对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态. 不可触及的对象不可能被复活,因为finalize()只会被调用一次

    以上三种状态中,是由于finalize()方法的存在,进行的区分.只有在对象不可触及时才可以被回收

image-20200623124755608

代码演示可复活的对象

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 测试Object类中finalize()方法,即对象的finalization机制。
*
* @author shkstart
* @create 2020 下午 2:57
*/
public class CanReliveObj {
public static CanReliveObj obj;//类变量,属于 GC Root


//此方法只能被调用一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
}


public static void main(String[] args) {
try {
obj = new CanReliveObj();
// 对象第一次成功拯救自己
obj = null;
System.gc();//调用垃圾回收器
System.out.println("第1次 gc");
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("第2次 gc");
// 下面这段代码与上面的完全相同,但是这次自救却失败了
obj = null;
System.gc();
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

MAT和JProfiler进行GCroots 溯源

MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器.用于查找内存泄漏以及查看内存消耗的情况.它基于Eclipse开发 的 是一款免费的性能分析工具

image-20200623130401814

使用Jprofiler分析OOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
*
* @author shkstart shkstart@126.com
* @create 2020 15:29
*/
public class HeapOOM {
byte[] buffer = new byte[1 * 1024 * 1024];//1MB

public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();

int count = 0;
try{
while(true){
list.add(new HeapOOM());
count++;
}
}catch (Throwable e){
System.out.println("count = " + count);
e.printStackTrace();
}
}
}

清除阶段

当成功分出内存中存活对象和死亡对象后 GC接下来的任务就是执行垃圾回收 释放掉无用对象所占有的内存空间 以便有足够的可用内存空间为新对象分配内存

目前在JVM中比较常见的三种垃圾收集算法是

标记-清除算法(Mark-Sweep)

复制算法(Copying)

标记压缩算法(Mark-Compact)

标记清除算法

image-20200624121038516

标记的是: 被引用的对象 可达对象 非垃圾对象

标记在 Header对象头

image-20200624121357435

缺点

  • 效率不算高
  • 在进行GC的时候 需要停止整个应用程序 导致用户体验差
  • 这种方式清理出来的空闲内存是不连续的 产生内存碎片 需要维护一个空闲列表

注意: 何为清除?

  • 这里的清除并不是 真的置空 而是把需要清除的对象地址保存在空闲的地址列表里 下次有新对象需要加载时 判断垃圾的位置空间是否足够 如果够就存放

复制算法

image-20200624122119572

image-20200624122218514

新生代 s1 s0 使用的也是复制算法

地址改变

优点

  • 没有标记和清除过程 实现简单 运行高效
  • 复制过去以后保证空间的连续性 不会出现碎片问题

缺点

  • 此算法的缺点也是很明显 需要两倍的内存空间
  • 对于G1这种分拆成为大量region的GC 复制不是移动 而意味着GC需要维护region之间对象引用关系 不管是内存占用或者时间开销也不小

特别的 如果系统中垃圾很多 复制算法不理想

需要复制的存活对象数量通常并不会太大,或者说非常低才可以

image-20200624123510999

标记 压缩算法

image-20200624123708813

image-20200624123934818

image-20200624124112997

image-20200624124318995

分代收集算法

image-20200624125452539

image-20200624125605193

image-20200624130007194

增量收集算法 分代算法

image-20200624130240985

image-20200624132303603

分区算法

image-20200624132606614

image-20200624132849024

垃圾回收相关概念

System.gc()理解

  • 在默认情况下 通过System.gc()或者Runtime.getRuntime().gc()的调用 会显式触发FullGC同时对老年代和新生代进行回收 尝试释放被丢弃对象占用的内存

  • 然而System.gc()调用附带一个免责声明 无法保证垃圾收集器的调用

  • JVM可以通过System.gc()调用来决定JVM的GC行为 二一般情况下 垃圾回收应该是自动进行的 无须手动触发 否则就太过于麻烦了 在一些特殊情况下 例如我们正在编写一个性能基准 我们可以在运行期间调用System.gc()

内存溢出和泄漏

  • 内存溢出对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一
  • 由于GC一直在发展,所以一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况
  • 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的FullGC操作,这时候会回收大量的内存,供应用程序继续使用
  • javadoc中对OOM的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存

image-20200624142836375

image-20200624143118774

image-20200624143308097

垃圾回收器

GC分类和性能

垃圾回收器概论

  • 垃圾收集器没有在规范中有过多的规定,可以由不同的厂商,不同版本的JVM来实现
  • 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本
  • 从不同的角度分析垃圾收集器,可以将GC分为不同的类型

垃圾回收器分类

  • 按线程数分,可以分为串行垃圾回收器并行垃圾回收器

image-20210328205230978

  • 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束

    • 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收期的性能表现可以超过并行回收器和并发回收器.

      所以串行回收被默认应用在客户端的Client模式下的JVM

    • 在并发能力较强的CPU上,并行回收器产生的停顿时间要短于串行回收器

  • 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然和串行回收一样,采用独占式,使用”Stop-the-world”机制

  • 按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器

    • 并发式垃圾回收器与应用线程交替工作,尽可能减少应用程序的停顿时间
    • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中所有的用户线程,直到垃圾回收过程完全结束

    image-20210328210002927

  • 按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器

    • 压缩式垃圾回收器会在回收完成之后,对存活对象进行压缩整理,清除回收后的碎片
    • 非压缩式的垃圾回收器不进行这步操作
  • 按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器

评估GC的性能指标

  • 吞吐量: 运行用户代码的时间占总运行时间的比例
    • 总运行时间: 程序运行时间+内存回收的时间
  • 垃圾收集开销: 吞吐量的补数,垃圾收集所用的时间与总运行时间的比例
  • 暂停时间: 执行垃圾收集时,程序的工作线程被暂停的时间
  • 收集频率: 相对于应用程序的执行,收集操作发生的频率
  • 内存占用: Java堆区所占的内存大小
  • 快速: 一个对象从诞生到被回收所经历的时间

吞吐量,暂停时间,内存占用 三者共同构成一个不可能三角

  • 三者总体表现会随着技术进步而越来越好.一款优秀的收集器通常最多同时满足其中的两项

  • 这三项里,暂停时间的重要性日益凸显. 因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低垃圾收集器运行时对应用程序的影响,即提高吞吐量.而内存的扩大对延迟反而带来负面的效果

  • 简单来说,主要抓住亮点: 吞吐量 暂停时间

吞吐量

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%

这种情况下应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的

吞吐量优先,以为着在单位时间内,STW的时间最短:0.2+0.2=0.4

image-20210328211732123

暂停时间

暂停时间是指一个时间段内的应用程序线程暂停,让GC线程执行的状态

例如,GC期间100毫秒的暂停时间意味着这100毫秒期间内没有应用程序线程是活动的

暂停时间优先,意味着尽可能让单次STW的时间最短:0.1+0.1+0.1+0.1+0.1=0.5

image-20210328212212936

image-20210328212510665

再设计或使用GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一,或尝试找到一个二者的折中

现在标准: 在最大吞吐量优先的情况下,降低停顿时间

不同垃圾回收器概述

image-20210328213137206

7款经典的垃圾收集器

  • 串行回收器: Serial,Serial Old
  • 并行回收器: ParNew, Parallel Scavenge, Parallel Old
  • 并发回收器: CMS, G1

image-20210328224316971

垃圾收集器的组合关系

image-20210328213356107

查看默认的垃圾收集器

-XX: +PrintCommandLineFlags: 查看命令行相关参数(包含使用的垃圾收集器)

使用命令行指令: jinfo -flag 相关垃圾回收器参数 进程ID