Java 垃圾回收详解

图片 10

三、GC的种类

Hotspot会根据宿主机的硬件特性和操作系统类型,将之分为客户端型(client-class)或者服务器型(server-class),如果是服务器型主机,Java
9之前默认使用Parallel GC,Java
9中默认使用G1。对于服务器型主机的选择标准是「CPU核心数大于1,内存大于2GB」,所以现在大部分的主机都可以认为是服务器型主机。

这里讨论的所有GC都是基于分代垃圾回收算法的。

CMS GC(-XX:UseConcMarkSweepGC)

图片 1

图5:串行 GC 和 CMS GC

如你所见,CMS GC 比我们前面所介绍的任何 GC 都要复杂的多。刚开始的
初始标记
步骤很简单。离类加载器最近的对象中的存活对象被搜索出来。所以,暂停时间很短。在并发标记步骤中,刚才已经确认的存活对象所引用的对象被跟踪并检查。这一步的差别在于它在处理的同时其他线程同时也在处理。在重新标记阶段,新添加的对象或者在并发标记阶段被停止引用的对象会被检查。最后,并发清除阶段,垃圾回收过程被执行。垃圾回收在其他线程还在进行的时候就执行。因为这一类型的
GC 是以这样的方式执行,GC 的暂停时间很短。CMS GC也被称作低延时
GC,所以当响应时间对所有的应用都很关键的时候使用这种 GC。

CMS GC 拥有较短的全局暂停时间这一优点,同时也有以下缺点。

  • 它比其他类型的 GC 使用更多的内存和 CPU
  • 默认没有提供压缩算法。

在使用这种 GC
之前需要认真检查。同时,如果多个内存碎片需要压缩,全局暂停时间的时间会比任何其他类型的
GC 都要长。所以你需要确认压缩任务执行的频率和时间。

2. 在老年代中进行标记

老年代的GC算法可以大致是认为是一个标记-整理(Mark-Compact,其实是混合了标记-清理,标记-复制和标记-整理)算法,所以老年代的垃圾清理首先要做的就是在老年代对存活的对象(可达性分析,关于不同的可达性可以参考JDK解构

Java中的引用和动态代理的实现)进行标记,对于寻求大吞吐量的服务器应用来说,这个过程往往需要是并发的。

标记的过程发生在Major
GC被触发之后,不同的GC对于MajorGC的触发条件和标记过程的实现也不尽相同。

Parallel GC(-XX:+UseParallelGC)

图片 2

图4:Serial GC 和 Parallel GC 之间的差别

从这张图片上很容易发现Serial GC 和 Parallel GC 之间的差异。Serial GC
只是用一个线程执行 GC,parallel GC 使用多个线程执行
GC,所以更快。当内存足够并且 CPU 内核够多时这种 GC
非常有用。它也被称作”吞吐量 GC(throughput GC)。“

一些简单的GC调优方法

G1 GC

最后,我们一起来看一下垃圾优先(G1)GC。

图片 3

图6:G1 GC 的布局

如果你想理解 G1
GC,忘掉你所知道的新生代和老年代的所有一切。如上图所示,每一个对象被分配到每个网格中,然后会执行
GC。一旦一个区域被填满,对象就会被分配到另一个区域,然后执行一次
GC。在G1 GC
中,将数据从新生代的3个区域移动到老年区的所有步骤都不存在。G1 GC
的创建时用于替换 CMS GC,因为从长远看后者会引发很多问题。

G1 GC 最大的优点是性能。它比我们前面讨论过的任何 GC
类型都要快。但是在 JDK 6中,这是一个所谓的早期版本所有只能用于测试。JDK
7的官方版本中已经包含这一类型 GC。以我个人的意见,我们在将 JDK 7应用到
NHN
的实际服务之前需要很长的时间的测试(至少一年),所以你可能需要等待一段时间。同时我听说了几次在
JDK 中使用 G1 GC 后JVM出现崩溃。所以请继续等待直到它更稳定。

本文译自:Understanding Java Garbage
Collection

