什么是JIT

JIT的全称是Just in time compilation,中文称之为即时编译,能够加速 Java 程序的执行速度。JIT是JVM最强大的武器之一。

java 编译发布 加速 java编译速度_c/c++

JVM client模式和Server模式区别

JVM Server模式与client模式启动,最主要的差别在于:-Server模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。

JVM工作在Server模式下可以大大提高性能,Server模式下应用的启动速度会比client模式慢大概10%,但运行速度比Client VM要快至少有10倍

当不指定运行模式参数时,虚拟机启动检测主机是否为服务器,如果是,则以Server模式启动,否则以client模式启动,J2SE5.0检测的根据是至少2个CPU和最低2GB内存。

由于服务器的CPU、内存和硬盘都比客户端机器强大,所以程序部署后,都应该以server模式启动,获取较好的性能;

JVM在client模式默认-Xms是1M,-Xmx是64M;JVM在Server模式默认-Xms是128M,-Xmx是1024M;

server:启动慢,编译更完全,编译器是自适应编译器,效率高,针对服务端应用优化,在服务器环境中最大化程序执行速度而设计。

client:快速启动,内存占用少,编译快,针对桌面应用程序优化,为在客户端环境中减少启动时间而优化;

当JVM用于启动GUI界面的交互应用时适合于使用client模式,当JVM用于运行服务器后台程序时建议用Server模式。

我们可以通过运行:java -version来查看jvm默认工作在什么模式。

clien模式下,新生代选择的是串行gc,旧生代选择的是串行gc

 server模式下,新生代选择的是并行回收gc,旧生代选择的是并行gc

一般来说我们系统应用选择有两种方式:吞吐量优先和暂停时间优先,对于吞吐量优先的采用server默认的并行gc方式,对于暂停时间优先的选用并发gc(CMS)方式。

其它延伸知识点

JDK有两种VM,VM客户端,VM服务器应用程序。这两种解决方案分享java运行环境的热点代码库,但使用不同的编译器,适用于客户机和服务器的独特的性能特点,这些差异包括编写内联政策和堆的默认值。

虽然服务器和客户端虚拟机类似,服务器VM已专门调整最大峰值操作速度。它的目的是执行长时间运行的服务器应用程序,它需要最快的运行速度超过一个快速启动时间或较小的运行时内存占用。

客户VM编译器是经典的虚拟机和实时升级(JIT)通过JDK的先前版本使用的编译器。客户端虚拟机提供了改进的运行应用程序和小程序的性能。java虚拟机的热点客户已减少应用程序的启动时间和内存占用特别调整,使其特别适合客户环境。在一般情况下,客户端系统更好的图形用户界面。

因此,真正的区别也在编译器级别上:

客户端虚拟机编译器不尝试执行由编译器在服务器虚拟机上执行的更复杂的优化,但在交换过程中,它需要较少的时间来分析和编译一段代码。这意味着客户端虚拟机可以更快地启动,并需要一个较小的内存占用。

服务器虚拟机包含一个先进的自适应编译器支持许多C++编译器的优化进行优化,同样的类型,以及一些优化,不能用传统的编译器完成的,比如积极的内联在虚拟方法调用。这是一个竞争和性能优势,静态编译器。自适应优化技术在它的方法是非常灵活的,通常优于甚至先进的静态分析和编译技术。

-Server模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升,原因是:当虚拟机在-Client模式的时候,使用的是一个代号为C1的轻量级编译器,而-Server模式启动的虚拟机采用相对重量级代号为C2的编译器,C2比C1编译器编译的相对彻底,服务起来之后,性能高。

一般只要变更-server KNOWN与-client KNOWN两个配置的先后顺序即可,前提是JAVA_HOME/jre/bin目录下同时存在server和client两个文件夹,分别对应各自的jvm

说了这么多其实总结成一句话就是:

JVM Server模式下应用启动慢但运行速度快,JVM Client模式下应用启动快但运行速度要慢些

推荐:服务器上请以Server模式运行,面客户端或GUI模式下就以Client模式运行

 

参考:https://www.jb51.net/article/129592.htm

 

JIT

