JVM 学习(二)Java 内存模型、方法内联、逃逸 --- 2019年4月

时间:2021-07-18 06:06:37

1、Java 的内存模型

  定义了 happens-before,如果同一个线程中,字节码的先后顺序,后者观测了前者的运行结果,那么就会按顺序执行。

  Java 线程之间的通信由 Java 内存模型控制。

  Java 内存模型通过定义了一系列的 happens-before 操作,不同线程的操作之间的内存可见性。

  happens-before 关系还具备传递性。

  解决数据竞争问题的关键在于构造一个跨线程的 happens-before 关系,操作A happens-before 操作B,是的操作A 之前的字节码结果对操作B 之后的字节码可见。

  Java 内存模型的底层实现

  Java 内存模型是通过内存屏障来禁止重排序的。写读操作才会用具体的指令来代替,可以理解为强制刷新处理器的写缓存,让当前线程所修改的内存对其他线程可见。写缓存是处理器用来加速内存存储效率的一项技术。

  例如 volatile 字段,内存写操作会无效其他处理器所持有、指向同一地址的缓存行,所以其他处理器可以立即见到该 volatile 字段的最新值。

  锁、volatile字段、final字段与安全发布

  锁操作也具备 happens-before 关系。解锁之后才能对同一把锁进行加锁。但是如果证明某把锁仅被同一线程持有,那么它可以移除响应的加锁解锁操作。例如加了 synchornized 关键字就不会强制刷新内存。

  volatile 可以看做是一个轻量级、不保证原子性的同步,性能优于锁操作。但是频繁的访问 volatile 字段也会因为不短强制刷新缓存而严重影响程序的性能。当然是写操作才会影响,所以实际情况中使用 volatiel 字段尽量多读少写。并且即时编译器无法将其分配到寄存器,只能存在内存中。

  final 字段的写操作后插入一个写写屏障,防止某些优化将新建对象的发布重排序至 final 字段的写操作之前。

  新建对象的安全发布,不仅仅包括 final 实例字段的可见性,还包括其他实例字段的可见性。

  扩展知识

    java 内存模型,围绕 顺序一致性内存模型。

    Java 的内存模型主要是为了方便线程之间的通信,抽象出来了线程的本地内存和共享的主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 开发者提供内存可见性的保证。

    执行程序时为了提高性能,编译器和处理器尝尝会对指令做重排序。Java 内存模型就用内存屏障来禁止重排序。(禁止屏障,针对特定类型的处理器重排序)

    as-if-serial 语义把单线程保护了起来,重排序不会改变线程执行的结果。

    重量级锁到自旋锁,自旋锁是空着等待锁,而不是阻塞等待。

    偏向锁 --- 轻量级锁 --- 重量级锁:

2、Java 语法糖与 Java 编译器

  泛型与类型擦除

  Java 泛型的类型擦除,指的是 Java 程序的泛型信息,在 JVM 里全部都丢失了。这样做是为了兼容引入泛型之前的代码。一般泛型擦除后类型会变为 Object 类,但是也可能是当前继承的类的*类。

  泛型主要是为了判断程序中语法是否正确。

  Java 语义与 Java 字节码中关于重写的定义不一致,因为 Java 编译器会成圣桥接方法作为适配器。

  其他语法糖

  变长参数、try-with-resources 以及 catch 代码块中捕获多种异常等语法糖。

3、即时编译

  通常而言,代码会先被 JVM 解释执行,之后反复执行的热点代码,则会被即时编译成机器码,直接运行在底层硬件之上。

  1)、分层编译模式

  结合了C1的启动性能优势和C2的峰值性能优势。通常情况下,C2机器码比C1机器码的执行效率高出30%以上。如果C1、C2编译了方法,并且未失效,那么 JVM 是不会再次发出该方法的编译请求。

  Java 8 默认开启了分层编译。

  2)、即时编译的触发

  JVM 会将编译线程按照1:2的比例分配给C1和C2,启动分层编译的阈值是动态调整的。

  3)、OSR 编译

  决定一个方法是否为热点代码的因素有两个,方法的调用次数、循环回边的执行次数(例如 for 循环的次数),即时编译就是根绝这两个计数器的和来触发。

  JVM 还存在一种以循环为单位的即时编辑,即OSR编译,循环回边计数器就是用来出发这种类型的编译的。

  ORS:程序执行过程中,动态替换掉 Java 方法栈帧,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。默认情况C1的 OSR 编译的阈值为13500,C2为10700.

  4)、Profiling

  profiling 收集反应程序执行状态的数据,用于编译器优化。

  分支 profile 和 类型 profile 的收集将会给应用程序带来不小的性能开销。一般情况下我们不会再解释执行过程中收集分支 profile 以及类型 profile。

  C2 可以根据收集得到的数据进行猜测,假设接下来的执行同样会安装收集的 profile 进行,从而做出比较激进的优化。

