转载

一夜搞懂 | JVM GC&内存分配

时代发展到现在,如今的内存动态分配与内存回收技术已经相当成熟,一切看似进入了“自动化”时代,不免发出疑问:"为啥我们还要了解垃圾收集和内存分配?"

答案很简单,当需要排查各种内存溢出/泄漏问题的时候,当垃圾收集成为系统达到更高并发量的瓶颈的时候,我们必须对"自动化"技术进行必要的监控和调节。

所以,我们要了解下 GC &内存分配,为工作中或者是面试中实际的需要打好基础。

二.核心知识点归纳

2.1 对象存活判定算法

在了解对象存活的判定之前,我们先来了解下四种引用类型

  • 强引用 StrongReference
  • 具有强引用的对象不会被 GC
  • 即便内存空间不足, JVM 宁愿抛出 OutOfMemoryError 使程序异常终止,也不会随意回收具有强引用的对象
  • 软引用 SoftReference
  • 只具有软引用的对象,会在 内存空间不足 的时候被 GC ,如果 回收之后内存仍不足,才会抛出 OOM 异常
  • 软引用常用于描述 有用但并非必需 的对象,比如实现内存敏感的高速 缓存
  • 弱引用 WeakReference
  • 只被弱引用关联的对象, 无论当前内存是否足够都会被 GC
  • 强度比软引用更弱,常用于描述 非必需 对象
  • 虚引用 PhantomReference
  • 仅持有虚引用的对象,在任何时候都可能被 GC (和弱引用一样)

  • 主要作用是为了垃圾收集器回收时收到一个系统通知( PhantomRefernece 类实现虚引用)

  • 与弱引用的区别:不同之处在于弱引用的 get 方法,虚引用的 get 方法始终返回 null , 弱引用可以 使用 ReferenceQueue , 虚引用必须 配合 ReferenceQueue 使用

  • 必须和 引用队列ReferenceQueue )联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用 加入到与之关联的引用队列

    (想要了解 虚引用详细用法 的读者,可以看下这篇文章: 强软弱虚引用,只有体会过了,才能记住 )

2.1.1 引用计数算法

定义:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的

然而在主流的Java虚拟机里未选用引用计数算法来管理内存,主要原因是它难以解决对象之间 相互循环引用 的问题,所以出现了另一种对象存活判定算法

//相互循环引用的DEMO
public class ReferenceCountingGC {
    public Object instance = null;
    
    private static final int _1MB = 1024 * 1024;
    
    /**
     *  这个成员属性的意义是占点内存,以便在GC日志中看清楚是否有回收过
     */ 
    private byte[] bigSize =new byte[2 * _1MB];
    
    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        
        objA = null;
        objB = null;
        
        System.gc();
    }
}

复制代码

2.1.2 可达性分析法

定义:通过一系列被称为『 GC Roots 』的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链 ,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的

可作为GC Roots的对象:

  • 虚拟机栈中引用的对象,主要是指栈帧中的 本地变量表
  • 本地方法栈中 Native 方法引用的对象
  • 方法区中 类静态属性 引用的对象
  • 方法区中 常量 引用的对象
  • JVM 内部的引用(基本数据类型对应的 Class 对象)
  • 所有被同步锁( synchronized 关键字)持有的对象
  • 反映 JVM 内部情况的 JMXBeanJVMTI 中的注册的回调、本地代码缓存等
一夜搞懂 | JVM GC&内存分配

Q:可达性分析算法中被判定不可达的对象真的被判『死刑』了吗?

A:在可达性分析算法中被判定不可达的对象还未真的判『死刑』,一共至少要经历两次标记过程:

GC Roots

判断对象是否有必要执行 finalize() 方法;若被判定为有必要执行 finalize() 方法,之后还会对对象再进行一次筛选,如果对象能在 finalize() 中重新与引用链上的任何一个对象建立关联,将被移除出“即将回收”的集合。

一夜搞懂 | JVM GC&内存分配

引申:有关 方法区GC ,可分成两部分

  • 废弃常量与回收 Java 堆中的对象的 GC 很类似,即在任何地方都未被引用的常量会被 GC

  • 无用的类

需满足以下三个条件才会被 GC

A.该类所有的实例都已被回收,即Java堆中不存在该类的任何实例;

B.加载该类的 ClassLoader 已经被回收;

C.该类对应的 java.lang.Class 对象没在任何地方被引用,即无法在任何地方通过 反射 访问该类的方法。

2.2 垃圾收集算法

前文讲了 JVM 会回收哪些对象,下文笔者将探究 JVM 如何回收这些对象

2.2.1 分代收集理论

Q1:三个假说是什么?

  • 弱分代假说:绝大多数对象都是 朝生夕灭
  • 强分代假说:熬过越多次垃圾收集过程的对象就 难以消亡
  • 跨代引用假说:跨代引用相对于同代来说 仅占极少数 (存在引用关系的对象应该倾向于同时生存或者同时消亡的,例如某个新生代被老年代所引用,该引用会使新生代对象在收集时同样存活,进而进入老年代)

