[java并发]java高并发系列 - 第21天:java中的CAS,你需要知道的东西

原文链接:查看原文

感谢公众号“ 路人甲Java”的分享,如有冒犯,请联系删除,快去关注他吧
在这里插入图片描述

本文主要内容

  1. 从网站计数器实现中一步步引出CAS操作
  2. 介绍java中的CAS及CAS可能存的问题
  3. 悲观锁和乐观锁的一些介绍及数据库乐观锁的一个常见示例
  4. 使用java中的原子操作实现网站计数器功能

我们需要解决的问题

需求: 我们开发了一个网站,需要对访问量进行统计,用户每次发一次请求,访问量+1,如何实现呢?

方式一:

代码如下:

package aboutThread.Concurrent.Day21;

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

 

public class Demo1 {
   //访问次数
    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(() ->{
                try {
                    for(int j = 0;j < 10;j++){
                        request();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally{
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ",耗时:" + (endTime - startTime) + ",count = "+ count);
    }
}

输出:

main,耗时:74,count = 947

代码中的count用来记录总访问次数,request() 方法表示访问一次,内部休眠5毫秒模拟内部耗时,request方法内部对count++操作。程序最终耗时1秒多,执行还是挺快的,但是count和我们的期望结果不一致,我们期望的结果是1000。

分析一下问题在哪里?

代码中采用的是多线程的方法来操作count,count++会有线程安全问题,count++操作实际上是由以下三步操作完成的:

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

如果有A、B两个线程同时执行count++,他们同时执行到上面步骤的第1步,得到的count是一样的,3步操作完成后,count只会+1,导致count只加了一次,从而导致结果不准确。

那我们应该怎么办呢?

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

我们前面学了synchronized、ReentrantLock可以对资源加锁,保证并发的正确性,多线程情况下可以保证被锁的资源被串行访问,那么我们用synchronized来实现一下。

使用synchronized实现:


package aboutThread.Concurrent.Day21;

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

public class Demo2 {
    //访问次数
    static int count = 0;

    //模拟访问一次
    public static synchronized 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(() ->{
                try {
                    for(int j = 0;j < 10; j++){
                        request();
                    }
                } catch (InterruptedException e) {
                   e.printStackTrace();
                } finally{
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ",耗时:"+ (endTime - startTime) +",count = " + count);
    }
}

输出:

main,耗时:5852,count = 1000

程序中request方法使用synchronized关键字,保证了并发情况下,reqeust方法同一时刻只允许一个线程访问,request加锁了相当于串行执行了,count的结果和我们预期的一致,只是耗时比较长,5秒多。


方式三:

我们看一下count++操作,count++操作实际上是被拆分为3个步骤执行:

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

方式2中我们通过加锁的方式让上面3步骤同时只能被一个线程操作,从而保证结果的正确性。

我们是否可以只在第三步加锁,减少加锁的范围,对第三步做以下处理:

获取锁
第三步获取一下count最新值,记做LV
判断LV是否等于A,如果相等则将B的值赋给count,并返回true,否则返回false

如果我们发现第3步返回的是false,我们就再次去获取count,将count赋值给A,对A+1赋值给B,再将A、B的值带入到上面的过程中执行,直到上面的结果返回false为止。

我们用代码来实现它:

package aboutThread.Concurrent.Day21;

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



public class Demo3 {
    
    //访问次数
    volatile static int count = 0;

    // 模拟访问一次
    public static void request() throws InterruptedException{
        //模拟耗时5秒
        TimeUnit.MILLISECONDS.sleep(5);
        int expectCount;
        do{
            expectCount = getCount();
        } while(!compareAndSwap(expectCount,expectCount + 1));
    }

    /**
     * 
     * @param expectCount 期望count的值
     * @param i 需要给count赋的值
     * @return
     */
    private static boolean compareAndSwap(int expectCount, int i) {
        //判断count当前值是否和期望的expectCount一致,如果一样将newCount赋值给count
        if(getCount() == expectCount){
            count = i;
            return true;
        }
        return false;
    }
    /**
     * 获取count当前值
     * @return
     */
    private static int getCount() {
        return 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(() -> {
                try {
                    for(int j = 0;j < 10; j++){
                        request();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally{
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.err.println(Thread.currentThread().getName() + ",耗时:" + (endTime - startTime) + ",count = " + count);
    }
}

输出:

main,耗时:73,count = 929

代码中使用 volatile 关键字修饰count,可以保证在多线程下的可见性。

volatile关键字的用法前面有讲过,请参考 volatile与Java内存模型

再看一下代码: compareAndSwap 方法,我们给起个简称叫 CAS ,这个方法有什么作用呢?这个方式使用 synchronized 关键字修饰了,能保证此方法是线程安全的,多线程情况下此方法是串行执行的。方法有2个参数,expectCount:表示期望的值,newCount:表示要给count设置的新值。方法内部通过 getCount() 获取count当前的值,然后与期望值expectCount比较,如果期望的值和count当前的值一直,则将新值newCount赋给count。

再看一下request()方法,方法中有个do-while循环,循环内部获取count当前值赋值给了expectCount,循环结束的条件是 compareAndSwap 返回true,也就是说如果compareAndSwap不成功,循环再次获取count的最新值,然后+1,再次调用compareaAndSwap方法,直到compareAndSwap返回成功为止。

代码中相当于将count++拆分开了,只对最后一步加锁了,减少了锁的范围,此代码的性能是不是比方式2快不少,还能保证结果的正确性。Java中已经给我们提供了CAS的操作,功能非常强大。


CAS

compare and swap的缩写,比较并交换

CAS操作包含三个操作数 — 内存位置(V),预期原值(A) 和 新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令返回之前返回该位置的值。(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值。)CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置,否则不要更改位置,只告诉我这个位置现在的值即可。”

通常将 CAS 用于同步的方式是从地址V读取值A,执行多步计算来获得新值B,然后使用 CAS 将 V 的值从A改为B。如果V处的值尚未同时更改,则CAS操作成功。

很多地方说CAS操作是非阻塞的,其实系统底层进行CAS操作的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,所以同一芯片上的其他处理器就暂时不能通过总线访问内存,保证了该指令在多处理器环境下的原子性。总线上锁的,其他线程执行CAS还是会被阻塞一下,只是时间会非常短暂,所以CAS是非阻塞的并不正确,只能说阻塞的时间是非常短的。

java中提供了对CAS操作的支持,具体在 sun.misc.Unsafe 类中,声明如下:

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

上面三个方法都是类似的,主要对4个参数做一下说明。

var1:表示要操作的对象
var2:表示要操作对象中属性地址的偏移量
var4:表示需要修改数据的期望的值
var5:表示需要修改为的新值

JUC包中大部分功能都依靠CAS操作完成的,所以这块也是非常重要的,有关Unsafe类,下一篇会具体讲到,期待!

synchronizedReentrantLock 这种独占锁属于 悲观锁它是假设需要操作的代码一定会发生冲突,执行代码的时候先对代码加锁,让其他线程在外面等候排队获取锁。 悲观锁如果锁的时间比较长,会导致其他线程一直处于等待状态,像我们部署的web应用,一般部署在tomcat中,内部通过线程池来处理用户请求,如果很多请求都处于等待获取锁状态,可能会耗尽tomcat线程池,从而导致系统无法处理后续请求,导致服务器处于不可用状态。

除此之外,还有 乐观锁乐观锁的含义就是假设系统没有发生并发冲突,先按无锁方式执行业务,到最后了检查执行业务期间是否有并发导致数据被修改了,如果有并发导致数据被修改了,就快速返回失败,这样操作使系统并发性能更高一些。 CAS中就使用了这样的操作。

关于了乐观锁,举一个关于数据库乐观锁的例子:

如果你们的网站中有调用支付宝充值接口的,支付宝那边充值成功了会回调商户系统,商户系统接收到请求之后会怎么处理呢?假设用户通过支付宝在商户系统充值100,支付宝那边会从用户账户中扣除100,商户系统接收到支付宝请求之后应该在商户系统中给用户账户增加100,并且把订单状态置为成功。

处理过程如下:

开启事务
获取订单信息
if(订单状态 == 待处理)
{
	给用户账户增加100
	将订单状态更新为成功
}
返回订单处理成功
提价事务

由于网络等各种问题,可能支付宝回调商户系统的时候,回调超时了,支付宝又发起了一笔回调请求,刚好这两笔请求同时达到上面代码,最终结果是给用户账户增加了200,这样用户可开心了。

那么我们用乐观锁来实现,给订单价格版本号version,要求每次更新订单数据,将版本号+1,那么上面的过程可以改为:

获取订单信息,将version的值赋值给V_A
if(订单状态 == 待处理){
	开启事务
	给用户账户增加100
	update 影响行数 = update 订单表 set version = version + 1 where id = 订单号 and version = V_A;
	if(update影响行数 == 1){
		提交事务
	}else{
		回滚事务
	}
}
返回订单处理成功

上面的update语句相当于我们说的CAS操作,执行这个Update语句时,多线程情况下,数据库会对当前订单记录加锁,保证只有一条执行成功,执行成功时,影响行数为1,执行失败的影响行数为0,根据影响行数来决定提交还是回滚。上面操作还有一点是将事务范围缩小了,也提升了系统并发处理性能。


CAS 的问题

这么好用也有问题?还真有:

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

循环时间长开销大
上面我们说过如果CAS不成功,则会原地循环(自旋操作),如果长时间自旋会给CPU带来非常大的执行开销。并发量比较大的情况下,CAS成功率可能会比较低,可能会重试很多次才能成功!


使用JUC中的类实现计数器

juc框架中提供了一些原子操作,底层通过Unsafe类中的cas操作实现的。通过原子操作可以保证数据在并发情况下的正确性。

此处我们使用 java.util.concurrent.atomic.AtomicInteger 类来实现计数器功能,AtomicInteger内部采用cas操作来保证对int类型数据增减操作在多线程情况下的正确性。

package aboutThread.Concurrent.Day21;

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

public class Demo4 {
    //访问次数
    static AtomicInteger count = new AtomicInteger();

    //模拟访问一次
    public static void request() throws InterruptedException{
        //模拟耗时5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        //对count原子+1
        count.incrementAndGet();
    }
    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(() -> {
                try {
                    for(int j = 0 ; j < 10;j++){
                        request();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally{
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ",耗时:" + (endTime - startTime) + ",count=" + count);
    }    
}

输出:

main,耗时:77,count=1000

耗时很短,并且结果和期望的结果一致。

关于原子类操作,都位于java.util.concurrent.atomic包中,下篇文章我们主要来介绍一下这些常用的类及各自的使用场景。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值