前段时间,给星球的球友们专门码了一篇文章《深刻分析Java的编译原理》,其中深刻的介绍了Java中的javac编译和JIT编译的区别及原理。并在文中提到:JIT编译除了具备缓存的功能外,还会对代码作各类优化,好比:逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型检测消除、 公共子表达式消除等。java
有球友阅读完这部份内容后,对JVM产生了浓厚的兴趣,本身回去专门学习了一下,在学习过程当中遇到一个小问题,关于Java内存分配的。因此和我在微信上作过简单的交流。主要涉及到Java中的堆和栈、数组内存分配、逃逸分析、编译优化等技术及原理。本文也是关于这部分知识点的分享。算法
关于JVM的内存结构及内存分配方式,不是本文的重点,这里只作简单回顾。如下是咱们知道的一些常识:数组
一、根据Java虚拟机规范,Java虚拟机所管理的内存包括方法区、虚拟机栈、本地方法栈、堆、程序计数器等。缓存
二、咱们一般认为JVM中运行时数据存储包括堆和栈。这里所提到的栈其实指的是虚拟机栈,或者说是虚拟栈中的局部变量表。微信
三、栈中存放一些基本类型的变量数据(int/short/long/byte/float/double/Boolean/char)和对象引用。多线程
四、堆中主要存放对象,即经过new关键字建立的对象。app
五、数组引用变量是存放在栈内存中,数组元素是存放在堆内存中。函数
在《深刻理解Java虚拟机中》关于Java堆内存有这样一段描述:学习
可是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会致使一些微妙的变化,全部的对象都分配到堆上也渐渐变得不那么“绝对”了。优化
这里只是简单提了一句,并无深刻分析,不少人看到这里因为对JIT、逃逸分析等技术不了解,因此也没法真正理解上面这段话的含义。
PS:这里默认你们都了解什么是JIT,不了解的朋友能够先自行Google了解下,或者加入个人知识星球,阅读那篇球友专享文章。
其实,在编译期间,JIT会对代码作不少优化。其中有一部分优化的目的就是减小内存堆分配压力,其中一种重要的技术叫作逃逸分析。
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种能够有效减小Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。经过逃逸分析,Java Hotspot编译器可以分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态做用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如做为调用参数传递到其余地方中,称为方法逃逸。
例如:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
复制代码
StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其余方法所改变,这样它的做用域就不仅是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或能够在其余线程中访问的实例变量,称为线程逃逸。
上述代码若是想要StringBuffer sb不逃出方法,能够这样写:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
复制代码
不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。
使用逃逸分析,编译器能够对代码作以下优化:
1、同步省略。若是一个对象被发现只能从一个线程被访问到,那么对于这个对象的操做能够不考虑同步。
2、将堆分配转化为栈分配。若是一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象多是栈分配的候选,而不是堆分配。
3、分离对象或标量替换。有的对象可能不须要做为一个连续的内存结构存在也能够被访问到,那么对象的部分(或所有)能够不存储在内存,而是存储在CPU寄存器中。
上面的关于同步省略的内容,我在《深刻理解多线程(五)—— Java虚拟机的锁优化技术》中有介绍过,即锁优化中的锁消除技术,依赖的也是逃逸分析技术。
本文,主要来介绍逃逸分析的第二个用途:将堆分配转化为栈分配。
其实,以上三种优化中,栈上内存分配实际上是依靠标量替换来实现的。因为不是本文重点,这里就不展开介绍了。若是你们感兴趣,我后面专门出一篇文章,全面介绍下逃逸分析。
在Java代码运行时,经过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis
: 表示开启逃逸分析 -XX:-DoEscapeAnalysis
: 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,须要指定-XX:-DoEscapeAnalysis
咱们知道,在通常状况下,对象和数组元素的内存分配是在堆内存上进行的。可是随着JIT编译器的日渐成熟,不少优化使这种分配策略并不绝对。JIT编译器就能够在编译期间根据逃逸分析的结果,来决定是否能够将对象的内存分配从堆转化为栈。
咱们来看如下代码:
public static void main(String[] args) {
long a1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
// 查看执行时间
long a2 = System.currentTimeMillis();
System.out.println("cost " + (a2 - a1) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
复制代码
其实代码内容很简单,就是使用for循环,在代码中建立100万个User对象。
咱们在alloc方法中定义了User对象,可是并无在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。通过JIT的逃逸分析以后,就能够对其内存分配进行优化。
咱们指定如下JVM参数并运行:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
复制代码
在程序打印出 cost XX ms
后,代码运行结束以前,咱们使用[jmap][1]
命令,来查看下当前堆内存中有多少个User对象:
➜ ~ jps
2809 StackAllocTest
2810 Jps
➜ ~ jmap -histo 2809
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
复制代码
从上面的jmap执行结果中咱们能够看到,堆中共建立了100万个StackAllocTest$User
实例。
在关闭逃避分析的状况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中建立的User对象并无逃逸到方法外部,可是仍是被分配在堆内存中。也就说,若是没有JIT编译器优化,没有逃逸分析技术,正常状况下就应该是这样的。即全部对象都分配到堆内存中。
接下来,咱们开启逃逸分析,再来执行下以上代码。
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
复制代码
在程序打印出 cost XX ms
后,代码运行结束以前,咱们使用jmap
命令,来查看下当前堆内存中有多少个User对象:
➜ ~ jps
709
2858 Launcher
2859 StackAllocTest
2860 Jps
➜ ~ jmap -histo 2859
num #instances #bytes class name
----------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
复制代码
从以上打印结果中能够发现,开启了逃逸分析以后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTest$User
对象。也就是说在通过JIT优化以后,堆内存中分配的对象数量,从100万降到了8万。
除了以上经过jmap验证对象个数的方法之外,读者还能够尝试将堆内存调小,而后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析以后,在运行期间,GC次数会明显减小。正是由于不少堆上分配被优化成了栈上分配,因此GC次数有了明显的减小。
因此,若是之后再有人问你:是否是全部的对象和数组都会在堆内存分配空间?
那么你能够告诉他:不必定,随着JIT编译器的发展,在编译期间,若是JIT通过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。可是这也并非绝对的。就像咱们前面看到的同样,在开启逃逸分析以后,也并非全部User对象都没有在堆上分配。