前言

在前两篇日记中,我梳理了 Java 基础和集合框架的核心知识。如果说集合是数据的容器,那么多线程就是驱动程序高效运行的引擎。今天,我要深入一个让我既兴奋又敬畏的主题——多线程与并发

记得第一次写多线程程序时,我天真地以为开几个线程就能让程序“飞起来”。结果迎来的不是性能提升,而是诡异的 Bug、随机崩溃的数据,以及深夜调试的崩溃。直到我系统学习了并发原理,才明白:多线程不是简单地“多开几个线程”,而是一套需要精密控制的艺术

从“线程安全”这个术语开始,到 JMM、锁优化、AQS 底层,我花时间才把零散的知识点串联成体系。今天,我将结合自己踩过的坑、读过的源码、做过的内容,带你深入 Java 并发的核心层。


1. 重新理解“线程”:从概念到本质

1.1 线程究竟是什么?与进程有何不同?

我曾经的误解:我以为线程就是“轻量级的进程”,多线程就是“多开几个执行流”。

现在的理解

  • 进程:是操作系统资源分配的基本单位,拥有独立的内存空间(堆、栈、数据段)。

  • 线程:是 CPU 调度的基本单位,共享进程的内存空间,但有独立的栈和程序计数器。

关键认知

Java 线程与操作系统线程是 1:1 映射(通过 pthread)。这意味着创建线程是有成本的(约 1MB 栈内存 + 系统调用开销)。而 Go 的协程初始只有 2KB,且由 Go 运行时调度,这就是为什么 Go 可以轻松创建十万级协程,而 Java 线程上万就可能崩溃。

1.2 线程的生命周期与状态转换

        源自《Java并发编程艺术》 java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法获取当前线程的状态

线程状态 解释
NEW 尚未启动的线程状态,即线程创建,还未调用start方法
RUNNABLE 就绪状态(调用start,等待调度)+  正在运行
BLOCKED 等待监视器锁时,陷入阻塞状态
WAITING 等待状态的线程正在等待另一线程执行特定的操作(如notify)
TIMED_WAITING 具有指定等待时间的等待状态
TERMINATED 线程完成执行,终止状态

学习线程状态时,我总混淆 BLOCKED 和 WAITING。直到看到了下面这张图,才彻底清晰:

核心区别

  • BLOCKED被动进入,因为抢锁失败,唤醒是自动的(锁释放时)。

  • WAITING主动进入,因为调用了 wait() 等方法,唤醒必须显式触发notify())。


2. 并发问题的根源:JMM 与三大特性

2.1 Java 内存模型(JMM):并发世界的宪法

JMM 不是真实存在的内存结构,而是一套规则,规定了多线程如何访问共享变量。

JMM 的核心思路是:定义主内存(大家共享的内存)和工作内存(每个线程自己的缓存),规定变量必须从主内存加载到工作内存才能操作,改完再写回主内存

我想的比喻

假设共享变量是一块公共黑板(主内存),每个线程都有一个自己的笔记本(工作内存/缓存)。
JMM 规定:你要修改黑板上的内容,必须先抄到笔记本上,改完再誊写回去。
问题就在于:什么时候抄?什么时候写回去?抄写顺序会不会乱?

这就是 可见性、原子性、有序性 三大问题的来源。

2.2 可见性:为什么改了值别人看不见?

        先想个场景:两个线程同时操作一个变量,比如线程 A 改了变量的值,线程 B 能不能立刻看到? 如果线程 B 看到的还是旧值,这就是可见性问题。为啥会这样?

        因为现在 CPU 都有缓存,线程操作变量时,会先把主内存里的变量读到自己的工作内存(比如 CPU 缓存)里,改完可能没及时写回主内存,另一个线程读的还是主内存的旧值

        JMM 里像 volatile 关键字就专门解决这个,用了 volatile 的变量,改完会立刻刷回主内存,同时让其他线程的缓存失效,必须重新从主内存读,这样就保证了可见性。

// 经典可见性问题
public class VisibilityProblem {
    private static boolean flag = false; // 没有 volatile!
    
    public static void main(String[] args) throws InterruptedException {
        Thread writer = new Thread(() -> {
            try { Thread.sleep(1000); } catch (InterruptedException e) {}
            flag = true;
            System.out.println("Flag 设置为 true");
        });
        
        Thread reader = new Thread(() -> {
            while (!flag) {
                // 可能永远循环,因为看不到 flag 的变化
            }
            System.out.println("看到 flag 变化了");
        });
        
        reader.start();
        writer.start();
    }
}

原因:CPU 缓存机制。线程 A 修改 flag 后,可能只写回自己的缓存,没同步到主内存。线程 B 读的仍然是旧值。

