好的,这是一份根据您提供的学习大纲精心撰写的超万字Java多线程深度解析。本回答将严格遵循大纲的每一个节点,从入门概念到高级案例,结合逐行注释的详尽代码示例,为您系统、深入地剖析Java多线程的核心知识。


本节目标

在开始之前,我们先明确本章的学习目标,这有助于我们聚焦重点:

  • 认识多线程:理解线程的基本概念、与进程的区别及其存在的意义。

  • 掌握多线程程序的编写:学会如何创建和启动一个Java线程。

  • 掌握多线程的状态:了解线程生命周期中的各个状态以及它们之间如何转换。

  • 掌握什么是线程不安全及解决思路:深入理解线程不安全的根源,并掌握保证线程安全的核心思想。

  • 掌握 synchronizedvolatile 关键字:精通Java提供的两个核心同步关键字的原理和用法。


1. 多线程-初阶

1.1 认识线程 (Thread)

1) 线程是什么?

线程(Thread),有时被称为轻量级进程(Lightweight Process),是操作系统能够进行运算调度的最小单位。它被包含在**进程(Process)**之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

简单来说,如果一个“进程”是你电脑上运行的一个应用程序(比如微信),那么“线程”就是这个应用程序内部同时执行的多个子任务。例如,微信这个进程,可能有一个线程负责接收和显示消息,一个线程负责发送消息,还有一个线程负责同步文件。

2) 为啥要有线程?

在只有进程的时代,要实现“同时”做多件事,只能开启多个进程。但这有几个问题:

  1. 资源开销大:每创建一个进程,操作系统都需要为其分配一套独立的内存空间、文件句柄等资源,开销很大。

  2. 通信复杂:进程间的内存是隔离的,要交换数据需要通过复杂的进程间通信(IPC)机制。

  3. 切换成本高:CPU在不同进程间切换时,需要切换整个内存上下文,成本很高。

线程的出现就是为了解决这些问题。一个进程内的所有线程共享该进程的资源(如内存空间、文件句柄等),它们可以方便地读写同一份数据。创建和销毁线程的开销远小于进程,CPU在线程间切换的成本也低得多。

引入线程的核心优势在于提升程序的并发能力和响应速度

  • 对于多核CPU:可以真正实现并行计算,将一个任务拆分成多个子任务交给不同线程,在不同核心上同时执行,从而显著提升程序性能。

  • 对于单核CPU:虽然无法并行,但可以实现并发。当一个线程因为等待I/O(如读取文件、请求网络)而阻塞时,CPU可以切换到另一个可运行的线程,从而避免CPU空闲,提高整体吞吐率。尤其对于有GUI(图形用户界面)的应用,可以将耗时操作放在后台线程执行,避免主线程(UI线程)卡顿,保证用户界面的流畅响应。

3) 进程和线程的区别

这是面试中非常经典的问题,我们可以从以下几个维度进行对比:

特性 进程 (Process) 线程 (Thread)
定义 操作系统进行资源分配和调度的基本单位,是应用程序的执行实例。 CPU调度的最小单位,是进程内的一条执行路径。
资源 拥有独立的内存地址空间、文件句柄等系统资源。 共享所在进程的内存空间和资源,但拥有自己独立的栈、程序计数器。
开销 创建、销毁、切换的开销大,需要操作系统深度介入。 创建、销毁、切换的开销小,属于轻量级操作。
通信 进程间通信(IPC)需要专门的机制(如管道、套接字、共享内存)。 线程间可以直接读写共享变量,通信方便,但也因此带来了同步问题。
健壮性 一个进程崩溃通常不会影响其他进程。 一个线程的崩溃(如未捕获的异常)会导致整个进程退出。
关系 一个进程至少包含一个线程(主线程)。 线程必须存在于进程之中。

4) Java的线程和操作系统的线程的关系

Java语言本身提供了跨平台的线程支持。我们在Java代码里创建的 java.lang.Thread 对象,与操作系统底层的线程是什么关系呢?

