JVM:02.垃圾回收

一、如何判断对象可以回收

1. 引用计数法

被引用就+1,不被引用就-1,清零就回收。但不能解决循环引用的问题

2. 可达性分析算法

JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象。扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收

可以作为 GC Root 的对象

  • 栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中本地方法引用的对象

查看GC Root 可以使用Eclipse Memory Analyzer 工具进行分析,略过

3. 四种引用

3.1 强引用

普通变量赋值即为强引用,如 A a = new A();

通过 GC Root 的引用链,如果强引用不到该对象,该对象才能被回收

image-20210901111903574

3.2 软引用(SoftReference)

例如:SoftReference a = new SoftReference(new A());

image-20210901111957328

如果仅有软引用该对象时,首次垃圾回收不会回收该对象,如果内存仍不足,再次回收时才会释放对象

软引用自身想释放时需要配合引用队列来释放

典型例子是反射数据

3.3 弱引用

例如:WeakReference a = new WeakReference(new A());

image-20210901112107707

如果仅有弱引用引用该对象时,只要发生垃圾回收,就会释放该对象

弱引用自身需要配合引用队列来释放

典型例子是 ThreadLocalMap 中的 Entry 对象

3.4 虚引用

例如: PhantomReference a = new PhantomReference(new A(), referenceQueue);

image-20210901112157901

必须配合引用队列一起使用,当虚引用所引用的对象被回收时,由 Reference Handler 线程将虚引用对象入队,这样就可以知道哪些对象被回收,从而对它们关联的资源做进一步处理

典型例子是 Cleaner 释放 DirectByteBuffer 关联的直接内存

3.5 终结器引用

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。

二、垃圾回收及其算法

1.垃圾回收

GC 的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度

GC 要点:

  • 回收区域是堆内存,不包括虚拟机栈
  • 判断无用对象,使用可达性分析算法三色标记法标记存活对象,回收未标记对象
  • GC 具体的实现称为垃圾回收器
  • GC 大都采用了分代回收思想
  • 根据 GC 的规模可以分成 Minor GCMixed GCFull GC

2. 垃圾回收算法

1. 标记清除法

image-20210831211008162

找到 GC Root 对象,即那些一定不会被回收的对象,如正执行方法内局部变量引用的对象、静态变量引用的对象。

  • 标记阶段:沿着 GC Root 对象的引用链找,给直接或间接引用到的对象加上标记。标记速度与存活对象线性关系
  • 清除阶段:释放未加标记的对象占用的内存。清除速度与内存大小线性关系

优点:速度较快

缺点:会产生内存碎片

2. 标记整理法

image-20210831211641241

  • 标记阶段、清理阶段与标记清除法类似。标记速度与存活对象线性关系
  • 多了一步整理的动作,将存活对象向一端移动,可以避免内存碎片产生。清除与整理速度与内存大小成线性关系

优点:避免内存碎片产生

缺点:性能上较慢

3. 标记复制法

image-20210831212125813

将整个内存分成两个大小相等的区域,from 和 to,其中 to 总是处于空闲,from 存储新创建的对象

  • 标记阶段与前面的算法类似
  • 在找出存活对象后,会将它们从 from 复制到 to 区域,复制的过程中自然完成了碎片整理
  • 复制完成后,交换 from 和 to 的位置即可

优点:避免内存碎片产生

缺点:占用成倍的空间

三种垃圾回收算法JVM会根据不同的情况使用,协同工作。新生代采用标记复制法、老年代一般采用标记整理法

标记的方法:三色标记法

  • 黑色 – 已标记
  • 灰色 – 标记中
  • 白色 – 还未标记

起始的三个对象还未处理完成,用灰色表示,该对象的引用已经处理完成,用黑色表示,黑色引用的对象变为灰色,依次类推,沿着引用链都标记了一遍,最后为标记的白色对象,即为垃圾

6221a99342802

并发漏标问题,暂时略过(黑马-面试专题-JVM)

三、分代垃圾回收

1. 新生代与老年代

Java会将堆内存等分为两块:新生代老年代

  • 大部分对象朝生夕灭,用完立刻就可以回收,存放于新生代,回收的比较频繁。

  • 另有少部分对象会长时间存活,每次很难回收,存放于老年代,回收不太频繁

image-20220303134410492

