基本概念

临界区

对共享变量存在读写操作的代码区域称之为临界区。

竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。

上面讲解了临界区和竞态条件,为了避免临界区的静态条件发生,主要有两种类型的解决 方式

  • 阻塞式的解决方案,比如 Synchronized, Lock
  • 非阻塞式的解决方案:原子变量,cas 操作

下面就主要讲解 Synchronized 关键字的作用。

线程安全分析

成员变量和静态变量的安全性

  • 如果没有共享,线程安全
  • 如果共享了

    • 如果只有读操作,线程安全
    • 如果有读写操作,则含有读写操作的代码就是临界区,需要考虑线程安全问题

      局部变量安全性

  • 局部变量时线程安全的

  • 局部变量引用的对象不一定

    • 如果对象没有逃离方法的作用访问,则线程安全
    • 如果对象逃离方法的作用范围,则需要考虑线程安全

      作用

  • Synchronized 的作用是使用对象锁来保证临界区内代码的原子性,临界区内的代码对外是不可以分割的【需要注意的是即使加了锁,依然还是会发生线程的上下文切换,但是没有拿到锁的对象是不会进入临界区代码执行,所以最终还是只有拿到锁的线程切换到临界区代码执行。】

    原理

    对象头

    32 位的虚拟机的对象头
    image.png
    其中 Mark Word 中存储的内容如下:
    image.png
    可以看到最后两个字节位可以表示加锁的状态

  • 最开始没有加锁的时候,最前面的 25 位表示 hashcode, 最后两位是 01

  • 当有线程信息的时候,最前面的 23 位保存的是线程信息
  • 当加上了 轻量级锁的时候,最后面的两位是 00
  • 当加上重量级锁的时候最后面两位是 10
  • 当发生 GC 的时候最后面两位是 11

64 位虚拟机的对象头的 Mark Word 中内容如下:
image.png

Monitor 对象

每一个 Java 对象都可以关联都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁,那么该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,Monitor 结构如下所示:
image.png

  • 刚开始的时候 Monitor 中 Owner 为 null
  • 当 Thread-2 线程执行 synchronized(obj) 的时候就会将 Monitor 的所有者 Owner 置为 Thread-2, Monitor 中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3, Thread-4, Thread-5 也来执行 Synchronized(obj), 就会进入 EntryList Blocked
  • 在 Thread-2 执行完同步代码块的内容之后,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时候是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前已经获得过锁,但是条件不满足而进入到 WAITTING 状态的线程。

注意:如果对象没有加上 synchronized 那就对象就不会关联 Monitor 监视器锁,也就没有上面的规则。

synchronized 的原理

当代码执行到 synchronized 锁住的方法或者代码块的时候对应的字节码指令是 monitorenter, 这个指令的作用就是将锁住的对象头中的mark word 置为 Monitor 监视器对象的指针,此时就让该锁对象关联了 Monitor 对象,就会遵守上面讲的哪些规则了。

synchronized 原理进阶

轻量级锁

  • 如果一个对象虽然有多个线程要加锁,但是加锁的时间是错开的(比如说线程1执行完了,线程2才过来,这种情形就是没有锁竞争),那么此时就可以使用轻量级锁来优化。
  • 轻量级锁的语法依然是 synchronized

下面的示例是两个方法同步块,领用同一个对象加锁。

  1. static final Object obj = new Object();
  2. public static void method1() {
  3. synchronized (obj) {
  4. // 同步块A
  5. method2();
  6. }
  7. }
  8. public static void method2() {
  9. synchronized(obj) {
  10. // 同步块B
  11. }
  12. }

代码运行加锁的过程如下:

  • 首先在线程的栈帧中创建锁记录(Lock Record)对象,内部可以存储锁定对象的 MarkWord

image.png

  • 将锁记录中 Object reference 指向锁对象,并尝试使用 cas 替换 Object的 Mark Word。将 Mark Word 的值存入锁记录。

image.png

  • 如果 cas 替换成功,对象头中存储了 锁记录 地址和状态 00,表示由该线程给对象加锁。

image.png

  • 如果 cas 失败,有两种情形
    • 如果是其他线程已经持有了该 Object 的轻量级锁,此时表示由竞争,进入锁膨胀过程(这个过程在后面看)。
    • 如果是自己执行了 synchronized 锁重入,那么再增加一条 Lock Record 作为重入的计数(下面就继续看这种情况)。

