并发编程

1 概览

image-20260121214114359

2 线程的基础知识

2.1 线程与进程的区别

  • 进程是资源分配的最小单元,进程是正在执行的程序实例,每个进程都有自己独立的内存,全局变量和文件描述符;

  • 线程是 CPU 调度的基本单元,一个进程中可以包含多个线程,线程共享进程的资源和地址空间。

  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

2.2 并行和并发的区别

现在都是多核 CPU,在多核 CPU 下:

  • 并行是指同一时刻,多个任务 同时执行,4 核 CPU 同时执行 4 个线程,解决多任务加速完成,依赖硬件资源(必须多个硬件执行单元)

  • 并发是指同一时间段,多个任务 交替执行,多个线程轮流使用一个或多个 CPU,解决多任务不阻塞,依赖调度算法

并发和并行都是为了提升系统处理多任务的能力

2.3 创建线程的方式

创建线程有 4 种方式:1.继承 Thread 类,重写 run()方法;2.实现 Runnable 接口,并实现该接口的 run()方法;3.实现 Callable 接口,重写 call()方法;4.线程池创建线程(项目中使用方式)。前两种方式线程执行完后都没有返回值,最后一种带返回值。

2.3.1 runnable 和 callable 的区别

  • Runnable 接口 run()方法没有返回值
  • Callable 接口 call()方法有返回值,是个泛型,和 Future、FutureTask 配合可以用来异步执行的结果
  • Callable 接口 call()方法运行抛异常;而 Runnable 接口的 run()方法的异常只能在内部消化,不能继续上抛

2.3.2 run() 和 start() 的区别

  • start():用来启动线程,通过该线程调用 run()方法执行 run()方法中所定义的逻辑代码。start()方法只能被调用一次。
  • run():封装了要被线程执行的代码,可以被调用多次。

2.4 线程的状态

PixPin_2026-01-22_20-33-01

