虚拟机系列:JVM中的内存分配
以下测试的jdk版本是1.8,垃圾收集器是ps+po
前文也说过对象的分配就是在堆上分配,对象主要分配在Eden区上,但是也可能按照线程优先分配在TLAB上,少数情况下可能会直接分配在老年代中。具体分配在哪里并不是百分百固定的,其会受垃圾回收器组合,以及虚拟机中与内存相关的参数设置的影响。
强调说明:空对象也会占用一些eden内存,也就是说jvm运行本身会占用一些内存,如下没有任何对象,但是eden占用了近3M:
配置如下
如果想要看的虚拟机垃圾回收的信息可以使用-XX:PrintGCDetails参数控制,idea中配置如下
如上代码配置 -Xms50m -Xmx50m -Xmn20m -XX:+PrintGCDetails -XX:+PrintHeapAtGC
来控制这块代码的的堆内存大小。
-Xms50m : 堆内存最小50M
-Xmx50m : 堆内存最大50M
-Xmn20m : 新生代20M,老年代就剩余30M
-XX:+PrintGCDetails:打印gc信息
含义是 Java堆最大最小都是50MB 且不可变化,由于新生代可以分为Eden区和两个Survivor区(from区和to区),且比例是8:1:1,所以Eden区大概16M,from区和to区各2M。具体参考堆内存如何分代。
1. 对象优先在Eden区分配
大多数情况下,对象是分配在新生代的Eden区(大多数对象朝生夕死),当Eden区的内存不够的时候 虚拟机会发起一次Minor GC。
代码如下
/**
* vm参数:-Xms50m -Xmx50m -Xmn20m -XX:+PrintGCDetails
*/
public class Allocation {
private static final int _1MB = 1024 * 1024;
private static void testAllocation(){
byte[] one = new byte[5*_1MB];
byte[] two = new byte[5*_1MB];
}
public static void main(String[] args) {
testAllocation();
}
}
运行下看看是不是这样大小以及比例划分的,GC日志如下
上面日志可以看出eden space 15360K,from space 2560K, to space 2560K
分别是eden=16MB,from=2MB,to=2MB,新生代总可用空间共17920K(Eden区+1个Survivor区)。与前面说的差不多
日志中 eden space 15360K, 87%
说明eden区被占用了87%,大约13MB,那代码中总共就两个对象每个5MB,为什么会有13MB呢,这个就是文章开头说的jvm运行本身也是会占用内存的
由日志可以看出one,two两个对象是直接分配在Eden区的。
2. 大对象直接进入老年代
所谓大对象就是需要大量连续续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,本节例子中的byte[]数组就是典型的大对象。如果一个对象很大,在进行内存分配的时候,即使内存中有空间,由于空间不连续无法分配给这个对象,进而提前触发垃圾回收,同时还可以避免大对象在eden区和2个Survivor区来回复制(开发期间尽量避免大对象的出现)。
-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot 的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew加CMS的收集器组合。 ----- 来自《深入理解虚拟机》
1.8默认的垃圾收集器是ps+po,所以这个参数是没有效果的,我们这里测试直接用较大的对象。
配置和前面一样,eden大小是16M。
/**
* vm参数:-Xms50m -Xmx50m -Xmn20m -XX:+PrintGCDetails
*/
public class Allocation {
private static final int _1MB = 1024 * 1024;
private static void testAllocation(){
byte[] one = new byte[20*_1MB];
}
public static void main(String[] args) {
testAllocation();
}
}
GC日志如下
如日志中ParOldGen total 30720K, used 20480K
表示老年代中被使用了大概20MB,由于这次对象较大,而eden中没有足够的内存可用,所以直接分配到老年代中。
3. 长期存活的对象将进入老年代
虚拟机中每个对象都是有年龄的,对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
设置。
vm的变化:年龄设置1,新生代大小10M
-Xms50m -Xmx50m -Xmn10m -XX:+PrintGCDetails -XX:MaxTenuringThreshold=1
/**
* vm参数:-Xms50m -Xmx50m -Xmn10m -XX:+PrintGCDetails -XX:MaxTenuringThreshold=1
*/
public class Allocation {
private static final int _1MB = 1024 * 1024;
private static void testAllocation(){
byte[] one = new byte[_1MB / 4]; // 较小的对象 可以存放到Survivor
byte[] two = new byte[2 * _1MB];
byte[] three = new byte[2 * _1MB];
byte[] four = new byte[2 * _1MB]; // 这里发生第一次gc
System.out.println("=======");
byte[] five = new byte[2 * _1MB];
byte[] six = new byte[2 * _1MB];
System.out.println("=======");
byte[] seven = new byte[2 * _1MB]; // 这里发生第二次gc
}
public static void main(String[] args) {
testAllocation();
}
}
正常的过程是one,two,three在eden区,分配four对象的时候eden空间不够 触发GC,one对象比较小,会进入Survivor区,two,three移到老年代,接着four对象进入eden区 。接着分配five, six,seven对象到eden区 ,seven对象分配的时候eden空间不够再次触发GC,由于one对象的年龄到了1,则会进入老年代。
从上面日志可以看出第一次gc之后新生代剩余992k(包含了),第二次gc剩余192k 小于one对象的大小(至于为什么没有回收干净,这点我也不是很清楚,有知道的大佬请告诉我)
我把-XX:MaxTenuringThreshold=15
在来看看日志
如图,第二次gc之后年轻代剩余912k,和第一次gc的992k变化不大,说明one对象年龄未到还是存活在Survivor区。
4. 动态对象年龄判定
虚拟机并不是严格按照对象年龄阀值-XX:MaxTenuringThreshold
来判断是否可以晋升老年代,而是会通过动态对象年龄判断。
所谓的动态对象年龄判断就是 把Survivor区中的所有对象,从年龄为1的对象开始计算其大小,一直累加到年龄为n的对象,假如累加到年龄n的时候大于等于Survivor区的一半,那么就会把年龄大于等于n的对象全部移到老年代。 并不是某个特定年龄对象累加(书中的描述容易导致误解)
规则是这样的规则,但是我按照这种规则通过代码实验并没有测试成功,最后的Survivor区占据仍然有很多
5. 空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总 空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看- XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。 ----- 摘抄自《深入理解虚拟机》
以上是对空间分配担保的规则,但是已经过期了,JDK 6 Update 24之后, -XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略。规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC。 如下
6. 对象栈上分配
除了以上5中最基本的分配原则外,还设计了栈上分配。为了减少对象在堆中分配的数量,JVM通过逃逸分析确定对象会不会被外部引用,对于不存在逃逸的对象可以直接分配在栈上(回收的时候不需要gc的介入,栈上的对象会随着方法一起出栈而回收内存),减轻gc的压力。
还有一种TLAB分配:全称是Thread Local Allocation Buffer,即线程本地分配缓存区,TLAB本身占用Eden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。
总结
本文介绍了在经典分代设计中的5种基本的内存分配原则:对象优先在Eden区分配,大对象直接进入老年代,长期存活的对象进入老年代,动态对象年龄判定以及空间分配担保,其实还有还会存在栈上分配(需要进行逃逸分析)以及线程本地缓冲区(TLAB)
栈上分配->TLAB->新生代->老年代
我是纪先生,用输出倒逼输入而持续学习,持续分享技术系列文章,以及全网值得收藏好文,欢迎关注公众号,做一个持续成长的技术人。
JVM虚拟机系列历史文章
转载自:https://juejin.cn/post/7072932282188857351