java并发访问共享数据的三种方式

引言

 

在我上一篇《微服务化之----熔断和隔离》 中,使用责任链模式来进行熔断和限流。其中的并发访问计数器使用的是AtomicInteger,来统计当前服务器的并发数,关键代码如下:

           private static AtomicInteger count = new AtomicInteger(0);  //并发计数器
 
            //以下三行代码会在多线程中执行
            count.getAndIncrement(); //进入方法调用,并发计数器+1  
            response = getNext().invoke(request); // 自动往下层执行  
            count.getAndDecrement();//结束方法调用,并发计数器-1  

通过调用count.get()即可获取当前服务器的并发数,有朋友就问我可否是用volatile int代替 AtomicInteger,即关键代码变为:

private static volatile int count = 0;
 
//以下三行代码会在多线程中执行
count++;
response = getNext().invoke(request); // 自动往下层执行 
count--;

答案是不可以的,理由就是count++count--不是原子性操作。

这个问题实际上就是在多线程环境下并发访问共享数据的问题,引出今天主题-- java并发访问共享数据的三种方式:

1synchronized 对共享变量进行变更的方法、代码块 使用synchronized关键字(或者Lock)。

2、对共享变量使用volatile关键字。

3、使用Atomic包中的原子性操作类。

在讲述这三种方式之前,先来看看什么是原子性操作

 

原子性

 

所谓原子性操作,就是执行的最小单位,不可再分割、执行完毕之前不会任何其他事件所中断。在java中,除longdouble型之外的基础类型变量,以及所有的引用型变量的赋值和读取操作都是原子性操作。

 

由于以前的操作系统是32位,long double型在java中是8个字节表示,一共占用64位,因此需要分成两次操作采用完成一个变量的赋值或者读取操作。64位操作系统越来越普及,在64位的HotSpot jvm实现中,对long double型做原子性处理(但由于jvm规范没有明确规定,不排除别的jvm实现还是按照32位的方式处理)。

 

int为例,int count=0 就是一个原子操作。假设count的当前值是0,另外一个线程设置count=100,这时获取count的值也许还是0。这里就不得不说下java中的主内存与工作内存:




以线程1为例,线程1先从主内存中复制一份 count的拷贝到工作内存,接下来对count重新赋值为100,这时线程2也开始执行 从主内存中获取countcopy到工作内存这时count的值依然还是0。只有当线程1count的新值同步到主内存完成,其他线程执行才能看到最新的值。这里引出volatile关键字。

 

对共享变量使用volatile关键字

 

对采用volatile关键字修饰变量的含义为:告诉jvm该变量直接操作主内存,而不是copy一份拷贝到工作内存,其流程变为:



 

这时每个线程里看到值都是主内存中的最新值。

 

但假设把在线程中的赋值操作改为count++(--),就无法保证每个线程里看到值都是主内存中的最新值了,即便该变量是volatile修饰。

究其原因 count的直接赋值是原子性操作,但count++操作不是,这个操作相当于两个原子操作:“取值操作”和“赋值操作”。

 

假设三个线程分别对 count进行+1操作,最终的结果有可能小于3(实际测试时需要调整到一个很大的值才能看到效果,这里的3只是为了说明演示):




原因就是因为count++--)不是原子性操作,相当于取值赋值两个操作。当线程1执行完“取值”操作,但还没有执行赋值操作;此时线程2开始执行取值操作,但此时count的值其实还是0。然后线程1和线程2分别再执行赋值操作,count的值最终变成了1,而不是2。线程3也是同样的道理。所有最终的count结果有可能是123中的任意一个,出现了不一致性。这就解释了文章开始部分,为什么不能是用volatile int 的原因。

 

这里写一段测试代码进行测试,为了达到测试效果,测试代码先执行count++操作,再执行count--操作测试代码如下:

public class Test {
    private volatile static int count = 0;
    public static void add(){
        count++;
    }
 
    public static void sub(){
        count--;
    }
 
    public static void main(String[] args) {
        Test test = new Test();
 
 
        //启动1000个线程
        for (int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    add();
                    try {
                        Thread.sleep(1);//为了更好的模拟,睡眠1ms
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    sub();
                }
            }).start();
        }
 
        //睡眠10秒的作用,等待所有线程执行完毕
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result:"+count);
    }
}

 

执行测试方法,期望的测试结果应该是0。但实际结果每次执行都不一样我本机执行三次分别为:391。说明count++ count--是非原子性的。

 

总结下:在多线程环境下,为了保证volatile修饰的共享变量的一致性,每个线程中对变量的操作必须是原子性操作(比如单纯的变量赋值:a=1)。一般业务场景为 共享变量表示某个状态,在不同的线程中赋不同的状态值。

 

那对非原子性的操作,该怎么处理呢,比如这里提到的count++操作。第一种方式是加锁方式:synchronized方法或代码块。

 

synchronized方法或代码块

 

