Java内存回收

Posted by 令德湖周杰伦 on 04-08,2024

0. 背景

Java服务上会不断地创建很多的对象,这些对象数据会占用系统内存,如果得不到有效的管理,内存的占用会越来越多,甚至会出现内存溢出的情况,所以,我们需要进行对内存进行合理地回收。

步骤可以简单拆分为:

  1. 标记
  2. 回收

1. 标记

如何判断一个对象是存,还是亡,其中亡的就是所谓的垃圾,首先需要找到这些垃圾,这个动作称为“标记”。

1.1 引用计数法

为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。

1.1.1 存在的问题

  • 需要额外的空间来存储计数器
  • 繁琐的更新操作,如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器-1
  • 无法处理循环引用对象

1.2 可达性分析算法

将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

1.2.1 GC Roots

可以理解为由堆外指向堆内的引用,主要包括:

  1. Java 方法栈桢中的局部变量
  2. 已加载类的静态变量
  3. JNI handles
  4. 已启动且未停止的 Java 线程

1.2.2 存在的问题

可达性分析虽然可以解决循环引用问题,但是也存在问题:

  • 在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将无引用设置为有访问的对象)或者漏报(将有引用设置为未被访问过的对象)
  • 漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存,可能会直接导致 Java 虚拟机崩溃

1.2.2 被回收最后一次挣扎

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

  1. 第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
  2. 第二次标记:
    1. 第一次标记后接着会进行一次筛选,对象是否有必要执行finalize()方法,
    2. 看对象finalize()方法中没有重新与引用链建立关联关系的,将第二次标记成功的对象将真的会被回收。

为了解决多线程问题,引出了STW

1.3 Stop-The-World

停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

1.3.1 安全点

java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

其中安全点为:找一个稳定的执行状态,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。

2. 回收

标记完成后(知道哪些是垃圾,哪些还是正在使用的内存),就是进行回收了,常见的回收方法有以下几种:

  • 清除(sweep)
  • 压缩(compact)
  • 复制(copy)

2.1 清除

具体做法:

  1. 把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。
  2. 当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

2.1.1 存在的问题

  • 造成内存碎片
  • 分配效率较低,空闲列表不是一块连续的内存空间,无法使用指针加法来做,需要按个遍历看是否能存放对象

2.2 压缩

即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间

2.2.1 存在的问题

  • 虽然够解决内存碎片化的问题,但代价是压缩算法的性能开销。

2.3 复制

  1. 先把内存区域分为两等分。分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。
  2. 当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容

2.3.1 存在的问题

  • 复制这种回收方式同样能够解决内存碎片化的问题,但堆空间的使用效率会低很多

为了尽可能提高堆内存的使用效率,引出了根据对象生命周期而诞生的分代思想。

3. 分代治理

将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

3.1 如何划分

  • 新生代,划分为3个区:eden + s0 + s1
  • 老年代

3.2 创建对象

当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。

3.2.1 存在的问题

由于堆空间是线程共享的,

  • 如何保证多线程下空间划分不会冲突 -> 同步
  • 同步后效率如何解决 -> 提前申请多个,并使用TLAB技术

3.2.2 TLAB

TLAB(Thread Local Allocation Buffer)技术的步骤

  1. 每个线程可以向 Java 虚拟机申请一段连续的内存(这个动作当然是同步的),比如 2048 字节,作为线程私有的 TLAB。
  2. 操作需要加锁,线程需要维护两个指针,一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
  3. 接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。
  4. 如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

3.3 分代标记和回收算法

内存使用也符合二八原则,大部分对象是朝生夕死,那么分代后,在新生代和老年代上也需要采用不同的方案(分而治之的思想)

  • 新生代:Minor GC
  • 老年代:Major GC
  • 永久代:Permanent GC

3.3.1 Minor GC

或者叫Young GC,是新生代的垃圾回收算法,
所有新生成的对象首先都是放在新生代的,新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。

核心思路:标记-复制算法

3.3.1.1 步骤

  1. Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代
  2. 如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

3.3.1.2 存在的问题

  1. Minor GC好处是不用对整个堆进行垃圾回收,
  2. 但如果老年代的对象可能引用新生代的对象,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots,岂不是又做了一次全堆扫描呢?

卡表:

  1. HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

  2. 在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

3.3.2 Major GC

也可以理解为FullGC,在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象,
但当老年代内存满时触发Major GC即Full GC(发生频率比较低,老年代对象存活时间比较长,存活率标记高)

