文章整顿自 博学谷狂野架构师
什么是JMM
并发编程畛域的关键问题
线程之间的通信
线程的通信是指线程之间以何种机制来替换信息。在编程中,线程之间的通信机制有两种,共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信形式就是通过共享对象进行通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送音讯来显式进行通信,在java中典型的消息传递形式就是wait()和notify()。
线程间的同步
同步是指程序用于管制不同线程之间操作产生绝对程序的机制。
在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个办法或某段代码须要在线程之间互斥执行。
在消息传递的并发模型里,因为音讯的发送必须在音讯的接管之前,因而同步是隐式进行的。
古代计算机的内存模型
物理计算机中的并发问题,物理时机到的并发问题与虚拟机中的状况有不少相似之处,物理机对并发的解决计划对于虚拟机的实现也有相当大的参考意义。
其中一个重要的复杂性起源是绝大多数的运算工作都不可能只靠处理器“计算”就能实现,处理器至多要与内存交互,如读取运算数据、存储运算后果等,这个I/O操作是很难打消的(无奈仅靠寄存器来实现所有运算工作)。
晚期计算机中cpu和内存的速度是差不多的,但在古代计算机中,cpu的指令速度远超内存的存取速度,因为计算机的存储设备与处理器的运算速度有几个数量级的差距,所以古代计算机系统都不得不退出一层读写速度尽可能靠近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算须要应用到的数据复制到缓存中,让运算能疾速进行,当运算完结后再从缓存同步回内存之中,这样处理器就毋庸期待迟缓的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,然而也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。
在多处理器零碎中,每个处理器都有本人的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算工作都波及同一块主内存区域时,将可能导致各自的缓存数据不统一,举例说明变量在多个CPU之间的共享。
如果真的产生这种状况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,须要各个处理器拜访缓存时都遵循一些协定,在读写时要依据协定来进行操作,这类协定有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
该内存模型带来的问题
古代的处理器应用写缓冲区长期保留向内存写入的数据。写缓冲区能够保障指令流水线继续运行,它能够防止因为处理器停顿下来期待向内存写入数据而产生的提早。
同时,通过以批处理的形式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的屡次写,缩小对内存总线的占用。
尽管写缓冲区有这么多益处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个个性会对内存操作的执行程序产生重要的影响:处理器对内存的读/写操作的执行程序,不肯定与内存理论产生的读/写操作程序统一!
处理器A和处理器B按程序的程序并行执行内存拜访,最终可能失去x=y=0的后果。
处理器A和处理器B能够同时把共享变量写入本人的写缓冲区(A1,B1),而后从内存中读取另一个共享变量(A2,B2),最初才把本人写缓存区中保留的脏数据刷新到内存中(A3,B3)。
当以这种时序执行时,程序就能够失去x=y=0的后果。
从内存操作理论产生的程序来看,直到处理器A执行A3来刷新本人的写缓存区,写操作A1才算真正执行了。尽管处理器A执行内存操作的程序为:A1→A2,但内存操作理论产生的程序却是A2→A1。
Processor A | Processor B | |
---|---|---|
代码 | a=1; //A1 x=1; //A2 | b=2; //B1 y=a; //B2 |
运行后果 | 初始状态 a=b=0 处理器容许失去后果 x=y=0 |
Java内存模型定义
JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作形式。JVM是整个计算机虚构模型,所以JMM是隶属于JVM的。
从形象的角度来看,JMM定义了线程和主内存之间的形象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个公有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的正本。
本地内存是JMM的一个抽象概念,并不实在存在。它涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。
Java内存区域
Java虚拟机在运行程序时会把其主动治理的内存划分为以上几个区域,每个区域都有的用处以及创立销毁的机会,其中蓝色局部代表的是所有线程共享的数据区域,而紫色局部代表的是每个线程的公有数据区域。
办法区
办法区属于线程共享的内存区域,又称Non-Heap(非堆),次要用于存储已被虚拟机加载的类信息、常量、动态变量、即时编译器编译后的代码等数据,依据Java 虚拟机标准的规定,当办法区无奈满足内存调配需要时,将抛出OutOfMemoryError 异样。
值得注意的是在办法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它次要用于寄存编译器生成的各种字面量和符号援用,这些内容将在类加载后寄存到运行时常量池中,以便后续应用。
JVM堆
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创立,是Java 虚拟机所治理的内存中最大的一块,次要用于寄存对象实例,简直所有的对象实例都在这里分配内存,留神Java 堆是垃圾收集器治理的次要区域,因而很多时候也被称做GC 堆,如果在堆中没有内存实现实例调配,并且堆也无奈再扩大时,将会抛出OutOfMemoryError 异样。
程序计数器
属于线程公有的数据区域,是一小块内存空间,次要代表以后线程所执行的字节码行号指示器。字节码解释器工作时,通过扭转这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异样解决、线程复原等根底性能都须要依赖这个计数器来实现。
虚拟机栈
属于线程公有的数据区域,与线程同时创立,总数与线程关联,代表Java办法执行的内存模型。每个办法执行时都会创立一个栈桢来存储办法的的变量表、操作数栈、动静链接办法、返回值、返回地址等信息。每个办法从调用直完结就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下(图有误,应该为栈桢):
本地办法栈
本地办法栈属于线程公有的数据区域,这部分次要与虚拟机用到的 Native 办法相干,个别状况下,咱们无需关怀此区域。
小结
这里之所以简要阐明这部分内容,留神是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同档次的概念。
Java内存模型概述
Java内存模型(即Java Memory Model,简称JMM)自身是一种形象的概念,并不实在存在,它形容的是一组规定或标准,通过这组标准定义了程序中各个变量(包含实例字段,动态字段和形成数组对象的元素)的拜访形式。
因为JVM运行程序的实体是线程,而每个线程创立时JVM都会为其创立一个工作内存(有些中央称为栈空间),用于存储线程公有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,
所有线程都能够拜访,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的本人的工作内存空间,而后对变量进行操作,操作实现后再将变量写回主内存,不能间接操作主内存中的变量,工作内存中存储着主内存中的变量正本拷贝,
后面说过,工作内存是每个线程的公有数据区域,因而不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来实现,其简要拜访过程如下图
须要留神的是,JMM与Java内存区域的划分是不同的概念档次,更失当说JMM形容的是一组规定,通过这组规定控制程序中各个变量在共享数据区域和公有数据区域的拜访形式,JMM是围绕原子性,有序性、可见性开展的(稍后会剖析)。
JMM与Java内存区域惟一类似点,都存在共享数据区域和公有数据区域,在JMM中主内存属于共享数据区域,从某个水平上讲应该包含了堆和办法区,而工作内存数据线程公有数据区域,从某个水平上讲则应该包含程序计数器、虚拟机栈以及本地办法栈。
或者在某些中央,咱们可能会看见主内存被形容为堆内存,工作内存被称为线程栈,实际上他们表白的都是同一个含意。对于JMM中的主内存和工作内存阐明如下
主内存
次要存储的是Java实例对象,所有线程创立的实例对象都寄存在主内存中,不论该实例对象是成员变量还是办法中的本地变量(也称局部变量),当然也包含了共享的类信息、常量、动态变量。
因为是共享数据区域,多条线程对同一个变量进行拜访可能会发现线程平安问题。
工作内存
次要存储以后办法的所有本地变量信息(工作内存中存储着主内存中的变量正本拷贝),每个线程只能拜访本人的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在本人的工作内存中创立属于以后线程的本地变量,当然也包含了字节码行号指示器、相干Native办法的信息。
留神因为工作内存是每个线程的公有数据,线程间无奈互相拜访工作内存,因而存储在工作内存的数据不存在线程平安问题。
数据同步
弄清楚主内存和工作内存后,接理解一下主内存与工作内存的数据存储类型以及操作形式,依据虚拟机标准,对于一个实例对象中的成员办法而言,如果办法中蕴含本地变量是根本数据类型(boolean,byte,short,char,int,long,float,double),将间接存储在工作内存的帧栈构造中,但假使本地变量是援用类型,那么该变量的援用会存储在性能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。
但对于实例对象的成员变量,不论它是根本数据类型或者包装类型(Integer、Double等)还是援用类型,都会被存储到堆区。
至于static变量以及类自身相干信息将会存储在主内存中。须要留神的是,在主内存中的实例对象能够被多线程共享,假使两个线程同时调用了同一个对象的同一个办法,那么两条线程会将要操作的数据拷贝一份到本人的工作内存中,执行实现操作后才刷新到主内存,简略示意图如下所示:
硬件内存架构与Java内存模型
硬件内存架构
正如上图所示,通过简化CPU与内存操作的繁难图,实际上没有这么简略,这里为了了解不便,咱们省去了南北桥并将三级缓存对立为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。
就目前计算机而言,个别领有多个CPU并且每个CPU可能存在多个外围,多核是指在一枚处理器(CPU)中集成两个或多个残缺的计算引擎(内核),这样就能够反对多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU外围中并行运行。
在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-Level Thread,KLT),它是由操作系统内核(Kernel)反对的线程,这种线程是由操作系统内核来实现线程切换,内核通过操作调度器进而对线程执行调度,并将线程的工作映射到各个处理器上。每个内核线程能够视为内核的一个分身,这也就是操作系统能够同时解决多任务的起因。
因为咱们编写的多线程程序属于语言层面的,程序个别不会间接去调用内核线程,取而代之的是一种轻量级的过程(Light Weight Process),也是通常意义上的线程,因为每个轻量级过程都会映射到一个内核线程,因而咱们能够通过轻量级过程调用内核线程,进而由操作系统内核将工作映射到各个处理器,这种轻量级过程与内核线程间1对1的关系就称为一对一的线程模型。如下图
如图所示,每个线程最终都会映射到CPU中进行解决,如果CPU存在多核,那么一个CPU将能够并行执行多个线程工作。
Java内存模型与硬件内存架构的关系
通过对后面的硬件内存架构、Java内存模型以及Java多线程的实现原理的理解,咱们应该曾经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。
对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程公有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种形象的概念,是一组规定,并不理论存在,
不论是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因而总体上来说,Java内存模型和计算机硬件内存架构是一个互相穿插的关系,是一种抽象概念划分与实在物理硬件的穿插。(留神对于Java内存区域划分也是同样的情理)
当对象和变量能够存储在计算机的各种不同存储区域中时,可能会呈现某些问题。两个次要问题是:
- 线程更新(写入)共享变量的可见性。
- 读取,检查和写入共享变量时的竞争条件。
共享对象的可见性
如果两个或多个线程共享一个对象,而没有正确应用 volatile
申明或同步,则一个线程对共享对象的更改对于在其余CPU上运行的线程是不可见的。
这样,每个线程最终都可能领有本人的共享对象正本,每个正本都位于不同的CPU缓存中,并且其中的内容不雷同。
下图简略阐明了状况。在右边CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其 count
变量更改为2。此更改对于在CPU上运行的其余线程不可见,因为count
的更新尚未刷新回主内存。
要解决此问题,您能够应用Java的volatile关键字。volatile
关键字能够确保变量从主内存中间接读取而不是从缓存中,并且更新的时候总是立刻写回主内存。
竞争条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的成员变量,则可能会呈现竞争条件。
设想一下,如果线程A将共享对象的变量count
读入其CPU缓存中。再设想一下,线程B也做了同样的事件,然而进入到了不同的CPU缓存。当初线程A增加一个值到count
,线程B执行雷同的操作。当初var1
曾经减少了两次,每次CPU缓存一次。
如果这些减少操作按程序执行,则变量count
将减少两次并将”原始值+ 2”后产生的新值写回主存储器。
然而,两个减少操作同时执行却没有进行适当的同步。无论线程A和B中的哪一个将其更新版本count
写回主到存储器,更新的值将仅比原始值多1,只管有两个减少操作。
该图阐明了如上所述的竞争条件问题的产生:
要解决此问题,您能够应用Java synchronized块。同步块保障在任何给定工夫只有一个线程能够进入代码的临界区。同步块还保障在同步块内拜访的所有变量都将从主存储器中读入,当线程退出同步块时,所有更新的变量将再次刷新回主存储器,无论变量是否申明为volatile。
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是围绕着程序执行的原子性、有序性、可见性开展的,上面咱们看看这三个个性。
本文由
传智教育博学谷狂野架构师
教研团队公布。如果本文对您有帮忙,欢送
关注
和点赞
;如果您有任何倡议也可留言评论
或私信
,您的反对是我保持创作的能源。转载请注明出处!