大对象的分配

正常情况下,一个对象会在年轻代的Eden中创建,然后通过垃圾回收和年龄管理之后,晋升到老年代。但对于某些比较大的对象,可能会直接分配到老年代去。

对于G1,对象大多数情况都会在Eden上分配,如果JVM判断一个对象为大对象(其阈值可以通过-XX:G1HeapRegionSize来设置),则会直接分配如老年代的大对象区域中。

对于其他的内存区域连续的GC,下面是从StackOverflow上搬运过来的对象在堆上的分配过程:

  1. 使用 thread local allocation buffer (TLAB),
    如果空间足够,则分配成功。
    从名称便可知,TLAB是线程独占的,所以线程安全,且速度非常快。如果一个TLAB满了,线程会被分配一个新的TLAB。
  2. 如果TLAB
    空间不够这次分配对象,但其中还有很多空间可用,则不使用TLAB,直接在Eden中分配对象。
    直接在Eden上分配对象要去抢占Eden中的指针操作,其代价较使用TLAB要大一些。
  3. 如果Eden的对象分配失败,出发Minor GC。
  4. 如果Minor GC完成后还不够,则直接分配到老年代。

垃圾回收的来源

Java 不会在代码中手动指定一块内存再释放它。有的开发者会将相关对象置为
null 或者使用 System.gc() 方法手动释放内存。设置为 null
不是什么大问题,但是调用 System.gc()
方法会剧烈的影响系统的性能,所以不应该使用。(幸好,我还没有看到 NHN
的开发者有使用这个方法。)

在 Java
中,由于开发者不需要在代码中手动释放内存,垃圾搜集器会查找不需要的对象(垃圾)并释放它们。垃圾搜集器基于以下两条假设创建(称它们为推测或者先决条件也许更准确)

  • 大多数对象很快变成不可达。
  • 只存在少量从老的对象到新对象的引用

这些假设称为“弱分代假设”(weak generational
hypothesis)
,为了强化这一假设,HotSpot
虚拟机在物理上分为两个部分-新生代(young generation)
老年代(old generation)

新生代:大多数新创建的对象都存放在这里。因为大多数对象很快就会变得不可达,很多对象都在新生代创建,然后就消失。当一个对象从这个区域消失的时候,我们就说发生了一次“小的
GC”(minor GC)

老年代:那些在新生代存活下来,并没有变成不可达的对象被复制到这里。它通常要比新生代大。由于容量更大,GC
发生的次数就没有新生代频繁。当对象从老年代消失时,我们就说发生了一次“大
GC”(major GC)
(或者是 “全 GC”(full GC))。

我们一起来看一下这幅图:

图片 4

图1:GC 区域和 数据流程

上图中的持久代(permanent generation)通常也称为“方法区(method
area)”
,它用于存储类或者字符常量。所以这个区域不是用于永久存储从老年代存活下来的对象。这个区域也可能会发生
GC。这个区域发生的 GC 也算作大 GC。

有人可能会想:

如果一个处于老年代的对象需要引用一个处于新生代的对象会怎么样?

为了解决这个问题,在老年代有一个称为“card
table”
的东西,是一个512字节大小的块。当老年代中的对象要引用一个新生代的对象时,它就会被记录在这个
table 中。当新生代执行 GC 的时候,只需要搜索这个 table
来确定它是否属于需要 GC 的对象,而不用检查老年代所有引用的对象。card
table 通过 write barrier 管理。write barrier 给小 GC
性能上带来极大的提升。尽管会有一点额外的开销,但是 GC 的总体时间减少了。

图片 5

图2:Card Table 的结构

3. 使用G1

