图片 18

Java GC专家系列1:理解Java垃圾回收

了解Java的垃圾回收(GC)原理能给我们带来什么好处?对于软件工程师来说,满足技术好奇心可算是一个,但重要的是理解GC能帮忙我们更好的编写Java应用程序。

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 调节通常意味着减少全局暂停事件的次数。

JVM本身是硬件的一层软件抽象,在这之上才能够运行Java程序,也才有了我们所吹嘘的平台独立性以及“一次编写,处处运行”。

上面是我个人的主观的看法,但我相信熟练掌握GC是成为优秀Java程序员的必备技能。如果你对GC执行过程感兴趣,也许你只是有一定的开发应用的经验;如果你仔细考虑过如何选择合适的GC算法,说明你对你所开发的程序有了全面的了解。当然这对一个优秀的程序员来说未必是一个通用的标准,但很少人会反对我关于”理解GC是作为优秀Java程序员的必备技能”的看法。

垃圾回收的来源

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))。

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

图片 1

图1:GC 区域和 数据流程

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

有人可能会想:

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

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

图片 2

图2:Card Table 的结构

在理解Java垃圾回收机制之前需要我们对于JVM的内存结构能够有一个充分的理解,便于后面对于Java垃圾回收机制的理解。

本文是成为Java GC专家的案例介绍GC调优相关的内容。

新生代的组成

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

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

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

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

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

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

图片 3

图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 区域创建,然后长期存活的对象通过存活区移动到老年代。

1. JVM内存区域

图片 4

JVM内存区域结构如上图所示。

1. 程序计数器:

  • 程序计数器是一块较小的内存区域,可以看作是当前线程所执行的字节码的行号指示器。
  • 每个线程拥有一个PC寄存器,在线程创建时创建并指向下一条指令的地址。执行本地方法时,PC的值为undefined。

2. Java虚拟机栈

  • 线程私有,生命周期与线程相同。
  • 描述的是Java方法执行的内存模型:每一个方法执行的同时都会创建一个栈帧(Stack
    Frame),由于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法的执行就对应着栈帧在虚拟机栈中的入栈,出栈过程。
  • 局部变量表、操作数栈、方法返回地址、动态连接

3. 本地栈方法

  • 与虚拟机栈所发挥的作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码服务),而本地方法栈则为虚拟机使用到的Native方法服务。

4. Java堆

  • 对于大多数的应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。
  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
  • 其唯一目的就是存放对象实例。
  • Java堆是垃圾收集器管理的主要区域,被称作是GC堆
  • 要注意,这个“堆”并不是数据结构意义上的堆,而是动态内存分配意义上的堆——用于管理动态生命周期的内存区域。

5. 方法区

  • 是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

基于上述的内容,可以较为详细的理解JVM内存机制。
在此需要详细叙述一下Java堆的问题。

从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为新生代和老年代;新生代还可以分为Eden、From Survivor、To Survivor。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。进一步划分的目的是为了更好地回收内存、更快地分配内存。

本文的目的是以通俗的方式为你介绍GC概念。我希望本文会对你有所帮助。事实上,我的同事们已经发表了一些在Twitter上非常受关注的[优秀文章](),你同样也可以拿来参考。

老年代的 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。

2. Java垃圾回收机制

回到垃圾回收上,在开始学习GC之前你应该知道一个词:stop-the-world。不管选择哪种GC算法,stop-the-world都是不可避免的。Stop-the-world意味着从应用中停下来并进入到GC执行过程中去。一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。GC调优通常就是为了改善stop-the-world的时间。

Serial GC(-XX:+UseSerialGC)

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

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

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

2.1 意义

Java语言中一个显著的特点就是引入了垃圾回收机制,使c++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。

基于的分代理论的垃圾回收

在Java程序里不需要显式的分配和释放内存。有些人通过给对象赋值为null或调用System.gc()以期望显式的释放内存空间。给对象设置null虽没什么用,但问题不会太大;如果调用了System.gc()却可能会为系统性能带来严重的波动,即便调用System.gc()系统也未必立即响应去执行垃圾回收。(所幸的是,在NHN未曾看到有工程师这么做。)

在使用Java时,程序员不需要在程序代码中显式的释放内存空间,垃圾回收器会帮你找到不再需要的(垃圾)对象并把他们移出。垃圾回收器的创建基于以下两个假设(也许称之为推论或前提更合适):

  • 大多数对象的很快就会变得不可达
  • 只有极少数情况会出现旧对象持有新对象的引用

