JVM之JIT&逃逸分析(java 逃逸对象)

createh51个月前 (02-01)技术教程12

Java程序运行过程:Java 源代码--->编译器--->jvm 可执行的 Java 字节码(.class)--->jvm--->jvm 中解释器--->机器可执行的二进制机器码---->程序运行。(class文件用十六进制编辑器打开前4个字节CAFEBABE就是magic word,JVM识别.class文件的魔数。所有java class文件都以这4个字节开头的。咖啡宝贝~)

字节码:Java 源代码经过虚拟机编译器编译后产生的文件(即扩展为.class 的文件),它不面向任何特定的处理器,只面向虚拟机。

采用字节码的好处:Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且由于字节码并不专对一种特定的机器,因此Java 程序无须重新编译便可在多种不同的计算机上运行。

字节序:指多字节数据在计算机内存中存储或网络传输时各字节的存储顺序。有小端和大端两组方式。

  • 小端:低位字节存放在内存的低地址端,高位字节存放在内存的高地址端。
  • 大端:高位字节存放在内存的低地址端,低位字节存放在内存的高地址端。

Java 语言的字节序是大端。

解释器分:字节码解释器和模板解释器。

字节码解释器:将Java字节码解释成C++代码(java代码->java字节码->C++代码),再编译成底层的硬编码给CPU执行。性能较低,早期JDK只有字节码解释器。

模板解释器:(触发即时编译)将Java字节码直接编译成硬编码。是JIT的一部分。

模板解释器下次执行时不会再做字节码解释器的解释过程,直接执行(即时编译器编译好的)硬编码。(所以运行效率比字节码解释器高

运行模式

根据底层使用不同的解释器,JVM有3种运行模式:解释模式、编译模式、混合模式。

1)-Xint纯字节码解释器;2)-Xcomp纯模板解释器;3)-Xmixed字节码解释器+模板解释器(JVM默认模式

解释模式:-Xint 纯字节码解释器

①不经过JIT直接由解释器Interpreter解释执行所有字节码。

②特点:启动快(不需要编译),执行慢。

③可通过-Xint参数指定为纯解释模式。

编译模式:-Xcomp 纯模板解释器

①不加筛选的将全部代码编译成机器码,不考虑其执行的频率是否有编译的价值。

②特点:启动慢(编译过程较慢),执行快。

③可通过-Xcomp参数指定为纯编译模式。

混合模式:-Xmixed 字节码解释器+模板解释器

为什么JVM默认采用-Xmixed模式而不是-Xcomp?

  • Xcomp会先将程序全部编译生成硬编码再运行。如果程序比较大,初次运行时会需要很长时间编译;
  • Xmixed会随着程序运行收集数据来做更深层次的优化。

性能比较: -Xmixed ≈ -Xcomp > -Xint

-Xmixed和-Xcomp的性能比较与具体程序大小有关。因为-Xcomp是将全部代码编译以后才执行。

①如果程序很小,-Xcomp性能较高,因为初期编译时间短;

②如果程序较大,-Xmixed性能较高,因为可以马上执行程序,运行一段时间以后触发即时编译后再将热点代码缓存起来。

JIT

JIT(Just In Time),当代码执行次数超过一定的阈值时,会将java字节码转换为本地代码,如主要的热点代码会被转换为本地代码,这样有利于大幅度提高java应用的性能。即时编译器生成热点代码供模板解释器运行。

JIT编译器称为后端编译器,与javac编译器不同。 因为javac编译的是java文件,JIT编译的是字节码文件。

现在有4种即时编译器:C1、C2、混合编译、GraalVM(jdk14后)。

1) C1编译器: client模式下的即时编译器。

可通过命令java -version查看当前jvm处于client还是server模式。64位机只有server模式。32位机可通过java -client -version指定为client模式。

①触发条件相对C2较宽松--需要收集的数据较少;

②编译优化较浅(e.g. 基本运算在编译时运算掉;e.g. String+final String的编译优化);

③生成的代码执行效率较C2低;

2) C2编译器: server模式下的即时编译器。

①触发条件较严格 -- 一般来说程序运行了一段时间后才会触发;