这主要取决于JVM的实现。在现代主流的JVM中(如HotSpot),Java线程与操作系统线程通常是一对一的映射关系。也就是说,我们每在Java中创建一个 Thread 对象并调用 start() 方法,JVM就会向操作系统请求创建一个对应的内核级线程(Kernel-Level Thread)。这个Java线程的生命周期和调度,都完全委托给了操作系统内核来管理。这样做的好处是能够充分利用多核CPU的并行能力,但缺点是线程的创建和切换都涉及到系统调用,有一定的开销。

扩展:早期或某些特定的JVM实现中,也存在过“绿色线程 Green Threads”,即在用户空间由JVM自己管理的线程,与操作系统无直接对应关系。这种模型切换快,但无法利用多核。现在已基本被内核级线程模型取代。)

1.3 创建线程

在Java中,创建线程主要有两种标准方式。

方法1:继承 Thread

这是最直观的方式,创建一个类继承自 java.lang.Thread,并重写 run() 方法。run() 方法中的代码就是新线程需要执行的任务。

Java

// MyThread.java
// 逐行注释:定义一个名为 MyThread 的类,它继承自 Thread 类。
class MyThread extends Thread {
    // 逐行注释:重写 Thread 类中的 run() 方法。
    // 这个方法体内的代码,就是这个新线程将要执行的任务逻辑。
    @Override
    public void run() {
        // 逐行注释:打印当前正在执行这段代码的线程的名称。
        System.out.println("通过继承 Thread 类创建的线程正在运行: " + Thread.currentThread().getName());
    }
}

// Main.java
public class Main {
    public static void main(String[] args) {
        // 逐行注释:创建一个 MyThread 类的实例,这就创建了一个线程对象。
        MyThread t1 = new MyThread();
        // 逐行注释:调用 start() 方法来启动线程。
        // JVM 会创建一个新的操作系统线程,并由这个新线程来执行 t1.run() 方法。
        // 注意:千万不要直接调用 t1.run(),那只是一个普通的方法调用,不会创建新线程。
        t1.start(); 
        
        System.out.println("main 方法在 " + Thread.currentThread().getName() + " 线程中执行完毕。");
    }
}
  • 优点:实现简单,代码直观。

  • 缺点:Java是单继承的,如果你的类已经继承了其他类,就无法再继承 Thread 类了,这限制了类的扩展性。

方法2:实现 Runnable 接口

这是更推荐、更灵活的方式。创建一个类实现 java.lang.Runnable 接口,并实现其 run() 方法。然后将这个 Runnable 的实例作为参数传给 Thread 类的构造方法。

Java

// MyRunnable.java
// 逐行注释:定义一个名为 MyRunnable 的类,它实现了 Runnable 接口。
class MyRunnable implements Runnable {
    // 逐行注释:实现 Runnable 接口中定义的 run() 方法。
    @Override
    public void run() {
        // 逐行注释:定义新线程的任务逻辑。
        System.out.println("通过实现 Runnable 接口创建的线程正在运行: " + Thread.currentThread().getName());
    }
}

// Main.java
public class Main {
    public static void main(String[] args) {
        // 逐行注释:创建一个 MyRunnable 类的实例。这个实例代表了一个“任务”,而不是一个线程。
        MyRunnable myTask = new MyRunnable();
        // 逐行注释:创建一个 Thread 对象,并将刚才创建的任务(myTask)作为参数传递进去。
        // 这样就将“任务”和“执行任务的线程”解耦了。
        Thread t2 = new Thread(myTask);
        // 逐行注释:调用 start() 方法启动线程,新线程会去执行 myTask.run() 方法。
        t2.start();

        System.out.println("main 方法在 " + Thread.currentThread().getName() + " 线程中执行完毕。");
    }
}
  • 优点

    1. 解耦:将线程(Thread)和任务(Runnable)分离,Runnable 只关心任务逻辑,更符合面向对象的设计。

    2. 灵活性:类实现了 Runnable 接口的同时,还可以继承其他类。

    3. 资源共享:多个线程可以共享同一个 Runnable 实例,方便实现对共享资源的并发访问。

其他变形(Lambda表达式)

在Java 8及以后,使用Lambda表达式可以极大地简化代码,尤其是对于 Runnable 这种函数式接口。

Java

