乐观锁详解

乐观锁:

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

优缺点

优点

  • 读写不加锁,保证了代码高速运行。悲观锁加锁解锁时上下文切换和调度延时,外加排斥等待效率非常低。

缺点

  • ABA 问题: 如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。
  • 循环判断开销大:为了保证CAS操作成功必须添加循环判断,如果长时间不成功,会给CPU带来非常大的执行开销。

实现原理

乐观锁的实现原理包含:volatile关键字和CAS操作。

volatile保证可见性

**官方解释:**volatile修饰的变量,在编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

  • 跟普通变量对比:volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
  • 跟synchronized对比:synchronize在读写的时候,其他线程不能读写,保持了排斥性。CPU加载写操作指令时会多执行了一个“load addl $0x0, (%esp)”操作,读操作不会因为指令重排序或者其他原因插入到写操作之前。这样保证了读写的同步,但不代表一定可靠。

CAS操作

CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。

  • 如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。

  • 否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)

    CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。 这其实和乐观锁的冲突检查+数据更新的原理是一样的。

源码解读

程序猿之间没有什么是用代码沟通不了的,如果有那就加上注释。

1.5-1.7版本的源码

public class AtomicInteger extends Number implements java.io.Serializable {  
    private volatile int value; 

    public final int get() {  
        return value;  
    }  

	// 获取值并且增加1
    public final int getAndIncrement() {  
        for (;;) {   // 这行是一个死循环
            // 从内存中读取数据
            int current = get();  
            int next = current + 1;  
            // 此数据与 (此数据+1 后的结果)进行CAS操作
            if (compareAndSet(current, next))  
                // 如果成功就返回结果,否则重试直到成功为止。
                return current;  
        }  
    }  
    
    public final boolean compareAndSet(int expect, int update) {  
        //  compareAndSet 利用JNI(Java Native Interface)来完成CPU指令的操作
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);  
    }  
}

在没有锁的机制下,字段value要借助volatile原语,保证线程间的数据是可见性。这样在获取变量的值的时候才能直接读取。然后来看看 ++i 是怎么做到的。

getAndIncrement 采用了CAS操作,每次从内存中读取数据然后将此数据和 +1 后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

1.8版本的源码

   /**
     * Atomically sets the value to the given updated value
     * if the current value {@code ==} the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful
     */
    public final boolean weakCompareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

注释是这么说的:如果内存中的值跟预期值相等,则原子性更新该值。当成功的时候返回true,失败的时候返回false。

unsafe.compareAndSwapInt调用native代码实现CAS操作,但是不保证结果成功。这是一项很好的优化,在1.7版本里面如果操作失败会一直占用CPU的资源,而在新版里面我们可以自行决定什么时候不再执行该操作。

ABA问题

先说问题产生的过程,最后给出解决方案:

  1. 线程1执行后因为某种原因中断;
  2. 线程2执行变更乐观锁对象的值,经历多轮变换之后恢复原值;
  3. 线程1恢复运行原子性更新操作成功,但是现场已经不是之前的现场了,可以看下面例子中的testList的变化。
public class CAS_ABA {

    private static AtomicInteger atomicInt = new AtomicInteger(100);
    private static List<Boolean> testList = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {

        Thread intT1 = new Thread(new Runnable() {
            @Override
            public void run() {
                int currentSize = testList.size();
                System.out.println("t1 操作前列表大小 testList size = " + currentSize);
                try {
//                    TimeUnit.SECONDS.sleep(2);
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                exchange(100, 101);
                System.out.println("t1 操作后列表大小 testList size = " + testList.size());
                if (testList.size() != currentSize + 1) {
                    System.out.println("testList size期待值是:" + (currentSize + 1) + " testList size 实际值是 " + testList.size() + " 发生了ABA问题");
                }

            }
        });

        Thread intT2 = new Thread(new Runnable() {
            @Override
            public void run() {
                int currentSize = testList.size();
                System.out.println("t2 操作前列表大小 testList size = " + currentSize);
                exchange(100, 101);
                exchange(101, 100);
                System.out.println("t2 操作后列表大小 testList size = " + testList.size());
                if (testList.size() != currentSize + 2) {
                    System.out.println("testList size期待值是:" + (currentSize + 2) + " testList size 实际值是 " + testList.size() + " 发生了ABA问题");
                }
            }
        });

        intT1.start();
        intT2.start();
        /**join的意思是使得放弃当前线程的执行,并返回对应的线程,例如下面代码的意思就是:
         程序在main线程中调用t1线程的join方法,则main线程放弃cpu控制权,并返回t1线程继续执行直到线程t1执行完毕
         所以结果是t1线程执行完后,才到主线程执行,相当于在main线程中同步t1线程,t1执行完了,main线程才有执行的机会
         */
        intT1.join();
        intT2.join();

        // 明明线程1先执行,因为某些原因会在线程2操作之后才开始执行,这个时候线程1执行操作

    }

    /**
     * CAS操作更新
     *
     * @param expect 内存期待值
     * @param update 更新后的值
     */
    private static void exchange(int expect, int update) {
        boolean isSuccess;
        isSuccess = atomicInt.compareAndSet(expect, update);
        testList.add(isSuccess);
    }
}


输出结果

t2 操作前列表大小 testList size = 0
t1 操作前列表大小 testList size = 0
t2 操作后列表大小 testList size = 2
t1 操作后列表大小 testList size = 3
testList size期待值是:1 testList size 实际值是 3 发生了ABA问题

循环判断造成的资源浪费

先说问题产生的过程,最后给出解决方案:

TIPS:1.7版本为了保证操作成功自带死循环,而1.8去掉了死循环,可以参照上面的源码解读。

为了保证一定操作成功,我们为原子操作添加循环判断。

public class CAS_InfiniteLoop {

