资讯动态

Java多线程从零到实战:线程池、锁与并发问题排查

发布时间:2026/10/6 9:27:20 来源:尧图企业网站定制
前两天我帮同事排查一个线上问题一张库存表同一个商品两个线程同时走到库存判断结果把最后一件商品卖了两次。他特别委屈说自己明明加了synchronized怎么还超卖我打开代码一看方法写在多台实例部署的Service上加锁锁的是各自进程内的对象。这就是典型的“以为会多线程其实不会”的场景。Java多线程从来不是“会new Thread就行”它涉及可见性、原子性、线程协作、线程池、锁的粒度和性能问题。这篇从零基础开始讲覆盖创建线程、生命周期、同步通信、线程池、JUC工具到电商扣库存实战最后再说说排查和调优适合刚学Java的人也适合准备Java面试的人希望能帮你在踩坑之前先把原理立住。1. 先把抽象概念落地进程、线程、并发和并行很多人一开始学多线程就被概念劝退什么“进程是资源分配的最小单位线程是CPU调度的最小单位”背下来跟没背一样。我换个说法把程序比作一家餐厅进程是这家餐厅本身包括厨房、桌子、菜单、仓库这些资源线程就是餐厅里干活的服务员和厨师他们在同一个餐厅里共享资源又各自执行任务。一个进程挂了餐厅倒闭但一个线程出问题可能只是某桌客人的菜送错了不影响整个餐厅营业。1.1 并发和并行单核也能并发并行需要多颗心脏这两个词在面试里几乎必考。并发的关键是“交替执行”只有一个CPU核心时多个线程轮流占用CPU每个线程跑一小段时间再切换宏观上像同时运行微观上是串行的。并行的关键是“同时执行”多个CPU核心真正同时处理不同线程。写代码时我们目标是让程序正确并发至于到底有没有真正并行取决于运行机器的核数和操作系统的调度。举个生活例子一个人一边写代码一边回消息那叫并发因为他在时间片上做切换两个人一个写代码一个回消息那叫并行。Java多线程程序即使跑在单核机器上也能表现出并发能力比如IO操作等待时可以切换到另一个线程继续计算总吞吐量反而可能更高。1.2 多线程不是银弹什么场景真正值得用这句话值得写进笔记多线程的价值不是“让代码跑得更快”而是“让多个任务重叠执行减少等待时间”。如果一个任务是纯CPU密集比如复杂数学计算线程数超过CPU核数后多线程反而会因为上下文切换变慢。如果是IO密集比如网络请求、数据库读写、文件操作线程在等待IO时让出CPU另一个线程趁机执行吞吐量能提升很多。所以开篇就记住一个判断标准任务里有没有大量阻塞等待有适合多线程没有规划好线程数后再用。真正的高并发系统核心不是开很多线程而是让每个线程干更少的活、等待更短的时间。2. 创建线程的四种方式别再只会new Thread了从零基础到入门的第一个实操点是搞明白Java里到底有哪些姿势能创建线程以及它们之间的区别。不要只会new Thread(new Runnable(){...})面试官一问就露馅。2.1 继承Thread类最直观但最不推荐第一种方式写出来长这样public class MyThread extends Thread { Override public void run() { System.out.println(线程运行中 Thread.currentThread().getName()); } public static void main(String[] args) { MyThread thread new MyThread(); thread.start(); } }继承方式最大的问题是Java是单继承你继承了Thread就不能继承其他业务基类而且把任务代码和线程控制代码耦合在一起。正常的业务类是不应该主动继承Thread的除非你写一个非常简单的演示demo。注意start()和run()的区别直接调run()只是当前线程执行一个普通方法不会开启新线程只有start()才会让JVM创建真正的线程去执行run()。2.2 实现Runnable接口推荐的基础写法public class Task implements Runnable { Override public void run() { System.out.println(任务执行中 Thread.currentThread().getName()); } } // 使用 Thread thread new Thread(new Task()); thread.start();这种方式把“任务逻辑”和“线程控制”分开了业务类实现Runnable还能再继承别的类。如果你只需要临时跑一个任务也可以用Lambda简化Thread thread new Thread(() - System.out.println(lambda任务)); thread.start();Runnable的局限是run()方法没有返回值也无法抛出受检异常。如果任务执行完你需要知道结果就得用下一种。2.3 Callable和Future让线程把结果带回来从Java 5开始提供了CallableV接口它的call()方法可以返回结果也可以抛出异常。但它不能直接传给Thread必须借助FutureTaskimport java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class CallableDemo { public static void main(String[] args) throws Exception { CallableInteger task () - { Thread.sleep(1000); return 1 1; }; FutureTaskInteger futureTask new FutureTask(task); Thread thread new Thread(futureTask); thread.start(); // 阻塞等待结果 Integer result futureTask.get(); System.out.println(计算结果 result); } }futureTask.get()会阻塞当前线程直到子线程执行完毕拿到返回结果。FutureTask本身也是一个Runnable所以可以传给Thread也可以提交给线程池。这种方式适合需要异步计算并汇总结果的场景。2.4 优先级总结真正常用的是线程池为了让你一眼看明白我整理了一张对比表创建方式可返回值是否推荐适用场景继承Thread否不推荐简单demo实现Runnable否常用无返回结果的异步任务Callable FutureTask是常用需要结果或异常处理线程池是/否强烈推荐绝大多数生产环境实际项目里直接new Thread()的场景非常少因为线程的创建和销毁是有成本的而且线程数不受控容易把资源耗尽。线程池我会在后面专门讲先记住一个结论创建线程的终极形态是交给线程池统一管理。但为了真正理解线程池的运作你依然需要知道前三种底层原理。3. 线程生命周期与状态切换面试高频代码验证才算真懂JVM中线程有六种状态很多人背得滚瓜烂熟但一让写代码演示就懵。这里我结合Thread类源码注释和实际输出带你完整走一遍状态机。3.1 六种状态及流转路径先看官方定义的状态枚举java.lang.Thread.StateNEW线程创建了但还没调用start()RUNNABLE调用start()后的状态表示就绪或正在运行。注意Java里把“可运行”和“运行中”合在一起底层由操作系统调度BLOCKED等待monitor锁比如进入synchronized方法或代码块但锁被别人持有WAITING无限期等待比如wait()、join()、LockSupport.park()TIMED_WAITING有明确等待时间比如sleep(1000)、wait(3000)、join(2000)TERMINATED线程执行完或异常退出状态流转不是随便跳的核心路径是NEW - RUNNABLE - 各种等待 - RUNNABLE - TERMINATED。其中等待状态必须被唤醒或超时后才能回到RUNNABLE而不是直接回到运行状态。3.2 sleep、yield、join、interrupt在状态机里各是什么角色Thread.sleep(ms)让当前线程进入TIMED_WAITING不释放锁。所以如果你在synchronized代码块里调sleep其他线程拿不到锁会一直等着。Thread.yield()谦让一下提示调度器“我暂时可以让出CPU”。线程仍然保持RUNNABLE只是从运行状态过渡到就绪状态让同优先级的线程有机会执行。注意仅是提示不保证一定生效。join()当前线程阻塞等待目标线程执行完。比如线程A里调用b.join()A会进入WAITING状态直到B结束。这个常用于主线程等待子任务完成。interrupt()不是强制终止线程而是设置线程的中断标志位。如果目标线程正在sleep()、wait()、join()会抛出InterruptedException并且清除中断标志。这也是为什么很多代码里捕获异常后要再调用Thread.currentThread().interrupt()恢复标志位。3.3 用一段代码验证状态切换写一个简单的验证程序把关键状态打印出来public class StateDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { try { System.out.println(子线程状态 Thread.currentThread().getState()); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println(1. 刚创建state t.getState()); // NEW t.start(); System.out.println(2. 调用start后state t.getState()); // RUNNABLE Thread.sleep(200); System.out.println(3. sleep期间state t.getState()); // TIMED_WAITING t.join(); System.out.println(4. 子线程结束state t.getState()); // TERMINATED } }注意一个细节t.getState()拿到的是那个时刻的状态主线程执行打印时子线程可能已经变了所以结果不一定严格按顺序但不影响理解状态机。这也是面试里经常设置的小陷阱。4. 线程安全为什么让人头秃可见性、原子性、有序性线程安全这个坑不是看到结果不对才叫问题它分三种根因可见性、原子性、有序性。很多人加锁只是“加了个心理安慰”不知道锁到底解决了什么。4.1 一个经典的子线程停不下来的例子先看一段“预期一秒后停掉实际永远跑不完”的代码public class VisibilityDemo { private static boolean flag true; public static void main(String[] args) throws Exception { Thread worker new Thread(() - { while (flag) { // 空转 } System.out.println(子线程退出); }); worker.start(); Thread.sleep(1000); flag false; System.out.println(主线程已把flag设为false); } }如果跑起来发现子线程永远不退出大概率是可见性问题主线程修改的flag对子线程不可见。为什么CPU缓存和寄存器优化导致子线程一直读自己本地缓存里的旧值。加volatile修饰flag可以解决private static volatile boolean flag true;volatile强制线程每次读取都从主内存读并保证写操作对别的线程立即可见。它解决的是可见性和一定程度的有序性但解决不了原子性。4.2 volatile解决不了一切为什么i还是错下面这个例子只能说明volatile不够用public class AtomicityDemo { private static volatile int count 0; public static void main(String[] args) throws Exception { for (int i 0; i 10; i) { new Thread(() - { for (int j 0; j 10000; j) { count; } }).start(); } Thread.sleep(3000); System.out.println(count count); // 很可能小于100000 } }count不是一条CPU指令而是“读-改-写”三步。即使count是volatile线程A和线程B同时读到同一个旧值各自加一后写回就丢失了一次更新。要保证原子性可以用synchronized、Lock或者AtomicInteger。4.3 synchronized的三种用法锁的到底是谁synchronized可以加在实例方法、静态方法、代码块上但锁的对象完全不同修饰实例方法锁的是当前实例this同一个对象的不同线程互斥不同实例之间不互斥。修饰静态方法锁的是当前类的Class对象所有实例共享这一把锁。修饰代码块可以自己指定锁对象比如lockObject。public class SyncDemo { public synchronized void methodA() { // 锁当前实例 // ... } public static synchronized void methodB() { // 锁Class对象 // ... } public void methodC() { synchronized (this) { // 锁当前实例 // ... } synchronized (SyncDemo.class) { // 锁Class对象 // ... } } }实际开发中建议用代码块缩小锁范围别把一个耗时很长的循环都锁住。锁的范围越大并发越低。4.4 可重入与死锁同一个线程能再次拿到同一把锁吗可重入性是指同一个线程如果已经持有一把锁再次进入会被该锁保护的代码块时不需要重新等待。synchronized和ReentrantLock都是可重入锁这能避免自己锁自己的时候死锁。死锁的经典场景线程A持有锁1等待锁2线程B持有锁2等待锁1。于是两个线程永远等下去。写代码时尽量按固定顺序获取多把锁并且使用带超时的锁获取方法比如lock.tryLock(3, TimeUnit.SECONDS)拿不到就放弃避免死等。4.5 Lock接口与synchronized怎么选从Java 5开始java.util.concurrent.locks.Lock提供了更灵活的锁。最常用的是ReentrantLockLock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }优势是支持中断获取锁、超时获取锁、多条件变量、公平锁。缺点是必须手动释放锁遗忘unlock()会造成严重问题所以finally里释放是铁律。日常简单同步优先用synchronized写法更简洁、不容易错需要更精细控制时才上Lock。5. 线程间通信生产者消费者模型的三种写法线程之间除了竞争锁还要互相协作。最常见的一道面试题就是手写生产者消费者模型。这里给你三种实现从手动到省心一步步看演进。5.1 用wait和notify手动实现理解JVM底层协作synchronized配合wait()和notifyAll()是最底层的线程通信方式。核心逻辑public class ProducerConsumerV1 { private final LinkedListInteger queue new LinkedList(); private final int capacity 10; public synchronized void produce(int value) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满生产线程等待 } queue.add(value); System.out.println(生产 value); notifyAll(); // 唤醒等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空消费线程等待 } int value queue.removeFirst(); System.out.println(消费 value); notifyAll(); return value; } }这里两个重点判断条件一定用while而不是if因为线程被唤醒后条件可能又被其他线程改变wait()会释放锁但被唤醒后必须重新获取锁才能继续执行。notifyAll()唤醒所有等待线程比notify()只唤醒一个更安全避免信号丢失导致某些线程永久等待。5.2 用Condition替代wait/notify语义更清晰ReentrantLock可以创建多个Condition比如notFull和notEmpty分别负责生产者和消费者的等待与唤醒。这样比单一wait/notifyAll要精细得多public class ProducerConsumerV2 { private final LinkedListInteger queue new LinkedList(); private final int capacity 10; private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public void produce(int value) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); } queue.add(value); notEmpty.signal(); } finally { lock.unlock(); } } public int consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value queue.removeFirst(); notFull.signal(); return value; } finally { lock.unlock(); } } }await()和signal()对应wait()和notify()。多个Condition的好处是消费者消费完只需要唤醒生产者队列不会把所有线程都拉起来减少无谓的锁竞争。5.3 用BlockingQueue直接封好所有逻辑实际项目里没人会傻傻手写等待逻辑java.util.concurrent.BlockingQueue已经把生产者消费者模型封装得非常好。最常用的是ArrayBlockingQueue和LinkedBlockingQueue。public class ProducerConsumerV3 { private final BlockingQueueInteger queue new ArrayBlockingQueue(10); public void produce(int value) throws InterruptedException { queue.put(value); // 队列满时阻塞 } public int consume() throws InterruptedException { return queue.take(); // 队列空时阻塞 } }put()和take()自动处理阻塞和唤醒。代码量直接从几十行降到几行。所以你明白为什么要学JUC了吗底层原理明白能解决疑难问题但日常工作直接用封装好的工具才是高效开发。5.4 三种方式怎么选手写wait/notify是校招面试和底层原理考察点必须掌握Condition适合需要精细控制多类等待队列的场景BlockingQueue是生产环境首选。如果你要处理实时流式数据、需要容量限制并且要求线程安全直接考虑ArrayBlockingQueue。6. 线程池把线程管理交给专业的人线程池是Java多线程里最值得花时间学的一块。面试必考、工作中必用。很多人只会ExecutorService executor Executors.newFixedThreadPool(10)但到深问就崩。这里我建议直接看ThreadPoolExecutor。6.1 ThreadPoolExecutor七大参数逐个拆解构造方法参数如下public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )执行逻辑可以概括成三句话当前线程数 核心线程数来一个任务创建一个新线程线程数达到核心线程数新任务先塞进任务队列队列满了再继续创建非核心线程直到maximumPoolSize线程数也满了触发拒绝策略。这段流程是面试手撕的高频题目建议配合一个图表记忆新任务 - 核心线程 - 队列 - 非核心线程 - 拒绝策略。不要把核心线程数当成“必然先跑起来再排队”很多人默认以为线程池会先把所有核心线程创建好其实只有任务来了才创建。6.2 四个自带线程池为什么不能直接用Executors提供了四种快捷线程池但生产环境不建议直接使用类型问题FixedThreadPool队列无界LinkedBlockingQueue默认Integer.MAX_VALUE任务堆积会OOMCachedThreadPool最大线程数为Integer.MAX_VALUE会创建极多线程导致OOMScheduledThreadPool同上队列无界SingleThreadExecutor同样是无界队列阿里开发规范里明确建议不要用Executors默认工厂创建线程池本质原因是大多数默认配置用无界队列或超大线程数会在极端流量下把系统拖垮。自定义ThreadPoolExecutor并明确参数是更稳的做法。6.3 核心线程数和最大线程数怎么算没有绝对公式但有一个合理判断方式CPU密集任务核心线程数 CPU核数或CPU核数 1尽量减少上下文切换。IO密集任务核心线程数 CPU核数 * (1 平均等待时间 / 平均计算时间)或者粗略取CPU核数 * 2。这块需要结合压测和实际监控动态调整。不要拍脑袋写个50就上线先压测再观察线程池活跃线程数和队列积压情况再调参。6.4 四种拒绝策略怎么选线程池默认的拒绝策略是AbortPolicy直接抛RejectedExecutionException。还有CallerRunsPolicy提交任务的线程自己执行被拒绝的任务相当于给调用方降速。DiscardPolicy静默丢弃新任务。DiscardOldestPolicy丢弃队列头上最老的任务然后重试提交新任务。实际业务里CallerRunsPolicy往往更安全因为它不会丢任务只是让生产者线程自己干活起到天然限流作用。如果业务允许丢消息才考虑DiscardPolicy。6.5 优雅关闭线程池关闭线程池不能直接shutdownNow()了事。标准做法executor.shutdown(); // 不再接收新任务等待已提交任务执行完 try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); // 超时则强制中断 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 记录日志必要时额外处理 } } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }shutdown()和shutdownNow()区别很大前者优雅关闭后者立即尝试中断所有正在执行的任务。生产环境一般先优雅关闭再兜底强制关闭避免任务丢失。7. JUC并发工具多线程协作不再是裸奔除了线程池java.util.concurrent包还提供了很多工具类面试常考日常也非常有用。挑几个重点讲一讲。7.1 CountDownLatch主线程等所有子线程干完活CountDownLatch用于让一个线程等待其他线程完成一组操作很像“发令枪倒计时”。典型用法CountDownLatch latch new CountDownLatch(5); for (int i 0; i 5; i) { new Thread(() - { System.out.println(子任务完成); latch.countDown(); }).start(); } latch.await(); // 主线程等待计数归零 System.out.println(所有子任务完成继续执行);await()会让当前线程进入WAITING直到计数器减到0。注意CountDownLatch的计数器不能重置是一次性的。适合拆分数个并行任务再汇总的场景。7.2 CyclicBarrier一组线程互相等待凑齐一起冲和CountDownLatch容易混淆的是CyclicBarrier它的场景是一组线程在执行到某个点后互相等待直到所有线程都到达再一起继续。CyclicBarrier barrier new CyclicBarrier(3, () - System.out.println(屏障解除一起执行)); for (int i 0; i 3; i) { new Thread(() - { System.out.println(线程到达屏障); try { barrier.await(); } catch (Exception e) { e.printStackTrace(); } System.out.println(线程继续执行); }).start(); }CyclicBarrier可以重复使用适合“分批次并行处理”的场景比如分段收集数据后统一汇总。区别一句话CountDownLatch是多个线程等主线程或主线程等多个线程CyclicBarrier是多个线程互相等。7.3 Semaphore信号量做限流Semaphore控制同时访问某资源的线程数量。相当于停车场的车位车位满了后来的车必须等。Semaphore semaphore new Semaphore(3); for (int i 0; i 10; i) { new Thread(() - { try { semaphore.acquire(); System.out.println(线程获取许可开始工作); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { semaphore.release(); } }).start(); }acquire()获取一个许可没有则阻塞release()释放许可。信号量可以做接口限流、连接池控制但它管理的是“同时执行的线程数”不是“接口调用次数”这个要分清楚。7.4 ConcurrentHashMap为什么取代了HashtableHashtable是给整个表加锁所有操作串行化并发量一高就卡死。ConcurrentHashMap在Java 8以后使用CAS加Synchronized锁桶只有哈希冲突落到同一个桶时才互斥读操作几乎无锁并发性能好很多。如果你维护的是高频读写的缓存优先选ConcurrentHashMap。7.5 ThreadLocal的便利和坑ThreadLocal是每个线程独享一份变量的机制常用于存储线程上下文比如用户身份、TraceId。但它有经典的“内存泄漏”问题如果线程池里的线程长期存活而你没有调用remove()ThreadLocalMap里的Value可能一直强引用无法回收。正确习惯是使用完在finally里调remove()。private static final ThreadLocalString CONTEXT new ThreadLocal(); public void process() { try { CONTEXT.set(业务标识); // 业务逻辑 } finally { CONTEXT.remove(); // 必须清理 } }使用ThreadLocal时尤其是配合线程池复用的场景不清理就是给别人埋雷。千万不要把ThreadLocal当成普普通通的Map随便用。8. 实战案例一个订单多线程扣库存的完整写法前面的知识单独看都很散现在把它们串成一个能落地的电商场景。假设你用Spring Boot MyBatis商品表里有一个stock字段现在有并发请求来扣库存怎么保证不多扣、不超卖8.1 常规错误写法有哪些很多人第一版长这样public void deductStock(int productId, int quantity) { Product product productMapper.selectById(productId); if (product.getStock() quantity) { product.setStock(product.getStock() - quantity); productMapper.updateById(product); } }这个代码在两个线程同时读到stock10时都会判断库存够然后都减到9再写回最终库存变成9等于超卖了一件。加synchronized到方法上只能解决单实例内串行但在分布式多实例部署下没用而且锁范围太大性能很差。8.2 单机版正确写法乐观锁和悲观锁对比如果项目是单机部署可以直接用数据库行锁或乐观锁。最简单的写法是使用乐观锁int rows productMapper.updateStockByVersion(productId, quantity, version); if (rows 0) { throw new RuntimeException(库存更新失败请重试); }对应的SQL是UPDATE product SET stock stock - #{quantity}, version version 1 WHERE id #{productId} AND version #{version}利用更新影响行数判断是否成功。这种方式并发高时会有不少重试但实现简单能防止超卖。如果要保证实时扣减且并发量极大通常用Redis Lua脚本、数据库乐观锁、分布式锁等组合方案。但这些属于高并发进阶先掌握单机正确写法更重要。8.3 用线程池模拟并发扣减为了验证锁是否生效写一个用线程池模拟并发扣减的测试public void simulateConcurrentDeduct() { int threadNum 50; ExecutorService executor new ThreadPoolExecutor( 16, 32, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() ); CountDownLatch start new CountDownLatch(1); CountDownLatch end new CountDownLatch(threadNum); for (int i 0; i threadNum; i) { executor.execute(() - { try { start.await(); deductStockWithLock(1, 1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { end.countDown(); } }); } start.countDown(); try { end.await(30, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } executor.shutdown(); System.out.println(并发扣减结束); }这里用到了CountDownLatch同时放行所有线程模拟突发流量用线程池管理线程而不是new Thread。如果最终库存是初始库存 - 成功扣减次数说明逻辑正确。8.4 实现一个线程安全扣减方法配合乐观锁把扣减方法改造成这样Transactional(rollbackFor Exception.class) public boolean deductStockWithLock(int productId, int quantity) { Product product productMapper.selectById(productId); if (product null || product.getStock() quantity) { return false; } int rows productMapper.updateStockByVersion(productId, quantity, product.getVersion()); if (rows 0) { // 版本冲突自旋重试 for (int i 0; i 3; i) { Product latest productMapper.selectById(productId); if (latest.getStock() quantity) { return false; } rows productMapper.updateStockByVersion(productId, quantity, latest.getVersion()); if (rows 0) { return true; } } throw new RuntimeException(系统繁忙请稍后重试); } return true; }这里面没有直接synchronized但数据库行锁和版本号保证了原子性。用乐观锁时要注意update本身是原子的select和update分离必须在update时带上版本条件否则就回到最初的错误。8.5 总结一套通用并发代码检查清单写任何并发扣减、库存更新、余额扣减时我建议按这个顺序自查有没有多个线程同时读旧值再覆盖写如果有就要考虑原子更新。锁的范围是不是最小能不能减少锁持有时间用的是单实例锁还是分布式锁部署多台机器时本地锁无效。失败后是抛异常还是重试重试有没有上限有没有用线程池和CountDownLatch验证并发场景这套清单能帮你避免80%的并发Bug。9. 多线程性能调优和定位问题别让线程拖垮系统多线程并发写完了还要会排查。线上出问题最怕的不是报错而是线程“卡死”但看起来一切正常。9.1 上下文切换为什么比想象中贵线程调度时操作系统要保存当前线程的寄存器、程序计数器、栈信息再加载另一个线程的上下文。一次切换成本在微秒级但高频切换会带来显著开销而且会污染CPU缓存。所以线程数不宜盲目增加锁越密集竞争越大上下文切换越频繁。9.2 减小锁粒度的几种常用手段读多写少场景用ReadWriteLock读锁可以并行写锁互斥。大对象锁拆成小对象锁比如分段锁思想像ConcurrentHashMap的桶锁。用LongAdder替代AtomicLong在高并发累加场景减少CAS竞争。尽量缩短临界区把耗时操作移到锁外面执行。9.3 快速定位死锁jstack是救命工具先写一个必然死锁的demo然后通过jstack查看线程转储jstack pid截图中会明确提示Found one Java-level deadlock: Thread-1: waiting to lock monitor 0x000000001... Blocked on lock: 0x000000076ad5c1a8同时还会列出每个线程当前持有哪些锁、等待哪些锁。看到deadlock关键字基本就能锁定是哪两个锁交互。记住一定要看线程状态大量BLOCKED或者WAITING的线程需要警惕。定位到后从代码层面调整锁顺序或使用带超时的tryLock()。9.4 线程池监控参数不能定完就不管线程池运行状态建议定期打印至少包括活跃线程数、核心线程数、最大线程数、队列大小、完成任务数、拒绝任务数。Spring Boot项目里可以写一个定时任务把ThreadPoolExecutor的指标输出到日志或监控平台。很多线上事故都是线程池队列悄悄积压等到OOM才发现所以要提前观测。我个人在实际项目里踩坑最多的就是把核心线程数拍脑袋定成固定值结果业务流量一波动队列积压、接口超时全部跟着来。后来改成动态监控、结合压测调参才稳定下来。多线程这门技术原理看得懂是第一步能写出正确并发代码是第二步能定位性能和死锁问题是第三步。这篇文章尽可能把三步都覆盖了你可以先收藏再照着代码敲一遍敲完发现问题欢迎留言或者私信和我讨论。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