深入理解Java虚拟机笔记二(垃圾收集器与内存分配策略)

Contents

  1. 1. 概述
  2. 2. 对象已死吗
    1. 2.1. 引用计数器
    2. 2.2. 可达性分析算法
    3. 2.3. 再谈引用
    4. 2.4. 生存还是死亡
    5. 2.5. 方法回收区
  3. 3. 垃圾收集算法
  4. 4. HotSpot 的算法实现
    1. 4.1. 枚举根节点
    2. 4.2. 安全点
    3. 4.3. 安全区域
  5. 5. 垃圾收集器
    1. 5.1. Serial收集器
    2. 5.2. ParNew收集器
    3. 5.3. Parallel Scavenge收集器
    4. 5.4. Serial Old 收集器
    5. 5.5. Parallel Old收集器
    6. 5.6. CMS(Comcurrent Mark Sweep)收集器
    7. 5.7. G1收集器
  6. 6. 理解GC日志
    1. 6.1. 垃圾收集器参数总结
  7. 7. 内存分配与回收策略
    1. 7.1. 对象优先在Eden分配
    2. 7.2. 大对象直接进入老年代
    3. 7.3. 长期存活的对象将进入老年代
    4. 7.4. 动态对象年龄判定
    5. 7.5. 空间分配担保

概述

程序计数器、虚拟机栈、本地方法栈这些区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。垃圾收集器关注的是堆和方法区中的垃圾。

对象已死吗

引用计数器

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

这种计数法无法解决循环引用的问题

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

再谈引用

在Java1.2之前,引用的定义是这样的:

如果reference类型的数据中存储的数值代表的是另外一块内存中的起始地址,就称这块内存代表着一个引用。

我们可以看到,这个引用的定义是非常狭隘的,和指针类似,只有引用、非引用区分。所以,在JAVA1.2以后提出了新的引用定义:

  • 强引用:在代码中普遍存在的,类似Object obj = new Object();。只要强引用还存在,垃圾回收期就永远不会回收被引用的对象
  • 软引用:用来描述一些还有用,但并非必须的对象。这样当系统要发生内存溢出异常之前,就会把软引用列进第二次垃圾回收的计划中。SoftReference
  • 弱引用:比软引用还弱的引用,被弱引用的对象只能存活到下一次垃圾回收之前。WeakReference
  • 虚引用:最弱的一种引用关系了。使用虚引用的唯一目的就是在这个对象回收前收到一个系统回收通知。PhantomReference

《Java编程思想》中也有相关内容

生存还是死亡

即使是不可达对象,也并非是非死不可的,这时候它们暂时处于”缓刑“阶段,真正宣告一个对象死亡,至少要经过两次标记过程

如果对象在进行根搜索后发现跟root不同根,就被标记一次,同时进行筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()已经被JVM调用过(说明一个对象的finalize()方法只能执行一次),JVM会将这两种情况视为“没有必要执行”。如果这个对象有必要执行finalize()方法,JVM就会把它放在F-Queue中,稍后JVM会触发一个低优先级的线程去执行。但是去执行并并不承诺会等待它运行结束,因为如果一个对象在finalize()方法中执行缓慢,甚至发生了死循环,就会导致F-Queue其他对象永久处于等待状态,更严重的话可能会拖垮整个内存回收系统。finalize()是对象逃脱死亡命运的最后一次机会,稍后GC将会对F-Queue进行第二次小规模的标记,如果在finalize()中将自己和root挂在一个根上(比如把自己赋值给某个类变量或者对象的成员变量),那么在这第二次标记将会被移除出“即将回收的集合”:如果对象还没有逃脱,那么就基本上真的被回收了。

两次标记过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive() {
System.out.println("haha, i'm still alive!");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();

//对象第一次拯救自己
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("5555, i'm dead!");
}

//对象第二次拯救自己,但是却跪了。因为finalize只能执行一次呀,亲!!
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("5555, i'm dead!");
}
}
}/*output:
finalize method executed!
haha, i'm still alive!
5555, i'm dead!
*/

我们可以清楚的看到,第一次在finalize()中赋值给类变量,所以和root同根自救了一次,但是因为finalize()只会执行一次,所以第二次标记时,JVM发现已经调用这个对象的finalize(),就知道没必要再执行finalize了,然后就被回收了。

作者非常不推荐使用finalize()方法自救对象,因为这是Java刚诞生为了使C/C++程序员更容易接受它作的一个妥协。它的运行带价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中提到它使用“关闭外部资源”之类的工作,这完全是对这种方法的用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或其他方法都可以做的更好、更及时,完全可以忘掉Java有finalize()。