新生代又细分为:伊甸园(eden)、幸存区From、幸存区To。

  • 新创建的对象首先分配在eden,eden空间不足时,触发垃圾回收(新生代的垃圾回收一般称为 minor gc ),eden中存活的对象复制到幸存区To中,存活的对象年龄加一,然后交换 from to

  • minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行

    image-20220303135222534

  • 接着继续往eden中添加对象,eden空间不足时,再次触发垃圾回收,eden和幸存区From中幸存的对象复制到幸存区To中,存活的对象年龄再加一,然后交换 from to (交换之后的幸存区To都是空的)。接着再向eden里添加新元素……

    image-20220303135734519

  • 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(对象头中4bit用来存放,所以最大就15)。有的情况是内存实在有限,没加几次就直接晋升老年代了

    • 还有一种现象是大对象直接晋升老年代,判断了eden里放不下,就去老年代看看能不能放下,能放下就直接放在老年代
  • 当老年代空间不足时,会先触发 minor gc,如果空间仍然不足,那么就触发 full fc ,停止的时间更长!full GC 之后如果空间还是不足,则触发OOM

    image-20220303140236127

2. GC相关JVM参数

相关JVM参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx 或 -XX:MaxHeapSize=size
新生代大小 -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRatio=ratio
晋升阈值 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
打印GC详情 -XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC -XX:+ScavengeBeforeFullGC

3. GC分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Code_10_GCTest {

private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_6MB]);
list.add(new byte[_512KB]);
list.add(new byte[_6MB]);
list.add(new byte[_512KB]);
list.add(new byte[_6MB]);
}
}

通过上面的代码,给 list 分配内存,来观察 新生代和老年代的情况,什么时候触发 minor gc,什么时候触发 full gc 等情况,使用前需要设置 jvm 参数。

4. GC规模

  • Minor GC 发生在新生代的垃圾回收,暂停时间短
  • Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1回收器特有(后面会讲)
  • Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免

如果是新的子线程里的内存溢出,并不会影响主线程或者其他线程的正常运行

四、垃圾回收器

部分相关概念:

  • 并行回收:指多条垃圾回收线程并行工作,但此时用户线程仍处于等待状态。
  • 并发回收:指用户线程与垃圾回收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上
  • 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。

垃圾回收器一般分为:串行、吞吐量优先、响应时间优先

1. Serial GC:串行

VM指令:

1
-XX:+UseSerialGC=serial + serialOld   // serial GC (新生代)+  serialOld(老年代)

Serial GC

串行回收器,单线程,eden 内存不足发生 Minor GC,采用标记复制算法,需要暂停用户线程

收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束,这也是著名的STW问题(Stop The World!)

image-20220303154827368

SerialOld GC

是 Serial 回收器的老年代版本,old 内存不足发生 Full GC,采用标记整理算法,需要暂停用户线程

整体来说适用于堆内存较少的情况

2. Parallel GC:吞吐量优先

VM指令:

1
-XX:+UseParallelGC ~ -XX:+UsePrallerOldGC    // UseParallelGC(新生代) +  UsePrallerOldGC(老年代)

Parallel GC

并行回收器,多线程,eden 内存不足发生 Minor GC,采用标记复制算法,需要暂停用户线程 , 注重吞吐量

image-20220303155615577

吞吐量优先就是暂停用户线程,将其他线程都用去GC,这样短时间内可以处理大量垃圾,让STW时间最短。缺点就是需要暂停用户线程

Parallel Old GC
Parallel GC老年代版本, old 内存不足发生 Full GC,采用标记整理算法,需要暂停用户线程

细节:

默认开启的收集线程数与CPU的数量相同。该收集器的目标是达到一个可控制的吞吐量。可以使用下面的参数来限制垃圾收集的线程数:

1
-XX:ParallelGCThreads=n

Parallel GC 还可设置自适应调节的参数。自适应调节策略:开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略

1
-XX:+UseAdaptiveSizePolicy

ParallelGC 收集器可以使用两个参数控制吞吐量:

1
2
XX:MaxGCPauseMillis=ms      // 控制最大的垃圾收集停顿时间(默认200ms)
XX:GCTimeRatio=rario=ratio // 1/(1+radio) 直接设置吞吐量的大小

3. CMS GC:响应时间优先

VM指令:

1
2
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
// ParNewGC(新生代,同ParallelGC) + CMSGC (老年代) + SerialOld (老年代,CMSGC并发失败就用这个补救)

CMS GC

Concurrent Mark Sweep GC,(并发标记清除),并发回收器,一种以获取最短回收停顿时间为目标的老年代回收器

  • 它是工作在 old 老年代,支持并发标记的一款回收器,采用并发清除算法。注重响应时间
    • 并发标记时不需暂停用户线程
    • 重新标记时仍需暂停用户线程
  • 如果由于内存碎片或者浮动垃圾过多而并发失败(即回收速度赶不上创建速度),会触发 Full GC,耗费时间较长

特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片

image-20220303162716397