// Main.java
public class Main {
    public static void main(String[] args) {
        // 逐行注释:使用 Lambda 表达式直接定义一个 Runnable 任务。
        // () -> { ... } 这部分就等同于一个实现了 run() 方法的匿名内部类。
        Runnable task = () -> {
            System.out.println("通过 Lambda 表达式创建的线程正在运行: " + Thread.currentThread().getName());
        };
        
        // 逐行注释:创建一个 Thread 对象,并将 Lambda 表达式定义的 task 传进去。
        Thread t3 = new Thread(task);
        // 逐行注释:启动线程。
        t3.start();

        // 甚至可以写成一行
        new Thread(() -> {
            System.out.println("一行代码创建并启动的线程: " + Thread.currentThread().getName());
        }).start();

        System.out.println("main 方法在 " + Thread.currentThread().getName() + " 线程中执行完毕。");
    }
}

1.4 多线程的优势-增加运行速度

多线程并非万能药,它在以下两种场景下能显著提升程序性能:

  1. CPU密集型任务(CPU-bound):对于需要大量计算的任务(如视频编码、科学计算),在多核CPU上,可以将任务分解给多个线程并行处理,充分利用所有CPU核心。比如一个4核CPU,理论上4个线程并行处理能获得接近4倍的速度提升(实际会因线程调度、同步等开销而略低)。

  2. I/O密集型任务(I/O-bound):对于需要频繁等待I/O操作(如读写文件、网络请求)的任务,单线程模式下,当线程等待I/O时,CPU会处于空闲状态。而多线程模式下,当线程A等待I/O时,操作系统可以把CPU时间片切换给线程B去执行其他任务,从而让CPU“忙起来”,提高系统的整体吞吐量和资源利用率。


2. Thread 类及常见方法

2.1 Thread 的常见构造方法

Java

// 1. 无参构造
Thread t1 = new Thread();

// 2. 指定线程名称
Thread t2 = new Thread("MyThread-Name");

// 3. 传入 Runnable 任务
Runnable task = () -> System.out.println("hello");
Thread t3 = new Thread(task);

// 4. 传入 Runnable 任务并指定线程名称
Thread t4 = new Thread(task, "MyTaskThread");

2.2 Thread 的几个常见属性

这些属性通过调用Thread对象的方法来获取。

  • long getId(): 获取线程的唯一ID。

  • String getName(): 获取线程的名称。

  • Thread.State getState(): 获取线程的当前状态(后面详述)。

  • int getPriority(): 获取线程的优先级(1-10,默认为5),但这只是给操作系统的一个“建议”,不保证严格执行。

  • boolean isDaemon(): 判断线程是否为守护线程。守护线程(Daemon Thread)是一种特殊的后台线程,当所有非守护线程都结束时,JVM会直接退出,而不会等待守护线程执行完毕(例如垃圾回收线程)。

  • boolean isAlive(): 判断线程是否还存活(即尚未终止)。

  • boolean isInterrupted(): 判断线程的中断标志位是否为true。

2.3 启动一个线程 - start()

start()run() 的区别是多线程编程的第一个关键点。

  • t.start(): 启动新线程。这个方法会请求JVM创建一个新的操作系统线程。这个新线程会处于**就绪(Runnable)**状态,等待操作系统调度。一旦被调度,新线程就会自动执行t.run()方法。这是一个异步调用,start()会立即返回,原线程继续向下执行。

  • t.run(): 普通方法调用。这只是在当前线程中执行run()方法里的代码,就像调用任何其他普通方法一样。它不会创建或启动新的线程。

2.4 中断一个线程

Java不推荐使用已废弃的 stop() 方法来强行终止线程,因为它不安全(会立即释放所有锁,可能导致数据状态不一致)。Java采用的是一种协作式的中断机制。

  • t.interrupt(): 设置中断标志。这个方法并不会直接中断线程,它只是将目标线程 t 的内部中断标志位(interrupted status)设置为 true

  • 线程需要自己检查这个中断标志,并决定如何响应。

Java

