明天你会感谢今天奋力拼搏的你。
ヾ(o◕∀◕)ノヾ
初学者可以先不去关注CPU、内存、JVM部分。可以先看多线程系列的其他文章,知其然,然后再去知其所以然。
因为电脑硬件中遇到的并发问题和JVM中的情况有很多相似之处,JVM中的很多处理方案也是参考的硬件方案。所以可以先了解一下硬件的相关知识。
由于CPU与存储设备的运算速度有几个数量级的差距,所以现代计算机系统中都会加入一层读写速度接近处理器运算速度的高速缓存。
CPU在读取数据时,先在L1中寻找,再从L2寻找,再从L3寻找,然后是内存,再后是外存储器。
高速缓存解决了CPU与内存之间的速度矛盾,但也带来了更高的复杂度:缓存一致性的问题。多CPU的的系统中,每个CPU都有自己的高速缓存,当不同CPU读取同样的数据进行缓存,然后进行不同的运算,最终写入主内存以哪个CPU为准?
在这种高速缓存回写的场景下,要遵循一定的协议,如:缓存一致性协议(MESI协议)多数CPU厂商对它进行了实现。
多处理器时,单个CPU对缓存中数据进行了改动,需要通知给其他CPU。也就是意味着,CPU处理要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致。

指令重排的场景:当CPU写缓存时发现缓存区块正被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行。
as-if-serial语义:指令重排时,不管怎么重排序,单个线程的执行结果不能被改变。
换句话说,编译器和处理器不会对存在数据依赖关系的操作做重排序。

两个问题:
1、CPU高速缓存下有一个问题:
缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步。在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。
2、CPU执行指令重排优化下有一个问题:
虽然遵守了as-if-serial语义,但仅在单CPU自己执行的情况下能保证结果正确。
多核多线程中,指令逻辑无法分辨因果关联,可能出现乱序执行,导致程序运行结果错误。
内存屏障(Memory Barrier) 是一个非常重要的概念,它是实现可见性、有序性的关键机制。Java 内存模型通过内存屏障来禁止特定类型的指令重排序,并确保线程间操作的可见性,从而保证多线程环境下的内存一致性。
处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题:
写内存屏障(Store Memory Barrier):在写指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排。
读内存屏障(Load Memory Barrier):在读指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据。
强制读取主内存内容,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题。
TIP:
Java内存模型是Java语言规范中提出的概念(官方文档)。
JVM运行时数据区是Java虚拟机规范中提出的概念(官方文档)。
学习内存模型为了让我们了解JVM做了哪些措施去保证共享变量在多线程下的原子性、可见性和有序性。
硬件内存模型:对特定的内存或高速缓存进行读写访问的过程抽象。
Java内存模型:是对多线程程序的语义描述,不规定如何执行多线程程序。但描述了多线程程序的合法行为(规则)。
Java内存模型屏蔽掉各种硬件和操作系统的内存访问差异,防止不同平台上的内存模型差异。
Java内存模型主要目标是定义程序中各个变量的访问规则。
其围绕着如何处理原子性、可见性和有序性这3个特征来建立的。
在JDK1.5(JSR-133)发布后,Java内存模型已经成熟完善。
提出内存模型是因为多线程中的一些问题:

如上图所示:
Java内存模型定义了一下8种操作来完成主内存和工作内存之间的交互。虚拟机实现时必须保证下面提到的每一种操作都是原子的,不可再分的。
除了这 8 种同步操作之外,还规定了一些同步规则来保证这些同步操作的正确执行,了解就行,就没一一列举了。
扩展,JIT即时编译:
如何解决指令重排的问题?
通过volatile实现。
可见性:让一个线程对共享变量的修改,能够及时的被其他线程看到。
Java内存模型中对volatile规定:
volatile如何实现它的语义:
volatile的读操作性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过总开销依然比锁要低。
但是注意,volatile的可见性不代表其申明的变量就是线程安全的,举例:
多个线程对一个volatile申明的静态变量进行循环自增多次,得到的结果可能比预计的要少。
那volatile应该使用在什么场景下合适?
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。
主内存和工作内存之间的8种操作保证原子性变量的操作,因此我们大致可以认为基本数据类型的访问读写是具备原子性的。
long和double的非原子协定
《Java语言规范》对于64位的数据类型(long和double)有一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,每次操作其中 32 位,这可能导致第一次写入后,读取的值是脏数据,第二次写完成后,才能读到正确值。即允许虚拟机实现时可以选择不保证64位数据类型的load、store、read和write这4个操作的原子性。

编码场景需要一个更大范围的原子性保证时,lock和unlock操作在代码中体现就是synchronized同步块关键字。因此在synchronized申明内的操作也具有原子性。
上文提到过的volatile保证了多线程操作时变量的可见性。
除了volatile之外,synchronized和final关键字也能实现可见性。
synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得。
final的可见性是指:当一个对象的构造函数完成时,它被认为是完全初始化的。如果一个线程只能在一个对象被完全初始化后才能看到该对象的引用,那么它就可以保证看到该对象的final字段的正确初始化值。(可防止this引用逃逸的问题) 官方文档
volatile的有序性:通过禁止指令重排来获得有序性。
synchronized的有序性:一个变量在同一时刻只允许一条线程对其进行lock操作,这条规则获得。
除了这2个关键字外,Java语言中还定义了一些其他的执行顺序规则:
程序次序规则,官方文档
同步次序规则,官方文档
先行发生的原则,如果一个操作先行发生于另一个操作,则第一个操作被第二个操作可见。这个原则是判断数据是否存在竞争,线程是否安全的主要依据。官方文档
下面是Java内存模型下的一些天然的先行发生规则,这些规则无需任何同步器就已经存在。如果两个操作之间不在此列中并且无法推到出下列规则,就表示他们没有顺序性保障,虚拟机可以对这操作进行重排。
这些规则的建立不是一定禁止指令重排,只是禁止会影响程序的指令重排。
JMM中对final的描述:
有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能。在这样的处理器上更新byte数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。
这个问题有时候被称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其他方式来解决问题。
因此JMM中建议:编程人员需要注意,尽量不要对 byte[] 中的元素进行重新赋值,更不要在多线程程序中这样做。
全部评论