【JVM】运行参数以及JIT优化

JVM运行参数以及JIT优化

JVM架构理解

JVM执行流程分析

编译器和解释器的协调工作流程 :

何为热点代码?程序中的代码只有是“热点代码”时,才会编译为本地代码

  • 被多次调用的方法

  • 被多次执行的循环体

目前热点代码的探测方式有两种:

  • 基于采样的热点探测

    • 虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”

    • 这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测

  • 基于计数器的热点探测

    • 采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器 ,统计方法的执行次数

    • 如果执行次数超过一定的阀值,就认为它是“热点方法”

    • 这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨

    • 在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法 ,因此它为每个方法准备了两个计数器

      • 方法调用计数器:方法调用计数器触发即时编译流程图

      • 回边计数器

        • 它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

    • 在确定虚拟机运行参数的前提下,这两个计数器都有确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译

      • client模式的阈值是1500次

      • server模式的阈值是10000次

      • 可以通过虚拟机参数: -XX:CompileThreshold设置 ,但是JVM还存在热度衰减,时间段内调用 方法的次数较少,计数器就减小

JVM有两种运行模式:Server模式与Client模式

  • Server VM的初始堆空间会大一些,默认使用的是并行垃圾回收器,启动慢运行快

  • Client VM初始堆空间会小一些,使用串行的垃圾回收器,它的目标是为了让JVM的启动速度更快,但运行速度会比Server模式慢些

  • JVM在启动的时候会根据硬件和操作系统自动选择使用Server还是Client类型的JVM

    • 64位操作系统只有server类型,且不支持client类型

    • 所以一般只要机器配置一般般,都应该使用server类型

JVM的运行参数

在jvm中有很多的参数可以进行设置,这样可以让jvm在各种环境中都能够高效的运行。 绝大部分的参数保持默认即可

标准参数

jvm的标准参数,一般都是很稳定的,在未来的JVM版本中不会改变,可以使用java -help 检索出所有的标准参数

-D 设置系统参数

public class TestJVM {
    public static void main(String[] args) {
        String str = System.getProperty("str");
        if (str == null) {
            System.out.println("鞋破露脚尖儿");
        } else {
            System.out.println(str);
        }
    }
}
  • 我们将上面的代码进行编译后执行

    • javac TestJVM.java

    • java TestJVM

    • java -Dstr='巴拉巴拉小魔仙' TestJVM

至于更多的标准参数的使用,可以使用java -help查阅

-x 参数

jvm的-X参数是非标准参数,在不同版本的jvm中,参数可能会有所不同,可以通过java - X查看非标准参数

[root@node3 ninja_download]# java -X
    -Xmixed           混合模式执行 (默认)
    -Xint             仅解释模式执行
    -Xbootclasspath:<用 : 分隔的目录和 zip/jar 文件> 设置搜索路径以引导类和资源
    -Xbootclasspath/a:<用 : 分隔的目录和 zip/jar 文件> 附加在引导类路径末尾
    -Xbootclasspath/p:<用 : 分隔的目录和 zip/jar 文件> 置于引导类路径之前
    -Xdiag            显示附加诊断消息
    -Xnoclassgc       禁用类垃圾收集
    -Xincgc           启用增量垃圾收集
    -Xloggc:<file>    将 GC 状态记录在文件中 (带时间戳)
    -Xbatch           禁用后台编译
    -Xms<size>        设置初始 Java 堆大小
    -Xmx<size>        设置最大 Java 堆大小
    -Xss<size>        设置 Java 线程堆栈大小
    -Xprof            输出 cpu 配置文件数据
    -Xfuture          启用最严格的检查, 预期将来的默认值
    -Xrs              减少 Java/VM 对操作系统信号的使用 (请参阅文档)
    -Xcheck:jni       对 JNI 函数执行其他检查
    -Xshare:off       不尝试使用共享类数据
    -Xshare:auto      在可能的情况下使用共享类数据 (默认)
    -Xshare:on        要求使用共享类数据, 否则将失败。
    -XshowSettings    显示所有设置并继续
    -XshowSettings:all   显示所有设置并继续
    -XshowSettings:vm    显示所有与 vm 相关的设置并继续
    -XshowSettings:properties  显示所有属性设置并继续
    -XshowSettings:locale       显示所有与区域设置相关的设置并继续
-X 选项是非标准选项, 如有更改, 恕不另行通知。
[root@node3 ninja_download]#

这么多参数,我们只需要注意以下几个参数:

-Xloggc:<file> 将 GC 状态记录在文件中 (带时间戳)
-Xms<size> 设置初始 Java 堆大小
-Xmx<size> 设置最大 Java 堆大小
-Xss<size> 设置 Java 线程堆栈大小

