您的位置:首页 > 编程语言 > Java开发

深入理解Java虚拟机 读书笔记——垃圾收集器与内存分配策略

2016-11-10 16:11 337 查看

第3章 垃圾收集器与内存分配策略

关于Java中的引用类型

强引用(Strong Reference):Object obj = new Object(); 这样的常规引用,只要引用还在,就永远不会回收对象。

软引用(Soft Reference):在发生内存溢出之前,进行回收,如果这次回收之后还没有足够的内存,则报OOM。

弱引用(Weak Reference):生存到下一次垃圾回收之前,无论当前内存是否够用,都回收掉被弱引用关联的对象。

虚引用(Phantom Reference):卵用没有的引用,完全不会对对象的生命周期有任何影响,也无法通过它得到对象的实例,唯一的作用也就是在对象被垃圾回收前收到一个系统通知。

垃圾回收算法

JAVA堆

线程共享的,存放所有对象实例和数组。垃圾回收的主要区域。可以分为新生代和老年代(tenured)。

新生代用于存放刚创建的对象以及年轻的对象,如果对象一直没有被回收,生存得足够长,老年对象就会被移入老年代。

新生代又可进一步细分为eden、survivorSpace0(s0,from space)、survivorSpace1(s1,to space)。刚创建的对象都放入eden,s0和s1都至少经过一次GC并幸存。如果幸存对象经过一定时间仍存在,则进入老年代(tenured)。



方法区

线程共享的,用于存放被虚拟机加载的类的元数据信息:如常量、静态变量、即时编译器编译后的代码。也称为永久代。如果hotspot虚拟机确定一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的ClassLoader被回收。

垃圾回收算法

标记-清除算法(Mark-Sweep)

从根节点开始标记所有可达对象,其余没标记的即为垃圾对象,执行清除。但回收后的空间是不连续的。

复制算法(copying)

将内存分成两块,每次只使用其中一块,垃圾回收时,将标记的对象拷贝到另外一块中,然后完全清除原来使用的那块内存。复制后的空间是连续的。复制算法适用于新生代,因为垃圾对象多于存活对象,复制算法更高效。

在新生代串行垃圾回收算法中,将eden中标记存活的对象拷贝未使用的s1中,s0中的年轻对象也进入s1,如果s1空间已满,则进入老年代;这样交替使用s0和s1,交替使用s0+eden和s1+eden,也就是说交替使用s0配合eden往s1里面怼,或者使用s1配合eden往s0里面怼。这种改进的复制算法,既保证了空间的连续性,又避免了大量的内存空间浪费。



对复制算法进一步优化:使用Eden/S0/S1三个分区

平均分成A/B块太浪费内存,采用Eden/S0/S1三个区更合理,空间比例为Eden:S0:S1==8:1:1,有效内存(即可分配新生对象的内存)是总内存的9/10。

算法过程:

1. Eden+S0可分配新生对象;

2. 对Eden+S0进行垃圾收集,存活对象复制到S1。清理Eden+S0。一次新生代GC结束。

3. Eden+S1可分配新生对象;

4. 对Eden+S1进行垃圾收集,存活对象复制到S0。清理Eden+S1。二次新生代GC结束。

5. goto 1。

标记-压缩算法(Mark-compact)

适合用于老年代的算法(存活对象多于垃圾对象)。

标记后不复制,而是将存活对象压缩到内存的一端,然后清理边界外的所有对象。



HotSpot的算法实现

GC Roots节点主要在全局性引用(常量或类静态属性)与执行上下文中(栈帧中的本地变量表)。如之前提过的:

JVM对那些没有根引用的对象进行来及回收,也就是无法从根对象中追述的对象。

JVM垃圾回收的根对象的范围有以下几种:

1、栈中引用的对象,引用是在栈帧中的本地变量表中的,真正的对象在堆中

2、方法区perm中的类静态属性引用的对象,以及常量引用的对象

3、本地方法栈中JNI(Native方法)的引用的对象

可达性分析对时间的敏感体现在GC停顿上。在对象引用关系还在不断变化的时候是没办法愉快的进行GC的,所以GC进行时必须停顿所有Java执行线程(GC会发动大招——The World!“Stop-The-World”)。

所以这就是频繁触发GC会卡的一比的原因,但是HotSpot也没有那么蠢,它会在类加载完成的时候计算出来里面的类型(使用OopMap,一种数据结构),也会在特定位置记录栈和寄存器中哪些位置是引用。

垃圾收集器

一下这些垃圾收集器每个都够我玩一年的,所以只是简单的介绍一下得了。每个都可以写一本厚厚的书了。

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器。在它进行垃圾收集时,必须暂停其它所有的工作线程(发动大招Stop-The-World),简单而且高效,是垃圾收集器的基本。

ParNew收集器

就是Serial收集器的多线程版本,除了多线程以外跟Serial收集器没啥区别。

Parallel Scavenge收集器

CMS等收集器的目标是缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控的吞吐量(Throughoutput)。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

Serial Old收集器

是Serial收集器的老年代版本,使用“标记-整理”算法。

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

CMS(Concurrent Mark Sweep)收集器

以获取最短回收停顿时间为目的的收集器。基于“标记-清除”算法。

分为4个步骤:

初始标记

并发标记

重新标记

并发清除

初始标记标记GC Roots能够直接关联到的对象,并发标记进行GC Roots Tracing,重新标记修正并发标记期间变动的那一部分对象,最后执行并发清除。

