参考:《并发编程的艺术》

一、Synchronized 使用

synchronized 的使用有三种形式

  • 普通同步方法,锁是当前实例对象
    代码参考地址
  • 静态同步方法,锁是当前类的 class 对象
    代码参考地址
  • 同步方法块,锁是 synchronized 括号内的对象
    image.png

    二、Synchronized 原理

    2.1、前置概念

    2.1.1、Java 对象头

    在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头(Mark Word、Class Metadata Address)、实例数据(Instance Data)和对齐填充(Padding)
    Java 对象头存储了 synchronized 使用的锁信息

Java 对象头包含两部分

  • Mark Word
    存储对象的 hashCode 、锁信息、分代年龄等。
    在 Hotspot markOop.hpp 中的定义如下:
    image.png
  • Class Metadata Address
    类型指针,指向对象的类元数据,JVM 通过该指针确定该对象是哪个类的实例对象。

JVM Mark Word 默认存储结构(以 32 位虚拟机为例)

锁状态 25 bit 4 bit 1bit 是否是偏向锁 2bit 锁标志位
无锁状态 对象的 HashCode 对象分代年龄 0 01

JVM Mark Word 在运行期间,会随着 锁标志位 的变更而变更,可能的四种变更结构如下(以 32 位虚拟机为例)

锁状态 25 bit 4 bit
对象分代年龄
1 bit
是否是偏向锁
2 bit
锁标志位
23 bit 3 bit
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC 标记 11
偏向锁 线程ID Epoch 对象分代年龄 1 01
无锁状态 对象的 HashCode 对象分代年龄 0 01

2.2、synchronized

JDK6 之后对 synchronized 进行了优化,引入了偏向锁、轻量级锁 。 所以 synchronized 锁存在四种锁状态,级别从低到高位: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 这几个状态会随着竞争情况逐渐升级。锁可以升级但是不能降级,这样做的目的是为了提高获得锁和释放锁的效率。

2.2.1、为什么会出现这几种锁?

锁的使用就是为了保证多线程下数据的完全性,不过安全性保证了,但是也带来了性能上的损耗。
而偏向锁、轻量级锁的出现就是为了在保证数据安全性的情况下,尽可能的降低性能上的损耗。

2.2.2、偏向锁

在大部分情况下,锁不仅仅不存在多线程间竞争,还总是被同一个线程获取到锁,而为了让线程获取到锁的代价更低,于是引入了偏向锁。

偏向锁的获取/撤销操作如下图:
偏向锁.png

2.2.2.1、偏向锁的获取

判断 mark word 中 偏向锁状态 线程ID

如果 偏向锁状态=0 && 线程ID is null ,当前锁表现为可偏向状态,通过 CAS 操作,把当前线程写入到 mark word 中

  • CAS 操作成功
    表示线程获取到锁,此时 mark word 标记如下:
    image.png
    同时接着执行代码块数据
  • CAS 操作失败。
    表示有线程已经获得偏向锁,说明当前存在锁竞争,需要撤销已经获得的偏向锁的线程,并把它持有的锁升级为轻量级锁。(该操作需要等到全局安全点,也就是没有线程执行字节码才能执行)

如果偏向锁状态=1 && 线程 ID is not null ,当前锁表现为已偏向状态,此时需要判断 maik word 中的线程ID是否等于当前线程ID

  • 如果 相等
    不在需要获取锁,可直接执行同步代码块
  • 如果 不相等
    说明锁偏向于其他线程,需要撤销锁并升级到轻量级锁
    2.2.2.2、偏向锁的撤销

    偏向锁的撤销不是把对象恢复到无锁可偏向锁状态(偏向锁不存在释放锁的概念)。
    而是在其他线程获取偏向锁的时,在 CAS 失败后,也就是当前偏向锁存在线程竞争,直接把偏向锁升级为轻量级锁。

对原偏向锁的线程进行撤销,原偏向锁线程有两种情况

  • 原偏向锁线程同步代码块已经执行完成,把 mark word 设置为无锁状态。
    当前争抢锁的线程可以基于 CAS 重新偏向当前线程
  • 原偏向锁线程同步代码块没有执行完成,把偏向锁升级为轻量级锁,然后继续执行同步代码块。

小贴士:在应用开发中,绝大部分情况下一定会存在2个以上的线程竞争,所以应该关闭偏向锁。
在 Java6和Java7 中 默认开启偏向锁。
可以通过 -XX:-UseBiasedLocking=false,关闭偏向锁,则程序会默认进入轻量级锁状态

2.2.3、轻量级锁

轻量级锁适用于同步代码块执行很快的场景,这样线程自旋的时间就很短,因为自旋也是需要消耗CPU资源的。如果自旋太长,就不再适合使用轻量级锁,而是升级为重量级锁。

轻量级锁.png
锁升级为轻量级锁后,对象的 mark word 也会有响应的变化。升级过程如下:

  • 线程在自己的栈帧中创建锁记录 LockRecord
  • 将锁对象中的对象头中的 mark word 复制到线程刚刚创建的锁记录中,官方称为:Displaced Mark Word
    此时线程堆栈和对象头状态如下:image.png
  • 将锁记录中的 Owner 指针指向锁对象
  • 将锁对象的对象头的 mark word 替换为指向所记录的指针,此时线程堆栈与对象头如下
    image.png

