线程安全(上)--完全搞懂volatile关键字

对于volatile这个关键字,相信不少朋友都据说过,甚至使用过,这个关键字虽然字面上理解起来比较简单,可是要用好起来却不是一件容易的事。java

这篇文章将从多个方面来说解volatile,让你对它更加理解。编程

计算机中为何会出现线程不安全的问题

volatile既然是与线程安全有关的问题,那咱们先来了解一下计算机在处理数据的过程当中为何会出现线程不安全的问题。数组

你们都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程当中会涉及到数据的读取和写入。因为程序运行过程当中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,因为CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,所以若是任什么时候候对数据的操做都要经过和内存的交互来进行,会大大下降指令执行的速度。缓存

为了处理这个问题,在CPU里面就有了高速缓存(Cache)的概念。当程序在运行过程当中,会将运算须要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就能够直接从它的高速缓存读取数据和向其中写入数据,当运算结束以后,再将高速缓存中的数据刷新到主存当中。安全

我举个简单的例子,好比cpu在执行下面这段代码的时候,bash

t = t + 1;

复制代码

会先从高速缓存中查看是否有t的值,若是有,则直接拿来使用,若是没有,则会从主存中读取,读取以后会复制一份存放在高速缓存中方便下次使用。以后cup进行对t加1操做,而后把数据写入高速缓存,最后会把高速缓存中的数据刷新到主存中。多线程

这一过程在单线程运行是没有问题的,可是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不一样的CPU中,所以每一个线程运行时有本身的高速缓存(对单核CPU来讲,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的,本次讲解以多核cup为主)。这时就会出现同一个变量在两个高速缓存中的不一致问题了。ide

例如:优化

两个线程分别读取了t的值,假设此时t的值为0,而且把t的值存到了各自的高速缓存中,而后线程1对t进行了加1操做,此时t的值为1,而且把t的值写回到主存中。可是线程2中高速缓存的值仍是0,进行加1操做以后,t的值仍是为1,而后再把t的值写回主存。spa

此时,就出现了线程不安全问题了。

Java中的线程安全问题

上面那种线程安全问题,可能对于不一样的操做系统会有不一样的处理机制,例如Windows操做系统和Linux的操做系统的处理方法可能会不一样。

咱们都知道,Java是一种夸平台的语言,所以Java这种语言在处理线程安全问题的时候,会有本身的处理机制,例如volatile关键字,synchronized关键字,而且这种机制适用于各类平台。

Java内存模型规定全部的变量都是存在主存当中(相似于前面说的物理内存),每一个线程都有本身的工做内存(相似于前面的高速缓存)。线程对变量的全部操做都必须在工做内存中进行,而不能直接对主存进行操做。而且每一个线程不能访问其余线程的工做内存。

因为java中的每一个线程有本身的工做空间,这种工做空间至关于上面所说的高速缓存,所以多个线程在处理一个共享变量的时候,就会出现线程安全问题。

这里简单解释下共享变量,上面咱们所说的t就是一个共享变量,也就是说,可以被多个线程访问到的变量,咱们称之为共享变量。在java中共享变量包括实例变量,静态变量,数组元素。他们都被存放在堆内存中。

volatile关键字

上面扯了一大堆,都没提到volatile关键字的做用,下面开始讲解volatile关键字是如何保证线程安全问题的。

可见性

什么是可见性?

意思就是说,在多线程环境下,某个共享变量若是被其中一个线程给修改了,其余线程可以当即知道这个共享变量已经被修改了,当其余线程要读取这个变量的时候,最终会去内存中读取,而不是从本身的工做空间中读取

例如咱们上面说的,当线程1对t进行了加1操做并把数据写回到主存以后,线程2就会知道它本身工做空间内的t已经被修改了,当它要执行加1操做以后,就会去主存中读取。这样,两边的数据就能一致了。

假如一个变量被声明为volatile,那么这个变量就具备了可见性的性质了。这就是volatile关键的做用之一了。

volatile保证变量可见性的原理

当一个变量被声明为volatile时,在编译成会变指令的时候,会多出下面一行:

0x00bbacde: lock add1 $0x0,(%esp);
复制代码

这句指令的意思就是在寄存器执行一个加0的空操做。不过这条指令的前面有一个lock(锁)前缀。

当处理器在处理拥有lock前缀的指令时:

在以前的处理中,lock会致使传输数据的总线被锁定,其余处理器都不能访问总线,从而保证处理lock指令的处理器可以独享操做数据所在的内存区域,而不会被其余处理所干扰。

但因为总线被锁住,其余处理器都会被堵住,从而影响了多处理器的执行效率。为了解决这个问题,在后来的处理器中,处理器遇到lock指令时不会再锁住总线,而是会检查数据所在的内存区域,若是该数据是在处理器的内部缓存中,则会锁定此缓存区域,处理完后把缓存写回到主存中,而且会利用缓存一致性协议来保证其余处理器中的缓存数据的一致性。

缓存一致性协议

刚才我在说可见性的时候,说“若是一个共享变量被一个线程修改了以后,当其余线程要读取这个变量的时候,最终会去内存中读取,而不是从本身的工做空间中读取”,其实是这样的:

线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其余处理器的操做状况,一旦嗅探到某到处理器打算修改其内存地址中的值,而该内存地址恰好也在本身的内部缓存中,那么处理器就会强制让本身对该缓存地址的无效。因此当该处理器要访问该数据的时候,因为发现本身缓存的数据无效了,就会去主存中访问。

有序性

实际上,当咱们把代码写好以后,虚拟机不必定会按照咱们写的代码的顺序来执行。例如对于下面的两句代码:

int a = 1;
int b = 2;
复制代码

对于这两句代码,你会发现不管是先执行a = 1仍是执行b = 2,都不会对a,b最终的值形成影响。因此虚拟机在编译的时候,是有可能把他们进行重排序的。

为何要进行重排序呢?

你想啊,假如执行 int a = 1这句代码须要100ms的时间,但执行int b = 2这句代码须要1ms的时间,而且先执行哪句代码并不会对a,b最终的值形成影响。那固然是先执行int b = 2这句代码了。

因此,虚拟机在进行代码编译优化的时候,对于那些改变顺序以后不会对最终变量的值形成影响的代码,是有可能将他们进行重排序的。

更多代码编译优化能够看我写的另外一篇文章: 虚拟机在运行期对代码的优化策略

那么重排序以后真的不会对代码形成影响吗?

实际上,对于有些代码进行重排序以后,虽然对变量的值没有形成影响,但有可能会出现线程安全问题的。具体请看下面的代码

public class NoVisibility{
    private static boolean ready;
    private static int number;
    
    private static class Reader extends Thread{
        public void run(){
        while(!ready){
            Thread.yield();
        }
        System.out.println(number);

    }
}
    public static void main(String[] args){
        new Reader().start();
        number = 42;
        ready = true;
    }
}

复制代码

这段代码最终打印的必定是42吗?若是没有重排序的话,打印的确实会是42,但若是number = 42和ready = true被进行了重排序,颠倒了顺序,那么就有可能打印出0了,而不是42。(由于number的初始值会是0).

所以,重排序是有可能致使线程安全问题的。

若是一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量以前的代码必定会比它先执行,而以后的代码必定会比它慢执行。

例如把上面中的number声明为volatile,那么number = 42必定会比ready = true先执行。

不过这里须要注意的是,虚拟机只是保证这个变量以前的代码必定比它先执行,但并无保证这个变量以前的代码不能够重排序。以后的也同样。

volatile关键字可以保证代码的有序性,这个也是volatile关键字的做用。

总结一下,一个被volatile声明的变量主要有如下两种特性保证保证线程安全。

  1. 可见性。
  2. 有序性。

volatile真的能彻底保证一个变量的线程安全吗?

咱们经过上面的讲解,发现volatile关键字仍是挺有用的,不但可以保证变量的可见性,还能保证代码的有序性。

那么,它真的可以保证一个变量在多线程环境下都能被正确的使用吗?

答案是否认的。缘由是由于Java里面的运算并不是是原子操做

原子操做

原子操做:即一个操做或者多个操做 要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。

也就是说,处理器要嘛把这组操做所有执行完,中间不容许被其余操做所打断,要嘛这组操做不要执行。

刚才说Java里面的运行并不是是原子操做。我举个例子,例如这句代码

int a = b + 1;
复制代码

处理器在处理代码的时候,须要处理如下三个操做:

  1. 从内存中读取b的值。
  2. 进行a = b + 1这个运算
  3. 把a的值写回到内存中

而这三个操做处理器是不必定就会连续执行的,有可能执行了第一个操做以后,处理器就跑去执行别的操做的。

证实volatile没法保证线程安全的例子

因为Java中的运算并不是是原子操做,因此致使volatile声明的变量没法保证线程安全。

对于这句话,我给你们举个例子。代码以下:

public class Test{
    public static volatile int t = 0;
    
    public static void main(String[] args){
    
        Thread[] threads = new Thread[10];
        for(int i = 0; i < 10; i++){
            //每一个线程对t进行1000次加1的操做
            threads[i] new Thread(new Runnable(){
                @Override
                public void run(){
                    for(int j = 0; j < 1000; j++){
                        t = t + 1;
                    }
                }
            });
            threads[i].start();
        }
        
        //等待全部累加线程都结束
        while(Thread.activeCount() > 1){
            Thread.yield();
        }
        
        //打印t的值
        System.out.println(t);
    }
}

复制代码

最终的打印结果会是1000 * 10 = 10000吗?答案是否认的。

问题就出如今t = t + 1这句代码中。咱们来分析一下

例如:

线程1读取了t的值,假如t = 0。以后线程2读取了t的值,此时t = 0。

而后线程1执行了加1的操做,此时t = 1。可是这个时候,处理器尚未把t = 1的值写回主存中。这个时候处理器跑去执行线程2,注意,刚才线程2已经读取了t的值,因此这个时候并不会再去读取t的值了,因此此时t的值仍是0,而后线程2执行了对t的加1操做,此时t =1 。

这个时候,就出现了线程安全问题了,两个线程都对t执行了加1操做,但t的值倒是1。因此说,volatile关键字并不必定可以保证变量的安全性。

什么状况下volatile可以保证线程安全

刚才虽说,volatile关键字不必定可以保证线程安全的问题,其实,在大多数状况下volatile仍是能够保证变量的线程安全问题的。因此,在知足如下两个条件的状况下,volatile就能保证变量的线程安全问题:

  1. 运算结果并不依赖变量的当前值,或者可以确保只有单一的线程修改变量的值。
  2. 变量不须要与其余状态变量共同参与不变约束。

讲到这里,关于volatile关键字的就算讲完了。若是有哪里讲的不对的地方,很是欢迎你的指点。下篇应该会讲synchronize关键字。

参考书籍:

  1. 深刻理解Java虚拟机(JVM高级特性与最佳实践)。
  2. Java并不是编程实战

关注公众号:苦逼的码农,获取更多原创文章,后台回复"礼包"送你一份资源大礼包。

相关文章
相关标签/搜索