从Java
9开始G1变成了默认的GC,G1中有一些细节的概念在上文中没有叙述,这里先介绍一下:

  1. Remembered
    Sets(Rsets):对于每个区域,都有一个集合记录这个区域中所有的引用。
  2. G1 refinement:G1中需要有一系列的线程不断地维护Rsets。
  3. Collection
    Sets(Csets):在垃圾回收中需要被回收的区域,这些区域中的可达对象(活着的对象)会被疏散。这些区域可能是任何年龄代。
  4. 写屏障(Write
    Barriers):对于每一次赋值操作,G1都会有两个写屏障,写之前(Pre-Write)一个,写之后(Post-Write)一个。Pre-write主要与SATB相关,Post-write主要与Rsets相关
  5. Dirty Card
    Queue:写屏障会将写的记录放入这个队列,会有线程将这里的对象不断的刷入Rsets。
  6. Green/Yellow/Red Zone:三个会影响处理Dirty Card
    Queue线程数的阈值。根据Dirty Card
    Queue中元素的个数,可以来设置一些GC行为(可以认为是逻辑上将Dirty
    Card
    Queue分隔成多个区域)。Green表示超过此阈值则开始新建线程来处理这个队列,Yellow表示超过此阈值,强制启动这些线程,Red表示超过此阈值则会让写操作的线程自己来执行G1
    refinement。

G1提供了丰富的基于不同目的的可调优的参数,列表如下:

参数 描述
-XX:+G1UseAdaptiveConcRefinement, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementGreenZone=<ergo>, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementYellowZone=<ergo>, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementRedZone=<ergo>, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementThreads=<ergo> 调节G1 refinement所使用的资源。
-XX:G1RSetUpdatingPauseTimePercent=10 调节G1 refinement所需要的时间在整个垃圾回收时间的比例,G1会根据这个时间动态地调节第一行的各个参数。
-XX:+ReduceInitialCardMarks 批量执行对象的生成,以减少初始标记的时间
-XX:-ParallelRefProcEnabled 使用多线程处理上文中所说的在重新标记阶段对引用的处理
-XX:G1SummarizeRSetStatsPeriod=<n> 设置n次垃圾回收后,打印Rsets的总结性报告。
-XX:GCTimeRatio=<n> 设置GC吞吐量。GC总共应该使用的时间是1 / (1 + n),这个参数会影响不同年龄代尺寸的增长。
-XX:G1HeapRegionSize 设置区域的大小

主要参考文档:

  1. Getting Started with the G1 Garbage
    Collector
  2. Garbage-First Garbage Collector
    Tuning
  3. Evaluating and improving remembered sets in the HotSpot G1 garbage
    collector
  4. G1GC
    Internals
  5. GC Algorithms:
    Basics
  6. Java中几种常量池的区分

新生代的组成

为了理解 GC,
我们先了解一下新生代,也就是对象第一次被创建的地方。新生代被分成3个区域。

  • 一个 Eden
  • 两个 存活(Survivor)

总共3个区域,其中两个是存活区。每一个区域的执行顺序是这样的:

  • 1、大部分新创建的对象都处于 Eden 区
  • 2、在 Eden 区域执行第一次 GC
    以后,存活下来的对象被移动到其中一个存活区。
  • 3、在 Eden 区域再次执行 GC
    以后,存活下来的对象继续堆积已经有对象的那个存活区。
  • 4、一旦一个存活区被存满,存活对象就会被移动到另一个存活区。然后被存满的那一个存活区数据就会被清掉(修改为无数据状态)。
  • 5、如此反复一定次数之后,还处于存活状态的对象被移动到老年区。

如果你仔细检查这些步骤,存活区域总是有一个是空的。如果两个存活区域同时都有数据,或者同时都为空,这意味着你的系统存在问题

通过小 GC 将数据堆积到老年代的过程可以参考下图:

图片 6

图3:GC 前后

注意在 HotSpot
虚拟机中,有两种技术用于快速内存分配。一个成为“bump-the-pointer”,另一个称为“TLABs(Thread-Local
Allocation Buffers)”

