很详细的java字节码 jvm学习笔记(一)

字节码相关概念

java虚拟机有两个重要特性,平台无关性和语言无关性,两者的基础都是字节码。字节码(Byte Code)是java虚拟机支持的指令格式,其本质是二进制字节流,包括并不限于.class文件,jar包,动态产生等形式,java虚拟机在运行时会读取字节流并加载。平台无关性的基础是虚拟机可运行在不同的平台与操作系统上,例如linux,android等,字节码需要通过java虚拟机编译或解释为相应平台的机器码后,再由操作系统执行机器码。语言无关性的基础是虚拟机只基于字节码而无关源代码的语言,如Kotlin java等。本文主要涉及hotspots虚拟机,概念性知识将在具体内容中简单介绍。

字节码简介

先简单介绍字节码结构,代码如下

public class test1 {
    int x = 5;
    final int y = 6;
    static int z = 7;
    static final int n = 8;
    int test(Integer num){
        int a;
        a=1;
        a=x;
        a=y;
        a=z;
        a=n;
        return a;
    }
}

在这里插入图片描述
上图所示二进制字节流即为字节码。下面主要使用javap -v 指令以及jcclasslib插件来查看字节码,为了方便理解阅读,与上面看到的二进制字节流的数据结构顺序有一定不同。

基础信息

  Compiled from "test1.java"
  public class com.byteCodes.test1
  minor version: 0
  major version: 52
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #4                          // com/byteCodes/test1
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 4, methods: 3, attributes: 1

包含了大小版本号,访问标志,本类与父类全限定类名,字段方法属性的数量等,#数字 代表指向常量池的引用

常量

Constant pool:
   #1 = Methodref          #6.#31         // java/lang/Object."<init>":()V
   #2 = Fieldref           #4.#32         // com/byteCodes/test1.x:I
   #3 = Fieldref           #4.#33         // com/byteCodes/test1.y:I
   #4 = Class              #34            // com/byteCodes/test1
   #5 = Fieldref           #4.#35         // com/byteCodes/test1.z:I
   #6 = Class              #36            // java/lang/Object
   #7 = Utf8               x
   #8 = Utf8               I
   #9 = Utf8               y
  #10 = Utf8               ConstantValue
  #11 = Integer            6
  #12 = Utf8               z
  #13 = Utf8               n
  #14 = Integer            8
  #15 = Utf8               <init>
  #16 = Utf8               ()V
  #17 = Utf8               Code
  #18 = Utf8               LineNumberTable
  #19 = Utf8               LocalVariableTable
  #20 = Utf8               this
  #21 = Utf8               Lcom/byteCodes/test1;
  #22 = Utf8               test
  #23 = Utf8               (Ljava/lang/Integer;)I
  #24 = Utf8               num
  #25 = Utf8               Ljava/lang/Integer;
  #26 = Utf8               c
  #27 = Utf8               a
  #28 = Utf8               <clinit>
  #29 = Utf8               SourceFile
  #30 = Utf8               test1.java
  #31 = NameAndType        #15:#16        // "<init>":()V
  #32 = NameAndType        #7:#8          // x:I
  #33 = NameAndType        #9:#8          // y:I
  #34 = Utf8               com/byteCodes/test1
  #35 = NameAndType        #12:#8         // z:I
  #36 = Utf8               java/lang/Object

常量池主要包含了字面量(Literal)和符号引用(Symbolic References),其中字面量包含了字符串以及各种基本类型的常量,符号引用则是指向了字面量,比如#4指向了#34的utf8字符串字面量,代表类的全限定类名。由于字节码是静态的,而真正的类对象需要在虚拟机运行时生成,所以字节码的常量池中无法获取到实际的内存地址,而是暂时通过全限定名做一个"符号引用"的标记,后续在虚拟机加载时,再通过符号引用来解析获取到实际的运行时内存地址,把符号引用转化为指向目标内存地址的直接引用。

