《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

一、概述

  • GC(Garbage Collection)需要完成的三件事

    • (1)哪些内存需要回收
    • (2)什么时候回收
    • (3)如何回收
  • GC主要面向Java堆和方法区中的内存

    • 原因:这部份内存的分配和回收都是动态的
      • 只有在程序处于运行期间时才能知道会创建哪些对象
      • 程序计数器、虚拟机栈、本地方法栈三个区域随线程而生、随线程而灭,内存分配和回收具有确定性

二、对象已死吗(判断对象是否存活)

1、引用计数算法
  • 基本思想

    • 给对象中添加一个引用计数器
    • 每当一个地方引用它时,计数器的值就+1
    • 当引用失效时,计数器的值就-1
    • 任何时刻计数器为0的对象就是不可能再被使用的
  • 问题:难以解决对象间循环引用的问题

    • 只存在相互引用时,两个对象的引用计数器均不为0,但也会被回收
    • 因此虚拟机并未采用引用计数算法来判定对象是否存活
2、可达分析算法(主流实现,Java/C#等均使用)
  • 基本思想

    • 通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索
    • 搜索所走过的路径称为引用链(Reference Chain)
    • 当一个对象到GC Roots没有任何引用链时,则证明此对象可回收
  • 可作为GC Roots的对象

    • 虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象
3、再谈引用(四种引用类型,强>软>弱>虚)
  • (1)强引用

    • 定义:类似Object obj = new Object()这样的引用

    • 回收时机:只要强引用存在,垃圾收集器永远不会回收掉被引用的对象(永远不回收

  • (2)软引用

    • 定义:有用但并非必需的对象引用

    • 实现:SoftReference

    • 回收时机:在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次回收(内存不足时回收

      • 如果这次回收还没有足够内存,才会抛出内存溢出异常
  • (3)弱引用

    • 定义:非必需对象的引用

    • 实现:WeakReference

    • 回收时机:当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(只要GC就会回收

      • 被弱引用关联的对象只能生存到下一次垃圾收集完成之前。
  • (4)虚引用

    • 定义:无法通过一个虚引用来获取一个对象实例

      • 一个对象是否有虚引用实例对其生存时间无影响
    • 实现:PhantomReference

    • 存在意义:在被虚引用关联的对象被GC回收时能收到一个系统通知

      • 声明虚引用的时候是要传入一个queue的。当虚引用所引用的对象已经执行完finalize函数的时候,就会把对象加到queue里面。可以通过判断queue里面是不是有对象来判断你的对象是不是要被回收了。
4、生存还是死亡(对象死亡的两次标记过程)
  • 第一次标记(同时进行一次是否需要执行finalize()方法的筛选)

    • 标记标准:如果对象在可达性分析后发现没有与GC Roots相连接的引用链

    • 筛选标准:

      • (1)当对象没有覆盖finalize()方法
      • (2)当对象的finalize()方法已经被虚拟机调用过
        • 任何一个对象的finalize()方法都只会被系统自动的调用一次
    • 筛选结果:如果满足两种筛选条件中的任意一种,均不必要执行finalize()方法

  • 第二次标记

    • 前提条件:这个对象被判定有必要执行finalize()方法

    • 过程:

      • 对象在第二次标记期间将会被放到一个F-Queue的队列当中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。(“执行”指只触发线程但不等待,避免线程阻塞导致其他队列中的对象无法回收)
      • 然后GC将对F-Queue中的对象进行第二次小规模的标记。
      • 两次标记后进行回收
    • 对象的自救

      • 方法:重写finalize()方法,重新与引用链上的对象建立链接

        • 例如将自己(this)赋值给某个变量或对象的成员变量
      • 结果:在第二次标记时,将被移出F-Queue

      • 注:尽量不要用这种方法,而是选择try/catch

5、回收方法区
  • 方法区(HotSpot虚拟机中的永久代)相较于堆中的新生代垃圾收集效率底很多

  • 永久代垃圾回收内容:

    • 废弃常量

      • 满足判定标准时,发生GC时会被回收
    • 无用的类

      • 类无用的时候也不一定会被回收,是否对类回收,可以通过HotSpot虚拟机中提供的-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassLoading查看类加载和卸载信息

      • 频繁自定义ClassLoader的场景需要虚拟机具备类卸载的功能,以保证永久代不会溢出

  • (1)废弃常量判定标准

    • 没有常量对象/类(接口)/方法/字段的符号引用常量池中的常量
    • 也没有其他地方引用该常量的字面量
  • (2)无用的类判定标准(同时满足三个条件

    • 条件一:该类所有的实例都已经被回收

      • 即Java堆中不存在任何该类的实例
    • 条件二:加载该类的ClassLoader已经被回收

    • 条件三:该类对应的java.lang.Class对象没有在人和地方被引用,无法在任何地方通过反射访问该类的方法

三、垃圾收集算法(四种)

1、标记-清除算法
  • 思想:

    • 标记出所有需要回收的对象

      • 标记:指判定对象是否已死时的两次标记
    • 在标记完成后统一回收所有被标记的对象

  • 不足;

    • 标记和清除过程效率都较低

    • 标记清除后会产生大量不连续的内存碎片问题

      • 空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够连续的内存而不得不提前触发另一次垃圾收集动作
2、复制算法(一般应用于新生代)
  • 思想:

    • 将可用的内存按容量划分为大小相等的两块,每次只使用其中一块
    • 当一块的内存用完了,就将还存活着的对象按顺序复制到另一块上面,然后堆已使用的半区进行一次性清除
  • 优点:

    • 不用考虑内存碎片
      • 移动后在另一个半区是按顺序排列好的,一次回收完整半区
  • 不足:

    • 实际可用内存被缩小
    • 在对象存活率较高时就要进行较多的复制操作,效率将会变低
  • 商业实现(一般用于回收新生代

    • 前提;研究表明新生代中的对象98%是“朝生夕死”的

    • 思想:

      • 将内存分为一块较大的Eden空间(80%)和两块较小的Survivor空间(10% + 10%)

      • 每次使用Eden和其中一块Survivor

      • 当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor空间上,最后清理掉的之前使用的空间

      • 这样只有一块Survivor的空间会被浪费

    • 分配担保

      • 如果另外一块Survivor空间没有足够容量存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代
3、标记-整理算法(一般应用于老年代)
  • 原因:老年代中对象存活率较高,复制算法成本过高

  • 思想:

    • 标记过程与“标记-清除”算法一致

    • 标记后让所有存活的对象向一端移动(整理)

    • 然后直接清理掉边界以外的内存

4、分代收集算法(通常商业应用)
  • 思想:
    • 根据对象的存活周期的不同将内存划分为几块

      • 一般将Java堆分为新生代和老年代
    • (1)对于新生代:采用复制算法

    • (2)对于老年代:标记-清除算法/标记-整理算法

四、HotSpot的算法实现(如何发起内存回收)

1、枚举根结点(如何快速寻找对象引用)
  • 根结点:以可达性分析的GC Roots根结点节点寻找引用链为例

    • 问题引出:如何避免逐个检查引用而消耗时间?
  • 可达性分析对时间的敏感体现:GC停顿

    • 可达性分析必须在一个能确保一致性的快照中进行
      • “一致性”的快照:在分析过程期间,整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况。否则分析结果的准确性就无法得到保证
  • 准确式GC

    • 定义:虚拟机可以知道内存中某个位置的数据具体是什么类型(reference类型指向的地址or具体值),从而在GC的时候能够准确判断堆上的数据是否还可能被使用

    • 好处:可以抛弃Classic VM基于handle(句柄)的对象查找方式

      • 因为在没有明确信息表明内存中哪些数据是reference的前提下,虚拟机无法保证GC后对象存在因位置移动而出现的问题,所以要使用handle来保持reference的稳定

      • (1) Exact VM用直接指针而不是handle来实现Java层的引用

      • (2)通常,通过直接指针来访问对象意味着“一次间接”,而通过句柄则意味着“两次或更多次间接”

  • HotSpot通过一组被称为OopMap的数据结构来达到直接得知哪些地方存放的是对象引用的目的

    • 在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来

    • 在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用

      • JIT编译是一种提高程序运行效率的方法
      • JIT编译器:在Java编程语言和环境中,即时编译器(JIT compiler,just-in-time compiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。当你写好一个Java程序后,源语言的语句将由Java编译器编译成字节码,而不是编译成与某个特定的处理器硬件平台对应的指令代码(比如,Intel的Pentium微处理器或IBM的System/390处理器)。字节码是可以发送给任何平台并且能在那个平台上运行的独立于平台的代码。
2、安全点
  • 问题引出:避免引用关系变化(OopMap内容变化的指令)过多而产生的空间成本开销

    • 为每一条指令都生成对应的OopMap将消耗大量空间
  • 解决方案:安全点

    • 定义:程序执行时,并非在所有地方都能停顿下来开始GC,只有在到达“安全点”这样的特定位置才能暂停

    • 选取标准:以程序(指令集)“具有让程序长时间执行的特征”为标准

      • 每条指令执行的时间很短,需要有足够的时间给安全点
      • 即使是指令流长度很长也无法保证程序长时间运行
      • “长时间执行”的明显特征:指令序列复用。如方法调用、循环跳转、异常跳转等
  • 如何在GC发生时,让所有线程(执行JNI调用线程除外)都到达最近的安全点上停顿下来?

    • (1)抢先式中断(几乎不使用)

      • 在GC发生时,首先将所有线程全部中断
      • 如果发现有线程中断的地方不在安全点上,就恢复线程,让它到达安全点
      • 不需要线程的执行代码主动配合
    • (2)主动式中断

      • 当GV需要中断线程时,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动区轮询这个标志
      • 发现中断标志为真时就自己中断挂起
      • 轮询标志的地方和安全点是重合的
3、安全区域(扩大的安全点)
  • 问题引出:在线程不执行(没有分配CPU时间,如处于Sleep状态或Block状态时)时,无法响应JVM的中断请求,到达安全的地方去中断挂起,JVM也不可能等待线程被重新分陪CPU时间

  • 解决方案:安全区域

    • 定义:在安全区域(一段代码)中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的

    • 思想:

      • 当线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region(在拥有标识的期间内,该线程在GC时可以不用再检查)
      • 当线程要离开Safe Region时,要检查系统是否已经完成了根结点枚举(或整个GC过程),如果完成了,则继续执行,否则就必须等待直到收到离开Safe Region的标识

五、垃圾收集器(7种)

《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

  • 上图是7种作用于不同分代的收集器,如果两个收集器之间存在连线,说明它们可以搭配使用
1、Serial收集器
  • 概述:

    • 是最基本、发展历史最悠久的收集器
    • 是虚拟机运行在Client模式下的默认新生代收集器
  • 特点:

    • 主要用于收集新生代

    • 采用分代收集算法

    • 是一个单线程的收集器

      • 它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作
      • 更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束
  • 优点:简单而高效

    • 限定单个CPU环境,没有线程交互开销

《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

2、PerNew收集器
  • 概述:

    • 是运行在Server模式下的虚拟机中首选的新生代收集器
      • 原因:除了Serial收集器外,目前只有他能与CMS收集器配合工作
  • 特点:

    • 主要用于收集新生代

    • 采用分代收集算法

    • 多线程收集器

    • 实现了让垃圾收集线程与用户线程(基本上)同时工作

      • HotSpot虚拟机中第一款真正意义上的并发(Concurrent)收集器
    • 默认开启的GC线程数与CPU的数量相同

《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

5、Parallel Scavenge收集器
  • 特点:
    • 用于收集新生代

    • 使用复制算法

    • 目标:“吞吐量优先”收集器

      • 吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))

      • 可理解为吞吐量与允许最大GC时间成正比

      • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间)

    • 适合在后台运算而不需要太多交互的任务

      • 停顿时间越短就越利于用户交互,良好的响应速度能提升用户体验

      • 高吞吐量可以高效率的利用CPU时间

    • 并行的多线程收集器

      • 并行与并发:并行是指同一时刻同时做多件事情,而并发是指同一时间间隔内做多件事情(并发通过时间片调度实现现实意义的并行)

《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

4、Serial Old收集器
  • 概述:

    • 是Serial收集器的老年代版本

    • 主要意义也是在于给Client模式下虚拟机使用

    • 如果在Server模式下,它主要还有两大用途

      • (1)与Parallel Scavenge收集器搭配使用
      • (2)作为CMS收集器的后备预案,在并发收集发生Conurrent Mode Failure使用
  • 特点:

    • 用于收集老年代

    • 使用标记-整理收集算法

5、Parallel Old收集器
  • 概述:

    • Parallel Old是Parallel Scavenge收集器的老年代版本
  • 特点:

    • 用于收集老年代

    • 使用“标记-整理”算法

    • 多线程

  • 应用场景:在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器

6、CMS(Concurrent Mark Sweep)收集器
  • 特点:

    • 用于收集老年代

    • 基于“标记-清除”算法实现

    • 目标:尽可能地缩短垃圾收集时用户线程的停顿时间

  • 标记-清除过程:

    • ①初始标记(速度快,但需确保"Stop the World")

    • ②并发标记(耗时长,但可与用户线程一起工作)

      • 并发标记阶段就是进行GC Roots Tracing的过程
    • ③重新标记(速度快,但需确保"Stop the World")

      • 目的:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
    • ④并发清除(耗时长,但可与用户线程一起工作)

  • 缺点(三条):

    • (1)CMS收集器对CPU资源非常敏感

      • 并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
    • (2)CMS收集器无法处理浮动垃圾,可能出现“Conurrent Mode Failure”失败而导致另一次Full GC的产生

      • 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS无法在档次收集中处理掉它们,只好留待下一次GC时再清理掉。这部分垃圾就称为“浮动垃圾”。

      • 此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时程序运作使用。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被**。如果预留空间无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案Serial Old收集器

    • (3)CMS是一款基于“标记-清除”算法实现的收集器,会有大量空间碎片问题。

《深入理解Java虚拟机》学习笔记之垃圾收集器与内存分配策略

5、G1收集器
  • 概述:

    • 是当今收集器技术发展的最前沿成果之一
    • 是一款面向服务端应用的垃圾收集器
  • 特点:

    • 采用分代收集(既能回收新生代,也能回收老年代)

      • 可以不需要其他收集器的配合管理整个堆,但是仍采用不同的方式去处理分代的对象。
    • 并行与并发

      • 能充分利用多CPU,多核环境下的硬件优势,缩短Stop-The-World停顿的时间,同时可以通过并发的方式让Java程序继续执行
    • 空间整合

      • G1从整体上来看,采用基于“标记-整理”算法实现收集器
      • G1从局部上来看,采用基于“复制”算法实现
    • 可预测停顿

      • 使用G1收集器时,Java堆内存布局与其他收集器有很大差别,它将整个Java堆划分成为多个大小相等的独立区域。

      • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表

      • 每次根据允许的收集时间(根据用户建立的可控时间模型),优先回收价值最大的Region

  • 运作过程:

    • 初始标记(速度快,但需确保"Stop the World")

    • 并发标记(耗时长,但可与用户线程一起工作)

    • 最终标记(需停顿线程,但是可并行执行)

      • ???
    • 筛选回收(时间可控,可与用户线程一起工作)

六、内存分配与回收策略

  • 自动内存管理解决的两个问题

    • (1)给对象分配内存
    • (2)回收分配给对象的内存
  • 对象的内存分配大致规律(主要是堆上分配

    • 主要分配在新生代的Eden区上

      • 如果启动了本地线程分配缓冲(TLAB),将按线程优先在TLAB上分配
    • 少数情况下也可能直接分配在老年代上

  • Minor GC和Full GC的区别

    • Minor GC:指发生在新生代的垃圾收集动作,该动作非常频繁,速度也快。

    • Full GC/Major GC:指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次的Minor GC。Major GC的速度一般会比Minor GC慢10倍以上。

1、对象优先在Eden分配
  • 大多数情况下,对象在新生代的Eden区中分配

  • 当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

2、大对象直接进入老年代
  • 大对象:需要大量连续内存空间的Java对象
    • eg:很长的字符串及数组
3、长期存活的对象将进入老年代
  • 虚拟机给每个对象定义了一个对象年龄(Age)计数器

  • 年龄增长规则:

    • 如果对象在Eden出生并经过第一次Minor GC后依然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设置为1

    • 对象进入Survivor空间之后,就只会伴随每次GC在两个Survivor空间来回复制

    • 对象在Survivor区中每“熬过”一次Minor GC,年龄+1

    • 直到年龄达到阈值,进入老年代

      • 默认阈值为15岁
      • 对象今生老年代的年龄阈值可以通过-XX:MaxTenuringThreshold参数设置
4、动态对象年龄判定
  • 虚拟机并不是永远的要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代

  • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold

5、空间分配担保
  • 目的:避免出现存活对象在GC时空间不足以容纳的情况

  • 过程(两步检查):

    • 第一步检查: 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

      • (1)如果这个条件成立,那么Minor GC可以 确保是安全的。

      • (2)如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败

    • 第二步检查:如果允许担保失败,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小

      • 如果大于,则将尝试进行一次Minor GC,尽管这个Minor GC是有风险的

      • 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

  • 分析:

    • 第一步检查:

      • 所冒“风险”:新生代使用复制收集算法,只使用其中一个Survivor空间来作为轮换备份,当Survivor空间不足以存放存活对象时,需要使用老年代来进行分配担保,让Survivor无法容纳的对象直接进入老年代

      • 因此:老年代最大可用的连续空间大于新生代所有对象的总空间能确保空间足够

    • 第二步检查:

      • 取过去的平均值来推测可能需要的对象空间进行比较是一种动态概率手段,存在风险(如某次Minor GC存活后的对象突增)

      • 解决:如果出现HandlePromotionFailure失败,那么只好在失败后重新发起一次Full GC

      • 好处:大部分时间还是会将HandlePromotionFilure设置为允许,避免Full GC过于频繁(Full GC成本很高)