note · 8,141

JUC 并发编程

Java 并发:JMM、volatile、线程池、锁、AQS、CAS、Synchronized。

Java并发笔记

JUC 并发编程

JMM Java内存模型

  • 缓存不一致性:在一个多核 CPU 系统中,每个核心都有自己独立的高速缓存(L1, L2 Cache)。 于是便有了下面两个概念。

主内存 (Main Memory) : 所有线程共享的内存区域

工作内存 (Working Memory) : 每个线程私有的内存区域

JMM 的解决的三大核心问题:原子性、可见性和有序性

原子性:一个操作或多个操作,要么全部执行并且执行过程不被任何因素打断,要么就都不执行。类似于数据库的事务。

可见性: 当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。因为工作内存的存在,普通变量无法保证立即可见。

有序性:程序的执行顺序按照代码的先后顺序执行。为了提升性能,编译器和处理器通常会对指令进行重排序 (Instruction Reordering),在多线程下这会导致执行结果错误。

线程安全 - 问题核心:原子性,可见性,有序性

  • 原子性
    • Lock锁 Synchronized
    • Atomic原子包
  • 可见性
    • volatile 读写屏障
    • Lock锁 Synchronized
  • 有序性
    • volatile 内存屏障 禁止指令重排序
    • Happens-Before原则
      • 如果操作 A happens-before 操作 B,那么 A 操作的执行结果将对 B 操作可见,并且 A 的执行顺序排在 B 之前。

Volatile

  • 保证内存的可见性 当一个变量被一个线程高频调用 会加入该线程的缓存 Volatile可强制从主内存加载
  • 禁止指令重排序 保证内存的有序性 cpu 是和缓存做交互的,但是由于 cpu 运行效率太高,所以会不等待当前命令返回结果从而继续执行下一个命令,就会有乱序执行的情况发生

进程和线程的区别

关系:一个进程可以包含一个或多个线程,但至少有一个主线程。线程是“轻量级的进程”。

目的:引入线程是为了在进程内部实现并发,减少程序在并发执行时所付出的时空开销,提高执行效率。而多进程则是为了让操作系统能够同时运行多个独立的程序,实现隔离和并行

资源上: 进程是操作系统分配资源的基本单位,而线程是 CPU 调度和执行的基本单位。

创建线程的方式

  1. 继承Thread 重写 run方法
  2. 实现Runnable接口
  3. 实现Callable接口
  4. 使用线程池

线程状态 - Java中的六种状态

  1. 新建 (NEW)使用new Thread()但是还没有调用start()方法
  2. 可运行 (RUNNABLE)调用了线程的 start() 方法后,线程就进入了 RUNNABLE 状态 。
    1. 在 Java 的虚拟机层面,RUNNABLE 实际上包含了操作系统层面的两种状态:就绪(Ready)和运行中(Running)
  3. 阻塞 (BLOCKED)当线程试图进入一个被 synchronized 关键字保护的代码块或方法,但该区域的“锁”已经被其他线程拿走时,它就会进入 BLOCKED 状态。
  4. 等待 (WAITING) 线程主动放弃 CPU,并且无限期地等待另一个线程执行特定的操作来唤醒它 。Object.wait()
  5. 计时等待 (TIMED_WAITING)Thread.sleep(long millis)
  6. 终止 (TERMINATED)执行完毕或意外中断

线程状态 - OS中的五种状态

  1. 新建状态 (New / Created) —— 正在办理入职

  2. 就绪状态 (Ready) —— 坐在工位上,等老板分配活儿

  3. 运行状态 (Running) —— 正在疯狂敲代码

  4. 阻塞/等待状态 (Blocked / Waiting) —— 电脑死机了,等 IT 来修

    含义: 线程在运行过程中,遇到了一些需要等待的事情,比如:等待读取磁盘文件(I/O 操作)、等待网络数据、等待获取锁、或者主动要求睡眠(Sleep)。

    状态: 操作系统非常聪明,它发现这个线程现在干不了活,就会立刻剥夺它的 CPU,把它扔进“阻塞队列”。直到它等待的事情完成了(比如网速恢复了),它才会被唤醒,但注意:唤醒后不是立刻执行,而是回到 就绪状态 (Ready) 重新排队。

  5. 终止状态 (Terminated / Exit) —— 彻底离职