字段

  int x;
    descriptor: I
    flags: (0x0000)

  final int y;
    descriptor: I
    flags: (0x0010) ACC_FINAL
    ConstantValue: int 6

  static int z;
    descriptor: I
    flags: (0x0008) ACC_STATIC

  static final int n;
    descriptor: I
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: int 8

字段表包含了 字段名,类型标识符,访问标志,属性表,可以看到final修饰的字段会有ConstantValue这一项额外的属性

方法

  public com.byteCodes.test1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_5
         6: putfield      #2                  // Field x:I
         9: aload_0
        10: bipush        6
        12: putfield      #3                  // Field y:I
        15: return
      LineNumberTable:
        line 7: 0
        line 8: 4
        line 9: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Lcom/byteCodes/test1;

  int test(java.lang.Integer);
    descriptor: (Ljava/lang/Integer;)I
    flags: (0x0000)
    Code:
      stack=1, locals=3, args_size=2
         0: iconst_1
         1: istore_2
         2: aload_0
         3: getfield      #2                  // Field x:I
         6: istore_2
         7: bipush        6
         9: istore_2
        10: getstatic     #5                  // Field z:I
        13: istore_2
        14: bipush        8
        16: istore_2
        17: iload_2
        18: ireturn
      LineNumberTable:
        line 14: 0
        line 15: 2
        line 16: 7
        line 17: 10
        line 18: 14
        line 19: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  this   Lcom/byteCodes/test1;
            0      19     1   num   Ljava/lang/Integer;
            2      17     2     a   I

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        7
         2: putstatic     #5                  // Field z:I
         5: return
      LineNumberTable:
        line 10: 0

方法表包含了,方法名,参数,返回值,限定符flags,指令Code,操作数栈深度stack,局部变量槽数量locals,参数数量args_size,本地变量表(LocalVariableTable) 行数对照关系表(LineNumberTable) 等

指令与本地变量

指令包含操作码与操作数,如12: bipush 8首先是指令的行数为12,bipush是操作码,为虚拟机要执行的操作,8是操作数,为操作码的作用对象。jvm的指令基本都是面向栈的,故该指令意为将 8 推至当前方法的操作数栈的栈顶。
本地变量表是暂时存放临时数据的地方,类似于cpu的寄存器。 例如14: istore_2意为将栈顶数据出栈保存到2号本地变量处。可以看到该指令没有操作数,只有操作码,对于一些数字较小的操作数,有许多指令将其写死到了操作码中,例如对于-1 0 12345等简单int的赋值不会使用到bipush指令,而是使用iconst,它们的效果是一样的,。点击如下链接查看全部指令

默认生成内容

可以看到该类中虽然我只写了一个方法,但实际上方法表中有三个方法,包括了普通方法test,构造方法test1,静态方法{},这是因为jvm会自动生成许多内容,例如上述的各种类基础信息,以及一个默认构造函数。可以看到,对于明明没有参数以及变量的默认构造函数,局部变量槽数量locals与参数数量args_size为什么仍为1呢?观察局部变量表,可以发现第一位为一个Name为this的变量,这是由于任何方法都会引入一个自身对象this。相应的static是静态的,没有实例对象,所以没有this,因此locals和args_size都为0

相关知识研究

下面通过字节码研究一下java的一些知识

final与static

对于final和stiatic,基础的功能省略,通过这段代码来看看他俩在字节码层面上的差别。首先是常量表,可以看到对于这四个变量的定义

    int x = 5;
    final int y = 6;
    static int z = 7;
    static final int n = 8;

常量表中只存在其中y n的值6,8,而x,z的值 5,7都不存在常量表中

  #11 = Integer            6
  #14 = Integer            8

然后是字段表,发现也只有y n有constantValue这个属性

  final int y;
    descriptor: I
    flags: (0x0010) ACC_FINAL
    ConstantValue: int 6
  static final int n;
    descriptor: I
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: int 8

那么constantValue是什么呢?在官方文档里我们可以找到

A variable of primitive type or type String, that is final and initialized with a compile-time constant expression , is called a constant variable.
一个final修饰的基本类型或String类型的变量,会在编译阶段完成初始化,被称作constantValue