②优化比较深(e.g. 会分析程序中有无堆栈操作,将不需要的堆栈操作优化掉);

③生成的代码执行效率较C1高。

C2编译优化e.g. 删去多余堆栈操作编码,程序也能正常运行。

3) 混合编译: 程序运行初期触发C1编译器,运行一段时间后触发C2编译器。

即时编译的最小单位是代码块(e.g. for/while循环),最大单位是方法。即时编译的触发条件:底层判断循环/方法执行次数达到N次就会触发即时编译;Client模式下,N默认值为1500;Server模式下,N默认值为10000。

java -XX:+PrintFlagsFinal -version | grep CompileThreshold 可查看触发条件CompileThreshold的N值。

热度衰减:已执行过若干次数过后如果有一段时间没有执行,已执行次数会倍速减少,未来要达到即时编译条件需要更多执行次数。E.g. 某方法已被调用7000次,本应再调用3001次就会触发即时编译,但没调用后次数会两倍速,减少到3500次时要达到触发条件需要再执行6501次。

e.g. 阿里早年的一个故障:热机切冷机故障

因为业务增加需要在集群里加节点(用于均衡负载),涉及到热机切换冷机。出现问题:将流量/压力切换到冷机上时冷机马上挂掉。冷机:刚运行程序不久; 热机:运行程序有一段时间。

热点代码:编译好的硬编码。(从机器的角度叫硬编码,在JVM中称为热点代码)

原因:1) 热机上有热点代码缓存,扛的并发更大。马上切换到冷机上时,冷机上没有热点代码缓存,并发达不到;2) 冷机上程序一边运行一边触发即时编译,CPU扛不住。

解决方案:先切少量流量到冷机上,等冷机上程序运行一段时间触发即时编译,再逐渐切换流量。

热点代码缓存区Codecache,存放即时编译生成的热点代码,位于方法区(所以基本不会出现OOM)。

源码 /openjdk/hotspot/src/share/vm/code/codeCache.hpp

命令 java -XX:+PrintFlagsFinal -version | grep CodeCache 查看热点代码缓存区大小。

initialCodeCacheSize: 初始大小;ReservedCodeCacheSize: 最大大小,这两个值也是需要调优的地方。(调优参数:initialCodeCacheSize和ReservedCodeCacheSize,调优时一般调为一样大。)

Server编译器模式下热点代码缓存大小起始于2496KB,Client编译器模式下热点代码缓存大小起始于160KB。对于较长时间没被调用的热点代码,Codecache会按照LRU自动清理

即时编译的线程调优,可通过参数-XX:CICompilerCount=N调优。

java -XX:+PrintFlagsFinal -version | grep CICompilerCount查看CICompilerCount为即时编译线程数量。

即时编译器运行类似队列,JVM中很多系统性的操作(e.g. GC,即时编译)都是通过VM_THREAD(JVM的系统线程)触发的。

当某个代码块执行N次达到触发即时编译的条件时会经历以下步骤:(System.gc的调用同理)

①执行引擎将这个即时编译任务写入队列QUEUE;

②VM_THREAD从这个队列中读取任务execution并运行;(所以即时编译是异步的)

AOT(ahead of time):是提前把代码编译成机器码的一种编译技术。这样直接颠覆了Java正常的编译过程,而是首次编译时即把Java代码编译成机器码,跳过了字节码这个中间环节,可想而知,当程序运行时,直接运行机器码性能要提高很多,但这样的做法直接跳过了字节码,显然是丢失了一些通用性。静态编译非常慢,启动非常快,在静态编译期间将源代码转换成和具体平台相关的本地代码。

JIT(just in time):编译技术是在通常的编译过程之上做了增强,JVM会根据运行过程中代码执行的热点情况,把一些热点代码提前编译成机器码,等下次执行这些热点代码的时候,就不用实时编译成机器码了,而是直接运行机器码即可,这样就提高了Java的运行速度。静态编译一般很快,静态编译和JIT没多大关系,启动慢,热点代码在运行期间动态转换成和具体平台相关的本地代码。(大多数Java应用程序越跑越快)

内联逃逸分析是对 Java 很重要的优化算法。

内联(Inlining)

