取法其上,得乎其中

Java多线程杂乱笔记: 管程 / 中断机制 / JMM / volatile / Sychronize与锁升级

概述

本想找一套较为系统性的JUC课程去深入学习,但最终仅形成了这篇较为散乱的文章, 令人难过。

其他多线程相关文章

基础

进程、线程

进程与线程实际上不必多数,前者为系统进行资源分配和调度的独立单位,每个进程都有自己的内存空间和系统资源。而同一个进程内可以多个任务并行运行,而运行的任务单位即为线程。显然,一个进程中会有多个线程。

管程

何为管程(Monitor)?本质来说其是作为Java内线程之间的同步机制而产生,每个对象都拥有一个管程,其随Java对象创建与销毁,底层由C++实现。当执行引擎执行到被标记为加锁的方法、代码块的时候,线程获取一个对象需要先获取到对象对应的管程,如果无法获取则被阻塞。

如上图中所示,当方法f被执行的时候会使用 monitorenter 和 monitorexit 指令声明在这两个指令之间对象的访问都需要获取管程。

而如果被加锁的是一个方法则如上图所示,直接对方法进行标记。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标记是否设置,从而判定是否需要持有被访问对象的管程。

用户线程、守护线程

Java线程分为用户线程和守护线程。其中守护线程是一种特殊的线程,在后台默默执行一些系统性的服务,作以支撑用户线程的运行,如垃圾回收线程。而用户线程是一个程序的主要工作线程,完成程序需要完成的业务操作。

两者最重要的区别是,当所有的用户线程执行完毕后不管守护线程是否结束都会将其关闭。

LockSupport

LockSupport是JDK中锁以及其他同步类在阻塞一个线程时的基本阻塞原语。

线程中断机制

首先,一个线程不应该被其他线程强制中断与停止,所以线程应该都由线程自行停止。并且Java中除了已经废弃的Thread.stop、Thread.suspend、Thread.resume都已经废弃了。

因此所谓的中断本质来讲是当前线程与其他线程的一种协商机制,即通过一个标志位“提醒”当前线程停止,而是否停止则依赖于当前线程是否实现对应的中断策略。

一般实现线程任务的中断策略共有两种方式,其一为自己设定标志位,其二为使用api内置的方法实现。

自定义标志位

一般为了保证状态的可见性,则要将对应的标志位使用volatile进行标记。除此之外也可以使用原子类来做以保证安全。

class Task extends Thread{

    public volatile boolean stop = false;
    // public AtomicBoolean stop = new AtomicBoolean(false);


    @Override
    public void run() {
        super.run();
        for(;;){
            if(stop) break;

            System.out.println("doing");
        }
    }
}

内置方法

在Thread类中主要提供了如下三种方法:

  • public void interrupt() 将线程的中断状态设为true,不会停止线程
  • public static boolean interrupted() 返回当前的中断状态,并清除中断状态
  • public boolean isInterrupted() 返回当前线程状态

其中最重要的就是当使用静态的interrupted()方法时,会清除中断状态,所以当多次调用该方法后所获得的值可能均不同。

除了interrupted()方法以外,当线程在睡眠状态中被打断亦会清除中断标记,所以需要注意这点,以避免任务线程无法相应中断标记的事故发生。

线程阻塞(唤醒)机制

一般线程的唤醒和阻塞往往是成双成对出现,最主要的方式共有三种,如下:

  • Object.wait() 与 Object.notifiy() 方法
  • JUC中的Condition.await() 与 Condition.signal() 方法
  • LockSupport.park() 与 LockSupport.unpark() 方法

其中前两者比较常见,Object自带的阻塞方法均需要依赖于Synchronized或lock锁块之中得以实现。但LockSupport则是封装底层的线程阻塞原语,相对于前两者没有限制条件

LockSupport主要设计了一个Permit的概念来做到阻塞和唤醒线程的功能,其中Permit共有0和1两个状态,即持有和未持有。正常情况下默认线程为持有许可。

JMM

JMM(Java内存模型,Java Memory Model)本身是一种抽象的概念,属于描述Java在内存管理时的一种规范。通过这种规范来确定在多线程下的原子性、可见性和有序性。

可见性

我们定义的所有共享变量都存储与主内存之中,但是当线程对其操作的时候会首先将其保存到该线程的工作内存之中,当计算完毕后再重新刷入主内存。

这也就意味着在多线程的场景下非常容易出现脏读问题。所以JMM的可见性保证是要做到规避这样的问题。

原子性

即设计到多个操作时的不可间断性。

有序性

为了提供更好性能,编译器和处理器通常会对指令序列进行重新排序本质来说,指令重排是可以保证语义是一致的,但主要问题出现在无法在多线程的场景下保证重排后语义一致

总而言之,有序性是为了确保有数据依赖的多个指令之间保持绝对的顺序,不受多个线程的重排而导致语义错误。

happens-before 原则

在JMM中一个操作依赖于另外一个操作的数据(可见性),或依赖于操作的数据(有序性),则这两个操作之间必须存在happens-before关系。本质来讲就是基于JMM可见性和有序性抽象出来的执行原则~

常见的原则有四种,如下:

  • 次序规则,即后续操作依赖于前者,则要求其有序
  • 锁定规则,线程的解锁操作必须先行发生于另外一个线程的加锁操作
  • volatile变量规则,一个volatile变量的写操作必须先行于后续的读操作
  • 传递规则,A先行于B,B先行与C,则A需要保证先行与C

如下述代码,如果线程A执行setValue(1),线程B随后执行getValue(),由于多个线程之间无法保证value对线程B的可见性(也包括有序性,其中有序性可以理解为在线程A中退出操作优先于赋值操作,但无奈在此例子中并未能展现),则将会产生脏读问题而如下问题根据volatile变量规则标记变量为volatile就可以解决问题。

    public int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

