ARTICLE DETAIL

资讯详情

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

多线程编程从原理到实战:线程池、锁、死锁与面试考点全解析

多线程编程从原理到实战:线程池、锁、死锁与面试考点全解析 做后台开发这些年我面试过的人少说也有几百位聊到多线程时能讲清楚的人真不多。不是大家不会用而是多线程这摊水太深——线程生命周期、锁的粒度、并发容器的取舍、线程池参数的调优每一块单独拎出来都够写一本书。这篇文章我就想把多线程从底层原理到实战应用从创建线程到面试考点一次性说透。不管你是刚接触编程的新手还是准备跳槽的资深开发都能从中找到自己需要的答案。多线程不是一个学了就能用的知识点它更像一套思维方式。单线程里你只需要关心做什么多线程里你要额外关心什么时候做谁先做做完了怎么同步这三个问题如果没有想清楚代码跑起来就是一团乱麻。我见过太多线上事故都是因为线程安全问题导致的——数据错乱、死锁、OOM、CPU飙高每一个都能让你在半夜被电话叫醒。这篇文章不会只讲概念我会把每个知识点掰开揉碎告诉你底层原理是什么、实际场景中怎么用、踩过的坑有哪些最后还会整理一份面试高频考点清单。内容会比较长但每一段都值得认真看。1. 先从根上理解进程和线程到底差在哪1.1 一个生活化的类比很多人对进程和线程的区别背得滚瓜烂熟但真正遇到问题时就蒙了。我给你打个比方进程就像一家餐厅线程就是餐厅里的服务员。餐厅有自己的场地、厨房、仓库内存空间服务员在餐厅里工作共享这些资源。如果你想开第二家餐厅得重新租场地、买设备、招人这就是创建新进程的代价。而在一家餐厅里多雇几个服务员共享同一个厨房和仓库这就是创建线程的代价。所以进程和线程的第一个区别就出来了进程是资源分配的最小单位线程是CPU调度的最小单位。进程之间相互独立一个进程崩了不影响另一个同一进程内的线程共享内存空间一个线程崩了可能把整个进程带崩。1.2 为什么线程的创建成本远低于进程进程之间是隔离的每个进程都有独立的地址空间、页表、文件描述符表等资源。操作系统创建一个新进程时需要分配这些资源建立地址映射关系这个开销是比较重的。创建一个线程则简单得多——线程共享进程的地址空间和大部分资源只需要分配独立的栈空间和线程控制块TCB所以线程的创建速度比进程快一个数量级。这也是为什么在高并发场景下我们优先选择多线程而不是多进程。不过要注意Python这种有GIL的语言是个例外后面会专门说。1.3 进程间通信和线程间通信的差异进程之间因为隔离通信必须借助操作系统提供的机制比如管道、消息队列、共享内存、信号量、Socket等数据还得做序列化拷贝效率低且复杂。线程之间共享内存通信直接读写变量就行高效又直观。但高效是把双刃剑——共享内存意味着多个线程同时读写同一个变量时会发生竞争。这个竞争如果不加控制轻则数据不对重则程序崩溃。我见过一个经典事故两个线程同时对同一个ArrayList做add操作结果数组越界直接抛异常服务就挂了。解决思路就是加锁或者用线程安全的容器这个后面详细展开。2. 线程的完整生命周期和底层调度机制2.1 线程的五种状态及转换很多人学线程状态时靠死记硬背其实理解了转换条件就能轻松记住。线程从创建到销毁一共有五种状态新建New创建了一个线程对象还没调用start()方法。此时线程还没有真正运行只是在JVM/操作系统中分配了一些元数据。就绪Runnable调用了start()方法后线程进入就绪队列等待CPU分配时间片。就绪状态的线程随时可以被调度器选中执行。运行Running线程获得了CPU时间片正在执行run()方法中的代码。阻塞Blocked/Waiting/Timed_Waiting线程因为某种原因放弃了CPU使用权比如等待获取锁、调用了sleep()/wait()/join()方法。阻塞结束后会回到就绪状态。死亡Terminatedrun()方法执行完毕或者抛出了未捕获的异常线程生命周期结束。这里有个新手常犯的错调用了sleep()后线程不会进入阻塞状态而是进入定时等待状态Timed_Waiting。但面试时你说阻塞也基本能过关关键是能说清楚线程在等待什么、放弃CPU后怎么恢复。2.2 线程调度时间片轮转是怎么回事现代操作系统大多使用抢占式调度也就是说线程能跑多久不是自己说了算而是由操作系统决定。系统把CPU时间切成一个个小片段通常十几毫秒到几十毫秒按一定策略分给各个线程。线程用完自己的时间片无论任务有没有做完都得暂停让出CPU给其他线程。这就是上下文切换Context Switch。切换的代价不小——操作系统要把当前线程的寄存器、程序计数器、栈指针等状态保存起来再加载下一个线程的状态。线程越多切换越频繁真正花在干活上的CPU时间占比就越低。所以不是线程开得越多越好线程数超过CPU核心数后大量时间会耗在切换上整体性能反而下降。2.3 GDB调试多线程的实际操作多线程调试是很多人的痛点。代码跑着跑着就卡死了不知道是死锁还是死循环用GDB可以很方便地排查。先用gdb启动程序或者gdb attach 进程PID挂载到正在运行的进程上。# 查看当前进程下所有线程的列表 (gdb) info threads # 输出类似 # 3 Thread 0x7f8b9c327700 (LWP 23452) 0x00007f8b9c1b7a3d in syscall () # 2 Thread 0x7f8b9c129700 (LWP 23450) pthread_cond_waitGLIBC_2.3.2 () # 1 Thread 0x7f8b9ccc2700 (LWP 23449) main () # 切换到指定线程查看其堆栈 (gdb) thread 2 (gdb) bt # 就能看到这个线程卡在哪个函数上了如果是死锁你会发现两个线程都停在pthread_mutex_lock或者pthread_cond_wait上互相等待对方释放锁。如果是一个线程CPU飙高但程序没响应切到对应线程看堆栈往往能看到一个死循环。调试多线程还有一个技巧用set scheduler-locking on可以让GDB在单步调试时只运行当前线程其他线程全部暂停这样才不会出现单步走着走着跑偏了的情况。3. 线程创建方式全对比3.1 Java创建线程的三种方式Java里创建线程核心就三种姿势继承Thread类重写run()方法然后new出来调start()。继承方式简单直接但Java只支持单继承继承了Thread就不能继承别的类了扩展性差。实现Runnable接口把任务逻辑放进run()方法然后传给Thread构造器。比第一种好因为接口可以多实现。但Runnable的run()方法没有返回值也抛不出受检异常。实现Callable接口 FutureTaskCallable能返回结果能抛异常配合FutureTask可以拿到异步执行结果。这是实际开发中最推荐的方式。import java.util.concurrent.*; public class ThreadDemo { public static void main(String[] args) throws Exception { // 方式一继承Thread不推荐 Thread t1 new Thread() { Override public void run() { System.out.println(方式一继承Thread); } }; t1.start(); // 方式二实现Runnable常用 Thread t2 new Thread(() - System.out.println(方式二实现Runnable)); t2.start(); // 方式三Callable FutureTask推荐可以获得返回值 FutureTaskInteger task new FutureTask(() - { Thread.sleep(1000); return 1 2; }); Thread t3 new Thread(task); t3.start(); Integer result task.get(); // 这里会阻塞等待任务完成 System.out.println(方式三返回结果 result); } }注意task.get()会阻塞当前线程直到子线程任务完成。如果你不想阻塞可以先用isDone()轮询或者用get(long timeout, TimeUnit unit)设置超时时间。3.2 Python多线程的两种标准和GIL这个坑Python创建线程的两种标准方式是threading.Thread和concurrent.futures.ThreadPoolExecutor。前者适合简单的开一个线程跑任务后者适合批量提交任务并统一获取结果。import threading import time def worker(name): print(f线程{name}开始) time.sleep(2) print(f线程{name}结束) # 方式一直接创建线程 t threading.Thread(targetworker, args(A,)) t.start() t.join() # 等待线程结束否则主线程可能先退出Python有一个绕不开的话题GIL全局解释器锁。GIL让同一时刻只有一个线程能执行Python字节码所以Python多线程在CPU密集型任务上不但不能加速反而会因为线程切换带来额外开销。但在IO密集型任务上网络请求、文件读写、数据库查询线程等待IO时会让出GIL切换到其他线程所以依然能获得很好的并发效果。如果你的任务是CPU密集型的Python里应该使用多进程multiprocessing模块而不是多线程每个进程有独立的Python解释器绕开GIL限制才能利用多核CPU。3.3 C和C#的线程创建要点C11标准库正式加入了线程支持用法是std::thread#include iostream #include thread void worker(int id) { std::cout 线程 id 运行中 std::endl; } int main() { std::thread t1(worker, 1); std::thread t2(worker, 2); t1.join(); // 等待t1结束 t2.join(); // 等待t2结束 return 0; }C线程需要注意的地方比较多std::thread对象析构时如果线程还在运行程序会直接终止所以必须调用join()或detach()线程函数传引用参数时要用std::ref包装否则会被拷贝。C#的线程创建更简洁Task.Run()是现代C#推荐的方式Task.Run(() Console.WriteLine(子线程运行中));Task底层使用的是线程池创建和调度开销远小于手动new Thread是.NET平台上的首选方案。3.4 Qt多线程继承QThread还是用QtConcurrentQt开发中多线程有两条路线继承QThread重写run()方法适合需要在独立线程中执行长任务的场景用QtConcurrent::run把任务丢到线程池里跑适合任务多且相对短小的场景。QThread还有一个容易被忽略的细节信号槽连接方式中默认的AutoConnection会检查信号发射线程和槽函数所在线程是否一致不一致时自动切换为队列连接也就是槽函数会在接收者所在线程执行。理解了这点就不会写出子线程里直接操作UI控件这种导致崩溃的代码了——UI操作必须放回主线程执行。4. 线程安全问题的根源与解决方案4.1 原子性、可见性、有序性——并发三大特性线程安全问题归根结底是三个特性被破坏。原子性一个操作要么全部执行完要么完全不执行不能被中断。比如count在底层其实是读取→加1→写回三步不是原子的两个线程同时执行就可能把加1操作覆盖掉。解决办法是加锁synchronized、ReentrantLock、使用AtomicInteger等原子类、或者用CAS比较并交换机制。可见性一个线程修改了共享变量其他线程不一定能立刻看到。因为CPU有缓存线程读的可能还是自己缓存里的旧值。volatile关键字可以解决可见性问题——保证每次读都能拿到最新值但它不保证原子性。所以volatile int count配合count依然是线程不安全的。有序性编译器和CPU为了优化性能可能会对指令进行重排。单线程下重排不影响结果但多线程下可能出现意想不到的顺序问题。加锁和volatile禁止重排序都能解决。4.2 synchronized和ReentrantLock怎么选Java里最常用的两种锁是synchronized关键字和ReentrantLock类。synchronized是JVM层面的锁使用简单自动释放即使抛异常也会释放锁在低竞争场景下性能已经很好了。JDK 6之后做了大量优化偏向锁、轻量级锁、锁粗化等日常开发中优先用synchronized就够了。ReentrantLock是API层面的锁功能更强大支持公平锁/非公平锁、可以响应中断、支持超时获取锁、可以绑定多个条件队列Condition实现精确唤醒。ReentrantLock lock new ReentrantLock(); try { lock.lock(); // 临界区代码 } finally { lock.unlock(); // 必须手动释放放在finally里防止死锁 }我的个人经验追求简单可靠用synchronized需要超时控制、可中断、多条件唤醒这些高级特性时再换ReentrantLock。不要一上来就无脑用ReentrantLock多一行手动释放锁的代码多一分死锁风险。4.3 数据库事务和本地锁不是一回事这一点特别容易混淆。多线程环境下的锁控制的是内存中代码块的执行互斥数据库的事务控制的是数据库数据的ACID特性。两者所处层级完全不同。一个典型误区是数据库行锁能防并发修改那代码里不用加锁了。实际上一个请求在查完数据库、还没写完数据库之间可能有另一线程插进来改了数据。你不加代码锁只靠数据库锁就会产生先读后写覆盖的问题。正确做法是代码层用分布式锁Redis的SETNX或ZooKeeper节点来控制操作顺序数据库锁则用来兜底保证最终一致性。5. 线程池生产环境真正的主角5.1 手动创建线程的代价很多新手习惯在需要时new Thread(...)任务结束线程就销毁了。这有两个问题频繁创建销毁线程操作系统要不断做资源分配和回收开销很大同时无限制创建线程内存迟早被耗尽——每个线程默认栈空间1MB1000个线程就是1GB的内存开销。线程池的思路是预先创建一定数量的线程放在池子里任务来了就分配给池中的空闲线程执行任务执行完线程不销毁回归池中等待下一个任务。重用线程避免重复创建销毁还能通过队列缓冲任务实现流量削峰。5.2 七参数面试必考题与配置建议Java的ThreadPoolExecutor构造函数有七个核心参数也是面试官最喜欢考的点corePoolSize核心线程数即使线程空闲也保留的线程数。maximumPoolSize最大线程数线程池中最多允许的线程数。keepAliveTime空闲存活时间非核心线程空闲超过这个时间就会被回收如果设置了allowCoreThreadTimeOut核心线程也会被回收。unitkeepAliveTime的时间单位。workQueue任务队列当线程数达到corePoolSize后新任务会放到队列里等待。threadFactory线程工厂用于创建线程可以指定线程名字、是否是守护线程等便于排查问题。handler拒绝策略当线程数达到maximumPoolSize且队列也满了时新任务的处理策略。参数配置必须结合业务场景。IO密集型任务文件读写、网络调用线程大部分时间在等待可以设置多一些线程推荐CPU核数乘以2左右CPU密集型任务线程基本一直在跑设置成CPU核数1即可。我见过某个团队把corePoolSize设成50但服务器只有4核CPU任务全是CPU密集计算——结果50个线程疯狂争抢4个核心大量时间耗在上下文切换上。后来调成5性能反而上去了。线程池参数没有银弹必须压测后调整。5.3 四种拒绝策略当线程池和队列都满了再提交新任务时触发的策略有四种AbortPolicy默认直接抛出RejectedExecutionException主调方可以感知到任务被拒。CallerRunsPolicy还在调用线程里执行这个任务不让任务丢失但会阻塞调用方起到自然限流的作用。DiscardPolicy静默丢弃任务不适合需要可靠处理的场景。DiscardOldestPolicy丢弃队列中等待最久的任务然后重试提交当前任务。实际项目中我比较推荐CallerRunsPolicy它把压力回传给调用方既不会丢数据也能反向压慢生产者的速度形成一种朴素的背压机制。但如果业务要求任务绝对不能丢那就得把拒绝的任务持久化到数据库或者消息队列后续补偿处理。6. 死锁所有并发问题的噩梦6.1 死锁的四个必要条件死锁的产生必须同时满足四个条件任何一个被破坏死锁都不会发生互斥条件资源同一时刻只能被一个线程持有。持有并等待持有资源的同时还去申请别的资源。不可剥夺条件资源只能被持有者主动释放不能被其他线程强行抢走。循环等待条件多个线程之间形成一条首尾相连的等待链比如线程A持有锁1等锁2线程B持有锁2等锁1。6.2 排查死锁的实用手段用Java的jstack工具排查死锁非常方便jps # 查看正在运行的Java进程PID jstack 进程PID # 导出线程堆栈信息如果存在死锁jstack输出末尾会明确提示Found one Java-level deadlock并且列出互相等待的锁和线程堆栈。这时去查代码里谁先持有了锁A又释放了锁B调整加锁顺序即可。另外JConsole和VisualVM也能图形化地检测死锁线程面板里选择检测死锁按钮会直接高亮出死锁的线程和它们等待的锁。6.3 从设计上杜绝死锁的四个思路破坏持有并等待一次性的用tryLock获取所有需要的锁Java的ReentrantLock.tryLock带超时机制获取不到全部锁就把已获取的释放掉。破坏循环等待给所有锁编号要求线程必须按序号从小到大获取锁避免交叉。减少锁粒度把一个对象上的大锁拆成多个细粒度锁降低锁竞争的概率。使用无锁数据结构比如ConcurrentHashMap、AtomicIntegerCAS本身就不会产生死锁。实际项目里最粗暴也最有效的办法是缩小锁的作用范围——能锁方法中的几行代码就别锁整个方法。锁的代码越少互相嵌套等待的可能性就越低。7. 多线程下载与断点续传的技术原理7.1 分块下载的底层逻辑多线程下载的核心原理就一句话把一个文件拆成多个小块每个线程负责下载其中一块全部下载完成后再拼接成完整文件。实现这个功能的关键是HTTP协议里的Range请求头。客户端向服务器请求时可以指定想要哪一段字节GET /file.zip HTTP/1.1 Host: example.com Range: bytes0-1048575服务器如果支持分段下载会返回206 Partial Content并在响应头里带上Content-Range: bytes 0-1048575/10485760表示本次返回的是整个文件的哪个部分。如果服务器不支持会返回200和整个文件。有了Range头多线程下载器的逻辑就清晰了先发一个HEAD请求拿到文件总大小。按总大小和线程数计算出每个线程要下载的起始和结束偏移量。每个线程发一个带上自己偏移量范围的GET请求。线程下载完自己的分段数据后按照偏移量写入本地文件对应位置使用RandomAccessFile的seek方法。所有线程完成后文件就拼装完整了。7.2 断点续传和临时文件记录的细节断点续传是在多线程下载基础上增加记忆能力。如果下载到一半程序崩了重启后怎么知道哪些分片已经下完了最简单的做法是记录下载进度到本地元数据文件比如JSON格式{ fileName: file.zip, fileSize: 10485760, chunks: [ {index: 0, start: 0, end: 1048575, finished: true}, {index: 1, start: 1048576, end: 2097151, finished: false} ] }重启后读取这个进度文件只对finished为false的分片发起下载请求就行。下载完成后再把分片合并删除临时文件和进度文件。有个细节容易忽略服务器返回的数据必须严格按照偏移量写入文件不能像顺序下载那样把流写进文件就行。用RandomAccessFile.seek(offset)定位写入位置然后用write(byte[])把分片数据写进去才能保证文件拼装正确。只要有一个分片写错位置整个文件就损坏了。7.3 写一个最小可用的多线程下载器这里我用Python写一个精简版的多线程下载器结构清晰适合学习原理import threading import requests def download_chunk(url, start, end, file_handle, lock): headers {Range: fbytes{start}-{end}} resp requests.get(url, headersheaders, streamTrue) file_handle.seek(start) for chunk in resp.iter_content(chunk_size8192): with lock: file_handle.write(chunk) def main(): url https://example.com/large-file.zip thread_count 4 # 先获取文件总大小只发HEAD请求不下载内容 head_resp requests.head(url) file_size int(head_resp.headers.get(Content-Length, 0)) chunk_size file_size // thread_count lock threading.Lock() with open(output.zip, wb) as f: threads [] for i in range(thread_count): start i * chunk_size end (i 1) * chunk_size - 1 if i thread_count - 1 else file_size - 1 t threading.Thread(targetdownload_chunk, args(url, start, end, f, lock)) threads.append(t) t.start() for t in threads: t.join() print(下载完成) if __name__ __main__: main()注意文件指针的移动file_handle.seek(start)必须先于写数据而且写操作要用lock保护防止多个线程同时写同一个文件时文件指针互相干扰。这套分片下载的原理其实和很多企业级下载工具、浏览器下载管理器是相通的。明白底层逻辑后如果再看到类似HTTP断点续传或多线程下载插件的实现基本一眼就能看懂。8. 多线程面试高频考点总结8.1 基础概念必问题多线程面试题里基础概念考察最密集的是这几类第一类是进程和线程的区别答题要点是资源分配与调度单位的区别、独立性与共享性、创建开销、通信方式。把本文第一章讲的内容答出来基本就能过关。第二类是创建线程的几种方式Java里是三选一Thread、Runnable、Callable要能说出各自的优缺点重点突出Callable的优势。第三类是sleep和wait的区别。核心是sleep()不释放锁wait()释放锁sleep()是Thread的静态方法wait()是Object的方法sleep()需要捕获InterruptedExceptionwait()要在同步代码块/同步方法中调用。还要能说出wait()必须在synchronized代码块里调用是因为它要操作当前线程的监视器锁状态。8.2 进阶考点并发工具与数据结构面试官问你ConcurrentHashMap怎么保证线程安全时不要只背答案。你要能说出JDK 7用的是分段锁Segment ReentrantLockJDK 8改成了CAS synchronized锁Node节点锁链表头或红黑树根节点这个过程是从锁竞争优化到细粒度同步的演进逻辑。CountDownLatch和CyclicBarrier的区别也是高频题前者是计数器递减模式等待的线程阻塞直到计数为0一次性使用后者是栅栏模式一组线程互相等待都到达后同时执行可循环复用。volatile与synchronized的区别也是必问volatile只保证可见性和有序性不保证原子性synchronized兼顾三者。如果面试官追问什么时候用volatile典型场景是状态标记位——一个线程修改flag其他线程读flag不需要复合操作用volatile就够了。8.3 项目经验怎么答不露怯面试官最爱问你项目里怎么用多线程的这道题考察的不是你会不会背API而是有没有真正的工程经验。比较好的回答套路是场景→方案→权衡→问题。比如我们有个推送服务要给十万用户发消息如果用串行方式要跑很久。我引入了线程池核心线程数根据CPU核数和IO等待耗时估算设为16最大线程数设为32队列容量设成1000。上线后发现线程数超过32后任务排队时延很高后来了解了Tomcat线程模型改用异步回调方式减少线程阻塞性能提升了3倍。这个回答提到了场景参数压测问题和解决方案面试官会认为你确实踩过坑、动过脑。如果你只说我用了线程池那基本就在面试评价表上打上经验浅薄了。9. 多线程编程的避坑经验清单9.1 线程数量的黄金法则真的存在吗网上流传了很多线程数计算公式CPU密集型用CPU核数1IO密集型用CPU核数×2。这些公式有参考价值但千万别死记硬套。现代系统的瓶颈五花八门数据库连接数、第三方接口限流、网络带宽、内存大小任何一个都可能成为真正的瓶颈。我个人的做法是先根据经验估算一个初始值然后用压力测试工具比如JMeter、wrk压出性能曲线观察吞吐量和响应时延的拐点多次调整后再确定最终值。线上运行一段时间后再根据监控数据微调。没有一劳永逸的参数只有不断调优的过程。9.2 线程安全容器的选择建议Java里线程安全的集合类很多容易选错。我的建议读多写少的Map用ConcurrentHashMap不要用Hashtable全表加锁性能差。需要按插入顺序遍历的Map用Collections.synchronizedMap(new LinkedHashMap())。需要做队列操作时单生产者单消费者用ConcurrentLinkedQueue多生产者多消费者用ArrayBlockingQueue或LinkedBlockingQueue。List的线程安全使用CopyOnWriteArrayList适合读多写极少比如白名单配置。Python里则要记住list、dict在CPython下因为有GIL保护某些操作本身是原子的但多个操作组合在一起比如d[count] 1依然不安全该加锁还是得加锁。9.3 真实线上事故给我的三个教训第一个教训是不要在持锁时做耗时操作。有一次我排查线上卡顿发现某个接口把网络请求都放在了synchronized块里一个线程卡在第三方接口超时上其他线程全在锁上排队整个服务响应全部变慢。后来把网络请求挪出锁只把读变量→更新变量这一步加锁问题立刻解决。第二个教训是全局静态变量是实现并发的最大坑。一个变量只要被多个线程同时写就一定有竞争。我见过一个案例开发用了public static SimpleDateFormat做日期格式化生产环境不定时报错。原因是SimpleDateFormat线程不安全多线程调用format()时内部Calendar状态混乱。改成ThreadLocalSimpleDateFormat后问题消失。第三个教训是线程池的线程名一定要自定义。线上排查问题如果所有线程都叫pool-1-thread-1根本定位不了问题是哪个业务触发的。用ThreadFactory设置业务相关的线程名比如order-push-thread-1出问题后看日志线程名就能锁定链路。这三条其实都是老生常谈但越基础的东西越容易在忙乱中忘掉。写并发代码时时刻问自己三个问题共享了什么在哪里互斥谁先执行谁后执行想清楚了再动手能省掉后面大量的排查时间。
返回列表