乐观锁与悲观锁
锁是并发控制
的方式,什么是并发控制呢?当程序出现并发情况时,需要通过一定的方式来保证并发情况下数据的准确性,保证多个用户同时操作同一数据时所得到的结果,与单一用户操作得到的结果是一致的,这就是并发控制;并发控制的目的是保证多个用户同时工作时,一个用户的工作不会对另一个用户的工作产生不合理的影响
。
如果程序没有做好并发控制,会导致数据的脏读、幻读和不可重读
等问题。下面举一个例子:

从案例图中不难发现问题所在,在不进行任何并发控制的情况下,两个用户同时读取数据,然后对数据进行处理更新时,会导致数据不准确。
我们常说的并发控制,一般都和数据库管理系统(DBMS)有关,在 DBMS 中的并发控制的任务就是确保在多个事务同时存取数据库中同一条数据时不破坏事务的隔离性和统一性以及数据库的统一性。
注意
乐观锁与悲观锁是一种并发控制思想,不是只有关系型数据库系统中存在乐观锁与悲观锁的概念,像其他数据库,如 memcahe
、hibernate
等都有类似的概念。所以不应该拿乐观锁、悲观锁与其它数据库锁进行对比。
乐观锁
乐观锁认为数据的操作是读取多、写入少,也就是并发写操作少。所以在使用乐观锁的场景中,读取数据时是不需要对数据进行加锁的;只有在提交更新数据的时候,才会对数据进行冲突检测,如果发现写入数据冲突了,则返回一个错误给用户,让用户去决定如何处理。
乐观锁在对数据进行处理的时候,不会使用数据库提供的锁机制,通常乐观锁的实现方式是记录数据版本,也就是在每一条数据上增添一个版本字段;在提交更新的时候,通过比较版本号检测冲突。
乐观锁认为数据库事务之间的数据竞争(data race)的概率很小,所以只在数据更新时进行冲突检测,所以使用乐观锁是不会产生任何的数据库锁和死锁。
实现方式
乐观锁的实现主要是涉及两个问题:数据的冲突检测
和数据更新
。其 CAS
就是一种典型的乐观锁实现技术。使用 CAS 技术实现乐观锁,存在ABA问题
(ABA 问题的罪魁祸首是每次提交更新时,比较的是被更新变量的值与当前线程保留的值;业务数据很少有呈单调变化,而 CAS 就是假设数据单调变化,每一次更新后的结果都不一样,甚至是不与历史数据重复
),如下图所示:

用户 A 连续提交了两次报名请求,此时两次请求获取到的已报名人数都是 10;当第一次报名请求处理完毕之后,已报名人数为 11,但此时有一个新的请求(用户 B 取消报名)被处理,报名总人数 - 1 = 10,然后到处理用户 A 的第二报名请求时,用已报名人数 10 与当前已报名人数对比,结果是相等的就执行了更新操作,就会导致总的报名人数比实际报名人还多了。这就是 CAS 的 ABA问题
引发的数据错误。
针对 ABA问题
,可使用数据版本号的方式去解决,也就是为每一个数据添加一个版本号属性,并在每一次更细操作时同时更新版本号,然后每一次冲突检测,使用数据版本号进行对比检测,这样可有效的避免 ABA问题
;但别忘了,我们这里讨论的是并发控制,CAS 只允许某一个时间点只有一个线程被提交更新操作,而其它没有被执行的线程任务不会被挂起,以操作失败的形式返回给用户处理(线程不被挂起,是为了防止 CAS 循环操作,可能导致线程一直不被执行而陷入死循环的问题,也就是CAS 自旋问题
),这样的结果就会导致高并发的情况下,会有大批量的线程被执行失败,显然这是不被允许的。

导致这个问题的根本原因是冲突检测粒度
没有把控好,在上图案例中,是通过使用数据版本号的方式进行冲突检测的,检测的细粒过小,导致在高并发下出现大批量的请求不被处理;换种方式检测,例如取消时,检测总人数 - 1 不能小于 0
;报名请求时,检测总人数 + 1 不能大于阈值
(阈值是设定的),而不是直接检测 总人数version 是否相等
。所以在使用乐观锁的场景下,一定要把控好冲突检测的粒度,粒度过大可能会导致 ABA 问题,过细会导致大批量的请求失败;高并发环境下锁粒度把控是一门重要的学问,选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。
提示
什么是 CAS
CAS(Compare And Swap),翻译过来就是比较并交换
,其作用是让 CPU 比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不更新;CAS 是原子性
操作(读和写两者同时具有原子性),因此在解决多线程安全性问题时,效益很高。
CAS 就是乐观锁一种实现,每次在数据提交更新时,都去当前更新的值与修改前的值是否一致,一致则更新,不一致则循环进行 CAS 操作(非阻塞同步机制)
。
缺点
1、ABA 问题
现有两个线程 A 和 B,它们同时读取一个变量 NUM = 10
,分别赋值 OLD_NUM_A
、OLD_NUM_B
,由于线程 B 先获取到了 CPU 调度,线程 B 检测 OLD_NUM_B == NUM
,执行更新操作 NUM = NUM - 1
,由于业务原因线程 B 又一次读取 NUM,并执行了 NUM = NUM + 1
;然后线程 A 执行的时 OLD_NUM_A = NUM
,执行 NUM = NUM - 1
。线程 A 和 B 执行的结果从字面量上来说没有任何问题,但是问题出现在 OLD_NUM_A
和检测时得到 NUM
已经不一致了。
解决方法: 在变量前面加上版本号,每次变量更新的时候变量的版本号都 +1,即 A->B->A
就变成了 1A->2B->3A
。
2、循环时间长开销大
如果 CAS 操作失败,就绪要循环进行 CAS 操作(循环同时将期望值更新为最新的),如果长时间不成功的话,那么会造成 CPU 极大的开销(这种循环也成为自旋
)。
解决方法: 限制自旋次数,防止进入死循环。
3、只能保证一个共享变量的原子操作
CAS的原子操作只能针对一个共享变量。
解决方法: 如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作。
悲观锁
悲观锁认为数据写操作频繁,即并发写的可能性高,每次去获取数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,当下一个线程想要访问数据时,就需要等待直到拿到该数据对象锁。Java 中最典型的悲观锁就是 synchronized
。悲观锁采用“先取锁再访问”的保守策略,为数据处理提供了安全保证,但同时带来了效率问题,因为数据加锁操作会使系统产生额外的开销,还有可能产生死锁;另外,还会降低并行性,如果一个线程锁定了某条数据,那么其他线程想要访问那条数据就必须等待当前线程处理完毕。
