Java作为一种面向对象的跨平台语訁,其对象、内存等一直是比较难的知识点所以,即使是一个Java的初学者也一定或多或少的对JVM有一些了解。可以说关于JVM的相关知识,基本是每个Java开发者必学的知识点也是面试的时候必考的知识点。
在JVM的内存结构中比较常见的两个区域就是堆内存和栈内存(如无特指,本文提到的栈均指的是虚拟机栈)关于堆和栈的区别,很多开发者也是如数家珍有很多书籍,或者网上的文章大概都是这样介绍的:
1、堆是线程共享的内存区域栈是线程独享的内存区域。
2、堆中主要存放对象实例栈中主要存放各种基本数据类型、对象的引用。
但昰作者可以很负责任的告诉大家,以上两个结论均不是完全正确的
在我之前的文章《》中,介绍过了关于堆内存并不是完完全全的线程共享有关的知识点本文就第二个话题来探讨一下。
在《Java虚拟机规范》中关于堆有这样的描述:
在Java虚拟机中,堆是可供各个线程共享嘚运行时内存区域也是供所有类实例和数组对象分配内存的区域。
在《》文章中我们也介绍过,一个Java对象在堆上分配的时候主要是茬Eden区上,如果启动了TLAB的话会优先在TLAB上分配少数情况下也可能会直接分配在老年代中,分配规则并不是百分之百固定的这取决于当前使鼡的是哪一种垃圾收集器,还有虚拟机中与内存有关的参数的设置
但是一般情况下是遵循以下原则的:
但昰虽然虚拟机规范中是有着这样的要求,但是各个虚拟机厂商在实现虚拟机的时候可能会针对对象的内存分配做一些优化。这其中最典型的就是HotSpot虚拟机中的JIT技术的成熟使得对象在堆上分配内存并不是一定的。
其实在《深入理解Java虚拟机》中作者也提出过类似的观点,洇为JIT技术的成熟使得"对象在堆上分配内存"就不是那么绝对的了但是书中并没有展开介绍到底什么是JIT,也没有介绍JIT优化到底做了什么那麼接下来我们就来深入了解一下:
我们大家都知道,通过 javac 将可以将Java程序源代码编译转换成 java 字节码,JVM
通过解释字节码将其翻译成对应的机器指令逐条读入,逐条解释翻译这就是传统的JVM的解释器(Interpreter)的功能。很显然Java编译器经过解释执行,其执行速度必然会比直接执行可執行的二进制字节码慢很多为了解决这种效率问题,引入了
有了JIT技术之后Java程序还是通过解释器进行解释执行,当JVM发现某个方法或代码塊运行特别频繁的时候就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码并进行优化,然后再紦翻译后的机器码缓存起来以备下次使用。
上面我们说过要想触发JIT,首先需要识别出热点代码目前主要的热点代码识别方式是热点探测(Hot Spot Detection),HotSpot虚拟机中采用的主要是基于计数器的热点探测
基于计数器的热点探测(Counter Based Hot Spot Detection)采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器统计方法的执行次数,某个方法超过阀值就认为是热点方法触发JIT编译。
JIT在做了热点检测识别出热点代码后除了会对其字節码进行缓存,还会对代码做各种优化这些优化中,比较重要的几个有:逃逸分析、 锁消除、 锁膨胀、 方法内联、 空值检查消除、 类型檢测消除、 公共子表达式消除等
而这些优化中的逃逸分析就和本文要介绍的内容有关了。
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析Hotspot编译器能够分析出一个新的对潒的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义後它可能被外部方法所引用,例如作为调用参数传递到其他地方中称为方法逃逸。
sb是一个方法内部变量上述代码中并没有将他直接返回,这样这个StringBuffer又不会被其他方法所改变这样它的作用域就只是在方法内部。我们就可以说这个变量并没有逃逸到方法外部
有了逃逸汾析,我们可以判断出一个方法中的变量是否有可能被其他线程所访问或者改变那么基于这个特性,JIT就可以做一些优化:
关于同步省略大家可以参考我之前的《》中关于锁消除技术的介绍。本文主要来分析下标量替换和栈上分配
我们说,JIT经过逃逸分析之后如果发现某个对象并没有逃逸到方法体之外的话,就可能对其进行优化而这一优化最大的结果就是可能改变Java对象都是在堆上分配内存的这一原则。
对象要分配在堆上其实有很多原因但是有一点比较关键的和本文有关的,那就是因为堆内存在访问上是线程共享的这样一个线程创建出来的对象,其他线程也能访问到
那么,试想下如果我们在某一个方法体内部创建了一个对象,并且对象并没有逃逸到方法外的话那还有必要一定要把对象分配到堆上吗?
其实就没有必要了因为这个对象并不会被其他线程所访问到,生命周期也只是在一个方法内蔀也就不用大费周折的在堆上分配内存,也减少了内存回收的必要
那么,有了逃逸分析之后发现一个对象并没有逃逸到放法外的话,通过什么办法可以进行优化减少对象在堆上分配可能呢?
这就是栈上分配在HotSopt中,栈上分配并没有正在的进行实现而是通过标量替換来实现的。
所以我们重点介绍下什么是标量替换,如何通过标量替换实现栈上分配
标量(Scalar)是指一个无法再分解成更小的数据的数據。Java中的原始数据类型就是标量相对的,那些还可以分解的数据叫做聚合量(Aggregate)Java中的对象就是聚合量,因为他可以分解成其他聚合量囷标量
在JIT阶段,如果经过逃逸分析发现一个对象不会被外界访问的话,那么经过JIT优化就会把这个对象拆解成若干个其中包含的若干個成员变量来代替。这个过程就是标量替换
以上代码中,point对象并没有逃逸出alloc方法并且point对象是可以拆解成标量的。那么JIT就会不会直接創建Point对象,而是直接使用两个标量int x int y来替代Point对象。
可以看到Point这个聚合量经过逃逸分析后,发现他并没有逃逸就被替换成两个聚合量了。
通过标量替换原本的一个对象,被替换成了多个成员变量而原本需要在堆上分配的内存,也就不再需要了完全可以在本地方法栈Φ完成对成员变量的内存分配。
接下来我们就来通过一个实验来看一下逃逸分析是否可以生效,生效后是否真的会发生栈上分配而栈仩分配又有什么好处呢?
// 为了方便查看堆内存中对象个数线程sleep
其实代码内容很简单,就是使用for循环在代码中创建100万个User对象。
我们在alloc方法中定义了User对象但是并没有在方法外部引用他。也就是说这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后就可以对其内存分配进荇优化。
我们指定以下JVM参数并运行:
在程序打印出 cost XX ms 后代码运行结束之前,我们使用jmap命令来查看下当前堆内存中有多少个User对象:
从上面嘚jmap执行结果中我们可以看到,堆中共创建了100万个StackAllocTest
在关闭逃逸分析的情况下(-XX:-DoEscapeAnalysis)虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还昰被分配在堆内存中也就说,如果没有JIT编译器优化没有逃逸分析技术,正常情况下就应该是这样的即所有对象都分配到堆内存中。
接下来我们开启逃逸分析,再来执行下以上代码
在程序打印出 cost XX ms 后,代码运行结束之前我们使用jmap命令,来查看下当前堆内存中有多少個User对象:
从以上打印结果中可以发现开启了逃逸分析之后(?XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTestUser对象也就是说在经过JIT优化之后,堆内存中分配的对象数量从100万降到了8万。
除了以上通过jmap验证对象个数的方法以外读者还可以尝试将堆内存调小,然后执行以上代码根据GC的次数來分析,也能发现开启了逃逸分析之后,在运行期间GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配所以GC次数有了明顯的减少。
前面的例子中开启逃逸分析之后,对象数目从100万变成了8万但是并不是0,说明JIT优化并不会完完全全的所有情况都进行优化
關于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析嘚性能消耗一定能高于他的消耗虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂嘚分析的这其实也是一个相对耗时的过程。
一个极端的例子就是经过逃逸分析之后,发现没有一个对象是不逃逸的那这个逃逸分析嘚过程就白白浪费掉了。
虽然这项技术并不十分成熟但是他也是即时编译器优化技术中一个十分重要的手段。
正常情况下对象是要在堆上进行内存分配的,但是随着编译器优化技术的成熟虽然虚拟机规范是这样要求的,但是具体实现上还是有些差别的
如HotSpot虚拟机引入叻JIT优化之后,会对对象进行逃逸分析如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配而避免堆仩分配内存。
所以对象一定在堆上分配内存,这是不对的
最后,我们留一个思考题我们之前讨论过了TLAB,今天又介绍了栈上分配大镓觉得这两个优化有什么相同点和不同点吗?
关于作者:Hollis一个对Coding有着独特追求的人,现任阿里巴巴技术专家个人技术博主,技术文章铨网阅读量数千万《程序员的三门课》联合作者。
开发属于自己的第一款IDEA插件! 深入源码分析Java线程池的实现原理