volatile简介
volatile是java提供的一种轻量级的同步方式。
volatile保证了共享变量对所有线程的可见性
在声明了volatile变量后,JMM会将该线程的本地内存强制写入到主内存中,并且会强制其他的线程的缓存的这个变量无效。
但在某些情况下volatile是没有用的:
import java.util.concurrent.CountDownLatch;
public class TestVolatile {
volatile public static int num=0;//设置num的起始值为0
//设置一个计数器,这个计数器在30个线程,每个线程执行完后-1
static CountDownLatch countDownLatch=new CountDownLatch(30);
public static void main(String[] args) throws InterruptedException{
for(int i=0;i<30;i++){
new Thread(){
public void run(){
for(int j=0;j<10000;j++){
num++;//运行30个线程,每个线程执行10000次num++
}
countDownLatch.countDown();
}
}.start();
}
countDownLatch.await();//主线程在等待30个线程都执行完后才会继续执行。
System.out.println(num);
}
}
在上面的代码中,因为num++不是一个原子性操作(读取,加一,赋值),所以有可能某一个线程在执行
读取后,另外一个线程已经将主内存中的num改掉了。这样最终的结果也就不会是我们期望的30000了
解决num++的非原子操作可以采用原子操作类:这样可以解决线程安全问题。
volatile public static AtomicInteger num=new AtomicInteger(0);//设置num的起始值为0
num.incrementAndGet();
概念
volatile关键字可以实现线程间的可见性,之所以可以实现这一点,原因在于JVM会保证被volatile修饰的变量,在线程栈中被线程使用时都会主动从共享内存(堆内存/主内存)中以实时的方式同步一次;
另一方面,如果线程在工作内存中修改了volatile修饰的变量,也会被JVM要求立马刷新到共享内存中去。因此,即便某个线程修改了该变量,其他线程也可以立马感知到变化从而实现可见性
volatile关键字只能修饰类变量和实例变量。方法参数、局部变量、实例常量以及类常量都是不能用volatile关键字进行修饰的"。
机器硬件CPU&JAVA内存模型
在深入理解volatile关键字之前,让我们先来回顾下并发问题产生的根本原因,这一点对于我们理解volatile关键字的存在意义是一个基础性问题。
我们知道在计算机系统中所有的运算操作都是由CPU来完成的,而CPU运算需要从内存中加载数据,计算完后再将结果同步回内存,
但是由于现代计算机的CPU的处理速度要比内存的访问速度牛逼N倍,如果CPU在进行数据运算时直接访问内存的话,由于内存的访问速度慢,这样就会拖慢CPU的运算效率。
为了解决这一问题,伟大的计算机科学家们就想到了一个办法,
通过在CPU和内存之间架设一层缓存,CPU不直接访问物理内存,而是将需要运算的数据从主内存中拷贝一份到缓存,运算的结果也通过缓存同步给主内存。
通过这种方法CPU的运行速度就大大提高了,目前主流的CPU都有L1、L2、L3三级缓存。
但是,这样的方式也带来了新的问题,那就是在多线程情况下同一份主内存中的数据值,会被拷贝多个副本放入CPU的缓存中,
如果两个线程同时对一个变量值进行赋值操作的话,就会产生数据不一致的问题,
例如:”变量i的初始值为0,两个线程同时加载到CPU缓存后,同时执行i+1的操作,按照道理说i的值此时应该是变成2,
而实际情况主内存的值可能还是1,因为两个线程彼此是不知道对方已经改动了这个变量的值的“。
而为了解决这样一个问题,
一些CPU制造商如Intel开发了诸如MESI协议这样的缓存一致性控制协议来解决CPU缓存与主内存之间的数据不一致问题,其基本操作大概就是在某个线程通过CPU缓存写入主内存时,
会通过信号的方式通知其他线程中CPU缓存中的值变为失效,从而让其他线程再次从主内存中同步一份数据到CPU缓存中。
以上关于CPU缓存与内存的介绍,并不是为了探讨关于CPU的原理,而是为了说明并发数据不一致问题产生的基本缘由是什么!同理,JAVA内存模型中的定义中,也进行了类似的设计。
在JAVA内存模型中,线程与主内存的关系是,线程并不直接操作主内存,而是通过将主内存中的变量拷贝到自己的工作内存中进行计算,完成后再将变量的值同步回主内存这样的方式进行工作
JAVA内存模型定义了线程与主内存之间的抽象关系,如下:共享变量(类变量以及对象的全局实例变量等都是共享变量)存储于主内存中,
每个线程都可以访问,这里的主内存可以看成是堆内存。每个线程都有私有的工作内存,这里的工作内存可以看成是栈内存。工作内存只存储该线程对共享变量的副本。
线程不能直接操作主内存,只有先操作了工作内存之后才能通过工作内存写入主内存。
以上关于工作内存及Java内存模型的概述,只是便于我们去理解JVM内存管理机制的一个抽象的概念,物理上并不是具体的存在。
从具体情况上来讲因为Java程序是运行在JVM之上的,并没有直接调用到操作系统的底层接口去操作硬件,所以线程操作数据进行运算最终还是通过JVM调用了受操作系统管理的CPU资源去进行计算。
而计算中涉及的CPU缓存与主内存的缓存一致性问题,则是操作系统层面的一层抽象,与Java工作内存彧主内存的划分并没有直接关系,它们是不同层次的设计。
如果非要用一张图来进行下类比,以便于大家好理解的话,那就来一张图吧:
根据图中的描述,
Java内存模型的区分的堆、栈内存只是虚拟机对自身使用的物理内存的内部划分,
它们对于操作系统管理来说就是一块被JVM使用的物理内存,而这个物理内存如果涉及CPU的运算操作,
CPU就会通过硬件指令对数据进行加载运算,最终更改物理内存中相应程序变量所对应的内存区块的值。
深入分析volatile的实现原理
synchronized是一个重量级的锁,虽然JVM对它做了很多优化,而下面介绍的volatile则是轻量级的synchronized。
如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度。
Java语言规范对volatile的定义如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
上面比较绕口,通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,
如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
volatile虽然看起来比较简单,使用起来无非就是在一个变量前面加上volatile即可,
但是要用好并不容易(LZ承认我至今仍然使用不好,在使用时仍然是模棱两可)。
内存模型相关概念
理解volatile其实还是有点儿难度的,它与Java的内存模型有关,所以在理解volatile之前我们需要先了解有关Java内存模型的概念,
这里只做初步的介绍,后续LZ会详细介绍Java内存模型。
操作系统语义
计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程中势必会涉及到数据的读写。
我们知道程序运行的数据是存储在主存中,这时就会有一个问题,
读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU高速缓存。
CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。
有了CPU高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。
在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,
在进行运算时CPU不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。
举一个简单的例子:
i++
当线程运行这段代码时,首先会从主存中读取i( i = 1),然后复制一份到CPU高速缓存中,
然后CPU执行 + 1 (2)的操作,然后将数据(2)写入到告诉缓存中,最后刷新到主存中。
其实这样做在单线程中是没有问题的,有问题的是在多线程中。
如下: 假如有两个线程A、B都执行这个操作(i++),按照我们正常的逻辑思维主存中的i值应该=3,但事实是这样么?
分析如下:
两个线程从主存中读取i的值(1)到各自的高速缓存中,然后线程A执行+1操作并将结果写入高速缓存中,
最后写入主存中,此时主存i==2,线程B做同样的操作,主存中的i仍然=2。所以最终结果为2并不是3。
这种现象就是缓存一致性问题。
解决缓存一致性方案有两种:
通过在总线加LOCK#锁的方式
通过缓存一致性协议
但是方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,
只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。
第二种方案,缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。
其核心思想如下:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,
因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
Java内存模型
上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下Java内存模型,
稍微研究一下Java内存模型为我们提供了哪些保证
以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。我们稍微看下volatile
原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性就像数据库里面的事务一样,他们是一个团队,同生共死。其实理解原子性非常简单,我们看下面一个简单的例子即可:
i = 0; ---1
j = i ; ---2
i++; ---3
i = j + 1; ---4
上面四个操作,有哪个几个是原子操作,那几个不是?
如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。
1---在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
2---包含了两个操作:读取i,将i值赋值给j
3---包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4---同三一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,
Java只保证了基本数据类型的变量和赋值操作才是原子性的
(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。
要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。
volatile是无法保证复合操作的原子性
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
Java提供了volatile来保证可见性。
当一个变量被volatile修饰后,表示着线程本地内存无效,
当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
当然,synchronize和锁都可以保证可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,
当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。
最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了
参考文章:
java高并发系列 - 第7天:volatile与Java内存模型