ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java多线程与并发编程实战:线程池、同步机制与死锁排查

Java多线程与并发编程实战:线程池、同步机制与死锁排查 1. 项目概述多线程Java到底在解决什么问题1.1 一个真实场景为什么单线程撑不住先聊个我实际遇到过的案例。之前接手过一个订单处理系统业务逻辑不算复杂接收订单、校验库存、扣减库存、生成通知。单线程版本跑起来一切正常日志清晰、错误好查可一旦促销活动上线请求量翻了几倍接口响应时间直接从80毫秒飙升到3秒数据库连接池被打满最后服务直接不可用。这就是典型的单线程瓶颈。CPU在等待数据库返回、等待网络响应、等待磁盘IO时线程只能干等着大量计算资源被白白浪费。Java多线程的核心价值就是在等待IO的间隙把CPU交给其他任务去用让机器资源真正跑起来。换句话说多线程不是为了炫技而是为了榨干硬件性能提升系统的吞吐量和响应速度。我在面试中经常问候选人一个问题你项目里为什么用多线程很多人张口就是为了提高性能但再追问一句具体怎么提高的线程数怎么定的就答不上来了。这篇文章我想老老实实聊清楚从线程创建、同步机制、并发容器到AQS底层再到实际排查死锁和性能问题的经验尽量把Java多线程这条线串起来。1.2 先理清三个基础概念进程、线程与协程要理解Java多线程得先把三个概念分清楚。进程是操作系统分配资源的基本单位每个进程有独立的内存空间进程之间默认不共享数据。线程是CPU调度的基本单位一个进程里可以包含多个线程同一个进程内的线程共享堆内存和方法区但每个线程有自己的虚拟机栈和程序计数器。协程则是更轻量的用户态调度单元JDK 21正式推出了虚拟线程底层就是类似协程的机制不过生产环境大规模落地还要时间。这里有个关键点很多人容易忽略Java里new Thread()创建的是Java层面的线程对象真正执行任务的是操作系统线程。Java线程和OS线程是1:1映射关系线程创建和销毁都要走系统调用成本很高。这也是为什么我后面会强调必须用线程池而不是每次都new Thread。还有一个经典问题多线程是不是越多越好不是。线程多了CPU要在线程之间切换每次切换都要保存和恢复上下文状态这部分开销叫上下文切换成本。当线程数量超过CPU核心数多出来的线程只能排队等待切换开销反而拖累整体性能。后面我会专门讲线程数怎么定。2. 核心细节解析线程创建方式与生命周期管理2.1 四种创建方式的对比与选型Java里创建线程有四种常见方式继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask、以及通过线程池提交任务。前两种是最基础的但实际项目里几乎不会直接用。继承Thread类的做法是重写run()方法代码长这样public class MyThread extends Thread { Override public void run() { System.out.println(任务执行中 Thread.currentThread().getName()); } } // 使用 new MyThread().start();这种方式的缺点在于Java是单继承一旦继承了Thread这个类就不能再继承其他业务类了。而且任务代码和线程逻辑耦合在一起不够灵活。实现Runnable接口是更好的选择public class MyTask implements Runnable { Override public void run() { System.out.println(任务执行中 Thread.currentThread().getName()); } } // 使用 Thread thread new Thread(new MyTask()); thread.start();Runnable的run()方法没有返回值也不能抛受检异常所以当你需要任务执行结果时就得用Callablepublic class MyCallable implements CallableString { Override public String call() throws Exception { Thread.sleep(2000); return 任务执行完成; } } // 使用 FutureTaskString futureTask new FutureTask(new MyCallable()); Thread thread new Thread(futureTask); thread.start(); String result futureTask.get(); // 阻塞等待结果这里有个细节futureTask.get()是阻塞方法如果任务一直没结束调用get()的线程会一直等下去。实际开发中建议用带超时的版本get(3, TimeUnit.SECONDS)避免任务卡死导致整个调用链超时。我个人在实际项目中的体会是前三种方式基本只适合写Demo或者面试演示生产环境中应该交给线程池统一管理。线程池能复用线程、控制并发数量、管理任务队列这才是多线程工程化的基石。2.2 线程池Executors的坑与ThreadPoolExecutor的正确姿势线程池这个话题几乎每个Java面试都会问到而且网上资料两极分化严重。要么是教你怎么用Executors.newFixedThreadPool()一行代码创建线程池要么是告诉你千万别用Executors。真实情况是什么我需要分开说。先看ThreadPoolExecutor的构造函数这是理解线程池的钥匙public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲线程存活时间 TimeUnit unit, // 时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )执行流程是这样的任务提交后如果当前线程数少于核心线程数创建新线程执行如果核心线程都在忙新任务进入任务队列排队如果队列满了继续创建新线程直到最大线程数如果线程数已经到最大值且队列也满了触发拒绝策略。Executors的几个快捷方法问题在哪newFixedThreadPool和newSingleThreadExecutor用的是无界队列LinkedBlockingQueue默认容量是Integer.MAX_VALUE。这意味着任务可以无限堆积当任务处理速度跟不上提交速度时内存会被任务对象占满最终OOM。newCachedThreadPool用的是SynchronousQueue没有队列缓冲来一个任务就尝试创建新线程最大线程数是Integer.MAX_VALUE如果任务长时间执行线程会被无限创建同样有资源耗尽风险。所以实际项目中我会自己手动创建线程池参数结合业务场景定。比如一个IO密集型的消息推送服务我会这样配ThreadPoolExecutor pushExecutor new ThreadPoolExecutor( 8, // 核心线程数CPU核心数 * 2 左右 32, // 最大线程数留出缓冲余量 60L, TimeUnit.SECONDS, // 空闲线程60秒回收 new ArrayBlockingQueue(2000), // 有界队列防止任务堆积 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略调用者执行 );关于拒绝策略我多说一句。AbortPolicy是默认的队列满了直接抛RejectedExecutionExceptionDiscardPolicy静默丢弃DiscardOldestPolicy丢弃最老的未处理任务CallerRunsPolicy让提交任务的线程自己执行这个任务。生产环境我最推荐CallerRunsPolicy它不会丢任务还能天然形成背压——提交线程被任务拖住自然不会继续疯狂提交。2.3 线程状态与中断机制Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这六种状态在面试中属于必背题但更重要的是理解它们之间的流转路径和触发条件。我画一条典型路径说明线程new出来是NEW状态调用start()后进入RUNNABLE。RUNNABLE其实是等待CPU调度和正在执行的合并状态因为Java层面很难区分这两个子状态。当线程试图进入synchronized同步块但锁被其他线程持有时进入BLOCKED状态当线程调用了Object.wait()或LockSupport.park()时进入WAITING状态带超时的等待sleep、wait(1000)等进入TIMED_WAITING。执行完run()方法后进入TERMINATED。有个坑我得提醒Thread.sleep()不会释放锁Object.wait()会释放锁。很多初学者复用synchronized里的wait逻辑时容易对为什么别人能进同步块产生困惑原因就在这里。sleep只是让线程暂停执行锁还在自己手里。中断机制也是高频考点。interrupt()方法并不是强制终止线程而是设置一个中断标志位。正确的中断响应模式是在任务循环里检查Thread.currentThread().isInterrupted()或者让阻塞方法抛出InterruptedException后恢复中断标志public void run() { while (!Thread.currentThread().isInterrupted()) { try { // 模拟业务处理 Thread.sleep(100); } catch (InterruptedException e) { // 重新设置中断标志让上层逻辑感知 Thread.currentThread().interrupt(); break; } } }我见过好多老项目用thread.stop()强行停线程这个方法早废弃了因为它会直接释放所有锁可能导致数据不一致。用标记位配合interrupt才是标准的优雅停机方式。3. 实操过程与核心环节实现同步、协作与并发容器3.1 synchronized与volatile从底层看锁的本质synchronized是Java最基础的同步机制从JDK 1.6开始经历了锁升级无锁 → 偏向锁 → 轻量级锁 → 重量级锁。JVM会通过-XX:UseBiasedLocking等参数控制不过JDK 15之后偏向锁被废弃了。这些底层细节面试会问但实际开发中我更关注synchronized的适用范围和性能特征。synchronized修饰实例方法锁的是this对象修饰静态方法锁的是Class对象修饰代码块锁的是括号里指定的对象。这里有个经典错误用synchronized实现计数器对象没锁对导致多个实例各锁各的public class Counter { private int count 0; // 错误写法锁的是this如果多个线程持有不同Counter实例锁不生效 public synchronized void increment() { count; } // 正确写法锁同一个对象比如用静态锁对象或保证单例 public void incrementCorrect() { synchronized (Counter.class) { count; } } }volatile和synchronized经常被放在一起比较。volatile有两个能力保证可见性和禁止指令重排序但不保证原子性。也就是说volatile int count 0; count这个操作仍然不安全因为count在字节码层面是读取-修改-写入三步操作中间可能被其他线程打断。那volatile到底有什么用适合的典型场景是状态标志位public class Server { private volatile boolean running true; public void stop() { running false; // 其他线程立即可见 } public void process() { while (running) { // 处理任务 } } }这里如果running不加volatileprocess()线程可能一直读到自己工作内存里的旧值导致循环永远跳不出去。加上volatile后每次读取都强制从主内存拿最新值。还有一个容易踩的坑是双重检查锁Double-Checked Locking单例。早期写法不加volatile创建对象过程有三步分配内存、初始化对象、引用指向内存地址。指令重排序可能导致引用先指向了未初始化的内存另一个线程判断实例非空直接使用就出问题了。所以双重检查锁必须写private static volatile Singleton instance;用volatile禁止重排序。3.2 Lock与Condition手动锁的正确用法从JDK 5开始java.util.concurrent.locks提供了Lock接口最常用的是ReentrantLock。相比synchronizedReentrantLock的优势在于第一支持非阻塞尝试获取锁tryLock()拿不到锁可以做其他事不用死等。第二支持公平锁/非公平锁切换通过构造函数传入true开启公平锁。第三支持超时获取锁tryLock(3, TimeUnit.SECONDS)避免无限等待。第四配合Condition可以实现精准唤醒不像synchronized的wait/notify只能随机唤醒。标准使用模式要注意lock()和unlock()要配套异常时必须释放锁ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }这里必须用try/finally包裹否则临界区代码抛出异常后锁永远不释放直接造成死锁。我在代码审查时见过多次这个错误写的人总是忘记unlock放finally里。Condition实现生产者消费者模型很经典ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() MAX_SIZE) { notFull.await(); // 队列满则等待 } queue.offer(item); notEmpty.signalAll(); // 唤醒消费者 } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空则等待 } queue.poll(); notFull.signalAll(); // 唤醒生产者 } finally { lock.unlock(); }注意await()和wait()一样会释放锁而且必须在循环里判断条件不能用if因为可能有多个消费者被唤醒其中一个消费完了另一个再醒来发现队列又空了需要再次等待。这就是虚假唤醒问题的标准解法。3.3 并发容器ConcurrentHashMap的演进与选型多线程操作Map第一反应应该是ConcurrentHashMap而不是Hashtable或HashMap。Hashtable给每个方法加synchronized锁的是整个表结构并发时所有线程竞争同一把锁性能很差。HashMap完全非线程安全多线程写入可能导致链表成环在JDK 8之前甚至会引起CPU 100%的问题。ConcurrentHashMap的锁粒度设计是亮点。JDK 7版本采用分段锁把整个Map分成16个Segment每个Segment加一把锁多线程访问不同Segment可以并行。JDK 8放弃了分段锁改用CAS加Synchronized锁单个桶的头节点进一步降低了锁竞争。put操作时先计算hash定位到某个桶桶为空就用CAS直接放入桶非空才锁住头节点做链表或红黑树的插入。读操作大多数情况下不加锁依赖volatile修饰的Node数组和Node节点保证可见性。用ConcurrentHashMap时有一个计数问题需要注意size()方法在多线程环境下是近似值JDK 8通过CounterCell数组分散计数mappingCount()比size()更推荐。但如果你需要严格的精确计数应该用LongAdder或以下显示的原子变量组合。还有CopyOnWriteArrayList适合读多写少的场景比如配置监听列表。写操作时复制整个底层数组修改的是副本最后用volatile引用替换指向新数组读操作永远无锁。它的缺点也很明显写成本高每次写都复制全量数组。如果写频繁这个类就是性能灾难。3.4 AQSJava并发基石的核心原理AQSAbstractQueuedSynchronizer是Java并发包的核心抽象类ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock都是基于它实现的。面试问你说说AQS几乎成了Java八股文环节的标配。AQS的核心是一个volatile int state状态字段和一个CLH变体队列。state的定义很灵活在ReentrantLock里表示重入次数0没锁1首次加锁每重入一次加1在Semaphore里表示剩余许可数量在CountDownLatch里表示剩余需要等待的计数。CLH队列本质上是一个FIFO的双向链表每个节点封装一个等待线程。线程获取锁失败时被封装成Node节点加入队列尾部然后通过LockSupport.park()挂起锁释放时唤醒队列头部的下一个等待线程。这里有个细节非公平锁在lock()时先尝试一次CAS抢锁抢不到才进队列所以可能出现后到线程插队成功的情况牺牲公平性换取更高吞吐量。我实际调试过ReentrantLock的加锁过程核心方法是acquire(int arg)public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }先尝试tryAcquire快速获取锁失败则addWaiter入队acquireQueued在队列里自旋或阻塞等待。理解了这个流程再看自定义锁就会豁然开朗把tryAcquire和tryRelease两个模板方法实现好就能控制自己的同步逻辑。4. 常见问题与排查技巧实录4.1 死锁实战一个看似正常却卡死的例子死锁是面试必考也是线上最棘手的问题之一。经典定义是两个或多个线程互相持有对方需要的资源互不相让导致无限期等待。条件是四个互斥、持有并等待、不可剥夺、循环等待。我在真实项目里排查过这样一个死锁。系统里有一个批量处理任务A线程先锁定了订单表记录再更新用户表记录B线程先锁定用户表记录再更新订单表记录。两个线程同时在执行各自持有对方需要的锁业务就悬挂住了。更麻烦的是死锁发生时线程既不报错也不退出接口表现为长时间无响应只能通过堆栈信息定位。排查死锁我推荐用jstack工具jstack -l 12345 thread_dump.txt在生成的dump文件里搜索Found one Java-level deadlock会直接列出死锁的线程ID、持有的锁、等待的锁、以及堆栈调用链。用jstack能看到完整的等待-持有关系一眼锁定元凶。预防死锁的办法有几个我在项目里都在用第一统一锁的顺序。比如所有更新操作都先锁订单表再锁用户表破坏循环等待条件。第二使用带超时的锁获取。用tryLock(5, TimeUnit.SECONDS)拿不到锁就放弃或重试避免无限等待。第三缩小锁的范围。只锁真正需要同步的字段操作不要在锁里执行IO、RPC调用等耗时操作。4.2 数据一致性从业务角度理解原子性、可见性与有序性Java内存模型JMM定义了三个核心特性原子性、可见性、有序性。要保证多线程环境下的数据一致性这三者一个都不能少。原子性指操作不可分割。i不是原子操作它包含读取i、计算i1、写回i三步。要保证原子性可以用synchronized、Lock或者用AtomicInteger这样的原子类。AtomicInteger底层依赖CAS比较并交换compareAndSet通过Unsafe类调用CPU原语实现没有锁的开销。可见性指一个线程修改共享变量后其他线程能立即看到。volatile和synchronized都能保证这一点。底层原理是MESI缓存一致性协议核心是按缓存行Cache Line维度同步数据。有序性指程序执行顺序不能被随意重排。CPU和编译器为了优化性能可能调整指令执行顺序单线程内不影响结果多线程下就可能出错。volatile通过内存屏障禁止重排序synchronized通过锁的互斥保证临界区内的操作对外表现为有序。业务开发中最典型的场景是先检查后执行if (cache.get(key) null) { // 检查 cache.put(key, loadFromDB(key)); // 执行 }这个逻辑在多线程下一定有问题多个线程可能同时发现缓存为空同时去查数据库造成缓存穿透。解决方案是加锁或者使用ConcurrentHashMap的putIfAbsent配合computeIfAbsent后者在JDK 8之后提供了原子性的存在则返回不存在则计算能力。4.3 性能调优线程数怎么定、上下文切换怎么降线程池线程数怎么定网上公式很多但实际项目里要分两类场景。CPU密集型任务线程数建议设为CPU核心数 1。加1是为了弥补偶尔的线程暂停导致的无效调度。IO密集型任务线程数建议设为CPU核心数 * (1 IO等待时间 / CPU计算时间)或者简单点用CPU核心数 * 2起步再根据压测结果调整。我在一个消息推送项目里踩过线程数设置不当的坑。起初配了200个线程处理HTTP请求QPS上不去不说GC时间飙高线程频繁切换导致CPU使用率看着很高但吞吐量反而下降。后来压测发现这个场景实际并发瓶颈在IO等待把线程数降到64增加任务队列长度QPS提升了近40%。这让我深刻体会到线程数不是越多越好上下文切换开销和内存占用都要算进去。减少上下文切换的手段包括使用无锁数据结构如ConcurrentLinkedQueue、减少锁竞争缩小同步块范围、合理设置线程池大小、避免在热点路径上做耗时操作。此外通过ThreadMXBean可以监控线程的CPU时间和阻塞时间压测时盯住这两个指标比闷头调参靠谱得多。4.4 面试高频题Kafka消费端多线程如何保证消息顺序既然热搜词里出现了Kafka消费端多线程保证消息顺序性这个话题我就专门拆开讲。Kafka保证顺序的前提是同一分区Partition内的消息有序不同分区之间不保证顺序。所以问题的核心是多线程消费同一分区时如何不破坏这个顺序。最朴素的方案是单线程消费不引入多线程顺序天然保证。但很多业务为了提升吞吐量确实需要多线程消费。这时候常用的思路有几种方案一按分区分配线程。每个分区绑定一个单线程的消费处理器分区之间并行。这样同一分区内依然只有一个线程处理消息顺序不破坏吞吐量随分区数线性提升。方案二多线程拉取加顺序提交。主线程负责从Kafka拉取一批消息按分区维度把消息派发给对应的处理线程但提交Offset时必须等所有分区的若干条消息都处理完用批内有序、批间顺序提交来保证恢复时不丢不乱。方案三自建队列加分片锁。用一个有序队列承接消息多个消费者的take线程并发取出任务但同一分区或同一业务键只允许一个消费者线程处理可以通过ConcurrentHashMapString, Semaphore做细粒度限制。实际生产中我见过一个订单回执系统用方案二实现。主线程每批拉取100条消息按orderId哈希分发到8个处理线程处理线程各自完成后把结果写入一个ConcurrentHashMap主线程等到100条全部完成再统一提交Offset。吞吐量提升明显而且宕机恢复也验证过最多重复消费一批消息不会丢消息。这里有个通用原则要记住多线程本身不破坏数据一致性破坏一致性的是对共享可变状态的无序访问。Kafka消息顺序问题本质上是业务有序性外加并发处理所以要针对分区维度做有序约束而不是笼统地并发处理。4.5 常见问题速查表我整理的一份避坑清单把这几年的经验汇总成一个速查表方便大家对照排查问题现象可能原因排查方向接口偶尔返回错误数据共享变量无同步存在竞态条件检查是否有可变字段被多线程读写考虑加volatile或加锁程序卡死不报错死锁或线程池队列满后被丢弃用jstack抓线程栈检查锁的持有与等待关系内存溢出无界队列堆积任务或线程创建过多检查Executors创建的无界队列改用有界队列数据重复处理未正确处理消息确认/Offset提交检查消费框架的提交时机和失败重试机制CPU飙升自旋锁、CAS循环重试或大量线程忙等用jmap查看线程状态top -H找CPU高的线程主线程提前退出线程池未关闭或shutdown未等待任务完成合理使用shutdown和awaitTermination这些坑我在项目里基本都踩过一遍所以才深知理论落地的差距。比如线程池未关闭导致主线程提前退出看起来是小问题但线上定时任务场景里任务没执行完进程就结束了数据对不上人天排查精力就耗在这上面。5. 实操心得我给新人的三条建议多线程学习有个特点理论看懂了代码会写了但真正的问题总是出现在你没预料到的地方。我自己的体会是先要把synchronized、volatile、ReentrantLock、ConcurrentHashMap这些基础工具用熟理解它们的适用边界再深入AQS源码理解为什么这么设计最后通过线上问题反推原理这个学习路径最扎实。实践层面我建议新人在自己的项目里主动做一次多线程改造。找一个当前是单线程处理的任务先测量处理耗时和吞吐率再用线程池并行化控制变量地对比优化效果。这样积累下来的数据感知能力比背一百道面试题都管用。我在带团队时也一直强调多线程的Bug往往不是第一个出现的也不是日志里能直接看到的它可能潜伏很久在特定并发量下才触发。所以从一开始就要把锁的范围、线程池参数、任务队列的边界写清楚做好监控和日志才能真的把Java多线程用好。最后分享一个小技巧排查多线程问题时先把问题复现出来再逐步缩小范围。比如怀疑是某个共享Map导致的可以在关键读写位置加探针日志打印时间戳、线程名和操作结果配合压测复现。多线程问题最忌讳凭感觉改代码一次只改一个变量改完回归验证这个习惯能帮你省下大量调试时间。
返回列表