阅读 440

JVM核心神技:GC的内存分配

学习导图

一.为什么要学习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中的注册的回调、本地代码缓存等

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

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

  • 第一次标记:当第一次可达性分析后没有与GC Roots相连接的引用链,被第一次标记
  • 第二次标记:

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

引申:有关方法区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 复制算法

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

  • Appel式回收

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

2.2.3 标记-清除算法

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

2.2.4 标记-整理算法

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

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中七种回收器

序号 收集器 收集范围 算法 执行类型
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:怎么判断对象是否存活呢?有几种方法?