深入学习JVM(二) -- JVM垃圾收集总结

637 阅读24分钟

JVM垃圾收集相关总结

只要努力就能解决的事情,我觉得就是最简单的事情!

最近学习了关于JVM相关的东西,距离上一次写JVM的内存模型已经时隔有个一年半载了,这次又深入的学习了一下;之前只是局限于表面,这次系统的学习了一下,下面对最近学习的东西进行总结(输出);目前计划可能会分成2到3的文章进行讲解;下面我们言归正传!

先说一下垃圾回收的区域:

  • 栈: 栈中的生命周期是跟随线程的,线程销毁也就释放了内存空间,所以一般不需要关注
  • 方法区: 这一块也会发生垃圾回收,也不是我们关注的重点
  • 堆: 这里的对象是垃圾回收的重点!

如果对栈,堆,方法区不熟悉的话可以看下上面的JVM的内存模型,然后再来看这篇文章,循环渐进的学习;

下面我对要说到的内容进行罗列一下:

  • 怎么判断对象的存活

  • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)

  • 如何判断一个常量是废弃常量

  • 如何判断一个类是无用的类

  • 垃圾收集有哪些算法,各自的特点?

  • HotSpot 为什么要分为新生代和老年代?

  • 常见的垃圾回收器有哪些?都负责哪部分的回收?

  • 各个垃圾收集器的搭配使用

  • Minor Gc 和 Full GC 有什么不同呢?

  • 垃圾回收器的一些重要参数

那接下来我们逐个讲解一下,一一攻破!

怎么判断对象的存活

一般有两种方式:引用计数法,可达性分析,jvm使用的是可达性分析!

引用计数法

给对象添加一个引用计数器,当对象增加一个引用时计数器就加1,引用消失时计数器就减1,引用计数等于0的时候,表示这个对象是可以回收的;这个方法实现简单,效率高,但是jvm没有采用这种算法来管理内存,主要的原因是这个算法它很难解决对象之间的相互循环引用的问题;

image.png

这三个对象各自相互依赖,导致各自的计数器都是1所以就导致回收不掉!

可达性分析算法

判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的

image.png

这里的Object5,6,7是可以被回收的;

可以作为GC Roots的对象有很多,包括:

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

判断对象的存活就说到这里,下面我们讲解一下第二个问题:各种引用!

live or die ?this is a problem.

可达性分析算法中不可达的对象非死不可吗?

转载:blog.csdn.net/luzhensmart

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。下面例子可以看出finalize()被执行,但是它仍然可以存活。

如果没有看懂就多看两遍。如果还没有看懂,那我就白复制了。。。用代码证明一下:

public class FinalizeEscapeGC {
 
    public static FinalizeEscapeGC SAVE_HOOK = null;
 
    public void isAlive() {
        System.out.println("yes, I am still alive :)");
    }
 
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        SAVE_HOOK = this;
    }
 
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
 
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
 
 
        //代码和上面的一样 但是这次自救失败
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

运行结果:一样的代码,一次逃脱,一次失败。因为对象的finalize()只能被系统执行一次。

