深刻浅出计算机组成原理学习笔记:第三十八讲

在我工做的十几年里,写了不少Java的程序。同时,我也面试过大量的Java工程师。对于一些表示本身深刻了解和擅长多线程的同窗,
我常常会问这样一个面试题:“ volatile这个关键字有什么做用?”若是你或者你的朋友写过Java程序,不妨来一块儿试着回答一下这个问题。java

就我面试过的工程师而言,即便是工做了多年的Java工程师,也不多有人能准确说出volatile这个关键字的含义。这里面最多见的理解错误有两个,
一个是把volatile当成一种锁机制,认为给变量加上了volatile,就好像是给函数加了sychronized关键字同样,不一样的线程对于特定变量的访问会去加锁;
另外一个是把volatile当成一种原子化的操做机制,认为加了volatile以后,对于一个变量的自增的操做就会变成原子性的了。面试

// 一种错误的理解,是把 volatile 关键词,当成是一个锁,能够把 long/double 这样的数的操做自动加锁
private volatile long synchronizedValue = 0;

// 另外一种错误的理解,是把 volatile 关键词,当成可让整数自增的操做也变成原子性的
private volatile int atomicInt = 0;
amoticInt++;

事实上,这两种理解都是彻底错误的。不少工程师容易把volatile关键字,当成和锁或者数据数据原子性相关的知识点。而实际上,
volatile关键字的最核心知识点,要关系到Java内存模型(JMM,Java MemoryModel)上。

虽然JMM只是Java虚拟机这个进程级虚拟机里的一个内存模型,可是这个内存模型,和计算机组成里的CPU、高速缓存和主内存组合在一块儿的硬件体系很是类似。
理解了JMM,可让你很容易理解计算机组成里CPU、高速缓存和主内存之间的关系。

缓存

1、“隐身”的变量  

一、程序做了什么?

咱们先来一块儿看一段Java程序。这是一段经典的volatile代码,来自知名的Java开发者网站dzone.com,后续咱们会修改这段代码来进行各类小实验。多线程

public class VolatileTest {
    private static volatile int COUNTER = 0;

    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }

    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }

    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}

首先

 

而后

最后

Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5

由于全部数据的读和写都来自主内存。那么天然地,咱们的ChangeMaker和ChangeListener之间,看到的COUNTER值就是同样的。ide

二、volatile关键字给去掉,会发生什么事情呢?

private static int COUNTER = 0;

没错,你会发现,咱们的ChangeMaker仍是能正常工做的,每隔500ms仍然可以对COUNTER自增1。可是,奇怪的事情在ChangeListener上发生了,函数

咱们的ChangeListener再也不工做了。在ChangeListener眼里,它彷佛一直以为COUNTER的值仍是一开始的0。彷佛COUNTER的变化,对于咱们的ChangeListener完全“隐身”了。性能

Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5

咱们去掉了volatile关键字。这个时候,ChangeListener又是一个忙等待的循环,它尝试不停地获取COUNTER的值,这样就会从当前线程的“Cache”里面获取。
因而,这个线程就没有时间从主内存里面同步更新后的COUNTER值。这样,它就一直卡死在COUNTER=0的死循环上了。网站

三、再也不让ChangeListener进行彻底的忙等待,而是在while循环里面,小小地等待上5毫秒,看看会发生什么状况?
那volatile关键字究竟表明什么含义呢?

咱们能够再对程序作一些小小的修改。咱们再也不让ChangeListener进行彻底的忙等待,而是在while循环里面,小小地等待上5毫秒,看看会发生什么状况。atom

static class ChangeListener extends Thread {
    @Override
    public void run() {
        int threadValue = COUNTER;
        while ( threadValue < 5){
            if( threadValue!= COUNTER){
                System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                threadValue= COUNTER;
            }
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) { e.printStackTrace(); }
        }
    }
}

好了,不知道你有没有本身动手试一试呢?又一个使人惊奇的现象要发生了。虽然咱们的COUNTER变量,仍然没有设置volatile这个关键字,可是咱们的ChangeListener彷佛“睡醒了”。
在经过Thread.sleep(5)在每一个循环里“睡上“5毫秒以后,ChangeListener又可以正常取到COUNTER的值了。spa

Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5

虽然仍是没有使用volatile关键字,可是短短5ms的Thead.Sleep给了这个线程喘息之机。既然这个线程没有这么忙了,
它也就有机会把最新的数据从主内存同步到本身的高速缓存里面了。因而,ChangeListener在下一次查看COUNTER值的时候,就能看到ChangeMaker形成的变化了。

四、那volatile关键字究竟表明什么含义?

这些有意思的现象,其实来自于咱们的Java内存模型以及关键字volatile的含义。 那volatile关键字究竟表明什么含义呢?它会确保咱们对于这个变量的读取和写入,

都必定会同步到主内存里,而不是从Cache里面读取。该怎么理解这个解释呢?咱们经过刚才的例子来进行分析。

虽然Java内存模型是一个隔离了硬件实现的虚拟机内的抽象模型,可是它给了咱们一个很好的“缓存同步”问题的示例。也就是说,若是咱们的数据,在不一样的线程或者CPU核里面去更新,

