文章目录
- 前言
- 堆中对象的分配策略
- 大对象直接进入老年代
- 本地内存分配缓冲区(Thread-local allocation buffer)
- 对象分配在栈上
- 逃逸分析概述
- 演示发生逃逸的对象
- 演示发生逃逸的对象
- StringBuffer不发生逃逸
- 逃逸分析之栈上分配
- 逃逸分析之同步省略
- 逃逸分析之标量替换
- 总结
前言
一般在java程序中,new的对象是分配在堆空间中的,但是实际的情况是,大部分的new对象会进入堆空间
中,而并非是全部的对象,还有另外两个地方可以存储new的对象,我们称之为栈上分配
以及TLAB
堆中对象的分配策略
如果对象在Eden区出生,并经过第一次MinorGC
后仍然存活,并且能被Survivor
区容纳的话,将被移动到Survivor区中,并将对象年龄设为1。对象在Survivor区中每经过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度时(默认为15岁,其实每个JVM、每个GC都有所不同),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过-XX:Max TenuringThreshold
来设置,也会有其他情况直接分配对象到老年代。
对象分配策略如下所示。
(1)优先分配到Eden区。
(2)大对象直接分配到老年代,在开发过程中应尽量避免程序中出现过多的大对象。
(3)长期存活的对象分配到老年代。
(4)通过动态对象年龄判断,如果Survivor区中相同年龄的所有对象的大小总和大于Survivor区的一半
,年龄大于或等于该年龄的对象可以直接进入老年代
,无须等到MaxTenuringThreshold中要求的年龄。
(5)空间分配担保,使用参数-XX:HandlePromotionFailure来设置空间分配担保是否开启,但是JDK 6 Update 24该参数不再生效,JDK 6 Update 24之后版本的规则变为,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC
。
大对象直接进入老年代
/**
* 测试:大对象直接进入老年代。
* -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
*/
public class Young0ldAreaTest {
public static void main(String[] args) {
byte[] buffer = new byte[1024 * 1024 * 20];//20M
}
}
20M的数据出现在ParOldGen区也就是老年代,说明大对象在Eden区存不下,直接分配到老年代。
本地内存分配缓冲区(Thread-local allocation buffer)
程序中所有的线程共享Java中的堆区域,但是堆中还有一部分区域是线程私有
,这部分区域称为线程本地分配缓存区
(Thread Local Allocation Buffer,TLAB)。
TLAB表示JVM为每个线程分配了一个私有缓存区域,这块缓存区域包含在Eden区内。简单说TLAB就是在堆内存中的Eden区分配了一块线程私有的内存区域
。什么是TLAB呢?
(1)从内存模型角度来看,新生代区域继续对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域
,如下图所示。
(2)多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量
,因此我们可以将这种内存分配方式称为快速分配策略
。
为什么有TLAB呢?原因如下。
(1)堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
。
(2)由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
。
(3)为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。在程序中,开发人员可以通过选项-XX:+/-UseTLAB
设置是否开启TLAB空间。下面我们通过代码演示-XX:UseTLAB
参数的设置。
/**
* 测试-XX:UseTLAB参数是否开启的情况:默认情况是开启的
*/
public class TLABArgsTest {
public static void main(String[] args) {
System.out.println("我只是来打个酱油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
默认情况下,TLAB空间的内存非常小,仅占有整个Eden区的1%
,我们可以通过选项-XX:TLABWasteTargetPercent
设置TLAB空间所占用Eden区的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden区中分配内存。
对象分配在栈上
逃逸分析概述
对象经过逃逸分析,有可能把对象分配到栈上。也就是说如果将对象分配到栈,需要使用逃逸分析手段。
逃逸分析是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java HotSpot编译器能够分析出一个新对象引用的使用范围,从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象的动态作用域。
当一个对象在方法中被定义后,若对象只在方法内部使用,则认为没有发生逃逸
。当一个对象在方法中被定义后,若它被外部方法所引用,则认为发生逃逸
。例如作为调用参数传递到其他地方中。
演示发生逃逸的对象
代码示例中的对象V的作用域只在method()方法区内,若没有发生逃逸,则可以分配到栈上,随着方法执行的结束,栈空间就被移除了。
演示发生逃逸的对象
如果想让上述代码中的StringBuffer sb不发生逃逸。
StringBuffer不发生逃逸
在JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析。
如果使用的是较早的版本,开发人员则可以通过以下参数来设置逃逸分析的相关信息。
(1)选项-XX:+DoEscapeAnalysis
开启逃逸分析。
(2)选项-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果。一般在开发中能使用局部变量的,就不要使用在方法外定义。
逃逸分析之栈上分配
JIT(Just In Time)编译器在编译期间根据逃逸分析的结果,发现如果一个对象没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了
。
逃逸分析之同步省略
线程同步的代价是相当高的,同步的后果是降低了并发性和性能。在动态编译同步块的时候,JIT编译器可以借助逃逸分析
,来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程
。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
。
代码中对hollis对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程访问,所以在JIT编译阶段就会被优化掉。优化后的代码如下:
当代码中对hollis这个对象进行加锁时的字节码文件如图所示。同步省略是将字节码文件加载到内存之后才进行的,所以当我们查看字节码文件的时候仍然能看到synchronized的身影,在字节码文件中体现为monitorenter和monitorexit,如图所示:
逃逸分析之标量替换
标量(Scalar)是指一个无法再分解成更小数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫作聚合量(Aggregate),Java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。
在JIT编译器的编译阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量。这个过程就是标量替换。
以上代码经过标量替换后,就会变成如下效果:
总结
- 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2
- 如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则3
- 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
- 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则5.
- 执行一次Young GC(minor collection)。
- 经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代。