Java内存管理及GC机制

Java GCGarbage Collection,垃圾收集,垃圾回收)机制,是JavaC++/C的主要区别之一。作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢。经过这么长时间的发展,Java GC机制已经日臻完善,几乎可以自动的为我们做绝大多数的事情。

虽然Java不需要开发人员显示的分配和回收内存,这对开发人员确实降低了不少编程难度,但也可能带来一些副作用:

  • 有可能不知不觉浪费了很多内存
  • JVM花费过多时间来进行内存回收
  • 内存泄露

因此,作为一名Java编程人员,必须学会JVM内存管理和回收机制,这可以帮助我们在日常工作中排查各种内存溢出或泄露问题,解决性能瓶颈,达到更高的并发量,写出更高效的程序。

Java内存管理

根据JVM规范,JVM把内存划分了如下几个区域:

  • 方法区
  • 堆区
  • 本地方法栈
  • 虚拟机栈
  • 程序计数器

其中,方法区和堆是所有线程共享的。
image

方法区

方法区存放了要加载的类的信息(如类名,修饰符)、类中的静态变量、final定义的常量、类中的field、方法信息,当开发人员调用类对象中的getNameisInterface等方法来获取信息时,这些数据都来源于方法区。

方法区是全局共享的,在一定条件下它也会被GC。当方法区使用的内存超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。

Hotspot虚拟机中,这块区域对应的是Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难,关于其会后面再介绍。

运行时常量池Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);

运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符abc已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。

堆区

堆区是理解JavaGC机制最重要的区域。

JVM所管理的内存中,堆区是最大的一块,堆区也是JavaGC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区用来存储对象实例及数组值,可以认为Java中所有通过new创建的对象都在此分配。

对于堆区大小,可以通过参数-Xms-Xmx来控制

  • -XmsJVM启动时申请的最小Heap内存,默认为物理内存的1/64但小于1GB;
  • -XmxJVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB
  • 默认当剩余堆空间小于40%时,JVM会增大Heap-Xmx大小,可通过-XX:MinHeapFreeRadio参数来控制这个比例;
  • 当空余堆内存大于70%时,JVM会减小Heap大小到-Xms指定大小,可通过-XX:MaxHeapFreeRatio来指定这个比例

对于系统而言,为了避免在运行期间频繁的调整Heap大小,我们通常将-Xms-Xmx设置成一样。

为了让内存回收更加高效(后面会具体讲为何要分代划分),从Sun JDK 1.2开始对堆采用了分代管理方式,如下图所示:

image

年轻代

对象在被创建时,内存首先是在年轻代进行分配(注意,大对象可以直接在老年代分配)。当年轻代需要回收时会触发Minor GC(也称作Young GC)。

年轻代由Eden Space和两块相同大小的Survivor Space(又称S0S1)构成,可通过-Xmn参数来调整新生代大小,也可通过-XX:SurvivorRadio来调整Eden SpaceSurvivor Space大小。

不同的GC方式会按不同的方式来按此值划分Eden SpaceSurvivor Space,有些GC方式还会根据运行状况来动态调整EdenS0S1的大小。

年轻代的Eden区内存是连续的,所以其分配会非常快;同样Eden区的回收也非常快(因为大部分情况下Eden区对象存活时间非常短,而Eden区采用的复制回收算法,此算法在存活对象比例很少的情况下非常高效,后面会详细介绍)。

如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java Heap Space异常。

老年代

老年代用于存放在年轻代中经多次垃圾回收仍然存活的对象,可以理解为比较老一点的对象,例如缓存对象。

新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:

  • 一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;
  • 另一种为大的数组对象,且数组对象中无引用外部对象。

当老年代满了的时候就需要对老年代进行垃圾回收,老年代的垃圾回收称作Major GC(也称作Full GC)。

老年代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。

本地方法栈

本地方法栈用于支持native方法的执行,存储了每个native方法调用的状态。

本地方法栈和虚拟机方法栈运行机制一致,它们唯一的区别就是,虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如SunJDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。

虚拟机栈

虚拟机栈占用的是操作系统内存,每个线程都对应着一个虚拟机栈,它是线程私有的,而且分配非常高效。一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作栈、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。