由于不一样的线程或CPU核有着本身各自的缓存,颇有可能在A线程的更新,到B线程里面是看不见的。

2、CPU高速缓存的写入

一、Java内存模型和计算机组成里的CPU结构对照起来看

一、咱们如今用的Intel CPU,一般都是多核的的。每个CPU核里面,都有独立属于本身的L一、L2的Cache,而后再有多个CPU核共用的L3的Cache、主内存。

二、由于CPU Cache的访问速度要比主内存快不少,而在CPU Cache里面,L1/L2的Cache也要比L3的Cache快。

三、因此,上一讲咱们能够看到,CPU始终都是尽量地从CPU Cache中去获取数据,而不是每一次都要从主内存里面去读取数据。

这个层级结构,就好像咱们在Java内存模型里面,每个线程都有属于本身的线程栈。线程在读取COUNTER的数据的时候,

实际上是从本地的线程栈的Cache副本里面读取数据,而不是从主内存里面读取数据。

若是咱们对于数据仅仅只是读,问题还不大。咱们在上一讲里,已经看到Cache Line的组成,以及如何从内存里面把对应的数据加载到Cache里。

二、写入策略:写直达

一、逻辑图

二、流程说明

最简单的一种写入策略,叫做写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,

一、写入前,咱们会先去判断数据是否已经在Cache里面了。若是数据已经在Cache里面了,咱们先把数据写入更新到Cache里面,再写入到主内存里面;

二、若是数据不在Cache里,咱们就只更新主内存。

三、存在的问题

写直达的这个策略很直观,可是问题也很明显,那就是这个策略很慢。不管数据是否是在Cache里面,咱们都须要把数据写到主内存里面。

这个方式就有点儿像咱们上面用volatile关键字,始终都要把数据同步到主内存里面。

三、写回

一、逻辑图

二、工做流程说明

这个时候,咱们就想了,既然咱们去读数据也是默认从Cache里面加载,可否不用把全部的写入都同步到主内存里呢?只写入CPU Cache里面是否是能够?

固然是能够的。在CPU Cache的写入策略里,还有一种策略就叫做写回(Write-Back)。这个策略里,咱们再也不是每次都把数据写入到主内存,而是只写到CPU Cache里。
只有当CPU Cache里面的数据要被“替换”的时候,咱们才把数据写入到主内存里面去。

写回策略的过程是这样的:

一、若是发现咱们要写入的数据,就在CPU Cache里面,那么咱们就只是更新CPU Cache里面的数据。同时,咱们会标记CPU Cache里的这个Block是脏(Dirty)的。

所谓脏的,就是指这个时候,咱们的CPU Cache里面的这个Block的数据,和主内存是不一致的。

二、若是咱们发现,咱们要写入的数据所对应的Cache Block里,放的是别的内存地址的数据,那么咱们就要看一看,那个Cache Block里面的数据有没有被标记成脏的。

三、若是是脏的话,咱们要先把这个Cache Block里面的数据,写入到主内存里面。

四、而后,再把当前要写入的数据,写入到Cache里,同时把Cache Block标记成脏的。

五、若是Block里面的数据没有被标记成脏的,那么咱们直接把数据写入到Cache里面,而后再把CacheBlock标记成脏的就行了。

在用了写回这个策略以后,咱们在加载内存数据到Cache里面的时候,也要多出一步同步脏Cache的动做。

六、若是加载内存里面的数据到Cache的时候,发现Cache Block里面有脏标记,咱们也要先把Cache Block里的数据写回到主内存,才能加载数据覆盖掉Cache。

三、存在问题

若是咱们大量的操做,都可以命中缓存。那么大部分时间里,咱们都不须要读写主内存,天然性能会比写直达的效果好不少

要解决这个问题,咱们须要引入一个新的方法,叫做MESI协议。这是一个维护缓存一致性协议。这个协议不只能够用在CPU Cache之间,也能够普遍用于各类须要使用缓存,

同时缓存之间须要同步的场景下。今天的内容差很少了,咱们放在下一讲,仔细讲解缓存一致性问题。

3、总结延伸

 

最后,咱们一块儿来回顾一下这一讲的知识点。经过一个使用Java程序中使用volatile关键字程序,咱们能够看到,在有缓存的状况下会遇到一致性问题。
volatile这个关键字能够保障咱们对于数据的读写都会到达主内存。

进一步地,咱们能够看到,Java内存模型和CPU、CPU Cache以及主内存的组织结构很是类似。在CPUCache里,对于数据的写入,

咱们也有写直达和写回这两种解决方案。写直达把全部的数据都直接写入到主内存里面,简单直观,可是性能就会受限于内存的访问速度。

而写回则一般只更新缓存,只有在须要把缓存里面的脏数据交换出去的时候,才把数据同步到主内存里。在缓存常常会命中的状况下,性能更好。

可是,除了采用读写都直接访问主内存的办法以外,如何解决缓存一致性的问题,咱们仍是没有解答。这个问题的解决方案,咱们放到下一讲来详细解说。

相关文章
相关标签/搜索