Java中Volatile 修饰符

Java中Volatile 修饰符
上次我们说到为什么会多卖一张票出去,其实是因为在多线程的情况下 
如果不同步 那么 线程与线程之间不是不具有可见性的。

可见性:

  可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

  可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就这这个操作同样存在线程安全问题。

原子性:

  1.原子性 
  原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。也就是说 在一个线程执行到某个 
  地方 也就是某个操作 这种 操作不能被其他线程打断 也就是 
  线程A 做 int x=1 的操作 首先线程A会去 读取主内存中 
  变量 x的值 在存在自己的 线程本地栈区 在做赋值为1的操作 
  这连续的操作不能被打断。 比如在读到X的时候 还没有赋值 
  为1 其他的线程抢占了CPU的执行权力 导致没有成功赋值为1 
  这就不是原子性操作

重排序:什么是重排序 其实我们写的代码并不是 顺序执行的 
因为 java内存模型 会对其我们写的代码进行优化

int x = 1 ;
boolean b=false;

这两句代码 并不是顺序执行的 可能会被优化成 

boolean b=false;
    int x = 1 ;

所有的优化 都是为了 我们的CPU能 并发执行 意思就是一个CPU同时运行几个线程 尽量节省 运行时间 不让CPU闲着。

但是这种重排序也要满足appens-before原则 
在什么之前执行 下面我们看

int x=1;  A
int y =x;  B 
boolean b = true; C

这种情况下java内存模型 简称 JMM 就不会对 A 和 B 进行重排序 
因为 有一个数据依赖关系原则 如果对他们 重新排序 那么执行结果 
会发生改变 所以不符合 jmm的 as-if-serial 原则

as-if-serial 原则 是指 不管你 为了提高运算速度 不管怎么样排序 你都必须 要保证最后的执行结果不能改变。

int x=1;  A
int y =x;  B 
int i  = y; C

appens-before 原则: 如果 代码 A依赖 B 那么 B又依赖C 
A-C A间接依赖C 传递依赖, 所以C的代码 也不能放到A的前面 
执行 也就是说不会执行重排序。

 大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

  也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

int i = i+1;
1
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

  这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

  比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

  可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。

  最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

  也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

  为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

  所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

在多线程 如果一个线程执行的代码进行重排序 对其他线程都是不可见 

int i= 10;
boolean  b= true;  //线程1

public get(){ //线程2
if(b){
    int y = i;
 }
}

如果这里线程 1 进行重排序 b=true;先执行 接着 线程2抢到CPU的 
执行权力 进入判断 y的值就是 0 而不是我们的10(int类型初始化都为0) 
因为一个线程执行对另外一个线程来说是不可见的,他们之间也没有数据依赖 所以也有可能不会顺序执行 这不是我们想要看到的,

int x =0;
x++;
1
2
我们再这里必须明白 线程都有自己的一个线程本地栈区 他们会从主内存中读取变量值 并存储在CPU的高速缓存中 (为什么出现CPU高速缓存 
因为cpu执行过快 不停的从主内存中读写并不能满足CPU的执行速度所以出现CPU缓存,每一个CPU都有一个高速缓存 意思4核的应该有4个), 当同事启动两个线程执行 他们会首先将X的值读入到所执行的CPU高速缓存中,在读取到本地栈去 进行操作 在写到主内存中 
这里假如 A线程 读到x的值为 0 并做了 +1的操作 那么B线程获取 
执行权力 B也读到了 x=0 那么并做+1 操作 写到内存中 那么x=1 
A线程获取执行权力 开始 写 x=1 那么最后开启两个线程 结果还是x=1 
就是因为这种缓存不一致的原因,因为x++不是原子性操作 可以被其他线程打断如何避免这种情况呢。

volatile 能满足 可见性 和有序性 意思被 volatile 修饰的变量 x 两个线程 
去读取 如果A线程 改变了 值 那么其他线程必须从 主内存中重新读取 x变量的值 不能冲高速缓存中读取了,保证了所有线程对共享变量X 
是可见的。

volatile 有序性:

//x、y为非volatile变量
//flag为volatile变量

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

  并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

volatile保证原子性吗?

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

  可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

  这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

  在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  假如某个时刻变量inc的值为10,

  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  那么两个线程分别进行了一次自增操作后,inc只增加了1。

  解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1从主内存中读取inc的值 x++ 实际就是 x+1的操作 
  执行过程应该是 线程1读取inc的值 并且+1 刷新回主内存中 让其他线程发现主内存中inc值发生改变

 但是因为不是原子性的操作所以  线程1 读取了inc的值后 可能线程又执行了  
 线程2读取 inc的值也是10  并且+1  刷回主内存中让 线程1发现 inc的值发生了改变
 使线程1放弃本地区域的inc值 重新从主内存中读取
 但是 可能线程2在做完+1的操作后 要刷回主内存中时  
 线程1又执行了  此时  线程2虽然做了+1操作 但是可能并没有刷回主内存中
 所以线程1发现的是主内存中的inc还是10 线程1做了+1 操作  线程2 把本地保存的 2刷入到主内存
 线程1把本地保存的2刷入到 主内存  这就导致了 两个线程做+1操作最后值还是11  而这种情况是可能发生的
 并不是一定发生 但是这个代码 还是线程不安全的

  根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

采用synchronized

public class Test {
    public  int inc = 0;

    public synchronized void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

这个样子就OK了

synchronized Lock 都会保证原子性 有序性 和可见性

volatile 只能满足 可见性 和有序性 Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件 —— 即变量真正独立于其他变量和自己以前的值 —— 在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错

volatile 使用场景

volatile 和 synchronized 实现 “开销较低的读-写锁”

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;

    public int getValue() { return value; }

    public synchronized int increment() {
        return value++;
    }
}

之所以将这种技术称之为 “开销较低的读-写锁” 是因为您使用了不同的同步机制进行读写操作。因为本例中的写操作违反了使用 volatile 的第一个条件,因此不能使用 volatile 安全地实现计数器 —— 您必须使用锁。然而,您可以在读操作中使用 volatile 确保当前值的可见性,因此可以使用锁进行所有变化的操作,使用 volatile 进行只读操作。其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作,因此当使用 volatile 保证读代码路径时,要比使用锁执行全部代码路径获得更高的共享度 —— 就像读-写操作一样。然而,要随时牢记这种模式的弱点:如果超越了该模式的最基本应用,结合这两个竞争的同步机制将变得非常困难。

volatile的原理和实现机制: 
  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

猜你喜欢

转载自blog.csdn.net/u012045045/article/details/88733173