首先确定一个前提,我们可以放在编译阶段完成的任务当然不会放到运行阶段再去处理。也即是说,final修饰的字面量类型数据,由于确定不会在运行阶段被修改,于是就直接在编译字节码时完成初始化了,在虚拟机运行时就不用再初始化了,而是可以直接使用。

然后再看方法表,首先是初始化方法init,逐行进行解释,查看全部指令

  public com.byteCodes.test1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_5
         6: putfield      #2                  // Field x:I
         9: aload_0
        10: bipush        6
        12: putfield      #3                  // Field y:I
        15: return
      LineNumberTable:
        line 7: 0
        line 8: 4
        line 9: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Lcom/byteCodes/test1;

方法名

  public com.byteCodes.test1();

参数与返回值,参数为空,返回值为V指void

    descriptor: ()V

栈深度为2,本地变量与参数都为1个this

      stack=2, locals=1, args_size=1

读取0号本地变量到栈顶,即this

         0: aload_0

invokespecial,非虚方法调用,与之对应的是虚方法调用,后面再提到,此处是调用父类Object的初始化方法init完成初始化。

         1: invokespecial #1                  // Method java/lang/Object."<init>":()V

依次将this,字面量5推至栈顶

         4: aload_0
         5: iconst_5

翻看上面的常量池,可以看到2号常量是一个对字段test1.x的符号引用

   #2 = Fieldref           #4.#31         // com/byteCodes/test1.x:I

在虚拟机运行时,该符号引用已经被解析为了对应实例对象的直接引用(内存地址),putfield的操作就是把栈顶的字面量5保存在对应地址的对象字段test1.x中。紧接着是相同的操作将final y赋值为6。

         6: putfield      #2                  // Field x:I

有朋友可能会疑惑,既然我编译完成后字节码中已经有constant value的值了,那为什么不把它直接写进final y里呢?既然也在运行时才在构造函数里赋值,那么final加不加有什么区别呢?这是因为实例对象和类对象都必须在java虚拟机运行后才会产生,没有运行时对象都不存在,当然就没有办法为对象的字段赋值

接下来我们看静态方法,跟上面的方法很类似就不具体解释了,区别在于,静态的方法是在类初始化时执行的,该过程在对象构造方法之前进行,所以没有了对象相关的操作,putstatic方法对应putfield方法,同样是通过解析后的直接引用,将栈顶的值赋给类字段Test1.z。注意此处只初始化了Test1.z为7,而没有初始化同样为static的Test1.n

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        7
         2: putstatic     #5                  // Field z:I
         5: return
      LineNumberTable:
        line 10: 0

最后是方法test,我们看看不同的变量赋值的区别

    int test(Integer num){
        int a;
        a=1;
        a=x;
        a=y;
        a=z;
        a=n;
        return a;
    }
  int test(java.lang.Integer);
    descriptor: (Ljava/lang/Integer;)I
    flags: (0x0000)
    Code:
      stack=1, locals=3, args_size=2
         0: iconst_1
         1: istore_2
         2: aload_0
         3: getfield      #2                  // Field x:I
         6: istore_2
         7: bipush        6
         9: istore_2
        10: getstatic     #5                  // Field z:I
        13: istore_2
        14: bipush        8
        16: istore_2
        17: iload_2
        18: ireturn
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  this   Lcom/byteCodes/test1;
            0      19     1   num   Ljava/lang/Integer;
            2      17     2     a   I

首先是a=1,先将1推至栈顶,然后再出栈保存到变量a所在的本地变量表槽位(Slot)2中

         0: iconst_1
         1: istore_2

然后是a=x,先将this对象推至栈顶,通过直接引用获取之前初始化好的x变量的值,然后出栈保存

         2: aload_0
         3: getfield      #2                  // Field x:I
         6: istore_2

然后是a=y,可以看到根本没有获取对象字段y,而是直接将6推至栈顶后保存

         7: bipush        6
         9: istore_2

然后是a=z,同样没有获取对象的操作,而是通过直接引用,直接获取了类变量z的值并保存。

        10: getstatic     #5                  // Field z:I
        13: istore_2