finalize method executed!
yes, I am still alive :)
no, i am dead :( 

各种引用(Reference)

无论是通过引用计数判断对象的存活,还是通过可达性分析算法判断对象的存活都与引用有关!下面我们先说一下强引用;

强引用(StrongReference)

一般的Object obj=new Object();就属于强引用。如果有GCRoots的强引用,垃圾回收绝对不会回收它,当内存不足时宁愿抛出OOM错误,使得程序异常停止,也不会回收强引用对象;

软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不够了,就会回收这写具有软引用的对象;

用代码解释一下软引用:

//代码来自:IT王小二(掘金名称)
public static void main(String[] args) {
    String str = new String("SunnyBear"); // 强引用
    SoftReference<String> strSoft = new SoftReference<String>(str);
    str = null; // 干掉强引用,确保只有strSoft的软引用
    System.out.println(strSoft.get()); // SunnyBear
    System.gc(); // 执行一次gc,此命令请勿在线上使用,仅作示例操作
    System.out.println("------------ gc after");
    System.out.println(str); // null
    System.out.println(strSoft.get()); // SunnyBear
}

弱引用(WeakReference)

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

示例代码:

public static void main(String[] args) {
    String str = new String("SunnyBear"); // 强引用
    WeakReference<String> strWeak = new WeakReference<String>(str);
    str = null; // 干掉强引用,确保只有strSoft的软引用
    System.out.println(strWeak.get()); // SunnyBear
    System.gc(); // 执行一次gc,此命令请勿在线上使用,仅作示例操作
    System.out.println("------------ gc after"); // null
    System.out.println(str); // null
    System.out.println(strWeak.get()); // null
}

虚引用(PhantomReference)了解即可

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

如何判断一个常量是废弃常量

运行时常量池主要回收的的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

我觉得在这里有必要了解一下关于运行时常量池和字符串常量池的一些注意点;更有利于理解这部分的内容!

摘抄: JDK1.8关于运行时常量池, 字符串常量池的要点

在JDK1.7之前运行时常量池逻辑包含字符串常量池其存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代

在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代

在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace) 对于直接做+运算的两个字符串(字面量)常量,并不会放入字符串常量池中,而是直接把运算后的结果放入字符串常量池中 (String s = "abc"+ "def", 会直接生成“abcdef"字符串常量 而不把 "abc" "def"放进常量池) 对于先声明的字符串字面量常量,会放入字符串常量池,但是若使用字面量的引用进行运算就不会把运算后的结果放入字符串常量池中了 (String s = new String("abc") + new String("def"),在构造过程中不会生成“abcdef"字符串常量) 总结一下就是JVM会对字符串常量的运算进行优化,未声明的,只放结果;已经声明的,只放声明 常量池中同时存在字符串常量和字符串引用。直接赋值和用字符串调用String构造函数都可能导致常量池中生成字符串常量;而intern()方法会尝试将堆中对象的引用放入常量池

String str1 = "a"; String str2 = "b"; String str4 = str1 + str2; //该语句只在堆中生成一个对象(str4) 这句被Java编译器做了优化, 实际上使用StringBuilder实现的(不在堆里生成str1和str2对象)

String str5 = new String("ab");(字符串常量池中不存在"ab"时)在字符换常量池中创建"ab"对象,在堆中生成了一个对象str5, str5指向堆上new的对象,而str5内部的char value[]则指向常量池中的char value[] 关于这个问题可以参考这篇博客:new String()究竟创建几个对象?

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾收集有哪些算法,各自的特点?

再讲垃圾收集算法之前我们先了解一下GC,GC可以分为两种:

Minor GC/YongGC

  • 特点:发生在新生代,发生较频繁,执行速度快
  • 触发条件/时机:Eden区空间不足/空间分配担保

FullGC/OldGC

  • 特点: 主要发生在老年代(新生代也会回收),较少发生,执行的速度较慢
  • 触发条件/时机:
    • 调用 System.gc()
    • 老年代区域空间不足
    • 空间分配担保失败
    • JDK 1.7 及以前的永久代(方法区)空间不足

好,那接下来我们讲解一下垃圾回收的算法

image.png

标记-清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  • 优点:

    • 内存利用率高
  • 缺点

    • 空间问题(标记清除后会产生大量不连续的碎片)
    image.png

对于是标记不需要回收的对象还是表示需要回收的对象;我看其他文章有争议的地方;这里我也查看了一些资料:

从这个动图中可以看出标记的是可达对象;如果觉得这个图没有说服力我又翻阅了其他的资料:

image.png

文章连接感兴趣的可以看下:Back To Basics: Mark and Sweep Garbage Collection