这两条假设被称为”弱分代假设“。为了证明此假设,在HotSpot
VM中物理内存空间被划分为两部分:新生代(young generate)老年代(old
generation)

新生代:大部分的新创建对象分配在新生代。因为大部分对象很快就会变得不可达,所以它们被分配在新生代,然后消失不再。当对象从新生代移除时,我们称之为”minor
GC
“。

老年代:存活在新生代中但未变为不可达的对象会被复制到老年代。一般来说老年代的内存空间比新生代大,所以在老年代GC发生的频率较新生代低一些。当对象从老年代被移除时,我们称之为”major
GC
“(或者full GC)。

看一下下图的示意:

图片 5
图1:GC区域和数据流向

图中的permanent
generation
称为方法区,其中存储着类和接口的元信息以及interned的字符串信息。所以这一区域并不是为老年代中存活下来的对象所定义的持久区。方法区中也会发生GC,这里的GC同样也被称为major
GC

有些人可能认为:

如果老年代的对象需要持有新生代对象的引用怎么办?

为了处理这种场景,在老年代中设计了”索引表(card
table)
“,是一个512字节的数据块。不管何时老年代需要持有新生代对象的引用时,都会记录到此表中。当新生代中需要执行GC时,通过搜索此表决定新生代的对象是否为GC的目标对象,从而降低遍历所有老年代对象进行检查的代价。该索引表使用写栅栏(write
barrier)
进行管理。wite barrier是一个允许高性能执行minor
GC的设备。尽管它会引入一个数据位的开销,却能带来总体GC时间的大幅降低。

图片 6
图2:索引表结构

Parallel GC(-XX:+UseParallelGC)

图片 7

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

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

2.2 垃圾回收的一些方法

Mark and Sweep

图片 8

算法分为“标记”和“清楚”两个阶段:
  • 首先标记出所需要回收的对象;
  • 在标记完成后统一回收所有被标记的对象。
缺点:
  • 效率问题:标记和清楚两个过程的效率都不高;
  • 空间问题:标记清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

Copy

图片 9

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完后,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉。
优点:
  • 不用考虑内存碎片等复杂情况
  • 实现简单,运行高效
缺点:
  • 空间缩小

Compact

图片 10

采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。
优点:
  • 解决了碎片的问题
缺点:
  • 代价高

在实际的回收机制中,对于不同的情况其实是使用不同的回收算法来应对不同的状况的。其基本假设是大部分对象只存在很短的时间,对于新生代使用copy算法,对于老年代使用compact算法。


新生代的结构

为了深入理解GC,我们先从新生代开始学起。所有的对象在初始创建时都会被分配在新生代中。新生代又可分为三个部分:

  • 一个Eden
  • 两个Survivor

在三个区域中有两个是Survivor区。对象在三个区域中的存活过程如下:

  1. 大多数新生对象都被分配在Eden区。
  2. 第一次GC过后Eden中还存活的对象被移到其中一个Survivor区。
  3. 再次GC过程中,Eden中还存活的对象会被移到之前已移入对象的Survivor区。
  4. 一旦该Survivor区域无空间可用时,还存活的对象会从当前Survivor区移到另一个空的Survivor区。而当前Survivor区就会再次置为空状态。
  5. 经过数次在两个Survivor区域移动后还存活的对象最后会被移动到老年代。

如上所述,两个Survivor区域在任何时候必定有一个保持空白。如果同时有数据存在于两个Survivor区或者两个区域的的使用量都是0,则意味着你的系统可能出现了运行错误。

下图向你展示了经过minor GC把数据迁移到老年代的过程:

图片 11
图3: GC前后

在HotSpot
VM中,使用了两项技术来实现更快的内存分配:”指针碰撞(bump-the-pointer)“和”TLABs(Thread-Local
Allocation Buffers)
“。

Bump-the-pointer技术会跟踪在Eden上新创建的对象。由于新对象被分配在Eden空间的最上面,所以后续如果有新对象创建,只需要判断新创建对象的大小是否满足剩余的Eden空间。如果新对象满足要求,则其会被分配到Eden空间,同样位于Eden的最上面。所以当有新对象创建时,只需要判断此新对象的大小即可,因此具有更快的内存分配速度。然而,在多线程环境下,将会有别样的状况。为了满足多个线程在Eden空间上创建对象时的线程安全,不可避免的会引入锁,因此随着锁竞争的开销,创建对象的性能也大打折扣。在HotSpot中正是通过TLABs解决了多线程问题。TLABs允许每个线程在Eden上有自己的小片空间,线程只能访问其自己的TLAB区域,因此bump-the-pointer能通过TLAB在不加锁的情况下完成快速的内存分配。