局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。

在局部变量表中,只有longdouble类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32bit),其它都是1Slot

需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)。

程序计数器

程序计数器是一个比较小的内存区域,可能是CPU寄存器或者操作系统内存,其主要用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。

如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。

Java对象访问方式

一般来说,一个Java的引用访问涉及到3个内存区域:JVM栈,堆,方法区。以最简单的本地变量引用:Object objRef = new Object()为例:

  • Object objRef表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;
  • new Object()作为实例对象数据存储在堆中;
  • 堆中还记录了能够查询到此Object对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中;

Java虚拟机规范中,只规定了指向对象的引用,对于通过reference类型引用访问具体对象的方式并未做规定,不过目前主流的实现方式主要有两种

通过句柄访问

通过句柄访问的实现方式中,JVM堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针。这种实现方法由于用句柄表示地址,因此十分稳定。
image

通过直接指针访问

通过直接指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在HotSpot虚拟机中用的就是这种方式。
image

JVM内存分配

Java对象所占用的内存主要在堆上实现,因为堆是线程共享的,因此在堆上分配内存时需要进行加锁,这就导致了创建对象的开销比较大。当堆上空间不足时,会出发GC,如果GC后空间仍然不足,则会抛出OutOfMemory异常。

为了提升内存分配效率,在年轻代的EdenHotSpot虚拟机使用了两种技术来加快内存分配 ,分别是bump-the-pointerTLABThread-Local Allocation Buffers)。

由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;

而对于TLAB技术是对于多线程而言的, 它会为每个新创建的线程在新生代的Eden Space上分配一块独立的空间,这块空间称为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而得。可通过-XX:TLABWasteTargetPercent来设置其可占用的Eden Space的百分比,默认是1%。在TLAB上分配内存不需要加锁,一般JVM会优先在TLAB上分配内存,如果对象过大或者TLAB空间已经用完,则仍然在堆上进行分配。因此,在编写程序时,多个小对象比大的对象分配起来效率更高。可在启动参数上增加-XX:+PrintTLAB来查看TLAB空间的使用情况。

image

对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Minor GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC

可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。

如果对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。用-XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

内存的回收方式

JVM通过GC来回收堆和方法区中的内存,这个过程是自动执行的。

说到Java GC机制,其主要完成3件事

  • 确定哪些内存需要回收;
  • 确定什么时候需要执行GC
  • 如何执行GC

JVM主要采用收集器的方式实现GC,主要的收集器有引用计数收集器跟踪收集器

引用收集器

引用计数器采用分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时,说明此对象已经不再被使用,可进行回收,如图所示:
image
在上图中,ObjectA释放了对ObjectB的引用后,ObjectB的引用计数器变为0,此时可回收ObjectB所占有的内存。

引用计数器需要在每次对象赋值时进行引用计数器的增减,他有一定消耗。

另外,引用计数器对于循环引用的场景没有办法实现回收。例如在上面的例子中,如果ObjectBObjectC互相引用,那么即使ObjectA释放了对ObjectBObjectC的引用,也无法回收ObjectBObjectC

因此对于Java这种会形成复杂引用关系的语言而言,引用计数器是非常不适合的,SunJDK在实现GC时也未采用这种方式。

跟踪收集器

跟踪收集器采用的是集中式的管理方式,会全局记录数据引用的状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用关系,这可能会造成应用程序暂停。

根集合元素:简单来讲,就是全局性的引用(常量和静态属性)和栈引用

  • 方法区中:常量+静态变量
  • Java栈中的对象引用(存在于局部变量表中,注意,局部变量表中存放的是基本数据类型和对象引用)
  • 传到本地方法中,还没有被本地方法释放的对象引用

主要有复制(Copying)标记-清除(Mark-Sweep)标记-压缩(Mark-Compact) 三种实现算法。

复制

复制采用的方式为从根集合扫描出存活的对象,并将找到的存活的对象复制到一块新的完全未被使用的空间中,如图所示:
image

