Java代码编译,大家都知道.java代码编译成.class文件,这个过程就是我们常说的编译,也叫前端编译。事实上,Java程序的编译运行不仅仅是将代码编译成.class文件,因为机器无法直接运行.class文件。Java培训还需要JIT或解释器将.class文件转换为机器代码。这个过程叫做运行时编译。今天我们将详细了解运行时编译器如何优化Java代码。ClassCompilationLoadingExecutionProcessClassCompilation在我们写好代码之后,我们需要使用JDK自带的javac工具将.java文件编译成.class文件,然后才能在虚拟机上运行。我们可以用javap反编译看看class文件包含了哪些信息。虽然预编译可以通过javac工具来完成,但实际上是一个非常复杂的过程,包括词法分析、符号表填充、注解处理、语义分析、类文件生成等。我们只需要关注常量池和方法表集合两部分即可。常量池主要记录类文件中出现的字面量和符号引用。字面常量包括字符串常量、声明为final的属性和一些基本类型的属性。符号引用包括类和接口的全限定名、类引用、方法引用、成员变量引用等。方法表集合主要包括一些方法的字节码、方法访问权限、方法名索引、描述符索引、JVM执行指令、属性集合。类加载当一个类的实例被创建或被其他对象引用时,虚拟机会通过类加载器将字节码文件加载到内存中,而不加载该类。不同的实现类由不同的类加载器加载。JDK中的本地方法类一般由Bootstrp加载器加载,JDK内部实现的扩展类一般由扩展加载器(ExtClassLoader)加载。类文件由系统加载器(AppClassLoader)加载。类加载后,类文件中的常量池信息等数据会保存在JVM内存的方法区中。类连接类加载完成后,会进行连接、初始化,最后使用。连接包括验证、准备和分析三个过程。验证:验证类是否符合Java规范和JVM规范,在保证规范的前提下避免危及虚拟机的安全。准备工作:为类的静态变量分配内存,并初始化为系统的初始值。对于被finalstatic修饰的变量,直接赋值给用户定义的值。解析:将符号引用转换为直接引用的过程。在编译时,Java类并不知道被引用类的实际地址,所以只能用符号引用来代替。类结构文件的常量池存储符号引用,包括类和接口的完全限定名、类引用、方法引用和成员变量引用等。如果要使用这些类和方法,需要将它们转换成JVM可以直接获取的内存地址或指针,即直接引用。类初始化类初始化是类加载的最后一步。这一步java训练组织JVM会先执行constructor方法,编译器在将.java文件编译成.class文件时会收集所有的类初始化代码,包括静态变量赋值语句、静态代码块、静态方法一起收集到()方法中。初始化类的静态变量和静态代码块为用户自定义值,初始化顺序与Java源码从上到下的顺序一致。私有静态整数i=1;static{i=0;}publicstaticvoidmain(String[]args){System.out.println(i);}运行结果:0调整位置看:static{i=0;}私有静态inti=1;publicstaticvoidmain(String[]args){System.out.println(i);}运行结果:1子类初始化时,会先调用父类的()方法,然后执行子类Class()方法:publicclassParent{publicstaticStringparentStr="parentstaticstring";static{System.out.println("parentstaticfields");System.out.println(parentStr);}publicParent(){System.out.println("parentinstanceinitialization");}}publicclassSubextendsParent{publicstaticStringsubStr="substaticstring";static{System.out.println("substaticfields");System.out.println(subStr);}publicSub(){System.out.println("subinstanceinitialization");}publicstaticvoidmain(String[]args){System.out.println("submain");newSub();}}运行结果:parentstaticfieldsparentstaticstringsubstaticfieldssubstaticstringsubmainparentinstanceinitializationsubinstanceinitializationJVM会保证()方法的线程安全,保证同一时刻只有一个线程执行。JVM在初始化和执行代码的时候,如果实例化了一个新的对象,就会调用初始化实例变量的方法,执行相应构造方法中的代码。即时编译初始化完成后,还没有结束。在调用和执行类的过程中,执行引擎会将字节码转换为机器码,然后才能在操作系统中执行。即时编译存在于将字节码转换为机器码的过程中。一开始,虚拟机中的字节码是由解释器编译的。当某个方法或代码块运行非常频繁时,虚拟机会将这些代码识别为“热代码”。为了提高热点代码的执行效率,即时编译器在运行时将这些代码编译成机器码,并进行各级优化,保存在内存中。即时编译器类型在HotSpot虚拟机中,内置了两种即时编译器,分别是C1编译器和C2编译器。这两个编译器的编译过程是不同的。C1编译器是一个简单快速的编译器。它的主要重点是局部优化。适用于执行时间短或对启动性能有要求的程序。它也被称为客户端编译器。C2编译器是用于对长时间运行的服务器应用程序进行性能调整的编译器。它适用于执行时间长或峰值性能要求的程序。它也被称为服务器编译器。在Java7之前,需要根据程序的特点选择合适的即时编译器。默认情况下,虚拟机使用解释器和即时编译器协同工作。Java7引入了分层编译,兼容了C1的启动性能优势和C2的峰值性能优势。当然也可以通过参数-clinet和-server强制指定虚拟机的即时编译器。分层编译分层编译将JVM的执行状态分为五个级别:Level0:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可以触发Level1编译;level1:可以称为C1编译,将字节码编译成本地代码,进行简单可靠的优化,不开启Profiling;layer3:也可以称为C1编译,启用Profiling,只执行方法调用次数和回环边沿执行C1编译次数profiling;layer4:可以称为C2编译,同样是将字节码编译成本地代码,但是会启用一些编译时间比较长的优化,甚至会根据性能监控信息优化进行一些不靠谱的激进操作。Java8中再次优化,默认开启分层编译。-client和-server的设置已经无效。如果只想启用C2,可以禁用分层编译(-XX:-TieredCompilation)。如果只想启用C1,可以在启用分层编译时使用参数:-XX:TieredStopAtLevel=1。热点检测在HotSpot虚拟机中,热点检测是JIT优化的一个条件。热点检测基于计数器。使用该方法的虚拟机为每个方法建立一个计数器来统计该方法的执行次数。如果执行次数超过某个阈值,则它被认为是“热方法”。虚拟机为每个方法准备了两种计数器,分别是方法调用计数器和返回计数器。下面我们分别来看一下。方法调用计数器:用于统计方法被调用的次数。方法调用计数器的默认阈值在C1模式下为1500次,在C2模式下为10000次。我们可以通过-XX:CompileThreshold手动修改。但是在分层编译的情况下,-XX:CompileThreshold指定的阈值是无效的,会根据当前要编译的方法数和编译线程数动态调整。当方法计数器和返回边缘计数器的总和超过方法计数器阈值时,将触发JIT编译器。边界计数器:用于统计方法中循环体代码执行的次数。在字节码中遇到控制流后跳转的指令称为“向后”。该值用于计算触发C1编译的阈值。在不开启分层编译的情况下,C1模式默认13995次,C2模式默认10700次。我们可以通过-XX:OnStackReplacePercentage=N手动修改。在分层编译的情况下,-XX:OnStackReplacePercentage指定的阈值也会失效,会根据当前要编译的方法数和编译线程数动态调整。edge-back计数器的主要目的是在堆栈上触发编译。在一些循环周期比较长的代码段中,当循环达到edge-back计数器的阈值时,JVM会认为这段是热代码,JIT编译器会将这段代码编译成机器码并缓存起来,缓存的机器码在循环时间段内直接执行。编译优化技术JIT编译器有一些经典的优化技术。通过一些检查和优化,它可以编译出具有最佳运行时性能的代码。比较常用的方法是方法内联和逃逸分析。1、方法内联方法调用需要经过压栈和出栈。调用方法将程序执行顺序转移到存储方法的内存地址。该方法执行完后,该方法返回到该方法之前的位置。因此,方法调用会有一定的时间和空间开销。方法内联是在编译优化时将目标方法的代码复制到调用方法中,而不真正调用实际的方法。示例:privateintadd1(intx1,intx2,intx3,intx4){returnadd2(x1,x2)+add2(x3,x4);}privateintadd2(intx1,intx2){returnx1+x2;}会被优化为:privateintadd1(intx1,intx2,intx3,intx4){returnx1+x2+x3+x4;}JVM会自动识别热点方法,然后判断是否使用方法内联优化,我们可以通过-XX:CompileThreshold设置热点方法的阈值,热点方法不一定进行内联优化。还取决于方法体的大小。频繁执行的方法,默认情况下,body小于325字节的方法会被内联,我们也可以通过参数-XX:MaxFreqInlineSize=N来设置这个值的大小。对于不经常执行的方法,默认情况下,方法体大小在被内联之前小于35字节。我们也可以通过参数-XX:MaxInlineSize=N来设置这个值的大小。在日常工作中,我们还可以通过修改JVM参数来改进方法内联,降低热点阈值,提高方法体阈值,让更多的方法被内联,这样会占用更多的内存。避免大方法体的出现,习惯使用小方法体。尽量使用final、private、static关键字修饰方法。由于继承,编码方法将需要额外的类型检查。2、逃逸分析逃逸分析是一种判断对象是否被外部方法引用或被外部线程访问的分析技术。编译器会根据逃逸分析的结果对代码进行优化。根据逃逸分析的结果,主要有三种优化手段:栈上分配、锁消除和标量替换。默认在栈上创建对象分配是在堆上分配内存。当堆内存中的对象不再被使用时,JVM垃圾回收器将回收该对象。与栈中分配的对象的创建和销毁相比,这个过程的消耗是比较低的。更多的时间和性能消耗。如果逃逸分析发现该对象只在方法中使用,则将该对象分配到栈上,并消除锁。在线程安全的情况下,尽量不要使用线程安全的容器。比如常用的StringBuffer中的append()就是通过Synchronized关键字修饰的。使用锁,虽然保证了线程安全,但是也会导致性能下降。比如在本地方法中创建的对象,只能被当前线程访问,不能被其他线程访问,所以是线程安全的。JIT编译会去掉这个对象的方法锁来提高性能。标量替换逃逸分析证明一个对象不会被外部访问。如果对象可以拆分,那么在程序实际执行的时候可能不会创建对象,而是直接创建它的成员变量。对象拆分后,对象的成员变量可以分配到栈上或者寄存器上,原来的对象不需要分配内存空间。这种编译优化称为标量替换。JVM参数中逃逸分析的参数配置:-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)-XX:-DoEscapeAnalysis关闭逃逸分析-XX:+EliminateLocks开启锁消除(jdk1.8默认开启))-XX:-EliminateLocks关闭锁消除-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)-XX:-EliminateAllocations关闭在底层硬件上运行以提高性能。文章来自Java知音
