2.1 Volatile
2.1.1 Volatile 的定义
Java 编程语言中允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。如果一个字段被声明成 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。
2.1.2 Volatile 的实现原理
有 volatile 变量修饰的共享变量进行写操作时会多执行一行 Lock 前缀的 CPU 指令,该指令在多核处理器下会引发两件事情。
将当前处理器缓存行的数据写回到系统内存。
这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效(CPU 缓存一致性协议)。
综上总结下 volatile 的两条实现原则。
Lock 前缀指令会引起处理器缓存回写到内存。 Lock 前缀指令会声言 LOCK# 信号,在多处理器环境中,LOCK# 信号在声言该信号期间,处理器可以独占任何共享内存(会锁住总线),目前处理器中 LOCK# 信号一般不锁总线。而且目前的 CPU 中如果访问的内存区域已经缓存在处理器内部,则不会声言 LOCK# 信号,反而会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效。 使用 MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。处理器使用嗅探技术保证它的内部缓存、系统内存、和其他处理器的缓存的数据在总线上保持一致。
2.1.3 Volatile 的使用优化(LinkedTransferQueue 中)
追加字节优化性能
对于部分现代处理器来说,处理器的高速缓存行是 64 个字节宽,并且不支持部分填充缓存行,所以意味着如果两个或多个变量都不足 64 字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器会缓存同样的变量,当一个处理器试图修改某一个变量时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中在同一行的另外几个变量,所以在多处理器情况下会严重影响效率。
Doug Lea 在 LinkedTransferQueue 中使头尾节点都扩充到 64 字节宽来填满整个高速缓存行,避免头结点和尾节点加载到同一个缓存行中,使头、尾节点在修改时不会互相锁定。
是否在使用 Volatile 变量时都需要追加到 64 字节
下面两种场景不适合。
缓存行非 64 字节宽的处理器。 如 P6 系列和奔腾处理器(高速内存行是 32 个字节宽)。
共享变量不会被频繁的写。 使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,会增加性能消耗。
2.2 synchronized
2.2.1 synchronized 的锁对象的三种形式
普通同步方法,锁是当前实例对象。
静态同步方法,锁是当前类的 Class 对象。
同步方法块,锁是 synchronized 括号中配置的对象
2.2.2 synchronized 同步执行的原理
代码块同步是使用 monitorenter 和 moniterexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 moniterexit 指令是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 moniterexit 与之配对。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。
2.2.3 Java 对象头
synchronized 用的锁是存在 Java 对象头中的。如果对象是数组类型,虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 个字宽存储。在 32 位虚拟机中,1 字宽等于 4 字节,即 32 bit。
对象头中结构主要分三个部分:
Mark Word,存储对象的 hashCode 或锁信息等。
Class Metadata Address,存储到对象类型数据的指针。
Array length,数组对象的长度。
Mark Word 中的结构:
25 bit,对象的 hashCode。
4 bit,对象分代年龄(所以对象的最大年龄为 15 岁,也是默认值)。
1 bit,是否是偏向锁(0 或 1)。
2 bit,锁标识位。
2 bit 的锁标识位:
00,轻量级锁。
10,重量级锁。
11,GC 标记。
01,偏向锁。
2.2.4 锁的升级
1.偏向锁
- 偏向锁加锁
一个线程进入同步代码块并获取到锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。
相同线程再次进入代码块的时候不需要 CAS 操作来加锁解锁,只需测试一下 Mark Word 中是否存储着指向当前线程的偏向锁。
测试成功,表示已经获取到锁。
测试失败,再测试一下 Mark Word 中的偏向锁标识是否为 1。
没有设置则使用 CAS 竞争锁(说明此时锁已经膨胀为轻量级锁了)。
如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
- 偏向锁撤销
偏向锁的释放是当其他线程竞争偏向锁时,持有偏向锁的线程才会释放锁。
等待全局安全点。
暂停拥有偏向锁的线程。
检查持有偏向锁的线程是否存活。
如果不处于活动状态,则将对象头设置成无锁状态(线程ID清空,偏向标识更新为 0);如果线程仍存活,拥有偏向锁的栈会被执行。
遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁。
最后唤醒线程。
2.轻量级锁
- 轻量级锁加锁
线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中(官方称为 Displaced Mark Word)。
线程尝试使用 CAS 将对象头的 Mark Word 替换为指向锁记录的指针。
成功,获得锁;失败,自旋继续尝试。
- 轻量级锁解锁
获取到锁的线程使用 CAS 将 Displaced Mark Word 替换回对象头。
成功,解锁成功;失败,膨胀为重量级锁(另外的线程在自旋 CAS 获取轻量级锁的时候超时失败,已经把锁膨胀为重量级锁,锁的标识位发生变化,由 00 变为 10,所以替换会失败)。
释放锁并唤醒等待的线程。
2.2.5 锁的优缺点
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问的同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗 CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
2.3 原子操作
2.3.1 处理器如何实现原子操作
通过使用 LOCK# 信号开启总线锁保证原子性。
通过缓存一致性机制(不允许同时修改由两个以上处理器缓存的内存区域数据)来保证操作的原子性,也可称作“缓存锁定”。
2.3.2 Java 实现原子操作
Java 中可通过 锁 和 循环 CAS 的方式来实现原子操作。Java 中的 CAS 操作是利用了处理器提供的 CMPXCHG 指令实现的。
2.3.3 CAS 操作的三大问题
ABA 问题,可增加版本号解决。
循环时间长开销大,需从 JVM 层解决,如果 JVM 能支持 CPU 的 pause 指令,那么效率会有一定的提升。pause 指令有两个作用:第一,它可以延迟流水线执行指令;第二,它可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
只能保证一个共享变量的原子操作,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以多个变量放在一个对象中进行一次 CAS 操作。