乐观锁和悲观锁

对待数据并发的锁的设计思想分为乐观锁和悲观锁。

  • 乐观锁
    乐观锁对每次数据操作保持乐观态度,不会对数据进行上锁。每次修改数据的时候需要对数据判断是否被修改过。
  • 悲观锁
    悲观锁在操作数据时候比较悲观,认为别人会同时修改数据。每次操作的时候会对数据上锁,防止在操作的过程中被他人同时操作导致更新丢失。

实现方式

  • 乐观锁:
  1. 采用版本号或者时间戳。在表中增加version 或者 updatetime 字段,在每次更新操作的时候对比下该字段,如果一直则更新数据,数据不等则放弃本次修改,根据实际业务需求做相应的处理。

    CAS包含了Compare和Swap两个操作,CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

  2. 采用 CAS 方式,对应 java 中的(compareAndSwap)。CAS 操作涉及到三个操作数,内存值(valueOffSet)、期望值(expect)、更新值(update)。当内存值和期望值一致的时候会更新数据,反之不会操作。

  • 悲观锁:
  1. 数据库实现方式:使用数据库的读锁、写锁、行锁等实现进程的悬挂阻塞等当前操作完成之后才能进行下一个操作。
  2. java 中采用 synchronize 关键字实现悲观锁。

优缺点

乐观锁和悲观锁并没有优劣之分,下面从功能上和竞争激烈程度两个方面来分析其各自的优势以及适用场景。

  • 适用场景:
    乐观锁:由于不上锁的特性,性能方面由于悲观锁,适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
    悲观锁 适合写操作比较多的场景,具有排他性。采用悲观锁的方式可以在数据库层面阻止其他事务对该数据的操作权限,防止读-写和写-写冲突。

  • 功能限制
    与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。
    例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

  • 竞争激烈程度
    如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
    当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
    当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

CAS的不足

  1. 无法解决ABA问题
    (1)线程1读取内存中数据为A
    (2)线程2将该数据修改为B
    (3)线程2将该数据修改为A
    (4)线程1对数据进行CAS操作
    在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
    但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

  2. 高竞争下的开销问题 在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。

  3. 功能限制 CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性。
    (1) 原子性不一定能保证线程安全,例如在Java中需要与volatile配合来保证线程安全;
    (2) 当涉及到多个变量(内存值)时,CAS也无能为力。