第五部分 高效并发.md

何言 2021年08月11日 88次浏览

第十二章 Java 内存模型与线程

12.1 概述

12.2 硬件效率与一致性

image20210720145616580.png

内存模式:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

处理器会对输入代码进行乱序执行 ( Out-Of-Order Execution ) 优化。

类似的 Java 虚拟机的即时编译器中有指令排序 ( Instruction Reorder ) 优化

12.3 Java 内存模型

Java 内存模型 ( Java Memory Model, JMM )

12.3.1 主内存与工作内存

变量 ( Variables ):实例字段、静态字段和构成数组对象的元素。(不包括局部变量与方法参数,因为那是线程私有的)

主内存 (Main Memory ) :变量存储的地方(不包括局部变量与方法参数)

工作内存(Working Memory):每条线程自己的工作内存区域,保存了被该线程使用的变量的主内存副本(一般不会有保存对象本身副本,保存的是字段或引用的副本)。线程的所有操作都必须在工作内存中进行。对于 volatile 变量,依然是使用该模型,只不过通过修改操作顺序让其看起来像在主内存中直接修改。

当然如果使用 reference 类型引用的对象在 Java 堆中可被各个线程共享,只是该 reference 本身在 Java 栈的局部变量中是线程私有的。

image20210720150957910.png

此处的内存模型与之前的 Java 堆等的划分不是同个层次的划分,两者没有任何对应关系,只是在两个层面的角度思考问题。

12.3.2 内存间的交互操作

