volatile关键字详解

前言
volatile关键字用于多线程编程,和java的内存模型有关,想要深入的了解它,需要先了解Java的内存模型。

本文分为下面几个模块讲解,本文内容主要来自《深入理解Java虚拟机》。

  • 1 内存模型(缓存一致性协议)
  • 2 Java内存模型
  • 3 并发编程的三个概念
  • 4Java内存模型是如何保证并发安全?
  • 5 深入解析volatile关键字
  • 6 volatile关键字使用场景

1 计算机内存模型

  • 在现代,计算机操作系统的多任务处理已经成为了一种标配,计算机去同时做几件事情,不仅是因为计算机运算能力强大了,还有一个重要原因是计算机的运算速度与它的存储和通信子系统速度的差异太大,大量的事件花费在磁盘的I/O,网络通信和数据库访问上,所以如果想要充分的使用处理器,可以通过让服务端同时服务多个客户端,这就是一个典型的并发场景。衡量一个服务的好坏,可以用TPS(Transactions Per Second),它代表一秒内服务端平均能够响应的请求总数。所以TSP与程序的并发能力有非常密切的关系,服务端是Java最擅长的领域之一。
  • 上面说了并发的原因,那么在计算机中每一次CPU的运算是怎么进行的呢?我们都知道计算机在执行程序的时候,每一条指令都是在CPU中执行的,CPU需要和数据交互,但是数据存放在物理内存(主内存)中,CPU的运算速度和主内存数据的读取速度不是一个量级的,如果CPU直接和主内存的数据交互的话,计算机速度就大大降低了,因为需要引入一个中间体,就是CPU中的高速缓存。
  • 增加了高速缓存之后就引入了一个问题,下面通过一个例子来讲解,比如 i = i + 1这个简单的操作,需要先从主内存中读取i的值,然后复制一份到CPU所在的高速缓存,然后CPU对其进行加1操作,然后将数据写入高速缓存,最后将高速缓存中的最新的i值刷新到主内存中存储。
  • 上面的情况如果在单线程中是没问题的,但是如果在多线程中就会有问题了,在多核CPU中,每一条线程运行于不同的CPU中,所以每个线程都有自己的高速缓存(如果是单核CPU的话,也会出现这种问题,只不过是以线程调度的形式分别执行的),还是上面的例子,我们演示多线程下的可能运行情况,线程1和线程2同时执行 i = i + 1操作,i的初始值为0的话,最终的结果是2吗?不一定的,下面分析:
    • 线程1和线程2同时读取i的值,存入到各自的高速缓存中,线程1这时候执行了加1操作,但是没来得及存入主内存,这时候线程2中还是最开始读取的i的值为0,那么最终我们得到i的结果就不是2而是1,这就是著名的缓存一致性问题。
    • 如果一个变量被多个线程共享,就被称为共享变量,它就在多个线程的CPU中有缓存,就会出现缓存一致性问题。下面通过一张图来感知一下。
      缓存一致性协议
  • 出现了上述的问题之后,为了解决缓存一致性问题,有两种解决办法,一种是整体加锁(类似于synchronized),其它的CPU只能阻塞访问不了,当然效率就低。还有一种是缓存一致性协议,比较著名的就是MESI协议:它能保证每个高速缓存中使用的共享变量的副本是一致的,它的原理是这样的,当CPU写入数据的时候,如果发现操作的变量是共享变量,会发出通知其它CPU将该变量的缓存行设置为无效状态,这样在其它CPU读取这个变量的时候,就会发现自己的缓存行无效,就需要去主内存中重新读取。

2 Java内存模型

  • Java内存模型(JMM Java Memory Model)的主要目标是定义程序中的各个变量访问的规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层操作。此处的变量包括了实例字段,静态字段和构成数组对象的元素,但不包括局部变量表和方法参数,因为后者是线程私有的。不存在竞争关系。
  • Java内存模型规定了所有的变量都存储在主内存中(可以类比计算机的物理内存),每条线程还有自己的工作内存(可以类比计算机的高速缓存)。线程工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。线程间不能访问其它线程的工作内存变量,线程间变量的传值需要通过主内存来完成,线程,工作内存和主内存的关系图如下:
    Java内存模型
  • 内存间交互操作,主内存和工作内存之间的具体交互协议,Java内存模型规定了8种操作,每一个操作都是原子性的,不可再分的
    • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
    • unlock(解锁):作用于主内存的变量,它把一个变量处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定。
    • read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。
    • load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。
    • assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。
    • store(存储):把工作内存的变量的值传递给主内存。
    • write(写入):把store操作的值入到主内存的变量中。
  • 并且这8种内存访问操作有以下规则
    • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但是工作内存不接受,或者从工作内存发起了回写,但是主内存不接受。
    • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
    • 一个变量在同一时刻只允许一条线程对其进行lock操作,但是lock操作可以被一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
    • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
    • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
    • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

3 并发编程的三个概念

