基础篇:深入JMM内存模型解析volatile、synchronized的内存语义
程序员文章站
2022-07-10 21:27:15
深入JMM解析volatile、synchorized的内存语义1 CPU高速缓存、MESI协议2 指令重排序和内存屏障指令...
目录
先介绍下多进程多线程在linux几种通信方式
- 管道:管道的实质是一个内核缓冲区,需要通信的两个进程各在管道的两端,进程利用管道传递信息
- 信号:信号是软件层次上对中断机制的一种模拟,进程不必阻塞等待信号的到达,信号可以在用户空间进程和内核之间直接交互
- 消息队列:消息队列是消息的链表,存放在内存中并由消息队列标识符标识,允许多个进程向它写入与读取消息
- 共享内存:多个进程可以可以直接读写同一块内存空间,是针对其他通信机制运行效率较低而设计的
- 信号量:信号量实质上就是一个标识可用资源数量的计数器,它的值总是非负整数
- 套接字:套接字可用于不同机器之间的进程间通信。有两种类型的套接字:基于文件的和面向网络(socket)
java设计上则是基于共享内存来实现进程,线程的通信
1 java内存模型,JMM(JAVA Memory Model)
- 1.1 线程A需要和线程B交互,则需要更新工作内存的共享变量副本到主存,然后线程B去主存读取更新后的变量
- 1.2 java线程之间的通信是由JMM控制的,JMM决定线程对共享变量的写入何时对另一线程可见。共享变量存在主存,线程拥有自己的工作内存(一个抽象的概念,它覆盖了缓存,写缓冲区,寄存器等)
2 CPU高速缓存、MESI协议
-
处理器的高速发展,CPU的性能和内存性能差距拉大,为了解决问题,CPU设置多级缓存,例如L1、L2、L3高速缓存(Cache)。
-
和JMM的内存布局相似,前者是系统级别,解决缓存一致性问题;后者是应用级别的,解决的是内存一致性问题
-
这些高速缓存一般都是独属于CPU内部的,对其他CPU不可见,此时又会出现缓存和主存的数据不一致现象,CPU的解决方案有两种
- 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
- 缓存一致性协议(MESI):缓存一致性协议也叫缓存锁定,缓存一致性协议会阻止两个以上CPU同时修改映射相同主存数据的缓存副本
-
MESI实现是依靠处理器使用嗅探技术保证它的内部缓存、系统主内存和其他处理器的缓存的数据在总线上保持一致
-
例:处理器打算回写脏内存地址,而此内存处于共享状态(Share);那么其他处理器会嗅探到,并将使自身的对应的缓存行无效,在下次访问相应内存地址时,刷新该缓存行
-
缓存数据状态有如下四种(MESI):
缓存状态 描述 M(Modifed) 在缓存行中被标记为Modified的值,与主存的值不同,这个值将会在它被其他CPU读取之前写入内存,并设置为Shared E(Exclusive) 该缓存行对应的主存内容只被该CPU缓存,值和主存一致,被其他CPU读取时置为Shared,被其他CPU写时置为Modified S(Share) 该值也可能存在其他CPU缓存中,但是它的值和主存一致 I(Invalid) 该缓存行数据无效,需要时需重新从主存载入
3 指令重排序和内存屏障指令
- 为提高程序性能,编译器和处理器经常会对指令做重排序,分别是编译器优化的重排序、指令并行级别的重排序,内存系统的重排序。
- 指令并行重排序和内存系统重排序归为处理器重排序
- 编译器级优化重排序,可由JMM规则禁止特定类型的指令重排;对于处理器级别重排序则是插入特定类型的内存屏障指令,以此禁止特定类型的重排序
- CPU的设计者提供内存屏障机制,是将对共享变量读写的高速缓存的强一致性控制权(MESI的机制)交给了程序员或编译器
- 这里介绍两种处理器级别的内存屏障指令
- 写内存屏障:该屏障之前的写操作先于之后的写操作;在指令后插入StoreBarrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
- 读内存屏障:该屏障之前的读操作先于之后的读操作;在指令前插入LoadBarrier,让高速缓存中的数据失效,强制从主内存加载数据
- 内存屏障有两个作用:阻止屏障两侧的指令重排序;强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
- JAVA的内存屏障指令,基本可以理解为在CPU内存屏障指令上二次封装
JAVA内存屏障指令 | 作用描述 |
---|---|
Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存),先于Store2及所有后续存储指令的存储。 |
Load1;LoadStore;Store2 | 确保Load1数据装载先于Store2及所有后续存储指令的存储。 |
Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。 |
Load1;LoadLoad;Load2 | 确保Load1数据的装载,先于Load2及所有后续装载指令的装载。 |
- 特殊的是StoreLoad,会使该屏障之前的所有内存访问指令(装载和存储指令)完成之后,才执行该屏障之后的内存访问指令;是一个”全能型”的屏障,它同时具有其他三个屏障的效果
- 用一句话描述java内存屏障的目的:把当前工作内存的数据全部刷新到主内存,并且其他工作内存的共享变量全部失效,真正需要用时再读取主存最新的值
4 happen-before原则
- 内存屏障是相对于jvm,cpu级别的内存一致性(内存可见性)的解决方案;为了让java程序员更容易理解,jsr-133使用happens-before的概念来说明不同操作之间的内存可见性
- 程序次序规则:同一个线程,任意一操作happens-before同线程之后的全部操作
- 监视器锁(synchronized)规则:对一个监视器锁的解锁,happens-before随后对这个锁的加锁
- volatile变量规则:对volatile变量的写操作,happens-before该volatile变量之后的任意读操作
- 传递性:如果A先于B;B先于C;则A先于C
- happens-before部分规则是基于内存屏障实现的
5 synchronized内存语义
class Count{
int a = 0;
public synchronized void writer(){// 1
a++; //2
} //3
public synchronized void reader(){// 4
int i = a; //5
} //6
}
- 根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens- before 6。 根据监视器锁规则,3 happens-before 4。根据happens-before的传递性得 2 happens-before 5。执行结果如下图
- 线程释放锁时内存语义:JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 线程获取锁时内存语义:JMM会把该线程对应的工作内存置为无效
6 volatile的内存语义
- volatile变量具有可见性,Java线程内存模型确保所有线程看到这个变量的值是最新的,并且单个volatile变量的读/写具有原子性;java编译器对volatile变量处理如下
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的前面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
- 注意i++是复合操作,即使 i 是volatile变量,也不保证i++是原子操作
volatile Object instance;
instance = new Object();
//相应汇编代码
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
- 当volatile变量修饰的共享变量进行写操作的反汇编代码会出现
0x01a3de24: lock addl $0×0,(%esp)
,其实就是插入了内存屏障导致的结果,lock表示volatile变量写时被缓存锁定了(MESI协议),作用如下- 禁止指令重排序
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
int a = 0; volatile boolean v = false;
线程A
a = 1; //1
v = true; //2
线程B
v = true; //3
System.out.println(a);//4
- 根据程序次序规则,1 happens-before 2;3 happens-before 4。根据volatile变量规则,2 happens-before 3。 根据happens-before的传递性规则,1 happens-before 4。程序的执行结果表现如下图
- volatile写的内存语义:写volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
- volatile读的内存语义:读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
- 非基本字段不应该用volatile修饰。其原因是volatile修饰对象或数组时,只能保证他们的引用地址的可见性
7 final内存语义
- final写内存语义:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。保障对象被引用之前,fianl域里的变量都是被初始化的
- 实现原理:编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
public class Example { int i; //普通类型 final int j; // 引用类型 public Example () { // 构造函数 i = 0; j = 1; } public static void writer () { // 写线程A执行 obj = new Example (); } public static void reader () { // 读线程B执行 Example object = obj; // 读对象引用 int a = object.i; // 读普通域 int b = object.j; // 读final域 } }
- final只会禁止对其修饰变量的写操作,被重排序到构造函数之外;普通变量 i 的赋值可能会被重排到序构造函数之外
- A线程创建obj,可能让线程B拿到初始化一半的obj;final变量 j 被初始化,而普通变量 i 还没初始化
- 疑问:内存屏障不是会禁止指令重排吗?个人猜想应该是编译器先重排序,此时普通变量已经在构造器外了,再根据final类型插入内存屏障。上面的代码执行可能有如下情况:
- final读内存语义
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
- 实现原理:要求编译器在读final域的操作前面插入一个LoadLoad屏障
- 当使用final修饰引用对象或者数组时,final只保证在构造器返回之前对引用对象的操作先于构造器返回之后的操作
public class Example { final int[] intArray; // intArray 是引用类型 public Example () { // 构造函数 intArray = new int[1]; intArray[0] = 1; //此操作对获取该对象引用的线程是可见的 } }
8 synchronized,volatile内存语义的原理梳理
9 应用题:延迟加载双重锁定是否真的安全
public class Instance { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (Instance.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
代码第7行instance=new Singleton();
创建了一个对象。这一行代码可以分解为如下的3行伪代码
memory = allocate(); // A1:分配对象的内存空间
ctorInstance(memory); // A2:初始化对象
instance = memory; // A3:设置instance指向刚分配的内存地址
假如2和3之间重排序之后的顺序如下
memory = allocate(); // A1:分配对象的内存空间
instance = memory; //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象
- 假如发生A3、A2重排序,线程是不保障赋值和初始化对象两步骤操作结果会一起同步到主存
- 因此第二个线程执行到
if (instance == null);// 4:第一次检查
时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用) - 相应的两个解决方法
- 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:
private volatile static Instance instance;
- 使用类加载器的全局锁,在执行类的初始化期间,JVM会去获取一个锁;这个锁可以同步多个线程对同一个类的初始化,每个线程都会试图获取该类的全局锁去初始化类
public class InstanceFactory { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { // 这里将导致InstanceHolder类被初始化 return InstanceHolder.instance ; } }
- 在锁内使用volatile修饰instance,volatile保障指令禁止重排序,并且保障变量的内存可见性:
10 题外话:伪共享(false sharing)
- 伪共享
- 前面介绍到每个CPU都有属于自己的高速缓存,但是缓存数据大小是怎样的呢?
- 这个大小并不是我们需求存多大就存多大的,而是一个固定的大小-64字节,缓存的加载更新都是以连续的64字节内存为单位,称之为缓存行
- 一缓存行是可以存在多个变量的,比如long类型(64位==8字节),可以存入8个
- 假如变量A和变量B是在同一连续的内存,CPU缓存加载A时,B也会被读取;反之亦然,A的脏回写导致在其他CPU相应内存失效的同时,同一缓存行的B内存也被标识为Modified(同舟共渡,一起翻船)
- 设想变量A和B没有关联,却刚好在同一缓存行;然后A被CPU-X处理,B被CPU-Y处理;因为CPU-X对A的缓存更新而导致B的缓存失效;CPU-Y要处理B,则要读取更新后的缓存行(B实际是没被更新),造成没必要的内存读取开销。这就是伪共享
- 伪共享的解决方法:
1- 填充字节,将对应的变量填充到缓存行的大小。如下面定义的类,声明额外的属性
2- 使用jdk的注解@Contended修饰变量,jvm会自动将变量填充到缓存行的大小。注意的是需要加入启动参数 -XX:-RestrictContendedpublic final static class FilledLong { /**value 加 p1 - p6;加对象头8个字节正好等于一缓存行的大小 */ //markWord + klass (32位机,64位是16字节) 8字节 public volatile long value = 0L; // 8字节 public long p1, p2, p3, p4, p5, p6; //48字节 }
关注公众号,大家一起交流
参考文章
- java并发编程的艺术(书籍)
- Linux进程间通信的几种方式
- java内存屏障
- 搞懂内存屏障-指令与JMM
- 杂谈什么是伪共享
本文地址:https://blog.csdn.net/u013591094/article/details/107349321