即时编译(Just-in-time Compilation,JIT)是一种通过在运行时将字节码翻译为机器码,从而改善字节码编译语言性能的技术。在HotSpot实现中有多种选择:C1、C2和C1+C2,分别对应JVM 的client模式、server模式和分层编译。

  1. C1编译速度快,优化方式比较保守;
  2. C2编译速度慢,优化方式比较激进,这也是为什么 -server 模式启动比较慢的原因;
  3. C1+C2在开始阶段采用C1编译,当代码运行到一定热度之后采用G2重新编译;

在1.8之前,分层编译默认是关闭的,可以添加-server -XX:+TieredCompilation参数进行开启。

通常JIT的有以下几种手段来优化JVM的性能:

  1. 针对特定CPU型号的编译优化,JVM会利用不同CPU支持的SIMD指令集来编译热点代码,提升性能。像intel支持的SSE2指令集在特定情况下可以提升近40倍的性能。
  2. 减少查表次数。比如调用Object.equals()方法,如果运行时发现一直是String对象的equals,编译后的代码可以直接调用String.equals方法,跳过查找该调用哪个方法的步骤。
  3. 逃逸分析。JAVA变量默认是分配在主存的堆上,但是如果方法中的变量未逃出使用的生命周期,不会被外部方法或者线程引用,可以考虑在栈上分配内存,减少GC压力。另外逃逸分析可以实现锁优化等提升性能方法。
  4. 寄存器分配,部分变量可以分配在寄存器中,相对于主存读取,更大的提升读取性能。
  5. 针对热点代码编译好的机器码进行缓存。代码缓存具有固定的大小,并且一旦它被填满,JVM 则不能再编译更多的代码。
  6. 方法内联,也是JIT实现的非常有用的优化能力,同时是开发者能够简单参与JIT性能调优的地方。

接下来我们将对上面的集中优化方式进行详细学习



1.方法内联



什么是方法内联
  1. 编译过程遇到方法调用,把目标方法体纳入编译范围且取代原方法优化手段
  2. 是编译优化最重要的

函数的调用过程

要搞清楚为什么方法内联有用,首先要知道当一个函数被调用的时候发生了什么

  1. 首先会有个执行栈,存储目前所有活跃的方法,以及它们的本地变量和参数
  2. 当一个新的方法被调用了,一个新的栈帧会被加到栈顶,分配的本地变量和参数会存储在这个栈帧
  3. 跳到目标方法代码执行
  4. 方法返回的时候,本地方法和参数会被销毁,栈顶被移除
  5. 返回原来地址执行

这种转移操作要求在转去前要保护现场并记忆执行的地址,转回后先要恢复现场,并按原来保存地址继续执行。也就是通常说的压栈和出栈。

这就是通常说的函数调用的压栈和出栈过程,因此,函数调用需要有一定的时间开销和空间开销,当一个方法体不大,但又频繁被调用时,这个时间和空间开销会相对变得很大,变得非常不划算,同时降低了程序的性能。根据二八原则,80%的性能消耗其实是发生在20%的代码上,对热点代码的针对性优化可以提升整体系统的性能。

那怎么解决这个性能消耗问题呢,这个时候需要引入内联函数了。内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换。显然,这样就不会产生转去转回的问题,但是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标程序代码量,进而增加空间开销,而在时间代销上不象函数调用时那么大,可见它是以目标代码的增加为代价来换取时间的节省。

JVM内联函数

举例:getter/setter 

  1. 如果没有方法内联,调用时需要创建并压入用于getter/setter的栈帧,访问字段,弹出栈帧,最后再到当前方法执行
  2. 内联后,就仅剩字段访问

C++是否为内联函数由自己决定,Java由编译器决定。Java不支持直接声明为内联函数的,如果想让他内联,你只能够向编译器提出请求: 关键字final修饰 用来指明那个函数是希望被JVM内联的,例:

public final void doSomething() {  

        // to do something  

}

总的来说,一般的函数都不会被当做内联函数,只有声明了final后,编译器才会考虑是不是要把你的函数变成内联函数。

JVM内建有许多运行时优化。首先短方法更利于JVM推断。流程更明显,作用域更短,副作用也更明显。如果是长方法JVM可能直接就跪了。第二个原因则更重要:方法内联

如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。比如说下面这个:
 

