JVM 4 内存分配和垃圾回收策略
内存分配和垃圾回收策略
Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也 有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。
对象分配的规则并不是固定的,这取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定。
对象优先在Eden中分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,将Eden和Survivor区域中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
在Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明 还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个 Survivor区之间来回复制,产生大量的内存复制操作。
比遇到一个大对象更加坏的消息就是遇到一 群“朝生夕灭”的“短命大对象”,因为大对象本身就会直接进入老年代,然后此时我们还需要频繁创建大对象,这就频繁需要连续的内存,会导致提前出发major gc,我们知道major gc的速度比minor gc本身就慢10倍以上,而且还会导致用户线程频繁的暂停,用户使用程序时会出现频繁的卡顿现象。
注意:
-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew加CMS的收集器组合。
长期存活的对象将进入老年代
HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。
为做到这点,虚拟机给每个对象定义了一个对象年龄 (Age)计数器,存储在对象头中。对象通常在Eden区 里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。 对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
动态对象年龄判断
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
发生Minor GC时,是使用复制算法将Eden区和Survivor区存活对象复制到另一个Survivor区:
- Survivor区只占新生代10%空间,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
- 内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证,如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。
- 内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间:
- 如果大于,则此次Minor GC是安全的;如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
- 上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor
GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。 - 老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。
理解GC日志
HotSpot虚拟机提供了 -XX:+PrintGCDetails 这个收集器日志参数, 告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性。
我们通过以下两段典型的GC日志来学如何阅读GC日志:
[GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
[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]
- GC日志开头的“[GC ”和“[Full GC ”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有 “Full”,说明这次GC是发生了Stop-The-World的。 例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC (System) ”。
[Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs]
- 接下来的“[DefNew ”、“[Tenured ”、“[Perm ”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew ”。如果是ParNew收集器,新生代名称就会变为“[ParNew ”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen ”,老年代和永久代同理,名称也是由收集器决定的。
- 后面方括号内部的“ 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命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件所用时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。
CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以看到user或sys时间超过real时间是完全正常的。