内联优化是 Java JIT 编译器非常重要的一种优化策略。简单地说,内联就是把被调用的方法的方法体,在调用的地方展开。这样做最大的好处,就是省去了函数调用的开销。对于频繁调用的函数,内联优化能大大提高程序的性能。执行内联优化是有一定条件的。第一,被内联的方法要是热点方法;第二,被内联的方法不能太大,否则编译后的目标代码量就会膨胀得比较厉害。

逃逸分析

对象一定分配在堆中吗? 不一定的。

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。其实,在编译期间,JIT 会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。

逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗的讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。那些逃不出方法的对象会在栈上分配。

通俗点讲,当一个对象被 new 出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。除此之外,如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种就被称为线程逃逸

逃逸是一种现象,分析是一种技术手段。逃逸:如果对象的作用域是局部的(仅创建线程可见)则不是逃逸,其它(外部线程可见)都是逃逸。E.g. 共享变量、返回值、参数、static变量。可理解为逃逸到方法外/线程外。

public class EscapeAnalysis {
  public static Object globalVariableObject; 
  public Object instanceObject;
  public void globalVariableEscape() { 
    globalVariableObject = new Object(); 
  } // 静态变量,逃逸
  public void instanceObjectEscape() { 
    instanceObject = new Object(); 
  } // 共享变量,逃逸
  public Object returnObjectEscape() { 
    return new Object(); 
  } // 返回值,逃逸


  public void noEscape1() {
    synchronized(new Object()) { //不是逃逸
        System.out.println(“hello”);
    }
  }


  public void noEscape2() { 
    Object noEscape = new Object(); 
  } //局部变量,不是逃逸


  public static void main(String[] args) {
    EscapeAnalysis analysis = new EscapeAnalysis();
      …
  }
}

基于逃逸分析JVM开发了三种优化技术:标量替换、锁消除、栈上分配。

开发优化技术原因:如果对象发生了逃逸,情况就会变得非常复杂(外部可能对该对象进行改变、重新赋值等),优化无法实施。所以这些优化措施都是在逃逸分析的基础(确定对象没有发生逃逸)上进行的。

从JDK1.7开始默认是开启逃逸分析的。 调节参数:-XX:+/-DoEscapeAnalysis;+开启,-关闭。

标量替换:把对象分解成一个个基本类型,并且内存分配不再是分配在堆上而是分配在栈上。好处:1、减少内存使用,因为不用生成对象头。2、程序内存回收效率高,并且GC频率会减少。

标量:不可再分;e.g. java中的基本数据类型;聚合量:可再分;e.g. 对象

/* Position中的xyz为标量,在外部不会被修改。position对象没有发生逃逸。
  JVM逃逸分析后会将test()中的标量position.x, position.y, position.z直接替换为1、2、3,提高程序效率。 */
public class ScalarReplace {
  …
  public static void test() {
    Position position = new Position(1, 2, 3);
    System.out.println(position.x); //标量替换后:System.out.println(1);
    System.out.println(position.y); //标量替换后:System.out.println(2);
    System.out.println(position.z); //标量替换后:System.out.println(3);
  }


  class Position {
    int x;
    int y;
    int z;
    public Position(int x, int y, int z) {
      this.x = x;
      this.y = y;
      this.z = z;
    }
  }
}

锁消除: 逃逸分析发现上锁的对象为局部变量,将锁消除来优化。

public void noEscape1() {
  synchronized (new Object()) { 
      System.out.println(“hello”);
  }
}
//----优化后----
public void noEscape1() {
  System.out.println(“hello”);
}

栈上分配: 逃逸分析如果是开启的,有些对象会在虚拟机栈上分配(相对传统观念中对象在堆区分配)。可降低垃圾收集器运行的频率。

如何证明栈上分配存在?(在不发生GC的前提下)生成一个对象100w次,然后看堆区是否有100w个该对象,如果没有则说明存在栈上分配。

public class StackAlloc {
  public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++)
      alloc();
    long end = System.currentTimeMillis();
    System.out.println((end-start)+” ms”);
    while (true);
  }


  public static void alloc() { StackAlloc obj = new StackAlloc(); }


}