Fork me on GitHub

JVM(2)

一、GC 背景知识

副标题:《深入理解 Java 虚拟机》读书笔记(2)

推荐阅读:面试必问之JVM篇

1.1 GC 背景

GC:garbage collection。
程序计数器、虚拟机栈、本地方法栈这三个区域随线程生灭,所以不需要过多考虑内存的回收问题。而 Java 堆和方法区则不同,不同之处在于:

  • 一个接口的多个实现类需要的内存可能不一样;
  • 一个方法的多个分支需要的内存可能不一样。

只有程序运行期间时才能知道会创建哪些对象,这部分内存分配是动态的,是垃圾收集器所关注的部分。

1.2 四种引用类型

  • 强引用,Strong Reference。类似Dog dog = new Dog();
  • 软引用,Soft Reference。只有内存不足时,JVM 才会回收该对象。属于java.lang.ref.SoftReference,一般用来实现缓存(如图片缓存、网页缓存,有用但非必须)。
  • 弱引用,Weak Reference。当 JVM 进行 GC 时,无论内存充足与否,都会被回收的对象。属于java.lang.ref.WeakReference,一般用来在回调函数中防止内存泄漏。
  • 虚引用,Phantom Reference。仅用在,这个对象呗收集器回收时收到一个系统通知。
级别 回收时机 用途 生存时间
从来不会 对象的一般状态 JVM 停止运行时终止
在内存不足时 联合 ReferenceQueue 构造有效期短/占内存大/生命周期长的对象的二级高速缓冲器(内存不足时清空此缓冲器) 内存不足时终止
在垃圾回收时 联合 ReferenceQueue 构造有效期短/占内存大/生命周期长的对象的一级高速缓冲器(系统发生 GC 时清空此缓冲器) GC 运行后终止
在垃圾回收时 联合 ReferenceQueue 来跟踪对象呗垃圾收集器回收的活动 GC 运行后终止

1.3 对象是否是垃圾的判断标准

释放对象的根本原则是该对象不再被引用。常见的几个判断方法,引用计数算法、可达性分析算法:

1.3.1 引用计数算法

  • 为对象添加一个引用计数器,每当有一个地方引用它,计数器加一;每当一个引用失效,计数器减一;任何时刻计数器为 0 的对象就是不可能再被使用的。
  • 评价:实现简单、判定效率高,但不乏解决对象间相互循环引用的问题,所以此算法不被当下的 JVM 采用。
1
2
3
4
5
6
7
// 对象间相互循环引用	
 public void buidDog(){
  Dog newDog = new Dog();
Tail newTail = new Tail();
newDog.tail = newTail;
newTail.dog = newDog;
}
  • newTail 拿着对 newDog 的引用,newDoy 拿着对 newTail 的引用,垃圾回收管理似乎始终无法回收这两个实际已经不再需要的对象。但虽然两个引用都是强引用,但垃圾收集器除了看强引用关系外,还会看对象是否被至少一个 GC roots 对象直接或间接引用。

“It’s important to note that not just any strong reference will hold an object in memory. These must be references that chain from a garbage collection root. GC roots are a special
class of variable that includes
Temporary variables on the stack (of any thread)
Static variables (from any class)
Special references from JNI native code”。

1.3.2 可达性分析算法

  • 通过一系列“GC Root”的对象作为起始点,从这些结点向下搜索(搜索路径称为引用链),当一个对象到 GC Roots 没有任何引用链相连时(即不可达时),证明此对象时不可用的。
  • [重点]哪些对象是 GC Roots ?
    1. 虚拟机栈的本地变量表中引用的对象,即局部变量;
    2. 方法区中类静态属性引用的对象;
    3. 方法区中常量引用的对象;
    4. 本地方法栈中 JNI(即 native 方法)引用的变量
    5. 官方文档:Garbage Collection Roots