Thread t = new Thread(() -> {
    // 逐行注释:循环检查当前线程的中断标志位。
    // !Thread.currentThread().isInterrupted() 是一种常见的检查方式。
    while (!Thread.currentThread().isInterrupted()) {
        System.out.println("线程正在运行...");
        try {
            // 逐行注释:让线程休眠1秒。
            // 如果线程在休眠(WAITING/TIMED_WAITING)状态时被中断,
            // 它会立即唤醒并抛出 InterruptedException。
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // 逐行注释:捕获到 InterruptedException,说明线程被中断了。
            // 抛出这个异常后,中断标志位会被自动清除(置为false)。
            // 因此,通常需要在 catch 块中处理中断逻辑,比如通过 break 退出循环。
            System.out.println("线程被中断了,即将退出。");
            // 逐行注释:如果希望上层代码也能知道中断发生,可以再次设置中断标志位。
            Thread.currentThread().interrupt(); 
            break; // 退出循环
        }
    }
});
t.start();
Thread.sleep(3000); // 主线程休眠3秒
System.out.println("主线程准备中断子线程。");
t.interrupt(); // 调用interrupt()方法设置中断标志
  • Thread.interrupted() (静态方法): 检查当前线程的中断状态,并且会清除中断标志位(即调用后标志位变为false)。

  • t.isInterrupted() (实例方法): 检查目标线程t的中断状态,但不清除中断标志位。

2.5 等待一个线程 - join()

t.join() 方法会让当前线程进入等待状态,直到目标线程t执行完毕。这在需要等待一个子任务完成后才能继续主任务的场景中非常有用。

Java

Thread t = new Thread(() -> {
    System.out.println("子线程开始执行...");
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("子线程执行完毕。");
});
t.start();

// 逐行注释:调用 t.join()。此时,main 线程会进入 WAITING 状态。
// 它会一直等待,直到线程 t 的 run() 方法执行结束。
t.join(); 

// 逐行注释:一旦 t.join() 返回,就说明线程 t 已经执行完了。
System.out.println("主线程在子线程结束后继续执行。");

2.6 获取当前线程 - Thread.currentThread()

这是一个静态方法,返回正在执行当前代码的 Thread 对象的引用。

2.7 休眠当前线程 - Thread.sleep()

Thread.sleep(long millis) 是一个静态方法,它会让当前线程RUNNABLE 状态进入 TIMED_WAITING 状态,暂停执行指定的毫秒数。时间到了之后,线程会重新回到 RUNNABLE 状态,等待CPU调度。

关键点sleep() 不会释放任何它已经持有的锁。


3. 线程的状态

3.1 观察线程的所有状态

Java通过 Thread.State 这个枚举类定义了线程的6种状态:

  1. NEW (新建): 创建了 Thread 对象,但还未调用 start() 方法。

  2. RUNNABLE (可运行): 调用了 start() 方法后,线程进入此状态。它包含了操作系统线程状态中的就绪(Ready)和运行中(Running)。一个处于RUNNABLE状态的Java线程可能正在CPU上执行,也可能正在等待操作系统分配CPU时间片。

  3. BLOCKED (阻塞): 线程正在等待获取一个synchronized监视器锁,但该锁被其他线程持有。

  4. WAITING (无限期等待): 线程调用了没有超时参数的 Object.wait()Thread.join()LockSupport.park() 后进入此状态。需要被其他线程显式地唤醒(通过notify()/notifyAll()unpark())。

  5. TIMED_WAITING (限时等待): 线程调用了带有超时参数的方法,如 Thread.sleep(t)Object.wait(t)Thread.join(t) 等。线程会在指定时间后自动唤醒,或被其他线程提前唤醒。

  6. TERMINATED (终止): 线程的 run() 方法已经执行完毕或因异常退出。

3.2 线程状态和状态转移

  • new Thread() -> NEW

  • t.start() -> RUNNABLE

  • RUNNABLE -> synchronized enter -> BLOCKED (如果锁被占用)

  • BLOCKED -> 获得锁 -> RUNNABLE

  • RUNNABLE -> wait() / join() -> WAITING

  • RUNNABLE -> sleep(t) / wait(t) -> TIMED_WAITING

  • WAITING / TIMED_WAITING -> notify() / notifyAll() -> BLOCKED (等待重新获取锁) -> RUNNABLE

  • TIMED_WAITING -> 时间到 -> BLOCKED (等待重新获取锁) -> RUNNABLE

  • RUNNABLE -> run() 方法结束 -> TERMINATED


