1. 为什么我们需要并发编程?
在单核CPU时代,程序执行是顺序的,就像一个人在厨房里做饭——切完菜才能开火炒菜。但现代计算机都是多核处理器,就像有了多个厨师可以同时工作。如果还坚持顺序执行,就相当于让其他厨师闲着看一个人忙活,这显然是对计算资源的巨大浪费。
我曾在电商大促期间遇到过这样的案例:一个商品详情页接口需要串行调用库存服务、价格服务和评价服务,每个服务耗时约100ms。当QPS达到1000时,系统直接崩溃。改为并发调用后,接口耗时从300ms降到120ms,吞吐量提升了2.5倍。这就是并发编程的威力。
注意:并发(Concurrency)和并行(Parallelism)是不同的概念。并发是逻辑上的同时发生(单核时间片轮转),并行是物理上的同时执行(多核真正同步)。本文主要讨论并发场景。
2. 线程:并发的基本执行单元
2.1 线程的本质与实现
线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中。用公司架构类比:
- 进程 = 一家公司(拥有独立办公空间和资金)
- 线程 = 公司员工(共享办公室资源但独立工作)
Java中创建线程的三种典型方式:
// 方式1:继承Thread类 class MyThread extends Thread { public void run() { System.out.println("Thread running"); } } // 方式2:实现Runnable接口 class MyRunnable implements Runnable { public void run() { System.out.println("Runnable running"); } } // 方式3:使用Lambda表达式 new Thread(() -> { System.out.println("Lambda thread running"); }).start();实际项目中更推荐方式2和3,因为:
- Java不支持多重继承,继承Thread类会占用继承名额
- Runnable接口更符合面向对象的设计原则
- 线程池只能接收Runnable/Callable任务
2.2 线程的生命周期与状态转换
线程从创建到销毁会经历多个状态(以Java为例):
- NEW:刚创建未启动
- RUNNABLE:可运行状态(可能在执行也可能在等待CPU时间片)
- BLOCKED:等待监视器锁(同步代码块)
- WAITING:无限期等待(wait()/join())
- TIMED_WAITING:限期等待(sleep()/wait(timeout))
- TERMINATED:执行结束
状态转换示意图:
NEW → RUNNABLE ↔ BLOCKED ↓ ↓ TERMINATED ← WAITING ↑ TIMED_WAITING我在排查一个线上问题时,发现线程大量处于BLOCKED状态。经查是因为一个同步方法执行时间过长(涉及数据库操作),改为更细粒度的锁后性能提升40%。这说明理解线程状态对性能调优至关重要。
3. 线程安全与同步机制
3.1 竞态条件与临界区问题
当多个线程同时访问共享资源时,如果没有正确同步,就会出现竞态条件(Race Condition)。举个转账的例子:
class Account { private int balance; // 不安全的实现 void transfer(Account target, int amount) { this.balance -= amount; target.balance += amount; } }如果两个线程同时执行A向B转账,可能出现:
- 线程1读取A.balance=100
- 线程2读取A.balance=100
- 线程1计算A.balance=100-50=50
- 线程2计算A.balance=100-30=70
- 最终A.balance可能是50或70,而不是预期的20
3.2 同步解决方案对比
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| synchronized | 方法/代码块加锁 | 简单易用 | 性能较差 | 简单的同步需求 |
| ReentrantLock | 显式锁API | 可中断、可定时、公平锁 | 需手动释放 | 复杂锁需求 |
| volatile | 变量可见性 | 轻量级 | 不保证原子性 | 状态标志位 |
| Atomic类 | CAS操作 | 高性能 | 只能保护单个变量 | 计数器等场景 |
实际项目中,我曾用AtomicInteger替代synchronized实现计数器,QPS从8000提升到12000。但要注意ABA问题,必要时使用AtomicStampedReference。
4. 等待通知机制深度解析
4.1 wait/notify的工作原理
等待通知机制是线程间协作的核心方式,其正确使用需要理解几个关键点:
- 必须在同步代码块中调用(持有对象监视器)
- wait()会释放锁,notify()不会立即释放锁
- 经典的生产者-消费者模式实现:
class Buffer { private Queue<Integer> queue = new LinkedList<>(); private int capacity; public Buffer(int capacity) { this.capacity = capacity; } public synchronized void produce(int item) throws InterruptedException { while (queue.size() == capacity) { wait(); // 缓冲区满,等待 } queue.offer(item); notifyAll(); // 通知消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 缓冲区空,等待 } int item = queue.poll(); notifyAll(); // 通知生产者 return item; } }4.2 常见陷阱与最佳实践
- 虚假唤醒问题:wait()返回后必须重新检查条件(用while而不是if)
- notify vs notifyAll:notify随机唤醒一个,notifyAll唤醒所有。在大多数情况下应该使用notifyAll
- 丢失唤醒问题:如果notify先于wait调用,通知会丢失。这解释了为什么条件检查要用while循环
我在消息队列实现中就遇到过虚假唤醒问题:消费者线程被唤醒后直接操作队列导致NPE。改为while循环检查后问题解决。
5. 线程池:并发编程的工业级解决方案
5.1 为什么需要线程池
直接创建线程的问题:
- 创建/销毁线程开销大(涉及系统调用)
- 无限制创建会导致资源耗尽
- 缺乏统一管理(难以监控、统计)
线程池的优势:
- 重用已有线程,降低开销
- 控制并发数量,避免资源竞争
- 提供定时执行、定期执行等功能
5.2 ThreadPoolExecutor核心参数
ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲线程存活时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 任务队列 RejectedExecutionHandler handler // 拒绝策略 )四种拒绝策略:
- AbortPolicy(默认):抛出RejectedExecutionException
- CallerRunsPolicy:由调用线程执行该任务
- DiscardPolicy:直接丢弃任务
- DiscardOldestPolicy:丢弃队列最前面的任务
5.3 线程池配置实践建议
- CPU密集型任务:核心线程数 = CPU核数 + 1
- IO密集型任务:核心线程数 = CPU核数 * 2
- 混合型任务:拆分不同线程池处理
- 队列选择:
- 需要控制并发量:ArrayBlockingQueue
- 大量短时任务:SynchronousQueue
- 优先级任务:PriorityBlockingQueue
在电商系统中,我们将订单创建(IO密集)和库存扣减(CPU密集)拆分到不同线程池,配合合适的队列大小和拒绝策略,在大促期间保持了系统稳定。
6. 高级并发模式与应用
6.1 Fork/Join框架
适用于可分解的递归型任务,采用工作窃取算法提高CPU利用率。典型实现:
class FibonacciTask extends RecursiveTask<Integer> { final int n; FibonacciTask(int n) { this.n = n; } protected Integer compute() { if (n <= 1) return n; FibonacciTask f1 = new FibonacciTask(n - 1); f1.fork(); FibonacciTask f2 = new FibonacciTask(n - 2); return f2.compute() + f1.join(); } }6.2 CompletableFuture异步编程
Java 8引入的函数式异步编程工具:
CompletableFuture.supplyAsync(() -> { // 异步获取商品信息 return getProductInfo(productId); }).thenApplyAsync(product -> { // 异步计算折扣 return calculateDiscount(product); }).thenAcceptAsync(result -> { // 异步保存结果 saveToDatabase(result); }).exceptionally(ex -> { // 异常处理 log.error("Process failed", ex); return null; });在实际项目中,用CompletableFuture重构串行调用链后,接口响应时间从450ms降至180ms。
7. 并发调试与性能优化
7.1 常见并发问题定位
死锁检测:
- jstack查看线程dump
- 查找"BLOCKED"状态和持有锁的信息
- 使用jConsole或VisualVM的可视化工具
线程泄漏排查:
- 监控线程数增长趋势
- 检查线程池配置(特别是非核心线程超时时间)
- 分析线程栈确定泄漏点
性能瓶颈分析:
- 使用Arthas的monitor命令统计方法调用耗时
- 用async-profiler进行CPU热点分析
- 关注锁竞争情况(JFR的lock视图)
7.2 优化实战经验
- 减少锁粒度:从方法级锁改为代码块锁
- 读写分离:用ReadWriteLock替代独占锁
- 无锁化设计:使用ConcurrentHashMap等并发容器
- 线程本地存储:ThreadLocal避免共享变量
- 异步化改造:将同步调用改为异步消息
在最近一次性能优化中,我们将用户会话管理从HashMap+synchronized改为ConcurrentHashMap,TPS从1200提升到3500。但要注意ConcurrentHashMap的size()方法不是精确值,需要精确计数时可以用AtomicLong配合。