《深入理解JAVA虚拟机(第2版)》- 第3章 - 学习笔记

第3章 垃圾收集器与内存分配策略

3.1 概述

  1. 垃圾收集器要完成三件事情:
    • 什么样的内存需要回收
    • 什么时候回收
    • 如何回收
  2. 垃圾收集器主要关注的区域是:Java堆和方法区。因为程序计数器、虚拟机栈、本地方法栈是线程私有的,随着线程的结束所使用的内存也会被释放。而Java堆和方法区则不是这样的。

3.2 对象已死吗?

让我们先来看下垃圾收集要完成的第一件事:什么样的内存需要回收?即已死的对象需要回收,那怎么判断去判断是否存活呢?有如下两个算法:

  1. 引用计数算法
    • 这个算法的思路是给对象增加一个引用计数器,有一个地方引用了该对象则引用计数器+1,引用无效(即不引用了)的时候则-1,当引用计数器为0时,则说明这个对象已死(可回收)。
    • 该算法的缺点是:很难解决对象之间的循环依赖
  2. 可达性分析算法
    先看一张可达性分析算法的示意图:
    • 该算法的思路是将一系列称为"GC ROOTS"的对象作为起点向下搜索,所经过的路径称为引用链,某个对象到GC ROOTS没有任何引用链,则说明该对象已死(可回收)
    • Java采用的就是可达性分析算法。
    • 被称为GC ROOTS的对象有以下几种:
      1. 虚拟机栈(局部变量表)中引用的对象。
      2. 方法区中静态属性引用的对象。
      3. 方法区中常量引用的对象。
      4. 本地方法栈中JNI引用的对象。

说到引用,Java根据引用的强度,由强到弱依次分为:强引用、软引用、弱引用、虚引用。

  1. 强引用(Strong Reference)

    被强引用引用的对象不会被垃圾收集器回收。类似"Object obj = new Object() "这样的引用,即为强引用。

  2. 软引用(Soft Reference)

    被软引用引用的对象,会在内存溢出发生前,进入到第二次回收。当第二次回收完成后内存空间仍然不够,则内存溢出。

  3. 弱引用(Weak Reference)

    被弱引用引用的对象,只能活到下一次垃圾收集之前,即在下一次垃圾收集的时候被回收掉。

  4. 虚引用(Phantom Reference)

    一个对象被虚引用所引用,不会影响这个对象的生存时间 。也无法通过虚引用去获取一个对象。虚引用的作用是监控被引用对象的回收情况,让应用程序知道对象是什么时候被回收的。

说完引用,我们再回到可达性分析,这里提一个问题:经可达性分析判定为已死的对象真就无力回天了吗(再活过来)?

答案是:可以的。

一个对象被判定为真正死亡要经历2次标记1次筛选,第一次标记即可达性分析被标记为不可用,此时会发生一次筛选,筛选条件是看对象是否需要执行finalize()方法(只有那些覆盖了finalize()方法,并且重来没有执行过该finalize()方法的对象,需要执行finalize()方法)。如果对象需要执行finalize()方法,则将对象放入到F-Queue对象,虚拟机会另起一个低优先级Finalizer线程去执行对象的finalize()方法,这里需要注意一个问题:虚拟机只保证finalize()方法执行,但是不会等待finalize()方法执行完。之所以这样,是因如果finalize()方法执行的时间过长,甚至出现了死循环的情况,将会导致F-Queue中的其他对象一直等待,最终导致整个内存回收系统崩溃。第二次标记发生在finalize()方法中,如果对象在此方法中与GC ROOTS又重新建立了连接,则在第二次标记的时候会将该对象从"即将回收"的列表中移出。

我们在3.1中提到垃圾收集器主要关注的区域是Java堆和方法区,那在方法区上垃圾收集器主要回收的是:废弃的常量和无用的类。

  1. 回收废弃的常量和回收Java堆中对象非常类似
  2. 要想回收无用的类,条件就比较苛刻了,必须符合以下三点:
    • 该类的所有实例都被回收了。
    • 该类的类加载器被回收了。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,在任何地方无法通过反射的机制访问到该类的方法。