4. 多线程带来的风险-线程安全 (重点)

4.1 观察线程不安全

让我们来看一个经典的例子:两个线程同时对一个共享变量进行10万次自增操作。

Java

public class UnsafeCounter {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 逐行注释:创建两个线程,它们都执行相同的任务:对 count 变量进行 100000 次自增。
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        });

        // 逐行注释:启动两个线程。
        t1.start();
        t2.start();

        // 逐行注释:主线程等待 t1 和 t2 都执行完毕。
        t1.join();
        t2.join();

        // 逐行注释:打印最终的 count 值。
        // 期望结果是 200000,但实际运行多次,结果几乎总是小于 200000。
        System.out.println("Final count: " + count);
    }
}

4.2 线程安全的概念

线程安全指的是,当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。换句话说,无论操作系统如何调度或交错执行这些线程,代码的执行结果都和预期一致,不会产生数据混乱或其他不可预期的后果。

4.3 线程不安全的原因

上述例子中 count++ 操作为什么会产生错误结果?这揭示了线程不安全的三个主要根源:

  1. 原子性 (Atomicity):一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。count++ 看似一个操作,但它在CPU层面至少被分解为三条指令:

    1. :从内存中读取 count 的当前值到CPU寄存器。

    2. :在寄存器中将值加1。

    3. 写:将寄存器中的新值写回内存。

      问题所在:线程调度是随机的。可能线程A执行完第1步(读到count=10),CPU就切换去执行线程B。线程B完整地执行了三步(读到10,加1,写回11)。然后CPU又切回线程A,线程A从第2步继续执行(在它自己的寄存器里的旧值10上加1),然后写回11。结果是,两个线程都执行了 count++,但 count 只增加了1,这就是所谓的“写丢失”。

  2. 可见性 (Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

    问题所在:为了提高性能,现代CPU都有自己的高速缓存(Cache)。线程修改变量时,可能只是先修改了自己CPU核心的缓存,并没有立即写回主内存。此时,其他线程(可能在不同CPU核心上)从主内存中读取的还是旧值,这就导致了数据不一致。

  3. 有序性 (Ordering):程序执行的顺序按照代码的先后顺序执行。

    问题所在:为了优化性能,编译器和处理器可能会对指令进行重排序(Instruction Reordering)。在单线程环境下,重排序不影响最终结果。但在多线程环境下,一个线程的重排序可能会破坏另一个线程的依赖关系,导致意想不到的错误。

总结:线程不安全的根本原因是 多个线程修改共享数据 时,由于 线程调度的随机性,导致 非原子性操作 被打断,再加上 内存可见性指令重排序 问题的共同作用。

4.4 解决方案-线程不安全问题

解决线程不安全问题的核心思想是 加锁,确保对共享资源的访问是互斥的。Java提供了多种同步机制,最核心的就是 synchronized 关键字。


5. synchronized 关键字 - 监视器锁(monitor lock)

synchronized 是Java的内置锁,它可以保证在同一时刻,只有一个线程可以进入被它修饰的代码块或方法,从而保证了代码块的原子性可见性

5.1 synchronized 的特性

  1. 互斥 (Mutual Exclusion):当一个线程进入 synchronized 代码块时,它会获取一个内部锁(也叫监视器锁 monitor lock)。只要这个线程不退出代码块,该锁就不会被释放,其他任何试图进入此代码块的线程都会被阻塞(BLOCKED),直到锁被释放。

  2. 可重入 (Reentrancy):一个线程可以多次获得同一个它已经持有的锁。如果一个 synchronized 方法内部调用了同一个对象的另一个 synchronized 方法,线程不会自己把自己锁死,而是可以直接进入。这是因为锁内部维护了一个持有者线程和计数器。

    Java

    public class ReentrantExample {
        public synchronized void methodA() {
            System.out.println("进入 methodA");
            methodB(); // 调用同一个对象的另一个同步方法
            System.out.println("退出 methodA");
        }
    
        public synchronized void methodB() {
            System.out.println("进入 methodB");
            System.out.println("退出 methodB");
        }
    
        public static void main(String[] args) {
            new ReentrantExample().methodA(); // 不会发生死锁
        }
    }
    

5.2 synchronized 使用示例

synchronized 主要有三种使用方式:

  1. 修饰实例方法:锁对象是当前实例对象(this

    Java

    // 逐行注释:synchronized 修饰实例方法。
    // 当一个线程调用这个方法时,它会锁定 SafeCounter 的这个实例对象(this)。
    // 其他线程如果想调用这个实例的任何 synchronized 方法,都必须等待。
    public synchronized void increment() {
        count++;
    }
    
  2. 修饰静态方法:锁对象是当前类的Class对象(SafeCounter.class

    Java

    // 逐行注释:synchronized 修饰静态方法。
    // 锁住的是 SafeCounter.class 这个对象。
    // 这会影响所有试图调用该类任何静态 synchronized 方法的线程。
    public static synchronized void staticIncrement() {
        staticCount++;
    }
    
  3. 修饰代码块:可以显式指定任何对象作为锁对象,提供了更高的灵活性。

    Java

    private final Object lock = new Object(); // 通常创建一个专门的锁对象
    
    public void blockIncrement() {
        // 逐行注释:synchronized 修饰代码块。
        // 进入这个代码块前,线程必须先获得 this.lock 对象的锁。
        // 相比修饰整个方法,这可以减小锁的粒度,只锁定必要的部分,提高性能。
        synchronized (lock) {
            count++;
        }
    }
    

synchronized 修复计数器问题:

Java

public class SafeCounter {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 逐行注释:创建一个锁对象,所有线程竞争这个对象。
        Object lock = new Object();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 逐行注释:在对 count 操作前,先获取 lock 对象的锁。
                synchronized (lock) {
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 逐行注释:线程 t2 也必须获取同一个 lock 对象的锁才能操作 count。
                // 由于锁的互斥性,t1 和 t2 的 count++ 操作不会同时发生。
                synchronized (lock) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        // 逐行注释:现在结果总是正确的 200000。
        System.out.println("Final count: " + count);
    }
}

扩展synchronized 除了保证原子性,它在释放锁时,会强制将线程工作内存中的修改刷新到主内存;在获取锁时,会强制让工作内存中的缓存失效,从主内存重新加载。因此,它也保证了内存可见性

5.3 Java 标准库中的线程安全类

  • Vector, Hashtable, StringBuffer:这些是早期的线程安全类,它们内部的方法基本都用synchronized修饰,性能较差,现在已不推荐使用。

  • java.util.concurrent 包:提供了更高效的线程安全容器,如 ConcurrentHashMap, CopyOnWriteArrayList,以及各种锁实现,是现代Java并发编程的首选。


6. volatile 关键字

volatile 是一个比 synchronized 更轻量级的同步机制。它不提供互斥性(不保证原子性),但能保证可见性和一定程度的有序性

6.1 volatile 保证内存可见性

当一个变量被声明为 volatile 后,JVM会保证:

  1. 写操作:对这个变量的写操作会立即被刷新到主内存中。

  2. 读操作:对这个变量的读操作会直接从主内存中读取,而不是使用CPU缓存。

这确保了任何线程对 volatile 变量的修改,都会立即对其他线程可见。

适用场景:一个线程写,多个线程读。

Java

public class VolatileVisibility {
    // 逐行注释:使用 volatile 关键字修饰 running 标志位。
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            // 逐行注释:线程 t 在循环中持续检查 running 变量。
            // 如果 running 没有被 volatile 修饰,t 可能只会读取自己缓存中的值(true),
            // 导致即使主线程修改了主内存的 running,这个循环也永远不会停止。
            // 有了 volatile,t 每次都会从主内存读取最新的 running 值。
            while (running) {
                // do nothing
            }
            System.out.println("线程 t 已停止。");
        });
        t.start();

        Thread.sleep(1000);
        
        // 逐行注释:主线程修改 running 的值。
        System.out.println("主线程设置 running = false");
        running = false;
    }
}

