文章编译自博学谷野架构师JMM并发编程领域的关键问题是什么线程间通信线程间通信指的是线程间交换信息的机制。在编程中,线程之间有两种通信机制,共享内存和消息传递。在共享内存并发模型中,程序的公共状态在线程之间共享,线程之间通过写-读公共状态来隐式通信记忆。典型的共享内存通信方式是通过共享对象进行通信。在消息传递的并发模型中,线程之间没有共同的状态,线程必须通过显式发送消息来进行显式通信。java中典型的消息传递方法是wait()和notify()。线程间同步同步是指程序用来控制不同线程之间操作发生的相对顺序的机制。在共享内存并发模型中,同步是显式完成的。程序员必须显式指定一个方法或一段代码需要在线程间互斥的情况下执行。在消息传递的并发模型中,由于消息必须在接收到消息之前发送,所以同步是隐式的。现代计算机的内存模型物理机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机的并发处理方案对于虚拟机的实现也有相当的参考意义。复杂性的重要来源之一是绝大部分计算任务不能仅靠处理器“计算”来完成。处理器至少要和内存进行交互,比如读取计算数据,存储计算结果。这种I/O操作很难消除(单靠寄存器不可能完成所有的计算任务)。早期计算机的cpu和内存的速度差不多,但在现代计算机中,cpu的指令速度远远超过内存的存取速度,因为计算机的存储设备和运算之间存在几个数量级的差距处理器的速度,所以现代计算机系统不得不增加一层读写速度尽可能接近处理器运行速度的高速缓存(Cache)作为内存和处理器之间的缓冲:复制数据需要对缓存的操作,这样操作就可以快速执行,当操作完成后,从缓存中同步回内存,这样处理器就不用等待缓慢的内存读取和写。基于缓存的存储交互解决了处理器和内存速度之间的冲突,但也给计算机系统带来了更高的复杂性,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。在多处理器系统中,每个处理器都有自己的高速缓存,并且它们共享相同的主内存(MainMemory)。当多个处理器的计算任务都涉及同一个主存区域时,可能会造成各自缓存数据的不一致。一个例子是在多个CPU之间共享变量。如果发生这种情况,同步回主存时谁的缓存数据会占上风?为了解决一致性问题,每个处理器在访问缓存时都需要遵循一些协议,读写时按照协议进行操作。此类协议包括MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly和DragonProtocol等。这种内存模型的问题现代处理器使用写缓冲区来临时保存写入内存的数据。写缓冲区保持指令流水线连续运行,避免处理器停顿和等待将数据写入内存造成的延迟。同时,通过批量刷新写缓冲区,将多次写入合并到写缓冲区中的同一内存地址,减少内存总线的占用。虽然写缓冲区有这么多好处,但是每个处理器上的写缓冲区只对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要影响:处理器对内存的读写操作的执行顺序不一定与内存实际的读写操作顺序一致!处理器A和处理器B按照程序的顺序并行进行内存访问,最终可能得到x=y=0的结果。处理器A和处理器B可以同时将共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后将自己的写缓冲区中的脏数据刷入内存(A3、B3)。在这个时序执行时,程序可以得到x=y=0的结果。从内存操作实际发生的顺序来看,写操作A1直到处理器A执行A3刷新自己的写缓冲区后才真正执行.虽然处理器A进行内存操作的顺序是:A1→A2,但实际内存操作发生的顺序是A2→A1。ProcessorAProcessorB代码a=1;//A1x=1;//A2b=2;//B1y=a;//B2runningresultinitialstatea=b=0processorallowstogetresultx=y=0Java内存模型定义JMM定义了Java虚拟机(JVM)如何在计算机的内存(RAM)中工作。JVM是整个计算机的虚拟模型,所以JMM属于JVM。JMM从抽象的角度定义了线程与主存的抽象关系:线程间的共享变量存储在主存(MainMemory)中,每个线程都有一个私有的本地内存(LocalMemory),线程的一份副本读/写共享变量存储在本地内存中。本地内存是JMM的一个抽象概念,并不真正存在。它涵盖了高速缓存、写入缓冲区、寄存器以及其他硬件和编译器优化。Java内存区域Java虚拟机在运行程序时会将其自动管理的内存划分为以上区域。每个区域都有其目的以及创建和破坏的时间。蓝色部分代表所有线程共享的数据区,紫色部分代表各个线程的私有数据区。方法区方法区属于线程共享的内存区域,也称为Non-Heap(非堆)。主要用于存放虚拟机已经加载的类信息、常量、静态变量、实时编译器编译后的代码等数据。根据Java虚拟机规范,当方法区不能满足内存分配要求时,会抛出OutOfMemoryError异常。值得注意的是,在方法区中有一个叫做运行时常量池(RuntimeConstantPool)的区域,主要用来存放编译器生成的各种字面量和符号引用,类被调用后会存放到运行时加载。在常量池中以供后续使用。JVM堆Java堆也是线程共享的内存区域。它是在虚拟机启动时创建的。它是Java虚拟机管理的最大一块内存。它主要用于存储对象实例。几乎所有的对象实例都在这里分配内存,注意Java堆是垃圾收集器管理的主要区域,所以常被称为GC堆。如果堆中没有内存来完成实例分配,堆不能再扩展,就会抛出OutOfMemoryError异常。程序计数器属于线程私有数据区,是一块很小的内存空间,主要表示当前线程执行的字节码行号指标。字节码解释器工作时,通过改变这个计数器的值来选择下一条要执行的字节码指令。分支、循环、跳转、异常处理、线程回收等基本功能都需要依赖这个计数器来完成。虚拟机栈属于线程私有数据区,与线程同时创建。总数与线程相关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈帧来存放方法的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用结束到虚拟机栈中一个栈帧的入栈出栈过程如下(图片有误,应该是栈帧):thelocalmethodstackThelocalmethodstackbelongsto线程的私有数据区,这部分主要和虚拟机使用的Native方法有关。一般来说,我们不需要关心这个区域。小结之所以在这里对这部分内容进行简单说明,是为了区分Java内存模型和Java内存区域的划分。毕竟,这两种划分属于不同层次的概念。Java内存模型概述Java内存模型(JavaMemoryModel,简称JMM)本身是一个抽象的概念,并不真正存在。它描述了一组规则或规范,通过它定义了程序中的每个变量(包括实例字段、静态字段和组成数组对象的元素)。由于JVM运行程序的实体是线程,而每个线程创建时,JVM都会创建一个工作内存(有些地方称为栈空间)用于存放线程私有数据,而Java内存模型规定所有变量存储在主存中,主存是共享内存区,所有线程都可以访问,但是线程对变量的操作(读赋值等)必须在工作内存中进行。首先,必须将变量从主存复制到自己的工作内存空间,然后对变量进行操作。运算完成后,将变量写回主存。不能直接操作主存中的变量。主内存中变量的副本存储在工作内存中。前面说过,工作内存是每个线程的,所以不同的线程不能互相访问对方的工作内存,线程之间的通信(传值)必须通过主内存来完成。简要接入流程如下图所示。需要注意的是,JMM和Java内存区的划分是不同的概念层次。说JMM描述的是一套规则更合适。通过这套规则,控制了程序中各种变量在共享数据区和私有数据区的访问方式。JMM基于原子性、顺序和可见性。展开(稍后分析)。JMM与Java内存区唯一的相似之处在于存在共享数据区和私有数据区。在JMM中,主存属于共享数据区。某种程度上应该包括堆和方法区,而工作内存数据线程私有数据区,某种程度上应该包括程序计数器、虚拟机栈、本地方法栈。可能在某些地方,我们可能会看到主内存被描述为堆内存,而工作内存被称为线程栈。事实上,它们都表达了相同的意思。关于JMM中的主内存和工作内存,主内存主要存放Java实例对象,线程创建的所有实例对象都存放在主内存中,不管实例对象是方法中的成员变量还是局部变量(也叫Localvariables)当然也包括共享的类信息、常量、静态变量。由于是共享数据区,多个线程在访问同一个变量时可能会发现线程安全问题。工作内存主要存放当前方法的所有局部变量信息(工作内存存放变量在主内存中的副本),每个线程只能访问自己的工作内存,即线程中的局部变量是对其他线程不可见,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建一个属于当前线程的局部变量,当然也包括字节码行号指示符和相关的Native方法信息。注意,由于工作内存是每个线程的私有数据,线程无法访问工作内存,所以工作内存中存储的数据不存在线程安全问题。数据同步弄清了主存和工作存之后,我们来了解一下主存和工作存的数据存储类型和操作方法。根据虚拟机规范,对于实例对象中的成员方法,如果方法中包含局部变量基本数据类型(boolean、byte、short、char、int、long、float、double)会直接存入帧栈工作内存的结构,但是如果局部变量是引用类型,那么变量引用会存放在函数内存的帧栈中,对象实例会存放在主内存(共享数据区,堆)中.但是对于实例对象的成员变量,无论是基本数据类型还是包装类型(Integer、Double等)还是引用类型,都会存放在堆区。至于静态变量和类本身的信息,会存放在主存中。需要注意的是,主存中的实例对象是可以被多个线程共享的。如果两个线程同时调用同一个对象的同一个方法,这两个线程会将要操作的数据复制到自己的工作内存中。中,运算完成后刷新到主存中。简图如下:硬件内存架构与Java内存模型硬件内存架构如上图所示。CPU和内存操作的简化图其实并没有那么简单。这里为了方便理解,我们省略了南北桥,将L3缓存统一为CPU缓存(有的CPU只有L2缓存,有的CPU有L3缓存)。就目前的计算机而言,一般都有多个CPU,每个CPU可能有多个内核。多核是指在一个处理器(CPU)上集成了两个或多个完整的计算引擎(核心),从而支持多任务并行执行,在多线程调度方面,每个线程都会映射到每个CPU核心并行运行。CPU内部有一组CPU寄存器。寄存器是CPU直接访问和处理的数据,是暂时存放数据的空间。一般CPU会从内存中取数据到寄存器中,然后进行处理,但是由于内存的处理速度比CPU低很多,所以CPU往往要花很多时间等待内存准备在处理指令时工作,所以加在寄存器和主存之间。CPU缓存比较小,但是访问速度比主存快很多。如果CPU总是对主存中同一地址的数据进行操作,很容易影响CPU的执行速度。这时候CPU缓存就可以将数据从主存中提取出来的数据暂时保存起来。如果寄存器需要取内存中同一位置的数据,可以直接从缓存中取,而不是从主存中取。需要注意的是,寄存器并不是每次都从缓存中获取数据。如果数据不在同一个内存地址,寄存器必须直接绕过缓存从内存中获取数据。所以你不会每次都从缓存中获取数据。这种现象有个专业的名字叫做缓存命中率。如果你从缓存中获取它,你就会命中它。如果你不从缓存中获取它,你就会错过它。可以看到缓存命中率的高低也会影响CPU的执行性能。这就是CPU、缓存和主存之间的简单交互过程。需要的数据会直接从缓存中获取),然后读取CPU缓存到寄存器中。当CPU需要向主存写入数据时,也会先将寄存器中的数据刷新到CPU缓存中,然后再将数据刷新到主存中。Java线程与硬件处理器了解了硬件的内存架构,再了解线程在JVM中的实现原理,了解线程的实现原理,有助于我们理解Java内存模型与硬件的关系内存架构。在Window系统和Linux系统中,Java线程的实现一般都是基于一对一的线程模型。所谓一对一模型,其实就是通过语言级程序间接调用系统内核的线程模型。即当我们使用Java线程时,Java虚拟机内部会调用当前操作系统的内核线程来完成当前任务。这里需要了解一个名词,内核线程(Kernel-LevelThread,KLT),它是操作系统内核(Kernel)支持的线程。线程执行调度并将线程的任务映射到各个处理器。每个内核线程都可以看作是内核的一个克隆,这就是操作系统可以同时处理多个任务的原因。由于我们写的多线程程序属于语言层面,所以程序一般不会直接调用内核线程,而是用一个轻量级进程(LightWeightProcess)代替,也就是通常意义上的线程。每一级进程都映射到一个内核线程,所以我们可以通过轻量级进程调用内核线程,然后操作系统内核将任务映射到各个处理器。轻量级进程与内核线程之间的一对一关系称为一对一线程模型。如下图所示,每个线程最终都会映射到CPU上进行处理。如果CPU有多个核心,那么一个CPU可以并行执行多个线程任务。Java内存模型与硬件内存架构的关系通过前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件上处理器执行,但Java内存模型与硬件内存架构并不完全一致。对于硬件内存,只有寄存器、高速缓存和主存的概念,没有区分工作内存(线程私有数据区)和主存(堆内存)。也就是说,Java内存模型对内存的划分对硬件内存没有影响。它没有任何作用,因为JMM只是一个抽象的概念,一套规则,实际上并不存在。无论是工作内存中的数据还是主存中的数据,对于计算机硬件来说,都会存储在计算机的主存中。当然,也有可能是存放在CPU缓存或者寄存器中,所以一般来说,Java内存模型和计算机硬件内存架构是一种交叉关系,是抽象概念划分和真实物理硬件的交叉。(注意,Java内存分区也是如此)当对象和变量可以存储在计算机的各个内存区域时,就会出现某些问题。两个主要问题是:线程更新(写入)共享变量的可见性。读取、检查和写入共享变量时的竞争条件。共享对象的可见性如果两个或多个线程在没有正确使用易失性声明或同步的情况下共享一个对象,则一个线程对共享对象所做的更改对于在其他CPU上运行的线程是不可见的。这样,每个线程最终都可以拥有自己的共享对象副本,每个线程都位于具有不同内容的不同CPU缓存中。下图简要说明了这种情况。在左侧CPU上运行的线程将共享对象复制到其CPU缓存中,并将其计数变量更改为2。此更改对在CPU上运行的其他线程不可见,因为对计数的更新尚未刷新回主内存。要解决这个问题,可以使用Java的volatile关键字。volatile关键字确保直接从主内存而不是从缓存中读取变量,并且更新总是立即写回主内存。竞争条件如果两个或多个线程共享一个对象并且多个线程更新该共享对象中的成员变量,就会发生竞争条件。想象一下,如果线程A将共享对象的变量计数读入其CPU缓存中。现在想象线程B做同样的事情,但进入不同的CPU缓存。现在线程A向count添加一个值,线程B也做同样的事情。现在var1已经增加了两次,每个CPU缓存一次。如果依次递增,则变量count会递增两次,并将“原值+2”得到的新值写回主存。然而,这两个增量操作是在没有适当同步的情况下同时执行的。无论线程A和B中的哪一个将其更新的版本计数写回内存,更新后的值只会比原始值多1,尽管有两次自增操作。该图说明了上述竞争条件问题的发生:要解决此问题,可以使用Java同步块。同步块保证在任何给定时间只有一个线程可以进入代码的关键部分。synchronized块还保证所有在synchronized块内访问的变量都会从主存中读取,当线程退出synchronized块时,所有更新过的变量都会重新刷新回主存,不管变量是否被声明作为挥发性的。JMM存在的必要性在了解了Java内存区的划分、硬件内存架构、Java多线程的实现原理、Java内存模型之间的具体关系之后,我们来说说Java内存模型存在的必要性.由于JVM运行程序的实体是一个线程,而在创建每个线程时,JVM会为其创建一个工作内存(有些地方称为栈空间)用于存放线程私有数据,而线程中的变量操作线程和主内存必须通过工作内存间接完成。主要过程是将变量从主存复制到各个线程各自的工作内存空间,然后对变量进行操作。运算完成后,将变量写回主存。如果有两个线程同时对内存中实例对象的变量进行操作,可能会引发线程安全问题。如下图,主存中有一个共享变量x。现在有两个线程A和B分别对变量x=1进行操作,并且在A/B线程的工作内存中都有一份共享变量x。假设现在线程A要修改x的值为2,但是线程B要读取x的值,那么线程B读取的值是线程A更新后的值2还是更新前的值1?答案是不确定的,即B线程可能在A线程更新前读取到值1,也可能在A线程更新后读取到值2。这是因为工作内存是每个线程的私有数据区,而当线程A变量x时,先把变量从主内存复制到A线程的工作内存,然后对变量进行操作,然后写入操作完成后变量x回到主存,B线程也是类似的,所以可能会造成主存和工作存数据不一致的问题。如果线程A修改后正在写数据回主存,此时线程B正在读主存,它会将x=1复制到自己的工作内存中。在内存中,线程B读取的值是x=1,但是如果线程A已经将x=2写回主存后,线程B才开始读取,那么此时线程B读取的x=2,但是哪一个发生第一的?这是不确定的,也就是所谓的线程安全问题。为了解决类似上述的问题,JVM定义了一套规则,通过这些规则来判断一个线程对共享变量的写操作何时对另一个线程可见。这套规则也称为Java内存模型(JMM),JMM围绕程序执行的原子性、有序性和可见性展开。让我们来看看这三个功能。本文由传智教育博学谷狂野建筑师教研团队发布。如果本文对您有帮助,请关注并点赞;有什么建议也可以留言或私信。您的支持是我坚持创作的动力。转载请注明出处!
