深入理解Java虚拟机(2)-聊一聊JVM的垃圾回收

192 阅读16分钟

带着几个问题我们进行深入分析

  • 那些内存需要回收
  • 什么时候回收
  • 如何回收

一、那些内存需要回收

 对于JVM的内存模型中,程序计数器,虚拟机栈和本地方法栈这三个区域为线程私有,随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来时就是已知的。当方法结束或者线程结束时,内存自然就跟随者回收了。

 而对于Java堆和方法去这两个区域则有这很显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不通条件分支锁需要的内存也可能不一样,只有处于运行期间。我们才能知道程序究竟创建了那些对象,创建了多少个对象,这部分内存的分配和回收是动态的。所以我们进行讨论的内存回收也是这两部分方法区

二、什么时候回收

 对于回收来说,和我们生活中卖书的情景很类似,假如我们有一个书架,书架上放置的书籍数量有限。当我们从高中升到大学,此时一些小学,初中,高中的课本我们会有一些我们不在会用到,为了给大学的课本可以放在书架上预留一些空间。我们会选择卖掉不用的课本,来释放空间。程序亦如此,JVM的内存空间是有限的,我们只能保留满足程序运行需要的内存,而释放掉我们不用的内存。

 当我们上大学时,我们为了减轻家里的压力,可能会在课余时间,去选择做高中数学的家教,此时我们要保留高中数学课本,所以之前的课本也不能选择全部卖掉,要有选择的进行保留。那么对于计算机程序来说,我们该如何选择呢?

  • 引出对象无用的判断算法:引用计数算法可达性分析算法

a.引用计数算法

描述:在对象中添加一个引用计数器,每当有地方应用它时,计数器值+1;当引用失效时,计数器-1;当计数器为0时,此时对象已死。那么我们JVM是不是用这个方法做对象已死判断的呢,答案是否定的。下面我们看一个例子。

public class ReferenceCountingGC {

    public Object instance = null;
    
    public static void main(String[] args) {

        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        System.gc();
    }
}

请看 objA.instance = objB; objB.instance = objA;这两个对象相互引用,所以引用计数都不为0,引用计数算法无法回收他们,但此时这两个对象实际上是不可能在访问的。 运行结果: 在VM options加参数:-XX:+PrintGC

[GC (System.gc())  3944K->720K(251392K), 0.0007575 secs]
[Full GC (System.gc())  720K->581K(251392K), 0.0032451 secs]

从运行结果可以看出,虚拟机并没有因为相互引用而放弃回收他们,这也说明了虚拟机不是用引用计数算法来判断对象是否存活的。

b.可达性分析算法

java是通过可达性分析算法来判断对象是否存活的。这个算法的基本思路就是通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链 如果某个对象到GC Roots之间没有任何的引用链相连,则证明对象不可能在使用。

  • 在java体系中,可作为GC Roots的对象有下面几种
    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆中使用的参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,譬如字符串常量池里的引用。
    • 在本地方法栈中JNI(通常说的Native方法)引用的对象
    • JAVA虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象(NUllPointException,OutOfMemoryError)等,还有系统类加载器。
    • 所有被同步锁持有的对象。
    • 反应Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等。
    • 还有因为局部回收,一些临时性的对象加入。
  • 引用概念扩充:当我们把不用的书籍都卖掉还无法满足大学课本全部摆在书架上 我们二次筛选不常用的卖掉。由强到弱。
    • 强引用:Object o = new Object(),引用关系还在就永远不会回收掉被引用的对象
    • 软引用:有用但非必须,二次回收范围内
    • 弱引用:非必须,生存到下一次垃圾收集为止。weakReference
    • 虚引用:唯一作用,为了能在这个对象被收集回收时收到一个系统通知。

我们在回来讨论对象什么时候回收的呢?

  • 根据上面我们可以得到的结论是: 对象无用的的判断是引用链不可达。
  • 回收的时间点对象已死:引用链不可达对于JVM来说也不是“非死不可的”,这时候还处于“缓刑”阶段,真正的死亡,至少要经历两次标记过程:引用链不可达 是第一次标记筛选,第二次筛选是此对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况视为“没有必要执行”。则对象已死
  • 如果有必要执行finalize(),在这个过程中可以重新关联引用链上的对象,此时则不会被回收,否则对象判断死亡。不建议使用.