解决方案volatile 关键字

  • 写操作后立刻刷回主内存

  • 读操作前使本地缓存失效,强制从主内存读

2.3 原子性:为什么 i++ 不是线程安全的?

        再说说原子性,比如 i++,看着简单,其实是 “读 i、加 1、写回 i” 三步。如果两个线程同时做,可能线程 A 刚读完 i=10,线程 B 就把 i 改成 11,线程 A 再加 1 写回去还是 11,结果就错了。

        JMM 里用 synchronized 或者 Lock 锁就能保证原子性,加了锁之后,同一时间只有一个线程能执行这三步,中间不会被打断。

// i++ 的真相:非原子操作
public class AtomicityProblem {
    private static int count = 0;
    
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++; // 实际是:读 count → 加1 → 写 count
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });
        
        t1.start(); t2.start();
        t1.join(); t2.join();
        
        System.out.println("期望: 20000, 实际: " + count); // 可能 < 20000
    }
}

解决方案

  1. synchronized:悲观锁,保证代码块原子性

  2. AtomicInteger:基于 CAS 的乐观锁,无锁实现

  3. ReentrantLock:更灵活的锁机制

2.4 有序性:编译器与 CPU 的“优化陷阱”

        还有有序性,就是代码执行顺序可能和你写的不一样。编译器或者 CPU 为了提速,会在不影响单线程结果的情况下调整指令顺序。

        比如你先初始化对象 A,再把 A 的引用给变量 a,可能被重排成先给引用再初始化。单线程没问题,但多线程下,另一个线程拿到 a 的引用时,A 可能还没初始化好,一调用就报错。

        这时候 volatile 或者 synchronized 就能通过内存屏障阻止这种重排序,保证顺序正确。

// 指令重排序可能导致的诡异问题
public class OrderingProblem {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000000; i++) {
            x = y = a = b = 0;
            
            Thread one = new Thread(() -> {
                a = 1;  // 语句1
                x = b;  // 语句2
            });
            
            Thread two = new Thread(() -> {
                b = 1;  // 语句3
                y = a;  // 语句4
            });
            
            one.start(); two.start();
            one.join(); two.join();
            
            // 理论上不可能出现 x==0 && y==0
            // 但如果指令重排:线程1先执行语句2,线程2先执行语句4
            // 就可能出现 (0, 0)
            if (x == 0 && y == 0) {
                System.out.println("出现重排序!");
            }
        }
    }
}

volatile 的另一作用:通过内存屏障禁止指令重排序。


3. 锁的深层原理:从 synchronized 到 AQS

3.1 synchronized 的升级之路:锁升级

具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁

  •         无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
  •         偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
  •         轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
  •         重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。

我的理解过程

  1. 偏向锁:假设只有一个线程用锁,直接在对象头标记线程ID,连 CAS 都不需要

  2. 轻量级锁:有竞争但不多,通过 CAS 自旋尝试,避免线程挂起

  3. 重量级锁:竞争激烈,自旋浪费 CPU,升级为操作系统级别的互斥锁

关键点:锁升级是不可逆的(除了批量重偏向等特殊场景)。

3.2  AQS:并发框架的灵魂

AQS(AbstractQueuedSynchronizer)是 JUC 包的基石。理解 AQS,才算真正理解 Java 并发。

        AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。

        AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

AQS三大核心:

  • 状态:state;
  • 控制线程抢锁和配合的FIFO队列(双向链表);
  • 期望协作工具类去实现的获取/释放等重要方法(重写)。

状态state

  • 这里state的具体含义,会根据具体实现类的不同而不同:比如在Semapore里,他表示剩余许可证的数量;在CountDownLatch里,它表示还需要倒数的数量;在ReentrantLock中,state用来表示“锁”的占有情况,包括可重入计数,当state的值为0的时候,标识该Lock不被任何线程所占有。
  • state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,比如getState、setState以及compareAndSetState操作来读取和更新这个状态。这些方法都依赖于unsafe类。

FIFO队列

  • 这个队列用来存放“等待的线程,AQS就是“排队管理器”,当多个线程争用同一把锁时,必须有排队机制将那些没能拿到锁的线程串在一起。当锁释放时,锁管理器就会挑选一个合适的线程来占有这个刚刚释放的锁。
  • AQS会维护一个等待的线程队列,把线程都放到这个队列里,这个队列是双向链表形式。