之所以网上有人说标记的是需要回收的对象,我觉得都是源于这本书:

在《深入理解Java虚拟机》第2版书中,69页写的是:

首先标记出出所有要回收的对象,在标记完成后统一回收所有被标记的对象。

结合目前查阅到的一些国外资料来看,我也认为标记的是不需要回收的对象也就是可达对象

复制算法

复制算法是将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完了,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存一次清理掉,这样使得每次都是对整个半区进行内存回收,一是提高了效率,二是不用考虑内存碎片情况;

image.png
  • 优点:
    • 简单高效
    • 不会出现内存碎片
  • 缺点:
    • 内存利用率低
    • 存活对象较多时效率就会下降

标记-整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

image.png
  • 优点
    • 利用率高
    • 没有内存碎片
  • 缺点
    • 标记和清除效率都不高(对比复制算法及标记清楚算法)

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量的对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集,而老年代的对象存活率比较高,而且没有额外的空间对它进行分配担保,所以我们必须选择 标记-清除 或者 标记-整理算法进行垃圾收集;

HotSpot 为什么要分为新生代和老年代?

结合上面的对分代收集算法的介绍回答;可以在不同的区,使用不同的算法;提高整体效率;

常见的垃圾回收器有哪些?都负责哪部分的回收?

image.png

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现!

Serial收集器

Serial (串行)收集器是最基本,历史最悠久的的垃圾收集器,大家看名字就知道这个收集器是一个单线程收集器,它的单线程的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾的收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(Stop The World

image.png

当然Serial收集器也有优点,例如简单高效(与其他收集器的单线程相比) ,Serial收集器由于没有线程交互的开销,自然可以获取的很高的单线程收集效率;

ParNew收集器

parNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数,收集算法,回收策略)和Serial收集器完全一样;

image.png

Parallel Scavenge 收集器

Parallel Scavenge(简称PS) 收集器也是使用复制算法的多线程收集器,它看上去几乎和ParNew都一样。 那么它有什么特别之处呢?

PS收集器关注点是吞吐量(高效率的利用CPU),CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