Bump-the-pointer 技术跟踪 Eden 区域最后分配的对象。那个对象将处于
Eden 区域的顶部。如果有新的对象需要创建,只需要检查对象的大小是否适合
Eden 区域。如果合适,新的对象将被放在 Eden
区域,并且新的对象处于顶部。所以,当创建新的对象时,只需要检查上一次创建的对象,这样可以做到较快的内存分配。但是,如果是在多线程环境那将是另外一个场景。为了保证
Eden
区域多线程使用的对象是线程安全的,将不可避免的使用锁,这会导致性能的下降。HotSpot
虚拟机使用 TLABs 来解决这个问题。使用 TLABs 允许每一个线程在 Eden
区域有自己的一小块分区。由于每一个线程只能访问它们自己的 TLAB,即使是
bump-the-pointer 技术也可以不使用锁就分配内存。

到现在我们快速的概述了新生代的
GC。你不必完全记住我刚才所提到的两种技术。你不知道它们也没什么大不了。但是请记住:对象是在
Eden 区域创建,然后长期存活的对象通过存活区移动到老年代。

在经过了几次跳票之后,Java
9终于在原计划日期的整整一年之后发布了正式版。Java
9引入了很多新的特性,除了闪瞎眼的Module
System
REPL,最重要的变化我认为是默认GC(Garbage
Collector)修改为新一代更复杂、更全面、性能更好的G1(Garbage-First)。JDK的维护者在GC选择上一直是比较保守的,G1从JDK
1.6时代就开始进入开发者的视野,直到今天正式成为Hotspot的默认GC,也是走了很长的路。

老年代的 GC

老年代在数据存满时会执行 GC。各种 GC
的执行过程因类型而异,所以如果你知道不同类型的 GC, 理解起来会容易一些。

在 JDK 7中,一共有5中类型的 GC。

  • 1、Serial GC
  • 2、Parallel GC
  • 3、Parallel Old GC(Parallel Compacting GC)
  • 4、ConCurrent Mark & Sweep GC (CMS)
  • 5、Garbage First(G1)GC

所有这些 GC 当中,serial GC 不可以在服务端使用。这种 GC 在只有一个
CPU 的桌面系统中才会创建。使用 serial GC 会明显的降低应用的性能。

现在我们一起来学习每一种 GC。

对G1的行为进行测试

如果想要看垃圾回收的具体执行过程,可以使用虚拟机参数-Xlog:gc*=debug或者-Xlog:gc*=info,前一个会打印更多的细节。注意传统的VM参数-XX:+PrintGCDetails在Java9中已经废弃,会有Warning信息。可以使用以下代码中的程序去测试:

static int TOTAL_SIZE = 1024 * 5;
static Object[] floatingObjs= new Object[TOTAL_SIZE];
static LinkedList<Object> immortalObjs = new LinkedList<Object>();
//释放浮动垃圾
synchronized static void renewFloatingObjs() {
    System.err.println("存活对象满========================================");
    if (floatingSize + 5 >= TOTAL_SIZE) {
        floatingObjs= new Object[TOTAL_SIZE];
        floatingSize = 0;
    }
}
//添加浮动垃圾
synchronized static void addObjToFloating(Object obj) {
    if (floatingSize++ < TOTAL_SIZE) {
        floatingObjs[floatingSize] = obj;
        if (immortalSize++ < TOTAL_SIZE) {
            immortalObjs.add(obj);
        } else {
            immortalObjs.remove(new Random().nextInt(TOTAL_SIZE));
            immortalObjs.add(obj);
        }
    }
}

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Byte[] garbage = new Byte[1024 * (1 + new Random().nextInt(20))];
                if (new Random().nextInt(20) < 2) {
                    if (floatingSize + 5 >= TOTAL_SIZE) {
                        renewFloatingObjs();
                    }
                    addObjToFloating(garbage);
                }
            }
        }).start();
    }
}

在这段代码中,模拟了常规程序的使用情况。不断的生成新的大小不等的对象,这些对象中会有大约10%的机会进入浮动垃圾floatingObjs,浮动垃圾会被定期清除。同时会有一部分的对象进入immortalObjs,这些对象被释放的机会更少,它们大概率将成为老年代的常住用户。

