美文网首页
从0开始学习JVM

从0开始学习JVM

作者: 胜舟 | 来源:发表于2021-01-26 23:27 被阅读0次

    本篇我们来介绍一下JVM,相信大家就算没了解过,也应该听过JVM这个词。它是Java的核心,但是由于我们在日常开发中基本不会涉及到它,也对它了解不多。但是不管是什么东西,只有当你彻底的清楚原理和内部细节时,才能更好的掌控它。为了至少能让大家看懂,我会尽量用简单的说明去形容这个复杂的机器,像洋葱一样一层一层的深入理解它。

     

     

    1. 什么是JVM

    JVM(Java Virtual Machine)即是Java虚拟机的缩写,所以不要再画蛇添足的叫JVM虚拟机了。

    它是运行Java字节码(即.class文件)的虚拟机,由于java是跨平台的,所以jvm虚拟机针对不同系统(windows,linux,macOS)也有不同的实现。

    个人理解:

    jvm相当于代码的一个翻译,到windows里就翻译成window能听懂的语言,到linux里就翻译成linux能听懂的语言。因此同样的代码在不同的平台,结果的执行操作也几乎是相同的,只是中间翻译的这一步不同。

    这应该就是java跨平台性的原理,它不需要开发者会“多国语言”,只要会java语言就够了。它也不需要每个国家(系统)都支持它的语言,只需要为每个国家搭配一名专业的翻译(JVM),将java语言翻译成这个国家能听懂的语言就够了。

     

    2.什么是字节码(.class)

    在java中,JVM可以理解的代码就叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向Java虚拟机(JVM)。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序的运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java程序无需重新编译便可在多种不同操作系统的计算机上运行。

    个人理解:

    字节码文件(.class)是通过Java文件(.java)编译后得到的,它只会被Jvm处理,也是JVM唯一能理解的语言。并且这个编译过程是无视平台的,也就是不管你在什么环境下进行编译,由java文件生成的.class文件都是一样的。和平台有关的步骤是Jvm解析字节码文件的时候,它需要将字节码文件解析成操作系统能执行的机器语言指令,然后执行指令。这一步由于不同的操作系统有不同的命令,所以会产生差异。

    整体流程:

    .java文件(源代码)===编译===》.class文件(字节码文件)===JVM解析===》二进制机器码

     

    3.为什么需要字节码(.class)

    通过前面我们都明白了JVM才是跨平台的核心,那为什么在此之前还要再加工一次,让.java类加工成.class呢?为啥不直接让jvm理解.java文件,直接进行翻译运行呢?这样我们就无需多关心一层.class文件了,直接将.java交给JVM,它内部该编译就编译,该执行就执行,对我们来说也不用关心那么多了。

    首先我必须说明,在我看来.class文件和java跨平台性没有多少直接联系,跨平台性核心是靠JVM去实现的,不同平台编译出的字节码文件是完全一样的。

    说个题外话:

    网上很多人看到这种问题一股脑就把跨平台性掏出来说,显得很“理所当然”,这是在我看来这多少显得有点不懂装懂。百度出来的大部分回答就是这样,但是至少也比那些看起来很牛掰却只会让你去百度,其实自己屁都答不出来的fw强多了。

    当然,现在我也只能以猜测性质简单的回答一下,因为我对这块内容的学习也不够深入,所以以后有机会更深入时再回来补充。

    字节码文件(.class)带来的好处

    ①提高效率

    对java文件进行语法检查、语义分析这一步会消耗大量的时间资源,编译成字节码文件相当于提前处理了源文件,提前做好了大量的工作。这样将字节码文件交给JVM运行的时候,就可以为JVM节省很多负担了。这样就可以做到让java程序能够运行高效,如果这些操作都直接不经处理的交给jvm去做,肯定就无法满足高效这一词,有些大项目光编译就要好几分钟。如果去掉提前编译,直接让JVM解析java文件,那么当你启动这个项目的时候一定会觉得慢的离谱。

    ②资源节约

    字节码文件可以看做是对源文件的提炼压缩封装,它占用更少的空间更快的传输速度。

    ③提升兼容性

    只要满足jvm的规范,即便你是其他语言的代码,只要能编译成class文件,同样可以运行在jvm中。(理论上如此,虽然现在还没有什么实现方式)

     

    4.什么是解释器,什么是编译器

    解释器(Interpreter):

    在计算机科学中,解释器是一种计算机程序,它直接执行由编程语言或脚本语言编写的代码,并不会把源代码预编译成机器码,解释器会一行一行的读取源代码,解释,然后立即执行。

    java虚拟机(jvm)启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,也就是将字节码文件中的每条内容都翻译成系统能识别的指令并执行。

    注意这里是直接执行,并没有编译出什么文件(就算有也是部分临时的),相当于实时翻译并执行。

    编译器(Compiler):

    编译器负责把一种编程语言(通常为高级语言)“翻译”成另外一种语言(通常为低级语言),后者往往是二进制的形式的机器语言,被称为目标代码(object code),这个转换的过程通常的目的是生成可执行的程序。

    在java中,将.java编译为.class文件就是编译器的一种操作。而如果我们又将.class编译成计算机CPU可以直接执行的机器语言,那这就也是编译操作了。

    注意这里并没有执行,只负责完整、彻底的翻译,然后生成翻译后的文件,并没有任何执行的操作。

     

    5.为什么Java是半编译半解释语言

    关于这个,有两种说法,我觉得都挺有道理

    ①先编译后解释

    编译型语言是将源码编译成机器指令的文件,直接就可以执行,C/C++就是这种。而java也是通过编译将源码java文件编译成字节码文件(.class),所以它显然具有编译的特点。

    解释型语言是在运行时一句一句的读取源码,一边翻译成机器指令一边执行。而java在运行时也是通过jvm读取字节码文件,一边翻译一边解释成机器指令并执行,所以它显然也具有解释的特点。

    所以java是半编译半解释型语言。

    ②一边解释一边编译

    jvm支持一种叫即时编译的技术,它被称之为:JIT(Just in time compiler)编译器。也就是jvm不仅仅有解释器的作用,它其实还有编译器的作用。

    jvm在执行java程序时,通常会将解释执行和编译执行二者结合起来进行,也就是一边解释一边编译。

    所以java是半编译半解释型语言。

     

    这两种说法,我认为都很有道理,而且我们也无需钻牛角尖非要探究Java被这样叫的原因,因为这已经是事实了。

    而且,更重要的是JIT,这玩意又是个啥?

     

    6.什么是JIT编译器(Just in time compiler),它的作用是?

    众所周知,解释器的设计和实现上比较简单,执行程序的效率又比编译器编译出来的程序慢。java一开始也是如此,但是它并没有不思进取,为了解决这个问题,后面JVM就开始使用HotSpot VM这个java虚拟机了。

    没错,JVM并不具体指代一种虚拟机,甚至当初有很多种JVM相互竞争,百家争鸣,当然随着时间的推移,逐渐统一了。

    HotSpot VM的一大功能就是JIT(Just in time compiler:即时编译器)。JIT也是一种编译器,所以它的功能也显而易见,就是将读取到的字节码文件编译成本地机器可以直接执行的机器语言。

    但是它并不是完全将所有文件都编译后才执行的,如果是这样,那java就可以完全叫编译型语言了。JIT只会编译所谓的热点代码(也有叫热点方法热点函数的),至于这个热点代码是如何判断检测的,我先放到后面再说。

    总之最重要的是,它是在JVM启动后进行编译的,也就是解释器一边在启动运行程序,JIT一边默默的将热点代码编译为机器语言的指令,这种编译方式也被称为动态编译

    在程序初启动时,完全依靠解释器运行,编译器就像个拖油瓶,在那慢吞吞的准备。但是通过时间的推移,编译器的作用开始发挥了,越来越多的代码被编译成机器指令,这些复杂的代码不再需要通过解释器一行一行解释执行了,而是直接就可以通过编译出的机器码运行了,所以程序会越运行越快

    静态编译和动态编译

    静态编译就是在程序执行前的编译,它并不启动任何程序,只是相当于将整篇文章(源码)翻译了一遍。在Java中,将.java文件编译为.class文件就是静态编译,又名前端编译

    动态编译则是在程序运行时进行的编译,它在程序启动后才开始进行编译工作,在JVM中,JIT编译就是动态编译,它将字节码文件编译为本地的机器码,并进行优化,这又名后端编译

     

    7.什么是HotSpot VM?它和JIT的关系是?

    HotSpot VM是一个由C++编写的Java虚拟机,也是目前范围最广的Java虚拟机,但它其实一开始并非Sun公司开发的,而是一个小公司设计的,只是后面被Sun发现并收购了而已。可能到这里我们才意识到,java虚拟机并不只有一家,另一个有名的是JRockit VM虚拟机。不过我们也无需担心,因为它也早在08年左右被Oracle一起收购了,我们现在只需要知道HotSpot VM就可以了。

    HotSpot VM可以视为JVM的实现,它主要功能包括一个解释器和两个编译器,这两个编译器就可以合称为JIT编译器了。没错,JIT编译器并不是一个编译器,它是分为了两种模式的编译器:client模式server模式。

    client模式是一种轻量级编译器,也叫C1编译器,占用内存小,启动快,耗时短,它会进行简单并可靠的优化,更注重效率

    server模式是一种重量级编译器,也叫C2编译器,启动慢,占用内存大,耗时长,但编译的代码执行效率更高,甚至会根据性能监控信息进行一些不可靠的激进优化,更注重质量

    为啥注重质量却反而不可靠了呢?或许是因为对完美的追求太高了,进行了一些钻了牛角尖的优化吧。。但是不用担心,一般在方法优化失败时,程序会撤销C2编译的这部分代码,重新用解释器进行解释执行。

     

    8.JDK7以前如何选用C1、C2编译器?

    ①自动选择

    通常虚拟机会根据机器的硬件和操作系统自动选择运行C1还是C2,详情可以参考官方提供的手册:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/server-class.html

    ②手动选择

    我们在启动程序的时候也可以通过手动去控制运行模式:

    混合模式(Mixed Mode): 使用解释器 + 其中一个JIT编译器,指令【-client】或【-server】 指定使用哪个JIT编译器。不加任何指令,默认启动也是此模式。
    解释模式(Interpreted Mode): 只使用解释器,禁用JIT,指令【-Xint】强制JVM使用解释模式。
    编译模式(Compiled Mode): 只使用编译器,指令【-Xcomp】 优先使用编译模式将所有字节码编译成本地机器码,解释模式作为备用。

    相信有人注意到了标题,为什么特别的说是JDK7以前呢?难道JDK7以后就不用选了吗?

    没错,从JDK7开始,Java引入了分层编译

     

    9.什么是分层编译?

    JDK6开始,Java引入了分层编译,这种方式综合了 C1 和 C2 的优势,不过这时它还只是初步的实现。

    而到JDK7才开始应用到server模式中,而server模式也常常是一些硬件系统的默认选择,所以也可看做是JDK7默认推荐使用分层编译了。当然我们还是可以通过前面的手动指令去 【-client】或【-server】强制选择。

    分层编译将 JVM 的执行状态分为了 5 个层次:

    • level 0:解释器解释执行,默认开启profiling。
    • level 1:C1 编译,执行不带profiling的C1代码,不开启 profiling。
    • level 2:C1 编译,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 代码,开启部分profiling。
    • level 3:C1 编译,执行带所有 Profiling 的 C1 代码,开启 profiling。
    • level 4:C2 编译,执行C2的代码,C2编译也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
    profiling(性能监控):

    其中profiling就是收集能够反应程序执行状态的监控数据,它由多项数据组成,其中最基本的就是方法的调用次数和循环回边的执行次数(这块内容后面小节会详解)。

    各层次执行效率:

    虽然分了5个层次,但其实也没那么复杂,我们可以把level 123看做是一层:C1,因为这三层都是执行进行C1编译后的代码,唯一的区别是带了多少的profiling。因为profiling是影响性能的,所以执行效率上:level1>level2>level3

    那有的人会觉得既然level1是执行C1代码的三个层次中,执行效率最高的,那为什么不一直执行level1呢?

    当然,level1因为没有开启profiling,执行效率最高,但是这仅限于C1,如果是C2编译的代码,通常要比C1代码的执行效率高出**30% **!!而profiling,可以理解为就是开启C2编译的条件或是钥匙。

    终止状态:

    level1由于放弃了钥匙,自然也失去了被编译为C2代码的机会,所以我们可以得知,level1和level4是属于终止状态的。当一个方法执行到终止状态后,除非编译后的代码失效了,否则它就不会再次发出该方法的编译请求了。也就是一旦方法执行到level1和level4,一般它就固定下来了,后面不出意外也会一直执行level1或level4的层次。

    分层编译的触发关系:

    但是不管是什么方法,它都是从level 0开头的,这层是解释器,不进行任何编译。

    下图是几种常见的编译路径,我们一边看图一边分析,该图片借用自公众号文章:【基本功 | Java即时编译器原理解析及实践】https://mp.weixin.qq.com/s/7PH8o1tbjLsM4-nOnjbwLw

    image-20210119012942933

    ①第一条路径是一般情况,热点方法从解释执行,到被第3层的C1编译,最后进入C2编译。

    ②第二种情况是trivial method,即不重要的方法,例如常见的getset方法,第3层的profiling没有收集到有价值的数据,JVM就会放弃对其进行C2编译,转而进行无profiling的C1编译。因为差别并不大,还不如把宝贵的资源留给更需要进行C2编译的方法。

    ③当C1忙碌的情况下,解释器在解释执行的过程中profiling,然后直接跳转到C2编译。

    ④当C2忙碌的情况下,由于第3层C1又比第2层慢不少,为了不浪费资源,先进行第2层的C1,再随着时间进行第3层的C1编译。最后等C2不忙了,才进入C2编译。没错,这条线路不会进入第1层C1,C2再忙也不能直接把可能需要优化的方法简单的用C1处理了就完事了。

    ⑤还有一种情况就是因为激进优化产生的问题,由于C2激进的策略,导致编译出来的C2代码有问题,这时候会进行反优化,重新进入解释器解释执行的阶段。

    分层优化就是这样灵活的在各个层次间反复横跳,以达到效率和质量的平衡点,显然这比单一的使用C1或C2要强大多了。

    JDK8开始

    并且从JDK8开始,默认启用分层编译Tiered Compilers,这时候已经没有【-client】和【-server】指令了,这时如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想使用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1,其实就相当于只进行到level1层次的分层编译。

     

    10.热点代码(Hot Spot Code)和热点探测

    java字节码文件(.class文件)是先以解释执行的方式被加载到虚拟机中,然后JIT编译器在程序运行过程中,对“热点代码”进行动态的编译,将这部分代码编译成机器可以直接执行的机器码(native code),同时也会对代码进行优化,从而提高运行效率。

    而这个热点代码就是通过Hotspot JVM探测出来的,这个探测行为就叫做热点探测

    热点探测检测机制根据不同的虚拟机,有不同的探测方法,目前主要有两种,分别是基于采样的热点探测基于计数器的热点探测。当然还有一些其他的,例如基于踪迹的热点探测,只是相对没有那么主流而已,这里我简单说明一下这两种探测方式,当然重点关注的还是HotSpot虚拟机使用的探测方式:基于计数器的热点探测

    基于采样的热点探测(典型应用:C9 虚拟机)

    采用这种方法的虚拟机会周期性的检查各个线程的栈顶,若某个或某些方法经常出现在栈顶,那这个方法就是“热点方法”。

    优点:实现简单,并且因为检查的是栈,所以可以很容易的获取方法调用的关系。

    缺点:容易受到线程阻塞或其他因素影响,具有较大随机性,很难精确的确定方法的热度。

    基于计数器的热点探测(典型应用:HotSpot 虚拟机)

    采用这种方法的虚拟机会为每个方法(甚至是代码块)建立并维护计数器,统计方法的执行次数,执行次数超过设定的阈值就会认为它是热点方法。

    优点:能更加精确和严谨的统计出代码的热度。

    缺点:需要额外负担维护计数器的开销,不能直接获取到方法的调用关系,探测和计算热点代码更麻烦了。

     

    11.HotSpotVM的热点探测机制

    HotSpot VM使用的是基于计数器的热点探测,但是它在此方法上,又分成了两个计数器,当一个方法的这两个计数器之和超过阈值,才会被认为是热点方法并发送编译请求。

    其实通过这个计数器的计数对象,我们可以明白HotSpotVM的热点代码是以“方法”为单位的。

    ①方法调用计数器:

    默认是统计一段时间内方法被调用的次数,这也是标准的计数方式,

    非分层编译时,阈值的默认值在C1(client)模式下是1500次,在C2(server)模式下是10000次。可通过-XX: CompileThreshold来设定这个值。但在分层编译开启时,这个阈值设定会被忽略,因为这时会根据当前待编译的方法数以及编译线程数来动态调整阈值

    阈值频率和半衰周期

    默认设置下,方法调用计数器不是统计调用的绝对次数,而是执行频率:是一段时间内方法被调用的次数。而当一段时间内仍然达不到阈值,无法触发编译请求。计数器就会被减少一半,这个过程被称为方法调用计数器的热度衰减,而这段时间就称为方法调用计数器的半衰周期,进行热度衰减的动作是在JVM进行垃圾回集时顺便进行的。

    正是因为这个热度衰减,才让方法有选择的被筛选为热点代码,而不是全部都被编译。有需要的话也可以通过参数自己设置半衰期的时间长度(单位是秒),甚至关闭这个热度衰减。如果关闭了热度衰减的话,只要程序运行的时间够久,所有方法都有被编译的可能(因为计数器只增不减),但是这也显然会给系统增添不小负担,这点要自己深入了解原理后自己权衡利弊。

    ②回边计数器:

    主要是统计方法内循环体代码执行的次数,在字节码遇到控制流后向后跳转的指令被称为回边(Back Edge),注意它统计的是回边次数,不是循环次数。如果是个空循环,它每次遇到控制流后都继续跳转到自己,这就不是回边了,也不会被统计。

    因为有些代码虽然被调用的次数没那么多,但是它执行一次就要循环个几十上百次的,这不就是很明显的热点代码了吗?所以通过结合回边计数器,可以更准确的探测到需要被编译的热点代码

    它的阈值是通过一段公式计算得来的,非分层编译时,C1 默认为 13995,C2 默认为 10700,但是我们也可以通过调整OSR比率 -XX:OnStackReplacePercentage 来间接的调整阈值:

    C1模式的回边计数器阈值计算公式:
    CompileThreshold*OnStackReplacePercentage/100;
    即:【方法调用计数器的阈值】乘以【OSR比率】/100;
    
    C2模式的回边计数器阈值计算公式:
    (CompileThreshold)*(OnStackReplacePercentage-InterpreterProfilePercentage)/100;
    即:【方法调用计数器的阈值】乘以(【OSR比率】减去【解释器监控比率】)/100;
    

    当然,在分层编译下,这个配置就无效了,因为这时会根据当前待编译的方法数以及编译线程数来动态调整阈值

    而且这个计数器并没有热度衰减,因此它统计的就是该方法执行循环的绝对次数。

    OSR运行时替换栈帧技术

    建立回边计数器另外的功能也是为了触发OSR(On-Stack Replacement,运行时替换栈帧技术),又名栈上替换或栈帧替换。在一些循环周期比较长的代码段中,例如它循环了1千次1万次甚至更多。当回边次数达到冲破回边计数器阈值时,JVM就有所动作了。首先它会认为这段是热点代码,然后让JIT将这个方法的循环体或从这个循环开始的代码进行编译。按照以往的逻辑,我们要等到至少下次调用这个方法时,才能将编译的代码派上用场,然后只能干巴巴的等着这个超长循环无止境的执行下去。。。直到它执行完当前的方法。。这就明显有点问题了,万一它就只调用这么一次呢?

    所以OSR就发挥作用了,它可以在这个方法执行的过程中,悄悄的跑到该方法执行的栈帧中,把需要的状态找出来。。然后迁移到编译优化后的代码层级的栈帧中,显然,这样这个方法就神不知鬼不觉的在执行到一半时,改为继续执行编译优化后的代码了。

     

    12.HotSpotVM执行方法的流程

    我们现在知道了HotSpotVM是怎么检测热点代码的了,现在我们大体的过一下整体的交互流程。

    当方法执行,先检查是否存在被JIT编译的版本

    如果存在,则用编译的本地代码执行,然后没了

    如果不存在,则方法调用计数器计数+1,继续步骤②

    判断两个计数器之和是否超过方法调用计数器的阈值

    如果超过,则向编译器提交编译请求,继续步骤③

    如果没超过?那就不提交编译请求了呗(废话),继续步骤③

    然后继续用解释的方式执行方法

    如果有循环代码(遇到回边指令),检查是否存在被编译的代码

    如果存在,则用编译的本地代码执行,然后没了

    如果不存在,则回边计数器计数+1,继续步骤⑤

    判断两个计数器之和是否超过回边计数器的阈值

    如果超过,则向JIT编译器提交触发OSR的编译请求,并继续进行循环,跳到步骤④,直到方法调用结束。

    另外,我们可以通过启动参数,设置让解释器等待JIT编译,也就是它不会在提交编译请求或继续解释执行了,而是坐下来等JIT编译结束,然后执行JIT编译的代码。不过这样做显然效率影响太多了,所以我也不贴参数了,感兴趣的话可以自己去了解一下。

     

    13.JIT的优化技术

    可能我们对JIT是如何将字节码编译成机器码的过程没多少兴趣,但是我们可以学习一下它采用了哪些优化技术和手段,毕竟它不仅是简单的进行编译,还进行了很多优化的操作。

    ①方法内联

    方法内联介绍:

    调用一个方法通常要经历压栈和出栈,如果当前执行的方法又调用了其他方法,就会加大时间和空间的开销。因为如果我们是在方法1的执行途中调用方法2,就需要将程序执行顺序转移到方法2的内存地址,将方法2的内容执行完后,再返回到方法1的位置继续执行。而这需要在执行方法2前,保存好执行到一半的方法1地址和环境,以便执行完方法2后进行恢复现场的工作(可以理解为一种上下文切换的精简版)。

    如果一层一层的下去,每层都是一笔开销,而如果我们将方法2的全部代码复制到方法1进行调用的地方,这样在调用方法1时,代码的逻辑没有任何改变,但是却不需要花费嵌套调用的开销了,这就是方法内联。

    代码举例:

    首先,编译器读取的是字节码.class文件,当然是不可能读取java文件的,但是这里我还是用java代码举了个例子,主要是方便大家理解的是这么个意思的。

    进行方法内联前:

    //方法1
    private int test(int x, int y) {
            int result=add(x,y);
            return result;
    }
    //方法2
    private int add(int s1, int s2) {
            return s1+s2;
    }   
    

    方法内联后:

    private int test(int x, int y) {
            int result=x+y;
            return result;
    }
    

    参数调整:

    方法内联不是百分百会被执行的,它会参考方法调用层数,目标方法的调用次数及字节码大小进行判断,我们也可以通过一些参数调整这个优化技术。

    说实话我不怎么喜欢贴参数配置,因为80%的人是不需要修改这些配置的,剩下20%在需要修改时会自己去百度。。。而且还要付出自己瞎JB改虚拟机参数的代价(大佬除外)。所以这里主要看看我们可以定制哪些参数就行了,不用记参数指令。

    • 如果方法是经常执行的,方法小于325字节的会进行内联优化,可以用参数-XX:MaxFreqInlineSize=N调整这个字节大小。

    • 如果方法不经常执行,方法小于35字节才会进行内联优化。可以用参数-XX:MaxInlineSize=N调整这个字节大小。

    • -XX:CompileCommand配置中的inline指令指定的方法会被强制内联,dontinline和exclude指定的方法始终不会被内联

    • @ForceInline注解的jdk内部方法会被强制内联,@DontInline注解jdk内部方法始终不会被内联

    • 方法的符号引用未被解析、目标方法所在类未被初始化、或目标方法是native方法,都会导致方法无法内联

    • C2默认不支持9层以上的方法调用(可通过-XX:MaxInlineLevel调整),以及1层的直接递归调用(可通过-XX:MaxRecursiveInlineLevel调整)

    另外还有不少其他的jvm参数,就不全贴了,可以自己去查一下,基本上参数名里带inline的都是关于方法内联的参数。

     

    ②逃逸分析

    逃逸分析介绍:

    逃逸分析(Escape Analysis)根据运行状态可以判断方法中的变量,是否会被外部方法引用或外部线程访问,如果会,那么这个行为就是逃逸,根据情况可以分为两种逃逸:

    方法逃逸

    当一个方法中的变量可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种行为称为方法逃逸。

    线程逃逸

    如果是多线程环境,有的变量没有作线程保护,可能会被外部线程访问到,这种行为称为线程逃逸。

    也就是这项技术并不是什么优化技术,而是进行某些专项优化前的必要分析,如果不会发生逃逸现象,才进行一些专项的优化。另外这项技术是从JDK1.6开始的,所以目前而言不算太成熟,因为逃逸分析需要一些复杂的运算,又要兼顾性能的损耗,所以会有一些误差。

    参数设置:

    可以通过JVM参数进行设置开启或关闭逃逸分析:

    -XX:+DoEscapeAnalysis 开启逃逸分析(jdk1.8 默认开启)
    -XX:-DoEscapeAnalysis 关闭逃逸分析
    

    它手下的优化方法主要有三种:栈上分配锁消除标量替换。也就是如果你没开启逃逸分析,那么就相当于关闭了这三种优化手段了。

    a.栈上分配(Stack Allocations)

    如果确定一个变量不会逃逸出方法外,那么C2会让这个对象直接在栈上分配内存创建实例,而不是在堆上,因为这样一来可以加快速度,二来是该变量占用的内存空间可以随着栈帧出栈而销毁。

    一般应用中,大多局部对象都可以使用栈上分配,这样垃圾收集器的压力就会小很多。

    b.同步消除(Synchronization Elimination)

    又名锁消除,多线程的情况下,有些变量会上同步锁,避免发生线程冲突问题。但是经过了逃逸分析后,该变量分析确定了它不会逃逸出线程,不会被其他线程访问到,那这个变量就没有必要上什么同步锁了,就可以清除掉这个变量的同步锁以节约资源。

    比较常用的例子是StringBuffer的append()方法,由于加了修饰符synchronized,所以是线程安全的,但是如果你是单线程环境,就完全没有必要上这个锁了。所以开启了逃逸分析和锁消除的情况下(还要是C2才行),优化时就会将同步锁清除,大大的提升效率。

    参数:

    -XX:+EliminateLocks 开启锁消除(jdk1.8 默认开启)
    -XX:-EliminateLocks 关闭锁消除
    
    c.标量替换(Scalar Replacement)

    标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。

    聚合量:

    相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的Object对象就是最典型的聚合量。

    如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。

    如果逃逸分析证明一个对象不会被外部访问的话,在优化时就可能进行标量替换。也就是在程序执行时,将不会创建整个对象,而是拆分为只创建它使用到的几个成员变量的标量。将对象拆分后,还可能结合栈上分配,将这些基本标量直接在栈上创建实例,可以为后续进一步的优化手段创建条件。

    这里用java语言举个例子,当然实际情况jvm是直接读取class文件的:

        public void test() {
            UserInfo user = new UserInfo();
            user.id = 1;
            user.age = 18;
        }
    

    经过标量替换:

        public void test() {
            int id = 1;
            int age = 18;
        }
    

    大概就是这么个意思,这样可以节省很多不必要的内存空间

    参数:

    -XX:+EliminateAllocations 开启标量替换(jdk1.8 默认开启)
    -XX:-EliminateAllocations 关闭就可以了
    
    逃逸分析的缺陷

    逃逸分析在JDK1.6中不太成熟,主要是不能保证逃逸分析的性能收益必定高于它的消耗。如果要完全准确地判断一个对象是否会逃逸,需要进行一系列的复杂分析,但是这样会很消耗资源,如果分析完后没有什么收获,就相当于凭空浪费了很多资源。所以目前虚拟机采用的是不那么准确,但时间压力相对较小的算法来完成逃逸分析。

    除此以外还有一些其他的优化手段,只是方法内联和逃逸分析是比较出名而已。

     

    14.不断进步的JVM

    JAVA 9 实验性的引入了一种新的编译模式 :AOT(Ahead of Time Compilation)提前编译器,它是在程序运行前,就直接把Java源码文件(.java文件)编译成本地机器码的过程。

    这种编译方式显然属于静态编译,和C++类似,所以优点和缺点都比较明显。

    优点:

    1.JVM将源码提前编译成机器语言可以直接执行的指令,在程序启动时就可以直接使用了。

    2.不用像越跑越高效的JIT编译器一样需要等待预热,在程序启动后才开始优化。

    缺点:

    1.由于是在运行前提前将源码全部编译为机器码,加上java本身也有很多的动态特征,编译质量自然就没有动态编译的JIT好了。它无法利用程序的动态行为,也不具有关于加载的类层次结构的信息。

    2.破坏了java的“一次编译,到处运行”的规则,必须为每个不同的硬件系统发行包。

    而JAVA 10中又实验性的发布了Graal编译器,它既可以作为动态编译器,在运行时编译热点方法;亦可以作为静态编译器,实现AOT编译。这回它是用java编写的了,潜在的准备替换C2编译器,甚至是潜在的HotSpot的替代方案,因为它还有个关联的项目:Graal VM。

    我们根据需求可以去了解这些,但是如果暂时不怎么用的上的话,就简单了解一下就好了。

     

     

    最后的总结和瞎哔哔:

    本篇我写了很久,谁叫我是打工人呢,每天只有一小部分时间可以自由支配。

    对于JVM,显然不是一篇1W字的文章就能概括的完的,如果真的这么点字就能把JVM说透,那我只能建议甲骨文公司赶快过来挖我了。所以本篇只能说是入门和教你怎么哔哔一下JVM的,让你不至于在别人提问的时候阿巴阿巴阿巴。如果能让大家从一无所知,到一知半解其实也足够了。

    本篇后面关于JVM的新的编译技术我也偷了个懒没有多说,也没有过多的介绍。因为我认为当我们还不够成熟时,过度深入的了解这些可能会有点钻牛角尖,这其实并没有什么必要(不是因为我懒所以没写)。当然如果有人对它们很有兴趣,也可以自己去多了解了解,能写一些文档分享出来就更好了(到时候让我康康)。更多的知识还是靠自己去挖的,当然最靠谱的方式还是看书,和跟在大牛屁股后面看文章(不是我这种,我最多是牛犊)。

     

     

     

    参考文档:

    1.【推荐】Java基础知识:https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/basis/Java%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86.md

    2.JVM,HotSpot和JIT关系梳理:

    https://blog.csdn.net/weixin_42447959/article/details/83451087

    3.浅谈对JIT编译器的理解:

    https://www.cnblogs.com/insistence/p/5901457.html

    4.jvm 解释器和JIT编译器:

    https://www.cnblogs.com/xiaodu9499/p/13291998.html

    5.什么是HotSpot VM & 深入理解Java虚拟机 JVM:

    https://www.cnblogs.com/charlesblc/p/5993804.html

    6.jvm中的热点代码检测机制:

    https://www.cnblogs.com/yanggb/p/13205449.html

    7.【推荐】Java编译(三) Java即时编译(JIT编译):运行时把Class文件字节码编译成本地机器码:

    https://blog.csdn.net/tjiyu/article/details/53948009?utm_source=blogxgwz0

    8.JVM学习笔记(六-执行引擎):

    https://blog.csdn.net/qq_27513919/article/details/107526739

    9.【推荐】基本功 | Java即时编译器原理解析及实践

    https://mp.weixin.qq.com/s/7PH8o1tbjLsM4-nOnjbwLw

    10.Java 面试-即时编译( JIT )

    https://zhuanlan.zhihu.com/p/86424591

    11.【推荐】OSR(On-Stack Replacement)是怎样的机制?

    https://www.zhihu.com/question/45910849/answer/100636125

    相关文章

      网友评论

          本文标题:从0开始学习JVM

          本文链接:https://www.haomeiwen.com/subject/mxfozktx.html