方法回收区

Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾回收,主要是因为在方法区进行垃圾回收的“性价比”很低:在堆中,尤其是在新生代中,常规应用进行一次垃圾回收一般可以回收70%-95%的空间,而永久代的垃圾回收效率也远低于此。

永久代的垃圾收集主要回收两部分内容:

  • 废弃常量:以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

  • 无用的类:判断一个类是无用的类,条件比废弃变量要苛刻的多,要同时满足下面3个条件才能算是“无用的类”:

    1. 该类所有的实例都已经被回收,意思是堆上没有该对象的实例了
    2. 加载该类的ClassLoader已经被回收
    3. 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法(因为通过反射,就一定要加载该类)

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

  • 标记-清除算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。有两个问题:一是效率不高;而是会产生内存碎片,当需要大的连续内存空间时,即使碎片内存总和远大于需求,也会触将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。实现简单,运行高效,但是空间缩小为原来的一半了。现在的商业JVM采用这种算法来回收新生代,IBM经过调研发现,新生代的对象98%都是朝生夕死的,所有并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor还存活着的对象一次性拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个内存空间的9/10,只有10%的内存是用来浪费的。当然了,我们无法保证每次回收只有少于10%的对象存活,当存活对象大于10%,就会借用其他内存(这里指老年代)进行分配担保。分配担保就相当于现实生活中的担保。
  • 标记-整理算法:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。(老年代就是采用这个方法)
  • 分代收集算法:当前商业虚拟机的垃圾回收都采用分代收集算法,这种算法没有啥特别的,就是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据每个代不同的特点采用最适当的回收算法。比如新生代存活对象少,就采用Eden-Survivor复制算法;老年代存活对象少,复制的话代价太大,就可以采用标记-整理算法。

总结:
标记-清除:简单,效率低,有内存碎片
复制:新生代,实现简单,运行高效,空间利用率低
标记-整理:老年代

HotSpot 的算法实现

枚举根节点

可达性分析在逐个检查引用链和GC停顿(保证分析工作的一致性)上浪费时间较多。主流Java虚拟机使用的都是准确式GC,虚拟机应当有办法直接得知哪些地方存放着对象的引用。HotSpot是通过一个OopMap的数据结构来达到这个目的的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

安全点

HotSpot没有为每条指令都生成OopMap,那样占用空间太多,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

另一个问题是如何让所有线程都跑到最近的安全点停顿。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

安全区域

如果线程没有在执行呢,比如线程处于Sleep状态或者Blocked状态,就需要安全区域解决了。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

HotSpot虚拟机的垃圾收集器

Serial收集器

最基本,历史最悠久。新生代的,单线程的,只会用一个CPU或一个线程工作,并且收集时,必须暂停所有的工作线程,直到收集结束。它依然是虚拟机运行在Client端的默认新生代收集器。简单而高效,因为它不需要考虑线程切换,只专注一次把收集工作搞定,而且在Client端,新生代的内存一般只有几十M或者一两百M的样子,完成一次收集工作完全可以控制在几十毫秒或者一百毫秒左右,不会有很大的停顿感。

ParNew收集器

这个本质上就是Serial收集器的多线程版本。许多运行在Server模式下的虚拟机中首选的新生代收集器,其中还有一个与性能无关但很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。(原因是Parallel Scavenge收集器和后面的G1收集器都没有使用传统的GC收集器代码框架,而是另外独立实现的,其余几种收集器则共用了框架代码)。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,当然也可以使用-XX:+UseParNewGC选项来显式指定使用

