CPU 缓存模型
为什么要弄一个 CPU 高速缓存(CPU Cauche)呢?
类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚至可以把内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
高速缓冲存储器Cache是位于CPU与内存之间的临时存储器,它的容量比内存小但交换速度快。
CPU是计算机的大脑,是负责执行指令的;自身的频率和指令执行的速度非常快,一秒执行的指令大概10^9
级别的;内存的的速度要比CPU慢上好几个级别,每秒处理的速度大概是10^6的级别的。
如果CPU要频繁的访问主内存的话,每次都需要等待很长的时间,执行性能就会低,大部分时间都在等待主内存返回数据,没有发挥出CPU的性能。
总结:CPU Cache 缓存的是内存数据,用于解决 CPU处理速度与内存读写速度不匹配的矛盾;内存缓存的是硬盘数据,用于解决硬盘访问速度过慢的问题。
如下图所示:
我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。
CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
CPU Cache 是由寄存器组成的吗
不,CPU Cache并不是由寄存器组成的。
CPU Cache是一种用于提高处理器读写速度的高速缓存存储器。它是位于CPU核心和主存(RAM)之间的一层存储器级别。
寄存器是CPU内部的最快和最小的存储器,用于存储CPU指令、数据和临时计算结果等。而CPU Cache是位于寄存器和主存之间的一种中间级别的存储器,它的容量比寄存器大但比主存小,速度介于寄存器与主存之间。
Cache的作用是缓存最频繁使用的指令和数据,以减少CPU对主存的访问时间,提高处理器的执行效率。当CPU需要访问内存时,它首先会在Cache中搜索所需的数据。如果Cache中存在该数据,就可以直接从Cache中读取,避免了访问主存的延迟。如果Cache中没有需要的数据,CPU会从主存中读取数据,并将从主存中读取的数据缓存到Cache中,以备后续使用。
因此,CPU Cache是一层独立于寄存器的存储器级别,它通过局部性原理和缓存替换策略等机制,提供了更快的数据访问速度,从而提升了CPU的整体性能。
指令重排序
计算机在执行程序时候,为了提高代码、指令的执行效率,编译器和处理器会对指令进行重新排序,一般分为编译器对于指令的重新排序、指令并行之间的优化、以及内存指令的优化。
这么多优化都是保证在单线程的情况下,执行的结果是不变的,下图就是描述整个的指令重排的优化的过程:
什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
编译器优化:编译器在生成目标代码的过程中会对指令进行重新排序,以提高程序执行效率。编译器优化技术包括常量传播、循环展开、代码内联、函数内散列等。这些优化技术能够减少指令的数目,提高指令级并行性,从而提高程序的执行速度。
指令级并行优化:处理器在执行指令的过程中,可以对指令进行重排序,以充分利用处理器的特性和资源,提高指令级并行性。处理器可以采用乱序执行(Out-of-Order Execution)技术,在保证程序的语义正确性的前提下,重新调度指令的执行顺序,以最大程度地提高指令的并行度和执行效率。
内存指令优化:内存访问是计算机中常见的瓶颈之一,处理器和编译器会采取优化技术来降低内存访问的开销。例如,编译器可以对内存访问模式进行分析和优化,以减少不必要的内存访问。处理器可以采用高速缓存(Cache)来缓存最近访问的数据,以减少对主存的访问时间。
这些优化技术在保持程序正确性的前提下,都是为了提高程序的执行效率和性能。它们能够充分利用处理器的特性和资源,减少不必要的指令和内存访问开销,从而提高程序的执行速度和响应性。
另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。
为什么需要内存屏障:编译器和处理器指令重排只能保证在单线程执行下逻辑正确,在多个线程同时读写多个变量的情况下,如果不对指令重排作出一定限制,代码的执行结果会根据指令重排后的顺序产生不同的结果。指令重排后的顺序每次执行时都可能不一样,显然我们希望我们的代码执行结果与代码顺序是逻辑一致的(可能不太准确),所以我们需要内存屏障。
内存屏障
内存屏障(Memory Barrier)是一种计算机硬件或软件机制,用于控制计算机处理器和内存之间的数据同步和可见性。它负责确保在多线程或多核处理器系统中,不同线程之间的内存操作具有正确的顺序和一致的结果。
内存屏障有以下几个主要作用:
保证内存可见性:内存屏障通过禁止处理器对指令的重排序以及对数据的缓存写回,来确保数据的可见性。它可以防止在多线程场景下,某个线程对某个共享变量的修改对其他线程不可见的情况。
确保指令顺序:内存屏障可以防止指令乱序执行,保证指令按照程序的原始顺序执行。它可以防止在多线程场景下,由于指令重排引起的数据一致性问题。
防止优化:内存屏障可以防止处理器对指令和数据的过度优化,以确保程序的执行结果符合预期。它可以防止在多线程场景下,由于指令和数据的优化导致的错误结果。
存屏障通常包括以下几种类型:
Load Barrier:用于确保一个线程读取的数据来自于主内存而不是本地内存。
Store Barrier:用于确保一个线程写入的数据同步到主内存而不是本地内存。
Full Barrier:包含Load Barrier和Store Barrier,用于确保一个线程读取和写入的数据都同步到主内存而不是本地内存。
Store-Load Barrier:用于确保一个线程写入的数据先于后续的读取操作,避免了指令重排问题。
禁止指令重排原理是基于内存屏障机制的。在Java中,为了提高程序的性能,编译器和处理器可能会对指令进行重排,从而优化程序的执行顺序。但是,在多线程环境下,指令重排可能会导致程序出现意想不到的问题,因此需要禁止指令重排。Java中提供了volatile关键字和synchronized关键字来禁止指令重排。
当一个变量被声明为volatile时,在读取和写入变量时,会使用内存屏障机制来确保变量的值的可见性和顺序性。具体来说,读取变量时,会插入Load Barrier,确保读取的数据来自于主内存而不是本地内存;写入变量时,会插入Store Barrier,确保写入的数据同步到主内存而不是本地内存。这样可以避免指令重排,保证变量的值的正确性和可见性。
当一个代码块被synchronized关键字修饰时,会使用内存屏障机制来确保代码块的原子性、可见性和顺序性。具体来说,在进入synchronized代码块前,会插入一个Lock Barrier,确保代码块的执行顺序和可见性;在退出synchronized代码块时,会插入一个Unlock Barrier,确保代码块的原子性和可见性。这样可以避免指令重排,保证代码块的正确性和可见性。
具体来说,当编译器进行指令重排时,会考虑到指令之间的依赖关系,如果不存在依赖关系,则可能会重排指令的执行顺序,以提高程序的执行效率。然而,对于volatile变量的读写操作,编译器必须保证这些操作的顺序性,不能将其与其他指令重排,否则会导致程序出现意外的结果。因此,编译器会在对volatile变量进行读写操作时,插入Load Barrier和Store Barrier,以确保这些操作的顺序性和可见性。这样就可以禁止指令重排,保证volatile变量的值的正确性和可见性。
处理器也会进行指令重排,同样需要考虑到指令之间的依赖关系。当处理器执行一条Load指令时,如果没有Load Barrier的限制,它可能会从本地内存中读取数据,而不是从主内存中读取数据。同样,当处理器执行一条Store指令时,如果没有Store Barrier的限制,它可能会将数据写入本地内存,而不是主内存。这些操作可能会导致程序出现意外的结果。因此,处理器也会在执行Load和Store指令时,根据内存屏障的类型,确保数据的顺序性和可见性,避免指令重排的问题。
具体来说,当编译器进行指令重排时,会考虑到指令之间的依赖关系,如果不存在依赖关系,则可能会重排指令的执行顺序,以提高程序的执行效率。然而,对于volatile变量的读写操作,编译器必须保证这些操作的顺序性,不能将其与其他指令重排,否则会导致程序出现意外的结果。因此,编译器会在对volatile变量进行读写操作时,插入Load Barrier和Store Barrier,以确保这些操作的顺序性和可见性。这样就可以禁止指令重排,保证volatile变量的值的正确性和可见性。
处理器也会进行指令重排,同样需要考虑到指令之间的依赖关系。当处理器执行一条Load指令时,如果没有Load Barrier的限制,它可能会从本地内存中读取数据,而不是从主内存中读取数据。同样,当处理器执行一条Store指令时,如果没有Store Barrier的限制,它可能会将数据写入本地内存,而不是主内存。这些操作可能会导致程序出现意外的结果。因此,处理器也会在执行Load和Store指令时,根据内存屏障的类型,确保数据的顺序性和可见性,避免指令重排的问题。
JMM(Java Memory Model)
JMM描述
JMM 是Java内存模型( Java Memory Model),简称JMM。它本身只是一个抽象的概念,并不真实存在,它描述的是一种规则或规范,是和多线程相关的一组规范。通过这组规范,定义了程序中对各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。需要每个JVM 的实现都要遵守这样的规范,有了JMM规范的保障,并发程序运行在不同的虚拟机上时,得到的程序结果才是安全可靠可信赖的。如果没有JMM 内存模型来规范,就可能会出现,经过不同 JVM 翻译之后,运行的结果不相同也不正确的情况。
Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java 5 开始,Java 开始使用新的内存模型 。
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
synchronized 关键字通过 Java 内存模型(Java Memory Model,JMM)来保证多线程之间的可见性和有序性。JMM 是一种规范,定义了 Java 程序在多线程环境下的内存模型,规定了线程之间的共享变量如何在内存中存储、如何交互、以及在什么时候能看到修改等。
为什么要遵守这些并发相关的原则和规范呢?
这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile、synchronized、各种 Lock)即可开发出并发安全的程序。
JMM 是如何抽象线程和主内存之间的关系?
Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
在 Java 中,线程之间的通信和数据共享是通过内存来实现的。Java 内存模型(Java Memory Model,JMM)是一种规范,定义了多线程程序中,线程之间如何与主内存进行交互以及如何对共享变量进行访问和修改。
在JMM中,每个线程都有自己的工作内存(也称为本地内存),它存储着线程私有的数据副本,包括栈帧、程序计数器等。而主内存则是所有线程共享的内存区域,它存储着所有的共享变量。
当线程访问共享变量时,它首先会把共享变量从主内存中读取到自己的工作内存中进行操作,包括读取、修改和写入。然后,线程对变量的操作完成后,必须将结果刷新到主内存中,以便其他线程可以看到最新的值。这个过程可以看作是线程和主内存之间的数据同步。
JMM规定了多线程在执行时的一些重要规则和原则,如原子性、可见性和有序性:
- 原子性:JMM保证了对共享变量的读取和写入可以被视为原子操作,即线程要么完全看到共享变量的修改结果,要么不看到。
- 可见性:当一个线程对共享变量进行修改后,在刷新到主内存之前,其他线程不一定能立即看到这个修改。为了确保可见性,需要通过volatile关键字或者使用synchronized或Lock等同步机制来进行同步操作。
- 有序性:JMM保证了线程内的操作按照程序的顺序执行,但不保证不同线程的操作顺序。为了保证有序性,需要使用volatile关键字、synchronized或Lock等同步机制或者使用显式的内存屏障。
总之,Java内存模型提供了一套规则和原则,保证了多线程程序中线程之间的通信和数据共享的正确性和一致性。通过有效使用JMM提供的同步机制,我们可以确保共享变量在多线程环境下的正确访问和修改。
在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
这和我们上面讲到的 CPU 缓存模型非常相似。
什么是主内存?什么是本地内存?
主内存 : 所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
本地内存 : 每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
从上图来看,线程 A 与线程 B之间如果要进行通信的话,必须要经历下面 2 个步骤:
- 线程 A 把本地内存中修改过的共享变量副本的值同步到主内存中去。
- 线程 B到主存中读取对应的共享变量的值。
也就是说,JMM 为共享变量提供了可见性的保障。
不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:
- 线程 A 和线程 B 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
- 线程 B 读取到的是线程 A 修改之前的值还是修改后的值并不确定,都有可能,因为线程 A 和线程 B 都是先将共享变量从主内存拷贝到对应线程的本地内存中。
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可):
- lock(锁定): 作用于主内存中的变量,将他标记为一个线程独享变量。
- unlock(解锁): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
- use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可):
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量- 实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
- …
Java 内存区域和 JMM 有何区别?
Java 内存区域和内存模型是完全不一样的两个东西 :
JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
happens-before 原则
happens-before 原则定义
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。
为什么引入happens-before 原则?
happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
代码分析
int stepA = 6;int stepB = 6;int stepC= stepA + stepB;
stepA happens-before stepBstepA happens-before stepCstepB happens-before stepC
虽然 stepA happens-before stepB,但对 stepA 和 stepB 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 stepA 和 stepB 必须是在 stepC 执行之前,也就是说 stepA,stepB happens-before stepC 。
happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
举个例子:操作 stepA happens-before 操作 stepB,即使操作 stepA 和操作 stepB 不在同一个线程内,JMM 也会保证操作 stepA 的结果对操作 stepB 是可见的。
happens-before 常见规则
定义
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则(Thread Start Rule):Thread对象start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法和Thread.isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过- Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule) :一个对象的初始化完成(构造函数结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
通俗理解
- 程序次序规则:在一个线程内,按照控制流顺序,如果操作A先行发生于操作B,那么操作A所产生的影响对于操作B是可见的。
- 管程锁定规则:对于同一个锁,如果一个unlock操作先行发生于一个lock操作,那么该unlock操作所产生的影响对于该lock操作是可见的。
- volatile变量规则:对于同一个volatile变量,如果对于这个变量的写操作先行发生于这个变量的读操作,那么对于这个变量的写操作所产的影响对于这个变量的读操作是可见的。
- 线程启动规则:对于同一个Thread对象,该Thread对象的start()方法先行发生于此线程的每一个动作,也就是说对线程start()方法调用所产生的影响对于该该线程的每一个动作都是可见的。
- 线程终止规则:对于一个线程,线程中发生的所有操作先行发生于对此线程的终止检测,也就是说线程中的所有操作所产生的影响对于调用线程Thread.join()方法或者Thread.isAlive()方法都是可见的。
- 线程中断规则:对于同一个线程,对线程interrupt()方法的调用先行发生于该线程检测到中断事件的发生,也就是说线程interrupt()方法调用所产生的影响对于该线程检测到中断事件是可见的。
- 对象终结规则:对于同一个对象,它的构造方法执行结束先行发生于它的finalize()方法的开始,也就是说一个对象的构造方法结束所产生的影响,对于它的finalize()方法开始执行是可见的。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C,也就说操作A所产生的所有影响对于操作C是可见的。
happens-before 和 JMM 什么关系?
如果想了解更多,可以参考这篇文章——JMM面试题