我们先从一个demo看起
public class Data {
public int a = 1;
public static void main(String[] args) {
Data data = new Data();
new Thread(() ->{
try {
//休眠一秒后修改data.a的值为0
Thread.sleep(1000);
data.a = 0;
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
while(data.a == 1){
}
System.out.println("------------");
}
}
现在猜测一下,main方法何时退出
同学们心想这还不简单嘛,一秒呗,那么猜测一秒退出的兄弟们请运行此段代码,没错你没有看错,真实的结果是永不退出!main方法一直处于循环中,请收起你们的黑人问号脸,然后把a的定义修改如下重新运行:
public volatile int a = 1;
what?怎么又一秒退出了啊!就换了一个马甲就牛逼了呢?那么有请我们的猪脚volatile登场!
JMM探了个头:不好意思,我先说几句话哈
在并发编程中,需要解决的两个问题:
1.线程之间如何通信?
2.线程之间如何同步?
通信:在命令式编程中,线程之间的通信包括共享内存和消息传递 而 java并发采用的是共享内存模型,线程之间共享程序的公共状态,通过读写内存总的公共状态来隐式通信
熟悉jvm的都知道,java中所有的实例域,静态域,和数组元素都存在堆内存中,堆内存在线程之间共享,下面是一个简单的java内存模型图
每一个线程在操作内存时,都会从主内存即共享内存中拷贝一个副本为本地内存(也叫工作内存,线程对变量的操作必须在工作内存中进行,每个线程都有自己独有的一份工作内存),然后对本地内存进行读写,本地内存的变化并不会被其他线程所看到。那么,如何实现多个线程之间的通信和同步呢?先了解一下混个眼熟:
JMM关于同步的规定:
1.线程解锁前,必须把共享变量的值刷回主内存
2.线程加锁前,必须读取共享内存的最新值到自己的本地内存
3.加锁解锁是同一把锁
并发编程必须满足三个特性:
1.可见性:当多线程访问某一个(同一个)变量时,其中一条线程对此变量作出修改,其他线程可以立刻读取到最新修改后的变量。
2.原子性 :一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
3.有序性:我们都知道处理器为了拥有更好的运算效率,会自动优化、排序执行我们写的代码,但会确保执行结果不变,即指令重排。
那么volatile到底为何能实现线程之间的通信呢?
volatile 是java虚拟机提供的轻量级的同步机制
保证可见性
禁止指令重排
不保证原子性
1.可见性:
回到最初的代码示例,我们没用volatile修饰字段a之前,线程1和线程2只是对自己的本地内存进行操作,即主线程(线程1)一直读取a=1,无限循环不死不休,而休眠了一秒的线程(线程2)休眠结束时把本地的a修改为0,但并没有刷新回主内存,所以线程2的操作对线程1是不可见的,线程1当然一直循环下去了?加上volatile关键字之后呢?怎么完成的线程通信?是怎么实现保证的可见性呢?
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将会从主内存中读取共享变量
即通信方式为:线程1对volatile共享变量的写操作,实质是向有可能将要读取这个变量的线程们发出消息
线程2随后读取一个被某个线程修改的volatile变量,实质是收到了后者发出的消息
结合起来就是线程1通过主内存向线程2进行通信(发送消息)
至此,已经知道了为什么加上volatile就可以让main方法正常退出,接下来一起了解其他特性
2.禁止指令重排
什么是指令重排?
1.编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语义。
int a = 0; // 语句 1
int b = 0; // 语句 2
a++;// 语句 3
b++;// 语句 4
例如单线程下语句1和2,3和4都没有数据依赖性,1234的执行顺序完全可以2143来执行而不会有什么影响,那什么是数据依赖性呢?
对同一数据的两个操作中只要有一个是写操作,那么就存在数据依赖性,比如写后写,读后写,写后读。
2.指令级并行的重排序。
现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
重排序可能造成的后果:
多线程下:
// 线程 1,本意是先进行初始化,初始化过后将标志位置1
init();
int inited = 1;
// 线程 2 监听标志位,为1时开始工作
while(inited == 1 ){
work();
}
线程1的初始化和标志位置1是没有数据依赖性的,完全可以重排序,先将标志位置1,但是线程2监听到标志位已经是1,立马开始工作,而线程1的初始化工作还未完成,问题就出现了。
volatile是如何保证有序性呢?答案是内存屏障Memory Barrier
Memory Barrier 可以保证内存可见性和特定操作的执行顺序
volatile写操作之后都会插入一个store屏障,将工作内存中的值刷回到主内存,在读操作之前都会插入一个load屏障,从主内存读取最新的数据(可见性),而无论是stroe还是load都会告诉编译器和cpu,屏障前后的指令都不要进行重排序优化(禁止指令重排)
3.不保证原子性
首先什么是原子性:一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
举例:
public class Data {
public volatile int a = 1;
public void add(){
++a;
}
}
public static void main(String[] args) {
Data data = new Data();
//100*100=10000
for (int i=0;i <100;i++){
new Thread(() ->{
for (int j=0;j <100;j++){
data.add();
}
}).start();
}
//防止上面的线程没有全部跑完,对结果造成干扰activeCount>2 是因为除了主线程之外还有一个守护线程
while(Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(data.a);
}
一百个线程,每个线程把a+1 运行100次,我们打印出来的结果应该是
1+100*100 = 10001,下面是我的一个运行结果(每次的结果大概率不同)
问题分析:++a可以简单理解为先把a加一(操作1)再把+1后的值赋值给a(操作2)(有兴趣的可以查看我的另一篇博客JVM层面的I++和++i)
在多线程环境下,线程1在把a=1加1后a=2,还没来得及重新赋值此时a=1,此线程2读取a=1,a+1=2,可能此时线程1进行赋值a=2,然后线程2也进行赋值a=2,这样++a运行了两次,但a的值为2。
如此操作一万次,在多线程环境下,有很大的概率达不到我们预期的结果
那么,怎么解决这个问题呢?我们只要让操作1和操作2是原子性的,不允许别的线程插队,就可以解决问题,请关注我的下一篇博客,原子操作类AtomicInteger详解。
接下来分享一个真正的线程安全的单例模式
/**
* 线程安全的DCL双端检索懒汉模式
*/
public class SingleVolatile {
private SingleVolatile(){
}
//Volatile修饰
private static volatile SingleVolatile instance = null;
public static SingleVolatile getInstance(){
if (instance == null){//双重检索
synchronized (SingleVolatile.class){
if (instance == null){
instance = new SingleVolatile();
}
}
}
return instance;
}
}
懒汉模式就不多做介绍,双端检索是为了如果已经存在实例,就不需要再次进入消耗比较大的同步代码块,那么,为什么要用valatile修饰实例呢?
因为在第一次检查instance时,可能instance不为null,但是instance的引用对象还没有完成初始化
因为instance = new SingleVolatile大致分为三个阶段:
1分配对象内存空间,
2初始化对象
3将instance指向分配的内存空间,而此时instance != null!
2和3阶段是不存在数据依赖性的,所以在指令重排的情况下会出现instance != null,但是对象却还没有完成初始化
有人认为volatile是轻量级的synchronized的时候实现,以下是二者的区别
1.volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;
synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
2.volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
3.volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
4.volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
5.volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
下面这个链接是JVM对volatile可见性和有序性实现的细节,感兴趣同学的点这里
volatile保证可见性有序性的JVM实现细节