1.3.3 待回收对象在 finalize()方法复活

  • 首先,此方法强烈不建议使用。

  • 某对象在标记为不可达后,会进行第一次标记,并筛选是否有必要执行 finalize()方法。

    • Java 中假定 finalize 的工作原理为:一旦垃圾回收器准备回收内存而释放对象所占内存的时候,会先调用该对象的 finalize 方法,然后在下一次再需要垃圾回收的时候才真正的回收对象!
    • finalize()的作用:finalize 用于在 GC 发生前事先调用去回收 JNI 调用中申请的特殊内存,下次 GC 发生时候保证 GC 后所有该对象的内存都释放了。
    • finalize 一般使用在使用了 JNI 的情景下,需要在 finalize 中调用 native 方法释放特殊内存,一般情况下不要使用 finalize。
  • 如果没有必要执行,会被 GC 回收;

  • 如果判定有必要执行,那么对象会进入 F-Queue 队列中,GC 将对队列中的对象进行二次标记,如果对象未能摆脱队列,那么将被彻底回收。

    • 摆脱方式就是在二级标记检查执行 finalize()方法时,与引用链上任何一个对象建立关联即可。

1.4 内存泄漏

存在一些被分配的对象,具有以下两个特点:

  1. 对象是可达的;
  2. 对象是无用的。

造成的结果就是存在一些对象不会被 GC 回收,但占用了内存。

1.4.1 System.gc()语句

特点:

  • 运行了System.gc()方法,但不保证 JVM 的垃圾收集器一定会执行。
  • GC 的线程优先级别较低。
  • HotSpot 将 GC 分解为一系列的小步骤,能够通过平缓的方式释放内存。

1.5 方法区的 GC

  • 这里的方法区即 HotSpot 中的永久代;
  • 永久代的回收效率较低;
  • 永久代的垃圾收集主要包括两个部分:废弃常量和无用的类;前者类似于回收 Java 堆中的对象;后者判定无用类较繁琐,需要同时满足 3 个条件:
    1. 该类的所有实例都已经被回收;
    2. 加载该类的 ClassLoader 已经被回收;
    3. 该类对应的 java.lang.Class 对象没有在任何地方呗引用,无法再任何地方通过反射访问该类的方法。
  • 判定了是无用的类,也不是必然被回收。
  • 在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
    • 由用户自定义的类加载器所加载的类是可以被卸载的。

二、GC 算法思想及实现

2.1 常见的 GC 算法

常见的 GC 算法有:标记-清除算法、复制算法、标记-整理算法、分代收集算法。

2.1.1 标记-清除算法

  • 思想:首先标记处所有需要回收的对象,标记完成后统一回收。
  • 特点:
    • 两个过程效率低;
    • 清除后产生大量内存碎片,如果碎片过多会导致以后分配较大对象时,因为无法找到足够的连续内存引起二次 GC 动作。

2.1.2 复制算法

  • 思想:将可用内存标记为两块,每次仅使用一块,另一块用来复制(如 hotSpot 将 Eden 区和 Survivor 区*2 大小设为 8:1:1;每次留出 1 份 Survivor 区不使用)。需要 GC 时,将使用区中的存活对象复制到复制区上,然后将已使用的内存空间一次性清理掉。
  • 特点:
    • 实现简单、运行高效,内存使用占约90%;
    • 适合复制量不大(对象迭代快、存活对象不多)的新生代;
    • 如果一份 Survivor 区大小不足,那么需要依赖其他内存(指老年代)进行分配担保。

2.1.3 标记-整理算法

  • 老年代适用;
  • 思想:首先标记待回收的对象,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

2.1.4 分代收集算法

  • 思想:根据对象存活周期的不同将内存划分为几块。一般将 Java 堆分为新生代和老年代,然后可以按块选择不同的 GC 算法。

2.2 GC 算法的具体实现

枚举 GC Roots

  • GC Roots 主要在全局性引用(常量、类静态属性)和执行上下文(栈帧的本地变量表)中。枚举过程需要关注两个问题:检查引用的耗时问题和枚举根节点的 GC 停顿问题上。

检查引用

  • 保守式 GC:JVM 不知道内存某个位置上的数据是引用类型还是整型还是别的什么类型。在 GC 时,判断数字是不是指向堆的指针,涉及到上下边界的检查、对齐检查等。缺点:如果有疑似指针指向对象,那么本该回收的对象就会逃过检查;因为不能断定某个数据是指针,所以数值不能修改,即不能移动对象,但添加句柄(即中间层)可以同时做到保守式 GC 和移动对象的功能。
  • 半保守式 GC:JVM 在栈上不记录类型信息,在对象上记录类型信息。特点:支持部分对象的移动,但仍然有“疑似指针”的问题。
  • 准确式 GC:JVM 能够判断所有位置上的数据是不是指向 GC 堆里的引用,包括活动记录(栈+寄存器)里的数据。
  • 在HotSpot中,使用 OopMap 的数据结构来让 JVM 获知存放对象引用的地方。这样就不用遍历整个内存去查找了,也不会将所有指令都生成 OopMap ,只会在安全点上生成 OopMap,在安全区域上开始 GC。
  • 扩展阅读:JVM参数设置、分析

