volatile关键字
# Volatile关键字
# CPU 缓存模型
CPU Cache 缓存是为了解决 CPU 处理速度和内存处理速度不对等的问题,内存缓存用于解决硬盘访问速度慢的问题,CPU 缓存用于解决内存访问慢的问题。
CPU Cache工作方式:
先读取一份数据到CPU Cache,当CPU需要用到的时候直接从CPU Cache中读取数据,当运算完成后,再将运算得到的数据写回Main Memory中。但会出现内存缓存不一致的问题。比如,两个线程同时执行i++操作,两个线程同时从CPU Cache中读取到i=1,两个线程做了1++运算完后再写回Memory后i=2,而正确结果应该是i=3。
# Java 内存模型 JMM
是一种抽象概念,并不真实存在,它描述的是一组规则或规范。
Java内存模型抽象了线程和主内存之间的关系,比如线程之间共享变量必须存储在主内存中。Java内存模型主要目的是屏蔽系统和硬件差异,避免同一套代码在不同的平台产生不一致。
JMM关于同步的规定:
1 线程解锁前,必须把共享变量的值刷新回主内存。
2 线程加锁前,必须读取主内存的最新值到自己的工作内存。
3 加锁解锁是同一把锁。
线程可以把变量保存在本地内存中,而不是直接在主内存中进行读写。这就可能造成了一个线程在主内存中修改了一个变量的值,而另一个线程还继续使用它在本地内存中的旧值,造成数据的不一致。
主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量。
本地内存:每个线程私有的本地内存来存储共享变量和副本, 并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是JMM抽象出来的概念,存储了主内存中的共享变量副本。
# 缓存一致性
通常称这种被多个线程访问的变量为共享变量。也就是说,一个变量在多个CPU中都存在缓存(一般多线程时才会出现),就可能出现缓存不一致的问题。
解决方案:
- 通过在总线上加锁的方式,使只能由一个 CPU 访问该变量的内存,只有等待这段代码执行完之后,CPU 才能从这个变量中读取值。
- 缓存一致协议,每个缓存中使用共享变量的副本。当 CPU 向内存写入数据时,如果发现操作的变量是共享变量,就会使其他副本中该变量的缓存行置为无效状态,当其他 CPU 需要读取整个变量时,发现自己缓存中该变量的缓存行是无效的,那么就会从内存中重新读取。
# 并发编程的三个重要特性
- 原子性:一组操作,要么全部执行并且不受任何因素的干扰而中断,要么都不执行。synchronized 保证代码片段的原子性。
- 可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
- 有序性:代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码执行顺序未必就是编写代码时候的顺序。volatile 关键字可以进制指令进行重拍优化。
# volatile 关键字的作用
是Java虚拟机提供的轻量级的同步机制。
保证操作的可见性、有序性(禁止指令重排),但不能保证原子性。
⭐ 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他的线程能立刻看到修改的值。
程序运行过程中的临时数据都是放在主存(物理内存)中,CPU执行速度很快,从内存中读写数据会大大降低效率。因此,CPU有了高速缓存,在程序运行时,会将运算需要的数据从主存中复制到CPU高速缓存中,CPU执行运算时就可以从高速缓存中读写数据,当程序运算完再存储到主存中。
单线程情况下不会出现任何问题;在多核CPU中,每条线程运行在不同CPU中,有不同的高速缓存,当他们读取公共数据进行操作时,没有及时将数据更新到主存中导致出现缓存一致性问题。
通过Volatile关键字,可以将修改的变量从高速缓存中立即更新到主存中,并且使其他CPU中该变量的缓存行无效,它只能从主存中重新读取。
⭐ 有序性:程序执行的顺序按照代码先后执行的顺序。
指令重排序:一般来说,处理器为了提高程序运行效率,可能会对代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果一致。
重排序不会影响单线程,但会影响多线程。
使用Volatile关键字,可以使在前面的代码一定在其前面全部执行完成,在其后面的代码一定都在其后面执行完成。
# volatile 关键字可见性测试
public class volatileTest {
public static void main(String[] args) throws InterruptedException {
MyData myData = new MyData();
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + ", 修改前: " + myData.numberData);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTO60();
System.out.println(Thread.currentThread().getName() + ", 修改后: " + myData.numberData);
}, "t1");
thread.start();
// 线程2:main线程,线程t1睡眠3s,保证线程2运行到此处,保证线程2已经读取到myData.numberData的值的时候线程1还没处理完毕。
// 如果线程t1不睡眠3s,那么它很快计算完毕,并修改主内存中的值,没有等到线程2读取myData.numberDatade时,myData.numberData就变成了60。
while (myData.numberData == 0) {
}
System.out.println(Thread.currentThread().getName() + ": " + myData.numberData);
}
}
class MyData {
int numberData = 0;
// volatile int numberData = 0;
public void addTO60() {
this.numberData = 60;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# volatile 关键字的原理
📢 内存屏障(Memory Barrier)又称内存栅栏
是一个CPU指令,具有两个作用:
1 保证特定操作的顺序执行。由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和处理器,不管什么指令都不能和这条 Memory Barrier 指令重新排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重新排序优化。
2 保证某些变量的内存可见性。强制刷出各种 CPU 的缓存数据,因此 CPU 上的线程都能读取到这些数据的最新版本。
💻 对 Volatile 变量进行写操作时:
会在写操作后加入一条 store 屏障指令,将工作内存中的共享变量值刷新回到主内存。
💻 对 Volatile 变量进行读操作时:
会在读操作前加入一条 load 屏障指令,从主内存中读取共享变量。
# synchronized 与 volatile 关键字的区别
两者是互补的存在。
- volatile 是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 好,但 volatile 只能用于变量,而 synchronized 关键字可以修饰方法和代码块。
- volatile 能保证数据的可见性,但不能保证数据的原子性;synchronized 两者都能保证。
- volatile 主要用于解决变量在多线程之间的可见性,而 synchronized 关键字解决的是多线程之间访问资源的同步性。
# 双重校验锁实现对象单例
class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
return new Singleton();
}
}
}
return singleton;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- Volatile 关键字:禁止指令重排,正常顺序 1:为singleton分配内存、2:初始化singleton、3:将对象引用singleton指向内存地址;重排序的顺序可能为1、3、2,这样就会导致singlton还未初始化时
singleton!=null
。此时拿着singleton去操作就会导致错误。 - 第一次校验:校验是否已经创建对象,如果创建了就直接返回,不加锁提高效率。
- 第二次校验:同步代码块中,判断对象是否已经创建;因为多线程的原因,A、B线程可能会同时运行到
singleton==null
,之后其中A进入同步代码块,B等待,A在同步代码块中创建完对象后释放锁,B会进入同步代码块,如果此时不进行判断,B将重新创建一个对象。
# volatile 能不能保证 i++安全
不能,volatile只能保证可见性和有序性,不能保证原子性。
i++ 并不是原子操作,它分为3步执行。1 从工作内存中读取i值;2 进行计算;3 将值赋值给i。用 volatile 修饰虽然保证了从工作内存写入主内存后,其他线程工作内存的可见性。但无法影响其他线程 cpu 已执行的 i++ 步骤。从而导致了使用 volatile 也不是线程安全的。
i++ 操作可以使用 synchronized 锁,或者使用 AtomicInteger。