从上边的测试可以得到如下GC日志1,这是一次完整的年轻代GC,从中可以看到,默认的区域大小为1M,同时将开始一次Full GC,其格式大致为[<虚拟机运行的时长>][<日志级别>][<标签>] GC(<GC的标识>) <其他信息>

//日志1
[0.014s][info][gc,heap] Heap region size: 1M
//一次完整的年轻代垃圾回收,伴随着一次暂停
[12.059s][info ][gc,start             ] GC(18) Pause Young (G1 Evacuation Pause)                            
[12.059s][info ][gc,task              ] GC(18) Using 8 workers of 8 for evacuation                          
[12.078s][info ][gc,phases            ] GC(18)   Pre Evacuate Collection Set: 0.0ms                         
[12.078s][info ][gc,phases            ] GC(18)   Evacuate Collection Set: 18.6ms                            
[12.079s][info ][gc,phases            ] GC(18)   Post Evacuate Collection Set: 0.3ms                            
[12.079s][info ][gc,phases            ] GC(18)   Other: 0.3ms                           
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)                            
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)                         
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463                            
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0                          
[12.078s][debug][gc,ergo,ihop         ] GC(18) Request concurrent cycle initiation (occupancy higher than threshold) occupancy: 485490688B allocation request: 0B threshold: 472331059B (45.00) source: end of GC
[12.078s][debug][gc,ihop              ] GC(18) Basic information (value update), threshold: 472331059B (45.00), target occupancy: 1049624576B, current occupancy: 521069456B, recent allocation size: 20640B, recent allocation duration: 817.38ms, recent old gen allocation rate: 25251.50B/s, recent marking phase length: 0.00ms
[12.078s][debug][gc,ihop              ] GC(18) Adaptive IHOP information (value update), threshold: 472331059B (47.37), internal target occupancy: 997143347B, occupancy: 521069456B, additional buffer size: 367001600B, predicted old gen allocation rate: 318128.08B/s, predicted marking phase length: 0.00ms, prediction active: false
[12.078s][debug][gc,ergo,refine       ] GC(18) Updated Refinement Zones: green: 15, yellow: 45, red: 75
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0
[12.079s][info ][gc,metaspace         ] GC(18) Metaspace: 5172K->5172K(1056768K)
[12.079s][debug][gc,heap              ] GC(18) Heap after GC invocations=19 (full 0):       
[12.079s][info ][gc                   ] GC(18) Pause Young (G1 Evacuation Pause) 803M->496M(1001M) 19.391ms                             
[12.079s][info ][gc,cpu               ] GC(18) User=0.05s Sys=0.00s Real=0.02s

Parallel Old GC(-XX:+UseParallelOldGC)

JDK 5 以后开始支持 Parallel Old GC。与并行 GC 相比,唯一的区别是这个 GC
算法是为老年代设计的。它的执行一共有三个步骤:标记-汇总-压缩。汇总这一步为
GC 已经执行过的区域单独标记存活的对象,这一步和 标记-交换-压缩
算法中的交换步骤是不一样的。这需要通过更复杂的步骤来完成。

2. Parallel/Throughput

Parallel在Java
9之前是服务器型宿主机中JVM的默认GC,其垃圾回收的算法和Serial基本相同,不同之处在与它使用多线程来执行。由于使用了多线程,可以享受多核CPU带来的优势,可以通过参数-XX:+UseParallelGC
-XX:+UseParallelOldGC显示指定。

Serial GC(-XX:+UseSerialGC)

上一段中我们介绍的新生代的 GC 使用的是这种类型。老年代的 GC 使用叫做
“标记-清除-压缩(mark-sweep-compact)”的算法。

  • 1、这个算法的第一步是标记老年代中的存活对象
  • 2、然后、从头开始检查堆,将存活的对象放到后面(交换)
  • 3、最后一步,用存活对象从头开始填充堆,这样这些存活对象连续堆放,并且将对分为两部分:一部分有对象另一部分没有对象(压缩)

Serial GC 适合小型内存和有少量CPU 内核的环境。

一、GC的作用范围

