线程

线程的状态及流转

Java中在Thread类里有一个枚举应Enum,明确规定了Java线程的6种状态。

  1. 创建线程对象(新建状态new)
  2. 调用start方法(就绪状态Runable),就绪状态如果抢到CPU的执行去哪就变成运行
  3. 阻塞状态(Blocked):无法获取锁对象。
  4. 等待状态waiting:遇到wait方法时
  5. 计时等待状态(TIME_WAITING),遇到 sleep 方法时。
  6. 结束状态(Terminated)本身的run方法执行完毕后。

创建线程的方式

  1. 继承Thread类
  2. 实现Runnable接⼝
  3. 实现Callable接⼝结合FutureTask,适⽤于执⾏有返回值的任务
  4. 通过线程池创建线程

多线程的应用

  • 软件中耗时操作(拷贝迁移文件;加载大文件)
  • 所有聊天软件
  • 所有后台服务器

线程和协程的区别

可以把县程想象成公司的正式员工:

  1. 成本高,每个人都要占工位,内存大约占 1 MB。入职离职手续麻烦,创建和销毁慢。
  2. 归老板管,操作系统就是老板。老板让你干你就干,让你停就得停(抢占式调度),切换任务时还要开交接会(上下文切换)

而协程就像是外包临时工:

  • 成本极低:自带小板凳就能干活(栈内存极小,几KB),招之即来挥之即去。
  • 归项目组管:程序自己就是组长(用户态调度)。组长发现A临时工在等快递(IO阻塞),立马让他去旁边休息,换B临时工上来干活,根本不需要惊动大老板(OS)。

线程问题

线程A里启动一个线程B,A线程需要等待B线程执行完后才能执行。都有哪些方法可以实现这种场景?

  1. Thread.join方法
  2. 并发工具类CountDownLatch
  3. CompletableFuture
import java.util.concurrent.CompletableFuture;
public class CompletableFutureDemo {
 public static void main(String[] args) {
 System.out.println("A 线程启动 B 线程");
 
 // 启动 B 线程,并等待它完成
 CompletableFuture.runAsync(() -> {
 System.out.println("B 线程执⾏");
 try { Thread.sleep(2000); } catch (InterruptedException e) {}
 System.out.println("B 线程执⾏完毕");
 }).join(); // 阻塞 A 线程,等待 B 完成 (注意这⾥)
 System.out.println("A 线程继续执⾏");
 }
}

线程池

使用线程池的好处:
通过使用线程池,可以起到资源管理、线程复用的效果,避免频繁地创建和销毁线程带来的性能开销。

具体解释:
线程创建和销毁是一项开销较大的操作,通过使用线程池,可以事先创建一些线程并将其放入池中。需要执行任务时,直接从池中复用线程,从而避免频繁的创建和销毁。

线程池的七个参数

  • 核心线程数量
  • 最大线程数量
  • 临时线程最大存活时间
  • 临时线程最大存活时间单位
  • 任务阻塞队列
  • 线程工厂,用于创建线程的工厂类
  • 任务的拒绝策略

线程池提交任务的流程

  1. 提交任务时,会先让核心线程去执行任务。
  2. 如果核心线程都在工作,则先将任务放到阻塞队列。
  3. 如果阻塞队列满了,创建临时线程去执行任务(核心线程加临时线程不能超过最大线程数)。
  4. 如果达到最大线程数,则会触发拒绝策略。

任务拒绝策略,怎么选择

线程池的拒绝策略是在任务队列满且线程数达到最大值时触发的兜底机制。JDK内置了4种策略:

  1. AbortPolicy,直接抛RejectedExecutionException,这是默认策略。调用方能立即感知任务被拒,适合核心业务场景,比如订单支付必须让上游知道这单没处理。
    2)CallerRunsPolicy,谁提交的任务谁执行。调用者线程被拉去干活,自然就提交不了新任务,相当于一个反压机制。适合允许短暂阻塞调用线程的场景,比如后台日志异步写入。
    3)DiscardOldestPolicy,踢掉队列里排队最久的那个任务,把当前任务塞进去。用的时候要小心,老任务可能比新任务更重要。
  2. DiscardPolicy,静默丢弃,不抛异常也不执行。只适合那些丢了也无所谓的场景,比如埋点上报。

如何设置线程池的线程数

线程数怎么设置,关键看是 CPU 密集型还是 IO 密集型。

对于 CPU 密集型任务(比如解密、压缩、复杂计算),这时候线程开太多没有意义,线程切换反而浪费时间。经验是设置为 CPU 核心数 + 1,多出来的一个是为了应对偶发的页缺失等中断。
IO密集型任务(比如读数据库线程),大部分时间在等IO返回,CPU闲着。这时候可以多开线程,让CPU在等待期间去处理其他任务,经验是CPU核心数乘2。