-Xint、-Xcomp、-Xmixed

在解释模式(interpreted mode)下,-Xint标记会强制JVM执行所有的字节码,当然这会降低运行速度,通常低10倍或更多

-Xcomp参数与它(-Xint)正好相反,JVM在第一次使用时会把所有的字节码编译成本地代码,从而带来最大程度的优化,然而,很多应用在使用-Xcomp也会有一些性能损失,当然这比使用-Xint损失的少,原因是-xcomp没有让JVM启用JIT编译器的全部功能。JIT编译器可以对是否需要编译做判断,如果所有代码都进行编译的话,对于一些只执行一次的代码就没有意义了

-Xmixed是混合模式,将解释模式与编译模式进行混合使用,由jvm自己决定,这是jvm默认的模 式,也是推荐使用的模式

测试,代码还是刚刚的代码

  • 编译模式下,第一次执行会比解释模式下执行慢一些,注意观察

  • 强制设置为解释模式:java -Xint TestJVM

  • 强制设置为编译模式:java -Xcomp TestJVM 【第一次会慢上一些】

  • 默认的混合模式:java TestJVM

-xx 参数

-XX参数也是非标准参数,主要用于jvm的调优和debug操作

-XX参数的使用有2种方式,一种是boolean类型,一种是非boolean类型

boolean类型

  • 格式:-XX:[+-]

  • 如:-XX:+DisableExplicitGC 表示禁用手动调用gc操作,也就是说调用System.gc()无效

非boolean类型

  • 格式:-XX:

  • 如:-XX:NewRatio=1 表示新生代和老年代的比值

-Xms 和 -Xmx

  • -Xms与-Xmx分别是设置jvm的堆内存的初始大小和最大大小

  • -Xms512m:等价于-XX:InitialHeapSize,设置JVM初始堆内存为512M

  • -Xmx2048m:等价于-XX:MaxHeapSize,设置JVM最大堆内存为2048M

  • 比如:java -Xms512m -Xmx2048m TestJVM

    • 适当的调整jvm的内存大小,可以充分利用服务器资源,让程序跑的更快

查看JVM的运行参数

运行java命令时打印参数

运行java命令时打印参数,需要添加-XX:+PrintFlagsFinal参数即可

java -XX:+PrintFlagsFinal -version

由于得到的信息国语庞大,这里就不展示了,我就直接说结果吧

  • 参数的值有boolean类型和数字类型

  • 值的操作符是 "=" 或 " :=" ,分表代表的是默认的值和被修改的值

查看正在运行的jvm参数

如果想要查看正在运行的jvm就需要借助于jinfo命令查看

编写一个程序,sleep 30秒,然后会有进程ID,可以拿到那个ID做测试

  • 查看所有的参数,用法:jinfo -flags <进程id>

  • 通过jps 或者 jps -l 查看java进程

  • 查看某一参数的值,用法:jinfo -flag <参数名> <进程id>

JIT使用以及优化

为什么要使用JIT

现在的流行的JVM产品,比如Hotspot都是既有编译器又有解析器

解释器与编译器特点:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行 ,在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。选择混合模式

  • 当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率

解析器的缺点:长期运行的时候,执行效率偏低

JIT编译器的缺点 :

  • 既然需要编译,那么编译后的代码使用空间换时间

  • 第一次编译花费的时间比起解释来说更长

为什么要有两个JIT

HotSpot虚拟机中内置了两个即时编译器(JIT):Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端运行模式和服务端运行模式

目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。至于程序使用哪个编译器,取决于虚拟机运行的模式

  • 对Client Compiler来说,它是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段

  • Server Compiler则是专门面向服务器端的,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器

JIT优化

公共子表达式的消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式,对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替E就可以了

公共子表达式分为以下两种:

  • 局部公共子表达式消除 :这种优化仅限于程序的基本块内

  • 全局公共子表达式消除 :这种优化范围涵盖了多个基本块

举个列子

int d = (cb)12+a+(a+b*c);

  • 如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码如下所示,是完全遵照Java源码的写法直译而成的

  • iload_2 // b
    imul // 计算b*c
    bipush 12 // 推入12
    imul // 计算(c*b)*12
    iload_1 // a
    iadd // 计算(c*b)*12+a
    iload_1 // a
    iload_2 // b
    iload_3 // c
    imul // 计算b*c
    iadd // 计算a+b*c
    iadd // 计算(c*b)*12+a+(a+b*c)
    istore 4

使用JIT优化:编译器检测到”cb“与”bc“是一样的表达式,而且在计算期间b与c的值是不变的。因此,这条表达式就可能被视为:int d = E*12+a+(a+E);

