0%

《深入理解Java虚拟机:JVM高级特性与最佳实践》垃圾收集器与内存分配策略

本文讨论垃圾收集和内存分配技术。我们知道:

1
2
虚拟机进程内存管理对所使用的“Native Memory”的具体分类为:
程序计数器,Java虚拟机栈,本地方法栈,Java堆,方法区,直接内存,自己写的JNI方法直接所分配内存,虚拟机进程运行自身需要内存和其他使用内存。

关于这些内存区域的“垃圾收集和内存分配”有以下几点:

  • “程序计数器,Java虚拟机栈,本地方法栈”这3个内存区域随线程而生,随线程而灭,栈中的栈帧(栈帧需要多少内存在类结构确定时就大体已知)随着方法的进入和退出执行出栈和入栈操作,线程结束时,这3个区域的内存自然就跟随着被回收,因此这3个区域的垃圾收集和内存分配具备确定性
  • “直接内存”这个内存区域的垃圾收集和内存分配跟C/C++语言类似:手动回收和分配内存(使用“直接内存”有使用“sun.misc.Unsafe”和“java.nio.ByteBuffer”两种方式,使用“java.nio.ByteBuffer”方式时,“回收内存”的方法会被自动调用,此时内存回收“不再手动”)[1]
  • “自己写的JNI方法直接所分配内存,虚拟机进程运行自身需要内存,其他使用内存”这3个内存区域的垃圾收集和内存分配方式是“手动的”
  • 对于“Java堆和方法区”这两个内存区域而言,垃圾收集和内存分配是动态的。由于两者存储对象的性质不同,因此,两者的垃圾收集和内存分配策略也不尽相同

由于“Java堆”是虚拟机进程中最重要的内存区域,因此,接下来围绕“Java堆”这个内存区域介绍垃圾收集和内存分配技术。不同JDK版本,采用的机制和实现有可能不同,本文选定JDK6版本(具体是jdk1.6.0_23)。
为了更好地管理“Java堆”内存,“Java堆”分为“新生代(又被称为“New”)”和“老年代(又被称为“Old”或者“Tenured”)两个年代区域。“新生代”又由“1个Eden区和2个Survivor区”构成,“新生代”可使用总内存为“1个Eden区与1个Survivor区”内存之和。

一、可达性分析——判断对象能否被回收

1.1、“引用”的扩展概念

在JDK 1.2之后,“引用”概念被扩展,分为:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)。详细描述见表1。

表1

名称 详细介绍
强引用 普遍存在于Java代码中,类似于Object obj=new Object()
软引用 在抛出内存溢出异常之前,虚拟机会断掉“软引用”关系,并进行一次垃圾收集,如果在垃圾收集后,内存还是不足,才会真正抛出内存溢出异常。JDK中关于“软引用”的相关类为java.lang.ref.SoftReference
弱引用 “弱引用”关系只能经历一次垃圾收集过程,之后该“弱引用”关系便会断掉。JDK中关于“弱引用”的相关类为java.lang.ref.WeakReference
虚引用 被“虚引用”的对象相当于没被引用,“虚引用”关系存在的唯一目的是:在被“虚引用”的对象被回收时,能够得到一个系统通知。JDK中关于“虚引用”的相关类为java.lang.ref.PhantomReference

1.2、可达性分析算法

1
以一系列被称为“GC Roots”的对象作为起始点,如果某个对象被引用可达,那么该对象不可被回收,否则,该对象可被回收。

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

  • Java虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

需要注意的是,上述“引用可达”中的“引用”包括“强引用,软引用和弱引用”,另外,由上一小节内容可知,“软引用”和“弱引用”关系在某些条件下会“断掉”,“断掉”就意味着“不可达”。

1.3、能否“死而复生”

对象真正可被回收,需要得到两次标记:
1、对象从“GC Roots”不可达,得到1次标记
2、“对象没有覆盖finalize()方法”或者“对象覆盖了finalize()方法,且该方法被虚拟机调用过1次”,得到1次标记;否则,有一个由虚拟机自动建立的低优先级的Finalizer线程会去触发执行该对象的finalize()方法,然后该对象得到1次标记