单CPU的话一般会考虑用Serial,多CPU的话一般考虑用ParNew,它默认开启的收集器线程数和CPU核数相同,当你想控制的时候,可以使用```-XX:ParallelGCThreads参数来限制收集器的线程数。

然后提前解释一下并行并发的概念,因为后面会有几个并发和并行的收集器:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。所以,遇到 Parallel 关键字的话,都是并行。所以当它们工作的时候,用户线程是阻塞的。所以也是 stop the world
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会根据时间片轮转交替进行),用户程序继续运行,而垃圾收集程序运行在另外一个CPU上。所以遇到 concurrent 关键字就是 GC 线程和用户线程在一段时间内交叉运行,不会将用户线程阻塞,不是 stop the world

Parallel Scavenge收集器

Parallel Scavenge也是一个新生代收集器,它也是使用复制算法的收集器,同时也是并行的多线程收集器。它的目标是达到可控制的CPU吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间)。比如虚拟机运行了100分钟,垃圾回收使用了1分钟,那么吞吐量就是99%。

这就说说一下应用场景了。

  • 停顿时间(垃圾回收时间): 停顿时间越短越适合于用户交互的程序,良好的响应速度能提升用户体验
  • 高吞吐量: 可以最高效率的利用CPU时间,尽快的完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

为了这两个目的,Parallel Scavenge收集器提供了2个参数:

  • -XX:MaxGCPauseMillis:大于0的毫秒数,收集器将尽力保证内存回收时间不超过这个值。不过不要异想天开认为把这个值设的特别小,就能使系统垃圾收集速度更快,GC停顿时间缩短肯定是有代价的,它会牺牲吞吐量和新生代空间来实现。
  • -XX:GCTimeRatio:大于0小于100的整数.假如设为N,那么垃圾收集时间占总时间的比率就是1/(1+N),比如设置为19,占比就是1/(1+19)=5%,默认值是99,即1%。
  • -XX:+UseAdaptiveSizePolicy:这也是一个有用的参数,放在这里说一下。它是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden、Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以一同最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器和ParNew收集器的一个重要区别

Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。这个收集器的主要意义就是被Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用;另外一个就是CMS的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。是在JDK 1.6之后才提供的。前面说过,Parallel Scavenge收集器采用了独立的架构,无法和CMS配合使用。那么,在JDK 1.6以前,Parallel Scavenge只能和Serial Old配合使用。因为Serial Old是单线程的,所以在多CPU情况下无法发挥性能,所以根本实现不了高吞吐量的需求,直到JDK 1.6推出了Parallel Old之后,Parallel Scavenge收集器和Parallel Old搭配,才真正实现了对吞吐量优先的控制。所以,在注重吞吐量及CPU资源敏感的场合,都可以考虑Parallel Scavenge和Parallel Old组合

CMS(Comcurrent Mark Sweep)收集器

CMS收集器是以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或者B/S系统上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,给用户最佳的用户体验。而CMS收集器就非常符合这类应用的需求

从名字上可以看出,”Mark Sweep“是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤:

  1. 初始标记(stop the world):初始标记仅仅只是标记一下GC roots能直接关联到的对象,速度很快
  2. 并发标记:并发标记就是进行GC Roots Tracing的过程
  3. 重新标记(stop the world):重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变化的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍微长一些,但远比并发标记的时间短
  4. 并发清除:垃圾清除

由于整个过程中,并发标记和并发清除时间最长,收集器线程可以和用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的优点在于并发收集、低停顿,但是也不是完美的,主要有3个显著的缺点:

  1. CMS收集器对CPU资源非常敏感。默认情况下,CMS的收集线程数=(CPU数目+3)/4,当CPU个数大于4的时候,CMS的收集线程不会超过整个CPU占用率的25%。但是在CPU个数比较小的情况下,CPU占用就会突然增大,这样对于初始标记和并发标记这样”Stop The World”的过程来说,用户就会明显感觉到停顿。虽然有了解决方法,但已经废除了,就不多说了。
  2. CMS收集器无法处理浮动垃圾,可能出现”Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,它需要预留一部分空间提供并发收集时的线程使用。在JDK1.5默认设置下,CMS收集器在老年代使用了68%的空间会被激活,这是一个偏保守的设置。如果在应用中,老年代增长不是太快,可以适当调高这个参数-XX:CMSInitiatingOccupancyFraction。要是CMS运行期间预留的内存无法满足程序的需要,就会出现”Concurrent Mode Failure”失败,这时候JVM会启动后备方案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,因为是单线程,停顿时间就会更长了。所以如果大量出现”Concurrent Mode Failure”,就可以将这个值调低
  3. CMS是基于标记-清除算法实现的收集器,所以会产生内存碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦:老年代还有空间但是没有连续的足够大的空间,于是不得不触发一次Full GC。为了解决这个问题,有一个开关叫做-XX:+UseCMSCompactAtFullCollection,用于在Full GC时开启内存碎片的合并整理过程。当然,这个内存整理没法并发,只有”Stop The World”了。另外,虚拟机还设计了一个参数-XX:CMSFullGCsBeforeCompaction,用于指定在多少次不压缩的Full GC后,跟着来一次带压缩的。

G1收集器

G1是一款面向服务端应用的垃圾收集器。与其他GC收集器相比,G1具备如下特点。

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

G1收集器的运作大致可划分为以下几个步骤:

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

总结:

Serial收集器:新生代;复制算法;单线程;GC区域名称(GCLogging):DefNew;优点:简单高效,Client模式下的默认新生代收集器;缺点:进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World”。
ParNew收集器:新生代;复制算法;多线程;GC区域名称(GCLogging):ParNew;优点:Server模式下的虚拟机中首选的新生代收集器。除了Serial收集器外,目前只有它能与CMS收集器配合工作;缺点:在单CPU的环境中绝对不会有比Serial收集器更好的效果
Parallel Scavenge收集器:新生代;复制算法;多线程;GC区域名称(GCLogging):PSYoungGen;优点:吞吐量优先。缺点:无法与CMS配合使用
Serial Old收集器:老年代;标记-整理算法(Mark-Compact);单线程;
GC区域名称(GCLogging):Tenured;优点:与Parallel Scavenge配合;作为CMS的后备方案。缺点:性能较低
Parallel Old收集器:老年代;标记-整理算法(Mark-Compact);多线程;GC区域名称(GCLogging):ParOldGen;优点:与Parallel Scavenge配合,真正成为吞吐量优先的收集器组合。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
CMS收集器:老年代;标记-清楚算法(Mark-Sweep);多线程;GC区域名称(GCLogging):ParOldGen;优点:响应速度快、停顿时间短。;缺点:1.对CPU资源敏感;2.无法处理浮动垃圾,因为GC标记和用户线程并发运行着;3.空间碎片多
G1:新生代,老年代;标记-整理,复制;多线程;优点:并行与并发,分代收集,空间整合,可预测的停顿

理解GC日志

1
33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC(System)”。

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致

垃圾收集器参数总结

参数 描述
UseSerialGC 虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收
UseParNewGC 打开此开关后,使用ParNew+Serial Old的收集器组合进行内存回收
UseConcMarkSweepGC 打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后壁收集器使用
UseParallelGC 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge+Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGC 打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合进行内存回收
SurvivorRatio 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1
PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
MaxTenuringThreshold 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄加1,当超过这个参数值时就进入老年代
UseAdaptiveSizePolicy
动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应对新生代的整个Eden和Survivor区的所有对象都存活的极端情况
ParallelGCThreads 设置并行GC时进行内存回收的线程数
GCTimeRatio GC时间占总时间的比率,默认值为99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效
MaxGCPauseMillis 设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效
CMSinitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后出发垃圾收集。默认值为68%,仅在使用CMS收集器时生效
UseCMSCompactAtFullCollection 设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS收集器时生效
CMSFullGCsBeforeCompaction 设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS收集器时生效

内存分配与回收策略

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

请看下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @VM param -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*
*/
public class TestEdenAllocation {

private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {

byte[] alloc1, alloc2, alloc3, alloc4;

alloc1 = new byte[2 * _1MB];
alloc2 = new byte[2 * _1MB];
alloc3 = new byte[2 * _1MB];
alloc4 = new byte[4 * _1MB];

}

}

先看vm的参数:

-XX:+UseSerialGC: 保证使用Serial/SerialOld收集器作为例子
-Xms20M -Xmx20M -Xmn10M: 限制堆为20M,10M新生代10M老年代
-XX:SurvivorRatio=8: 新生代Eden区与一个Survivor区比例8:1
-XX:+PrintGCDetails: 收集器日志参数

输出如下:

[GC[DefNew: 6817K->484K(9216K), 0.0038248 secs] 6817K->6628K(19456K), 0.0038598 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 5072K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 56% used [0x00000000f9a00000, 0x00000000f9e7af60, 0x00000000fa200000)
from space 1024K, 47% used [0x00000000fa300000, 0x00000000fa379110, 0x00000000fa400000)
to space 1024K, 0% used [0x00000000fa200000, 0x00000000fa200000, 0x00000000fa300000)
tenured generation total 10240K, used 6144K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 60% used [0x00000000fa400000, 0x00000000faa00030, 0x00000000faa00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 2519K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 11% used [0x00000000fae00000, 0x00000000fb075e08, 0x00000000fb076000, 0x00000000fc2c0000)
No shared spaces configured.

分析如下:

分配alloc4时发生一次minorGC,Eden区从6817K减少到484K。由于alloc1、alloc2、alloc3都是存活的所以没有对象可收。
这次GC发生的原因是给allo-cation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。
GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。
这次GC结束后,4MB的allocation4对象顺利分配在Eden中,因此程序执行完的结果是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、allocation2、allocation3占用)

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Sur-vivor区之间发生大量的内存复制。注意PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。

在JDK 6 Update 24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

Updated: