什么是Volatile
volatile是java虚拟机提供的轻量级的同步机制,volatile三个特性。
- 保证可见性
- 不保证原子性
- 禁止指令重排
了解Volatile需要先知道JMM(内存模型)
什么是JMM
JMM(java内存模型java Memory Model,简称JMM)本身是一种抽象的概念不真实存在,他描述的是一组规则或规范,通过这组规范定义了程序各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM三大特性
- 可见性
- 原子性
- 有序性
可见性
首先我们来理解什么是主内存,主内存 (java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问) 实际就是硬件的内存条(有8G内存,16G 内存等),我们new student(); 这么个实例对象就在主内存里。当我们实例对象student 有属性age = 18,假设,有3个线程来修改这个年龄,线程为T1,T2,T3,每个线程都会(变量副本拷贝)从主内存拷贝一份到自己的工作内存中,也就是线程T1工作内存有18,线程T2,T3工作内存中也有18,这个时候线程T1修改了age=15,同时把15写回给主内存,此时T2,T3是不知道线程T1已经修改了主内存age=15的。那么种情况我们就需要一种机制,当一个线程修改了主内存要通知其他线程。
这种及时通知状况就是JMM内存模型中的第一个特性可见性。
图1如下:
代码演示
package com.test;
import java.util.concurrent.TimeUnit;
class MyData{
int number = 0;
public void addTo50(){
this.number = 50;
}
}
/**
* 1.验证volatile的可见性
* 1.1 假如 int number = 0; number变量之前没有添加volatile关键字修饰
*/
public class VolatileDemo {
public static void main(String[] args){
MyData myData = new MyData();//new 资源类
//线程A
new Thread(() ->{
System.out.println(Thread.currentThread().getName() + "\t come in");
//模拟运算,暂停3秒
try {
TimeUnit.SECONDS.sleep(3);
myData.addTo50();
System.out.println(Thread.currentThread().getName() + "\t update number value:"+myData.number);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A线程").start();
//线程B main线程
while (myData.number == 0){
//main线程就一直在这里等待循环,直到number值不在等于0.
}
System.out.println(Thread.currentThread().getName() + "\t over");
}
}
运行结果如图:
线程B main线程 是处在死循环中,因为没人通知现在线程B number已经修改了写回主内存了。
下面我们修改下代码,加上volatile关键字
class MyData{
volatile int number = 0;
public void addTo50(){
this.number = 50;
}
}
/....../
//线程B main线程
while (myData.number == 0){
//main线程就一直在这里等待循环,直到number值不在等于0.
}
System.out.println(Thread.currentThread().getName() + "\t over,main get number value:"+myData.number);
运行结果如图:
当我们加上了volatile 关键字,实现了可见性,一个线程修改及时通知另外的线程。
原子性
- 原子性指的是什么意思?
保证数据完整一致性,即不可分割,完整性,某个线程正在做某个具体业务时,中间不可以被加塞或者分割,需要整体完整要么成功,要么同时失败。
volatile是不保证原子性的,我们来用代码演示下
import java.util.concurrent.TimeUnit;
class MyData {
volatile int number = 0;
//volatile不保证原子性
public void addPP() {
number++;
}
}
/**
* 1.验证volatile不保证原子性
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();//new 资源类
//20个线程 每个线程执行1000次 20 * 1000 = 20000
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 1; j <=1000 ; j++) {
myData.addPP();
}
},String.valueOf(i)).start();
}
//需要等待上面20个线程执行计算完,在用main线程取得最终结果值是多少,也就是 原子性
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t finally number value:"+myData.number);
}
}
运行代码如图:
为什么volatile不能保证原子性,下面我们来研究下,为什么数值每次都小于20000,假设我们有3个线程t1,t2,t3,我们知道数值存在主内存中,当调用number++时,每个线程都会变量拷贝到自己的工作内存中,当t1线程拷贝执行加1后写入主内存时被其他线程抢到,t1线程挂起,t2线程加1写入了主内存,那么这时候volatile及时通知了其他线程,但是t1活了速度太快还没来得急得到通知就把自身的值写入主内存。那么这时候t1就覆盖了t2计算的值,主内存就是覆盖值,然后在通知其他线程,其他线程得到是覆盖的值,所以如此循环最终值会小于20000,当然也存在点好,正好20000的情况。
如何解决原子性问题
- 第一种 加synchronized 不推荐,属于杀鸡用牛刀,synchronized性能也不是很好。
代码如下:
/......../
//volatile不保证原子性
public synchronized void addPP() {
number++;
}
- 第二种 推荐使用java工具类atomic
代码如下:
package com.test;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
class MyData {
volatile int number = 0;
//volatile不保证原子性
public void addPP() {
number++;
}
//带原子性的number++
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic(){
atomicInteger.getAndIncrement();
}
}
/**
* 1.验证volatile不保证原子性
* 2.是用AtomicInteger 保证原子性
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();//new 资源类
//20个线程 每个线程执行1000次 20 * 1000 = 20000
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 1; j <=1000 ; j++) {
myData.addPP();//不带原子性
myData.addAtomic();//带原子性
}
},String.valueOf(i)).start();
}
//需要等待上面20个线程执行计算完,在用main线程取得最终结果值是多少,也就是 原子性
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t int type finally number value:"+myData.number);
System.out.println(Thread.currentThread().getName()+"\t AtomicInteger type finally number value:"+myData.atomicInteger);
}
}
运行结果如图:
如果想了解为什么加Atomic就可以保证原子性,请看我专门描述的文章,这里不做讲解。
禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须考虑指令之间的数据依赖性(先有父母才有子)。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果是无法预测的。
指令重排通俗讲,比如考试,考试时候卷子的顺序,和我们实际做的题的顺序是不一样的,我们实际做题是先把会做的做了,然后在做不会的,这就是重排。
在多线程下如果不加volatile会出现指令重排,也就是结果会有多个。
下面我们做个例子
编译器 指令重排
定义 int a,b,x, = 0;
线程1 | 线程2 |
---|---|
x=a; | y=b; |
b=1; | a=2; |
结果可能 x=0,y=0 | |
如果编译器对这段代码执行重排优化后,肯能出现下列状况 | |
线程1 | 线程2 |
b=1; | a=2; |
x=a; | y=b; |
结果可能 x=2,y=1 |
代码演示
这个结果有两种,第一种是a=6,第二种是a=5,多线程环境中线程交替执行,出现指令重排,两个线程中使用的变量能否保正一致性是无法确定的,语句1和语句2交替,语句2先执行,语句1后执行,那么另外线程太快抢到了method02,flag=true,进来时a=1这个语句还没执行,就变成a = 0+5;所以出现结果无法预测。
public class SortDemo {
int a = 0;
boolean flag = false;
public void method01() {
a = 1;//语句1
flag = true;//语句2
}
public void method02() {
if (flag) {
a = a + 5;//语句3
System.out.println("value:" + a);
}
}
}
什么时候使用volatile
下面我们来做个验证,首先我们创建一个单线程执行单例模式代码如下:
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo()");
}
public static SingletonDemo getInstance(){
if(instance == null){
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args){
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
}
}
运行结果如图:
根据运行结果,单线程下这个单例模式没毛病,那么我们修改下在多线程下执行看看代码如下:
/...../
public static void main(String[] args){
//多线程调用
for (int i = 0; i < 10; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
运行结果如图:
这时我们看到多线程下这种单例写法就不能满足我们要求了,下面我们解决多线程下单例模式调用方案,也就是DCL (Double Check Lock双端检锁机制) 代码如下:
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo()");
}
//如上厕所,我先判断厕所有人吗?没人我进去了,然后锁上门,我不放心在推推这个门,推不动了,我在开始干活。DCL机制就是加锁前和后都进行一次判断
//DCL (Double Check Lock双端检锁机制)
public static SingletonDemo getInstance(){
if(instance == null){
synchronized (SingletonDemo.class){
if(instance == null){
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args){
for (int i = 0; i < 10; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}
synchronized 不能直接加在方法上,那样性能是相当差的,实际开发中很少这么用。下面我来看运行结果:
看上去,我们解决了这个出现多实例的问题,但是在高并发下,这样的方式是线程安全的吗? 答案肯定不是,为什么呢? 原因就是计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。,没错关键点指令重排,一旦指令重排上面代码会出现一个现象,某一个线程执行到第一次检查,读取instance不为null时,instance的引用对象可能没有完成初始化。
实例化代码 instance = new SingletonDemo(); 可以分为3个步骤完成实例化。
memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance != null;
因为步骤2和步骤3不存在数据依赖关系 ,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
memory = allocate(); //1.分配对象内存空间
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance != null;但是对象还没有初始化完成!
instance(memory); //2.初始化对象
指令重排只会保证串行语义的执行一致性(单线程),但并不会关心多线程间的语义一致性。
所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程不安全问题。
通俗讲正常情况下比如,公司有新同事儿要来,那么会第一步分配座位,第二步配置电脑网线,第三步人来工作,也就是我们看到的真人。
指令重排,第一步分配座位,但是这时候我们就直接看新来同事儿的座位这,那么这时候我们是看不到真人的,但是我们的目光已经看新同事座位这了,也就是(instance指向刚分配的内存地址),对象没有完成初始化,可能这时候这位新同事儿在路上还没到呢,但是我们已经关注新同事儿座位这了。这时就成了有名无实,当我们来找这位新同事儿聊天是不存在的,但是我们某个同事儿还是来找了(某个线程取值)这时候就没找到真人,也就是取值取到了null.这就造成了线程安全问题。
那我们怎么来解决这个问题呢?这时候就用到了volatile了,volatile禁止指令重排特性,下面修改代码如下:
private static volatile SingletonDemo instance = null;
这样我们就保证了线程安全。