本小节快速浏览了新生代上的GC知识。上面讲的两项技术无需刻意记忆,只需要明白对象开始是创建在Eden区,然后经过在Survivor区域上的数次转移而存活下来的长寿对象最后会被移到老年代。

Parallel Old GC(-XX:+UseParallelOldGC)

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

2.3 Generation

图片 12

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。

新生代

  • 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
  • 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个
    Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空,
    如此往复。
  • 当survivor1区不足以存放
    eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full
    GC,也就是新生代、老年代都进行回收
  • 新生代发生的GC也叫做Minor
    GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)

老年代

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

持久代

  • 用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate
    等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
  • Java
    1.8后,使用Metaspace替代,其好处是不舍限制,杜绝了OutOfMemoryError,使用系统内存

老年代垃圾回收

当老年代数据满时,便会执行老年代垃圾回收。根据GC算法的不同其执行过程也会有所区别,所以当你了解了每种GC的特点后再来理解老年代的垃圾回收就会容易很多。

在JDK 7中,内置了5种GC类型:

  1. Serial GC
  2. Parallel GC
  3. Parallel Old GC(Parallel Compacting GC)
  4. Concurrent Mark & Sweep GC (or “CMS”)
  5. Garbage First (G1) GC

其中Serial
GC务必不要在生产环境的服务器上使用
,这种GC是为单核CPU上的桌面应用设计的。使用Serial
GC会明显的损耗应用的性能。

下面分别介绍每种GC的特性。

CMS GC(-XX:UseConcMarkSweepGC)

图片 13

图5:串行 GC 和 CMS GC

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

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

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

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

2.4 垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

图片 14

  • Serial收集器(复制算法):
    新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
  • Serial Old收集器 (标记-整理算法):
    老年代单线程收集器,Serial收集器的老年代版本;
  • ParNew收集器 (复制算法):
    新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
  • Parallel Scavenge收集器 (复制算法):
    新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 =
    用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
  • Parallel Old收集器 (标记-整理算法):
    老年代并行收集器,吞吐量优先,Parallel
    Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法):
    老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
  • G1(Garbage First)收集器 (标记-整理算法):
    Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

Serial GC(-XX:+UseSerialGC)

在前面介绍的年轻代垃圾回收中使用了这种类型的GC。在老年代,则使用了一种称之为”mark-sweep-compact“的算法。

  1. 首先该算法需要在老年代中标记出存活着的对象
  2. 然后从前到后检查堆空间中存活的对象,并保持位置不变(把不再存活的对象清理出堆空间,称为空间清理)
  3. 最后,把存活的对象移到堆空间的前面部分以保持已使用的堆空间的连续性,从而把堆空间分为两部分:有对象的和无对象的(称为空间压缩)

Serial GC适用于CPU核数较少且使用的内存空间较小的场景。

G1 GC

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

图片 15

图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

3. 内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存
以及
回收分配给对象的内存。一般而言,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓存(TLAB),将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中。总的来说,内存分配规则并不是一层不变的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

  1. 对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。现在的商业虚拟机一般都采用复制算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
    当进行垃圾回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后处理掉Eden和刚才的Survivor空间。(HotSpot虚拟机默认Eden和Survivor的大小比例是8:1)当Survivor空间不够用时,需要依赖老年代进行分配担保。
  2. 大对象直接进入老年代。所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
  3. 长期存活的对象将进入老年代。当对象在新生代中经历过一定次数(默认为15)的Minor
    GC后,就会被晋升到老年代中。
  4. 动态对象年龄判定。为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

需要注意的是,Java的垃圾回收机制是Java虚拟机提供的能力,用于在空闲时间以不定时的方式动态回收无任何引用的对象占据的内存空间。也就是说,垃圾收集器回收的是无任何引用的对象占据的内存空间而不是对象本身。


Parallel GC(-XX:+UseParallelGC)

图片 16
图4:Serial GC与Parallel GC的区别

图中可以容易的看出serial GC与parallel GC的区别。Serial
GC使用单一线程执行GC,而parallel GC则使用多个线程并发执行,因此parallel
GC 较serial GC具有更快的速度。Parallel
GC适用于多核CPU且使用了较大内存空间的场景。Parallel
GC又被称为”高吞吐GC(throughput GC)