在并发编程中有三个问题,原子性问题,可见性问题,有序性问题,如果这三个问题能够解决的话,并发编程就没有问题了,下面我们逐个讲解:

  • 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
    • 比如常见的转账问题,如果A向B转账100元,A转账后,扣完钱后,操作突然终止,B这时候没有收到钱,就会造成很大的影响。所以必须保证这两个操作具备原子性。
    • 比如编程中的 i = 1 赋值操作,也必须要具备原子性,如果不具备原子性的话,计算就没法正确进行。
  • 可见性:可见性是指当多个线程访问同一个变量时,如果某一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。比如下面的操作,线程1首先将i初始化值为0,然后将i赋值为10,但是此时i的值还在高速缓存中,并没有被刷新到主存中,线程2得到的还是i=0的值,所以j的值就是0,不是10。这就是可见性问题。
    //线程1
    int i = 0;
    i = 10;
    
    //线程2
    int j = i;
    
  • 有序性:即程序执行的顺序按照代码的先后顺序执行,比如下面这段代码,语句1和语句2是有前后关系的,但是程序在执行的时候可能会发生指令重排序。
    int i = 0;					
    boolean flag = false;		
    
    i = 10;						//语句1
    flag = true;				//语句2
    
  • 指令重排序指的是:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序会考虑数据之间的依赖性,如果后一条执行对前一条指令有依赖性,处理器就会保证指令前一条指令先于后一条指令执行,所以指令重排序对单线程是没有影响的,但是对多线程就有影响了,比如下面这段代码:
    //线程1
    context  = initContext();//语句1
    inited = true;			  //语句2
    
    //线程2
    while(!inited) {
    	// do something wait
    }
    doSomething(context);
    
  • 上述线程1中语句1和语句2 是没有依赖性的,那么语句1和语句2 是可能会被指令重排序的,就会导致context实际上并没有初始化的时候,inited就被赋值为true了,那么在线程2中就会跳出while循环,执行doSomething(context),但是此时context并没有初始化,就可能出问题了。

4 Java内存模型是如何保证并发安全?

下面我们看一下Java内存模型是如何保证上述并发编程的的三个特性的。

  • 原子性

    • Java内存模型中用来保证原子性的操作有read, load,assign,use,store,write,可以认为基本类型的访问读写是具备原子性的(long和double的有些例外,可以忽略不计)。比如 int i =1就是原子性的,但是 x++, x = x + 1, y = x 这些就不是原子性的操作了。
    • x++和x = x +1:都包括三个步骤,读取x的值,加1,写入新的值。
    • y = x 则是从主内存中读取x的值,再将x的值写入工作内存。
    • 如果想要实现更大范围的操作原子性,可以使用synchronized和Lock,因为在同一个时刻只有一个线程执行代码,并且在释放锁之前会将对变量的修改刷新到主存当中,所以就不存在原子性问题了。
  • 可见性

    • 可见性是指当一个线程修改了共享变量的值,其它的线程能够立即得知这个修改,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
    • final关键字的可见性:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去(因为this引用逃逸是一件很危险的事情,其它线程有可能通过这个引用访问到了初始化一半的对象),那么在其它线程就可以看到这个final字段的值,比如下面这段代码就可以保证i和j的可见性:
      public static final int i;
      public final int j;
      static {
      	i = 0;
      }
      {
      	//也可以选择在构造函数中初始化
      	j = 0;
      }
      
    • 另外通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
  • 有序性:

    • 在Java内存模型中,可以通过synchronized和Lock来保证有序性,另外前面提到过volatile能够保证一定程度的有序性。但是如果Java内存模型的有序性仅仅依靠volatile和synchronized来完成,那么有一些操作就会变得很繁琐,但是我们在并发编程过程中,并没有感知到这一点,是因为Java语言有一个happens-before原则,它是判断线程是否存在竞争,线程是否安全的重要依据,如果两个操作之间的关系不能被下列规则推导出来的话,就没有顺序性保障,虚拟机就可以对他们随意地进行排序,我们看下这些规则:
      • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
      • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
      • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
      • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
      • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
      • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
      • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
      • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