image.png

  • 当退出 synchronized 代码块时如果有取值为 null 的锁记录。表示有重入,此时可以删除改锁记录,表示重入数减一。

image.png

  • 当退出 synchronized 代码块时如果锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。

    • 恢复成功,说明解锁成功。
    • 失败,说明轻量级锁进行了锁膨胀或者已经升级为重量级锁,进入重量级锁解锁流程。

      锁膨胀

      如果在尝试加轻量级锁的过程中,cas 操作无法操作成功,此时有一种情况表示由其他线程为此对象已经加上了轻量级锁(有竞争),此时就需要进行锁膨胀,将轻量级锁变成重量级锁。
  • 当 Thread-1 进行轻量级加锁的时候,Thread-0 已经对该对象加了轻量级锁。

image.png

  • 此时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    • 为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址。
    • 然后自己进入 Monitor 的 EntryList BLOCKED

image.png

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word的值恢复给对象头失败(由上图可知,此时对象头里面已经是保存了 Monitor 地址,而不是 Lock Record 地址)。这时就会进入重量级解锁流程,按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中的 BLOCKED 线程。

自旋优化

  • 重量级锁竞争的时候,可以使用 自旋来进行优化,如果当前线程自旋成功(也就是这时候持有锁的线程已经退出了同步块,释放了锁,此时当前线程就可以避免阻塞)
  • 自旋会占用 cpu 时间,单核 cpu 自旋就是浪费,多核的 cpu 自旋才可以发挥优势。
  • java6 之后自旋是自适应的。
  • java7 之后不能控制是否开启自旋。

偏向锁

轻量级锁在没有竞争时(比如就自己一个线程在执行),每次重入的时候依然还是要进行 cas 操作。在 java6 之后引入了 偏向锁来进一步进行优化:只有第一次使用 cas 将线程id设置到对象的 Mark Word 之后,之后发现这个线程ID 是自己的就表示没有竞争,不用重新 cas。 以后只有没有发生竞争,那么这个对象就归该线程所有。
比如下面的代码

  1. static final Object obj = new Object();
  2. public static void m1() {
  3. synchronized (obj) {
  4. // 同步块A
  5. m2();
  6. }
  7. }
  8. public static void m2() {
  9. synchronized(obj) {
  10. // 同步块B
  11. m3();
  12. }
  13. }
  14. public static void m3() {
  15. synchronized(obj) {
  16. // 同步块C
  17. }
  18. }

image.png

偏向状态

再看一下 64 为的对象头格式
image.png

  • 之前说的是最后两位记录的是锁的状态,比如 01 表示没有上锁,00 是轻量级锁等。
  • 看上图会发现,state 为 normal 和 Biased 的最后两位都是 01,但是倒数第三位是不一样的,倒数第三位为1表示偏向锁,为0 表示没有加锁。

对象创建的过程

  • 如果开启了偏向锁(默认是开启的),那么对象创建之后,mark word的值最后三位是 101, 此时它的thread、epoch、age都是0.
  • 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想要避免延迟可以加上 VM 参数 xx::BiasedLockingStartupDelay=0
  • 如果没有开启偏向锁,那么对象创建之后,markword 值的最后三位是 001,此时它的 hashcode,age都是0,第一次用到 hashcode 时才会赋值。

    撤销-调用对象hashcode

    调用了对象的 hashcode,但是偏向锁的对象 MarkWord 中存储的是线程的id,所以调用 hashCode 之后会导致偏向锁被撤销。

  • 轻量级锁会在锁记录中记录 hashCode

  • 重量级锁会在 Monitor 中记录 hashCode

撤销-其他线程使用对象

  • 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。

批量重偏向

  • 如果对象虽然被多个线程访问,但是没有竞争,这是偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID.
  • 当撤销偏向锁阈值超过 20 次后,jvm会觉得有可能偏向错了,此时会给这些对象加锁时重新偏向到加锁线程。

批量撤销

  • 当撤销偏向锁阈值超过 40 次之后,jvm 会觉得此时不应该偏向了,于是,整个类的对象都会变成不可偏向,新建的对象也是不可偏向的。

锁消除和锁粗化

https://zhuanlan.zhihu.com/p/359950338

ReentrantLock

https://www.yuque.com/docs/share/cada84ec-126c-4ee1-a2a6-56a04b6b9223?#