3.3 垃圾收集算法

3.3.1 标记-清除(Mark-Sweep)算法
  1. 最基础的垃圾收集算法
  2. 该算法分为两个阶段:标记和清除,先将要回收的对象都标记出来,然后再一起进行清除。
  3. 该算法的缺点:
    • 效率不高,标记和清除两个动作效率都不高。
    • 会产生不连续的内存碎片,导致为大对象分配内存的时候没有足够的连续空间,从而提前触发GC。

标记-清除算法示意图如下:

3.3.2 复制(Copying)算法
  1. 为了解决效率问题。
  2. 复制算法是将可用的内存按着容量分为两块大小相等的空间,每次只使用一块空间。当一块空间快用完的时候,将存活的对象复制到另外一块空间中,然后将上一块空间整个清除。
  3. 由于每次清理的是整块空间,则不会出现不连续的内存碎片的问题。内存分配的时候,只要移动堆顶的指针即可。
  4. 最大的问题是空间利用率不高,每次只能使用原来一半的空间,代价比较大。
  5. 大多数商业虚拟机的新生代都是采用复制算法进行回收,将新生代按着8:1:1,分为Eden、From Survivor、To Survivor三个区域。每次只使用Eden和From Survivor两个区域。当回收时,将Eden和From Survivor中存活的对象复制到To Suivivor中,如果To Survivor不够空间来存放存活下的对象,则根据分配担保将这些对象晋升为老年代。

复制算法示意图如下:

3.3.3 标记-整理(Mark-Compact)算法
  1. 可应用在老年代。
  2. 标记-整理算法是先将存活的对象标记,然后将存活的对象向一端移动,最后将端边界以外的空间进行清理。

标记-整理算法示意图如下:

3.3.4 分代收集(Generational Collection)算法
  • 当代商用虚拟机都是采用分代收集算法。
  • 分代收集算法主要是根据新生代老年代不同的特性采用各自适合的垃圾收集算法,例如:新生代采用复制算法,老年代采用标记-清除或标记-整理算法。

3.4 HotSpot算法实现

3.4.1 枚举根节点
  • Stop The World,在进行可达性分析的时候,需要所有执行线程都中断暂停,不允许在这个过程当中对象的引用关系还在发生变化,这样分析的结果就不准确了。

  • 由于目前的虚拟机都是采用的准确式GC,在Stop The World的时候,不需要检查所有执行上下文和全局的引用位置,虚拟机就应该有办法知道什么位置上是对象引用。

    那 HotSpot 是如何知道什么位置上是对象引用的呢?

    就HotSpot来说,它是通过一个叫做OopMap的数据类型来达到这个目的的。类加载的时候,HotSpot会将对象内什么位移量上是什么数据类型计算出来,JIT编译的时候,会在"特定的位置"上记录栈和寄存器中什么位置上是引用。这样GC在扫描的时候就可以直接知道这些信息了。

3.4.2 安全点
  • 什么是安全点?

    借助OopMap可以快速且准确的完成GC ROOTS枚举。可以引起OopMap变化的指令很多,如果为每个这样的指令都生成OopMap,那将要付出很大的空间成本。

    HotSpot在实现的时候也的确没有为每个指令生成OopMap,只是在"特定的位置"上记录这些信息,这些"特定的位置"称为"安全点"。

    程序在执行的时候并不是任何一个位置上都可以暂停开始GC的,只有到达安全点上才能暂停

  • 安全点的选定标准

    安全点如果太少则GC等待时间太久,安全点太多则GC的频率较高增大运行时的负荷。

    安全点的选定标准是:是否具有让程序长时间运行的特征。

    典型的是执行序列复用,例如:方法调用、循环跳转、异常跳转,具有这些功能的指令才会产生安全点。

  • GC发生的时候如何让线程都跑到安全上暂停?

    • 抢先式中断(Preemptive Suspension)

      不需要线程的执行代码主动配合,GC的时候,会将所有线程中断,如果发现线程不在安全点上,则恢复该线程,让它跑到最近的安全点上。现在已经没有虚拟机采用这种方式来中断线程去响应GC事件了。

    • 主动式中断(Voluntary Suspension)

      GC要中断线程的时候,不会直接操作线程,只是会设置一个标志,线程会轮询这个标志,当发现为true的时候则主动将自己中断。

      轮询的位置与安全点是重合,另外再加上创建对象需要分配内存的地方。

