简介
悲观锁和乐观锁都属于比较抽象的概念;
我们可以用拟人的手法来想象一下:
- 悲观锁:像有些人,凡事都往坏的想,做最坏的打算;在java中就表现为,总是认为其他线程会去修改共享数据,所以每次操作共享数据时,都要加锁(比如我们前面介绍过的内置锁和显式锁)
- 乐观锁:像乐天派,凡事都往好的想,做最好的打算;在Java中就表现为,总是认为其他线程都不会去修改共享数据,所以每次操作共享数据时,都不加锁,而是通过判断当前状态和上一次的状态,来进行下一步的操作;(比如这节要介绍的无锁,其中最常见的实现就是CAS算法)
目录
- 乐观锁的简单实现:CAS
- 乐观锁的优点&缺点
- 乐观锁的适用场景
正文
1. 乐观锁的简单实现:CAS
CAS的实现原理是比较并交换,简单点来说就是,更新数据之前,会先检查数据是否有被修改过:
- 如果没有修改,则直接更新;
- 如果有被修改过,则重试;
下面我们通过一个代码来看下CAS的应用,这里举的例子是原子类AtomicInteger
public class AtomicDemo { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(1); ExecutorService service = Executors.newFixedThreadPool(10); for (int i = 0; i < 100; i++) { service.submit(()->{ // 这里会先检查AtomicInteger中的值是否被修改,如果没被修改,才会更新,否则会自旋等待 atomicInteger.getAndIncrement(); }); } try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(atomicInteger.get()); } } 复制代码
可以看到,输出的永远都是101,说明结果符合预期;
这里我们看下getAndIncrement的源码,如下所示:
// AtomicInteger.java public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // UnSafe.java public final int getAndAddInt(Object var1, long var2, int var4) { int var5; // 这里就是上面的CAS算法核心 do { // 1. 先取出期望值 var5(var1为值所在的对象,var2为字段在对象中的位移量) var5 = this.getIntVolatile(var1, var2); // 2. 然后赋值时,获取当前值,跟刚才取出的期望值 var5作比较 // 2.1 如果比较后发现值被修改了,则循环do while,直到当前值符合预期,才会进行更新操作(默认10次,超过10次还不符合预期,就会挂起线程,不再浪费CPU资源) // 2.2 如果比较后发现值没被修改,则直接更新 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // 3. 返回旧值,即期望值 return var5; } 复制代码
这里假设我们不是用的原子变量,而是普通的int来执行自增,那么就有可能出现结果<预期的情况(因为自增不是原子操作),比如下面的代码
// 不要用这种方式来修改int值,不安全 public class AtomicDemo { static int m = 1; public static void main(String[] args) { ExecutorService service = Executors.newFixedThreadPool(10); for (int i = 0; i < 1000; i++) { final int j = i; service.submit(()->{ m++; }); } try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(m); } } 复制代码
多运行几次,你会发现结果可能会小于预期,所以这就是原子类的好处:不用加锁就可以实现自增等原子操作
2. 乐观锁的优点&缺点
它的优点很多,比如:
- 没有锁竞争,也就不会产生死锁问题
- 不需要来回切换线程,降低了开销(悲观锁需挂起和恢复线程,如果任务执行时间又很短,那么这个操作就会很频繁)
优点看起来还可以,那它有没有缺点呢?也是有的:
- ABA问题:比如线程1将共享数据A改为B,然后过一会又改为A,那么此时线程2访问数据时,会认为该数据没被修改过(当前值符合预期值),这样我们就无法得知数据中间是否真的被修改过,以及修改的次数
- 开销问题:如果自旋一直不符合预期值,那么就会一直自旋,从而导致开销很大(JDK6之前)
- 原子操作的局限性问题:虽然CAS可以保证原子操作,但是只是针对单个数据而言的;如果有多个数据需要同 步,CAS还是无能为力
下面我们就针对这几个缺点来提出对于的解决方案
ABA问题
出现ABA问题,主要是因为我们没有对修改过程进行记录(就好比程序中的日志记录功能)
那么我们可以通过版本号的方式来记录每次修改,比如每修改一次,给对象的版本号属性加1
不过现在有了AtomicStampedReference这个类,它帮我们封装了所需的状态值,拿来即用,如下所示:
public class AtomicStampedReference<V> { private static class Pair<T> { final T reference; // 这里的stamp就是状态值,每次CAS都会同时比较当前值T和状态值stamp final int stamp; private Pair(T reference, int stamp) { this.reference = reference; this.stamp = stamp; } static <T> Pair<T> of(T reference, int stamp) { return new Pair<T>(reference, stamp); } } // 下面就是同时比较当前值和状态值 public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp))); } } 复制代码
开销问题
利用CAS进行自旋操作时,如果发现当前值一直都不等于期望值,就会一直循环(JDK6之前)
所以这里就引出了一个适应性自旋锁的概念:当尝试过N次后,发现还是不成功,则退出循环,挂起线程(JDK6之后,有了适应性自旋锁)
这里的N是不固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源
---- 参考自《不可不说的Java“锁”事》
大致意思就是,如果一个线程之前自旋成功过,获取过锁,那么后面就会让这个线程多自旋一会,比如20次(信用高)
但是如果如果一个线程之前自旋没成功过或者很少成功,那么后面就会让这个线程少自旋一会,比如5次(信用低)
这里需要纠正一个观点:自旋锁的次数设置问题,从JDK6开始,-XX:PreBlockSpin这个VM参数已经没有意义了,在JDK7中已经被移除了;JDK6版本之后,默认都是用适应性自旋锁来动态设置自旋的次数
如下图所示:
在IDEA中添加-XX:PreBlockSpin=1参数,运行会报错如下:
原子操作的局限性问题
CAS的原子操作只是针对单个共享变量而言的(就像前面介绍的同步容器一样,虽然每个方法都有锁,但是复合操作却无法保证原子性)
不过AtomicReference这个类会有所帮助,它内部有一个V属性,我们可以将多个共享变量封装到这个V属性中,然后再对V进行CAS操作
源码如下:
public class AtomicReference<V> implements java.io.Serializable { private static final long serialVersionUID = -1848883965231344442L; private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicReference.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } // 这里的V我们可以自己定义一个类,然后将多个共享变量都封装进去 private volatile V value; } 复制代码
3. 乐观锁的适用场景
分析乐观锁的适用场景之前,我们可以先看下悲观锁的适用场景
悲观锁是一来就上锁,所以比较适合写多读少的场景,因为上了锁,可以保证数据的一致性
那么乐观锁对应的,就是从来都不上锁,所以比较适合读多写少的场景,因为读不会修改数据,所以CAS时成功的概率很大,也就不会有额外的开销
总结
- 乐观锁的简单实现:CAS,比较并交换
- 乐观锁的优点&缺点:
优点 | 缺点 |
---|---|
没有锁竞争,也就不会产生死锁问题 | ABA问题(加状态值解决) |
不需要来回切换线程,降低了开销 | 自旋时间过长导致的开销问题(旧版本JDK6之前才有的问题,JDK6之后默认用适应性自旋来动态设置自旋次数) |
多个共享变量不能保证原子操作(用AtomicReference封装多个共享变量) |
- 乐观锁的适用场景:读多写少
参考
- 《实战Java高并发》
- 不得不说的Java琐事
- 自旋次数的设置问题:-XX:PreBlockSpin