ARTICLE DETAIL

资讯详情

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

Java线程进阶:从线程池配置到死锁排查与并发实战

Java线程进阶:从线程池配置到死锁排查与并发实战 聊到JAVA进阶十个里有八个会卡在线程这道坎上。线程这玩意儿日常写着好像也就new Thread().start()一句话的事可一旦到了性能优化、线上排查、面试深挖它就是区分初级和进阶的一道分水岭。线程池怎么配、锁怎么选、死锁怎么排查、ThreadLocal怎么用才不泄漏这些问题不是背几道面试题就能解决的得真正理解线程背后的运行机制。这篇文章我就从线程的本质讲起一路拆到线程池、并发工具、死锁排查最后落到面试高频考点上。适合那种已经把synchronized写得滚瓜烂熟但碰到并发场景还是心里没底的同学也适合准备跳槽想系统捋一遍线程知识点的朋友。我会把我实际踩过的坑、排查过的问题、还有那种网上文档里不会明说的经验都一并写出来。1. 线程到底是什么从进程说起1.1 进程与线程的区别很多教材一上来就列对比表但说实话不干活的人背了也白背。我用一个餐厅的类比来解释进程就是一家餐厅它拥有自己的厨房、库房、收银台也就是独立的内存空间、文件句柄和系统资源。线程就是餐厅里的厨师和服务员他们共享同一间厨房和库房一起为客人提供服务也就是共享进程的内存和资源。进程与线程最本质的区别在于进程是资源分配的基本单位线程是CPU调度的基本单位。一个进程挂了不影响其他进程但一个线程崩了如果处理不当整个进程可能跟着崩。进程之间相互隔离线程之间却天然共享内存这正是并发编程既高效又危险的根源。我先说一个很多新手容易忽略的点线程切换不是免费的。CPU从一个线程切到另一个线程要保存当前线程的寄存器状态、程序计数器等上下文信息下次切回来再恢复这个开销叫上下文切换。进程切换的代价比线程切换大得多因为进程切换还要切换内存地址空间这也就是为什么多线程比多进程在并发场景下更受欢迎的原因之一。1.2 线程为什么能提升性能又为什么会带来麻烦当年单核CPU时代引入线程核心目的是不浪费CPU。一个线程发起了IO请求在等待磁盘或网络响应时CPU是空闲的。这时候切换到另一个线程去跑计算相当于把等待时间利用了起来。到了多核时代线程的价值就更直接了8核CPU想跑满至少得8个线程同时跑。但线程带来性能的同时也带来了几个老大难问题竞态条件多个线程同时读写共享变量互相覆盖结果。可见性问题一个线程改了值另一个线程看不到最新值。死锁线程互相持有对方需要的锁谁也不让谁。上下文切换开销线程开太多CPU全在保存和恢复寄存器正经活反而不干了。这里要提一个很有意思的检索问题——线程切换时会泄漏吗。直接说结论线程切换本身不会泄漏。切换只是保存和恢复寄存器状态不涉及内存分配和释放哪来的泄漏真正会泄漏的是线程对象本身比如每次请求都new Thread()线程跑完也没人回收或者线程池被创建了却一直不关闭。所谓“泄漏”往往是生命周期管理不当的另一种说法。我自己刚学线程时干过一件蠢事为了加快处理速度方法里每次调用都开一个线程结果一次高并发压测直接把内存打爆了。后来才明白线程是昂贵资源创建和销毁都有成本与其频繁开关不如复用。2. 线程的创建方式与生命周期管理2.1 三种创建方式的正确选型Java里创建线程有三种方式继承Thread类、实现Runnable接口、实现Callable接口。// 方式一继承Thread class MyThread extends Thread { Override public void run() { System.out.println(继承Thread); } } new MyThread().start(); // 方式二实现Runnable new Thread(() - System.out.println(实现Runnable)).start(); // 方式三实现Callable可以获取返回值 CallableString callable () - callable结果; FutureTaskString task new FutureTask(callable); new Thread(task).start(); String result task.get(); // 阻塞等待执行结果从进阶的角度讲强烈不建议用继承Thread的方式。Java是单继承一旦继承了Thread这个类就不能再继承其他类以后想加功能都麻烦。而Runnable和Callable只是接口灵活性高得多。Callable比Runnable强的地方是能返回结果、能抛异常但代价是FutureTask.get()会阻塞调用线程用的时候要注意别把异步变回同步。2.2 线程生命周期与状态切换Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这个知识点面试经常问但很多人背得溜问深一点就露馅。NEW创建了线程对象但还没调用start()。RUNNABLE包括运行中和就绪两种状态等待CPU时间片。BLOCKED线程在等待进入同步代码块也就是在等锁。WAITING线程在等待被显式唤醒比如调用了wait()、join()。TIMED_WAITING带超时时间的等待比如sleep(1000)。TERMINATED线程执行完毕。状态之间的切换路径很关键。比如wait()之后进入 WAITING必须由其他线程调用notify()或notifyAll()才能唤醒sleep()则进入 TIMED_WAITING时间到了自动醒来不需要别人唤醒。这个区别看着小实际写代码时影响巨大比如生产者消费者模型如果该用notifyAll()却只用了notify()可能造成所有消费者都在等待却没人唤醒的“假死”状态。注意线程的run()方法执行完线程就进入 TERMINATED不能重新start()。同一个线程只能启动一次这个坑新手必踩。想复用线程逻辑请扔给线程池。2.3 守护线程的正确打开方式守护线程是Java里一个容易被忽略但很有用的概念。调用setDaemon(true)就能把线程标记为守护线程但必须在start()之前调用否则会抛出IllegalThreadStateException。守护线程的特点是当进程中只剩下守护线程时JVM会自动退出不会等守护线程运行完。这个特性适合做后台任务比如心跳检测、定时清理、监控上报。但反过来想如果你在守护线程里写了一些重要的资源清理逻辑比如写日志、保存状态进程退出时可能根本没执行完就被强制终止了。我之前在一个项目里用守护线程做定时上报数据本来想着好用不阻塞主线程退出结果有一次发布时发现最后一批数据没上报排查了很久才意识到是守护线程被强制终止了。所以现在我的经验是低优先级的辅助任务可以交给守护线程但绝不能把关键业务逻辑放在守护线程里。3. 线程安全的本质互斥、可见性与死锁3.1 竞态条件与synchronized原理先想一个问题多个线程同时执行count这个操作安全吗不安全。因为count不是原子操作它在字节码层面是“读取–加一–写回”三步。两个线程可能同时读到同一个值各自加一再写回结果只加了一次这就是竞态条件。synchronized是Java最基础的同步机制它能保证进入同步代码块的线程互斥执行同时保证修改对后续进入的线程可见。它的原理在JVM层面是对象监视器monitor每个Java对象天生就带一个监视器。线程进入synchronized代码块要获取对象的监视器锁别的线程想进就只能 BLOCKED 等着。用生活类比就是厕所门锁一个人进去了把门锁上其他人只能在外面等着。出来之后开门下一个再进。这就是互斥。synchornized有三种用法// 锁对象不同实例之间互不影响 synchronized (lockObject) { } // 锁实例方法同一个实例的不同线程互斥 public synchronized void method() { } // 锁类所有实例全局互斥 public static synchronized void staticMethod() { }实际开发中的经验是锁的粒度要尽量小。能锁代码块就别锁整个方法不然一个方法里不需要同步的耗时操作也会阻塞其他线程得不偿失。但也别锁到连原子性都保证不了。锁的粒度是个平衡的艺术没有绝对标准要看业务场景。3.2 volatile与AtomicInteger的取舍synchronized太重了有些场景只是想要个可见性不想互斥。这时候可以用volatile。它的核心作用是当一个变量被声明为volatile后线程对这个变量的读操作会直接从主内存读写操作会直接写回主内存从而保证其他线程能立即看到最新值。但注意一个最容易记错的知识点volatile保证可见性不保证原子性。也就是说volatile int count的count依然是不安全的因为三步操作中间依然可能被插队。volatile适合用来做状态标志位比如volatile boolean running true; // 线程A检测标志位需要立即看到线程B的修改 while (running) { // 执行任务 } // 线程B停止任务 running false;如果既要可见性又要原子性可以用AtomicInteger。它内部通过CASCompare And Swap实现线程安全的原子操作比synchronized性能更好因为它不需要线程挂起和恢复。有人问AtomicInteger线程安全吗答案是安全的。CAS操作是CPU级别的原子指令不会被线程调度打断。但要注意CAS存在ABA问题线程读到值A另一个线程改成B又改回A第一个线程的CAS检查发现值还是A就以为没人动过。大多数业务场景ABA无伤大雅但如果要严格避免可以用AtomicStampedReference加版本号。三者的选择建议工具可见性原子性适用场景volatile保证不保证状态标志位、开关控制synchronized保证保证代码块互斥、复合操作AtomicInteger保证保证单一变量计数、累加3.3 死锁的成因与排查工具死锁是并发编程最经典的问题也是面试和线上事故的重灾区。死锁发生的四个必要条件缺一不可互斥资源同一时刻只能被一个线程占用。持有并等待线程已经持有一个资源还在等待另一个资源。不可剥夺资源不能被强行抢走只能由持有者主动释放。循环等待线程A等线程B的资源线程B等线程A的资源形成环路。典型的死锁代码// 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 业务 } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 业务 } }两个线程各持一把锁都在等对方的锁谁也不放手就死锁了。排查死锁有一套标准步骤。先用jps查出Java进程ID然后用jstack 进程ID导出线程栈。如果存在死锁jstack会直接打印出类似Found one Java-level deadlock的提示并且指出两个线程分别等待哪把锁。我用过一次当时直接定位到了两个服务之间互相调用时锁顺序不一致的问题。jvisualvm也可以看图形界面更直观线上环境如果没法用图形界面jstack是首选。解决死锁的常用思路一是让所有线程按固定顺序获取锁比如先锁A再锁B避免环路二是用tryLock带超时时间拿不到锁就重试或放弃而不是无限等待三是尽量缩小锁的范围减少持有锁的时间。4. 线程池进阶必备的核心工具4.1 线程池的核心参数与阻塞队列选择为什么要用线程池而不是new Thread三句话复用线程降低开销、控制并发数量防止系统被拖垮、方便统一管理生命周期。线程池不是银弹但掌握好它并发编程的基本功就扎实了一半。ThreadPoolExecutor有七个核心参数面试必考new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 );这里最容易让新手懵的是线程池的执行流程新的任务来了先看核心线程数有没有满没满直接开新线程执行满了一边任务先放进工作队列队列也满了再尝试开非核心线程到最大线程数如果最大线程数也满了触发拒绝策略。阻塞队列的选择是线程池配置的关键坑点。常见的有三种LinkedBlockingQueue链表实现默认无界队列。如果把它当线程池的队列用任务可以无限往里塞永远不会触发拒绝策略这意味着最大线程数参数形同虚设。ArrayBlockingQueue数组实现有界队列必须指定容量。这个才能让线程池面对突发流量时有“兜底”机制。SynchronousQueue没有存储空间的队列任务直接交给线程不经过排队。适合需要“有多少任务就开多少线程”的场景吞吐量高但风险也大。我看到很多团队用默认线程池Executors.newFixedThreadPool()它的队列是LinkedBlockingQueue无界的这在高并发下有内存溢出风险。阿里Java开发规范明确禁止使用Executors的快捷方法创建线程池就是这个原因。我的建议是生产环境全部用ThreadPoolExecutor显式构造队列用有界的。4.2 拒绝策略与线程池配置经验当线程池饱和达到最大线程数且队列满时会触发拒绝策略Java内置四种AbortPolicy默认直接抛RejectedExecutionException让调用方感知并处理。CallerRunsPolicy不抛异常让提交任务的线程自己执行这个任务。相当于变相降速适合对实时性要求不高的场景。DiscardPolicy静默丢弃不通知不报错。风险是业务数据悄悄丢失。DiscardOldestPolicy丢弃队列里最旧的任务再尝试提交新任务。配置线程池参数没有标准答案但有一个相对靠谱的经验公式。CPU密集型任务核心线程数CPU核心数1没必要开太多开多了都在抢CPU反而增加上下文切换。IO密集型任务核心线程数可以开到CPU核心数 * 2因为IO操作时CPU是空闲的可以多开线程让它们交替使用CPU。更精确的方法是压测观察响应时间、吞吐量和系统负载逐步调整。我还想特别提醒一点线程池的线程名一定要自定义。用一个ThreadFactory给线程起名比如order-pool-thread-1。否则排查线上问题时线程栈里全是pool-1-thread-1这种名字根本分不清是哪个业务模块排查效率极低。这是我吃了很多次亏之后的血泪经验。最关键的一点用完线程池要调用shutdown()或shutdownNow()。如果你在框架里动态创建了线程池又忘记关闭线程池里的空闲线程会一直存活这就是线程泄漏的直接原因之一。4.3 线程池踩坑实录先分享一个我实际线上遇到的经典问题线程池的异常被吞了。当时用execute()提交任务某天线上突然发现一批数据没处理日志里也没报错。排查半天发现线程池里某个任务抛了异常而execute()方式没有捕获异常直接终止了那条线程但线程池会静默创建一个新线程顶上异常信息就消失在黑夜里了。解决办法有二要么任务里自己try/catch并记录日志要么改用submit()然后主动检查Future.get()的异常。再讲讲完整等线程池任务跑完的问题。常用的工具是CountDownLatchCountDownLatch latch new CountDownLatch(10); ExecutorService pool Executors.newFixedThreadPool(5); for (int i 0; i 10; i) { pool.submit(() - { try { // 业务逻辑 } finally { latch.countDown(); // 每个任务完成就减一 } }); } latch.await(30, TimeUnit.SECONDS); // 主线程等待所有任务完成最多30秒这个能解决“java线程等待都完成”的需求。注意两个细节countDown()要放在finally里防止任务异常时计数永远减不完await()要带超时时间否则万一有个任务卡死主线程会无限等下去。还有一个细节是execute()和submit()的区别submit()能拿Future可以拿到返回值、捕获异常但代价是Future.get()会阻塞。用的时候权衡好即可。5. 线程间协作与高级并发工具5.1 wait/notify与并发工具类线程间的协作比单纯的互斥更高级。经典的生产者消费者模式最基础的实现就是wait()和notifyAll()synchronized (queue) { while (queue.isEmpty()) { queue.wait(); // 队列空生产者等待 } queue.remove(); queue.notifyAll(); // 唤醒所有等待的生产者 }这里的核心知识点是wait()会让出锁并进入 WAITING 状态而sleep()不会释放锁这是两者最本质的区别。还有一个小坑判断条件要用while而不是if因为线程被唤醒后需要重新检查条件用if可能直接跳过条件判断导致消费掉不存在的元素。除此之外JDK还提供了更高级的并发工具CountDownLatch一个或多个线程等待其他线程完成特定操作计数只能减不能增。CyclicBarrier多个线程互相等待都到达屏障点后才一起继续别名叫“循环栅栏”因为它可以重复使用。Semaphore信号量控制同时访问某个资源的线程数量比如限流。FutureTask代表一个异步计算的结果可以取消、可以判断是否完成。5.2 线程切换的代价与“泄漏”疑问回到之前那个问题线程切换时会泄漏吗。我想更完整地聊一下。上下文切换本身不会泄漏任何内存但频繁切换会消耗大量的CPU时间。一次上下文切换的耗时大概是几微秒听起来微不足道但如果每秒切换几万次、几十万次CPU的可利用时间就大打折扣了。我之前做过一个批量推荐任务数据量几十万条每个任务都很轻我用了一个100个线程的线程池来处理。结果发现性能反而比10个线程还差。后来用配置中心看线程状态发现CPU有大量时间花在上下文切换上。任务太轻计算量远小于调度开销多线程毫无意义。然后说真正的线程泄漏。最常见的原因是手动new Thread且没有关闭机制高并发下线程数爆炸式增长内存耗尽。线程池未调用shutdown空闲线程一直存在因为线程池引用被GC回收不了。无界队列堆积任务队列里的任务占用大量内存间接导致系统缓慢甚至OOM。排查线程泄漏的方法网上搜一下就发现基本都指向jstack和jconsole。先jps找到进程ID再用jstack看线程快照数一下有多少线程、各自是什么状态。如果发现某个状态是WAITING的线程数量异常多且线程名都相似那基本就是线程池泄漏或者线程饿死。5.3 面试题实战高频考点拆解既然是进阶篇章最后落回面试场景。我梳理几个线程相关的高频面试题直接把答题思路写出来。sleep和wait的区别sleep属于Thread类wait属于Object类sleep不释放锁wait释放锁并让出CPUsleep自动醒来wait需要别人唤醒或超时。如何保证线程安全答三点原子性synchronized、Lock、CAS、可见性volatile、有序性happens-before原则。还要补充说优先考虑封装共享数据减少共享范围。手写双重检查锁单例class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里volatile不仅要防止第三个线程读到半初始化的对象还要防止指令重排。具体原理是new Singleton()在底层分三步分配内存、初始化对象、把引用指向内存。如果不加volatile第三步可能先于第二步执行另一个线程看到引用非空但对象还没初始化完使用时就出问题了。线程池参数怎么配置不要背公式按CPU密集型和IO密集型的思路答然后强调有界队列、自定义ThreadFactory、明确拒绝策略这样回答会比只会背corePoolSize定义的人高一个层次。关于“java面试题”这个话题很多人喜欢疯狂刷题但我发现真正有效的准备方式是把线程池、锁、并发工具这几个核心概念吃透然后亲手写几个并发程序观察它们的运行结果。比如写一个死锁复现再自己用jstack排查一遍这个过程比刷一百道题都管用。我这几年带团队面试问掉为“线程池参数怎么填”的人不少能说出“为什么不能无界队列”的人真不多。聊到这里我对线程这个东西最大的体会就是它不是一个孤立的知识点而是内存模型、操作系统调度、并发设计思想交汇在一起的东西。你把它拆开看每个点都不难难的是组合起来的时候能快速定位问题。如果你读到这里准备实践我建议从两个小实验开始一个是用线程池并发处理一批任务并等待全部完成一个是故意写一个死锁再用jstack把它揪出来。这两个做完线程基本就算入门了。
返回列表