细节:CMS 收集器的运行过程分为下列4步:

  • 初始标记:仅仅标记GC Roots能直接关联到的对象,速度很快,需要“Stop The World”
  • 并发标记:从GC Roots的直接关联对象开始遍历所有对象的过程,耗时较长但不需要停顿用户线程
  • 重新标记:并发标记期间,并发运行的用户线程可能会产生垃圾,因此需要对整个堆空间进行重新标记,需要STW。耗时长
  • 并发清除:对标记的对象进行清除回收,用户线程可并发执行。但此时的用户线程还是可能会产生新垃圾,这些垃圾就叫浮动垃圾,只能由下一次GC来处理,因此堆内存也需要预留一些空间(这个比例由下面第二个参数决定)给这些浮动垃圾,不能等整个堆内存都满了才GC。浮动垃圾如果也非常多,内存不够放,就会并发失败

响应时间优先:由于第2、4步可以并发执行用户线程,所以整个程序的响应时间比较快,称为响应时间优先

并发失败:由于CMSGC使用的是标记清除,可能导致会有很多内存碎片,或者是浮动垃圾过多,都会进而导致并发失败。老年代就会退化为serialOld回收器,进行标记整理,很耗费时间,这也是CMSGC的一个缺点

应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务

搭配使用:CMS 收集器的内存回收过程是与用户线程一起并发执行的,可以搭配 ParNew 回收器(多线程,新生代,复制算法)与 Serial Old 收集器(单线程,老年代,标记整理算法)使用。

其他细节:

1
2
3
4
5
6
// 手动设置并行线程数 和 用来GC的线程数(一般为其前者的四分之一)
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
// CMSGC的内存占比,即达到堆内存的百分之多少,进行GC,比例越小,GC越早
-XX:CMSInitiatingOccupancyFraction=percent
// 见老年代调优案例1
-XX:+CMSScavengeBeforeRemark

4. G1 回收器

定义: Garbage First

从JDK9变为默认GC,JDK9废弃了之前的CMS回收器。

它将堆内存划分为多个大小相等的区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备。

特点:

  • 同时注重吞吐量和响应时间(低延迟)
  • 适合超大堆内存
  • 整体上是标记整理算法,两个区域之间是复制算法

相关参数:
JDK8 并不是默认开启的,所需要参数开启。9以后就不用开了

1
2
3
-XX:+UseG1GC
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time

G1 垃圾回收阶段:

image-20220303163644010

4.1 G1垃圾回收阶段

(1)Young Collection

新生代回收:回收新生代垃圾 E:eden,S:幸存区,O:老年代

  1. 初始时,所有区域都处于空闲状态

  2. 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象

  3. 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程

  4. 复制完成,将之前的伊甸园内存释放

  5. 随着时间流逝,伊甸园的内存又有不足

  6. 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代

  7. 释放伊甸园以及之前幸存区的内存

6220affb65f9c

(2)Young Collection + CM

新生代回收+并发标记:

  1. 在 Young GC 时会进行 GC Root 的初始标记

  2. 当老年代占用内存超过阈值后,进行并发标记,这时无需暂停用户线程,阈值由下面的参数决定

    1
    -XX:InitiatingHeapOccupancyPercent=percent (默认45%)
    image-20210831222813959

(3)Mixed Collection

会对新生代 + 老年代 + 幸存区等进行混合回收,然后回收结束,重新进入新生代收集。

  1. 上一步的并发标记之后,会有最终标记阶段解决漏标问题,此时需要暂停用户线程。这些都完成后就知道了老年代有哪些存活对象

    image-20210831222828104
  2. 进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(这几个地方垃圾多,回收了能释放的空间多)的区域(图中标红的O)(这个策略也是Gabage First 名称的由来)

    暂停时间目标由此命令决定。回收的少自然暂停的时间就短

    1
    -XX:MaxGCPauseMillis=ms

    下图显示了伊甸园和幸存区的存活对象复制 与 老年代和幸存区晋升的存活对象的复制。两个复制阶段也会STW

image-20210831222859760
  1. 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集

    image-20210831222919182

G1的Full GC

G1 在老年代内存不足时(老年代所占内存超过阈值)

如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理

如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC,然后退化成 serial Old 收集器串行的收集,就会导致停顿的时候长。

4.2 G1 新生代跨代引用

4.3 …

黑马视频后面还有些细节内容暂时略过,碰到再说

5. 回收器对比

不同回收器的Full GC情况不同,新生代的GC都可以叫minor gc,

  • SerialGC(串行)
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • ParallelGC(并行)
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • CMS(并发)
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,且垃圾产生速度快于垃圾回收速度,便会触发 Full GC
  • G1(并发)
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,且垃圾产生速度快于垃圾回收速度,便会触发 Full GC