核心对比:为什么 Java 是 6 种,而 OS 是 5 种?

Java 虚拟机(JVM)是跑在操作系统之上的程序。Java 的线程状态是对 OS 状态的一种重新封装和映射。它们的核心差异在于:

操作系统状态Java 线程状态为什么会有差异?
Ready (就绪)

+

Running (运行)
👉 RUNNABLE (可运行)合并了! 对 Java 程序员来说,区分线程是在排队还是在执行意义不大,因为这是操作系统 CPU 调度器管的事,JVM 管不了。所以 Java 把它们统称为 RUNNABLE(只要没被阻塞,都在这个状态)。
Blocked (阻塞)👉 BLOCKED (阻塞)

👉 WAITING (无限期等待)

👉 TIMED_WAITING (限期等待)
细分了! Java 作为一门高级语言,为了更精细地控制多线程并发(比如锁机制、线程通信),把操作系统底层粗暴的“阻塞”状态,根据导致阻塞的具体原因拆分成了三种不同的状态。

Sleep与Wait的区别

对比维度Thread.sleep(long millis)Object.wait()
是否释放锁不释放释放
所属类java.lang.Thread (静态方法)java.lang.Object (实例方法)
主要用途线程暂停,时间控制线程协作,等待通知
唤醒条件时间到 或 被中断notify/notifyAll唤醒 或 被中断
使用前提任何地方都可以调用必须synchronized 块或方法中调用
生活比喻抱着枕头(锁)睡觉,别人只能干等让出座位(锁)去等候区,等别人叫号

线程池

线程池的核心目的是减少开销:

  1. 线程创建和销毁的开销
  2. 上下文切换的开销
    1. 当线程数量远远超过 CPU 核心数时,CPU 就必须在大量线程之间来回切换,让人人都有“上机”的机会。
  3. 内存耗尽的风险
public ThreadPollExecutor(int corePoolSize,
                          int maximuPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Ruanable> workQueue,
                          ThreadFactor threadFactory,
                          RejectedExcutionHandler handler)
  • CPU密集型
    • corePoolSize = CPU核数+1
  • IO密集型
    • corePoolSize = CPU核数*2
  • 混合型

但是实际问题是复杂的,这并不能很好的适配实际的情况。

核心思想是要尽可能发挥CPU的性能

测试参数,压测,动态调整参数,得到最好的结果。

  • 拒绝策略
    • AbortPolicy (中止策略) - 默认策略 直接抛出一个 RejectedExecutionException 异常
    • CallerRunsPolicy (调用者运行策略) 将该任务回退给提交它的那个线程(调用线程)来亲自执行
    • DiscardPolicy (丢弃策略)
    • DiscardOldestPolicy (丢弃最旧策略)

线程池的执行流程

提交任务 → 核心线程是否已满?
  ├─ 未满 → 创建核心线程执行
  └─ 已满 → 任务入队
       ├─ 队列未满 → 等待执行
       └─ 队列已满 → 创建非核心线程
           ├─ 未达最大线程数 → 执行任务
           └─ 已达最大线程数 → 执行拒绝策略

线程池是怎么保证核心线程不会被回收的

用一句话总结就是:死循环(获取任务) + 阻塞队列 (BlockingQueue) 的条件阻塞

  • 对于核心线程
    • 调用的是阻塞队列的 take() 方法。这是一个无限期阻塞的方法。如果队列为空,该方法会将当前线程挂起(底层通过 LockSupport.park() 实现),线程进入 WAITING 状态,不消耗 CPU 资源。它会一直停在这个位置死等,直到有新任务被 offer 进队列并唤醒它。

