JavaGC调优(1)——GC算法和垃圾回收

本章简要介绍GC的算法和垃圾回收器, 下一章节再详细讲解GC算法的实现

目录

一、简介

1.1、标记可达对象(Marking Reachable Objects)

1.2、垃圾的定位(Marking)

1.3、GC垃圾清除算法

1.4、三色标记法

二、GC的回收

2.1、JVM GC怎么判断对象可以被回收了?

2.2、按代的垃圾回收机制

2.3、JVM GC什么时候执行?

三、JVM的垃圾收集器

3.1、Serial(-XX:+UseSerialGC)

3.2、SerialOld(-XX:+UseSerialGC)

3.3、ParallelScavenge(-XX:+UseParallelGC)

3.4、ParallelOld(-XX:+UseParallelOldGC)

3.5、ParNew(-XX:+UseParNewGC)

3.6、CMS (-XX:+UseConcMarkSweepGC)

3.7、GarbageFirst(G1)

3.8、ZGC

3.9、Shenandoah

3.10、Epsilon

四、逃逸分析

4.1逃逸分析的定义

4.2 逃逸分析的方法


一、简介

垃圾收集器都专注于两件事情:(1)查找所有存活对象(2)抛弃其他的部分,即死对象,不再使用的对象

1.1、标记可达对象(Marking Reachable Objects)

标记可达对象,就是存活的对象。有以下四种特定元素被指定为“GC根元素(Garbage Collection Roots)”。 GC遍历(traverses)内存中整体的对象关系图(object graph),从GC根元素开始扫描,到直接引用,以及其他对象(通过对象的属性域)。所有GC访问到的对象都被 标记(marked) 为存活对象。

  • 当前正在执行的方法里的局部变量和输入参数
  • 活动线程(Active threads)
  • 内存中所有类的静态字段(static field)
  • JNI引用

1.2、垃圾的定位(Marking)

垃圾的定位就是怎么去找垃圾,目前主要有两种

  • 引用计数(reference count)
  • 根可达算法(Root Searching)——java使用的算法

1.3、GC垃圾清除算法

  • 标记-清除(Mark-Sweep):在标记阶段完成后, 直接清除所有的垃圾。
  • 复制(Coping):拿一半空闲空间,将所有的存活对象,移动到空闲区
  • 标记-整理(Mark-Compact):将所有存活对象, 移到内存空间的起始处, 压缩空间

1.4、三色标记法

记录哪些对象被扫描

JavaGC调优(1)——GC算法和垃圾回收_第1张图片

  • 白色:未被标记的对象
  • 灰色:自身被标记,成员变量未被标记
  • 黑色:自身和成员变量都被标记

产生漏标:

  • 正在标记时,新增一个黑到白的引用,未对黑色重新处理。
  • 正在标记时,删除了灰到白对象的引用,白对象会被漏标

二、GC的回收

JVM GC只回收堆区方法区内的对象。而栈区的数据,在超出作用域后会被JVM自动释放掉,所以其不在JVM GC的管理范围内。在Java中不能显式的分配和注销缓存,将相关的对象设置成null 来试图显示的清除缓存,但是并不是设置为null 就会一定被标记为可回收,有可能会发生逃逸。使用System.gc()会触发Full GC ,这非常影响性能。

2.1、JVM GC怎么判断对象可以被回收了?

  • 对象没有引用
  • 作用域发生未捕获异常
  • 程序在作用域正常执行完毕
  • 程序执行了System.exit()
  • 程序发生意外终止(被杀线程等)

2.2、按代的垃圾回收机制

默认的新生代(Young generation)、老年代(Old generation)所占空间比例为 1 : 2 。

持久代(Permanent generation)

也称之为 方法区(Method area):用于保存类常量以及字符串常量。注意,这个区域不是用于存储那些从老年代存活下来的对象,这个区域也可能发生GC。发生在这个区域的GC事件也被算为 Major GC 。只不过在这个区域发生GC的条件非常严苛,必须符合以下三种条件才会被回收:

  • 所有实例被回收
  • 加载该类的ClassLoader 被回收
  • Class 对象无法通过任何途径访问(包括反射)