要谈GC的作用范围,首先要谈JVM的内存结构,JVM内存中主要有以下几个区域:堆、方法区(JVM规范中的叫法,Hotspot大致对应的是Metaspace)、栈、本地方法栈、PC等,其中GC主要作用在堆上,如下图所示:

图片 7

JVM内存结构

其中堆和方法区是所有线程共享的,其他则为线程独有,HotSpot
JVM使用基于分代的垃圾回收机制,所以在堆上又分为几个不同的区域(在G1中,各年龄代不再是连续的一整片内存,为了描述方便,这里还使用传统的表示方法),具体如下图所示:

图片 8

JVM堆中的分区

Java 垃圾回收详解

知道 Java
的垃圾回收(GC)怎么工作有什么好处?作为一个软件工程师,满足智力上的好奇心可能是一个理由,但是同时理解
GC 怎么工作可以帮助你写出更好的 Java 应用。

这是我自己非常个人、主观的看法,但是我相信一个精通 GC
的人很可能是一个更好的 Java 开发者。如果你对 GC
的过程感兴趣,那说明你已经有了开发一定规模应用的经验。如果你曾仔细思考选择正确的
GC
算法,说明你已经完全理解了你所开发的应用的功能。当然,这可能不是评判一个优秀开发者的通用标准。然而,当我说要想成为一名出色的
Java 开发者必须理解 GC 时,我想很少会有人反对。

这是我“成为 Java GC 专家” 系列文章的第一篇。本文将介绍
GC,在下一篇文章中,我将讨论分析 GC 的状态以及 NHN 的 GC 调节示例。

在了解 GC
之前你需要知道一个术语。这个术语就是“全局暂停事件”(stop-the-world)。不管你选择什么
GC
算法,全局暂停事件都会发生。全局暂停事件意味着JVM将停止当前应用的运行来执行
GC。当全局暂停事件发生时,除了 GC
所需要的线程外,所有的线程都会停止执行任务。被中断的任务只有当 GC
任务完成以后才会恢复。GC 调节通常意味着减少全局暂停事件的次数。

二、GC负责的事情

GC的发展是随着JDK(Standard
Edition)的发展一步步发展起来的,垃圾回收(Garbage
Collection)可以说是JDK里最影响性能的行为了。GC做的事情,说白了就是「通过对内存进行管理,以保障在内存足够的时候,程序可以正常的使用内存」。具体而言,GC通常做的事情有以下3个:

G1设计目标和适用对象

G1的设计目标是让大型的JVM可以动态的控制GC的行为以满足用户配置的性能目标。G1会在平衡吞吐和响应时延的基础上,尽可能的满足用户的需求。它适用的JVM往往有以下特征:

  1. 堆的大小可能达到数十G(或者更大),同时存活的对象数量也很多。
  2. 对象的分配和年龄增长的行为随着程序的运行不断的变化
  3. 堆上很容易形成碎片
  4. 要求较少的Stop-The-World暂停时间,通常小于数百毫秒

1. 分配对象和对象的年龄管理

通常而言,GC需要管理「在上图中的年轻代(Young)分配对象,然后通过一系列的年龄管理,将之销毁或晋升到老年代(Tenured)中去」的过程。这个过程会伴随着若干次的Minor
GC。

对于普通的对象而言,分配内存是一件很简单而且快速的事情。在对象还未创建时,其所占内存大小通过类的元数据就可以确定,而Eden区域的内存可以认为是连续的,所以给对象分配内存要做的只是在上图中Eden区域中把指针移动相应的长度,并将地址返回给对象的引用即可。当然实际的过程比这个复杂,在下文中会提到。

不过,有时候一个对象会直接在老年代中创建,这个点也会在后边提到。

  1. GC的作用范围
  2. GC负责的事情
  3. JVM中的4种GC
  4. G1的一些细节
  5. 使用Java 9正式版对G1进行测试
  6. 一些简单的GC调优方法

年轻代回收(Young-only)

