竞态条件 & 临界区

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称 存在竞态条件。
导致竞态条件发生的代码区称作 临界区。
在临界区中使用适当的同步就可以避免竞态条件。


如果一段代码存在:对共享资源的多线程读写操作,称这段代码为 临界区 (Critical Section)
多个线程在临界区内执行,由于代码的执行序列不同,而导致结果无法预测,就称存在 竞态条件 (Race Condition)

变量的线程安全分析

成员变量和静态变量是否线程安全

  • 如果变量没有被共享,则它们线程安全
  • 如果变量被共享,根据它们的状态是否能够改变,又分以下两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题

局部变量是否线程安全

  • 局部变量存储在栈帧的局部变量表中,每个方法都在对应线程的栈中创建栈帧,局部变量不会被其他线程共享,局部变量是线程安全的
  • 但局部变量引用的对象未必是线程安全的,要看该对象是否被共享 且 被执行了读写操作
    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全问题

      synchronized & 原理

      synchronized 是一种同步锁,它修饰的对象有以下几种 ```java // 修饰一个代码块 synchronized(对象) { // 临界区 }

// 修饰一个方法 public synchronized void test() { } // 等价于 public void test() { synchronized(this) { } }

// 修饰静态方法,作用的对象是:这个类的所有对象 public synchronized static void test() { } // 等价于 public void test() { synchronized(类名.class) { } }

  1. **代码 对应的字节码**
  2. ```cpp
  3. static final Object lock = new Object();
  4. static int counter = 0;
  5. public static void main(String[] args) {
  6. synchronized (lock) {
  7. counter++;
  8. }
  9. }
  10. // 方法级别的 synchronized 不会在字节码指令中有所体现
  11. public static void main(java.lang.String[]);
  12. descriptor: ([Ljava/lang/String;)V
  13. flags: ACC_PUBLIC, ACC_STATIC
  14. Code:
  15. stack=2, locals=3, args_size=1
  16. 0: getstatic #2 // <- lock引用 (synchronized开始)
  17. 3: dup
  18. 4: astore_1 // lock引用 -> slot 1
  19. 5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
  20. 6: getstatic #3 // <- i
  21. 9: iconst_1 // 准备常数 1
  22. 10: iadd // +1
  23. 11: putstatic #3 // -> i
  24. 14: aload_1 // <- lock引用
  25. 15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
  26. 16: goto 24
  27. 19: astore_2 // e -> slot 2
  28. 20: aload_1 // <- lock引用
  29. 21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
  30. 22: aload_2 // <- slot 2 (e)
  31. 23: athrow // throw e
  32. 24: return
  33. Exception table:
  34. from to target type
  35. 6 16 19 any
  36. 19 22 19 any

轻量级锁

轻量级锁 用于优化 Monitor 这类重量级锁。
轻量级锁的使用场景:当一个对象被多个线程访问,但访问的时间是错开的,即不存在竞争,此时就可以使用轻量级锁来优化。


创建锁记录 (Lock Record) 对象。每个线程的虚拟机栈都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word,记录锁对象的地址(不再一开始就使用 Monitor,锁记录是 JVM 层面的)
管程 - 图1
让锁记录中的 Object reference 指向锁对象,并尝试用 CAS 操作替换锁对象的 Mark Word,将 Mark Word 的值存入锁记录

  • 如果 CAS 操作替换成功,对象头中存储锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
  • 如果 CAS 操作替换失败,有两种情况
    • 如果是其它线程已经持有了该对象的轻量级锁,表明有竞争,进入锁膨胀过程
    • 如果是自己执行了 synchronized 锁重入,那么再添加一条锁记录作为重入的计数

图片.png图片.png
当退出 synchronized 代码块解锁时

  • 如果:锁记录的值为 null,表示有锁重入,这时重置锁记录,表示重入计数 - 1
  • 如果:锁记录的值不为 null,这时使用 CAS 操作将 Mark Word 的值恢复给对象头

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

      锁膨胀

      如果在尝试加轻量级锁的过程中,CAS 操作无法成功。
      例如:Thread-0 已经对该对象加了轻量级锁,当 Thread-1 进行轻量级加锁时,CAS 操作失败,此时便会进入锁膨胀流程,给对象加上重量级锁 (使用 Monitor)
  • 即:为锁对象申请 Monitor 锁,让 Object 指向重量级锁地址 (将 Mark Word 改为 Monitor 的地址, 并且状态改为 10(二进制) )

  • 并且该线程放入 Monitor 的 EntryList 中,进入阻塞状态 (blocked)

当 Thread-0 退出同步块解锁时,使用 CAS 操作将 Mark Word 的值恢复给对象头,会失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 blocked 线程

偏向锁

偏向锁用于:优化轻量级锁重入
轻量级锁在没有竞争时,每次重入 (该线程执行的方法中再次锁住该对象)仍然需要执行 CAS 操作。
Java 6 中引入偏向锁来做进一步优化:

  • 只有第一次使用 CAS 将线程 ID 设置到锁对象的 Mark Word ,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS
  • 以后只要不发生竞争,这个对象就归该线程所有
  • 偏向锁默认是有延迟的,不会在程序一启动就生效,而是在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态。如果想避免延迟,可以加参数 - XX:BiasedLockingStartupDelay=0 禁用延迟
  • 如果想禁用偏向锁,可以加参数 -XX:-UseBiasedLocking

偏向锁被撤销的情况

  • 调用锁对象的 hashCode() ,会将偏向锁升级为重量级锁
    • 偏向锁的对象 MarkWord 中存储的是线程 id
    • 轻量级锁会在锁记录中记录 hashCode
    • 重量级锁会在 Monitor 中记录 hashCode
  • 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
  • 调用了 wait() / notify() 导致锁膨胀而使用重量级锁

批量重偏向

  • 如果锁对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向 T1 线程的锁对象仍有机会重新偏向 T2 线程。重偏向会重置Thread ID
  • 当撤销超过20次(阈值)后,JVM 就会在给对象加锁时,重新偏向至加锁线程

批量撤销

  • 当撤销偏向锁超过 40 次(阈值)后,JVM 就会将整个类的所有对象都改为不可偏向的

    Monitor & 原理

    上篇:内存与垃圾回收篇
    Java 提供同步机制、互斥锁机制来保证:同一时刻只有一个线程能访问共享资源,这些机制的保障来源于 Monitor。
    Monitor 被翻译为监视器 或 管程,属于一种进程同步互斥工具。
    每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上重量级锁后,该对象头的 Mark Word 就被设置指向 Monitor 对象的指针。

Monitor 的结构图
管程 - 图4


在 HotSpot JVM 中 Monitor 是基于 C++ 由 objectMonitor 实现,其主要数据结构如下

  1. ObjectMonitor::ObjectMonitor() {
  2. _header = NULL;
  3. _count = 0;
  4. _waiters = 0,
  5. _recursions = 0;
  6. _object = NULL;
  7. _owner = NULL;
  8. _WaitSet = NULL;
  9. _WaitSetLock = 0 ;
  10. _Responsible = NULL;
  11. _succ = NULL;
  12. _cxq = NULL; //多线程竞争锁进入时的单向链表
  13. FreeNext = NULL;
  14. _EntryList = NULL; //
  15. _SpinFreq = 0;
  16. _SpinClock = 0;
  17. OwnerIsThread = 0;
  18. }

ObjectMonitor 重要属性解读:

  • _owner:指向持有该 ObjectMonitor 对象的线程
  • _WaitSet:存放处于 wait 状态的线程队列(双向循环链表,_WaitSet 是第一个节点)
  • _EntryList:存放处于等待锁 blocked 状态的线程队列
    • 双向循环链表,_EntryList 是第一个节点
    • _owner 从该双向循环链表中唤醒线程结点
  • _recursions:锁的重入次数
  • _count:该线程获取锁的次数

Monitor 的原理
当线程执行到临界区代码时,如果使用了 synchronized,先查询 synchronized 中所指定的对象 (obj) 是否绑定了 Monitor (绑定时,将对象头中的 Mark Word 置为 Monitor 指针)

  • 如果没有绑定,则先去与 Monitor 绑定,并将 Owner 设为当前线程
  • 如果已经绑定,则去查询该 Monitor 是否已经有了 Owner
    • 如果没有,则 Owner 与将当前线程绑定
    • 如果有,则当前线程放入 EntryList,进入阻塞状态 (blocked)

当持有对象锁的线程,将临界区中代码执行完毕后,该对象的 _Owner 便会被置为 null,此时 _EntryList 中处于阻塞状态的线程会被叫醒并竞争,此时的竞争是非公平的

wait() / notify() & 原理

obj.wait() 让进入 object 监视器的线程 (owner) 到 waitSet 无限制等待,直到 notify 为止。
obj.wait(long n) 有时限的等待,到 n 毫秒后结束等待,或是被 notify 唤醒提前结束等待
obj.notify() 从 object 绑定的 Monitor 的 waitSet 中等待的线程中挑一个唤醒
obj.notifyAll() 从 object 绑定的 Monitor 的 waitSet 中等待的线程全部唤醒
wait() 会释放对象锁
blocked 状态的线程会在锁被释放时被唤醒,但是处于 waiting 状态的线程只有当锁对象调用 notify() / notifyAll(),才会被唤醒。
但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争


必须获得此对象的锁,才能调用 wait() 和 notify()

  1. public class Test1 {
  2. final static Object LOCK = new Object();
  3. public static void main(String[] args) throws InterruptedException {
  4. //只有在对象被锁住后才能调用wait方法
  5. synchronized (LOCK) {
  6. LOCK.wait();
  7. }
  8. // 如果在这里调用 LOCK.wait() 会抛出 IllegalMonitorStateException
  9. }
  10. }

当有多个线程需要进行条件判断时,对象调用了wait(),此时这些线程都会进入 WaitSet 中等待。如果使用notify(),可能会造成虚假唤醒(唤醒的不是满足条件的等待线程),这时就需要使用 notifyAll(),并且条件判断用 while

  1. synchronized (LOCK) {
  2. while(//不满足条件,一直等待,避免虚假唤醒) {
  3. LOCK.wait();
  4. }
  5. // 满足条件后再运行
  6. }
  7. // 另一个线程
  8. synchronized (LOCK) {
  9. // 唤醒所有等待线程
  10. LOCK.notifyAll();
  11. }

wait() & sleep() 对比
不同点

  • sleep() 是 Thread 类的静态方法。wait() 是 Object 类的方法,任何对象实例都能调用
  • sleep() 在阻塞时不会释放对象锁,而 wait() 在阻塞的时候会释放对象锁
  • sleep() 不需要强制与 synchronized 配合使用,而 wait() 需要与 synchronized 一起使用

相同点

  • 阻塞状态都为 timed_waiting
  • 它们都可以被 interrupted() 中断

    park() / unpark() & 原理

    park() / unpark() 都是 LockSupport 类中的方法 ```java //暂停线程运行 LockSupport.park;

//恢复线程运行 LockSupport.unpark(threadName);

  1. **park() & wait() 对比**
  2. - wait(),notify() 必须配合 Object Monitor 一起使用,而 park() / unpark() 不必
  3. - park() / unpark() 是以线程为单位来阻塞和唤醒线程的,而 notify() 只能随机唤醒一个等待线程
  4. - park() / unpark() 可以先 unpark() park(),然后继续执行,不暂停线程运行。而 wait() / notify() 不能
  5. - park() 不会释放锁,而 wait() 会释放锁
  6. ---
  7. 每个线程都有一个自己的 Parker 对象。<br />Parker 对象由 _counter(信号量),_cond(条件变量),__mutex(互斥锁) 三部分组成。
  8. ---
  9. LockSupport.park() 的实现原理是:经过二元信号量作的阻塞,**要注意的是,这个信号量最多只能加到1**。<br />也能够理解成获取 释放许可证的场景。<br />unpark() 会释放一个许可证,park() 则是获取许可证。<br />若是当前没有许可证,则进入休眠状态,知道许可证被释放了才被唤醒。<br />**不管执行多少次 unpark(),也最多只会有一个许可证。**
  10. ---
  11. **先调用 park() 再调用 unpark() 时**
  12. - 线程运行时,会将 Parker 对象中的 _counter 值设为 0
  13. - **调用 park() 后**,
  14. - 会先检查 _counter 的值是否为 0
  15. - 如果为 0,获得 _mutex 互斥锁, 线程进入 _cond 条件变量 (阻塞队列) 阻塞
  16. - 放入阻塞队列中后,会再次将 _counter 设置为 0
  17. - **调用 unpark() 后**
  18. - 会将 _counter 值设置为 1
  19. - 唤醒 _cond 条件变量 (阻塞队列) 中的线程
  20. - 线程恢复运行,并将 _counter 值设置为 0
  21. **先调用 unpark(),再调用 park()**
  22. - 调用 unpark(),会将 _counter 设置为 1(线程运行时为 0
  23. - 调用 park()
  24. - 先检查 _counter 的值是否为 0。因为 unpark() 已经把 _counter 设置为 1,不为 0,所以将 _counter 的值设置为 0,但不放入阻塞队列 _cond
  25. <a name="ovDKs"></a>
  26. # 活跃性
  27. 一个并发应用程序能及时执行的能力称为活跃性<br />[死锁 & 活锁 & 饥饿的博客](https://blog.csdn.net/qq_22054285/article/details/87911464)
  28. <a name="TMyj5"></a>
  29. ## 死锁
  30. 死锁是指:两个或两个以上的线程在执行过程中,由于:竞争资源或者由于:彼此通信而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去,此时称系统处于死锁状态或系统产生了死锁<br />**发生死锁的必要条件**
  31. - 互斥条件:进程对资源的使用是排他性的使用,某资源只能由一个进程使用,其他进程需要使用只能等待
  32. - 请求保持条件:进程至少保持一个资源,又提出新的资源请求,新资源被占用,请求被阻塞,被阻塞的进程不释放自己保持的资源
  33. - 不可剥夺条件:进程获得的资源在未完成使用前不能被剥夺,获得的资源只能由进程自身释放
  34. - 环路等待条件:发生死锁的时候,必然存在进程-资源环形链
  35. **定位死锁的方法**
  36. - jps + jstack ThreadID:在 Java 控制台中的 Terminal 中输入 **jps **指令查看运行中的线程 ID,使用 **jstack ThreadID **查看线程状态
  37. - cmd 命令 jconsole 检测死锁
  38. - 如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程 id 来定位是哪个线程,最后再用 jstack 排查
  39. **预防死锁的方法**
  40. - 摒弃请求保持条件:系统规定进程运行之前,一次性申请所有需要的资源,进程在运行期间不会提出资源请求,从而摒弃请求保持条件
  41. - 摒弃不可剥夺条件:当一个进程请求新的资源得不到满足时,必须释放占有的资源,进程运行时占有的资源可以被释放,意味着可以被剥夺
  42. - 摒弃环路等待条件:可用资源线性排序,申请必须按照需要递增申请,线性申请不再形成环路
  43. <a name="lXovc"></a>
  44. ## 活锁
  45. > 活锁是指:线程 1 可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。
  46. 考虑一个场景:进程 P1 占有 A 资源,请求 B 资源,进程 P2 占有 B 资源,请求A 资源。<br />如果是等待式的请求,两者都会陷入无尽的等待中,这是**死锁**。<br />如果不是等待式的请求,而是一旦发现资源被占有就失败,整个请求取消(回滚)并重新开始。此时 P1 放弃占有 A 资源重新开始,P2 放弃占有 B 资源重新开始。则进程 P1P2 可能会出现重复不断的开始 - 回滚循环。称这种情况为**活锁。**
  47. <a name="Hxj75"></a>
  48. ## 饥饿
  49. 饥饿是指:系统不能保证某个进程的等待时间上界,从而使该进程长时间等待,当等待时间给进程推进和响应带来明显影响时,称发生了进程饥饿。<br />当饥饿到一定程度的进程所赋予的任务即使完成也不再具有实际意义时称该进程被饿死。
  50. <a name="OKxf5"></a>
  51. # ReentrantLock
  52. **ReentrantLock Synchronized 厉害的方面**
  53. - 在等待获取锁的过程中可打断
  54. - 可以设置获得锁等待超时时间
  55. - 可以设置为公平锁(先到先得)
  56. - 支持多个条件变量(具有多个 waitset
  57. - **共同点**:都支持可重入
  58. **ReentrantLock 基础用法**
  59. ```java
  60. // 获取 ReentrantLock 对象
  61. ReentrantLock lock = new ReentrantLock();
  62. // 加锁
  63. lock.lock();
  64. try {
  65. // 需要执行的代码
  66. } finally {
  67. // 释放锁
  68. lock.unlock();
  69. }

可重入

  • 可重入是指:同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
  • 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

可打断

  • 如果某个线程处于阻塞状态,可以调用其 interrupt() 让其停止阻塞,获得锁失败
  • 就是:处于阻塞状态的线程,被打断了就不用阻塞了,直接停止运行。也可以打断正在运行的线程
  • 获取锁的方法使用 lock.lockInterruptibly()、tryLock()可以被打断

锁超时

  • 使用 lock.tryLock() 会返回获取锁是否成功。成功返回 true,反之返回false
  • 使用 lock.tryLock(long timeout, TimeUnit unit) 指定等待时间,不指定则立即返回结果
  • 使用 tryLock() 就是:获取失败了、获取超时了或者被打断了,不再阻塞,直接停止运行

公平锁

  • ReentrantLock 默认是不公平的
  • ReentrantLock lock = new ReentrantLock(true) 参数 true,设置为公平锁
  • 公平锁一般没有必要,会降低并发度

    公平锁:每个线程抢占锁的顺序为调用 lock() 的先后顺序,类似于排队吃饭。 非公平锁:每个线程抢占锁的顺序不定,和调用 lock() 的先后顺序无关。

条件变量

  • synchronized 中也有条件变量,就是 waitSet,当条件不满足时进入 waitSet 中等待
  • ReentrantLock 支持多个条件变量
  • 使用要点:
    • await() 前需要获得锁
    • await() 执行后会释放锁,进入 conditionObject 等待
    • await() 的线程被唤醒(或打断、或超时)去重新竞争 lock 锁
    • 竞争 lock 锁成功后,从 await() 后继续执
      1. static ReentrantLock lock = new ReentrantLock();
      2. // 条件变量
      3. static Condition condition = lock.newCondition();
      4. // 阻塞
      5. condition.await();
      6. // 唤醒
      7. condition.signal();