2.4.1 示例问题

  1. 线程包括哪些状态?
    新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、时间等待(TIMED_WALTING)、终止(TERMINATED
  2. 线程状态之间是如何变化的?
    • 创建线程对象是 新建状态
    • 调用了 start()方法转变为 可执行状态
    • 线程获取到了 CPU 的执行权,执行结束是 终止状态
    • 在可执行状态的过程中,如果没有获取 CPU 的执行权,可能会切换其他状态
      • 如果没有获取锁(synchronized 或 lock)进入 阻塞状态,获得锁再切换为可执行状态
      • 如果线程调用了 wait()方法进入 等待状态,其他线程调用 notify()唤醒后可切换为可执行状态
      • 如果线程调用了 sleep(50)方法,进入 计时等待状态,到时间后可切换为可执行状态

2.5 顺序执行线程

join() 方法是 Thread 类的一个实例方法。当在一个线程中调用另一个线程的 join() 方法时,当前线程会被阻塞,直到被调用 join() 方法的线程执行完毕。也就是说,A 线程等待 B 线程执行完毕,就需要在 A 线程中令 B 线程调用 join 方法。

Thread t1 = new Thread(() -> {
    System.out.println("Thread 1");
});
Thread t2 = new Thread(() -> {
    try {
        t1.join(); // 加入t1线程,等待t1线程执行完毕后再执行t2线程
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    System.out.println("Thread 2");
});
// 顺序可以打乱
t1.start();
t2.start();

2.6 notify

当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了 notify 后只要一个线程会由等待池进入锁池,而 notifyAll 会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。

2.7 wait 和 sleep

共同点
wait(),wait(long)和 sleep(long)的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  1. 方法归属不同
  • sleep(long) 是 Thread 的静态方法
  • 而 wait(),wait(long)都是 Object 的成员方法,每个对象都有
  1. 醒来时机不同
    • 执行 sleep(long)和 wait(long)的线程都会在等待相应毫秒后醒来
    • wait(long)和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  2. 锁特性不同 (重点)
    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

2.8 打断线程

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止
  • 使用 stop 方法强行终止(不推荐,方法已作废)
  • 使用 interrupt 方法中断线程
    • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出 InterruptedException 异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

2.9 乐观锁和悲观锁

乐观锁和悲观锁其实是个概念并非是具体的锁,悲观锁是指悲观的认为并发情况下,线程获取资源会有线程安全问题,所以在一开始获取线程资源时加锁,保证线程安全,synchronize,lock 都是悲观锁的实现适合读写频繁,锁竞争激烈的情况。乐观锁是一开始认为并发冲突不会发生,只在进行执行操作去提交时检验数据是否有被修改过。通常通过 cas 或版本号去实现乐观锁。适合读操作频繁,锁竞争不激烈的情况。

3 线程中并发安全

3.1 synchronized 关键字

Java 语言 synchronized 关键字一文全掌握

3.1.1 基本原理

synchronized 是 Java 中用于解决并发问题的核心关键字,它通过确保多个线程对共享资源的 互斥访问,来避免线程安全问题(如竞态条件、数据不一致等)。

synchronized 是基于一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元,由 JVM 提供,C++语言实现,线程获得锁需要使用对象(锁)关联 Monitor。

Monitor 的结构如下:

  • Owner:存储当前获取锁的线程,只能有一个线程可以获取
  • EntryList:关联没有抢到锁的线程,处于 Blocked 状态的线程
  • WaitSet:关联调用了 wait()方法的线程,处于 Waiting 状态的线程
PixPin_2026-01-25_12-32-07

3.1.2 进阶-锁升级

首先提出问题:Monitor 结构怎么和 lock 对象关联的?

对象的内存结构

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Istance Data)和对齐填充。

image-20260126124145589

最重要的便是 MarkWord:

image-20260126124630215

重量级锁: 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。

轻量级锁: 在很多的情况下,在 Java 程序运行时,同步块中的代码都是 不存在竞争 的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此 JVM 引入了轻量级锁的概念。

image-20260127122533557

加锁流程

  1. 在线程栈中创建一个 LockRecord,将其 obj 字段指向锁对象。
  2. 通过 CAS 指令将 LockRecord 的地址存储在对象头的 markword 中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
  3. 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置 LockRecord 第一部分为 null,起到了一个重入计数器的作用。
  4. 如果 CAS 修改失败,说明发生了竞争,需要膨胀为重量级锁。

解锁流程

  1. 遍历线程栈,找到所有 obj 字段等于当前锁对象的 LockRecord。
  2. 如果 LockRecord 的 MarkWord 为 null,代表这是一次重入,将 obj 设置为 null 后 continue。
  3. 如果 LockRecord 的 MarkWord 不为 null,则利用 CAS 指令将对象头的 markword 恢复成为无锁状态。如果失败则膨胀为重量级锁

偏向锁: 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 MarkWord 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

image-20260127122959831

3.1.3 示例问题

  1. Monitor 实现的锁属于重量级锁,你了解过锁升级吗?
    Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

    描述
    重量级锁 底层使用的 Monitor 实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
    轻量级锁 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是 CAS 操作,保证原子性。
    偏向锁 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个 CAs 操作,之后该线程再获取锁,只需要判断 markword 中是否是自己的线程 id 即可,而不是开销相对较大的 CAS 命令。

3.2 Java 内存模型(JMM)

JMM(JavaMemoryModel)Java 内存模型,定义了共享内存中 多线程程序读写操作 的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。

JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域 (主内存)
线程跟线程之间相互隔离,线程跟线程交互需要通过主内存

image-20260129213344042

3.3 比较再交换-CAS

CAS 体现的一种乐观锁的思想,在 无锁情况 下保证线程操作共享数据的原子性。

image-20260131113106105

一个当前内存值 V、旧的预期值 A、即将更新的值 B,当且仅当旧的预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false。如果 CAS 操作失败,通过自旋的方式等待并再次尝试,直到成功。

  • CAS 使用到的地方很多:AQS 框架、Atomic 开头的类
  • 在操作共享变量的时候使用的自旋锁,效率上更高一些
  • CAS 的底层是调用的 Unsafe 类中的方法,都是操作系统提供的,由 C/C++实现

乐观锁和悲观锁的区别

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算修改了也没关系,继续重试。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

3.4 volatile 关键字

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。

对于 1 立即可见,假设有以下代码

boolean stop = false;
// 线程1
while(!stop){
    doSomething();
}

// 线程2
stop = true

当线程 2 更改了 stop 变量的值之后,但是还没来得及写入主存当中,线程 2 转去做其他事情了,那么线程 1 由于不知道线程 2 对 stop 变量的更改,因此还会一直循环下去。

  1. 使用 volatile 关键字会强制将修改的值 立即写入主存
  2. 使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU 的 L1 或者 L2 缓存中对应的缓存行无效);
  3. 由于线程 1 的工作内存中缓存变量 stop 的缓存行无效,所以线程 1 再次读取变量 stop 的值时会去主存读取。