最后是a=n,可以看到与a=1是一模一样的,直接推至栈顶并保存。

        14: bipush        8
        16: istore_2

总结一下,final关键字修饰的字面量由于不会被修改,可以省略了获取对象并通过引用获取字段值的过程,直接在编译阶段把所有用到的地方先写好,但是还是有对象初始化的赋值过程。static关键字使用时省略了获取对象,但仍需要初始化。而static+final,则既不需要初始化使用也不需要获取对象,使用跟a=1这样的常量是一模一样的。这就是为什么常量推荐使用static final来定义,从性能规范上来讲都是最好的

调用方法

上面在test1类的构造方法中有使用到invokespecial这个操作码,而操作数是Object类的init的符号引用,以此来调用Object类的初始化方法init。大家知道如果没有明确指定那么所有的类父类都是Object。实际上我们一共有五个方法调用的操作码

invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)这也是Java语言中最常见的方法分派方式。
invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic指令:用于调用类静态方法(static方法)。
invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

上述五种方法可以简单分为两种,一种是编译时可以确定的非虚方法,包括使用
invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法,以及被final修饰的方法(即使是invokevirtual)。另一种是运行时才能确定的虚方法。而非虚方法可以在编译时轻易的触发一个非常牛b的优化技术,内联(inline)。有朋友可能要问了,字节码不是编译结果吗?还搁这编译?因为这里提到的是字节码编译为机器码的过程。

jit编译器

之前提到,java平台无关性的基础是jvm可以将字节码在不同的平台都编译为相应使用环境的机器码。而jit编译器就是其中很重要的一环。

解释和编译

在这里需要先提一下解释型语言和编译型语言这两个概念,不知道的伙伴可以自行搜索,这里简单提一下,例如C为编译型语言,特点是先编译在直接执行,执行很快,而python,js是解释型语言,正常使用不需要编译,每解释一行运行一行,可以在控制台里实时敲代码运行,所以运行效率会比较慢。而java是综合型的,在源码到字节码阶段是纯编译,此时优化并不多,在字节码到机器码阶段是编译+解释,大头的优化都在字节码编译到机器码这个过程。当然出于优化考虑,编译和解释是有一定规则的。

hotspot虚拟机

对于我们常用的hotspot的虚拟机,它采用了分层编译的策略,动态的决定编译和解释的占比。并且这个过程没有绝对的先后之分,不是先编译再解释这样,而是动态交替进行的。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括
·第0层,程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
·第1层,使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能
·第2层,仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能
·第3层,仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
·第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

顺便提一嘴,hotspot虚拟机的名字就是来自于它的技术,热点代码探测(Hot Spot)

HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译(On-StackReplacement,OSR)。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。

简单粗略不求甚解的说,虚拟机运行时会为方法和循环体维护计数器,当方法调用和循环时会增加计数器的值。当一定时间内值超过限制,就会触发编译,编译完成后下一次再执行代码不需要解释执行而是直接运行机器码了。

内联与优化

回到非虚方法调用的优化操作内联上,代码如下所示

    static int nei(int a){
        int b=2;
        return a+b;
    }
    int test(){
        int a=2;
        a = nei(a);
        return a;
    }

经过内联后的代码

    int test(){
        int a=2;
        int b=2;
        a = a+b;
        return a;
    }

没错就是这么简单,其实它跟C中的内联inline是一样的,即把目标方法的内容"移"到自己方法上,从而省略了调用方法的开销。但是由于java不能自己手动指定内联,只能通过虚拟机自动优化,故只有指向明确唯一的非虚方法才可以,虚方法由于不能明确是否有继承多态等情况导致不会自动内联(实际上虚方法也有可能内联但是会有额外消耗和检测)除此之外还有许多限制,例如调用次数少于MaxtrivalSize不会内联,编译后机器码大于InlineSmallCode不会内联,字节码大于MaxInlineSize不会内联,为什么会有这么多限制呢?这是因为被内联的方法会编译成机器码放入代码缓存(code cache)中,而代码缓存存放在内存中区域有限,所以不能无节制使用,当然以上参数和代码缓存区大小都是可调的,可以作为jvm调优的一个思路。说了那么多限制,那么内联有啥用呢?我所知主要作用有两个,第一是省略了方法调用过程,二是方便后续别的优化。

