预备知识
介绍此关键字 需要先理解这几个概念
高速缓存:
计算执行程序时每条指令都是在cpu 中执行,而程序临时数据存放在物理内存中,写入和读取数据的速度比cpu 执行的指令速度慢,高速缓存就解决了此问题,在读取数据与写入数据是都是放在高速缓存中,其实是高速缓存是将主内存中需要用的复制一份到高速缓存中,然后在高速缓存中计算。
比如 i = i+ 1 首先 读取主内存 i 复制到高速缓存中,然后高速内存中 +1 ,最后在刷新到了主内存中去。
每个线程运行时都会有自己的高速缓存,所以有两个线程,个执行一次 i = i +1 代码时 最终结果一定是2 吗?
一致性:
在多核cpu 中,不同线程可以在不同核中,如果两个不再同一个核中的线程 读取主内存中的 i =0 ,那么每个线程的高速内存中都会是进行 i = 0+1 的操作,所以最后他们都刷回主内存中为1,
这种情况是很容易发生的,比如在第一个线程的高速内存中还在计算,而未将计算结果刷回主内存这段时间差之间,第二个线程可能读取到主内存中 i =0 的值,这显然是不对的,这就是著名的一致性问题。
解决问题的硬件办法:
1. 总线加锁
2.缓存一致性协议
解释:1cpu 与组件之间靠总线来通信,如果在总线上加锁则阻碍了其他cpu 对其他的组件访问(比如内存),这样只有 i + 1 代码执行完毕后后面的代码才会执行,这样的确解决了一致性问题,但是效率低下,比如阻塞了cpu 无法访问内存。所以出现了缓存一致性协议,Intel 的MESI 协议保证了,共享的变量的副本是一致的,核心思想是,当cpu在更新写数据时发现是共享的,则cpu 会发出通知告诉其他cpu 将缓存此变量的数据置位无效,然后其他cpu 需要读取这个变量时发现是无效的则重新去内存中读取。
原子性:
一个操作或者多个操作为一个整体执行,要么都执行,要么都不执行。例如经典案例银行转账,其中一个账户减少,另一个账户增多,这两个操作必须都执行,或者都不执行。所以高并发需要解决这个问题
可见性:
当多个线程访问同一个变量的时候,一个线程更改了变量则其他线程能立即看到修改的值。
例如
线程1执行此代码
int i = 0;
i=10;
线程2执行此代码
j = i;
防止第一个线程在高速缓存中 i = 10 已经执行,但是还未刷回主内存中时候,线程2就已经进行 j = i 的操作,即让第二个线程看见了变量的变化, i = 10 操作结果刷回了主内存中去。所以高并发需要解决此问题
有序性:
程序执行的顺序是按照代码的先后顺序去执行,
但是还会发生指令重排序的问题(一般来讲处理器为了提高效率会优化代码,不保证代码的先后顺序,但是保证最终执行结果和代码顺序执行的结果是一致的),比如:下面顺序的四行代码
int a = 3; //语句1
int b = 5; //语句2
a = a + 3; //语句3
b = a*a ;// 语句4
正常来讲 执行顺序应该是 1 2 3 4 但是真正在jvm 中可以是按照 2 1 3 4 顺序执行,因为对于代码来说 1 和 2 执行先后顺序,对于结果没有影响,但是对于 3 和 4的语句来讲 一定是 3 4 这样的顺序,因为重排序时会考虑数据之间的依赖性
synchronized
synchronized
关键字不仅能保证原子性,也能保证可见性。
原子性:当一个代码块或方法被 synchronized
修饰时,它会确保同一时刻只有一个线程可以执行这段代码。这意味着在这个代码块或方法内部的操作不会被其他线程打断,从而确保复合操作(比如自增 count++
)不会被分割,保证了操作的原子性。
可见性:当一个线程退出 synchronized
代码块或方法时,它会自动释放锁,而在释放锁之前,会将对共享变量的更改刷新到主内存中。相反,当一个线程进入 synchronized
代码块或方法时,它会先清空自己的工作内存,从而确保加载的是主内存中最新的变量值。这一过程确保了对共享变量的修改对于其他线程是可见的,即实现了可见性。
volatile
双重检查的单例模式
好的说到一个重点了
下面是一个双重检查的单例模式
public class Singleton {
// 双重检查
private Singleton() {
}
public volatile static Singleton singleton;
public static Singleton getInstance() {
if (singleton == null) { //代码A
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
- 问题代码A 为啥不去除?
答:把最外层的初次校验【代码A】给去掉后,其代码结构就相当于对整个方法都进行了加锁(在方法上枷锁了, public static syncchronized Singleton getInstance() ),从而造成了,不管是否当前对象为空,收到new对象的请求后,都会去做加锁、释放锁的操作。
- 解释为什么要用volatile ?
答: singleton = new Singleton(); 中的 new Singleton(); 不是一个原子操作,new 一个对象包括三步,1 分配内存,2 对象初始化 3 赋值对象引用, 在这三步中2 与3 可能发生重排序, 这样就导致 当调用这个实例时这个实例的引用所指向的对象内部还没初始化完成就拿来用,可能造成空指针异常。
保证可见性:
volatile
变量的修改对于所有线程都是立即可见的。当一个线程修改了volatile
变量的值,这个修改会立刻被刷新到主内存中,其他线程在访问该变量时会直接从主内存中读取最新的值,而不是从各自的线程工作内存中读取,这确保了变量的可见性
禁止指令重排序
在多线程环境中,重排序可能导致一些意料之外的结果。volatile
变量的读写操作会添加内存屏障(memory barrier),确保对volatile
变量的读写与其他内存操作之间保持一定的顺序关系,从而禁止某些可能导致问题的指令重排序。
成员变量的实例时被 volatile 修饰会让这三步禁止重排序,保证顺序执行,拿到一个完整的对象实例。
- 这里可能有个疑问,程序为什么会吧没有初始化好的对象来拿用?
答:这是因为,当第三步执行完对象的引用会被从程序直接用,jvm 是不会去判断这个引用内部是否初始化完毕这个对象!