并发编程01——并发初步专题

164 阅读12分钟

1、串行、并发与并行

在讨论并发编程问题的时候,应该知道什么是串行、并行和并发?

这已经是一个老生常谈的问题,面试官在问起时,我总会举给小孩喂零食的例子:两个小孩,一个家长,在串行情况下,就是一个孩子吃完零食再给另一个吃,并发情况就是一个家长给一个孩子喂一口,又去喂另一个孩子一口这样轮流吃饭的情况,而并行就是,两个家长同时喂两个孩子。这种方式虽然很容易理解,但毕竟面试官听了太多这样的例子,已经不会让他们觉得眼前一亮了。

昨天看书的时候看到一句话,觉得总结的非常到位:并发往往是带有部分串行的并发,而并发的极致就是并行,其实,并发说白了就是分配时间片进行串行执行,也就等于部分串行,而并发所追求的就是更加高效的执行,当处理器充足时,就可以达到极致的高效,也就是并行的方式。

继承Thread类和实现Runnable接口的区别

public class ThreadCreationCmp {
    public static void main(String[] args) {
        Thread t;
        CountingTask ct = new CountingTask();

        //获取处理器个数
        final int availableProcessors = Runtime.getRuntime().availableProcessors();

        for (int i = 0; i < 2 * availableProcessors; i++) {
            t = new Thread(ct);
            t.start();
        }

        for (int i = 0; i < 2 * availableProcessors; i++) {
            t = new CountingThread();
            t.start();
        }
    }

    static class Counter{
        private int count = 0;

        public void increment(){
            count++;
        }

        public int value(){
            return count;
        }
    }

