JUC系列:(四)内存
JMM
Java内存模型(Java Memory Model),是一个抽象的概念。
详细内容:
系统存在一个主内存(Main Memory),Java中所有的变量都存储在主存中,对所有线程都是共享的
每条线程都有自己的工作内存(Working Memory),保存的是主存中变量的拷贝
线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量
线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成
JMM的作用:
- 屏蔽各种硬件和操作系统的差异
- 规定线程和内存之间的关系
JVM和JMM之间的关系:
- JMM中的主内存、工作内存与 JVM中的堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的
内存交互
JMM中定义了8种操作(工作内存与主内存交互)
三大特性
可见性
- 一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
1 | static boolean run = true; // 如果不加volatile |
原因:
- 线程从主内存读了run的值
- JIT编译器会将run的值缓存到工作内存(的高速缓存中),减少对主内存run的访问,提高效率
- 主内存修改了run的值,但是工作内存中还是老的值
原子性
- 要么所有的操作都成功,要么所有的操作都不执行,不会出现中间状态
1 | static int a = 0; |
- 结果理应是20000,但是由于并发不安全,结果往往偏小
- 反编译class文件,a++的字节码如下:
- 一个线程对共享变量操作到一半,另外线程也可能来操作共享变量,干扰了前一个线程的操作
1 | 9 getstatic #32 <org/example/TestVolatile.a : I> // 取值操作 |
有序性
虽然,在本线程内看来,所有指令都是有序的(或者说从执行的结果来看,是和有序一样的)
但是,从其他线程来看,这个线程的指令顺序未必就是代码编写的顺序
1 | // 线程1 |
为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:
1 | [源代码] -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> [最终执行指令] |
Cache
计算机中的缓存
发出内存访问请求时,先查看缓存内是否有请求数据
- 如果存在(命中),则不用访问内存直接返回该数据
- 如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器
从 CPU 到 | 大约需要的时钟周期 |
---|---|
寄存器 | 1 cycle (4GHz 的 CPU 约为 0.25ns) |
L1 | 3~4 cycle |
L2 | 10~20 cycle |
L3 | 40~45 cycle |
内存 | 120~240 cycle |
伪共享
缓存以缓存行(cache line)为单位
缓存会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。CPU要保证数据的一致性,需要做到某个CPU核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效,这就是伪共享
解决方法:
- padding:通过填充,让数据落在不同的cache line中
- @Contended:原理参考,无锁→Adder→优化机制→伪共享
Linux 查看 CPU 缓存行大小:
1 | cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size |
缓存一致
缓存一致性:多个处理器运算都涉及到同一块主内存区域时,可能各自的缓存数据不一致
MESI是一种缓存一致性协议,CPU中每个缓存行使用四种状态标记:
- M:被修改(Modified)
- 该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的
- 该缓存行中的内存需要写回
- 再次修改不需要广播
- E:独享的(Exclusive)
- 该缓存行只被缓存在该 CPU 的缓存中,是未被修改过的
- 修改不需要广播
- S:共享的(Shared)
- 该缓存行可能被多个 CPU 缓存
- 修改前,需要广播,使别人变成Invalid的
- I:无效的(Invalid)
- 该缓存是无效的,可能有其它 CPU 修改了该缓存行
处理机制
单核CPU:
- 自动保证内存操作的原子性
多核CPU:
- 总线锁定:当处理器要操作共享变量时,在 BUS 总线上发出一个 LOCK 信号,其他处理器就无法操作这个共享变量。该操作会导致大量阻塞,从而增加系统的性能开销(平台级别的加锁)
- 缓存锁定:当处理器对缓存中的共享变量进行了操作,其他处理器有嗅探机制,将各自缓存中的该共享变量的失效,读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现
有的情况不会使用缓存锁定:
- 操作的数据跨多个行
- 有的处理器不支持缓存锁定
volatile
volatile:保证可见性,保证有序性(禁止指令重排),不保证原子性
synchronized为什么也可以保证有序性和可见性?(即使它无法禁用指令重排和处理器优化)
- 有序性:加锁之后,只有一个线程获得了锁。相当于单线程,由于数据依赖的存在,指令重排没有问题
- 可见性:加锁前,将清空工作内存中的值,后续使用需要从主内存中重新读取新的值;解锁前,把工作内存中的值刷新到主内存
缓存一致
- 对volatile变量,写指令后,插入写屏障
- 对volatile变量,读指令前,插入读屏障
内存屏障的作用:
- 确保对变量的“读-改-写”操作原子执行
- 阻止指令重排
- 强制把缓存中的脏数据写回主内存
保证可见性
- 写屏障(Store Barrier),保证屏障之前对共享变量的操作,都同步到主存中
- 读屏障(Load Barrier),保证凭证之后对共享变量的读取,从主存刷新值,加载的是主存最新的值
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到写屏障之前
保证有序性
- 写屏障(Store Barrier),确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障(Load Barrier),确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
只能保证本线程内的代码不被重排,无法避免指令交错
1 | // 无法避免指令交错 |
双重锁检查
禁止重排序上:
单例模式的双重锁检查
1 | class Singleton { |
1 | memory=allocate(); // 分配内存 |
如果instance变量不使用volatile关键词,可能发生重排序
1 | memory=allocate(); // 分配内存 |
happens-before
JMM具备一些先天的有序性,即不需要通过任何同步手段,就能够得到保证的安全
- 程序次序规则:操作之间有先后依赖关系
- 锁定规则:unlock操作先行发生于,后面对同一个锁的lock操作
- 这就是为什么,解锁之前的写操作,对接下来加锁的线程是可见的
- volatile规则:对volatile变量的写操作先行发生于后面对这个变量的读
- 传递规则:A->B,B->C,推导出A->C
- 线程启动规则:Thread对象的start方法先行发生于后面的每一个操作
- 线程中断规则:对线程interrupt()方法的调用。先行发生于检测到中断事件的发生
- 线程终止规则:线程中所有的操作,都先行发生于线程的终止检测(比如thread.join())
- 对象终结规则:一个对象的初始化(构造函数执行结束),先行发生于他的finalize()方法
设计模式
终止模式
停止标记用volatile:保证该变量在多个线程之间的可见性
1 | public class Termination { |
1 | Termination termination = new Termination(); |
Balking
犹豫模式:发现另一个线程或本线程已经做了一样的事,就无需再做了,直接返回
1 | public class MonitorService { |
- 标题: JUC系列:(四)内存
- 作者: 布鸽不鸽
- 创建于 : 2024-04-10 20:34:10
- 更新于 : 2024-01-10 14:58:18
- 链接: https://xuedongyun.cn//post/39218/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。