5 深入解析volatile关键字

  • volatile关键字的作用:
    • 当一个变量被volatile修饰以后,它会保证此变量对所有线程的可见性,也就是一旦这个值发生了变化,其它线程是立马能够得知新值的,普通的变量是不具备这个特性的,普通变量的值在线程间传递必须要经过主内存来完成。
    • 禁止指令重排序
  • volatile的可见性:我们通过下面一个例子来看下volatile关键字的作用,如果不加volatile关键字的话,代码如下:
    //线程1
    boolean shutdown = false;
    while(!shutdown) {
    	//do something
    }
    
    //线程2
    shutdown = true;
    
    • 如果不加volatile关键字的话,上述代码在线程2执行shutdown = true的时候,线程1有可能并不能立马感知到,就会出现错误。我们分析一下。首先线程1将shutdown的值拷贝一份放在自己的工作内存中,此时shutdown为false, 循环在继续。此时线程2 将shutdown的值变为true,虽然赋值操作是原子性的,但是这个值有可能并没有立马刷新到主内存中,线程2失去了cpu的时间片,此时线程1的值仍然为false,就会继续进入循环。
    • 如果加了volatile来修饰shutdown变量的话, 就会强制将修改的值立即写入主存,这样就会导致线程2进行shutdown的修改时候,就会通知线程1中shutdown的缓存行无效,如果线程1想要使用就需要去主内存中重新读取新值,但是需要等待线程2写入值完成(shutdown = true 仍然分为两个部分,首先是修改线程2工作内存的值,然后将修改的值写入主存中)。
  • 上面我们知道volatile能保证可见性,但是能保证原子性吗?答案是不能,我们看下面的代码,下面的代码虽然我们加了volatile关键字,但是执行结果并不是我们期望的1000,因为volatile虽然能保证修改后的值立马可见,但是value++操作并不是原子性的,比如value的值为5,线程1加1之后的值6不一定会立马刷新到主存中,线程2执行加1的时候,从主内存得到的就不是6而是5,所以最终的结果就不是1000了。
    public class VolatileTest {
        private volatile int value = 0;
    
        private void increase() {
            value++;
        }
    
        public static void main(String[] args) {
            final VolatileTest test = new VolatileTest();
            for (int i = 0; i < 10; i++) {
                new Thread() {
                    public void run() {
                        for (int j = 0; j < 100; j++)
                            test.increase();
                    }
                }.start();
            }
            System.out.println(test.value); //结果不确定
        }
    }
    
    • 想要实现线程安全就可以对方法加synchronized关键字,这个时候volatile就可以去掉了,如下:
    public class VolatileTest {
    	public int value = 0;
    
    	public synchronized void increase() {
    		value++;
    	}
    
    	public static void main(String[] args) {
    		final VolatileTest test = new VolatileTest();
    		for (int i = 0; i < 10; i++) {
    			new Thread() {
    				public void run() {
    					for (int j = 0; j < 100; j++)
    						test.increase();
    				};
    			}.start();
    		}
    
    		while (Thread.activeCount() > 1)
    			Thread.yield();
    		System.out.println(test.value); //1000
    	}
    }
    
    • 或者将synchronized换为可见的lock和unlock操作,如下:
    public class VolatileTest {
    	public int value = 0;
    
    	Lock lock = new ReentrantLock();
    
    	public void increase() {
    		lock.lock();
    		try {
    			value++;
    		} finally {
    			lock.unlock();
    		}
    	}
    
    	public static void main(String[] args) {
    		final VolatileTest test = new VolatileTest();
    		for (int i = 0; i < 10; i++) {
    			new Thread() {
    				public void run() {
    					for (int j = 0; j < 100; j++)
    						test.increase();
    				};
    			}.start();
    		}
    
    		while (Thread.activeCount() > 1)
    			Thread.yield();
    		System.out.println(test.value);
    	}
    }
    
    • 或者使用原子类AtomicInteger:
    public class VolatileTest {
    	public AtomicInteger value = new AtomicInteger();
    
    	Lock lock = new ReentrantLock();
    
    	public void increase() {
    			value.incrementAndGet();
    	}
    
    	public static void main(String[] args) {
    		final VolatileTest test = new VolatileTest();
    		for (int i = 0; i < 10; i++) {
    			new Thread() {
    				public void run() {
    					for (int j = 0; j < 100; j++)
    						test.increase();
    				};
    			}.start();
    		}
    
    		while (Thread.activeCount() > 1)
    			Thread.yield();
    		System.out.println(test.value);
    	}
    }
    
  • volatile能保证有序性吗?volatile关键字禁止指令重排序有两层意思:
    • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
    • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
  • 还是前面例子中的代码,如果inited加上了volatile关键字修饰的话,就不会出现问题了,因为执行到语句2的时候,能够保证语句1一定执行了。
    //线程1
    context  = initContext();//语句1
    inited = true;			  //语句2
    
    //线程2
    while(!inited) {
    	// do something wait
    }
    doSomething(context);
    

6 volatile关键字使用场景

  • 1 一般对boolean的变量控制在多线程下使用volatile修饰,就拿前面的例子,如下:
    volatile boolean inited = false
    //线程1
    context  = initContext();//语句1
    inited = true;			  //语句2
    
    //线程2
    while(!inited) {
    	// do something wait
    }
    doSomething(context);
    
    2 在单例模式中使用,比如常用的Eventbus库就是采用这种方式,关于单例模式详解可以点击设计模式-单例模式查看:
    public class EventBus {
        static volatile EventBus defaultInstance;
    	public static EventBus getDefault() {
            EventBus instance = defaultInstance;
            if (instance == null) {
                synchronized (EventBus.class) {
                    instance = EventBus.defaultInstance;
                    if (instance == null) {
                        instance = EventBus.defaultInstance = new EventBus();
                    }
                }
            }
            return instance;
        }
    }
    
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值