河北网站建设案例,网站登陆怎么做,兰州市建设工程安全质量监督站网站,博达软件网站建设在并发编程中#xff0c;线程之间的通信是一个很关键的问题#xff0c;而该问题解决方案主要可分为两大类#xff1a;消息传递、共享内存。前者有以Erlang语言为代表的Actor模型#xff0c;而后者中典型的则是Java语言。对于消息传递机制而言#xff0c;线程之间必须通过发… 在并发编程中线程之间的通信是一个很关键的问题而该问题解决方案主要可分为两大类消息传递、共享内存。前者有以Erlang语言为代表的Actor模型而后者中典型的则是Java语言。对于消息传递机制而言线程之间必须通过发送消息以进行显式地通信。而同步过程则是隐式地因为消息的发送必须在消息的接收之前而对于共享内存机制来说线程之间可以通过读、写内存中的公共状态来实现隐式地通信但同步操作则需通过开发者显式地进行指定。可以看到由于Java的并发采用是共享内存机制所以在谈多线程并发编程之前需要对JMM(Java Memory Model)Java内存模型有一定的了解abstract.pngCPU内存模型与缓存一致性问题CPU内存模型在谈论Java内存模型之前我们先来了解下现代CPU内存模型。下面是一个双核CPU的组成示意图每个CPU都包含一个独有的一级缓存同时还有一个可被所有CPU共享的二级缓存。多级Cache的作用就是为了缓冲现代CPU与主内存Ram之间严重不匹配的速度figure 1.jpegCache Coherency 缓存一致性问题双核甚至多核CPU的出现使得多个线程可以在不同的CPU中执行可以大大减少单核CPU由于频繁切换线程而引起的上下文切换开销。目前看来好像一切都是美妙的。但是很快人们发现这会引发一个新的问题——Cache Coherency 缓存一致性问题。假设现在我们有两个线程A、B分别使用CPU #1、#2执行其中在主内存Ram有一个共享变量a其初始值为1Step 1 : 线程A将变量a的值修改为2线程A首先获取共享变量a值由于两级缓存L1 Cache、L2 Cache均未命中故只能从主内存Ram中加载然后将a1缓存到两级缓存中最后线程A修改了变量a的值为2并将其写入两级缓存、主内存中figure 2.jpegStep 2 : 线程B对变量a进行自增线程B首先获取共享变量a值二级缓存L2 Cache被命中其值为2然后对变量a自增变为3并将其写入两级缓存、主内存中figure 3.jpeg目前为止一切都是正常的经过Step1、2两步操作后主内存中变量a的值变为3符合我们的预期Step 3 : 线程A对变量a进行自增线程A首先获取共享变量a值一级缓存L1 Cache被命中其值为2然后对变量a自增变为3并将其写入两级缓存、主内存中figure 4.jpeg等等好像哪里不对啊在Step2后共享变量在主内存中已经是3了那么Step3中线程A如果再次对其自增后主内存中的变量a的值应该更新为4才对啊。但实际上执行完Step3后主内存变量a的却依然是3相信聪明的朋友可能已经看出来原因所在了在Step2后虽然主内存中变量a的值已经更新为3了但是在CPU #1独有的L1 Cache中变量a的值却还是2未被更新。换言之由于各CPU内部Cache之间的不可见性CPU无法感知到其他CPU Cache对数据所做的更新、修改从而引发 Cache Coherency 缓存一致性问题总线加锁为了解决Cache Coherency 缓存一致性问题早期是通过直接对主内存与共享Cache(即这里的L2 Cache)之间的总线加锁来解决的。在我们上面的例子中线程A的工作就是将变量a修改为2然后再对其自增而B线程的工作是将变量a的值自增一次。现在假设依然是Step 1先执行即线程A将主存中变量a的值修改为2了。由于总线加锁机制的存在在线程A第一次从主存中加载值为1的变量a时总线即会主存中变量a进行加锁使得其他CPU(即这里的CPU #2的线程B)无法读、写该变量只等进行等待直到线程A完成了对该变量的全部操作Step1、Step3——即先将变量a修改为2再自增为3。当线程A将变量a的值3最终写入主内存后总线才会将该锁释放。此时线程B才可以从主内存中加载变量a执行自增操作并最终将a4写入到主内存中(当然总线在此期间同样会再次对总线进行加锁以保证CPU #2的线程B对其进行独占)。即在总线加锁的机制下如果线程A先拿到总线锁则线程A、B的任务执行顺序是Step1、Step3、Step2。虽然通过总线加锁的方式可以解决我们上面提到的缓存一致性问题但是弊端同样显而易见总线加锁会导致其他线程完全无法操作该变量只能进行等待。换句话说总线加锁的效率太低、开销太大严重浪费了多核CPU的性能MESI协议-缓存锁为了解决总线加锁的弊端现代CPU在访问Cache的过程中可通过遵循一些协议来解决缓存一致性问题。典型地有Intel的MESI缓存一致性协议。在MESI协议中当多个CPU从主内存加载同一个共享变量的数据并缓存到各自Cache后一旦某个CPU修改了该变量在其缓存中的数据后立刻将修改后的数据同步到主内存中。通过对Cache中该数据所在的缓存行加锁来阻止其他CPU同时修改主内存中该变量的数据当主内存数据被修改完毕后即释放锁。与此同时其他CPU可通过总线嗅探机制感知到该变量的数据变化从而将自己CPU内部相应的缓存数据失效。可以看到MESI协议一方面让CPU可以感知其他CPU中缓存数据的修改、变化来及时将自己Cache中的数据失效另一方面只对缓存数据在回写到主内存的过程进行加锁即使用缓存锁的方式大大减小了锁的粒度提高了多核CPU的利用率Java Memory Model有了前面CPU内存模型的引子现在让我们回到正题来了解下什么是Java Memory Model(Java内存模型)。Java试图定义一种内存模型其能够屏蔽各种硬件底层、操作系统的内存访问差异以保证Java程序在各种平台下的一致的内存访问效果。而在JDK 1.5版本中通过实现JSR-133Java内存模型才被真正地完善地成熟地建立起来了。所以本文所谈论的Java内存模型均是基于JSR-133而言的Java内存模型规定共享变量存储在主内存中当Java线程使用该共享变量时需要先将其拷贝到该线程所属的工作内存中。换句话说线程对共享变量的操作只能在该线程的工作内存中进行而不能直接读写主内存中的变量。当然线程之间也无法直接访问对方工作内存中的变量所以线程之间共享变量值的传递均需通过主内存来完成。其示意图如下所示可以看到JMM在设计上与我们之前介绍的CPU内存模型有很大相似之处figure 5.jpeg上面所说的共享变量具体则是指实例变量、静态变量以及数组中的元素但不包括局部变量、方法参数。因为后者(局部变量、方法参数)是线程私有的自然不会被共享。需要注意的是Java内存模型只是是一个抽象的概念其中主内存一般对应于计算机硬件的Ram而工作内存则不真实存在其可能会对应于CPU寄存器、缓存或Ram。同时这里希望大家不要将JMM中的主内存、工作内存与JVM Java虚拟机中的堆、栈、方法区等Java内存区域相混淆因为二者不是在一个层次上的内存划分这里JMM是为Java并发而服务的并发编程的三个特性Java内存模型就是围绕着在并发过程中如何处理原子性、可见性、有序性这三个特性来建立的其通过相关的规范、规则来避免并发编程可能出现的线程安全问题Atomicity 原子性原子性是指一个或多个操作是不可中断的要么全部执行要么全部不执行不存在只执行其中一部分的情况。在JMM中定义了以下八种操作来完成共享变量在主内存与工作内存之间的具体交互。与此同时下面提及的每一种操作均由JMM来直接保证其具备原子性所以Java虚拟机实现时必须要满足下列操作的原子性lock(锁定) 作用于主内存的变量它把一个变量标示为一条线程独占的状态unlock(解锁) 作用于主内存的变量它把一个处于锁定状态的变量释放出来释放后的变量才可以被其他线程锁定read(读取) 作用于主内存的变量它把一个变量的值从主内存传输到线程内的工作内存中以便随后的load操作使用load(载入) 作用于工作内存的变量它把read操作从主内存中得到的变量值放入工作内存的变量副本中use(使用) 作用于工作内存的变量它把工作内存中一个变量的值传递给执行引擎每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行此操作assign(赋值) 作用于工作内存的变量它把一个从执行引擎接收到的值赋给工作内存的变量每当虚拟机遇到一个给变量赋值的字节码指令时执行此操作store(存储) 作用于工作内存的变量它把工作内存中一个变量的值传递到主内存中以便随后的write操作使用write(写入) 作用于主内存的变量它把store操作从工作内存中得到的变量值放入主内存的变量中在开发中通常需要保证多个操作的原子性所以在JMM中提供了lock、unlock操作尽管虚拟机未将这两个操作开放提供给用户使用。但是却提供了更高层次的字节码指令monitorenter、monitorexit来隐式地使用了这两个操作而这两个字节码指令反映到Java代码层面就是同步块——synchronized关键字。所以synchronized块之间的一系列操作同样具备原子性Note值得一提的是对于long、double类型变量而言JMM并不强制要求虚拟机在实现时保证read、load、store、write操作的原子性即所谓的long、double的非原子性协定。不过就实际开发而言我们也无需过多担心这点。因为目前大多数商用虚拟机几乎都会选择实现long、double数据读写操作的原子性Visibility 可见性可见性则是当一个线程修改了共享变量的值其他线程能够立即感知到这个修改。前面我们已经提到JMM中各线程的工作内存相互是不可见的即不可以直接访问其他线程的工作内存。所以在JMM中可见性是通过主内存作为传递媒介来实现的即线程在修改了共享变量的值后需要同步回主内存在线程读取时从主内存拷贝副本Ordering 有序性重排序一般大家会认为程序的执行是按我们程序编码时的顺序关系顺序执行但实际上并不是这样。现代CPU会利用一些诸如多级流水线(多条指令可重叠执行)等并行技术来提高执行效率。CPU可以将多条指令打乱来重新组织执行顺序而不按程序编码时的顺序进行执行即CPU的乱序执行(out-of-order executionOOE)。与此同时编译器在很多情况下(例如优化等)也会对指令执行顺序进行调整。基于此不论是编译器还是处理器CPU都会对程序指令进行重排序。通过下面这个示例即可观察到重排序这一现象public class Ordering { public static int x 0; public static int y 0; public static void test1() throws Exception { HashSet resultSet new HashSet();for( long i0; i500000*100); i ) { x 0; y 0; Map map new ConcurrentHashMap(); Thread threadA new Thread(() - {int a y; // ① x 1; // ② map.put(a, a); }); Thread threadB new Thread(() - {int b x; // ③ y 1; // ④ map.put(b, b); }); threadA.start(); threadB.start(); threadA.join(); threadB.join(); String result { a map.get(a) , b map.get(b) } ; resultSet.add(result); } System.out.println(resultSet); }}从下面执行结果中红框部分我们可以看到竟然出现a1,b1的执行结果其可以说明在程序的执行顺序中 ②比③先执行、④比①先执行。而要满足上述的执行顺序要么是因为②比①先执行要么是因为④比③先执行。即程序指令发生了重排序figure 6.pngAs-If Serial 语义As-If Serial 语义是指无论做怎样的重排序单线程的执行结果都不应被改变。即在本线程内进行观察程序的执行是有序的而没有乱序执行看上去是串行的。考虑下面的例子下面3行代码的顺序是 ①-②-③但是由于①与②之间不存在任何数据依赖关系所以编译器、处理器可以对①、②操作进行重排序。即实际的执行顺序可能是 ②-①-③虽然对指令进行了重排序但并不影响最终的结果double pi 3.14159; // ①double r 2.0; // ②double s pi * r * r; // ③所以说遵守As-If Serial语义的编译器、Runtime和硬件(CPU等)共同把单线程程序保护了起来即在程序中不应能够观察到重排序的效果。其为开发单线程程序的开发者创建了一个幻觉即单线程程序是按程序编码的顺序来执行的。Java内存模型在单线程中的有序性可由As-If Serial语义提供保证Happens-Before 先行发生原则在Java的多线程程序中如果在一个线程中观察另外一个线程则可以发现其所有的操作都是无序的。其原因在于指令的重排序、工作内存与主内存同步延迟。为此Java提供了volatile、synchronized两个关键字来保证线程间操作的有序性。而如果在Java内存模型中所有的有序性都仅仅依靠volatile、synchronized来实现的话那么就会导致我们在实际开发多线程程序时非常繁琐。为此Java内存模型中提出了一个 Happens-Before 先行发生原则其定义了两项操作之间的偏序关系。当A操作先行发生于操作B其含义是在发生B操作之前A操作产生的影响(包括但不限于修改共享变量的值、发送消息、调用方法)能够被B操作观察到程序顺序规则 在一个线程内按照分支、循环等控制流等顺序编写在前面的操作先行发生于书写在后面的操作 监视器锁规则 一个unlock操作先行发生于后面对同一个锁的lock操作。这里的后面是指令在执行时间上的先后顺序 volatile变量规则 对一个volatile变量的写操作先行发生于后面对这个变量的读操作。这里的后面是指令在执行时间上的先后顺序 线程启动规则 Thread对象的start()方法先行发生于此线程的每一个动作 线程终止规则 线程中的所有操作都先行发生于对此线程的终止检测我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行 线程中断规则 对线程interrupted()方法的调用先行发生于被中断线程的代码检测到中断事件的发生可以通过Thread.interrupted()方法检测到是否有中断发生 对象终结规则 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始 传递性 如果操作A先行发生于操作B、操作B先行发生于操作C那么可以得出操作A先行发生于操作C的结论在JMM中这些原则无需任何其他同步手段协助就已经存在。故在实际的开发过程中Happens-Before 先行发生原则是我们判断数据竞争、线程安全的主要依据可以在编码中直接应用。如果两个操作之间的关系不在上述原则之列且无法从上述原则中推导出来那么就无法保证他们的执行顺序即发生重排序当然值得一提的是Happens-Before原则实际上是对Java内存模型的一种近似性描述不够严谨但是可以方便我们日常开发应用参考。例如在一个线程中存在如下代码double pi 3.14159; // ①double r 2.0; // ②double s pi * r * r; // ③根据程序顺序原则我们可以得到下面的三个偏序关系①先行于③发生②先行于③发生①先行于②发生前2个偏序关系显然是必要的但是对于第3个偏序关系则不是必要的。也就是说①、②发生重排序②先行于①发生并不会改变程序的执行结果。故如果Happens-Before所禁止的重排序并不会改变程序的执行结果JMM将不会要求编译器、处理器来禁止该重排序。即在此种条件下JMM允许进行重排序。这样做的目的也是显然易见的即最大程度减少对编译器、处理器优化的限制Memory Barrier 内存屏障JMM向上给开发者提供了一些规则来保证并发编程时的一定有序性向下则是通过编译器在适当位置插入相关Memory Barrier 内存屏障指令禁止特定类型的重排序来实现相关操作的有序。Memory Barrier 内存屏障又称作Memory Fence内存栅栏其是对一类CPU指令的统称。其作用在于保证CPU执行相关操作时一定的有序避免CPU对相关指令的乱序执行。具体的JMM将内存屏障指令分为以下四种类型LoadLoad屏障 在指令序列 Load1; LoadLoad; Load2 中该类型屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作StoreStore屏障 在指令序列 Store1; StoreStore; Store2 中该类型屏障确保Store1数据的内存写入(使其对其他处理器可见)先于Store2及其后所有存储指令的操作LoadStore屏障 在指令序列 Load1; LoadStore; Store2 中该类型屏障确保Load1数据的装载先于Store2及其后所有存储指令的操作StoreLoad屏障 在指令序列 Store1; StoreLoad; Load2 中该类型屏障确保Store1数据的内存写入(使其对其他处理器可见)先于Load2及其后所有装载指令的操作。在大多数处理器的实现中该类型屏障由于同时具备其他三个类型屏障的效果所以其是一个万能屏障。当然该屏障开销也是最大的参考文献Java并发编程之美 翟陆续、薛宾田著深入理解Java虚拟机·第2版 周志明著JSR-133: Java Memory Model and Thread Specification