备注:
1、Finalizer线程触发执行对象的finalize()方法,但是并不保证finalize()方法一定会执行完成
2、对象的finalize()方法最多被触发执行1次
3、对象的finalize()方法执行过程中,可将对象自身变为从“GC Roots”可达,从而“死而复生”
4、对象的finalize()方法运行代价高昂,而且该方法能做的使用“try-finally”或者其他方式都可以做到,因此,应该完全忘记掉该方法的存在

二、垃圾收集

2.1、基本垃圾收集算法描述

基本垃圾收集算法描述见表2。

表2

中文名称 英文名称 描述 不足
标记-清除 Mark-Sweep “标记阶段”:标记所有需要回收的对象;“清除阶段”:统一回收被标记的对象 “标记阶段”和“清除阶段”效率不高,标记清除之后会产生大量内存碎片
复制 Copying 将可用内存分为相同大小的两半内存。“标记阶段”:标记所有需要回收的对象;“回收阶段”时,将一半内存中未在“标记阶段”被标记的对象复制到另外一半内存 可使用内存减少一半
标记-整理 Mark-Compact “标记阶段”:标记所有需要回收的对象;“整理阶段”:保留未被标记的对象,并进行紧凑整理 “标记阶段”和“整理阶段”效率不高

2.2、垃圾收集器

在继续介绍之前,先介绍几个概念:
1、并行和并发
“原始并行”的含义是:存在多CPU,多个进程/线程在不同CPU上同时运行;“原始并发”的含义是:存在单CPU,多个进程/线程在该单CPU上交替运行。“本文上下文的并行”的含义是:多个GC线程并行/并发运行,用户线程暂停;“本文上下文的并发”的含义是:GC线程和用户线程并行/并发运行。
2、吞吐量和停顿时间控制平衡
吞吐量=用户线程运行时间/(用户线程运行时间+GC线程运行时间)。想要减小停顿时间,在不考虑优化垃圾回收算法前提下,只能减小待回收垃圾内存空间的大小,这会导致虽然单次垃圾回收停顿时间减小,但是在一个恒定时间区间内,垃圾回收次数增加,停顿时间总和增加,因此,停顿时间减小,吞吐量也随之减小。
停顿时间越小,越适合交互应用程序;吞吐量越大,越适合后台计算式不需要太多交互的应用程序。
3、不同年代垃圾回收具有独立性
通过一定技术手段,新生代和老年代的垃圾收集器能够独立完成所处年代的垃圾回收工作。即可独立完成所处年代的可达性分析,并进行垃圾回收。
4、安全点、安全区域和STW
为完成垃圾回收过程的一些关键步骤,必须暂停所有用户线程,只允许运行GC线程,此时的状态被称为“Stop The World(STW)”。此时,用户线程或暂停于安全点(Safepoint),或处于安全区域(Safe Region)中(处于安全区域中的用户线程或处于Running状态,或处于Sleep状态,或处于Blocked状态,或跟安全点处的用户线程一样处于暂停状态。需要注意的是,处于Sleep或者Blocked状态的用户线程一定处于安全区域中)。“安全区域”可被认为是扩展的“安全点”,接下来的图示中,以“安全点”涵盖“安全点和安全区域”。
5、新生代垃圾收集器与老年代垃圾收集器配对
新生代的垃圾收集器与老年代的垃圾收集器不能任意匹配,存在一个配对关系。如图1所示。

图1

2.2.1、Serial收集器

采用“复制算法”的“新生代”垃圾收集器,JVM运行在Client模式下首选的“新生代”垃圾收集器,主要关注点在于尽量小的停顿时间。进行GC过程时,既不“并行”,也不“并发”,需要“Stop The World”。示意图如图2所示。

图2

2.2.2、ParNew收集器