    static class CountingTask implements Runnable{
        private Counter counter = new Counter();

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                doSomething();
                counter.increment();
            }
            System.out.println("CountingTask:" + counter.value());
        }

        private void doSomething(){
            //使当前线程休眠随机时间
            Random random = new Random();
            int randomNum = random.nextInt(100);
            try {
                Thread.sleep(randomNum);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class CountingThread extends Thread{
        private Counter counter = new Counter();

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                doSomething();
                counter.increment();
            }
            System.out.println("CountingThread:" + counter.value());
        }

        private void doSomething(){
            //使当前线程休眠随机时间
            Random random = new Random();
            int randomNum = random.nextInt(100);
            try {
                Thread.sleep(randomNum);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

2、竞态与竞态条件

竞态

一段程序在多线程状态下执行,有时候执行结果正确有时执行结果错误,这种因为时间分配造成的结果不一致的情况叫做竞态

看一下书中的例子:

多个线程worker0-3,它们在做把a + 1的操作,但是因为没处理并发问题,导致出现了问题:

时刻worker-0worker-2worker-3worker-1
t1把a值保存为0
t2从内存中读取a=0到寄存器从内存中读取a=0到寄存器
t3a=0自增1a=0自增1
t4从内存中读取a=0到寄存器把a=1从寄存器保存到内存把a=1从寄存器保存到内存
t5a=0自增1
t6把a=1从寄存器保存到内存

问题就是,首先就是t2时刻两个线程同时读取到了一个值,这样如果修改必定会造成并发问题;另外一点就是t4时刻,a=1在将要保存到内存之前,突然worker-0读取了旧值,造成后续的修改覆盖了刚才做的修改。

竞态会造成读取到过时的数据、还有丢失更新,从而影响线程安全性,也就是一个数据的更新没有体现到其他线程对数据的读取上,也就是读了旧值(读到了脏数据)。就是上面这个例子,罪魁祸首基本就是worker-0了,它读取到旧值,并且在别人更新数据后,又把数据给覆盖了。

竞态模式

竞态模式有两个,也就是这两种情况造成了竞态(数据不一致的情况出现):

  1. read-modify-write:读--改--写模式
  2. check-then-act:先检测后执行

读--改--写模式就是:其实还是上面这个例子,在对面修改了值但还没更新之前就已经读取,也就是读取了旧值,从而影响了其他线程的更新。

先检测后执行:比如要是a<10就把a+1,如果当前一个线程A读取到了值为9,另一个线程B也读取到了值为9,这个时候,A把值+1之后,明明线程B正确的操作是不加1(因为现在a=10,不满足条件),但是因为更新没有收到,造成还是进行了加1操作。

局部变量就不会造成竞态的情况。

线程安全问题的三种表现:原子性、可见性、有序性。

3、原子性

书中的定义说的是:某个线程的读、写操作,对于其他线程而言,要么都执行,要么都不执行,其他线程不会看到其中的中间操作。

而这个最常见的问题就是a++问题:也就是它是分为3部分:读取a的值,a+1操作,保存回主内存,这样就不是一个原子性操作。

保证了某变量的原子性,也就保证了它不受竞态影响。(废话,原子性就是要么都执行要么都不执行,也就确保了竞态不发生,因为竞态就是多线程情况下出现问题)

原子性操作是针对多线程而言的,单线程可以说没有原子性操作的概念

基本数据类型:byte、short、int、float、boolean、char类型和引用类型都是写操作原子类型,而long和double是写操作非原子类型,注意是写操作。

而对于long和double类型可以通过加上volatile就可以保证写操作的原子性,但是不能保证read-modify-write和check-then-act的原子性。

所有变量的读操作都是原子性。

实现原子性的两种方式:锁和CAS

锁(Lock)

锁具有排他性,这样就可以避免其他线程同时访问该共享数据,而锁几乎是在软件层面上实现的。

CAS

CAS是通过比较和交换的方式,这个更多的是从硬件的方式实现,可以算做硬件锁 ???????(看不懂....)。

image-20220108151718500

两个原子性操作,放一起就是原子性操作吗?

a = 2;
b = 1;

当然不是,线程A读取了a = 2和b = 1之后,然后其他人可能进行修改,这样肯定就不满足原子性的要求了。

4、可见性

操作系统方面的理解

什么是可见性呢?通俗点说,就是一个线程修改了共享变量,能否立刻让其他线程知道,否则还是会出现上面那种读完被修改并覆盖的线程安全问题。

而出现可见性的问题可能与硬件有关系:

img

我们每次都是把共享变量从主内存中读取到各自的寄存器中,再进行操作,这样就导致,该线程可能无法读取到其他线程对自己寄存器中的数据副本的更新,这样必然会出现可见性问题。

不过可以通过缓存一致性协议来解决。要保证可见性,我们就需要保证对数据的修改操作要及时写入高速缓存或主内存当中,而不是停留在写缓冲器中,这个操作叫做冲刷处理器缓存

而要保证数据修改后能及时拿到更新后的数据,那么该处理器必须从其他高速处理器或竹内从当中读取,这个操作叫做刷新处理器缓存。

要保证可见性需要保证:

  • 修改变量的线程及时进行冲刷处理器缓存操作
  • 读取变量的线程要及时进行刷新处理器缓存操作

volatile的作用

可以使用volatile关键字,它有两个作用:

  • JIT(即时编译器)可能帮助我们优化当前的代码,但是这种优化在多线程情况下可能出现问题,这第一个作用就是告诉JIT,这个变量可能是共享的,麻烦别给优化
  • 另一个作用就是确保冲刷处理器缓存和刷新处理器缓存的操作得以执行

一些疑问

单处理器系统会有可见性问题吗?

会有,因为它也是并发执行的,一个线程执行时修改,另一个线程读不到这个修改,势必有可见性的问题。

可见性和原子性的区别

二者保证的不是一个东西,比如线程A和线程B在操作a的值,线程C打算对a进行+1操作,那么这个时候,保证原子性可以确保线程C的+1操作是满足条件的,但是不能保证我们拿到a的值是最新值。

而如果保证了可见性,确实能保证拿到了最新值,但是不能保证进行+1操作不会被其他线程所打扰。

5、有序性

重排序

Java编译器可能对当前的代码进行了优化,从而导致执行顺序和代码的顺序不一致。

内存操作顺序划分

像重排序可能出现的原因有很多种:比如Java编译器、处理器、存储子系统(写缓冲器、高速缓存)。

而一般内存的操作顺序可以这样划分:

  • 源代码顺序:也就是代码里写的内存访问的顺序
  • 程序顺序:经过编译器之后的内存访问顺序,也就是字节码中的顺序
  • 执行顺序:处理器执行的内存访问实际顺序
  • 感知顺序:给定处理器感知到与其他线程交互后的实际顺序

重排序的划分

重排序可以划分为两种:一种叫指令重排序;一种叫存储子系统重排序。

image-20220108160941788

指令重排序

javac静态编译:把.java文件变为.class文件;JIT动态编译:把.class文件动态变为当前机器能识别的机器码。

javac编译不会造成指令重排序,而JIT编译则会造成指令重排序。

处理器为了效率也会造成指令重排序,指令的读取是按照顺序读的,但执行却不是,哪条指令就绪就先执行哪条。这些指令的结果不会直接存入内存中,而是先存到重排序缓冲器中,最后按照本来的读取顺序保存至主内存中,所以不会造成结果受影响。

处理器乱序执行还用到了猜测执行的技术,猜测会走哪条线,先执行,如果实际不一致再丢弃它。

存储子系统重排序

存储子系统包括:写缓冲器和高速缓存。

什么是存储系统重排序呢,可能我们执行的没错,但是其他处理器对相应操作的感知顺序与程序顺序不一致。

比如:有两个值data = 0,flag = false

处理器1执行把data变为1,flag变为true

处理器2执行循环检查,但flag=true的时候打印data的值

这个时候可能会因为处理器、高速缓存、写缓冲器的问题,导致处理器1先把flag的值改成了true,然后才输出data的值,这时处理器2发现flag变成true了,会打印data的值,可是处理器1还没更新data,这样就有了问题。

6、貌似串行语义

重排序是看上去没什么问题的,这是一种优化,不是胡乱的随意排序,这相当于是一种单线程形式下和源代码一致的重排,这也叫做貌似串行语义,As-if-serial,英文名应该很熟悉,多线程中经常提到它。

而为了保证貌似串行语义能够实现,对于存在数据依赖关系的情况,代码就不会重排,那么什么情况是存在数据依赖关系呢?说白了就是不是读读的情况,像读后写,写后读,写后写都会造成数据依赖关系。

什么意思呢?

image-20220108173510278
  • 设置了一个变量和值,后面用到它,这样肯定不能重排,重排了变量还没有就使用了
  • 读取一个变量,然后改这个变量的值,这样肯定也不行,这样读取的就是修改后的值了
  • 两次修改变量值肯定也不行,应该是后面覆盖前面,怎么可能是前面覆盖后面

存在数据依赖关系不许重排,但存在控制依赖关系是允许重排的。比如if语句,先执行循环体和循环条件是可以重排的,这就是上面说的猜测执行技术。

7、保证内存访问的顺序

上面提到的貌似串行语义指的是可以像串行的方式确保语句的正确执行,但是要在多线程就要保证禁止重排(当然是部分重排的禁止,否则性能大幅下降)。

禁止重排序是调用底层处理器提供相应指令来实现的,也就是内存屏障来实现。而Java可以通过synchronized和volatile来实现有序性。

可见性和有序性的区别

  • 可见性是有序性的基础:有序性就是要保证其他线程看到某线程的执行顺序的情况,首先就是要保证可见
  • 有序性影响可见性:可能本来应该在某一行就可见的,结果代码重排了导致执行完相关指令后才可见,这样就和我们的预期有出入

8、上下文切换

上下文切换就是并发情况下,各线程靠分配时间片依次执行,一个线程执行到一半切换到另一个线程,这就叫做上下文切换。那么线程上下文是什么呢?一般包括通用寄存器的内容和程序计数器的内容。

  • 线程暂停:Java的线程从RUNNABLE状态切换到非RUNNABLE状态就是线程暂停
  • 线程唤醒:Java的线程从非RUNNABLE状态切换到RUNNABLE状态就是线程唤醒

上下文切换的方法

自发性上下文切换:

  • Thread.sleep(long millis);
  • Object.wait()方法
  • Thread.yield()方法
  • Thread.join()方法
  • LockSupport.park()方法

非自发性上下文切换:

  • 线程时间片用完
  • 或者更高级别的线程加入
  • 垃圾回收器的垃圾回收也可能导致

上下文切换的开销

直接开销:

  • 操作系统保存和恢复上下文所须的开销,主要是处理器的时间开销
  • 线程调度器调度线程的开销

间接开销

  • 处理器高速缓存重新加载的开销,某个线程可能从一个处理器切出,随后又切入另一处理器,但这时这里的高速缓存并未添加数据,这时需要重新加载过来

资源的争用和调度

高并发:在同一时间内,处于RUNNABLE的线程数量越多,这种就叫做高并发。

image-20220108221458932

虽然高并发情况下,多线程争用并不一定更多,如果大家执行的很有序,那么等待时间就很短,如果低并发,但大家都在等,争用肯定更多,这样效率也不高。我们希望达到的是一种高并发、低争用的场景。

资源调度器内部维护了一个等待队列,资源争用时,争用到的出队列,没争用到的再回到队列中,不断竞争资源的独占权。

调度策略有公平和非公平之分,公平调度就是按顺序执行,不插队,非公平调度就是要进行竞争,唤醒的线程需要竞争才能获取独占权进行执行。

公平调度的吞吐率较低,但是每个线程获取到资源的时间基本一致(不需要竞争),而非公平调度吞吐率较高,可是可能会造成某些线程长时间等待造成饥饿现象。