暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

并发基础(一):并发理论

星河之码 2022-07-24
251

「尺有所短,寸有所长;不忘初心,方得始终。」

线程安全是多线程编程时的计算机程序代码中的一个概念,在程序开发中,面试中,线程安全是一个很常见并且容易写出bug的地方,今天就来聊聊什么是线程安全

一、什么是线程安全

  • 「线程安全」

    在进程中有多个线程同时运行同一段代码时,「线程安全就是通过同步机制保证各个线程都可以正常且正确的执行,运行结果与预期是一致的」

  • 「线程不安全」

    不提供数据访问保护,「有可能出现多个线程先后更改数据造成所得到的数据是脏数据」

  • 举个例子说明

    假设12306有1000张票,A和B同时来买票,在线程不安全的情况下,A和B首先读取到的都是余票1000,买完后都执行1000-1= 999,最终A和B都买完后系统中剩下999张票,而不是998张。

二、CPU时间⽚

CPU运算速度比IO速度快百倍千倍,我们的程序在CPU执行的时候往往会需要进行IO操作,而在等待IO操作的时候,CUP如果闲置下来, 那CUP资源将被大大的浪费,因此如何将CPU资源合理的利用,就成为计算机性能的一大难题。

为了减少不必要的CPU资源的浪费,合理的利用CPU资源提升计算机的效率和性能,「计算机中将CPU的执行碎片化,每个线程都去争夺CPU的运行时间碎片,抢到的线程执行,未抢到的线程则等待,这样当CUP闲置等待IO时,其他线程就可以争夺到CPU的执行时间,这样CPU就可以不用等待,而线程所争抢的CPU执行时间就是CPU时间⽚」

  • 「CPU时间⽚」

    「CPU把自己的时间分为若干个单位的片段,每在一个进程上执行完一个单位的时间就切换到另外一个进程上去执行指令」

    一个内存可以划分出不同的内存区域分别由多个进程管理,当一个进程IO阻塞的时候可以切换到另外一个进程执行指令,为了合理公平的把CPU分配到各个进程。

三、并发三要素

3.1 可见性: CPU缓存引起

「即:一个线程对共享变量的修改,另外一个线程能够立刻读取到」

CPU加缓存是为了提升CPU的利用率, 但是在多核CPU的情况下,每个CPU都有着自己独立的缓存,它们各自

之间是不可见的,这就会导致对应CPU读取的数据都是自己缓存的,无法看到别人对共享数据的修改,从而导

致并发可见性问题。

如下代码模拟一下不同线程修改全局变量的值

public class Cisibility {

private static int a = 1;

public static void main(String[] args) {
Thread thread_1 = new Thread(new Runnable() {
@Override
public void run() {
a = 10;
System.out.println("子线程 thread_1 a=" + a);
}
});
thread_1.start();
int b = a;
System.out.println("主线程 a=" + a + " b=" +b);
}
}

上述代码在执行时,执行结果如下:

子线程thread_1与主线程分别在CPU中执行,「当thread_1执行a=10的时候,CPU会先把a的初始值加载到缓存区(a=1),然后再给a重新赋值(a=10),但是此时的a=10只是在thread_1的缓存区,并没有立即更新到主内存中,因此主线程执行b = a时,主线程加载到a的值还是1」

「当一个线程修改了全局变量后,其他的线程没有立即读到修改后的值」

3.2 原子性: 分时复用引起

「即一个行为可以包含一个操作或者多个操作,这些操作要么全部执行成功,要么全部失败,只要有一个操作失败,就要时整个行为全部失败」

比如上述中的12306的案例

3.3 有序性: 重排序引起

「即程序执行的顺序按照代码的先后顺序执行」

四、Java内存模型

「java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信」。java中的共享变量是存储在主内存中的,多个线程执行是将共享内存中的变量拿出来放在自己的工作内存中,操作完成后,再将最新的变量放回共享变量,这时其他的线程就可以获取到最新的共享变量。

4.1 主内存和工作内存

CPU处理器上的寄存器的读写的速度比内存快几个数量级,对于访问内存的操作CPU需要等待,这就会造成CUP利用率的大幅度下降,为了解决这种速度矛盾,在CPU和内存之间加入了高速缓存。

「Java内存模型,简称JMM,指的就是共享内存模型。JMM决定一个线程对共享变量的写入时,能对另一个线程可见」。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(main memory)中
  • 每个线程都有一个私有的工作内存,也叫本地内存(local memory)
  • 本地内存中存储了该线程以读/写共享变量的副本

本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

从上图可以看出「线程A和线程B是通过共享变量在进行【隐式通信】,如果线程A更新后数据并没有立即写回到主存,而此时线程B去读取数据,则读到的是过期的数据,即【脏读】现象」

