Java对象实例和数组元素都是在堆上分配内存的吗?
答:不一定。满足特定条件时,它们可以在(虚拟机)栈上分配内存
这和我们平时的理解可能有些不同。虚拟机栈一般是用来存储基本数据类型、引用和返回地址的,怎么可以存储实例数据了呢?
这是因为Java JIT(just-in-time)编译器进行的两项优化,分别称作逃逸分析(escape analysis)和标量替换(scalar replacement)
结合栈上分配 & TLAB分配, Java对象分配流程描述为:
一、栈上分配
- 本质:Java虚拟机提供的一项优化技术
- 基本思想: 将线程私有的对象打散分配在栈上
- 优点:
- 可以在函数调用结束后自行销毁对象,不需要垃圾回收器的介入,有效避免垃圾回收带来的负面影响
- 栈上分配速度快,提高系统性能
- 局限性
- 栈空间小,对于大对象无法实现栈上分配
- 只有满足 逃逸分析 & 标量替换 的对象才会栈上分配
栈上分配依赖于逃逸分析和标量替换
1.1 技术基础: 逃逸分析
逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针
- 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中被访问到的地方无法确定——这样指针就成功“逃逸”了。
- 如果指针存储在全局变量或者其它数据结构中,因为全局变量是可以在当前子程序之外访问的,此时指针也发生了逃逸
逃逸分析的目的: 判断对象的作用域是否超出函数体[即:判断是否逃逸出函数体]
简单来讲,JVM中的逃逸分析可以通过分析对象引用的使用范围(即动态作用域),来决定对象是否要在堆上分配内存,也可以做一些其他方面的优化。
//user的作用域超出了函数setUser的范围,是逃逸对象
//当函数结束调用时,不会自行销毁user
private User user;
public void setUser(){
user = new User();
user.setId(1);
user.setName("blueStarWei");
}
//u只在函数内部生效,不是逃逸对象
//当函数调用结束,会自行销毁对象u
public void createUser(){
User u = new User();
u.setId(2);
u.setName("JVM");
}
1.2 标量替换
逃逸分析只是栈上内存分配的前提,接下来还需要进行标量替换才能真正实现
所谓标量,就是指JVM中无法再细分的数据,比如int、long、reference等。相对地,能够再细分的数据叫做聚合量
public class EscapeAnalysisTest {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 5000000; i++) {
allocate();
}
System.out.println((System.currentTimeMillis() - start) + " ms");
Thread.sleep(600000);
}
static void allocate() {
MyObject myObject = new MyObject(2019, 2019.0);
}
static class MyObject {
int a;
double b;
MyObject(int a, double b) {
this.a = a;
this.b = b;
}
}
}
- 关闭逃逸分析之后,堆上有5000000个MyObject实例,而开启逃逸分析之后,就只剩下90871个实例了,不管是实例数还是内存占用都只有原来的2%不到
- MyObject就是一个聚合量,因为它由两个标量a、b组成
通过逃逸分析,JVM会发现myObject没有逃逸出allocate()方法的作用域,标量替换过程就会将myObject直接拆解成a和b,也就是变成了:
static void allocate() {
int a = 2019;
double b = 2019.0;
}
可见,对象的分配完全被消灭了,而int、double都是基本数据类型,直接在栈上分配就可以了。
所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配。
1.3 栈上分配示例
package com.blueStarWei.templet;
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("blueStarWei");
}
}
上述代码调用了1亿次alloc(),如果是分配到堆上,大概需要1.5GB的堆空间,如果堆空间小于该值,必然会触发GC。
- 使用如下参数运行,发现不会触发GC
-server -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations
- 使用如下参数(任意一行)运行,会发现触大量GC
//不使用逃逸分析
-server -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations
//不使用标量替换
-server -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:-EliminateAllocations
1.4 JVM参数解析
参数 | 作用 | 备注 |
---|---|---|
-server | 使用server模式 | 只有在server模式下,才可以弃用逃逸分析 |
-Xmx15m | 设置最大堆空间为15m | 如果在堆上分配,必然触发大量GC |
-Xms15m | 设初始对空间为15m | |
-XX:+DoEscapeAnalysis | 启用逃逸分析 默认启用 | |
-XX:-DoEscapeAnalysis | 关闭逃逸分析 | |
-XX:+PrintGC | 打印GC日志 | |
-XX:-UseTLAB 关闭TLAB | TLAB(Thread Local Allocation Buffer)线程本地分配缓存区 | |
-XX:+EliminateAllocations | 启用标量替换,允许对象打散分配到栈上 | 默认启用 |
-XX:-EliminateAllocations | 关闭标量替换 |
二、TLAB 分配
TLAB,全称Thread Local Allocation Buffer, 即:线程本地分配缓存。这是一块线程专用的内存分配区域。
TLAB占用的是eden区的空间。在TLAB启用的情况下(默认开启),JVM会为每一个线程分配一块TLAB区域。
- 为什么需要TLAB?
- 这是为了加速对象的分配。
- 由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。
- 考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。
- 局限性
- TLAB空间一般不会太大(占用eden区),所以大对象无法进行TLAB分配,只能直接分配到堆上。
2.1 分配策略
一个100KB的TLAB区域,如果已经使用了80KB,当需要分配一个30KB的对象时,TLAB是如何分配的呢?
此时,虚拟机有两种选择:
- 第一,废弃当前的TLAB(会浪费20KB的空间);
- 第二,将这个30KB的对象直接分配到堆上,保留当前TLAB(当有小于20KB的对象请求TLAB分配时可以直接使用该TLAB区域)。
JVM选择的策略是:在虚拟机内部维护一个叫refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,反之,则会废弃当前TLAB,新建TLAB来分配新对象。
【默认情况下,TLAB和refill_waste都是会在运行时不断调整的,使系统的运行状态达到最优。】
2.2 JVM指令
参数 | 作用 | 备注 |
---|---|---|
-XX:+UseTLAB | 启用TLAB | 默认启用 |
-XX:TLABRefillWasteFraction | 设置允许空间浪费的比例 | 默认值:64,即:使用1/64的TLAB空间大小作为refill_waste值 |
-XX:-ResizeTLAB | 禁止系统自动调整TLAB大小 | |
-XX:TLABSize | 指定TLAB大小 | 单位:B |