JavaEE初阶-详解Java多线程(Multithreading)
本文系统讲解了Java多线程编程的核心知识,内容涵盖: 线程基础概念 线程与进程的区别 创建线程的两种方式(继承Thread/实现Runnable) 线程生命周期与状态转换(NEW/RUNNABLE/BLOCKED等) 线程安全机制 线程不安全的三大根源(原子性/可见性/有序性) synchronized关键字原理与使用(方法/代码块) volatile关键字的内存可见性保证 线程协作 wait/
好的,这是一份根据您提供的学习大纲精心撰写的超万字Java多线程深度解析。本回答将严格遵循大纲的每一个节点,从入门概念到高级案例,结合逐行注释的详尽代码示例,为您系统、深入地剖析Java多线程的核心知识。
本节目标
在开始之前,我们先明确本章的学习目标,这有助于我们聚焦重点:
-
认识多线程:理解线程的基本概念、与进程的区别及其存在的意义。
-
掌握多线程程序的编写:学会如何创建和启动一个Java线程。
-
掌握多线程的状态:了解线程生命周期中的各个状态以及它们之间如何转换。
-
掌握什么是线程不安全及解决思路:深入理解线程不安全的根源,并掌握保证线程安全的核心思想。
-
掌握
synchronized
、volatile
关键字:精通Java提供的两个核心同步关键字的原理和用法。
1. 多线程-初阶
1.1 认识线程 (Thread)
1) 线程是什么?
线程(Thread),有时被称为轻量级进程(Lightweight Process),是操作系统能够进行运算调度的最小单位。它被包含在**进程(Process)**之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
简单来说,如果一个“进程”是你电脑上运行的一个应用程序(比如微信),那么“线程”就是这个应用程序内部同时执行的多个子任务。例如,微信这个进程,可能有一个线程负责接收和显示消息,一个线程负责发送消息,还有一个线程负责同步文件。
2) 为啥要有线程?
在只有进程的时代,要实现“同时”做多件事,只能开启多个进程。但这有几个问题:
-
资源开销大:每创建一个进程,操作系统都需要为其分配一套独立的内存空间、文件句柄等资源,开销很大。
-
通信复杂:进程间的内存是隔离的,要交换数据需要通过复杂的进程间通信(IPC)机制。
-
切换成本高: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() + " 线程中执行完毕。");
}
}
-
优点:
-
解耦:将线程(
Thread
)和任务(Runnable
)分离,Runnable
只关心任务逻辑,更符合面向对象的设计。 -
灵活性:类实现了
Runnable
接口的同时,还可以继承其他类。 -
资源共享:多个线程可以共享同一个
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 多线程的优势-增加运行速度
多线程并非万能药,它在以下两种场景下能显著提升程序性能:
-
CPU密集型任务(CPU-bound):对于需要大量计算的任务(如视频编码、科学计算),在多核CPU上,可以将任务分解给多个线程并行处理,充分利用所有CPU核心。比如一个4核CPU,理论上4个线程并行处理能获得接近4倍的速度提升(实际会因线程调度、同步等开销而略低)。
-
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种状态:
-
NEW
(新建): 创建了Thread
对象,但还未调用start()
方法。 -
RUNNABLE
(可运行): 调用了start()
方法后,线程进入此状态。它包含了操作系统线程状态中的就绪(Ready)和运行中(Running)。一个处于RUNNABLE
状态的Java线程可能正在CPU上执行,也可能正在等待操作系统分配CPU时间片。 -
BLOCKED
(阻塞): 线程正在等待获取一个synchronized
监视器锁,但该锁被其他线程持有。 -
WAITING
(无限期等待): 线程调用了没有超时参数的Object.wait()
、Thread.join()
或LockSupport.park()
后进入此状态。需要被其他线程显式地唤醒(通过notify()
/notifyAll()
或unpark()
)。 -
TIMED_WAITING
(限时等待): 线程调用了带有超时参数的方法,如Thread.sleep(t)
、Object.wait(t)
、Thread.join(t)
等。线程会在指定时间后自动唤醒,或被其他线程提前唤醒。 -
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++
操作为什么会产生错误结果?这揭示了线程不安全的三个主要根源:
-
原子性 (Atomicity):一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
count++
看似一个操作,但它在CPU层面至少被分解为三条指令:-
读:从内存中读取
count
的当前值到CPU寄存器。 -
改:在寄存器中将值加1。
-
写:将寄存器中的新值写回内存。
问题所在:线程调度是随机的。可能线程A执行完第1步(读到count=10),CPU就切换去执行线程B。线程B完整地执行了三步(读到10,加1,写回11)。然后CPU又切回线程A,线程A从第2步继续执行(在它自己的寄存器里的旧值10上加1),然后写回11。结果是,两个线程都执行了 count++,但 count 只增加了1,这就是所谓的“写丢失”。
-
-
可见性 (Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
问题所在:为了提高性能,现代CPU都有自己的高速缓存(Cache)。线程修改变量时,可能只是先修改了自己CPU核心的缓存,并没有立即写回主内存。此时,其他线程(可能在不同CPU核心上)从主内存中读取的还是旧值,这就导致了数据不一致。
-
有序性 (Ordering):程序执行的顺序按照代码的先后顺序执行。
问题所在:为了优化性能,编译器和处理器可能会对指令进行重排序(Instruction Reordering)。在单线程环境下,重排序不影响最终结果。但在多线程环境下,一个线程的重排序可能会破坏另一个线程的依赖关系,导致意想不到的错误。
总结:线程不安全的根本原因是 多个线程修改共享数据 时,由于 线程调度的随机性,导致 非原子性操作 被打断,再加上 内存可见性 和 指令重排序 问题的共同作用。
4.4 解决方案-线程不安全问题
解决线程不安全问题的核心思想是 加锁,确保对共享资源的访问是互斥的。Java提供了多种同步机制,最核心的就是 synchronized
关键字。
5. synchronized
关键字 - 监视器锁(monitor lock)
synchronized
是Java的内置锁,它可以保证在同一时刻,只有一个线程可以进入被它修饰的代码块或方法,从而保证了代码块的原子性和可见性。
5.1 synchronized
的特性
-
互斥 (Mutual Exclusion):当一个线程进入
synchronized
代码块时,它会获取一个内部锁(也叫监视器锁 monitor lock)。只要这个线程不退出代码块,该锁就不会被释放,其他任何试图进入此代码块的线程都会被阻塞(BLOCKED),直到锁被释放。 -
可重入 (Reentrancy):一个线程可以多次获得同一个它已经持有的锁。如果一个
Javasynchronized
方法内部调用了同一个对象的另一个synchronized
方法,线程不会自己把自己锁死,而是可以直接进入。这是因为锁内部维护了一个持有者线程和计数器。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
主要有三种使用方式:
-
修饰实例方法:锁对象是当前实例对象(
Javathis
)。// 逐行注释:synchronized 修饰实例方法。 // 当一个线程调用这个方法时,它会锁定 SafeCounter 的这个实例对象(this)。 // 其他线程如果想调用这个实例的任何 synchronized 方法,都必须等待。 public synchronized void increment() { count++; }
-
修饰静态方法:锁对象是当前类的Class对象(
JavaSafeCounter.class
)。// 逐行注释:synchronized 修饰静态方法。 // 锁住的是 SafeCounter.class 这个对象。 // 这会影响所有试图调用该类任何静态 synchronized 方法的线程。 public static synchronized void staticIncrement() { staticCount++; }
-
修饰代码块:可以显式指定任何对象作为锁对象,提供了更高的灵活性。
Javaprivate 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会保证:
-
写操作:对这个变量的写操作会立即被刷新到主内存中。
-
读操作:对这个变量的读操作会直接从主内存中读取,而不是使用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. wait
和 notify
synchronized
解决了互斥问题,但有时我们需要更复杂的线程协作,比如一个线程需要等待某个条件满足后才能继续执行,而这个条件由另一个线程来促成。这就是 wait/notify
机制的用武之地。
wait()
, notify()
, notifyAll()
是 java.lang.Object
类的方法,意味着任何Java对象都可以作为锁和条件变量。它们必须在 synchronized
代码块中调用。
-
obj.wait()
:-
当前线程必须已经持有
obj
对象的锁。 -
调用后,当前线程会立即释放
obj
对象的锁。 -
线程进入该对象的等待队列(Wait Set),状态变为
WAITING
或TIMED_WAITING
。
-
-
obj.notify()
:-
当前线程必须已经持有
obj
对象的锁。 -
调用后,它会从
obj
对象的等待队列中随机唤醒一个线程。 -
被唤醒的线程不会立即执行,而是进入
BLOCKED
状态,尝试重新获取obj
的锁。只有获取成功后,才能从wait()
的地方继续执行。
-
-
obj.notifyAll()
:-
与
notify()
类似,但它会唤醒等待队列中的所有线程。 -
这些被唤醒的线程会一起去竞争锁,只有一个能成功,其他继续阻塞。
-
经典范式:生产者-消费者模型
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 wait
和 sleep
的对比 (面试高频题)
特性 | 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)(线程安全的推荐实现)
Javapublic 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. wait
和notify
中我们已经手动实现了一个简单的阻塞队列(生产者消费者模型)。
-
标准库:
java.util.concurrent
包提供了BlockingQueue
接口和多种实现,如ArrayBlockingQueue
(有界,基于数组)和LinkedBlockingQueue
(可有界,基于链表),它们内部封装了复杂的并发控制,是生产环境的首选。
8.3 定时器
-
标准库:
-
java.util.Timer
:早期API,有缺陷(单线程执行任务,任务异常会导致整个Timer停止)。 -
ScheduledThreadPoolExecutor
:现代Java中推荐的方式,功能更强大,是线程池的一种。
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); // 逐行注释:创建一个任务。 Runnable task = () -> System.out.println("任务在3秒后执行"); // 逐行注释:调度任务,在3秒后执行一次。 scheduler.schedule(task, 3, TimeUnit.SECONDS); // 逐行注释:关闭线程池。 scheduler.shutdown();
-
8.4 线程池
-
是什么:预先创建一组线程,任务来了直接交给池中的线程执行,执行完后线程不销毁而是等待下一个任务。
-
好处:复用线程,减少创建和销毁的开销;控制并发线程数,防止资源耗尽。
-
标准库:
Javajava.util.concurrent.Executors
工厂类提供了创建常用线程池的便捷方法。// 逐行注释:创建一个固定大小为 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. 总结-保证线程安全的思路
回顾全文,保证线程安全主要有以下几种核心思路:
-
避免共享(线程封闭):不共享数据是解决线程安全问题的最有效方法。使用
ThreadLocal
为每个线程提供变量的副本,或者保证对象只在单一线程内访问。 -
不可变(Immutability):如果共享的数据是不可变的(
final
修饰),那么它天生就是线程安全的,因为所有线程只能读取它,不会产生数据冲突。例如String
类。 -
使用同步机制:当必须共享可变数据时,使用锁来保证互斥访问。
-
使用
synchronized
关键字来保护代码块或方法。 -
使用
volatile
来保证共享变量的内存可见性(但不能保证原子性)。 -
使用
java.util.concurrent.locks
包下更灵活的Lock
实现(如ReentrantLock
)。
-
-
使用线程安全的容器:优先使用
java.util.concurrent
包提供的并发容器,而不是自己去同步ArrayList
或HashMap
。
这份详尽的指南希望能帮助您彻底掌握Java多线程的核心概念与实践。多线程编程是Java高级开发的基石,理解其原理并熟练运用,将使您在构建高性能、高并发应用时游刃有余。
更多推荐
所有评论(0)