我们知道,线程执行是要占用 CPU 的,CPU 是从寄存器里取数据的,寄存器里没有数据的话,就要从内存中取,而众所周知这两者的速度差异极大,可谓是一个天上一个地上,所以为了缓解这种矛盾,CPU 内置了三级缓存,每次线程执行需要数据时,就会把堆内存的数据以 cacheline(一般是 64 Byte) 的形式先加载到 CPU 的三级缓存中来,这样之后取数据就可以直接从缓存中取从而极大地提升了 CPU 的执行效率(如下图示)
但是这样的话由于线程加载执行完数据后数据往往会缓存在 CPU 的寄存器中而不会马上刷新到内存中,从而导致其他线程执行如果需要堆内存中共享数据的话取到的就不会是最新数据了,从而导致数据的不一致
举个例子,以执行以下代码为例
//线程1执行的代码 int i = 0; i = 10;
//线程2执行的代码 j = i;
在线程 1 执行完后 i 的值为 10,然后 2 开始执行,此时 j 的值很可能还是 0,因为线程 1 执行时,会先把 i = 0 的值从内存中加载到 CPU 缓存中,然后给 i 赋值 10,此时的 10 是更新在 CPU 缓存中的,而未刷新到内存中,当线程 2 开始执行时,首先会将 i 的值从内存中(其值为 0)加载到 CPU 中来,故其值依然为 0,而不是 10,这就是典型的由于 CPU 缓存而导致的数据不一致现象。
那么怎么解决可见性导致的数据不一致呢,其实只要让 CPU 修改共享变量时立即写回到内存中,同时通过总线协议(比如 MESI)通过其他 CPU 所读取的此数据所在 cacheline 无效以重新从内存中读取此值即可
有序性
除了可见性造成的数据不一致外,指令重排序也会造成数据不一致
int x = 1; ① boolean flag = true; ② int y = x + 1; ③
以上代码执行步骤可能很多人认为是按正常的 ①,②,③ 执行的,但实际上很可能编译器会将其调换一下位置,实际的执行顺序可能是 ①③②,或 ②①③,也就是说 ①③ 是紧邻的,为什么会这样呢,因为执行 1 后,CPU 会把 x = 1 从内存加载到寄存器中,如果此时直接调用 ③ 执行,那么 CPU 就可以直接读取 x 在寄存器中的值 1 进行计算,反之,如果先执行了语句 ②,那么有可能 x 在寄存器中的值被覆盖掉从而导致执行 ③ 后又要重新从内存中加载 x 的值,有人可能会说这样的指令重排序貌似也没有多大问题呀,那么考虑如下代码
publicclassReordering{
privatestaticboolean flag; privatestaticint num;
publicstaticvoidmain(String[] args){ Thread t1 = new Thread(new Runnable() { @Override publicvoidrun(){ while (!flag) { Thread.yield(); }
System.out.println(num); } }, "t1"); t1.start(); num = 5; ① flag = true; ② } }
以上代码最终输出的值正常情况下是 5,但如果上述 ① ,② 两行指令发生重排序,那么结果是有可能为 0 的,从而导致我们观察到的数据不一致的现象发生,所以显然解决方案是避免指令重排序的发生,也就是保证指令按我们看到的代码的顺序有序执行,也就是我们常说的有序性,一般是通过在指令之间添加内存屏障来避免指令的重排序
V put(K key, int hash, V value, boolean onlyIfAbsent){ lock(); ... tab[index] = new HashEntry<K,V>(...); ... unlock(); }
然后是通过以下方式来根据 key 来读取 value 的
V get(Object key, int hash){ if (count != 0) { // read-volatile HashEntry<K,V> e = getFirst(hash); while (e != null) { if (e.hash == hash && key.equals(e.key)) { V v = e.value; if (v != null) return v; return readValueUnderLock(e); // recheck } e = e.next; } } returnnull; }
可以看到 put 时是直接给数组中的元素赋值的,而由于 get 没有加锁,所以无法保证线程 A put 的新元素对执行 get 的线程可见。
put 是有加锁的,所以其实如果 get 也加锁的话,那么毫无疑问 get 是可以立即拿到 put 的值的。为什么加锁也可以呢,其实这是 JLS(Java Language Specification Java 语言规范) 规定的几种情况,简单地说就是支持 happens before 语义的可以保证数据的强一致性,在官网(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)中列出了几种支持 Happens before 的情况,其中指出使用 volatile,synchronize,lock 是可以确保 happens before 语义的,也就是说使用这三者可以保证数据的强一致性,可能有人就问了,到底什么是 happens before 呢,其实本质是一种能确保线程及时刷新数据到内存,另一线程能实时从内存读取最新数据以保证数据在线程之间保持一致性的一种机制,我们以 lock 为例来简单解释下
publicclassLockDemo{ privateint x = 0;
privatevoidtest(){ lock(); x++; unlock(); } }
如果线程 1 执行 test,由于拿到了锁,所以首先会把数据(此例中为 x = 0)从内存中加载到 CPU 中执行,执行 x++ 后,x 在 CPU 中的值变为 1,然后解锁,解锁时会把 x = 1 的值立即刷新到内存中,这样下一个线程再执行 test 方法时再次获取相同的锁时又从内存中获取 x 的最新值(即 1),这就是我们通常说的对一个锁的解锁, happens-before 于随后对这个锁的加锁,可以看到,通过这种方式可以保证数据的一致性
// 写 final V putVal(K key, V value, boolean onlyIfAbsent){ ... for (Node<K,V>[] tab = table;;) { if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; } } ... }
staticfinal <K,V> booleancasTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v){ return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); }