java中共享变量分析和volatile

2020-12-01  本文已影响0人  上善若泪

1 共享变量

1.1 简单理解

Java并发一直都是开发中比较难也比较有挑战性的技术,对于很多新手来说是很容易掉进这个并发陷阱的,其中尤以共享变量最具代表性,其实关于讲这个知识点网上也不少,但是想讲讲自己对这个概念的理解。
共享变量比较典型的就是指类成员变量,在类中定义了很多方法对成员变量的使用,如果是单实例,当有多个线程同时来调用这些方法,方法又没加控制,那么这些方法对成员变量的操作就会使得该成员变量的值变得不准确了

1.2 CountDownLatch

CountDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。
是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了
CountDownLatch类中只提供了一个构造器:

//参数count为计数值
public CountDownLatch(int count) {  };  

类中有三个方法是最重要的:

//调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
public void await() throws InterruptedException { };   
//和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };  
//将count值减1
public void countDown() { };  

1.3 代码示例

用一个最典型的i++例子来说明:

public class Test {
   private int i = 0;
   private final CountDownLatch mainLatch = new CountDownLatch(1);

   public void add(){
        i++;
   }
   private class Work extends Thread{
        private CountDownLatch threadLatch;
        public Work(CountDownLatch latch){
            threadLatch = latch;
        }
        @Override
        public void run() {
            try {
                mainLatch.await();
            } catch (InterruptedException e) {
                    e.printStackTrace();
            }
            for (int j = 0; j < 1000; j++) {
                add();
             }
                threadLatch.countDown();
            }
        }
        public static void main(String[] args) throws InterruptedException {
            for(int k = 0; k < 10; k++){
                Test test = new Test();
                CountDownLatch threadLatch = new CountDownLatch(10);
                for (int i = 0; i < 10; i++) {
                    test.new Work(threadLatch).start();
                }
                test.mainLatch.countDown();
                threadLatch.await();
                System.out.println(test.i);
            }
        }
    }

1.4 示例说明

java.util.concurrent.CountDownLatch的作用就像一个门闩或是闸门那样。上面这段代码一共执行10次,每次启动10个线程同时执行。mainLatch.await()相当于门闩挡着线程,让准备好的线程处于等待状态,当所有的线程都准备好时再调用mainLatch.countDown()方法,打开门闩让线程同时执行
在这里用这个类的原因,是想让创建的10个线程都准备好后再一起并发执行,这样才能很明显的看出add方法里面的i++效果。如果不引入CountDownLatch,只执行test.new Work(threadLatch).start(),则获得的结果可能看不出来线程竞争共享变量产生的错误情况。threadLatch这个CountDownLatch的作用是让10个线程都执行完run方法的for循环后通知主线程的threadLatch.await()停止等待打印出当前i的值。
取了几个比较明显的结果。当然,你也可以多运行几次看看效果。

在这里插入图片描述
在这里插入图片描述

共享变量i没做任何同步操作,当有多个线程都要读取并修改它时,问题就产生了。正确的结果应该是10000,但是我们看到了,不是每次结果都是10000。这段代码最初的版本不是这样的,因为现在的CPU哪怕是家用级PCCPU核心频率都非常高,所以完全看不出效果,run方法中的循环次数越大,i的并发问题就越明显,大家可以动手试下。对于上图的运行结果,和硬件平台有关
有同学会有疑问了,既然共享变量没加同步处理,那为什么还是会出现10000的结果呢?关于这点猜想这可能是JVM优化的结果,对于还没有很深入的研究,不敢随便下结论,请知道的朋友帮忙解答一下。

Java中,线程是怎么操作共享变量的呢?我们都知道,Java代码在编译后会变成字节码,然后在JVM里面运行,而像实例域i这样的变量是存储在堆内存Heap Memory中的,堆内存是内存中的一块区域。线程的执行其实说到底就是CPU的执行,当今的CPU(Intel)基本上都是多核的,因此多线程都是由多核CPU来处理,并且都有L1L2L3CPU缓存,CPU为了提高处理速度,在执行的时候,会从内存中把数据读到缓存后再操作,而每个线程执行add方法操作i++的过程是这样的:

  1. 线程从堆内存中读取i的值,将它复制到缓存中
  2. 在缓存中执行i++操作,并将结果赋给变量i
  3. 再用缓存中的值刷新堆内存中的变量i的值

