程序员

从 Synchronized 到锁的优化

2021-03-12  本文已影响0人  __Y_Q

我们知道 SynchronizedJava 中解决并发问题的一种最常用的方法, 也是最简单的一种方法. 被也被称为内置锁.

Synchronized 的作用主要有三个:

 
从语法上讲, Synchronized 总共有三种用法:

关于使用方式, 这里就不再进行一一描述了. 我们直接进入正题, 看 Synchronized 的底层实现原理是什么.

1. Synchronized 原理

首先, 我们先来看一段代码, 使用了同步代码块和同步方法, 通过使用 javap 工具查看生成的 class 文件信息来分析 synchronized 关键字的实现细节.

代码片段
对代码进行反编译后的结果如下
  public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic    
         3: dup
         4: astore_1
         5: monitorenter  //---------------------------------------------1.
         6: aload_1
         7: monitorexit    //---------------------------------------------2.
         8: goto          16
        11: astore_2
        12: aload_1
        13: monitorexit   //---------------------------------------------3.
        14: aload_2
        15: athrow
        16: return
        ...

  public static synchronized void test();
    descriptor: ()V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED //---------------------------------------------4.
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 21: 0

从生产的 class 信息中, 可以清楚的看到两部分内容

先看反编译出 main 方法中标记的 1 与 2. monitorenter / monitorexit 关于这两条指令的作用, 参考 JVM 中对他们的描述如下:

monitorenter
每个对象有一个监视器锁 monitor, 当 monitor 被占用时就会处于锁定状态, 线程执行 monitorenter 指令时尝试获取 monitor 的所有权, 过程如下

 
monitorexit
执行 monitorexit 的线程必须是对应 monitor的所有者. 执行指令时, monitor的进入数减 1. 如果减 1 后进入数为 0, 则线程退出 monitor. 不再是这个 monitor 的所有者. 其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权.

 

monitorenter 指令是在编译后插入到同步代码块开始的位置, 而 monitorexit 是插入到方法的结束处和异常处. 这也就是为什么在 3 处会单独有一个 monitorexit 了.

 

ACC_SYNCHRONIZED
当方法调用时, 调用指令将检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置, 如果设置了, 执行线程将先获取 monitor, 获取成功之后才能执行方法体. 方法执行完后再释放 monitor, 在方法执行期间, 其他任何线程都无法再获得同一个 monitor 对象.
其实这个和上面 monitorentermonitorexit 本质上没有区别, 只是方法的同步是一种隐式的方式来实现的, 无需通过字节码来完成.

看完这些, 是不是觉得有点和 AQS 中的 state 相似? 如果看完了 从 LockSupport 到 AQS 的简单学习 这篇文章的朋友, 再来看这里, 我相信应该会很容易理解.

这里既然说到了监视器锁 monitor , 我们一起来看这到底是什么.
 


2. 监视器锁 monitor

监视器锁 monitor 本质是依赖于底层操作系统的 Mutex Lock (互斥锁) 来实现的. 每个对象都对应于一个可称为 "互斥锁" 的标记, 这个标记用来保证在任一时刻, 只能有一个线程访问该对象.

以下是 Mutex 的工作方式

Mutex 工作流程

 

3. 为什么说 Synchronized 是重量级锁?

Synchronized 是通过对象内部的一个叫监视器锁 monitor 来实现的, 监视器锁本质又是依赖于底层的操作系统的互斥锁 Mutex Lock 来实现的.

而从 Mutex Lock (互斥锁) 的工作流程我们可以得知是自旋和阻塞, 既然是阻塞那么肯定有唤醒. 由于 Java 的线程是映射到操作系统的原生线程之上的, 所以说如果要阻塞或者唤醒一条线程, 都需要操作系统来帮忙完成, 这就需要从用户态转到内核态. 这个成本非常高, 状态之间的转换需要相对较长的时间, 因此状态转换需要消耗很多处理器时间. 这就是为什么 Synchronized 效率低的原因. 因此, 这种依赖于操作系统互斥锁 Mutex Lock 所实现的锁, 我们称之为 "重量级锁".

But, 在 JDK1.6 中为了获得锁和释放锁带来的性能消耗, 引入了 偏向锁轻量级锁, 使得 SynchronizedReentrantLock 的性能基本持平. ReentrantLock 只是提供了比 Synchronized 更丰富的功能, (比如尝试获取锁,尝试释放锁等) 而不一定有更优的性能, 所以在 Synchronized 能实现需求的情况下, 尽量还是优先考虑使用 Synchronized 来进行同步.

