面试宝典

第三篇:锁的由来,并发三特性全解析

文章目录一、前言二、三大源头2.3.1第一,经典案例:双重检查创建单例对象2.3.2第二,我们认为的new操作:instance=newSingleton();2.3.3第三,实际优化后的执行路径:instance=newSingleton();2.1.1理论:从单核CPU到多核CPU2.1.2实践:多线程可见性问题2.1缓存导致可见性问题2.2线程切换带来的原子性问题2.3编译优化带来的有序性问

文章目录



一、前言

不管什么语言,并发的编程都是在高级的部分,因为并发的涉及的知识太广,不单单是操作系统的知识,还有计算机的组成的知识等等。说到底,这些年硬件的不断的发展,但是一直有一个核心的矛盾在: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-

原创不易,完成人机校验,阅读全文

相关推荐