为啥多个线程操作一个变量没有用到缓存一致性

最近学了一个知识点:缓存一致性,然后就不理解为啥下面的代码没用到缓存一致性

public class threadtest {

    private static long a = 0l;
    private static long count = 1_000_000_000L;
    private static CountDownLatch fence = new CountDownLatch(2);

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for(int i = 0; i < count; i++) {
                a++;
            }
            fence.countDown();
        }).start();
        new Thread(() -> {
            for(int i = 0; i < count; i++) {
                a++;
            }
            fence.countDown();
        }).start();
        fence.await();
        System.out.println(a);
    }
}

回答

在计算机执行指令时,每条指令是在CPU中执行的,而指令执行过程中势必涉及到数据的读取,由于程序执行过程中所需的数据一般存放在主存中,但是从内存中读取数据和写入数据和CPU指令的执行速度相比较而言还是太慢了,因此就有了高速缓存。

在程序执行时,从主存拷贝一份数据到CPU的高速缓存,然后CPU就可以直接从高速缓存中读取和修改数据,当运算结束后再将高速缓存中的数据刷新到主存中

例如:

i = i+1

线程执行这条语句时会从主存中读取i的值然后复制到高速缓存中,然后CPU 指定对 i 进行 加1 操作,然后将结果写回高速缓存中,最后再将高速缓存的内容更新到主存中。

当单线程执行这条语句时没有任何问题,但是多线程就不行了,在多核CPU中每条线程可能运行在不同CPU之间,因此每个线程都有自己的高速缓存,在同时进行加1 操作时可能同时读取到的 i 的值都是原始值,导致最终结果只加了 1而不是加2 这就是缓存一致性问题。通常称这种被多个线程访问的变量共享变量

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1. 通过在总线加LOCK#锁的方式

    早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题

  2. 通过缓存一致性协议

    由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

为啥多个线程操作一个变量没有用到缓存一致性

java内存模型

虽然java程序所有的运行都是在虚拟机中,涉及到的内存等信息都是虚拟机的一部分,但实际也是物理机的,只不过是虚拟机作为最外层的容器统一做了处理。虚拟机的内存模型,以及多线程的场景下与物理机的情况是很相似的,可以类比参考。 Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。需要注意的是这里的变量跟我们写java程序中的变量不是完全等同的。这里的变量是指实例字段,静态字段,构成数组对象的元素,但是不包括局部变量和方法参数(因为这是线程私有的)。这里可以简单的认为主内存是java虚拟机内存区域中的堆,局部变量和方法参数是在虚拟机栈中定义的。但是在堆中的变量如果在多线程中都使用,就涉及到了堆和不同虚拟机栈中变量的值的一致性问题了。 Java内存模型中涉及到的概念有:

  • 主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。

  • 工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。可以与前面说的高速缓存相比。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。这里需要说明一下:主内存、工作内存与java内存区域中的java堆、虚拟机栈、方法区并不是一个层次的内存划分。这两者是基本上是没有关系的,上文只是为了便于理解,做的类比

    Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存

  • 并发三大问题

    在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题

  • 下面看一下Java 语言本身对 原子性提供了怎样的支持呢

    1.原子性

    ​ Java 中对基本数据类型的读取和赋值操作都是原子性操作,即这些操作时不可中断,要么执行,要么不执行

    int i = 10; //原子操作
    int j = i;	//非原子操作,该语句执行过程 1.读取 i 的值 2.将读取的值赋值给j;
    i++;		//非原子操作 过程:读取x的值,进行加1操作,写入新的值。
    x = x + 1	//非原子操作 过程:读取x的值,进行加1操作,写入新的值。

  • 可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

综上所述

缓存一致性一直是有效的只是因为java 的内存模型以及对原子性的支持,导致在多线程操作时会发生线程安全问题