上线需要关注那些线程指标

  • 运行时状态指标
    核心线程数;活跃线程数;队列大小
  • 任务处理效率指标
    任务提交速率;完成速率;任务平均耗时;任务等待时间
  • 资源占用指标
    CPU使用率;内存占用
  • 异常情况指标
    拒绝任务数;线程创建/销毁数

线程安全

线程安全的核心是在多线程并发访问共享资源时,程序的执行结果始终符合预期,且不会出现数据错乱或逻辑错误。其根源在于下面三个方面:

  1. 原子性:操作是否不可分割?
  2. 可见性:线程对共享变量的修改是否对其他线程可见?
  3. 有序性:代码执行顺序是否可能被编译器和 CPU 重排序?

Thread local.

ThreadLocal提供了一种线程级别的数据存储机制,每个线程都拥有自己的独立ThreadLocal,意味着每个线程都会独立安全地操作变量,而不会影响其他线程。

典型使用场景:

  1. 用户身份信息存储:
    在请求拦截器或过滤器中,鉴权校验用户身份后,将用户信息存入 ThreadLocal 中。在请求的后续链路中,如果需要获取用户信息,直接从 ThreadLocal 中获取。

  2. 线程安全:
    ThreadLocal 可以用来存储一些需要并发安全处理的成员变量。

  3. 日志上下文存储:
    在常见的日志框架中,经常使用 ThreadLocal 来存储与当前线程相关的日志上下文。

主要的两个作用:
4. 在线程中传递数据:在同一个线程执行过程中,ThreadLocal 的数据一直在。所以可以在前面把数据放到 ThreadLocal 中,在后面需要时再取出来,这就避免了数据通过参数在多层方法中传递。
5. 解决并发问题

实现原理

  1. Thread 类对象中维护了 ThreadLocalMap 成员变量。
  2. ThreadLocalMap 类对象中维护了 Entry 数组,Entry 数组中的每一个元素都是一个 Entry 对象:
    (a) 每个 Entry 对象中存储了一个 ThreadLocal 对象与其要存入的数据 value。
    (b) 每个 Entry 对象在 Entry 数组中的位置,是通过 ThreadLocal 对象的 threadLocalHashCode 计算出来的,以此快速定位 Entry 对象在 Entry 数组中的位置。
  3. 所以,在 Thread 中可以存储多个 ThreadLocal 对象。

内存泄露

内存泄露:存在无法回收的对象
假设我们单独开启一个线程,并且将数据存储到 ThreadLocal 中。当线程执行任务结束退出时,线程对象被销毁,那么线程与 ThreadLocalMap 实例对象之间的关系就不存在了。在 GC 时,ThreadLocalMap 对象、ThreadLocal 对象以及之前存储的数据都会被回收掉,所以其实不存在内存泄漏。

Synchronized

每个Java对象头部都有一个mark word,存储着对象的运行时数据,如哈希码、锁状态信息。锁状态信息标志了synchronized的状态。

原理

Java的同步机制是分层抽象的

  1. 在语言层面:是一个关键字
  2. 在JVM字节码层面:对应的是monitorenter和monitorexit两台哦字节码指令,表示进入临界区,告诉JVM在此处需要所和解锁
  3. 在JVM内部通过Monitor(监视器)来管理线程的竞争和等待
    每个锁对象都关联一个监视器,内部通过三个逻辑区域管理线程竞争:
    owner:当前持有锁的线程。
    entryList:等待锁的阻塞线程队列。
    流程如下:
    当线程执行 monitorenter 时,JVM 会检查锁对象的 monitor:
    (a) 若 owner 为空,线程成为 owner。
    (b) 若 owner 被占用,线程进入 entrylist 阻塞。
    当线程执行 monitor exit 时,JVM 释放锁并唤醒 entrylist 中的线程,让它们去竞争锁。
  4. 在操作系统层面,需要从用户态切换到内核态,向操作系统底层申请互斥量,性能开销很大,因此称其为重量锁。

锁升级过程

synchronized,由于涉及到操作系统层面申请互斥量,还涉及到线程的阻塞和唤醒,都需要从用户态切换到内核态,性能开销很大,因此称其为重量级锁。
在 JDK 1.6 中,考虑到不同竞争场景和不同竞争强度的场景,对 synchronized 进行了优化,经历了从无锁到偏向锁,再到轻量级锁,最后到重量级锁的过程。

无锁-偏向锁

最初是无锁的状态。当一个 synchronized 代码块被线程首次进入时,JVM 会将锁标记为偏向锁,并在锁对象头中记录下该线程的 ID。如果该线程再次请求同一把锁(即重入锁),通过比较线程 ID,它会直接获得锁。这主要是为了没有锁竞争且需要重入的场景而引入的。

偏向锁-轻量级锁
如果有其他线程来请求获取锁,此时偏向锁就会被撤销。两个线程会通过 CAS 方式自旋尝试获取锁。获取锁后,JVM 会将锁标记为轻量级锁。

获取锁的流程:

  1. 将锁对象头中的 mark word 复制到线程栈中的锁记录结构中。
  2. 尝试通过 CAS 操作将锁对象头的 mark word 的内容更新为指向锁记录的指针。如果这个更新操作成功,那么这个线程就成功获取了这个对象的轻量级锁。

