【GC系列】JVM堆内存分代模型及常见的垃圾回收器

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

1. 内存分代模型

为什么要说JVM的内存分代模型呢,因为内存分代和垃圾回收器的运行是有关系的。

现在大部分用到的垃圾回收器在逻辑上是分代的,除了G1之外的其他垃圾回收器在逻辑上和物理上都是分代的。

  • 除了Epsilon、ZGC、Shenandoah之外的GC都是是逻辑分代模型
  • G1是逻辑分代,物理上不分代
  • 除此之外的不仅逻辑分代,而且物理分代

逻辑分代是给内存做一些概念上的区分,物理分代是真正的物理内存。

具体划分

新生代(young)和老年代(old/tenured)。

新生代:刚new出来的那些对象

老年代:垃圾回收了很多次都没有把它回收掉的老对象

新生代又分为:

  • eden 默认比例是8。新new出来的对象放在eden区。
  • survivor(s1) 默认比例是1。垃圾回收一次之后跑到这个区域,该区域存放的对象不同,采取的垃圾回收算法也不同。
  • survivor(s2) 默认比例是1。

新生代存活的对象较少,使用的垃圾回收算法是拷贝算法(Copying)。

老年代活着的对象较多,垃圾回收算法适用标记压缩(Mark Compact)或者标记清除(Mark Sweep)。

几个GC的概念

  • MinorGC/YGC 新生代空间耗尽时触发的垃圾回收。
  • MajorGC/FullGC 在老年代无法继续分配空间时触发,新生代、老年代同时进行垃圾回收。

分代空间参数配置

-Xms-Xmx

-Xmn

X是非标参数,m是memory内存,s是最小值,x是最大值,n是new

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
分代空间大小参数配置

2. 一个对象的生命历程-从出生到消亡

一个对象被new出来之后,首先尝试进行栈上分配,栈上如果分配不下才会进入eden区;

eden区经过一次垃圾回收之后进入一个survivor区-s1区;

survivor区(s1)经过一次垃圾回收之后又进入另一个survivor区-s2区,同时eden区的某些对象也会跟着进入s2;

当对象年龄到某一个值后,会进入到old区。这个值可以通过参数:

-XX:MaxTenuringThreshold

进行配置。

下面这个图能够帮助我们了解JVM中内存分区的概念。

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
一个对象从出生到消亡的过程

3. 对象如何进行栈上分配

C语言中的struct结构体就可以直接在栈上分配,在Java中也有栈上分配的理念。

在JVM中,堆是线程共享的,因此堆上的对象对于各个线程都是共享和可见的,只要持有对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但对于垃圾收集器来说,无论筛选可回收对象,还是回收和整理内存都需要耗费时间

如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,无须通过垃圾收集器回收,可以减小垃圾收集器的负载

JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能

综上,栈上分配:

  • 线程私有小对象
  • 无逃逸

在某一段代码中使用,出了这段代码就没有其他的代码认识它。

比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。

  • 支持标量替换

用普通的属性、普通的类型代替对象就叫标量替换。

意思是说JVM允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个字段视作局部变量进行分配。

  • 无需调整

站上分配的对象随栈帧出栈而销毁,无需内存调整。

4. 线程本地分配

对象在栈上分配不下了,会优先进行本地分配。

线程本地分配,Thread Local Allocation Buffer,简称TLAB。

很多线程都向Eden区分配对象,分配对象的线程会进行内存空间的征用,谁抢到就算谁的。出现多线程的同步,效率就会降低,因此设计了这个TLAB机制-线程本地分配。

TLAB特征:

  • 占用Eden区的大小模式是1%,这1%的空间时线程独有,分配对象的时候先向这块空间进行分配。
  • 多线程的时候不用竞争Eden区就可以申请空间,效率提高。
  • 分配的是小对象。
  • 无需调整。

我们来测试一下,在栈上分配和TLAB是否提升了效率。

public class TLABTest {

    // -XX:-DoEscapeAnalysis 去掉逃逸分析
    // -XX:-EliminateAllocations 去掉标量替换
    // -XX:-UseTLAB 去掉TLAB
    public static void main(String[] args) {
        TLABTest t = new TLABTest();
        long start = System.currentTimeMillis();
        //执行1000万次alloc
        for (int i = 0; i < 1000_0000; i++) {
            t.alloc(i);
        }
        long end = System.currentTimeMillis();
        System.out.println("spends " + (end - start));
    }

    // 该方法只new一个对象出来,没有任何引用指向他,
    // 除了这个方法就没人认识它,所以没有逃逸
    void alloc(int id) {
        new User(id, "name" + id);
    }

    class User {
        int id;
        String name;

        public User(int id, String name) {
            this.id = id;
            this.name = name;
        }
    }
}

执行时加上

-XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:-UseTLAB
【GC系列】JVM堆内存分代模型及常见的垃圾回收器
  • -XX:-DoEscapeAnalysis 去掉逃逸分析
  • -XX:-EliminateAllocations 去掉标量替换
  • -XX:-UseTLAB 去掉TLAB

运行结果:

spends 1237

然后在去掉-XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:-UseTLAB,也就是默认情况下执行:

spends 643

效率很明显提升了!

5. 常见的垃圾回收器

