对象的内存分配与回收

这里主要涉及对象在 Java 堆中的内存分配和回收。Java 堆是垃圾收集器管理的主要区域,因此被称为 GC 堆。

由于当前收集器都采用分代垃圾收集算法,因此 Java 堆可以划分为 —— 新生代、老年代。新生代进一步划分为 —— Eden 空间、S1、S2 空间。如此划分的目的是为了更好地回收内存,更快地分配内存。

大部分情况下,对象现在 Eden 区域分配。在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或 S1,并且对象的年龄还会增加 1(Eden 区Survivor 区后对象的初始年龄变为 1)。

当年龄达到一定程度后,就会晋升到老年代中,这个阈值也会动态调整。当新生代 GC 时,S0 空间不够用时,有一部分未达到条件的实例会因为放不下而提前进入老年代。

对象优先在 eden 区分配

大多数对象优先在 eden 区分配,如 allocation1 优先存在 eden 区域。
当 eden 区没有足够空间时,虚拟机会发起一次新生代 GC。当 Survivor 空间仍然无法存入数据时,只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。

allocation1 = new byte[30900*1024];
allocation2 = new byte[30900*1024];

大对象直接进入老年代

概念:大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
原因:为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

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

为了使用分代收集思想来管理内存,虚拟机给每个对象一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1. 对象在 Survivor 中每熬过一次 MinorGC, 年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过 -XX:+PrintTenuringDistribution 来打印出当次 GC 后的 Threshold。

动态对象年龄判断

晋升到老年代需要的年龄阈值是动态判定的。

空间分配担保机制

如果 YougGC 时新生代有大量对象存活下来,而 survivor 区放不下了,这时必须转移到老年代中,但这时发现老年代也放不下这些对象了,那怎么处理呢﹖其实 JVM 有一个老年代空间分配担保机制来保证对象能够进入老年代。

在执行每次 YoungGC 之前,JVM 会先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。因为在极端情况下,可能新生代 YoungGC 后,所有对象都存活下来了,而 survivor 区又放不下,那可能所有对象都要进入老年代了。这个时候如果老年代的可用连续空间是大于新生代所有对象的总大小的,那就可以放心进行 YoungGC。

但如果老年代的内存大小是小于新生代对象总大小的,那就有可能老年代空间不够放入新生代所有存活对象,这个时候 VM 就会先检查-XX: HandlePromotionFailure 参数是否允许担保失败,如果允许,就会判断老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 YoungGC,尽快这次 YoungGC 是有风险的。如果小于,或者-XX: HandlePromotionFailure 参数不允许担保失败,这时就会进行一次 Full GC。

在允许担保失败并尝试进行 YoungGC 后,可能会出现三种情况:

  • YoungGC 后,存活对象小于 survivor 大小,此时存活对象进入 survivor 区中
  • YoungGC 后,存活对象大于 survivor 大小,但是小于老年大可用空间大小,此时直接进入老年代。
  • YoungGC 后,存活对象大于 survivor 大小,也大于老年大可用空间大小,老年代也放不下这些对象了,此时就会发生”Handle Promotion Failure”,就触发了 Full GC。如果 Full GC 后,老年代还是没有足够的空间,此时就会发生 OOM 内存溢出了。

《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:

JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

调试对象分配过程

-verbose:gc
-Xmx200M
-Xms200M
-Xmn50M
-XX:+PrintGCDetails
-XX:TargetSurvivorRatio=60
-XX:+PrintTenuringDistribution
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:MaxTenuringThreshold=3
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
/*
* 本实例用于java GC以后,新生代survivor区域的变化,以及晋升到老年代的时间和方式的测试代码。需要自行分步注释不需要的代码进行反复测试对比
*
* 由于java的main函数以及其他基础服务也会占用一些eden空间,所以要提前空跑一次main函数,来看看这部分占用。
*
* 自定义的代码中,我们使用堆内分配数组和栈内分配数组的方式来分别模拟不可被GC的和可被GC的资源。
*
*
* */
 
public class JavaGcTest {
 
