深入理解Java虚拟机四

一、Java内存模型

1.Java内存模型与happens-before关系

       为了让应用程序能够免于数据竞争的干扰,Java 5引入了明确定义的Java内存模型。其中最为重要的一个概念便是happens-before关系 。happens-before关系是用来描述两个操作的内存可见性的。如果操作X happens-before操作Y,那么X的结果对于Y可见。在同一个线程中,字节码的先后顺序(program order)也暗含了happens-before关系:在程序控制流路径中靠前的字节码happens- before靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。除了线程内的happens-before关系之外,Java内存模型还定义了下述线程间的happens-before关系。

  • 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
  • volatile字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
  • 线程的启动操作(即Thread.starts()) happens-before 该线程的第一个操作。
  • 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过Thread.isAlive()或Thread.join()判断该线程是否中止)。
  • 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的InterruptedException异常,或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用)。
  • 构造器中的最后一个操作 happens-before 析构器的第一个操作。
  • happens-before关系还具备传递性。如果操作X happens-before操作Y,而操作Y happens-before操作Z,那么操作X happens-before操作Z。

2.Java内存模型的底层实现

       Java内存模型是通过内存屏障(memory barrier)来禁止重排序的。对于即时编译器来说,它会针对前面提到的每一个happens-before关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。这些内存屏障会限制即时编译器的重排序操作。以volatile字段访问为例,所插入的内存屏障将不允许volatile字段写操作之前的内存访问被重排序至其之后;也将不允许volatile字段读操作之后的内存访问被重排序至其之前。然后,即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。以我们日常接触的X86_64架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。举例来说,对于volatile字段,即时编译器将在volatile字段的读写操作前后各插入一些内存屏障。然而,在X86_64架构上,只有volatile字段写操作之后的写读内存屏障需要用具体指令来替代。(HotSpot所选取的具体指令是lock add DWORD PTR [rsp],0x0,而非mfence。)该具体指令的效果,可以简单理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。强制刷新写缓存,将使得当前线程写入volatile字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该volatile字段的最新值。

3.锁、volatile、final与安全发布

       volatile字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问volatile字段也会因为不断地强制刷新缓存而严重影响程序的性能。在X86_64平台上,只有volatile字段的写操作会强制刷新缓存。因此,理想情况下对volatile字段的使用应当多读少写,并且应当只有一个线程进行写操作。volatile字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile字段的每次访问均需要直接从内存中读写。final实例字段则涉及新建对象的发布问题。当一个对象包含final实例字段时,我们希望其他线程只能看到已初始化的final实例字段。因此,即时编译器会在final字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至final字段的写操作之前。在X86_64平台上,写写屏障是空操作。新建对象的安全发布(safe publication)问题不仅仅包括final实例字段的可见性,还包括其他实例字段的可见性。当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。

4.synchronized的实现

       在Java程序中,我们可以利用synchronized关键字来对程序进行加锁。它既可以用来声明一个synchronized代码块,也可以直接标记静态方法或者实例方法。当声明synchronized代码块时,编译而成的字节码将包含monitorenter和monitorexit指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是synchronized关键字括号里的引用),作为所要加锁解锁的锁对象。

public void foo(Object lock) {
        synchronized (lock) {
          lock.hashCode();
        }
      }
      // 上面的Java代码将编译为下面的字节码
      public void foo(java.lang.Object);
        Code:
           0: aload_1
           1: dup
           2: astore_2
           3: monitorenter
           4: aload_1
           5: invokevirtual java/lang/Object.hashCode:()I
           8: pop
           9: aload_2
          10: monitorexit
          11: goto          19
          14: astore_3
          15: aload_2
          16: monitorexit
          17: aload_3
          18: athrow
          19: return
        Exception table:
           from    to  target type
               4    11    14   any
              14    17    14   any

       当用synchronized标记方法时,你会看到字节码中方法的访问标记包括ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java虚拟机需要进行monitorenter操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java虚拟机均需要进行monitorexit操作。

public synchronized void foo(Object lock) {
        lock.hashCode();
      }
      // 上面的Java代码将编译为下面的字节码
      public synchronized void foo(java.lang.Object);
        descriptor: (Ljava/lang/Object;)V
        flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=1, locals=2, args_size=2
             0: aload_1
             1: invokevirtual java/lang/Object.hashCode:()I
             4: pop
             5: return

       这里monitorenter和monitorexit操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是this;对于静态方法来说,这两个操作对应的锁对象则是所在类的Class实例。关于monitorenter和monitorexit的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。当执行monitorenter时,如果目标锁对象的计数器为0,那么说明它没有被其他线程所持有。在这个情况下,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。当计数器减为0时,那便代表该锁已经被释放掉了。之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁。举个例子,如果一个Java类中拥有多个synchronized方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操作。因此,我们需要设计这么一个可重入的特性,来避免编程里的隐式约束。