对于 2 禁止进行指令重排序,用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。举个例子:

image-20260131205955585

使用技巧:

  1. 写变量让 volatile 修饰的变量的在代码最后位置
  2. 读变量让 volatile 修饰的变量的在代码最开始位置

3.5 抽象队列同步器-AQS

全称 AbstractQueuedSynchronizer ,即抽象队列同步器,是构建锁或者其他同步组件的基础框架,例如 ReentrantLockSynchronousQueue 等等都是基于 AQS 实现的。

AQS 内部维护了一个先进先出的双向队列,队列中存储的排队的线程。

在 AQS 内部还有一个属性 state,这个 state 就相当于是一个资源,默认是 0(无锁状态),如果队列中的有一个线程修改成功了 state 为 1,则当前线程就相等于获取了资源。
在对 state 修改的时候使用的 CAS 操作,保证多个线程修改的情况下原子性。

image-20260202212133340

AQS 和 Synchronized 的区别

Synchronized AQS
关键字,C++实现 Java 实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供多种解决方案

3.6 ReentrantLock 实现原理

可重入锁,调用 lock()方法获取锁知乎再次调用 lock()不会阻塞,底层使用了 CAS + AQS 队列实现,支持公平锁和非公平锁,两者的实现类似。

构造方法如下,接受一个可选的公平参数(默认非公平锁),当设置为 true 时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

public ReentrantLock() {
    sync = new NonfairSync(); //默认,非公平
}
 
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync(); //根据参数创建
}
  • 线程来抢锁后使用 cas 的方式修改 state 状态,修改状态成功为 1,则让 exclusiveOwnerThread 属性指向当前线程,获取锁成功
  • 假如修改状态失败,则会进入双向队列中等待,head 指向双向队列头部,tail 指向双向队列尾部
  • 当 exclusiveOwnerThread 为 null 的时候,则会唤醒在双向队列中等待的线程
  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

3.7 synchronized 和 lock 的区别

  1. 语法层面

    • synchronized 是关键字,源码在 JVM 中,C++实现。
    • Lock 是接口,源码由 JDK 提供,由 Java 实现。
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁。
  2. 功能层面

    • 二者均属于悲观锁,都具备基本的互斥、同步、锁冲入功能。

    • Lock 提供了许多如公平锁、可打断、可超时、多条件遍历等 synchronized 不具备的功能。。

      在对应的代码包中有相关代码。lock\01.ReentrantLockTest.java

    • Lock 有适合不同场景的实现,如 ReentrantLock、RentrantReadWriteLock(读写锁)。

  3. 性能层面

    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖。
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能。

3.8 死锁产生的条件

image-20260203163426720

此时程序不会结束,这种现象就是死锁现象;线程 t1 持有 A 的锁等待获取 B 锁,线程 t2 持有 B 的锁等待获取 A 的锁。

当程序出现了死锁现象,我们可以使用 jdk 自带的工具:jpsjstack

  • jps:输出 JVM 中运行的 进程状态 信息
  • jstack:查看 java 进程内 线程的堆栈 信息
image-20260203163824639

3.9 ConcurrentHashMap ⭐

ConcurrentHashMap 是一种线程安全的高效 Map 集合底层数据结构:

  • JDK1.7 底层采用分段数组 + 链表实现;采用的是 Segment 分段锁,底层使用的是 ReentrantLock,当多个 key 在一个 Segment [x] 时,只能有一个线程操作这个数据。

    image-20260203165403186
  • JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树;采用 CAS+Synchronized 来保证并发安全进行实现。CAS 控制数组节点的添加,synchronized 只锁定当前链表或红黑二叉树的首节点,只要 hash 不冲突,就不会产生并发的问题,效率得到提升。

    image-20260203165611621