    public static void main(String[] args) throws InterruptedException {
        //空跑一次main函数来查看java服务本身占用的空间大小,我这里是占用了3M。所以40-3=37,下面分配三个1M的数组和一个34M的垃圾数组。
 
 
        // 为了达到TargetSurvivorRatio(期望占用的Survivor区域的大小)这个比例指定的值, 即5M*60%=3M(Desired survivor size),
        // 这里用1M的数组的分配来达到Desired survivor size
        //说明: 5M为S区的From或To的大小,60%为TargetSurvivorRatio参数指定,可以更改参数获取不同的效果。
        byte[] byte1m_1 = new byte[1 * 1024 * 1024];
        byte[] byte1m_2 = new byte[1 * 1024 * 1024];
        byte[] byte1m_3 = new byte[1 * 1024 * 1024];
 
        //使用函数方式来申请空间,函数运行完毕以后,就会变成垃圾等待回收。此时应保证eden的区域占用达到100%。可以通过调整传入值来达到效果。
        makeGarbage(34);
 
        //再次申请一个数组,因为eden已经满了,所以这里会触发Minor GC
        byte[] byteArr = new byte[10*1024*1024];
        // 这次Minor Gc时, 三个1M的数组因为尚有引用,所以进入From区域(因为是第一次GC)age为1
        // 且由于From区已经占用达到了60%(-XX:TargetSurvivorRatio=60), 所以会重新计算对象晋升的age。
        // 计算方法见上文,计算出age:min(age, MaxTenuringThreshold) = 1,输出中会有Desired survivor size 3145728 bytes, new threshold 1 (max 3)字样
        //新的数组byteArr进入eden区域。
 
 
        //再次触发垃圾回收,证明三个1M的数组会因为其第二次回收后age为2,大于上一次计算出的new threshold 1,所以进入老年代。
        //而byteArr因为超过survivor的单个区域,直接进入了老年代。
        makeGarbage(34);
    }
    private static void makeGarbage(int size){
        byte[] byteArrTemp = new byte[size * 1024 * 1024];
    }
}
 

多种 GC 概念

GC 包含 Partial GCFull GC

  • Partial GC 分为 Young GC,Old GC 和 Mixed GC。
    • Young GC 只收集新生代的 GC;
    • Old GC 只收集老年代的 GC,只有 CMS 的 concurrent collection 有这个模式;
    • Mixed GC 收集新生代的 GC 和部分老年代的 GC,只有 G1 有这个模式。
  • Full GC 收集新生代、老年代、永生代(存在的话)的 GC。
  • Young GC:当 eden 区分配满时触发,一部分被 GC,一部分升到老年代;
  • Full GC:当触发 Young GC 时,如果平均晋升大小比老年代的剩余空间大,则直接触发 Full GC。
  • System.gc()heapdump 带 GC 也是触发 Full GC。

垃圾回收过程

  • 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
  • 清空 Eden 和 From Survivor 分区;
  • From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。

每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达动态阈值时,升级为老生代。大对象也会直接进入老生代。

老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

是否可以进行垃圾回收

如何判断一个对象已经无效

引用计数法

给对象添加引用计数器,每有一个其他对象引用该对象,计数器加一。当引用失效,计数器减一。当计数器为 0 的对象就不可能被使用。

该方法简单、效率高,但是如果两个对象互相引用,那么这两个对象就无法被回收。

可达性分析算法

通过一系列被称为”GC Roots”的对象作为起点,从这些节点向下搜索,节点走过的路径被称为引用链。当一个对象到 GC Roots 没有任何引用链相连的话,则证明对象是不可用的。

可以作为 GC roots 的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象;本地方法栈中引用的对象;方法区中类静态属性引用的对象;方法区常量引用的对象;所有被同步锁持有的对象。

对象引用类型

强软弱虚引用,只有体会过了,才能记住 - CodeBear - 博客园

强引用
使用的大部分引用是强引用。如果一个对象具有强引用,GC 不回去回收。当内存不足,JVM 会抛出 OutOfMemoryError 错误,也不会回收。
让一个对象没有强引用,就将其设为 null。

软引用
当对象只有软引用,如果空间够,不 GC;反之,GC。软引用可以实现内存敏感的高速缓存。
软引用使用时,SoftReference<String> 就是一个软引用对象,使用 get 方法获得对象值。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

弱引用
当对象具有弱引用,GC 遇到弱引用对象,直接 GC。弱引用同样可以与引用队列一起使用。WeakReference<String> 就是弱引用对象。

