前一篇从整体上了解了一下JVM的运行时数据区,它由线程私有的栈内存和线程共享的堆内存、方法区组成。本章节将详细了解一下堆内存又被分为哪些区域,或者说JVM是如何把对象分配到这些区域上的
JVM根据对象在内存中存活时间的长短,把堆内存分为新生代(包括一个Eden区、两个Survivor区)和老年代(Tenured或Old)。Perm代(永久代,Java 8开始被“元空间”取代)属于方法区了,而且仅在Full GC时被回收。大致如下图
为对象分配空间,就是把一块确定大小的内存从堆中划分出来(有一种例外情况,就是有可能经过JIT优化编译后,对象被拆分成标量类型从而变成了栈上分配)。新创建的对象主要分配在新生代的Eden区上,如果JVM启动了本地线程分配缓冲(TLAB,Thread Local Allocation Buffer),则对象将按线程优先分配在TLAB上,此区域仍然位于新生代的Eden区内。
关于TLAB
创建对象需要从堆中划分出一块确定大小的区域,那分配内存就是把指针从可用空闲区域挪动一段与对象大小相等的距离。而对象的创建是很频繁的行为,在并发情况并不是线程安全的,可能出现在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。为了解决这个问题,一个可行的方案就是TLAB,即把内存分配的动作按照线程划分在不同的空间内进行,即每个线程在堆内预先分配一小块内存,称为“本地线程分配缓冲”。哪个线程要给对象分配内存,就在自己的TLAB上分配,当自己的TLAB用完再去申请新的TLAB,这个时候再去进行指针的同步锁定,从而减小开销。
对象优先分配在Eden区
大部分情况下,对象会在新生代的Eden区中分配空间,当Eden区没有足够大小的连续空间来分配给新创建的对象时,JVM将会触发一次Minor GC
HotSpot的开发人员将GC执行分为比较模糊的三种模型:
- Minor GC:发生在新生代,回收新生代中的垃圾,速度很快但也很频繁
- Major GC:发生在老年代,比Minor GC慢10倍以上;通常会伴随一次Minor GC
- Full GC:回收所有区域,包括堆内存、方法区(Java 8之前的“永久代”,Java 8开始取代永久代的“元空间”)和直接内存,速度慢,工作线程的暂停时间长
绝大多数对象所占的内存空间会在Minor GC中被回收(IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的),那些存活下来的对象会被分配到某一个Survivor(幸存区,名字也很形象),但如果Survivor的空间不足以安置存活对象的话,JVM会通过“空间分配担保机制”提前转移这些对象到老年代去。
- 新生代中为什么有两个Survivor区?为什么每次只使用其中一个呢?
这跟新生代采用的垃圾回收算法有关,新生代用的是“复制”算法,该算法的特点是牺牲一定的空间成本,来换取高效率的垃圾回收,此算法不会产生内存碎片,回收后内存比较规整。关于各回收算法的细节,下一个章节再介绍,这里就不累赘了。
- “空间分配担保”是什么?
在发生Minor GC之前,JVM会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则JVM会查看
HandlePromotionFailure
设置值是否允许担保失败。若允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure
设置不允许冒险,那这时要改为进行一次Full GC。
下面这个示例代码演示了Survivor区空间不足,对象通过分配担保机制被提前转移到老年代去。Debug执行三条对象创建语句,通过JDK自带的Java VisualVM工具jvisualvm
(同时安装Visual GC插件),可以直观的看到各个内存区的变化情况。
/**
* -Xms90m -Xmx90m -XX:+UseParNewGC
*
* 固定堆大小:90m
* - Young Gen: 1/3 * 90 = 30m (默认 Tenured / Young = 2)
* - Survivor * 2 : 1/10 * 30 = 3m * 2 (两个Survivor,默认 Eden / Survivor = 8)
* - Eden: 8/10 * 30 = 24m
* - Tenured: 2/3 * 90 = 60m (默认 Tenured / Young = 2)
*/
public class HandlePromotionDemo {
public static void main(String[] args) {
byte[] obj1 = new byte[1024 * 1024 * 2];
byte[] obj2 = new byte[1024 * 1024 * 10];
byte[] obj3 = new byte[1024 * 1024 * 20];
}
}
以下三个截图分别展示了三个对象依次创建后的内存各区情况
大对象直接进去老年代
大对象就是那些需要大量连续内存空间的对象,比如数组及很长的字符串。过多的大对象容易导致当内存空间仍然还有不少时就会提前触发GC以获取足够连续的空间来分配给这些大对象。
虚拟机提供了一个参数-XX:PretenureSizeThreshold
,那些大于这个参数值的对象将直接在老年代分配,避免在Eden区和两个Survivor区之间发生大量的内存复制(新生代采用的是“复制”垃圾回收算法)。
下面这个示例代码指定一个Survivor区域容量大小为4MB,同时设置-XX:PretenureSizeThreshold=3145728
,即3MB,之后创建一个略大于3MB的对象。运行此程序后,从VisualVM GC中可以看到此对象被分配到了老年代。
/**
* -Xmn16m -Xms30m -Xmx30m -XX:SurvivorRatio=2 -XX:+UseParNewGC -XX:PretenureSizeThreshold=3145728 -XX:-UseTLAB
*
* Fixed Heap: 30MB
* - Survivor * 2: 4MB * 2
* - Eden: 8MB
* - Tenured: 14MB
*/
public class BiggerThanPretenureSizeThresholdObjToOld {
public static void main(String[] args) throws Exception {
System.gc(); // 尝试清除由监测工具生成的临时对象
Thread.sleep(10000L);
byte[] obj = new byte[1024 * 1024 * 3 + 1];
boolean flag = true;
while(flag) {
Thread.yield();
}
}
}
对于极端情况,参数-XX:PretenureSizeThreshold
未设置,而对象大于Eden空间的话,则同样直接在老年代分配空间
长期存活的对象会被晋升到老年代
虚拟机在进行内存回收的时候,为了能够识别哪些对象应该继续留在新生代(某一个Survivor区)、哪些对象应该被晋升(转移)到老年代,它给每个对象定义了一个对象年龄(Age)计数器。所有在新生代出生的对象,年龄可以认为是0,此时的数值没有任何意义。当对象经过第一次Minor GC后任然存活,并且Survivor有足够的空间来容纳它的话,对象被顺利转移到Survivor中,此时对象开始拥有实际意义的年龄,为1岁。在此之后,Survivor中的对象每“熬过”一次Minor GC,年龄就会增加1岁,当达到一定的年龄阈值(默认是15岁,可通过参数-XX:MaxTenuringThreshold
设置),对象就会被晋升到老年代中。老年代中的对象就没有年龄的意义了。
下面我们通过一个示例来演示一下:对象年龄达到阈值后被晋升到老年代。设置参数,固定堆大小为90MB,新生代45MB,其中Eden和Survivor各占15MB、15MB、15MB,年龄阈值为2岁。
/**
* -XX:+PrintGCDetails -Xmn45m -Xms90m -Xmx90m -XX:SurvivorRatio=1 -XX:MaxTenuringThreshold=2 -XX:+UseParNewGC
*
* Fixed Heap: 90M
* - Survivor *2: 15M *2
* - Edeb: 15M
* - Tenured(Old): 45M
*/
public class AgeOlderThanTenuringThresholdObjToOld {
public static void main(String[] args) throws Exception {
System.gc(); //尝试清除由监测工具生成的临时对象
byte[] obj1 = new byte[1024 * 1024 * 2]; //执行完这一行之后各区使用情况: Eden[obj1]: 2/15, S0: 0/15, S1: 0/15, Old: 0/45
byte[] obj2 = new byte[1024 * 1024 * 2]; //执行完这一行之后各区使用情况: Eden[obj1,obj2]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45
byte[] obj3 = new byte[1024 * 1024 * 12]; //对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 4/15, Old: 0/45
obj3 = null; //obj3不再有任何引用关联,下次GC的时候将会被回收
byte[] obj4 = new byte[1024 * 1024 * 4]; //对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次Minor GC来释放空间给obj4,这次GC中obj3会被回收,之后各区使用情况: Eden[obj4]: 4/15, S0:[obj1(age=2),obj2(age=2)]: 4/15, S1: 0/15, Old: 0/45
byte[] obj5 = new byte[1024 * 1024 * 12]; //对象obj5创建成功之前,虚拟机检测到Eden剩余空间(15-4=11)不足以分配给obj5,因此触发第三次MinorGC来释放空间给obj5,这次GC中由于obj1,obj2的年龄都到达了阈值2岁,所以这两个对象将被晋升到老年代,之后各区使用情况:Eden[obj5]: 12/15, S0: 0/15, S1[obj4(age=1)]: 4/15, Old[obj1,obj2]: 4/45
}
}
Debug逐行执行上面5个对象的创建语句,每个对象创建成功后的各区使用情况如下各图:
动态对象年龄判断
虚拟机并不是永远的要等到对象年龄达到阈值后才能晋升到老年代,当Survivor中相同年龄(比如N)的所有对象的大小总和大于Survivor的一半的时候,那些年龄大于等于N所有对象将会直接提前进入老年代。
示例代码如下:固定堆大小为90MB,新生代45MB,其中Eden和Survivor各占15MB、15MB、15MB,未设置最大年龄阈值,使用默认值15。
/**
* -XX:+PrintGCDetails -Xmn45m -Xms90m -Xmx90m -XX:SurvivorRatio=1 -XX:+UseParNewGC
*
* Fixed Heap: 90M
* - Survivor *2: 15M *2
* - Edeb: 15M
* - Tenured(Old): 45M
*/
public class DynamicAge {
public static void main(String[] args) throws Exception {
System.gc(); //尝试清除由监测工具生成的临时对象
byte[] obj1 = new byte[1024 * 1024 * 4]; //执行完这一行之后各区使用情况: Eden[obj1]: 4/15, S0: 0/15, S1: 0/15, Old: 0/45
byte[] obj2 = new byte[1024 * 1024 * 4]; //执行完这一行之后各区使用情况: Eden[obj1,obj2]: 8/15, S0: 0/15, S1: 0/15, Old: 0/45
byte[] obj3 = new byte[1024 * 1024 * 12]; //对象obj3创建成功之前,虚拟机检测到Eden剩余空间(15-8=7)不足以分配给obj3,因此触发第一次Minor GC来释放空间给obj3,obj1和obj2晋升到幸存区,年龄为1,各区使用情况: Eden[obj3]: 12/15, S0: 0/15, S1[obj1(age=1),obj2(age=1)]: 8/15, Old: 0/45
obj3 = null; //obj3不再有任何引用关联,下次GC的时候将会被回收
byte[] obj4 = new byte[1024 * 1024 * 4]; //对象obj4创建成功之前,虚拟机检测到Eden剩余空间(15-12=3)不足以分配给obj4,因此触发第二次MinorGC来释放空间给obj4,这次GC中由于Survivor区中的obj1和obj2的大小之和8超过了Survivor大小15的一半,所以这两个对象将被提前晋升到老年代,而对象obj3由于没有任何引用,直接被回收了,之后各区使用情况: Eden[obj4]: 4/15, S0: 0/15, S1: 0/15, Old[obj1,obj2]: 8/45
}
}
Debug逐行执行上面4个对象的创建语句,每个对象创建成功后的各区使用情况如下各图:
至此,关于对象在堆内各区分配的几种情况就大致讲解到这里。下一章将了解一下垃圾收集器的原理。
上一篇:理解JVM(3)- 运行时数据区