ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java多线程进阶:线程池、并发容器与线上排查实战

Java多线程进阶:线程池、并发容器与线上排查实战 多线程这块很多人学完理论还是写不出稳的代码——一上生产环境就出问题数据对不上、接口变慢、日志乱成一团。我见过不少项目的线上事故最后排查下来都是并发基础没打牢。这篇东西不打算从头讲什么是线程而是按我在实际项目里从“能跑”到“扛得住”的顺序把创建方式、同步机制、线程池、并发容器和排查手段串起来讲一遍。每个部分都有可以直接落地的代码和参数参考也有我踩过的坑。适合已经会写简单Java程序、但一碰到多线程就心里没底的开发者。1. 多线程没那么玄乎先搞懂执行模型再写代码很多初学者一上来就去看各种锁的源码结果越看越糊涂。我自己的经验是先搞清楚线程到底在计算机里怎么被执行的再回头看你写的代码很多问题会豁然开朗。1.1 线程是操作系统调度的执行单元一个Java程序启动后main方法跑在主线程里。当你手动创建Thread或者通过线程池提交任务JVM会向操作系统申请新的执行流。每个线程有自己的调用栈、程序计数器但共享同一个进程的堆内存和方法区。这意味着两个线程可以同时读同一个对象也可以同时去改它——这就是并发问题的根源。从操作系统的视角看一个线程在执行过程中会在三种状态间切换运行状态占用CPU、就绪状态等待CPU分配、阻塞状态等待I/O、等待锁、等待条件变量。Java线程状态虽然分成了NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种但本质上还是在描述同一件事。只是Java把“运行”和“就绪”合并成了RUNNABLE让你没法直接看出线程是真正在跑还是在排队等CPU。提示看线上线程状态时如果大量线程卡在RUNNABLE且CPU居高不下多半是死循环或者真正的计算密集型任务如果大量线程卡在BLOCKED或WAITING则要重点排查锁竞争和资源等待。1.2 为什么多线程不一定更快一个常见的误区是“线程开得越多处理越快”。实际上每个线程的创建、上下文切换、销毁都有成本。操作系统在切换线程时要保存和恢复寄存器、程序计数器、栈指针这个过程本身消耗CPU时间。如果你的任务是I/O密集型比如查数据库、调远程接口、读写文件多线程能把等待I/O的时间利用起来如果任务是CPU密集型比如大量计算、复杂算法那线程数接近CPU核心数就够了开多了反而因为上下文切换变慢。举个生活化的例子你去餐厅吃饭一个服务员可以同时接待多桌客人因为点菜、上菜之间有空闲等待时间。但如果每桌客人都要求服务员站在原地等菜出锅那再多的服务员也没用反而互相挡路。写代码也一样得先想清楚你的任务是“等得多”还是“算得多”。我一般会在项目里先做一个简单的压测用1个线程、核心线程数、2倍核心线程数分别跑同一批任务观察吞吐量和响应时间的变化。大多数情况下你会看到一个明显的拐点那个拐点就是这台机器上的合理线程数参考值。2. 创建线程的四种姿势别只会继承ThreadJava里创建线程的方式粗略分有四类继承Thread、实现Runnable、实现Callable、使用线程池。这四种方式各有应用场景但实际开发里90%的并发任务我都会交给线程池剩下的用Callable或者Runnable包装后提交。2.1 继承Thread最直观但别滥用public class MyThread extends Thread { Override public void run() { System.out.println(线程执行中 Thread.currentThread().getName()); } } // 使用 MyThread t new MyThread(); t.start();这种方式简单直观但有几个硬伤Java是单继承一旦继承了Thread就不能再继承其他类run()方法没有返回值无法获取执行结果每次执行任务都要新建线程无法复用。我在真实项目中基本不用这种方式只在写一些测试Demo时图方便才用。2.2 实现Runnable把任务和线程分开public class Task implements Runnable { Override public void run() { // 具体业务逻辑 System.out.println(处理任务中...); } } Thread worker new Thread(new Task()); worker.start();实现Runnable的好处是把“要执行的任务”和“执行任务的线程”解耦。任务本身不知道也不关心自己跑在哪个线程上这样任务可以被反复提交给不同线程。这是一个更符合面向对象设计的做法也是线程池出现之前的主流写法。2.3 Callable和FutureTask需要返回结果时的选择import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class CallableTask implements CallableInteger { Override public Integer call() throws Exception { Thread.sleep(1000); return 42; } } FutureTaskInteger futureTask new FutureTask(new CallableTask()); Thread t new Thread(futureTask); t.start(); Integer result futureTask.get(); // 阻塞等待结果Callable的call()方法可以返回结果也可以抛出异常。但直接用FutureTask包一层再手动起线程的方式本质上还是没解决线程复用问题。我一般在需要同步拿结果的场景会用ExecutorService.submit()直接返回Future?既解决了线程复用又能拿结果代码也简洁得多。2.4 线程池真正的主流生产方式关于线程池的细节后面有一整节单独讲这里先给一个直观对比方式是否复用线程能否获取结果适合场景继承Thread否否仅学习演示Runnable否否简单异步任务CallableFutureTask否是需要结果的一次性任务线程池是是几乎所有生产环境场景拿到一个需求先问自己三个问题这个任务需要马上执行吗需要拿到执行结果吗任务是频繁产生的吗如果前两个答案是“需要”第三个答案是“频繁”那基本就直接走线程池不用犹豫。3. 共享变量的并发难题可见性、原子性、有序性线程创建方式搞清楚了真正的坑才开始。多个线程一起读写共享变量时会出现三类问题可见性、原子性、有序性。这三个词听起来抽象但对应到实际故障就非常具体。3.1 可见性你在另一个线程里改的值主线程看得见吗先看这段代码public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (!flag) { // 空循环等待 } System.out.println(检测到flag变化退出循环); }); worker.start(); Thread.sleep(1000); flag true; // 主线程修改变量 worker.join(); } }直觉上主线程睡了1秒后再把flag改成true子线程应该马上退出循环。但如果你在多核机器上跑这个程序可能永远不退出。原因在于每个线程会把变量从主内存拷贝到自己的工作内存也就是CPU高速缓存子线程一直在自己缓存里读flag根本没注意到主线程已经改了主内存里的值。解决办法是给flag加上volatile关键字它保证了对变量的写操作能立即刷回主内存读操作也会强制从主内存读取。但要注意volatile只保证可见性不保证原子性。比如多个线程同时对count做count即使count被声明为volatile结果仍然是不准确的。因为count在底层是“读取-修改-写回”三步不具备原子性。3.2 原子性你以为是两行代码其实是好几条指令count在字节码层面包括GETSTATIC、IADD、PUTSTATIC等指令执行过程中随时可能被其他线程插进来。要保证操作的原子性常用的手段有使用AtomicInteger、AtomicLong等原子类底层基于CASCompare-And-Swap实现使用synchronized或Lock给代码块加锁使用LongAdder在高并发下比AtomicLong性能更好import java.util.concurrent.atomic.AtomicInteger; public class Counter { private final AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }CAS的原理可以想象成“先检查再更新”只有当前内存里的值仍然等于你预期的旧值时才把新值写进去否则就重试。这种方式不需要加锁竞争不激烈时性能很高但如果竞争非常激烈CAS会频繁重试性能反而下降。3.3 有序性编译器和CPU可能不按代码顺序执行为了优化执行效率编译器和CPU可能对指令进行重排序。在单线程环境下重排序不会影响最终结果因为会保证结果一致。但在多线程环境下一个线程看到的执行顺序可能和代码写的顺序完全不一样。经典的“双重检查锁定”单例模式就踩过这个坑早期Java版本里未加volatile的单例字段可能导致其他线程拿到一个“构造了一半”的对象。public 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在这里不仅保证了可见性还通过内存屏障禁止了指令重排序确保instance被完整构造后其他线程才能看到。3.4 synchronized和Lock哪种加锁方式更适合你synchronized是JVM内置的锁机制使用简单不需要手动解锁代码块执行完自动释放。JDK在后期对synchronized做了大量优化偏向锁、轻量级锁、自旋锁大部分场景下性能已经不输Lock。Lock比如ReentrantLock则提供了更多功能可中断锁、公平锁、超时等待、多个条件变量。我自己的选型标准很简单默认先写synchronized只有在需要“尝试获取锁、获取不到就算了”、需要“等待可中断”、需要“公平队列”时才换ReentrantLock。很多新手一上来就用ReentrantLock写一堆lock()、unlock()忘记在finally里释放直接导致死锁。用synchronized至少不会犯这种低级错误。4. 线程池怎么配才合理七个参数一个都不能拍脑袋线程池是Java并发里用得最多也最容易配错的组件。很多人直接用Executors.newFixedThreadPool(10)结果生产环境一出流量就出事。原因在于他们对背后的七个核心参数完全不了解。4.1 七参数拆解ThreadPoolExecutor的完整构造参数如下public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程空闲存活时间 TimeUnit unit, // 存活时间单位 BlockingQueueRunnable workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )每个参数的作用corePoolSize即使线程空闲也保留的最小线程数。默认情况下新任务到来时先创建核心线程去执行核心线程忙不过来再往队列里放。maximumPoolSize线程池允许的最大线程数。当任务队列也满了的时候会继续创建非核心线程直到达到这个上限。keepAliveTime unit非核心线程空闲多久后被回收。workQueue核心线程忙时任务先排队。常用有界队列ArrayBlockingQueue或者LinkedBlockingQueue。threadFactory创建线程的工厂可以给线程起名字方便排查问题。handler任务队列和最大线程都满了之后的处理策略。4.2 什么是有界队列、无界队列、同步队列LinkedBlockingQueue默认不传容量的话是“无界队列”容量是Integer.MAX_VALUE。这意味着任务永远能排进队列永远不会触发拒绝策略maximumPoolSize形同虚设。如果任务积压过多内存直接被打爆。这就是我不用newFixedThreadPool的原因——它的默认队列就是无界的。ArrayBlockingQueue需要指定容量是有界队列。队列满后才会启用非核心线程非核心线程也满了才会触发拒绝策略。SynchronousQueue不存任务直接把任务转交给线程没有空闲线程就创建新线程配合maximumPoolSize可以做到“临时扩容”。一个比较稳的经验值CPU密集型任务corePoolSize设为CPU核数加1或等于核数I/O密集型任务corePoolSize设为核心数的2倍以上具体要看阻塞比例我之前专门用“线程耗时/CPU计算耗时比”估算过。4.3 拒绝策略怎么选JDK内置了四种拒绝策略策略行为AbortPolicy直接抛出RejectedExecutionException会打断业务CallerRunsPolicy提交任务的那个线程自己执行适合削峰DiscardPolicy直接丢弃不提示慎用DiscardOldestPolicy丢弃队列里最老的任务再尝试提交新任务我在业务系统里一般用CallerRunsPolicy。它的好处是任务不丢而且慢下来的响应会反向压迫上游限流不会让线程池瞬间被击穿。但要注意如果当前提交任务的是请求线程那么这个请求会阻塞在线程池的任务执行里接口响应时间会变长。所以这个策略适合内部任务不适合直接用在计算开销极大的请求链路中。4.4 线程池的优雅关闭和线程泄漏很多项目上线后发观察一次接口就没反应了。绝大多数情况是线程池没正确关闭或者任务里抛了异常导致线程不正常回收。shutdown()会让线程池等已提交任务执行完才关shutdownNow()会尝试中断正在执行的任务并返回等待队列中的任务列表。另一个容易忽略的问题是线程池里的线程如果一直处于WAITING状态比如调了阻塞队列的take方法且没有中断机制shutdownNow()也未必能顺利回收。这种情况很隐蔽排查的时候要结合线程栈看看那些线程到底卡在哪一行代码。5. 并发容器与典型业务场景生产消费者模型实战并发问题不只会发生在自己写的变量上JDK自带的集合类很多也不是线程安全的。用错集合几乎是一秒钟就踩雷的事情。5.1 HashMap、ArrayList为什么在多线程下会出问题HashMap在并发扩容时早期版本会出现死循环虽然JDK 8后改成尾插法缓解了这个问题但仍然可能丢数据、覆盖数据。ArrayList在多线程同时add时可能导致数组越界或元素丢失。这些问题的根源在于它们的内部状态在无同步的情况下被多个线程同时改写。需要线程安全的场景可以用ConcurrentHashMap分段锁/CAS优化读多写少的首选CopyOnWriteArrayList写时复制适合读多写极少的场景Collections.synchronizedList粗暴地给所有方法加锁性能一般BlockingQueue族本身就是线程安全的队列适合生产消费者模型5.2 BlockingQueue实现生产消费者生产消费者模型是并发编程最经典的场景它有几种不同的构建方式但核心思想都是把“生产”和“消费”解耦。队列放在中间充当缓冲区两边都不需要知道对方的存在。import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class TaskQueue { private final BlockingQueueRunnable queue new LinkedBlockingQueue(100); public void produce(Runnable task) { try { queue.put(task); System.out.println(生产任务 task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public Runnable consume() { try { return queue.take(); // 队列为空时阻塞等待 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } }这里用put和take而不是add和poll是因为前两者自带阻塞效果队列满了生产者会等待队列空了消费者会等待。这比自己在外面写while (queue.isEmpty())轮询要靠谱得多也省CPU。5.3 一个完整的多线程任务分发Demo把前面讲的东西组合一下写一个简化版的异步任务分发器import java.util.concurrent.*; public class TaskDispatcher { private final ThreadPoolExecutor executor; public TaskDispatcher() { ExecutorService baseExecutor new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new NamedThreadFactory(task-dispatcher), new ThreadPoolExecutor.CallerRunsPolicy() ); this.executor (ThreadPoolExecutor) baseExecutor; } public Future? submit(Runnable task) { return executor.submit(task); } public void shutdownGracefully() { executor.shutdown(); try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); } } static class NamedThreadFactory implements ThreadFactory { private final String prefix; private int count 0; NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { return new Thread(r, prefix - (count)); } } }用的时候只需要TaskDispatcher dispatcher new TaskDispatcher(); dispatcher.submit(() - { // 模拟耗时操作 }); dispatcher.shutdownGracefully();这只是最基础的任务分发但已经够看懂线程池怎么在一个真实模块里工作了。下一步可以做的扩展包括任务结果回调用Future或者CompletableFuture、失败重试、定时调度等。6. 黑盒定位线程问题排查工具与经验代码写完了人总得面对生产环境。线程问题最头疼的地方在于它不是每次都能复现同一个接口偶尔慢一次数据偶尔错一笔。这时候靠直觉看代码不如靠工具看真实运行状态。6.1 jstack看线程状态和锁等待jstack pid是排查线程问题的第一把钥匙。执行后会输出进程里所有线程的栈信息重点关注BLOCKED和WAITING的线程。如果看到大量线程卡在同一个对象上并且stack里带着锁信息说明发生了锁竞争。有一次我们线上出现了接口超时我打了一份jstack发现几十个线程卡在同一个数据库连接池获取连接的方法上。正常逻辑是连接池没连接了应该等待并超时但某个连接没被正确归还导致所有线程都堵在那。这个问题如果只看代码根本发现不了是因为连接池复用逻辑里某个分支没有执行finally中的归还操作。提示分析jstack时重点关注线程名字。线程池里自己起名字的线程一眼就能看出是哪个池子的问题不自定义线程名的排查成本会高很多。6.2 死锁的检测与预防死锁的产生需要四个条件同时成立互斥、持有并等待、不可剥夺、循环等待。实际编码里最常见的原因是“锁的顺序不一致”——线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1两者刚好交错互相等对方释放锁。死锁的检测可以借助jstack看到类似“Found one Java-level deadlock”的提示。但要等到检测到就已经晚了最好在代码层面做好预防加锁的顺序全局一致比如统一按某个业务ID排序后再加锁使用tryLock(timeout)给获取锁加超时拿不到就放弃释放已持有的锁尽量减少锁的范围把资源竞争尽可能集中到最小临界区6.3 CPU飙高和线程泄漏的排查思路线程泄漏不是一次性的问题而是“只增不减”的隐性问题。常见原因有没关闭线程池、任务异常导致线程卡死、第三方库内部缓存线程不被回收。排查时用jstack看线程数量变化再用top -H -p pid看具体线程CPU占用。如果一个线程长期占满一个CPU核心多半是死循环或者热点计算。CPU飙高的处理思路先抓线程快照定位到线程ID再把线程ID转成十六进制在jstack输出里搜到对应线程栈看它到底在跑什么代码。有时候你会发现是一个正则表达式在回溯匹配有时候是HashMap.get在一个超大链表上遍历。这些问题的共同点是“看起来只是数据量大了一点但执行时间突然失控”。6.4 日志和压测辅助验证排查问题最好能先恢复现场的近似条件。我的习惯是写一个小的复现程序用线程池并发调目标方法配合不同参数组合看是否能稳定复现问题。如果能复现就在关键代码段打印日志和时间戳定位耗时分布。这个方法听起来笨但比在线上改代码打日志要安全得多。压测时还有一个容易被忽略的点测试机器的CPU核数和生产者在线上的核数相差很大配置的线程池参数需要按机器规格单独调整不能直接沿用测试环境的配置。我自己就吃过这个亏拿到一个32核的压测结果后往8核生产环境一配效果完全两码事。7. 常见误区与我的实操判断最后把多线程实践里反复出现的一些误区集中列一下都是我在代码评审和排障过程中实际看到过的。7.1 volatile真的能保证线程安全吗不能。我经常看到有人给普通变量加上volatile就说它是线程安全的。volatile能保证可见性和一定程度的顺序性但对于复合操作如count、list.add()、map.put()这种组合动作无能为力。它适合的场景很窄一个线程写、多个线程读的状态开关或者作为“先行发生”规则的触发点。7.2 线程数是不是越多越好不是。前面说过上下文切换有成本。线程数增加到一定程度后每个线程的执行效率会下降整体吞吐量可能不再增长甚至下降。从资源角度看每开一个线程默认栈大小可能就要占用几百KB到1MB内存无限开线程迟早遇到内存或者句柄限制。我之前见过一个项目每接收一个外部通知就手动new Thread(...).start()一天上千万次调用直接导致线程数量爆炸。后面改成线程池加限流CPU占用下降了一截稳定性也上来了。7.3 synchronized和ReentrantLock到底怎么选如果团队没有特殊要求我会优先使用synchronized。理由前面说过了不容易漏解锁语义清楚。ReentrantLock在多路分支需要手动解锁的复杂场景用起来很容易出错尤其是有多个return语句的方法。当然如果你确实需要“公平锁”或者“可中断等待”那ReentrantLock不可替代。7.4 写并发代码时最重要的三个习惯第一共享变量的范围尽量缩小。能用局部变量就不要放到成员变量能用参数传递就不要做全局状态。第二所有共享资源的访问统一走同步手段不要有的地方加锁有的地方裸读。第三给线程起好名字给锁操作加上注释和边界说明。这是给自己减少排查成本也是给组里后人留活路。多线程的代码写起来并不难难的是让它在高并发下稳定运行。把自己写的线程池参数、加锁逻辑、队列选型都想清楚再配合故障排查手段大部分并发问题都能在萌芽阶段被摁住。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表