系列的上一篇是按照内存的布局来将内存模型,而这一篇是按照实际程序中对于内存的读写来讲内存模型。

缓存一致性

影响处理器性能重要因素除了CPU的计算能力,另外一个因素就是 IO 操作。CPU 要从内存中读取数据进行计算,然后把运算结果存储到内存中。

但是内存的读取速度与CPU的处理能力简直不能比,差好几个数量级,所以人们又想出了办法:缓存。用它来作为存储器和CPU的过渡。由于高速缓存的读写速度与CPU的处理速度差不多,CPU读取数据时从主存读到缓存中,然后再通过缓存读取到CPU中,写入数据也一样,需要先写入缓存中,然后再从缓存更新到主存中。这样就解决了CPU与存储器之间的矛盾。

但是另一个问题出现了:缓存一致性

什么是缓存一致性问题?

在多核处理器的计算机系统中,由于每一个CPU都有自己的缓存,而它们又共享一个主内存,当多个处理器的运算任务涉及到同一块主存区域内时,将可能导致各自的缓存数据不一样,那么将来同步到主内存时就出现了问题,该以哪个缓存为准呢?

大神们早就设计好了相应的读写协议,协议种类繁多,此处就不多讲了。

Java内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

Java内存模型规定了所有变量都必须存储在主内存中。每条线程还可以有自己的工作内存。工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对于变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,不同线程之间也无法访问对方工作内存中的变量,线程之间变量的值的传递都是需要通过主内存来完成。

它们的关系如图:

default

主内存和工作内存之间的交互

Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时,必须保证下面的每一种操作都是原子的。

1.lock

作用于主内存变量,将该变量标识为一条线程独占的状态。

2.unlock

作用于主内存变量,与 1 正好相反。

3.read

作用于主内存的变量,从主内存中传输到线程的工作内存中,以便随后的load 操作。

4.load

作用于工作内存的变量,他把read操作从主内存中得到的变量值放入工作内存的变量副本中。

5.use

作用于工作内存的变量,它把工作内存中的一个变量的值传递到执行引擎。

6.assign

作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。

7.store

作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write使用。

8.write

作用于主内存变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

Volatile

当一个变量定义为 volatile 之后,它将具备两种特性:

1.可见性

变量对所有线程的可见性,即当一条线程修改了此变量的值后其他线程可以立即得知(普通变量对于其他线程只能等到修改线程存到主存中后,从主存中读取)。

需要注意的是volatile虽然有此特性,但是并不是线程安全的。看下面一段代码

package com.study;

import java.util.concurrent.atomic.AtomicInteger;

public class VolitileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    public static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        AtomicInteger a ;
        System.out.println(race);
    }
}

结果并不是:200000.

每次运行这个程序虽然结果不一样,但是都会小于期望的数字。为什么呢?

问题出在 increase()函数中的race++这句指令。

当执行这句指令时,机器必须先将这个变量从主内存中取出来,然后加一操作,最后再写回主存中。

如果一个线程取出这个变量执行加一操作时比如将race加到10001,其他线程可能已经把变量加到10005了,但是本线程在最后一步时,将数字10001同步到主内存中,于是造成了结果比正确的小。

2.禁止指令重排

在程序编译时,编译器在优化代码时,可能会将代码的顺序打乱,会干扰并发的正确执行。比如

boolean initialized = true;

// Some Operations
//假设以下代码在线程A中执行
....

intialized = false;

//假设以下代码在线程B中执行
while(initialized) {
    ....
}

该程序的意思是:先初始化变量为true,在执行完一段代码后,设置为false。但是如果重排后,会导致设为false的代码先执行,于是干扰到了线程B的操作。

如果在第一行代码前加一个修饰符 volatile, 则不会产生重排现象。

原因是什么呢?

反编译后,会发现,在有volatile修饰的变量赋值后,有一行“lock addl $ 0x0, (%esp)”操作。而这行操作的效果在于产生了一个内存屏障(Memory Barrier),防止把该屏障之后的指令挪到此指令之前执行。

这里所说的指令重排序不是任意重排,而是将不依赖的两条指令重排。

比如

int a = 1; int b = 2*a; int c = 2;

第一条和第二条因为有相互依赖的关系,所以不能重排,但是第三条指令很可能在重排后就在第一条指令前面执行了。

而lock addl $ 0x0, (%esp)指令把修改同步到内存时,意味着之前所有的操作都已经执行完毕,是依赖于前面的操作的,所以指令重排序无法越过内存屏障。

volatile变量的读操作性能与普通变量几乎没什么差别,但是写操作会慢一点,因为它需要再本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低。

Java 内存模型的特征

原子性(Atomicity):由 Java 内存模型来直接保证的原子性变量操作包括 read,load,assign,use,store,和write。

可见性(Visibility):当一个线程修改了一个共享变量的值,其他线程能够立即得知这个修改。

Java 内存模型是通过在变量修改后将新值同步回主存,在变量读取前从主存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,无论是 volatile 变量还是普通变量都是如此。

普通变量与volatile 变量的区别就是,volatile的特殊规则保证了新值能立即同步到主内存,每次使用前能立即从主内存刷新,普通变量做不到这点。

有序性(Ordering):volatile 关键字本身包含了禁止指令重排序的语义。