为避免脏读,一般通过同步机制(控制不同线程间操作发生的相对顺序)来解决,或通过volatile关键字使变量都能够强制刷新到主存,从而对每个线程都是可见的。

4.2 内存间交互操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

  • 「read (读取)」

    作用于「主内存」的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • 「load (载入)」

    作用于「工作内存」的变量,它把read操作从主内存中得到的变量值放人工作内存的变量副本中。

  • 「use (使用)」

    作用于「工作内存」的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • 「assign (赋值)」

    作用于「工作内存」的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • 「store (存储)」

    作用于「工作内存」的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • 「write (写入)」

    作用于「主内存」的变量,它把store操作从工作内存中得到的变量的值放人主内存的变量中。

  • 「lock (锁定)」

    作用于「主内存」的变量,它把一个变量标识为-条线程独占的状态。

  • 「unlock (解锁)」

    作用于「主内存」的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

五、指令重排序

5.1重排序概念

在执行程序时「为了提高性能,编译器和处理器常常会对指令做重排序」。从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

  • 「编译器优化的重排序」

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 「指令级并行的重排序」

    处理器将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 「内存系统的重排序」

    处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。

比如如下代码经过重排序后,不一定按照先1后2的顺序执行,经重排序之后可能按照先2后1的顺序执行。

//语句1
int a = 1;
//语句2
int b =2

5.2. 数据依赖性

「如果两个操作访问同一个变量,且这两个操作中有一个为【写操作】,此时这两个操作之间就存在数据依赖性,存在数据依赖关系的两个操作,不可以重排序」

「指令重排序需要遵守数据依赖性」

数据依赖性存在三种情况:

  • 「写后读」

    写一个变量之后,再读这个位置。

    a = 1;
    b = a;

  • 「写后写」

    写一个变量之后,再写这个变量。

    a = 1;
    a = 2;

  • 「读后写」

    读一个变量之后,再写这个变量。

    a = b;
    b = 1;

数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

5.3 as-if-serial

「即:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变」

下面看一个计算圆面积的代码示例:

double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

案例中A和C之间,B和C之间都存在数据依赖关系

「由于指令重排序不能违背数据依赖性,否则程序的结果会被改变,因此在最终执行的指令序列中,C不能被重排序到A和B的前面,但是A与B之间没有依懒性」,因此上述程序指令重排后有两种执行顺序:

5.4 happens-before

5.4.2 什么是happens-before

happen-before是JMM最核心的概念,JMM可以通过happen-before关系向程序提供跨线程的内存可见性保证(「如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序保证a操作将对b操作可见」)。

  • 如果操作A  happens-before  操作B,那么操作A的执行结果将对操作B可见,而且操作A的执行顺序排在操作B之前。

  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。

    如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM是允许这种重排序。

5.4.2happen-before具体的规则

  • 「程序顺序规则」一个线程内,按照代码顺序,前面的操作先行发生(happens-before)于后面的操作

  • 「监视器锁规则」一个解锁操作先行发生(happens-before)于后面对同一个锁额lock操作(即先解锁后加锁)。

  • 「volatile变量规则」对一个volatile变量的写操作先行发生(happens-before)于后面对这个变量的读操作(即先写后读)。

  • 「传递规则」如果操作A先行发生于操作B,而操作B又先行发生于操作C,那么操作A先行发生于操作C。

    即:如果A happens-before B,且B happens-before C,那么A happens-before C。

  • 「线程启动规则(start()规则)」如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • 「线程中断规则」对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

  • 「线程终结规则(Join()规则)」如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作先行发生(happens-before)于线程A从ThreadB.join()操作成功返回。

  • 「对象终结规则(对象finalize规则)」一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

六、并发和并行

了解了上述的基础知识之后,再来看看什么是并发和并行

6.1并发

我们常说的并发,是指同一时间,服务能处理的请求数。

  • 「并发的同时运行是一个假象」

    了解了上述的CPU时间片后,我们知道「CPU在某一个时间点只能为某一个个体来服务」,因此不可能同时处理多任务,并发是通过 CPU 快速的时间片切换实现的。

  • 「并发是在某个时间段之内处理的任务的总量,量越大并发越高」


6.2并行

并行顾名思义就是同时进行

  • 并行的多进程同时运行是真实存在的,「可以在同一时刻同时运行多个进程」
  • 「并行需要依赖多个硬件资源,单个是无法实现的」(如两台机器同时运行)。

6.3 总结

通过上述的描述,总结下来就是

  • 「并发需要单位时间内有处理多个任务的能力,可以不同时」
  • 「并行需要有同时处理多个任务的能力」

「并发和并行的关键就是【同时】执行」


文章转载自星河之码,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论