对于纯粹的年轻代回收,其算法很简单,与Parallel和CMS的年轻代十分类似,这是一个多线程并行执行的过程,同样需要Stop-The-World(对应上边日志中的Pause Young),停下来所有的工作线程,然后将Eden上存活的对象拷贝到Suvivor区域,这里会将很多个对象从多个不同的区域拷贝到少数的几个区域内,所以这一步在G1中叫做疏散(Evacuation),同时把Suvivor上触及年龄阈值的对象晋升到老年代区域。

2. 使用Parallel

从上文中可知,Java
8默认的GC是Parallel,它也叫Throughput,所以它的目的是尽可能的增加系统的吞吐量。在Parallel里,可以通过参数调节最大停止时间(-XX:MaxGCPauseMillis,默认无设置)和吞吐量(-XX:GCTimeRatio,默认值是99,即最大使用1%的时间来做垃圾回收)来调优GC的行为。其中设置最大停止时间可能会导致GC调节各年龄代分区的尺寸(通过增量来实现)。

3. CMS

CMS和G1都属于「Mostly Concurrent Mark and Sweep Garbage
Collector」,可以使用-XX:+UseConcMarkSweepGC参数打开。CMS的年轻代垃圾回收使用的是Parallel New来做,其行为和Parallel中的差不多相同,他们的实现上有一些不同的地方,比如Parallel可以自动调节年轻代中各区的大小,用的是广度优先搜索等。

老年代使用CMS,CMS的回收和Parallel也基本类似,不同点在与,CMS使用的更复杂的可达性分析步骤,并且不是每次都做压缩的动作,这样达到的效果就是,Stop-The-World的时长会降低,JVM运行中断的时间减少,适合在对延迟敏感的场景下使用。

CMS在Java
9中已经被废弃,但了解CMS的行为对理解G1会有一些帮助,所以这里还是会简单的叙述一下。CMS的步骤大致如下:

  1. 第一次标记
    从GC
    Roots开始,找到它们在老年代中第一个可达的对象,这些对象或者是直接被GC
    Roots引用,或者通过年轻代中的对象被GC
    Roots引用。这一步会Stop-The-World。
  2. 并发标记
    在第一次标记的基础上,进一步进行可达性分析,从而标记存活的对象。这一步叫「并发」标记,是因为做标记的线程是和应用的工作线程并发执行的,也就是说,这一步不会Stop-The-World。
  3. 第二次标记
    在并发标记的过程中,由于程序仍在执行,会导致在并发标记完成后,有一些对象的可达性会发生变化,所以需要再次对他们进行标记。这一步会Stop-The-World。
  4. 清理
    回收不使用的对象,留作以后使用。

CMS的设计比较复杂,所以也带来了一些问题,比如浮动垃圾(Floating
Garbage,指的是在第一步标记可达,但在第二步执行的同时已经不可达的对象),由于不做老年代压缩,导致老年代会出现较多的内存碎片。

老年代回收(concurrent cycle)

G1的老年代回收是在老年代空间触及一个阈值(Initiating Heap Occupancy
Percent)之后,这个回收伴随着年轻代的回收工作,但与上边所说的回收有些不同。

  1. 年轻代回收:伴随着年轻代的回收工作,同时会执行并发标记和一部分清理的工作,这样可以共用年轻代垃圾回收的Stop-The-World。
    1. 第一次标记:对应一次Pause Initial Mark
      和CMS的步骤类似,首先进行第一次标记。但实现方法上有很大的区别,G1首先对当前堆上的对象情况进行一个虚拟快照(Snapshot-At-The-Beginning),然后根据这个快照对老年代的对象和区域进行标记,并执行之后的垃圾回收。之后像CMS一样会有并发标记的过程。
      这样会产生一个问题,在这次回收结束之后,会有些对象在并发标记的过程中,它的可达性已经变化,导致已经不可达的对象仍然没有被回收。但是这样能带来更好的响应时间。
    2. 重新标记:对应一次Pause Remark
      在这个阶段,G1首先完成上一步开始的标记工作,之后会对特殊引用的对象进行处理(具体可以参考JDK解构

      Java中的引用和动态代理的实现),还有对Metaspace区域进行垃圾回收。这一步会进行Stop-The-World。
    3. 清理:对应一次Pause Cleanup
      这一步主要做的是收集当前堆中的内存区域信息,对空的区域进行回收,为接下来的空间回收做一些准备工作,清理结束之后,通常会伴随着一次年轻代回收,如果判断不需要进行空间回收,则会进入下一个年轻代回收的工作。这一步会进行Stop-The-World。
  2. 混合垃圾回收:对应一次或多次Pause Mixed
    主要做的是对老年代的区域内存进行疏散(Evacuation),也包含对年轻代的区域回收工作。同时这一步也会动态地调整IHOP