新生代(Young generation)

绝大多数最新被创建的对象都会被分配到这里,由于大部分在创建后很快变得不可达,很多对象被创建在新生代,然后“消失”。(Minor GC):对象从新生代“消失”的过程。

新生代空间的构成(默认空间分配:Eden : Fron : To = 8 : 1 : 1

  • 一个伊甸园空间(Eden)
  • 两个幸存者空间(Fron Survivor、To Survivor)

新生代空间的执行
1、绝大多数刚刚被创建的对象会存放在伊甸园空间(Eden)。
2、在伊甸园空间执行第一次GC(Minor GC)之后,存活的对象被移动到其中一个幸存者空间(Survivor)。
3、此后,每次伊甸园空间执行GC后,存活的对象会被堆积在同一个幸存者空间,两个幸存者空间,必须有一个是保持空的。
4、当一个幸存者空间饱和,还在存活的对象会被移动到另一个幸存者空间。然后会清空已经饱和的那个幸存者空间。
5、在以上步骤中重复N次(N = MaxTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会被移动到老年代。
也有例外出现,对于一些比较大的对象(需要分配一块比较大的连续内存空间)则直接进入到老年代。一般在Survivor 空间不足的情况下发生。

老年代(Old generation)

对象没有被 MinorGC 回收,会被拷贝到这里。其区域分配的空间要比新生代多。也正由于其相对大的空间,发生在老年代的GC次数要比新生代少得多。对象从老年代中回收的过程,称之为:Major GC 或者 Full GC。

老年代空间的构成

只有一个区域,Full GC(Major GC)发生的次数不会有Minor GC 那么频繁,并且做一次Major GC 的时间比Minor GC 要更长(约10倍)。

老年代对象引用新生代的对象

老年代中存在一个 card table ,它是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询 card table 来决定是否可以被回收,而不用查询整个老年代。这个 card table 由一个write barrier 来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。

2.3、JVM GC什么时候执行?

eden区空间不够存放新对象的时候,执行Minro GC。升到老年代的对象大于老年代剩余空间的时候执行Full GC,或者小于的时候被HandlePromotionFailure 参数强制Full GC 。调优主要是减少 Full GC 的触发次数,可以通过 NewRatio 控制新生代转老年代的比例,通过MaxTenuringThreshold 设置对象进入老年代的年龄阀值。

三、JVM的垃圾收集器

JDK1.8的默认GC:"-XX:+UseParallelGC",是 Parallel Scavenge + Parallel Old组合

查看命令:java -XX:+PrintCommandLineFlags -version。

新生代收集器

  • Serial (-XX:+UseSerialGC)
  • ParNew(-XX:+UseParNewGC)
  • ParallelScavenge(-XX:+UseParallelGC)
  • G1 收集器

老年代收集器

  • SerialOld(-XX:+UseSerialOldGC)
  • ParallelOld(-XX:+UseParallelOldGC)
  • CMS(-XX:+UseConcMarkSweepGC)
  • G1 收集器

JavaGC调优(1)——GC算法和垃圾回收_第2张图片

3.1、Serial(-XX:+UseSerialGC)

串行(单线程)收集器,在JDK1.3之前是Java虚拟机新生代收集器的唯一选择。是当JVM需要进行垃圾回收的时候,需暂停所有的用户线程,直到回收结束。

使用算法:复制算法+标记-整理算法

运行流程:新生代采用复制算法,暂停所有的用户线程;老年代采用标记-整理算法,暂停所有的用户线程

3.2、SerialOld(-XX:+UseSerialGC)

SerialOld是Serial收集器的老年代收集器版本,是一个单线程收集器,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

3.3、ParallelScavenge(-XX:+UseParallelGC)

ParallelScavenge又被称为吞吐量优先收集器,和 ParNew 收集器类似,是一个新生代收集器。

ParallelScavenge收集器的目标是达到一个可控件的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。如果虚拟机总共运行了100分钟,其中垃圾收集花了1分钟,那么吞吐量就是99% 。

使用算法:复制算法

运行流程:新生代采用复制算法多线程GC,暂停所有用户线程;老年代采用标记-整理算法,暂停所有用户线程

3.4、ParallelOld(-XX:+UseParallelOldGC)

ParallelOld是并行收集器,和SerialOld一样,ParallelOld是一个老年代收集器,是老年代吞吐量优先的一个收集器。这个收集器在JDK1.6之后才开始提供的,在此之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge整体的速度。而ParallelOld的出现后,“吞吐量优先”收集器才名副其实!

使用算法:标记 - 整理算法

运行流程:新生代采用复制算法多线程GC,暂停所有的用户线程;老年代采用标记-整理算法多线程GC,暂停所有的用户线程

3.5、ParNew(-XX:+UseParNewGC)

ParNew其实就是Serial收集器的多线程版本。除了Serial收集器外,只有它能与CMS收集器配合工作。ParNew是许多运行在Server模式下的JVM首选的新生代收集器。但是在单CPU的情况下,它的效率远远低于Serial收集器,所以一定要注意使用场景。

使用算法:复制算法+标记-整理算法

运行流程:新生代采用复制算法多线程GC,暂停所有的用户线程;老年代采用标记-整理算法,暂停所有的用户线程

3.6、CMS (-XX:+UseConcMarkSweepGC)

响应大于吞吐

CMS是一个老年代收集器,全称 Concurrent Mark and Sweep,是JDK1.4后期开始引用的新GC收集器,在JDK1.5、1.6中得到了进一步的改进。它是对于响应时间的重要性需求大于吞吐量要求的收集器。对于要求服务器响应速度高的情况下,使用CMS非常合适。

CMS特点:就是用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停。

CMS的缺点:

  1. 内存碎片。由于使用 标记-清理 算法,导致产生内存碎片。不过CMS收集器把未分配的空间汇总成一个列表,当有JVM需要分配内存空间时,会搜索列表找到符合条件的空间来存储对象。但内存碎片问题依然存在,如果对象需要3块连续空间来存储,因为内存碎片原因,寻找空间,就会导致Full GC。

  2. 需要更多的CPU资源。由于使用了并发处理,很多情况下都是GC线程和应用线程并发执行的,这样就需要占用更多的CPU资源,也是牺牲了一定吞吐量的原因。

  3. 需要更大的堆空间。因为CMS标记阶段应用程序的线程还是执行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间。CMS默认在老年代空间使用68%时候启动垃圾回收。可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

  4. 漏标。虽然CMS进行两次扫描标记,但是依然会产生漏标问题。这就引出了G1。

使用算法:对年轻代采用并行 标记-复制 算法, 对老年代使用并发 标记-清除 算法。

执行过程:

1)初始标记(STW initial mark)第一次停顿

这个阶段需要虚拟机停顿正在执行的应用线程,官方的叫法STW(Stop Tow World)。从根对象扫描直接关联的对象,并作标记。这个过程会很快的完成。

2)并发标记(Concurrent marking)

