本文共 10739 字,大约阅读时间需要 35 分钟。
在介绍Java内存模型前,先简单介绍物理机对并发的处理方案,两者处理思想相似。
处理器在执行“计算”时,需要与内存进行交互,如读取运算数据、存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓存的内存读写。 基于高速缓存的存储交互解决了处理器与内存的速度矛盾,但也为计算机系统带来更高的复杂度,并且高速缓存引入也了一个新的问题:缓存一致性(Cache Coherence,高速缓存和内存数据的一致性)。此外,多处理器系统中,每个处理器除了拥有私有的高速缓存外,还共享同一主内存(Main Memory),多个处理器的运算任务涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。为解决上述两个一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。多处理器系统的并发处理模型如下: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d66d76xd-1616482428728)(…/img/物理机并发处理模型.png)] 除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。Java内存模型的目标是屏蔽各种硬件和操作系统的内存访问差异,以实现多平台场景下达到一致的内存访问效果。在JDK 1.5以后,Java内存模型逐渐成熟和完善。
Java内存模型对外提供的接口还是程序中变量的访问规则,即虚拟机将变量存储到内存,以及从内存中获取变量。注意,这里的变量仅指非线程私有变量(不包括局部变量和方法参数),包括实例字段、静态字段和构成数组对象的元素。 Java内存模型规定所有的变量(实例字段、静态字段、构成数组对象的元素,不包括局部变量和方法参数)都存储在主内存(Main Memory)中。每个线程则拥有自己的工作内存(Working Memory)。在线程的工作内存中,保存该线程使用到的变量的主内存副本拷贝。Java内存模型如下:主内存与工作内存交互时,必须遵循交互协议,才能保证主内存与工作内存的一致性。Java内存模型定义8种原子操作和执行规则来实现这一点。这8种操作是:
(1) lock(锁定):作用于主内存的变量,它把一个变量标志为一条线程独占的状态。 (2) unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 (3) read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。 (4) load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。 (5) use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。 (6) assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋给工作内存的变量。 (7) store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。 (8) write(写入):作用于主内存中的变量,它把store操作从主内存中得到的变量值放入主内存的变量中。 对应执行流程如下:8种基本操作对应的执行规则比较繁琐,这里不再介绍,后续会学习其等效判断原则——先行发生原则,来确定一个访问在并发环境下是线程安全的。
Java内存模型是围绕着并发过程中如何处理原子性、可见性和有序性这三个特征来建立。
Java中基本数据类型的读取和赋值操作是原子性操作。所谓原子性操作就是指这些操作是不可中断的,要么一定做完,要么就没有执行。 比如:
i = 2; // 赋值操作,是原子性操作 j = i; // 读取i的值,然后再赋值给j, 2步操作 i++; // 读取i的值,加1,再写回主存,3步操作 有个例外是,虚拟机规范中允许对64位数据类型(long和double),分为2次32位的操作来处理,但是最新JDK实现时还是实现了原子操作的。 JMM只实现了基本的原子性,诸如i++那样的操作,必须借助于synchronized或Lock对象来保证整个代码块的原子性。 综上,在Java中只有以下两类场景能保证原子性: (1) 基本数据类型的读取和赋值操作是原子性操作; (2) lock操作和unlock操作之间的操作是原子性操作。由于lock和unlock操作未直接开放给用户,可使用更高层次的字节码指令monitorenter和monitorexit隐式调用这两个操作。对应Java代码,就是synchronized代码块是原子性操作,Lock对象修饰的代码块是原子性操作。可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即得知修改的值。
Java内存模型是通过在变量修改后将新值同步到主内存,在变量读取前从主内存刷新变量值规则,来实现可见性的。 对于volatile修饰的变量,可以保证可见性。(volatile变量具有可见性,能保证新值立即同步到主内存,以及每次使用前立即从主内存刷新) 除了volatile外,synchronized和final关键字也可实现可见性。synchronized同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这个规则保证。 final关键字的可见性则是通过“被final修饰的字段,在构造器中完成初始化后,如果构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值”保证。 综上,在Java中有三类场景可保证可见性: (1) volatile 关键字 (2) synchronized 关键字 (3) final 关键字Java内存模型的有序性是指:如果在本线程内部观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。
Java提供volatile关键字和syncronized关键字保证线程间操作的有序性。 (1) volatile关键字本身包含禁止指令重排序的语义。 (2) syncronizd关键字通过“一个变量在同一时刻只允许一个线程对其进行lock操作”保证有序性。 综上,在Java中有两类场景可保证有序性: (1) volatile 关键字 (2) synchronized 关键字在介绍内存交互操作规则时,曾提到等效判断原则——先行发生原则,该原则用来确定一个访问在并发环境下是线程安全的。详细来说,“先行发生规则”是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生是Java内存模型中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,就是说A产生的影响能被B观察到,”影响“包括修改了内存中的共享变量值、发送了消息、调用了方法等。 Java内存模型定义一些先行发生关系,对于这些先行发生关系,无需任何同步器协助,可以直接使用。而对于不在此列的关系,就没有顺序性保障,虚拟机可以随意的进行重排。也就是说,先行发生原则能够保证有序性。这些先行发生规则是: (1) 程序次序规则(Program Order Rule):在一个线程内,代码的书写顺序和执行顺序一致。总结来说,线程内表现串行语义。 (2) 管程锁定规则(Monitor Lock Rule):unlock 操作先行发生于后面对同一个锁的 lock 操作。 (3) volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。 (4) 线程启动规则(Thread Start Rule):线程对象 start() 方法先行发生于此线程的每一个动作。 (5) 线程终止规则(Thread Termination Rule):线程中所有操作先行发生于对此线程的终止检测。常用的终止检测有Thread join()方法结束、Thread.isAlive()方法返回值进行终止检测。 (6) 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生:可以通过 Thread.interrupted() 方法检测到是否有中断发生。 (7) 对象终结规则(Finalizer Rule):一个对象的初始化完成先行发生于它的 finalize() 方法的开始。 (8) 传递性(Transitivity):如果操作A先行发生于操作B,B先行发生于C,那么A先行发生于C。 注意,“时间先后顺序”和“先行发生原则”没有太大关联,“先行发生原则”并不要求“时间先后顺序”,而“时间先后顺序”也无法保证“先行发生原则”(指令重排序)。并发问题中,受“时间先后顺序”干扰时,必须以“先行发生原则”为准。volatile可以说是Java虚拟机提供的最轻量级的同步机制,Java内存模型对volatile专门定义了一些特殊的访问规则。使用volatile修饰的共享变量(也称volatile 变量),具备三个特性:
(1) 保证了不同线程对该变量操作的内存可见性; (2) 无法保证变量操作的原子性; (3) 禁止指令重排序;变量在线程间传递均需要通过主内存来完成,例如,线程A修改一个变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
对于 volatile 变量,在一个线程的工作内存被修改后,会立即同步回主内存,另一个线程每次在使用该 volatitle 变量时,都必须重新从主内存加载,进而保证可见性。 对可见性的误解,认为以下描述成立:“volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其他线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的”。这句话的论据部分并没有错,但是其论据并不能得出“基于volatile变量的运算在并发下是安全的”这个结论。volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。因为volatile关键字无法保证“原子性”,所以使用volatile关键字修饰的变量如果是原子性的操作,则具备原子性,否则必须使用syncronized或Lock对象等保证原子性。 对于不符合以下两条规则的运算场景,均需通过加锁来保证原子性:
(1) 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。 (2) 变量不需要与其他的状态变量共同参与不变约束。 使用volatile变量控制并发的示例代码如下:volatile boolean shutdownRequested;public void shutdown() { shutdownRequested = true;}public void doWork() { while(!shutdownRequested) { // do something }}
指令重排序是指:代码书写的顺序与代码实际执行顺序不同,指令重排序是编译器或者处理器为了提高程序性能做出的优化。(编译成机器码后,重新调整下顺序,可能更符合CPU的特点,能最大限度的发挥CPU的性能)。
指令重排序不是指令任意重排或指令任意排序,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,其顺序不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、 2之前或者中间,只要保证CPU执行后面依赖到A、 B值的操作时能获取到正确的A和B值即可。 普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”(Within-Thread As-If-SerialSemantics)。 但是,指令重排序后干扰程序的并发执行,使其无法达到“代码书写顺序”的效果。 指令重排序功能在JDK 1.5之后才真正实现。 https://www.zybuluo.com/946898963/note/1065387加入volatile关键字的代码会多出一个lock前缀指令。
lock前缀指令实际相当于一个内存屏障(Memory Barrier或Memory Fence),内存屏障提供了以下功能: 1.指令重排序时不能把后面的指令重排序到内存屏障之前的位置(禁止指令重排序); 2.使得本CPU的Cache写入内存,该写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。假设T表示一个线程,V1和V2分别表示两个volatile类型变量,那么在进行read、load、use、assign、store和write操作时需要满足以下规则:
(1) 读可见性 只有当线程T对变量V1执行的前一个动作是load的时候,线程T才能对变量V1执行use动作;并且,只有当线程T对变量V1执行的后一个动作是use的时候,线程T才能对变量V1执行load动作。线程T对变量V1的use动作可以认为是和线程T对变量V1的load、read动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次使用V1前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V1所做的修改后的值。即使用变量:read->load->use (2) 写可见性 只有当线程T对变量V1执行的前一个动作是assign的时候,线程T才能对变量V1执行store动作;并且,只有当线程T对变量V1执行的后一个动作是store的时候,线程T才能对变量V1执行assign动作。线程T对变量V1的assign动作可以认为是和线程T对变量V1的store、write动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次修改V1后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V1所做的修改。即修改变量:assign->store->write (3) 禁止指令重排序 假定动作A是线程T对变量V1实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V1的read或write动作;类似的,假定动作B是线程T对变量V2实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量V2的read或write动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。 (4) long和double变量的非原子协定 Java 内存模型要求lock、unlock、read、load、assign、use、store和write这8个操作都具有原子性,但是对于64位的数据类型 long 和 double,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位类型是long和double的数据读写操作划分为两次32位的操作来进行。这就是所谓的 long 和 double 的非原子性协定(Nonatomic Treatment of double and long Variables)。 尽管long和double具有“非原子协定”,但是大多数虚拟机都将其操作“原子化”。因此,在编码时,无需特别将long和double变量声明为volatile。volatile变量读操作性能与普通变量无异,写操作性能会慢一些,因为需要在本地代码中插入“内存屏障指令”来保证处理器不发生乱序执行。
volatile变量对总开销要比锁低。如果一个场景既能使用锁也能使用volatile变量,则尽量使用volatile变量。编写无锁的代码,提高性能。如优化因synchronized滥用导致的性能问题。
使用syncronized关键字,可解决共享资源竞争问题。
synchronized关键字可保证所修饰元素的原子性、可见性、有序性。
对于非原子性操作,诸如i++那样的操作,可以借助synchronized来保证整个代码块的原子性。 synchronized也可实现可见性。synchronized同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这个规则保证。 对于有序性,syncronizd关键字通过“一个变量在同一时刻只允许一个线程对其进行lock操作”这个规则来保证。根据synchronized关键字修饰元素的类型,可将其使用场景分为两类:使用syncronized修饰代码块(也称同步代码块)或使用syncronized修饰方法(也称为同步方法)。其中,使用syncronized修饰方法,又根据是否是static方法,分为使用syncronized修饰类方法和syncronized修饰对象方法。
(1) synchronized作用于代码块 synchronized关键字作用于方法中的代码块,此时基于 synchronized 指定实例加锁。用法实例如下:synchronized(instanceName) { // instanceName 表示指定实例 /* 代码块 */}
(2) synchronized作用于对象方法
synchronized关键字作用于对象方法,此时基于当前对象加锁。使用模式如下:synchronized 访问类型 返回值 methodName() { /* 方法体 */}
(3) synchronized作用于类方法
synchronized关键字作用于对象方法,此时基于当前类加锁。使用模式如下:synchronized 访问类型 static 返回值 methodName() { /* 方法体 */}
使用syncronized关键字可以保证在同一时刻仅有一个线程访问该同步代码块或同步方法。synchronized 通过下述规则保证该特性:
(1) 当多个线程同时访问synchronized同步块或同步方法时,只能有一个线程得到执行,其他线程必须等待。 (2) 当一个线程访问synchronized同步块或同步方法时,其他线程仍可同时调用object中其它非synchronized同步块或方法。 (3) 当一个线程访问synchronized同步块或同步方法时,它会获得整个object的对象锁或类锁。因此,其他线程对于object的所有同步块或同步方法的访问都将被暂时阻塞,直至当前线程执行完这个同步块或同步方法。 (4) 当线程完成synchronized同步块或同步方法访问时,它会释放整个object的对象锁或类锁。因此,需要调用object上同步块或同步方法的其他线程将尝试获取这个object的对象锁。synchronized 本质使用 Java 内置的 Monitor(管程、监视器)Object 实现线程同步。对同步代码块和同步方法,使用不同的实现方式。对于同步代码块,则是使用 monitorenter 和 monitorexit 指令 实现的;而同步方法,则是采用 ACC_SYNCHRONIZED标记符实现。
(1) 同步代码块 monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM则保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,该对象将处于锁定状态。 根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。 注意: (a)首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题; (b)其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。 (2) 同步方法 当某个线程要访问该方法时,会检查该方法是否有 ACC_SYNCHRONIZED 标志,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。不同虚拟机,对Monitor Object的实现略有不同。这里以HotSpot为例,简单介绍monitor的实现(基于ObjectMonitor实现)。ObjectMonitor的主要数据结构如下:
ObjectMonitor() { _header = NULL; _count = 0; // 记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; // ObjectMonitor的持有者 _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
可以看到,ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表(等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1;若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
暂无
转载地址:http://mbvcz.baihongyu.com/