深入理解JMM和并发三大特性(上)
前言
JMM属于整个Java并发编程中最难的部分也是最重要的部分(JAVA多线程通信模型——共享 内存模型),涉及的理论知识比较多,我会从三个维度去分析: JAVA层面、JVM层面 、硬件层面。 在了解JMM和并发编程之前先来看看什么是并发,多线程有什么作用,并发编程出现bug的根本原因是什么。 并发和并行 并发和并行目标都是最大化CPU的使用率 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。 多线程的作用 对于多线程的作用无法有这三个:同步、互斥、分工。 同步:指的是A线程的结果需要依赖B线程的结果。比如说用户访问tomcat开启一个tomcat的线程。而tomcat线程回去访问应用程序线程,应用程序线程又会调用jdbc的线程访问数据。拿到一系列结果后放回给tomcat 在放回给用户。这就是线程之间的同步协作。 互斥:指的是A线程在使用这个资源,其他线程无法使用,必须等到A线程释放后才能访问该资源。比如在数据库中对某个数据加了写锁,在一个线程进行写操作的时候另一个线程无法访问到该数据。 分工:每个线程分配不同的任务,最后结果汇总起来。比如说在计算很大数据是,开启多个线程,每个线程计算一部分,最后把所有线程计算的结果汇总 并发编程出现bug的根本原因 根本原因在于:可见性、原子性和有序性问题。这也是并发的三大特性
并发编程三特性
可见性 当一个线程修改了共享变量的值,其他线程能够看到修改的值 。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介 的方法来实现可见性的。 如何保证可见性:
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized 关键字保证可见性。
- 通过 Lock保证可见性。
- 通过 final 关键字保证可见性
有序性 即程序执行的顺序按照代码的先后顺序执行 。JVM 存在指令重排,所以存在有序性问题。 如何保证有序性:
- 通过 volatile 关键字保证可见性。
- 通过 内存屏障保证可见性。
- 通过 synchronized关键字保证有序性。 通过 Lock保证有序性。
原子性 一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行 。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。如何保证原子性:
- 通过 synchronized 关键字保证原子性。
- 通过 Lock保证原子性。
- 通过 CAS保证原子性。
下面深入分析这三大特性,在分析三大特性之前还需要了解JMM的内存模型
JMM内存模型
JMM定义: Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的: 规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。 JMM与硬件内存架构的关系 Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系: 内存交互操作 关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
lock(锁定) :作用于主内存的变量,把一个变量标识为一条线程独占状态。unlock(解锁) :作用于主内存变量,把一个处于锁定状态的变量释放出来,释放 后的变量才可以被其他线程锁定。read(读取) :作用于主内存变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用load(载入) :作用于工作内存的变量,它把read操作从主内存中得到的变量值放 入工作内存的变量副本中。use(使用) :作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign(赋值) :作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store(存储) :作用于工作内存的变量,把工作内存中的一个变量的值传送到主 内存中,以便随后的write的操作。write(写入) :作用于主内存的变量,它把store操作从工作内存中一个变量的值 传送到主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作;如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。 但
Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行 。 - 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 (load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过 了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用 这个变量前需要重新执行load或assign操作初始化变量的值 - 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许 去unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和 write操作) 。
可见性深入分析
下面通过一个例子对可见性进行深入分析
package demo;
public class visibilityDemo {
private boolean flag = true;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
visibilityDemo test = new visibilityDemo();
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
Thread.sleep(1000);
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
解释一下,load方法是一个while循环,依赖于flag这个变量,refresh方法修改flag变量值。执行main方法,首先flag是true,因此执行load方法是一个死循环,refresh修改flag为false,能不能结束这个循环呢?看一下执行结果 并没有跳出循环。为什么没有跳出循环呢?这里涉及到并发编程的可见性。就是说B线程修改了flag的值,但是这个值对于A线程来说不可见。那为什么不可见,我们来了解一下线程的内存数据是如何调用的。 在看下这个图,假设线程1在执行while方法,首先会先从自己的工作内存当中加载flag,但是第一次加载的时候工作内存中没有flag这个值,那么就会从主内存中通过read、load获取到flag=true到工作内存中,线程1通过use来使用这个flag。此时线程1在执行死循环。 假设此时线程三修改这个flag的值,同样第一次线程三的工作内存中没有flag,就会从主内存中通过reda、load加载到工作内存中,通过assign对flag赋值为false,然后通过store、write写入到主内存中。此时主内存中的flag=false。 但是线程一中的工作内存有这个flag的值为true,所以不会重新从主内存获取flag的值。因此线程三对flag的修改对于线程一来说是不可见的。 看了上述的过程就会产生一系列的问题,比如
- 说什么时候可见,就是说什么时候会重新从主内存获取flag呢?
简单的来说当线程一的工作内存中的flag没有了,自然会重新从主内存中获取,那么此时flag就是false了。当然还有其他的手段,下面会讲到。 - 那什么时候工作内存的falg会没有呢?
- 什么时候会刷新主内存的值,是一修改就刷新还是线程结束后在刷新?
在解释这些问题之前,先来看下如何修改这些代码能够跳出循环,再根据这些代码一一解释。
方案1:在flag 加上 volatile 即 private boolean flag = true; 改为 private volatile boolean flag = true; 结果:跳出循环 方案2:在 count 加上 volatile 结果:跳出循环 方案3:使用内存屏障 结果:跳出循环 方案4:使用Thread.yield() 结果:跳出循环 方案5:使用System.out.println() 结果:跳出循环 方案6 使用int count 类型换成Integer 方案7:使用凭证 等等还有很多方案,以上方案都是能够满足可见性的。 下面具体一个个方案来解释:首先要从java层面是很难去解释,要从JMM角度去解释,JMM角度上面有解释过了也就是如何让工作内存重新从主内存加载flag这个值。在解析这些方案之前先回答下上面提出的几个问题 什么时候会刷新主内存的值,是一修改就刷新还是线程结束后在刷新? 可以这么理解,如果不做任何额外操作的话,在线程中修改变量的话是不会马上刷新主内存的值的,当这个线程对这个变量不使用的情况下,那么这个变量就会回收,在回收前就会刷新主内存的值。 什么时候工作内存的falg会没有呢? 线程中的缓存是有时间限制的,内存空间本身就比较小,如果数据多了不够放了自然会淘汰之前的缓存。或者在while里面执行了1毫秒,才用到这个falg,那么这个falg就会被淘汰。就是说缓存淘汰时间不到1毫秒。或者一些其他操作也会导致缓存淘汰。这涉及到缓存淘汰的机制和硬件层面有关,这就超纲了,知道有这么回事就好。 方案解析 方案一解析:为什么加上volatile 能够跳出循环 首先volatile是java的关键字,要找原因要去看jvm的源码。 volatile在hotspot的实现 这串代码意思是判断是不是volatile修饰的,如果是执行OrderAccess::streload() ,OrderAccess::storeload() 这个意思是加上内存屏障。这个内存屏障是jvm层面,不要跟其他搞混了。jvm的内存屏障主要有四种:storestore loadload storeload loadstore OrderAccess::streload() 在linux系统x86中的实现
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
if (os::is_MP()) {
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
实际上OrderAccess::streload() 调用了fence() 方法。首先这个fence() 方法会先判断处理器是不是多核的,如果是单核的不会出现可见性问题。如果是多核的 会加上 ("lock; addl $0,0(%%rsp) 这一串代码,这是汇编层面的指令。可以叫做lock前缀指令 也可以理解为内存屏障的意思。汇编指令就和硬件有关了。来看下lock前缀指令的作用
- 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执 行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很 大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock前缀指令的执行开销。
- LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
- LOCK前缀指令会等待
它之前所有的指令完成、并且所有缓冲的写操作写回内存 (也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新 store buffer的操作会导致其他cache中的副本失效 。
概念有点多,来总结一下。也即是volatile在jvm的实现中会调用OrderAccess::streload() ,OrderAccess::streload() 调用fence() 。fence() 存在lock前缀指令。这lock前缀指令把B线程修改后的falg值立即写入到主内存,并且让其他副本也即是A线程中的falg缓存失效,那么A线程就会从主线程获取flag的值。
方案二和方案一相同。反正volatile就是让B线程flag的assign操作完成立即store、write写入主内存。并且让A线程的flag失效。
方案三使用内存屏障和LOCK前缀指令效果一样。内存屏障这里先了解下后续会深入理解
方案四解析:为什么使用Thread.yield() 能够跳出循环 因为Thread.yield() 会释放时间片,释放时间片就会有上下文切换,上下文切换是这样的,A线程在执行while循环flag为false,在切换上下文之前会先保存这个时间片的产生的值,就是同步回主内存。在下一次的轮到A线程执行的时候会从主内存总获取上下文的值继续执行。由于JMM规定了:不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。所以在A线程上下文切换时flag不会同步回主内存。并且上下文切换大概需要几毫秒的时间,所以A线程中的flag早就被淘汰了,下一次会直接读取主内存的flag的值,由于refresh方法修改了flag的值并且刷新到主内存当中,所以主内存falg在refresh方法结束后就已经是false了。这就是Thread.yield()能过跳出循环的原因
方案五解析:为什么使用使用System.out.println()能够跳出循环 看下println()的源码 synchronized底层也是调了fence()
方案六:使用Integer为什么可以跳出循环 可以看到Integer的value值被 final 修饰了。jvm对final定义是不可变的,保证final修饰的变量具有可见性
方案七使用了凭证和方案五类似,底层调用了fence()
以上就是可见性的分析。
总结
Java中可见性如何保证? 方式归类有两种:
- jvm层面 storeLoad内存屏障 ===> x86 lock替代了mfence
- 上下文切换 Thread.yield();
还有原子性和有序性没有分析、voliete深入理解,下一篇在分析
|