虚引用
虚引用,并不会决定对象的生命周期。一个对象持有虚引用,那么与没有引用一样。任何时候都可能被 GC 回收。
虚引用主要用于跟踪对象被垃圾回收的活动。虚引用必须和引用队列联合使用。

程序设计中一般很少使用弱引用与虚引用,使用弱引用的情况较多,因为软引用可以加速 JVM 堆垃圾内存的回收速度,维护系统运行安全,防止内存溢出。

不可达对象并非一定 GC

即便在可达性分析中不可达的对象,也并非一定要 GC 的。一个对象被 GC 需要两个标记过程:
可达性分析中不可达对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。

判断一个常量是废弃常量

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。

判断一个类是无用的类

同时满足 3 个条件才算无用的类:

  1. 该类所有的实例都被回收
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该方法

垃圾收集算法

标记 -清除算法

概念:标记阶段,将不需要回收的对象进行标记;清除阶段,将没被回收的对象都回收掉。
问题:效率问题、空间问题(清除后存在内存碎片)

复制算法

概念:将内存分为大小相同的两块,当一块的内存使用完后,将仍然存在的对象复制到另一块上,然后将该块内存清理掉。

标记-整理算法

概念:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

新生代,可以选择”标记-复制”算法,只需要复制少量对象。老年代,对象存活概率高,而且没有额外的空间对它进行分配担保,所以需要使用”标记-清楚”或”标记-整理”算法来进行垃圾收集。

垃圾收集器

Serial 收集器

单线程收集器,应用程序暂停,单线程进行 GC,新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。
JDK1.5 前,与 Parallel Scavenge 收集器搭配使用;另一种作为 CMS 收集器的后备方案。

ParNew 收集器

Serial 的多线程版本。应用程序暂停,多线程进行 GC,新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Scavenge 收集器

多线程,但是吞吐量优先,可牺牲等待时间换取系统吞吐量
采用标记-复制算法。

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器

CMS:一种以获得最短停顿时间为目标的收集器,非常适用 B/S 系统。

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1 收集器

一种兼顾吞吐量和停顿时间的 GC 实现,是 JDK 9 以后的默认 GC 选项。

G1 (Garbage-First) 是一款面向服务器的垃圾收集器, 主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时, 还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器将整个堆内存划分为多个大小相等的独立区域(Region),每个 Region 的大小可以在 1MB 到 32MB 之间动态调整。这些 Region 可以是 Eden 区、Survivor 区、老年代区或 Humongous 区(用于存储大对象)。

  1. 初始标记(Initial Marking):标记与根对象直接关联的对象,这个阶段需要暂停应用程序线程,但停顿时间很短。
  2. 并发标记(Concurrent Marking):从根对象开始,并发地遍历整个堆,标记所有存活的对象。这个阶段不会暂停应用程序线程,因此不会影响应用程序的正常运行。
  3. 最终标记(Final Marking):标记在并发标记阶段发生变化的对象,这个阶段需要暂停应用程序线程,但停顿时间比初始标记阶段稍长。
  4. 筛选回收(Live Data Counting and Evacuation):根据用户设置的最大停顿时间目标,对各个 Region 的回收价值和成本进行排序,选择部分 Region 进行回收。这个阶段会将存活的对象移动到其他 Region,并回收被清空的 Region。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

特点:

  1. 可以控制回收垃圾的时间,根据期望的停顿时间制定回收计划
  2. 大对象处理上,在 CMS 内存中,如果一个对象过大,进入 S1、S2 区域的时候大于改分配的区域,对象会直接进入老年代。G1 处理大对象时会判断对象是否大于一个 Region 大小的 50%,如果大于 50%就会横跨多个 Region 进行存放。

什么情况下应该考虑使用 G1:

  • 实时数据占用超过一半的堆空间
  • 对象分配或者晋升的速度变化大
  • 希望消除长时间的 GC 停顿(超过 0.5-1 秒)

G1 设置参数 - 控制 G1 回收垃圾的时间
-XX:MaxGCPauseMillis=200 (默认 200ms)

ZGC 收集器

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

在 ZGC 中出现 Stop The World 的情况会更少!

详情可以看 : 《新一代垃圾回收器 ZGC 的探索与实践》

参考

参考链接:JavaGuide - JVM垃圾回收