在“初始标记”的基础上继续向下追溯标记。注意这里是并发标记,表示用户线程可以和GC线程一起并发执行,这个阶段不会暂停用户的线程哦。

3)并发预清理(Concurrent precleaning)

这个阶段是并发,JVM查找正在执行“并发标记”阶段时候进入老年代的对象(可能这时会有对象从新生代晋升到老年代,或被分配到老年代)。通过重新扫描,减少在一个阶段“重新标记”的工作,因为下一阶段会STW。

4)重新标记(STW remark)第二次停顿

这个阶段会再次暂停正在执行的应用线程,重新从根对象查找并标记并发阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致),并处理对象关联。这一次耗时会比“初始标记”更长,并且这个阶段可以并行标记。

5)并发清理(Concurrent sweeping)

这个阶段是并发的,应用线程和GC清除线程可以一起并发执行。

6)并发重置(Concurrent reset)

这个阶段任然是并发的,重置CMS收集器的数据结构,等待下一次垃圾回收。

3.7、GarbageFirst(G1)

G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

G1和CMS的区别

1、G1垃圾回收器是compacting的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer(撞点)的方式;
2、G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;

G1的特性

G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。

Heap Region

G1垃圾回收器把堆划分成一个个大小相同的Region。在HotSpot的实现中,整个堆被划分成2048左右个Region。每个Region的大小在1-32MB之间,具体多大取决于堆的大小。

