第三篇:锁的由来,并发三特性全解析
文章目录
一、前言
不管什么语言,并发的编程都是在高级的部分,因为并发的涉及的知识太广,不单单是操作系统的知识,还有计算机的组成的知识等等。说到底,这些年硬件的不断的发展,但是一直有一个核心的矛盾在:CPU、内存、I/O设备的三者的速度的差异。这就是所有的并发的源头。
CPU与内存:缓存,CPU增加了缓存,以均衡与内存的差异;(但是带来可见性问题)
CPU与IO设备:进程、线程,操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;(但是带来原子性问题)
编译程序指令重排序,编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。(但是带来有序性问题)
常见地,synchronized和Lock都可以同时解决原子性、可见性、有序性三个问题,volatile只能解决可见性和有序性两问题,要求操作必须是原子的,否则无法保证线程安全。
二、三大源头
2.1 缓存导致可见性问题
2.1.1 理论:从单核CPU到多核CPU
单核:所有的线程都是在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是同一个CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的;
多核:每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没有那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存(CPU的解决的方案:MESI协议)。
单核CPU与多核CPU对比如下图:

金手指:一句话小结缓存在多线程下的可见性问题
1、单核cpu,只有一个cpu,所以只有一个缓存,所有的内存都是使用这个缓存,一旦缓存内存修改,所有cpu都可见,没有可见性问题
2、多核cpu,有多个cpu,每一个cpu中一个缓存,所以n个cpu就有n个缓存,n个缓存与同一个主存交互数据,主存中的数据对于所有缓存可见,但是不同缓存之间的数据是不可见的。
多核cpu单线程下,缓存从主存中拿数据,运算,然后再存入主存,没有问题;
多核cpu多线程下,两个线程使用两个不同的cpu,两个cpu缓存都从主存中拿数据,运算,然后再存入主存,这个过程中,每一个线程都是基于自己CPU缓存里的count值来计算,但是自己的缓存数据的修改只有自己可见,造成可见性问题。
3、小结:从 CPU-缓存-主内存 到 执行引擎-工作内存-主内存,对于JMM,就是各个线程之间的工作内存不可见,所以造成可见性问题。
2.1.2 实践:多线程可见性问题
测试一下可见性,书写以下的代码:
public class Test {
private static long count = 0; // 类变量count
// 添加一万
private void add10K() {
int idx = 0;
// 当小于10000的时候不断加 while (idx++ < 10000) {
count += 1; } }
public static long calc() throws InterruptedException {
final Test test = new Test();
// 创建两个线程,这两个线程执行 add() 操作
Thread th1 = new Thread(test::add10K);
Thread th2 = new Thread(test::add10K);
// 启动两个线程
th1.start();
th2.start();
th1.join(); // 暂停主线程,加入thread1,thread1执行完之后执行main
th2.join(); // 暂停主线程,加入thread2,thread2执行完之后执行main return count; // 返回类变量count }
public static void main(String[] args) throws InterruptedException {
System.out.println(calc()); // 执行,返回count }}1234567891011121314151617181920212223242526272829运行结果如下:可以见得不是20000的值。
110221
原因:原子性无法保证,线程不安全,count += 1; 不是原子操作,是三步操作,可以被打断。
其他的,可见性和有序性都无法保证(即使用volatile修饰保证可见性和有序性也没用,原子性无法保证)
解决方法:同步阻塞保证原子性(synchronized)和非同步阻塞保证原子性(cas)
synchronized == for + if(cas),注意cas有一个ABA问题,虽然这里累加不涉及
我们假设线程A和线程B同时开始执行,那么第一次都会将count=0读到各自的CPU缓存里,执行完count+=1之后,各自CPU缓存里面的值都是1,同时写入内存后,我们后发现现在内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,两个线程都是基于CPU缓存里的count值来计算,所以导致最终count的值都是小于20000的。这就是缓存的可见性的问题。
总结:一个线程对共享变量的修改,另外一个线程能够立即看到,我们称为可见性.
2.2 线程切换带来的原子性问题
原子性定义:我们把一个或者多个操作在CPU执行过程中不被中断的特性称为原子性。
1、时间片的引入,CPU分片执行:为了提供CPU的利用率,操作系统允许某个进程分时使用CPU,当一个进程执行IO操作时候(即count+=1 读盘、操作、写盘三操作中的读盘/写盘),这个时候这个进程将自己标记为休眠的状态,并且让出CPU使用权,让给其他线程执行,这样CPU的使用率就会上来。另一方面,如果这个时候有一个进程要进行IO操作,这个时候会发现已经有进程在IO操作,这个时候新来的进程就会等待。当上个IO进程结束的时候,会再进行这个新来的IO进程,这样IO的使用率也上去了。
2、高级语言中的语句往往不是原子性的:所有的高级的语言一条语句往往需要CPU中的多条指令。例如count+=1,至少需要三条CPU指令:
指令1:首先,需要把变量count从内存加载到CPU的寄存器;
指令2:之后,在寄存器中执行+1操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)
3、操作系统任务切换,可以发生在任何一条CPU指令执行完。注意,是CPU指令,而不是高级语言里的一条语言,所以,高级语言中的一条语句是可以被中途打断的。

上面说的是,读磁盘-计算-写磁盘,这里演示的是 读内存-计算-写内存,实际上是一样的。
金手指:一句话解析分时时间片在多线程下的原子性问题
1、如果操作系统不支持分时使用CPU,即必须一个进程执行完成之后之后,再执行下一个进程,每一个进程的执行都不会被打断,每一个进程的执行都是原子的,不存在原子性问题。
2、如果操作系统支持分时使用CPU,一个进程执行读写磁盘或者读写内存的时候,会把CPU让出来,让给其他进程使用,如果获得CPU使用权的进程和当前进程修改同一变量,造成安全问题,该问题归根结底是因为打断了对一个变量读写的原子操作。
3、小结:从 CPU-主内存 到 执行引擎-工作内存-主内存,对于JMM,就是各个线程之间的工作内存访问主内存的操作无法原子化,所以造成原子性问题。
2.3 编译优化带来的有序性问题
2.3.1 第一,经典案例:双重检查创建单例对象
public class Singleton {
private Singleton(){}
private static Singleton instance;
public static Singleton getInstance() { if (instance == null) {
synchronized (Singleton.class) { if (instance == null)
instance = new Singleton(); } } return instance; }}12345678910111213注意:这里的懒汉式单例没有使用volatile关键字修饰instance变量禁止重排序,所有即使使用双层if判断,也是线程不安全的。
2.3.2 第二,我们认为的new操作:instance = new Singleton();
instance = new Singleton();
第一步,分配一块内存M;
第二步,在内存M上初始化Singleton对象,得到M的地址;
第三步,然后M的地址返回给instance变量。 // 最后赋值给instance变量
如果是这样执行,无论单线程还是多线程都不会有问题。
单线程不必多说,多线程下也是安全的(因为一定要第三步赋值给instance变量)
举例:线程A执行到第二步,假设被线程B线程切换,当然不会被切换,因为我们使用synchronized ,在instance没有完成赋值操作之前,线程A不会释放同步锁,所以线程B根本进不来。
所以,按照这个三步骤,可以保证多线程安全
2.3.3 第三,实际优化后的执行路径:instance = new Singleton();
instance = new Singleton();
第一步,分配一块内存M;
第二步,将M的地址赋值给instance变量;// 解释:这个时候Singleton对象还没有初始化,M地址为&M,但是注意,这里完成对instance变量赋值操作后,线程A就会释放同步锁,线程B就可以进来了,这就是指令重排序造成的懒汉式单例双重检查不安全的地方
第三步,最后在内存M上初始化Singleton对象。// 解释:这个时候再来初始化,但是内存M的地址没有给instance变量
如下,由于instance = new Singleton();底层对应的指令重排序,所以线程A第二步就是instance=&M,完成了对instance对象的修改,就会释放同步锁,就会被切换到线程B,这是instance==&M,因为instance已经被赋值修改,不再是null,所以线程B不会再执行自己的instance=new Singleton三步骤了,但是线程B看到M内存中没有初始化的Singleton对象,出错

金手指:一句话小结指令重排序在多线程下的有序性问题(懒汉式单例的双层if判断为例)
1、不使用指令重排序,单线程和多线程下没有线程安全问题
单线程不必多说,多线程下也是安全的(因为一定要第三步赋值给instance变量)
举例:线程A执行到第二步,假设被线程B线程切换,当然不会被切换,因为我们使用synchronized ,在instance没有完成赋值操作之前,线程A不会释放同步锁,所以线程B根本进不来。
所以,按照这个三步骤,可以保证多线程安全
2、使用指令重排序,单线程下没问题,多线程下线程安全问题
由于instance = new Singleton();底层对应的指令重排序,所以线程A第二步就是instance=&M,因为完成了对instance对象的修改,就会释放同步锁,就会被切换到线程B,这时instance==&M,因为instance已经被赋值修改,不再是null,所以线程B不会再执行自己的instance=new Singleton三步骤了,但是线程B看到M内存中没有初始化的Singleton对象,出错
3、小结:从 CPU-缓存-主内存 到 执行引擎-工作内存-主内存,对于JMM,就是各个线程之间的执行引擎 指令重排序,所以造成有序性问题。
三、Java中如何解决可见性和有序性问题?(JMM:Java内存模型)
3.1 可见性和有序性
根本的原因:缓存导致可见性,编译优化导致有序性。
解决办法:禁用缓存和编译优化。如果全部的禁用缓存和编译优化,那我们的程序的性能会很差,所以我们的操作是按需禁用缓存以及编译优化。
3.2 JMM处理可见性和有序性问题
JMM(Java内存模型)解决上面的问题主要是volatile、synchronized和final三个关键字,以及八项Happens-Before规则。
3.2.1 volatile:保证可见性
保证可见性不被破坏,就要禁用缓存,强制cpu运算器需要数据的时候直接从主存中拿,因为主存只有一块,对所有的线程都是可见的,从主存中拿就可以保证数据修改对所有线程可见,就解决了可见性问题。但是,不能对所有的变量都禁用缓存,会效率低下。
Java规定,对于使用volatile关键字修饰的变量,cpu对其读写,强制在主存中操作。但是,哪些变量需要使用volatile关键字修饰呢?这就是Java程序员的工作了,其实就是多线程临界区中使用的那些变量,一般只有一个或几个。
volatile保证可见性:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取和写入。
public class VolatileExample {
int x = 0;
volatile boolean v = false; // 使用volatile修饰,保证可见性,从主存中读写
public void writer() {
x = 42; v = true; // 写到主存中 }
public void reader() { if (v == true) {
//x为多少?
System.out.println(x);
} }}12345678910111213141516在JDK1.5之前可能会出现x=0的情况,因为变量x可能被CPU缓存而导致可见性问题。1.5之后对volatile语义进行了增强,就是利用Happens-Before规则。
3.2.2 Happens-Before规则(JVM中已经定义的有序性:前面一个操作的结果对后续操作是可见的)
1、程序次序规则:在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任何操作。(解释:同一个线程中,或单线程下,有序性不变,执行顺序就是编写顺序)
2、volatile变量规则:对一个volatile编程的写操作,Happens-Before于后续对这个volatile变量的读操作。(解释:volatile强制保证有序性,作为happen-before 8条中的一条)
3、传递规则:如果A Happens-Before B,且B Happens-Before C,那么A Happens-
原创不易,完成人机校验,阅读全文