2.2.3.1、加锁

线程尝试i使用 CAS 将 mark word 替换为指向锁记录的指针。

  • 成功
    表示线程获取到锁
  • 失败
    表示其他线程竞争锁,当前线程尝试使用自旋来获取锁

    自旋锁概述 自旋指当有多个线程竞争锁,没获取到锁的线程原地循环等待,而不是把线程阻塞,直到获取到锁的线程释放锁之后,自旋的线程马上获取到锁。 自旋可以看成一个啥也没做的for循环,例如: for(;;){ } 虽然自旋自旋会消耗CPU资源,但是大部分情况下同步代码块执行时间短,看似无意义的循环反而能够提升性能。

    自旋锁有一定控制条件,并不会无条件的一直空转下去。默认:自旋次数为10次。可以通过 preBlockSpin 修改。 在 JDK 1.6 后,自旋引入了自适应自旋锁。自适应自旋 的次数不是固定不变的,而是根据前一次在同一个锁上自 旋的时间以及锁的拥有者的状态来决定。

    如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并 且持有锁的线程正在运行中,那么虚拟机就会认为这次自 旋也是很有可能再次成功,进而它将允许自旋等待持续相 对更长的时间。

如果对于某个锁,自旋很少成功获得过, 那在以后尝试获取这个锁时将可能省略掉自旋过程,直接 阻塞线程,避免浪费处理器资源

2.2.3.2、解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过 CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的 MarkWord 中,如果成功表示没有竞争。如果失败,表示 当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。

2.2.4、重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起 阻塞来等待被唤醒了。

参考代码

加了同步代码块,在字节码中能够看到 monitorenter 和 monitorexit 这两个操作指令,该指令是成对出现的。

  • monitorenter 表示去获得一个对象监视器。
  • monitorexit 表 示释放 monitor 监视器的所有权,使得其他被阻塞的线程 可以尝试去获得这个监视器 monitor

每一个 JAVA 对象都会与一个监视器 monitor 关联,可以把它理解成为一把锁,当一个线程想要执行一段被 synchronized 修饰的同步方法或者代码块时,该线程得先 获取到 synchronized 修饰的对象对应的 monitor。
而这这个操作依赖操作系统的 MutexLock(互斥锁)来实现的, 线 程被阻塞后便进入内核(Linux)调度状态,这个会导致系 统在用户态与内核态之间来回切换,严重影响锁的性能。

2.2.4.1、重量级锁加锁基本流程

image.png
任意线程对 Object(Object 由 synchronized 保护)的访 问
首先要获得 Object 的监视器

  • 如果获取失败:线程进入同步队列,线程状态变为 BLOCKED。
  • 如果获取成功,则执行同步代码块

当获取 Object 监视器成功的线程释放锁(Monitor.Exit)会唤醒阻塞在同步队列中线程,使其重新尝试获取 Object监视器

2.3、通过简单实例分析 synchronized 可能的锁状态

假设存在同步代码块
image.png
此时有 Thread1、Thread2 等多个线程执行该代码块。

这时候会有三种情况

  • 1、只有 Thread1 会进入临界区执行代码块
  • 2、Thread1 和 Thread2 交替进入临界区,竞争不激烈
  • 3、Thread1、Thread2、Thread3 等多个同时进入临界区,竞争激烈

上述三种情况分别对应了 偏向锁、轻量级锁、重量级锁。

2.3.1、偏向锁

CAS 操作,将 mark word 锁标志位设为:”01“,同时将 Thread1 线程ID 记录到 mark word 中。

JVM 偏向 Thread1,总是由 Thread1 进入临界区执行同步代码块。
Thread1 只有在第一次进入临界区需要执行 CAS 操作,以后在进入临界区不会再有同步操作带来的开销。

2.3.2、轻量级锁

偏向锁场景是一个理想化的场景,当且只有一个线程的时候,才能保证 100% 的实现。
更多情况是在 Thread1 进入到临界区执行代码块时,Thread2 也会尝试进入临界区执行代码块。
当 Thread2 进入到临界区,但是 Thread1 同步代码块还没有执行完成,会暂停 Thread1 ,然后升级锁为轻量级锁。
升级后, Thread1 接着执行 同步代码块。Thread2 以自旋的方式才再次尝试获取锁。

2.3.3、重量级锁

如果 Thread1 和 Thread2 正常交替执行,那么轻量级锁可以满足锁的需求。
但是如果 Thread1 和 Thread2 或者其他 Thread 同时进入临界区,轻量级锁会膨胀为重量级锁。
当升级为重量级锁后,当 Thread1 获取到了锁,Thread2 或者其他 Thread 就会被阻塞。

2.4、锁的对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 始终得不到锁竞争的线程使用自旋空转会消耗CPU 追求响应时间,锁占用时间短
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,锁占用时间较长

三、其他概念

3.1、锁消除

// TODO

3.2、锁膨胀

// TODO