java-基础-volatile关键字的作用与用法

  • Post author:
  • Post category:java



前言:

在学习

volatile

关键字的时候,我们需要了解什么是

可见性

,什么是

原子操作。


作用:

1.volatile让变量每次在使用的时候,都从主存中取。而不是从各个线程的“工作内存”。

2.volatile关键字可以防止指令重排。

3.volatile具有synchronized关键字的“可见性”,但是没有synchronized关键字的“并发正确性”,也就是说不保证线程执行的有序性。

也就是说,volatile变量对于每次使用,线程都能得到当前volatile变量的最新值。但是volatile变量并不保证并发的正确性。

4.在Java内存模型中,有main memory,每个线程也有自己的memory (例如寄存器)。为了性能,一个线程会在自己的memory中保持要访问的变量的副本。这样就会出现同一个变量在某个瞬间,在一个线程的memory中的值可能与另外一个线程memory中的值,或者main memory中的值不一致的情况。

一个变量声明为volatile,就意味着这个变量是随时会被其他线程修改的,因此不能将它cache在线程memory中。


以下例子展现了volatile的作用:

public class TestThread extends Thread{

		  private static boolean pleaseStop = true;

		  public void run() {

		    while (pleaseStop) {

		     // do some stuff...
		    	
		    }

		 }
		  public static void main(String[] args) throws InterruptedException {
			  new TestThread().start(); //启动子线程
			  Thread.sleep(1000); //mian线程睡眠1s
			  pleaseStop = false;
		}

		}

我们运行上述代码可以发现,当静态变量 pleaseStop = false 时,子线程并没有结束运行

造成子线程不能停止的原因:

CPU中存在高速缓存:L1,L2,L3。 L1,L2 属于每个CPU的独立缓存,L3属于多个CPU的共享缓存

1.在多核CPU中存在多级缓存的情况下,静态变量pleaseStop 的值存储在主内存中,子线程run()方法中的的pleaseStop 变量的值读取的是一个CPU中高速缓存中的值,而主线程是在另一个CPU中修改pleaseStop 的值为false,将主内存的pleaseStop 的值变成false,然而子线程run()方法中的的pleaseStop 变量的值读取的是还是那个CPU中高速缓存中的值true,导致子线程不能停止,这时候给 pleaseStop 添加关键字volatile ,就可以解决问题,就是volatile 的可见性。

下面就是上面这段文字的图解,标注一下 此图来自蚂蚁课堂。

3.volatile底层实现原理:

volatile关键字是通过Lock锁的汇编指令实现的,让副本数据主动刷新主内存的数据,从而保证数据的一致性。这里需要说一下CPU总线,工作内存只要访问总内存中的数据,都会经过CPU总线,总线可以帮助我们解决多个不同CPU副本数据一致性的问题。volatile通过总线解决数据一致性问题的的方式有两种。

一种是总线锁的形式:使用总线锁的形式实现,只能有一个cpu操作主内存中的数据,再通过总线通知cpu更新副本中的值。效率比较低(使用在一些很老的处理器中),基本被淘汰。

一种是mesi协议:效率高(现在流行的处理器) 总嗅探机制通过判断当前cpu的四种状态,做出不同的反应,保证副本数据的一致性。

MESI协议可以理解为cpu的多种状态

M(Modefied 修改)状态 :如果CPU副本状态与主内存状态不一致的情况下,当前状态为M

E (Exclusive 独享)当只有一个cpu线程的情况下,CPU副本与主内存状态一致的情况下,当前状态为E

S (Shared 共享) 在多个CPU线程的情况下,每个副本之间的数据如果保持一致的情况下,当前状态为S

I (Invalid 无效) 多个CPU副本数据不一致  当前状态为I

4. Volatile一般情况下不能代替sychronized,因为volatile不能保证操作的

原子性

,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。如果配合Java 5增加的atomic wrapper classes,对它们的increase之类的操作就不需要sychronized。

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

例如:

package  mythread;

