ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java并发编程:线程池参数、阻塞队列与死锁排查实战

Java并发编程:线程池参数、阻塞队列与死锁排查实战 上周排查一个线上问题定时任务线程池在高峰期把内存顶到报警线jstack 一看全部线程都堆在同一个同步块前面排队后面还挂着几千个等待执行的任务。查到最后发现根因根本不在锁而在创建线程池时图省事用了无界队列任务只进不出老年代越涨越高差点触发java: outofmemoryerror: insufficient memory。这种问题几乎每个写 Java 的人都会遇到只是早晚而已。这篇笔记我按实际工作中最常涉及的方向整理线程与进程的边界、线程调度策略、线程安全的三个根源、线程池参数与阻塞队列选择、submit/execute 的区别、线程协作与死锁排查以及守护线程的坑。适合刚接触并发、准备 Java 面试或者写了好几年业务代码但没系统梳理过线程机制的读者。内容以 HotSpot 上的主流 JVM 行为为准代码基于 JDK 8 到 21 之间的常见写法个别地方会补充新特性。1. 线程与进程先把并发的地基打牢很多人一上来就背线程池参数结果连线程池里的线程和执行的任务之间的基本关系都没想清楚。我的建议是先花二十分钟把线程与进程、线程状态、调度方式这三件事过一遍后面所有问题都会顺很多。1.1 进程是资源容器线程是执行单元进程是操作系统分配资源的基本单位线程是 CPU 调度执行的基本单位。一个 Java 进程启动后除了执行 main 方法的 main 线程JVM 内部还有 GC 线程、JIT 编译线程、信号处理线程等。同一个进程内的线程共享堆和方法区JDK 8 之后是元空间各自独享虚拟机栈和程序计数器。这句话落到实际排查里最有用的一个推论是线程的私有栈是有内存代价的。HotSpot 在 64 位 Linux 上默认的线程栈大小是 1MB 左右也就是说创建一万个线程光栈就占约 10GB 的虚拟内存还不算线程调度本身的开销。所以当你看到应用没爆堆却起不了新线程报错通常是java.lang.OutOfMemoryError: unable to create native thread这跟堆内存 OOM 是两码事。线上用new Thread()裸起线程起多了就等着挨这一刀这也是后面讲线程池的一个重要理由。1.2 线程调度策略Java 里设优先级到底有没有用线程调度这件事应用层能干预的其实非常少。主流操作系统用的是抢占式调度操作系统内核按时间片把 CPU 分配给线程Java 线程在 HotSpot 上基本是 1:1 映射到操作系统原生线程的。你可能会在资料里看到异类线程调度策略异类短运行线程调度策略这类说法它们其实是操作系统调度层面的研究术语说的是在多核异构平台上比如大小核架构让调度器把线程分配到更合适的核心上。Java 应用层无法直接指定线程跑在哪个核taskset这类命令也只能做到进程级 CPU 亲和没法精确到单个 Java 线程。所以实际开发里不用纠结这个概念知道Java 线程最终由操作系统调度应用层控制力很弱就够了。Thread.setPriority(int)在 Java 里的作用也仅仅是提示。JDK 文档明确写了优先级是平台相关的HotSpot 在 Linux 上把它映射到 nice 值而且不同调度器对 nice 的响应程度不一样。不要指望把某个线程优先级调到 10它就能跑得比别的线程快。Thread.yield()同理它只是建议调度器让出 CPU甚至可以被直接忽略。依赖优先级和 yield 做业务逻辑属于给自己埋雷。1.3 线程状态机看到 BLOCKED/WAITING 别慌线程状态是排查问题必须背下来的东西尤其看 jstack 的时候满屏WAITING和BLOCKED分不清的话很容易误判。Java 线程一共六种状态状态说明常见触发方式NEW已创建但未 start调用了new Thread()RUNNABLE可运行包含就绪和运行中正常执行任务BLOCKED阻塞等待监视器锁等着进synchronized代码块WAITING无限期等待Object.wait()、Thread.join()、LockSupport.park()TIMED_WAITING带超时等待Thread.sleep()、wait(timeout)、join(timeout)TERMINATED已结束run 方法返回或抛出异常看 jstack 时记住BLOCKED 大概率是锁竞争WAITING 大概率是有人在等条件或等线程结束TIMED_WAITING 一般是 sleep 或带超时的等待。这三个状态出现很正常真正要查的是谁持有了锁没释放“谁一直没被唤醒”而不是看到等待状态就慌。顺便说一句获取当前线程名。默认情况下线程叫 Thread-0、Thread-1线程池里的线程叫 pool-1-thread-1这种名字在日志里几乎没有排查价值。我习惯在创建线程池时传入自定义 ThreadFactory给线程起业务相关的名字例如order-query-1、report-worker-2。这样日志里一打Thread.currentThread().getName()立刻知道是哪条链路的哪个线程在跑。Thread t new Thread(() - { try { Thread.sleep(1000); } catch (InterruptedException ignored) { } }, worker-1); System.out.println(t.getName() state: t.getState()); // NEW t.start(); Thread.sleep(200); System.out.println(t.getName() state: t.getState()); // TIMED_WAITING t.join(); System.out.println(t.getName() state: t.getState()); // TERMINATED2. 线程安全问题并发 Bug 的三个根源线程安全这件事往深了挖能写一整本书但落到日常的 Bug 上根源就三个原子性、可见性、有序性。把这三个概念搞清楚你就能看懂百分之九十五的并发问题的讨论。2.1 原子性i 为什么不是线程安全的count表面上一行代码实际编译成字节码至少是四步读取字段、加载常量 1、相加、写回字段。线程在执行到任意一步时都可能被切换出去于是两个线程同时执行count时各拿各的旧值加完写回来其中一个加法的结果就被覆盖了。经典的复现方式就是多个线程同时对一个共享 int 累加最后结果永远小于理论值。解决办法是让读-改-写变成一个不可分割的操作。最简单的是synchronized锁住方法再现代一点用AtomicInteger这种基于 CAS 的原子类private final AtomicInteger count new AtomicInteger(); public void increment() { count.incrementAndGet(); }AtomicInteger适合简单的计数器场景但 CAS 有 ABA 问题和自旋开销不要因为原子类性能好就处处用。复杂一点的复合操作比如先检查再执行这种仍然需要锁或者更高级的工具。2.2 可见性volatile 只解决一半问题原子性解决的是多个线程同时改一个变量的问题可见性解决的是一个线程改了另一个线程能不能立刻看到的问题。每个 CPU 核心有自己的缓存线程读共享变量时可能读到的是寄存器或缓存里的旧副本而不是主内存里的最新值。这个问题的经典例子是一个线程改 flag另一个线程循环读 flag结果循环永远出不来。volatile的关键字语义就是保证可见性写 volatile 变量会强制把修改刷回主内存读 volatile 变量会强制从主内存读并且通过内存屏障防止相关指令重排序。但要记住一个边界volatile不解决原子性。volatile int count同样扛不住两个线程同时count因为加法本身不是原子的。volatile适合的场景是一个线程写、多个线程读的状态标记或者是被AtomicInteger之外的其他安全对象内部的字段修饰。我看到不少人用volatile去修复计数器的并发问题那是用错了地方。2.3 有序性重排序和 happens-before为了提升性能CPU 和 JIT 编译器可能会对指令做重排序。单线程内重排序不会改变执行结果但多线程环境下一个线程看到的操作顺序可能和另一个线程实际执行的顺序完全不同。经典的例子是双重检查锁的单例如果instance字段不声明成volatile另一个线程可能看到一个已经分配了内存但还没完成构造的对象因为给引用赋值和调用构造方法这两步可能被重排序。Java 内存模型用 happens-before 规则约束重排序。简单理解只要两个操作之间有 happens-before 关系前一个操作的结果对后一个操作可见且顺序不会被重排。常见的规则包括volatile 写 happens-before 随后的 volatile 读锁的解锁 happens-before 后续的加锁线程 start 之前的所有操作 happens-before 该线程执行的任何操作。实战里不需要背全这些规则但要养成一个习惯多个线程共享的可变变量要么加锁要么用 volatile 修饰并保证读写操作本身原子要么用并发包里的类。裸奔的共享变量在并发下翻车只是时间问题。2.4 synchronized 与 Lock两种互斥姿势线程互斥最常用的两套方案是synchronized和ReentrantLock。synchronized是 JVM 层面的监视器锁可重入异常时自动释放锁HotSpot 对它有偏向锁、轻量级锁、重量级锁的升级优化。ReentrantLock是java.util.concurrent.locks下的实现提供了更丰富的功能。我的选择标准很直白简单互斥、代码块不大用synchronized因为写法简单、不易出错JVM 优化也足够好。需要超时获取锁、可中断等待、公平锁、或者多个条件队列时用ReentrantLock。ReentrantLock最实用的一个能力是tryLock(timeout)拿不到锁就返回不会无限期阻塞Lock lock new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务代码 } finally { lock.unlock(); } } else { // 超时未拿到锁走降级逻辑 }还有两个常见的锁坑。一是拿字符串常量当锁对象abc这种字符串在 JVM 里可能被池化不同地方用同一个字符串锁结果互相锁住。二是Integer等包装类型在缓存范围内-128 到 127也是同一个对象拿它做锁对象会在意想不到的地方产生竞争。锁对象建议用私有静态的Object实例或者用ReentrantLock这类显式锁。3. 线程池参数、队列、提交方式一次讲透线程池是面试的重灾区也是线上问题的高发区。很多人的理解停留在核心线程、最大线程两个参数但真正决定线程池行为的是队列这也是线程池的阻塞队列选择这个问题为什么总被拿出来单独聊。3.1 七个参数和提交流程ThreadPoolExecutor的构造参数一共有七个参数含义corePoolSize核心线程数默认不会被回收maximumPoolSize最大线程数keepAliveTime非核心线程空闲存活时间unitkeepAliveTime 的时间单位workQueue任务队列threadFactory创建线程的工厂handler拒绝策略线程池的执行流程是这样的提交任务时如果当前线程数小于核心线程数直接创建新线程执行。线程数已经达到核心线程数就把任务放进任务队列。如果队列满了并且线程数还没到最大线程数就继续创建新线程执行。如果队列满了且线程数已经到最大走拒绝策略。记住这个顺序很重要因为它天然带来一个坑如果任务队列是无界的那么第 3 步和第 4 步永远不会触发最大线程数和拒绝策略成了摆设。很多人配置了maximumPoolSize20以为能扛住突发流量结果队列是new LinkedBlockingQueue()无界队列实际永远只有核心线程在干活任务全压在队列里。3.2 阻塞队列怎么选LinkedBlockingQueue / ArrayBlockingQueue / SynchronousQueue阻塞队列是线程池的缓冲层它的选择直接决定线程池的弹性。队列容量特点适合场景LinkedBlockingQueue默认无界可指定容量链表实现入队出队有独立锁吞吐量较高缓冲任务指定容量时也可用ArrayBlockingQueue必须有界数组实现单锁可设置公平性需要严格控制队列长度的场景SynchronousQueue不存元素一个任务必须立刻交给某个线程否则提交线程阻塞适合直传配合缓存线程池PriorityBlockingQueue无界按优先级取任务有优先级需求的调度DelayQueue无界延迟取出任务定时任务调度生产中我踩过最大的坑就是直接Executors.newFixedThreadPool(10)。它内部用的是默认LinkedBlockingQueue容量是Integer.MAX_VALUE任务堆积起来能吃掉大量堆内存最终就是线上那个outofmemoryerror: insufficient memory的来源之一。你在监控里看到老年代涨起来、GC 频繁 Full GC线程 dump 一看队列里挂了几百万个任务八成就是无界队列惹的祸。正确做法是明确业务诉求。拿我处理过的订单查询接口为例下游是一个容易超时的服务高峰期可能有大量请求。我用的配置是核心线程 8最大线程 16队列用ArrayBlockingQueue容量 2000拒绝策略用CallerRunsPolicy——队列和线程都被占满时让提交任务的线程自己跑这个任务等于把压力反压给调用方而不是让任务堆积到内存里ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), new NamedThreadFactory(order-query), new ThreadPoolExecutor.CallerRunsPolicy() );SynchronousQueue也很特殊。它不存任务提交线程必须等有一个工作线程来取否则就阻塞。Executors.newCachedThreadPool用的就是它特点是只要有新任务就尝试创建新线程空闲线程 60 秒回收。适合任务量小但短时间并发高的场景但如果任务提交速度长期高于处理速度线程数会一直涨。我在 JMeter 压测时见过这个场景线程数冲到几百个CPU 全耗在上下文切换上。压测归压测线上如果要用它得想清楚任务频率。3.3 submit 和 execute 的区别返回值与异常处理execute(Runnable)和submit(Runnable/Callable)是提交任务的两种方式很多人在面试时能说出submit 有返回值就停了但实际最重要的区别在异常处理上。execute提交任务后直接交给线程池执行任务里抛出的 RuntimeException 会直接抛到工作线程中导致这个工作线程退出ThreadPoolExecutor发现线程异常退出后会根据需要补一个新的工作线程。你会在日志里看到异常堆栈然后发现线程池里的线程号变了。submit则不同。它会用FutureTask包装任务任务内部抛出的异常被捕获并存起来工作线程不会退出。异常要等到调用future.get()时才会以ExecutionException的形式抛出来。FutureInteger future executor.submit(() - { if (true) { throw new IllegalStateException(task failed); } return 1; }); try { Integer result future.get(3, TimeUnit.SECONDS); } catch (ExecutionException e) { // 真正的业务异常在这里 Throwable cause e.getCause(); } catch (TimeoutException e) { // 超时处理 future.cancel(true); }选型经验是不需要结果的、且任务内部会自己捕获处理异常的任务用execute更简单需要拿返回值、或者希望把异常统一交给 future 处理的用submit。但注意submit有个隐患——如果代码里只是executor.submit(task)却从不调get()任务异常会被静默吞掉排查问题时日志里什么都没有。所以我写完 submit 一定会检查有没有对应的异常处理要么调 get要么在任务内部 catch 住打日志。3.4 生产环境线程池配置经验别用 Executors 偷懒Executors的几个静态方法都是面试题级别的反面教材newFixedThreadPool默认无界队列任务可能堆积到 OOM。newCachedThreadPool最大线程数Integer.MAX_VALUE高并发下可能创建海量线程。newScheduledThreadPool无界队列定时任务堆积同样有风险。真正生产环境推荐直接用ThreadPoolExecutor构造方法把参数都显式写出来。线程数怎么定网上有各种公式我的实践是分两类CPU 密集型任务的理想线程数大约是 CPU 核数 1反正线程多了也不会让计算变快反而是纯计算场景下线程切换浪费资源。IO 密集型任务网络请求、文件读写、数据库调用的建议公式是CPU 核数 * (1 等待时间 / 计算时间)。举个例子一个任务里 80% 的时间在等下游接口返回20% 在本地计算那 8 核机器大概配 8 * (1 4) 40 个线程左右。当然这是理论值线上要结合压测和实际响应时间调整但至少能给你一个起点。还有一个细节核心线程数和最大线程数不要拍脑袋写。如果你明确知道任务是短平快、不堆积的核心线程可以小一些如果任务有稳定的积压量核心线程数就得按吞吐量估算否则会出现核心线程打满、队列一直半满的极端状态。另外可以通过prestartAllCoreThreads()预启动核心线程避免第一个请求到来时临时建线程的延迟。配置完还要能监控。我习惯重写beforeExecute和afterExecute钩子方法打日志记录每个任务的耗时同时暴露线程池活跃线程数、队列长度到监控系统。这些都是老生常谈但真的能救命。4. 线程协作与死锁从 wait/notify 到 jstack 排查线程池解决的是任务怎么执行的问题但多个线程之间怎么配合是另一套话题。配合不好轻则效率低下重则死锁整个应用卡死。4.1 wait/notify 的正确姿势Object.wait()和Object.notify()是最底层的线程协作方式现在写业务代码基本不会直接用但面试常问而且要理解原理后才能用好BlockingQueue这类高级工具。使用 wait/notify 有三个硬性要求必须在持有该对象监视器锁的代码块中调用否则抛IllegalMonitorStateException。wait()会释放当前持有的锁所以调用后其他线程可以抢到锁。用while循环检查条件不能用if因为存在伪唤醒而且从 wait 返回后条件可能仍不满足。一个典型的生产者消费者片段synchronized (queue) { while (queue.isEmpty()) { queue.wait(); // 释放锁等消费者来唤醒 } Item item queue.poll(); queue.notifyAll(); // 通知生产者可以继续放了 return item; }notify()只唤醒一个线程notifyAll()唤醒所有等待线程。正常情况下用notifyAll()更安全因为notify()可能唤醒一个条件不满足的线程导致它又继续等待极端情况下造成假死。现在如果你用LinkedBlockingQueue的take/put方法这些细节都被封装好了自己手写很容易踩坑。4.2 CountDownLatch / CyclicBarrier / Semaphore 怎么选实际开发里更常用的协调工具是并发包里的三个类它们的用途完全不同工具核心机制典型场景CountDownLatch计数器递减归零后放行等待线程一次性的等待 N 个任务完成CyclicBarrier计数器递减N 个线程到齐后同时放行可循环使用多线程分批聚合如分页拉取后汇总Semaphore许可证数量控制同时访问的线程数限流控制连接数等资源访问CountDownLatch我想多提一句因为它是等一批任务执行完的最常用工具。注意它是一次性的计数器归零后就不能再用了如果需要分段等待用CyclicBarrier。Semaphore常见误区是把它当线程池用其实它只管限流不管线程创建它更像一个许可证发放器比如一个接口最多允许 20 个线程同时调下游用Semaphore(20)控制进入的线程数量超出的先排队。4.3 死锁的形成条件与 jstack 排查死锁是线程协作的终极噩梦也是面试必问。四个必要条件缺一不可互斥资源同一时刻只能被一个线程持有。持有并等待线程持有一个资源不放同时等待另一个资源。不可剥夺已持有的资源不能被其他线程抢走。循环等待多个线程之间形成一条等待环。经典的死锁代码长这样public class DeadlockDemo { private static final Object LOCK_A new Object(); private static final Object LOCK_B new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (LOCK_A) { sleep(50); synchronized (LOCK_B) { System.out.println(t1 got B); } } }, t1); Thread t2 new Thread(() - { synchronized (LOCK_B) { sleep(50); synchronized (LOCK_A) { System.out.println(t2 got A); } } }, t2); t1.start(); t2.start(); } }排查死锁的标准姿势是靠 jstack步骤很固定jps -l找到目标 Java 进程的 PID。jstack pid输出线程 dump。搜索Found one Java-level deadlockJVM 会直接帮你把死锁环上的线程和锁列出来。如果你能看到waiting to lock 0x...和locked 0x...再结合线程栈里的业务类名基本就能定位到是哪两段代码互相锁了。预防死锁的手段我从实践里挑三个最有用的。第一全局固定锁获取顺序所有涉及多把锁的代码都按同一顺序加锁循环等待就没了。第二用ReentrantLock.tryLock(timeout)代替无条件的lock()拿不到就等一会儿再重试超时后走降级。第三尽量缩小锁的持有时间不要在临界区里做 IO、做网络请求、做耗时的计算把锁范围压缩到最小。4.4 守护线程什么时候用什么时候别用守护线程是 JVM 里一个常被忽略的概念。调用thread.setDaemon(true)把线程标记为守护线程JVM 在所有非守护线程结束时直接退出不会管守护线程跑没跑完。我理解的守护线程定位是后台的辅助协作者。典型的用法有统计线程、心跳发送、日志刷盘前的临时上报、监控指标的采集。JDK 内部的垃圾回收等相关线程也多是 JVM 内部创建的守护线程。但守护线程有几个非常容易踩的坑我详细说一下。第一个坑JVM 退出时守护线程会被立即终止finally块不一定执行。你如果在守护线程里做资源清理、数据库事务、消息确认这类关键操作JVM 一退出这些动作可能只做到一半数据就尴尬了。第二个坑线程池默认创建的工作线程是非守护的。如果你用自定义ThreadFactory把线程池里的线程设成守护线程任务跑到一半时 main 线程结束JVM 会直接把整个进程退掉任务凭空消失日志里什么都查不到。这种问题比非守护线程挂在那里还难排查。第三个坑守护线程和后台任务是两个概念。后台任务即使不是守护线程也不影响 JVM 生命周期但守护线程一定不能承担关键业务。我的习惯是能用普通线程和线程池解决的问题尽量不引入守护线程只有那种应用没了它也不该存在的附属任务比如进程内监控上报才标记成守护线程。另外ThreadGroup这套机制现在已经很少用了统一异常处理直接实现Thread.UncaughtExceptionHandler就好比依赖 ThreadGroup 里那套方法好用得多。回到开头那个线上问题。线程池的队列选择看起来只是配置里的一行代码实际却决定了应用的稳定性边界。Java 线程相关的知识体系不算大但每个点都能延伸出无数线上事故。我这些年最深切的体会就是不要背参数要理解每个配置在任务从提交到执行这条链路上到底扮演什么角色。队列是缓冲还是无底洞提交方式是吞异常还是暴露异常锁是保护共享状态还是制造死锁这些想明白了Java 并发这块基本就稳了。
返回列表