前言:一场由于“抢票”引发的血案
在多线程和高并发的场景下,最怕的就是共享数据被改乱了。比如两个人同时买最后一张火车票,如果不加控制,两人都以为自己买到了,结果系统扣了两次钱,票却只有一张。为了解决这个问题,我们在程序设计中引入了“锁”的概念。
针对不同的业务场景,大佬们发明了两种截然不同的并发控制思想:悲观锁与乐观锁。今天我们就来彻底扒下它们的外衣,看看它们到底是怎么玩的!
一、 悲观锁(Pessimistic Lock)—— “总有刁民想害朕”
1. 核心思想
悲观锁,人如其名,极其悲观。它认为在并发环境下,只要我不把门锁死,别人就一定会来捣乱修改数据。
因此,它的策略是:先上锁,再操作。任何想要访问受保护数据的线程,都必须先获取锁。拿不到锁的就在外面排队(挂起/阻塞),直到上一个线程操作完毕释放锁。
2. 典型代表
- Java中的
synchronized关键字。 - Java中的
ReentrantLock。 - 数据库里的表锁、行锁(如
SELECT ... FOR UPDATE)。
3. 代码实战与底层解析
我们来看一个经典的转账或者扣库存的例子:
importjava.util.concurrent.locks.ReentrantLock;publicclassPessimisticLockDemo{privateintticketCount=10;// 定义一个典型的悲观锁privatefinalReentrantLocklock=newReentrantLock();publicvoidbuyTicket(StringuserName){// 第一步:一上来就加锁!不给别人任何机会lock.lock();try{// 此时其他线程执行到 lock.lock() 会被全部阻塞(挂起排队)System.out.println(userName+" 准备买票,当前余票:"+ticketCount);if(ticketCount>0){// 模拟业务处理耗时Thread.sleep(100);ticketCount--;System.out.println(userName+" 买票成功!剩余:"+ticketCount);}else{System.out.println(userName+" 买票失败,已经被抢光了...");}}catch(InterruptedExceptione){e.printStackTrace();}finally{// 最后一步:无论是正常结束还是发生异常,必须要释放锁!lock.unlock();}}}【优缺点剖析】
- 优点:绝对安全。不管并发多高,数据绝对不会错。
- 缺点:太重了!线程一直加锁释放锁、阻塞唤醒,需要请求操作系统从用户态切换到内核态,开销极大。这就好比上公共厕所,进去一个人就把大门锁了,外面的人只能干等。
二、 乐观锁(Optimistic Lock)—— “阳光大男孩的自信”
1. 核心思想
乐观锁非常开朗,它认为并发冲突是小概率事件,平时大家各凭本事去拿数据,不会有人来捣乱。
因此,它的策略是:不上锁,只在最后更新的时刻去检查一下数据有没有被人动过。
- 如果没被动过,更新成功!
- 如果被动过了(别人捷足先登),更新失败,然后选择重试(自旋)或者放弃。
2. 核心技术:CAS (Compare And Swap - 比较并交换)
CAS 是乐观锁的灵魂。它包含三个参数:
- V:内存里的当前值
- E:我期望的原本旧值
- N:我想要修改成的新值
执行逻辑:当我要更新时,只有发现内存当前值 V 等于我期望的旧值 E 时,我才把值修改为 N。如果不等于,说明被别人改过了,我就放弃或者重新拿最新的 V 值再试一次。
3. 代码实战与底层解析
Java并发包java.util.concurrent.atomic下的原子类全是乐观锁的实现。
importjava.util.concurrent.atomic.AtomicInteger;publicclassOptimisticLockDemo{// 这是一个利用 CAS 实现的乐观锁整数类,底层无 synchronizedprivateAtomicIntegerticketCount=newAtomicInteger(10);publicvoidbuyTicket(StringuserName){intexpect;// 我期望拿到的旧票数intupdate;// 我抢购完后的新票数// 核心套路:自旋(死循环) + CAS检查do{// 首先看一眼当前的余票是多少expect=ticketCount.get();if(expect<=0){System.out.println(userName+" 发现没票了,放弃挣扎。");return;}// 假设我买走一张,期望新票数update=expect-1;// 下面这行就是 CAS 操作:// "如果当前内存里的票数和我看的时候(expect)还是一致的,那就把它变成 update。"// 如果在这期间有人买走了,ticketCount.compareAndSet 会返回 false,循环继续(这就叫自旋)。}while(!ticketCount.compareAndSet(expect,update));System.out.println(userName+" 买票成功!剩余:"+update+" (使用了乐观理念)");}}4. 致命漏洞:ABA问题
乐观锁有一个经典 Bug:ABA 问题。
老王看一眼桌上的钱是 100 块(这是 A),准备闭眼拿走。这时小偷把 100 块偷走了(变成 B),过了几秒觉得心虚,又放了 100块假钞回去(又变回 A)。老王睁眼一看,还是 100 块(满足CAS条件),开开心心拿走了。但他不知道这中间钱已经被掉包了!
解决方案:加个版本号(Stamp)。每次修改数据不仅对比值,还要看版本号递增了没。Java 中提供了AtomicStampedReference专门用来解决 ABA 问题!
总结:到底谁更胜一筹?
没有绝对的王者,只有最适合的兵器。
- 读多写少的场景:冲突较小,使用乐观锁(CAS)。省去了线程挂起的时间,性能极高。
- 写多读少的场景:冲突极其激烈。如果还用乐观锁,会导致大量线程在死循环(自旋),把CPU直接打满耗爆!这时候必须用悲观锁(synchronized 或 Lock),乖乖排队才是王道。