3.4.3 安全区域
  • 为什么会出现安全区域,它是要解决什么问题的?

    我们可以通过安全点来保证程序在执行的时候,在不太长的时间内就有遇到能够进入到GC的安全点。

    那些不执行的程序该怎么办呢?例如:线程处于Sleep、Blocked状态。它们没办法响应JVM的中断请求,跑到安全点上中断挂起。

    安全区域就是为了要解决这种情况的。

  • 什么是安全区域?

    **安全区域是指在一段代码片段中,引用关系不会发生改变。GC可以在这个区域的任何位置上开始。**
    
    当线程执行到安全区域的代码时,首先标识自己进入到了安全区域,这样虚拟机在开始GC的时候,就会跳过这个线程。当线程要离开安全区域的时,首先检查GC ROOTS枚举是否完成(或整个GC是否完成),如果已经完成,则继续执行。如果没有完成,需要等到可以离开安全区域的信号为止。
    

3.5 垃圾收集器

用下图来说明HotSpot的新生代和老年代各自适合使用什么垃圾收集器,以及垃圾收集器之前哪些能相互合作。

从上图我们可以看出来:

  • 适合新生代的垃圾收集器是:Serial、ParNew、Parallel Scavenge。
  • 适合老年代的垃圾收集器是:Serial Old、CMS、Parallel Old。
  • 能与CMS配合使用的垃圾收集器是:Serial、ParNew。
  • 能与Serial Old配合使用的垃圾收集器是:Serial、ParNew、Parallel Scavenge。
  • 能与Parallel Old配合使用的垃圾收集器是:Parallel Scavenge。
  • CMS与Serial Old之间的关系是:Serial Old作为CMS的后备预案,当并发收集出现Concurrent Mode Failure的时候使用。

由于下边会出现并行和并发的概念,所以在这里先进行一下说明:在垃圾收集器这个语境上下文中,对并行和并发的理解:

  • 并行:多个垃圾收集线程同时进行,此时用户线程处于等待状态。
  • 并发:用户线程与垃圾收集线程同时进行(不一定并行,可能会交替执行),用户线程和垃圾收集线程分别运行在不同的CPU上。
3.5.1 Serial收集器
  1. 是一款历史最悠久的收集器。
  2. 适用于新生代,采用复制算法。
  3. Serial收集器是一款单线程收集器,这里"单线程"的含义不单是只有一个CPU或一个线程拉执行垃圾收集,更重要的含义是垃圾收集的时候,不允许用户线程运行,直到垃圾收集完毕为止。

Serial/Serial Old收集器运行示意图如下:

3.5.2 ParNew收集器
  1. ParNew收集器是Serial收集器的多线程版本。
  2. 适用于新生代,采用复制算法。

ParNew/Serial Old收集器运行示意图如下:

3.5.3 Parallel Scavenge收集器
  1. 适用于新生代,采用复制算法
  2. 并行的多线程收集器
  3. 看起来和ParNew收集器没有什么不同,它的特别之处在于,它主要关注的是:达到一个可控制的吞吐量。
  4. 吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集时间),例如:虚拟机总共运行时间(即运行用户代码的时间 + 垃圾收集时间)是100分钟,垃圾收集用掉了1分钟,则吞吐量为99%。

Parallel Scavenge/Parallel Old收集器运行示意图如下:

3.5.4 Serial Old收集器
  1. Serial收集器的老年代版本。
  2. 采用的是标记-整理算法。
  3. 作为CMS收集器的后备预案,当并发收集的时候出现Concurrent Mode Failure的时候使用。