从对G1的GC日志的分析,可以看到G1的垃圾回收行为是基于一个可预测的模型:GC会不断的主动触发垃圾回收,在这个过程中不断地进行信息统计和系统GC参数的设置,然后将上边这些步骤安排在这些垃圾回收过程中。

四、G1的一些细节

G1与以上3种GC相同,也是基于分代的垃圾回收器。它的垃圾回收步骤可以分为年轻代回收(Young-only
phase,类似于Minor GC)和混合垃圾回收阶段(Space-reclamation
phase)。下图是Oracle文档中对于此两个阶段的示意图:

图片 9

3. 在老年代中进行压缩

在上一条的基础上,将还存活的对象进行压缩(CMS和G1的行为与此有些不同之处),压缩的过程就是将存活的对象从老年代的起点进行挨个复制,使得老年代维持在一片连续的内存中,消除内存碎片,对于内存分配速度的提升会有很大的帮助。

4. G1

由于「引入了并发标记」和「不做老年代压缩」,CMS可以带来更好的响应时延表现,但同时也带来了一些问题。G1本身就是作为CMS的替代品出现的,在它的使用场景里,堆不再是连续的被分为上文所说的各种代,整个堆会被分为一个个区域(Region),每个区域可以是任何代。如下图所示:

图片 10使用G1的JVM某时刻的堆内存

其中有红色方框的为年轻代(标S的为Survivor区域,其他为Eden),其他蓝色底的区域为老年代(标H的为大对象区域,用以存储大对象)。

1. Serail

Serail是最早的一款GC,它只使用一个线程来做所有的Minor和Major垃圾回收。它在运行时,其他所有的事情都会暂停。其工作方式十分简单,在需要GC的安全点,它会停止所有其他线程(Stop-The-World),对年轻代进行标记-复制,或对老年代进行标记-整理。

可以使用JVM参数-XX:+UseSerialGC来开启此GC,当使用此参数时,年轻代和老年代将都是用Serial来做垃圾回收。在年轻代使用标记-复制算法,将Eden中存活的对象和非空的Suvivor区(From)中存活的对象复制到空的Suvivor区(To)中去,同时将一部分Suvivor中的对象晋升到老年代去。在老年代则使用标记-整理算法。

看起来Serial古老而简陋,但在宿主机资源紧张或者JVM堆很小的情况下(比如堆内存大小只有不到100M),Serial反而可以达到更好的效果,因为其他并发或并行GC都是基于多线程的,会带来额外的线程切换和线程间通信的开销。

1. 使用不同的索引对象

引用的类型会直接影响其所引用对象的GC行为,当要做一些内存敏感的应用时,可以参考使用合适的引用类型。具体可以参考JDK解构
– Java中的引用和动态代理的实现。

本文将主要讲解GC调优需要知道的一些基础知识,会涉及到一些GC的实现细节,但不会对实现细节做很全面的阐述,如果你看完本文之后,能对GC有一个大致的认识,那本文的写作目的也就达到了。由于在这次写作过程中,恰逢Java
9正式版发布,之前都是依赖Java
8的文档写的,如果有不正确的地方还望指正。本文将包含以下内容:

You can leave a response, or trackback from your own site.

Leave a Reply

网站地图xml地图