三.如何回收

1.分代收集理论
  • 弱分代之说:绝大对象都是朝生夕灭的。
  • 强分代之说:熬过越多次垃圾收集过程的对象就越难消亡 推出一致设计原则: 收集器应该将Java划分出不同的区域,然后将回收对象一局其年龄分配到不通区域。

2.新生代和老年代(参考下面分标记-复制算法)

  • 新生代和老年代:新生代又被划分成Eden 空间、 From Survivor 和 To Survivor 三块区域。 我们把Eden : From Survivor : To Survivor 空间大小设成 8 : 1 : 1 ,对象总是在 Eden 区出生, From Survivor 保存当前的幸存对象, To Survivor 为空。一次 gc 发生后: 1)Eden 区活着的对象 + From Survivor 存储的对象被复制到 To Survivor ;
    1. 清空 Eden 和 From Survivor ;
    1. 颠倒 From Survivor 和 To Survivor 的逻辑关系: From 变 To , To 变 From 。可以看出,只有在 Eden 空间快满的时候才会触发 Minor GC 。而 Eden 空间占新生代的绝大部分,所以 Minor GC 的频率得以降低。当然,使用两个 Survivor 这种方式我们也付出了一定的代价,如 10% 的空间浪费、复制对象的开销等。

进入老年代的情况:

  • 大对象直接进入老年代 参数配置:-XX:PretenureSizeRhreshold 对象超过这个值直接进入老年代,为了减少在新生代高额的复制开销
  • 长期存活的对象进入老年代 参数配置:-XXMaxTenuringThreshold

元空间/永久代:

  • 用于方法区的存储, java8 用元空间使用本地内存代替 永久代 之前永久代是占用堆内存。

3.垃圾回收算法

 卖书的话我们可以选择两种方式: 一种是我们主动把书送到废品收货站去,另一种则是收货老板主动上门,而且对于书的新旧程度不同我们可以卖做新书和旧书进行分类。对于垃圾回收选择的是后者。

 我们先来分析下垃圾回收的几种算法,看一看对于JVM来说是如何进行回收分类的。

  • a.标记-清除算法(Mark-Sweep):分两个阶段第一个节点标记,第二个阶段清除。标记判断算法,可达性分析算法。 回收前的状态:

回收后的状态:

缺点:

  • 第一个执行效率不稳定,如果java堆中包含大量对象,而且其中大部分是需要回收的,这时必须要进行大量标记和清除动作。导致标记和清除过程的执行效率都随着对象数量增长而降低;

  • 第二个内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片过多可能导致当以后再程序运行过程中需要分配比较大的对象时无法找到足够连续的内存而不得不触发另一次垃圾收集动作。

  • b.标记复制算法:

    • 为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了“半区复制”垃圾回收算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,这一块内存用完了,就将还存活着的对象复制到另一块上面,然后在把已使用过的内存一次性清理掉。
    • 如果内存中多数的对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,运行高效。不过这样做的缺陷也显而易见,代价是将可用内存缩小为了原来的一半,造成空间浪费。 回收前状态:

回收后状态:

  • c.标记整理算法:
    • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。这种被形象描述为 “Stop The World”
    • 如果像标记-清除那样完全不考虑移动和整理内存的话,碎片化的问题就只能依赖更为复杂的内存分配器和内存访问器来解决。 回收状态的状态:

回收状态后的状态:

4.垃圾收集器

HotSpot虚拟机垃圾收集器

a.Serial收集器

特点:

  • 新生代收集器
  • 单线程 Stop The World
  • 单CPU环境来说,Serial由于没有线程交互的开销,可以很高效的进行垃圾收集动作
  • 适用于运行在客户端模式下的虚拟机
b.ParNew 收集器

特点:

  • 相比Serial收集器,支持多线程并行收集
  • 其余与Serial基本一致,单CPU下Serial效率更高一些
c.Parallel Scavenge收集器 ([ˈpærəlel] [ˈskævɪndʒ])

