在不同的 CPU 架构中,为了避免因为指令重排序,或者缓存一致性问题,都提供了不同的内存屏障指令,同时在不同操作系统中,都提供了一种封装,封装了内存屏障。
在我们的 Java 线程中,如何能在不同操作系统,不同硬件,任然能保证线程安全性的?这就要引出 JMM (java 内存模型),它就是为了屏蔽操作系统的差异 ,让一套代码在不同平台下都能达到线程安全的目的。
一、什么是 JMM我们都知道 Java 程序是运行在 Java 虚拟机上的,Java 语言是跨平台跨语言,一处编译,多处运行。
JMM(Java 内存模型)是一种符合内存模型规范的,屏蔽了各种硬件和操作系统差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java 内存模型规定了所有变量都存放在主内存中,每个线程都有自己的工作内存,线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而 不能直接读写主内存。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。流程如下:
可见性导致的原因,总结如下:
(1)CPU 高速缓存
(2)指令重排序
但是也不是所有的程序指令都会存在可见性可指令重排序问题。
二、Happens - Before 模型前面说了这么多,都是为了讲清楚到底是什么原因导致了在多线程环境下的可见性和有序性问题,也说到了 volatile 关键字的本质,通过 #Lock 指令 解决可见性问题。
那么有哪些情况是不通过添加 Volatile 关键字,也能保证在多线程的情况下还能保证可见性和有序性的?
从 JDK 1.5 开始,引入了一个 Happens - Before 的概念来说明多线程操作共享变量的可见性问题,所以我们可以认为在 JMM 中,如果一个操作的执行结果需要对另一个线程可见,那么这两个操作必须要存在 Happens - Before 关系,这两个操作可以是同一个线程的,也可以是不同线程。
1、程序顺序规则一个线程中的每个操作,Happens - Before 这个线程中的任意后续操作,可以简单的认为是 as - if - serial ,意思是,不管怎么重排序,单线程的执行结果不能改变。
a、处理器不能对存在依赖关系的操作进程重排序,因为重排序会改变程序的执行结果。
b、对没有依赖关系的指令,即使重排序,也不会改变在单线程环境下的执行结果。
具体来看下面这段代码,A 和 B 允许重排序,但是 C 是不允许重排,因为存在依赖关系。根据as-if-serial 语义,在单线程环境下, 不管怎么重排序,最终执行的结果都不会发生变化。int a=2; //A int b=2; //B int c=a*b; //C2、传递性规则
还是上面的代码,我们知道 :
A Happens - Before B
B Happens - Before C
A Happens - Before C
这三个happens-before关系,就是根据happens-before的传递性推导出来的。很多同学这个时候又有 疑惑了,不是说,A和B之间允许重排序吗?那是不是A happens-before B不一定存在,也可能是B可以重排序在A之前执行呢?
没错,确实是这样,JMM不要求A一定要在B之前执行,但是他要求的是前一个操作的执行结果对后一 个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一直,这种情况下,是允许重排序的。
不管 A 和 B 是否重排序,A 和 B 都 Happens - Before C 是一定存在的。
3、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成立。
这里也是因为volatile 修饰的重排序规则的存在,导致 1 和 2 是不允许重排序的,在 volatile 重排序规则表 中,如果第一操作是普通变量的读/ 写,第二个操作是 volatile 的写,那么这两个操作之间不允许重排 序。这里有可能会有疑问,前面讲的程序顺序规则中,在单线程中,如果两个指令之 间不存在依赖关系,是允许重排序的,也就是1 和 2的顺序可以重排,那么是不是意味着最终4输出的结果是0呢?
下面是 volatile 关键字重排序规则:
4、监视器锁规则一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作。
5、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();
}
6、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
7、DCL问题 单例模式
//instance=new DCLExample();
a、为对象分配内存
b、初始化对象
c、把内存空间的地址复制给对象的引用
指令重排序后
a、为对象分配内存
b、把内存空间的地址复制给对象的引用
c、初始化对象(还没有执行的时候。
造成不完整对象
三、RennerantLock 的实现原理锁的核心功能:满足互斥性,同一时刻只允许一个线程执行。
为了达到这个目的,怎么实现。
(1)锁的抢占,需要有一个标志位来标记,达到互斥。
(2)抢占锁怎么处理,没抢占锁怎么处理,(自旋,等待,排队)
(3)抢占锁的释放过程,怎么处理。LockSupport.unpark();
(4)抢占锁的公平性,公平锁/非公平锁
如何让线程等待?
(1)wait/notify (线程通信机制,无法指定唤醒某个线程)
(2)LockSupport.unpark/unpark; (阻塞一个指定线程)
(3)condition await/signal
没有抢占到锁的线程会构成一个双向链表。
如上图,ThreadA 调用 unlock 方法释放锁 会从AQS 队列中 head 节点的下一个节点 LockSupport.unpark(ThreadB); ThreadB 会自旋去抢占锁。 每个节点都会做自旋操作,知道抢占锁为止,如果通过一次自旋操作没有抢占到锁,LockSupport.park(this) 阻塞当前线程。
公平锁和非公平锁的公平性体现在抢占锁的临界点,刚好抢占到锁。
公平锁要先考虑到等待队列中是否有线程阻塞等待。
非公平锁进来抢占锁的线程直接抢占锁,不用先考虑等待队列。



