深入理解Java虚拟机(3)——垃圾回收策略

JVM的内存模型有5个部分:虚拟机栈、程序计数器、本地方法栈、堆、方法区。

 

程序计数器、虚拟机栈、本地方法栈都是线程私有的,会随着线程的创建而创建,线程的结束而销毁。因此,垃圾回收器在何时回收这三块区域的问题就解决了。

 

此外,虚拟机栈、本地方法栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,并且每个栈帧的本地变量表都是在类加载时就确定的。因此以上三个区域内存分配和回收具有确定性,当方法结束或者线程结束时,内存自然就跟着回收了。

 

然而,堆和方法区则不一样。堆和方法区所有线程共享,并且都在JVM启动时创建,一直运行到JVM停止。因此它们没办法根据线程的创建而创建,随线程结束而释放。

 

堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间确定。

方法区存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,只有在程序运行期间才能知道JVM要加载多少类。

综上:堆和方法区的分配和内存回收具有不确定性,所以这部分内存的分配和回收都是动态的。

 

垃圾回收(GC)考虑的3个问题:

  • 哪些内存需要回收?

  • 什么时候回收?

  • 如何回收?

 

1、堆内存的回收

 

1.1 判断对象是否要回收?

 

一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收。两种判别方式:

  • 引用计数法

        每个对象都有一个计数器,当这个对象被一个变量或者对象引用一次,该计数器加一;若该引用失效则计数器减一。当计数器为0,表明该对象是无效对象。

 

  • 可达性分析法

          所有和GC Root直接或间接关联的对象都是有效对象,和GC Root没有关联的对象就是无效对象。

         

  可作为GC Roots包括:

  • 虚拟机栈的引用类型变量(栈帧中局部变量表中引用类型的变量引用)

  • 方法区中类静态属性引用

  • 方法区中常量引用

  • 本地方法栈中Native方法(JNI)引用

 

可作为GC Roots的节点主要在全局性的引用(如常量或类静态变量)与执行上下文(如栈帧中的本地变量表)中。

 

PS:如下面循环引用示例,objA和objB的实例虽然互相有关联,但是没有与GCRoots(这里是objA和objB这两个引用)相连,因此被判定是可以回收的。

对比:引用计数法虽然简单,但是不能解决循环引用的问题。

因此,目前主流语言均使用可达性分析方法来判断对象是否有效。

 

循环引用示例如下:

objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();//假设发生回收,采用引用计数法,objA和objB不能被回收,循环引用

 

1.2 回收无效对象的过程

 

当JVM筛选出无效对象后,并不是马上回收对象,而是给对象一次重生的机会,具体过程如下:

  • 判断对象是否覆盖了finalize()方法

    • 若已覆盖该方法,并且该对象的finalize()方法还没有被执行过,那么会将finalize()扔到F-Queue的队列之中;

    • 若未覆盖该方法,则直接释放该对象内存

 

  • 执行F-Queue队列中的finalize()方法

        虚拟机会以较低优先级执行这些finalize()方法,不会确保所有的finalize()方法都会执行结束。如果finalize()出现耗时操作,虚拟机就直接停止执行,将该对象清除。

 

  • 对象重生或死亡

        如果执行finalize()方法时,将this赋给某个类变量或者成员变量,那么该对象重生了;否则,就会被GC清除。

 

注意:

  • 强烈不建议使用finalize()函数进行任何操作!如果要释放资源,使用try-finally。因为,finalize()方法不确定大,开销大,无法保证顺利执行。

  • 任意一个对象的finalize()方法都只会被系统自动调用一次

 

2、方法区的内存回收

 

特点:

  • JVM规范不要求虚拟机在方法区中实现垃圾回收

  • 方法区中进行垃圾回收“性价比”一般比较低(即大部分不需要被回收)

 

方法区中主要清除两种垃圾:

  • 废弃常量

  • 无用的类

 

2.1 如何判定废弃常量?

 

与清除对象类似,若是常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。

 

2.2 如何判断一个类是“无用的类”?

 