Region的状态有三种:young(新生代)old(老年代)Humongous(半满)

每一个Region,它要么是young的,要么是old的。还有一类十分特殊的Humongous。所谓的Humongous,就是一个对象的大小超过了某一个阈值——HotSpot中是Region的1/2,那么它会被标记为Humongous。G1并不要求相同类型的region要相邻。在逻辑上,分代依旧是连续的。

JavaGC调优(1)——GC算法和垃圾回收_第3张图片

其中E代表的是Eden,S代表的是Survivor,H代表的是Humongous,剩余的深蓝色代表的是Old(或者Tenured),灰色的代表的是空闲的region。
每一个分配的Region,都可以分成两个部分,已分配的和未被分配的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。这个做法实际上就是bump-the-pointer(撞点,加速内存分配技术,跟踪在eden创建的最后一个对象,并会放在eden顶部,再新创建对象,只检查eden是否有剩余空间,如果有空间,对象就会被创建在eden顶部。)。每一次的回收,G1会选择可能回收最多垃圾的Region进行回收。与此同时,G1回收器会维护一个空间Region的链表。每次回收之后的Region都会被加入到这个链表中。

内存分配

每一次都只有一个Region处于被分配的状态,被称为current region。在多线程的情况下,这会带来并发的问题。G1回收器采用和CMS一样的TLABs(Thread-Loacl Allocatiuon Buffers,加速内存分配技术,该方案为每一个线程在eden分配一块独享空间Buffer,这样每个线程只访问自己的空间分配内存,再与bump-the-pointer技术结合可以在不加锁的情况下分配内存)的手段。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。

G1的STAB方案(解决漏标问题)

STAB全称Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。如果灰对象到白对象的直接引用或者间接引用被替换了,或者删除了,白对象就会被漏标,从而导致被回收掉,这是非常严重的错误。

SATB算法机制中,在GC开始时创建一个对象快照,在并发标记时所有快照中当时的存活对象就认为是存活的,标记过程中新分配的对象也会被标记为存活对象,不会被回收。这种机制能够很好解决新创建对象漏标的情况。STAB核心的两个结构就是两个Bitmap。Bitmap分别存储在每个Region中,并发标记过程里的两个重要的变量:preTAMS(pre-top-at-mark-start,代表着Region上一次完成标记的位置) 以及nextTAMS(next-top-at-mark-start,随着标记的进行会不断移动,一开始在top位置)。SATB通过控制两个变量的移动来进行标记,移动规则如下:

  • 假设第n轮并发标记开始,将该Region当前的Top指针赋值给nextTAMS,在并发标记标记期间,分配的对象都在[ nextTAMS, Top ]之间,SATB能够确保这部分的对象都会被标记,默认都是存活的。
  • 当并发标记结束时,将nextTAMS所在的地址赋值给previousTAMS,SATB给[ Bottom, previousTAMS ]之间的对象创建一个快照Bitmap,所有垃圾对象能通过快照被识别出来。
  • 第n+1轮并发标记开始,过程和第n轮一样。

