写点什么

Java 并发机制的底层实现原理

作者:周杰伦本人
  • 2022 年 5 月 07 日
  • 本文字数:3425 字

    阅读完需:约 11 分钟

Java 并发机制的底层实现原理

Java 代码在编译后会变成 Java 字节码,字节码被类加载器加载到 JVM 里,JVM 执行字节码,最终需要转化为汇编指令在 CPU 上执行,Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。

2.1 volatile 的应用

在多线程并发编程中 synchronized 和 volatile 都扮演着重要的角色,volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果 volatile 变量修饰符使用恰当的话,它比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。本文将深入分析在硬件层面上 Intel 处理器是如何实现 volatile 的,通过深入分析帮助我们正确地使用 volatile 变量。

1.volatile 的定义与实现原理

Java 语言规范第 3 版中对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁要更加方便。如果一个字段被声明成 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。


volatile 是如何来保证可见性的呢?


有 volatile 变量修饰的共享变量进行写操作的时候会多出 Lock 前缀的指令,Lock 前缀的指令在多核处理器下会引发了两件事情


1)将当前处理器缓存行的数据写回到系统内存。


2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。


为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。


volatile 的两条实现原则。


1)Lock 前缀指令会引起处理器缓存回写到内存。


Lock 前缀指令导致在执行指令期间,声言处理器的 LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存[。但是,在最近的处理器里,**LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。**在 8.1.4 节有详细说明锁定操作对处理器缓存的影响,对于 Intel486 和 Pentium 处理器,在锁操作时,总是在总线上声言 LOCK#信号。但在 P6 和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言 LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。


2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。


处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在 Pentium 和 P6 family 处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

2.2 synchronized 的实现原理与应用

具体表现为以下 3 种形式。


  • 对于普通同步方法,锁是当前实例对象。

  • 对于静态同步方法,锁是当前类的 Class 对象。

  • 对于同步方法块,锁是 Synchonized 括号里配置的对象。


当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。


那么锁到底存在哪里呢?锁里面会存储什么信息呢?


从 JVM 规范中可以看到 Synchonized 在 JVM 里的实现原理,JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步


是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是使用另外一种方式实现的,细节在 JVM 规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。


monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。**任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

2.2.1 Java 对象头

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。


Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。

2.2.2 锁的升级与对比

锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,


只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark


Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。


偏向锁是如何撤销的?


偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。图 2-1 中的线程 1 演示了偏向锁初始化的流程,线程 2 演示了偏向锁撤销的流程。


轻量级锁

(1)轻量级锁加锁


线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。


(2)轻量级锁解锁


轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图 2-2 是两个线程同时争夺锁,导致锁膨胀的流程图。

2.3 原子操作的实现原理

处理器如何实现原子操作?


第一个机制是通过总线锁保证原子性。


第二个机制是通过缓存锁定来保证原子性。


但是有两种情况下处理器不会使用缓存锁定。


第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。


第二种情况是:有些处理器不支持缓存锁定。对于 Intel 486 和 Pentium 处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。


Java 如何实现原子操作?


在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。

使用循环 CAS 实现原子操作

CAS 实现原子操作的三大问题


1)ABA 问题。


2)循环时间长开销大。


3)只能保证一个共享变量的原子操作。

使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁。

2.4 本章小结

本章我们一起研究了 volatile、synchronized 和原子操作的实现原理。Java 中的大部分容器和框架都依赖于本章介绍的 volatile 和原子操作的实现原理,了解这些原理对我们进行并发编程会更有帮助。

发布于: 刚刚阅读数: 2
用户头像

还未添加个人签名 2020.02.29 加入

还未添加个人简介

评论

发布
暂无评论
Java并发机制的底层实现原理_5月月更_周杰伦本人_InfoQ写作社区