五、垃圾回收调优

预备知识:

  • 掌握 GC 相关的 VM 参数,会基本的空间调整

  • 掌握相关工具

  • 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

查看当前所有JVM虚拟机参数命令,在Terminal输入:

1
java  -XX:+Printjava FlagsFinal -version | findstr "GC"

1. 调优领域

gc只是调优的一个领域而已,其他还有:内存、锁竞争、cpu 占用、io等

2. 调优目标

低延迟:CMS(大部分公司也在用) G1(jdk9开始设为默认GC) ZGC

高吞吐量:ParallelGC

3. 最快的 GC 是不发生GC

首先排除减少因为自身编写的代码而引发的内存问题

  • 数据是不是太多?
    resultSet = statement.executeQuery(“select * from 大表”) –> 应该加个 limit n
  • 数据表示是否太臃肿
    • 对象图 –> 只查自己所需要的就行
    • 对象大小 引用一个Object最小是16字节, 包装类型是Integer 24个字节, 但int 等基本数据类型只要4个字节,能用基本类型可以用基本数据类型
  • 是否存在内存泄漏
    • static Map map等结构 …
    • 解决:软引用、弱引用、第三方缓存实现(redis等)

4. 新生代调优

新生代的特点:

  • 所有的 new 操作分配内存都是非常廉价的,伊甸园中分配很快
    • TLAB (thread-lcoal allocation buffer):每个线程自己私有的一点点内存进行内存分配,多个线程同时分配内存时不会干扰
  • 死亡对象回收零代价(因为都是标记复制,没用的对象直接释放掉内存)
  • 大部分对象用过即死(朝生夕死)
  • Minor GC 所用时间远小于 Full GC

因为新生代的这些特点,所以GC调优也是先从新生代开始,那么如何新生代调优?

  • 加大新生代内存空间,但大小有说法
    • 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
    • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长
    • 总的来说新生代还是要尽可能地设大,Oracle官方给出的建议是占据1/4到1/2为宜。新生代GC算法一般是标记复制,复制占的时间比标记长,但因为朝生夕死,这些时间也不会太长
    • 新生代内存设置为能容纳:[ 并发量 * ( 请求-响应 ) ] 的数据为宜
  • 幸存区需要能够保存 当前活跃对象+需要晋升的对象。幸存区太小,JVM会动态的调整晋升的阈值,有些其实也不是太重复用的对象,直接给晋升了,那就得等老年代GC后才能回收,无形中延长了这部分对象的寿命。因此我们希望这些存活时间短的对象留在幸存区,以便新生代GC就直接回收掉
  • 晋升阈值配置得当,让长时间存活的对象尽快晋升。真正长时间存活的对象如果一直留在新生代,又要耗费幸存区内存又浪费新生代GC时间

新生代调优案例:Full GC 和 Minor GC 频繁

分析:新生代内存太小导致Minor GC 频繁,同时幸存区太小使得JVM把存活时间短的对象晋升到老年区,导致频繁Full GC

解决:加大新生代内存空间,适当增加晋升阈值,让存活时间短的对象留在幸存区

5. 老年代调优

以CMS为例

  • CMS 的老年代内存越大越好

  • 先尝试不做调优,如果没有 Full GC 那么老年代空间很充裕,不用扩大。即使发生Full GC,也应该先尝试调优新生代。

  • 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3

  • 调整老年代空间占用了多少时,进行GC,即给浮动垃圾留一定空间。越低则触发GC越早,一般是75%-80%

    1
    -XX:CMSInitiatingOccupancyFraction=percent

老年代调优案例1:请求高峰期发生 Full GC,单次暂停时间特别长(CMS)

分析:确定是CMS的前提下,四个阶段前两个耗时很短,第三个重新标记耗费时间较长,它是重新扫描整个推空间。如果在重复标记时刚好业务大量发生,在新生代创建了大量对象,那么这个重新标记的时间就会很长。

解决:因此可以在重复标记之前先对新生代进行一次GC,下面这个参数就是这个设置的开关

1
-XX:+CMSScavengeBeforeRemark

老年代调优案例2:jdk1.7环境,采用CMS GC,老年代充裕情况下,发生 Full GC

分析:jdk1.7 及以前,堆是放在永久代里的,永久代空间设置小了,会触发整个堆的一次Full GC。

解决:增大永久代的初始值和最大值


JVM:02.垃圾回收
http://jswanyu.github.io/2022/04/10/JVM/02-垃圾回收/
作者
万宇
发布于
2022年4月10日
许可协议