概述
做为Java开发人员,我们编写的代码是以“.java”为文件后缀的,也就是常说的源码。源码在经过javac命令编译之后,就会生成一个对应“.class”文件,这个就是字节码文件。它为Java的一次编译,到处运行提供了基础。通过JVM的映射,同一份字节码文件,可以在不同的系统上运行,这里就得益于JVM的内部处理了,即:通过JVM屏蔽了底层硬件的差异。但实际上字节码文件还不能算编译完成,它是一个“半成品”,通过JVM内部的处理,才能够转换成处理器能够识别的指令代码。
一般地将Java代码的编译分为两个阶段:前端编译和后端编译。前面说的class字节码文件就是前端编译的产物,后端编译最终的结果就是将其转换成计算机能够识别的机器语言。这里引用Hollis大神博客的一张图: 前后端编译过程.png这里不去深入探究具体的【词法分析】、【语法分析】、【语义分析】的具体工作原理,只要明白这些步骤是生成字节码文件所必要的校验过程就行。它们实际上就是对源代码进行各种语法层面以及语义层面的校验,以保证最终生成的字节码是标准的、没有语法和语义上的错误。
通过上图可以看到,前端编译的最终产物是【中间代码】,这个中间代码所指的就是Java的字节码文件。因为代码到了这里还不具有被处理器运行的能力,还需要一层JVM的加工,将其与具体操作系统相关联,生成针对所处系统的机器语言代码,此时机器代码才具有被处理器识别并执行的能力。
解释器(Interpreter)
解释器顾名思义就是解释代码用的,上面提到了前端编译的最终产物是中间代码,这时的中间代码不具有被处理器识别并执行的能力,解释器的职责就是将此时的字节码逐条解释成处理器能够识别的机器码指令。所以从这里也可以解释,为什么高级语言执行效率会这么慢,主要就是多了这个解释的过程,像Java、Python等执行效率都比较慢。所以通俗讲:解释器在Java中的作用充当了Java程序和计算机之间的“翻译官”角色,将计算机“看”不懂的语言翻译成它能够“看”懂并执行的机器语言。
及时编译器(JIT)
因为纯粹靠解释器来工作,效率会很慢,JVM引入了及时编译器JIT(Just In Time)的概念,它可以加快Java中部分代码的执行效率。下面简单介绍一下它的工作内容:
在我们日常编码中,经常会遇到有些方法执行频率很高,或者有些代码段执行频率很高(例如循环结构)。JVM会随着程序的不断执行,进行深入分析,将程序中那些执行频率比较高的方法或者代码段编译成机器码,然后将这段机器码缓存起来,这段代码也就是所谓的“热点”代码,HotSpot由此而得名。一旦代码被缓存,后续如果继续执行这段代码的时候,直接从缓存中获取这段机器码,就可以跳过前面的编译阶段,执行效率得到极大提高。请看下面这张图,很好地解释了JIT的工作原理: JIT工作原理.png这里可能会有疑问:“既然缓存机器码执行效率那么高,为什么还会有热点代码判断?干脆全部进行缓存不是更好?”这实际上也是一种策略,因为有很多代码(大部分)都是之执行一次,如果这种代码也要进行缓存,完全是在浪费资源。而如果一段代码被多次执行,那么浪费这一次的编译就会换来后续非常客观的效率回报。
这里还有一个概念--栈上替换(OSR) :在有的时候,可能某段循环执行的代码还在不停的循环中,如果在某次循环之后,计数器达到了某一阈值,这时JVM已经认定这段代码是热点代码,此时编译器会将这段代码编译成机器语言并缓存后,但是这段循环仍在执行,JVM就会把执行代码替换掉,那么等循环到下一次时,就会直接执行缓存的编译代码,而不需要必须等到循环结束才进行替换,这个就是所谓的栈上替换。
热点检测
想要进入JIT,就必须要通过热点代码的筛选,目前JVM的热点探测(Hot Spot Detection)主要有两种方式:
-
基于采样的热点探测(Sample Based Hot Spot Detection):周期性地检测各个线程的栈顶,如果发现某个方法经常出现在栈顶,换句话说就是某个方法频繁被调用,导致频繁入栈和出栈,那么就可以认为这个方法是热点代码。它的缺点是无法精确确认一个方法的热度,容易受线程阻塞或别的原因干扰探测的准确性。
-
基于计数器的热点探测(Counter Based Hot Spot Detection):这种探测方式会为每个方法,甚至是代码段添加两个计数器,用于统计执行的次数,它们一旦超过某种阈值,就判定为这段代码为热点代码。HotSpot虚拟机中采用的就是这种热点探测方法。这两个计数器分别是:方法计数器(用于统计方法调用次数)和回边计数器(统计for或者while的运行次数的计数器 )。
JIT运行模式
JIT的运行模式有两种:client模式和server模式。这两种模式采用的编译器是不一样的,client模式采用的是代号为C1的轻量级编译器,特点是启动快,但是编译不够彻底;而server模式采用的是代号为C2的编译器,特点是启动比较慢,但是编译比较彻底,所以一旦服务起来后,性能更高。可以通过java -version命令查看当前系统中安装的jvm使用的是那种模式:
JavaVersion命令截图.png可以看到,我这里使用的是server模式。但是也可以看到后面有个【mixed mode】字样,这表示编译器和解释器采用的是混合模式,即:这两者会同时工作,根据具体的情况,采用不同的运行策略。
另外,在jdk6u25之后,引入了分层编译的概念,它实际上就是一种渐进的编译策略,在系统运行初期,执行频率较高的代码采用C1编译器编译 ,随着时间的推移,执行频率较高的代码会再次被G2编译器编译,以达到最高的性能。分层编译实际上可以分为五层:
-
第0层(解释层)启动:这一层主要是提供了一些比较关键性方法的性能,快速进入C1层。
-
第1层(C1编译器):通过上一层提供的一些关键方法的性能信息来优化这些代码。本层不包含性能优化的信息。
-
第2层:这一层仍然是基于C1编译器优化的结果来处理的,此时会有少数方法通过C1编译器的编译,在本层会为这些少数方法的调用次数和循环分支执行情况,收集它们的性能分析信息。
-
第3层:这一层会得到C1编译器编译的所有方法以及对所有的性能优化信息
-
第4层:这一层只对C2编译器有效。
优化
优化代码缓存
提到优化,首先就不得不说缓存空间的大小,由于在运行期间,随着热点代码的不断缓存,可能会出现缓存空间不足的情况,此时一旦缓存空间被填满,后续的热点代码就无法进入,那么程序的性能必然会因此而下降,所以设置缓存空间的大小是很有必要的,目的是要最大程度保证热点代码都能被编译。并且编译器的选择不同,它的代码缓存大小也是不一样的,在OpenJDK中,如果是分层编译的话,代码缓存默认大小是240M,如果是不分层编译,缓存大小默认只有48M,这里可以使用 –XX:ReservedCodeCacheSize选项来增加缓存区的大小。另外还有一个:-XX:InitialCodeCacheSize 选项,它用来指定代码缓存空间的初始大小。
代码缓存空间大小并不是可以无限增大的,假如JVM是32位,那么运行程序的大小就不能超过4GB,缓存内存也是包含在这个4GB空间内的,在具体应用场景中需要结合实际情况来选择最优的缓存空间大小。
优化编译阈值
前面说过,对于热点代码的识别,主要就是通过方法的进入次数以及代码块的循环执行次数来确定,那么具体这个次数达到多少才能被认为是热点代码?这个值就是编译阈值。标准编译是被-XX:CompileThreshold 这个选项的值所触发的。client模式下它的默认值是1500,server模式下,默认值是10000,如果改变这里的阈值,会使得编译器在相对正常的情况下提前(或推迟)编译代码。改变阈值是比较流行的做法。
其他优化策略
其他的方式基本都不是直接作用于编译器的设置上,而是一些分析手段,通过具体的内存情况分析,找出问题出现的原因,并且以此找到合理的解决方案。
-
可以使用-XX:+PrintCompilation,它的默认值是false,添加该选项运行程序,可以打印出编译日志。
-
也可以使用jstat命令查看一些可见性信息,如果程序启动时没有添加PrintCompilation选项,可以考虑使用此方法:
当然还有一些其他的概念:内联、向量化、范围检查消除、循环展开等等,这些概念在还有待研究,这里就不做深入解释了。
网友评论