ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多线程面试与工程实战:锁、线程池、跨语言与线上排查

多线程面试与工程实战:锁、线程池、跨语言与线上排查 多线程这块内容面试官问了十几年题面几乎没怎么变过但能把答案落到细节上的人一直不多。很多人背了一堆术语一被追问“为什么用线程池不用 new Thread”“volatile 到底解决了什么问题”就卡壳。这篇汇总不是题库罗列而是按考察意图把多线程拆成几块——概念地基、线程安全、手写场景题、语言横向对比、线上排查每一块都补上我实际被问过、也实际踩过坑的细节。Java、Python、C、C#、Qt、Flutter 的线程模型差异多进程与多线程的取舍断点续传式多线程下载的分片设计CPU 飙高的完整排查链路都会讲到。不管你是在准备面试还是在写业务代码时被并发问题折磨过都能直接从里面挑走能用的东西。1. 面试官在多线程这块到底想验证什么1.1 三类问题三种考察意图多线程的面试题看着杂其实翻来覆去就是三类意图。第一类是概念辨析比如进程和线程的区别、并发和并行的区别、同步和异步的区别。这类题问的不是定义而是想看你能不能把抽象概念落到具体资源上很多人背“线程是调度的最小单位”但一问“那线程切换时到底切换了什么”就答不上来。第二类是原理追问通常从一个简单问题切入然后一层层往下钻比如从“synchronized 怎么用”一路追问到锁升级、对象头 Mark Word、monitor 的 enter 和 exit。第三类是场景落地给你一段有并发缺陷的代码或者一个线上现象让你现场分析原因并给出方案。三类问题的准备方式完全不同。概念类要的是准确的表述加一个恰当的类比原理类要的是能把底层机制串成一条链场景类要的是有排查路径而不是猜。我见过不少人原理背得滚瓜烂熟一问“你们线上线程池怎么配的、为什么这么配”就含糊其辞这恰恰是面试官最在意的部分因为参数选型直接反映你有没有真正跑过生产环境。注意面试里最容易被扣分的不是答错而是答得含糊。比如“线程池线程数一般设为 CPU 核数的两倍”这种话说了不如不说一定要带上前提条件。1.2 高频考点分布地图把近几年常见的问法归一下类大致是这样分布的。这张表我自己用来做过复习清单按出现频率排了序最后两列的“追问深度”是指面试官通常会往下钻几层。考点类别典型问法出现频率常见追问深度线程安全三要素原子性、可见性、有序性分别由什么保证极高追到 happens-before、内存屏障锁机制synchronized 和 Lock 的区别极高追到 AQS、锁升级、公平性线程池核心参数含义、拒绝策略、参数怎么算极高追到队列选型、线上事故复盘死锁死锁的四个必要条件、怎么排查高追到 jstack 实战、gdb 调试并发容器ConcurrentHashMap 为什么高效高追到分段锁到 CASsynchronized 的演进线程通信wait/notify 与 Condition 的区别高追到虚假唤醒、为什么用 while内存模型volatile 的作用、能不能保证原子性高追到指令重排、单例双检锁编程题手写生产者消费者、两个线程交替打印中高追到三种写法的取舍异步与回调Future、CompletableFuture 的用法中追到线程池隔离、异常处理跨语言模型GIL、isolate、QThread 的差异中追到选型理由这张表里我标“极高”的三块基本占了面试时间的六成以上。所以时间紧的话先把锁、线程池、线程安全三要素吃透剩下的按需补。2. 概念地基进程、线程、并发与并行的边界在哪2.1 进程和线程的本质区别一句话说到根上最标准的表述是进程是操作系统分配资源的基本单位线程是 CPU 调度的基本单位。这句话本身没错但只说这句话信息量太低面试官通常紧接着会问“那线程共享了进程的哪些资源”。这时候要能报出来同一进程内的线程共享地址空间代码段、数据段、堆、打开的文件描述符、信号处理方式、当前工作目录各自独立拥有的是栈、寄存器上下文、线程本地存储、errno 这类线程私有变量。我一般会用一个类比把它讲透进程像一栋独立的房子有自己的地基、水电、门牌号线程像房子里的不同住户共用厨房和卫生间堆和文件描述符但每个人有自己的卧室栈。这个类比能顺带解释两个常见现象一是线程之间传数据快因为不用跨地址空间拷贝二是一个线程把堆写坏了整栋房子的人都遭殃所以线程崩溃往往导致整个进程挂掉而进程之间相对隔离。面试时把“共享什么、独享什么”这两列讲清楚比背定义有用得多。2.2 多进程还是多线程判断依据是什么这个问题看起来是八股其实是个真实的架构决策。判断的核心只有一条任务之间需不需要高频共享大量数据。如果需要多线程更合适因为共享内存的通信成本极低如果不需要多进程往往更稳因为隔离性好一个进程崩了不会带走其他进程而且能绕开某些语言层面的并发限制。举几个具体的判断场景。计算密集且数据耦合紧密的任务比如图像处理里多个分块共享一张大图用多线程任务之间基本独立、容错要求高的比如一批互不相关的报表生成用多进程或者多机分发IO 密集型的服务端请求处理通常用线程池加异步 IO因为线程大部分时间在等网络和磁盘用多进程反而增加调度和内存开销。还有一个常被忽略的点进程的内存开销是实打实的每个进程有独立地址空间几百个进程的内存占用会很可观而线程栈默认一到几 MB只要控制好线程数量开销小得多。实操心得跨平台项目里多进程方案的可移植性通常更好因为进程间通信可以用管道、socket、共享内存等标准手段而多线程方案一旦涉及内存模型不同语言和运行时的行为差异会明显变大。2.3 上下文切换的成本究竟花在哪里很多人知道“上下文切换有开销”但说不清开销具体在哪。一次切换大致包含三部分一是保存当前线程的寄存器上下文包括程序计数器、通用寄存器、栈指针这部分是纯 CPU 指令量级在几百纳秒到微秒二是内核态的调度逻辑本身包括选择下一个可运行线程、更新调度数据结构三是间接成本也是最大的一块——CPU 缓存和 TLB 的失效。新线程被调度上来后它访问的数据大概率不在当前 CPU 的 L1、L2 缓存里得重新从内存加载这个“缓存冷启动”的代价可能比前两部分加起来还高。所以线程数不是越多越好。线程太多会导致两个后果一是切换频率上升CPU 大量时间花在切换而不是干活上二是每个线程分到的时间片变短缓存的命中率进一步下降。实测中可以在 Linux 上用vmstat 1看cs列每秒上下文切换次数如果这个值长期高得离谱而us用户态 CPU 占比并不高基本可以判断是切换开销过重这时候该做的是减少线程数或者把任务批量合并而不是继续加线程。# 每秒刷新一次观察 cs上下文切换和 us/syCPU 时间分布 vmstat 1 # 查看指定进程的线程级 CPU 占用-H 表示按线程展示 top -H -p pid3. 线程安全与锁面试密度最高的一章3.1 原子性、可见性、有序性怎么落到代码上并发问题的根源就三个原子性、可见性、有序性。原子性指的是一个操作不可被中断比如i看起来是一行代码实际包含读、加、写三步多线程下必然出问题。可见性指的是一个线程的修改其他线程能不能立刻看到根源在于每个 CPU 有自己的写缓冲和缓存写操作不会立刻刷到主内存。有序性指的是编译器和 CPU 可能对指令重排以提升性能重排后会破坏某些依赖关系。落到具体手段上保证原子性靠锁或者 CAS 类原子操作保证可见性靠内存屏障在语言层面就是 volatile 这类关键字保证有序性靠禁止特定重排同样是内存屏障在起作用。这里有个常见误区必须点出来——volatile 能保证可见性和一定程度的有序性但不保证原子性volatile int count; count依然是线程不安全的。Java 里的 happens-before 规则是这套机制的抽象描述最常用的几条是程序顺序规则同一线程内前面的操作对后面可见、volatile 变量规则对 volatile 的写 happens-before 后续的读、锁规则解锁 happens-before 后续加锁、线程启动和终止规则。我习惯用一个例子说明指令重排的危害就是经典的双重检查单例。instance new Singleton()这行实际分三步分配内存、初始化对象、把引用指向内存。如果第二步和第三步被重排另一个线程可能拿到一个引用非空但对象未初始化完成的对象。解决办法是给 instance 加 volatile禁止这个重排。面试时能把这个例子讲完基本就证明你真理解有序性了而不是背了定义。3.2 synchronized 与 Lock选型背后的逻辑两者的核心差异有三个层面。第一是使用方式synchronized 是语言层面的关键字加锁解锁由 JVM 自动完成不怕忘记释放Lock 是接口必须手动在 finally 里 unlock忘记就会一直占着锁。第二是能力Lock 支持可中断获取锁lockInterruptibly、支持超时获取tryLock 带时间参数、支持公平锁、支持多个条件变量Condition这些 synchronized 都做不到。第三是性能早期版本里 synchronized 是重量级锁性能明显差但现代 JVM 引入了偏向锁、轻量级锁的优化在低竞争场景下性能已经基本持平。选型的逻辑其实很直接如果只是简单互斥优先用 synchronized代码短、不易出错如果需要超时、可中断、多条件队列用 ReentrantLock。多条件队列这个能力在实践中比想象中重要比如手写一个有界阻塞队列用两个 Condition 分别管理“队列非空”和“队列未满”比用 wait/notify 加一个判断清晰得多也避免了 notifyAll 唤醒所有线程再重新竞争的浪费。// 用两个 Condition 实现有界队列避免 notifyAll 的全量唤醒 public class BoundedQueueT { private final Object[] items; private int head, tail, count; private final ReentrantLock lock new ReentrantLock(); private final Condition notEmpty lock.newCondition(); private final Condition notFull lock.newCondition(); public BoundedQueue(int size) { items new Object[size]; } public void put(T x) throws InterruptedException { lock.lock(); try { while (count items.length) notFull.await(); // 必须用 while防虚假唤醒 items[tail] x; if (tail items.length) tail 0; count; notEmpty.signal(); } finally { lock.unlock(); } } }关于 synchronized 的锁升级顺便补一个容易被考到的更新JDK 15 起偏向锁默认被禁用后续版本中相关实现已逐步移除。原因也值得了解——偏向锁的撤销需要在一个安全点停下来在高并发场景下这个停顿带来的开销超过了它节省的成本。面试时如果被问“锁升级的过程”把无锁、偏向锁、轻量级锁CAS 自旋、重量级锁这条路径讲清楚再补一句偏向锁的现状会显得你的知识是新的。注意无论用 wait/notify 还是 Condition.await/signal判断条件必须用 while 循环而不是 if。因为线程被唤醒时条件可能已经不成立了虚假唤醒或者条件被其他线程抢先改变了用 if 会直接往下执行导致数据错乱。3.3 死锁的四个条件与线上排查实操死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。四个条件同时成立才会死锁所以破局也从这四个方向入手。工程上最常用的是破坏循环等待——给所有锁定义全局顺序所有线程按同一顺序加锁这条最实用也最容易落地。其次是破坏持有并等待用一次性申请全部资源的方式tryLock 失败就释放已持有的锁并重试。破坏不可剥夺则依赖锁的超时机制。线上排查死锁的路径很固定。Java 环境先jps -l找到进程号然后jstack pid输出线程栈JVM 会直接给出 “Found one Java-level deadlock” 的检测结果并把互相等待的线程和它们持有的锁列出来。如果没有自动检测出来就手工找处于 BLOCKED 状态的线程看它等的是哪个锁以及持有那个锁的线程又在等什么。Linux 层面用gdb的话thread apply all bt能把所有线程的调用栈打出来从栈里找pthread_mutex_lock这类阻塞点再对照代码定位加锁顺序。# Java直接定位死锁 jps -l jstack pid | grep -A 40 deadlock # C/C用 gdb 挂到进程上查看所有线程栈 gdb -p pid (gdb) info threads (gdb) thread apply all bt (gdb) set scheduler-locking off # 查看时不要让线程乱跑4. 手写题与场景题从生产者消费者到线程池参数4.1 生产者消费者三种写法与各自的坑这个题几乎是并发编程的必考题三种写法的取舍值得说清楚。第一种是 wait/notify 加 synchronized代码最短但坑最多必须在同步块内调用 wait否则抛 IllegalMonitorStateException判断条件必须用 whilenotify 只唤醒一个线程如果唤醒的是同类线程可能造成“信号丢失”所以很多人直接上 notifyAll代价是无差别唤醒带来的竞争。第二种是 Lock 加 Condition两个条件队列各管一头精确唤醒代码稍长但逻辑清晰是我在生产代码里更愿意用的写法。第三种是直接上 BlockingQueue把并发控制交给成熟组件业务代码只关心 put 和 take出错概率最低。面试官如果继续追问通常会问“如果队列满了生产者应该阻塞还是丢弃”。这不是纯技术问题取决于业务语义。日志采集这种场景丢了就丢了用 offer 加超时订单处理这种场景绝对不能丢必须阻塞或者走降级补偿。回答时把业务语义和技术手段对应起来比单纯报 API 名字有说服力得多。4.2 线程池参数到底怎么算线程池的核心参数是核心线程数、最大线程数、空闲存活时间、工作队列、线程工厂、拒绝策略。面试里真正拉开差距的是“参数怎么定”。先讲一个常见的反例Executors.newFixedThreadPool用的是无界队列任务堆积时不会触发拒绝策略而是一直往队列里塞最终 OOM。所以生产环境更推荐手动new ThreadPoolExecutor把队列设成有界并明确拒绝策略。线程数的计算有个经典公式来自 Brian Goetz 的实践总结线程数 CPU 核数 × 目标 CPU 利用率 × (1 等待时间 / 计算时间)举个具体的算例。假设机器是 8 核目标是 CPU 利用率 100%单个任务的计算耗时 50ms等待 IO 耗时 950ms。那么等待时间与计算时间的比值是 950/50 19代入公式得到 8 × 1 × (1 19) 160 个线程。这个数字看着吓人但它说明的是 IO 密集型任务确实需要远超核数的线程数因为绝大多数时间线程都在等。反过来纯计算任务等待时间为 0公式给出 8 个线程多加线程只会增加切换开销一般取核数加一多出来的那一个是为了在某个线程偶尔发生缺页等短暂停顿时顶上去。任务类型典型场景线程数经验值队列选择CPU 密集型加解密、图像编码、数值计算核数 1小容量有界队列IO 密集型网络请求、数据库访问、文件读写核数 × (1 等待/计算)容量适中按 QPS 和耗时估算混合型典型业务接口拆分后分别配置拆成两个线程池隔离参数算出来只是起点还要考虑队列容量。容量估算可以按“峰值 QPS × 可接受的排队等待时间”来定。比如峰值 2000 QPS平均任务耗时 100ms希望最坏情况下任务排队不超过 2 秒那么队列里最多积压 2000 × 2 4000 个任务。超过这个量就该触发拒绝让上游感知到压力而不是无声地堆积。实操心得一定要给线程池起有业务含义的名字通过自定义 ThreadFactory否则线上出问题时jstack 里全是 pool-1-thread-3 这种名字根本分不清是哪个业务的线程池在捣乱。另外不同业务尽量用不同线程池隔离一个慢接口拖垮整个线程池的教训我见过太多次。4.3 断点续传与多线程下载的分片设计多线程下载是另一个高频场景题核心是 HTTP 的 Range 请求。服务端如果支持会在响应头里带Accept-Ranges: bytes客户端就可以用Range: bytes0-1023请求指定区间服务端返回 206 Partial Content 和对应的Content-Range。续传的前提是先把文件总大小和每个分片的进度记录下来下次启动时读回进度只请求未完成的部分。分片设计有三个要点。第一是分片大小的选择太小则连接数多、管理开销大太大则单线程串行时间长、断点重传的代价高实践中单文件 2 到 8 个分片比较均衡具体看文件大小和网络状况。第二是文件写入方式必须支持随机位置写入Java 用 RandomAccessFile 的 seekPython 用文件对象的 seekC 用 pwrite否则多线程写同一文件会互相覆盖。第三是完整性校验全部下载完成后要合并并校验整体哈希值部分实现会在每个分片自带校验值便于定位坏块。import os, threading, requests URL https://example.com/bigfile.bin THREADS 4 CHUNK 1024 * 256 def download(start, end, idx, path): headers {Range: fbytes{start}-{end}} # 断点续传以追加模式打开分片临时文件避免重复下载已完成部分 mode ab if os.path.exists(path) else wb done os.path.getsize(path) if mode ab else 0 headers[Range] fbytes{start done}-{end} with requests.get(URL, headersheaders, streamTrue) as r, open(path, mode) as f: for chunk in r.iter_content(CHUNK): f.write(chunk) def main(): total int(requests.head(URL).headers[Content-Length]) step total // THREADS threads [] for i in range(THREADS): start i * step end total - 1 if i THREADS - 1 else (start step - 1) t threading.Thread(targetdownload, args(start, end, i, fpart{i}.tmp)) t.start() threads.append(t) for t in threads: t.join() # 合并后再校验整体哈希确认没有丢块 with open(bigfile.bin, wb) as out: for i in range(THREADS): with open(fpart{i}.tmp, rb) as p: out.write(p.read())这段代码有两个需要留意的地方。一是 Range 的边界是闭区间最后一个分片要取到total - 1写错一位就会少一个字节这个 bug 非常隐蔽文件能打开但哈希对不上。二是 Python 的多线程在这里确实有加速效果因为瓶颈在网络 IO 而不是 CPU线程在等数据的时候释放了 GIL这也是下一节要展开的话题。5. 横向对比不同语言的多线程模型差在哪5.1 Java 与 C# 的共享内存路线异同Java 和 C# 都走共享内存加锁的路线概念上高度对应Java 的 synchronized 对应 C# 的 lockvolatile 意思接近Thread 类都很像线程池也都是核心基础设施。差别在于细枝末节C# 提供了async/await这套语言级异步语法糖把回调写成了顺序代码的样子配合 Task 和线程池使用是目前 C# 处理 IO 并发的首选方式Java 对应的是 CompletableFuture 和虚拟线程虚拟线程在近几年的版本中已经正式可用它把线程的调度从内核搬到了用户态使得“一个请求一个线程”这种简单写法在超高并发下也扛得住。C# 里还有个容易被考到的点是volatile和Interlocked的分工volatile 管可见性Interlocked 管原子性Interlocked.Increment对应的就是 Java 里的 AtomicInteger。这一点和 Java 完全一致说明共享内存模型的本质约束是语言无关的只是表达方式不同。面试时如果被问到跨语言对比抓住“共享内存模型下大家都在解决同样三个问题只是 API 名字不同”这条主线就不会答散。5.2 Python 的 GIL 与真正能提速的写法Python 的 GIL全局解释器锁是绕不开的话题。CPython 的实现里同一时刻只允许一个线程执行 Python 字节码所以多线程在纯计算任务上完全无法利用多核甚至因为切换开销比单线程还慢。但这不意味着多线程没用当线程执行的是 IO 操作时底层调用会释放 GIL其他线程可以继续跑所以在网络请求、文件读写这类场景下多线程依然能带来成倍的吞吐提升。选型判断很简单CPU 密集用 multiprocessing每个进程有独立的解释器和 GIL能真正并行IO 密集用 threading 或者 asyncio前者适合已有同步库的场景后者适合全新项目且 IO 边界清晰的情况。还有个细节容易被忽略多进程之间数据不共享参数和返回值都要序列化大数据量时这个开销可能吃掉并行带来的收益所以分片要足够大。至于网上说的“Python 3.13 之后没有 GIL 了”实际是提供了可选的自由线程构建默认构建仍然带 GIL且自由线程模式对单线程性能有一定影响生产环境要谨慎评估。注意在 Python 里用多线程做计算加速是典型的错误用法很多性能问题排查到最后都是这个原因。判断标准很简单如果你的线程函数里没有 IO 调用也没有 sleep那多线程基本不会带来加速。5.3 C、Qt 与 Flutter 的线程模型差异C 走的是标准库线程加原子操作的路子std::thread、std::mutex、std::atomic、std::condition_variable是主力工具。C 相比 Java 多了一个必须自己操心的维度内存序。std::atomic的 load 和 store 可以指定 memory_order从最宽松的 relaxed 到最严格的 seq_cst选错会导致极难复现的 bug选得太严又会损失性能。默认用 seq_cst 是安全的只在确认瓶颈且理解语义时才放宽。C 里另一个高频问题是线程的 join 和 detach如果 thread 对象析构时既没 join 也没 detach程序会直接终止这是个很硬的坑。Qt 的线程模型有自己的规矩GUI 对象只能在主线程操作跨线程更新界面必须通过信号槽。信号槽的连接方式里AutoConnection 在跨线程时会自动变成队列连接槽函数会在接收者所属的线程里执行这实际上是 Qt 自带的一套消息传递机制。QThread 有两种用法继承并重写 run 适合一次性任务moveToThread 把工作对象搬到子线程适合长期驻留的任务加事件循环后者更符合 Qt 的设计意图。Flutter 则是完全不同的思路Dart 是单线程加事件循环没有共享内存式的线程重计算要放到 isolate 里isolate 之间通过端口传消息所以不存在锁的问题代价是数据要拷贝或者转移。语言/框架并发单元共享内存典型同步手段最需要注意的坑JavaThread / 虚拟线程是synchronized、Lock、原子类线程池参数与队列选型C#Thread / Task是lock、Interlocked、async/awaitasync 与同步阻塞混用导致死锁PythonThread / Process线程共享进程不共享Lock、Queue、multiprocessingGIL 导致计算任务无法并行Cstd::thread是mutex、atomic、条件变量内存序选错、忘记 joinQtQThread是信号槽、QMutex跨线程操作 GUI 对象FlutterIsolate否端口通信大对象传输的序列化开销6. 场景排查题CPU 飙高、线程卡死怎么定位6.1 CPU 持续飙高的完整排查链路线上 CPU 飙高是并发相关面试里最常被问的场景题答案必须是一条可执行的链路而不是“用工具看一下”。完整流程是先用top找到 CPU 占用最高的进程再用top -H -p pid找到该进程内占用最高的线程记下线程 ID接着把线程 ID 转成十六进制因为 jstack 输出的 nid 是十六进制然后在 jstack 的输出里搜这个 nid定位到具体线程和它的调用栈最后根据栈顶的方法名判断是死循环、正则回溯、频繁 GC 还是锁竞争。这套流程里有一个关键判断点如果 CPU 高但线程栈显示大部分线程处于 WAITING 或 TIMED_WAITING那大概率不是业务代码的问题而是 GC 线程在疯狂工作。这时候要去看 GC 日志或者用jstat -gcutil pid 1000观察各代内存的变化如果老年代持续增长且 Full GC 频繁但回收效果差基本可以确定是内存泄漏导致 GC 抖动方向就从并发转向了内存分析。top -H -p pid # 找到最耗 CPU 的线程记下 PID 列 printf %x\n tid # 转成十六进制例如 12345 - 3039 jstack pid | grep -A 30 nid0x3039 jstat -gcutil pid 1000 # 每秒输出一次 GC 统计观察 O 列是否持续接近 1006.2 gdb 调试多线程的常用命令C 和 C 项目排查多线程问题时gdb 是主力工具。挂到运行中的进程上用gdb -p pid进去之后info threads列出所有线程及其当前栈帧thread n切换线程thread apply all bt一次性打印所有线程的调用栈这是排查死锁和卡死最快的手段。如果怀疑是某个线程持有锁不放可以在thread apply all bt的结果里搜 mutex 相关的调用帧找出所有停在锁获取处的线程再对照代码看谁持有锁。调试多线程有个必须知道的设置set scheduler-locking。默认是 off意味着你在单步调试时其他线程仍在运行断点位置和变量值随时可能变化调试体验很差。设成 on 之后只让当前线程运行其他线程暂停单步跟踪会清晰很多但要注意这改变了程序的真实执行时序可能掩盖某些竞态问题所以定位到具体位置后要切回 off 验证。实操心得排查偶发的并发问题时日志比调试器更好用。在关键的加锁、解锁、状态变更处埋上带时间戳和线程 ID 的日志出问题时按时间线还原往往比多次重现更高效。代价是日志要控制在可接受量级高频路径上不要打太多。6.3 常见问题速查表把日常和面试里最常遇到的并发问题整理成对照表方便直接查。现象大概率原因排查手段处理方向CPU 高但吞吐低频繁上下文切换或自旋等待vmstat 看 cstop -H 看线程分布减少线程数改用阻塞等待CPU 高且 GC 频繁内存泄漏导致 GC 抖动jstat、GC 日志转向内存分析查对象引用链任务堆积、响应变慢线程池队列过长或线程数不足监控队列长度和活跃线程数调整参数或拆分线程池隔离偶发数据错乱缺少同步或用了 volatile 做计数检查共享变量是否在同步块内加锁或换原子类程序卡住无响应死锁或线程被永久阻塞jstack、gdb 打印全部栈统一定义加锁顺序加超时内存持续增长ThreadLocal 未清理、监听器未注销堆转储后按引用链分析在 finally 里做清理唤醒后逻辑异常用 if 而非 while 判断条件检查条件等待的写法改成 while 循环线程池拒绝任务队列满且线程数达上限看拒绝策略和上游 QPS加容量或做降级限流ThreadLocal 那条值得单独说一句因为它太常见了。ThreadLocal 的 key 是弱引用value 是强引用线程池里的线程长期存活如果业务代码用完不 removevalue 就一直挂在那个线程上对象无法回收。刚开始可能只是几十 KB跑几天之后就是几百 MB。解决办法很简单但必须形成条件反射在 finally 里调用 remove。7. 答这类题的表达方式内容准备到这一步最后聊一下怎么把它说出来。我的经验是任何并发问题都按“现象、根因、手段、代价”四段来组织。先说现象比如“多个线程同时自增导致结果偏小”再说根因落到原子性、可见性、有序性中的哪一个然后给手段说明为什么选这个方案而不是另一个最后主动交代代价比如加锁会带来串行化和竞争开销CAS 在竞争激烈时会大量自旋反而更慢。主动说代价这一点很重要它说明你是在做工程权衡而不是背答案。还有一个细节被问到自己没深究过的领域时别硬编。比如被问到某个语言的特定并发原语可以直接说“这块我在项目里用得不多我的理解是……可能不准确”。面试官对知识边界清晰的候选人评价通常不低反而对什么都答得很顺、一追问就崩的人比较警惕。我自己踩过最深的坑是线程池的队列选型早期图省事用了无界队列压测没问题上线后被一个慢下游拖住任务越堆越多最后直接把服务拖垮。后来把队列改成有界并配上拒绝策略同时按业务拆成多个线程池做隔离才彻底解决。所以现在我评估一个并发方案的第一个问题不是“快不快”而是“压力上来之后它会怎么失败”。这个视角上的转变比多背几道题有用得多。
返回列表