特点:

  • 复制算法
  • 并行收集
  • 相比于ParNew,优点:达到一个可控的吞吐量,吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)
  • 吞吐量优先收集器
  • 自适应调节策略 参数:
  • 最大垃圾收集停顿时间:-XX:MaxGCPauseMillis(>0的毫秒)
  • 吞吐量大小:-XX:GCTimeRatio(0-100)
  • 开关参数:-XX:UseAdaptiveSiezPolicy 当这个参数开启后,不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象的大小(-XX:PretenureSizeThreshold)等参数。根据上面两个参数-XX:MaxGCPauseMillis和-XX:GCTimeRatio设定一个目标,具体参数的调节工作由虚拟机完成。 并行:并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此用户线程是处于等待状态 并发:并发描述的是多条垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器占用了一部分系统资源,此时应用程序的处理吞吐量受到一定影响。
d.Serial Old收集器

特点:

  • 单线程收集
  • Serial 的老年代版本
  • 用途:客户端模式下HotSpot虚拟机的使用;JDK5之前的版本和Parallel Scavenge 收集器搭配使用;CMS收集器发生失败时预案
e.Parallel Old收集器

Java8默认组合收集器示意图

特点:

  • 老年代
  • 多线程并行收集
f.CMS收集器

收集过程:

  • 初始标记:Stop The World, 标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程,整个过程耗时较长但不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记: 修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的耗时 在初始化和并发标记之间。
  • 并发清除: 清理删除掉标记阶段判断的已死对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。 特点:
  • 标记-清除算法
  • 并发收集-低停顿
  • 缺点:
    • 对处理器资源非常敏感,会占用一部分线程而导致程序变慢,吞吐量降低 占用25%以上处理器运算资源
    • 无法处理浮动垃圾,有可能出现 Concurrent Mode Failure 而导致另一次Stop The World 的Full GC
    • 基于标记-清除算法 会产生空间碎片
g.G1收集器

特点:

  • 面向服务端应用的垃圾回收器
  • 设计目标替换CMS收集器,在JDK9 为默认收集器
  • 之前的收集器垃圾收集目标要么是整个新生代(Minor GC),要么是整个老年代(Major GC),要么是整个java堆(Full GC),而G1采用新模式,它可以面向堆内存进行任何部分来组成回收集,进行回收,衡量标准不再时属于哪个分代,而是那块内存中存放垃圾数量最多,回收收益最大,这就是Mixed GC模式
  • G1不在坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域,每个Region都可以根据需要,扮演Eden 空间、Survivor空间 或者老年代空间。收集器能够对扮演不同角色的Region采用不通的策略去处理。这样无论是新创建的对象还是存活一段时间、熬过多次收集的旧对象都能获取到很好的收集效果。
  • 特殊的Humongous区域用来存储大对象,超过region容量的一半。通过-XX:G1HeapRegionSize设定1MB~32MB ,大对象存储空间不够,可以用连续多个region存储 收集过程:
  • 初始标记: 标记GC Roots能关联的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配对象。这个阶段需要停顿,但时间很短,而且借用进行Minor GC的时候同步完成,这个时候没有额外的停顿。
  • 并发标记: 从GC Roots 进行可达性分析,递归扫描整个堆里的对象图,找出回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象扫描完,还要重新处理SATB记录的并发时有引用变动的对象
  • 最终标记: 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后少量的SATB记录
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region存活对象复制到Region中,在清理掉整个 旧Region的全部空间。这里的擦偶作涉及存活对象的移动是必须暂停用户线程的,由多条收集器线程并行完成的。
低延迟垃圾回收器
Shenandoah收集器和ZGC收集器待补充

方法区的内存回收

主要回收两部分内容:废弃的常亮 和不在使用的类型 判定一个常亮是否废弃:

  • 该类所有的实例都已经回收,也就是java堆中不存在该类及任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

学习书籍: 《深入理解JAVA虚拟机 第2版》 《深入理解JAVA虚拟机 第3版》

第三版在本块内容加了低延迟的垃圾收集器Shenandoah收集器和ZGC收集器,更新了java 8 方法区中使用元空间代替永久代。 其他内容大体一致。

熊猫笔记邮箱: panda_nodes@163.com