为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
缓存一致性问题:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如MESI协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
实际上为了提高性能,编译器和处理器在运行时都会对指令做重排序。可以分为以下三类:
指令重排序可以保证串行(单线程)语义一致,但是没有义务保证多线程间的语义也一致?,所以在多线程下,指令重排序可能会导致一些问题。
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
JMM提供了一种强大的抽象,使得程序员可以更方便地编写多线程程序而不用过于担心底层的硬件和操作系统的细节。通过使用合适的同步机制,如synchronized关键字、volatile关键字、Locks等,程序员可以确保在多线程环境下的线程安全性和正确的数据共享。
线程间通信必须要经过主内存。
如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种原子同步操作(了解即可):
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可):
Java 内存区域和内存模型是完全不一样的两个东西:
可见性(共享对象可见性):线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。
如果两个或者更多的线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的:共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,然后修改了这个对象。只要CPU缓存没有被刷新回主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。
下图示意了这种情形。跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去。
解决这个内存可见性问题你可以使用:
多线程竞争(Race Conditions)问题:当读,写和检查共享变量时出现race conditions。
如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生race conditions。
想象一下,如果线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增加了两次,每个CPU缓存中一次。如果这些增加操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中去,修改后的值仅会被原值大1,尽管增加了两次:
解决这个问题可以使用Java同步块。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。
原子性:指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。
实现原子性:
补充:long和double的非原子性协定
Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(double、long)定义了相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次的32位操作来进行,即允许虚拟机可以不保证64位数据类型的load、store、read和write操作的原子性。
非原子性协定可能导致的问题
如果有多个线程共享一个未申明为volatile的long或double类型的变量,并且同时对其进行读取和修改操作,就有可能会有线程读取到"半个变量"的数值或者是一半正确一半错误的失效数据。
在实际应用中的解决
因为上述可能造成的问题,势必在对long和double类型变量操作时要加上volatile关键字。volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。
实际上如下:
所以我们编写代码时一般不需要将long和double变量专门申明为volatile
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。
一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:
是实际上优化后的执行路径却是这样的:
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常,并且线程B无需获取锁。
happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可,但是JMM对编译器和处理器限制的越死,程序执行的效率就越低。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。
对于这两个矛盾的需求,JMM就需要找到两者之间的平衡。所以happens-before 原则的设计思想其实非常简单:
JMM禁止了会影响程序执行的重排序,同时放开了对不影响程序执行结果的重排序的限制。也就是说,只要不影响程序运行的结果,编译器和处理器想怎么优化都可以。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
happens-before的常见规则
happens-before 的规则有?8 条,下面给出常见的5条:
JMM 抽象了 happens-before 原则 来解决 指令重排序问题。
我们看下面这段代码:
int userNum = getUserNum(); // 1
int teacherNum = getTeacherNum(); // 2
int totalNum = userNum + teacherNum; // 3
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。注意,这是?JMM向程序员做出的保证。
举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。关系如下图所示:
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性:
综上所述,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。
合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,这里你应该也明了了。所谓“按需禁用”只需要提供给程序员按需禁用缓存和编译优化的方法即可。这就是Java内存模型的功劳了。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。解决了并发编程中的可见性、原子性以及有序性问题。
参考来源:
JMM(Java 内存模型)详解 | JavaGuide(Java面试 + 学习指南)