实现获取/释放等方法

  • 这里的获取和释放方法,是利用AQS的协作工具类里最重要的方法,是由协作类自己去实现的,并且含义各不相同;
  • 获取方法:获取操作会以来state变量,经常会阻塞(比如获取不到锁的时候)。在Semaphore中,获取就是acquire方法,作用是获取一个许可证; 而在CountDownLatch里面,获取就是await方法,作用是等待,直到倒数结束;
  • 释放方法:在Semaphore中,释放就是release方法,作用是释放一个许可证; 在CountDownLatch里面,获取就是countDown方法,作用是将倒数的数减一;
  • 需要每个实现类重写tryAcquire和tryRelease等方法。

3.3 死锁的产生与避免

3.3.1 死锁产生的四个条件

我曾遇到死锁,调试了很久才发现。同时满足四个必要条件才会产生死锁:

  1. 互斥条件:互斥条件是指多个线程不能同时使用同一个资源。
  2. 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
  3. 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
  4. 环路等待条件:环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链。
3.3.2 避免死锁的实践

        避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件

什么是资源有序分配法?

        线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。


4. 线程池:不只是池化,更是资源管理艺术

4.1 创建属于我们的线程池

为什么不用 Executors 快捷创建?

原因:

  • newFixedThreadPool:队列无界,可能 OOM

  • newCachedThreadPool:最大线程数 Integer.MAX_VALUE,可能创建过多线程

  • newSingleThreadExecutor:队列无界

正确做法

手动配置 ThreadPoolExecutor

        手动创建 ThreadPoolExecutor 需要指定 7 个核心参数,理解这些参数才能用好。比如创建一个适合处理 IO 密集型任务的线程池(IO 密集型任务线程数可以多些,一般是 CPU 核心数的 2 倍)

// 获取CPU核心数 用于合理设置线程数
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;

// 手动配置线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
    corePoolSize, // 参数一:核心线程数 线程池长期维持的最小线程数
    corePoolSize * 2, // 参数二:最大线程数 线程池能容纳的最多线程数
    60L, // 空闲线程存活时间 参数三:超过核心线程数的空闲线程 多久后销毁
    TimeUnit.SECONDS, // 参数四:存活时间单位
    new ArrayBlockingQueue<>(100), // 参数五:任务阻塞队列 核心线程忙时 新任务存这里
    Executors.defaultThreadFactory(), // 参数六:线程创建工厂 用于设置线程名 优先级等
    new ThreadPoolExecutor.AbortPolicy() // 参数七:拒绝策略 队列满且线程数达最大时 如何处理新任务
);

// 提交任务的两种方式和之前一致 这里用execute提交Runnable(无返回值 不能捕获异常)
threadPool.execute(() -> {
    System.out.println(IO任务执行中 + Thread.currentThread().getName());
    // 模拟IO操作 比如数据库查询 网络请求
});

// 关闭线程池 推荐用shutdown 等待已提交任务完成后再关闭
threadPool.shutdown();
// 若需要强制关闭 可调用shutdownNow 会中断正在执行的任务 返回未执行的任务
// List<Runnable> unExecutedTasks = threadPool.shutdownNow();

4.2 核心线程数设置为0可不可以?

可以。当核心线程数为0的时候,会创建一个非核心线程进行执行。

从下面的源码也可以看到,当核心线程数为 0 时,来了一个任务之后,会先将任务添加到任务队列,同时也会判断当前工作的线程数是否为 0,如果为 0,则会创建线程来执行线程池的任务。

4.3 shutdown ()与shutdownNow()以及线程池的优雅关闭

线程池中shutdown (),shutdownNow()这两个方法有什么作用?

  • shutdown使用了以后会置状态为SHUTDOWN,正在执行的任务会继续执行下去,没有被执行的则中断。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常
  • 而 shutdownNow 为STOP,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。 它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,但是这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。

        从下面的源码【高亮】注释可以很清晰的看出两者的区别:

4.3.1 shutdown 源码:
public void shutdown() {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        checkShutdownAccess();
        // 高亮
        advanceRunState(SHUTDOWN);
        interruptIdleWorkers();
        onShutdown();
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
}
4.3.2 shutdownNow 源码:
public List<Runnable> shutdownNow() {
    List<Runnable> tasks;
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        checkShutdownAccess();
        // 高亮
        advanceRunState(STOP);
        interruptWorkers();
        // 高亮
        tasks = drainQueue();
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
    // 高亮
    return tasks;
}
4.3.3 正确的关闭线程池方法:

根据源码的解读,我询问了AI相关的正确的关闭线程池的方法,给出如下代码可供大家学习:

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ThreadPoolShutdownBestPractice {
    public static void safeShutdownThreadPool(ExecutorService executor, long timeout, TimeUnit unit) {
        // 第一步:优雅关闭,拒绝新任务,允许已提交任务执行
        executor.shutdown();
        
        try {
            // 第二步:等待指定时间,让线程池完成剩余任务
            if (!executor.awaitTermination(timeout, unit)) {
                // 第三步:超时未关闭,强制终止
                List<Runnable> unexecutedTasks = executor.shutdownNow();
                
                // 可选:处理未执行的任务(记录日志、重试等)
                System.out.println("线程池超时,强制关闭!未执行的任务数:" + unexecutedTasks.size());
                for (Runnable task : unexecutedTasks) {
                    // 这里可以根据业务需求处理未执行的任务,比如记录日志、放入重试队列等
                    System.out.println("未执行的任务:" + task.toString());
                }
                
                // 再次等待一小段时间,确保强制关闭生效
                if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
                    System.err.println("线程池最终仍未关闭,可能存在线程卡死!");
                }
            }
        } catch (InterruptedException e) {
            // 等待过程中当前线程被中断,立即强制关闭
            executor.shutdownNow();
            // 恢复中断状态,让上层感知
            Thread.currentThread().interrupt();
            System.err.println("等待线程池关闭时被中断,已强制关闭线程池");
        }
    }

    // 测试示例
    public static void main(String[] args) {
        // 创建线程池(实际开发中建议自定义线程池参数,而非Executors)
        ExecutorService executor = Executors.newFixedThreadPool(3);
        
        // 提交测试任务
        for (int i = 0; i < 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                try {
                    // 模拟任务执行(比如业务逻辑)
                    System.out.println("任务" + taskId + "开始执行");
                    TimeUnit.SECONDS.sleep(2); // 模拟耗时操作
                    System.out.println("任务" + taskId + "执行完成");
                } catch (InterruptedException e) {
                    System.out.println("任务" + taskId + "被中断,执行终止");
                    // 中断后清理资源(比如关闭连接、回滚事务等)
                    Thread.currentThread().interrupt();
                }
            });
        }
        
        // 安全关闭线程池:等待3秒,超时则强制关闭
        safeShutdownThreadPool(executor, 3, TimeUnit.SECONDS);
        System.out.println("线程池最终状态:" + (executor.isTerminated() ? "已终止" : "未终止"));
    }
}

附加小提问:单例模型既然已经用了synchronized,为什么还要在加volatile?

        使用 synchronized 和 volatile 一起,可以创建一个既线程安全又能正确初始化的单例模式,避免了多线程环境下的各种潜在问题。这是一种比较完善的线程安全的单例模式实现方式,尤其适用于高并发环境。

public class Singleton {
    private static volatile Singleton instance;  // 必须 volatile!
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {  // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {  // 第二次检查
                    instance = new Singleton();  // 非原子操作!
                }
            }
        }
        return instance;
    }
}

关键new Singleton() 可能被重排序为:分配内存 → 设置引用 → 初始化对象
           如果线程 A 执行到“设置引用”但未初始化,线程 B 看到 instance 不为 null,就会返回未初始化的对象。
   volatile 在保证变量的可见性的同时禁止这种重排序。

总结

希望大家阅读后能有所收获,能与我一同进步。

我的核心收获

  1. 纠正线程认知偏差,掌握线程核心特性摆脱了 “线程是轻量级进程” 的片面理解,明确 Java 线程的六种生命周期状态,清晰区分 BLOCKED 与 WAITING 的核心差异,建立了对线程执行本质的正确认知。

  2. 吃透 JMM 核心规则,攻克三大并发问题理解 Java 内存模型(JMM)“主内存 - 工作内存” 的核心设计,掌握可见性、原子性、有序性问题的产生根源,以及 volatile 关键字、原子类等对应的解决方案,能通过代码复现并验证并发问题。

  3. 深挖锁机制底层原理,掌握并发核心基石理清 synchronized 的锁升级流程(无锁→偏向锁→轻量级锁→重量级锁),理解 AQS 作为 JUC 框架核心的三大要素(state 状态、FIFO 等待队列、自定义获取 / 释放方法),同时掌握死锁产生的四大条件及资源有序分配的规避策略。

  4. 掌握线程池实践精髓,实现资源高效管理学会手动配置线程池的核心参数,摒弃 Executors 工具类的弊端,理解核心线程数设为 0 的底层逻辑,掌握 shutdown 与 shutdownNow 的区别,并能通过 “shutdown+awaitTermination+shutdownNow” 的组合实现线程池的优雅关闭。

  5. 厘清单例模式并发陷阱,理解 volatile 关键作用明确双重检查锁单例模式中,synchronized 仅能保证原子性和可见性,而 volatile 能解决 instance 对象初始化时的指令重排序问题,两者搭配才能实现高并发下的安全单例。

学习小贴士
        学习并发时,不要只看概念,一定要动手写代码、制造并发问题、然后解决它。


只要你转身,我就在这里。                                ----《方圆几里》薛之谦

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