前言
JVM定义“Java内存模型”让Java的并发内存访问操作不会产生歧义,同时屏蔽各种硬件和操作系统的内存访问差异。内存模型内容很多,有指令重排序、happenBefore、8大原子指令等。在这之前我们必须了解硬件层是如何支持并发的,物理机的并发处理对虚拟机并发有相当大的参考意义。
一、处理器、内存、高速缓存关系
CPU除了控制器、运算器等器件还有一个重要的部件就是寄存器。CPU读取指令是往内存里面去读取的,读一条指令放到CPU中,CPU去执行,对内存的读取速度比较慢,所以从内存读取的速度去决定了这个CPU的执行速度的。所以为了弥补这个缺陷,所以去添加了高速缓存的机制。如下图展示了处理器/cpu、调整缓存、主内存的结构:
二、缓存一致性协议
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾, 但是也为计算机系统带来更高的复杂度, 它引入了一个新的问题: 缓存一致性(Cache Coherence) 。在多核处理器系统中, 每个处理器都有自己的高速缓存, 而它们又共享同一主内存(Main Memory) , 这种系统称为共享内存多核系统(Shared Memory Multiprocessors System) , 如图下图所示。 当多个处理器的运算任务都涉及同一块主内存区域时, 将可能导致各自的缓存数据不一致。 如果真的发生这种情况, 那同步回到主内存时该以谁的缓存数据为准呢? 为了解决一致性的问题, 需要各个处理器访问缓存时都遵循一些协议, 在读写时要根据协议来进行操作, 这类协议有MSI、MESI(Illinois Protocol) 、 MOSI、Synapse、 Firefly及Dragon Protocol等。
以MESI协议为例,每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态 |
描述 |
---|---|
M(Modified) |
这行数据有效,但数据被修改了,和内存中的数据不一致,数据只存在于本Cache中 |
E(Exclusive) |
这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中 |
S(Shared) |
这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中 |
I(Invalid) |
这行数据无效 |
三、缓存行/CacheLine
CPU从内存读取数据时实际是按块读取的,这一块多大的数据呢,目前业界都是用的64字节,这是一个经验值,缓存行越大,局部性空间效率越高,但读取时间慢;反之缓存行越小,局部性空间效率越低,但读取时间快,所以就取了一个折中值:64字节。这个块就是缓存行。也就是说如果某条指令读写了一个字节内存,那么在内存操作的时候都会把这1字节所在附近的64字节读写一次。
如上图所示,多核CPU就会遇到这样一个问题,xy的数据在一个缓存行,第一个核/cpu将x改了,第二个核将y改了,他们再使用另一个值的时候,他们都不知道值变了,这就出现了缓存一致性的问题,第二节的MESI协议就是来解决这个问题的。
MESI协议是怎么生效的?如上图中x被改了之后他给自己标记成Modified,然后数据写回内存后通知其他核,给他们的这个缓存行状态改成Invalid,意思就是告诉他们我改过了,你们这个都无效了,如果需要用到最新数据,重新去内存中取。
四、缓存行/CacheLine对齐
cache line 对齐主要是为了避免false share/伪共享。简单来说: 缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行。MESI协议解决了内存一致性,同时由于也会通知其他核来刷新缓存,导致线程缓存的频繁刷新,影响性能,这就是伪共享。那么Java验证解决伪共享呢?看两个例子:
示例1:
public class CacheLineTest01 {
private static class T {
public volatile long x = 0;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
arr[1].x = i;
}
});
final long startTime = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
//输出两个线程执行完成所需的时间,平均结果:250
System.out.println(System.currentTimeMillis() - startTime);
}
}
示例2:
public class CacheLineTest02 {
private static class Padding{
public volatile long p1,p2,p3,p4,p5,p6,p7;//cache line padding缓存行对齐
}
private static class T extends Padding{
public volatile long x = 0;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
arr[1].x = i;
}
});
final long startTime = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
//输出两个线程执行完成所需的时间,平均结果:85
System.out.println(System.currentTimeMillis() - startTime);
}
}
简单讲一下写的是T有个成员变量x,然后一个数组里面有两个T对象,下面用两个线程去循环一千万次修改数组里的两个T的成员变量x的值,最后输出执行所用时间。第二个图只是在成员变量x前后加了7个long类型成员变量,其他都没变。大家可以看到很明显的第二种写法所用时间短了很多,为什么会这样呢?这就是上面说的缓存行的问题,示例1大概率两个T是在一个缓存行中,一个缓存行64字节,一个long类型8字节,所以每次修改的时候他都要修改缓存行的状态并通知另一个核缓存行无效了,以及从内存中读取数据所以就慢了,反观第二个图中前后都有7个long类型的成员变量,所以两个T不会在同一个缓存行中,他就不需要去通知另一个核数据变了,时间就节省下来了。这种做法类似空间换时间的概念,所以写程序是要斟酌,现在也有挺多开源软件采用了这种作法。
参考:《深入理解计算机系统》《深入理解Java虚拟机》