3.10 保证多线程的执行安全

Java 并发编程的三大特性:原子性、可见性、有序性。

  1. 原子性:一个线程在 CPU 中操作不可暂停,也不可中断,要不执行完成,要不不执行。

    image-20260212155713612

    上述代码中可以给方法加 synchronized 关键字,也可以使用 LOCK 锁。

  2. 可见性:让一个线程对共享变量的修改对另一个线程可见。

    image-20260212155942928

    使用 volatile 关键字让共享变量 flag 对另一个线程可见。

  3. 有序性

    指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

    image-20260212160159111

    可以使用 vlotile 解决指令重排序的问题。

4 线程池

4.1 线程池的参数和原理 ⭐

4.1.1 核心参数

public ThreadPoolExecutor(int corePoolSize,
                int maximumPoolSize,long keepAliveTime,
                TimeUnit unit,
                BlockingQueue<Runnable> workQueue,
                ThreadFactory threadFactory,
                RejectedExecutionHandler handler)
  • corePoolSize 核心线程数目
  • maximumPoolSize 最大线程数目 =(核心线程 + 非核心线程的最大数目)
  • keepAliveTime 生存时间 - 非核心线程的生存时间,生存时间内没有新任务,此线程资源会释放
  • unit 时间单位 - 非核心线程的生存时间单位,如秒、毫秒等
  • workQueue- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建非核心线程 执行任务
  • threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  • handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

4.1.2 执行原理

image-20260212163030396

详情查看对应代码 threadpool\01.TestThreadPoolExcutor.java

4.2 常见阻塞队列

workQueue- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  1. ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
  2. LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
  3. DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
LinkedBlockingQueue ArrayBlockingQueue
默认无界,支持有界 强制有界
底层是链表 底层是数组
懒惰的,创建节点时添加数据 提前初始化 Node 数组
入队会生成新 Node Node 需要是提前创建好的
两把锁 (头尾) 一把锁(效率较低)
image-20260213092940374

4.3 确定核心线程数

  1. 高并发、任务执行时间短 →(CPU 核数+1),减少线程上下文的切换,增加 CPU 执行效率

  2. 并发不高、任务执行时间长
    IO 密集型的任务 → (CPU 核数 * 2 + 1) 多个线程执行

    计算密集型任务 →(CPU 核数 + 1) 增加 CPU 执行效率

  3. 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)

4.4 线程池的种类

4.4.1 固定线程数的线程池

public static ExecutorService newFixedThreadPool(int nThreads){
    return new ThreadPoolExecutor(nThreads,nThreads,
		OL,TimeUnit.MILLISECONDS,
		new LinkedBlockingQueue<Runnable>();
}
  • 核心线程数与最大线程数一样,没有救急线程
  • 阻塞队列是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE

适用于任务量已知,相对耗时的任务

4.4.2 单线程化的线程池,

只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行

public static ExecutorService newSingleThreadExecutor() {
    returnnew FinalizableDelegatedExecutorService
		(new ThreadPoolExecutor(1, 1,)
		0L,TimeUnit.MILLISECONDS,
		new LinkedBlockingQueue<Runnable>());
}
  • 核心线程数和最大线程数都是 1
  • 阻塞队列是 LinkedBlockingQueue,最大容量为 Integer.MAX_VALUE

适用于按照顺序执行的任务

4.4.3 可缓存线程池

public static ExecutorService newCachedThreadPool){
    return new 	ThreadPoolExecutor(O,Integer.MAX_VALUE,
				60L,TimeUnit.SECONDS,
				new SynchronousQueue<Runnable>();
}
  • 核心线程数为 0
  • 最大线程数是 Integer.MAX_VALUE
  • 阻塞队列为 SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。即所有任务都交予创建的非核心线程执行。

适合任务数比较密集,但每个任务执行时间较短的情况。

4.4.4 计划执行线程池

提供了 “延迟” 和 “周期执行” 功能的 ThreadPoolExecutor。

ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);

