视频学习
谈谈对Volatile的理解
一个面试题进行展开的知识点
- 相关知识:
- JUC(java.util.concurrent)
- 进程和线程
进程:后台运行的程序(我们打开的一个软件,就是进程)
线程:轻量级的进程,并且一个进程包含多个线程(同在一个软件内,同时运行窗口,就是线程) - 并发和并行
并发:同时访问某个东西,就是并发
并行:一起做某些事情,就是并行
JUC下的三个包
java.util.concurrent
java.util.concurrent.atomic
java.util.concurrent.locks
1.volatile 和jmm内存模型的可见性
Volatile在日常的单线程环境是应用不到的
Volatile是Java虚拟机提供的轻量级的同步机制(三大特性)
- 保证可见性
- 不保证原子性
- 禁止指令重排
JMM是什么?
JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程解锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:
数据传输速率:硬盘 < 内存 < < cache < CPU
上面提到了两个概念:主内存 和 工作内存
-
主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存
-
工作内存:但我们实例化 new student,那么 age = 25 也是存储在主内存中
-
- 当同时有三个线程同时访问 student中的age变量时,那么每个线程都会拷贝一份,到各自的工作内存,从而实现了变量的拷贝
即:JMM内存模型的可见性,指的是当主内存区域中的值被某个线程写入更改后,其它线程会马上知晓更改后的值,并重新得到更改后的值。
缓存一致性
为什么这里主线程中某个值被更改后,其它线程能马上知晓呢?其实这里是用到了总线嗅探技术
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。
为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。
MESI
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线风暴
总线嗅探技术有哪些缺点?
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。
JMM的特性
JMM的三大特性,volatile只保证了两个,即可见性和有序性,不满足原子性
- 可见性
- 原子性
- 有序性
package com.wfg;
import java.util.concurrent.TimeUnit;
/**
* java
*
* @Title: com.wfg
* @Date: 2020/8/22 16:25
* @Author: wfg
* @Description:
* @Version:代码验证volatile的可见性
*/
class MyData{
volatile int number=0;
void addto60(){
this.number=60;
}
}
/**
* Volatile Java虚拟机提供的轻量级同步机制
*
* 可见性(及时通知)
* 不保证原子性
* 禁止指令重排
*/
public class demo1 {
public static void main(String args []) {
// 资源类
MyData myData = new MyData();
// AAA线程 实现了Runnable接口的,lambda表达式
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 线程睡眠3秒,假设在进行运算
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改number的值
myData.addto60();
// 输出修改后的值
System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
}, "AAA").start();
while(myData.number == 0) {
// main线程就一直在这里等待循环,直到number的值不等于零
}
// 按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
// 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over");
/**
* 最后输出结果:
* AAA come in
* AAA update number value:60
* 最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性
*/
}
}
最后线程没有停止,并行没有输出 mission is over 这句话,说明没有用volatile修饰的变量,是没有可见性
当我们修改MyData类中的成员变量时,并且添加volatile关键字修饰
主线程也执行完毕了,说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。
2. volatile 不保证原子性
通过前面对JMM的介绍,我们知道,各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后在写回到主内存中的。
这就可能存在一个线程AAA修改了共享变量X的值,但是还未写入主内存时,另外一个线程BBB又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。
原子性
不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。
数据库也经常提到事务具备原子性
代码测试:
为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法
最后通过 Thread.activeCount(),来感知20个线程是否执行完毕,这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
package com.wfg;
/**
* java
*
* @Title: com.wfg
* @Date: 2020/8/22 16:59
* @Author: wfg
* @Description:
* @Version:
*/
public class demo2 {
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 1; i <=20 ; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
//这个方法表示number++
myData.add();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){
// yield表示不执行
Thread.yield();
}
// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
// 查看最终的值
// 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
}
}
最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性,
第一次的结果:并且多次实验每次都不一样
为什么出现数值丢失
volatile并不能保证操作的原子性。这是因为,比如一条number++的操作,会形成3条指令。
getfield //读
iconst_1 //++常量1
iadd //加操作
putfield //写操作
假设有3个线程,分别执行number++,都先从主内存中拿到最开始的值,number=0,然后三个线程分别进行操作。假设线程0执行完毕,number=1,也立刻通知到了其它线程,但是此时线程1、2已经拿到了number=0,所以结果就是写覆盖,线程1、2将number变成1。
解决的方式就是:
- 对addPlusPlus()方法加锁。
- 使用java.util.concurrent.AtomicInteger类。
class MyData{
volatile int number=0;
AtomicInteger atomicInteger = new AtomicInteger();
void addto60(){
this.number=60;
}
void add(){
// try {
// TimeUnit.MILLISECONDS.sleep(100);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
number++;
}
void addAtomicInteger(){
atomicInteger.getAndIncrement();
}
}
public class demo2 {
public static void main(String[] args) {
MyData myData = new MyData();
for (int i = 1; i <=20 ; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
myData.add();
myData.addAtomicInteger();
}
},String.valueOf(i)).start();
}
while (Thread.activeCount()>2){
// yield表示不执行
Thread.yield();
}
// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
// 查看最终的值
// 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000
System.out.println(Thread.currentThread().getName() + "\t finally number value: " + myData.number);
System.out.println(Thread.currentThread().getName() + "\t finally atomicInteger number value: " + myData.atomicInteger);
}
}
3. volatile 禁止指令重排 (有序性)
volatile可以保证有序性,也就是防止指令重排序。所谓指令重排序,就是出于优化考虑,CPU执行指令的顺序跟程序员自己编写的顺序不一致。就好比一份试卷,题号是老师规定的,是程序员规定的,但是考生(CPU)可以先做选择,也可以先做填空。
int x = 11; //语句1
int y = 12; //语句2
x = x + 5; //语句3
y = x * x; //语句4
以上例子,可能出现的执行顺序有1234、2134、1342,这三个都没有问题,最终结果都是x = 16,y=256。但是如果是4开头,就有问题了,y=0。这个时候就不需要指令重排序。
volatile底层是用CPU的内存屏障(Memory Barrier)指令来实现的,有两个作用,一个是保证特定操作的顺序性,二是保证变量的可见性。在指令之间插入一条Memory Barrier指令,告诉编译器和CPU,在Memory Barrier指令之间的指令不能被重排序。
4. volatile 的应用
单例模式:
public class SingletonDemo {
private static SingletonDemo singletonDemo=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法");
}
//DCL模式 Double Check Lock 双端检索机制:在加锁前后都进行判断
public static SingletonDemo getInstance(){
if (singletonDemo==null){
singletonDemo=new SingletonDemo();
// synchronized (SingletonDemo.class){
// if (singletonDemo==null){
// singletonDemo=new SingletonDemo();
// }
// }
}
return singletonDemo;
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i+1)).start();
}
}
}
这个输出并不确定
常见的DCL(Double Check Lock)模式虽然加了同步,但是在多线程下依然会有线程安全问题
这个漏洞比较tricky,很难捕捉,但是是存在的。instance=new SingletonDemo();可以大致分为三步
memory = allocate(); //1.分配内存
instance(memory); //2.初始化对象
instance = memory; //3.设置引用地址
其中2、3没有数据依赖关系,可能发生重排。如果发生,此时内存已经分配,那么instance=memory不为null。如果此时线程挂起,instance(memory)还未执行,对象还未初始化。由于instance!=null,所以两次判断都跳过,最后返回的instance没有任何内容,还没初始化。
解决的方法就是对singletondemo对象添加上volatile关键字,禁止指令重排。
public class SingletonDemo {
private static volatile SingletonDemo singletonDemo=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法");
}
//DCL模式 Double Check Lock 双端检索机制:在加锁前后都进行判断
public static SingletonDemo getInstance(){
if (singletonDemo==null){
//singletonDemo=new SingletonDemo();
synchronized (SingletonDemo.class){
if (singletonDemo==null){
singletonDemo=new SingletonDemo();
}
}
}
return singletonDemo;
}
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i+1)).start();
}
}
}
在测试中没添加 volatile 时我并没有发现多次创建对象,不知道是否jvm做了相应的优化