public   class  JoinThread  extends  Thread
{
     public   static volatile int  n  =   0 ;
    public   void  run()
    {
         for  ( int  i  =   0 ; i  <   10 ; i ++ )
             try 
        {
                n  =  n  +   1 ;
                sleep( 3 );  //  为了使运行结果更随机,延迟3毫秒 

            }
             catch  (Exception e)
            {
            }
    }

     public   static   void  main(String[] args)  throws  Exception
    {

        Thread threads[]  =   new  Thread[ 100 ];
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  建立100个线程 
            threads[i]  =   new  JoinThread();
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  运行刚才建立的100个线程 
            threads[i].start();
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  100个线程都执行完后继续 
            threads[i].join();
        System.out.println( " n= "   +  JoinThread.n);
    }
}


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

n  =  n  +   1 ;

n ++ ;

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

package  mythread;

public   class  JoinThread  extends  Thread
{
     public   static int  n  =   0 ;

     public static   synchronized   void  inc()
    {
        n ++ ;
    }
     public   void  run()
    {
         for  ( int  i  =   0 ; i  <   10 ; i ++ )
             try 
            {
                inc();  //  n = n + 1 改成了 inc(); 
                sleep( 3 );  //  为了使运行结果更随机,延迟3毫秒 

            }
             catch  (Exception e)
            {
            }
    }

     public   static   void  main(String[] args)  throws  Exception
    {

        Thread threads[]  =   new  Thread[ 100 ];
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  建立100个线程 
            threads[i]  =   new  JoinThread();
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  运行刚才建立的100个线程 
            threads[i].start();
         for  ( int  i  =   0 ; i  <  threads.length; i ++ )
             //  100个线程都执行完后继续 
            threads[i].join();
        System.out.println( " n= "   +  JoinThread.n);
    }
}

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


什么是原子级别的操作:

所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。也可以这样理解:如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。将整个操作视作一个整体是原子性的核心特征。


示例一:

int a = 1;


这种指令操作一般是原子的。因为对应着一条计算机指令,cpu将立即数1搬运到变量a的内存地址中即可


示例二:

i++;


从语法的级别来看,这是一条语句,是原子的。但是从实际执行的二进制指令来看,也不是原子的,其一般对应三条指令,首先将变量a对应的内存值搬运到某个寄存器(如eax)中,然后将该寄存器中的值自增1,再将该寄存器中的值搬运回a的内存中


示例三:

int a = b;

从语法的级别来看,这是也是一条语句,是原子的;但是从实际执行的二进制指令来看,由于现代计算机CPU架构体系的限制,数据不可以直接从内存搬运到另外一块内存,必须借助寄存器中断,这条语句一般对应两条计算机指令,即将变量b的值搬运到某个寄存器(如eax)中,再从该寄存器搬运到变量a的内存地址。

Java 可见性

可见性是指当某个线程修改了共享变量的值,其他线程能否立刻知晓。

内存模型

主存              所有线程都可以访问

本地内存       每个线程私有的内存

java 的所有变量都存储在主内存中

– 每个线程有自己独的工作内存,保存了该线程使用到的变量副本,是对主内存中变量的一份拷贝

– 每个线程不能访问其他线程的工作内存,线程间变量传递需要通过主内存来完成

– 每个线程不能直接操作主存,只能把主存的内容拷贝到本地内存后再做操作(这是线程不安全的本质),然后写回主存

可见性的方法

volatile

这种方式可以保证每次取数直接从主存取

它只能保证内存的可见性,无法保证原子性

它不需要加锁,比 synchronized 更轻量级,不会阻塞线程

不会被编译器优化

然而要求对这个变量做原子操作,否则还是会有问题

虽然 volatile 是轻量级,但是它也需要保证 读写的顺序不乱序,所以可以有优化点,比如在单例实现方式中的双重校验中,使用

临时变量

降低 volatile 变量的访问。

synchronized

Synchronized 能够实现原子性和可见性;在 Java 内存模型中,synchronized规 定,线程在加锁时,先清空工作内存 → 在主内存中拷贝最新变量的副本到工作内存 → 执行完代码 → 将更改后的共享变量的值刷新到主内存中 → 释放互斥锁。

所以如果无法用 volatile 做可见性,则可以考虑用 synchronized 可以做可见性的保证。



版权声明:本文为qq_38669394原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。