方法调用过程

首先既然提到方法调用过程,也就绕不开jvm,在此简单提一下,详细内容请查阅资料。
我们可以简单的把虚拟机结构划分为两个部分,一个是线程共有的部分,包括了类对象,实例对象,类变量等,另一个部分是线程私有的部分,也就是虚拟机栈。栈由栈帧构成。如下图所示在这里插入图片描述
虚拟机栈的结构是栈(废话),其中的每一个栈帧都代表该线程的一个方法调用,栈顶的栈帧代表着当前方法调用,每当调用方法时都将生成一个栈帧推至栈顶,例如刚才的invokespecial init调用父类构造方法,在生成栈帧的过程中会为其分配空间,如图所示,栈帧包括:

  • 局部变量表 也即刚才字节码中的方法表的最后一部分,包含了变量名,位置,变量的作用域等,须知其中的每一个槽位slot是可复用的,如果有作用域结束的有可能会有别的变量复用空出的slot,其大小由stack的值指定,类似寄存器
    Code:
      stack=1, locals=3, args_size=2
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      19     0  this   Lcom/byteCodes/test1;
            0      19     1   num   Ljava/lang/Integer;
            2      17     2     a   I
  • 操作栈 也就是刚才的那些操作码所操作的栈,基本上所有的操作码都是对栈顶的数据进行出栈-cpu-入栈这样的操作,其深度由locals的值指定
  • 动态链接,包含一个指向运行时数据常量中的该栈帧所属方法的引用,这个引用就是为了实现动态链接,对于常量池中的对方法的符号引用,如果是非虚方法,则可以在类加载过程中解析为直接引用,或者一些在第一次运行时解析,这种称为静态解析,而对于另一些方法,会在每一次运行时转化为直接引用,称为动态链接
  • 返回地址,方法正常退出,即出栈时,给到pc计数器的地址,存储了下一条要执行的指令,如果有返回值则会将其推至上一栈帧的操作栈栈顶,从而实现一次方法调用+返回的过程。

因此我们可以知道,通过内联的方式跳过方法调用,实际上可以节省掉整个分配内存,建立栈帧,销毁栈帧,返回的过程,节省了巨大的开销

后续的优化

内联优化另一个重要目的是方便后续的优化,许多代码不经过内联无法做出进一步的优化,因为当它们分处两个方法中时缺乏联系,编译器无法进行有效的分析,对于上述内联后的代码,

    int test(){
        int a=2;
        int b=2;
        a = a+b;
        return a;
    }

首先可以消除公共子表达式

    int test(){
        int a=2;
        int b=a;
        a = a+b;
        return a;
    }

然后进行复写传播

    int test(){
        int a=2;
        a = a+a;
        return a;
    }

可以看到相比于最开始的代码,确实优化了很多,反映到机器码上,从需要调用方法获取内存,到两三行的计算和寄存器交互就能搞定了,当然口说无凭,我们再来研究一下实际上编译的机器码是不是这么一回事

汇编