采用“复制算法”的“新生代”垃圾收集器,JVM运行在Server模式下首选的“新生代”垃圾收集器,主要关注点在于尽量小的停顿时间。进行GC过程时,“并行”不“并发”,需要“Stop The World”。示意图如图3所示。

图3

2.2.3、Parallel Scavenge收集器

采用“复制算法”的“新生代”垃圾收集器,主要关注点在于达到可控制的吞吐量,且能够进行自适应调节。进行GC过程时,“并行”不“并发”,需要“Stop The World”。示意图如图4所示。

图4

2.2.4、Serial Old收集器

采用“标记-整理算法”的“老年代”垃圾收集器,主要关注点在于尽量小的停顿时间。进行GC过程时,既不“并行”,也不“并发”,需要“Stop The World”。示意图如图5所示。

图5

2.2.5、Parallel Old收集器

采用“标记-整理算法”的“老年代”垃圾收集器,主要关注点在于达到可控制的吞吐量,且能够进行自适应调节。进行GC过程时,“并行”不“并发”,需要“Stop The World”。示意图如图6所示。

图6

2.2.6、CMS收集器

采用“标记-清除算法”的“老年代”垃圾收集器,主要关注点在于尽量小的停顿时间。整个GC过程分为4个步骤:初始标记,并发标记,重新标记和并发清除。示意图如图7所示。

图7

1、初始标记
标记GC Roots能直接可达的对象。既不“并发”,也不“并行”。需要“Stop The World”。
2、并发标记
标记GC Roots间接可达的对象。“并发”不“并行”。
3、重新标记
修正“2、并发标记”阶段由于用户程序运行造成的变动。“并行”不“并发”。需要“Stop The World”。
4、并发清除
清除不可达对象。“并发”不“并行”。

几个缺点:
1、“并发”导致“吞吐量”下降
2、会出现“浮动垃圾”;如果“并发清除”阶段用户线程运行内存需求得不到满足,会导致出现“Concurrent Mode Failure”
3、具有“标记-清除算法”的固有缺点,即产生大量内存碎片

三、内存分配

内存分配的几条重要原则如下:
1、对象优先在新生代的Eden区进行分配
2、大对象直接进入老年代,涉及到的JVM参数为PretenureSizeThreshold
3、新生代中能够长期存活的对象将进入老年代,而不是一直处于新生代的Survivor区中,涉及到的JVM参数为MaxTenuringThreshold
4、如果新生代Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象可直接进入老年代
在内存分配过程中,如果出现分配失败,会触发垃圾收集过程(涉及到“新生代和老年代的垃圾收集过程”,也涉及到“Minor GC,Major GC和Full GC等过程”)。具体详细的内存分配过程和垃圾收集触发过程非常复杂,网上也缺少这方面的资料,因此最好的方式是阅读OpenJDK相应模块的源代码。

四、其他

4.1、JVM运行的Client模式和Server模式

JVM的运行模式之分。JVM运行在Client模式,启动速度快,适合于交互应用程序;JVM运行在Server模式,启动速度慢,整体性能好,适合后台计算式不需要太多交互的应用程序。

4.2、JDK的product,debug和fastdebug版本

JDK的版本之分。不同的版本具有不同的应用场景,比如“product版本的JDK”经过优化,运行速度快;“debug版本的JDK”未经过优化,但能够打印很多有用的日志信息;“fastdebug版本的JDK”介于“product版本”和“debug版本”之间。从源代码编译生成JDK时,通过不同的编译参数可生成不同的JDK版本。

4.3、Minor GC,Majoc GC和Full GC

Minor GC指代发生在新生代的垃圾收集过程,由于新生代内的对象具有朝生夕灭的特性,因而Minor GC回收速度快,但也发生得非常频繁。
“Major GC”和“Full GC”都没有官方正式的定义,因此想要了解它们具体详细的含义,最好查看OpenJDK源代码。不过可以明确的一点是,“Major GC”和“Full GC”指代的垃圾收集过程相对于“Minor GC”速度慢得多。