volatile

前述可知volatile能够保证变量在操作时保证可见性与有序性,那么它的具体的内存语义是什么呢?

首先,当写一个volatile变量时,根据JMM规范会将该线程对应的工作内存中的变量值立即刷回主内存。其次在读一个volatile变量时,根据JMM规范会将线程对应的工作内存中的变量值设置为无效,直接从主内存中读取变量并替换之。

那么JMM是以什么样的方式将这样的规则落地实现的呢?其主要采用了JVM设计的四种内存屏障指令,分别如下:

  • LoadLoad( Load1; LoadLoad; Load2 ), 保证Load1必须先行执行于Load2之前
  • StoreStore(如上),保证store2及其后的写,后行于store1刷新到主内存
  • LoadStore(如上),保证store2及其后的写,后行于load1已读到数据
  • StoreLoad(如上),保证load2及其后的读,后行于store1刷新到主内存

因此,volatile设计到读写的时候,会在前后设置对应的屏障指令

  • 读操作,在读操作的后面分别插入StoreStore和StoreLoad两个指令

    • LoadLoad,因为当前volatile读会重新从内存中读取,而后续的load如果先行当前读,将会导致其为过期数据,即脏读
    • LoadStore,同上,为了防止脏读
  • 写操作,在写操作的前面插入StoreStore,后面插入StoreLoad

其上在单线程下很好理解为什么volatile能够保证可见性与有序性了,但是在多核心下就有些不好理解。主要原因是在JMM外还存在着MESI协议保证多核缓存的一致性问题。

(对于这个方面了解不太深入,或许需要深入学习操作系统后才能明白系统层面的处理方式)

应用场景

  • 状态标志
  • 保证

Synchronized与锁升级

先复习一下对象头存了什么

在JVM中有学习过,对于对象头而言分为两个部分,其一为对象标记(Mark Word),其二为指明对象对应类型的指针

其中对象标记默认占用8个字节,类型指针也是8个字节。也就是说一个没有字段数据的对象,默认会占用16个字节,但由于JVM会对类型指针进行压缩,所以对象标记和类型指针一起占用12个字节,而为了保证大小为8的倍数,会对其填充4个字节。

而对于多线程而言,我们主要需要复习的是如下图所示的对象标记的信息。

这些信息都是与对象自身定义没有关系的,它会根据对象的状态而复用该空间去存储不同的数据。说白了,就是在没加锁、加不同的锁,以及其他情况所存储的数据根据需要而不同。

而下面将大幅度的提到如上图所示的信息。

Synchronize的性能变化

在Java的早期版本中,Synchronize属于一种重量级的锁,主要是因为管程(monitor)依赖于操作系统的Mutex Lock实现,那么就意味着每次阻塞或唤醒一个Java线程都需要操作系统切换CPU状态,由用户态转换为内核态,这样导致效率很是低下,这样的操作时间甚至可能超过同步代码执行的时间还要长。

因此在Java6之后为了减少获得锁和释放锁带来的性能损耗,引入了轻量级锁和偏向锁。这两个锁与Synchronize的主要关系是:当使用Synchronize同步代码时,并不会先使用基于操作系统的锁,而会根据不同的情况,而使用不同的锁。

主要顺序是:

  1. 无锁
  2. 偏向锁,只有一个线程来访问
  3. 轻量级锁,有两个线程交替访问
  4. 重锁,多个线程进行访问

因此也就衍生了本段落的主题:锁升级。即Synchronize是如何一步步从无锁升级到重锁的。

偏向锁

当一段同步代码一直被同一个线程多次访问,由于当前仅有这一个线程,那其实频繁的加锁、释放锁是没有意义的,倒不如直接在对象头中记录本次线程的指针,且不释放锁。当下次再有线程想要持有对象锁的时候,先对比对象头中的指针与该对象是否相同,如果相同则自动持有锁(等同于 CAS(null, 当前线程指针) )。

那么,单线程持有多久算偏行锁呢?根据JVM的默认设置,是持有4秒种。可以通过指令 -XX:BiasedLockingStartupDelay=0 修改对应的延时时间。

那么其后,如果出现一个线程也来竞争呢?那么就不能再使用偏向锁了。

具体流程如下:

  1. 竞争线程使用CAS方式试图持有偏向锁但失败,因为可能原偏向线程已结束,但没有更改锁状态
  2. 等待全局安全点
  3. 暂停原持有偏向锁线程
  4. 检查偏向锁线程是否处于同步块,如果已退出,那么设置为无锁状态,竞争线程获得锁
  5. 如果偏向锁线程仍处于运行状态,则锁升级为轻量级锁,且原偏向锁线程唤醒,并持有该轻量级锁
  6. 竞争线程自旋等待轻量级锁

轻量级锁

轻量级锁的使用场景最重要的特征是:有线程来来参与锁的竞争,但数量并不多。所以可以直接采用CAS自旋的策略来进行竞争。

当线程持有了轻量级锁时,会将锁标志位改为00。其后如果其他线程前来竞争锁,会采用CAS自旋的方式试图获取锁,即CAS(01, 00)。

当自旋达到一定的次数和程度的时候,则会升级为重量级锁在JDK1.6之前默认为10次,而之后采用自适应自旋的方式,通过计算同一个锁上一次自旋所耗费的时间及根据当前拥有锁的线程的状态来决定。

重量级锁

显然,如果大量线程进行CAS试图获得轻量级锁,那么必然会进入重锁状态啦。

Java多线程杂乱笔记: 管程 / 中断机制 / JMM / volatile / Sychronize与锁升级

https://ku-m.cn/index.php/archives/712/

作者

KuM

发布时间

2022-11-23

许可协议

CC BY 4.0

添加新评论