使用java -XX:+PrintCommandLineFlags -version命令查看默认收集器(JDK1.8默认PS+PO(Parallel Old,下面会介绍)

Serial Old收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

Parallel Scavenge收集器的老年代版本,用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

到这里我们先对上面的垃圾收集器进行一些总结:

  • 新生代

    收集器收集器对象和算法收集器类型
    Serial新生代,复制算法单线程
    ParNew新生代,复制算法并行的多线程收集器
    Parallel Scavenge新生代,复制算法并行的多线程收集器
  • 老年代

    收集器收集器对象和算法收集器类型
    Serial Old老年代,标记整理算法单线程
    Parallel Old老年代,标记整理算法并行的多线程收集器
    CMS(Conc Mark Sweep)老年代,标记清除算法并行和并发收集器
    G1(Garbage First)跨新生代和老年代,复制算法+标记整理算法并行和并发收集器

    虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

    上面我们对垃圾收集器做了总结,我们还有CMS和G1没有说到,接下来我们就承上启下,趁热打铁介绍一下CMS和G1,这也是一个分水岭,CMS的诞生也是并发收集的一个里程碑的出现;

    CMS收集器

    CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户的体验的应用上使用;同时也是HotSpot虚拟机第一款真正意思上的并发收集器,它第一次实现了让垃圾收集线程和与应用线程(基本上)同时工作;

    回收流程

    image.png
  • 初始标记: 暂停所有其他线程,标记一下GCRoots能直接关联的对象,速度很快,这个阶段是STW(Stop the world)的;

  • **并发标记:**同时开启GC和用户线程,从GCRoots根开始对堆中的对象进行可达性分析,但是这个阶段结束不能保证全部找到所有的可达对象,因为用户线程在不断的更新引用域,所以GC线程无法保证可达性分析的实时性;

  • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(STW)。这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短

  • 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫

优点: 耗时最长的并发标记和并发清理过程都是和用户线程一起工作的,所以总体上来说CMS收集器的内存回收过程是与用户线程一起并发执行的;

缺点:

  • CMS虽然是老年代的垃圾回收期但是它会扫描新生代

    在老年代中如何确保Current obj 是存活的?这也是为什么CMS虽然是老年代的gc,但仍要扫描新生代的原因(不光在重新标记中需要扫描新生代,在初始标记也会扫描新生代)

    这个阶段是在重新标记(remark)中,也有文章说这是重新标记的一个子阶段(Rescan

    **重点来了:全量的扫描新生代和老年代会不会很慢?**肯定会。

    CMS号称是停顿时间最短的GC,如此长的停顿时间肯定是不能接受的。

    如何解决呢?

    必须要有个能快速识别新生代和老年代活着的对象的机制

    在新生代的解决方法: 在扫描新生代前进行一次Minor GC

    老年代的解决方法:老年代的机制与一个叫CARD TABLE的东西(这个东西其实就是个数组,数组中每个位置存的是一个byte)密不可分。

    CMS将老年代的空间分成大小为512bytes的块,card table中的每个元素对应着一个块。

    并发标记时,如果某个对象的引用发生了变化,就标记该对象所在的块为 dirty card

    在重新标记之后并发清理之前有个并发预清理阶段,在这个阶段会重新扫描该块,将该对象引用的对象标识为可达。

    下面我们用图来解释一下:

    并发标记时对象的状态:

    但随后current obj的引用发生了变化:

    current obj所在的块被标记为了dirty card。

    随后到了pre-cleaning(预清理阶段)阶段,该阶段的任务之一就是标记这些在并发标记阶段被修改了的对象,之后那些通过current obj变得可达的对象也被标记了,变成下面这样:

    同时dirty card标志也被清除。老年代的机制就是这样。

    不过CARD TABLE还有其他的作用;

    还记得上面在扫描新生代之前进行的一次Minor GC吗? 如果这个阶段老年代的引用了新生代的,怎么办?光扫描新生代是扫描不出来的;但是这种情况根据研究表明(怎么研究的我也不知道,就是有研究表明),在所有的引用中,老年代引用新生代这种场景不足1%

    当有老年代引用新生代,对应的card table被标识为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的)。

    所以,Minor GC通过扫描card table就可以很快的识别老年代引用新生代。

  • 由于并发清理阶段用户线程还在运行,程序的运行自然会有新的垃圾产生,这一部分的垃圾出现在标记之后,CMS无法在当次收集中处理掉他们,只好留到下次GC时在清理掉,这一部分垃圾就称为浮动垃圾;

  • 由于浮动垃圾的存在,因此需要预留一部分内存,意味着CMS收集不能像其他收集那样等待老年代快满的时候再回收,在1.6的版本中老年代空间使用阈值是92%(CMSInitiatingOccupancyFraction设置),如果预留的内存不够存放浮动垃圾就会出现Concurrent Mode Failure,这时虚拟机将临时启动Serial Old来代替CMS;

  • 同时也会产生内存碎片(标记-清除算法) CMS的解决方案是使用UseCMSCompactAtFullCollection参数(默认开启),在顶不住要进行Full GC时开启内存碎片整理。这个过程需要STW,碎片问题解决了,但停顿时间又变长了。虚拟机还提供了另外一个参数CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认为0,每次进入Full GC时都进行碎片整理)

到这里垃圾收集器还有G1没有说到,在说G1收集器之前我们先讲解一下各个垃圾收集器的搭配使用;

各个垃圾收集器的搭配使用

image.png

连线表示可以 新生代老年代 配套使用的垃圾收集器

到这里这篇文章就先告一段落,由于篇幅太长,关于G1的知识点也比较多,所有就另开一篇文章来讲解G1垃圾收集器;