耗时最长的并发标记和并发清除步骤都是与用户工作线程一起工作的,所以很快。

优点:并发收集,低停顿。

缺点:

对CPU资源敏感。

无法处理浮动垃圾,伴随CMS程序运行时产生的新的垃圾,所以必须预留足够的内存空间给用户使用。

因为基于“标记-清除”算法,所以会产生碎片,碎片过多会触发Full GC,Full GC会卡顿。

G1(Garbage-First)收集器

当今收集器技术发展的最前沿成果之一,屌的一比,但是很难理解。

特点:并行与并发;分代收集;空间整合;可预测的停顿。

之所以能建立可预测的停顿时间模型,是因为它可以避免在整个Java堆中进行全区域的垃圾收集,跟踪各个Region里面垃圾堆积的价值大小,后台维护一个优先列表,优先回收价值最大的。

内存分配与回收策略

对象优先分配在eden分区

Eden分区没有足够空间时,虚拟机将发起一次MinorGC

在下面的代码中,我们设置了堆的最大空间和最小空间都是20M,也就是说堆不可扩展。

然后设置了新生代空间为10M,剩下的10M就是老年代,XX:SurvivorRatio=8设置了Eden区与一个Survivor区的大小比例是8:1。

所以最后的结果应该是eden区是8M,s0是1M,s1是1M,old区是10M。

实例代码如下:

public class TestAllocation {

private static final int _1MB = 1024 * 1024;

/**
* VM参数:-verbose:gc -Xmx20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void testAllocation(){
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB];   //出现一次Minor GC
}

public static void main(String[] args) {
testAllocation();
}
}


运行结果:

Heap PSYoungGen total 9216K, used 7292K [0x00000000ff600000,

0x0000000100000000, 0x0000000100000000) eden space 8192K, 89% used

[0x00000000ff600000,0x00000000ffd1f058,0x00000000ffe00000) from

space 1024K, 0% used

[0x00000000fff00000,0x00000000fff00000,0x0000000100000000) to

space 1024K, 0% used

[0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) ParOldGen

total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000,

0x00000000ff600000) object space 10240K, 40% used

[0x00000000fec00000,0x00000000ff000010,0x00000000ff600000) Metaspace

used 2579K, capacity 4486K, committed 4864K, reserved 1056768K class

space used 287K, capacity 386K, committed 512K, reserved 1048576K

由于使用的是JDK1.8,所以结果和书中有所不同,至于为什么会这样,还在调查中。

大对象直接进入老年代

大对象就是那种很长的字符串和数组(例如前面的byte数组),大对象对于虚拟机来说是一个坏消息,尤其是那些“朝生夕灭”的“短命大对象”,写程序时应当避免,因为大对象会导致还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThredshold参数,大于这个参数的对象直接在老年代中分配,避免在Eden区和两个survivor区中发生大量内存复制(新生代采用复制算法)

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

虚拟机给每个对象设置了一个年龄计数器,对象在Eden区出生,经历一次Minor GC之后会进入Survivor区,并且年龄变为1,在Survivor区中每“熬”过一次GC就成长一岁,然后成长到15岁(默认)就老了,就会进入老年代。这个进老年代的阈值可以通过参数-XX:MaxTenuringThredshold设置。

动态对象年龄判定

死板的设定老年阈值为15可能有些死板,所以虚拟机还有比较动态的方法。如果在Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半,那这个年龄也算是老年了,后续年龄大于等于该年龄的对象就可以直接扔到老年代。(一个国家平均年龄在30岁,寿命才40,那对于这个国家30岁以上就该退休了。如果某国屌的一比,平均年龄250,寿命能到300,那不得250岁再退休啊)

空间分配担保

Minor GC之前,虚拟机会先检查老年代的最大可用连续空间是否能装下当前新生代的所有对象,如果成立,这个Minor GC才确保是安全的。因为就怕Minor GC之后,一个也没清理掉,而且Survivor也装不下,都tm要往老年代怼,老年代如果装不下就会触发Full GC,就麻烦了。

如果Minor GC是安全的还好,如果Minor GC不安全,虚拟机就会查看HandlePromotionFailure设置值是否允许担保失败。

一般为了避免Full GC过于频繁,都是会允许担保失败的。

如果允许担保失败,就会检查老年代最大可用的连续空间是否大于历次晋级到老年代的对象的平均大小(动态概率的手段,也就是通过以往晋升到老年代的对象的经验,来猜测下次能不能装下),如果猜测可以,则尝试进行一次Minor GC,如果猜测不可以,那没办法了,直接Full GC吧。

像这种担保类似贷款的模式,如果好好的突然某次Minor GC之后存活的对象突增,那就担保失败呗,只能再重新发起一次Full GC,浪费的时间是最多的,但是没办法,人生就是一场博弈。如果Full GC也没空间了,那就OOM,彻底GG。

在JDK1.6 Update24之后,HandlePromotionFailure这个参数已经废了,直接就用动态概率来决定下一步是尝试Minor GC,还是直接放弃就Full GC了。

总结

内存回收与垃圾收集器对系统性能、并发能力的影响很大,虚拟机也提供了大量的参数来调节它,没有最好的方案,只能是通过结合实际的应用需求、实现方式选择最优的收集方式才能获取最高的性能。

所以如果想要实际虚拟机调优,需要对每个具体收集器的行为、优势和劣势、调节参数有着深入的了解。这也是牛X的高端高并发Java工程师和一般的low b码农的差别。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
相关文章推荐