锁一共有 4 种状态: 级别从低到高依次是: 无锁状态 -> 偏向锁状态 -> 轻量级锁状态 -> 重量级锁状态
锁可以升级, 但是不能降级.

在 JDK1.6 中, 除了引入偏向锁与轻量级锁的概念, 还有锁消除, 锁粗化等等.

接下来我们了解锁是如何优化前, 需要先了解一个重要的概念, 那就是 java 对象头.

 

4. java 对象头

Synchronized 锁是存在 java 对象头中的, 那么什么是 java 对象头呢?
Hotspot 虚拟机中, 对象在内存的分布为三个部分, 头像头, 实例数据, 对齐填充. 而对象头主要包括

Hotspot 虚拟机: JVM 虚拟机, 总的来说是一种标准规范, 虚拟机有很多实现版本. 主要作用就是运行 java 的类文件的. 而 Hotspot 虚拟机是虚拟机的一种实现, 它是 SUN 公司开发的, 是 sun jdk 和 open jdk 中自带的虚拟机, 同时也是目前使用范围最广的虚拟机.

Hotspot 与 JVM 两者的区别一个是实现方式, 一个是标准.

额外知识点 : Java 对象头一般占有 2 个机器码(在 32 位虚拟机中, 1 个机器码等于 4 字节, 也就是 32 bit), 但是如果对象是数组类型, 则需要 3 个机器码, 因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小, 但是无法从数组的元数据来确认数组的大小, 所以用一块来记录数组的长度. 下图是Mark Word 默认的存储结构 (32 位虚拟机)

Mark Word 默认结构

对象头信息是与对象自身定义的数据无关的额外存储成本, 但是考虑到虚拟机的空间效率, Mark Work 被设计成一个非固定的数据结构, 以便在极小的空间内存储更多的信息. 也就是说, Mark Word 会随着程序的运行发生变化, 变化状态如下(32 位虚拟机)

Mark Word 的状态变化

我们现在知道锁的状态及相关信息是存在了 java 对象头中的 Mark Word 中. 接着来看下锁是如何优化的. 无锁状态就不再说了, 我们从最低的偏向锁开始.

 

5. 锁优化 - 偏向锁

什么是偏向锁
偏向锁, 顾名思义, 它会偏向于第一个访问锁的线程. 如果在运行过程中只有一个线程访问同步块, 会在对象头和栈帧中的锁记录里存储当前线程的 ID, 以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁, 只需要简单的判断一下对象头的 Mark Word 里是否存储着当前线程的ID.

 
为什么要引入偏向锁
经过研究发现, 在大多数情况下, 锁不仅不存在多线程竞争, 而且总是由同一线程多次获得, 为了让线程获得锁的代价更低而引入了偏向锁, 减少不必要的 CAS 操作, 从而提高性能.

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径, 因为轻量级锁的获取和释放依赖多次的 CAS 原子指令. 而偏向锁只需要在置换线程 ID 的时候依赖一次 CAS 原子指令. 因为一旦出现多线程竞争的情况就必须撤掉偏向锁, 膨胀为轻量级锁. 所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗.

 
偏向锁的三种状态

如果 JVM 启用偏向锁, 那么一个新建未被任何线程获取的对象头 Mark Word 中的 Thread Id 为0, 是可以偏向但未偏向任何线程, 被称为匿名偏向状态. 而无锁状态是不可偏向也未偏向任何线程, 不可再变为偏向锁. 记住!无锁状态不能变成偏向锁!

 
偏向锁获取过程

  1. 在一个线程进入同步块的时候, 检测 Mark Word 中是否为可偏向状态, 即 [是否是偏向锁] = 1, [锁标志位] = 01
  2. 若为可偏向状态, 检测 Mark Word中记录的线程 ID 是否是当前线程 ID, 如果是执行步骤 5, 不是执行步骤 3. 若为不可偏向状态, 直接执行轻量级锁流程.
  3. 如果线程ID并未指向当前线程,则通过 CAS 操作竞争锁, 竞争成功则将 Mark Word 中线程 ID 设置为当前线程 ID. 然后执行步骤 5, 否则执行步骤 4.
  4. 通过 CAS 获取偏向锁失败, 则表示有竞争. (CAS 获取偏向锁失败说明至少有过其他线程曾获得过偏向锁, 因为线程不会主动释放偏向锁). 当到达全局安全点 (safepoint) 时, 会首先挂起拥有偏向锁的线程, 然后检查持有偏向锁的线程是否还活着, (因为有可能持有偏向锁的线程已经执行完毕, 但是该线程不会主动释放偏向锁)
    • 如果还存活, 接着判断是否还在同步代码块中执行.
      • 若还在同步代码块中执行, 直接升级为轻量级锁.
      • 若未在同步代码块中执行, 则看是否可重偏向,
        • 不可重偏向: 直接撤销偏向锁, 变为无锁状态后, 升级为轻量级锁.
        • 可重偏向: 修改 Mark Word为匿名偏向状态, 通过 CAS 将新线程 ID给 Mark Word 赋值.唤醒新线程, 执行同步代码块.
    • 如果不存活, 也需要判断是否可重偏向.
      • 不可重偏向: 直接撤销偏向锁, 变为无锁状态后, 升级为轻量级锁.
      • 可重偏向: 修改 Mark Word为匿名偏向状态, 通过 CAS 将新线程 ID给 Mark Word 赋值.唤醒新线程, 执行同步代码块.

