概述
- 并发编程产生的背景是在单核时代多任务处理,CPU的运算速度与它的存储和通信子系统的速度差距实在是太大了,大量的时间消耗在磁盘I/O,网络通信或者数据库访问上;为了尽可能“压榨”CPU的运算能力,同时处理多个任务是非常有效的手段,即多任务处理-并发处理;
- 多任务处理包括系统中多个应用并发执行,也包括单个应用中的多个子任务并发执行,由于线程是CPU的最小执行单元,所以本质上都是多线程并发执行,属于抢占式并发;当然,多核并发也是基于多线程的并发;如果从多任务并发执行的角度来看,协程也是并发,属于协调式并发;
- 缓存
- 程序的运算任务不可能只靠CPU完成,至少需要内存(无法仅靠寄存器),但是内存的速度和CPU的差别比较大;
- 现代计算机系统都不得不加入一层或多层读写速度尽可能接近CPU运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样CPU就无需等待缓慢的内存读写了;
- 高速缓存解决了CPU运算速度与内存速度之间的矛盾,但是引入了一个新的问题:缓存一致性;每个处理器都有自己的高速缓存,而他们又共享同一主内存,当多个CPU的运算任务都涉及同一块主内存时,可能导致各自的缓存数据不一致;
- 多线程并发是为了程序运行的更快,但不是启动更多的线程就能让程序最大限度的并发执行;多线程并发存在很多挑战,比如上下文切换,死锁,软硬件资源限制等;
- 源码基于 Android-SDK-29 中的JDK源码;
JMM
- JMM 是 Java Memory Model;
- 并发编程中,需要处理两个关键问题:线程之间如何通信和线程之间如何同步;通信是指线程之间如何交换信息,同步是指线程之间如何控制操作发生的相对顺序;并发有两种模型:共享内存和消息传递;
- 共享内存
- 共享内存通过线程间共享的公共状态进行隐式通信,同步是显式进行的;
- 消息传递
- 消息传递通过发送消息显式进行通信,同步是隐式进行的;
- 共享内存
- 缓存
- 缓存一致性问题;
- 重排序
- 数据依赖性
- 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,这两个操作之间存在数据依赖性;数据以来分为3种:
- 写后读
- 写后写
- 读后写
- 编译器和处理器对不会改变存在数据依赖的两个操作的执行顺序;
- 数据依赖仅针对单个处理器中执行的指令序列和单个线程中执行的操作;不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑;
- 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果;
- 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,这两个操作之间存在数据依赖性;数据以来分为3种:
- 在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,包括:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的顺序;
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行;
- 编译器优化重排序属于编译器重排序,指令级并行重排序和内存系统重排序属于处理器重排序;对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止);对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序;
- 现代的处理器使用写缓冲区临时保存向内存写入的数据;写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟,同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用;虽然写缓冲区有很多好处,但每个处理器上的写缓冲区,仅仅对它所在处理器可见,处理器对内存的读写的执行顺序,不一定与内存实际发生的读写操作顺序一致;常见的处理器都允许Store-Load重排序,常见的处理器都不允许对存在数据依赖的操作做重排序;
- 为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序;JMM把内存屏障分为4类:
- LoadLoad Barriers
- StoreStore Barriers
- LoadStore Barriers
- StoreLoad Barriers
- 数据依赖性
- as-if-serial
- as-if-serial的语义:不管怎么重排序,单线程程序的执行结果不能被改变;
- 编译器,runtime和处理器都必须遵守as-if-serial语义;
- as-if-serial语义把单线程程序保护了起来,无需担心重排序会干扰他们,也无需担心内存可见性问题;
- 顺序一致性内存模型
- 顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,为程序员提供了极强的内存可见性保证;顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行;
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序;在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见;
- 顺序一致性内存模型是理论参考模型,JMM和处理器内存模型都会以顺序一致性内存模型为参考;
- JMM没有顺序一致性的相关保证;
- 未同步程序在JMM中不但整体的执行顺序是无序的,而且所有的线程看到的操作顺序也可能不一致;
- 同步程序在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“溢出”到临界区之外),JMM在进入和退出临界区这两个关键时间点做一些特殊处理,使得线程在这两个时间点具有与一致性内存模型相同的内存视图;
- 顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,为程序员提供了极强的内存可见性保证;顺序一致性内存模型有两大特性:
- JMM
- Java采用的是共享内存模型;JMM主要目的是定义程序中各种变量的访问规则,此处的变量与Java语言中的变量有所不同,包括实例字段,静态字段和数组对象的元素,比包括局部变量/方法参数/异常参数;JMM通过控制主内存与每个线程的本地内存之间的交互,来为程序提供内存可见性保障;
- JMM定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存存储了该线程已读/写共享变量的副本;本地内存是JMM的抽象概念,并不真实存在,涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化;
- JMM定义了8种操作来完成主内存与工作内存之间的交互:
- lock:作用于主内存的变量,把一个变量标识为一条线程独占的状态;
- unlock:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
- read:作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
- load:作用于工作内存的变量,把read操作从主内存中得到的变量值放入到工作内存的变量副本中;
- use:作用于工作内存的变量,把工作内存的变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码时将会执行这个操作;
- assign:作用于工作内存的变量,把一个从执行引擎接收的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
- store:作用于工作内存的变量,把工作内存中一个变量的值传递给主内存中,以便后续的write操作使用;
- write:作用于主内存中的变量,把store操作从工作内存中的得到的变量的值放入主内存的变量中;
- JMM对内存操作的规则
- 如果要把一个变量从主内存拷贝到工作内存,就要按顺序执行read和load操作;如果要把变量匆匆工作内存同步回主内存,就要按顺序执行store和write操作;JMM只要求上述两个操作必须按顺序执行,但不要求是连续执行;
- 不允许read和load,store和write操作单独出现;
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存中;不允许一个线程无原因(没有发生assign操作)的把数据从线程的工作内存同步回主内存中;
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load和assign)的变量;换句话说,在use和assign之前,必须先执行assign和load操作;
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同多次的unlock操作,变量才会被解锁;
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,执行引擎在使用这个变量前,需要重新load和assign操作以初始化变量的值;
- 如果一个变量事先没有被lock操作锁定,那就不允许对其执行unlock操作,也不允许去unlock一个被其他线程锁定的变量;
- 对一个变量执行unlock操作之前,必须先把此变量同步会主内存中(执行store,write操作);
- JMM的内存可见性保证分为:
- 单线程程序:单线程程序不会出现内存可见性问题;
- 正确同步的多线程程序:具有顺序一致性;
- 未同步/未正确同步的多线程程序:提供了最小安全保障,线程执行时读到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false);
- 原子性
- JMM直接保证的原子性变量操作包括read,load,assign,use,store,write,lock,unlock;
- 基本数据类型的访问,读写都是具备原子性的,long和doubel并非是原子性的;
- lock和unlock可以提供一个更大范围的原子性保证;
- 可见性
- volatile:volatile变量确保了可见性;
- synchronized:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中;
- final:final变量在构造器中一旦被初始化完成,并且构造器没有把this引用传递出去,那么在其他线程就能看到final字段的值;
- 有序性
- Java程序中天然的有序性总结为:如果在本线程内观察,所有的操作都是有序的(as-if-serial);如果在一个线程中观察另一个线程,所有的操作都是无序的,在不同线程中,观察到的是不同的操作序列;
- volatile变量禁止指令重排序优化;
- synchronized,一个变量在同一时刻只允许一条线程对其进行lock操作,从而保证了有序性;
- happens-before
- JMM使用hanppens-before的概念来阐述操作之间的内存可见性,如果一个操作执行的结果要对另一个操作可见,那么这两个操作之间必须存在hanppens-before关系,这里的两个操作既可以是一个线程之内,也可以是在不同线程之间;
- happens-before 是JMM 在程序层面的简单描述;happens-before 用来判断数据是否存在竞争,线程是否安全的非常有用的手段;
- hanppen-before规则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before于随后的对这个锁的加锁;
- volatile变量规则:对一个volatile域的写,hnppens-before于任意后续对这个volatile域的读;
- 传递性:如果 A happens-before B,且B happens-before C,那么 A happens-before C;
- start规则:如果线程A执行操作ThreadB.start,那么A线程的ThreadB.start 操作 happens-before 于线程B中的任意操作;
- join规则:如果线程A执行操作ThreadB.join并成功返回,那么线程B中的任意操作 happens-before 于线程A从ThreadB.join 操作成功返回;
volatile
- volatile变量的两个特性
- 保证此变量对所有线程的可见性;
- 禁止指令重排序优化;
- volatile的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;即对一个volatile变量读时,load和use必须是连续的;
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量;即对一个volatile变量写时,assign和store必须是连续的;
- volatile的内存语义的实现
- 在每个volatile写操作前面插入StoreStore屏障,在每个volatile写操作后面插入StoreLoad屏障;
- 在每个volatile读操作后面插入一个LoadLoad屏障,在每个volatile读操作的后面插入一个LoadStore屏障;
- volatile变量写操作在X86下生成的汇编指令会多出Lock前缀指令;Lock前缀指令在多核处理器下会引发两件事情:
- 1.将当前处理器缓冲行的数据写回到系统内存;
- 2.写回操作会使其他CPU里缓存了该内存地址的数据无效;
- volatile是JVM提供的轻量级同步机制;由于volatile变量只能保证可见性以及有序性,在不符合以下两条规则的场景中,仍然要通过加锁(synchronized,java.util.concurrent中的锁或原子类)来保证原子性;
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
- 变量不需要与其他的状态变量共同参与不变约束;
synchronized
- 锁可以让临界区互斥执行;
- 锁的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中;
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量;
- 锁的内存语义的实现
- concurrent 包的锁通过 volatile + CAS 来实现锁;
- X85 CPU架构,会在CAS操作时,如果是多核,会生成lock前缀指令;lock指令会有以下三个效果:
- 1.在lock执行期间,会锁住总线或者缓存锁定来保证指令执行的原子性;
- 2.禁止该指令,与之前和之后的读写指令重排序;
- 3.把写缓冲区中的所有数据刷新到内存中;
- CAS同时具有 volatile 读和写的内存语义;
线程
- JVM的线程模型普遍都采用基于操作系统原生线程模型来实现,即采用1:1的线程模型;线程调用采用抢占式调度,由系统分配CPU时间片来执行程序;
- 线程状态
- New:创建后尚未启动的线程,还没有调用start方法;
- Runnable:包括操作系统线程状态中的Running和Ready;
- Waitting:不会被分配CPU时间片,要等待其他线程显式唤醒;能进入该状态的方法有
Object#wait()
Thread#join()
LockSupport#park()
; - Timed Waitting:不会被分配CPU时间片,无须其他线程显式唤醒,在一定时间后会由系统自动唤醒;能进入该状态的方法有
Thread#sleep(long)
Object#wait(long)
Thread#join(long)
LockSupport#parkNanos(long)
LockSupport#parkUntil(long)
; - Blocked:在线程等待进入同步区域的时候,线程进入这种状态;阻塞状态与等待状态的区别是:阻塞状态在等待着获取一个排它锁,这个时间将在另外一个线程放弃这个锁的时候发生;等待状态则是在等待一段时间或者唤醒动作的发生;
- Terminated:已终止线程的状态;
- 线程间通信
- 通信的本质是交换数据;从这个角度来看,只要涉及JMM中变量的读写,就存在线程间通信;对于普通变量,一个线程对其进行修改,会在之后的某个时间点将工作内存刷新到主内存中,其他线程在主内存更新之后的某个时间点会将更新本线程的工作内存,从而完成一次通信;对于普通变量的通信,并不靠谱;
- volatile变量,每次写立即 store 到主内存,每次读都从主内存中重新 load;
- synchronized,只有一个线程能够获得锁并执行锁内代码,并且在加锁时,重新从主内存 load 变量,在解锁时,同步 store 到主内存中;任意线程对Object(Object由synchronized保护)的访问,首先要获取Object的监视器;如果获取失败,则进入同步队列,线程状态变为BLOCKED,当访问Object的前驱(获得锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取;
- 等待/通知机制
- notify:通知一个在对象上等待的线程,使其从wait方法返回,而返回的前提是该线程获取到了对象的锁;
- notifyAll:通知所有等待在该对象上的线程;
- wait:调用该方法的线程进入WAITTING状态,只有等待另外线程的通知或者中断才会返回,调用wait方法之后,会释放对象的锁;
- 管道输入/输出流
- 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于:它主要用于线程间的数据传输,而传输的媒介为内存;
- 管道输入/输出流主要包括如下四种具体实现:PipedOutputStrream,PipedInputStream,PipedReader和PipedWriter;前两种面向字节,后两种面向字符;
- Thread#join
- 如果线程A调用了ThreadB#join方法,那么线程A等待ThreadB终止之后才join方法返回;