Java做为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点,因此,即便是一个Java的初学者,也必定或多或少的对JVM有一些了解。能够说,关于JVM的相关知识,基本是每一个Java开发者必学的知识点,也是面试的时候必考的知识点。java
在JVM的内存结构中,比较常见的两个区域就是堆内存和栈内存(如无特指,本文提到的栈均指的是虚拟机栈),关于堆和栈的区别,不少开发者也是如数家珍,有不少书籍,或者网上的文章大概都是这样介绍的:面试
一、堆是线程共享的内存区域,栈是线程独享的内存区域。算法
二、堆中主要存放对象实例,栈中主要存放各类基本数据类型、对象的引用。数组
可是,做者能够很负责任的告诉你们,以上两个结论均不是彻底正确的。缓存
在我以前的文章《Java堆内存是线程共享的!面试官:你肯定吗?》中,介绍过了关于堆内存并非完彻底全的线程共享有关的知识点,本文就第二个话题来探讨一下。多线程
在《Java虚拟机规范》中,关于堆有这样的描述:app
在Java虚拟机中,堆是可供各个线程共享的运行时内存区域,也是供全部类实例和数组对象分配内存的区域。函数
在《Java堆内存是线程共享的!面试官:你肯定吗?》文章中,咱们也介绍过,一个Java对象在堆上分配的时候,主要是在Eden区上,若是启动了TLAB的话会优先在TLAB上分配,少数状况下也可能会直接分配在老年代中,分配规则并非百分之百固定的,这取决于当前使用的是哪种垃圾收集器,还有虚拟机中与内存有关的参数的设置。性能
可是通常状况下是遵循如下原则的:优化
可是,虽然虚拟机规范中是有着这样的要求,可是各个虚拟机厂商在实现虚拟机的时候,可能会针对对象的内存分配作一些优化。这其中最典型的就是HotSpot虚拟机中的JIT技术的成熟,使得对象在堆上分配内存并非必定的。
其实在《深刻理解Java虚拟机》中,做者也提出过相似的观点,由于JIT技术的成熟使得"对象在堆上分配内存"就不是那么绝对的了。可是书中并无展开介绍到底什么是JIT,也没有介绍JIT优化到底作了什么。那么接下来咱们就来深刻了解一下:
咱们你们都知道,经过 javac 将能够将Java程序源代码编译,转换成 java 字节码,JVM 经过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。这就是传统的JVM的解释器(Interpreter)的功能。很显然,Java编译器通过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢不少。为了解决这种效率问题,引入了 JIT(Just In Time ,即时编译) 技术。
有了JIT技术以后,Java程序仍是经过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。而后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,而后再把翻译后的机器码缓存起来,以备下次使用。
上面咱们说过,要想触发JIT,首先须要识别出热点代码。目前主要的热点代码识别方式是热点探测(Hot Spot Detection),HotSpot虚拟机中采用的主要是基于计数器的热点探测
基于计数器的热点探测(Counter Based Hot Spot Detection)。采用这种方法的虚拟机会为每一个方法,甚至是代码块创建计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
JIT在作了热点检测识别出热点代码后,除了会对其字节码进行缓存,还会对代码作各类优化。这些优化中,比较重要的几个有:逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型检测消除、 公共子表达式消除等。
而这些优化中的逃逸分析就和本文要介绍的内容有关了。
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种能够有效减小Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。经过逃逸分析,Hotspot编译器可以分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态做用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如做为调用参数传递到其余地方中,称为方法逃逸。
例如:
public static String craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
复制代码
sb是一个方法内部变量,上述代码中并无将他直接返回,这样这个StringBuffer有不会被其余方法所改变,这样它的做用域就只是在方法内部。咱们就能够说这个变量并无逃逸到方法外部。
有了逃逸分析,咱们能够判断出一个方法中的变量是否有可能被其余线程所访问或者改变,那么基于这个特性,JIT就能够作一些优化:
关于同步省略,你们能够参考我以前的《深刻理解多线程(五)—— Java虚拟机的锁优化技术》中关于锁消除技术的介绍。本文主要来分析下标量替换和栈上分配。
咱们说,JIT通过逃逸分析以后,若是发现某个对象并无逃逸到方法体以外的话,就可能对其进行优化,而这一优化最大的结果就是可能改变Java对象都是在堆上分配内存的这一原则。
对象要分配在堆上其实有不少缘由,可是有一点比较关键的和本文有关的,那就是由于堆内存在访问上是线程共享的,这样一个线程建立出来的对象,其余线程也能访问到。
那么,试想下,若是咱们在某一个方法体内部建立了一个对象,而且对象并无逃逸到方法外的话,那还有必要必定要把对象分配到堆上吗?
其实就没有必要了,由于这个对象并不会被其余线程所访问到,生命周期也只是在一个方法内部,也就不用大费周折的在堆上分配内存,也减小了内存回收的必要。
那么,有了逃逸分析以后,发现一个对象并无逃逸到放法外的话,经过什么办法能够进行优化,减小对象在堆上分配可能呢?
这就是栈上分配。在HotSopt中,栈上分配并无正在的进行实现,而是经过标量替换来实现的。
因此咱们重点介绍下,什么是标量替换,如何经过标量替换实现栈上分配。
标量(Scalar)是指一个没法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还能够分解的数据叫作聚合量(Aggregate),Java中的对象就是聚合量,由于他能够分解成其余聚合量和标量。
在JIT阶段,若是通过逃逸分析,发现一个对象不会被外界访问的话,那么通过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
复制代码
以上代码中,point对象并无逃逸出alloc方法,而且point对象是能够拆解成标量的。那么,JIT就会不会直接建立Point对象,而是直接使用两个标量int x ,int y来替代Point对象。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
复制代码
能够看到,Point这个聚合量通过逃逸分析后,发现他并无逃逸,就被替换成两个聚合量了。
经过标量替换,本来的一个对象,被替换成了多个成员变量。而本来须要在堆上分配的内存,也就再也不须要了,彻底能够在本地方法栈中完成对成员变量的内存分配。
Talk Is Cheap, Show Me The Code
No Data, No BB;
接下来咱们就来经过一个实验,来看一下逃逸分析是否能够生效,生效后是否真的会发生栈上分配,而栈上分配又有什么好处呢?
咱们来看如下代码:
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
复制代码
其中-XX:-DoEscapeAnalysis表示关闭逃逸分析。
在程序打印出 cost XX ms 后,代码运行结束以前,咱们使用jmap命令,来查看下当前堆内存中有多少个User对象:
➜ ~ 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对象:
➜ ~ 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次数有了明显的减小。
前面的例子中,开启逃逸分析以后,对象数目从100万变成了8万,可是并非0,说明JIT优化并不会完彻底全的全部状况都进行优化。
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,并且这项技术到现在也并非十分红熟的。
其根本缘由就是没法保证逃逸分析的性能消耗必定能高于他的消耗。虽然通过逃逸分析能够作标量替换、栈上分配、和锁消除。可是逃逸分析自身也是须要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是通过逃逸分析以后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分红熟,可是他也是即时编译器优化技术中一个十分重要的手段。
正常状况下,对象是要在堆上进行内存分配的,可是随着编译器优化技术的成熟,虽然虚拟机规范是这样要求的,可是具体实现上仍是有些差异的。
如HotSpot虚拟机引入了JIT优化以后,会对对象进行逃逸分析,若是发现某一个对象并无逃逸到方法外部,那么就可能经过标量替换来实现栈上分配,而避免堆上分配内存。
因此,对象必定在堆上分配内存,这是不对的。
最后,咱们留一个思考题,咱们以前讨论过了TLAB,今天又介绍了栈上分配。你们以为这两个优化有什么相同点和不一样点吗?