尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java多线程与线程池实战:从线程创建到死锁排查

Java多线程与线程池实战:从线程创建到死锁排查 线程Java程序员绕不开的一道坎。不管你是刚接触Java还是已经写了几年业务代码只要系统一上量、接口一压测多线程的问题就会找上门来。很多朋友收藏了一堆多线程教程结果打开全是概念堆砌看得懂字、看不懂道理。这篇我尽量用干活的思路来讲从线程怎么创建开始一直到线程池核心参数怎么调、线上死锁怎么排查全部串成一条线。零基础的同学按顺序读一遍能建立起完整体系有经验的朋友可以直接跳到后面看线程池和排查部分那里有我踩过的一些坑。1. 先搞清楚线程到底是什么1.1 从进程到线程为什么要并发写单线程程序的时候代码是一行一行往下走的逻辑清楚、不用操心数据打架的问题。但你要是写过爬虫、批量导入、或者同时给几千个用户提供查询服务就会发现单线程根本顶不住。一个HTTP请求来了数据库查询要200ms你在那儿干等后面的请求全堵成一串用户体验直接就崩了。这时候就需要多线程。进程是操作系统分配资源的基本单位线程是CPU调度的基本单位一个进程里可以同时跑多个线程它们共享进程的内存空间。用生活里的类比进程就好比一家餐厅线程就是在餐厅里干活的服务员。餐厅只有一个进程服务员有好几个线程大家共用同一个厨房共享内存但每个人忙自己的单子。之所以要共享内存是因为线程之间交换数据方便。但共享也意味着风险——两个服务员同时往同一张订单上改菜名最后订单上写的是什么这种问题在多线程里就叫竞态条件Race Condition是后面所有同步机制的根源。1.2 线程和协程、多进程的区别很多刚接触多线程的人会纠结Java有线程Python有协程进程也可以开多个到底用哪个这其实取决于场景。多进程开销大但隔离性好一个崩溃不牵连其他适合做任务边界清晰的粗粒度并行比如数据分析里按数据分片跑多个计算进程。多线程开销小、共享内存方便但需要处理同步适合IO密集但共享数据多的场景。协程则是用户态的轻量级调度切换成本比线程低得多但Java里协程虚拟线程直到JDK 21才算正式成熟之前想用得靠第三方库。做Java开发大部分并发场景优先考虑线程就对了工具链最成熟、资料最多踩坑也有前人填过的路。2. 线程创建的几种方式别再只背Runnable2.1 继承Thread类class MyThread extends Thread { Override public void run() { System.out.println(线程运行中: Thread.currentThread().getName()); } } // 启动 new MyThread().start();写法最简单但有两个明显的坑一是Java是单继承继承了Thread就不能再继承别的类二是任务逻辑和线程生命周期耦合在一起如果你想让同一个任务跑多个线程就得重建对象。这种方式一般只适合快速验证一个想法或者写测试用例的时候用。正规项目里我基本不会用因为职责太混乱了——线程是线程任务是任务得分开。2.2 实现Runnable接口class Task implements Runnable { Override public void run() { System.out.println(任务执行中: Thread.currentThread().getName()); } } Thread t new Thread(new Task()); t.start();Runnable把任务从线程里解耦出来同一个Task实例可以丢给多个线程跑。如果你有多个线程要执行同一个任务逻辑这个就很方便。用法简单也是Java里最经典的写法。但它有个天生的缺陷run()方法没有返回值也抛不出受检异常。假如你让一个线程去调远程接口拿结果然后想在线程结束后用这个结果做下一步处理Runnable就干不了这事了。2.3 使用Callable和FutureTaskExecutorService executor Executors.newFixedThreadPool(4); FutureInteger future executor.submit(new CallableInteger() { Override public Integer call() throws Exception { Thread.sleep(1000); return 42; } }); // 其他事情...然后获取结果 Integer result future.get(); System.out.println(结果 result);Callable解决了Runnable不能返回结果的问题call()方法有返回值还能声明抛出异常。配合FutureTask或者线程池的submit方法你可以提交一个任务让它慢慢跑等需要结果的时候调用future.get()去取——get()会阻塞当前线程直到任务完成。实践中经常有这种需求主线程启动了三个子线程去并行查数据然后等三个结果都回来之后再合并。用Callable加CountDownLatch或者直接get()就能实现。我这里提到的一个关键点future.get()默认是无限阻塞的线上用的时候最好设置超时比如future.get(3, TimeUnit.SECONDS)不然网络抖动可能导致线程卡死。2.4 Lambda简化但别踩变量捕获的坑JDK 8之后Runnable和Callable都可以用Lambda写Thread t new Thread(() - System.out.println(lambda 线程)); t.start();很方便但有一个隐性陷阱Lambda里访问外部局部变量时这个变量必须是final或事实上final赋值后不再修改。不是final会直接编译报错。如果你确实想在多线程里改数据不要想着用普通局部变量绕过应该用原子类或者本来就设计为线程安全的容器。3. 线程生命周期与状态流转3.1 六种状态一次记牢线程的状态在Thread.State枚举里定义了六个很多人只知道“运行中”“结束了”但实际排查问题时要看的是完整状态机状态说明触发的典型操作NEW线程对象已创建还没调用start()new Thread(...)RUNNABLE可运行状态包含就绪和运行中调start()或者从等待/阻塞中被唤醒BLOCKED阻塞在锁上等待获取monitor锁synchronized同步块竞争失败WAITING无限期等待需被显式唤醒调wait()、join()、LockSupport.park()TIMED_WAITING有超时时间的等待调sleep()、wait(timeout)、join(timeout)TERMINATED已结束run()执行完或发生未捕获异常这里的重点BLOCKED和WAITING是两个完全不同的状态。BLOCKED是被动地等锁WAITING是被动地等待被唤醒。排查死锁时如果看到大量线程处于BLOCKED状态那基本能断定是锁竞争激烈或死锁如果看到WAITING状态很多且线程池线程数没耗尽那很可能是有线程在等某个永远不会到达的信号。3.2 sleep和wait的常见误区sleep是Thread的静态方法作用是让当前线程睡眠指定时间不释放对象锁。wait是Object的实例方法作用是在持有锁的情况下主动释放锁并进入等待队列必须由notify或notifyAll唤醒。很多面试题会问“sleep和wait的区别”回答的核心就一句释放锁还是不释放锁。实际编码中常见的坑是在同步代码块里调用sleep结果把其他线程也堵住了——因为锁还在你手里别人拿不到。另外一个高频坑wait()调用之前必须先拿到锁否则抛IllegalMonitorStateException。很多新人写生产者消费者模型时直接就写object.wait()然后被这个异常虐一下才明白wait/notify必须放在synchronized里配对使用。提示JDK 5之后的并发包提供了ReentrantLock配合Condition能实现更灵活的等待/通知模型可以精确唤醒“某类”线程实际业务里比原生wait/notify好用得多。后面我会单独讲。3.3 线程的优雅中断Thread的stop()方法是个危险的东西调用后线程直接停止并抛出ThreadDeath资源不释放、锁直接放弃、数据一致性被破坏。老代码里可能见过用stop()现在千万别用。正确的停止方式是协作式中断通过interrupt()方法设置中断标志线程自己决定何时响应、如何清理。class Worker implements Runnable { private volatile boolean running true; Override public void run() { while (running !Thread.currentThread().isInterrupted()) { // 干活 } // 清理资源 } } // 停止线程 worker.running false;如果线程正在sleep或waitinterrupt()会抛出InterruptedException。很多人的习惯是catch住就完事这是一个大忌。正确的做法是当前线程响应了中断应该恢复中断标志Thread.currentThread().interrupt()或者干脆把异常往上抛让上层感知到中断发生。4. 线程安全的核心从volatile到锁再到CAS4.1 volatile只解决可见性不解决原子性volatile修饰的变量保证了多线程下的可见性——一个线程修改了这个变量其他线程能立刻看到最新值。它通过禁止指令重排序和写回主存来实现。但volatile解决不了原子性问题。经典的计数器例子volatile int count 0; // 多个线程执行 count;count不是原子操作它等于“读-改-写”三步。两个线程可能同时读到count5各自加1后写回6结果实际加了两次但只加了1。所以volatile适合的场景是一个线程写、其他线程读。比如状态标志位、开关配置不适合做计数器。4.2 synchronized的三种用法与锁升级synchronized从JDK 6开始做了大量优化锁不再是“重操作”。它有三种用法修饰实例方法锁的是当前实例对象修饰静态方法锁的是Class对象修饰代码块锁的是括号里指定的对象public synchronized void update() { // 锁当前对象 } public void updateBlock() { synchronized (this) { // 锁当前对象 } } public static synchronized void updateStatic() { // 锁Class对象 }曾今有一个我见过的经典错误在静态方法上用synchronized锁实例字段。这就是锁不住——静态方法锁的是Class对象多个线程执行时各自锁的对象都不一样等于没锁。JVM的synchronized底层是无锁模式偏向锁、轻量级锁、重量级锁逐级升级。偏向锁会偏向第一个获取锁的线程减少CAS竞争开销竞争激烈时升级为轻量级锁用自旋代替线程挂起自旋几次还不成功就升级为重量级锁进入操作系统内核态的互斥量。大多数业务场景下synchronized的性能已经够用不需要盲目替换成Lock。4.3 显式锁ReentrantLock更灵活ReentrantLock和synchronized相比多了几个实用能力可中断地获取锁lockInterruptibly()尝试非阻塞获取锁tryLock()支持公平锁构造参数传true配合Condition实现精确唤醒ReentrantLock lock new ReentrantLock(true); // 公平锁 lock.lock(); try { // 临界区 } finally { lock.unlock(); // 务必在finally中释放 }写锁有个铁律lock()和unlock()必须配对unlock放在finally里。用synchronized不需要担心这一点因为JVM会自动释放但ReentrantLock不主动释放死锁风险全在程序员手里。我在code review时见过不少同事在业务代码里调了lock()中间一return就漏掉了unlock()这种bug特别难查因为线上要并发量高才能触发。4.4 CAS与原子类CASCompare-And-Swap是无锁编程的核心原语比较内存中的值是否为期望值如果是则更新为新值否则失败重试。Java里AtomicInteger、AtomicLong、AtomicReference等原子类都是基于CAS实现的。AtomicInteger count new AtomicInteger(0); count.incrementAndGet(); // 原子自增CAS的优点是线程竞争不激烈时比锁高效得多因为它不涉及线程挂起和上下文切换。但有两个典型问题ABA问题一个值被改成A变成B又变回ACAS无法识别“被改过”。解决方式是用AtomicStampedReference加版本号。自旋开销高竞争下CAS一直失败重试CPU空转。这种情况反而用synchronized性能更好。线上真实场景中我一般这样选简单的计数、序号生成用AtomicInteger / AtomicLong需要维护多个变量的一致性用锁或数据库唯一约束兜底高并发限流场景用LongAdder它在高竞争下的吞吐比AtomicLong更好。5. 线程池必须手写ThreadPoolExecutor5.1 Executors的陷阱《阿里巴巴Java开发手册》里明确禁止用Executors创建线程池原因是它提供的方法背后藏着坑newFixedThreadPool()和newSingleThreadExecutor()用的无界队列LinkedBlockingQueue任务积压时队列无限增长内存可能被耗尽。newCachedThreadPool()的最大线程数是Integer.MAX_VALUE如果任务处理速度跟不上提交速度线程会无限制创建最终把线程资源和内存拖垮。newScheduledThreadPool()同样有最大线程数的隐患。正确姿势是自己配置ThreadPoolExecutor明确每个参数。这也是一种对系统资源的掌控感建议养成习惯。5.2 核心参数逐个说ThreadPoolExecutor有七个核心参数我用一个“腊味煲仔饭”的比喻来记特别形象corePoolSize店里固定的两个师傅就算没客人也要养着。maximumPoolSize临时工最多再雇四个师傅。keepAliveTime临时工闲下来超过这个时间就辞退。unitkeepAliveTime的时间单位。workQueue店门口排队的顾客等待区。threadFactory怎么雇佣师傅创建线程的方式。handler排队的顾客等不下了怎么办拒绝策略。参数作用建议corePoolSize常驻线程数IO密集型通常2倍CPU核数CPU密集型CPU核数1maximumPoolSize最大线程数根据任务堆积容忍度内存余量来定keepAliveTime非核心线程空闲存活时间一般30~60秒workQueue任务等待队列有界优先大小根据峰值估算handler拒绝策略AbortPolicy默认抛异常CallerRunsPolicy适合不丢任务场景线程池处理任务的核心流程是线程数小于corePoolSize创建新线程处理任务。线程数大于等于corePoolSize把任务放入workQueue。workQueue满了且线程数小于maximumPoolSize创建新线程处理任务。workQueue满了且线程数等于maximumPoolSize执行拒绝策略。5.3 拒绝策略到底选哪种四个拒绝策略对应四种业务取舍策略行为适用场景AbortPolicy抛出RejectedExecutionException默认适合明确告知系统过载CallerRunsPolicy由提交任务的线程自行执行该任务不想丢弃任务降级为同步执行DiscardPolicy悄悄丢弃任务业务上可容忍丢任务比如日志DiscardOldestPolicy丢弃队列中最旧的任务追求最新任务优先实际项目里我推荐一个组合拳用有界队列 CallerRunsPolicy。提交线程自己执行任务等于用提交者的速度来反向限制提交速率相当于“自动背压”。很多系统压测时就直接用这个策略配合监控告警看到线程池活跃线程数持续高位就赶紧扩机器。5.4 线程数到底设置多少这个公式被问了无数次我说一下实用版CPU密集型线程数 CPU核数 1多出来的一个是为了弥补偶尔的缺页、GC停顿。IO密集型线程数 CPU核数 × 2再根据等待时间和计算时间的比例调整。更精确的方式线程数 CPU核数 × (1 等待时间 / 计算时间)。但说实话项目里最靠谱的方法还是压测。先按经验公式设一个值然后模拟线上流量跑一轮观察CPU利用率、线程活跃度、队列积压量。我做过一个订单导出的场景IO等待特别长2核4G的机器配了64个线程反而吞吐最高再高就频繁上下文切换。5.5 线程池的工作流程图线程池的execute方法内部逻辑画个图解释提交任务 → 线程数 corePoolSize? ├─ 是 → 创建核心线程执行 └─ 否 → 队列入队 ├─ 入队成功 → 返回 └─ 入队失败(队列满) → 线程数 maximumPoolSize? ├─ 是 → 创建非核心线程执行 └─ 否 → 执行拒绝策略关键点只有队列满了才会创建非核心线程。很多人理解成“线程数满就直接扩线程”这是错的。理解这个流程是理解线程池调优的基础。6. 并发容器与协作工具6.1 线程安全的Map怎么选HashMap在多线程下会出各种诡异问题包括CPU 100%的环形链表JDK 7JDK 8改成红黑树后虽然不会死循环了但数据丢失、覆盖问题依然存在。并发场景下选Map有四个选项Map特点适用场景Hashtable全表锁性能差兼容老代码新代码不要用Collections.synchronizedMap也是对全map加锁包装简单但不高效ConcurrentHashMap分段锁/CASSynchronized读多写多都不错首选绝大多数场景ConcurrentSkipListMap跳表有序需要有序Key或者范围查询ConcurrentHashMap的读操作不加锁写操作在JDK 8后是CAS synchronized锁单个桶的链表头节点所以并发度远高于Hashtable。但要注意ConcurrentHashMap的size()不是精确值是近似值因为统计过程中数据还在变。别拿它做精确计数。6.2 CountDownLatch和CyclicBarrier的差别CountDownLatch是一个一次性计数器主线程await等待其他线程执行countDown()计数器减到0时主线程继续。比如并发请求三个外部接口全部返回后汇总。CyclicBarrier是循环屏障N个线程互相等待全部到齐后同时放行然后可以重复使用。比如多线程分批处理数据每个批次结束后等大家同步一次。选型就一句话要是“主线程等待多个任务完成”用CountDownLatch要是“多个线程互相等待到齐再同时执行”用CyclicBarrier。6.3 Semaphore限流Semaphore维护一个许可数量acquire()获取许可release()归还许可。它非常适合做简单限流控制同时访问某个资源的线程数。Semaphore semaphore new Semaphore(3); // 同时最多3个线程 public void accessDB() throws InterruptedException { semaphore.acquire(); try { // 访问数据库 } finally { semaphore.release(); } }这里有个坑acquire()可以被interrupt但是有些场景你不想让中断打断限流逻辑可以用acquireUninterruptibly()。release()必须在finally里执行否则抛异常后许可证不归还很快就把信号量占满了后面的线程全部堵死。7. 实战工具人模型实现生产者消费者生产者消费者模型应该是多线程学习里最经典的实战了我这里给一个基于BlockingQueue的实现比手动用wait/notify简洁得多。class TaskQueue { private final BlockingQueueTask queue new ArrayBlockingQueue(100); public void produce(Task task) { try { queue.put(task); // 队列满时阻塞 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public Task consume() throws InterruptedException { return queue.take(); // 队列空时阻塞 } } // 生产者 class Producer implements Runnable { private final TaskQueue taskQueue; Producer(TaskQueue taskQueue) { this.taskQueue taskQueue; } Override public void run() { for (int i 0; i 1000; i) { taskQueue.produce(new Task(i)); } } } // 消费者 class Consumer implements Runnable { private final TaskQueue taskQueue; Consumer(TaskQueue taskQueue) { this.taskQueue taskQueue; } Override public void run() { while (true) { try { Task task taskQueue.consume(); // 处理任务 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }BlockingQueue的put和take方法自带阻塞语义内部帮你处理了锁和等待唤醒比自己写wait/notify健壮得多。ArrayBlockingQueue是数组结构的有界队列LinkedBlockingQueue是链表结构可设边界也可以无界。生产场景里一定用有界队列防止生产过快把内存打爆。在线上一个数据同步工具里我用的就是多个生产者线程从不同数据源拉取消息放入有界队列消费者线程池从队列取出消息写入下游。整个系统只需要监控队列积压量这一个指标就能判断系统瓶颈在哪非常清晰。8. 死锁、活锁与性能排查实录8.1 死锁产生的四个必要条件写多线程代码死锁是最大的噩梦。死锁的四个条件缺一不可互斥资源只能被一个线程占用。占有且等待线程持有一个资源又在等待另一个资源。不可剥夺已被占用的资源不能被其他线程强行抢走。循环等待多个线程之间形成环形等待关系。典型的死锁场景线程A持有锁1等待锁2线程B持有锁2等待锁1。解决方向其实就围绕这四条破坏互斥可以用读写锁分离破坏占有且等待可以一次性申请所有资源破坏不可剥夺可以用超时释放破坏循环等待就统一锁的获取顺序。8.2 用jstack排查死锁线上排查死锁最快的方法是jstack。线程死锁是瞬间就能用状态判断出来的。先拿到Java进程的PID然后执行jstack PID thread_dump.txt打开文件找Found one Java-level deadlock这一段jstack会直接把死锁的线程、持有的锁、等待的锁、对应的代码行全部列出来。这比看日志猜快十倍。看到BLOCKED状态的线程大量存在且互相等待基本就是死锁没跑。另一个高频场景是线程“假死”——线程池里所有线程都处于WAITING状态队列里堆着任务。这种一般是任务内部在等待一个永远到不了的信号比如调用了future.get()但提交的任务因为异常挂了。排查方法同样是jstack看线程栈找到卡住的调用位置。8.3 数据不一致的坑不只是加锁这么简单很多新人认为线程安全问题就是加synchronized。但其实有一个经常被忽略的点懒加载的单例模式。双重检查锁DCL如果不加volatile存在指令重排序问题——对象引用先被赋值但构造函数还没执行完成其他线程拿到半初始化的对象。class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里的volatile必不可少不是为了可见性而是为了防止“分配内存-调用构造器-赋值引用”这三步被重排序成“分配内存-赋值引用-调用构造器”。这个面试必问题很多人背了答案但不理解为什么。理解了指令重排序才叫真懂。8.4 常见问题速查表问题现象可能原因排查思路CPU 100%但负载不高大量自旋CAS或轻量级锁竞争jstack看线程栈找高频自旋调用请求响应越来越慢线程池队列积压或线程阻塞监控队列大小jstack看BLOCKED状态数据偶尔丢失非原子操作并发写检查是否有i类操作改用原子类或加锁接口超时但不报错future.get()无超时阻塞设置get(timeout)应用启动后不退出线程池非守护线程未关闭用完shutdown()或者main退出前统一关闭8.5 编程习惯与避坑清单这部分是我个人经验的浓缩每一条都是踩过的坑锁的粒度宁细勿粗。能用代码块锁就不用整个方法锁持有锁的时间越短其他线程等待的时间越短并发度越高。但反过来锁拆得太碎又容易出现复杂的跨锁逻辑平衡点是锁内逻辑不超过几行简单操作。能用原子类就不用锁。计数器、序列生成这种高频小操作AtomicLong和LongAdder性能好代码还简单。不要一上来就sychronized。线程池必须命名。给threadFactory设置有意义的线程名前缀比如order-pool-thread-否则jstack看到一堆pool-1-thread-5你压根不知道是哪个业务在跑。这个习惯在排查时能省几个小时。连接资源用完必须关。线程里用数据库连接、HTTP连接一定要try-with-resources或finally关闭。线程池复用时你漏掉的连接很快会把连接池耗尽。不要在线程里直接操作UI组件。这是桌面程序的老问题但思维模式可以迁移到其他场景跨线程操作共享的可变对象前想清楚状态是否安全。Android、Swing等框架里强制要求UI更新在主线程执行本质就是避免竞态。9. 面试高频点速记虽然这篇主要讲实战但有不少同学是在准备面试时搜到多线程文章的我按面试高频程度整理几个必须闭着眼都能答的题线程的创建方式继承Thread、实现Runnable、实现Callable FutureTask以及线程池创建。线程安全的理解原子性、可见性、有序性三个维度缺一个都算不上安全。synchronized与ReentrantLock的区别一个是JVM层面的内置锁自动释放一个是API层面的显式锁手动释放ReentrantLock支持超时、可中断、公平锁、多Condition。sleep与wait的区别释放锁还是不释放一个是Thread静态方法TIMED_WAITING一个是Object实例方法要在synchronized里。线程池参数七个参数什么含义、核心流程四条分支拒绝策略四种。JMM与可见性Java内存模型规定每个线程有自己的工作内存volatile保证变量修改后立刻回写主存。ThreadLocal原理每个线程有自己的ThreadLocalMap变量在线程间隔离但要注意内存泄漏——ThreadLocalMap的key是弱引用value是强引用线程池中长生命周期线程不remove会积累内存。ThreadLocal的坑值得单独说一说。我在一个日志链路跟踪的组件里用了ThreadLocal存traceId结果线上发现内存缓慢增长。原因是线程池复用时前一个任务的traceId没清掉下一个任务又set了新的ThreadLocalMap里的Entry一直堆积。解决办法是在任务结束后finally里调remove()。这个细节很多人直到线上OOM才悔悟。10. 从一个问题开始学透多线程学到这里框架性的知识已经齐了。我给零基础的朋友一个我自己的学习路径先写一个卖票程序多个线程同时卖100张票用synchronized解决超卖问题然后改成用Lock做一遍再改成用AtomicInteger做一遍再改成用BlockingQueue做生产者消费者最后把所有版本并排对比观察同一问题不同方案的区别。这个练习做完你对锁、原子操作、队列协作的理解会比刷十篇教程都牢。因为你会亲眼看到“不加锁会卖超”“加了锁不睡会下降速度”“用队列能解耦”这些结论是怎么来的。多线程的学习本质上就是不断踩并发问题的坑踩得多了看到代码里的隐患就像看到鞋里的沙子一样自然。我个人在这些年Java开发的体会是多线程并不神秘它的一切设计都围绕着“共享数据的正确性”和“资源利用的效率”这两条主线。遇到并发问题先问数据共享得对不对再问锁和线程池配置得合不合理大多数疑难杂症都能从这两条主线找到出口。最后再分享一个小技巧本地写多线程代码时JVM参数加上-XX:PrintGCDetails配合jstack使用能在排查时少走很多弯路。特别是遇到内存和线程交织的问题时GC日志和线程栈两份证据互相印证原因很快就能定位。收藏这篇不等于会多线程把代码敲一遍、把坑踩一遍它才是你的东西。
返回列表