条件要苛刻得多,必须同时满足下面3个条件,才可能会被回收:

  • 该类的所有对象都已经清除

  • 该类的java.lang.Class对象没有被任何对象或变量引用

        一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象会在类加载到方法区时被创建,在方法区中该类被删除时清除。

  • 加载该类的ClassLoader已经被回收

 

3 垃圾回收算法

 

前面我们知道如何判断一个对象是否无效、判断常量是否废弃、判断一个类是否无用,也就知道了哪些内存需要被回收,那么如何对这些内存进行回收呢?

 

3.1 标记-清除算法

 

利用前面的方法判断需要清除哪些数据,并且做上标记,在标记完成后统一进行回收。

不足:

  • 效率问题,标记和清除两个过程的效率都不高。

  • 空间问题,标记清除后会产生大量碎片空间,导致无法存储大对象(无法找到足够连续内存而不得不触发一次GC)

 

3.2 复制算法

 

将内存分为两部分,只将数据存储在其中一份中。当需要回收垃圾时,将存活的对象复制到另一块内存中,然后再把已使用过的内存空间一次性清理掉。

优缺点:

  • 避免了碎片空间,但是空间利用率不足,只有一半

  • 每次需要将有用的数据(特别多时)全部复制到另一片内存上去,效率不高

  • 分配内存的时候,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效

 

现代商业虚拟机都是采用这种收集算法来回收新生代的。

 

如何解决空间利用率低的问题?

 

新生代中的对象98%都是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间。因此将堆内存划分为3块:Eden、 Survivor1、Survivor2,内存大小分别是8:1:1。

为对象分配内存时,只使用Eden+Survivor1,Survivor1是上次GC中存活下来的对象,当发现Eden+Survivor1即将满时,JVM会发起一次MinorGC,清除掉废弃的对象,并将存活的对象复制到另一块内存Survivor2中。然后,接下来Survivor1和Survivor2角色互换,使用Survivor2+Eden进行内存分配。

 

特点:

  • 通过这种方式,只浪费了10%的内存空间就可以避免内存碎片的问题

  • 98%的对象会被回收只是一般场景下,无法保证每次回收只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

 

什么是分配担保?

 

当JVM准备为一个对象分配内存时,若Eden+Survivor1中空闲内存不足,会触发一次Minor GC,如果GC完之后,Survivor2的内存空间放不下新对象。那么此时需要将Eden+Survivor1的存活对象都转移到老年代中,然后再将新对象存入Eden区。这个过程称为“分配担保”。

 

3.3 标记-整理算法

 

在回收垃圾之前,将所有废弃的对象做上标记,然后将所有未被标记的对象都向一端移动,然后直接清理掉端边界以外的内存。

 

分析:

是一种老年代的垃圾回收算法。老年代中的对象一般寿命较长,因此每次GC有大量对象存活。

若采用复制算法,

  • 会有很多复制操作,效率低下

  • 而且最关键的是,当Eden+Survivor1中都装不下某个对象时,没有其他区域给它做分配担保。

因此,老年代一般都采用标记-整理算法。

 

对比:

  • 效率上,复制算法>标记-清理算法>标记-整理算法(仅从时间复杂度上看)

  • 内存利用率,标记-整理算法=标记-清理算法>复制算法

  • 内存规整度,复制算法=标记-整理算法>标记-清理算法

 

总结:标记-整理算法比复制算法多了一个标记阶段,比标记-清理算法多了一个整理阶段。

 

3.4 分代收集算法

 

将内存划分为老年代和新生代。

  • 老年代存放寿命较长的对象,使用标记-整理算法;

  • 新生代存放“朝生夕死”的对象,使用复制算法。

 

4、Java引用的种类

 

JDK1.2以前对引用的定义:

如果引用类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

优缺点:

  • 定义纯粹,但太过狭隘。无法描述一类对象:当内存足够时,保留;当内存清理之后还是很紧张,则可以抛弃。

 

4.1 强引用

 

普遍存在,类似“Object obj = new Object()”这类的引用。