什么是协程

  • 在传统的 Java 中: Java 原生的 Thread 模型通常是 1:1 模型,即一个 Java 线程底层映射到一个操作系统的内核线程。并且这样一个线程会占到内容1MB多,这导致在面对高并发的 I/O 密集型场景(比如高并发 API 接口、大规模网页爬虫)时,系统容易遇到瓶颈。(注:Java 21 引入了虚拟线程 Virtual Threads,这其实就是 Java 官方实现的协程,用来解决这个问题)
  • 在 Go 语言中: Go 语言在并发编程上极其出色,正是因为它原生内置了协程,即 Goroutine。Go 在运行时(Runtime)层面实现了一套非常高效的 M:N 调度模型(也就是著名的 GMP 模型),将 M 个 Goroutine 动态映射到 N 个操作系统线程上执行,协程的创建、销毁和上下文切换完全在用户态完成,开销极小,占的内存在几KB。 完美屏蔽了底层的复杂性,开发者只需要一个 go 关键字就能轻松实现高并发。

  • 悲观锁 (排他锁、互斥锁) 并发高 读写竞争高的情况
    • Synchronized 无锁 - 偏向锁 - 自旋锁 - 重量级锁
    • ReentryLock AQS
  • 乐观锁
    • 自旋锁
    • CAS机制 Compare And Set
  • 公平锁
    • new ReentryLock(true)
    • 等待的线程先来后到 效率较低 避免线程饥饿
  • 非公平锁
    • 多数情况非公平锁更好
    • 等待的线程不保证先到先得 效率更高 可能出现线程饥饿
  • 排他锁 - 写锁 ReentryReadWriteLock.ReadLock
  • 共享锁 - 读锁 ReentryReadWriteLock.WriteLock
  • 可重入锁 同一个线程可以重复持有一个锁
  • 锁升级 无锁 - 偏向锁(仅第一次获取锁有开销) - 轻量级锁(自旋锁) - 重量级锁(Monitor实现,排他锁) Synchronized的锁升级机制

悲观锁与乐观锁

  • 悲观锁
    • 始终假设并发操作会发生冲突(“悲观” 地认为冲突是常态),因此在操作共享资源前,会先获取锁,确保自己独占资源,直到操作完成后才释放锁。在整个过程中,其他线程会被阻塞,无法操作该资源。
    • synchronized、ReentrantLock
    • MySQL中的行锁,表锁
  • 乐观锁
    • 假设并发操作很少发生冲突(“乐观” 地认为冲突是少数情况),因此不会提前获取锁,而是在操作资源时通过某种机制检测是否发生冲突,如果没有冲突则成功操作,否则放弃操作或重试。
    • 版本号机制
    • CAS Compare And Set

ReentrantLock机制 - AQS

AQS

ReentrLock的底层是AQS

AbstractQueuedSynchronizer 抽象队列同步器

抽象框架 封装了

  • 同步状态管理 state 同步状态 (volatile修饰)
  • 线程的阻塞和唤醒(动作)
  • 等待队列的管理 CLH 队列 (FIFO等待队列) 存线程

Synchronized

无论是加在方法上,还是加在代码块(对象/类)上,synchronized 的本质都是对象锁

Synchronized的锁升级机制

无锁 - 偏向锁 - 轻量级锁(自旋锁) - 重量级锁

Cas是什么

CompareAndSet (自旋锁就有用到)

  • 实现原子类
  • CPU开销过大 线程反复尝试更新一个变量(自旋锁)
  • 实现乐观锁
  • ABA问题?
    • 版本号机制 Version
  • 只能保证一个共享变量的原子性
  • 数据库的实现 - 版本号机制 (Version Number)
  • Automic包是使用CAS保证的并发安全

交替打印

方法一:基础派 synchronized + wait/notify

这是最基础、最必须掌握的解法。它考察你对对象监视器(Monitor)底层机制的理解。

核心思想: 两个线程共用一把锁。拿到锁的线程打印数字,然后唤醒另一个线程,接着自己进入等待状态并释放锁。

public class AlternatePrintWaitNotify {
    private static int count = 1;         // 共享的计数器
    private static final Object lock = new Object(); // 共享的锁对象
    private static final int MAX = 100;   // 打印上限
 
    public static void main(String[] args) {
        Runnable printTask = () -> {
            while (true) {
                synchronized (lock) {
                    // 1. 检查是否结束
                    if (count > MAX) {
                        lock.notifyAll(); // 结束前唤醒可能还在等待的线程,防止死锁
                        break;
                    }
 
                    // 2. 打印并累加
                    System.out.println(Thread.currentThread().getName() + " 打印: " + count++);
 
                    // 3. 叫醒另外一个线程
                    lock.notify();
 
                    // 4. 自己进入等待状态,并释放锁 (如果还没打印完的话)
                    if (count <= MAX) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }
                }
            }
        };
 
