这两天看公众号,学习了一个知识点,Java对象并不是都会在堆内存中分配空间的。之前写了一篇比较长的关于JVM学习的笔记,里面说过,Java创建对象实例的时候,大部分新生对象都是存放在堆内存Eden区中的,少数情况下也可能会直接分配到老年代中,分配规则并不是固定不变的,这主要取决于当前选用的哪种垃圾回收器,以及设置的JVM参数。比如对于大多数垃圾回收器来说,如果要创建的对象大小超过 -XX:PretenureSizeThreshold: 参数的设置时,这个大对象会直接接入老年代。而对于G1垃圾回收器,它是将整个堆分成固定大小的分区域,有一类分区标记称为H,代表Humongous,表示这类分区是用于存储巨大对象的(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代。
前面说的都不是今天的重点,今天的话题时,Java对象是否都是创建在堆内存中的?既然能这样问,那答案肯定是“No”!那么,具体是怎样的呢?
我们大家都知道,通过 javac 将可以将Java源代码编译成 java 字节码,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。这就是传统的JVM的解释器(Interpreter)的功能。很显然,Java编译器经过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢很多。为了解决这种效率问题,于是出现了 JIT(Just In Time ,即时编译) 技术。Java虚拟机标准中对JIT并没有做任何规范说明,所以这是虚拟机实现的自定义优化技术。HotSpot虚拟机中就有采用了这种技术,它在执行Java代码时可以采用“解释执行”和“编译执行”两种方式。如果采用编译执行方式,就会用到JIT。JIT编译启动时,程序还是通过JVM解释执行的,但是如果在代码运行的过程中发现,某个方法或某一段代码执行的特别频繁,就会被标记为热点代码,将其发送给JIT编译器,JIT编译器会将其编译为本地机器码,保存起来,以备下次使用。由此我们可以知道,JIT它并不是将所有的字节码都编译为本地机器码的,我们首先需要找到热点代码。目前主要的热点代码识别方式是热点探测,HotSpot虚拟机中采用的是 基于计数器的热点探测:虚拟机会为每个方法或代码块创建一个计数器,统计其执行的次数,当执行此时超过设定的阈值,JVM就认为这是个热点代码,将其发送给JIT,触发JIT编译。JIT在编译热点代码前,会对其字节码进行缓存, 并进行各种优化,比如锁消除、锁膨胀、方法内联、空值检查消除、类型检查消除、公共子表达消除、逃逸分析等。其中,逃逸分析就和我们今天的主题相关。
1.逃逸分析
逃逸分析(Escape Analysis) - 分析对象的动态作用域。假如我们在一个方法内定义了一个对象,如果它被作为参数传递到其他地方,被本方法外的方法引用,这就就叫做方法逃逸。
有了逃逸分析,我们就可以判断出一个方法中的变量是否有可能被其他线程所访问或改变,JIT就可以据此进行一系列的优化,如标量替换、栈上分配。
如果我们经过逃逸分析发现,某个对象并没有发生方法逃逸,那么它的生命周期则始于方法调用,卒于方法结束,那么此时它就是方法内的局部变量,而堆内存是线程间共享的,如果将它分配到堆中,方法结束后,它将不在被任何对象所引用,还需要GC进行回收,很不划算,于是 JIT就会将其分配到方法的栈帧中,这就是栈上分配。实际上在HotSpot中,栈上分配并不是直接在方法的栈帧中放入一个对象,它是通过标量替换的方式存储的,即将对象分解成组成对象的若干个成员变量,这些变量是无法再分解的更小的数据,叫做标量,然后用这些标量来代替之前的对象,这就叫标量替换。通过标量替换,原本的一个对象,被替换成多个成员变量。而原本需要在堆上分配的内存,也就不再需要了,完全可以在本地方法栈中完成对成员变量的内存分配。
2.验证JIT优化效果
代码:
import java.io.IOException; import java.io.InputStream; /** * @Date 2020/3/17 16:20 * @Version V1.0 **/ class Person{ String name; String address; int age; public Person(String name, String address, int age){ this.name = name; this.address = address; this.age = age; } } public class Demo { public static void createPerson(){ Person person = new Person("jack", "China", 20); System.out.println("name: " + person.name + ",address:" + person.address + ",age: " + person.age); } public static void main(String[] args) throws IOException { for (int i=0;i<10000;i++){ createPerson(); } InputStream in = System.in; in.read(); } }
上面代码中,我们在createPerson()方法中创建10000个Person对象,但并没有在方法外部引用过它,所以这些对象都没有发生逃逸。然后我们分别在禁用和启用JIT编译器的状态下运行,看看JIT的优化效果。
-1. 禁用JIT
设置JVM参数为 -Xmx2G -Xms2G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
-XX:-DoEscapeAnalysis 表示跳过逃逸分析。
然后通过jmap命令查看堆中有多少个Person对象。
可以看到,关闭JIT后,对象全部创建在了堆中,共计10000个Person对象,虽然它们都没有逃逸到方法外。结论:在没有JIT编译器时,没有逃逸分析技术,对象全部创建在堆内存中。
-2. 启用JIT
接下来我们打开JIT逃逸分析运行看:
设置JVM参数为 -Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
然后通过jmap命令查看堆中的Person对象数量 : jmap -histo pid
上面是多次运行的结果,每一次都不一样,但对象的总数是小于10000的,说明JIT编译器优化后,有对象被分配到了栈中,而不是堆中,这样可以减少堆的GC次数。
当然,我们看到Person对象在堆内存中的数量不是0,说明JIT编译器会对所有情况都进行优化,只有当一段代码或方法频繁执行,被检测为热点代码时才会去进行优化。
3.逃逸分析并不成熟
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。
4.总结
正常情况下,对象是要在堆上进行内存分配的,但是随着编译器优化技术的成熟,虽然虚拟机规范是这样要求的,但是具体实现上还是有些差别的。
如HotSpot虚拟机引入了JIT优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存。
所以,对象一定在堆上分配内存,这是不对的。