ParNew/Serial Old收集器运行示意图如下:

3.5.5 Parallel Old收集器
  1. Parallel Scavenge收集器的老年代版本。
  2. 采用标记-整理算法。

Parallel Scavenge/Parallel Old收集器运行示意图如下:

3.5.6 CMS(Concurrent Mark Sweep)收集器
  1. CMS收集器是以获取最短回收停顿时间为目标的收集器。
  2. 采用标记-清除算法
  3. CMS运行的四个步骤:
    • 初始标记(CMS initial mark):需要Stop The World,将与GC ROOTS有直接关联的对象标记出来。
    • 并发标记(CMS concurrent mark):是GC ROOTS Tracing的过程,即可达性分析的过程。这个过程允许用户线程并发运行。
    • 重新标记(CMS remark):需要Stop The World,修正并发标记的时候由于用户线程还在执行所导致标记变化的那一部分对象的标记记录。
    • 并发清除(CMS concurrent sweep):用户线程与垃圾收集同时进行。这个阶段由于用户线程同时运行,所以还会产生一些新的垃圾,又因为这部分垃圾没有被标记,所以这些垃圾就只能等到下一次GC的时候回收了,这部分垃圾被称为浮动垃圾(Floating Garbage)。
  4. 该算法有以下几个缺点:
    • 用户线程同时进行会占用一部分CPU资源,无法利用全部CPU资源区进行垃圾收集。
    • 会产生浮动垃圾。
    • 由于采用的是比较-清除算法,所以会产生不连续的内存碎片。

CMS收集器运行示意图如下:

3.5.6 G1(Garbage-First)收集器

这里就先简单整理了下G1运行的四个步骤:

  1. 初始标记(Initial Marking):需要Stop The World,将那些与GC ROOTS直接关联的对象标记出来。
  2. 并发标记(Concurrent Marking):GC ROOTS Tracing的过程,即可达性分析,标记处存活的对象。这个过程允许用户线程并发运行。
  3. 最终标记(Final Marking):需要Stop The World,修正并发标记阶段由于用户线程并发运行从而导致标记发生变化的那一部分对象的标记记录。
  4. 筛选回收(Live Data Counting and Evacuation):需要Stop The World,其实这个阶段也可以做到与用户线程并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程可以大幅提高收集效率。

G1收集器运行示意图如下:

(PS:这部分内容稍微有点水,以后有时间和精力了再好好梳理下吧)

3.6 内存分配与回收策略

3.6.1 对象优先在Eden上分配

大多数情况下,新创建的对象都会在Eden上分配内存。当Eden空间不足的时候,会触发一次Minor GC。

与Minor GC对应的是Major GC / Full GC,那这两个都是什么呢?

  • 新生代GC(Minor GC):发生在新生代上的GC。
  • 老年代GC(Major GC / Full GC):发生在老年代上的GC。

下面举个例子来说明下内存分配的情况:

假设场景如下:

     Java堆总共20MB,其中新生代10MB(按着8:1:1,Eden、From Survivor、To Survivor,分别为8MB、1MB、1MB),老年代10MB。
     
     有4个对象要分配内存,它们分别是:
          1、allocation1(大小:2MB)
          2、allocation2(大小:2MB)
          3、allocation3(大小:2MB)
          4、allocation4(大小:4MB)
     
     这4个对象创建的顺序是 allocation1 -> allocation2 -> allocation3 -> allocation4

