Volatile关键字和JMM内存模型
- 一JUC并发包API 包介绍
- 二JMM(Java Memory Model)
- 三 volatile关键字
- 3.1.可⻅性
- 3.1.1.问题演示
- 3.1.1.1案例代码
- 3.1.1.2.案例分析
- 3.1.2.volatile 保证可见性演示
- 3.1.2.1对number添加了volatile修饰
- 3.1.2.2运⾏结果是:
- 3.2.原⼦性
- 3.2.1.不保证原子性问题演示
- 3.2.1.1.案例代码
- 3.2.1.2.案例分析
- 3.2.2.保证原子性演示--对 addPlusPlus()⽅法加锁
- 3.2.2.1.案例代码
- 3.2.2.2.案例分析
- 3.2.3.保证原子性演示--使用AtomicInteger类
- 3.2.3.1.案例代码
- 3.2.3.2.案例分析
- 3.3.有序性
- 3.3.1.指令重排问题演示
- 3.3.1.1.案例代码
- 3.3.1.2.案例分析
- 3.3.2.解决指令重排问题演示
- 3.3.2.1.案例代码
- 3.3.2.2.案例分析
- 四 哪些地⽅⽤到过volatile?
- 4.1.单例模式的安全问题
- 4.1.1传统单线程方式
- 4.1.1.1案例代码
- 4.1.1.2案例分析
- 4.1.2.多线程方式
- 4.1.2.1.案例代码
- 4.1.2.2.案例分析
- 4.1.3.多线程方式--synchronized加锁
- 4.1.3.1.案例代码
- 4.1.3.2.案例分析
- 4.1.4.多线程方式--DCL(Double Check Lock)双端检查模式
- 4.1.4.1.案例代码
- 4.1.4.2.案例分析
- 4.1.4.3.解决方案
一JUC并发包API 包介绍
java.util.concurrent:(juc)
- 并发与并⾏的不同?
- 并发,如同,秒杀⼀样,多个线程访问同⼀个资源
- 并⾏,⼀堆事情 ⼀块去做,如同,⼀遍烧热⽔,⼀个拆⽅便⾯包装
java.util.concurrent.atomic
- AtomicInteger 原⼦性引⽤
java.util.concurrentlocks
- Lock接⼝
- ReentrantLock 可重⼊锁
- ReadWriteLock 读写锁
二JMM(Java Memory Model)
JMM是指Java内存模型,不是JVM,不是所谓的栈、堆、⽅法区。每个Java线程都有⾃⼰的⼯作内存。操作数据,⾸先从主内存中读,得到⼀份拷⻉,操作完毕后再写回到主内存。
1、由于JVM运⾏程序的实体是线程,⽽每个线程创建时JVM都会为其创建⼀个⼯作内存(有些地⽅成为栈空间),⼯作内存是每个线程的私有数据区域,⽽Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在⼯作内存中进⾏。
2、⾸先要将变量从主内存拷⻉到⾃⼰的⼯作内存空间,然后对变量进⾏操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。
3、各个线程中的⼯作内存中存储着主内存中的变量副本拷⻉,因此不同的线程间⽆法访问对⽅的⼯作内存,线程间的通信 (传值)必须通过主内存来完成。
期简要访问过程如下图:
JMM可能带来可⻅性、原⼦性和有序性问题。
所谓可⻅性,就是某个线程对主内存内容的更改,应该⽴刻通知到其它线程。
所谓原⼦性,是指⼀个操作是不可分割的,不能执⾏到⼀半,就不执⾏了。
所谓有序性,就是指令是有序的,不会被重排。
三 volatile关键字
volatile关键字是Java提供的⼀种轻量级同步机制。
- 它能够保证可⻅性和有序性
- 但是不能保证原⼦性
- 禁⽌指令重排
3.1.可⻅性
就是某个线程对主内存内容的更改,应该⽴刻通知到其它线程
3.1.1.问题演示
3.1.1.1案例代码
/**
* volatile关键字是Java提供的一种轻量级同步机制。
* <p>
* 它能够保证 可见性 和 有序性
* 但是不能保证 原子性
* 禁止指令重排(编辑器优化的重排、指定并行的重排、内存系统的重排)
*/
class KjxData {
int number = 0;
public void setTo60() {
this.number = 60;
}
}
public class KjxDemo {
public static void main(String[] args) {
volatileVisibilityDemo();
}
//volatile可以保证可见性,及时通知其它线程主物理内存的值已被修改
private static void volatileVisibilityDemo() {
System.out.println("可见性测试");
//资源类
KjxData KjxData = new KjxData();
//启动一个线程操作共享数据
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 执行");
try {
TimeUnit.SECONDS.sleep(3);
KjxData.setTo60();
System.out.println(Thread.currentThread().getName() + "\t 更新number值: " + KjxData.number);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "ThreadA").start();
while (KjxData.number == 0) {
//main线程持有共享数据的拷贝,一直为0
}
System.out.println(Thread.currentThread().getName() + "\t main获取number值: " + KjxData.number);
}
}
3.1.1.2.案例分析
KjxData类是资源类,⼀开始number变量没有⽤volatile修饰,所以程序运⾏的结果是:
虽然⼀个线程把number修改成了60,但是主内存中的变量值并没有修改,ThreadB线程持有的仍然是最开始的0,所以⼀直循环,程序不会结束。
3.1.2.volatile 保证可见性演示
3.1.2.1对number添加了volatile修饰
/**
* volatile关键字是Java提供的一种轻量级同步机制。
* <p>
* 它能够保证 可见性 和 有序性
* 但是不能保证 原子性
* 禁止指令重排(编辑器优化的重排、指定并行的重排、内存系统的重排)
*/
class KjxData {
volatile int number = 0;
public void setTo60() {
this.number = 60;
}
}
3.1.2.2运⾏结果是:
添加了volatile修饰修饰后,ThreadA在工作内存中对变量值修改后同时也修改共享内存中的变量值通知其他线程变量值已修改。在线程A修改了变量值在其他线程中是可见的。
3.2.原⼦性
不可分割,完整性,也即某个线程正则做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整,要么同时成功,要么同时失败。
3.2.1.不保证原子性问题演示
假设有这样一个需求:模拟20个并发,每个线程将同一变量循环加1000次。我们预期的结果是20000。
3.2.1.1.案例代码
/**
* volatile关键字是Java提供的一种轻量级同步机制。
* <p>
* 它能够保证 可见性 和 有序性
* 但是不能保证 原子性
* 禁止指令重排(编辑器优化的重排、指定并行的重排、内存系统的重排)
*/
class MyData {
//定义一个遍历,在方法中++操作
volatile int number = 0;
public void addPlusPlus() {
number++;
}
}
public class VolatileDemo {
public static void main(String[] args) {
atomicDemo();
}
/**
* 模拟20个并发,每个线程将同一变量循环加1000次。我们预期的结果是20000。
*
* @date 2023/3/9
*/
private static void atomicDemo() {
System.out.println("原子性测试");
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
//为了达到理想结果,严谨性需要添加如下判断
//当前执行的程序中线程存活数大于2说明当前任务没执行完
//一个程序中有两个固定线程(main和gc线程)
while (Thread.activeCount() > 2) {
//表示让其他线程继续执行
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t int类型最终number值: " + myData.number);
}
}
3.2.1.2.案例分析
通过多次测试发现很难达到20000,程序看着也没有任何问题但结果就是不符合预期。这是因为并发操没有保证原子性,++的过程出现了数据覆盖,导致结果使用小于预期。
volatile并不能保证操作的原⼦性。这是因为,⽐如⼀条number++的操作,会形成3条指令查看对应的二进制指令代码如下:
在java代码中看似是一条语句其实编译后在正真操作时是3条指令,在3条指令执行的过程中cpu是并行的,所以在并行并发操作的过程中就会出问题。
假设有3个线程,分别执⾏number++,都先从主内存中拿到最开始的值,number=0,然后三个线程分别进⾏操作。假设线程0执⾏完毕,number=1,也⽴刻通知到了其它线程,但是此时线程1、2已经拿到了number=0,所以结果就是写覆盖,线程1、2将number变成1。
解决的⽅式就是:
- 对 addPlusPlus()⽅法加锁。
- 使⽤ java.util.concurrent.AtomicInteger类。
3.2.2.保证原子性演示–对 addPlusPlus()⽅法加锁
3.2.2.1.案例代码
在++操作的代码块中添加synchronized关键字加锁
class MyData {
//定义一个遍历,在方法中++操作
volatile int number = 0;
public synchronized void addPlusPlus() {
number++;
}
}
3.2.2.2.案例分析
加了synchronized关键后代码是同步的其他线程是不能执行的,那么结果就可以得到保证。
引发问题:
synchronized在方法中一锁,那么在高并发的情况下,一个业务方法被锁死那性能必然大打折扣。所以说在高并发的情况下不能用synchronized关键字
3.2.3.保证原子性演示–使用AtomicInteger类
使⽤ java.util.concurrent.AtomicInteger类。
3.2.3.1.案例代码
1、创建原子型变量
2、使用atomicInteger.getAndIncrement()方法实现自增
3、调用方法
class MyData {
volatile int number = 0;
public void addPlusPlus() {
number++;
}
//创建一个具有初始值 0的新原子整数
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
//自增操作 以原子方式递增当前值的 1
atomicInteger.getAndIncrement();
}
}
public class VolatileDemo {
public static void main(String[] args) {
atomicDemo();
}
/**
* 模拟20个并发,每个线程将同一变量循环加1000次。我们预期的结果是20000。
*
* @date 2023/3/9
*/
private static void atomicDemo() {
System.out.println("原子性测试");
MyData myData = new MyData();
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
//原子型操作
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
//为了达到理想结果,严谨性需要添加如下判断
//当前执行的程序中线程存活数大于2说明当前任务没执行完
//一个程序中有两个固定线程(main和gc线程)
while (Thread.activeCount() > 2) {
//表示让其他线程继续执行
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t int类型最终number值: " + myData.number);
System.out.println(Thread.currentThread().getName() + "\t AtomicInteger类型最终number值: " + myData.atomicInteger);
}
}
3.2.3.2.案例分析
可⻅,由于Volatile不能保证原⼦性,出现了线程重复写的问题,最终结果⽐20000⼩。⽽ AtomicInteger可以保证原⼦性
3.3.有序性
计算机在执⾏程序时,为了提⾼性能,编译器和处理器常常会对指令做重排,⼀般分以下三种:
1、单线程环境⾥⾯确保程序最终执⾏结果和代码顺序执⾏的结果⼀致;
2、处理器在进⾏重排序时必须要考虑指令之间的数据依赖性;
3、多线程环境中线程交替执⾏,由于编译器优化重排的存在,两个线程中使⽤的变 量能否保证⼀致性是⽆法确定的,结果⽆法预测。
volatile
可以保证有序性
,也就是防⽌指令重排序
。
所谓指令重排序,就是出于优化考虑,CPU执⾏指令的顺序跟程序员⾃⼰编写的顺序不⼀致。就好⽐⼀份试卷,题号是⽼师规定的,是程序员规定的,但是考⽣(CPU)可以先做选择,也可以先做填空。
int x = 11; //语句1
int y = 12; //语句2
x = x + 5; //语句3
y = x * x; //语句
以上例⼦,可能出现的执⾏顺序有1234、2134、1342,这三个都没有问题,最终结果都是x = 16, y=256。但是如果是4开头,就有问题了,y=0。这个时候就不需要指令重排序。
3.3.1.指令重排问题演示
观看下⾯代码,在多线程场景下,说出最终值a的结果是多少?
5或者6
我们采⽤ volatile 可实现禁⽌指令重排优化,从⽽避免多线程环境下程序出现乱序执⾏的现象
3.3.1.1.案例代码
/**
* volatile可以保证 有序性,也就是防止 指令重排序。
*
* 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致;
* 处理器在进行重排序时必须要考虑指令之间的数据依赖性;
* 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
*/
public class ResortSeqDemo {
int a=0;
boolean flag=false;
/*
多线程下flag=true可能先执行,还没走到a=1就被挂起。
其它线程进入method02的判断,修改a的值=5,而不是6。
*/
public void method01(){
a=1;
flag=true;
}
public void method02(){
if (flag){
a+=5;
System.out.println("*****retValue: "+a);
}
}
public static void main(String[] args) {
ResortSeqDemo resortSeq = new ResortSeqDemo();
new Thread(()->{resortSeq.method01();},"ThreadA").start();
new Thread(()->{resortSeq.method02();},"ThreadB").start();
}
}
3.3.1.2.案例分析
通过多次结果发现一直是6,这就有疑问了不是说可能会出现指令重排吗?我们认真思考一下,在 多线程下flag=true可能先执行,还没走到a=1就被挂起。其它线程进入method02的判断,修改a的值=5,而不是6。
3.3.2.解决指令重排问题演示
使用volatile来修饰变量即可
3.3.2.1.案例代码
/**
* volatile可以保证 有序性,也就是防止 指令重排序。
*
* 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致;
* 处理器在进行重排序时必须要考虑指令之间的数据依赖性;
* 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
*/
public class ResortSeqDemo {
volatile int a=0;
boolean flag=false;
/*
多线程下flag=true可能先执行,还没走到a=1就被挂起。
其它线程进入method02的判断,修改a的值=5,而不是6。
*/
public void method01(){
a=1;
flag=true;
}
public void method02(){
if (flag){
a+=5;
System.out.println("*****retValue: "+a);
}
}
public static void main(String[] args) {
ResortSeqDemo resortSeq = new ResortSeqDemo();
new Thread(()->{resortSeq.method01();},"ThreadA").start();
new Thread(()->{resortSeq.method02();},"ThreadB").start();
}
}
3.3.2.2.案例分析
为什么volatile 可实现禁⽌指令重排优化,从⽽避免多线程环境下程序出现乱序执⾏的现象?说说它的原理
我们先来了解⼀个概念,内存屏障(Memory Barrier)⼜称内存栅栏,是⼀个CPU指令,volatile底层就是⽤CPU的内存屏障(Memory Barrier)指令来实现的,它有两个作⽤
- ⼀个是保证特定操作的顺序性
- ⼆是保证变量的可⻅性。
由于编译器和处理器都能够执⾏指令重排优化。所以,如果在指令间插⼊⼀条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插⼊内存屏障可以禁⽌在内存屏障前后的指令进⾏重排序优化。内存屏障另外⼀个作⽤是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读到这些数据的最新版本。
四 哪些地⽅⽤到过volatile?
4.1.单例模式的安全问题
4.1.1传统单线程方式
4.1.1.1案例代码
/**
* 单例设计模式的安全问题
* 常见的DCL(Double Check Lock)双端检查模式虽然加了同步,但是在多线程下依然会有线程安全问题。
*/
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\tSingletonDemo构造⽅法执⾏了");
}
public static SingletonDemo getInstance() {
if (instance == null) {
if (instance == null) {
instance = new SingletonDemo();
}
}
return instance;
}
public static void main(String[] args) {
//main线程操作
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
}
}
4.1.1.2案例分析
单线程的情况下构造方法执行一次,没有人和他抢,不会出现任何问题
4.1.2.多线程方式
4.1.2.1.案例代码
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\tSingletonDemo构造⽅法执⾏了");
}
public static SingletonDemo getInstance() {
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();
}
}
}
4.1.2.2.案例分析
进过多次测试发现构造方法并不是只执行一次,也就说明有多个对象了,这违背了单例模式的初衷。
4.1.3.多线程方式–synchronized加锁
在创建对象的方法上加一把锁,这种方式虽然解决了只创建一个对象,但是别忘了在多线程情况下,这种暴力加锁,严重影响性能。
4.1.3.1.案例代码
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\tSingletonDemo构造⽅法执⾏了");
}
public static synchronized SingletonDemo getInstance() {
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();
}
}
}
4.1.3.2.案例分析
在创建对象的方法上加一把锁,这种方式虽然解决了只创建一个对象,但是别忘了在多线程情况下,这种暴力加锁,严重影响性能。
4.1.4.多线程方式–DCL(Double Check Lock)双端检查模式
调整后,采⽤常⻅的DCL(Double Check Lock)双端检查模式加了同步,但是在多线程下依然会有线程安全问题。
4.1.4.1.案例代码
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\tSingletonDemo构造⽅法执⾏了");
}
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();
}
}
}
4.1.4.2.案例分析
这个漏洞⽐较tricky,很难捕捉,但是是存在的。 instance=new SingletonDemo();通过查看SingletonDemo()对应的二进制文件,可以⼤致分为三步
剖析:
在多线程的环境下,由于有指令重排序的存在,DCL(双端检锁)机制不⼀定线程安全,我们可以加⼊volatile可以禁⽌指令重排。
原因在与某⼀个线程执⾏到第⼀次检测,读取到的instance不为null时,instance的引⽤对象可能没有完成初始化。
步骤2和步骤3不存在数据依赖关系,⽽且⽆论重排前还是重排后,程序的执⾏结果在单线程中并没有改变,因此这种重排优化是允许的。
但是指令重排只会保证串⾏语义的执⾏⼀致性(单线程),并不关⼼多线程的语义⼀致性。所以,当⼀条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题
public static SingletonDemo getInstance() {
if (instance==null) {
synchronized (SingletonDemo.class){
if (instance == null) {
//多线程情况下,可能发⽣指令重排
instance = new SingletonDemo();
}
}
}
return instance;
}
4.1.4.3.解决方案
如果发⽣指定重排,那么,
- 此时内存已经分配,那么
instance=memory
不为null。 - 碰巧,若遇到线程此时挂起,那么
instance(memory)
还未执⾏,对象还未初始化。 - 导致了
instance!=null
,所以两次判断都跳过,最后返回的instance
没有任何内容,还没初始化。
解决的⽅法就是对 singletondemo
对象添加上volatile
关键字,禁⽌指令重排。
private volatile static SingletonDemo instance = null;
设计模式 - 单例模式(一)