引言
在互联网飞速发展的今天,高并发已经成为系统设计的核心挑战之一。从电商秒杀、社交平台的消息推送,到金融系统的交易处理,无一不考验着系统的并发处理能力。而多线程编程,正是应对高并发场景的基石技术。它允许程序同时执行多个任务,充分利用多核CPU的计算能力,大幅提升系统吞吐量和响应速度。
然而,多线程编程并非易事。线程的创建与销毁、资源共享的冲突、死锁的规避、性能的优化……每一个环节都充满了陷阱。稍有不慎,就可能导致数据不一致、程序崩溃甚至系统雪崩。因此,深入理解多线程、守护线程、线程安全、线程同步、互斥锁等核心概念,是每一位后端开发者、架构师的必修课。
本文将从最基础的线程概念讲起,逐步深入到线程安全机制、同步工具、锁优化,再到实际案例和最佳实践,力求用2万余字的篇幅,将高并发编程的基石知识一网打尽。无论你是初学者还是有一定经验的开发者,都能从中获得启发和收获。
第一章:多线程基础
1.1 进程与线程
在操作系统中,进程是资源分配的最小单位,线程是CPU调度的最小单位。一个进程可以包含多个线程,它们共享进程的资源(如内存、文件句柄等),但每个线程拥有独立的程序计数器、栈和局部变量。
进程的特点:
独立性:进程之间相互隔离,一个进程崩溃通常不影响其他进程。
资源开销大:创建、销毁进程需要分配独立的地址空间,成本较高。
通信复杂:进程间通信(IPC)需要借助管道、消息队列、共享内存等机制。
线程的特点:
轻量性:线程创建、切换的开销远小于进程。
资源共享:同一进程内的线程共享堆内存和方法区,通信方便(只需通过共享变量)。
风险:由于资源共享,多线程同时访问共享数据时容易引发线程安全问题。
1.2 线程的生命周期
在Java中,线程的生命周期由Thread.State枚举定义,共包含6种状态:
NEW(新建):线程对象已创建,但尚未调用start()方法。
RUNNABLE(可运行):线程正在Java虚拟机中执行,但它可能正在等待操作系统分配CPU时间片(就绪状态),或者正在执行(运行状态)。这两种状态在Java中统一为RUNNABLE。
BLOCKED(阻塞):线程试图获取一个锁,而该锁被其他线程持有,进入阻塞状态,直到获得锁。
WAITING(等待):线程调用了Object.wait()、Thread.join()或LockSupport.park()方法,等待另一个线程执行特定操作(如通知或中断)。
TIMED_WAITING(限时等待):线程调用了带超时参数的方法,如Thread.sleep(long)、Object.wait(long)、Thread.join(long)等。
TERMINATED(终止):线程执行完毕或因异常退出。
状态流转图:
text
NEW -> RUNNABLE -> BLOCKED/WAITING/TIMED_WAITING -> RUNNABLE -> TERMINATED
1.3 创建线程的四种方式
1.3.1 继承Thread类
java
public class MyThread extends Thread { @Override public void run() { System.out.println("线程运行中..."); } public static void main(String[] args) { MyThread t = new MyThread(); t.start(); // 启动线程 } }缺点:Java单继承,如果已经继承了其他类,则无法再继承Thread。
1.3.2 实现Runnable接口
java
public class MyRunnable implements Runnable { @Override public void run() { System.out.println("线程运行中..."); } public static void main(String[] args) { Thread t = new Thread(new MyRunnable()); t.start(); } }优点:解耦了任务定义与线程执行,且可以继续继承其他类,推荐使用。
1.3.3 实现Callable接口 + FutureTask
Runnable的run()方法没有返回值,也不能抛出受检异常。如果需要线程执行后返回结果,可以使用Callable。
java
public class MyCallable implements Callable<String> { @Override public String call() throws Exception { return "任务执行结果"; } public static void main(String[] args) throws Exception { FutureTask<String> futureTask = new FutureTask<>(new MyCallable()); Thread t = new Thread(futureTask); t.start(); String result = futureTask.get(); // 阻塞获取结果 System.out.println(result); } }1.3.4 线程池
频繁创建和销毁线程会带来较大开销,线程池通过复用线程来提升性能。
java
ExecutorService executor = Executors.newFixedThreadPool(5); executor.submit(() -> { System.out.println("线程池任务执行"); }); executor.shutdown();1.4 线程常用方法详解
start():启动线程,使其进入RUNNABLE状态,由JVM自动调用run()方法。注意:直接调用run()只是普通方法调用,不会启动新线程。
run():线程执行的任务逻辑,需要重写。
sleep(long millis):让当前线程进入TIMED_WAITING状态,释放CPU,但不释放锁。
yield():提示线程调度器当前线程愿意让出CPU,但调度器可能忽略该提示。线程从运行状态转为就绪状态。
join():等待调用join的线程终止。例如,在线程A中调用线程B的join(),则A进入WAITING状态,直到B执行完毕。
interrupt():中断线程,设置中断标志。被中断的线程可以在适当时候响应中断(如抛出InterruptedException或检查isInterrupted())。
isAlive():判断线程是否存活(已启动且未终止)。
setPriority(int priority):设置线程优先级(1-10),但优先级依赖操作系统实现,不一定有效。
1.5 线程优先级
Java线程优先级范围是1~10,默认优先级为5(NORM_PRIORITY)。高优先级线程获得CPU时间片的概率更高,但不能保证绝对顺序。不同操作系统的线程优先级映射可能不同,因此依赖优先级实现业务逻辑是不可靠的。
1.6 守护线程的概念
线程分为两类:用户线程和守护线程(Daemon Thread)。守护线程是一种特殊线程,它在后台运行,为其他线程提供服务。当JVM中只剩下守护线程时,JVM就会退出。典型的守护线程如垃圾回收线程(GC)。
设置守护线程需在线程启动前调用setDaemon(true)。
java
Thread daemonThread = new Thread(() -> { while (true) { System.out.println("守护线程运行..."); try { Thread.sleep(1000); } catch (InterruptedException e) {} } }); daemonThread.setDaemon(true); daemonThread.start();注意:守护线程中打开的资源(如文件、网络连接)可能不会正确关闭,因为JVM退出时不会等待守护线程执行finally块。因此,守护线程不适合执行需要清理资源的任务。
第二章:守护线程深入
2.1 定义与特点
守护线程(Daemon Thread)是一种服务于用户线程的线程,它的生命周期依赖于用户线程。当所有用户线程结束时,守护线程会随JVM一起终止,无论它是否正在执行。守护线程的典型特征是“默默奉献”,比如JVM的垃圾回收线程、内存监控线程等。
特点:
当只剩下守护线程时,JVM自动退出。
守护线程的finally块不一定执行(因为JVM可能直接退出)。
守护线程创建的子线程默认也是守护线程。
必须在start()之前调用setDaemon(true),否则会抛出IllegalThreadStateException。
2.2 设置守护线程的方法
java
Thread thread = new Thread(task); thread.setDaemon(true); thread.start();
可以通过thread.isDaemon()检查线程是否为守护线程。
2.3 守护线程的典型应用
垃圾回收线程:JVM中最著名的守护线程,负责回收不再使用的对象内存。
心跳检测:在分布式系统中,守护线程可以定期发送心跳包,检测连接是否存活。
日志清理:定期清理过期日志文件。
缓存刷新:定时刷新本地缓存。
2.4 注意事项:finally块不一定执行
考虑以下代码:
java
Thread daemon = new Thread(() -> { try { System.out.println("守护线程开始工作"); // 模拟长时间任务 Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println("守护线程finally执行"); } }); daemon.setDaemon(true); daemon.start(); // 主线程很快结束 Thread.sleep(1000); System.out.println("主线程结束");输出结果可能只有“守护线程开始工作”和“主线程结束”,而“守护线程finally执行”可能不会出现。因为主线程结束后,JVM检测到只剩守护线程,立即退出,不会等待守护线程的finally块执行。
结论:不要在守护线程中执行重要的资源清理操作,如关闭文件、释放数据库连接等。这些操作应该在用户线程中完成,或者通过Runtime.addShutdownHook添加钩子函数。
第三章:线程安全
3.1 什么是线程安全?
当多个线程同时访问一个共享可变变量时,如果没有采取正确的同步措施,就可能导致数据不一致、程序执行错误。如果一个类在单线程环境下表现正确,在多线程环境下也表现正确,那么这个类就是线程安全的。
线程安全的核心问题是:多个线程对共享变量的读写操作相互干扰。这种干扰通常表现为三种情况:
原子性问题:一个操作(或一组操作)被中途打断,导致执行结果不符合预期。例如,i++操作看似一步,但实际包含“读取-修改-写入”三步,可能被其他线程打断。
可见性问题:一个线程对共享变量的修改,另一个线程不能立即看到。这是因为每个线程有自己的工作内存(CPU缓存),修改后可能没有及时刷新到主内存。
有序性问题:编译器和处理器为了提高性能,可能会对指令进行重排序。在多线程环境下,重排序可能导致意想不到的结果。
3.2 线程不安全示例:计数器的错误
java
public class Counter { private int count = 0; public void increment() { count++; // 非原子操作 } public int getCount() { return count; } } // 多线程测试 public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Runnable task = () -> { for (int i = 0; i < 10000; i++) { counter.increment(); } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.getCount()); // 可能小于20000 }运行多次,结果通常小于20000,因为count++不是原子操作,两个线程可能同时读取到相同的count值,然后各自加1,导致少加一次。
3.3 Java内存模型(JMM)
Java内存模型定义了多线程程序中共享变量的访问规则,旨在屏蔽不同硬件和操作系统的内存访问差异。JMM规定:
所有变量存储在主内存中。
每个线程拥有自己的工作内存,保存了该线程使用的变量的副本。
线程对变量的操作(读取、赋值)必须在工作内存中进行,不能直接读写主内存。
不同线程之间无法直接访问对方工作内存中的变量,需要通过主内存来传递。
三大特性:
原子性:一个或多个操作要么全部执行且不被中断,要么全部不执行。Java中,对基本类型的读写(除long/double)是原子操作,但像i++这种复合操作不是原子的。可以通过synchronized、Lock、Atomic类保证原子性。
可见性:当一个线程修改了共享变量,其他线程能立即看到这个修改。volatile关键字保证可见性,synchronized和Lock也能保证(通过释放锁前刷新到主内存)。
有序性:程序执行的顺序按照代码的先后顺序执行。Java允许编译器和处理器对指令重排序,但遵循as-if-serial语义(单线程下不能改变执行结果)。volatile可以禁止指令重排序,synchronized和Lock也能保证其代码块内部有序(但代码块之间依然可能重排)。
3.4 volatile关键字
volatile是轻量级的同步机制,保证:
可见性:写volatile变量后,会强制刷新到主内存;读volatile变量前,会从主内存重新加载。
有序性:禁止指令重排序(通过内存屏障实现)。
适用场景:一个线程写,多个线程读;或者用作状态标志。
java
public class FlagExample { private volatile boolean running = true; public void stop() { running = false; // 写volatile,对其他线程立即可见 } public void run() { while (running) { // 执行任务 } } }不适用场景:复合操作(如count++),因为volatile不保证原子性。
3.5 实现线程安全的策略
无状态:如果一个类不包含任何共享变量,那么它是线程安全的。例如,工具方法类(没有成员变量)天生线程安全。
不可变:对象一旦创建,其状态就不能改变。所有成员变量用final修饰,且不提供修改方法。不可变对象是线程安全的,如String、Integer。
线程封闭:将共享变量限制在单个线程内访问,就不会发生竞争。常见方式:
栈封闭:局部变量(存在于虚拟机栈,线程私有)。
ThreadLocal:每个线程保存自己的副本。
同步机制:通过锁或原子类保护共享状态。
第四章:线程同步机制
线程同步是为了协调多个线程对共享资源的访问,确保线程安全。Java提供了多种同步机制,从基础的synchronized到丰富的并发工具类。
4.1 synchronized关键字
synchronized是Java内置的锁机制,可以修饰方法或代码块。它保证:
原子性:同一时刻只有一个线程执行同步代码。
可见性:进入同步块前会清空工作内存,从主内存加载最新变量;退出时会将修改刷新到主内存。
有序性:同步块内部的代码不会被重排序到块外。
4.1.1 三种用法
修饰实例方法:锁对象是当前实例(this)。
java
public synchronized void method() { // 同步代码 }修饰静态方法:锁对象是当前类的Class对象。
java
public static synchronized void staticMethod() { // 同步代码 }修饰代码块:可以指定任意对象作为锁。
java
public void block() { synchronized (lockObject) { // 同步代码 } }
4.1.2 底层原理
synchronized在JVM中是通过monitorenter和monitorexit指令实现的。每个对象都有一个监视器锁(monitor)。当线程进入同步代码时,尝试获取对象的monitor所有权;如果获取失败,则进入阻塞队列等待。
在Java 6之后,JVM对synchronized进行了大量优化,引入了锁升级机制:
偏向锁:无竞争时,偏向第一个获取锁的线程,避免CAS操作。
轻量级锁:当有另一个线程竞争时,偏向锁升级为轻量级锁,通过自旋尝试获取锁,避免线程阻塞。
重量级锁:如果自旋超过一定次数或线程数较多,则升级为重量级锁,线程真正阻塞,由操作系统调度。
锁升级是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
4.1.3 可重入性
synchronized是可重入锁,即一个线程已经获得某个对象的锁,可以再次进入该对象的其他同步方法/代码块。实现原理:每个锁关联一个计数器,当线程第一次获得锁时计数器为1,每次重入增加1,退出同步块时递减,直到0才释放锁。
4.2 ReentrantLock
ReentrantLock是java.util.concurrent.locks包提供的显式锁,实现了Lock接口,具备比synchronized更丰富的功能。
4.2.1 基本用法
java
Lock lock = new ReentrantLock(); lock.lock(); try { // 同步代码 } finally { lock.unlock(); // 必须在finally中释放,避免死锁 }4.2.2 主要特性
可重入:与synchronized相同。
可中断:
lock.lockInterruptibly()允许在等待锁时响应中断。可定时:
tryLock(long time, TimeUnit unit)尝试获取锁,超时返回false。公平性:构造函数可指定公平锁(
new ReentrantLock(true)),按照线程请求锁的顺序分配,但公平锁会降低吞吐量。多个条件队列:通过
newCondition()可以创建多个条件对象,实现精细的线程等待/唤醒。
4.2.3 Condition示例
java
class BoundedBuffer { final Lock lock = new ReentrantLock(); final Condition notFull = lock.newCondition(); final Condition notEmpty = lock.newCondition(); final Object[] items = new Object[100]; int putptr, takeptr, count; public void put(Object x) throws InterruptedException { lock.lock(); try { while (count == items.length) notFull.await(); // 等待不满 items[putptr] = x; if (++putptr == items.length) putptr = 0; count++; notEmpty.signal(); // 通知非空 } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); // 等待不空 Object x = items[takeptr]; if (++takeptr == items.length) takeptr = 0; count--; notFull.signal(); // 通知不满 return x; } finally { lock.unlock(); } } }4.2.4 ReentrantLock vs synchronized
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 用法 | 隐式,自动释放 | 显式,需手动释放 |
| 可中断 | 不支持 | 支持 |
| 超时 | 不支持 | 支持 |
| 公平性 | 非公平 | 可指定公平 |
| 条件等待 | 一个条件(wait/notify) | 多个Condition |
| 性能 | 优化后与Lock接近 | 略高(特定场景) |
选择建议:如果不需要高级功能,优先使用synchronized(简洁、不易出错);如果需要可中断、多条件等,则使用ReentrantLock。
4.3 读写锁 ReentrantReadWriteLock
读写锁维护一对锁:读锁(共享锁)和写锁(独占锁)。它允许多个线程同时读,但写操作必须独占。在读多写少的场景下,可以大幅提升并发性能。
java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); Lock readLock = rwLock.readLock(); Lock writeLock = rwLock.writeLock(); // 读操作 readLock.lock(); try { // 读取共享数据 } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { // 修改共享数据 } finally { writeLock.unlock(); }锁降级:写锁可以降级为读锁,即持有写锁的线程可以再获取读锁,然后释放写锁,实现数据的可见性保护。但读锁不能升级为写锁,否则可能导致死锁。
4.4 StampedLock(Java 8)
StampedLock是读写锁的改进,支持三种模式:写锁、悲观读锁、乐观读。乐观读不阻塞写操作,通过版本戳判断数据是否被修改。
java
StampedLock lock = new StampedLock(); // 乐观读 long stamp = lock.tryOptimisticRead(); int data = sharedData; if (!lock.validate(stamp)) { // 乐观读失败,升级为悲观读锁 stamp = lock.readLock(); try { data = sharedData; } finally { lock.unlockRead(stamp); } } // 写锁 long stamp = lock.writeLock(); try { sharedData = newValue; } finally { lock.unlockWrite(stamp); }注意:StampedLock是不可重入的,且不支持条件等待。
4.5 其他同步工具类
4.5.1 CountDownLatch(倒计时门闩)
允许一个或多个线程等待其他线程完成操作。初始化一个计数器,线程完成任务后调用countDown()递减,等待线程调用await()阻塞,直到计数器归零。
java
CountDownLatch latch = new CountDownLatch(5); for (int i = 0; i < 5; i++) { new Thread(() -> { // 执行任务 latch.countDown(); }).start(); } latch.await(); // 等待所有任务完成 System.out.println("所有任务完成");4.5.2 CyclicBarrier(循环屏障)
让一组线程互相等待,直到所有线程都到达某个公共屏障点,然后继续执行。可以重用。
java
CyclicBarrier barrier = new CyclicBarrier(5, () -> { System.out.println("所有线程到达屏障,执行额外操作"); }); for (int i = 0; i < 5; i++) { new Thread(() -> { System.out.println("线程" + Thread.currentThread().getName() + "开始工作"); try { Thread.sleep((long)(Math.random()*1000)); barrier.await(); // 等待其他线程 System.out.println("线程" + Thread.currentThread().getName() + "继续执行"); } catch (Exception e) {} }).start(); }4.5.3 Semaphore(信号量)
控制同时访问特定资源的线程数量。通过acquire()获取许可,release()释放许可。
java
Semaphore semaphore = new Semaphore(3); // 最多允许3个线程同时访问 for (int i = 0; i < 10; i++) { new Thread(() -> { try { semaphore.acquire(); System.out.println(Thread.currentThread().getName() + "获取许可,开始工作"); Thread.sleep(1000); } catch (InterruptedException e) {} finally { semaphore.release(); } }).start(); }4.5.4 Exchanger(交换者)
用于两个线程交换数据。每个线程调用exchange()方法,当两个线程都到达交换点时,交换数据。
java
Exchanger<String> exchanger = new Exchanger<>(); new Thread(() -> { try { String data = "来自线程A的数据"; String received = exchanger.exchange(data); System.out.println("线程A收到: " + received); } catch (InterruptedException e) {} }).start(); new Thread(() -> { try { String data = "来自线程B的数据"; String received = exchanger.exchange(data); System.out.println("线程B收到: " + received); } catch (InterruptedException e) {} }).start();第五章:互斥锁
互斥锁(Mutex)是最基本的同步机制,保证同一时刻只有一个线程访问共享资源。从广义上讲,synchronized、ReentrantLock都是互斥锁的实现。本章深入探讨锁的实现原理、分类及优化策略。
5.1 锁的实现机制
5.1.1 自旋锁
自旋锁是指线程在尝试获取锁时,如果锁已被占用,线程不会立即阻塞,而是循环等待(自旋),不断检查锁是否释放。自旋的好处是避免了线程上下文切换的开销,适用于锁持有时间很短的场景。缺点:如果锁持有时间长,自旋会浪费CPU。
Java中的自旋锁实现:java.util.concurrent.atomic包下的AtomicReference、AbstractQueuedSynchronizer(AQS)中使用了自旋。
5.1.2 阻塞锁
阻塞锁是指线程在获取锁失败时,会被挂起(进入阻塞状态),由操作系统调度唤醒。线程上下文切换开销较大,但CPU利用率更高。synchronized在升级为重量级锁后,就是阻塞锁。
现代JVM会根据锁的竞争情况自适应选择自旋或阻塞。
5.2 公平锁与非公平锁
公平锁:按照线程请求锁的顺序分配锁,先到先得。优点是避免线程饥饿,缺点是吞吐量较低(因为需要维护队列和唤醒)。
非公平锁:线程在请求锁时,可以插队,直接尝试获取锁,获取不到再进入队列。优点是减少线程唤醒开销,吞吐量高,但可能导致某些线程一直得不到锁(饥饿)。
synchronized是非公平锁。ReentrantLock默认非公平,但可通过构造参数指定公平。
5.3 锁优化技术
5.3.1 锁粒度细化
将一个大锁拆分成多个小锁,减少锁竞争。经典案例:ConcurrentHashMap在Java 7之前使用分段锁(Segment数组),每个Segment独立加锁;Java 8之后使用CAS + synchronized对每个桶加锁,粒度更细。
5.3.2 锁粗化
JVM自动将多个连续的加锁、解锁操作合并成一个范围更大的锁,以减少锁操作次数。例如:
java
for (int i = 0; i < 100; i++) { synchronized (lock) { // 少量操作 } } // JVM可能会优化为: synchronized (lock) { for (int i = 0; i < 100; i++) { // 少量操作 } }5.3.3 锁消除
JIT编译器通过逃逸分析,发现某个锁对象不可能被其他线程访问,就会消除这个锁。例如,在局部变量上加锁,而该变量不会逃逸出方法。
5.3.4 锁升级
如前所述,synchronized从偏向锁->轻量级锁->重量级锁的升级过程,就是为了在不同竞争程度下获得最佳性能。
5.4 死锁、活锁与饥饿
5.4.1 死锁
两个或多个线程互相持有对方需要的锁,且都不释放,导致所有线程都无法继续执行。
死锁产生的四个必要条件:
互斥:资源一次只能被一个线程占用。
请求与保持:线程已持有至少一个资源,又请求其他资源,且不释放已有资源。
不可剥夺:资源只能由持有者主动释放。
循环等待:存在一个线程等待链,形成环路。
死锁示例:
java
Object lockA = new Object(); Object lockB = new Object(); // 线程1 new Thread(() -> { synchronized (lockA) { try { Thread.sleep(100); } catch (Exception e) {} synchronized (lockB) { System.out.println("线程1获得两个锁"); } } }).start(); // 线程2 new Thread(() -> { synchronized (lockB) { try { Thread.sleep(100); } catch (Exception e) {} synchronized (lockA) { System.out.println("线程2获得两个锁"); } } }).start();避免死锁的方法:
按顺序获取锁(破坏循环等待)。
使用定时锁(
tryLock),超时后释放已持有的锁。检测死锁并恢复(如通过jstack分析)。
5.4.2 活锁
线程虽然没有阻塞,但始终无法取得进展,因为一直在重复相同的操作。例如,两个线程互相谦让,都释放自己的锁给对方,结果谁也无法继续。活锁可以通过引入随机等待时间来避免。
5.4.3 饥饿
线程因为无法获得所需资源而一直无法执行。例如,低优先级的线程总是被高优先级线程抢占;或者非公平锁导致某个线程永远抢不到锁。可以通过公平锁来避免饥饿。
第六章:实践案例与常见问题
6.1 单例模式与双重检查锁定(DCL)
单例模式中,懒汉式需要保证线程安全。双重检查锁定是一种常见实现,但需要正确使用volatile。
java
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; } }为什么需要volatile?instance = new Singleton()不是原子操作,可分解为:
分配内存
初始化对象
将内存地址赋值给instance
由于指令重排序,可能先执行3再执行2,导致另一个线程在第一次检查时看到instance不为空,却访问到一个未初始化的对象。volatile禁止了重排序,保证对象初始化完成后再赋值。
6.2 生产者消费者模式
生产者消费者模式是多线程协作的经典案例。可以使用wait/notify或BlockingQueue实现。
6.2.1 使用wait/notify
java
class Storage { private final int MAX_SIZE = 10; private LinkedList<Object> list = new LinkedList<>(); public void produce() throws InterruptedException { synchronized (list) { while (list.size() == MAX_SIZE) { list.wait(); // 仓库满,生产者等待 } list.add(new Object()); System.out.println("生产一个,当前库存:" + list.size()); list.notifyAll(); // 唤醒等待的消费者 } } public void consume() throws InterruptedException { synchronized (list) { while (list.isEmpty()) { list.wait(); // 仓库空,消费者等待 } list.remove(); System.out.println("消费一个,当前库存:" + list.size()); list.notifyAll(); // 唤醒等待的生产者 } } }6.2.2 使用BlockingQueue
BlockingQueue内部已经处理了同步,实现更简洁。
java
BlockingQueue<Object> queue = new LinkedBlockingQueue<>(10); // 生产者 new Thread(() -> { try { queue.put(new Object()); System.out.println("生产一个"); } catch (InterruptedException e) {} }).start(); // 消费者 new Thread(() -> { try { queue.take(); System.out.println("消费一个"); } catch (InterruptedException e) {} }).start();6.3 线程池详解
线程池通过复用线程,减少创建销毁开销,并有效控制并发数。
6.3.1 ThreadPoolExecutor 参数
java
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
corePoolSize:核心线程数,即使空闲也保留。
maximumPoolSize:最大线程数。
keepAliveTime:非核心线程空闲存活时间。
unit:时间单位。
workQueue:任务队列,用于存放等待执行的任务。
threadFactory:线程工厂,用于创建线程(可自定义命名)。
handler:拒绝策略,当队列满且线程数达到最大值时执行。
拒绝策略:
AbortPolicy:抛出RejectedExecutionException(默认)。
CallerRunsPolicy:调用者线程自己执行任务。
DiscardPolicy:直接丢弃任务。
DiscardOldestPolicy:丢弃队列中最旧的任务,然后重试提交。
6.3.2 线程池工作流程
提交任务时,如果当前线程数 < corePoolSize,创建新线程执行。
如果当前线程数 >= corePoolSize,将任务放入workQueue。
如果workQueue已满,且当前线程数 < maximumPoolSize,创建新线程执行。
如果workQueue已满且当前线程数 == maximumPoolSize,执行拒绝策略。
当线程空闲时间超过keepAliveTime,非核心线程会被终止;若允许核心线程超时,可通过
allowCoreThreadTimeOut(true)设置。
6.3.3 常用线程池(Executors工具类)
newFixedThreadPool(int n):固定大小线程池,队列无限大(LinkedBlockingQueue)。newCachedThreadPool():可缓存线程池,空闲线程60秒回收,队列SynchronousQueue(直接交付)。newSingleThreadExecutor():单线程线程池,保证任务顺序执行。newScheduledThreadPool(int core):定时任务线程池。
警告:生产环境不建议使用Executors创建线程池,因为可能使用无界队列(如FixedThreadPool的队列Integer.MAX_VALUE)或允许创建大量线程(CachedThreadPool最大线程数Integer.MAX_VALUE),容易导致OOM。推荐直接使用ThreadPoolExecutor自定义参数。
6.4 ThreadLocal原理与内存泄漏
ThreadLocal提供了线程局部变量,每个线程拥有自己的副本,互不干扰。
6.4.1 原理
每个Thread内部有一个ThreadLocalMap,键为ThreadLocal实例(弱引用),值为存储的对象。当调用set(value)时,以当前ThreadLocal为键,将值放入当前线程的map中;get()则从map中取出。
6.4.2 内存泄漏问题
ThreadLocalMap的key是弱引用(WeakReference<ThreadLocal>),但value是强引用。如果ThreadLocal对象被回收(因为弱引用),而value还存在,并且线程一直存活(如线程池中的线程),那么value将无法被回收,造成内存泄漏。
解决方法:使用完ThreadLocal后,手动调用remove()方法清除。
java
ThreadLocal<String> tl = new ThreadLocal<>(); try { tl.set("value"); // 使用... } finally { tl.remove(); // 防止内存泄漏 }6.5 并发容器
6.5.1 ConcurrentHashMap
Java 7:采用分段锁(Segment继承ReentrantLock),默认16个Segment,每个Segment独立加锁,支持16个线程并发写。
Java 8:放弃分段锁,使用CAS + synchronized对每个桶加锁。当链表长度超过8时,转为红黑树,提高查询效率。size()等方法通过累加计数器实现,无需全局锁。
6.5.2 CopyOnWriteArrayList
适用于读多写少的场景。写操作时,复制一份新数组,在新数组上进行修改,然后将原数组引用指向新数组。读操作不加锁,直接在原数组上读。缺点:写操作开销大,且数据可能有一致性问题(最终一致性)。
6.5.3 BlockingQueue 实现类
ArrayBlockingQueue:有界数组队列,公平性可配置。
LinkedBlockingQueue:链表实现,可有界或无界。
PriorityBlockingQueue:支持优先级排序。
DelayQueue:元素只有到达延迟时间才能被取出。
6.6 常见问题模拟
6.6.1 线程不安全示例:i++
前面已经演示过,解决方案:
使用synchronized修饰increment()。
使用AtomicInteger。
使用ReentrantLock。
6.6.2 死锁模拟与诊断
死锁示例代码如5.4.1。诊断死锁:使用jstack pid查看线程堆栈,会发现“Found one Java-level deadlock”提示。
6.6.3 活锁模拟
java
public class LiveLock { static class Spoon { private Diner owner; public Spoon(Diner owner) { this.owner = owner; } public synchronized void use() { System.out.println(owner.name + " 使用勺子"); } public void setOwner(Diner diner) { this.owner = diner; } } static class Diner { private String name; private boolean isHungry; public Diner(String name) { this.name = name; isHungry = true; } public void eatWith(Spoon spoon, Diner spouse) { while (isHungry) { if (spoon.owner != this) { try { Thread.sleep(1); } catch (Exception e) {} continue; } if (spouse.isHungry) { System.out.println(name + " 让给 " + spouse.name); spoon.setOwner(spouse); continue; } spoon.use(); isHungry = false; System.out.println(name + " 吃饱了"); spoon.setOwner(spouse); } } } public static void main(String[] args) { Diner husband = new Diner("丈夫"); Diner wife = new Diner("妻子"); Spoon spoon = new Spoon(husband); new Thread(() -> husband.eatWith(spoon, wife)).start(); new Thread(() -> wife.eatWith(spoon, husband)).start(); } }两个线程互相谦让,导致谁也无法吃饭。可通过随机等待或优先级解决。
第七章:性能考量与最佳实践
7.1 避免过度同步
同步会带来性能开销(锁竞争、上下文切换)。应尽量减少同步的范围,只同步必要的代码块。例如:
java
// 不好的实践:同步整个方法 public synchronized void process() { // 读取配置(无需同步) // 修改共享变量(需同步) // 记录日志(无需同步) } // 好的实践:只同步必要的部分 public void process() { // 读取配置 synchronized (this) { // 修改共享变量 } // 记录日志 }7.2 减少锁的持有时间
尽量缩短锁内代码的执行时间,将不需要同步的操作移到锁外。例如,不要在锁内执行耗时操作(如IO、网络请求)。
7.3 选择合适的并发工具
读多写少:使用读写锁、CopyOnWriteArrayList。
计数等待:CountDownLatch、CyclicBarrier。
流量控制:Semaphore。
生产者消费者:BlockingQueue。
原子操作:AtomicInteger、LongAdder(高并发下性能更好)。
7.4 线程池大小设置
CPU密集型任务:线程数 = CPU核心数 + 1(避免缺页中断导致CPU空闲)。
IO密集型任务:线程数 = CPU核心数 * (1 + 平均等待时间/平均计算时间)。或者使用公式:线程数 = CPU核心数 * (1 + IO耗时/CPU耗时)。
一般经验值:IO密集型可设置为2倍CPU核心数以上。但最佳实践是通过压测逐步调整。
7.5 监控与调试
jstack:打印线程堆栈,分析死锁、线程状态。
jvisualvm:可视化监控线程数、CPU、内存。
Arthas:阿里巴巴开源的Java诊断工具,可以实时查看线程状态、方法调用等。
日志:记录线程池活跃度、任务队列大小等关键指标。
7.6 常见误区
使用volatile保证原子性:volatile不能保证复合操作原子性,如count++。
忘记在finally中释放锁:ReentrantLock必须手动释放,否则导致死锁。
线程池用完不关闭:导致JVM无法退出。
使用ThreadLocal后不remove:内存泄漏。
认为单例模式简单:DCL需要volatile,枚举单例更安全。
滥用线程优先级:依赖操作系统,不可靠。
第八章:总结与展望
8.1 核心回顾
本文从多线程基础出发,系统梳理了以下核心概念:
多线程:进程与线程、生命周期、创建方式、常用方法。
守护线程:定义、设置、应用场景及注意事项。
线程安全:原子性、可见性、有序性,Java内存模型,volatile,线程安全策略。
线程同步:synchronized、ReentrantLock、读写锁、StampedLock,以及CountDownLatch等同步工具。
互斥锁:自旋锁、阻塞锁、公平性、锁优化、死锁等。
实践案例:单例模式、生产者消费者、线程池、ThreadLocal、并发容器。
高并发编程的核心思想是:在保证线程安全的前提下,最大限度地提高并发性能。这需要开发者深入理解底层原理,并结合实际场景灵活运用。
8.2 未来展望
随着硬件和编程语言的发展,并发编程模型也在不断演进:
协程(Coroutine):比线程更轻量,由用户态调度。Go语言的goroutine已经大放异彩。Java正在通过Project Loom引入虚拟线程(Virtual Threads),预计在Java 21及以后版本中成熟。虚拟线程将极大地简化并发编程,开发者可以像编写同步代码一样编写高并发程序,而无需担心线程开销。
响应式编程:通过异步数据流和背压机制,处理高并发场景。如Reactor、RxJava等框架。
无锁编程:基于CAS和硬件支持的原子操作,设计无锁数据结构,避免锁竞争带来的开销。