上面写的这三步并不是严格按照JVMCPU指令的步骤来的,但过程就是这么一回事,方便大家理解。通过上面这个过程我们可以看出问题了,如果有多个线程同时要修改i,那么都需要先读取堆内存中的变量i值,然后把它复制到缓存后执行i++操作,再将结果写回到堆内存的变量i中。这个执行的时间非常短,可能只有零点几纳秒(主要还是跟硬件平台有关),但还是出现了错误。产生这种错误的原因是共享变量的可见性,线程1在读取变量i的值的时候,线程2正在更新变量i的值,而线程1这时看不到线程2修改的值。这种现象就是常说的共享变量可见性。
下图是线程执行的抽象图,也可以说是Java内存模型的抽象示意图,可能不严谨,但大意是这样的。

在这里插入图片描述

现在选用开发框架一般都会选择Spring,或是类似Spring这样的东西,而代码中经常用到的依赖注入的Bean如果没做处理一般都会是单例模式。试想一下,按下面这个方式引用Service或其它类似的Bean,在UserService中又不小心用到了共享变量,同时没有处理它的共享可见性,即同步,那将会产生意想不到的结果。不光Service是单例的,Spring MVC中的Controller也是单例的,所以编写代码的时候一定要注意共享变量的问题。

    @Autowired
    private UserService userService;

所以要尽可能的不使用共享变量,避开它,因为处理好共享变量可见性不是一个很简单的问题。如果有非用不可的理由,请使用java.util.concurrent.atomic包下面的原子类来代替常用变量类型。比如用AtomicInteger代替intAtomicLong代替long等等,具体可以参考API文档。如果需求比这更复杂,那还得想其它解决办法。
转载于:http://www.blogjava.net/bolo

2 volatile

出现上面的问题,可以使用关键字volatile来解决

2.1 volatile简介

volatile是什么
对于volatile, <The Java Language Specification Third Edition>是这样描述的:
A field may be declared volatile, in which case the Java memory model ensures that all threads see a consistent value for the variable.意思是,如果一个变量声明为volatile, Java内存模型保证所有的线程看到这个变量的值是一致的。

"… the volatile modifier guarantees that any thread that reads a field will see the most recently written value.” - Josh Bloch

Josh Bloch 说 "volatile描述符保证任意一个程序读取的是最新写的值“

有人会问,内存不是存放变量值的地方吗,线程T1写,然后线程T2读,怎么会出现不一致的情况呢(上面的那个demo就可以完美阐释)

2.2 缓存

实际上内存不是唯一存储变量的地方。CPU往往会把变量的值存放到缓存中。假如一个CPU,即使在多线程环境下也不会出现值不一致的情况。但是,在多CPU,或者多核CPU的情况就不是这样了。如下图所示,在多个CPU情况下,每个CPU都有独立的缓存,CPU通过连接相互获取缓存内容。线程T1的可能运行在CPU 0上,它从内存中读取值放到缓存中做运算,比如执行方法foo;线程T2运行于CPU 1上,执行方法bar

void foo(void)
{
   a = 1;
   b = 1;
  }

void bar(void)
{
  while (b == 0) continue;
  assert(a == 1);
}
在这里插入图片描述 在多CPU情况下,由于CPU各自缓存的原因,线程可能观察到不一致的变量值。
volitate标志通过CPU基本的指令,比如(mfence x86 Xeonmembar SPARC)添加内存界限,让缓存和内存之间的值进行同步

2.3 使用

volatile的一个作用
由于volatile保证一些线程写的值,另外一些线程能够立即看得到。我们可以通过这一特性,实现信号或事件机制。比如下面程序里主线程可以发送信号(把stopSignal设为true), 把线程workerThread立即终止。

public class  WorkerOwnerThread{
    // field is accessed by multiple threads.
    private static  volatile boolean  stopSignal;
    private static  void  doWork(){
         while (!stopSignal){
            Thread t = Thread.currentThread(); 
                System.out.println(t.getName()+ ": I will work until i get STOP signal from my Owner...");
            }
        System.out.println("I got Stop signal . I stop my work");
  }
 private static   void stopWork(){
   stopSignal = true;
   //Thread t = Thread.currentThread(); 
  //System.out.println("Stop signal from " + t.getName()  );
 }
public static void main(String[] args) throws InterruptedException {
 Thread workerThread = new Thread(new Runnable() {
     public void run() {
          doWork();    }
      });
 workerThread.setName("Worker");
    workerThread.start();
    //Main thread
    Thread.sleep(100);
    stopWork();
    System.out.println("Stop from main...");
 }
}

参考资料:

Memory Barrier
为什么需要Memory Barrier
Java 内存模型和Memory Barrier

上一篇下一篇

猜你喜欢

热点阅读