复制收集器方式仅需要从根集合扫描所有存活对象,当要回收的空间中存活对象较少时,复制算法会比较高效(年轻代的Eden区就是采用这个算法),其带来的成本是要增加一块空的内存空间及进行对象的移动。

标记 - 清除

标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行清除,标记和清除过程如下图所示:

image

上图中蓝色的部分是有被引用的存活的对象,褐色部分没被引用的可回收的对象。在marking阶段为了mark对象,所有的对象都会被扫描一遍,扫描这个过程是比较耗时的。

清除阶段回收的是没有被引用的对象,存活的对象被保留。内存分配器会持有空闲空间的引用列表,当有分配请求时会查询空闲空间引用列表进行分配。

标记-清除动作不需要进行对象移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除直接回收不存活对象占用的内存,因此会造成内存碎片。

标记 - 压缩

标记-压缩和标记-清除一样,是对活的对象进行标记,但是在清除后的处理不一样,标记-压缩在清除对象占用的内存后,会把所有活的对象向左端空闲空间移动,然后再更新引用其对象的指针,如下图所示:
image

很明显,标记-压缩在标记-清除的基础上对存活的对象进行了移动规整动作,解决了内存碎片问题,得到更多连续的内存空间以提高分配效率,但由于需要对对象进行移动,因此成本也比较高。

虚拟机中的GC过程

为什么要分代回收

在一开始的时候,JVMGC就是采用标记-清除-压缩方式进行的,这么做并不是很高效,因为当对象分配的越来越多时,对象列表也越来也大,扫描和移动越来越耗时,造成了内存回收越来越慢。

然而,经过对Java应用的分析,发现大部分对象的存活时间都非常短,只有少部分数据存活周期是比较长的。

虚拟机中GC的过程

经过上面介绍,我们已经知道了JVM为何要分代回收,下面我们就详细看一下整个回收过程。

年轻代的GC过程

  1. 在初始阶段,新创建的对象被分配到Eden区,survivor的两块空间都为空。
    image

  2. Eden区满了的时候,minor garbage被触发
    image

  3. 经过扫描与标记,存活的对象被复制到S0,不存活的对象被回收
    image

  4. 在下一次的Minor GC中,Eden区的情况和上面一致,没有引用的对象被回收,存活的对象被复制到survivor区。然而在survivor区,S0的所有的数据都被复制到S1,需要注意的是,在上次minor GC过程中移动到S0中的两个对象在复制到S1后其年龄要加1。此时EdenS0区被清空,所有存活的数据都复制到了S1区,并且S1区存在着年龄不一样的对象,过程如下图所示:
    image

  5. 再下一次MinorGC则重复这个过程,这一次survivor的两个区对换,存活的对象被复制到S0,存活的对象年龄加1Eden区和另一个survivor区被清空。
    image

  6. 下面演示一下Promotion过程,在经过几次Minor GC之后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是8),就会被从年轻代Promotion到老年代。
    image

  7. 随着MinorGC一次又一次的进行,不断会有新的对象被promote到老年代
    image

  8. 上面基本上覆盖了整个年轻代所有的回收过程。最终,MajorGC将会在老年代发生,老年代的空间将会被清除和压缩

image

从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。

因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下(基于大部分对象存活周期很短的事实)高效,如果在老年代采用停止复制,则是非常不合适的。

老年代的GC过程

老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。

一般,老年代用的算法是标记-压缩算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生Minor GC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。

永久代的GC过程

关于方法区即永久代的回收,永久代的回收有两种:

  • 常量池中的常量
    • 常量的回收很简单,没有引用了就可以被回收
  • 无用的类信息
    • 对于无用的类进行回收,必须保证3点:
      • 类的所有实例都已经被回收
      • 加载类的ClassLoader已经被回收
      • 类对象的Class对象没有被引用(即没有通过反射引用该类的地方)

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。

垃圾收集器

通过上面的介绍,我们已经了解到了JVM的内存回收过程,而在虚拟机中,GC是由垃圾回收器来具体执行的,所以,在实际应用场景中我们需要根据应用情况选择合适的垃圾收集器,下面我们就介绍一下垃圾收集器。

串行(Serial)收集器