    private static AtomicInteger atomicInt = new AtomicInteger(100);
    //维护一个对象引用以及整数“标记”,可以原子方式更新。
    private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);

    public static void main(String[] args) throws InterruptedException {
        Thread intT1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("t1 start");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                exchange(100,101);

            }
        });

        Thread intT2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("t2 start");
                atomicInt.compareAndSet(100, 101);
                atomicInt.compareAndSet(101, 102);
                System.out.println("t2 over");
            }
        });

        intT1.start();
        intT2.start();
        intT1.join();
        intT2.join();


    }

    /**
     * CAS操作更新,用do-while操作循环判断保证一定更新
     */
    private static void exchange(int expect, int update) {
        boolean isSuccess;
        do {
            isSuccess = atomicInt.compareAndSet(expect, update);
			System.out.println("操作:"+isSuccess);
        } while (!isSuccess);
    }
}

t2 start
t1 start
t2 over
操作:false
操作:false
操作:false

……

从打印里面我们可以得出,为了能操作成功线程进入了死循环,这个如果发生在1.7的版本里面基本上是无解的,只能看着CPU资源被浪费。

正确的使用姿势

下面说说正确的使用姿势。

  • 避免ABA问题
    • 我们用AtomicStampedReference标记对象,而不直接锁定对象。
    • AtomicStampedReference利用版本标记更新(每次操作后版本+1)的方式保证操作对象的原子性。如果版本不是预期版本,就算对象是预期值也同样说明了场景已经发生改变,这时候操作失败。
  • 避免死循环
    • 定义一个条件,当多次操作失败且符合该条件的时候放弃这个操作。
public class CAS_RightPosture {
    // AtomicStampedReference维护一个对象保证其可以原子方式更新。
    private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);

    public static void main(String[] args) throws InterruptedException {

        Thread refT1 = new Thread(new Runnable() {
            @Override
            public void run() {

                int stamp = atomicStampedRef.getStamp();
                // stamp = 0
                System.out.println("t1 开始执行时 版本是 " + stamp);
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // stamp = 2
                System.out.println("t1 睡完一觉 版本是 " + atomicStampedRef.getStamp());
                int update = 101;
                boolean isSuccess = exchange(100, 101, stamp);
                System.out.println("t1 操作是否成功:" + isSuccess + "期望值:" + update + " 更新后的值是:" + atomicStampedRef.getReference());

            }
        });

        Thread refT2 = new Thread(new Runnable() {
            @Override
            public void run() {
                int update = 101;
                boolean isSuccess = exchange(100, 101, atomicStampedRef.getStamp());
                System.out.println("t2 操作是否成功:" + isSuccess + "期望值:" + update + " 更新后的值是:" + atomicStampedRef.getReference());

                update = 100;
                isSuccess = exchange(101, 100, atomicStampedRef.getStamp());
                System.out.println("t2 操作是否成功:" + isSuccess + "期望值:" + update + " 更新后的值是:" + atomicStampedRef.getReference());
            }
        });

        refT1.start();
        refT2.start();

    }

    /**
     * CAS操作更新,为了保证一定更新所以用do-while操作循环判断,为了防止进入死循环添加条件当条件满足之后退出任务
     */
    private static boolean exchange(int expect, int update, int stamp) {
        boolean isSuccess;
        int condition = 0;
        do {
            // 只有期待值和期待版本跟内存中数据一致才更新
            isSuccess = atomicStampedRef.compareAndSet(expect, update, stamp, stamp + 1);
            if (condition++ > 10) {
                System.out.println("失败次数太多,为了不浪费CPU退出任务");
                break;
            }
        } while (!isSuccess);
        return isSuccess;
    }
}

t1 开始执行时 版本是 0
t2 操作是否成功:true期望值:101 更新后的值是:101
t2 操作是否成功:true期望值:100 更新后的值是:100
t1 睡完一觉 版本是 2
失败次数太多,为了不浪费CPU退出任务
t1 操作是否成功:false期望值:101 更新后的值是:100

使用场景

  • 对文件进行批量操作,分多个线程执行为了避免对某个文件重复操作,这个时候加上一个原子类型AtomicBoolean标示这个文件是否已经操作过。synchronize加锁只允许单个线程对该文件状态进行读写,乐观锁则不然。
  • 向系统请求某中资源,比如说去多人去图书馆借同一种书,对书加乐观锁如果全借出去了那借出请求失败,当然如果有人愿意等也可以写个循环一直等待。多个图书管理员(线程)同时查询某中图书,如果是悲观锁只能是管理员轮询图书状态,乐观锁则可并发。

java.util.concurrent.atomic包下面的原子变量类

原子更新类型 名称 描述
基本类型 AtomicBoolean 原子更新布尔类型
基本类型 AtomicInteger 原子更新整型
基本类型 AtomicLong 原子更新长整型
数组类型 AtomicIntegerArray 原子更新整型数组里的元素
数组类型 AtomicLongArray 原子更新长整型数组里的元素
数组类型 AtomicReferenceArray 原子更新引用类型数组的元素
数组类型 AtomicBooleanArray 原子更新布尔类型数组的元素
引用类型 AtomicReference 原子更新引用类型
引用类型 AtomicReferenceFieldUpdater 原子更新引用类型里的字段
引用类型 AtomicMarkableReference 原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和应用类型
字段类型 AtomicIntegerFieldUpdater 原子更新整型的字段的更新器
字段类型 AtomicLongFieldUpdater 原子更新长整型字段的更新器
字段类型 AtomicStampedReference 原子更新带有版本号的引用类型。

参考

CAS和ABA问题

Java多线程3 原子性操作类的使用

AtomicBoolean类实现

你可能感兴趣的:(java,线程)