JVM 维护了一个集合存放所有存活的线程, 通过遍历该集合判断是否有线程的 ID 等于持有偏向锁线程的 ID, 有的话表示存活.

 
至于是否还在同步块中执行: 这个就需要说到锁记录 Lock Record

当代码进入同步块的时候, 如果此时同步对象未被锁定 (即 [锁标志位] = 01) , 虚拟机会在当前线程的栈帧中新建一个空间, 来存放锁记录 Lock Record , 锁记录用于存储记录目前对象头 Mark Word 的拷贝 (官方称之为 Displaced Mark Word) 以及记录锁对象的指针 owner.

栈帧: 这个概念涉及的内容较多, 不便于展开叙述. 从理解下文的角度上来讲, 只需要知道, 每个线程都有自己独立的内存空间, 栈帧就是其中的一部分. 里面可以存储仅属于该线程的一些信息.

在偏向锁时也有 Lock Record 存在, 只不过作用不大. Lock Record 主要用于轻量级锁和重量级锁.


 
锁记录可以做什么?
可以统计重入的次数, 判断当先线程是否还在同步块中执行.以及在轻量级锁中会大量用到.

统计重入次数
线程每次进入同步块(即执行monitorenter)都会新建一个锁记录, 并将新建锁记录中的 Displaced Mark Word 设为 null . 用来作为统计重入的次数. owner 指向当前的锁对象.

每次解锁 (即执行monitorexit) 的时候都会从最低的一个相关的锁记录移除. 所以可以通过遍历线程栈中的Lock Record来判断线程是否还在同步块中.

下图是一个重入三次的 Lock Record 示意图.

轻量级锁重入

为什么 JVM 选择在线程栈帧中添加 Displaced Mark WordnullLock Record 来表示重入计数而不是将重入次数直接放在对象头的 Mark Word 中呢. 之前说过, Mark Word 的大小是有限制的, 已经存不下该信息了.

那么为什么不只创建一个锁记录在其中记录重入次数呢? 这点我也没有想明白. 如果有知道答案的朋友, 请留言告知一下, 万分感谢 !!!
 

 
偏向锁的撤销过程
偏向锁的撤销在上面第 4 点有说到, 偏向锁使用了一种等到竞争出现才释放偏向锁的机制: 偏向锁只有遇到其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放, 线程本身不会主动去释放偏向锁. 偏向锁的撤销需要等待全局安全点(在这个时间点上没有字节码正在执行), 它会首先暂停拥有偏向锁的线程, 判断锁对象是否处于被锁定状态, 撤销偏向锁后恢复到无锁或轻量级锁的状态. 我们发现, 这个开销其实还是挺大的, 所以如果某些同步代码块大多数情况下都是有两个及以上的线程竞争的话, 那么偏向锁就会值一种累赘, 对于这种情况, 建议一开始就把偏向锁关闭.

注意: 偏向锁撤销是指在获取偏向锁的过程中因不满足条件导致要将锁对象改为非偏向锁状态, 而偏向锁释放是指退出同步块时的过程.

 
关闭偏向锁
偏向锁在 JDK 6JDK 7 中默认启动的. 由于偏向锁是为了在只有一个线程执行同步块的时候提高性能. 如果能确定应用程序里所有的锁通常情况下处于竞争状态, 可以通过 JVM 参数关闭偏向锁. 那么程序默认会进入轻量级锁的状态.

 
偏向锁流程图

偏向锁流程 (1).png

 

6. 锁优化 - 轻量级锁