跟二进制流的字节码文件类似,机器码我们人类当然是看不懂的,所以得借用到hsdis-amd64.dll这个插件,放入jdk\jre\bin\server||client中,就可以通过运行时添加虚拟机指令来打印汇编了,详细内容可以上网搜索。我们这里需要以下几个指令

  • -XX:+UnlockDiagnosticVMOptions 必须打开这个选项才能调试
  • -Xcomp 强制jvm走编译模式,否则可能会解释执行
  • -XX:-TieredCompilation 关闭分层编译,否则会输出一大堆中间内容
  • -XX:CompileCommand=inline,*Test3.*强制内联,否则会受到上述内联条件限制
  • -XX:CompileCommand=compileonly,*Test3.*仅编译指定类文件,否则会把虚拟机运行的所有方法都编译打印出来
  • -XX:+PrintAssembly 打印汇编
    下面仅截取test方法部分,未内联前是这样的
  0x0000020864753180: mov    %eax,-0x6000(%rsp)
  0x0000020864753187: push   %rbp
  0x0000020864753188: sub    $0x10,%rsp         ;*synchronization entry
                                                ; - com.byteCodes.Test3::test@-1 (line 13)
  0x000002086475318c: mov    $0x2,%edx
  0x0000020864753191: xchg   %ax,%ax
  0x0000020864753193: callq  0x0000020864726620  ; OopMap{off=56}
                                                ;*invokestatic nei
                                                ; - com.byteCodes.Test3::test@3 (line 14)
                                                ;   {static_call}
  0x0000020864753198: add    $0x10,%rsp
  0x000002086475319c: pop    %rbp
  0x000002086475319d: test   %eax,-0x18c31a3(%rip)        # 0x0000020862e90000
                                                ;   {poll_return}
  0x00000208647531a3: retq                      ;*invokestatic nei
                                                ; - com.byteCodes.Test3::test@3 (line 14)
  0x00000208647531a4: mov    %rax,%rdx
  0x00000208647531a7: add    $0x10,%rsp
  0x00000208647531ab: pop    %rbp
  0x00000208647531ac: jmpq   0x00000208647520e0  ;   {runtime_call}
  0x00000208647531b1: hlt    
  0x00000208647531b2: hlt    
  0x00000208647531b3: hlt    
  0x00000208647531b4: hlt    
  0x00000208647531b5: hlt    
  0x00000208647531b6: hlt    
  0x00000208647531b7: hlt    
  0x00000208647531b8: hlt    
  0x00000208647531b9: hlt    
  0x00000208647531ba: hlt    
  0x00000208647531bb: hlt    
  0x00000208647531bc: hlt    
  0x00000208647531bd: hlt    
  0x00000208647531be: hlt    
  0x00000208647531bf: hlt       

内联后则是这样

  0x000002ccf82531e0: sub    $0x18,%rsp
  0x000002ccf82531e7: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - com.byteCodes.Test3::test@-1 (line 13)

  0x000002ccf82531ec: mov    $0x4,%eax
  0x000002ccf82531f1: add    $0x10,%rsp
  0x000002ccf82531f5: pop    %rbp
  0x000002ccf82531f6: test   %eax,-0xa31fc(%rip)        # 0x000002ccf81b0000
                                                ;   {poll_return}
  0x000002ccf82531fc: retq   
  0x000002ccf82531fd: hlt    
  0x000002ccf82531fe: hlt    
  0x000002ccf82531ff: hlt    

汇编码与字节码类似,最左边的一大串地址就是指令在内存中的地址,紧接着是操作码,再然后是1-2个操作数,0x开头的是地址的引用,%开头的是寄存器,具体操作码需要自行百度,根据平台不同有所不同。可以看到未内联前指令非常多,并且有callq这个操作码,后面有写是*invokestatic nei,对函数进行了调用,而内联后指令大幅缩短,调用指令也没有了,可以看出内联确实效果非常好,指令的量不到原先的三分之一,也没有了方法调用的过程。

虚方法内联

内联效果这么好,但是只能由非虚方法触发,问题是我们平时写代码,除了工具类会使用static,其他大部分时候方法都是非static的啊,怎么办呢。实际上虚拟机确实是有相关处理的,也就是去虚化(devirtualize),简单介绍一下。

Profiling

Profiling是jit在运行时对系统进行的分析,并将分析数据保存为profile data,通过分析推测脑补可以让虚拟机获得更好的性能,可参考这篇文章

完全去虚化

完全去虚化通过一种叫做类型继承关系分析(class hierarchy analysis)(CHA)的技术,通过profilng,推测出调用者的类型,从而确定唯一的目标方法,如果能够成功确定到调用的方法是唯一的,那么即使是invokevirtual也能成功去虚化内联。

条件去虚化

如果调用者有重载多态继承之类的,无法完全去虚化,那么只能条件去虚化,这种方法更为复杂一点,原文在这,除了该文,google百度全网都没找到官方文档,相关内容也少得可怜,找到的可以踢我一下

总结

内联相关内容就到此为止了,总的来说我们尽量使用非虚方法,使用虚方法如非必要也尽量使用有唯一指定的,会有较大的优化

访问静态变量直接使用类名

由于第一个学习的语言是python,导致我之前有一个不好的习惯,就是非常喜欢用this,现在来研究一下this的问题,代码如下

public class Test2 {
    static int a;
    static void test(){
        System.out.println(a);
    }
    void test1(){
        this.a = 1;
        this.test();
    }
    void test2(){
        Test2.a = 1;
        Test2.test();
    }
    void test3(){
        a = 1;
        test();
    }

    public static void main(String[] args) {
        Test2 t = new Test2();
        t.test1();
        t.test2();
        t.test3();
    }
}

可以看到三种写法都是没问题的,输出正常都是1,那么区别呢?字节码来力

 0 aload_0
 1 pop
 2 iconst_1
 3 putstatic #3 <com/byteCodes/Test2.a : I>
 6 aload_0
 7 pop
 8 invokestatic #5 <com/byteCodes/Test2.test : ()V>
11 return
0 iconst_1
1 putstatic #3 <com/byteCodes/Test2.a : I>
4 invokestatic #5 <com/byteCodes/Test2.test : ()V>
7 return

可以看到test23字节码是相同的,可以看到12主要的区别在于,由于1是引用的this,所以会多一个加载this对象的过程,再通过对象获取到static字段,而23不用,并且既然是引用类变量或者方法,那么理应直接通过类来使用,用this既不直观也不好。
plus:如果是非static,那么31也是一样的,所以用3的形式是最简单方便的。
plus+:曾经遇到一个问题,spring cloud 的项目,有个spring注解一直不起作用,后来发现问题是由于,spring必须通过容器装载对象才能加载到注解,所以即便是同一个类调用方法,也不能直接this调用,必须通过@Resource @autowire注解获取bean,然后再用bean调用,这样注解就正常生效了。

循环内外定义变量

不知道大家有没有听说过,或者是自己有习惯(我),喜欢在循环外部定义变量,网上能搜到不同的说法,有的说会有复数个对象产生,有的实测运行速度,今天我们来通过字节码和汇编来看看到底有什么区别

    int a = 0;
    int test1(){
        for (int i = 0; i < 12; i++) {
            Integer x = new Integer(i);
            a+=x;
        }
        return a;
    }
    int test2(){
        Integer x;
        for (int i = 0; i < 12; i++) {
            x = new Integer(i);
            a+=x;
        }
        return a;
    }
    void test3(){
        for (int i = 0; i < 11; i++) {
            int x = i;
            a+=x;
        }
    }
    void test4(){
        int x;
        for (int i = 0; i < 11; i++) {
            x = i;
            a+=x;
        }
    }

直接放结论,无论是字节码层面还是汇编层面,1与2,3与4两种写法没有任何区别,也就是说,无论是对象还是基础类型,在循环内外定义变量使没有任何区别的,所以大家怎么舒服怎么来吧。下面是1 3的字节码,可以看到代码量虽然不多但是编译为字节码还是不少的,至于机器码一个方法一百多行就不贴了,有兴趣可以自己弄来看看
test1

 0 iconst_0
 1 istore_1
 2 iload_1
 3 bipush 12
 5 if_icmpge 36 (+31)
 8 new #3 <java/lang/Integer>
11 dup
12 iload_1
13 invokespecial #4 <java/lang/Integer.<init> : (I)V>
16 astore_2
17 aload_0
18 dup
19 getfield #2 <com/byteCodes/Test4.a : I>
22 aload_2
23 invokevirtual #5 <java/lang/Integer.intValue : ()I>
26 iadd
27 putfield #2 <com/byteCodes/Test4.a : I>
30 iinc 1 by 1
33 goto 2 (-31)
36 aload_0
37 getfield #2 <com/byteCodes/Test4.a : I>
40 ireturn

