概述
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,由虚拟机中的垃圾收集器完成,本文主要介绍给对象分配内存的那点事儿。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。
Minor GC和Full GC的区别
新生代Minor GC:指发生在新生代的垃圾收集动作,因为Java对象大多数都具有朝生夕灭的特性,多以Minor GC非常频繁,一般回收速度也比较快。
先来回顾下垃圾回收算法,通常新生代按照8:1:1(eden space + survivor from space + survivor to space)进行内存划分,新生产的对象会被放到eden space,当eden内存不足时,就会将存活对象移动到survivor区域,如果survivor空间也不够时,就需要从老年代中进行分配担保,将存活的对象移动老年代,这就是一次Minor GC的过程。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常都伴随着至少一次的Minor GC(但并非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比Minor GC慢10倍以上。
一、GC日志分析
1.1 输出日志参数
要查看GC日志,需要设置一下jvm的参数。关于输出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 日志文件的输出路径
在这里我使用PrintGCDetails这个参数。
2.示例
package edu.jyu.jvm;
/**
* GC日志演示
* @author Jason
*
*/
public class GCLogDemo {
public static void main(String[] args) {
int _1m = 1024 * 1024;
byte[] data = new byte[_1m];
// 将data置为null即让它成为垃圾
data = null;
// 通知垃圾回收器回收垃圾
System.gc();
}
}
3.设置JVM参数
现在要运行这个演示类,在运行的时候要加上虚拟机参数PrintGCDetails,下面我分别在Eclipse和命令行中运行这个程序。
3.1 在Eclipse运行
-verbose:gc -Xms30M -Xmx30M -Xmn10M -XX:PermSize=200M -XX:MaxPermSize=200M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:SurvivorRatio=8
JVM参数说明
Xms=30M //最小堆内存
Xmx=30M //最大堆内存
Xmn=10M //年轻代所占内存
PermSize=200M //永久代所占最小内存
MaxPermSize=200M //永久代所占最大内存
SurvivorRatio=8 //年轻代中Eden区和Survivor区的比值
PrintGCDetails //打印出垃圾回收日志
PrintGCDateStamps //打印出垃圾回收的时间
3.然后点Run运行,运行后就输出了下面结果,这些就是GC日志。
[GC (System.gc()) [PSYoungGen: 3686K->664K(38400K)] 3686K->672K(125952K), 0.0016607 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 664K->0K(38400K)] [ParOldGen: 8K->537K(87552K)] 672K->537K(125952K), [Metaspace: 2754K->2754K(1056768K)], 0.0059024 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)
from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)
to space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)
ParOldGen total 87552K, used 537K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)
object space 87552K, 0% used [0x0000000081400000,0x00000000814864a0,0x0000000086980000)
Metaspace used 2761K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 299K, capacity 386K, committed 512K, reserved 1048576K
3.2 在命令行运行
因为Eclipse已经把类编译好了,所以我直接进入工程目录下运行。
输出结果
[GC (System.gc()) [PSYoungGen: 3702K->784K(38400K)] 3702K->792K(125952K), 0.0014644 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 784K->0K(38400K)] [ParOldGen: 8K->648K(87552K)] 792K->648K(125952K), [Metaspace: 2763K->2763K(1056768K)], 0.0057136 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5c00000, 0x00000000d8680000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5c00000,0x00000000d5c534a8,0x00000000d7c80000)
from space 5120K, 0% used [0x00000000d7c80000,0x00000000d7c80000,0x00000000d8180000)
to space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)
ParOldGen total 87552K, used 648K [0x0000000081400000, 0x0000000086980000, 0x00000000d5c00000)
object space 87552K, 0% used [0x0000000081400000,0x00000000814a2318,0x0000000086980000)
Metaspace used 2770K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 300K, capacity 386K, committed 512K, reserved 1048576K
4.GC日志详解
GC日志开头的”[GC”和”[Full GC”说明了这次垃圾收集的停顿类型,如果有”Full”,说明这次GC发生了”Stop-The-World”。因为是调用了System.gc()方法触发的收集,所以会显示”[Full GC (System.gc())”,不然是没有后面的(System.gc())的。不用过分解读,两个 都表示发生了gc,后面的具体信息会指明在哪些区域发生了垃圾回收。如:第二行的full gc,在PSYoungGen、ParOldGen、Metaspace对应的内存区域发生了gc。–通常也可以分别理解为触发了minor gc和full gc(通常full gc会触发一次monor gc)
“[PSYoungGen”和”[ParOldGen”是指GC发生的区域,分别代表使用Parallel Scavenge垃圾收集器的新生代和使用Parallel old垃圾收集器的老生代,这里显示的名字和使用的收集器有关。为什么是这两个垃圾收集器组合呢?因为我的jvm开启的模式是Server,而Server模式的默认垃圾收集器组合便是这个,在命令行输入java -version就可以看到自己的jvm默认开启模式。还有一种是client模式,默认组合是Serial收集器和Serial Old收集器组合。
在方括号中”PSYoungGen:”后面的”3686K->664K(38400K)”代表的是”GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)” --为何gc后没有清零,还有664k,不知道原因,可能是jvm内部产生的对象,用于垃圾收集!
在方括号之外的”3686K->672K(125952K)”代表的是”GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”–指整个java堆
再往后的”0.0016607 sec”代表该内存区域GC所占用的时间,单位是秒。
再后面的”[Times: user=0.00 sys=0.00, real=0.00 secs]”,user代表进程在用户态消耗的CPU时间,sys代表代表进程在内核态消耗的CPU时间、real代表程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。
至于后面的”eden”代表的是Eden空间,还有”from”和”to”代表的是Survivor空间。
heap:系统运行完成前的快照(所有gc完成后)
young区域就是新生代,存放新创建对象;
tenured是年老代,存放在新生代经历多次垃圾回收后仍存活的对象;
perm是永生代,存放类定义信息、元数据等信息。
可使用参数-XX:+PrintHeapAtGC 打印在进行一次GC前后堆的信息
二、分配规则
1.对象优先在Eden分配
在大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
如下代码中,尝试分配3个2MB大小和1个4MB大小的对象。在运行通过-Xms20M、-Xmx20M和-Xmn10M这三个参数限制Java堆大小为20MB,切不可扩展,其中10MB分配给新生代剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8比1,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。
执行testAllocation()中分配allocation4对象的语句会发生一次Minor GC,这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB剩下的空间已经不足够分配allocation4所需要的4MB内存,因此发生Minor GC.GC期间虚拟机又发生已有的3个2MB大小全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代中去。
这次GC结束后,4MB的allocation4对象被顺利分配在Eden,因此程序执行完的结果是Eden占用4MB(被alloction4占用),Survivor空闲,老年代被占用6MB(被alloction1、2、3占用)。
/**
* VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8 -XX:+UseSerialGC
*/
public class MinorGCTest {
private static final int _1MB = 1024 * 1024;
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];
}
public static void main(String[] agrs) {
testAllocation();
}
}
VM参数说明
示例代码说明
该段代码一共创建了4个数组对象,在给allocation4分配空间前的内存空间使用情况如下
需要执行一次MinorGC才能给allocation4分配空间,分配成功以后内存空间使用情况如下:
2. GC日志
[GC [DefNew … …]
GC日志开头的信息通过设置-verbose:gc参数后才能输出。
“[GC"和”[Full GC"说明这次垃圾收集的停顿类型,如果这次GC发生了Stop-The-World,则为"[Full GC",否则为"[GC"
"[DefNew “表示GC发生的区域为Serial收集器的新生代中,DefNew是"Default New Generation"的缩写。Serial收集器的老年代和永久代分别表示为"Tenured”、“Perm”
** eden space 8192K, 52% used**
新生代的Eden区总共大小为8MB,使用掉的4MB是用来存放allocation4对象
tenured generation total 10240K, used 6144K
老年代大小为10MB,使用掉的6MB是用来存放allocation1、allocation2和allocation3这3个对象
2.大对象直接进入老年代
所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。虚拟机提供了一个
-XX:PretenureSizeThreshold参数,令大于这些设置值的对象直接在老年代中分配。这样做的目的是在避免Eden区及两个Survivor区之间发生大量的内存拷贝(复习一下:新生代采用复制算法手机内存)。
注意:XX:PretenureSizeThreshold这个参数只能在ParNew和Serial两款收集器中起作用
/**
* VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8 -XX:+UseSerialGC
* -XX:PretenureSizeThreshold=3145728
*/
public class TestClass2 {
private static final int _1MB = 1024 * 1024;
public static void testPretenureSizeThreshold() {
byte[] allocation;
allocation = new byte[4 * _1MB];
}
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
testPretenureSizeThreshold();
}
}
VM参数说明
示例代码说明
该段代码创建了一个数组对象allocation,大小为4MB,已经超出PretenureSizeThreshold设置的范围,该对象将直接被分配到老年代中。
- GC日志
tenured generation total 10240K, used 4096K
老年代大小为10MB,用掉的4MB用来存放allocation对象
3.长期存活的对象将进入老年代
虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1.对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。
我们可以分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行下面的代码
/**
* VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1
*/
public class TestClass3 {
private static final int _1MB = 1024 * 1024;
public static void testTenuringThreshold() {
byte[] allocation1, allocation2, allocation3;
allocation1 = new byte[_1MB / 4];
allocation2 = new byte[4 * _1MB];
allocation3 = new byte[4 * _1MB];
allocation3 = null;
allocation3 = new byte[4 * _1MB];
}
public static void main(String[] agrs) {
testTenuringThreshold();
}
}
VM参数说明
示例代码说明
该段代码创建了3个数组对象,当执行到"allocation3 = new byte[4 * _1MB]; "时,Eden已经被占用了256KB + 4MB,而创建allocation3需要4MB,已经超过Eden的大小8MB,需要先发生一次MinorGC,才能保证有空间存放allocation3
- GC日志
设置参数为MaxTenuringThreshold=1的运行结果
由GC日志开头的两句"[GC [DefNew"可知,该段代码一共发生了2次GC,第一次是"allocation3 = new byte[4 * _1MB]; ",第二次是执行allocation3 = null时
allocation1在经过第一次GC时,对象年龄变成了1,由于设置的MaxTenuringThreshold=1,当发生第二次GC时,allocation1的年龄已经超出了设置的阀值,allocation1进入到老年代,因此,新生代的from space使用空间为0,对应GC语句为from space 1024K, 0% used
设置参数为MaxTenuringThreshold=15的运行结果
由于设置的MaxTenuringThreshold=15,发生第二次GC时,allocation1的年龄没有超出设置的阀值,因此,新生代的from space使用空间不为0,对应GC语句为from space 1024K, 44% used
有些情况,需要设置-XX:TargetSurvivorRatio=90,不过不设置,则可能并没有达到年龄阈值就进入老年代了,是因为达到了动态对象年龄判定的条件
4.动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄的所有对象的总大小大于Survivor空间的一半,那么,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
执行代码中的testTenuringThreshold2()方法,并设置参数-XX:MaxTenuringThreshould=15,会发现运行结果中的Survivor的空间占用仍然为0%,二老年代比预期增加6%,也就是说allocation1、allocation2对象都直接进入老年代,而没有等到15岁的临界年龄。因为这两个对象加起来达到512KB,并且他们是同年的,满足同年对象达到Survivor空间的一般规则。我们只要注释掉一个对象的new操作,就会发现另一个不会晋升到老年代中去了。
public class EdenAllocationTest
{
private static final int _1MB = 1024 * 1024;
public static void testTenuringThreshold2()
{
byte[] allocation1 = new byte[_1MB/4];
byte[] allocation2 = new byte[_1MB/4];
byte[] allocation3 = new byte[4 * _1MB];
byte[] allocation4 = new byte[4 * _1MB];
allocation4=null;
allocation4 = new byte[4 * _1MB];
}
}
5.空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,仍然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC 过于频繁
先解释YGC:
当对象生成在EDEN区失败时,出发一次YGC,先扫描EDEN区中的存活对象,进入S0区,S0放不下的进入OLD区,再扫描S1区,若存活次数超过阀值则进入OLD区,其它进入S0区,然后S0和S1交换一次。
那么当发生YGC时,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,如果大于,那么这次YGC是安全的,如果不大于的话,JVM就需要判断HandlePromotionFailure是否允许空间分配担保。
允许分配担保:
JVM继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则正常进行一次YGC,尽管有风险(因为判断的是平均大小,有可能这次的晋升对象比平均值大很多);
如果小于,或者HandlePromotionFailure设置不允许空间分配担保,这时要进行一次FGC。
新生代采用的是复制收集算法,S0和S1始终只是用其中一块内存区,当出现YGC后大部分对象仍然存活的话,就需要老年代进行分配担保,把survior区无法容纳的对象直接晋升到老年代。
那么这种空间分配担保的前提是老年代还有容纳的空间,一共有多少对象会活下来,在实际完成内存回收之前是无法明确知道的,所以只好取之前每次回收晋升到老年代对象容量的平均值大小作为经验值,与老年代的剩余空间比较,决定是否进行FGC来让老年代腾出更多空间。
参考:
gc日志:
https://blog.csdn.net/brushli/article/details/78609816
http://ifeve.com/useful-jvm-flags-part-8-gc-logging/
https://blog.csdn.net/hp910315/article/details/50936629
内存分配和回收:
https://blog.csdn.net/fxkcsdn/article/details/81435749
https://www.jianshu.com/p/fd1d4f21733a
https://www.cnblogs.com/yangchunchun/p/7405502.html
https://blog.csdn.net/v123411739/article/details/78941793