0%

Java虚拟机基本原理

JVM

Java虚拟机基本原理

image-20231114163800316

1.Java代码运行

机器码:CPU能理解的代码格式。机器码是一个个的字节。

C语言运行:编译为机器码,在机器上执行。为了理解机器码,可以用反汇编工具将机器码反编译为汇编代码。

Java语言运行的初始设计:设计一个面向Java语言的特性的虚拟机,将Java代码转化为虚拟机能识别的指令序列,即Java字节码(.class文件)。

Java虚拟机可以由硬件实现,但更为常见的是运行在现有平台windows、linux上。

虚拟机的好处:

  1. 跨平台
  2. 代码托管环境。
    1. 代码运行
    2. 无关业务逻辑,而代码需要的处理(内存管理,垃圾回收,数组越界,动态类型,安全权限)
  3. 利用运行信息优化代码执行效率。

Java虚拟机如何运行Java字节码

虚拟机主要区域划分:

  1. 方法区
  2. PC寄存器(程序计数器)
  3. Java方法栈
  4. 本地方法栈(native方法)

虚拟机的执行:

  1. 加载。将class文件加载到虚拟机中的方法区。
  2. 运行。
    1. 调用进入一个Java方法,Java虚拟机在当前线程的Java方法栈中生成一个栈帧,用来存放局部变量和字节码的操作数。
    2. 当退出当前执行的方法时,Java虚拟机弹出当前线程的当前栈帧,并舍弃。

字节码→机器码:

字节码无法直接在硬件上执行。是虚拟机可识别的操作码。类似于机器可识别的机器码。

字节码转化为机器码有两种方式:

  1. 解释执行。运行时,逐行解释为机器码。优点:无需等待编译。
  2. 即时编译(JIT)。将一个方法中包含的所有字节码编译为机器码后再执行。优点:峰值运行速度快。

大部分代码使用解释执行。执行次数多的代码会被即时编译先先编译为机器码,之后直接执行机器码。

优点:即时编译可以收集运行时信息,理论上,即时编译后的代码比C++运行速度更高。

Java7开始,HotSpot默认采用分层编译:热点方法先被C1编译,然后C1编译后的热点方法进一步被C2编译。

HotSpot 装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。

2.Java基本类型

Java之前的语言Smalltalk,万物皆对象

基本类型:工程上的考虑,速度和内存占用比引用类型的性能好

boolean。Java虚拟机使用1位int的数值0,1来表示 false,true。高位到地位的转换,使用掩码(去除高位)实现。

浮点值的运算

  1. +0.0F
  2. -0.0F
  3. NaN

占用内存:

  • 栈上64位机器,boolean,byte,char,short,int都占用8个字节
  • 堆上byte1个字节,char,short是2个字节,int8个字节

加载:

Java 虚拟机的算数运算几乎全部依赖于操作数栈。我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。

3.类加载过程

Java语言类型:

基本类型

引用类型

  1. 接口
  2. 数组类
  3. 泛型

基本类型是JVM直接定义好的。数组类由JVM直接生成。

类加载器加载的类型:类、接口

类字节流形式:.class文件,程序内部生成,网络字节流

  1. 加载

    读取各类字节流,用类加载器加载到JVM中。

    类加载器分类:

    1. 启动类加载器

      C++编写的bootstrap class loader。

    2. 扩展类加载器(Java9升级为平台类加载器)

    3. 应用类加载器

      加载应用程序路径下的类。主要指系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径

    4. 自定义类加载器

    双亲委派机制:优先用自己的父类加载器去加载类,无法找到时,再自己去加载

    类的唯一性是由类加载器实例以及类的全名一同确定的。

  2. 链接

    1. 验证

      验证类是否符合Java虚拟机规范

    2. 准备

      静态字段内存分配;实现虚方法的动态绑定的方法表

    3. 解析

      编译生成的类之间的符号引用改为实际引用

  3. 初始化

    静态字段、常量赋值。

    其他直接赋值操作,执行所有静态代码块中的代码

4-5.JVM如何执行方法

方法编译

  • 重载方法

    • 定义:Java语言:相同名称,不同参数

    • 重载方法的选择

      1. 不考虑自动拆装箱和可变长参数下选取重载方法
      2. 1中未找到,则可考虑拆装箱。
      3. 2中未找到,考虑拆装箱+可变长参数

      一个阶段中找到了多个方法,则选择一个形式参数类型最为贴切的方法。优先选择更明确的子类。

  • 重写方法

    • 定义:

      • JVM:相同名称,相同参数且返回值类型相同
    • 重写方法的选择

静态绑定和动态绑定

JVM如何识别方法:类名+方法名+方法描述符(参数类型+返回类型)

静态绑定的条件:私有实例方法、构造方法、静态方法、final修饰的方法、接口默认方法

方法调用的五种指令:

  1. invokestatic:静态方法
  2. invokespecial:私有实例方法、构造器、接口默认方法、使用super关键字调用父类的实例方法或者构造器
  3. invokevirtual:非私有实例方法
  4. invokeinterface:接口方法
  5. invokedynamic:动态方法

符号引用解析为实际引用:

方法指向类C。1,2,3指令的解析:

  1. 在类中查找符合名字和描述符的方法

  2. 在C的父类中继续搜索,直到Object类

  3. 在C所实现或间接实现的接口中搜索非私有、非静态方法,找到多个时,任意返回一个。

    C所实现或间接实现的接口???

虚方法

invokevirtual、invokeinterface

动态绑定:在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

需要动态绑定的方法:非final修饰的invokevirtual、invokeinterface

方法表

image-20231114103508468

每个类都有一个方法表,存储索引和当前类及其祖先类中非私有的实例方法(包括抽象方法)的对应关系。

对于重写的父类方法,子类的索引和父类的索引值相同。

解析符号引用是将符号值转化为方法表的索引值。(实际上不是索引值)

加载中第二步链接中的第二个阶段,使用方法表这种数据结构提供方法之间的引用的索引值。

动态绑定相对于静态绑定的额外操作

  1. 访问栈上的调用者
  2. 读取调用者的动态类型
  3. 读取该类型的方法表
  4. 读取方法表中某个索引值所对应的目标方法

内联缓存

用途:加快动态绑定。

原理:缓存虚方法调用中调用者的动态类型,以及该类型对应的方法。

单态缓存:只缓存了一种动态类型以及它所对应的目标方法。JVM使用的是单态缓存。

多态方法,遇到缓存中类型不匹配时,退化为不使用缓存,使用方法表找目标方法。

调用方法开销

内联缓存无法消除调用方法开销。方法内联可以消除

  1. 记录程序在方法中的位置。
  2. 创建栈帧,压入、弹出栈帧

6.异常

遇到异常时Java代码执行

try,catch,finaly

  1. try
  2. 挨个遍历catch
  3. finaly
  4. 重新抛出异常

异常分类

Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。
Exception,涵盖程序可能需要捕获并且处理的异常。

Throwable

Exception error

RunException

JVM中遇到异常的执行

每个类的方法编译时,都会构建异常表。每捕获一个异常,会有一条信息。

image-20231114162239034

遇到异常时,JVM从上到下遍历该方法的异常表,匹配到相应类型时,程序运行交给此类型对应的字节码;未找到时,弹出刚方法的栈帧,接着遍历当前方法的异常表

try-with-resources

好处:

  1. 简化关闭资源。
  2. 自动添加了supressed异常(避免原异常消失)。

7.反射

反射方法使用

1
2
3
Class<?> aclass = Class.forName("Reflect");
Method method = aclass.getMethod("test",int.class);
method.invoke(null, 0);

反射场景

接口访问的日志记录,我使用了AOP,里边用到了反射的invoke方法

idea中代码可用字段提示。

spring依赖反转的实现

反射实现与性能

  • 具体实现:

    • 本地实现:

      native,使用了c++。先调用委派实现,然后调用本地实现,然后进入目标方法

      • 优点:是第一次使用动态实现的3-4倍。
    • 动态实现:

      先生成字节码,然后直接调用目标方法。

      • 优点:生成字节码后,速度是本地实现的20倍。动态实现无需经过Java到C++再到Java的切换
  • 委派实现:为了在本地实现和动态实现之间动态切换,在本地实现、动态实现上而封装的。

性能差距:1.3→6.7

指定动态实现:-Dsun.reflect.inflationThreshold= 来调整。默认值15

反射调用不使用本地实现机制:-Dsun.reflect.noInflation=true

修改存储动态实现个数:-XX:TypeProfileWidth。默认值2

影响反射性能

  1. 方法内联,目标方法是否可内联到当前方法中
  2. 拆装箱
  3. 变量逃逸分析,是否可优化为栈上分配
  4. 权限检查。method.setAccessible(true); // 关闭权限检查

8-9 JVM执行方法中的invokedynamic指令

方法句柄

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MethodHandleTest {

public void testMethodHandle(Object o) {
System.out.println("testMethodHandle");
}

public static void main(String[] args) throws Throwable {
MethodHandles.Lookup l = MethodHandles.lookup();

MethodType methodType = MethodType.methodType(void.class, Object.class);

MethodHandle methodHandle = l.findVirtual(MethodHandleTest.class, "testMethodHandle", methodType);

methodHandle.invokeExact(new MethodHandleTest(), new Object());

}
}

介绍

方法句柄是一个强类型的,能够被直接执行的引用 [2]。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。

invokeExact:Java 编译器会根据所传入参数的声明类型来生成方法描述符。

invoke:自动适配参数类型

原理

方法句柄没有运行时权限检查,因此,应用程序需要负责方法句柄的管理。

方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量。

调用invokedynamic指令的实现

java7引入invokedynamic。

每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。

将调用点(CallSite)是一个抽象的 Java 类,原本由 Java 虚拟机控制的方法调用以及方法链接暴露给了应用程序控制。

lamada实现&性能

将函数式接口内容生成静态函数,然后用invokedynamic指令调用。

lamada也会被方法内联和逃逸分析优化。

性能基本没差别。

10Java对象的内存使用

对象内容

  • 隐藏的对象头

    • 标记字段

      占用8个字节

      • 锁信息
      • 哈希码
      • GC信息
    • 类型指针

      指向该对象的类。

      64位机器,默认使用压缩指针,占用4个字节。

      起始位置默认8字节对齐,可以标记 2的32次方 * 8 个 = 32G内存的地址。-XX:ObjectAlignmentInBytes,默认值为 8

  • 字段

    • 父类所有字段
    • 自身所有字段

对象占用内存举例

64位机器,32G内存,默认8字节对齐

Integer,对象头占用12字节,int属性占用4个字节。共占用16个字节

字段重排列

优点:节省对齐可能浪费的空间

规则:

  1. 如果一个字段占用C字节,那么该字段的偏移量需要对齐至对象起始地址的NC。

    Long类型的long字段,对象占了12字节,但long字段需要从16字节开始,中间空4个字节。

  2. 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。

  3. 使用了压缩指针的64位机器,子类第一个字段对齐至4N;关闭压缩指针,子类第一个字段对齐至8N。

11-12垃圾回收

堆空间消亡对象的内存回收,回收后用于新对象。

垃圾识别

引用技术法

缺点:循环引用的对象,即使是真的垃圾,也识别不了。

可达性分析

  • 介绍:除了roots对象可达的对象外,其他对象为垃圾对象。

  • roots对象种类

    可从堆外指向堆内的堆外对象

    • 栈帧里的局部变量
    • 已加载类的静态变量
    • JNI handles
    • 未停止多线程中的对象
stop-the-wold和安全点
  • stop-the-wold

    • 介绍:垃圾回收线程等待所有线程进入安全点,再执行标记和清理。
    • 原因:可达性分析对象时,对象的引用有可能被修改,有未标记为可达,却在清理时被修改为可达,这时被当成垃圾回收就出问题了。所以可以被修改的地方,需要暂停下来,等标记清理完成后,再继续执行。
  • 安全点:使堆栈不会被修改的机制。

    检测的位置接收到要求停留在安全点时,挂起当前线程。

    • 使用JNI调用本地方法时:入口处进行安全点检测
    • 解释执行字节码
    • 执行即时编译器生成的机器码:生产机器码时,子啊生成代码的方法出口和非计数循环插入安全点检测
    • 线程阻塞

垃圾清除

方式:

  • 直接清除

    • 缺点:内存碎片,总内存够,但无法分配
  • 压缩(整理)

    即把存活的对象聚集到内存区域的起始位置

    • 缺点:压缩算法的性能开销
  • 复制

    把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

    • 缺点:堆空间利用率低。

垃圾分代

程序中对象实际生存时间、次数不同。

根据存在时间,次数将对象分为年轻代、老年代。根据不同代可以使用不同的回收算法加快回收。比如:年轻代使用复制算法;老年代使用清除、压缩算法。

年轻代:存在时间很短、使用次数很少就消亡。比如:一次列表查询,返回给前端的实体对象。

老年代:存在时间很长、使用次数很多。比如:数据库配置类生成的对象,程序运行期间一直被使用。

年轻代回收

年轻代空间划分

初始值 E(8) :S(2(From(1):To (1))。程序运行期间,JVM根据对象生成速度,自动调整E 和 S区大小。

可设置参数固定E区 和 S区大小。

默认对象复制超过15次,或者年轻代单个Survivor区空间占用大于50%时,复制次数多的会升级为老年代。

对象申请空间

堆空间线程共享,但不能让两个线程的私有对象共用相同的内存,即避免线程内存竞争。

  1. 首次new 对象时,线程向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
  2. 接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。
  3. 如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

卡表

  • 原因

    老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。可以避免扫描整个老年代。

  • 实现

    将整个堆划分为每个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。有指向新生代对象引用的卡标识为脏卡。

    在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

    然后复制存活对象到另一个S区,并更新指向该对象的引用,更新引用的同时,设置引用所在的卡的标识位为脏卡。

缓存行实现

byte数组实现,64B的缓存行。可以加载64张卡,即32KB内存。任意线程对缓存行的引用更新操作,都会让整个缓存行中的内容写回主存。

13Java内存模型

乱序执行种类:

  • 即时编译器重排序
  • 处理器乱序执行
  • 内存系统重排序

即时编译器对代码不影响单线程结果的乱序优化:

  1. 延迟加载数据到寄存器,减少栈空间占用时间

    可能在 b = 1之后再将a加载到寄存器中

    1
    2
    3
    4
    5
    int a = 0,b =0;
    public void test{
    int z1 = a;
    b = 1;
    }
  2. 无意义语句移出循环

代码顺序

后边的代码依赖(读或写)到了前边的代码时,代码的顺序性规定。

如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。

  • 单线程代码的顺序按语法从上至下
  • 解锁在加锁之前
  • volatile字段的写操作在此字段的读操作之前
  • 线程的启动操作在该线程的第一个操作之前
  • 线程的最后一个操作在线程终止之前
  • A线程对B线程的中断操作在B线程接收中断时间之前
  • 构造器的最后一个操作在析构器之前。
  • happens-before具有传递性

内存模型的实现

Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。

内存屏障种类:读读、读写、写写、写读

即时编译器会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。有内存屏障,即时编译器不会重排序。

处理器:x86_64架构内存屏障:只有写读。对与x86_64架构机器,只有写读屏障会替换为具体的指令。

JVM使用的指令是lock add DWORD PTR [rsp],0x0。即强制刷新处理器的写缓存到主内存。主内存写操作后无效化其他处理器对此数据所在缓存行的数据。

锁、volatile,final

在解锁时,Java 虚拟机需要强制刷新缓存。

volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。适合读多写少。

volatile标记的字段不会被分配到寄存器中,每次加载到寄存器计算需要去内存读取。

sychronized实现

010e6380ccee4b20e70ebf3474eef600

偏向锁→轻量级锁(自旋)→重量级锁

线性执行:所有线程大部分时间使用同一资源的情况。如:数据库插入大量数据,使用MQ打散拆分时,需要用顺序MQ线性执行。

重量级锁保证共享变量的同步。针对多个线程同时竞争同一把锁的情况。

轻量级锁,针对线程少量竞争同一把锁的情况。避免很快能拿到锁,却执行了耗资源的操作系统线程阻塞和唤醒操作

偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。

sychronized编译

锁对象编译生成的字节码包含monitorenter 指令以及多个 monitorexit 指令。

方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机进行 monitorexit 操作。

重量级锁

为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

与线程阻塞相比,自旋状态的线程处于运行状况,只不过跑的是无用指令。可能会浪费大量的处理器资源。

轻量级锁

加锁操作

  1. Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
  2. Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。
    1. 如果是轻量级锁的状态标记,替换为锁记录地址,获取到轻量级锁;
    2. 如果不是
      1. 该线程重复获取同一把锁。将锁记录清零,以代表该锁被重复获取。
      2. 其他线程持有该锁,膨胀为重量级锁,阻塞当前线程。

解锁操作

  1. 当前锁记录(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录)的值为 0,则代表重复进入同一把锁,直接返回。
  2. Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。
    1. 如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
    2. 如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。

偏向锁

撤销

当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java 虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。

类偏向锁失效&撤销

  • 失效介绍

    如果某一类锁对象的总撤销数超过了一个阈值20(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。

  • 失效&撤销实现

    在每个类中维护一个 epoch 值,当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。

    在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。

    为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。

  • 撤销介绍

    如果总撤销数超过阈值40(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。

16-17即时编译

五种编译

  1. 解释执行

  2. 不带profiling的C1编译

    终态,不会再被别的编译

  3. 带少量profiling(方法调用次数和循环回边计数)的C1编译。

    比全量profiling的C1编译快30%

  4. 全量profiling的C1编译

  5. C2编译

    比C1编译快30%

    终态,不会再被别的编译

编译的选择

image-20231117191235574

编译的配置

关闭分层编译,会直接使用C2编译。

在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。

Graal 编译 todo

Java10引入

profilling收集种类

  1. 方法调用次数
  2. 循环回边次数
  3. 分支跳转
  4. instance类型判断
  5. 非私有实例方法调用指令、强制类型转换 checkcast 指令
  6. 引用类型的数组存储 aastore 指令

优化类别

if else 剪枝。一直跳转一个分支,会只编译一个分支,遇到判断相同时,跳转到此条件。剪枝还会联动其他优化,如:方法内联。

instanceof 剪枝

优化失败处理

通过预埋陷阱,检测到执行了未优化的分支时,回退到解释执行

instanceof 判断

如果 instanceof 的目标类型是 final 类型,那么 Java 虚拟机仅需比较测试对象的动态类型是否为该 final 类型。

如果目标类型不是 final 类型,比如说 Exception,那么 Java 虚拟机需要从测试对象的动态类型开始,依次测试该类,该类的父类、祖先类,该类所直接实现或者间接实现的接口是否与目标类型一致。

18即时编译器的中间表达形式

编译器分为前端和后端。

​ 前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式,也就是 IR(Intermediate Representation )。

​ 后端会对 IR 进行优化,然后生成目标代码。

Java字节码不适合做IR,因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR(每个变量只能被赋值一次)。

即时编译器会将 Java 字节码转换成 SSA IR。

SSA IR可以使用工具表达为图。

如果是为了可读性高,无需优化:

1
2
3
4
5
6
7
x1=4*1024 经过常量折叠后变为 x1=4096

x1=4; y1=x1 经过常量传播后变为 x1=4; y1=4

y1=x1*3 经过强度削减后变为 y1=(x1<<1)+x1

if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为 y1=1

19Java字节码

基于栈的计算模型

每当为 Java 方法分配栈桢时,Java 虚拟机需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。

执行每一条指令之前,Java 虚拟机会提前将指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。

栈{

​ 栈帧{

​ 操作数栈,

​ 局部变量数组[]

​ }

}

操作数栈

局部变量表

字节码程序可以将计算的结果缓存在局部变量区之中。

依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。

long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元。

Java字节码

操作数栈字节码

1
2
pop // 弹出(舍弃)栈顶元素
dup // 复制栈顶元素。常用于复制 new 指令所生成的未经初始化的引用。

变量加载&存储字节码

1
2
3
4
5
6
7
8
9
10
11
iload // 加载int类型 
fload // 加载float类型
aload // 加载引用类型
istore // 存储int类型

iaload // 加载int[]
astore_1 [o] // 加载局部变量数组下标为1,名称为o的Object对象

iconst // 加载-1至5之间的int值
lconst // 加载0,1的long值
idc // 加载常量池中的常量值

方法调用字节码

1
2
3
4
5
invokevirtual
invokestatic
invokespecial
invokedynamic
invokeinterface

返回字节码

1
2
3
4
5
return //无返回值
ireturn // 返回int
athrow

iflt 6 // 控制流指令均附带一个或者多个字节码偏移量,代表需要跳转到的位置。

计算字节码

1
2
iadd // 加法,消耗栈顶的两个元素。
iinc M N // 直接作用于局部变量区的指令是 (M 为非负整数,N 为整数)。该指令指的是将局部变量数组的第 M 个单元中的 int 值增加 N,常用于 for 循环中自增量的更新。还有自增、自减

Java 字节码

1
2
3
4
5
6
7
8
9
10
new 目标类
newarray 目标类
anewarray 目标类 // 新建引用类型数组
multianewarray // 新建多维数组
instanceof 目标类
checkcast 目标类
monitorenter // 为栈顶对象加锁
monitorexit // 为栈顶对象解锁
getstatic // 静态字段访问
getfield // 实例字段访问

20-21方法内联

介绍

没有方法内联,调用一个方法前需要当前方法执行位置,为新方法创建并压入栈帧,访问字段,弹出栈帧,恢复当前方法执行。

方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。

每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。

内联越多,生成代码的执行效率越高。内联越多,编译时间越长,程序达到峰值性能的时刻将被推迟。

方法内联规则

  • 会内联

    1. 自动拆箱总会被内联
    2. -XX:CompileCommand 中的 inline 指令指定的方法
  • 不会内联

    1. 调用字节码对应的符号引用未被解析
    2. 目标方法所在的类未被初始化
    3. 目标方法是 native 方法
    4. Throwable 类的方法不能被其他类中的方法所内联
    5. C2 不支持内联超过 9 层的调用(可以通过虚拟机参数 -XX:MaxInlineLevel 调整),
    6. C2 不支持1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel 调整)。
    7. 由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法
  • JVM方法内联参数

    1
    2
    -XX:ReservedCodeCacheSize //编译生成的机器码会被部署到 Code Cache 之中。Code Cache 已满,即时编译已被关闭的警告信息(CodeCache is full. Compiler has been disabled)

    image-20231121164318808

去虚化规则

  1. 类型

    List list = new ArrayList();

    list.add(1);// 明确类型是 ArrayList,可以将ArrayList.add()方法进行内联

  2. 类层次分析

    假设只有一种调用。其他可能性增加陷阱,命中陷阱,去优化。

  3. 增加条件去虚化

    动态调用改为明确的多个if {}else{}调用来去虚化

22 虚拟机的intrinsic

使用高效的CPU指令替代本地实现。

Java9开始比较多。

intrinsic的本地方法也能够被方法内联。

23 逃逸分析

优化内容:

  • 锁消除

  • 标量替换

    方法内联后,没有逃逸出当前方法的对象。会使用标量替换来分配对象。没有使用栈上分配。

    标量替换将原本对对象的字段的访问,替换为一个个局部变量的访问。

    标量替换可能分配在栈上,也可能直接分配在寄存器上。标量替换分配的对像的一个个字段不再连续。

  • if条件部分逃逸分析(Graal编译器实现)

堆上分配:堆上的内容对任何线程都是可见的。Java 虚拟机需要对所分配的堆内存进行管理。

32JNI

场景

Java 语言较难表达,甚至是无法表达的应用场景。

  1. 我们希望使用汇编语言(如 X86_64 的 SIMD 指令)来提升关键代码的性能;
  2. 我们希望调用 Java 核心类库无法提供的,某个体系架构或者操作系统特有的功能。

Java中native修饰的方法.

实现

在调用 native 方法前,Java 虚拟机需要将该 native 方法链接至对应的 C 函数上。

链接方式一:Java 虚拟机自动查找符合默认命名规范的 C 函数,并且链接起来。

  1. javac -h . org/example/Test.java命令,将在当前文件夹(对应-h后面跟着的.)生成名为org_example_Test.h的头文件
  2. 编写.c文件,实现.h的方法
  3. 编译.c文件

总结

系统预热作用

缓存加载,C2分层编译执行,对象到老年代,偏向锁失效