volatile 不保证原子性:

对 volatile int count; count++; 这样的操作,volatile 依然无法保证线程安全。因为它只保证了每次读count和写count都是从主内存进行的,但“读-改-写”这三个步骤之间仍然可能被其他线程打断。

volatile 防止指令重排序:

volatile 还会插入内存屏障,阻止编译器和处理器对其前后指令的重排序,这在某些高级并发场景(如实现双重检查锁的单例模式)中至关重要。


7. waitnotify

synchronized 解决了互斥问题,但有时我们需要更复杂的线程协作,比如一个线程需要等待某个条件满足后才能继续执行,而这个条件由另一个线程来促成。这就是 wait/notify 机制的用武之地。

wait(), notify(), notifyAll()java.lang.Object 类的方法,意味着任何Java对象都可以作为锁和条件变量。它们必须在 synchronized 代码块中调用

  • obj.wait():

    1. 当前线程必须已经持有 obj 对象的锁。

    2. 调用后,当前线程会立即释放 obj 对象的锁。

    3. 线程进入该对象的等待队列(Wait Set),状态变为 WAITINGTIMED_WAITING

  • obj.notify():

    1. 当前线程必须已经持有 obj 对象的锁。

    2. 调用后,它会从 obj 对象的等待队列中随机唤醒一个线程。

    3. 被唤醒的线程不会立即执行,而是进入 BLOCKED 状态,尝试重新获取 obj 的锁。只有获取成功后,才能从 wait() 的地方继续执行。

  • obj.notifyAll():

    1. notify()类似,但它会唤醒等待队列中的所有线程。

    2. 这些被唤醒的线程会一起去竞争锁,只有一个能成功,其他继续阻塞。

