JMM
并行与并发

并行:同一时刻,多条指令在多个处理器上同时执行。

并发:同一时刻,多条指令在同一个处理器上执行,利用cpu的执行时间片,分别快速切换;
并发的三大特性:
可见性
一个线程改变了共享变量的值,其他线程立刻可以看到修改后的值。 各个线程执行完后刷回主内存实现的,实现的可见性。
如何实现可见性:
- volatile关键字可以保证可见性----volatile修饰的变量被一个线程修改后,会立刻从CPU的高速缓冲刷回主内存,从而保证了线程间的可见性。
- 通过 synchronized 关键字保证可见性。
- 通过 Lock保证可见性。
- 通过 final 关键字保证可见性
- 通过 内存屏障保证可见性
synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
程序的执行顺序是按照代码的先后顺序执行的,因为JVM有指令重排
如何保证有序性:
- 通过 synchronized 关键字保证有序性
- 通过 Lock保证有序性
- 通过内存屏障保证有序性
- volatile关键字 ----可以防止指令重排
单例模式中volatile关键字的作用:
/** * DCL双锁校验 * 线程安全 * volatile关键字用于防止指令重排序 */ class Singleton { private static volatile Singleton singleton; private Singleton() {} public static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
首先我们要了解对象的创建过程(new关键字),它简单的分为三个阶段:
1.分配对象内存空间.
2.初始化对象.
3.设置对象指向内存空间.
那么实际上第三步和第二部的关系是可以进行互换的,在JVM的优化中存在一种指令重排序的现象,为了加快JVM的运行速度,
指令重排序会在不影响结果的情况下,对JVM的指令进行重新排序.
那么当出现指令重排序时,原本1,2,3的顺序则可能变为1,3,2.此时当代码运行到3时,另一个线程恰好在获取该单例,那么此时代码就会返回一个没有初始化完成的单例对象,这是非常危险的. (如下图所示)
原子性
一个或多个操作,要么全部成功,要不全部失败,不被其他因素所打扰而中断。因为线程执行 线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
如:对于一段代码,一个线程还没执行完这段代码但是时间片耗尽,在等待CPU分配时间片,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个程同时执行同一段代码,也就是原子性问题。
在32位机器上对 Long/Double 型变量进行加减操作会存在并发问题,非 volatile 类型的 long 和 double 型变量是 8 字节 64 位的, 32 位机器读或写这个变量的时候把它们分成两个 32 位操作,可能一个线程读取了某个值的高 32 位,低 32 位被另一个线程修改了。 官方推荐最好把 long/double 变量声明为 volatile 或是同步加锁 synchronized 以避免并发问题
线程切换带来原子性问题
i = 0; // 原子性操作
j = i; // 不是原子性操作,包含了两个操作:读取i,将i值赋值给j
i++; // 不是原子性操作,包含了三个操作:读取i值、i + 1 、将+1结果赋值给i
i = j + 1; // 不是原子性操作,包含了三个操作:读取j值、j + 1 、将+1结果赋值给i
public class Test { public int a = 0; public void increase() { a++; } public static void main(String[] args) { final Test test = new Test(); for(int i = 0; i < 10; i++) { new Thread() { public void run() { for(int j = 0; j < 1000; j++) test.increase(); }; }.start(); } while(Thread.activeCount() > 1) { Thread.yield(); } System.out.println(test.a); } }
目的:10个线程将inc加到10000。结果:每次运行,得到的结果都小于10000。

JMM
JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可 以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

内存交互操作

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放 入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给 工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主 内存中,以便随后的write的操作
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值 传送到主内存的变量中。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放 后的变量才可以被其他线程锁定。
MESI缓存一致性协议
缓存行有4种不同的状态
Modified:修改
Exclusive:独占
Shared:共享
Invalid:无效
当一个线程从主内存中加载工作内存中时,此时该变量所在的缓存状态为独占状态。
当另一个线程也从主内存加载该变量后,此时该变量所在的缓存行状态为共享状态
当一个线程修改了该变量后,此时该变量所在的缓存状态为修改状态,刷回主内存后,其他线程该变量的缓存行是失效状态,需要从主内存中重新加载。
总线锁定
一个处理器在总线上输出LOCK#信号,使得其他处理器对内存的操作请求都会被阻塞,该处理器独占共享内存
缓存锁定
- 由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。
- 缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取
两种不能使用缓存锁的情况
第一种情况是操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
第二种情况是处理器不支持缓存锁定,对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
volatile作用
1.可见性:一个线程改了后,会立刻刷回到主内存。
2.有序性:禁止指令重排
3.不能保证原子性:
缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
以下代码即使被volatile修饰了,最后结果也是小于10000,当两个线程同时从工作内存返回主内存时,一个线程先返回,会导致另一个线程返回的值无效。
public class Test { public volatile int a = 0; public void increase() { a++; } public static void main(String[] args) { final Test test = new Test(); for(int i = 0; i < 10; i++) { new Thread() { public void run() { for(int j = 0; j < 1000; j++) test.increase(); }; }.start(); } while(Thread.activeCount() > 1) { Thread.yield(); } System.out.println(test.a); } }
CPU架构

一个 CPU 里通常会有多个 CPU 核心,比如上图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次。
上面提到的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些存储设备共同构成了金字塔存储层次。如下图所示:

CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Line(缓存行),所以 CPU Line 是 CPU 从内存读取数据到 Cache 的单位。
至于 CPU Line 大小,在 Linux 系统可以用下面的方式查看到,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 L1 Cache 一次载入数据的大小是 64 字节。

那么对数组的加载, CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率, 从而可提高程序的性能。
但是,在我们不使用数组,而是使用单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该要规避它。

伪共享问题
现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A 和 B,这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 Cache Line 中,又因为 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。
1.最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。

2. 1 号核心读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。

3.接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。

4.1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。

5.之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。

所以,可以发现如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。
因此,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)。
避免伪共享的方法:
1.缓冲行填充
2..使用 @sun.misc.Contended 注解(java8) 注意需要配置jvm参数:-XX:-RestrictContended


浙公网安备 33010602011771号