1 学习内容
- 初识volatile关键字
- 机器硬件CPU
- Java内存模型
- CPU缓存一致性问题
2 具体内容
2.1 初识volatile关键字
启动两个线程,一个线程负责对变量进行修改,一个变量负责对变量输出,代码示例如下:
/**
* 开启两个线程,一个进行读操作,一个线程负责写数据
* @author kangna
*
*/
public class VolatilFoo {
// init_value 最大值
final static int Max = 5;
//init_value 初始值
static int init_value = 0;
public static void main(String args[]){
/**
* 启动一个Reader线程,当发现init_value 和 local_value 不同时,则输出 init_value 被修改的信息
*/
new Thread(()->
{
int localValue = init_value;
while(localValue < Max) {
if(init_value != localValue){
System.out.printf("the init_value is update to [%d] \n", init_value);
localValue = init_value;
}
}
}, "Reader").start();
/**
* 启动一个Update线程,用于对init_value的修改,当local_value>=5时退出生命周期
*/
new Thread(()->
{
int localValue = init_value;
while(localValue < Max){
System.out.printf("the init_value will be change to [%d] \n", ++localValue);
init_value = localValue;
// 短暂
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "update").start();
}
}
运行结果:
从控制台的输出我们可以看出,Reader线程根本没有感知到init_value的变化,而进入了死循环,我使用volatile关键字,然后看运行结果
static volatile int init_value = 0;
输出结果:
这下好了,看来是volatile关键字发挥了作用。
2.2 机器硬件CPU
在计算机中所有的运算操作都是由CPU的寄存器完成的,CPU指令的执行需要涉及数据的读和写操作,CPU所能访问的所有数据只能是计算机的主存(通常指RAM),虽然CPU的发展频率不断地上升,但受制于制作工艺以及成本的限制,计算机内存访问速度上并没有多大的突破,因此CPU的处理速度和内存的访问速度之间的差距是越来越大,几何级别的差距。
2.2.1 CPU Cache模型
由于两边速度的不对等,通过传统的FSB(前端总线)直连内存的访问方式很明显会导致内存资源受限,降低CPU整体的吞吐量,于是就有了在内存和CPU之间架设缓存的设计,现在缓存数量增加到了3级,最靠近CPU的缓存为L1,然后依次是L2,L3和主内存。由于程序指令和程序数据的行为和热点分布差异很大,因此L1 Cache又划分成L1i(i为instruction)和L1d(d是data的首字母)这两种有各专门用途的缓存,CPU Cache又是由很多个Cache Line 构成的,Cache Line可以认为是CPU Cache最小缓存单位,目前主流CPU Cache的Cache Line大小都是64字节。
- Cache的出现是为了解决CPU直接访问内存效率低下的问题,程序在运行的过程中, 会将运算所需要的数据从主存复制一份到CPU Cache中,这样CPU对数据的操作可以直接对CPU Cache中的数据进行读和写,当运算结束后,再将CPU Cache中的最新数据刷新到主内存中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大的提高了CPU的吞吐能力,有了CPU Cache之后,整体的CPU和主内存之间交互的架构大致如图。
2.2.2 CPU缓存一致性问题
缓存的出现极大地提高了CPU的吞吐能力,但是同时也引入了缓存不一致的问题,比如 i++这个操作,在程序运行过程中,首先需要将主内存中数据复制一份到CPU Cache中,那么CPU寄存器在进行数据计算的时候就直接到Cache中读取和写入,当整个过程结束后再将Cache中的数据刷新到主存中。
具体过程如下:
(1)读取主内存的 i 到CPU Cache中
(2)对 i 进行加 1 操作
(3)将结果写回到CPU Cache
(4)将数据刷新到主内存中
这个 i++操作在单线程下不会出现任何问题,但是在多线程情况下就会有问题,每个线程都有自己的工作内存(本地内存,对应于CPU中的Cache),变量 i 会在多个线程的本地内存中都有一个副本。如果同时有两个线程执行 i++ 操作,假设 i 的初始值初始值为 0 ,每一个线程都从主内存中取出 i 的值存入CPU Cache中,然后经过计算再写入主内存中,很有可能 i 在经过了两次自增运算后结果还是 1 ,这就是缓存不一致问题。
为了解决缓存不一致问题,通常有两种解决方式:
-
通过总线加锁的方式
-
通过缓存一致性协议
如果采用总线加锁的方式,则会阻塞其他CPU对其它组件的访问,从而使得只有一个CPU能够访问这个变量的内存,然而这种方式效率低下,于是产生了第二种方式通过缓存一致性协议的方式解决不一致问题,如图
在缓存一致性协议中出名的是MESI协议,MESI协议保证了每一个缓存中使用的共享变量都是一致的,大致思想是:当CPU在操作Cache中的数据时,如果发
现该变量是一个共享变量,也就是说在其他的CPU Cache中也存在一个副本,那么进行如下操作:
-
读取操作,不做任何处理,只是将Cache中的数据读取到寄存器
-
写入操作,发出信号通知其它CPU将该变量的Cache Line置为无效状态,其它
CPU在进行该变量读取的时候不得不到主内存中再次获取。
2.3 Java内存模型
Java内存模型(Java Memory Mode,JMM)指定了Java虚拟机如何与计算机的主内存(RAM)进行工作,Java内存模型决定了一个线程对共享变量的写入何时对其它线程可见,Java内存模型抽象了线程和主内存之间的抽象关系
- 共享变量存储于主内存中,每个线程都可以访问
- 每个线程都有自己的本地内存或者称为工作内存
- 工作内存中只能存储线程共享变量的副本
- 线程不能直接操作主内存,只有先操纵了工作内存之后才能写入内存
工作内存和Java内存模型一样也是一个抽象概念,它其实并不存在它涵盖了缓存、寄存器、编译器优化以及硬件等。
Java内存模型是一个抽象的概念,其与计算机硬件的结构并不完全一样。比如计算机物理内存不会存在栈内存和堆内存的划分,无论是堆内存还是栈内存都会对应到物理的主内存,当然也有一部分堆栈内存数据可能会存入CPU Cache寄存器中,下图为Java内存模型与CPU硬件架构交互图。
我们再来看一个图
Java内存模型规定了一个线程如何,及何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
堆:动态的分配内存大小,生存期也不必事先告诉编译器,运行时动态分配,Java的垃圾收集器会自动收集这些不再使用的数据,由于是动态分配所以他的存取速度有点慢。
栈:栈的存取速度比堆快,速度仅次于计算机中的寄存器。栈的数据共享,存放基本数据类型和对象句柄,存在于栈中数据的大小和生命周期是确定的,缺乏一定的灵活性。
Java内存要求调用栈和本地变量存放在线程栈上(Thread Stack),对象存在于堆上。一个本地变量也可能是一个指向本地的应用,应用的本地变量是存放于线程栈上的,对象存放于堆上。如果两个线程同时调用了同一个对象,那么这两个线程可以同时访问对象的成员变量(两个线程拥有对象成员变量的私有拷贝)。
当同一个数据被分别存储到计算机的各个内存区域时,势必会导致多个线程在各自的工作内存看到的数据有可能是不一样,在Java语言中如何保证不同线程对某个共享变量的可见性?请听下回详解。
3总结
- 引入了volatile关键字
- 介绍了CPU模型
- CPU缓存一致性解决方案
- Java内存模型