1. Java内存模型(JMM)
1.1 JMM数据原子操作
- read(读取): 从主内存读取数据
- load(载入): 将主内存读取到的数据写入工作内存
- use(使用): 从工作内存读取数据用于计算
- assign(赋值): 将计算好的值重新赋值到工作内存中
- store(存储): 将工作内存数据写入主内存
- write(写入): 将store过去的变量值赋值给主内存中的变量
- lock(锁定): 将主内存变量加锁,标识为线程独占状态
- unlock(解锁): 将主内存变量解锁,解锁后其他线程可以锁定该变量
1.2 CPU缓存一致性
1.2.1 缓存一致性-总线加锁
线程2 从主存中read值之后 开始加锁,直到store 线程执行完之后 unlock,其他线程才可以执行。
缺点:
存在严重的性能问题
1.2.2 缓存一致性-MESI 缓存一致性协议
- 对应代码:
public class VolatileVisiblityTest {
private static volatile boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("waiting data...");
while (!initFlag) {
}
System.out.println("---------------success");
}).start();
Thread.sleep(2000);
new Thread(() -> prepareData()).start();
}
public static void prepareData() {
System.out.println("准备数据中....");
initFlag = true;
System.out.println("数据准备完毕");
}
}
MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:
- MESI
- M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
- E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
- S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
- I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用
- CPU的读取遵循下面几点:
- 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
- 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
- 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。
- 汇编#Lock前缀指令
**
volatile关键字修饰的变量 在线程对其赋值操作的时候,会在其汇编语言中加上#Lock前缀,如上图。有2个功能:
[1] 将当前处理器缓存行中的数据立刻刷新到主内存中
[2] 这个写回内存的操作会触发 CPU总线嗅探机制 , 会引起在其他CPU核里缓存了该内存地址的数据无效(MESI缓存一致性协议)。从而 从表面上实现了内存可见性。
- VM参数配置
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisiblityTest.prepareData
1.3 volatile 无法保证线程操作的原子性
- 代码:
public class VolatileAtomicTest {
static int num = 0;
static void increase() {
num++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increase();
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(num);
}
}
结果:num<=10000
- 分析:
线程1计算完之后 通过主线向主存更新num值,此时MESI缓存一致性及CPU嗅探,使得线程2已经计算完的num丢失。原本两个线程计算完之后应该是2,此时只能是1;
2. 重排序
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:
在不改变程序执行结果的前提下,尽可能提高并行度。
JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序**。
一般重排序可以分为如下三种:
- 编译器优化的重排序 : 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行的重排序 : 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序 : 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL问题,这个在以后的文章中会具体去聊。
针对编译器重排序,JMM的编译器重排序规则会禁止一些特定类型的编译器重排序;
针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。
3.happens-before 原则
JMM内存模型->happens-before原则->重排序
3.1 定义
A happens-before B就是A先行发生于B(这种说法不是很准确),定义为hb(A, B)。
在Java内存模型中,happens-before的意思是前一个操作的结果可以被后续操作获取。
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
上述(1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上述(2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。
4.内存屏障
4.1 定义
一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。这个使内存数据对CPU核可见的技术被称为内存屏障或内存栅栏。
4.2 功能
首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;
其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。
4.3 指令集
4.3.1 Store Barrier(Store屏障)
强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。
这会使得程序状态对其它CPU可见,这样其它CPU可以根据需要介入。
4.3.2 Load Barrier(Load屏障)
强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。这使得从其它CPU暴露出来的程序状态对该CPU可见,这之后CPU可以进行后续处理。
4.3.3 Full Barrier (Full屏障)
Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。
引用
https://www.jianshu.com/p/64240319ed60
https://juejin.cn/post/6844903600318054413#heading-2