4.4、G1收集器

G1收集器管控新生代和老年代,整体采用“标记-整理算法”,局部采用“复制算法”,主要关注点在于尽量小的停顿时间,并且已经达到“可预测的停顿时间”水平。在G1收集器模型中,新生代和老年代的内存区域不再连续,Java堆被分为多个大小相等的区域(Region),该模型下的新生代和老年代都是一部分Region(不需要连续)的集合。G1收集器保存Region内对象的价值大小(回收该对象获得的空间大小和回收该对象所需时间的经验值),在后台维护一个优先列表,在进行垃圾回收过程时,优先回收价值最大的Region(这也是Garbage-First名称的由来)。
G1收集器进行垃圾回收时,整个GC过程分为4个步骤:初始标记,并发标记,最终标记和筛选回收。示意图如图8所示。

图8

1、初始标记
标记GC Roots能直接可达的对象。既不“并发”,也不“并行”。需要“Stop The World”。
2、并发标记
标记GC Roots间接可达的对象。“并发”不“并行”。
3、最终标记
修正“2、并发标记”阶段由于用户程序运行造成的变动。“并行”不“并发”。需要“Stop The World”。
4、筛选回收
根据Region回收价值大小进行排序,依优先级回收Region。“并行”不“并发”。需要“Stop The World”。

4.5、GC日志阅读

GC日志中关键内容的格式描述如下:

1
2
3
4
5
6
7
[GC/Full GC {Java堆GC前后内存变化描述} 可有可无的“两者之间逗号” {方法区/永久代GC前后内存变化描述}, 整个GC过程耗费时间]

Java堆GC前后内存变化描述=[年代名称:该年代的{GC前后内存变化描述} 可有可无的“该年代GC过程耗费时间”]...[年代名称:该年代的{GC前后内存变化描述} 可有可无的“该年代GC过程耗费时间”] Java堆的{GC前后内存变化描述}

方法区/永久代GC前后内存变化描述=[方法区/永久代名称:该年代的{GC前后内存变化描述} 可有可无的“该年代GC过程耗费时间”]

GC前后内存变化描述=GC前占用内存->GC后占用内存(总内存)

关于年代名称的值列表如表3。

表3

年代 名称值列表 描述
Java堆的年轻代 DefNew,PSYoungGen
Java堆的老年代 Tenured,CMS,PSOldGen,ParOldGen
方法区/永久代 Perm,CMS Perm,PSPermGen 方法区/永久代在不同垃圾收集器中具有不同名称

GC日志例子如下:

1
2
3
4
5
[GC [DefNew: 4679K->371K(9216K), 0.0034140 secs] 4679K->4467K(19456K), 0.0035090 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [Tenured: 0K->4467K(10240K), 0.0093960 secs] 4679K->4467K(19456K), [Perm : 29K->29K(12288K)], 0.0095620 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]
[Full GC [Tenured: 4467K->4454K(10240K), 0.0065910 secs] 4467K->4454K(19456K), [Perm : 29K->23K(12288K)], 0.0067530 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC [PSYoungGen: 376K->0K(9216K)] [PSOldGen: 4096K->4467K(10240K)] 4472K->4467K(19456K) [PSPermGen: 1698K->1698K(12288K)], 0.0029130 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC [PSYoungGen: 0K->0K(9216K)] [ParOldGen: 4467K->4454K(10240K)] 4467K->4454K(19456K) [PSPermGen: 1697K->1692K(12288K)], 0.0067000 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

参考文献: [1]http://blog.csdn.net/aitangyong/article/details/39323125 [2]http://stackoverflow.com/questions/198577/real-differences-between-java-server-and-java-client [3]https://blogs.oracle.com/kto/entry/mustang_jdk_6_0_fastdebug [4]https://plumbr.eu/blog/garbage-collection/minor-gc-vs-major-gc-vs-full-gc [5]http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
您的支持将鼓励我继续分享!