串行收集器Java SE56中客户端虚拟机所采用的默认配置,它是最简单的收集器,比较适合于只有一个处理器的系统。在串行收集器中,minormajor GC过程都是用一个线程进行垃圾回收。

使用场景

首先,串行GC一般用在对应用暂停要求不是很高和运行在客户端模式的场景,它仅仅利用一个CPU核心来进行垃圾回收。在现在的硬件条件下,串行GC可以管理很多小内存的应用,并且能够保证相对较小的暂停(在Full GC的情况下大约需要几秒的时间)。

另一个通常采用串行GC的场景就是一台机器运行多个JVM虚拟机的情况(JVM虚拟机个数大于CPU核心数),在这种场景下,当一个JVM进行垃圾回收时只利用一个处理器,不会对其它JVM造成较大的影响。

最后,在一些内存比较小和CPU核心数比较少的硬件设备中也比较适合采用串行收集器。

相关命令参数

启用串行收集器:-XX:+UseSerialGC

1
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar c:\demo.jar

并行收集器

并行收集器采用多线程的方式来进行垃圾回收,采用并行的方式能够带来极大的CPU吞吐量。它在不进行垃圾回收的时候对正在运行的应用程序没有任何影响,在进程GC的时候采用多线程的方式来提高回收速度。

因此,并行收集器非常适用于批处理的情形。当然,如果应用对程序暂停要求很高的话,建议采用下面介绍的并发收集器。

默认一个Ncpu的机器上,并行回收的线程数为N。当然,并行的数量可以通过参数进行控制:-XX:ParallelGCThreads=<desired number>

并行收集器是Server级别机器(CPU大于2且内存大于2G)上采用的默认回收方式。

在单核CPU的机器上,即使配置了并行收集器,实际回收时仍然采用的是默认收集器。如果一台机器上只有两个CPU,采用并行回收器和默认回收器的效果其实差不多,只有当CPU个数大于2时,年轻代回收的暂停时间才会减少。

应用场景

并行回收器适用于多CPU、对暂停时间要求短的情况下。通常,一些批处理的应用如报告打印、数据库查询可采用并行收集器。

相关命令参数

  • 在年轻代用多线程、老年代用单线程

    启用命令:-XX:+UseParallelGC

    1
    java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelGC -jar c:\demo.jar
  • 年轻代和老年代都用多线程

    启用命令:-XX:+UseParallelOldGC

    当启用-XX:+UseParallelOldGC 选项时,年轻代和老年代的垃圾收集都会用多线程进行,在压缩阶段也是多线程。因为HotSpot虚拟机在年轻代采用的是停止-复制算法,年轻代没有压缩过程,而老年代采用的是标记-清除-压缩算法,所以仅在老年代有compact过程。

    1
    java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelOldGC -jar c:\demo.jar

CMS(Concurrent Mark Sweep)收集器

CMS收集器试图用多线程并发的形式来减少垃圾收集过程中的暂停。CMS收集器不会对存活的对象进行复制或移动。CMS采用的基础算法是标记 - 清除

CMS过程

  • 初始标记(STW initial mark)
  • 并发标记(Concurrent marking)
  • 并发预清理(Concurrent precleaning)
  • 重新标记(STW remark)
  • 并发清理(Concurrent sweeping)
  • 并发重置(Concurrent reset)

初始标记:在这个阶段,需要虚拟机停顿正在执行的任务,官方的叫法STW(Stop The Word)。这个过程从垃圾回收的”根对象”开始,只扫描到能够和”根对象”直接关联的对象,并作标记。所以这个过程虽然暂停了整个JVM,但是很快就完成了。

并发标记:这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。并发标记阶段,应用程序的线程和并发标记的线程并发执行,所以用户不会感受到停顿。

并发预清理:并发预清理阶段仍然是并发的。在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段”重新标记”的工作,因为下一个阶段会Stop The World

重新标记:这个阶段会暂停虚拟机,收集器线程扫描在CMS堆中剩余的对象。扫描从”根对象”开始向下追溯,并处理对象关联。

并发清理:清理垃圾对象,这个阶段收集器线程和应用程序线程并发执行。

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