轻量级锁-重量级锁

当有较多线程短时间内请求获取锁,意味着当前竞争比较激烈。而轻量级锁会使用 CAS 方式,会占用 CPU,因此此时会发生锁膨胀,JVM 将锁标记为重量级锁。重量级锁中竞争失败的线程会陷入阻塞,不会占用 CPU。

总的来说,所升级的过程受益思路其实是为了适配不同竞争强度下的场景。

ReentrantLock

Lock是接口,ReentrantLock是其实现类,底层实现是AQS。

AQS

AQS 是阻塞式锁和相关同步器工具的框架,主要由两部分组成:

  1. state 属性:
    用于表示共享资源的状态,分为独占模式和共享模式。不同的子类实现中,对于 state 值有不同的含义。
    (a) 独占模式:只有一个线程能够访问资源。
    (b) 共享模式:允许多个线程使用。
    此外,使用 CAS 机制更新 state 属性,保证对 state 值修改的线程安全。

  2. FIFO 等待队列(双向队列)
    用于存储等待获取锁的线程。因为有些线程可以抢占到锁,而有些线程无法抢占到锁,此时没有竞争到锁的线程就会被打包成一个 Node 节点,按照顺序组成一个双向链表。当释放锁时,会唤醒双向链表中的第一个 Node 节点。

独占模式(ReentrantLock)只有一个线程能够访问资源
共享模式(CountDownLatch)允许多个线程同时访问资源。

ReentrantLock

ReentrantLock 是 Java 并发包中的一类。它提供了比 synchronized 关键字更灵活的锁机制,支持公平锁和非公平锁,并且可以中断、超时等高级特性。

在这里插入图片描述

ReentrantLock 如何实现公平锁和⾮公平锁

先解释公平和非公平的概念:

  1. 公平竞争:锁资源的线程严格按照请求的顺序来分。
  2. 非公平竞争:锁资源的线程允许插队来尝试抢占锁资源。

ReentrantLock 默认是非公平锁。其底层 AQS 的实现是:当线程没有抢占到锁时,会将其打包成 Node 节点加入到 FIFO 双向链表。
在上述背景的情况下:

  1. 公平锁的实现:当线程在竞争锁资源时,会判断双向链表中是否有线程在等待。如果有,就加入到链表的尾部进行等待。
  2. 非公平锁的实现:当线程在竞争锁资源时,不管双向链表中是否有线程在等待,都会去尝试抢占锁资源。如果抢占不到,再加入双向链表。

公平和非公平,其实是相对于在双向链表中等待的线程而言的。

CAS

CS 原理(Compare and Swap,比较再交换):在更新前检查数据是否被修改,若未被修改则更新,否则自旋重试

具体解释如下:

主存中有一个共享变量。当线程想要修改这个变量的值时,具体流程如下:

  1. 初始计算:
    线程需要先把该变量的值拷贝到自己的工作内存中,进行计算操作,得到一个新的值。

  2. 比较与写入:
    线程带着旧值和新值再次找到主存中的共享变量,比较旧值和当前主存中的共享变量是否相同:
    (a) 若相同:说明这期间没有其他线程修改过,主存中的共享变量是线程安全的。因此,把新值写入主存的共享变量中。
    (b) 若不同:说明期间有其他线程修改过,主存中的共享变量不是线程安全的。此时需要自旋重试。

  3. 自旋重试:
    再次取得主存中共享变量的值,回到线程的工作内存进行计算操作,得到新值。然后再次比较旧值和主存中的共享变量的值是否相同,重复上述逻辑,直至自旋成功。

CAS 的应用场景:

  1. AQS 中大量使用 CAS 来实现基本操作。
  2. juc包下的原子类中,大量使用 CAS + volatile来保证线程安全。

CAS 的优劣点:

  1. 优点:不加锁,无锁并发保证线程安全。
  2. 缺点:自旋占用 CPU。

乐观锁和悲观锁

  1. 乐观锁
    (a) 核心态度:持乐观态度,认为并发冲突的概率较低。
    (b) 工作机制:在提交时检查数据是否被修改,若未被修改则提交,否则重试或抛出异常。
    © 实现方式:例如版本号、CAS 及乐观锁实现。
    (d) 应用场景:适合读多写少,可以降低所带来的性能开销。
    (e) 特点:并发性能高;系统能够容忍或处理失败的情况,因为乐观锁可能会因为并发冲突导致执行失败。

  2. 悲观锁
    (a) 核心态度:持悲观态度,认为并发冲突的概率高。
    (b) 工作机制:每次操作数据时都加锁,确保线程安全。
    © 实现方式:例如 synchronized 及悲观锁实现。
    (d) 应用场景:适合写多读少,避免频繁冲突导致的多次重试;数据一致性要求极高(如金融系统)。
    (e) 特点:并发性能低。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