3.3.3 Permanent GC

JDK1.8以前,永久代用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类

3.4 GC回收器

上面说到的标记和回收的算法,但这些算法都需要由垃圾收集器去执行。常见的回收器有:

  • 新生代可配置的回收器:Serial、ParNew、Parallel Scavenge
  • 老年代配置的回收器:CMS、Serial Old、Parallel Old

3.4.1 新生代回收器

3.4.1.1 Serial 垃圾回收器

串行回收器,采用 标记-复制算法,进行垃圾回收
特点:

  • GC时,单线程,使用-XX:+UseSerialGC参数可以设置新生代使用这个串行回收器
  • 存在stw时间长的问题

3.4.1.2 ParNew 垃圾回收器

Serial的多线程版本,除了使用多线程之外,其余参数和Serial一模一样,采用 标记-复制算法,进行垃圾回收,使用-XX:+UseParNewGC参数可以设置新生代使用这个并行回收器

特点:

  • ParNew默认开启的线程数与CPU数量相同,在CPU核数很多的机器上,可以通过参数-XX:ParallelGCThreads来设置线程数
  • 新生代首选的垃圾回收器,它是唯一一个能与老年代CMS配合工作的
  • 也存在stw时间长的问题

3.4.1.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU),其中吞吐量 = 代码运行时间 / ( 代码运行时间 + 垃圾收集时间),JDK1.8 默认收集器

相关参数:

  • -XX:+UseParallelGC参数可以设置新生代使用这个并行回收器

  • -XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间,可用把虚拟机在GC停顿的时间控制在MaxGCPauseMillis范围内,如果希望减少GC停顿时间可以将MaxGCPauseMillis设置的很小,但是会导致GC频繁,从而增加了GC的总时间,降低了吞吐量。所以需要根据实际情况设置该值。

  • -Xx:GCTimeRatio:设置吞吐量大小,它是一个0到100之间的整数,默认情况下他的取值是99,那么系统将花费不超过1/(1+n)的时间用于垃圾回收,也就是1/(1+99)=1%的时间。

  • -XX:+UseAdaptiveSizePolicy打开自适应模式,在这种模式下,新生代的大小、eden、from/to的比例,以及晋升老年代的对象年龄参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点

3.4.2 老年代回收器

3.4.2.1 SerialOld 垃圾回收器

老年代的串行回收器,采用 标记-压缩 算法,进行垃圾回收
特点:

  • 单线程
  • CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

3.4.2.2 ParallelOldGC 回收器

同新生代一样也是关注吞吐量的回收器,使用:标记-压缩算法进行实现
相关参数:

  • -XX:+UseParallelOldGc进行设置老年代使用该回收器
  • -XX:+ParallelGCThreads也可以设置垃圾收集时的线程数量

3.4.2.3 CMS 回收器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常关注用户体验的应用上,使用了 标记-清除算法。

步骤:

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

优点:并发收集、低停顿

  • 它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
  • CMS不会等到应用程序饱和的时候才去回收垃圾,而是在某一阀值的时候开始回收,回收阀值可用指定的参数进行配置:-XX:CMSInitiatingoccupancyFraction来指定,默认为68,也就是说当老年代的空间使用率达到68%的时候,会执行CMS回收。
  • 如果内存使用率增长的很快,在CMS执行的过程中,已经出现了内存不足的情况,此时CMS回收就会失败,虚拟机将启动老年代串行回收器;SerialOldGC进行垃圾回收,这会导致应用程序中断,直到垃圾回收完成后才会正常工作
  • 标记清除法有个缺点就是存在内存碎片的问题,那么CMS有个参数设置-XX:+UseCMSCompactAtFullCollecion可以使CMS回收完成之后进行一次碎片整理。

缺点:

  • 对 CPU 资源敏感
  • 无法处理浮动垃圾
  • 会导致空间碎片产生

3.5 G1 收集器

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

3.5.1 region

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。

|Eden|S0|S1|老年代|永久代

G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。
其中:

  • 如果Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。H-obj有如下几个特征: * H-obj直接分配到了old gen,防止了反复拷贝移动。
  • 一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。

3.5.2 步骤

  1. 初始标记,GC线程,stw
  2. 并发标记,同时开启用户线程和GC线程
  3. 最终标记,GC线程,stw
  4. 筛选回收,GC线程,stw
    G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字Garbage-First的由来)  。
    这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)

