JMM是一个抽象的概念:描述的是一组围绕原子性、有序性、可见性的规范。其定义程序中各个变量的访问规则,即虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量是共享变量。
JMM规定:所有共享变量存储在主内存中,每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存上进行,线程不能直接读写主内存的共享变量。不同的线程之间也无法访问对方工作内存中的变量,线程间的变量值的传递均需通过主内存来完成。
共享变量:所有实例域,静态域和数组元素都是放在堆内存中(即所有线程均可以访问到,可共享)。共享数据会出现线程安全的问题 非共享变量:局部变量,方法定义参数和异常处理器参数不会线程共享。非共享数据不会出现线程安全的问题
当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要额外的同步,或者在调用方式进行任何其他的协调操作,调用这个对象的行为都可以获取到正确的结果。出现线程安全的问题一般是因为主存和工作内存数据不一致性和重排序导致的。
并发编程主要解决两个问题:
JMM规定了一个线程对共享变量的写入何时对其他线程是可见的。如果线程A改了主存中的某一数据,而线程B不知道同时并发修改,这样在写回主存中就有一些问题。如果线程A要和线程B进行通信,要经过这两步:
但如果线程A更新数据后并没有及时写回到主存,而此时线程B读到了原本的数据,也就是过期的数据,这就出现了脏读现象。这个问题可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次修改遍历都能够强制刷新到主存,从而对每个线程都可见。
例:
主内存中i = 0 线程1: load i from 主存 // i = 0 i + 1 // i = 1 线程2: load i from主存 // 线程1还没将i的值写回主存,所以i还是0 i + 1 //i = 1 线程1: save i to 主存 线程2: save i to 主存 现在主存中的值还是1,可我们的预期值是2 复制代码
JMM定义了8个操作来完成主内存和工作内存的互相操作:
对于基本数据类型的读取和赋值操作都是原子性操作,即这些操作是不可中断的,要么做完,要么不做。如果有两个线程同时对i进行赋值,一个赋值为1,另一个为-1,则i的值要么为1要么为-1。
i = 2; //1 j = i; //2 i++; //3 i = i + 1; //4 其中,1是赋值操作,是原子操作,而234都不是原子操作。 2是读取赋值 3和4都是读取,修改,赋值 复制代码
JMM只是保证了单个操作具有原子性,并不保证整体原子性。synchronize关键字具有原子性:
public class AtomicExample { /*private AtomicInteger cnt = new AtomicInteger(); public void add() { cnt.incrementAndGet(); } public int get() { return cnt.get(); }*/ private int cnt = 0; public synchronize void add() { cnt++; } public int get() { return cnt; } public static void main(String[] args) throws InterruptedException { final int threadSize = 1000; AtomicExample example = new AtomicExample(); final CountDownLatch countDownLatch = new CountDownLatch(threadSize); ExecutorService executorService = Executors.newCachedThreadPool(); for (int i = 0; i < threadSize; i++) { executorService.execute(() -> { example.add(); countDownLatch.countDown(); }); } countDownLatch.await(); executorService.shutdown(); System.out.println(example.get()); } } 输出结果:1000 如果不加synchronize关键字,每次输出结果都小于1000 复制代码
一个线程修改了共享变量的值,其他线程能够立即得知这个修改。 JMM是通过在遍历修改后将新值同步回主存,在遍历读取前从主内存刷新遍历值来实现可见性。
实现方式:
在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序和工作内存与主内存同步延迟。在JMM中,允许编译器和处理器对指令进行重排序,重排序的过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。 实现方式:
为什么要进行指令重排序?
现在的CPU都是采用流水线来执行指令的,一个指令的执行有:取指、移码、执行、访存、写回五个阶段,多条指令可以同时存在流水线中同时被执行。流水线是并行的,也就是说不会在一条指令上耗费很多时间而导致后续的指令都卡在执行之前的阶段。我们编写的程序都要经过优化后(编译和处理器对我们编写的程序进行优化后以提高效率)才被运行。优化分为很多种,其中一种就是重排序。即重排序就是为了提高性能。
重排序的两大规则: as-if-serial规则和happens-before规则
定义: 不管怎么进行重排序,单线程程序的执行结果不能被改变 。编译器,runtime和处理器都必须遵守as-if-serial语义。 为了遵守该规则,编译器和处理器不会对存在数据依赖关系的操作做重排序,如果不存在数据依赖关系,那么这些操作可能被编译器和处理器重排序,就比如一个求长方体面积:
int a = 2; //A int b = 4; //B int c = a * b; //C 复制代码
其中,AC存在数据依赖关系,BC也存在,而AB不存在,所以在最终执行指令序列的时候,C不能排在AB的前面(这样会改变程序的结果),但是AB并没有数据依赖性关系。也就是说编译器和处理器可以重排AB之间的执行顺序,先B后A,先A后B都可以。as-if-serial规则把单线程程序保护了起来,这也就就是说遵守as-if-serial语义的编译器、runtime和处理器给了我们一个幻觉:单线程的程序是按照顺序来执行的。其实并不是,as-if-serial语义使程序员无需担心重排序的影响,也无须单行内存可见性的问题。
1.JMM对程序员的保证: 如果操作A先行发生与操作B,在操作B发生之前,操作A的影响(修改主内存中共享变量的值、调用方法等)是操作B可见的 。
2.JMM对编译器和处理器重排序的约束规则: 两个操作之间存在happens-before关系,并不意味具体实现时必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致。那么这种重排序在JMM之中是被允许的。
总结一下就是: 只要不改变程序的执行结果(单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行
这么说来,如果线程A的写操作write和线程B的读操作read之间存在happens-before关系,尽管write和read在不同的线程中执行,但JMM向程序员保证write操作对read操作可见。
以下是JMM天然的先行发生关系,如果两个操作之间没有下面的关系,并且无法从下面的关系推导,则jvm可以对其随意的进行重排序:
重排序的分类:
定义: 如果两个操作访问同一个变量,且这两个操作有至少有一个为写操作,此时这两个操作就存在数据依赖性 三种情况: 读后写、写后写、写后读 。只要重排序两个操作的执行顺序,那么程序的执行结果将会被改变。 如果重排序会对最终执行结果产生影响,编译器和处理器在重排时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。例如:刚才的计算长方形面积的程序,长宽变量没有任何关系,执行顺序改变也不会对最终结果造成任何的影响,所以可以说长宽没有数据依赖性。
class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a * a; //4 …… } } } 复制代码
我们开两个线程AB,分别执行writer和reader,flag为标志位,用来判断a是否被写入,则我们的线程B执行4操作时,能否看到线程A对a的写操作?不一定,12操作并没有数据依赖性,编译器和处理器可以对这两个操作进行重排序,也就是说可能A执行2后,B直接执行3,判断为true,接着执行4,而此时a还没有被写入。这样多线程程序的语义就被重排序破坏了。
编译器和处理器可能会对操作重排序,这个是要遵守数据依赖性的,即不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。所以在并发编程下这就有一些问题了。