【手撕JAVA多线程】1.从设计初衷去看JAVA的线程操作
目录
前言
概述
主动阻塞/唤醒
代码示例
实现
为什么必须在同步块中使用
计时等待是如何实现的
被动阻塞/唤醒
为什么要有被动阻塞/唤醒
实现(锁升级)
前言
JAVA多线程相关的内容很多很杂,但工作中用到的频率不高,用到的面也不全,导致是学了又忘忘了又学,在面对并发的场景下没办法很好的去解决问题,我相信不止一个人会存在以上这样的情况。出现这样的问题,是因为没有对多线程建立成体系的认识。接下来博主就将用一个系列去带大家去建立这个体系,博主有信心,这个系列能让大家从此对JAVA多线程体系有丝滑且根深蒂固的认识。
JAVA多线程部分的内容,无非分为三部分:
-
线程的操作
-
线程状态模型
-
线程基础操作:创建线程、阻塞/唤醒、等待/唤醒
-
-
线程安全问题,即JMM
-
一系列的线程同步工具、线程编排工具
本文先讲第一部分:线程的操作。
当然这一部分只会讲线程的状态操作,不会去将怎么创建线程、结束线程这些基础操作,相信大家都有一定基础看这篇文章才是有益的。
概述
线程的操作这一部分无非内容就是:
-
JAVA中的线程状态模型
-
线程的基本操作(新建、阻塞、等待)。
如果只是按照顺序来聊一遍,那和市面上大多数教程也没什么区别了,我们要建立关于线程操作这一部分的体系认识,才能从思想上彻底吃透,做到内化于心,这样怎么都不会忘记。JAVA线程操作部分的体系认识:
1.线程状态的控制是线程的核心操作
线程存在的根本意义本身就在于控制程序的执行,线程上跑的是程序,通过控制线程状态来实现对程序执行的控制,想暂停执行就阻塞当前线程,想要继续执行就让当前线程处于“就绪”状态去等待CPU的时间片。所以线程最核心的就是对于它的控制操作,即线程的状态操作。
2.JAVA的线程状态也是基于操作系统的原生状态,只是根据自己的需要将阻塞拆分成了多种
JAVA作为一门编程语言,它是运行在操作系统上的,所以他的线程对应的就是操作系统的真实线程,操作系统原生的线程状态有三种:就绪、运行、阻塞。JAVA自然不能违背这三种基础状态,JAVA只是将阻塞状态拆成了几种:轻量级阻塞(等待、计时等待)、重量级阻塞(阻塞)。
为什么要拆成轻量级阻塞和重量级阻塞?JAVA作为一门面向应用开发的语言,要提供线程操控能力,就要允许开发者来手动阻塞/唤醒线程,轻量级阻塞就是是给开发者调用Object的wait()和notify()来手动阻塞/唤醒线程的。重量级阻塞是因为手动操作阻塞/唤醒需要一个绝对线程安全的环境,即需要一个同步块,JDK提供了synchronized 原语,用来保证资源绝对被单一线程持有,从而创造出一个同步块出来。可以理解为两者底层都是调用系统调用对线程进行了真正的阻塞,轻量级阻塞是主动阻塞,重量级阻塞是由JVM层面来控制的阻塞。
3.线程的核心操作是阻塞/唤醒
在JAVA的多线程编程中,我们无非就是通过操作线程“阻塞/唤醒”来实现对线程的控制,这里面无非可做的就两件事:
-
通过wait()和notify来实现主动的阻塞/唤醒
-
通过synchronized关键字来实现被动的阻塞/唤醒
所以搞清楚JAVA线程的阻塞,其实就搞明白了JAVA多线程的核心操作。
主动阻塞/唤醒
代码示例
api:
-
wait(),等待notify/notifyAll唤醒
-
wait(long timeout),计时等待,到时自动唤醒,也可被notify/notifyAll唤醒
-
notify,唤醒,随机选择一个阻塞的线程唤醒
-
notifyAll,唤醒所有阻塞的线程,让他们去自由争抢
public class WaitNotifyExample {private final Object lock = new Object();private boolean condition = false; public void producer() throws InterruptedException {synchronized (lock) {System.out.println("生产者线程开始...");// 模拟生产耗时Thread.sleep(2000);condition = true;System.out.println("生产者完成,通知消费者...");lock.notify(); // 或者使用 notifyAll() 唤醒所有等待线程}} public void consumer() throws InterruptedException {synchronized (lock) {System.out.println("消费者线程开始,等待通知...");while (!condition) { // 使用 while 防止虚假唤醒lock.wait(); // 释放锁并等待}System.out.println("消费者被唤醒,继续执行...");condition = false;}} public static void main(String[] args) {WaitNotifyExample example = new WaitNotifyExample();new Thread(() -> {try {example.consumer();} catch (InterruptedException e) {e.printStackTrace();}}).start();new Thread(() -> {try {example.producer();} catch (InterruptedException e) {e.printStackTrace();}}).start();} }
实现
主动的阻塞/唤醒,是为了提供灵活的线程编排能力,能灵活的控制线程的执行进度,既然要足够灵活,那么就要求线程能被主动阻塞在任何地方,于是JVM选择能让线程被阻塞在任何类上。所以JVM会为每个对象维护一个监视器(Monitor),Java的wait()和notify()机制是通过JVM底层的监视器(Monitor)实现的。
class ObjectMonitor {private Thread owner; // 当前持有锁的线程private int recursion; // 重入次数private Queue<Thread> entryList; // 等待获取锁的线程队列private Queue<Thread> waitSet; // 等待条件的线程集合private Object object; // 关联的Java对象private PlatformEvent platformEvent; // 平台相关的同步原语 }
【question】为什么要有两个队列?
waitSet里面存的是blocked(阻塞状态)的线程,entryList里面存的是可执行的线程,等待操作系统分配时间片。被阻塞的线程由waitSet唤醒,进入entryList,等待操作系统时间片来执行。
wait()时的队列操作:
-
保存状态:保存当前线程的锁重入信息
-
释放锁:将监视器的owner设为null,重入计数归零
-
加入waitSet:将线程加入等待集合队列,状态变为WAITING
-
系统调用:调用操作系统原语阻塞线程
-
移出调度:线程从操作系统的运行队列中移除
notify()时的队列操作:
-
检查waitSet:查看是否有线程在等待
-
选择线程:从waitSet队列中选择一个等待线程
-
移动队列:将选中线程从waitSet移到entryList
-
改变状态:线程状态从WAITING变为BLOCKED
-
系统调用:调用操作系统原语唤醒线程
为什么必须在同步块中使用
private final Object lock = new Object();private boolean flag = false;//错误的并发代码(伪代码,实际会抛异常)public void problematicScenario() {// 线程A执行:if (!flag) { // 检查条件// 此时发生线程切换// 线程B执行并设置flag=true,调用notify()// 线程A恢复执行,调用wait() -> 永远等待!lock.wait(); // 错过了通知,永远阻塞}}//正确的同步版本public void correctScenario() throws InterruptedException {synchronized (lock) {// 检查和等待是原子操作while (!flag) {lock.wait(); // 不会错过通知}}}
计时等待是如何实现的
核心流程:
-
验证监视器所有权 - 检查当前线程是否持有锁
-
释放监视器锁 - 原子性地释放对象锁
-
加入等待队列 - 线程加入对象的wait_set队列
-
系统级阻塞 - 调用操作系统原语(futex/park/wait)阻塞线程
-
超时管理 - 启动定时器监控超时
-
等待唤醒 - 等待notify/notifyAll调用或超时到期
-
重新竞争锁 - 被唤醒后重新获取监视器锁
-
恢复执行 - 继续执行wait()后面的代码
如何实现超时管理:
-
操作系统负责超时 (主流实现)
-
JVM调用带超时参数的系统调用
-
操作系统内核维护定时器
-
内核自动唤醒超时的线程
被动阻塞/唤醒
为什么要有被动阻塞/唤醒
被动阻塞可以理解为JDK提供的原语级别的能力,用来保证资源绝对被单一线程持有。
为什么要提供这种原语级别的能力:
因为我们仔细想想就能想到纯靠编码去进行主动阻塞是无法保证线程安全的。不管什么写法都不能保证wait()一定是线程安全的。所以JDK要自己提供一个原语来保证创造一个线程安全的环境,也就是创造一个同步区域、同步块。这就是JDK提供的(被动阻塞/唤醒)同步原语:synchronized关键字。
实现(锁升级)
synchronized 关键字用于实现线程同步,它可以保证同一时刻只有一个线程执行被synchronized 修饰的代码块或方法。
底层实现原理
-
对象头(Mark Word) 每个 Java 对象在内存中都有一个对象头,对象头中包含锁信息,用于实现 synchronized。
-
锁的四种状态 无锁状态:对象未被任何线程锁定 偏向锁:偏向第一个访问对象的线程,减少同一线程获取锁的开销 轻量级锁:当存在多个线程竞争但竞争不激烈时使用 重量级锁:当线程竞争激烈时,会阻塞未获取到锁的线程
-
锁升级过程 无锁 → 偏向锁 → 轻量级锁 → 重量级锁(单向升级)
无锁:
当同步代码首次被一个线程访问,那么就会在Mark Word记录该线程的ID,从无锁状态(001)变成偏向锁(101)。
偏向锁:
当下一次同步代码被访问时,那么就会检测该线程ID与锁的Mark Word 中的线程ID是否是相同。
相同:则直接进入同步代码,因为之前没有释放锁
不同:表示发生了竞争,会尝试使用CAS来替换Mark Word里面的线程ID。竞争成功则会替换Mark Word 里面的线程ID,竞争失败可能会变成轻量级锁。
轻量级锁:
当有第二个线程尝试获取锁时,偏向锁升级为轻量级锁,两个线程自由争抢,没抢到的就CAS自旋等待。
重量级锁:
当自旋超过一定次数(默认10次)或等待线程数超过CPU核心数的一半,锁升级为重量级锁,这时候会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。