CPU、内存、缓存的关系
要理解JMM,要先从计算机底层开始,下面是一份大佬的研究报告
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的!如果我们计算一次a+b所需要的的时间:
- CPU读取内存获得a,100纳秒
- CPU读取内存获得b,100纳秒
- CPU执行一条指令 a+b ,0.6纳秒
也就是说99%的时间花费在CPU读取内存上了,那如何解决速度不均衡问题?
早期计算机中cpu和内存的速度是差不多的,但在现代计算机中cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了
CPU缓存
什么是CPU缓存
在计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
下图是一个典型的存储器层次结构,我们可以看到一共使用了三级缓存:
为什么要有多级CPU Cache
在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集
下图是我电脑的三级缓存,可以看到层级越小容量越小。速度越快价格越高!!
在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3。
为了充分利用 CPU Cache,Java提出了内存模型这个概念
Java内存模型(Java Memory Model,JMM)
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的工作内存(Local Memory),工作内存中存储了该线程以读/写共享变量的副本。
举个栗子:多个线程去修改主内存中的变量a。线程不能直接修改主内存中的数据,先把数据拷贝到工作内存,线程对私有的工作内存修改然后再同步到主内存。那这样做会带来什么问题呢?
JMM导致的并发安全问题
从JMM角度看,如果两个线程同时调用 a=a+1这个函数(假设a的初始值是0),A、B线程同时从主内存中拷贝a=0,然后修改写回,最后主内存为a=1,咋搞?
如下是代码栗子
public class MainTest {
private long count = 0;
public void incCount() {
count += 1;
}
public static void main(String[] args) throws InterruptedException {
MainTest test = new MainTest();
Count count = new Count(test);
Count count1 = new Count(test);
count.start();
count1.start();
Thread.sleep(5);
System.out.println("result is :" + test.count);
}
private static class Count extends Thread{
private MainTest m;
public Count(MainTest m){
this.m = m;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
m.incCount();
}
}
}
}
执行结果
// 第一次执行
> Task :lib-test:MainTest.main()
result is :11861
// 第二次执行
> Task :lib-test:MainTest.main()
result is :10535
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量a,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 a 的操作对于线程 B 而言就不具备可见性了 。
要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁
原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
我们都知道CPU资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片段结束以后。
那么线程切换为什么会带来bug呢?因为操作系统做任务切换,可以发生在任何一条CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如count++,在java里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实count++包含了三个CPU指令
有序性
即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
在单线程的情况下,CPU执行语句并不是按照顺序来的,为了更高的执行效率可能会重新排序,单线程下是可以提高执行效率且保证正确。但在多线程下反而变成了安全问题,Java提供volatile来保证一定的有序性。此处不做深入!
volatile
volatile特性
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性
【面试题】为什么volatile不能保证a++的线程安全问题
:线程执行a++要经历读取主内存-加载-使用-赋值-写内存-写回主内存几个阶段,而且a++不是原子操作,至少可以分为三步执行。线程A、B同时从主内存读取a的值,A线程执行到加载阶段切换上下文交出CPU使用权,B线程完成整个操作并刷新了主内存中a的值。此时A线程继续赋值等其他操作,已经造成了安全问题。可见性是保证线程每次读取时必须读取主内存的值,对后续的操作没有限制,不会因为主内存中的值改变而中断了操作。如果是原子性则可以,synchronized可以保证原子性。
volatile 的实现原理
有volatile修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令
- 将当前处理器缓存的数据写回到系统内存
- 这个写回内存的操作会使其他CPU里缓存了该地址的数据无效
单例模式的双重锁为什么要加volatile
public class TestInstance{
private volatile static TestInstance instance;
public static TestInstance getInstance(){ //1
if(instance == null){ //2
synchronized(TestInstance.class){ //3
if(instance == null){ //4
instance = new TestInstance(); //5
}
}
}
return instance; //6
}
}
需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题。
instance = new TestInstance()可以分解为3行伪代码
a. memory = allocate() //分配内存
b. ctorInstanc(memory) //初始化对象
c. instance = memory //设置instance指向刚分配的地址
上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题。当线程A在执行第5行代码时,B线程进来执行到第2行代码。假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象
总结
因为CPU与内存的速度差距越来越大,为了弥补速度差距引入了CPU缓存,又因为缓存导致线程安全问题,从前到后缕出一条线来就很容易理解了。如果只是单线程完全不担心什么指令重排,想要更高的执行效率必然付出安全风险。知其然,知其所以然!