5.1 Serial

当Serial工作的时候,所有正在工作的线程全部停止。

线程停止有一个safe point(安全点),需要找到一个安全点上进行线程停止,该垃圾回收器停顿时间较长(STW - Stop The World),现在用的较少。

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
Serial

5.2 Serial Old

用于老年代,也是STW,采用的是标记清除算法(Mark-Sweep),单线程。

5.3 Parallel Scavenge

如果JVM没有做过任何参数设定的话,默认就是Parallel ScavengeParallel Old(PS+PO)。

Parallel Scavenge和Serial的区别是PS是多线程清理垃圾。

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
Parallel Scavenge

5.4 Parallel Old

用于老年代,STW,多线程执行,采用标记压缩整理算法(Mark-Compact)。

5.5 ParNew

Parallel NEW的意思,就是Parallel Scavenge的新版本。它就是在PS的基础上做了一些增强一遍它能和CMS配合使用,CMS在某一个特定阶段的时候会和ParNew同时运行。

ParNew工作的时候其余线程不能工作,必须等GC结束才行。

它工作时也STW,采用的是Copying算法,多线程执行。

5.6 CMS

前面几种垃圾回收器有一个共性就是STW,就是我垃圾回收器在工作的时候其他人都不许动,得等着,我干完了,你们才能继续工作。

CMS的诞生就是试图解决这个问题。然而CMS本身的问题很多,目前任何JDK版本默认的垃圾回收器都不是CMS。

CMS - Concurrent Mark Sweep,并发标记清除。从线程的角度看,CMS进行垃圾回收的时候和工作线程同时进行,但是它依然很慢。

在以往内存较小的时候,速度很快,但现在服务器内存已经很大了,相当于原来在10平米的房间内清理垃圾,现在需要在100平米甚至更大的空间内清理垃圾,即使使用多线程来清理也需要很长的时间。

CMS的四个阶段

  1. CMS initial mark 初始标记阶段

该阶段STW直接找到最根上的对象并标记,其他对象不标记。

  1. CMS concurrent mark 并发标记阶段

GC 80%的时间浪费在并发标记阶段。所以该阶段和工作线程同时运行,客户端可能感觉响应变慢了,但是至少还有点反应。

工作线程一边产生垃圾,一边对垃圾进行标记,这个过程不可能标记完,所以了重新标记阶段。

  1. CMS remark 重新标记阶段

这也是一个STW,在并发标记阶段产生的新垃圾,在该阶段进行重新标记一下,需要工作线程停下来,时间不是很长。

  1. CMS concurrent sweep 并发清理阶段

该阶段由于有工作线程也在运行,因此在执行过程中会产生新的垃圾,这时候的垃圾叫浮动垃圾浮动垃圾在下一次CMS运行的时候再把它清理掉

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
CMS

CMS的触发条件

老年代分配不下了会触发CMS,其初始标记是单线程,重新标记是多线程。

CMS的缺点

  • Memory Fragmentation 内存碎片

如果内存很大,一旦老年代产生了很多内存碎片的时候,从年轻代进入到老年代的对象就找不到空间了-PromotionFailed。

这时,CMS请出了Serial Old这个上古时代的回收器使用单线程进行标记压缩,那效率就可想而知了。

-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction 默认为0 指经过多少次FGC才进行压缩
  • Floating Garbage 浮动垃圾

可以通过参数配置降低触发CMS的阈值。

-XX:CMSInitiatingOccupancyFraction 92% 可以降低该值,让老年代有足够的空间

5.7 G1

G1也能并发进行垃圾回收,与CMS相比,其优点如下:

  • G1在GC过程中会进行整理内存,不会产生很多内存碎片
  • G1的STW更可控,可以指定可期望的GC停顿时间

在G1中将内存区域划分为多个不连续的区域(Region),每个Region内部是连续的。

【GC系列】JVM堆内存分代模型及常见的垃圾回收器
G1内存区域划分

在划分的区域中H区(Humongous),这表示这些Region存储的是巨大对象(humongous object,H-obj)-大小大于等于region一半的对象

一个Region的大小可以通过参数

-XX:G1HeapRegionSize

设定,取值范围从1M到32M,且是2的指数。如果不设定,那么G1会根据Heap大小自动决定。

5.8 ZGC

ZGC是一种并发的不分代的基于Region且支持NUMA的压缩收集器。因为只会在枚举根节点的阶段STW, 因此停顿时间不会随着堆大小或存活对象的多少而增加

ZGC的目标

  • 垃圾回收停顿时间不超过10ms
  • 无论是相对小的堆(几百MB)还是大堆(TB级)都能应对自如
  • 与G1相比,吞吐量下降不超过15%
  • 方便日后在此基础上实现新的gc特性、利用colored pointers和读屏障进一步优化收集器

6. 参考资料

  1. Getting Started with the G1 Garbage Collector
  2. Java Garbage Collection Basics
  3. https://wiki.openjdk.java.net/display/zgc/Main

首发公众号 行百里er,欢迎老铁们关注阅读指正。也可访问我的 GitHub github.com/xblzer/JavaJourney


原文始发于微信公众号(行百里er):【GC系列】JVM堆内存分代模型及常见的垃圾回收器