并发编程——CAS

Posted 耶瞳

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程——CAS相关的知识,希望对你有一定的参考价值。

如果有兴趣了解更多相关内容的话,可以来我的个人网站看看:耶瞳空间

一:前言

首先看一个案例:我们开发一个网站,需要对访问量进行统计,用户每发送一次请求,访问量+1,如何实现?我们模拟有100个人同时访问,并且每个人对网站发起10次请求,最后总访问次数应该是1000次。

模拟代码如下:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class Demo 
    // 总访问量
    static int count = 0;

    // 模拟访问的方法
    public static void request() throws InterruptedException 
        // 模拟耗时5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        count++;
    

    public static void main(String[] args) throws InterruptedException 
        // 开始时间
        long startTime = System.currentTimeMillis();
        int threadSize = 100;
        CountDownLatch countDownLatch = new CountDownLatch(threadSize);

        for (int i = 0; i < threadSize; i++) 
            Thread thread = new Thread(new Runnable() 
                @Override
                public void run() 
                    // 模拟用户行为,每个用户访问10次网站
                    try 
                        for (int j = 0; j < 10; j++) 
                            request();
                        
                     catch (InterruptedException e) 
                        e.printStackTrace();
                     finally 
                        countDownLatch.countDown();
                    
                
            );
            thread.start();
        
        // 利用countDownLatch实现100个线程结束之后再执行以下代码
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
    

很明显count不是1000,那么问题出在哪呢?

对于代码中的count++,其实是分这样几个步骤执行的:

  1. 获取count的值,记作A:A=count
  2. 将A值+1,得到B:B=A+1
  3. 将B值赋值给count

如果有A、B这两个线程同时执行count++,他们通知执行到上面步骤的第一步,得到的count是一样的,那么3步操作结束后,count只加了1,导致count结果不正确

那么怎么解决这个问题呢?可以在对count++操作的时候,我们让多个线程排队处理,多个线程同时到达request()方法的时候,只能允许一个线程进去操作,其他的线程在外面等待,等里面的处理完毕出来之后,外面等着的再进去一个,这样操作的count++就是排队进行的,结果一定是正确的

怎么实现排队效果呢?java中synchronized关键字和ReentrantLock都可以实现对资源加锁,保证并发正确性,多线程的情况下可以保证被锁住的资源被串行访问

在代码中就是在request()方法里加synchronized修饰,具体可见我的另一篇博客:并发编程——共享模型之管程



可见上锁之后,访问量就正常了,但也注意到,耗时从63ms变成了5335ms,那是因为在方法上加锁,导致TimeUnit.MILLISECONDS.sleep(5);也被串行执行了,因此仅仅等待就花费了5000ms,而结果执行错误只是因为count++被并发访问,因此我们可以只给count++加锁:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class Demo 
    // 总访问量
    volatile static int count = 0;

    /**
     * @param expectCount 期望值count
     * @param newCount 需要给count赋值的新值
     * @return boolean
     */
    public static synchronized boolean compareAndSwap(int expectCount, int newCount) 
        // 判断count当前值是否和期望值expectCount一致,如果一致,则将newCount赋值给count
        if(getCount() == expectCount) 
            count = newCount;
            return true;
        
        return false;
    

    public static int getCount() 
        return count;
    

    // 模拟访问的方法
    public static void request() throws InterruptedException 
        // 模拟耗时5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        int expectCount; // 表示期望值
        while(!compareAndSwap(expectCount = getCount(), expectCount + 1))  
    

    public static void main(String[] args) throws InterruptedException 
        // 开始时间
        long startTime = System.currentTimeMillis();
        int threadSize = 100;
        CountDownLatch countDownLatch = new CountDownLatch(threadSize);

        for (int i = 0; i < threadSize; i++) 
            Thread thread = new Thread(new Runnable() 
                @Override
                public void run() 
                    // 模拟用户行为,每个用户访问10次网站
                    try 
                        for (int j = 0; j < 10; j++) 
                            request();
                        
                     catch (InterruptedException e) 
                        e.printStackTrace();
                     finally 
                        countDownLatch.countDown();
                    
                
            );
            thread.start();
        
        // 利用countDownLatch实现100个线程结束之后再执行以下代码
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
    

很明显这样程序的效率得到了很大的提升

二:CAS介绍

那现在回到正题,CAS全称“Compare And Swap”,即“比较并替换”。CAS操作包含三个操作数:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。所以这里的预期值E本质上指的是“旧值”。

我们以一个简单的例子来解释这个过程:

  1. 如果有一个多线程共享的变量i原本等于5,我现在在线程A中,想把它设置为新的值6,我们使用CAS来做这个事情。
  2. 首先我们用i去与5对比,发现它等于5,说明没有被其它线程改过,那我就把它设置为新的值6,此次CAS成功,i的值被设置成了6;
  3. 如果不等于5,说明i被其它线程改过了(比如现在i的值为2),那么我就什么也不做,此次CAS失败,i的值仍为2。

在这个例子中, i就是V,5就是E,6就是N。

那有没有可能我在判断了i为5之后,正准备更新它的新值的时候,被其它线程更改了i的值呢?不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,这个重试的过程也叫做自旋。当然也允许失败的线程放弃操作。正是基于这样的原理,CAS即时没有使用锁,也能发现其他线程对当前线程的干扰,从而进行及时的处理。

CAS的缺点:

  • CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
  • 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。

三:基本使用

java提供了对CAS操作的支持,现在用CAS修改上面的例题:

public class Demo 
  // 总访问量
  static AtomicInteger count = new AtomicInteger(0);

  // 模拟访问的方法
  public static void request() throws InterruptedException 
    // 模拟耗时5毫秒
    TimeUnit.MILLISECONDS.sleep(5);
    // 修改访问量
    while (true) 
      int oldCount = count.get();
      int newCount = oldCount + 1;
      if (count.compareAndSet(oldCount, newCount)) 
        break;
      
    
  

  public static void main(String[] args) throws InterruptedException 
    long startTime = System.currentTimeMillis();
    int threadSize = 100;
    CountDownLatch countDownLatch = new CountDownLatch(threadSize);

    for (int i = 0; i < threadSize; i++) 
      Thread thread = new Thread(() -> 
        // 模拟用户行为,每个用户访问10次网站
        try 
          for (int j = 0; j < 10; j++) 
            request();
          
         catch (InterruptedException e) 
          e.printStackTrace();
         finally 
          countDownLatch.countDown();
        
      );
      thread.start();
    
    // 利用countDownLatch实现100个线程结束之后再执行以下代码
    countDownLatch.await();
    long endTime = System.currentTimeMillis();
    System.out.println(Thread.currentThread().getName() + ", 耗时:" + (endTime - startTime) + ", count = " + count);
  

不过需要注意的是,在获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。volatile可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,而是到主存中获取它的值,线程操作volatile变量都是直接操作主存。即一个线程对volatile变量的修改,对另一个线程可见。CAS必须借助volatile才能读取到共享变量的最新值来实现比较并交换的效果。关于volatile,详细介绍可以看我的这篇博客:并发编程——可见性与有序性

以上是关于并发编程——CAS的主要内容,如果未能解决你的问题,请参考以下文章

Spark系列

Go并发编程之美-CAS操作

Java并发多线程编程——CAS

Java并发编程之CAS二源码追根溯源

并发编程的灵魂:CAS机制详解

并发编程CAS