> 文章列表 > JVM垃圾回收GC 详解(java1.8)

JVM垃圾回收GC 详解(java1.8)

JVM垃圾回收GC 详解(java1.8)

目录

垃圾判断算法(你是不是垃圾?)

引用计数法

可达性算法

对象的引用

强引用

软引用

弱引用

虚引用

对象的自我救赎

垃圾回收算法--分代

标记清除算法

复制算法

标记整理法

垃圾处理器


垃圾判断算法(你是不是垃圾?)

引用计数法

最容易想到的一种方式是引用计数法,啥叫引用计数法,简单地说,就是对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收

String ref = new String("Java");

以上代码 ref 引用了右侧定义的对象,所以引用次数是 1
 

如果在上述代码后面添加一个 ref = null,则由于对象没被引用,引用次数置为 0,由于不被任何变量引用,此时即被回收,动图如下

看起来用引用计数确实没啥问题了,不过它无法解决一个主要的问题:循环引用!啥叫循环引用

public  class TestRC {    TestRC instance;    public TestRC(String name) {    }    public static  void main(String[] args) {        // 第一步	TestRC a = new TestRC("a");	TestRC b = new TestRC("b");        // 第二步	a.instance = b;	b.instance = a;        // 第三步	a = null;	b = null;    }
}

按步骤一步步画图

到了第三步,虽然 a,b 都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。

可达性算法

现代虚拟机基本都是采用这种算法来判断对象是否存活,可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕, 如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」, 会被 GC 回收。

如图示,如果用可达性算法即可解决上述循环引用的问题,因为从GC Root 出发没有到达 a,b, 所以 a,b 可回收

a, b 对象可回收,就一定会被回收吗? 并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生 GC 时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!

注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!

虽然可达性分析算法解决了循环依赖的问题,但是也产生了许多新的问题。进行可达性分析(搜索)的过程是需要时间的,而此时程序也是在并行运行着,不断产生新的对象,丢弃无用的对象。整个内存的状态是在变化的,所以目前主流的垃圾回收算法大多都要进行一种操作 Stop-The-World,翻译过来即停止世界,即暂停所有的用户线程,避免内存堆的状态发生变化。

那么这些 GC Roots 到底是什么东西呢,哪些对象可以作为 GC Root 呢,有以下几类

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

虚拟机栈中引用的对象

如下代码所示,a 是栈帧中的本地变量,当 a = null 时,由于此时 a 充当了 GC Root 的作用,a 与原来指向的实例 new Test() 断开了连接,所以对象会被回收。

public class Test {public static  void main(String[] args) {Test a = new Test();	a = null;    }
}

方法区中类静态属性引用的对象

如下代码所示,当栈帧中的本地变量 a = null 时,由于 a 原来指向的对象与 GC Root (变量 a) 断开了连接,所以 a 原来指向的对象会被回收,而由于我们给 s 赋值了变量的引用,s 在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!

public  class Test {public  static Test s;    public static  void main(String[] args) {Test a = new Test();	Test.s = new Test();	a = null;    }
}

方法区中常量引用的对象

如下代码所示,常量 s 指向的对象并不会因为 a 指向的对象被回收而回收

public  class Test {public  static  final Test s = new Test();        public static void main(String[] args) {	    Test a = new Test();	    a = null;        }
}

 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

方法调用中的时候,是作为 GC Root ,如果方法调用完成了,这些 JNI 引用对象是可以被回收的,如果不想要这些 JNI 引用对象被回收,可以设置为全局变量。

如果想让某些局部引用在从 C 函数返回后不被 JVM 回收,则可以借助 JNI 函数 NewGlobalRef,将该局部引用转换为全局引用。被全局引用的对象,不会被 JVM 回收,只能通过 JNI 函数 DeleteGlobalRef 消除全局引用后,才可以被回收。

这是简单给不清楚本地方法为何物的童鞋简单解释一下:所谓本地方法就是一个 java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python 等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。

当调用 Java 方法时,虚拟机会创建一个栈桢并压入 Java 栈,而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。

JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {...   // 缓存String的class   jclass jc = (*env)->FindClass(env, STRING_PATH);}

 

如上代码所示,当 java 调用以上本地方法时,jc 会被本地方法栈压入栈中, jc 就是我们说的本地方法栈中 JNI 的对象引用,因此只会在此本地方法执行完成后才会被释放。一旦从 C 函数中返回至 Java 方法中,那么局部引用将会失效,JVM 在整个 Tracing 过程中就不再考虑这些局部引用,也就是说,一段时间后,局部引用占用的内存将会被回收。

对象的引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

并不是说只要是和 GC Roots 有一条联系(Reference Chain),对象就是存活的,它还与对象引用级别有关。

强引用

属于普通常见的那种,也就是我们 new 出来的对象,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用

只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止(不太对,如果是 Young GC 不一定会回收,如果是 Full GC 则一定会回收,参考下面例子)。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

虚引用

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

栗子说明一下

添加配置JVM参数 -XX:+PrintGC -XX:+PrintGCDateStamps -Xmx5m -Xms5m

public class test {static class MyOb {private int i = 1;// 覆盖finalize()方法@Overrideprotected void finalize() throws Throwable {super.finalize();System.out.println("====== obj [" + this.toString() + "] is gc =======");}}// 消耗大量内存public static void drainMemory() {String[] array = new String[1024 * 10];for(int i = 0; i < 1024 * 10; i++) {for(int j = 'a'; j <= 'e'; j++) {array[i] += (char)j;}}}
}

首先先写一个 覆盖 finalize 方法的对象,进行 GC 的时候会进入,另外有个消耗大量内存的方法。

    public static void main(String[] args) {MyOb ob = new MyOb();drainMemory();}

强引用的,我们看看输出

2020-08-28T16:33:56.546+0800: [GC (Allocation Failure)  1024K->616K(5632K), 0.0011773 secs]
2020-08-28T16:33:56.578+0800: [GC (Allocation Failure)  1633K->827K(5632K), 0.0006751 secs]
2020-08-28T16:33:56.591+0800: [GC (Allocation Failure)  1851K->1049K(5632K), 0.0022424 secs]
2020-08-28T16:33:56.594+0800: [GC (Allocation Failure)  2073K->1273K(5632K), 0.0010249 secs]
2020-08-28T16:33:56.596+0800: [GC (Allocation Failure)  2297K->1521K(5632K), 0.0011336 secs]

明显内存不足进行 GC 的时候,判断到 ob 可达的,是不会进行回收的。

然后看看软引用

    public static void main(String[] args) {SoftReference<MyOb> ob = new SoftReference<>(new MyOb());drainMemory();}
2020-08-28T16:37:38.730+0800: [GC (Allocation Failure)  1024K->584K(5632K), 0.0013387 secs]
2020-08-28T16:37:38.764+0800: [GC (Allocation Failure)  1601K->842K(5632K), 0.0007544 secs]
2020-08-28T16:37:38.784+0800: [GC (Allocation Failure)  1866K->1066K(5632K), 0.0011751 secs]
2020-08-28T16:37:38.787+0800: [GC (Allocation Failure)  2090K->1314K(5632K), 0.0010511 secs]
2020-08-28T16:37:38.789+0800: [GC (Allocation Failure)  2338K->1554K(5632K), 0.0009488 secs]

同样在没有内存溢出时候,他和强引用是没有区别的

我们把消耗内存的算法改一下,让它产生内存溢出

 // 消耗大量内存public static void drainMemory() {String[] array = new String[1024 * 1000];for(int i = 0; i < 1024 * 10; i++) {for(int j = 'a'; j <= 'b'; j++) {array[i] += (char)j;}}}

再重新跑一下

2020-08-28T16:39:38.310+0800: [GC (Allocation Failure)  1024K->624K(5632K), 0.0012934 secs]
2020-08-28T16:39:38.351+0800: [GC (Allocation Failure)  1641K->884K(5632K), 0.0011559 secs]
2020-08-28T16:39:38.365+0800: [GC (Allocation Failure)  1407K->934K(5632K), 0.0011081 secs]
2020-08-28T16:39:38.366+0800: [GC (Allocation Failure)  934K->942K(5632K), 0.0007891 secs]
2020-08-28T16:39:38.367+0800: [Full GC (Allocation Failure)  942K->706K(5632K), 0.0035185 secs]
2020-08-28T16:39:38.371+0800: [GC (Allocation Failure)  706K->706K(5632K), 0.0002779 secs]
2020-08-28T16:39:38.371+0800: [Full GC (Allocation Failure)  706K->690K(5632K), 0.0051638 secs]
====== obj [com.example.demo.conf.test$MyOb@51fe5ed6] is gc =======
Exception in thread "main" java.lang.OutOfMemoryError: Java heap spaceat com.example.demo.conf.test.drainMemory(test.java:33)at com.example.demo.conf.test.main(test.java:27)

 可以看到再抛出 OOM 之前,会先清理掉这个对象(强引用 OOM 前如果还是可达的,也不会清理掉,可以自己试一下)

然后看弱引用

    public static void main(String[] args) {ReferenceQueue<MyOb> queue = new ReferenceQueue<>();//创建一个引用给定对象并在给定队列中注册的新的弱引用。WeakReference<MyOb> ob = new WeakReference<>(new MyOb() , queue);drainMemory();Thread.sleep(1000);System.out.println("ob = " + ob.get());System.out.println("queue poll = " + queue.poll());}

记得把之前的调OOM的内存消耗部分修改回来

得到的结果有多种情况

2020-08-28T17:38:16.625+0800: [GC (Allocation Failure)  1024K->600K(5632K), 0.0008772 secs]
2020-08-28T17:38:16.657+0800: [GC (Allocation Failure)  1624K->839K(5632K), 0.0007530 secs]
2020-08-28T17:38:16.671+0800: [GC (Allocation Failure)  1863K->1009K(5632K), 0.0005883 secs]
====== obj [com.example.demo.conf.test$MyOb@51fe5ed6] is gc =======
2020-08-28T17:38:16.672+0800: [GC (Allocation Failure)  2033K->1201K(5632K), 0.0006779 secs]
2020-08-28T17:38:16.674+0800: [GC (Allocation Failure)  2225K->1353K(5632K), 0.0005505 secs]
2020-08-28T17:38:16.675+0800: [GC (Allocation Failure)  2377K->1529K(5632K), 0.0010291 secs]
ob = null
queue poll = java.lang.ref.WeakReference@270421f5
2020-08-28T17:39:50.249+0800: [GC (Allocation Failure)  1024K->592K(5632K), 0.0007809 secs]
2020-08-28T17:39:50.283+0800: [GC (Allocation Failure)  1610K->843K(5632K), 0.0029328 secs]
2020-08-28T17:39:50.304+0800: [GC (Allocation Failure)  1867K->1045K(5632K), 0.0008099 secs]
2020-08-28T17:39:50.306+0800: [GC (Allocation Failure)  2069K->1157K(5632K), 0.0012691 secs]
2020-08-28T17:39:50.308+0800: [GC (Allocation Failure)  2181K->1341K(5632K), 0.0009482 secs]
2020-08-28T17:39:50.313+0800: [GC (Allocation Failure)  2365K->1485K(5632K), 0.0014565 secs]
ob = com.example.demo.conf.test$MyOb@270421f5
queue poll = null

可以发现,Young GC 是不一定会回收,由系统来判断回收的时机。

然后我们改一下代码,改用 System.gc() 触发 FULL GC 看看有什么不同

    public static void main(String[] args) {ReferenceQueue<MyOb> queue = new ReferenceQueue<>();//创建一个引用给定对象并在给定队列中注册的新的弱引用。WeakReference<MyOb> ob = new WeakReference<>(new MyOb() , queue);System.gc();System.out.println("ob = " + ob.get());System.out.println("queue poll = " + queue.poll());}
2020-08-28T18:25:22.967+0800: [GC (Allocation Failure)  1024K->632K(5632K), 0.0014284 secs]
2020-08-28T18:25:23.001+0800: [GC (Allocation Failure)  1650K->846K(5632K), 0.0008219 secs]
2020-08-28T18:25:23.016+0800: [GC (System.gc())  1370K->886K(5632K), 0.0005198 secs]
2020-08-28T18:25:23.017+0800: [Full GC (System.gc())  886K->707K(5632K), 0.0045606 secs]
====== obj [com.example.demo.conf.test$MyOb@51fe5ed6] is gc =======
ob = null
queue poll = null

可以发现 GC 后肯定会取消对象的引用

注意:上面的控制台顺序不一定和真实的gc顺序一样,关键得看 ob.get() 是否为空去判断

对象的自我救赎

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

如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从代码清单3-2中我们可以看到一个对象的 finalize() 被执行,但是它仍然可以存活。

并不鼓励大家使用这个方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它并不能等同于C和C++语言中的析构函数,而是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。有些教材中描述它适合做“关闭外部资源”之类的清理性工作,这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉Java语言里面的这个方法。

垃圾回收算法--分代

标记清除算法

步骤很简单

  1. 先根据可达性算法标记出相应的可回收对象(图中黄色部分)
  2. 对可回收的对象进行回收

优点是不需要额外空间

缺点是两次扫描耗时严重,会产生内存碎片

复制算法

把堆等分成两块区域, A 和 B,区域 A 负责分配对象,区域 B 不分配, 对区域 A 使用以上所说的标记法把存活的对象标记出来,然后把区域 A 中存活的对象都复制到区域 B(存活对象都依次紧邻排列)最后把 A 区对象全部清理掉释放出空间,这样就解决了内存碎片的问题了。

不过复制算法的缺点很明显,比如给堆分配了 500M 内存,结果只有 250M 可用,空间平白无故减少了一半!这肯定是不能接受的!另外每次回收也要把存活对象移动到另一半,效率低下(我们可以想想删除数组元素再把非删除的元素往一端移,效率显然堪忧)

标记整理法

前面两步和标记清除法一样,不同的是它在标记清除法的基础上添加了一个整理的过程 ,即将所有的存活对象都往一端移动, 紧邻排列(如图示),再清理掉另一端的所有区域,这样的话就解决了内存碎片的问题。

但是缺点也很明显:每进一次垃圾清除都要频繁地移动存活的对象,效率十分低下。

垃圾处理器

名称

负责区域

使用算法

描述

Serial

young

复制算法

单线程,Client默认新生代处理器

ParNew

young

复制算法

多线程,Serial的多线程版

Parallel Scavenge

young

复制算法

多线程,注重停顿时间和吞吐量

Serial Old

old

标记整理

Client默认老年代处理器,Serial的老年版本,CMS的后备方案

Parallel Old

old

标记整理

多线程,注重停顿时间和吞吐量

CMS

old

标记清除

整个过程分为4个步骤:

  1. 初始标记(CMS initial mark) -stop the world
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark) -stop the world
  4. 并发清除(CMS concurrent sweep)

G1

young/old

整体标记整理,

局部复制

多线程,内存分区(各分区大小相同,同一分区逻辑角色相同,都是新生代等,回收以分区为单位,复制到另一个分区),首先回收垃圾最多的分区,低停顿,使用暂停预测模型。