private int add4(int x1, int x2, int x3, int x4) {  

        return add2(x1, x2) + add2(x3, x4);  

}  

private int add2(int x1, int x2) {  

        return x1 + x2;  

}

运行一段时间后JVM会把add2方法去掉,并把你的代码翻译成:

private int add4(int x1, int x2, int x3, int x4) {  

        return x1 + x2 + x3 + x4;  

}

方法内联的条件

  1. 内联越多执行效率越高,但是编译时间会延长
  2. 内联越多,机器码越长容易使java内存溢出

JVM会自动的识别热点方法,并对它们使用方法内联优化。那么一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置:

  1. 使用client编译器时,默认为1500;
  2. 使用server编译器时,默认为10000;

但是一个方法就算被JVM标注成为热点方法,JVM仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(可以通过-XX:MaxFreqInlineSize=N来设置这个大小)

如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(可以通过-XX:MaxInlineSize=N来设置这个大小)

我们可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

 

如果想要知道方法被内联的情况,可以使用下面的JVM参数来配置:

-XX:+PrintCompilation //在控制台打印编译过程信息

-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断

-XX:+PrintInlining //将内联方法打印出来



方法内联的其他隐含条件

虽然JIT号称可以针对代码全局的运行情况而优化,但是JIT对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果

想要对热点的方法使用上内联的优化方法,最好尽量使用final、private、static这些修饰符修饰方法,避免方法因为继承,导致需要额外的类型检查,而出现效果不好情况。

这就是JVM中简单的方法内联,当然方法内联还有很多限制,执行规则如下表所示:

java 编译发布 加速 java编译速度_运维_02

 

参考:


 



2.逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

例如:

public static StringBuffer craeteStringBuffer(String s1, String s2) {

        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
}

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。

甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要StringBuffer sb不逃出方法,可以这样写:

public static StringBuffer craeteStringBuffer(String s1, String s2) {

        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
}

不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。

如果能证明一个对象不会逃逸到方法或线程外,则可能为这个变量进行一些高效的优化。

方法逃逸的几种方式如下:

public class EscapeTest {

    public static Object obj;

    public void globalVariableEscape() {  // 给全局变量赋值,发生逃逸

        obj = new Object();

    }

    public Object methodEscape() {  // 方法返回值,发生逃逸

        return new Object();

    }

    public void instanceEscape() {  // 实例引用发生逃逸

        test(this);

    }

}

标量替换

Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况。

栈上分配

我们都知道Java中的对象都是在堆上分配的,而垃圾回收机制会回收堆中不再使用的对象,但是筛选可回收对象,回收对象还有整理内存都需要消耗时间。如果能够通过逃逸分析确定某些对象不会逃出方法之外,那就可以让这个对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

在一般应用中,如果不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了。

故名思议就是在栈上分配对象,其实目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。 

private static int fn(int age) {

        User user = new User(age);
        int i = user.getAge();
        return i;
}

User对象的作用域局限在方法fn中,可以使用标量替换的优化手段在栈上分配对象的成员变量,这样就不会生成User对象,大大减轻GC的压力,下面通过例子看看逃逸分析的影响。

public class JVM {

    public static void main(String[] args) throws Exception {

        int sum = 0;

        int count = 1000000;

        //warm up

        for (int i = 0; i < count ; i++) {

            sum += fn(i);

        }
        Thread.sleep(500);

        for (int i = 0; i < count ; i++) {

            sum += fn(i);

        }

        System.out.println(sum);

        System.in.read();

    }


    private static int fn(int age) {

        User user = new User(age);

        int i = user.getAge();

        return i;

    }

}

class User {

    private final int age;

    public User(int age) {

        this.age = age;

    }

    public int getAge() {

        return age;

    }

}

分层编译和逃逸分析在1.8中是默认是开启的,例子中fn方法被执行了200w次,按理说应该在Java堆生成200w个User对象。

1、通过java -cp . -Xmx3G -Xmn2G -server -XX:-DoEscapeAnalysis JVM运行代码,-XX:-DoEscapeAnalysis关闭逃逸分析,通过jps查看java进程的PID,接着通过jmap -histo [pid]查看java堆上的对象分布情况,结果如下:

java 编译发布 加速 java编译速度_JVM_03

