Java并发:乐观锁
作者:汤圆
个人博客:javalover.cc
简介
悲观锁和乐观锁都属于比较抽象的概念;
我们可以用拟人的手法来想象一下:
- 悲观锁:像有些人,凡事都往坏的想,做最坏的打算;在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