在新生代上建立一个全局的数据结构( 记忆集 ),将老年代划 分成若干小块 ,标识出老年代哪一块内存存在跨代引用, Minor GC 时,在跨代引用的内存里的对象才会加入到 GC Roots 进行扫描

Q2:垃圾收集器一致的设计原则

Java

Q3:如何根据各个年代的特点选择算法呢?

  • 新生代:大批对象死去,只有少量存活。使用『复制算法』,只需复制少量存活对象即可
  • 老年代:对象存活率高。使用『标记—清理算法』或者『标记—整理算法』,只需标记较少的回收对象即可

这三种算法,笔者将在下文为您详细解析

2.2.2 复制算法

  • 定义:把可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用尽后,把还存活着的对象『复制』到另外一块上面,再将这一块内存空间一次清理掉
  • 优点:每次都是对整个半区进行内存回收, 无需考虑内存碎片 等复杂情况,只要移动堆顶指针,按顺序分配内存即可, 实现简单,运行高效
  • 缺点:每次可使用的内存缩小为原来的一半, 内存使用率低
一夜搞懂 | JVM GC&内存分配
  • Appel 式回收

分为一块较大的 Eden 空间和两块较小的 Survivor 空间,在 HotSpot 虚拟机中默认比例为8:1:1。每次使用 Eden 和一块 Survivor ,回收时将这两块中存活着的对象一次性地复制到另外一块 Survivor 上,再做清理。可见只有 10% 的内存会被“浪费”,倘若 Survivor 空间不足还需要依赖其他内存(老年代)进行分配担保

2.2.3 标记-清除算法

  • 首先『标记』出所有需要回收的对象,然后统一『清除』所有被标记的对象
  • 是最基础的收集算法
  • 缺点:
  • 『标记』和『清除』过程的效率不高
  • 空间碎片太多。『标记』『清除』之后会产生大量 不连续的内存碎片 ,可能会导致后续需要分配较大对象时,因无法找到足够的连续内存而提前触发另一次 GC ,影响系统性能
一夜搞懂 | JVM GC&内存分配

2.2.4 标记-整理算法

  • 首先『标记』出所有需要回收的对象,然后进行『整理』,使得存活的对象都向一端移动,最后直接清理掉端边界以外的内存
  • 优点:即没有浪费50%的空间,又不存在空间碎片问题,性价比较高
  • 缺点: 移动 在老年代每次回收都存在大量对象存活区域,必须 暂停用户应用程序才能进行Stop The World
  • 一般情况下,老年代会选择标记-整理算法。
一夜搞懂 | JVM GC&内存分配

2.2.5 和稀泥式

解决方法:大部分时间使用标记-清除算法,当内存空间的碎片程度影响到内存分配,再使用标记-整理算法进行收集

2.3 HotSpot 算法实现&垃圾回收器

接下来介绍如何在 HotSpot 虚拟机上实现对象存活判定算法和垃圾收集算法,并保证虚拟机高效运行

2.3.1 枚举根节点

主流 JVM 使用的都是 准确式 GC ,在执行系统停顿之后无需检查所有执行上下文和全局的引用位置,而是通过一些办法直接 获取到存放对象引用的地方 ,在 HotSpot 中是通过一组称为 OopMap 的数据结构来实现的,完成类加载后会计算出对象某偏移量上某类型数据、 JIT 编译时会在 特定的位置 记录栈和寄存器中是引用的位置。这样 GC 在扫描时就可直接得知这些信息,并快速准确地完成 GC Roots 的枚举

2.3.2 安全点

上述“特定的位置”被称为安全点,即程序执行时并非在所有地方都停顿执行 GC ,只在到达安全点时才暂停,降低 GC 的空间成本

  • 安全点的选定标准:可让程序 长时间执行 的地方,如方法调用、循环跳转、异常跳转等具有指令序列复用的特征

  • 使所有线程在 最近的安全点上再停顿 的方案:

  • 抢先式中断:无需代码主动配合,在 GC 发生时把所有线程全部中断,若线程中断处不在安全点上就恢复线程,让它“跑”到安全点上。现在 几乎没有虚拟机实现采用抢先式中断 来暂停线程从而响应 GC 事件
  • 主动式中断:在 GC 要中断线程时不直接对线程操作,而是设置一个中断标志,让各个线程在执行时主动轮询它,当中断标志为真时就自己中断挂起

2.3.3 安全区域

安全点机制只能保证程序执行时,在不太长的时间内遇到可进入 GC 的安全点,但在程序不执行时(如线程处于 SleepBlocked 状态)线程无法响应 JVM 的中断请求,此时就需要安全区域来解决

  • 安全区域: 引用关系不会发生变化的一段代码片段 ,在安全区域中的任意地方开始 GC 都是安全的(因为引用关系不变),可看做是扩展的安全点

  • 执行过程:

    当线程执行到安全区域中的代码时就标识一下,如果这时 JVM 要发起 GC 就不用管被标识的线程;

    在线程要离开安全区域时检查系统是否已经完成了根节点枚举,若完成则线程可以继续执行,否则等待直到收到可以安全离开安全区域的信号为止