        new Thread(printTask, "线程A (奇数)").start();
        new Thread(printTask, "线程B (偶数)").start();
    }
}

面试官可能会追问的细节:

  • 为什么**wait()**必须在**synchronized**里面? 答:因为 wait() 的语义是“释放当前持有的锁并挂起”,如果你都没拿到锁,怎么释放?所以必须在同步块内。
  • **notify()****wait()**的顺序能反吗? 答:绝对不能。如果先 wait(),线程就直接挂起并释放锁了,后面的 notify() 根本执行不到,两个线程会互相等待,造成死锁。

方法二:进阶派 ReentrantLock + Condition (JUC 推荐)

在实际开发中,我们更推荐使用 JUC 包下的高级锁。它的优势在于可以创建多个 Condition(条件变量),实现精准唤醒

虽然对于两个线程交替打印,它和 wait/notify 区别不大,但在“三个线程交替打印 ABC”的场景下,它的威力就显现出来了(A 明确唤醒 B,B 明确唤醒 C)。

这里演示使用单个 Condition 实现 1-100 交替打印:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
 
public class AlternatePrintLock {
    private static int count = 1;
    private static final int MAX = 100;
 
    // JUC 的可重入锁
    private static final ReentrantLock lock = new ReentrantLock();
    // 类似于 wait/notify 的条件变量
    private static final Condition condition = lock.newCondition();
 
    public static void main(String[] args) {
        Runnable printTask = () -> {
            while (true) {
                lock.lock(); // 加锁
                try {
                    if (count > MAX) {
                        condition.signalAll(); // 结束前唤醒其他线程
                        break;
                    }
 
                    System.out.println(Thread.currentThread().getName() + " 打印: " + count++);
 
                    condition.signal(); // 相当于 notify()
 
                    if (count <= MAX) {
                        condition.await();  // 相当于 wait()
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    lock.unlock(); // 关键点:必须在 finally 中释放锁!
                }
            }
        };
 
        new Thread(printTask, "线程A").start();
        new Thread(printTask, "线程B").start();
    }
}

面试官可能会追问的细节:

  • **ReentrantLock****synchronized**有什么区别?

答:synchronized 是 JVM 层面的关键字,自动释放锁;ReentrantLock 是 API 层面的类,提供了更丰富的功能(如公平锁、响应中断、超时获取锁等),但必须手动在**finally**块中调用**unlock()**释放锁,否则如果发生异常会导致死锁。


💡 附加题:如果是“三个线程交替打印 ABC”怎么做?

如果面试官把题目升级为 3 个或更多线程的按顺序交替执行,最优雅的解法是引入一个“状态变量 (State)”来控制到底该谁执行。

int state = 0; // 0代表该A执行,1代表该B执行,2代表该C执行
 
// 线程 A 的代码
while(true) {
    lock.lock();
    try {
        while (state % 3 != 0) { // 如果没轮到我,我就睡
            conditionA.await();
        }
        print("A");
        state++;                 // 状态变更,轮到下一个
        conditionB.signal();     // 精准唤醒 B
    } finally {
        lock.unlock();
    }
}

注意:这里判断状态必须用 _while_ 循环,防止虚假唤醒。

掌握了这两种写法和其背后的“锁+等待/唤醒”机制,应对所有形式的交替打印问题都不在话下了。

幂等性如何保证

一个操作如果多次执行所产生的影响,与一次执行的影响相同,我们就称这个操作是幂等的

  • 天然幂等:SELECT(查询)、DELETE(删除特定ID)、UPDATE table SET status = 1 WHERE id = 1(绝对值更新)。
  • 非幂等:INSERT(多次执行会产生多条数据)、UPDATE table SET amount = amount - 10 WHERE id = 1(相对值更新)。
  1. 前端防重 - 防止正常用户 但不防止恶意用户
  2. Token 机制(防重 Token)—— 适用于前后端交互的提交场景
    1. 提交前先获取一个全局唯一的 Token,服务端验证后销毁。
  3. 数据库唯一索引(Unique Key)—— 适用于 Insert
  4. 乐观锁(Optimistic Locking)—— 适用于 Update
    1. 在数据库表中增加一个 version(版本号)字段。

i++是原子操作吗

读取 - 修改 - 写入”(Read-Modify-Write)的复合操作