executorService.schedule(new Demo(),0, TimeUnit.SECONDS); // 立即执行
executorService.schedule(new Demo(), 2, TimeUnit.SECONDS); // 2秒后执行
executorService.schedule(new Demo(), 4, TimeUnit.SECONDS); // 4秒后执行

4.5 为什么不建议用 Executors 创建线程池

参考阿里开发手册《Java 开发手册-嵩山版》

【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:

  1. FixedThreadPool 和 SingleThreadPool :
    允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      // 创建阻塞队列其长度为 Integer.MAX_VALUE
                                      new LinkedBlockingQueue<Runnable>());
    }
    
  2. CachedThreadPool :
    允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
    
  3. 通过 ThreadPoolExecutor 方式,指定七个参数

    ThreadPoolExecutor threadpool = new ThreadPoolExecutor(
        2, 3, 0,
        TimeUnit.MILLISECONDS,
        queue,
        r -> new Thread(r, "myThread-" + c.getAndIncrement()), // 线程工厂
        new ThreadPoolExecutor.AbortPolicy()); // 拒绝策略
    

5 使用场景

5.1 线程池使用场景

5.1.1 CountDownLatch

CountDownLatch(闭锁/倒计时锁)基于 AQS 实现,用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值
  • await()用来等待计数归零
  • countDown()用来让计数减一
image-20260213111003431

具体使用场景:在我们项目上线之前,我们需要把数据库中的数据一次性的同步到 es 索引库中,但是当时的数据好像是 1000 万左右,一次性读取数据肯定不行(oom 异常),当时我就想到可以使用线程池的方式导入,利用 CountDownLatch 来控制,就能避免一次性加载过多,防止内存溢出。

image-20260213150500228

5.1.2 Future

在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?

image-20260213150936229

在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的 没有依赖关系,就可以使用线程池 + future 来提升性能

5.1.3 异步线程

异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间。

image-20260213153317161

5.2 控制某个方法充许并发访问线程的数量

SemaphoreCase 使用流程

  • 创建 Semaphore 对象,可以给一个容量仓
  • semaphore.acquire():请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
  • semaphore.release():释放一个信号量,此时信号量个数 +1
image-20260213154503677

5.3 对 ThreadLocal 的理解

ThreadLocal 是多线程中对于解决线程安全的一个操作类,它会 为每个线程都分配一个独立的线程副本 从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享

1719982991053-c983c4b8-7c5f-4e2d-a90d-5b42ebd533d5

案例:使用 JDBC 操作数据库时,会将每一个线程的 Connection 放入各自的 ThreadLocal 中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免 A 线程关闭了 B 线程的连接。

5.3.1 基本使用

image-20260213160908614

5.3.2 实现原理

image-20260213161352678
  1. set()方法:
image-20260213161932517
  1. get()/remove()方法:

    image-20260213162046290

    remove()和 get()类似。

5.3.3 内存泄漏问题

Java 对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则 GC 并不会回收它。即便堆中内存不足了,宁可出现 OOM,也不会对其进行回收。

    User u = new User()
    
  • 弱引用:表示一个对象处于可能有用且非必须的状态。在 GC 线程扫描内存区域时,一旦发现弱引 I 用,就会回收到弱引 I 用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。

    User user = new User();
    WeakReference weakReference =new WeakReference(user);
    

每一个 Thread 维护一个 ThreadLocalMap,在 ThreadLocalMap 中的 Entry 对象继承了 WeakReference。其中 key 为使用弱引用的 ThreadLocal 实例,value 为线程变量的副本。

image-20260213163645232
  • ThreadLocal 对象不再被外部引用(例如:static ThreadLocal tl = null;),由于 key 是弱引用,GC 会回收 key(此时 Entry 的 key 变为 null)。
  • value 是强引用,且被 ThreadLocalMap 持有 → value 无法被 GC 回收
  • 如果 线程长期存活(例如线程池中的线程),ThreadLocalMap 会一直存在 → value 持续占用内存内存泄漏。所以建议主动 remove 释放 key、value。例如实际应用上的用户登录。
posted @ 2026-03-19 16:57  solarlemon  阅读(0)  评论(0)    收藏  举报