轻量级锁是由偏向锁升级来的, 偏向锁运行在一个线程进入同步块的情况下, 当有第二个线程进入产生锁竞争的情况下, 就会自动升级为轻量级锁. 其他线程会通过自旋的形式尝试获取锁, 线程不会阻塞, 从而提高性能.

轻量级锁的获取主要有两种情况

轻量级锁获取过程

  1. 拷贝对象头中的 Mark Word 到当前线程栈帧的锁记录中. 并且虚拟机通过使用 CAS 操作尝试将对象头的 Mark Word 更新为指向锁记录的指针, 并将锁记录里的 owner 指针指向锁对象. 这个操作成功执行步骤 2, 失败执行步骤 3.

  2. 如果这个更新动作成功了, 那么当前线程就拥有了该对象的锁. 并且对象头的 Mark Word 的锁标志位改为 00, 即表示此对象处于轻量级锁定状态, 这时候线程堆栈与对象头的状态如下图所示.

  1. 如果这个更新操作失败了, 虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧
    • 如果不是: 说明这个锁对象已经被其他线程抢占了, 则通过自旋稍微等待一下, 有可能持有锁的线程很快就会释放锁.
      但是当自旋超过一定次数(默认允许自旋 10 次, 可以通过虚拟机参数更改), 或者一个线程在持有锁, 一个在自旋, 又有第三个线程来竞争的时候, 就会膨胀为重量级锁. 除了持有锁的线程外, 其他线程阻塞. 对象头Mark Word 中指向锁记录的指针改为指向重量级锁(互斥量)的指针, 同时将锁标志位改为 10.
    • 如果是: 说明当前线程已经拥有了这个对象的锁, 现在是重入状态. 可直接进入同步块继续执行. 同时会添加一条锁记录 Lock Record, 其中 Displaced Mark Wordnull, 起到一个重入计数器的作用.

 

轻量级锁解锁过程

  1. 遍历当前线程栈帧, 找到所有 owner 指向当前锁对象的 Lock Record
  2. 如果 Lock RecordDisplaced Mark Wordnull 说明这是一次重入, 删除此锁记录, 接着 continue . 这即为一次解锁结束.
  3. 如果 Displaced Mark Word 不为 null, 并且对象头中的 Mark Word 仍然指向当前线程的锁记录, 那就通过 CAS 操作把对象头中的 Mark Word 恢复成为 Lock Record 中拷贝过去的 Displaced Mark Word 值.
  4. 如果替换成功, 则 continue. 也即为一次解锁结束.
  5. 如果替换失败. 说明外面有一个线程到达了自旋的总次数, 或者外面至少还有两个线程来竞争锁, 导致锁已经膨胀为重量级锁. 从而改变了对象头中 Mark Word 的内容. 那就要在释放锁的同时, 唤醒被挂起的线程. 重新争夺锁访问同步块.

轻量级锁能提升程序同步性能的依据是 "对于绝大部分锁在整个同步周期内都是不存在竞争的" 这是一个经验数据. 如果没有竞争, 轻量级锁使用 CAS 操作避免了使用互斥量的开销, 但是如果存在竞争, 除了互斥量的开销外, 还额外发生了 CAS 操作. 因此在有竞争的情况下, 轻量级锁会比传统的重量级锁更慢.

 

7. 锁优化 - 重量级锁

重量级锁就是我们常说的传统意义上的锁, 其利用操作系统底层的同步机制去实现 Java 中的线程同步.
下图是整个偏向锁到轻量级锁再膨胀为重量级锁的流程图. 可能不是很清晰.

锁升级流程

 

8. 锁优化 - 锁消除

何为锁消除?
锁消除即删除不必要的加锁操作, 在介绍这个之前, 先说说 逃逸和逃逸分析.

逃逸是指在方法之内创建的对象, 除了在方法体之内被引用之外, 还在方法体之外被引用. 也就是说在方法体之外引用方法内的对象, 在方法执行完毕后, 方法中创建的对象应该被 GC 回收. 但是由于该对象被其他变量引用, 导致 GC 无法回收.

这个无法被回收的对象成为 "逃逸" 对象. Java 中的逃逸分析, 就是对这种对象的分析.

那么接着回到锁消除, Java JIT Java 即时编译 会通过逃逸分析的方式, 去分析加锁的代码是否被一个或者多个线程使用, 或者等待被使用. 如果分析证实, 只有一个线程访问, 在编译这个代码段的时候, 就不会生成 Synchronized 关键字, 仅仅生代码对应的机器码.

