什么是JMM
那什么是JMM呢?
首先,我们都知道Java程序是运行在Java虚拟机上的,同时我们也知道,JVM是一个跨语言跨平台的实现,也就是Write Once、Run Anywhere。
那么JVM如何实现在不同平台上都能达到线程安全的目的呢?所以这个时候JMM出来了,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行,流程图如下:
再总结一下: JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。
目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题
本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓
存,写缓冲区,寄存器以及其他的硬件和编译器优化。
JMM层面的内存屏障
那JMM如何保证可见性呢? 听懂了的同学肯定很清楚,JMM既然定义了内存访问的规范,那么它必然也定义了可见性的语义。
而在JMM中 ,针对底层提供的内存屏障指令进行了封装,提供了四类内存屏障指令。
load表示从主内存中读取一个数据到工作内存、 store表示工作内存中的数据存储到主内存,基于这个点我们简单来解读这几个指令的含义,实际上它就是针对CPU内存屏障指令的一个封装。
有了这样的内存屏障指令,那么我们就可以在合适的地方插入这些指令来避免CPU层面优化带来的可见性问题,那么在Java中,如何插入这些指令呢?那就需要回到本堂课的主题,Volatile指令。
回顾使用volatile关键字保证可见性
我们可以使用【hsdis】这个工具,可以获取JIT编译器生成的汇编指令来看看volatile进行的写操作带来的影响和变化。
在运行的代码中,设置jvm参数如下
- 【-server -Xcomp -XX:+UnlockDiagnosticVMOptions - XX:+PrintAssembly
-XX:CompileCommand=compileonly,App.(替换成实际运行的代码)】 - 然后在输出的结果中,查找下lock指令,会发现,在修改带有volatile修饰的成员变量时,会多一个lock指令。这个lock指令在前面我已经讲解清楚了,它相当于一个内存屏障,通过这个指令来解决可见性问题。
volatile源码实现
执行javap -v xxx.class,打印字节码
在字节码中,我们可以看到修饰了volatile关键字的属性,多了一个ACC_VOLATILE。
public static volatile boolean stop;
descriptor: Z
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
这个指令,会通过字节码解释器来执行,因为对于flag这个变量的
修改,会调用 putstatic , 它的实现在bytecodeInterpreter.cpp
文件中。
volatile实现的源码分析
在bytecodeInterpreter.cpp这个文件中,找到_putstatic这个指令的解析代码,如下。
主要看一下cache-is_volatile()这段代码。is_volatile就是判断当前变量是否为volatile修饰,这个方法的定义在accessFlags.hpp中。
其中cache变量是java代码中变量stop在常量池缓存中的一个实例,因为变量i被volatile修饰,所以cache->is_volatile()为真,给变量i的赋值操作由release_int_field_put方法实现。
实际上这个方法在orderAccess.hpp中只是一个定义,它的具体实现,又根据不同的CPU和操作系统,有不同的时实现。不知道大家还记得前面咱们讲的JMM这块的内容吗?我们说JMM是一个抽象内存模型,他可以让一套代码在不同的操作系统和硬件上提供统一的解决方案。然后我们通过搜索发现,orderAccess.hpp下有很多的实现。以orderAccess_linux_x86.inline.hpp为例。
inline void OrderAccess::release_store(volatile jint* p, jint v) {
*p = v; }
可以看到其实Java的volatile操作,在JVM实现层面第一步是给予了C++的原语实现。c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的memory barrier。被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化。
volatile:禁止编译器对代码进行某些优化.Lock :汇编指令,lock指令会锁住操作的缓存行(cacheline), 一般用于readModify-write的操作;用来保证后续的操作是原子的cc代表的是寄存器,memory代表是内存;这边同时用了”cc”和”memory”,来通知编译器内存或者寄存器内的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取)
这边的read/write请求不能越过lock指令进行重排,那么所有带有lock prefix指令(lock ,xchgl等)都会构成一个天然的x86 Mfence(读写屏障),这里用lock指令作为内存屏障,然后利用asm volatile(“” ::: “cc,memory”)作为编译器屏障. 这里并没有使用x86的内存屏障指令(mfence,lfence,sfence),应该是跟x86的架构有关系,x86处理器是强一致内存模型所以,总的来说,在JVM层面,对于loadload、loadstore、storestore只是利用了编译器的屏障防止编译器重排序,而对于storeload屏障,除了编译器屏障之外,还是用了lock指令作为内存屏障来防止cpu层面的指令重排问题
Happens-Before模型
前面说了这么多,都是为了讲解清楚,到底是什么原因导致了在多线程环境下的可见性和有序性问题。并且也了解了volatile解决可见性问题的本质。那么有没有哪些情况是,不需要通过增加volatile关键字,也能保证在多线程
环境下的可见性和有序性的呢?
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
程序顺序规则
一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial。
as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变。
- 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果。
- 对于没有依赖关系的指令,即便是重排序,也不会改变在单线程环境下的 执行结果。
具体来看下面这段代码,A和B允许重排序,但是C是不允许重排,因为存在
依赖关系。根据as-if-serial语义,在单线程环境下, 不管怎么重排序,最终
执行的结果都不会发生变化。
int a=2; //A
int b=2; //B
int c=a*b; //C
传递性规则
仍然看下面这段代码,根据程序顺序规则可以知道,这三者之间存在一个
happens-before关系。
int a=2; //A
int b=2; //B
int c=a*b; //C
- A happens-before B。
- B happens-before C。
- A happens-before C。
JMM不要求A一定要在B之前执行,但是他要求的是前
一个操作的执行结果对后一个操作可见。这里操作A的执行结果不需要对操
作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺
序执行的结果一直,这种情况下,是允许重排序的。
volatile变量规则
对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变
量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排,这
个规则前面已经分析得很透彻了,所以没什么问题,我们再来观察如下代
码,基于前面两种规则再结合volatile规则来分析下面这个代码的执行顺序,
假设两个线程A和B,分别访问writer方法和reader方法,那么它将会出现以
下可见性规则。
public class VolatileExample {
int a=0;
volatile boolean flag=false;
public void writer(){
a=1; //1
flag=true; //2
}
public void reader(){
if(flag){ //3
int i=a; //4
}
}
}
- 1 happens before 2、 3 happens before 4, 这个是程序顺序规则
- 2 happens before 3、 是由volatile规则产生的,对一个volatile变量的
读,总能看到任意线程对这个volatile变量的写入。 - 1 happens before 4, 基于传递性规则以及volatile的内存屏障策略共同 保证。
那么最终结论是,如果在线程B执行reader方法时,如果flag为true,那么意
味着 i=1成立。
监视器锁规则
一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个
锁的加锁操作。
int x=10;
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
start规则
如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作
happens-before线程B中的任意操作。
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
// 主线程调用 t1.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,x==10
});
// 此处对共享变量 x修改
x = 10;
// 主线程启动子线程
t1.start();
}
join规则
join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任
意操作happens-before于线程A从ThreadB.join()操作成功的返回。
Thread t1 = new Thread(()->{
// 此处对共享变量 x 修改
x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见
// 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见
// 此例中,x==100
DCL问题
//instance=new DCLExample();
- 为对象分配内存
- 初始化对象
- 把内存空间的地址复制给对象的引用
指令重排序后
- 为对象分配内存
- 把内存空间的地址复制给对象的引用
- 初始化对象(还没有执行的时候。
造成不完整对象
J.U.C(Java.util.Concurrent)
Lock ->synchronized
锁是用来解决线程安全问题的
ReentrantLock
重入锁 -> 互斥锁
ReentrantLock的实现原理
满足线程的互斥特性
意味着同一个时刻,只允许一个线程进入到加锁的代码中。 -> 多线程环境下,线程的顺序访问。
锁的设计猜想(如果我们自己去实现)
-
一定会设计到锁的抢占 , 需要有一个标记来实现互斥。 全局变量(0,1)
-
抢占到了锁,怎么处理(不需要处理.)
-
没抢占到锁,怎么处理
- 需要等待(让处于排队中的线程,如果没有抢占到锁,则直接先阻塞->释放CPU资源)。
- 如何让线程等待?
- wait/notify(线程通信的机制,无法指定唤醒某个线程)
- LockSupport.park/unpark(阻塞一个指定的线程,唤醒一个指定的线程)
- Condition
- 需要排队(允许有N个线程被阻塞,此时线程处于活跃状态)。
- 通过一个数据结构,把这N个排队的线程存储起来。
- 需要等待(让处于排队中的线程,如果没有抢占到锁,则直接先阻塞->释放CPU资源)。
-
抢占到锁的释放过程,如何处理
LockSupport.unpark() -> 唤醒处于队列中的指定线程.\ -
锁抢占的公平性(是否允许插队)
公平
非公平
AbstractQueuedSynchronizer(AQS)
- 共享锁
- 互斥锁
ReentrantLock的实现原理分析