4. 总结

  • 哪些内存需要回收?(对象是否可以被回收的两种经典算法: 引用计数法 和
    可达性分析算法)
  • 什么时候回收? (堆的新生代、老年代、永久代的垃圾回收时机,MinorGC
    和 FullGC,分配内存失败时运行)
  • 如何回收?(三种经典垃圾回收算法(标记清除算法、复制算法、标记整理算法)及分代收集算法
    和 七种垃圾收集器,扫描根节点,扫描到的是需要回收的)

参考并感谢
1. 图解Java
垃圾回收机制
2. 深入理解Java虚拟机 第二版
周志明
3.
慕课网

Parallel Old GC(-XX:+UseParallelOldGC)

Parallel Old GC在JDK 5中被引入,与Parallel
GC相比唯一的区别在于Parallel的GC算法是为老年代设计的。它的执行过程分为三步:标记(mark)–总结(summary)–压缩(compaction)。其中summary步骤会会分别为存活的对象在已执行过GC的空间上标出位置,因此与mark-sweep-compact算法中的sweep步骤有所区别,并需要一些复杂步骤才能完成。

CMS GC(-XX:+UseConcMarkSweepGC)

图片 17
图5:Serial GC与CMS GC

从图上可看出并发标记-清理(Concurrent Mark-Sweep)
GC比以后上其他GC都要复杂。开始时的初始标记(initial
mark)比较简单,只有靠近类加载器的存活对象会被标记,因此停顿时间(stop-the-world)比较短暂。在并发标记(concurrent
mark)阶段,由刚被确认和标记过的存活对象所关联的对象将被会跟踪和检测存活状态。此步骤的不同之处在于有多个线程并行处理此过程。在重标记(remark)阶段,由并发标记所关联的新增或中止的对象瘵被会检测。在最后的并发清理(concurrent
sweep)阶段,垃圾回收过程被真正执行。在垃圾回收执行过程中,其他线程依然在执行。得益于CMS
GC的执行方式,在GC期间系统中断时间非常短暂。CMS
GC也被称为低延迟GC,适用于所有应用对响应时间要求比较严格的场景

CMS GC虽然具有中断时间断的优势,其缺点也比较明显:

  • 与其他GC相比,CMS GC要求更多的内存空间和CPU资源
  • CMS GC默认不提供内存压缩

使用CMS
GC之前需要对系统做全面的分析。另外为了避免过多的内存碎片而需要执行压缩任务时,CMS
GC会比任何其他GC带来更多的stop-the-world时间,所以你需要分析和判断压缩任务执行的频率及其耗时情况。

G1 GC

最后我们学习有关G1垃圾回收的介绍。

图片 18
图6:G1 GC的布局

如果你想清晰的理解GC,请先忘记上面介绍的有关新生代和老年代的知识。如上图所示,每个对象在创建时会分析到一个格子中,后续的GC也是在格子中完成的。每当一个区域分配满对象后,新创建的对象就会分配到另外一个区域,并开始执行GC。在这种GC中不会出现其他GC中的对象在新生代和老生代三区域中移动的现象。G1是为了取代在长期使用中暴露出大量问题且饱受抱怨的CMS
GC。

G1最大的改进在于其性能表现,它比以上任何一种GC都更快速。它在JDK6中以早期版本的形式释放出来以用于测试,它真正的发布是在JDK7中。我个人认为在NHN真正在生产环境使用JDK7至少还需要1年的测试时间,所以还需要等待一段时间。并且我听说在JDK6中使用G1偶尔会出现JVM崩溃现象。所以稳定版尚需时日。

接下来的文章中会讲解GC调优,但我想先提一个问题。如果应用中所有对象的类型和大小都是一样的,WAS上使用的GC可以设置相同的GC选项。如果在WAS上创建的对象的大小和生命周期各不相同的对象,配置的GC选项也各不相同。换名话说,不能因为一个服务使用了GC选项”A”,其他的不同服务使用相同的选项”A”也能获取最好的表现。所以为了找到WAS线程的最佳值,每个WAS实例需要通过持续的调优和监控以便找到最优的配置和GC优项。这不只是来自我的个人经验,而是来自于JavaOne
2010上工程师们对于Oracle JVM讨论后的一致看法。

本节我们只简单介绍Java中的GC基础。下一章节,我将会讨论关于如何监控GC状态以及如何做性能调优。

本文参考了2011年12月出版的《Java
性能》和Oracle网站上提供的白皮书《Java HotspotTM 虚拟机内存管理》。

作者:Sangmin Lee, 性能实验室高级工程师,NHN公司

发表评论

电子邮件地址不会被公开。 必填项已用*标注