经典范式:生产者-消费者模型

Java

import java.util.LinkedList;
import java.util.Queue;

public class ProducerConsumer {
    private final Queue<String> queue = new LinkedList<>();
    private final int MAX_SIZE = 5;
    private final Object lock = new Object();

    class Producer implements Runnable {
        @Override
        public void run() {
            int i = 0;
            while (true) {
                synchronized (lock) { // 逐行注释:获取锁
                    // 逐行注释:使用 while 循环检查条件,防止“虚假唤醒”。
                    while (queue.size() == MAX_SIZE) {
                        try {
                            System.out.println("队列已满,生产者等待...");
                            lock.wait(); // 逐行注释:队列满,释放锁并等待
                        } catch (InterruptedException e) { e.printStackTrace(); }
                    }
                    String item = "Item-" + (i++);
                    queue.add(item);
                    System.out.println("生产者生产了: " + item);
                    lock.notifyAll(); // 逐行注释:生产后,唤醒所有等待的线程(可能是消费者)
                }
            }
        }
    }

    class Consumer implements Runnable {
        @Override
        public void run() {
            while (true) {
                synchronized (lock) { // 逐行注释:获取锁
                    while (queue.isEmpty()) {
                        try {
                            System.out.println("队列为空,消费者等待...");
                            lock.wait(); // 逐行注释:队列空,释放锁并等待
                        } catch (InterruptedException e) { e.printStackTrace(); }
                    }
                    String item = queue.poll();
                    System.out.println("消费者消费了: " + item);
                    lock.notifyAll(); // 逐行注释:消费后,唤醒所有等待的线程(可能是生产者)
                }
            }
        }
    }
    
    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();
        new Thread(pc.new Producer()).start();
        new Thread(pc.new Consumer()).start();
    }
}

7.4 waitsleep 的对比 (面试高频题)

特性 wait() sleep()
所属类 java.lang.Object java.lang.Thread
释放锁 ,调用后立即释放对象锁。 不会,线程休眠时仍然持有锁。
使用场景 必须在 synchronized 块或方法中调用。 可以在任何地方调用。
唤醒方式 需要被其他线程通过notify/notifyAll唤醒。 自动在超时后唤醒。
作用 用于线程间的协作与通信。 用于暂停当前线程的执行。

由于篇幅限制,后续的8. 多线程案例9. 总结 将以更精炼的形式呈现,但依然会包含核心代码和解释。

8. 多线程案例

8.1 单例模式

