• 原子变量与CAS算法(二)


    一、锁机制存在的问题

    (1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

    (2)一个线程持有锁会导致其它所有需要此锁的线程挂起。

    (3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

    volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。

    独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

    二、原子操作

    在说明原子操作之前,我们先看下面这段Java代码:

    public static void main(String[] args) {
        int i = 10;
        i = i++;  // i = 10
    }
    

    上述,i++的原子性问题,i++实际上分为三个步骤"读->改->写",等价于下面的三条语句。

    int tmp = i;
    i = i+1;
    i = tmp;
    

    我们针对i++,做一个小测试:

    实现功能:开了2个线程,对同一个共享整型变量分别执行一亿次加1操作。

    public class AtomicTest {
        private static final int COUNT_TIMES = 10000 * 10000;
        private static volatile int counter = 0;
        public static void main(String[] args) {
            Runnable r = () -> {
                for (int i = 0; i < COUNT_TIMES; i++) {
                    counter++;
                }
            };
            Thread t1 = new Thread(r);
            Thread t2 = new Thread(r);
            t1.start();
            t2.start();
            try {
                t1.join();
                t2.join();
                System.out.printf("counter = %d
    ", counter);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    

    程序执行结果:

    ​ 开了2个线程对同一个共享整型变量分别执行一亿次加1操作,我们期望最后打印出来counter的值为200000000(2亿),但事与愿违,运行上面的代码,counter的值是极有可能不等于2亿的,而且每次运行结果都不一样,总是小于2亿。为什么会出现这个情况呢?从Java内存模型的角度来看,简单的counter++的执行过程其实分为如下三步:

    第一步,从主内存中加载counter的值到线程工作内存
    第二步,执行加1运算
    第三步,把第二步的执行结果从工作内存写入到主内存
    

    那么现在假设主内存中counter的值是100,两个线程现在都同时执行counter++,则可能出现如下情况:

    线程 1 从主内存中加载counter的值100到线程 1 到工作内存 // 100
    线程 2 从主内存中加载counter的值100到线程 2 到工作内存 // 100
    线程 1 执行加1运算得到结果101 
    线程 2 执行加1运算得到结果101
    线程 1 把101写入主内存中的counter变量
    线程 2 把101写入主内存中的counter变量
    

    线程1和2都执行了+1运算,本来我们期望得到102,但却错误的得到了101这个值。

    从上面这个引起错误的流程可以看出,之所以结果错误,其本质是两个线程同时操作了同一个对象,线程1执行++运算的过程中插入了线程2的++操作,也就是说从另外一个线程的角度看++操作并不是一个原子操作。

    现在我们已经知道多线程并发执行counter++其结果并不正确的原因了,但怎么解决这个问题呢?既然错误是因为++不是一个原子操作,那么我们想办法使其成为原子操作就可以了,所以我们可以:1,加锁,2,使用原子变量。

    解决办法:

    方法一、加锁

    伪码如下:

    for (int i = 0; i < COUNT_TIMES; i++) {
            lock();
            counter++; 
            unlock();
    }
    

    ​ 就是在执行counter++之前加锁,防止其它线程同时执行这一句,这一句执行完之后解锁让其它线程有机会执行这一句。虽然这个方法可以解决问题,但大家可以自己试一下,你会发现加锁之后性能急剧下降,主要原因是锁冲突会造成线程等待别人解锁而造成线程切换,这种上下文切换开销很大。

    方法二、原子变量

    public class AtomicTest {
    private static final int COUNT_TIMES = 10000 * 10000;
        private static volatile int counter = 0;
        private static volatile AtomicInteger atomCounter = new AtomicInteger(0); //Java提供的int型原子变量
        public static void main(String[] args) {
            Runnable r = () -> {
    			for (int i = 0; i < COUNT_TIMES; i++) {
                     atomCounter.addAndGet(1); //原子变量的加法
                  }
            };
            Thread t1 = new Thread(r);
            Thread t2 = new Thread(r);
            t1.start();;
            t2.start();
            try {
                t1.join();
                t2.join();
                System.out.printf("atomCounter = %d
    ", atomCounter.get());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    

    ​ 代码中使用的AtomicInteger类,其addAndGet()方法执行加法运算,这个方法执行加法操作时是原子的,所以不需要我们在代码中加锁。如果我们运行这段代码,会发现它比前面提到的加锁方法效率高很多,加锁方法执行1亿次加法所用时间是使用原子变量的好几倍。为什么使用原子变量效率会高出这么多呢?要想找到答案,就得分析原子变量提供的原子操作是怎么实现的。

    三、原子变量的实现原理

    如何保证i++这种操作的原子性的原理呢?

    int i = 0;
    for (;;) {
        v = i;
        //lock cmpxchg指令的的逻辑  ---START---
        lock总线
        if (i == v) {
            i = i + 1;
            break;
        }
        unlock总线
        //lock cmpxchg指令的的逻辑  ---END---
    }
    

    我们可以清楚的看到原子变量的原子操作也是用到了锁,只不过这个是硬件指令级别的锁,比我们软件实现的锁高效很多,更重要的是从上面的伪码可以看出,如果出现了冲突,只是不停的循环重试,而不会切换线程。

    Java中使用的是循环调用CAS操作实现的原子变量的原子操作.

    四、CAS (Compare and Swap)

    乐观锁用到的机制就是CAS,Compare and Swap。

    CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。

    下面以AtomicInteger的实现为例,分析一下CAS是如何实现的。

    public class AtomicInteger extends Number implements java.io.Serializable {
        // setup to use Unsafe.compareAndSwapInt for updates
        // 基于该类可以直接操作特定内存的数据
        private static final Unsafe unsafe = Unsafe.getUnsafe();
        // 表示该变量值在内存中的偏移地址
        private static final long valueOffset;
        static {
            try {
                valueOffset = unsafe.objectFieldOffset
                    (AtomicInteger.class.getDeclaredField("value"));
            } catch (Exception ex) { throw new Error(ex); }
        }
        // 用volatile修饰,保证了多线程之间的内存可见性
        private volatile int value;
        public final int get() {return value;}
    }
    
    1. Unsafe,是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。

    2. 变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。

    3. 变量value用volatile修饰,保证了多线程之间的内存可见性。

    看看AtomicInteger如何实现并发下的累加操作:

    public final int getAndAdd(int delta) {    
        return unsafe.getAndAddInt(this, valueOffset, delta);
    }
    //unsafe.getAndAddInt
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        return var5;
    }
    

    假设线程A和线程B同时执行getAndAdd操作(分别跑在不同CPU上):

    1. AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据Java内存模型,线程A和线程B各自持有一份value的副本,值为3。
    2. 线程A通过getIntVolatile(var1, var2)拿到value值3,这时线程A被挂起。
    3. 线程B也通过getIntVolatile(var1, var2)方法获取到value值3,运气好,线程B没有被挂起,并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为2。
    4. 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值(3)和内存的值(2)不一致,说明该值已经被其它线程提前修改过了,那只能重新来一遍了。
    5. 重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。

    整个过程中,利用CAS保证了对于value的修改的并发安全,继续深入看看Unsafe类中的compareAndSwapInt方法实现。

    五、模拟CAS

    实例一:新建了10个线程去数数。

    public class AtomicDemo {
    	public static void main(String[] args) {
    		Jack jack = new Jack();
    		for (int i = 0; i < 10; i++) {
    			new Thread(jack).start();
    		}
    	}
    }
    class Jack implements Runnable {
    	// private volatile int num = 1;
    	private int num = 1;
    	@Override
    	public void run() {
    		try {
    			Thread.sleep(200);
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.print(getNum() + "  ");
    	}
    	public int getNum() {
    		return num++;
    	}
    }
    

    程序运行结果分析:结果是,少数情况下出现正儿八经的从1数到10,大多数情况下都是有重复数字,然后数到8或者9。无论有没有volatile都是这样,也就说明了volatile在这里并没有保证线程安全。这是为什么呢?因为volatile只是保证操作都是在主存中进行,可是并不保证线程的互斥和原子性。

    实例二:使用原子类实现上述功能

    public class AtomicDemo {
    	public static void main(String[] args) {
    		Jack jack = new Jack();
    		for (int i = 0; i < 10; i++) {
    			new Thread(jack).start();
    		}
    	}
    }
    class Jack implements Runnable {
    	// private volatile int num = 1;
    	private AtomicInteger num = new AtomicInteger();
    	@Override
    	public void run() {
    		try {
    			Thread.sleep(200);
    		} catch (InterruptedException e) {
    			e.printStackTrace();
    		}
    		System.out.print(getNum() + "  ");
    	}
    	public int getNum() {
    		return num.getAndIncrement();
    	}
    }
    

    然后运行多几次,发现没问题.

    用java模拟一下CAS算法的实现,但实际上是jvm底层硬件实现cas的.

    public class TestCompareAndSwap {
        public static void main(String[] args) {
            final CompareAndSwap compareAndSwap = new CompareAndSwap(0);
            for (int i = 0; i < 50; i++){
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        int expected = compareAndSwap.get();          System.out.println(compareAndSwap.compareAndSet(expected, (int)(Math.random()*50)));
                    }
                }).start();
            }
        }
    }
    class CompareAndSwap{
        // 这里不需要加volatile修饰 内存值V
        // 因为compareAndSwap方法中加了synchronized,保证了内存的可见性
        // 所以在compareAndSwap中获取value时会自动去主存中取,而不会取线程缓存
        private int value;
        public CompareAndSwap(int value){
            this.value = value;
        }
        // 获取内存值
        // 加锁,表示刷新,和volatile是相同的作用,用于获取预期原值A
        public synchronized int get(){
            return this.value;
        }
    
        /**
         * compareAndSwap函数主要就是一个CAS的操作。
         * 当多个线程尝试使用CAS同时更新一个变量时,只有一个线程能够更新成功,其余线程都将失败。
         * 但是,失败的线程并不会被挂起(这个地方就是与获取锁的不同之处,当获取锁失败时,线程会被挂起),
         * 而是被告知在这次竞争中失败,并可以再次尝试。(这里可以决定是否再次尝试或则执行恢复操作或则不执行任何操作)
         * @param expectedValue
         * @param newValue
         * @return
         */
        // 比较
        // expectedValue : 当前内存值V
        // oldValue == value :旧的预期值A,也就是当前线程的value副本
        // newValue : 即将更新的值
        public synchronized int compareAndSwap(int expectedValue, int newValue){
            //获取旧值
            int oldValue = this.value;
            //如果期望值与当前V位置的值相同就给予新值
            if (expectedValue == oldValue){
                this.value = newValue;
            }
            //返回V位置原有的值
            return oldValue;
        }
        // 在这里再进行一次比较是为了方便后面的显示
        public synchronized boolean compareAndSet(int expectedValue, int newValue){
            // 比较并交换
            return expectedValue == compareAndSwap(expectedValue, newValue);
        }
    }
    

    记住,实际的CAS是没有锁的,即没有synchronized的,它是由硬件保证了比较-更新操作的原子性的,即上面的compareAndSwap方法的原子性。

    六、CAS缺点

    CAS虽然很高效地解决了原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。

    1)ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    public boolean compareAndSet(
    
    	V expectedReference, // 预期引用
    
    	V newReference, // 更新后的引用
    
    	int expectedStamp, // 预期标志
    
    	int newStamp // 更新后的标志
    )
    

    2)循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

    3)只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

    参考资料

    本片文章,主要整理自互联网,便于自己复习知识所用,以下为参考链接!
    【1】并发基础之原子操作与原子变量

    【2】占小狼

    【3】[并发编程--CAS自旋锁 - 井底之蛙 - CSDN博客](

  • 相关阅读:
    Windows下 maven3.0.4的安装步骤+maven配置本地仓库
    eclipse+webservice开发实例
    android:layout_gravity和android:gravity属性的差别
    再议指针---------函数回调(qsort函数原理)
    Windows下curl使用
    jquery.validate+jquery.form提交的三种方式
    java final keyword
    URAL 1577. E-mail(简单二维dp)
    【 D3.js 入门系列 --- 7 】 理解 update, enter, exit 的使用
    Code:log4
  • 原文地址:https://www.cnblogs.com/ch-forever/p/10208437.html
Copyright © 2020-2023  润新知