JMM
现代计算机CPU缓存
三级缓存
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能的内存和硬盘价格极其昂贵,如果都采用高性能的内存造价可能要高几个数量级。为了平衡成本与速度,CPU厂商在CPU中内置了少量的高速缓存以解决内存读取速度和CPU运算速度之间的不匹配问题。
随着多核CPU的发展,CPU缓存通常分成了三个级别:L1,L2,L3。级别越小越接近CPU速度也更快,同时容量也越小。L1速度最快,每个核上都有一个L1缓存,L1缓存每个核上其实有两个L1缓存,一个用于存数据的L1d Cache(DataCache),一个用于存指令的L1i Cache(InstructionCache);一般情况下每个核上也有一个独立的L2缓存;L3缓存是三级缓存中最大的一级,在同一个CPU插槽之间的核共享一个L3缓存。
下面是从CPU访问各级存储需要的时间:
| 从CPU到 | 大约需要的CPU周期 | 大约需要的时间(单位ns) |
|---|---|---|
| 寄存器 | 1 cycle | |
| L1 Cache | ~3-4 cycles | ~0.5-1 ns |
| L2 Cache | ~10-20 cycles | ~3-7 ns |
| L3 Cache | ~40-45 cycles | ~15 ns |
| 跨槽传输 | ~20 ns | |
| 内存 | ~120-240 cycles | ~60-120ns |
缓存一致性协议
从我们上面的描述中我们知道了现在CPU的结构基本入下图所示:
CPU结构
高速缓存很好地解决了处理器与内存的速度矛盾,但是带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为了解决缓存一致性问题,需要各个处理器访问缓存的时候都要遵循一些协议,这些协议有很多种,最著名的就是MESI(Modified Exclusive Shared Or Invalid)。定义了缓存行(Cache Line)的四种状态,而CPU对缓存行的操作可能会产生不一致的状态,因此缓存控制器监听到本地操作和远程操作的时候,需要对同一地址的缓存行的状态进行一致性修改,从而保证数据在多个缓存之间保持一致性。
每个Cache line有4个状态,可用2个bit表示,它们分别是:
| 状态 | 描述 | 监听任务 |
|---|---|---|
| M修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
| E独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
| S共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
| I无效 (Invalid) | 该Cache line无效。 | 无 |
缓存行
缓存锁会“锁定”共享对象,如果仅锁定所用对象,那么有大有小、随用随取,对于CPU来说利用率还达不到最大化。根据计算机的“局部性原理”,当访问某个数据时候大概率会访问相邻的数据(比如数组的遍历、Java中各种集合类型),所以为了执行效率的最大化,一次会获取一整块的内存数据放入缓存。那么这一块数据,通常称为缓存行(Cache Line)。缓存行是CPU缓存中可分配、操作的最小存储单元。与CPU架构有关,通常有32~256字节不等。目前64位架构下,64字节最为常用。
缓存伪共享
由于缓存行的存在当你读取一个特定的内存地址,整个缓存行将从主存读入缓存。一个缓存行可以存储多个变量,而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果多个CPU需要修改位于“同一个缓存行的变量”,就会无意中影响彼此的性能(因为缓存行会因为修改而失效造成Cache Miss),这就是伪共享(False Sharing)。
我们可以使用数据填充的方式来避免缓存伪共享的问题,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。
public class CacheLine {
public static final long COUNT = 1_0000_0000;
public static Tmp[] arr = new Tmp[2];
static {
arr[0] = new Tmp();
arr[1] = new Tmp();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
arr[1].x = i;
}
});
long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("耗时:" + (System.currentTimeMillis() - start));
}
private static class Tmp {
//public long p1,p2,p3,p4,p5,p6,p7;
public volatile long x;
//public long q1,q2,q3,q4,q5,q6,q7;
}
}
---------------------------------------------------------------
这段程序运行后大概输出:耗时:3726
但当我们打开Tmp类的两个注释后,耗时:640
这段程序运行后耗时输出为:耗时:3726。但当我们打开Tmp类的两个注释后耗时输出为:耗时:640。这也证明了缓存行的存在。
著名的高性能队列Disruptor也是使用了类似的数据填充技术来实现高性能并发,点击可以看到它源码的实现。在Java8中已经提供了官方的解决方案,通过@sun.misc.Contended注解可以实现同样的效果,但是需要在JVM加上启动参数:-XX:-RestrictContended才会生效。
JMM
Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台上能达到一致的内存访问效果。C、C++等语言是直接使用物理硬件和操作系统的内存模型,有可能导致程序在一套平台上并发完全正常,而在另一套平台上却出错,因此需要针对不同平台来编程。
JMM
Java定义的内存模型与CPU的缓存模型非常相似。JMM规定了所有变量(与Java编程中所说的变量有区别,包括实例对象、静态字段,不包括局部变量、方法参数)的存储都在主内存中(可以与前面的主内存类比)。每条线程还有自己的工作内存(可以与前面的高速缓存类比),工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行。不同线程无法直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成。
线程安全性
从上面的Java内存模型可以看出当多线程对同一个数据做操作时候就有可能存在问题。比如对一个变量i=1,线程A在自己的工作内存中将其变为2,而从工作内存同步至主内存并不一定是即时发生的,那么另一个线程B在线程A将变更写入到主内存之前看到的还是1,在使用的时候就可能出错,这就是线程安全性问题。
在并发编程中我们就需要围绕着三个线程安全性问题来进行:原子性,可见性,有序性。
原子性
原子性即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。可以类比数据库的事务,要么都成功要么都回滚。Java中提供的各种锁可以提供原子性的支持,如synchronized关键字。
可见性
可见性是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,依赖主内存作为传递媒介的方法来实现可见性。无论是普通变量还是volatile变量都是如此,volatile通过特殊规则保证了新值能立即同步到主内存,以及每使用前立即从内存刷新。
除了volatile之外,synchronized关键字同样可以保证可见性。
有序性
有序性即程序执行的顺序按照代码的先后顺序执行。但是为了程序运行的性能,在满足as-if-serial语义前提下,允许编译器、处理器对指令进行“指令重排序”来达到性能最大化。
as-if-serial语义:无论如何重排序,执行结果要与在单线程状态下一致。
Java提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
public class ReOrdering {
private static int a , b;
private static int x, y;
public static void main(String[] args) throws InterruptedException {
for (int i =0; i < Integer.MAX_VALUE; i++) {
a = 0; b = 0;
x = 0; y = 0;
CountDownLatch cdl = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
try {
cdl.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
try {
cdl.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
});
t1.start();
t2.start();
cdl.countDown();
// 原文中用CountDownLatch实现,感觉join更简洁些,
t1.join();
t2.join();
if (i % 10000 == 0) {
System.out.println("time:" + i + ",x=" + x + ",y=" + y);
}
// 这个是不应该有的情况
if (x == 0 && y == 0) {
System.out.println("reordering time:" + i + ",x=" + x + ",y=" + y);
break;
}
}
}
}
---------------------------------------------------------------
time:0,x=0,y=1
time:10000,x=0,y=1
time:20000,x=0,y=1
reordering time:27409,x=0,y=0
上面这段代码可以验证“指令重排序”的存在,理论上是不应该出现x=0&&y=0的情况。著名的DCL(Double Check Lock)问题,也是因为指令重排序才会出现,在jdk1.5之后可以通过对变量增加volatile来解决,不过DCL问题也是理论上存在很难通过程序模拟出来。
总的说Java内存模型相当于CPU缓存模型的一种抽象吧,屏蔽了各种平台的差异,引入缓存提升了性能但也带来了复杂性。这篇写的偏理论,但是只有明白了底层模型的实现,再遇到问题才能很快的明白为什么,也能更好的支撑我们写出比较好的多线程程序。
深入理解Java虚拟机--周志明
高性能队列——Disruptor
每秒钟承载600万订单级别的无锁并行计算框架-Disruptor
第八章JMM和底层实现原理笔记
CPU缓存一致性协议MESI