什么是乐观锁
乐观锁在操作数据时非常乐观,每次读数据的时候都认为别人不会同时修改数据,所以不会上锁。只是在更新数据的时候,判断一下在此期间别人是否修改了数据,如果修改了则放弃更新,否则执行操作。
什么是悲观锁
悲观锁在操作数据时比较悲观,每次读数据的时候都认为别人会同时修改数据。所以每次在读数据的时候都会上锁,直到操作完成后才会释放锁,这样别人想读取数据就会阻塞直到获取锁。
乐观锁与悲观锁的实现方式
乐观锁的实现方式主要有两种:CAS 机制和版本号机制
CAS(Compare And Swap,比较并交换)
CAS 机制是最典型的乐观锁实现方式,在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是基于 CAS 实现的乐观锁。
CAS 操作包括了 3 个操作数:内存地址(V)、旧值(A)、拟写入的新值(B)
操作数据之前先记录目前数据的地址 V 与值 A ,操作数据得到结果为 B。
操作结束后,如果内存地址 V 的值等于 A 即表明在此期间数据未被修改,则将该位置的值更新为 B,否则不进行任何操作。
许多 CAS 的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
这里引出一个新的问题,既然 CAS 包含了 Compare 和 Swap 两个操作,它又如何保证原子性呢?
答案是:CAS 是由 CPU 支持的原子操作,其原子性是在硬件层面进行保证的。
下面看一下 Java 中的 AtomicInteger 的源码:
1 | public class AtomicInteger extends Number implements java.io.Serializable { |
版本号机制
版本号机制一般用于数据库,在 mysql 数据库中的 InnoDB 存储引擎的 MVCC 控制方式(即 Mutil-Version Concurrency Control,多版本并发控制)就是基于此机制实现。
其基本思路是,在数据中增加一个字段 version ,表示该数据的版本号,每当数据被修改,版本号加 1。当某个线程查询数据时,将该数据的版本号一起查出来,在该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作,否则重试更新操作,直到更新成功。
也可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。
下面以“更新玩家金币数”为例,看看版本号机制是如何应对并发问题的。
游戏系统需要更新玩家的金币数,更新后的金币数依赖于当前状态(如金币数、等级等),因此更新前需要先查询玩家当前状态。
1 |
|
上面的实现方式,没有进行任何线程安全方面的保护。如果有其他线程在 query 和 update 之间更新了玩家的金币数,会导致这一线程覆盖掉更新的信息,导致玩家金币数的不准确。
版本号机制为玩家信息增加一个字段:version。在初次查询玩家信息时,同时查询出version信息;
1 |
|
在执行 update 操作时,校验 version 是否发生了变化,如果 version 变化,则重试更新操作。
悲观锁的实现
传统的关系型数据库里就用到了很多悲观锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁也是悲观锁思想的实现。
对于 Java 中的自增操作,可以直接通过 synchronized 上锁,保证任何时刻只有一个线程在执行这个函数
1 | private static int value = 0; |
对于数据库更新玩家金币数的操作,也可以通过 select ... for update
查询语句进行查询,该查询语句会为该玩家数据加上排它锁,直到事务提交或回滚时才会释放排它锁。在此期间,如果其他线程试图更新该玩家信息或者执行 select for update,会被阻塞。
乐观锁的缺点
1. ABA 问题
对于 CAS 机制,若在线程 1 操作数据期间,线程 2 把数据由 A 变为 B 再变为 A,线程 1 在要更新数据时就会认为数据没变化。J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。不过,大部分情况下 ABA 问题不会影响程序并发的正确性。
版本号机制不存在 ABA 问题。
2. 自旋开销大
自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
3. 难以实现跨域的原子操作
例如,CAS 只能保证单个变量操作的原子性,当涉及到多个变量时,CAS 是无能为力的,而悲观锁 synchronized 则可以通过对整个代码块加锁来处理。
再比如版本号机制,如果 query 的时候是针对表1,而 update 的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
悲观锁的缺点
悲观锁由于被堵塞线程不断尝试去获得锁,会造成性能损耗,以及加锁和释放锁都需要消耗额外的资源。
乐观锁与悲观锁的使用场景
功能限制
正如乐观锁的第三个缺点所说,乐观锁很难实现跨域的原子操作,如果在功能上无法通过乐观锁实现这个业务,就只能通过上悲观锁来实现。
竞争程度
如果悲观锁和乐观锁都可以使用,那么就要考虑竞争的激烈程度:
当竞争不激烈(即读多写少)时选乐观锁,可以增加并发量,相比而言悲观锁会锁住代码块或数据,其他线程无法同时访问,降低并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(即写多读少)时选悲观锁,因为竞争激烈时,CAS 自旋的概率会比较大,从而浪费更多的CPU资源,效率低于 synchronized 。