IT还有可能继续以下优化:代数化简(Algebraic Simplification),把表达式变为:int d = E13+a2;

表达式进行变换之后,再计算起来就可以节省一些时间了

方法内联

在使用JIT进行即时编译时,将方法调用直接使用方法体中的代码进行替换,这就是方法内联

它减少了方法调用过程中压栈与入栈的开销,同时为之后的一些优化手段提供条件

如果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;
}

方法逃逸以及分析

逃逸分析(Escape Analysis)是动态分析对象作用域的分析算法:方法之外,是否能使用这个对象

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上

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

逃逸分析包括 :

  • 全局变量赋值逃逸

  • 方法返回值逃逸

  • 实例引用发生逃逸

  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量

举个列子:

public class EscapeAnalysis {
    //全局变量
    public static Object object;
    
    public void globalVariableEscape(){//全局变量赋值逃逸
        object = new Object();
    } 
    public Object methodEscape(){ //方法返回值逃逸
        return new Object();
    } 
    public void instancePassEscape(){ //实例引用发生逃逸
        this.speak(this);
    } 
    public void speak(EscapeAnalysis escapeAnalysis){
        System.out.println("Escape Hello");
    }
}

使用方法逃逸的案例进行分析:

public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

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

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}
  • 不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法

使用逃逸分析,编译器可以对代码做如下优化 :

  • 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步

  • 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配

  • 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析 :

  • 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定 -XX:-DoEscapeAnalysis

  • -XX:+DoEscapeAnalysis : 表示开启逃逸分析
    -XX:-DoEscapeAnalysis : 表示关闭逃逸分析

对象的栈上内存分配

我们知道,在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对

  • JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈

举个了列子:使用for循环,在代码中创建100万个User对象

public class EscapeAnalysisTest {
    public static void main(String[] args) {
        long a1 = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            alloc();
        } 
        // 查看执行时间
        long a2 = System.currentTimeMillis();
        System.out.println("cost " + (a2 - a1) + " ms");
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    } 
    private static void alloc() {
        User user = new User();
    } 
    static class User {
    }
}
  • 我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化

我们制定以下JVM参数并运行:关闭逃逸分析看结果

  • -Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails 
    -XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象

  • 从jmap执行结果中我们可以看到,堆中共创建了100万个 StackAllocTest$User 实例

~ jps
2809 StackAllocTest
2810 Jps
~ jmap -histo 2809
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
  • 在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中

  • 也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的。即所有对象都分配到堆内存中

我们制定以下JVM参数并运行:开启逃逸分析看结果

  • -Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails 
    -XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象

  • 从打印结果中可以发现,开启了逃逸分析之后,在堆内存中只有8万多个StackAllocTest$User对象。 也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了8万

~ jps
709
2858 Launcher
2859 StackAllocTest
2860 Jps
~ jmap -histo 2859
num #instances #bytes class name
---------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class

总结:

除了以上通过jmap验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少

所以:是不是所有的对象和数组都会在堆内存分配空间?

  • 不一定,随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。但是这也并不是绝对的。就像我们前面看到的一样,在开启逃逸分析之后,也并不是所有User对象都没有在堆上分配

标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替

继续举个列子:这就是标量替换

//有一个类A
public class A{
    public int a=1;
    public int b=2
} 
//方法getAB使用类A里面的a,b
private void getAB(){
    A x = new A();
    x.a;
    x.b;
} 
//----------------分割线-------------------------
//JVM在编译的时候会直接编译成
private void getAB(){
    a = 1;
    b = 2;
} 

同步锁消除

同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁 在JIT编译时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销

public class TestLockEliminate {
    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    } 
    public static void main(String[] args) {
        long tsStart = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
        getString("TestLockEliminate ", "Suffix");
    } 
    System.out.println("一共耗费:" + (System.currentTimeMillis() -tsStart) + " ms");
    }
}
  • StringBuffer的append操作却需要执行同步操作,代码如下:

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

逃逸分析和锁消除分别可以使用参数 -XX:+DoEscapeAnalysis 和 -XX:+EliminateLocks (锁消除必须在-server模式下)开启。

使用如下参数运行上面的程序: 【不开启同步锁消除】

  • -XX:+DoEscapeAnalysis -XX:-EliminateLocks

    • (耗时240ms)

使用如下命令运行程序 :【开启同步锁消除】

  • -XX:+DoEscapeAnalysis -XX:+EliminateLocks

    • (耗时220毫秒)

 

.

 

 

posted @ 2019-01-29 20:51  鞋破露脚尖儿  阅读(1597)  评论(0编辑  收藏  举报