Java并发:乐观锁

2021年6月18日 19点热度 0条评论 来源: 汤圆学Java

作者:汤圆

个人博客:javalover.cc

简介

悲观锁和乐观锁都属于比较抽象的概念;

我们可以用拟人的手法来想象一下:

  • 悲观锁:像有些人,凡事都往坏的想,做最坏的打算;在java中就表现为,总是认为其他线程会去修改共享数据,所以每次操作共享数据时,都要加锁(比如我们前面介绍过的内置锁显式锁
  • 乐观锁:像乐天派,凡事都往好的想,做最好的打算;在Java中就表现为,总是认为其他线程都不会去修改共享数据,所以每次操作共享数据时,都不加锁,而是通过判断当前状态和上一次的状态,来进行下一步的操作;(比如这节要介绍的无锁,其中最常见的实现就是CAS算法)

目录

  1. 乐观锁的简单实现:CAS
  2. 乐观锁的优点&缺点
  3. 乐观锁的适用场景

正文

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. 乐观锁的优点&缺点

它的优点很多,比如:

  1. 没有锁竞争,也就不会产生死锁问题
  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时成功的概率很大,也就不会有额外的开销

总结

  1. 乐观锁的简单实现:CAS,比较并交换
  2. 乐观锁的优点&缺点:
优点 缺点
没有锁竞争,也就不会产生死锁问题 ABA问题(加状态值解决)
不需要来回切换线程,降低了开销 自旋时间过长导致的开销问题(旧版本JDK6之前才有的问题,JDK6之后默认用适应性自旋来动态设置自旋次数)
多个共享变量不能保证原子操作(用AtomicReference封装多个共享变量)
  1. 乐观锁的适用场景:读多写少

参考

    原文作者:汤圆学Java
    原文地址: https://www.cnblogs.com/jalon/p/14896886.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。