换句话说, 即使我们开发人员加上了 Synchronized , 但是只要 JIT 发现这段代码只会被一个线程访问, 也会把Synchronized 去掉.

 

9. 锁优化 - 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部.

    public static void main(String[] args) throws Exception {
        synchronized (object) {
            test();
        }
        // 中间可穿插其他代码
        synchronized (object) {
            test1();
        }
        synchronized (object) {
            test2();
        }
    }

上面代码存在三块代码段, 分割成三个临界区, 在 JIT 编译时会将其合并成一个临界区. 用一个锁对其进行访问控制. 减少了锁的获取和释放的次数. 编译后的等效代码如下

    public static void main(String[] args) throws Exception {
        synchronized (object) {
            test();
            test1();
            test2();
        }
    }

锁粗化默认是开启的。如果要关闭这个特性可以在 Java 程序的启动命令行中添加虚拟机参数-XX:-EliminateLocks.

 

10. 锁优化 - 自旋锁与自适应自旋锁

自旋锁的来由
自旋锁我们都知道是为了让该线程执行一段无意义的自旋, 等待一段时间, 不会被立刻挂起, 看持有锁的线程是否会很快释放.

可是为什么要引入自旋锁呢?

首先互斥同步对性能最大的影响就是上面我们说过的阻塞的实现, 因为阻塞和唤醒线程的操作都需要由用户态转到内核态中完成, 这些操作给系统的并发性能带来很大压力.

其次虚拟机的开发团队也注意到许多应用上面, 共享数据的锁定状态只会持续很短一段时间, 为了这一段很短的时间频繁的阻塞唤醒线程非常不值得. 于是, 就引入了自旋锁.

自旋锁的缺点
自旋锁虽然可以避免线程切换带来的开销, 但是它却占用了处理器的时间. 如果持有锁的线程很快就释放了锁, 那么自旋的效率就非常好. 反之, 自旋的线程就会白白浪费处理器的资源带来性能上的浪费. 所以说自旋的次数必须要有一个限度, 例如 10 次. 如果超过这个次数还未获取到锁, 则就阻塞.

了解了自旋锁, 那自适应的自旋锁呢?

自适应自旋锁
在 JDK1.6 中引入了自适应的自旋锁, 自适应就意味着自旋的次数不再是固定的, 它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的.

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

如果对于某个说, 自旋很少成功过, 那么在以后要获取这个锁时将可能省略掉自旋的过程, 以避免浪费处理器资源.

简单来说, 就是线程如果自旋成功了, 则下次自旋的次数会更多, 如果自旋失败了, 则自旋的次数减少.

 

OK, 到这里也差不多了, 关于锁的优化基本就分析完了, 最后来个总结吧.

11. 总结

偏向锁 轻量级锁 重量级锁
本质 取消同步操作 CAS 操作代替互斥同步 互斥同步
优点 不阻塞, 执行效率高, 只有第一次获取偏向锁时需要 CAS 操作, 后面只需要对比线程 ID 不会阻塞 不会空耗 CPU
缺点 适用场景太局限, 若产生竞争, 会有额外的偏向锁撤销的消耗 自旋会浪费 CPU 资源 阻塞唤醒, 用户态切换到内核态. 重量级操作

偏向锁, 轻量级锁, 重量级锁都是 java 虚拟机自己内部实现, 当执行到 synchronized 同步代码块的时候, java 虚拟机会根据启用的锁和当前线程的争用情况来决定如何执行同步操作.

在所有的锁都启用的情况下线程进入临界区时会先获得偏向锁, 如果已经存在偏向锁了, 则会尝试获取轻量级锁, 启用自旋锁, 如果自旋也没获取到锁, 则使用重量级锁, 没有获取到锁的线程被阻塞挂起, 知道持有锁的线程执行完同步代码块后去唤醒它们.

如果线程争用激烈, 那么应该禁用偏向锁.

不同的锁有不同特点, 每种锁只有在其特定的场景下, 才会有出色的表现, java中没有哪种锁能够在所有情况下都能有出色的效率. 引入这么多锁的原因就是为了应对不同的情况.


网上也摘抄了不少博客上的内容, 自己整理了一下, 变成自己能看懂的.
参考来源:
Java synchronized原理总结
synchronized 底层原理
synchronized原理和锁优化策略(偏向/轻量级/重量级)

至此本章到这里就结束了, 看到这里, 如果对你有帮助, 请点赞关注. 谢谢大家.
上一篇下一篇

猜你喜欢

热点阅读