底层原理
效率和安全
效率也可以理解为性能、资源利用率
安全也可以理解为一致性、正确性
一般来说,不管是计算机还是各种开发出来的组件,都需要在效率和安全之间做取舍
而在设计的时候,有两种方式
1、优先考虑效率,然后在此基础上提供必要的安全保障(以牺牲一些效率为代价),最终达到期望的平衡状态
2、优先考虑安全,然后在此基础上逐步提高效率(以牺牲一些安全为代价),最终达到期望的平衡状态
计算机优先考虑效率
在我的理解中,计算机使用了上述的第一种设计方式,即优先考虑效率
那么为了效率,计算机做了哪些努力?
首先,我们需要知道一个事实:CPU和内存、磁盘之间的速度差异,令人惊愕。如下图所示,
(假设 CPU 执行一条普通指令需要一秒,那么 CPU 读写内存得等待7.5天的时间,等待磁盘就更不用说了)
https://blog.csdn.net/get_set/article/details/79466402
一、各级缓存
为了缓和速度差异、提高资源使用率(防止高速的资源有太多时间都浪费在等待低速资源的返回上),引入了各种缓存:
CPU三级缓存
页缓存
文件系统缓存
二、CPU时间片(分时复用)
同时,因为CPU太快了,所以它最容易空闲,对它的使用需要进一步优化:
这里就引出了CPU时间片的概念,即每个线程在执行的时候,每次获取到的都是固定的时间片,一个线程不可能长时间独占CPU
假设线程独占CPU,那么如果在执行期间,线程被阻塞在了IO操作上,而其他线程也用不了CPU,这时CPU就会处于“长时间的”(联系上图,对于CPU来说,确实很漫长)空闲等待的状态,造成了CPU资源的浪费
(这也导致了多线程执行顺序完全无法预测,有些在单线程内没问题的代码,在多线程中就会遇到难以预料的问题)
三、多线程
联系上一点,需要使用多线程配合CPU时间片的使用,才能发挥CPU时间片的功效
四、多核
为了让一台机器的处理能力更强大,引入多核,让更多的CPU核心参与运算
五、指令重排
不管是编译器还是CPU,当它们判断对指令乱序执行可以提高效率(编译器和处理器为了提高并行度)的时候,都可能对指令进行重排
只是,它们会受到一个规则的制约,即as-if-serial,它的语义是:
不管怎么重排序,都有一个底线,(单线程)程序的执行结果不能被改变。
六、抢占式
为了提高内存本身的资源使用率,操作系统对上层提供抢占式的资源使用规则,因为抢占式的使用规则,资源利用率高。这一点可以联系生活中厕所和饮水机的使用,如果完全公平,比如某个人只能在上午10点10分到20分之间上厕所、接水,那么不仅人不方便,而且厕所和饮水机作为一种资源,它们的利用率也会降低很多。
回到计算机中就是,服务器内存被撑爆,导致某些服务直接挂掉
这个点只是提一下,跟多线程无关
到底有哪些线程安全问题
计算机对效率的极致追求,必然会导致安全性的缺失,体现出来就是三种线程安全问题:
可见性、有序性、原子性
联系上文中说到的计算机为了提高效率做的事,咱们看看三大线程安全问题及其原因:
1、可见性
可见性是指,某个线程修改了共享变量,随后其他线程在读取这个变量的时候,却没有读到最新值。因为第一个线程修改了贡献变量之后,可能CPU还没来得及把最新的值写回主存,第二个线程就已经在另一个CPU核中开始了读取操作,这时读到的是旧数据。
即,多核及CPU缓存,导致了可见性问题
2、有序性
有序性问题是指,指令重排在多线程中引发的诡异问题
可以联系双重检查锁中的对象创建语句,代码中的一条指令,对应着三个步骤(分配内存空间、初始化对象、将地址赋值给变量),后两个步骤可能会被重排,从而导致其他线程可能读取到没有初始化好的对象
即,多线程及指令重排,导致有序性有问题
3、原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
有些程序中看似一条指令,对应到CPU执行的时候,会对应着多条指令,而由于多线程切换,导致某些线程的操作结果被覆盖。
由于操作系统提供的分时复用CPU,多线程会频繁切换,而对于“读-改-写”操作(可以联系 i=i+1 操作),可能会出现这样的步骤:1读-1改-2读-2改-2写-1写,最终线程2的结果被线程1的结果覆盖
即,分时复用CPU及多线程切换导致了原子性问题
解决方式
对于可见性和有序性:
JVM 对开发人员提供按需禁用 CPU 缓存和禁用指令重排的方法
对于原子性:
使用锁或者 CAS,限制同时访问共享资源的线程数
注:JMM中有个工作内存的概念,但它是一个抽象的概念,并不是实际的空间,可以将其理解为对CPU寄存器和缓存的抽象。JMM中的主存对应的就是内存。
JMM提供了什么?
为了做到平台无关,JMM提供了先行发生原则和几个关键字,程序员无需关心它们底层是怎么实现的(不过我们可以稍微了解下,以更好地使用它们)
先行发生原则
怎么理解先行发生原则?其实就是保证了可见性。如果说A先行发生于B,意思就是说A对共享变量的操作结果对B是绝对可见的。
先行发生原则共有八个,可以简单分为三类。
一、关键字相关(两个)
1、管程的锁规则(synchronized 相关)
前面文章提到了管程的概念,在Java里,synchronized就是对管程的一种实现。
这个规则可以理解为:先执行临界区(被synchronized保护的代码区域)代码的线程,在执行期间对共享变量的写操作,对后面执行临界区代码的线程时可见的。
2、volatile 变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
即,(在时间上)后面读 volatile 变量的线程一定能读到之前别的线程对这个变量的写操作的结果。
二、线程相关(三个)
1、线程 start 规则
这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
也就是说,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。
2、线程join规则
这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
也就是说,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。
3、线程中断规则
对线程interrupt ()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
也就是说,如果我们在代码中使用Thread.interrupted ()方法检测到线程已经被中断了,那么 Java 虚拟机保证 interrupt () 方法已经首先被调用过了。
三、其他(三个)
1、对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
2、程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这里可以联系前面提到的 as-if-serial。
3、传递性规则
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。比较容易理解。
几个关键字
一、synchronized
可以保证可见性、原子性和有序性。
原子性很容易理解,因为同一时间只能有一个线程执行临界区的代码。
至于可见性,可以联系上文说到的先行发生原则中的管程的锁规则。
至于网上说的synchronized可以保证有序性,我个人觉得不是太严谨,下文会提到这一点。
synchronized的底层实现原理可以参见这几篇文章:
1、https://xiaomi-info.github.io/2020/03/24/synchronized
2、https://cloud.tencent.com/developer/article/1465413
3、https://zhuanlan.zhihu.com/p/377423211
二、volatile
可以保证有序性(这里指禁止指令重排)和可见性,不能保证原子性
三、final
final即最终、不可变的意思。
用于修饰类,则代表类不可被继承;用于修饰方法,表示方法不可以被重写;用于修饰变量,表示变量一旦被初始化,就不可以再更改。
final修饰变量的时候,要么在类初始化的时候被初始化;要么在构造函数中被初始化。
final在jvm层面的实现原理是使用了内存屏障,具体可参考:
https://www.cnblogs.com/hexinwei1/p/10025840.html
https://www.cnblogs.com/jojop/p/13971054.html
https://www.jianshu.com/p/cba722d994fa
1、写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含2个方面:
JMM禁止编译器把final域的写重排序到构造函数之外
编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
写final域的重排序规则可以确保:在对象引用为任何线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
2、读final域的重排序规则如下:
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象引用。
可以简单理解为:一定在return之前写,一定在读对象之后读。
内存屏障
怎么理解内存屏障?
内存屏障的作用:
- 在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。
- 在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效
参考:
https://www.cnblogs.com/yaowen/p/11240540.html
https://www.jianshu.com/p/cba722d994fa
浅析各技术点之间的依赖关系
一、先行发生原则
依赖于内存屏障
二、volatile
依赖于内存屏障(底层使用了lock指令完成类似内存屏障的作用)
先行发生原则中有一条跟它有关
三、final
依赖于内存屏障
四、synchronized
有使用到lock指令
五、lock
lock 的作用
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序
再提一下这个lock指令,lock顾名思义,就是锁,主要是对CPU总线和高速缓存加锁。它后面可以跟一些指令,不同的CPU支持的指令集不同。
CPU对于lock的实现,就是锁总线或者锁缓存,所谓的锁缓存就是使用MESI协议
参考:
https://www.cnblogs.com/xrq730/p/7048693.html
六、MESI协议
CPU缓存行的四种状态,Modify、Exclusive、Share、Invalid
可参考:
https://www.jianshu.com/p/6745203ae1fe
https://yunwang.github.io/volatile
七、CAS
CAS,顾名思义,比较并交换,可以保证原子性
CAS 使用了 UnSafe 方法,而 UnSafe 底层也是使用的 lock
可参考:
https://blog.csdn.net/qq_41490274/article/details/122631259
八、简图
后面几个的关系可以简略为下图
实例讲解
一、双重检查锁
public class Singleton {
private volatile static Singleton uniqueSingleton;
private Singleton() {
}
public Singleton getInstance() {
if (null == uniqueSingleton) { // 第一个if
synchronized (Singleton.class) {
if (null == uniqueSingleton) { // 第二个if
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}
1、第一个if和第二个 if 分别起什么作用?
(1)第一个 if 的作用:在已有一个实例被创建好的情况下,新的线程可以直接获取到这个实例,没必要再进入同步代码块了(这样比较消耗性能)
(2)第二个 if 的作用:想象还没有已经创建好的实例的时候,两个线程几乎同时执行到 synchronized那里。其中线程A抢到了锁(此时B会被阻塞在 synchronized 这儿),进入同步代码块创建了一个实例,然后释放锁。这时,线程B发现锁被释放了,于是自己获取了锁,进入同步代码块,如果没有第二个if,那么线程B将再次创建一个实例。这样就有不止一个实例了。
2、在上一条讲解第二个 if 的作用的时候,其实我们默认了一个规则,即 线程A在同步代码块内对共享变量的赋值操作 对后面进入同步代码块的线程B是可见的。但这其实并不是理所当然的。这一点是由先行发生原则中的管程的锁原则保证的。
3、为什么需要用volatile来修饰共享变量?不用的话可能会出什么问题?
想象一个场景:当线程C在同步代码块中执行到uniqueSingleton = new Singleton();
语句时,线程D正好执行到了第一个 if(注意:这里指同步代码块外面的if)。
众所周知,uniqueSingleton = new Singleton();
语句其实可以分为三步
(1)分配内存空间
(2)初始化对象
(3)将变量 uniqueSingleton 指向刚分配好的内存空间
而由于重排序的存在,实际执行起来的时序可能是:
(1)分配内存空间
(2)将变量 uniqueSingleton 指向刚分配好的内存空间
(3)初始化对象
假设线程C执行完第二步,还没来得及执行第三步的时候,线程D执行了第一个if的条件判断,这时线程D发现 uniqueSingleton 不为null,于是直接拿着这个没有被初始化好的对象回去使用了,结果就会出现空指针异常
注意:这里的线程D发现 uniqueSingleton 不为 null 其实只是一个可能事件,因为线程D发现其不为null的前提是它能“看见”线程C执行完第二步的结果,即线程C执行完第二步的结果立即对线程D可见。这里的一个细微之处在于,JVM保证synchronized同步代码块执行完之后,对共享变量的操作会写回内存,但并不保证在synchronized同步代码块执行期间就一定不执行将共享变量刷回内存的操作。
而 volatile 在这里起到了什么作用呢?禁止指令重排,即禁止uniqueSingleton = new Singleton();
语句的细分三步的重排序。这样就不会有线程D读到没有初始化的对象的情况发生了。
简单理解一下(不一定对,这一块以后有时间可以深入研究)这里volatile起到的作用:三步,lock加在了“赋值”那里(因为volatile是作用在变量上的,而这三步里只有“赋值"操作真正涉及到了这个变量),编译器收到的代码顺序,“赋值”操作在第三步,而lock不运行目标步骤之前的操作重排序到其之后,所以编译器不能把“初始化”重排序到“赋值”之后。
4、不是说synchronized能保证有序性吗?为什么还要用volatile来保证可见性?
synchronized的有序性指的是:整个同步代码块对于多个线程是有序执行的,即线程A执行完同步代码块之后,线程B才可以开始执行。
synchronized不能保证同步代码块中的代码不被指令重排(理解起来很简单,对于同步代码块中的内容,synchronized已经保证同一时刻只有一个线程执行了,那么根据as-if-serial,指令是可以被重排的)
而这里的问题是由指令重排引起的,只能用volatile解决。
5、synchronized的有序性、原子性和可见性,分别起了什么作用?
可见性,上面第2点
原子性,防止两个线程同时进入代码块,同时创建实例
有序性,确保多个线程串行进入同步代码块
二、多线程下的i++
volatile为什么解决不了i++的问题?
https://www.cnblogs.com/yaowen/p/11240540.html 这篇博客的评论有一定的解释,但解决不了我的疑惑:
假设,两个线程(A和B)都先读了,然后其中线程A(或者B)改了,按照MESI,这时CPU会让线程B(或者A)的缓存失效,当线程B恢复执行的时候,会发现自己的缓存失效了,会从主存重新读取值(这时能读取到刚才那个线程修改后的新值),所以怎么就产生了一个线程的+1操作被覆盖的问题?
目前我能猜到的解释(以后有时间需要印证):
比如线程A使用CPU0执行了第一步,即将原值读取到了自己的寄存器中,这时线程A被切换走了(CPU0要执行别的任务了),CPU0关于线程A的寄存器和程序计数器的内容都会被保存到系统内核中(下次重新执行线程A的时候再读回来)。线程B开始i++的三步,执行到第二步的时候(即需要对原值进行修改的时候),触发了MESI协议,使其他CPU中的缓存里的这个变量都失效。但是由于线程A的上下文被保存到了系统内核,等到线程A被执行的时候,不需要从自己的高速缓存取原值了,所以就算CPU0中该变量失效了,线程A也不会去主存重新取数据。
可参考:
https://blog.csdn.net/qq_34556414/article/details/107007592
理解不了,为什么用cas去自增,就能解决i++的问题。cas能控制多核不乱序吗?如果多线程运行在单核上,那么只靠volatile,不用cas,会有i++的问题吗?
可参考:
https://juejin.cn/post/6844904018267865102
号外
一、多线程到底为什么提高了效率?
1、多核时代,多线程可以充分利用多核
2、磁盘和网络 io 太慢,然而有了 dma 的参与,可以让 cpu 去干别的
一个反例就是,对于 cpu 密集型的,线程数是不建议弄的太多的,因为对于 CPU 密集型的,如果线程数太多,反而会因为频繁的上下文切换导致效率下降
二、cas 和自旋的关系?
cas 是比较并交换,但 cas 的返回结果是 true 或者 false,本身应该没有自旋这么一说
想要实现自旋,我们可以在 cas 返回 false 时,不停的重试,直到其返回 true 为止
三、volatile 和 cas,我们经常灵活运用,比如自己的代码里,比如 JUC 里的大量组合使用(AQS、阻塞队列等都是组合使用 volatile 和 cas 实现的)
现在顾不上,但以后有兴趣可以深研的问题:
一、jvm对final功能的实现是使用storestore和loadload内存屏障,但是x86架构(我们一般用的都是这个)中,storestore、loadload、loadstore都是不支持的,x86只支持storeload。所以jvm对final加的storestore和loadload在x86上其实都没有,那么在x86上,jvm要怎么保证final的功能呢?是通过JSR-133对final的优化吗?
可以参考这篇文章的评论:
http://ifeve.com/java-memory-model/
x86不会对写-写、读-读、读-写做重排序,但是编译器会呀
二、jvm对volatile的功能支持,是把四种内存屏障都加在适当的位置,但
1、x86只支持storeload,其他三种不生效?
2、从实际汇编来看,volatile对应的汇编语句中连storeload都没有,只有lock指令作为前缀,所以最终volatile的功能只是通过并不是依托于内存屏障,而是依托于lock指令?
https://blog.csdn.net/weixin_31128057/article/details/114880914
三、参考文档https://www.one-tab.com/page/nrX4ruu_RC-1oPVE2VsrKA中,有关于JSR-133、MESI、处理器结构讲解等的非常好的文章,以后想了解可以来这里找资源
参考:
除了正文提到的文章,还有下面这些
https://time.geekbang.org/column/article/84017
https://www.cnblogs.com/caozibiao/p/14145010.html
https://www.one-tab.com/page/a1mn--r0R-KuEvDD8Bg_1A
https://www.zhihu.com/question/296949412
https://blog.csdn.net/zx48822821/article/details/86589753
https://zhuanlan.zhihu.com/p/212268670
https://blog.csdn.net/hjsir/article/details/80713783