synchronized是对一个对象加锁,这里的锁是“排它锁”或者说“独占锁”。简单的讲就是同一时刻只有一个线程在执行count++操作,count字段作为对象的成员变量。synchronized的用法:

1synchronized使用在静态方法上,会对该类下所有的对象进行加锁。

2synchronized使用在非静态方法上,会对该类每个对象分别进行加锁。

3synchronized使用在代码块上,可以对指定对象进行局部加锁。这里又分为静态代码块,和非静态代码块,效果与使用在方法上相同。

 

需要注意的是采用synchronized加锁方式后,count字段就不必使用volatile修饰了。这里以synchronized使用在静态方法为例,写与上述相同逻辑demo进行测试:

class Test1 {
    private volatile static int count = 0;
    public synchronized static void add(){
        count++;
    }
 
    public synchronized static void sub(){
        count--;
    }
 
    public static void main(String[] args) {
        Test test = new Test();
 
        //启动1000个线程
        for (int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    add();
                    try {
                        Thread.sleep(1);//为了更好的模拟,睡眠1ms
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    sub();
                }
            }).start();
        }
 
        //睡眠3秒的作用,等待所有线程执行完毕
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result:"+count);
    }
}

 

多次重复执行mian方法,打印结果均为:0

符合我们的预期,测试通过。

 

简单总结下synchronized,这种方式其实是通过加锁独占的方式执行一段代码,对共享变量进行一系列的更新操作。但这种方式的代价相对于volatile来说说是非常昂贵的,所以在可以确定共享变量的操作是原子性操作是,建议用volatile,而不要使用synchronized

 

除了通过synchronized加锁方式保证共享变量的一致性外,从java1.5开始还提供了Atomic包(java.util.concurrent.atomic),支持一些原子性的操作。

 

Atomic包中的原子性操作

 

通过查阅Atomic包中的源码,可以发现它们都是通过调用底层的CAS方法完成原子性的赋值。这里以AtomicIntegergetAndIncrement方法为例进行讲解,类似上述的count++,只是这里是原子性的。源码如下:

public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1); // valueOffset 为当前线程内的对象值
    }

 

可以看到实际上是调用的UnsafegetAndAddInt方法,它的第二个参数首次传入的是“当前线程内的对象值” 简称期望值,方法内容为:

public final int getAndAddInt(Object var1, long var2, int var4) { // var2为期望值
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);//var5 为实时获取的当前值
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
 
        return var5;
    }

 

可以看到这里是一个死循环,这里的getIntVolatilecompareAndSwapInt都是native方法,是非java实现的,通过查阅多方资料,理解其内部主要实现为:通过var5 = this.getIntVolatile(var1, var2)方法,获取该对象的当前值。compareAndSwapInt方法判断期望值var3 是否与当前值var5相等,如果相等就把新的当前值赋值为 var5+ var4(这里var41),跳出循环操作完成,否则把期望值var3改为var5,并继续循环获取当前最新的var5,直到var3var5相等。这种循环判断有些地方称之为自旋,或者自旋锁,是乐观锁的一种实现方法。

 

Unsafe类中compareAndSwapxxx系列方法,简称为CAS原子性方法,底层是通过cpucas指令完成的原子性操作。java.util.concurrent包中的大量类都是基于Unsafe类的CAS方法实现的,后面有时间在对ReentrantLock等实现方式进行单独总结。

 

我们以AtomicInteger代替synchronized重新实现上述示例:

class Test2 {
    private volatile static AtomicInteger count = new AtomicInteger(0);
    public static void add(){
        count.getAndIncrement();
    }
 
    public static void sub(){
        count.getAndDecrement();
    }
 
    public static void main(String[] args) {
        Test test = new Test();
 
        //启动1000个线程
        for (int i=0;i<1000;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    add();
                    try {
                        Thread.sleep(1);//为了更好的模拟,睡眠1ms
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    sub();
                }
            }).start();
        }
 
        //睡眠3秒的作用,等待所有线程执行完毕
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result:"+count);
    }
}

 

多次重复执行mian方法,打印结果均为:0

符合我们的预期,测试通过。

 

简单总结下:Atomic包中的原子性操作是通过CAS指令不停的自旋循环判断完成的,在高并发的情况下冲突的可能性会增大,这时会不停的循环判断,有一定的性能消耗。但总体来讲性能好过于synchronized独占方式,且性能低于volatile

 

最终结论:在多线程环境下,访问共享变量,能用volatile的地方尽量使用volatile;其次考虑使用Atomic包中原子性操作类;最后考虑使用synchronized “独占的方式。

另外我们也可以不是用synchronized进行加锁,可以使用java1.5中的Lock (如:ReentrantLock)加锁,性能上相对来说也会好一些。只是后续准备单独对java1.5中的Lock单独进行总结,本篇采用synchronized加锁方式进行示例讲解。

 

 

以上仅为个人总结观点,如有不当之处,欢迎指正。

猜你喜欢

转载自moon-walker.iteye.com/blog/2383941
今日推荐