可以发现:关闭逃逸分析之后,User对象一个不少的都在堆上进行分配。

2、通过java -cp . -Xmx3G -Xmn2G -server JVM运行代码,结果如下:

java 编译发布 加速 java编译速度_java_04

可以发现:开启逃逸分析之后,只有41w左右的User对象在Java堆上分配,其余的对象已经通过标量替换优化了。

3、通过java -cp . -Xmx3G -Xmn2G -server -XX:-TieredCompilation运行代码,关闭分层编译,结果如下:

java 编译发布 加速 java编译速度_运维_05

可以发现:关闭了分层编译之后,在Java堆上分配的User对象降低到1w多个,分层编译对逃逸分析还是有影响的。

编译阈值

即时编译JIT只在代码段执行足够次数才会进行优化,在执行过程中不断收集各种数据,作为优化的决策,所以在优化完成之前,例子中的User对象还是在堆上进行分配。

那么一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置:

1、使用client编译器时,默认为1500;

2、使用server编译器时,默认为10000;

意味着如果方法调用次数或循环次数达到这个阈值就会触发标准编译,更改CompileThreshold标志的值,将使编译器提早(或延迟)编译。

除了标准编译,还有一个叫做OSR(On Stack Replacement)栈上替换的编译,如上述例子中的main方法,只执行一次,远远达不到阈值,但是方法体中执行了多次循环,OSR编译就是只编译该循环代码,然后将其替换,下次循环时就执行编译好的代码,不过触发OSR编译也需要一个阈值,可以通过以下公式得到。

-XX:CompileThreshold = 10000
-XX:OnStackReplacePercentage = 140
-XX:InterpreterProfilePercentage = 33
OSR trigger = (CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage)) / 100 = 10700

其中trigger即为OSR编译的阈值。

那么如果把CompileThreshold设置适当小一点,是不是可以提早触发编译行为,减少在堆上生成User对象?我们可以进行通过不同参数验证一下:

1.-XX:CompileThreshold = 5000,结果如下:

java 编译发布 加速 java编译速度_运维_06

2.-XX:CompileThreshold = 2500,结果如下:

java 编译发布 加速 java编译速度_运维_07

3.-XX:CompileThreshold = 2000,结果如下:

java 编译发布 加速 java编译速度_java 编译发布 加速_08

4.-XX:CompileThreshold = 1500,结果如下:

java 编译发布 加速 java编译速度_运维_09

在我的机器中,当设置到1500时,在堆上生成的User对象反而升到4w个,目前还不清楚原因是啥...

JIT编译在默认情况是异步进行的,当触发某方法或某代码块的优化时,先将其放入编译队列,然后由编译线程进行编译,编译之后的代码放在CodeCache中,CodeCache的大小也是有限的,通过-XX:-BackgroundCompilation参数可以关闭异步编译,我们可以通过执行java -cp . -Xmx3G -Xmn2G -server -XX:CompileThreshold=1 -XX:-TieredCompilation -XX:-BackgroundCompilation JVM命令看看同步编译的效果:在java堆上只生成了2个对象。

同步消除

线程同步的代价是相当高的,同步的后果是降低并发性和性能。逃逸分析可以判断出某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作,这样就能大大提高并发程度和性能,通过-XX:+EliminateLocks可以开启同步消除。

劣势  

1.热点代码的编译过程是有成本的,如果逻辑复杂,编程成本更高;

2.编译后的代码会被存放在有大小限制的CodeCache中,如果CompileThreshold设置的太低,JIT会将一大堆执行不那么频繁的代码进行编译,并放入CodeCache,导致之后真正执行频繁的代码没有足够的空间存放;

3.栈上分配受限于栈的空间大小,一般自我迭代类的需求以及大的对象空间需求操作,将导致栈的内存溢出;故只适用于一定范围之内的内存范围请求。

测试代码

测试代码:

public class Test {

    public static void alloc() {

        byte[] b = new byte[2];

        b[0] = 1;

    }



    public static void main(String[] args) {

        long b = System.currentTimeMillis();

        for (int i = 0; i < 100000000; i++) {

            alloc();

        }

        long e = System.currentTimeMillis();

        System.out.println(e - b);

    }

}

开启逃逸分析