目标:保证一个类只有一个实例。

  • 饿汉模式(天生线程安全)

    Java

    // 逐行注释:实例在类加载时就创建,由JVM保证其唯一性和线程安全。
    public class SingletonEager {
        private static final SingletonEager INSTANCE = new SingletonEager();
        private SingletonEager() {}
        public static SingletonEager getInstance() {
            return INSTANCE;
        }
    }
    
  • 懒汉模式 - 双重检查锁定(DCL)(线程安全的推荐实现)

    Java

    public class SingletonDCL {
        // 逐行注释:使用 volatile 关键字,防止指令重排序。
        // 如果没有 volatile,可能一个线程拿到一个“半初始化”的对象。
        private static volatile SingletonDCL instance;
        private SingletonDCL() {}
    
        public static SingletonDCL getInstance() {
            // 逐行注释:第一次检查,如果不为null,直接返回,避免不必要的加锁开销。
            if (instance == null) {
                // 逐行注释:加锁,保证只有一个线程能进入创建实例的代码块。
                synchronized (SingletonDCL.class) {
                    // 逐行注释:第二次检查,防止多个线程同时通过第一次检查后重复创建实例。
                    if (instance == null) {
                        instance = new SingletonDCL();
                    }
                }
            }
            return instance;
        }
    }
    

8.2 阻塞队列

在7. waitnotify中我们已经手动实现了一个简单的阻塞队列(生产者消费者模型)。

  • 标准库java.util.concurrent包提供了BlockingQueue接口和多种实现,如ArrayBlockingQueue(有界,基于数组)和LinkedBlockingQueue(可有界,基于链表),它们内部封装了复杂的并发控制,是生产环境的首选。

8.3 定时器

  • 标准库

    • java.util.Timer:早期API,有缺陷(单线程执行任务,任务异常会导致整个Timer停止)。

    • ScheduledThreadPoolExecutor:现代Java中推荐的方式,功能更强大,是线程池的一种。

    Java

    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    // 逐行注释:创建一个任务。
    Runnable task = () -> System.out.println("任务在3秒后执行");
    // 逐行注释:调度任务,在3秒后执行一次。
    scheduler.schedule(task, 3, TimeUnit.SECONDS);
    // 逐行注释:关闭线程池。
    scheduler.shutdown();
    

8.4 线程池

  • 是什么:预先创建一组线程,任务来了直接交给池中的线程执行,执行完后线程不销毁而是等待下一个任务。

  • 好处:复用线程,减少创建和销毁的开销;控制并发线程数,防止资源耗尽。

  • 标准库java.util.concurrent.Executors工厂类提供了创建常用线程池的便捷方法。

    Java

    // 逐行注释:创建一个固定大小为 5 的线程池。
    ExecutorService threadPool = Executors.newFixedThreadPool(5);
    
    // 逐行注释:提交10个任务给线程池执行。
    for (int i = 0; i < 10; i++) {
        final int taskID = i;
        // 逐行注释:submit 方法用于提交任务。
        threadPool.submit(() -> {
            System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行任务 " + taskID);
        });
    }
    
    // 逐行注释:关闭线程池,不再接受新任务,等待已提交任务执行完毕。
    threadPool.shutdown();
    

9. 总结-保证线程安全的思路

回顾全文,保证线程安全主要有以下几种核心思路:

  1. 避免共享(线程封闭):不共享数据是解决线程安全问题的最有效方法。使用ThreadLocal为每个线程提供变量的副本,或者保证对象只在单一线程内访问。

  2. 不可变(Immutability):如果共享的数据是不可变的(final修饰),那么它天生就是线程安全的,因为所有线程只能读取它,不会产生数据冲突。例如String类。

  3. 使用同步机制:当必须共享可变数据时,使用锁来保证互斥访问。

    • 使用synchronized关键字来保护代码块或方法。

    • 使用volatile来保证共享变量的内存可见性(但不能保证原子性)。

    • 使用java.util.concurrent.locks包下更灵活的Lock实现(如ReentrantLock)。

  4. 使用线程安全的容器:优先使用java.util.concurrent包提供的并发容器,而不是自己去同步ArrayListHashMap


这份详尽的指南希望能帮助您彻底掌握Java多线程的核心概念与实践。多线程编程是Java高级开发的基石,理解其原理并熟练运用,将使您在构建高性能、高并发应用时游刃有余。

Logo

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

更多推荐