前言
今天聊聊java中的并发的理论知识。这是后面我们分析JUC源码的理论基础。
happens-before原则
原子性,有序性,可见性是并发问题的源头。
CPU 增加了缓存,以均衡与内存的速度差异;却导致了程序的可见性问题!
操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;却导致了原子性问题
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用;却导致了有序性问题
所以总结起来就是 cpu缓存导致了可见性问题,多线程的切换导致了原子性问题,编译优化导致了有序性问题!
那么我们只需要禁用缓存 禁用编译优化就能解决其中的 有序性和可见性问题。但是也不能一股脑的全部都禁用了而是需要合理的禁用。
这个时候JMM就出来了,它是一个很复杂的概念,我们简单的理解一下就是JVM 提供如何按需禁用缓存和编译优化的方法,这些方法包括volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
-
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
这个很容易理解 在一个线程中的前面的操作肯定是对后续操作可见的。int a = 1; //1 int b = 2; //2 int c = a + b ; //3
3处访问 a,b是一定能访问到的,2处如果要访问 a也是能成功访问的。
-
管程规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
private int a = 1; private synchronized void add(){ a = 2; } private synchronized void get(){ System.out.println(a); }
假如线程A执行 add方法 线程B 随即get方法 是一定能获取线程A更改之后的值的。
-
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
private int a = 1;
private volatile boolean flag = false;
private void change(){
a = 3; //1
flag = true; //2
}
private void get(){
if (flag){ //3
//a = 3 //4
}
}
线程A执行change 线程B随即执行get 获取的a = 3
-
传递性:如果A happens-before B , B happens-before C 那么A happens-before C
上面的例子对这个规则也适应:1 happens-before 2 ,2happens-before3 ,1happens-before3
-
start()规则:如果线程A执行了B.start()(启动线程B) ,那么A线程的B.start() 操作 happens-before 于线程B中的任意操作
a = 3; //1
Thread thread = new Thread(() -> {
System.out.println(a); //2
});
a = 5; //3
thread.start(); //3
代码3 happens-before 2 所以输出a值为5
- join()规则:如果线程A执行B.join() 并成功返回,那么线程B中的任意操作happens-before 于线程A从B.join() 操作成功返回
Thread thread = new Thread(() -> {
a = 7; //1
});
thread.start(); //2
thread.join(); //3
System.out.println(a); //4
1 happens-before 4 这里输出 7
通过上面简单的介绍 synchronized是保证可见性就是 happens-before中的管程规则。
那么JUC包中的Lock是如何保证可见性的呢?
MESA管程模型
并发编程两个棘手的问题:互斥 和同步
互斥:解决同一时刻对共享资源只有一个线程能访问。
同步:解决线程的相互协调通信问题。
上面两个问题是整个行业面临的问题 并不是哪一门语言所面临的问题。所以既然是行业存在的问题那么一定会有行业解决方案-信号量和管程模型
java解决并发问题采用的是实现MESA管程模型。可能你会问:什么是管程?其实就是一个编程模型。只要简单的知道它是为了解决并发问题的编程模型。目前有三种管程模型:Hasen模型、Hore模型、和MESA模型,java语言采用的是MESA管程模型。
我们先看一个图
上面就是管程模型 其实说白了就是对共享变量的封装 然后提供一个入口,同一时刻只能有一个线程进入管程。其他的线程将会在入口的等待队列中排队。那么我们说并发编程的两个问题其中的互斥是不是就解决了?
同一时刻只有一个线程能进入管程,其他的线程在入口的等待队列中等待!
那第二个问题同步怎么解决呢?
例如线程A进入管程 线程B在入口的等待队列中等待,如果某一个条件满足 线程A调用了一个对象的wait()方法,那么线程A就会在这个对象上等待,更官方一点我们就把这个对象的等待队列称作 条件变量等待队列。那么线程B进来了 在这个对象上调用了notify方法通知线程A可以从这个条件变量队列中醒来了。这样就解决了线程的相互协作问题了。
所以管程模型能解决并发中的两大难题。
上面我们说了线程B调用对象的notify方法通知线程A可以执行了,那么线程A什么时候醒?这个问题就是上述三种管程模型的最主要的区别:
- 在Hasen模型中 要求notify方法放到代码的最后,这样B通知完A后 B就结束了,然后A再执行这样就能保证同一时刻只有一个线程执行
- 在Hore模型中,B通知完A后,B阻塞(进入阻塞队列),A马上执行;等A执行完之后,再唤醒B。比起Hasen模型B多了一次阻塞唤醒的操作。
- 在MESA管程模型中,B通知完A后,B还是会继续执行,A并不立即执行,仅仅是从条件变量的等待队列进入到入口的等待队列(这里注意 A再次执行时 可能条件又不满足了,所以需要循环的方式检查条件变量)。但这样的好处是 notify不用刻意的放在代码的最后,B也没有多余的阻塞唤醒操作。
说到这里 我们java的 synchronized 不正是这样操作的吗!所以 synchronized 就是一个简单的管程模型。为什么是一个简单的管程模型?因为它只有一个条件变量 也就是只有一个条件等待队列。 例如如下代码,当前线程就在 obj上面等待了。
synchronized(obj){
obj.wait();
}
那JUC中的lock-condition是不是呢?答案是肯定的! 它是一个标准的管程模型的实现,只不过是通过java代码实现的。
通过上面的一通介绍 其实也不难发现 管程就是用几个队列来解决并发编程的互斥和同步问题的。入口等待队列解决互斥问题!条件等待队列解决同步问题! 这个我们后面分析JUC Lock的实现的基础 AQS的时候会再次体现!
结束语
本篇文章介绍比较重要的两个概念:一个是java内存模型中的 happens-before原则,另一个是解决并发编程问题的管程模型。理解了这两个概念对于你去理解synchronized关键字 和JUC包下面的锁会很有帮助。