下面让我们来看下从 allocation1 -> allocation2 -> allocation3 -> allocation4 顺序创建对象内存分配会有什么样的变化。

  1. 首先依次创建了allocation1、allocation2、allocation3,这三个对象总共的大小为6MB,Eden有足够的空间(总共有8MB)分配给这三个对象。

    此时Java堆的分配情况是:Eden被占用6MB,From Survivor被占用0MB,To Survivor被占用0MB,老年代被占用0MB。

  2. 创建allocation4的时候,需要4MB的空间,而Eden只剩下2MB的空间不够分配给allocation4对象了,此时会触发一次Minor GC,将存活下来的对象从Eden、From Survivor移动到To Survivor中(即给新建的对象腾出空间),但我们发现Minor GC后,allocation1、allocation2、allocation3这三个对象仍然都还存活着,而To Survivor只有1MB的空间,不够将这三个对象(allocation1、allocation2、allocation3)都存入其中,这个时候就要借助分配担保机制,将这三个对象(allocation1、allocation2、allocation3)提前存入到老年代当中。

    最终Java堆的分配情况是:Eden被占用4MB(allocation4),From Survivor被占用0MB,To Survivor被占用0MB,老年代被占用6MB(allocation1 + allocation2 + allocation3)。

3.6.2 大对象直接进入老年代
  1. 所谓大对象就是那些需要大量连续空间的对象,例如:很长的字符串或数组。
  2. 我们可以通过设置虚拟机的-XX:PretenureSizeThreshold参数,令大小超过这个设置值的对象直接进入老年代。但是这参数只对Serial、ParNew这两个虚拟机有用
3.6.3 长期存活的对象晋升为老年代

如果一个对象出生在Eden,当经历过第一次Minor GC后存活下来,并且To Survivor有足够的空间能容纳下它,则这个对象会被移动到To Survivor并将该对象的GC年龄设置为1。之后该对象在To Survivor中每熬过一次Minor GC,对象的GC年龄都会+1,当GC年龄到达一个阈值的时候(默认是15),则会晋升到老年代。

3.6.4 动态对象年龄判断

除了上边提到的GC年龄达到阈值后会晋升到老年代以后,还有一个动态对象年龄的判断标准:即在To Survivor中的对象,相同GC年龄的对象总大小如果超过了To Survivor空间的一半,则该处于该GC年龄或者大于该GC年龄的对象都将被晋升到老年代。

3.6.5 分配担保

上文中提到过Eden空间不够分配的时候,会触发一次Minor GC,而想要顺利触发Minor GC也是要满足一些特定条件。根据JDK不同版本不同,具体的要满足的条件也不同:

  1. JDK1.6 Update24 之前版本

    当老年代可用的连续空间大于整个新生代的大小则可以直接触发Minor GC。如果小于,则要检查是否允许担保失败,如果不允许,则直接触发Major GC / Full GC。如果允许,则判断下老年代的可用连续空间是否大于历次晋升到老年代的平均大小,如果小于,则触发Major GC / Full GC。如果大于则尝试触发Minor GC,由于这个大小判断是基于一个经验值,所以这次Minor GC是有可能失败的。

  2. JDK1.6 Update24 之后版本

    当老年代可用连续空间的大小大于整个新生代的大小或大于历次晋升到老年代的平均大小,即可触发Minor GC,否则将触发Major GC / Full GC。

上一篇:《深入理解JAVA虚拟机(第2版)》- 第2章 - 学习笔记

下一篇:《深入理解JAVA虚拟机(第2版)》- 第6章 - 学习笔记

相关推荐
Amagi.几秒前
对比介绍Java Servlet API (javax.servlet)和Apache HttpClient这两个库
java·servlet·apache
易雪寒1 小时前
Maven从入门到精通(二)
java·maven
ersaijun1 小时前
【Obsidian】当笔记接入AI,Copilot插件推荐
人工智能·笔记·copilot
易雪寒1 小时前
Maven从入门到精通(三)
java·python·maven
AskHarries2 小时前
maven父子工程多模块如何管理统一的版本号?
java·spring boot·后端·maven
码农娟2 小时前
hutool 集合相关交集、差集
java
Good_tea_h2 小时前
如何实现Java中的多态性
java·开发语言·python
IT毕设梦工厂2 小时前
计算机毕业设计选题推荐-项目评审系统-Java/Python项目实战
java·spring boot·python·django·毕业设计·源码·课程设计
Flying_Fish_roe3 小时前
Cassandra 和 ScyllaDB
java
梨瓜3 小时前
GC-分代收集器
java·开发语言·jvm