一、多核并发缓存架构
在计算机里面有多个cpu和主内存,早期的计算机只有主内存和cpu。cpu要读取数据,而数据一般在硬盘上的,一开始是先把数据读取到主内存,然后再cpu和主内存进行交互,去拿些数据,或者说再和这些数据做些运算。早期的计算机是cpu和主内存直接打交道的。这么多年的发展,cpu的计算速度是非常快的。在摩尔定律里面。cpu每隔18个月左右,它的运算速度会提升很多,但是主内存的读取数据的速度,和存储数据的速度并不大。随着cpu的高速提升,然后cpu和主内存的数据的交换肯定有性能瓶颈的,一直会卡在主内存,为了解决这个问题,然后再他俩之间引入了cpu缓存,cpu寄存器也可以看成是cpu缓存。cpu缓存的速度和cpu的速度是很接近的。读取数据,就是和cpu缓存频繁打交道的。
一、java线程内存模型
1、概念:
Java线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽了底层不同计算机的区别,严格的讲java内存模型是Java线程内存模型
比如在上面的图中,多个线程同时运行程序,如果是多核cpu的话,可能是一个线程利用一个一核cpu,比如一些共享变量是存储到主内存里面的。而线程不会频繁的和主线程做交互,而是把主内存里面的共享变量复制一份到工作内存里面。所以线程运行程序的时候是和工作内存频繁的在做交互,这样性能会很高,同时线程B,C也是这样。
举个例子,代码如下:
public class VolatileVisibility {
//共享变量
private static boolean initFlag=false;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
System.out.println("waiting data.....");
while(!initFlag){
}
System.out.println("-------------------------success");
}
}).start();
Thread.sleep(2000);
new Thread(new Runnable() {
public void run() {
prepareData();
}
}).start();
}
public static void prepareData(){
System.out.println("prepareing data.....");
initFlag=true;
System.out.println("prepare data end.....");
}
}
运行的结果如下:第一个线程没有结束
解释:上面的initFlag:也就是上面图中的共享变量。之前是等于false。这两个线程是会同时进行操作这个变量,把这个共享变量分别加载到自己的工作内存从而操作,所以一开始在两个线程的工作内存上都为false,第二个线程把initFlag改为true了。而第一个线程是没有感知的,因为改的是第二个变量副本和主内存中的共享变量,第一个线程没有改自己的变量副本的。
如何去修改上面的死循环呢?
在共享变量上加上一个volatile关键字就可以了:保证多线程在操作共性变量的可见性。这样答问题的话是太入门了,下面会说底层的原理,怎么达到可见性的。
想要把volatile搞明白的话,必须还要把java内存模型搞明白:
二、java内存模型:
java内存模型除了上面的图的以后还定义了一些原子操作,程序的运行是按照上面的图运行走的。
1、java中的线程原子操作:
read:就是把主内存的共享变量读取出来,也就是对应下面的操作
use:线程1操作做的就是取反操作
两个线程,线程1等待数据的操作,线程2准备数据的操作:
1、首先主内存的值为false,随着程序的运行,第一个线程开始执行的时候把主内存的变量加载到工作内存的里面:会经历read操作,把主内存的数据给 读取出来。
2、load把数据变量读取到工作内存里面。
3、use:做的是取反操作
上面的就是线程1的操作。
第二个线程就是把主内存的数据读取后来然后再load下,这时把变量放到工作内存里面取了。
上面的图就是解释没有加volatile关键字的程序之前的效果。
而加了volatile关键字之后早期底层实现:
加了volatile关键字之后,两个线程之间的工作内存的副本变量就是可见的,就是达到同步数据的效果,早期的硬件级别使用的是总线加锁的效果。
总线:学过计算机组成原理的都听说过总线的,比如说主内存和工作内存是通过总线来操作的,cpu和主内存交互,在物理硬件中是cpu在一个地方,主内存在另一个地方。两个是通过一排一排的线连接的。就可以把这个线当作成总线。数据的传输是通过总线来传输的。早期的volatile也解决了共享变量一致性的问题,也就是没有加锁之前这两个线程是并行在执行的。
加锁之后就把并行的操作变成串行的操作了。
A、总线加锁(性能太低)
cpu从主主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu没法去读或写这个数据,直到这个cpu使用完成数据释放锁之后其他cpu才能读取该数据。
下面的图就是早期的volatile关键字的底层情况,底层实现并不一定是代码实现的,而是硬件实现的。
在上面的图中,早期的操作是在read的过程之前做一个lock的操作,其他的线程通过总线拿主内存中的数据的话,这时发现数据已经加上一把锁了,这时是拿不到数据的,这时的锁可能为写锁啊,读锁啊,暂时先不管,直到在线程2所有的操作执行完执行unlock操作释放锁之后,其他的线程才可以再来拿数据,而其他线程可能涉及到锁的争抢。而拿到值的线程最先肯定要加锁。最新拿到锁的线程就能够拿到新的值了,其他的线程就需要等待。这就可以解决早期的共享变量的一致性的问题。
没有加锁之前,两个线程是完全并行再执行的。但是加完锁之后,可能一开始还是并行在执行的,但是这两个线程读取到同一个变量的时候,他们之间可能就争抢锁。争抢锁可能就需要排队。把完全并行的操作就变成串行的操作了。
这种早期总线加锁的效率降低。这种很多时候不是java代码实现的,而是硬件去实现的。
而现在java底层对volatile关键字是怎么实现的呢?
先说下MESI缓存一致性协议
volatile的底层实现就是借助于MESI缓存一致性协议实现的
B、MESI缓存一致性协议:
多个cpu从主内存读取同一个数据到各自的告诉缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己的缓存里的数据失效。
volatile底层的实现大概就是这么操作。
在多线程执行的时候会把共享变量放入到自己的工作内存,当其中的某个cpu修改了缓存中的副本变量的值,如果线程2把这个值给修改了,这个线程只要把这个值同步到主内存的话,其实store的操作就是把这个值写入到主内存了,然后通过write操作写入到主内存的对象里面去。当cpu启动MESI缓存一致性协议被启动之后,当线程2通过store的操作的时候,通过总线的一刹那。然后线程1的启动cpu总线嗅探机制就会感知总线的关键字很敏感;initFlag,并且把自己本身的工作内存的原本的值给失效。然后线程1所在的cpu发现这个变量的地址已经失效了。然后线程1重新去主内存read操作和load操作,这时true取反跳出了while的循环,这就把可见性的问题给解决了。线程之间无法进行数据传输,必须通过主内存。这就是通过MESI缓存一致性协议来解决共享变量可见性的问题。
上面的就是volatile的底层的一个最简单的实现,volatile关键字借助了MESI缓存一致性协议实现了,其实还是借助更多的来实现的,比如加锁的实现:底层是用c语言来实现的。
下面提出两个问题:
1、如果两个子线程同时回写主内存的话,是个问题,
2、如果其中一个线程没有同步到主内存的时候,另一个线程的cpu总线嗅探机制已经监听到initFlag的值有变化之后,然后把自己的工作内存中的initFlag失效。这时线程1读的数据还是false,这又是一个问题。所以并发编程不是那么简单的
由下篇文章揭晓答案,明天继续去写深入汇编语言来分析volatile关键字。