Java高并发底层原理(九)—— ReentrantLock 为什么能够实现互斥
synchronized可以通过对象监视器实现互斥同一时刻只允许一个线程进入临界区。Java 还提供了另一种常用锁privatefinalReentrantLocklocknewReentrantLock();publicvoidincrement(){lock.lock();try{count;}finally{lock.unlock();}}ReentrantLock没有使用synchronized包围临界区却同样可以保证只有一个线程修改count。除此之外它还支持可重入、公平锁、可中断等待和超时获取等能力。本章只围绕一个主问题展开ReentrantLock为什么能够实现互斥。后文会从锁状态开始逐步引出 CAS、owner、重入计数、等待队列、公平策略和内存语义。每一节只在前文基础上增加一个必要条件不重复证明已经建立过的结论。一、锁需要记录什么状态先不看ReentrantLock的源码只考虑一把最简单的互斥锁需要解决什么问题。一把锁至少要区分两种状态空闲和已占用。可以先用一个整数表示state 0表示锁空闲state 1表示锁已被占用。classSimpleLock{privatefinalAtomicIntegerstatenewAtomicInteger(0);publicbooleantryLock(){returnstate.compareAndSet(0,1);}publicvoidunlock(){state.set(0);}}这段SimpleLock不是完整锁只用来建立第一个概念互斥首先需要一个可被竞争的状态位。线程调用tryLock()时如果能把state从0改成1就说明它成功占用了锁如果改失败就说明锁已经被其他线程占用。所以锁的本质不是某个特殊的物理对象而是一组围绕状态变化建立的规则谁能把空闲状态改成占用状态谁就能进入临界区谁持有占用状态谁才有资格释放它。二、为什么获取锁需要 CAS上一节的state只是记录状态还没有说明多个线程同时修改它时如何保证安全。问题出在普通判断和赋值不是一个不可分割的整体。假设线程 A 和线程 B 同时执行下面的逻辑if(state0){state1;}可能出现这样的多线程交错步骤Thread AThread Bstate1读取到002读取到003写入114写入11两个线程都基于“我刚才看到的是0”做出判断最后都会认为自己获得了锁。这里破坏互斥的根因是读取、判断、写入被拆成了多个步骤中间被另一个线程插入了。CAS 要解决的正是这个问题它把“当前值是否仍然等于期望值”和“如果相等就写入新值”合并成一个原子操作。state.compareAndSet(0,1);其中0是期望值1是新值。只有执行 CAS 的那一刻state仍然是0当前线程才会成功写入1。多个线程同时竞争时最多只有一个线程能完成从0到1的状态转换。OpenJDK 中的ReentrantLock在锁空闲时也会先通过 CAS 修改同步状态CAS 成功后当前线程才进一步成为持锁线程。到这里我们只解决了“谁先占住状态位”的问题还没有解决“占住之后应该由谁释放”的问题。三、只记录 state 为什么还不够沿用前文的SimpleLock如果锁只记录state它只能知道“锁是否被占用”却不知道“锁被谁占用”。这会带来一个直接问题没有获得锁的线程也可能错误地释放锁。例如线程 A 已经获得锁线程 B 没有获得锁但 B 错误调用了unlock()。如果unlock()只是简单地把state改回0线程 C 就可能随后获得锁。此时线程 A 仍在临界区中线程 C 也进入了临界区互斥就被破坏了。因此独占锁不能只记录状态还必须记录当前持锁线程字段作用state记录锁是否被占用后面还会扩展为重入次数owner记录当前持有这把锁的线程有了owner之后释放锁前必须校验当前线程是否就是持锁线程if(owner!Thread.currentThread()){thrownewIllegalMonitorStateException();}这一步补上了前文state模型缺失的身份约束CAS 决定谁能第一次占住锁owner决定谁有资格继续操作这把锁。四、什么是可重入有了owner之后还可以继续解决一个常见场景同一个线程已经持有锁又在临界区中调用另一个也需要同一把锁的方法。publicvoidmethodA(){lock.lock();try{methodB();}finally{lock.unlock();}}publicvoidmethodB(){lock.lock();try{// 执行业务}finally{lock.unlock();}}线程 A 进入methodA()后已经持有锁随后调用methodB()时又申请同一把锁。如果锁只允许state 0的线程进入那么线程 A 会等待自己释放锁而它又必须等methodB()返回后才能继续执行外层unlock()这就形成自我阻塞。可重入锁的处理方式是如果申请锁的线程已经是owner就允许它再次进入但要把state从单纯的占用标记扩展为重入计数。state值含义0锁空闲1owner 获取了一次锁2owner 重入了一次总共获取两次nowner 总共获取了n次简化后的获取逻辑如下booleantryAcquire(){ThreadcurrentThread.currentThread();intcurrentStategetState();if(currentState0){if(compareAndSetState(0,1)){ownercurrent;returntrue;}}elseif(ownercurrent){setState(currentState1);returntrue;}returnfalse;}这段逻辑是在前文基础上的增量扩展锁空闲时仍然通过 CAS 竞争锁已被占用时如果 owner 是当前线程就增加重入计数如果 owner 不是当前线程获取失败。五、重入之后如何释放锁如前所述state已经从“是否占用”扩展成了“重入次数”。因此释放锁也不能简单地把state改回0而是每次unlock()只减少一次计数。对于前文的methodA()和methodB()嵌套调用线程 A 一共调用了两次lock()也必须调用两次unlock()。第一次解锁只把state从2减到1线程 A 仍然持有锁第二次解锁把state从1减到0锁才真正释放。简化后的释放逻辑如下voidrelease(){ThreadcurrentThread.currentThread();if(owner!current){thrownewIllegalMonitorStateException();}intnewStategetState()-1;if(newState0){ownernull;}setState(newState);}这一节只补充释放规则每一次成功加锁都必须对应一次解锁只有state减到0owner 才会被清除锁才真正进入空闲状态。六、为什么 unlock 通常不需要 CAS前文已经说明获取锁时需要 CAS是因为多个线程可能同时尝试把state从0改成1。但锁被某个线程持有后情况发生了变化重入和解锁都只能由 owner 合法执行。也就是说在state 0的持锁阶段不存在两个合法线程同时修改同一把独占锁重入计数的情况。非 owner 调用unlock()会被拒绝而 owner 自己增加或减少计数不需要和其他线程争夺这次计数修改。所以 CAS 主要出现在锁所有权发生转移的入口锁空闲时多个线程竞争成为 owner一旦 owner 确定重入计数的增加和减少就属于 owner 自己的内部状态维护。这并不是说unlock()不重要。相反unlock()是后续线程能否继续推进的关键只是它通常不需要用 CAS 解决“多个合法释放者同时竞争”的问题。七、获取锁失败后为什么不能一直重试到目前为止锁已经能够记录状态、区分 owner并支持重入。但还有一个问题没有解决如果锁被其他线程持有获取失败的线程应该怎么办最直接的做法是不断重试while(!tryAcquire()){}这种方式称为忙等待。它的问题是线程虽然没有进入临界区却一直占用 CPU不断读取state、尝试 CAS、失败后再重复。竞争激烈时多个线程还会反复争夺保存state的 Cache Line进一步放大 CPU 和缓存一致性开销。短时间自旋有时有价值因为锁可能很快释放但如果持锁时间较长失败线程继续空转就没有意义。ReentrantLock不能只靠 CAS 循环还需要一种机制把失败线程保存起来并让它们暂时停止运行。这就引出了 AQS。AQS全名是AbstractQueuedSynchronizer在这里先只引入它的一个作用用等待队列管理获取同步状态失败的线程。后文只围绕这个作用展开不提前进入 AQS 的完整源码细节。八、等待队列解决了什么问题假设线程 A 已经持有锁线程 B、C、D 依次调用lock()并且都获取失败。沿用前文结论这些失败线程不能无限忙等所以它们会进入 AQS 等待队列。队列至少解决两个问题第一记录哪些线程正在等待第二锁释放时能找到相对靠前的等待线程继续推进。可以把等待关系理解为B 先等待C 排在 B 后面D 排在 C 后面。AQS 中等待线程会被包装成节点节点中保存等待线程以及前后节点引用。这里不展开节点字段只建立本章需要的抽象获取失败的线程从 CPU 上持续竞争转为在队列中暂停等待。这个转变很重要。前文的 CAS 负责解决“谁能成功获得锁”等待队列负责解决“失败者如何不浪费 CPU”。两者不是替代关系而是连接关系先尝试获取失败后排队被唤醒后再重新尝试获取。九、释放锁后为什么只唤醒一个线程有了等待队列后unlock()的后半段才有意义当 owner 把state减到0锁真正释放队列中的等待线程需要被唤醒。如果前文的 B、C、D 都在等待通常只需要优先唤醒靠前的线程例如 B。因为即使同时唤醒 B、C、D最终也只有一个线程能通过 CAS 获得锁另外两个线程恢复运行后还要再次失败并重新等待。这会增加线程调度、上下文切换和 Cache Line 竞争。因此释放锁后的策略不是“把锁直接交给所有等待线程”也不是“直接指定某个线程已经获得锁”而是唤醒相对靠前的等待线程让它恢复运行后重新检查状态并尝试获取。这里要保留一个关键结论唤醒不等于交付锁。被唤醒线程只是重新获得竞争机会只有它再次成功修改state并成为 owner才真正进入临界区。十、公平锁和非公平锁有什么区别前文已经有了等待队列接下来才能解释公平锁和非公平锁的差异。差异不在于是否使用state、CAS、owner 或等待队列而在于新来的线程是否必须尊重队列中已经等待的线程。ReentrantLock默认使用非公平模式ReentrantLocklocknewReentrantLock();也可以显式创建公平锁ReentrantLocklocknewReentrantLock(true);公平锁在尝试获取锁前会检查队列中是否已经有排在前面的等待线程。如果有新来的线程不能直接插队而是进入队尾等待。非公平锁则允许新来的线程先尝试 CAS如果它刚好在锁释放后抢到了state就可能先于队列中的旧线程获得锁。用前文的 B、C、D 等待队列举例线程 B 已经在队首等待线程 A 刚释放锁线程 D 此时正好新调用lock()。在非公平模式下D 可能因为正在 CPU 上运行而率先 CAS 成功在公平模式下D 会先检查前面是否已有等待线程如果有就不能直接绕过 B。非公平锁允许插队是为了减少线程切换成本新来的线程可能已经处于运行态可以马上进入临界区队首线程即使被唤醒也可能还要等待操作系统调度。公平锁减少插队带来的饥饿风险但可能牺牲一部分吞吐。公平锁也不等于绝对时间顺序。线程可能被中断、取消或受到操作系统调度影响。它表达的是一种获取锁策略尽量尊重等待队列不让新线程随意绕过已有前驱。十一、ReentrantLock 如何保证可见性前文主要解释互斥同一时刻只有 owner 能进入临界区。但锁还必须解决另一个问题前一个线程在临界区中修改的数据后一个线程获得同一把锁后必须能够看到。// Thread Alock.lock();try{data42;}finally{lock.unlock();}// Thread Block.lock();try{System.out.println(data);}finally{lock.unlock();}如果线程 A 成功释放lock线程 B 随后成功获得同一把lock那么线程 A 在释放锁之前对共享数据做出的修改必须对线程 B 可见。这是锁的内存同步语义。换句话说state、CAS、owner 和等待队列解释的是“线程如何排他地进入临界区”锁的内存语义解释的是“临界区中的数据如何在线程之间正确传递”。如果只有互斥而没有可见性后一个线程虽然不会和前一个线程同时执行临界区却仍可能看不到前一个线程的修改这样锁就不能完成并发控制的目标。因此ReentrantLock和synchronized一样不只是控制进入顺序还在成功释放和后续成功获取同一把锁之间建立可见性保证。十二、为什么 unlock 必须写在 finally 中ReentrantLock与synchronized在使用方式上有一个重要区别synchronized会在代码块退出时自动释放 monitor而ReentrantLock必须显式调用unlock()。标准写法是lock.lock();try{// 访问共享数据}finally{lock.unlock();}finally的必要性来自前文的 owner 和等待队列规则。线程获得锁后如果临界区抛出异常并提前退出但没有执行unlock()state就不会归零owner 也不会被清除等待队列中的线程就可能长期无法继续。需要注意lock()应放在try之前。如果lock()本身没有成功获得锁就不应该在finally中执行unlock()。标准结构表达的是只有成功进入临界区之后才确保退出时释放锁。十三、ReentrantLock 的完整执行过程现在可以把前面的增量条件合并成一条执行链。线程调用lock()时先根据state判断锁是否空闲。若state 0多个线程通过 CAS 竞争成功者把自己记录为 owner 并进入临界区。若state 0且 owner 是当前线程说明发生重入只需要增加state。若state 0且 owner 不是当前线程说明锁被其他线程持有当前线程获取失败并进入 AQS 等待队列。线程调用unlock()时先检查当前线程是否为 owner然后把state减一。如果state仍大于0说明只是退出了一层重入锁还没有真正释放如果state变成0owner 被清除锁进入空闲状态并唤醒等待队列中相对靠前的线程。被唤醒线程不会直接拥有锁而是重新回到获取流程通过 CAS 再次竞争。在这条链路中每个组件都有明确分工state承载锁状态和重入计数CAS 保证空闲锁只能被一个线程抢占owner 限制重入和释放资格AQS 队列保存失败线程唤醒动作把等待线程重新送回竞争入口锁的内存语义保证临界区修改能被后续持锁线程看到。写后去重检查检查项处理结果state的含义是否重复定义只在第一节完整定义第四节仅增量扩展为重入计数CAS 竞争过程是否重复画图只在第二节用一次多线程交错表说明后文均回指owner 的必要性是否重复证明只在第三节用非 owner 错误解锁说明后文直接使用结论可重入例子是否重复展开只在第四节完整展开methodA()/methodB()第五节只承接释放规则等待队列结构是否重复绘制未重复绘制队列图只用文字说明队首、队尾和唤醒关系总结是否逐节复述已改成因果链整合不逐条重复各节结论本章总结ReentrantLock的实现可以看成一条逐步收紧的因果链共享变量需要互斥访问所以必须有一个状态表示锁是否空闲状态会被多个线程同时竞争所以获取入口必须使用 CASCAS 只能决定谁抢到锁不能说明谁有资格继续操作所以还要记录 owner同一个 owner 可能嵌套申请同一把锁所以state又从占用标记扩展为重入计数重入计数只有归零才表示所有临界区层级都已经退出因此真正释放锁发生在最后一次unlock()。一旦释放动作出现问题又从“谁持有锁”转向“失败线程如何继续”。如果失败线程一直自旋CPU 会被无效竞争消耗所以 AQS 用等待队列保存它们如果释放时唤醒所有等待线程又会制造新的竞争浪费所以通常只推进相对靠前的线程被唤醒线程仍要重新竞争是因为锁的所有权始终只能通过状态转换确认而不是由唤醒动作直接赠送。因此ReentrantLock并不是由某一个技巧实现互斥而是把状态竞争、持有者身份、重入计数、失败等待、释放唤醒和内存语义连接成一条完整路径先用 CAS 建立唯一 owner再用 owner 和state维持独占关系最后通过 AQS 队列和锁的同步语义把竞争失败的线程和临界区数据一起安全地推进到下一轮执行。