首先关于Java并发的通信机制是基于共享内存实现的,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信,这对程序员是透明的,我们需要理解其工作机制,以防止内存可见性问题,从而编写出正确同步的代码。
同步指用于控制不同线程间操作发生相对顺序的机制,我们需要显式的指定方法或代码块需要在线程之间互斥执行。
由于Java的这种通信方式,一个线程要跟另一个通信,何时将共享变量刷新到内存,另一个又何时知道该去内存中读取,这就是内存可见性问题,而JMM就是解决这个问题的。
Java内存模型(JMM):决定一个线程对共享变量的写入何时对另一个线程可见。
有两点需要注意:1,这里何时指的并非时间而是某个动作的完成,当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷到主内存中,同时使其它处理器中的缓存失效,让其去主内存中读取该值;还有synchronized的锁释放,CAS操作。2,同步是显式的,是需要我们来做的。JMM对未同步或未正确同步的多线程程序只提供最小的安全性,也就是JMM保证线程读取到的值不会无中生有,要么是之前线程写入的值,要么是默认值(0,null,false)。所以“决定”并非正确保证之意,
首先来看下JMM下线程与主内存之间的关系问题。共享变量在主内存中,每个线程都有一个自己私有的本地内存,里面存储着内存中共享变量的副本。这里本地内存是对缓存,寄存器等的抽象。
来看看JMM的抽象结构示意图,来自《Java并发编程的艺术》:
假设两个线程A,B,A将其更新后的共享变量刷新到主内存,B到主内存中去读取该共享变量的值,实质上就是线程A在向线程B发送消息,基于的是主内存,JMM控制的就是主内存与每个线程的本地内存的交互。上面说Java线程间通信机制是隐式的,对程序员不可见,那么JMM就为我们提供了内存可见性的保证,对于正确同步的代码(指的是synchronized,volatile,final的运用),我们就可以得到正确的执行结果。
并发下程序的执行顺序问题:程序是按顺序执行的吗?我们在看程序代码时,总是按顺序来读的,我们假设它们在并发下就是这么执行的,是谁保障了这种顺序性?
关于重排序:
在执行程序时为了提高性能,编译器和处理器会对指令做重排序。编译器,处理器重排序,导致多线程的程序出现内存可见性的问题。JMM编译器重排序的规则会禁止特定类型的重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。JMM就是通过此来确保在不同的编译器和处理器平台上的内存可见性保证。
数据依赖性:两个操作访问同一个变量,且至少有一个为写操作,则二者之间存在数据依赖性。编译器与处理器不会改变存在数据依赖关系的两个操作的执行顺序,因为对它们的重排序会改变程序的执行结果。
注意数据依赖性指的是单个处理器的指令序列和单个线程中执行的操作,不同处理器和不同线程之间的数据依赖性不被考虑。
关于内存屏障:
StoreLoader 屏障同时具有其它三个屏障的效果。volatile的内存语义,final的内存语义都是通过上述内存屏障来实现的。
针对重排序JMM的基本方针就是:在不改变正确同步的程序的执行结果的前提下,尽可能为编译器和处理器的优化打开方便之门。
happens-before
用来阐述操作之间的内存可见性。如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作即可以是在一个线程内,也可以是不同线程之间。
happens-before是JMM最核心的概念。程序员基于它提供的内存可见性保证来编程。
hannpens-before定义:
1,如果一个操作hannpens-before另一个操作,第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前(这是对于程序员来说的,也就是你可以按这种顺序来理解程序)。
我们也就是依据此保证来理解阅读源码的。
2,两个操作之间存在hannpens-before关系,并不意味着Java平台的具体实现必须要按照hannpens-before关系指定的顺序来执行,只要重排序之后的执行结果与按照happens-before关系来执行的结果一致就可。
这是JMM对编译器和处理器重排序的约束原则。这一条阐述的是JMM设计遵守的一个基本原则:只要不改变程序的执行结果,想怎么优化都行。
上面这两条一个说第一个操作在第二这操作之前执行,一个说并不一定在之前执行,这部矛盾吗?只是阐述的侧重不同,程序员本身对重排序并不关心,我们关心的是执行结果不能被改变。
happens-before建立在JMM对编译器和处理器重排序的规则和实现之上,它保证了正确同步的多线程程序的执行结果不被改变;它给我们这样一种幻觉:正确同步的多线程的程序是按照happens-before指定的顺序来执行的。这里happens-before指定的顺序指的是hannpens-before规则中的-程序顺序规则,所以我们可以按照代码顺序来阅读代码。
hannpens-before规则
1,程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续的操作。
2,监视器锁的规则:锁的解锁happens-before随后对它的加锁。
3,volatile变量的规则:对一个volatile域的写happens-before于任意后续对它的读。
4,传递性:A happens-before B, B happens-before C,那么A happens-before C。
5,start()规则:线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before 于B线程的任意操作。
这意味着:线程A在执行ThreadB.start()之前对共享变量所做的修改,在线程B执行后都将对B可见。
6,join()规则:线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before 于线程A从ThreadB.join()操作成功返回。
这意味着:线程A执行操作ThreadB.join()并成功返回后,线程B的任意操作都将对线程A可见。
happens-before与JMM之间的关系:
一个happens-before规则其背后的实现依赖于多个编译器和处理器的重排序规则,我们不需要去掌握这些复杂的重排序规则及他们的实现,我们只需根据happens-before的规则来编程。
想想自己在阅读JUC下源码时是怎么理解那些正确同步的代码的,我们看到synchronized会想到互斥,锁的释放还会引起共享变量的刷新,一个线程的对锁的释放与随后获取的线程实质上是在通信;看到volatile会想到它的读/写是原子的,且与锁的获取/释放具有相同的内存语义;看到循环CAS想到原子操作,且它具有volatile读/写的内存语义;对于代码的执行顺序我们都默认是按顺序的,我们认为程序是按代码顺序来执行的,可编译器与处理器是会重排序的,那是谁给了你这种保障,让你有这种按顺序执行的幻觉?是JMM,你只要按照happens-before规则来编程,编写的程序是正确同步的,你就可以按顺序来理解它,编译器和处理器的重排序不会影响到你,因为JMM对他们的限制,禁止了那些会改变执行结果的重排序。
关于JMM与顺序一致性模型:
顺序一致性模型是一个理论参考模型,JMM和处理器内存模型在设计时通常以它为参照。JMM对正确同步的多线程程序的内存一致性做了如下保证:正确同步的程序的执行具有一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
那么我们就可以参考顺序一致性模型来理解我们的多线程的代码。
顺序一致性模型下的多线程程序执行情况:
首先模型有两大特点:1,一个线程的所有操作必须按照程序的顺序来执行。2,每个操作必须是原子且立即对所有线程可见,这样所有线程都将看到一个单一的操作执行顺序。
假设一正确同步程序,A线程3个操作执行后释放监视器锁,随后B获取该锁执行。其执行效果图:
对于正确同步程序JMM与顺序一致性模型执行的不同:JMM中临界区内的代码可以重排序,只要不改变程序执行结果。
参考
《Java并发编程的艺术》