JavaGC调优(1)——GC算法和垃圾回收_第4张图片

  • A阶段,初始标记阶段,需要STW,将扫描Region的Top值赋值给nextTAMS。
  • A-B阶段:并发标记阶段。
  • B阶段,并发标记结束阶段,此时并发标记阶段生成的新对象都会被分配在[nextTAMS,Top]之间,这些对象会被定义为“隐式对象”,同时_next_mark_bitmap也开始存储nextTAMS标记的对象的地址。
  • C阶段,清除阶段,_next_mark_bitmap_prev_mark_bitmap会进行交换,同时清理[ Bottom, previousTAMS ]之间被标记的所有对象,对于“隐式对象”会在下次垃圾收集过程进行回收(如第F步),这也是SATB存在弊端,会一定程度产生未能在本次标记中识别的浮动垃圾。

3.8、ZGC

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

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB级别的堆(未来支持16TB)。

与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

ZGC垃圾回收周期如下图所示:JavaGC调优(1)——GC算法和垃圾回收_第5张图片

ZGC只有三个STW阶段:初始标记再标记初始转移

ZGC关键技术

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。

着色指针

着色指针是一种将信息存储在指针中的技术。它直接把标记信息记在引用对象的指针上。尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。

JavaGC调优(1)——GC算法和垃圾回收_第6张图片

3.9、Shenandoah

Shenandoah 的目标是将垃圾回收的停顿时间控制在 10ms 以内,这意味着 Shenandoah 不仅需要在并发标记阶段实现并发,还需要在标记清除阶段实现并发。Shenandoah 与 G1 有很多相同点,都采用了基于 Region 的内存布局,在标记阶段均采用了并发标记。事实上,Shenandoah 在代码实现上使用了很多 G1 的代码,因此 Shenandoah 有很多特点和 G1 是一样的。

Shenandoah 相比 G1 ,至少存在下面 3 处改进。

  1. 在最终的回收阶段,采用的是并发整理,由于和用户线程并发执行,因此这一过程不会造成 STW,这大大缩短了整个垃圾回收过程中系统暂停的时间。

  2. 默认情况下不使用分代收集,也就是 Shenandoah 不会专门设计新生代和老年代,因为 Shenandoah 认为对对象分代的优先级并不高,不是非常有必要实现。(至于不实现分代,对 Shenandoah 性能能带来什么好处,笔者也不是很清楚,猜测原因可能是,不进行分代可能在设计上更简单吧。毕竟 Shenandoah 是 RedHat 公司设计实现的,不是 Oracle 的官方团队,他们从零开始设计,工作量巨大)

  3. 采用“连接矩阵”代替记忆集。在 G1 以及其他经典垃圾回收器中均采用了记忆集来实现跨分区或者跨代引用的问题,每个 Region 中都维护了一个记忆集,浪费了很多内存,且导致系统负载也更重,「因此在 Shenandoah 中摒弃了这种实现方式,而是采用连接矩阵来解决跨分区引用的问题」。

3.10、Epsilon

Epsilon垃圾收集器 是JDK 11中推出的一款完全消极的垃圾回收器。分配有限的内存分配,最大限度降低消费内存占用量和内存吞吐时的延迟时间。

主要用途如下 :

  • 性能测试(它可以帮助过滤掉GC引起的性能假象)
  • 内存压力测试(例如,知道测试用例 应该分配不超过1GB的内存, 我们可以使用-Xmx1g –XX:+UseEpsilonGC, 如果程序有问题, 则程序会崩溃)
  • 非常短的JOB任务(对象这种任务, 接受GC清理堆那都是浪费空间)
  • VM接口测试
  • Last-drop 延迟&吞吐改进

四、逃逸分析

一般认为new出来的对象都是被分配在堆上,通过对Java对象分配的过程分析,可以知道有两个地方会导致Java中new出来的对象并一定分别在所认为的堆上。这两个点分别是Java中的逃逸分析和TLAB(Thread Local Allocation Buffer)。本文首先对这两者进行介绍,而后对Java对象分配过程进行介绍。

4.1逃逸分析的定义

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

4.2 逃逸分析的方法

Java Hotspot编译器使用的是

Choi J D, Gupta M, Serrano M, et al. Escape analysis for Java[J]. Acm Sigplan Notices, 1999, 34(10): 1-19.

 

你可能感兴趣的:(Java组件,jvm,java,垃圾回收)