对象是否可回收

JVM中,要对堆中对象进行回收,首先要做的就是判断对象是否还存活,既不能再被使用。

引用计数算法

给每一个对象添加一个引用计数,有地方引用它时,就将计数加1,当引用失效时,就将引用计数减1.在引用计数为0的时候,表示该对象可回收。

  • 优点:实现简单,判定效率高
  • 缺点:很难解决循环引用问题。
  1. C:\Users\oliver>java -version
  2. java version "1.8.0_172"
  3. Java(TM) SE Runtime Environment (build 1.8.0_172-b11)
  4. Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

就目前使用的Java虚拟机(Sun公司的Hotspot)而言,并没有使用该算法来判断一个对象是否可回收。可以使用一个简单的示例来证明:

  1. public class RefCountTest {
  2. private RefCountTest ref;
  3. private byte[] bytes = new byte[1024 * 1024 * 3];
  4. public static void main(String[] args) throws InterruptedException {
  5. RefCountTest refCountTest_1 = new RefCountTest();
  6. RefCountTest refCountTest_2 = new RefCountTest();
  7. // 相互引用,达成循环
  8. refCountTest_1.ref = refCountTest_2;
  9. refCountTest_2.ref = refCountTest_1;
  10. TimeUnit.SECONDS.sleep(3);
  11. System.out.println("GC start");
  12. System.gc();
  13. }
  14. }

添加虚拟机参数:-XX:+PrintGCDetails 可以查看GC日志。GC日志如下:

GC start [GC (System.gc()) [PSYoungGen: 7454K->3856K(37888K)] 7454K->6936K(123904K), 0.0081575 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[Full GC (System.gc()) [PSYoungGen: 3856K->0K(37888K)] [ParOldGen: 3080K->6665K(86016K)] 6936K->6665K(123904K), [Metaspace: 2632K->2632K(1056768K)], 0.0161090 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
Heap PSYoungGen total 37888K, used 655K [0x00000000d6200000, 0x00000000d8c00000, 0x0000000100000000) eden space 32768K, 2% used [0x00000000d6200000,0x00000000d62a3ee8,0x00000000d8200000) from space 5120K, 0% used [0x00000000d8200000,0x00000000d8200000,0x00000000d8700000) to space 5120K, 0% used [0x00000000d8700000,0x00000000d8700000,0x00000000d8c00000)

ParOldGen total 86016K, used 6665K [0x0000000082600000, 0x0000000087a00000, 0x00000000d6200000)

object space 86016K, 7% used [0x0000000082600000,0x0000000082c82650,0x0000000087a00000)

Metaspace used 2639K, capacity 4486K, committed 4864K, reserved 1056768K

class space used 284K, capacity 386K, committed 512K, reserved 1048576K

其中,手动调用System.gc()垃圾回收类型时Full GCPSYoungGen表示使用的是Parallel Scavenge垃圾回收器表示的新生代。3865K表示回收之前的该内存区域已使用的容量;0K表示垃圾回收之后该区域使用的内存总容量。37888表示该区域的总容量。
也就是说,对于相互循环引用的对象,在Hotspot JVM中是可以回收的,这表示该虚拟机使用的不是引用计数算法来区分一个对象是否存活。

可达性分析算法

定义一系列的GC Root,然后从这个GC Root出发开始向下搜索,搜索所走过的路径称之为引用链(Reference Chain)。当一个对象不在任何一条引用链上的时候,表示该对象已经不能再被使用,可以被回收。
如上图所示,虽然对象D还引用着E、F,但是他们不与任何GC Root相连接,表示这些对象都是不可用的。

GC Root

Java中,以下几类对象可以作为GC Root

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

引用

引用的一般概念:如果一个数据表示的是一块内存的起始地址,那么就称该数据是这块内存区域的一个引用
所以在平常所说的垃圾回收概念中,无论是哪一种算法都先要确定对象是否还有存活的引用?因为可以这样理解:对象表示的是内存中的一份数据,引用表示的是该数据的起始地址,垃圾回收表示将这部分内存进行回收处理,也就是内存内的数据不复存在。那么,当还有有效引用存在的时候,肯定不能对对象进行回收,因为当前引用还指望使用这份数据完成业务逻辑呢!

但是Java中对引用的概念进行了扩充,将引用分为以下4种:

  • 强引用(Strong Reference

表示一个处于引用链上的不同对象,只要该引用存在,垃圾回收器就永远不会回收被引用的对象。

  • 软引用(Soft Reference

用来描述一些还有用但并非必需的对象。该类对象带系统将要发生OOM之前被再一次列入回收范围。如果本次回收的内存还不足以分配,才会导致OOM

  • 弱引用(Weak Reference

也是用来描述那些非必需对象。该类对象最多只能存活到下一次垃圾回收之前。当下一次垃圾回收发生时,无论当前内存是否足够,都会将该类引用相关联的对象进行回收。

  • 虚引用(Phantom Reference

一个对象是否由虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获取到对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾回收器回收的时候收到一个系统的通知。

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

  1. val referenceQueue:ReferenceQueue<Date> = ReferenceQueue(); // 引用队列
  2. // 虚引用与引用队列相关联
  3. var datePhantomReference:PhantomReference<Date> = PhantomReference(Date(),referenceQueue)
  4. println(datePhantomReference.get())

如果一个类重写了finalize()方法,那么该类对象被回收之前会回调一次该方法;如果在该方法中将该对象重新与GC Root建立关联,则该对象就不会被回收。 注意:每个对象的finalize()方法只会被执行一次

具体参考深入理解Java虚拟机第3章:3.2.4

垃圾回收算法

标记-清除算法

原理: 分为标记与清除两个阶段。标记表示对需要回收的对象做标记;清除表示将上一步中标记的对象全部进行回收。这是最基本的算法,往后的算法基本及时基于此改良而来的。

该算法有如下缺点:

  • 标记与清除过程的效率都不高
  • 清除之后会导致出现大量的不连续空间。当往后需要分配大内存对象时,很可能因为找不到合适的内存空间而引发又一次的垃圾回收。
存活对象 可回收对象 未使用对象

回收前:

回收后:

复制算法

将内存区域分为大小相等两块,每次只要使用其中一块存放对象,当这一块的内存空间使用完毕,就将还存活的对象复制到另一块内存空间,然后再将已使用的内存空间一次性清除掉。

  • 优点

分配内存只需要推进指针,顺序分配,运行高效且不必考虑内存碎块的问题;

  • 缺点

相当于只使用一半的内存空间,对内存空间浪费较大。

存活对象 可回收对象 未使用对象

回收前:

回收后:

现代的虚拟机采用的都是采用这种算法来回收新生代,IBM中的专门研究表明,新生代中的对象98%是“朝生夕死”的,并不需要按照按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块较小的Survivorfromto)空间。其中,Eden:from:to = 8:1:1

  1. 使用Edenfrom保存对象,内存区域不足以再分配时,也就是触发内存回收时,会将两者中存活的对象复制到to区域,然后清空Eden区域和from区域;

1589380527(1).png

  1. 接下来将新生对象保存在Eden区和to区域,当内存不足以再分配时,会触发垃圾回收,这次会将两者的Edento中存活的对象复制到from区域。如此循环。

image.png

当然,上面表述的是理想情况,即每次Eden区域和使用的Survivor区域的存活对象总大小小于另一块Survivor区域的大小。相反,现实中总是会出现另一块Survivor区域不足以存放所有存活对象的情况,这个时候这部分对象会转移到老年代中存储。

标记-整理算法

复制算法在存活率比较高的时候要进行较多的复制操作,效率会变低,因此只能是用于新生代。更关键的是,如果不想浪费50%的空间,就需要额外的内存担保,以应对100%对象都存活的情况。所以这种算法对于老年代来说是不可取的。因此,有人根据老年代的特点提出了标记-整理算法。

该算法的前半部分基本与标记-清除算法一样,都是标记出需要回收的对象;但是后续步骤是将所有存活的对象都往一端移动,然后将存活对象所占的区域以外的区域全部清除。

存活对象 可回收对象 未使用对象

回收前:

回收后:

分代收集算法

现代商业虚拟机基本上都是使用这种算法,主要思想就是根据对象存活周期的不同分为几个块:一般把Java分为新生代和老年代。新生代使用复制算法;老年代因为对象存活率较高且没有了额外空间对其进行担保,因此使用标记-清除或标记-整理算法。

Hotspot算法实现

枚举根节点

在以前的VM中,是基于handle来查找对象的,因为在GC发生之后,对象有可能会被移动到其他的内存空间,在不敢确定某个地址中存放的是具体的数据还是某个引用的前提下,VM是不敢将该内存中的数据都全部进行替换的,因此需要一个句柄来保证引用的稳定性。例如,在垃圾回收之后,如果将地址为123456的对象移动到了654321,在没有明确信息表明内存中的哪些数据是引用的情况下,虚拟机是不敢将内存中所有为123456的值改成654321的,因此需要一个句柄(handle)来标记引用,即使它移动了位置也能够清楚地区分出来:这本就是个引用,而不是数据。由于存在handle,因此每次定位对象都需要多出一次对handle的访问。

但是,现在的虚拟机都是用了
准确式内存管理**,即虚拟机知道内存中某个位置的数据是什么类型的,这样在GC的时候虚拟机就能够准确地判断出堆上的数据是否还能够被使用,减少了对handle的一次访问,即通过handle查找对象的一次开销,提升了执行性能。

在可达性分析算法中,我们知道Java定义了一系列的GC Roots节点,通过这些节点查找引用链便可得到哪些对象是不可用的,哪些是可用的。GC Roots一般在全局性引用(常量和类变量)和执行上下文(栈帧中的本地变量表)中,但是由于很多应用中仅仅方法区就有几百兆,如果要逐个查找这里面的引用,那么必然会很消耗时间。另外,在full GC发生的时候,会停止java所有的线程,称之为stop the world,这个时候如果还去遍历查找GC Roots,再查找GC Roots引用链的话,那停顿的时间就会加大,造成非常不好的体验。好在现在使用的是准确式GC,在系统停顿下来之后,不需要一个不漏地检查完所有全局性引用和执行上下文,而是直接就知道那些地方存放着这类引用,这大大地提升了效率。

在Hotspot中,使用了一组称之为OopMap的数据结构来记录对象引用的。在类加载完成之后,Hotspot会把对象内什么偏移量上是什么样的类型数据计算出来。在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用。这就是准确式内存管理是实现原理。

安全点

Hotspot并不会为每一条指令都生成OopMap,这样的话内存消耗会很大。只有在特定的位置才会生成OopMap记录,这些位置称之为安全点**safepoint**)。也就是说,只有在安全点的位置才能够进行GC操作。

安全点的准则:

  • 不能太少,这样会导致GC等待时间过长
  • 不能过于频繁,这样会过分增大运行时的负荷

安全点的选定基本上是以具有让程序长时间执行的特征为标准的。因此,安全点都是出现在长时间执行的指令(方法调用、异常跳转、循环跳转等)处。

如何在GC的时候让所有运行线程(不包含native)都跑到最近的安全点上再停顿下来呢?两种方案:抢先式中断和主动式中断。

  • 抢先式中断:在GC的时候,默认把所有线程全部中断,当发现线程中断的地方不在安全点上的话,就恢复线程,让其跑到安全点上(几乎没有虚拟机使用这种方式);
  • 主动式中断:发生GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮行这个标志,发现中断标记为真时就自己中断挂起。轮询标志的地方与安全点时重合的(也就是说,每到一处安全点就访问一次标志),另外再加上创建对象时需要分配内存的地方。

安全区域

上面说到,安全点保证了运行的线程能够跑到对应位置进行停顿。但是,对于一个处于sleep或blocked状态的线程来说,是不能响应JVM的中断请求并跑到安全点位置去自己中断挂起的。引入安全区域就是为了解决这个问题的。

安全区域指的是在一带代码片段中,引用关系不会发生变化。这个区域中的任何地方开始GC都是安全的(sleep状态和blocked状态的线程就是出于安全区域中)。因此安全区域也可以看做是扩展的安全点。

当线程执行到安全区域的时候,首先标识自己已经进入安全区域。当GC开始的时候,不需要管进入安全区域的线程。当线程需离开安全区域的时候,首先会检查系统是否已经完全了根节点枚举(或者整个GC过程),是的话线程继续执行,否则必须等待,直到收到可以离开安全区域的信号。

垃圾回收器

内存分配与回收策略