5.重量级锁、轻量级锁、偏向锁

       重量级锁是Java虚拟机中最为基础的锁实现。在这种状态下,Java虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。Java线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合posix接口的操作系统(如macOS和绝大部分的Linux),上述操作是通过pthread的互斥锁(mutex)来实现的。此外,这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。为了尽量避免昂贵的线程阻塞、唤醒操作,Java虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。Java虚拟机给出的方案是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
       轻量级锁多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。对象头中的标记字段(markword):它的最后两位便被用来表示该对象的锁状态。其中,00代表轻量级锁,01代表无锁(或偏向锁),10代表重量级锁,11则跟垃圾回收算法的标记有关。当进行加锁操作时,Java虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。然后,Java虚拟机会尝试用CAS(compare-and-swap)操作替换锁对象的标记字段。这里解释一下,CAS是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。假设当前锁对象的标记字段为X…XYZ,Java虚拟机会比较该字段是否为X…X01。如果是,则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为00。此时,该线程已成功获得这把锁,可以继续执行了。如果不是X…X01,那么有两种可能。第一,该线程重复获取同一把锁。此时,Java虚拟机会将锁记录清零,以代表该锁被重复获取。第二,其他线程持有该锁。此时,Java虚拟机会将这把锁膨胀为重量级锁,并且阻塞当前线程。当进行解锁操作时,如果当前锁记录(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录)的值为0,则代表重复进入同一把锁,直接返回即可。否则,Java虚拟机会尝试用CAS操作,比较锁对象的标记字段的值是否为当前锁记录的地址。如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。
       偏向锁如果说轻量级锁针对的情况很乐观,那么接下来的偏向锁针对的情况则更加乐观:从始至终只有一个线程请求某一把锁。具体来说,在线程进行加锁时,如果该锁对象支持偏向锁,那么Java虚拟机会通过CAS操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为101。在接下来的运行过程中,每当有线程请求这把锁,Java虚拟机只需判断锁对象标记字段中:最后三位是否为101,是否包含当前线程的地址,以及epoch值是否和锁对象的类的epoch值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。这里的epoch值是一个什么概念呢?我们先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且epoch值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。如果某一类锁对象的总撤销数超过了一个阈值(对应Java虚拟机参数-XX:BiasedLockingBulkRebiasThreshold,默认为20),那么Java虚拟机会宣布这个类的偏向锁失效。具体的做法便是在每个类中维护一个epoch值,你可以理解为第几代偏向锁。当设置偏向锁时,Java虚拟机需要将该epoch值复制到锁对象的标记字段中。在宣布某个类的偏向锁失效时,Java虚拟机实则将该类的epoch值加1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的epoch值。为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java虚拟机需要遍历所有线程的Java栈,找出该类已加锁的实例,并且将它们标记字段中的epoch值加1。该操作需要所有线程处于安全点状态。如果总撤销数超过另一个阈值(对应Java虚拟机参数-XX:BiasedLockingBulkRevokeThreshold,默认值为40),那么Java虚拟机会认为这个类已经不再适合偏向锁。此时,Java虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。

6.Java语法糖

       Java语法糖-是一种帮助开发人员提高开发效率的小甜点,原理是将一些繁琐的事情交给编译器来处理,开发人员少做一些事情,当然,本纸上这些事情还必须要做,只是有编译器来做了。Java语法糖主要包含:

  •        包装类型和基本类型间的转换,自动装箱和拆箱的设计;
  •        泛型的设计;
  •        变长参数的设计;
  •        try-with-resources,关闭资源的设计;
  •        在同一个catch代码块中捕获多种异常;
  •        finally代码块总是被执行的设计;
  •        foreach循环数组的设计;
  •        foreach循环Iterable对象的设计。

7.Java即时编译

       即时编译-直接将Java字节码编译成机器码,运行在底层硬件之上,这么玩是为了提高代码的执行效率,通俗点就是能使代码跑的更快一些。即时编译的触发点是热点代码,即时编译仅针对热点代码来触发,热点代码(即时动态编译比较耗时)是通过方法的调用次数或者回边循环的次数来标示的,这里也侧面反映出来即时编译是针对方法块的。机器码越快,需要的编译时间就越长。分层编译是一种折衷的方式,既能够满足部分不那么热的代码能够在短时间内编译完成,也能满足很热的代码能够拥有最好的优化。解释执行-将Java字节码一段一段的编译成机器码在底层硬件上运行,即时编译是一个相对解释执行而言的概念,它将热点代码先编译成机器码缓存起来,在解释执行字节码的时候判断出已经缓存起来了就不在编译直接获取执行就可以了。profile-是收集运行时状态信息,用于编译器优化,当然,收集信息也是耗性能的,所以,也是有前提条件的,当存在优化的可能性时才去费劲吧啦的收集相关信息。当然如果假设优化失败那就去优化,即从执行即时编译生成的机器码切换回解释执行。
       在编译原理课程中,我们通常将编译器分为前端和后端。其中,前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式,也就是IR(Intermediate Representation )。后端会对IR进行优化,然后生成目标代码。如果不考虑解释执行的话,从Java源代码到最终的机器码实际上经过了两轮编译:Java编译器将Java源代码编译成Java字节码,而即时编译器则将Java字节码编译成机器码。对于即时编译器来说,所输入的Java字节码剥离了很多高级的Java语法,而且其采用的基于栈的计算模型非常容易建模。因此,即时编译器并不需要重新进行词法分析、语法分析以及语义分析,而是直接将Java字节码作为一种IR。不过,Java字节码本身并不适合直接作为可供优化的IR。这是因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR。这种IR的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。具体来说,C2和Graal采用的是一种名为Sea-of-Nodes的IR,其特点用IR节点来代表程序中的值,并且将源程序中基于变量的计算转换为基于值的计算。

你可能感兴趣的:(深入理解Java虚拟机四)