OopMap

  • 可以把 OopMap 简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。OopMap 就是一个附加的信息,告诉你栈上哪个位置本来是个什么类型的东西。
  • 这个信息是在 JIT 编译时跟机器码一起产生的(会在特定的位置记录下栈和寄存器中哪些位置是引用)。因为只有编译器知道源代码跟产生的代码的对应关系。
  • 这样,GC 在扫描时就可以直接得知这些信息了。
  • 附:这些特定的位置就是安全点。主要在:
    • 循环的末尾
    • 方法临返回前/调用方法的 call 指令后
    • 可能抛异常的位置

安全点 safepoint

  • 浅显的理解:安全点,意思是当前 JVM 的状态是安全的,如果有需要,可以在这个位置暂停。
  • 每个方法可能会有好几个 OopMap ,就是根据 safepoint 把一个方法的代码分成几段,每一段代码一个 OopMap ,作用域自然也仅限于这一段代码。
  • 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的 OopMap 就会包含多条记录。
  • 让所有线程停顿在安全点,有两种策略:抢占式(不采用)和主动式。
  • 安全区域:在一段代码中,引用关系不会发生变化,整个区域发生 GC 都是安全的。

GC 停顿

  • 可达性分析时,整个执行系统最好被冻结在某个时间点上,以免发生分析过程中对象引用关系还在不断变化的情况。所以在 GC 进行时,必须停顿所有 Java 执行线程。

三、HotSpot 的收集器

HotSpot的收集器有大约7款,用于新生代:Serial、ParNew、Parallel Scavenge;用于老年代:CMS、Serival Old、Parallel Old;跨界G1。

3.1 Serial 收集器

  • 历史最早收集器;
  • 新生代采取复制算法、老年代采用标记-整理算法。
  • 单线程,即垃圾回收时,必须暂停其他所有的工作线程,直到它收集结束。称为“stop the world”。
  • 特点:简单高效,是 client 模式下的默认新生代收集器。

3.2 ParNew 收集器

  • 是 Serival 收集器的多线程版本,即多线程收集。
  • 新生代采取复制算法、老年代采用标记-整理算法。
  • 只有 ParNew 能与 CMS 收集器(是第一款真正意义上的并发收集器,可以边制造垃圾边收集垃圾)配合工作,所以是 Server 模式下首选的新生代收集器。

3.3 Parallel Scavenge 收集器

  • 跟 ParNew 类似,但关注点不同;
  • 其他收集器关注点在于尽可能地缩短垃圾收集时用户线程的停顿时间;Parallel Scavenge 收集器关注一个可控的吞吐量(Throughput):CPU用于运行用户代码的时间与CPU总消耗时间的比值。
    • 前者适合较多交互的程序,能够提升用户体验;后者可以高效率利用 CPU 时间,能够尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
  • 虚拟机运行 100 分钟,GC 耗时 1 分钟,那么吞吐量就是 99%。
  • 提供两个参数 MaxGCPauseMillis 和 GCTimeRatio 参数。
    • MaxGCPauseMillis 控制最大垃圾收集停顿时间。过大引起单次 GC 耗时长,过小引起 GC 次数增加,吞吐量下降。
    • GCTimeRatio 直接设置吞吐量大小。介于 0 到 100 之间(默认 99),运行客户代码时间/垃圾回收时间,若参数设为19,则最大 GC 时间占总时间的 5%。

3.4 Serial Old 收集器

  • Serial 收集器的老年代版本;
  • 主要给 Client 模式下的虚拟机使用;如果在 Server 模式下,有两大用途:
    • 与 Parallel Scavenge收集器搭配使用;
    • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure时使用。

3.5 Parallel Old 收集器

  • Parallel Scavenge 收集器的老年代版本;
  • 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

