在介绍GC之前有必要先了解一下JVM的内存划分,这样在后面介绍GC和各种不同的GC collector的时候更容易理解。
下面这张图是“偷”的别人的,很经典的描述了jvm的体系结构,我们只需要关注最大的那一块——运行时数据区域。
运行时区顾名思义是jvm在运行时的内存结构,主要有以下5种。
1.方法区
方法区是各个线程共享的一块内存区域,当虚拟机装载一个class文件时,它会从二进制数据中解析类型的信息,这些信息便是存储在方法区,包括类的静态变量也会存储到该区域。虚拟机规范把该区域划分为堆的一部分,但是实际上它还有个别名Non-Heap,很明显是用来和堆做区分的。在讨论GC时我们习惯把这个区域叫做永久代,本质上它俩不是一个概念,对于HotSpot来说,永久代仅仅是实现方法区的一种方式。并且HotSpot后续的版本计划移除永久代,如果该区域内存不足时,会发生OOM。
2.堆
堆和方法区一样也是各个线程共享的一块内存区域,所有的对象实例包括数组都在这里分配内存。比如当我们new Object()的时候便是在这里分配的内存,java堆可以说是jvm管理的最大的一块内存区域,需要注意的一点是和方法区一样jvm规范并没有要求堆是连续的,jvm可以在运行时动态的扩展和收缩堆。为了更好的实现GC,现代的jvm针对堆又做了细化,将一整块堆分成不同的区域。下面这张图来自oracle官方网站,详细的画出了堆的详细情况。
图倒是蛮大的- -,还是横着的,凑合着看吧,整个堆分为三个区域,Young区、Tenured区(也就是Old区)、Perm区,习惯上我们称为年轻代,年老代,永久代(实际上GC就是按照这三个代进行分代收集的)。细心的朋友可能会注意到每个区域都有一块virtual,有必要说明一下virtual是做什么,我们知道堆可以在运行时扩充,比如在配置虚拟机参数的时候通常会指定-Xmx,-Xms最大堆和初始堆,这里的virtual就是预留的内存区域,其值为最大堆减去初始堆的值,实际上操作系统一开始就会划分-Xmx大小的内存给jvm,只不过jvm一开始可能不需要那么大的空间,因此jvm将一部分内存标记为virtual区,留着后面扩展用。三种内存区域中Young区稍微复杂些,这里又分为三个区域分别为一个Eden和两个Survivor区(一个to
survivor一个from survivor),名字很有意思,一个是伊甸园一个是幸存区,关于这几个区具体存放的是什么后面做进一步解释。
3.虚拟机栈
虚拟机栈为线程私有,因此处于这个区域的值不需要考虑并发的问题。jvm为每个java线程都分配一个栈,为该线程私有,栈内存随着线程的销毁而释放。jvm为每个方法都会生成一个栈帧用于保存局部变量表,操作数栈(这些概念在我之前的博客中也提到过)等信息。基本类型和对象的引用都可以在栈中存储。该区域有可能抛出StackOverflowerror和OOM异常。
4.本地方法栈
本地方法栈类似虚拟机栈,只不过虚拟机栈是为java方法服务,本地方法栈为本地的Native方法服务。
5.程序计数器
程序计数器是一块非常小的内存区域,它是当前线程执行的字节码的行号指示器,总是指向下一个要执行的指令,该区域是唯一不会发生OOM的内存区。
上面说过虚拟机栈,本地方法栈和程序计数器它们的内存分配在编译期基本就可以确定,并且内存随着线程的方法结束或者线程结束而销毁,因此这部分内存不需要考虑回收的问题。堆和方法区的内存分配相比来说就具有了不确定性,而且这部分的内存分配和回收都是动态的,因此jvm需要针对这两块内存做GC。
趁热打铁,刚刚讲完堆的分代,正好来看下为什么要分代以及各个代中存储的对象有何不同。
之所以要分代很明显的一个原因是方便做垃圾收集,因为垃圾收集只是针对那些没有被引用的孤对象进行的,而研究表明java中大多数的对象都是短命的,但也有一些对象存活的时间比较长。因此为了针对这些生命不一的对象做收集,将堆划分为不同的代来存放这些对象,也就是说Young区中的对象都是比较“年轻的”,同理可理解Old区。这就是为什么叫做Young和Old的原因。
实际上GC并不是java独有的,GC的历史要比java悠久。关于GC的基本原理比如引用计数法,可达性分析法等等就不作介绍了。GC主要有两种,一种是minor gc另一种是major gc也有称为young gc和old gc的。minor gc发生在Young区,并且时间通常非常短,major gc发生在Old区,时间较长,需要控制major gc的次数和GC时间。
jvm按照对象存活的时间,给对象一个类似我们人类“年龄的概念”,实际上每经过一次GC,存活下来的对象年龄便+1,大多数情况下对象优先分配到Eden区,这就是为什么这里叫Eden区的原因,当Eden区的没有足够内存分配的时候,便会触发一次Minor GC。此时存活下来的对象年龄+1,当达到一定年龄的时候,表明该对象存活比较稳定,会把该部分对象移到年老代(我们可以通过参数-XX:MaxTenuringThreshold 控制经过多少次Minor gc后便进入old区,默认为15),如果在Minor GC的时候发现to
survivor存放不下这些对象,则会直接存放到年老代。需要注意的一点是当要分配大对象(比如数组和长字符串)的时候,也是直接将他们分配到年老代的,因此我们尽量避免大对象尤其是短命大对象的使用,因为这很容易引起old区的内存不够分配,从而提前触发full gc。当年老代没有足够内存的时候便会触发Major GC,通常minor gc的时间非常短对于程序影响可以忽略,但是major gc的过程则要比minor gc长不少,因此要尽量避免major gc的发生(当发生gc的时候会暂停所有的应用线程执行,官方称为stop
the world,这里的时间指的就是stop the world的时间)。
来看一下目前几种主要的GC算法,之所以这些算法并存是因为针对不同的区域通常需要使用特定的算法。
1.标记-清除
很明显该算法分为两步,第一步是标记出需要回收的对象,第二步针对这些对象做清理动作。该算法的缺点主要有两个:一是效率问题,标记和清除的效率都不高,二是清除之后会产生大量不连续的内存碎片,这会导致运行过程中如果需要分配较大的对象,会由于找不到足够的连续内存而提前触发一次垃圾收集。
2.复制算法
复制算法将内存分为大小相等的两块,每次使用一块,当一块使用完时,将还存活的对象全部复制到另一块区域中,然后清理第一块的内存,该算法的好处不言而喻,不会产生内存碎片,但是只使用一块内存未免代价太大,并且在存活对象非常多的时候,效率肯定会低下。实际上在java中绝大多数的对象都是“短命的”,因此不需要按照1:1来划分内存,HotSpot默认将Eden区和两个survivor区按照8:2的比例进行划分,也就是Eden:survivor=8:1(当然我们可以通过-XX:SurvivorRatio设置Survivor的大小,该值如果为8,则表示Eden区占Young的十分之八,两个Survivor占Young区的十分之二),该算法非常适合在minor
gc时使用。
3.标记-整理
复制算法针对有大量存活的对象时效率低下,因此不适合对年老代的回收,但是标记清除又有碎片的问题,因此产生了标记整理算法。标记整理算法和标记清除算法类似,第一步都是标记,而第二步整理阶段是将存活的对象移向一端,然后直接清理边界以外的内存,这样不会产生碎片效率也不算太差。
针对上面几种算法,HotSpot主要提供了以下几种实现:
图中黄色背景是年轻代的收集器,浅灰色背景是年老代的收集器,蓝色背景表示垃圾收集器,两两直线相连表示两种收集器可以共用。下面分别介绍一下这六种收集器:
1."Serial"会引起stop the world,基于拷贝的单线程收集器。
2."ParNew"即是serial的多线程版本,不同于 "Parallel Scavenge" ,ParNew可以和CMS配合一起使用威力更大。
3. "Parallel Scavenge"会引起stop the world,基于拷贝的多线程收集器。
4."Serial Old"会引起stop the world,基于标记-清除-整理的单线程收集器。
5."CMS"是一种并发短暂停的收集器,其中的某些步会引起stw,后面详细讲解。
6."Parallel Old"一种并发的基于标记-整理的收集器,Parallel Scavenge的年老代版本。
以上六种最复杂的是CMS收集器,后面会详细讲解。
ParNew是多线程并行收集器,CMS是并发收集。这里不得不提一句,并行指的是多个垃圾收集线程一起进行回收工作,此时应用线程是停止,但是并发指的是收集线程和应用线程同时执行,也就是垃圾收集工作的时候并不影响应用(这里的不影响只是相对的,实际CMS的工作过程分为好多步,有些步骤也会发生stop the world)。
既然ParNew和Parallel Scavenge都是针对新生代的并行收集,那么他们两个有什么不同呢?
像CMS和ParNew等收集器主要关注的是减少因收集而引起的应用停顿时间,而Parallel Scavenge主要关注的是应用的吞吐量,所谓的吞吐量就是CPU用于运行应用程序时间和CPU总消耗时间的比值,比如虚拟机总共运行100分钟,而垃圾收集用掉了1分钟,吞吐量即为99/100=99%。而对于Parallel Scavenge有个特殊的参数 -XX:+UseAdaptiveSizePolicy应用该参数JVM可以在运行时自动调整堆内存各个区的大小,不需要人为的配置。
针对虚拟机参数使用-XX配置不同的收集器,主要有以下几种:
UseSerialGC 是"Serial" + "Serial Old"
UseParNewGC 是 "ParNew" + "Serial Old"
UseConcMarkSweepGC 是"ParNew" + "CMS" + "Serial Old"。 年老代的回收绝大多数时间使用"CMS"。但是当发生concurrent mode failure错误的时候会切换到"Serial Old" 。
UseParallelGC是"Parallel Scavenge" + "Serial Old"
UseParallelOldGC是"Parallel Scavenge" + "Parallel Old"
上面这张图是CMS收集器的几个工作阶段分别是:初始标记,并发标记,重新标记,并发清除。其中的1,3两个步骤需要暂停所有的应用程序线程的。第一次暂停从root对象开始标记存活的对象,这个阶段称为初始标记;第二次暂停是在并发标记之后, 暂停所有应用程序线程,重新标记并发标记阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致)。第一次暂停会比较短,第二次暂停通常会比较长,并且 remark这个阶段可以并行标记。一个CMS会发生两次STW。因此在使用CMS的垃圾收集器的时候,通常我们使用jstat查看的fullgc(有一种说法是fullgc的次数就是STW的次数)次数和cms发生的次数为2:1的关系。关于CMS的参数有不少需要关注的点:
其他的一些关于GC的参数还有下面一些:
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
上面个的参数主要涉及GC日志的打印,jvm还有很多其他的参数不一一描述了,网上有很多详细的讲解。
讲了那么多GC,下面来分析一段GC日志
519.514: [GC 519.514: [ParNew: 5149852K->83183K(5662336K), 0.0831770 secs] 6955196K->1905793K(9856640K), 0.0833560 secs] [Times: user=0.57 sys=0.03, real=0.08 secs ]
前面的519.514表示了自虚拟机启动到该GC发生的秒数,[GC表示本次是普通的GC当然还有[Full GC,[ParNew表示使用的是ParNew收集器对年轻代做收集, 5149852K->83183K(5662336K)分别表示GC前该区域已使用的内存大小,GC后该区域使用的内存大小,该区域的总大小。 0.0831770 secs表示GC所占用的时间单位为秒,后面更详细的时间user=0.57 sys=0.03, real=0.08 secs与Linux的time命令所输出的时间含义一致。