4、字节码

  1)、操作数栈

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

  执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。

  执行指令时,JVM 会将该指令所需的操作数栈弹出,并且将指令的结果重新压入栈中。

  有几条指令直接作用在操作数栈上的。最常见的 dup:复制栈顶元素,pop:舍弃栈顶元素

  dup 常用于复制 new 指令生成的 未经初始化的引用,例如 Object o = new Object();

  正常情况下,操作数栈的压入弹出都是一条条指令完成,除了抛异常,JVM 直接清除操作数栈上的所有内容,然后将异常实例压入操作数栈上。

  2)、局部变量区

  Java 方法栈帧的另一个重要组成部分就是局部变量区,字节码程序可以将计算结果缓存在局部变量区之中。

  3)、字节码的各种指令

  包括了常数指令(iconst、bipush、sipush、Iconst等)、加载指令和存储指令(iload、istore int类型)

  数组加载、存储指令(iaload、laload和iastore、lastore等)、返回指令(ireturn、lreturn等)。

5、方法内联

  方法内联:在编译过程中遇到方法调用时,将目标方法的方法纳入编译范围之中,并取代原方法调用的优化手段。(类似株连)

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

  内联越多,生成代码的执行效率越高。 然而对于即时编译器来说,内联越多,编译时间也就越场,而程序达到峰值的时刻也将被推迟。也会导致生成的机器码越长。

  即时编译器可以在解析过程中替换方法调用字节码,也可以在 IR 图中替换方法调用 IR 节点。

  内联规则:

  由@Forcelnline 注解的方法(仅限于 JDK 内部方法)会被强制内联。

  调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。

  C2 不支持内联超过9层的调用,以及1层的直接递归调用。

  总体来说,即时编译器中的内联算法,更青睐于小方法。

  动态绑定

  即时编译器需要先对虚方法调用进行去虚化,即转换位一个或多个直接调用,然后进入方法内联。

  去虚化分为完全去虚化(基于类型推导、基于类型层次分析)、条件去虚化。

6、逃逸分析

  逃逸:新建的对象,被存入堆中(静态字段或者堆中对象的实例字段),如果对象存入堆中,其他线程就能获得该对象的引用。(局部对象实例,逃出方法)

  或者对象被传入未知代码中,即时编译器无法确认该方法调用,会不会将调用者所传入的参数转存储至堆中。可以认为方法调用的调用者以及参数是逃逸的。

  基于逃逸分析的优化

  锁消除:即时编译器能够证明对象不逃逸,对该对象的加锁、解锁操作没有意义。因为其他线程对该对象无法操作,因此也不能够对其加锁、解锁。这种情况,即时编译器就可以消除对该不逃逸锁对象的加锁、解锁操作。

  逃逸分析的结果,多被用于新建对象操作转换成栈上分配或者标量替换。

  栈上分配:当逃逸分析能证明新建对象不逃逸,JVM 就可以将该对象分配到栈上,在 new 语句所在的方法退出时,通过弹出当前方法的栈帧来自动回收所分配的内存空间。(这样就无须借助垃圾回收器来处理不再被引用的对象)

  但是 HotSpot 虚拟机并没有采用栈上分配的技术,而是采用了标量替换技术。

  标量替换:将原本对对象的字段的访问,替换成一个个局部变量的访问。这些字段没有分配实际内存,和栈上分配一样,甚至可以直接存在寄存器中,不需要内存空间。