3.6 CMS 收集器

  • Concurrent Mark Sweep
  • 目标为获得最短回收停顿时间,应用在很大部分的互联网站或者 B/S 系统的服务端上。
  • 基于标记-清除算法实现。清理过程分为四步:
    • 初始标记:依然需要“STW”,标记 GC Roots 能直接关联到的对象,速度快。
    • 并发标记:进行 GC RootsTracing。
    • 重新标记:依然需要“STW”,修正并发标记期间因用户程序继续运作导致的标记产生变动的那一部分对象的标记记录。耗时比初始标记时间稍长,但比并发标记时间短。
    • 并发清除。
  • 特点:
    • 并发收集、低停顿;
    • 对 CPU 资源非常敏感,如果 CPU 多于 4 个,GC 线程不少于 25%,并且随 CPU 数量的增加而下降。但如果 CPU 个数不足 4 个时,GC 线程数量对用户程序的影响可能变得很大。为了应对这种情况,JVM 提供了一种“增量式并发收集器”,但因效果差被标记为 deprecated ,不提倡使用。
    • 无法处理浮动垃圾,此处的浮动垃圾主要指“并发清除”阶段用户产生的垃圾,只能留待下一次 GC 时再清理掉。
    • 因为采用的标记-清除算法,所以会产生大量空间碎片。

3.7 G1 收集器

  • 成熟、商用级别的收集器,面向服务端应用。

  • 特点:并行与并发、分代收集、空间整合、可预测的停顿。

    • 并行与并发:使用多个 CPU 来缩短 STW 停顿的时间,GC 时仍能够运行 Java 程序。
    • 分代收集:能够独立管理整个 GC 堆。
    • 空间整合:运行期间不会产生内存碎片,收集之后能提供规整的可用内存。
    • 可预测的停顿:建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不超过 N 毫秒。
    • 重新划分 Java 堆,新老生代的隔离不再是物理隔离(新生代不再需要物理上连续),而是逻辑隔离。
    • 内存“化整为零”的漏洞:即回收新生代时也需要扫描老生代,可以通过在GC roots 枚举范围中加入 Remembered Set 来解决。
  • 不计算维护 Remembered Set 的操作,G1 运作步骤:

    1. 初始标记
    2. 并发标记
    3. 最终标记
    4. 筛选回收

3.8 GC 日志

  • 最前面的数字是 GC 发生的时间(自 JVM 启动以来经过的秒数);
  • “GC”和“FULL GC”,如果有“FULL”说明这次 GC 发生了 STW;
  • “3324K -> 152K(3712K)”:方括号内的表示“GC 前该内存区域已使用容量->GC 后该内存区域已使用容量(该内存区域总容量)”;方括号外的表示“GC 前 Java 堆已使用容量-> GC 后 Java 堆已使用容量(Java 堆总容量)”。
  • “0.0025925 secs”,指该区域内 GC 耗时。

四、内存如何分配

内存分配规则并不是百分百固定的,分配细节取决于采用的哪一种垃圾收集器组合,还取决于 JVM 中与内存相关的参数的设置。
以下是一些最普遍的内存分配规则:

4.1 对象优先在 Eden 分配

  • 大多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时,JVM 将发起一次 Minor GC。
    • Minor GC,即新生代 GC,发生频繁,回收速度较快;
    • Major GC/Full GC,即老年代 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC。

4.2 大对象直接进入老年代

  • 大对象多指很长的字符串以及数组。会给 JVM 带来不少麻烦,比如提前触发 GC 等。

4.3 长期存活的对象将进入老年代

  • JVM 给每个对象定义了一个对象年龄(Age)计数器。
  • 如果对象在Eden出生,并经历一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且 Age 设为1;对象在 Survivor 区中没熬过一次 Minor GC ,Age加一,当年龄增加到一个阈值(默认15),会被晋升到老年代。

4.4 动态对象年龄判定

  • 除了配置年龄晋升阈值外,还可以适应不同程序的内存状况动态调节。
  • 比如:如果 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,那么大于或等于该年龄的对象直接进入老年代。

4.5 空间分配担保

  • Minor GC 前,JVM 会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
    • 如果这个条件成立,那么 Minor GC 可以担保是安全的。
    • 如果不成立,JVM 会查看 HandlePromotionFailure 设置值是否允许担保失败(此 Handle 在 JDK 6 后已不再使用)。
      • 如果允许担保失败(即冒风险),那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次有风险的 Minor GC;如果小于,或者不允许担保失败,那么要改为一次 FULL GC。
-------------The End-------------