Java并发多线程编程——volatile关键字
Posted 小志的博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发多线程编程——volatile关键字相关的知识,希望对你有一定的参考价值。
一、volatile的概述
1.1、volatile的理解
- volatile是Java虚拟机提供的轻量级的同步机制。
1.2、volatile的三大特性
1.2.1、保证可见性
- 保证可见性:即及时通知其他线程,祝物理内存的值已经被修改。
(1)、各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的。
(2)、这就可能存在一个线程A修改了共享变量X的值还未写回主内存中时 ,另外一个线程B又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享变量X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.
1.2.2、不保证原子性
- 不保证原子性,原子性:即不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分隔。需要整体完整,要么同时成功,要么同时失败。
1.2.3、禁止指令重排
- 禁止指令重排
二、JMM的概述
2.1、JMM的理解
- JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念 并不真实存在,它描述的是一组规则或规范通过规范定制了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.
2.2、JMM关于同步规定
- 线程加锁前,必须读取主内存的最新值到自己的工作内存。
- 线程解锁前,必须把共享变量的值刷新回主内存。
- 加锁解锁是同一把锁。
2.3、主内存和工作内存
- 由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方成为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可访问。
- 线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作空间,然后对变量进行操作,操作完成再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存储存着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,此案成间的通讯(传值) 必须通过主内存来完成。
- 其简要访问过程如下图:
三、volatile的三大特性代码验证示例
3.1、保证可见性
3.1.1、没有添加volatile关键字,不保证可见性代码示例
-
示例代码
import java.util.concurrent.TimeUnit; /** * @description: 验证volatile的可见性代码示例 * (1)、假如int number =0; number变量之前根本没有添加volatile关键字修饰 * @author: xz */ class Mydata //在主内存中声明一个全局变量,初始值为0 int number =0; //此方法拷贝主内存中变量的值0到自己的工作内存,并修改为30,然后把值为30刷新回主内存中 public void update() this.number= 30; public class volatileDemo1 public static void main(String[] args) Mydata mydata=new Mydata(); //第一个线程:线程A调用add方法,number的初始值0修改为30 new Thread(()-> //线程A进入并睡眠3秒钟 System.out.println(Thread.currentThread().getName()+"\\t come in"); try TimeUnit.SECONDS.sleep(3); catch (InterruptedException e) e.printStackTrace(); //3秒后修改number的值为30 mydata.update(); System.out.println(Thread.currentThread().getName()+"\\t update number value:"+mydata.number); ,"A").start(); //第二个线程:main线程 while(mydata.number ==0) //main线程就一直再这里等待循环,直到number的值不再等于0 System.out.println(Thread.currentThread().getName()+"\\t 任务完成");
-
输出结果如下图
3.1.2、添加volatile关键字,保证可见性代码示例
-
示例代码
import java.util.concurrent.TimeUnit; /** * @description: 验证volatile的可见性代码示例 * (1)、假如int number =0; number变量之前添加volatile关键字修饰 * @author: xz */ class Mydata //在主内存中声明一个全局变量,初始值为0 volatile int number =0; //此方法拷贝主内存中变量的值0到自己的工作内存,并修改为30,然后把值为30刷新回主内存中 public void update() this.number= 30; public class volatileDemo1 public static void main(String[] args) Mydata mydata=new Mydata(); //第一个线程:线程A调用add方法,number的初始值0修改为30 new Thread(()-> //线程A进入并睡眠3秒钟 System.out.println(Thread.currentThread().getName()+"\\t come in"); try TimeUnit.SECONDS.sleep(3); catch (InterruptedException e) e.printStackTrace(); //3秒后修改number的值为30 mydata.update(); System.out.println(Thread.currentThread().getName()+"\\t update number value:"+mydata.number); ,"A").start(); //第二个线程:main线程 while(mydata.number ==0) //main线程就一直再这里等待循环,直到number的值不再等于0 System.out.println(Thread.currentThread().getName()+"\\t 任务完成,main线程获取到number的值:"+mydata.number);
-
输出结果如下图
3.2、不保证原子性代
3.2.1、添加volatile关键字,不保证原子性代码示例
-
示例代码
/** * @description: 验证volatile的不保证原子性代码示例 * @author: xz */ class MyData2 volatile int number =0; //此时number前面加了volatile关键字修改,volatile不保证原子性。 public void addCount() number++; public class volatileDemo2 public static void main(String[] args) MyData2 mydata2 =new MyData2(); //10个线程,每个线程执行1000次,没次执行number的值加1,最后正确结果为10000 for(int i=1;i<=10;i++) new Thread(()-> for(int j=1;j<=1000;j++) mydata2.addCount(); , String.valueOf(i)).start(); //需要等待上面10个线程全部计算完成后,在用main线程获取最终的结果值,查看输出是多少 while(Thread.activeCount()>2) Thread.yield(); System.out.println(Thread.currentThread().getName()+"\\t finally number value:"+ mydata2.number);
-
输出结果如下图
3.2.2、volatile关键字不保证原子性的原理
- 因为number++被拆分成了3个指令,
- 第一步:多个线程进来都会拿到原始的值0,
- 第二步:多个线程都会在自己的工作内存进行加1操作,
- 第三步:多个线程进行putfiled写操作会出现写覆盖,导致无法保证原子性。
3.2.3、volatile关键字不保证原子性的解决方案(不使用synchronized的方式)
-
第一种方式:使用synchronized关键字
-
第二种方式:使用AtomicInteger原子类(建议使用此方式)
-
示例代码
import java.util.concurrent.atomic.AtomicInteger; /** * @description: volatile关键字不保证原子性的解决方案 * @author: xz */ class MyData3 volatile int number =0; //通过使用原子类解决volatile AtomicInteger atomicInteger =new AtomicInteger(); public void addMyAtomic() atomicInteger.getAndIncrement(); public class volatileDemo3 public static void main(String[] args) MyData3 mydata3 =new MyData3(); //10个线程,每个线程执行1000次,没次执行number的值加1,最后正确结果为10000 for(int i=1;i<=10;i++) new Thread(()-> for(int j=1;j<=1000;j++) mydata3.addMyAtomic(); , String.valueOf(i)).start(); //需要等待上面10个线程全部计算完成后,在用main线程获取最终的结果值,查看输出是多少 while(Thread.activeCount()>2) Thread.yield(); System.out.println(Thread.currentThread().getName()+"\\t finally number value:"+ mydata3.atomicInteger);
-
输出结果如下图
3.3、禁止指令重排
3.3.1、有序性的概述
-
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下3种。
-
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
-
处理器在进行重新排序是必须要考虑指令之间的数据依赖性。
-
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测。
3.3.2、禁止指令重排的概述
-
volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
-
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
(1)、保证特定操作的执行顺序。
(2)、保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。 -
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障的前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因为任何CPU上的线程都能读取到这些数据的最新版本。
3.3.3、禁止指令重排示例1
-
示例代码
public void sort() int x=11;//语句1 int y=12;//语句2 x=x+5;//语句3 y=x*x;//语句4
-
【语句顺序输出分析】
-
情况1:在多线程环境下语句输出的顺序可能是:语句1—>语句2—>语句3—>语句4
-
情况2:在多线程环境下语句输出的顺序可能是:语句2—>语句1—>语句3—>语句4
-
情况3:在多线程环境下语句输出的顺序可能是:语句1—>语句3—>语句2—>语句4
-
【语句4 可以重排后变成第一条么?】
-
不可以。因为处理器在进行重新排序是必须要考虑指令之间的数据依赖性。存在数据的依赖性,语句4没办法排到第一个。
3.3.4、禁止指令重排示例2
-
在单线程环境下
声明4个变量,初始值都为0. int a ,b ,x,y=0; 线程1 线程2 x=a; y=b; b=1; a=2; 输出结果 x=0 y=0 -
在多线程环境下
声明4个变量,初始值都为0. int a ,b ,x,y=0; 线程1 线程2 b=1; a=2; x=a; y=b; 输出结果 x=2 y=1,说明在多线程环境下,由于编译器优化重排的存在,两个线程使用的变量能否保持一致是无法确定的.
四、线程安全性获得保证
- 工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其他线程可见。
- 对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。
五、哪些地方用到过volatile
5.1、单例模式的示例代码(单线程环境)
-
代码示例
public class SingletonDemo1 private static SingletonDemo1 instance=null; private SingletonDemo1() System.out.println(Thread.currentThread().getName()+"\\t 构造方法"); public static SingletonDemo1 getInstance() if(instance==null) instance=new SingletonDemo1(); return instance; public static void main(String[] args) System.out.println(SingletonDemo1.getInstance()==SingletonDemo1.getInstance()); System.out.println(SingletonDemo1.getInstance()==SingletonDemo1.getInstance()); System.out.println(SingletonDemo1.getInstance()==SingletonDemo1.getInstance());
-
输出结果如下
-
【总结】
-
输出结果3次都为true,说明单线程环境下单例模式都是同一对象。
5.2、单例模式的示例代码(多线程环境)
-
代码示例
public class SingletonDemo1 private static SingletonDemo1 instance=null; private SingletonDemo1() System.out.println(Thread.currentThread().getName()+"\\t 构造方法"); public static SingletonDemo1 getInstance() if(instance==null) instance=new SingletonDemo1(); return instance; public static void main(String[] args) for (int i = 1; i <=10; i++) new Thread(() -> System.out.println(SingletonDemo1.getInstance()); ,String.valueOf(i)).start();
-
输出结果如下
-
【总结】
-
遍历10次,输出5次构造方法,说明10个线程获取了5次不同的对象,违背了单例模式。
5.3、使用volatile关键字 + DCL(双端检锁) 机制解决出现在5.2.2示例代码中的问题
-
示例代码
public class SingletonDemo1 //volatile关键字 private static volatile SingletonDemo1 instance=null; private SingletonDemo1() System.out.println(Thread.currentThread().getName()+"\\t 构造方法"); /** * 双重检测机制 * @return */ public static SingletonDemo1 getInstance() if(instance==null) synchronized (SingletonDemo1.class) if(instance==null) instance=new SingletonDemo1(); return instance; public static void main(String[] args) for (int i = 1; i <=10; i++) new Thread(() -> System.out.println(SingletonDemo1.getInstance()); ,String.valueOf(i)).start();
-
输出结果如下
-
【总结】
-
遍历10次,输出1次构造方法,说明10个线程获取的都是同一对象,没有违背单例模式。
5.4、单独使用 DCL(双端检锁) 机制 无法解决出现在5.2.2示例代码中的问题
-
DCL(双端检锁) 机制不一定线程安全,原因是有指令重排的存在。
-
加入volatile可以禁止指令重排,原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
instance=new SingletonDem(); 可以分为以下步骤(伪代码) memory=allocate();//1.分配对象内存空间以上是关于Java并发多线程编程——volatile关键字的主要内容,如果未能解决你的问题,请参考以下文章
Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)