对于 Java 内存模型,其必须提供一下 8 中操作来进行数据交互,各虚拟机在实现内存模型时,必须提供这些操作的原子操作:

  • lock ( 锁定 ):把一个主存中的变量标识为一条线程独占
  • unlock ( 解锁 ):把主存中锁定的变量解锁
  • read (读取):把工作内存中的变量值传输到工作内存,以便之后的 load
  • load (载入):把使用 read 操作从工作内存中获取的值放入工作内存的变量副本
  • use (使用):把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时会执行该操作
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时会执行这个操作
  • store(存储):把工作内存中的一个变量的值传送到主内存中,以便后续 write 的调用
  • write (写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

同时 虚拟机还规定了以下规则:

  • store 和 write,read 和 load 必须顺序执行,但不用连续执行
  • store 和 write,read 和 load 必须成对出现,不能单独出现
  • 不允许一个线程丢弃它最近的 assign 操作,变量在工作内存中改变了之后必须把变换同步回主线程
  • 不允许一个线程无原因地把数据从县城的工作内存同步回主线程,即读取后不做任何操作原样写会主内存。
  • 一个新的变量只能在主内存中 " 诞生 ",不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量,即执行 use、store 操作之前,必须先执行 assign 和 load 操作。
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会解锁
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行 lock 后,需要重新执行 load 或 assign 操作以初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程 lock 的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中 (执行 strore、write 操作 )

之后还会有针对 volatile 的规定。

在最新的 JSR-133 文档中,已经将操作缩减为 read、write、lock 和 unlock 四种。但这只是语言描述上的等价化简。

12.3.3 对于 volatile 型变量的特殊规则

volatile 的作用:

  1. 保证此变量对所有线程的可见性,当某个线程对该值进行修改时,新值对所有其他线程来说是可以立即得知的。普通变量需要等该值从工作内存写回主内存才可见。

    这里可见只是指那一条字节码瞬间是一致的,在不符合以下两条规则的运算场景中,使用 volatile 依然会有并发不一致的问题:

    • 运算结果并不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值
    • 变量不需要与其他的状态变量共同参与不受约束

    volatile 的一个使用场景

    volatile boolean stopFlag;
    
    public void shutdown(){
        stopFlag = true;
    }
    
    public void doWork(){
        while(stopFlag){
            // 业务
        }
    }
    
  2. 禁止指令重排序优化普通的变量仅会保证在该方法的执行过程 中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的 执行顺序一致。

    boolean isInit = false;
    
    public void init(){
        // init 操作
        isInit = true;
    }
    

    isInit 变量没有加入 volatile,如果 init 操作中代码没有引用到 isInit 这个变量,则可能会触发指令重排,isInit = true 可能会被提前,此时 Init 操作还没完成,如果在另一个线程中判断是否初始化,则可能会出现错误。因此我们需要给该变量加入 volatile 以禁止指令重排。

使用 volatile 之后在字节码指令层面没有区别,但是会再生成汇编指令的时候修改顺序以及加入屏障来防止指令重排。

12.3.4 针对 long 和 double 型变量的特殊规则

long 和 double 的非原子性协定:允许虚拟机实现自行选择是否 要保证64位数据类型的load、store、read和write这四个操作的原子性

12.3.5 原子性、可见性与有序性

Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的

  1. 原子性 (Atomicity)

    Java 虚拟机会保证八条指令具有原子性。同时可以使用 lock 和 unlock 操作来实现一个更大范围的原子性保证。这两条指令虚拟机一般不会开放给用户使用,但可以使用字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。在代码层面就是 同步块 —— synchronized 关键字。

  2. 可见性 (Visibility)

    可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。上文在讲解 volatile 变量的时候我们已详细讨论过这一点。Java 内存模型是通过在变量修改后将新值同步回主内 存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是 普通变量还是 volatile 变量都是如此。普通变量与volatile 变量的区别是,volatile 的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。

    此外,synchronized 和 final 也可以实现可见性。

  3. 有序性 (Ordering)

    • 在本线程观察,线程内表现为串行语义。

    • 在其他线程观察,因为指令重排和工作内存主存同步延迟因此操作都是无序的。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

12.3.6 先行发生原则

  • 语序次序规则: 在一个线程内,按照控制流顺序,在之前的操作先行发生于后面的操作。
  • 管程锁定规则:unlock 操作先行发生于后面对同一个锁的 lock 操作
  • volatile 变量规则:对 volatile 变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则: Thread 对象的 start() 方法先行发生于此线程的没有给动作
  • 线程终止规则:线程中所有操作都先行发生于对此操作的终止检测,比如 Thread::join()Thread::isAlive()
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
  • 传递性:如果 A 先行发生于 B,B 先行发生于 C,则 A 先行发生于 C。

12.4 Java 与线程

实现线程主要有三种方式:使用内核线程实现 (1 : 1),使用用户线程实现 (1 : N),使用用户线程加轻量级进程混合实现 (N : M)

  1. 内核线程实现

内核:由操作系统内核 (Kernel) 支持的线程。

内核线程 ( Kernel-Level Thread, KLT ):直接由操作系统内核支持的线程。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个 内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1 的关系称为一对一的线程模型。

image20210721092404835.png

  1. 用户线程实现

用户线程 (User Thread, UT):不是内核线程的线程

image20210721092711389.png

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都 需要由用户程序自己去处理。

  1. 混合实现

image20210721093151755.png

  1. Java 线程的实现

主流虚拟机如 HotSpot 为直接把 Java 线程映射到一个操作系统原生线程实现,不会干涉操作系统的线程调度。

12.4.2 Java 线程调度

调度方式:

  • 协同式
  • 抢占式

image20210721100003035.png

12.4.3 状态转换

Java 中线程具有六种状态

  • 新建:创建后尚未启动
  • 运行:包括操作系统线程状态中的 Running 和 Ready
  • 无限期等待:不会被分配资源运行,直到被其他线程显式唤醒
    • 没有 Timeout 的 wait 方法
    • 没有 Timeout 的 join 方法
    • LockSupport::park() 方法
  • 限期等待:不会被分配资源运行,但在某时刻会由 系统 自动唤醒
    • Thread::sleep() 方法
    • 设置了 Timeout 的 wait 方法
    • 设置了 Timeout 的 join 方法
    • LockSupport::parkNanos() 方法
    • LockSupport::parkUntil() 方法
  • 阻塞:与等待状态的区别为阻塞状态在等待着获取到一个排他锁
  • 结束

image20210721100954854.png

12.5 Java 与协程 (pass)

第十三章 线程安全与锁优化

13.1 概述

13.2 线程安全

定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下 的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对 象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

13.2.1 Java 语言中的线程安全

按 线程安全 由强到弱分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

  1. 不可变

当一个变量被 final 修饰时,该变量绝对是线程安全的,不存在并发问题。

  1. 绝对线程安全

能完全满足线程安全 的定义

  1. 相对线程安全

满足线程安全的弱化定义,在单次调用时时线程安全的,Java 语言中大部分声称线程安全的类都属于这种类型。

  1. 线程兼容

需要在调用段正确的使用同步手段来保证安全,我们常说的这个对象线程不安全一般指这种情况

  1. 线程对立

无论如何都不能安全并发执行的代码,这类代码通常是有害的,应尽量避免

12.2.2 线程安全的实现方法

  1. 互斥同步

    synchronized 关键字会被编译成 monitorenter 和 monitorexit 指令。

    在执行 monitorenter 时,首先要尝试获取对下令的锁,如果对象没有被锁定,或者该线程已经持有锁,则把锁的计数器加一,在执行 monitorexit 的时候计数器减一。一旦计数器为零,锁就被释放了。如果获取锁失败,就需要阻塞等待,直达锁被之前上锁的线程释放。

    • 被 synchronized 修饰的同步块对同一条线程来说是可重入的。这意味着同一个线程可以反复进入同步块
    • 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,回无条件地阻塞后面其他线程的进入。无法强制已经获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

    重入锁:(ReentranLock) 是 Lock 接口最常见的一种实现,顾名思义,它与 synchronized 一样是可重入的。ReentranLock 与 synchronized 类似,不过提供了一些高级功能,主要有以下三项:等待可中断、可实现公平锁和锁绑定多个条件。

    • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待
    • 公平锁:当多个线程在等待同一个锁的时候,按照申请锁的时间顺序来依次获得锁。而非公平锁所有等待锁的线程都有同样的概率可以获取到锁。 reentranLock 的默认实现 与 synchronized 一样都为非公平锁,但 ReentranLock 可通过带布尔值的构造函数要求使用公平锁。
    • 锁绑定多个条件:一个 ReentrantLock 对象可以同时绑定多个 Condition 对象。在 synchronized 中,锁对象的 wait() 或 notify() 等方法可以配合实现一个隐含的条件,但如果要和多个条件关联的时候,就需要额外添加一个锁。而 ReentrantLock 无需这样做。

    推荐优先使用 synchronized 而不用 ReentrantLock 的利用:

    • 只需要基础的同步功能。synchronized 是语法层面,较为清晰,不用程序员维护锁对象
    • 使用 Lock 需要确保最后要释放锁,包括出现异常,否则出异常后永远不释放锁,导致程序阻塞。
  2. 非阻塞同步

    CAS 指令:比较并交换。实现非阻塞同步需要处理器提供一系列原子性的原子操作指令,其中 Java 里最终暴露出来的有 CAS 指令。

    CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V 表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合 A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的 旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

    sun.misc.Unsafe 类里几个方法包装了 CAS 指令,但在 JDK 9 之前只有启动类加载器加载的 Class 才能访问到它。比如 整数原子类中的方法就使用了该 CAS 操作。而用户如果需要使用 CAS 操作,则要么通过反射调用,要么使用 Java 类库来间接使用。直到 JDK 9,Java 类库踩在 VarHandle 类里开放了 CAS 操作。

    乐观同步:假设操作不会出现问题,操作后发现问题才进行补救。

    ABA 问题:如果初值为 A ,在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从 来没有被改变过

  3. 无同步方案

    有一些代码天生线程安全,不需要任何同步措施,比如:

    • 可重入代码:(纯代码)指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不 会出现任何错误,也不会对结果有所影响。

      可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源, 用到的状态量都由参数中传入,不调用非可重入的方法等。我们可以通过一个比较简单的原则来判断 代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返 回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

    • 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就 看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可 见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

      Java 中可以使用 java.lang.ThreadLocal 来实现 线程本地存储。

13.3 锁优化

为了在线程之间更高效地共享数据和解决竞争问题,从而提高程序的执行效率,人们对锁进行了许多优化,比如:适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等。

13.3.1 自旋锁和自适应自旋

自旋:忙循环

当获取锁失败时,先不进入阻塞状态,先进入一段时间的自旋,如果自旋期间锁被释放了,则可以直接获取锁。避免了线程状态切换的开销。

  • 自适应自旋:对于同一个对象,如果自旋等待成功,则增加下次自旋的时间。反之则减少。

13.3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享 数据竞争的锁进行消除。主要判定依据来源于逃逸分析。

13.3.3 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和 解锁,则可以将锁的加锁和解锁的作用范围扩大一边一次加锁完成所有操作。

13.3.4 轻量级锁

不使用操作系统互斥量,通过在高层次的对对象头中的锁指针进行 CAS 操作来实现加锁。如果出现锁竞争,则需要自旋等锁,实际上当冲突过于频繁时会自动更新为重量级锁,重量级锁使用了系统接口,才具有挂起线程的能力。解锁的过程也同样使用 CAS 操作进行。在有竞争的情况下,因为多了一开始的 CAS 操作的开销,因此有竞争的情况下轻量级锁比重量级锁更慢。

13.3.5 偏向锁

当一个锁第一次被一个对象获取的时候,该对象会进入偏向模式,该模式下这个对象每次进入同步块时,虚拟机都不再进行任何同步操作。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是 否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位 为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去 执行。

当偏向状态时,对象头需要存储线程 ID,无法存储哈希码,因此当一个对象已经计算过一 致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要 计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

image20210721170904515.png

image20210721171024617.png