本文转载于我的个人公众号“阿东编程之路”
多任务处理在现代计算机系统中几乎是一项必备的功能了,让计算机同时去做多个事情,并不是因为计算机的处理能力强大,而是因为计算机的运算速度和子系统的速度差距很大,如果计算机不去同时处理多任务,大量的时间都浪费在了磁盘IO上,网络通信和数据库访问上,而在等待磁盘IO等耗时操作时计算机却不能做其他事情,所以我们要去“压榨”计算机的运算能力,为了让计算机同时做多件事情,就有了多线程。
一. 内存模型
我们用的编程语言比如Java,C++,python这些,都是高级语言,最终都需要通过编译器编译成机器能够看懂的机器指令去交给CPU执行这些指令,执行时候的数据是放在主内存中(计算机物理内存)。
-
随着时代的发展,CPU的运行速度越来越快,CPU、物理内存、读写IO设备之间的速度差异也越来越大,根据木桶理论(一个桶能装多少水的取决于最短的那块木板)我们知道,这样的话就算cpu的运算速度再快,程序整体性能也是取决于最慢的IO读写操作,对于IO读写操作操作系统的优化的其实就是我们在上文提到的多线程多任务处理;那cpu和内存之间的速度差异该如何解决呢?
-
我们在程序中遇到这种速度差异比如读数据库慢的时候可以可以在数据库之上加个缓存来提升响应速度,CPU也是如此,在CPU中加入多级缓存(高速缓存)来解决CPU和内存之间的速度差异。
-
如果是单核CPU,对于内存相当于只有一套缓存,多线程访问时,CPU都会将需要操作的内存加载进缓存,即使有多个线程,操作的也是同个CPU中的缓存,发生线程切换了也不会出现覆盖操作的问题。
但是现在都是多核CPU,如果是多核CPU还是没有问题吗?
-
多核CPU下,多个CPU都有自己的缓存,多个cpu的运算任务涉及一块主内存区域时,肯定会导致会出现数据不一致的问题。
-
假设CPU1读取主内存a的数据为1到CPU高速缓存里,并进行+1操作,此时CPU1还未将a=2写回主内存时,CPU2读取主内存的a数据到CPU高速缓存中进行操作,读取的数据也是1,进行+1操作,那最终a是等于2还是3呢?等于2的话肯定就有问题了。
-
为了解决缓存一致性问题,就需要各处理器访问缓存时遵守一些协议,读写时根据协议进行操作,缓存一致性协议有很多,比如MESI、MSI、MOSI、Synapse等,我们就先来谈下最出名的MESI协议吧。
MESI全称Modified Exclusive Shared Invalid,这四个英文单词也是代表着缓存的四种状态,MESI协议保证了每个cpu中缓存使用的变量副本是一致的。
四种状态分别是:
M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。I(Invalid):这行数据无效。
MESI的原理的简单来说就是:当CPU写共享变量时,会通知其他CPU将缓存设置为无状态,当其他cpu使用该数据时发现缓存已经变成无效状态后会去重新读取内存中的数据,这样就保证每个CPU缓存中的数据是一致的(具体MESI原理还包括总线嗅探之类的,大家感兴趣可以去了解下)。
我们可以看下硬件层面的内存模型:
所以硬件方面的cpu缓存不一致问题是由缓存一致性协议解决的。
二. Java内存模型
什么是Java内存模型呢,Java内存模型和上面的内存模型有什么区别?
Java虚拟机定义了一种规范 - Java内存模型来屏蔽硬件和操作系统内存的访问差异,因为不同的物理架构可以有不一样的物理模型,我理解Java内存模型就是上述内存模型的一种实现,我们先来看下Java的内存模型:
可以看到Java内存模型和内存模型有着高类比度,可以认为Java内存模型是内存模型的一种实现,Java线程模型规定所有变量存储在主内存中(Java内存模型的主内存可以和内存模型的主内存类比,但是实际上是JVM的内存,也只是物理内存的一部分),每个线程有自己工作内存(可以类比cpu的高速缓存),同时线程的工作内存中还保存使用变量的内存副本(我理解就是主内存变量的引用),线程对变量的读取和赋值操作也必须在工作内存中进行,不能直接操作主内存,所以不同线程间的通信只能通过主内存来完成,无法直接访问线程的工作内存,所以就需要一套读写操作的规范来保证各个线程工作内存的一致性。
Java内存模型定义了八种操作来实现内存间的交互,且这八种操作必须为原子性的。
-
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
-
unlock(解锁):作用于主内存的变量,他把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
-
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
-
load(载入):作用于工作内存的变量,它把read操作从主内存中得到变量放入工作内存的变量副本中。
-
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用该变量的值的字节码指令时会执行这个操作。
-
assign(赋值):作用于工作内存的变量,它把执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到给变量赋值的字节码指令时将会执行这个操作。
-
store(存储):作用于工作内存的变量,它把工作内存中的变量值传送到主内存中,以便后续的write操作。
-
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
需要强调一点,Java内存模型只是一种抽象的概念,包括这八种操作,只是帮助我们开发者去解决线程安全问题的。
三. 保证线程安全的三大因素
造成多线程线程安全问题是由哪些因素引起的?
1. 可见性:
-
这个就是我们上面说的可见性问题就知道在运行在不同CPU核上的线程对缓存的写操作互相是不可见的,会有可能覆盖操作,对于Java内存模型而言,就是需要保证不同线程所以保证线程安全的条件之一是保证可见性;java内存模型的可见性是通过变量修改过将值写回主内存,在变量读之前会去主内存中刷新变量值实现的。并且Java提供volatile关键字(具体volatile我们下篇文章再分析),volatile是保证修改立即可见的,现在简单说下就是volatile修饰的变量修改后会立刻将值写回主内存,每次用之前都从主内存刷新。(synchronized,final,juc下的lock都可以保证可见性,实现方式和volatile不一样,这个我们下篇文章一起分析)。
2. 原子性:
-
线程的执行单元是时间片,就是操作系统只允许线程执行一个时间片时间,时间片之间到了之后就会切换任务去唤醒休眠的线程。所以在线程执行一段代码时很有可能切换了多次,我们在上面讲了Java是高级语言,会被编译器编译成机器能识别的机器指令去执行,所以尽管有可能代码只有一行也会有线程安全问题,比如i++这个代码:
public class Demo {
private int i = 0;
public void increment() {
i ++;
}
}
因为class文件是一个二进制文件,无法直观看懂,需要执行反编译命令,直接看下图:
不知道大家有没有注意开头的CAFEBABE(咖啡宝贝),这个是用来标识java class文件的,Java之父 James 高司令还真是一个热爱咖啡爱到极致的男人!
我们对class文件执行反编译命令看下字节码:
javap -v Demo.class
-
我们看到有四步操作,但其实这个也只是字节码,再转成机器码只会更多,多条指令是无法保证原子性的,保证同一个时刻只有一个线程进入这个四行代码块才能保证原子性,Java能保证原子性的也有synchronized关键字和lock接口(这个也在下篇文章详细分析)。
3. 有序性:
-
为了保证处理器内部的运算单元能够被充分利用,处理器在能保证最终结果不影响的前提下会对代码进行乱序执行(Out-Of-Order Execution)优化,虽然能保证最终结果不受影响,但是其他线程在执行中读的结果却可能不按照顺序性。Java虚拟机也有类似的指令重排的优化,这里的案例大家可以看下我之前的文章《单例模式那些事儿》中对DCL单例模型的分析,所以有序性也是影响线程安全的因素之一,Java的volatile是通过lock前缀指令(相当于加一层内存屏障)防止指令重排的。
所以要保证线程安全,就必须保证可见性,原子性,有序性,Java可以通过一些关键字和接口来实现这三个特性。
四. 总结
本文通过介绍了抽象的内存模型以及解决内存模型的缓存一致性协议,来类比到Java内存模型及Java内存模型的内存交互规范,还介绍了保证线程安全的三大特性:可见性,原子性,有序性;java内存模型其实就是一种为了屏蔽硬件和内存访问的差异,以及解决可见性,原子性,有序性来保证线程安全的一种抽象规范,具体的实现就是Java自带的那些的关键字和工具包,下篇文章我们会详细分析Java保证线程安全三大特性的那些的关键字和工具包volatile,synchronized及lock的实现原理。
如果觉得文章不错可以点个赞和关注!
参考书籍及文章:
1.《深入理解Java虚拟机》 作者:周志明
2.https://zhuanlan.zhihu.com/p/29881777
3.https://www.zhihu.com/question/64586462/answer/576543433
3.http://www.yebangyu.org/blog/2016/01/09/memory-consistencyandcachecoherence