执行:java  -server  -Xmx10m  -Xms10m  -XX:+DoEscapeAnalysis  -XX:+PrintGC  Test
打印:
[GC (Allocation Failure)  2048K->672K(9728K), 0.0012560 secs]
[GC (Allocation Failure)  2720K->744K(9728K), 0.0009568 secs]
[GC (Allocation Failure)  2792K->752K(9728K), 0.0013591 secs]
8
 
关闭逃逸分析
执行:java  -server  -Xmx10m  -Xms10m  -XX:-DoEscapeAnalysis  -XX:+PrintGC  Test
打印:
......省略
[GC (Allocation Failure)  2736K->688K(9728K), 0.0005100 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004587 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0005108 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0005064 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004930 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004780 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004464 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0008060 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0011400 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0008325 secs]
......省略
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004733 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004299 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0004180 secs]
[GC (Allocation Failure)  2736K->688K(9728K), 0.0003703 secs]
1315

扩展

在上一小节,当我们开启了逃逸分析,如果内存足够大,打印的日志就只有程序执行的时间,如果我们调小启动参数中年轻代的内存,就会发现日志中存在GC日志。

为啥会有GC呢??我们明明开启的逃逸分析,按理来说,应该会在栈上分配对象的啊。下面将回答这个问题

我使用的是JDK1.8,默认使用混合模式,你可以会问:什么是混合模式?

在Hotspot中采用的是解释器和编译器并行的架构,所谓的混合模式就是解释器和编译器搭配使用,当程序启动初期,采用解释器执行(同时会记录相关的数据,比如函数的调用次数,循环语句执行次数),节省编译的时间。在使用解释器执行期间,记录的函数运行的数据,通过这些数据发现某些代码是热点代码,采用编译器对热点代码进行编译,以及优化(逃逸分析就是其中一种优化技术)

现在我们知道了什么是混合模式,但是我们怎么知道我们的JDK采用了混合模式呢?

在windows系统中,我们通过cmd命令进行命令行窗口,执行命令

java -verion
执行结果:
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)
//mixed mode 表示用的混合模式,interpreted mode 表示使用解释器, compiled mode 表示采用编译器
为什么会有GC日志?

在程序启动初期,我们使用的解释器执行,使用解释器执行没有逃逸分析的技术,因此对象在年轻代进行分配,关于对象的分配和我们上面的分析流程一致,当年轻代空间不足,就会触发GC,关于对象的创建以及GC的触发可以参考我的文章:http://www.jianshu.com/p/941fe93d21c2

使用解释器执行,积累的程序执行的相关数据,使用编译器对热点代码进行编译,并且采用逃逸分析技术进行优化。对象将在栈上分配,随着栈帧的出栈而消亡。

只使用编译器执行上面的代码会是什么效果?

启动参数:

-server -Xcomp -verbose:gc -XX:+DoEscapeAnalysis  -XX:-UseTLAB  -Xmx20m -Xms20m -Xmn3m

程序打印的日志:

Time cost is 144426438

对比上面的日志,我们发现使用的时间多了两个数量级,而且没有GC日志,为什么呢?

没有GC日志是因为程序使用编译器来执行程序,并进行了逃逸分析的优化操作;时间多了两个数量级是因为编译器编译的过程缓慢,今天先来点开胃小菜,接下来将写其他的文章来讲解编译器和解释器的混合。

总结

虽然概念上的JVM总是在Java堆上为对象分配空间,但并不是说完全依照概念的描述去实现;只要最后实现处理的“可见效果”与概念中描述的一直就没问题了。所以说,“you can cheat as long as you don’t get caught”。Java对象在实际的JVM实现中可能在GC堆上分配空间,也可能在栈上分配空间,也可能完全就消失了。这种行为从Java源码中看不出来,也无法显式指定,只是聪明的JVM自动做的优化而已。

但是逃逸分析会有时间消耗,所以性能未必提升多少,并且由于逃逸分析比较耗时,目前的实现都是采用不那么准确但是时间压力相对较小的算法来完成逃逸分析,这就可能导致效果不稳定,要慎用。

参考:

https://www.jianshu.com/p/20bd2e9b1f03

https://www.jianshu.com/p/3835450d49d0