JUC系列:(四)内存

布鸽不鸽 Lv4

JMM

Java内存模型(Java Memory Model),是一个抽象的概念。

详细内容:

  • 系统存在一个主内存(Main Memory),Java中所有的变量都存储在主存中,对所有线程都是共享的

  • 每条线程都有自己的工作内存(Working Memory),保存的是主存中变量的拷贝

  • 线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量

  • 线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

JMM的作用:

  • 屏蔽各种硬件和操作系统的差异
  • 规定线程和内存之间的关系

JVM和JMM之间的关系:

  • JMM中的主内存、工作内存与 JVM中的堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的

内存交互

JMM中定义了8种操作(工作内存与主内存交互)

image-20231206192803118

三大特性

可见性

  • 一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
1
2
3
4
5
6
7
8
9
10
11
12
static boolean run = true;	// 如果不加volatile

public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(run){
System.out.println("running");
}
}).start();

Thread.sleep(1);
run = false; // 线程不会如预想的停下来
}

原因:

  • 线程从主内存读了run的值
  • JIT编译器会将run的值缓存到工作内存(的高速缓存中),减少对主内存run的访问,提高效率
  • 主内存修改了run的值,但是工作内存中还是老的值
image-20231206194127613

原子性

  • 要么所有的操作都成功,要么所有的操作都不执行,不会出现中间状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int a = 0;

public static void main(String[] args) throws InterruptedException {

for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
a++;
}
}).start();
}

while (Thread.activeCount() > 2) {
Thread.yield();
}

System.out.println("a = " + a);
}
  • 结果理应是20000,但是由于并发不安全,结果往往偏小
  • 反编译class文件,a++的字节码如下:
    • 一个线程对共享变量操作到一半,另外线程也可能来操作共享变量,干扰了前一个线程的操作
1
2
3
4
 9 getstatic #32 <org/example/TestVolatile.a : I>	// 取值操作
12 iconst_1
13 iadd
14 putstatic #32 <org/example/TestVolatile.a : I> // 赋值操作

有序性

  • 虽然,在本线程内看来,所有指令都是有序的(或者说从执行的结果来看,是和有序一样的)

  • 但是,从其他线程来看,这个线程的指令顺序未必就是代码编写的顺序

1
2
3
4
5
6
7
8
9
10
11
12
// 线程1
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}

// 线程2
num = 2;
ready = true;

// 很有可能出现,先ready=true,再num=2的情况

为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:

1
[源代码] -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> [最终执行指令]

Cache

计算机中的缓存

发出内存访问请求时,先查看缓存内是否有请求数据

  • 如果存在(命中),则不用访问内存直接返回该数据
  • 如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器
image-20231207153152691
从 CPU 到大约需要的时钟周期
寄存器1 cycle (4GHz 的 CPU 约为 0.25ns)
L13~4 cycle
L210~20 cycle
L340~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

缓存一致

缓存一致性:多个处理器运算都涉及到同一块主内存区域时,可能各自的缓存数据不一致

image-20231207154559164

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
2
3
4
// 无法避免指令交错
volatile i = 0;
new Thread(() -> {i++});
new Thread(() -> {i--});
image-20231207163034006

双重锁检查

禁止重排序上:

单例模式的双重锁检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
private static volatile Singleton instance;

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 避免赋值操作指令重排
}
}
}
return instance;
}
}
1
2
3
memory=allocate();		// 分配内存
ctorInstanc(memory); // 初始化对象
s=memory; // s指向刚分配的地址

如果instance变量不使用volatile关键词,可能发生重排序

1
2
3
memory=allocate();		// 分配内存
s=memory; // 此时instance不为空,但是还没初始化完成。别的线程判断不为空,直接返回了没初始化的对象
ctorInstanc(memory); // 初始化对象

happens-before

JMM具备一些先天的有序性,即不需要通过任何同步手段,就能够得到保证的安全

  1. 程序次序规则:操作之间有先后依赖关系
  2. 锁定规则:unlock操作先行发生于,后面对同一个锁的lock操作
    • 这就是为什么,解锁之前的写操作,对接下来加锁的线程是可见的
  3. volatile规则:对volatile变量的写操作先行发生于后面对这个变量的读
  4. 传递规则:A->B,B->C,推导出A->C
  5. 线程启动规则:Thread对象的start方法先行发生于后面的每一个操作
  6. 线程中断规则:对线程interrupt()方法的调用。先行发生于检测到中断事件的发生
  7. 线程终止规则:线程中所有的操作,都先行发生于线程的终止检测(比如thread.join())
  8. 对象终结规则:一个对象的初始化(构造函数执行结束),先行发生于他的finalize()方法

设计模式

终止模式

停止标记用volatile:保证该变量在多个线程之间的可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Termination {

private Thread monitor;
private volatile boolean stop = false;

public void start() {
monitor = new Thread(() -> {
while (true) {
Thread thread = Thread.currentThread();
if (stop) {
System.out.println("后置处理");
break;
}

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("被打断,退出睡眠");
}
}
});
monitor.start();
}

public void stop() {
stop = true;
monitor.interrupt();
}
}
1
2
3
4
Termination termination = new Termination();
termination.start();
Thread.sleep(5000);
termination.stop();

Balking

犹豫模式:发现另一个线程或本线程已经做了一样的事,就无需再做了,直接返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MonitorService {

private boolean starting = false;

public void start() {
// 需要加锁,synchronized能保证可见性
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
// actually do sth...
}
}
  • 标题: JUC系列:(四)内存
  • 作者: 布鸽不鸽
  • 创建于 : 2024-04-10 20:34:10
  • 更新于 : 2024-01-10 14:58:18
  • 链接: https://xuedongyun.cn//post/39218/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论