特点:

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

相关参数:

  • -XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值

3.5.3 SATB

全称是Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。 那么它是怎么维持并发GC的正确性的呢?根据三色标记算法,规定对象存在三种状态:

  • 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
  • 灰:对象被标记了,但是它的field还没有被标记或标记完。
  • 黑:对象被标记了,且它的所有field也被标记完了。

3.6 ZGC 收集器

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)
    从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收

3.6.1 CMS与G1停顿时间瓶颈

由CMS步骤可知:

  1. 标记阶段停顿分析,主要包括「初识标记」和「再标记」的阶段会stw
  2. 清理阶段:点出有存活对象的分区和没有存活对象的分区,该阶段是STW的

由G1的步骤可知:

  1. 标记阶段停顿分析,主要包括「初识标记」和「再标记」的阶段会stw
  2. 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的

主要瓶颈:
四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

3.6.2 ZGC原理

ZGC采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,步骤如下:

  1. 初始标记,GC线程,stw
  2. 并发标记,GC线程和用户线程
  3. 再标记,GC线程,stw(stw时间很短,最多1ms,超过1ms则再次进入并发标记阶段)
  4. 并发转移准备,GC线程和用户线程
  5. 初识转移,GC线程和用户线程
  6. 并发转移,GC线程和用户线程(与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加)

核心技术:色指针和读屏障技术

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:

  • 并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。
  • 而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

4. 实践经验和调优

4.1 实践经验

4.1.1 大对象导致OOM

配置:堆内存为4G,永久代:128M,年轻代512M,其中EC:341M,S0C:85M,S1C:85M,老年代:3G多点

使用CMS进行老年代回收,且超过50%,开始进行回收

排查步骤:

  1. 观察jvm监控,ec和oc在同一时刻同时上涨了,而且涨幅一样
  2. 理论上,新对象会被直接回收,为啥直接进入了老年代
  3. 查询出来是个大对象(消息listSize:5w+), 在eden区分配后,没有直接进入s区,直接进入了老年代
  4. 查询gc日志,分析原因
  5. 进入老年代后触发了FullGC,但Full GC 过程中并发标记的过程中,用户频发刷新页面,频繁生成大对象塞入到老年代,当老年代内存不够了就直接OOM了

查看GC相关日志:

  • XX:+PrintGcDetails
  • XX:+PrintGcStamps
  • dmesg -T|grep java

4.1.2 优化老年代内存回收比例设置

场景:服务耗时有规律的突刺(实践间隔周期性,耗时涨幅幅度一样),涉及核心链路,需要进行优化

排查步骤:

  1. 根据时间点分析,查询日志分析原因
  2. 发现该时间点进行了full gc
  3. 通过gc日志 和 内存分析,发现服务的内存超过阀值,进行了full gc 且是规律性的,既然超过full gc 的时间规律,那么老年代的内存,会不会规律的卡在某个范围内且不会突破一个上限的阀值呢
  4. 通过调整老年代内存回收比例,将服务发生full gc 的次数减少,即full gc 的周期大大拉长了,服务的接口耗时也相对稳定了很多。

一般稳定在线服务的老年代内存是相对稳定的(不是绝对的),我们可以找到一个回收的平衡点来减少full gc的次数。

4.2 调优经验

  1. 根据服务类型,选择合适的垃圾回收算法
  2. 核心思路:减少Full GC的次数和时间
  3. 不要显式调用 System.gc()
  4. 尽量减少临时对象的使用。在方法结束后,临时对象便成为了垃圾,所以减少临时变量的使用就相当于减少了垃圾的产生,从而减少了GC的次数
  5. 对象不用时最好显式置为 Null。一般而言,为 Null 的对象都会被作为垃圾处理,所以将不用的对象显式地设为 Null 有利于 GC 收集器对垃圾的判定;
  6. 尽量使用 StringBuilder 来代替 String 的字符串累加。因为 String 的底层是 final 类型的数组,所以 String 的增加其实是建了一个新的 String,从而产生了过多的垃圾;
  7. 允许的情况下尽量使用基本类型(如 int)来替代 Integer 对象。因为基本类型变量比相应的对象占用的内存资源会少得多;
  8. 合理使用静态对象变量。因为静态变量属于全局变量,不会被 GC 回收
  9. 小心并避免大对象,size要做控制
  10. 优化老年代内存回收比例设置