JVM 系列 - 内存区域 - Java 堆(五)
特点
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,也被称为 “GC堆”,是被所有线程共享的一块内存区域,在虚拟机启动时被创建。
- 唯一目的就是储存对象实例和数组(JDK7 已把字符串常量池和类静态变量移动到 Java 堆),几乎所有的对象实例都会存储在堆中分配。随着 JIT 编译器发展,逃逸分析、栈上分配、标量替换等优化技术导致并不是所有对象都会在堆上分配。
-
Java 堆是垃圾收集器管理的主要区域。堆内存分为新生代 (Young) 和老年代 (Old) ,新生代 (Young) 又被划分为三个区域:Eden、From Survivor、To Survivor。
堆默认内存划分 - 从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的线程本地分配缓存区(Thread Local Allocation Buffer,TLAB)。
- 根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过
-Xmx
和-Xms
控制)。
Java 堆会出现的异常
- 如果 Java 堆可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。
运行时数据区
运行时数据区JIT 编译器
即时编译器(Just-in-time Compilation,JIT)
- Java 程序最初是通过解释器来解释执行的,当虚拟器发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译为机器码,并进行各种层次的优化,完成这个任务的编译器成为即使编译器(JIT)。
- 在 HotSpot 实现中有多种选择:C1、C2 和 C1 + C2,分别对应 client、server 和分层编译。
1、C1 编译速度快,优化方式比较保守;
2、C2 编译速度慢,优化方式比较激进;
3、C1 + C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 G2 重新编译;
在 JDK8 之前,分层编译默认是关闭的,可以添加-server -XX:+TieredCompilation
参数进行开启。
JIT 工作原理图
什么是热点代码
- 被多次调用的方法:方法调用的多了,代码执行次数也多,成为热点代码很正常。
- 被多次执行的循环体:假如一个方法被调用的次数少,只有一次或两次,但方法内有个循环,一旦涉及到循环,部分代码执行的次数肯定多,这些多次执行的循环体内代码也被认为“热点代码”。
如何检测热点代码
- 基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。
缺点:不够精确,容易受到线程阻塞或外界因素的影响
优点:实现简单、高效,很容易获取方法调用关系 - 基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。
缺点:实现麻烦,不能直接获取方法的调用关系
优点:统计结果精确
HotSpot 虚拟器为每个方法准备了两类计数器:方法调用计数器和回边计数器,两个计数器都有一定的阈值,超过阈值就会触发JIT 编译。
-XX:CompileThreshold
可以设置阈值大小,Client 编译器模式下,阈值默认的值1500,而 Server 编译器模式下,阈值默认的值则是10000。
回边计数器
逃逸分析
- 逃逸分析是 Java 虚拟机中的一种优化技术,但它并不是直接优化代码,而是为其他优化手段提供优化依据的分析技术。
- 逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸。
- 可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
对象的三种逃逸状态
- GlobalEscape(全局逃逸) 一个对象的引用逃出了方法或者线程。例如,一个对象的引用是复制给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
- ArgEscape(参数逃逸) 在方法调用过程中传递对象的引用给调用方法,这种状态可以通过分析被调方法的二进制代码确定。
- NoEscape(没有逃逸) 一个可以进行标量替换的对象,可以不将这种对象分配在堆上。
private Object o;
/**
* 给全局变量赋值,发生逃逸(GlobalEscape)
*/
public void globalVariablePointerEscape() {
o = new Object();
}
/**
* 方法返回值,发生逃逸(GlobalEscape)
*/
public Object methodPointerEscape() {
return new Object();
}
/**
* 实例引用传递,发生逃逸(ArgEscape)
*/
public void instancePassPointerEscape() {
Object o = methodPointerEscape();
}
/**
* 没有发生逃逸(NoEscape)
*/
public void noEscape() {
Object o = new Object();
}
配置逃逸分析
- 开启逃逸分析,对象没有分配在堆上,没有进行GC,而是把对象分配在栈上。 (
-XX:+DoEscapeAnalysis
开启逃逸分析(JDK8 默认开启,其它版本未测试) ) - 关闭逃逸分析,对象全部分配在堆上,当堆中对象存满后,进行多次GC,导致执行时间大大延长。堆上分配比栈上分配慢上百倍。(
-XX:-DoEscapeAnalysis
关闭逃逸分析) - 可以通过
-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果
标量替换
- 标量
一个数据无法再分解为更小的数据来表示了,Java 虚拟机中的基本数据类型 byte、short、int、long、boolean、char、float、double 以及 reference 类型等,都不能再进一步分解了,这些就可以称为标量。 - 聚合量
一个数据可以继续分解,就称为聚合量。对象就是最典型的聚合量。
如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复到基本数据类型来访问,就叫标量替换。 - 替换过程
如果逃逸分析可以证明一个对象不会被外部访问,并且这个对象可以拆散的话,那程序真正执行时将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外(栈上存储的数据,有很大概率会被虚拟机分配至物理机器的高速寄存器中存储),还可以为后续的进一步优化手段创造条件。
-XX:+EliminateAllocations
可以开启标量替换
-XX:+PrintEliminateAllocations
查看标量替换情况(Server VM 非Product 版本支持)
栈上分配
栈上分配的技术基础是逃逸分析和标量替换。使用逃逸分析确认方法内局部变量对象(未发生逃逸,线程私有的对象,指的是不可能被其他线程访问的对象)不会被外部访问,通过标量替换将该对象分解在栈上分配内存,不用在堆中分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。方法执行完后自动销毁,而不需要垃圾回收的介入,减轻 GC 压力,从而提高系统性能。
使用场景:对于大量的零散小对象,栈上分配提供了一种很好的对象分配策略,栈上分配的速度快,并且可以有效地避免垃圾回收带来的负面的影响,但由于和堆空间相比,栈空间比较小,因此对于大对象无法也不适合在栈上进行分配。
测试栈上分配:
public static void alloc() {
byte[] b = new byte[2];
b[0] = 1;
}
public static void main(String[] args) {
long timeMillis = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
// 开启使用栈上分配执行时间 6 ms左右
// 关闭使用栈上分配执行时间 900 ms左右
System.out.println(System.currentTimeMillis() - timeMillis);
}
- 开启使用栈上分配(JDK8 默认开启,其它版本未测试),
-XX:+DoEscapeAnalysis
表示启用逃逸分析,栈上分配依赖于 JVM 逃逸分析结果。 - 禁止使用栈上分配,
-XX:-DoEscapeAnalysis
表示禁用逃逸分析。
注意:如果使用 idea 等工具测试,需使用 Run 执行
同步消除
线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)
测试同步消除:
public static void alloc() {
byte[] b = new byte[2];
synchronized (b) {
b[0] = 1;
}
}
public static void main(String[] args) {
long timeMillis = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
// 开启使用同步消除执行时间 10 ms左右
// 关闭使用同步消除执行时间 3870 ms左右
System.out.println(System.currentTimeMillis() - timeMillis);
}
- 开启同步消除 (
-XX:+EliminateLocks
(JDK8 默认开启,其它版本未测试) ) - 关闭同步消除(
-XX:-EliminateLocks
) - 可以通过配置
-XX:+PrintEscapeAnalysis
开启打印逃逸分析筛选结果
注意:如果使用 idea 等工具测试,需使用 Run 执行
TLAB
- TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区。这是线程私有的,在新生代 Eden 区分配内存区域,默认是开启的,也可以通过
-XX:+UseTLAB
开启。TLAB 的内存非常小,默认设定为占用新生代的1%,可以通过-XX:TLABWasteTargetPercent
设置 TLAB 占用 Eden Space 空间大小。 - 由于对象一般会分配在堆上,而堆是全局共享的。同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步,而在竞争激烈的场合内存分配的效率又会进一步下降。JVM 使用 TLAB 来避免多线程冲突,每个线程使用自己的 TLAB,这样就保证了不使用同步,提高了对象分配的效率。
- 由于 TLAB 空间一般不会很大,因此大对象无法在 TLAB 上进行分配,总是会直接分配在堆上。TLAB 空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。这时虚拟机会有两种选择,第一,废弃当前 TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的 TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作 refill_waste 的值,当请求对象大于 refill_waste 时,会选择在堆中分配,若小于该值,则会废弃当前 TLAB,新建 TLAB 来分配对象。这个阈值可以使用
-XX:TLABRefillWasteFraction
来调整,它表示 TLAB 中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的 TLAB 空间作为 refill_waste。默认情况下,TLAB 和 refill_waste 都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整 TLAB 的大小,可以使用-XX:-ResizeTLAB
禁用,并使用-XX:TLABSize
手工指定一个 TLAB 的大小。-XX:+PrintTLAB
可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
扩展
虚拟机对象分配流程:首先如果开启栈上分配,JVM 会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行 TLAB 分配,如果 TLAB 分配不成功,再尝试在 Eden 区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。
虚拟机对象分配流程