CMS缺点

  • CMS回收器采用的基础算法是Mark-Sweep。所有CMS不会整理、压缩堆空间。这样就会有一个问题:经过CMS收集的堆会产生空间碎片CMS不对堆空间整理压缩节约了垃圾回收的停顿时间,但也带来的堆空间的浪费。为了解决堆空间浪费问题,CMS回收器不再采用简单的指针指向一块可用堆空间来为下次对象分配使用。而是把一些未分配的空间汇总成一个列表,当JVM分配对象空间的时候,会搜索这个列表找到足够大的空间来hold住这个对象。

  • 需要更多的CPU资源。为了让应用程序不停顿,CMS线程和应用程序线程并发执行,这样就需要有更多的CPU,单纯靠线程切换是不靠谱的。并且,重新标记阶段,为空保证STW快速完成,也要用到更多的甚至所有的CPU资源。当然,多核多CPU也是未来的趋势!

  • CMS的另一个缺点是它需要更大的堆空间。因为CMS标记阶段应用程序的线程还是在执行的,那么就会有堆空间继续分配的情况,为了保证在CMS回收完堆之前还有空间分配给正在运行的应用程序,必须预留一部分空间。也就是说,CMS不会在老年代满的时候才开始收集。相反,它会尝试更早的开始收集,已避免上面提到的情况:在回收完成之前,堆没有足够空间分配!默认当老年代使用68%的时候,CMS就开始行动了。– XX:CMSInitiatingOccupancyFraction =n来设置这个阀值。

总得来说,CMS回收器减少了回收的停顿时间,但是降低了堆空间的利用率。

应用场景

CMS收集器主要用在应用程序对暂停时间要求很高的场景,比如桌面UI应用需要及时响应用户操作事件、服务器必须能快速响应客户端请求或者数据库要快速响应查询请求等等。

相关命令参数

启用CMS收集器:-XX:+UseConcMarkSweepGC

设置线程数:-XX:ParallelCMSThreads=<n>

1
java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=2 -jar c:\demo.jar

G1收集器

G1Garbage First,它是在Java 7中出现的新的收集器,它的目标是替换掉现有的CMS收集器(产生于JDK 5)。G1具有并行、并发、分代收集、空间整合、可预测的停顿等特点。

  • 并行与并发

    G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

  • 分代收集

    与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

  • 空间整合

    CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC

  • 可预测的停顿

    这是G1相对于CMS的另一大优势,降低停顿时间是G1CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

执行过程

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

初始标记(Initial Marking):初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。

并发标记(Concurrent Marking):并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。

最终标记(Final Marking):最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

筛选回收(Live Data Counting and Evacuation):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

相关命令参数

启用G1收集器:-XX:+UseG1GC

1
java -Xmx12m -Xms3m -XX:+UseG1GC -jar c:\demo.jar

JVM 参数汇总

GC 优化配置

配置 描述
-Xms 初始化堆内存大小
-Xmm 堆内存最大值
-Xmn 新生代大小
-XX:PermSize 初始化永久代大小
-XX:MaxPermSize 永久代最大容量

GC 类型设置

配置 描述
-XX:+UseSerialGC 串行垃圾收集器
-XX:+UseParallelGC 并行垃圾收集器
-XX:+UseConcMarkSweepGC 并发标记扫描垃圾收集器
-XX:+UseG1GC G1垃圾收集器
-XX:ParallelCMSThreads= 并发标记扫描垃圾回收器 = 为使用的线程数量

垃圾回收统计信息

配置 描述
-XX:+PrintGC 每次GC时打印相关信息
-XX:+PrintGCDetails 每次GC时打印详细信息
-XX:+PrintGCTimeStamps 打印每次GC的时间戳
-XX:+PrintHeapAtGC 打印GC前后的详细堆栈信息

Minor GC、Full GC触发条件

Minor GC触发条件

当Eden区满时,触发Minor GC。

Full GC触发条件

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 【concurrent mode failure】通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 【promotion failed】由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

JVM内存管理及GC机制

了解CMS(Concurrent Mark-Sweep)垃圾回收器

深入理解JVM(5):Java垃圾收集器