只要强引用还在,GC永远不会回收被引用的对象。

 

4.2 软引用

 

只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象。

通过SoftReference类实现软引用。

软引用的生命周期比强引用短一些。

 

4.3 弱引用

 

只要GC运行,软引用所指向的对象就会被回收。

弱引用用WeakReference类实现。

弱引用的生命周期比软引用短一些。

 

4.4 虚引用

 

也称幽灵引用或者幻影引用,最弱的一种引用关系。

和没有引用没有区别,无法通过虚引用取得一个对象实例。

唯一目的:在这个对象被GC回收时会收到一个系统通知。

用PhantomReference类来实现虚引用。

 

 

5、HotSpot虚拟机上的算法实现

 

5.1 枚举根节点的代价

可达性分析中,可作为GC Roots的节点主要在全局性的引用(如常量或类静态变量)与执行上下文(如栈帧中的本地变量表)中。

 

主要有两种代价:

  • 很多应用方法区就有数百兆,如果逐个检查引用,会消耗很多时间;

  • 可达性分析对执行时间的敏感还体现在GC停顿上。

 

什么是GC停顿?

 

可达性分析必须在一个一致性的快照中进行——即整个分析期间,系统就像是冻结了。否则一边进行分析,对象引用关系不断变化,分析结果的准确性就无法满足。

因此,为了保证分析结果的准确性,GC进行时,必须停顿所有的Java执行线程(称为“Stop The World”的一个重要原因)

 

 

目前主流虚拟机用的都是准确式GC,即当进行GC时,虚拟机有办法知道哪些地方存放着对象引用(不需要检查完所有执行上下文和全局的引用位置)。在HotSpot实现中,使用一组OopMap的数据结构来实现。

OopMap会在类加载完成的时候,记录对象内什么偏移量上是什么类型的数据,在JTI编译过程中,也会在特定的位置记录下栈和寄存器哪些位置是引用。这样,在GC扫描的时候就可以直接得到这些信息了。

 

5.2 安全点

 

HotSpot可以在OopMap数据结构的协助下,快速完成GC Roots枚举,

 

考虑一个问题:

如果OopMap内容变化的指令非常多,如果为每一条指令生成一个OopMap,那么将需要大量额外空间,这样GC空间成本将会变得很高。

 

SafePoint不能太少,以至于GC等待时间长;

太频繁,运行时的负荷大。

 

HotSpot的实现:

 

  • HotSpot并不会为每条指令都产生OopMap,只是在特定的位置记录了这些信息,这些位置成为“安全点”(SafePoint);

  • 程序执行时只有在达到安全点的时候才停顿开始GC;

  • 一般具有较长运行时间的指令才能被选为安全点,如方法调用、循环跳转、异常跳转等。

 

还需考虑的一个问题:

如何在GC发生时,让所有线程都“跑”到最近的安全点上再停顿下来?

 

两种方案:

  • 抢先式中断

           首先把所有线程中断,若某个线程不在安全点上,就恢复线程让它跑到安全点上。几乎没有虚拟机采用这种方式。

 

  • 主动式中断

        不直接对线程操作。需要中断线程时,设置一个GC标识,各个线程会轮询这个标志并在需要时对自己中断挂起。轮             询标志的地方和安全点是重合的。

 

5.3 安全区域

 

考虑问题:

SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint。但是,程序“不执行”的时候呢?

程序不执行指的是线程没有分配CPU时间(如Sleep、Blocked状态),这时线程没办法响应JVM的中断请求,走到“安全”的地方去中断挂起。JVM等待线程重新分配CPU时间也是不合理的。

 

解决方式——安全区域

 

安全区域是指:一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

 

在线程执行到安全区域代码时,

  • 首先标识自己进入安全区域,当这段时间里JVM发起GC,不用管这些标识为安全区域的线程了;

  • 在线程要离开安全区域时,要检查系统是否已经完成了根节点枚举,

    • 如果完成,线程继续执行;

    • 否则等待直到收到可以安全离开安全区域的信号为止。

 

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值