test3

 0 iconst_0
 1 istore_1
 2 iload_1
 3 bipush 11
 5 if_icmpge 26 (+21)
 8 iload_1
 9 istore_2
10 aload_0
11 dup
12 getfield #2 <com/byteCodes/Test4.a : I>
15 iload_2
16 iadd
17 putfield #2 <com/byteCodes/Test4.a : I>
20 iinc 1 by 1
23 goto 2 (-21)
26 return

尽量减少对变量的重复计算

跟刚才非常相似的一个场景,代码如下

    void test1(){
        int x = list.size();
        for (int i = 0; i < x; i++) {}
    }
    void test2(){
        for (int i = 0; i < list.size(); i++) {}
    }

这不是跟上一个差不多吗,应该是一样的吧?来上字节码
test1

 0 aload_0
 1 getfield #5 <com/byteCodes/Test5.list : Ljava/util/List;>
 4 invokeinterface #6 <java/util/List.size : ()I> count 1
 9 istore_1
10 iconst_0
11 istore_2
12 iload_2
13 iload_1
14 if_icmpge 23 (+9)
17 iinc 2 by 1
20 goto 12 (-8)
23 return

test2

 0 iconst_0
 1 istore_1
 2 iload_1
 3 aload_0
 4 getfield #5 <com/byteCodes/Test5.list : Ljava/util/List;>
 7 invokeinterface #6 <java/util/List.size : ()I> count 1
12 if_icmpge 21 (+9)
15 iinc 1 by 1
18 goto 2 (-16)
21 return

乍一看好像差不多,该有的都有,谁也不比谁好多少,甚至test1还多出来了两行,但是仔细看,test2的循环goto到2,循环体内有aload_0,getfield,invoke的装载变量获取字段调用方法的list.size过程,而test1的循环goto到12,list.size在循环体之外,上面说过调用方法的开销是很大的,需要开辟内存新建栈帧,所以我们得出结论test1不需要重复调用方法,完胜(机器码差距更大,同样太长不贴了,有兴趣自己跑跑)

字符串拼接使用 StringBuilder

曾经听大佬说,+就是用的stringBuilder,但是如果在循环体内使用+会额外创建对象,非常的不好,那么现在来看一下

public class Test6 {
    String test1(){
        String a = "";
        for (int i = 0; i < 10; i++) {
            a += "a";
        }
        return a;
    }
    String test2(){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10; i++) {
            sb.append("a");
        }
        return sb.toString();
    }
}

直接上字节码,test1

 0 ldc #2
 2 astore_1
 3 iconst_0
 4 istore_2
 5 iload_2
 6 bipush 10
 8 if_icmpge 37 (+29)
11 new #3 <java/lang/StringBuilder>
14 dup
15 invokespecial #4 <java/lang/StringBuilder.<init> : ()V>
18 aload_1
19 invokevirtual #5 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
22 ldc #6 <a>
24 invokevirtual #5 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
27 invokevirtual #7 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
30 astore_1
31 iinc 2 by 1
34 goto 5 (-29)
37 aload_1
38 areturn

test

 0 new #3 <java/lang/StringBuilder>
 3 dup
 4 invokespecial #4 <java/lang/StringBuilder.<init> : ()V>
 7 astore_1
 8 iconst_0
 9 istore_2
10 iload_2
11 bipush 10
13 if_icmpge 29 (+16)
16 aload_1
17 ldc #6 <a>
19 invokevirtual #5 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
22 pop
23 iinc 2 by 1
26 goto 10 (-16)
29 aload_1
30 invokevirtual #7 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
33 areturn

可以看到大佬所说不错,test1循环体内不仅会新建对象,而且还会有多达3次虚方法调用和1次非虚方法调用,而test2中不仅没有新建对象,还仅有一次虚方法调用,非常的不错,Stringbuilder完胜。

暂时没了 未完待续

参考文献:深入理解Java虚拟机,互联网

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值