面试题系列--乐观锁和悲观锁
一,基本概念
- 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
- 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
注意:乐观锁本质没有锁,因此使用它可以提高代码执行效率,不会阻塞,不会等待,会重试。
二,锁的实例
乐观锁:1.在Java中java.util.concurrent.atomic包下面的原子变量类(采用CAS机制)。
2.版本号机制。
悲观锁:悲观锁的实现方式就是加锁,通过给代码块加锁,或数据加锁。
1.给数据加锁–传统的关系型数据库中的锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
2.给代码块加锁–比如Java里面的同步原语synchronized关键字的实现也是悲观锁。
1,CAS机制
CAS操作方式:即compare and swap 或者compare and set ,涉及到三个操作数,数据所在的内存地址(V),预期值(A),新值(B)。当需要更新时,判断当前内存地址的值与之前取到的值是否相等,若相等,则用新值更新,若不等则重试,一般情况下是一个自旋操作,即不断的重试。
CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的现值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器将重复匹配。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)
CAS是乐观锁技术,当多个线程同时尝试使用CAS更新同一个变量时,只有其中一个线程能成功更新变量的值,其它线程均会失败,但失败的线程并不会被挂起,只是被告知这次竞争失败,并且允许失败的线程再次尝试,当然也允许失败的线程放弃操作。这里再强调一下,乐观锁是一种思想,CAS是这种思想的一种实现方式。虽然与加锁相比,CAS比较交换会使程序看起来更加复杂一些。但由于其非阻塞性,对死锁问题天生免疫,更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁方式的实现拥有更优越的性能。
在Java1.5之前是靠synchronized关键字保证同步的,这是一种独占锁,也是一种悲观锁,是一个重量级的操作,因为加锁需要消耗额外的资源,还因为线程状态的切换会涉及操作系统核心态和用户态的转换;所以在1.5之后Java增加了并发包Java.util.concurrent.*(j.u.c)。J.U.C就是建立在CAS之上的,相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。不过随着JVM对锁进行的一系列优化(如自旋锁、轻量级锁、锁粗化等),synchronized的性能表现也已经越来越好。
现在来介绍AtomicInteger。AtomicInteger是java.util.concurrent.atomic包提供的原子类,利用CPU提供的CAS操作来保证原子性;除了AtomicInteger外,还有AtomicBoolean、AtomicLong、AtomicReference等众多原子类。原子操作类大致可以分为4类:原子更新基本类型,原子更新数组类型,原子更新引用类型,原子更新属性类型。这些原子类中都是用了无锁的概念,有的地方直接使用了CAS机制。
下面以 java.util.concurrent 中的 AtomicInteger 为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解 getAndIncrement 方法,该方法的作用相当于 ++i 操作。
public class AtomicInteger extends Number implements java.io.Serializable { //在没有锁的机制下,字段value要借助volatile原语,保证线程间的数据是可见性。 private volatile int value; //Unsafe用于实现对底层资源的访问 private static final Unsafe unsafe = Unsafe.getUnsafe(); //valueOffset是value在内存中的偏移量 private static final long valueOffset; //通过Unsafe获得valueOffset static { try { valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } public final int getAndIncrement() {//相当于++i操作 for (;;) { int current = get();//获取值 int next = current + 1;//+1操作 if (compareAndSet(current, next))//current是预期值,即从主存中取来还未操作过的值,next更新后的值 return current; } } }
源码分析说明如下:
(1)getAndIncrement()实现的自增操作是自旋CAS操作:每次获取数据时,都要循环进行compareAndSet判断,如果执行成功则退出,否则一直执行。
(2)其中compareAndSet是CAS操作的核心,它是利用Unsafe对象实现的。其中unsafe.compareAndSwapInt(this, valueOffset, expect, update);是借助C来调用CPU底层指令实现的。
1 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
类似如下逻辑:
1 if (o == expected) {//this=expect 2 o = x //this=updata 3 return true; 4 } else { 5 return false; 6 }
(3)Unsafe是用来帮助Java访问操作系统底层资源的类(如可以分配内存、释放内存),通过Unsafe,Java具有了底层操作能力,可以提升运行效率;强大的底层资源操作能力也带来了安全隐患(类的名字Unsafe也在提醒我们这一点),因此正常情况下用户无法使用。AtomicInteger在这里使用了Unsafe提供的CAS功能。
(4)valueOffset可以理解为value在内存中的偏移量,对应了CAS三个操作数(V/A/B)中的V;偏移量的获得也是通过Unsafe实现的。
(5)value域的volatile修饰符:Java并发编程要保证线程安全,需要保证原子性、可视性和有序性;CAS操作可以保证原子性,而volatile可以保证可视性和一定程度的有序性;在AtomicInteger中,volatile和CAS一起保证了线程安全性。关于volatile作用原理的说明涉及到Java内存模型(JMM),这里不详细展开。
2.版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一,当线程A要更新数据时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值与当前主内存中的version值相同,则提交更新,若不相同,则重复更新,直到更新成功。
eg:updata table set x=x+1,version=version+1 where id=#{id} and version=#{version};
对于这句代码来说,如果能够使用id+version查到数据,说明该数据没有再被修改过。如果查询不到,则说明数据被修改过,则会重复查询。
需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。
悲观锁机制存在以下问题:
1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。
3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
对比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。
要注意乐观锁的实现本质是没有使用锁的:
(1)乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了;AtomicInteger便是一个例子。
(2)有时乐观锁可能与加锁操作合作,例如,在前述updateCoins()的例子中,MySQL在执行update时会加排它锁。但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”这一事实。
三,乐观锁和悲观锁的使用场景
1、功能限制
与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
2、竞争激烈程度
如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
- 当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
- 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
四,CAS机制的缺点
1、ABA问题
假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:
(1)线程1读取内存中数据为A;
(2)线程2将该数据修改为B;
(3)线程2将该数据修改为A;
(4)线程1对数据进行CAS操作
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。
在AtomicInteger的例子中,ABA似乎没有什么危害。但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。
对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2、高竞争下的开销问题
在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。
3、功能限制
CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着当涉及到多个变量(内存值)时,CAS也无能为力。
除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。