Java volatile的作用

Java volatile关键字作用是,使系统中所有线程对该关键字修饰的变量共享可见,可以禁止线程的工作内存对volatile修饰的变量进行缓存。下面分别详细介绍下volatile的这3个主要的作用。

作用一:可见性

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

先看一段代码,假如线程1先执行,线程2后执行:

//线程1 boolean stop = false; while(!stop){
    doSomething();
} //线程2 stop = true;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了:

第一:使用volatile关键字会强制将修改的值立即写入主存;

第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

那么线程1读取到的就是最新的正确的值。

作用二:保证有序性

我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

 1 package com.paddx.test.concurrent;
 2 
 3 public class Singleton {
 4     public static volatile Singleton singleton;
 5 
 6     /**
 7      * 构造函数私有,禁止外部实例化
 8      */
 9     private Singleton() {};
10 
11     public static Singleton getInstance() {
12         if (singleton == null) {
13             synchronized (singleton) {
14                 if (singleton == null) {
15                     singleton = new Singleton();
16                 }
17             }
18         }
19         return singleton;
20     }
21 }

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

(1)分配内存空间。

(2)初始化对象。

(3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

(1)分配内存空间。

(2)将内存空间的地址赋值给对应的引用。

(3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

作用三:保证原子性

volatile关键字用于声明简单类型变量,如int、float、 boolean等数据类型。如果这些简单数据类型声明为volatile,对它们的操作就会变成原子级别的。但这有一定的限制。例如,下面的例子中的n就不是原子级别的: 

  1. package  mythread;  
  2. public   class  JoinThread  extends  Thread  
  3. {  
  4.      public   static volatile int  n  =   0 ;  
  5.     public   void  run()  
  6.     {  
  7.          for  ( int  i  =   0 ; i  <   10 ; i ++ )  
  8.              try   
  9.         {  
  10.                 n  =  n  +   1 ;  
  11.                 sleep( 3 );  //  为了使运行结果更随机,延迟3毫秒   
  12.             }  
  13.              catch  (Exception e)  
  14.             {  
  15.             }  
  16.     }  
  17.   
  18.      public   static   void  main(String[] args)  throws  Exception  
  19.     {  
  20.         Thread threads[]  =   new  Thread[ 100 ];  
  21.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  22.              //  建立100个线程   
  23.             threads[i]  =   new  JoinThread();  
  24.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  25.              //  运行刚才建立的100个线程   
  26.             threads[i].start();  
  27.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  28.              //  100个线程都执行完后继续   
  29.             threads[i].join();  
  30.         System.out.println( " n= "   +  JoinThread.n);  
  31.     }  
  32. }   

    
如果对n的操作是原子级别的,最后输出的结果应该为n=1000,而在执行上面积代码时,很多时侯输出的n都小于1000,这说明n=n+1不是原子级别的操作。原因是声明为volatile的简单变量如果当前值由该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作: 

n  =  n  +   1 ; 
n ++ ; 

      如果要想使这种情况变成原子操作,需要使用synchronized关键字,如上的代码可以改成如下的形式: 

  1. package  mythread;  
  2.   
  3. public   class  JoinThread  extends  Thread  
  4. {  
  5.      public   static int  n  =   0 ;  
  6.      public static   synchronized   void  inc()  
  7.     {  
  8.         n ++ ;  
  9.     }  
  10.      public   void  run()  
  11.     {  
  12.          for  ( int  i  =   0 ; i  <   10 ; i ++ )  
  13.              try   
  14.             {  
  15.                 inc();  //  n = n + 1 改成了 inc();   
  16.                 sleep( 3 );  //  为了使运行结果更随机,延迟3毫秒   
  17.               }  
  18.              catch  (Exception e)  
  19.             {  
  20.             }  
  21.     }  
  22.   
  23.      public   static   void  main(String[] args)  throws  Exception  
  24.     {  
  25.         Thread threads[]  =   new  Thread[ 100 ];  
  26.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  27.              //  建立100个线程   
  28.             threads[i]  =   new  JoinThread();  
  29.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  30.              //  运行刚才建立的100个线程   
  31.             threads[i].start();  
  32.          for  ( int  i  =   0 ; i  <  threads.length; i ++ )  
  33.              //  100个线程都执行完后继续   
  34.             threads[i].join();  
  35.         System.out.println( " n= "   +  JoinThread.n);  
  36.     }  
  37. }   

    上面的代码将n=n+1改成了inc(),其中inc方法使用了synchronized关键字进行方法同步。因此,在使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作都是原来操作,当变量的值由自身的上一个决定时,如n=n+1、n++ 等,volatile关键字将失效,只有当变量的值和自身上一个值无关时对该变量的操作才是原子级别的,如n = m + 1,这个就是原级别的。所以在使用volatile关键时一定要谨慎,如果自己没有把握,可以使用synchronized来代替volatile。 

Java中的原子操作包括:
1)除long和double之外的基本类型的赋值操作
2)所有引用reference的赋值操作
3)java.concurrent.Atomic.* 包中所有类的一切操作
count++不是原子操作,是3个原子操作组合
1.读取主存中的count值,赋值给一个局部成员变量tmp
2.tmp+1
3.将tmp赋值给count
可能会出现线程1运行到第2步的时候,tmp值为1;这时CPU调度切换到线程2执行完毕,count值为1;切换到线程1,继续执行第3步,count被赋值为1------------结果就是两个线程执行完毕,count的值只加了1;
还有一点要注意,如果使用AtomicInteger.set(AtomicInteger.get() + 1),会和上述情况一样有并发问题,要使用AtomicInteger.getAndIncrement()才可以避免并发问题


版权声明:本文为JAVASCHOOL原创文章,未经本站允许不得转载。