2.3.4 JVM 中七种回收器

一夜搞懂 | JVM GC&内存分配
序号 收集器 收集范围 算法 执行类型
1 Serial 新生代 复制 单线程
2 ParNew 新生代 复制 多线程并行
3 Parallel 新生代 复制 多线程并行
4 Serial Old 老年代 标记整理 单线程
5 CMS 老年代 标记清除 多线程并发
6 Parallel Old 老年代 标记整理 多线程
7 G1 全部 复制算法,标记-整理 多线程

注意并发和并行的概念:

GC 中:

  • 并行: 多条 垃圾收集线程并行工作 ,而用户线程仍处于等待状态
  • 并发垃圾收集线程与用户线程 一段时间内同时工作(交替执行)

在普通情景中:

  • 并行:**多个程序在多个 CPU **上同时运行,任意一个时刻可以有很多个程序同时运行,互不干扰
  • 并发:**多个程序在一个 CPU **上运行, CPU 在多个程序之间快速切换,微观上不是同时运行,任意一个时刻只有一个程序在运行,但宏观上看起来就像多个程序同时运行一样,因为 CPU 切换速度非常快,时间片是 64ms (每 64ms 切换一次,不同的操作系统有不同的时间),人类的反应速度是 100ms ,你还没反应过来, CPU 已经切换了好几个程序了

2.4 内存分配和回收策略

对象的内存分配广义上是指在堆上分配,主要是在 新生代Eden 区上,如果启动了 TLAB ,将按线程优先在 TLAB 上分配,少数情况下也可能会分配在老年代中。分配细节还是取决于所使用的 GC 收集器组合以及虚拟机中与内存相关的参数的设置。以下介绍几条普遍的内存分配规则

  • 对象优先在 Eden 分配 :大多数情况下对象在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时虚拟机将发起一次 Minor GC
  • 新生代 GC :发生在新生代的垃圾收集动作。较频繁、回收速度也较快
  • 老年代 GCMajor GC/Full GC ):发生在老年代的垃圾收集动作。出现 Major GC 经常会伴随至少一次的 Minor GC 。速度一般比 Minor GC 慢10倍以上

  • 大对象直接进入老年代:对于需要大量连续内存空间的 Java 对象(如很长的字符串以及数组),如果大于虚拟机设定的 -XX:PretenureSizeThreshold 参数值将直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制

  • 长期存活的对象将进入老年代:虚拟机会给每个对象定义一个年龄计数器,当对象在 Eden 出生并经过第一次 Minor GC 后仍存活且能被 Survivor 容纳的话,将被移动到 Survivor 空间中并将对象年龄设为1;当对象在 Survivor 区中每“熬过”一次 Minor GC 年龄就+1,直至增加到一定程度(默认为 15岁 ,可通过 -XX: MaxTenuringThreshold 设置)就会被晋升到老年代中

  • 动态对象年龄判定:为了能更好地适应不同程序的内存状况,虚拟机并不要求一定要达到 -XX: MaxTenuringThreshold 设置值才能晋升到老年代,当 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象可以直接进入老年代

  • 空间分配担保:在发生 Minor GC 之前虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,若是,说明可确保 Minor GC 是安全的,反之虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败;若允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;若大于,将尝试进行一次 Minor GC ,若小于或者不允许担保失败,将改为进行一次 Full GC

解释:当大量对象在 MinorGC 后仍然存活的情况时,需要借助老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代,但前提是老年代本身还有容纳这些对象的剩余空间,由于在完成内存回收之前无法预知实际存活对象,只好取之前每次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,从而决定是否进行 Full GC 来让老年代腾出更多空间

三.课堂小测试

恭喜你!已经看完了前面的文章,相信你对 JVM GC &内存分配已经有一定深度的了解,下面,进行一下课堂小测试,验证一下自己的学习成果吧!

Q1:垃圾回收算法你了解几种?请你简要分析一下,并说明其优缺点?

Q2: Java 的引用机制有几种?请简要分析下,并说明其在 Android 中的应用场景有哪些?

Q3:安全点你了解过吗?安全区呢?请你介绍下安全区相对安全点的优势在哪里?

Q4:怎么判断对象是否存活呢?有几种方法?

上面问题的答案,在前文都提到过,如果还不能回答出来的话,建议回顾下前文

如果文章对您有一点帮助的话,希望您能点一下赞,您的点赞,是我前进的动力

本文参考链接:

  • 《深入理解Java虚拟机》第3版
  • JVM面试知识点梳理
  • 强软弱虚引用,只有体会过了,才能记住
  • 深入JVM对象引用
  • 要点提炼| 理解JVM之GC&内存分配
  • JVM(HotSpot) 垃圾收集器
  • 垃圾收集器与内存分配策略
  • 深入理解并发/并行,阻塞/非阻塞,同步/异步
  • 软引用、弱引用、虚引用-他们的特点及应用场景
原文  https://juejin.im/post/5e815d2451882573c66ceec8
正文到此结束
Loading...