做Java开发这么多年,线程相关的坑我没少踩。刚入行那会儿,以为多线程不就是new Thread然后start,后来又经历了线上服务线程池被打满、死锁导致接口集体超时、诡异的数据错乱怎么调都复现不了,才慢慢明白Java多线程这门课,确实是进阶路上绕不过去的分水岭。面试基本上必问,线上问题排查十有八九跟并发相关,很多框架源码里到处都是线程、锁、异步的影子,看不懂这些,看源码就跟看天书一样。
这篇文章不打算做成API手册式的罗列,而是按照我自己从入门到实战这些年沉淀下来的思路,把Java多线程拆成几个核心板块:先搞懂线程的底层认知,再掌握创建线程的正确姿势,接着把锁、原子性、可见性这些线程安全的关键吃透,然后重点讲讲生产环境中真正常用的线程池,最后配合异步编程和问题排查的真实案例。不管你是刚准备进阶的初级开发者,还是已经写过一些并发代码但总感觉不踏实的同学,这篇文章应该都能帮你在脑子里搭起一个清晰的框架。内容尽量说人话,用实际场景来解释底层原理,代码能跑的直接给出可复制的版本。
1. 多线程到底难在哪:先解决认知问题
先说一个很扎心的事实:很多同学学了多线程,背了一堆概念,一到写代码或者排查问题的时候依然手足无措。原因很简单,多线程的难点从来不在API,而在“并发问题往往是不确定的,bug难以复现,逻辑难以推演”。单线程程序就像一根直线,从头到尾执行顺序是确定的;多线程程序就像好几根线缠在一起,交叉、竞争、互相等待,任何一步时序不同,结果就可能不一样。
1.1 并发与并行:别再把两者说混了
并发和并行这两个词天天挂在嘴边,但很多人其实没分清楚。并发(Concurrency)指的是多个任务在同一时间段内交替执行,单核CPU上也能实现并发,因为CPU时间片足够小,看起来像同时进行;并行(Parallelism)则是多个任务在同一个时刻真正同时执行,这要求多核CPU或者多台机器。举个生活化的例子:你一个人一边泡面一边刷手机,这是并发,因为你只是在快速切换;你和朋友一个泡面一个刷手机,这是并行,因为两个人真的同时在干。
Java多线程既可以跑在单核上实现并发,也可以利用多核实现并行,这取决于你的调度和硬件资源。搞清楚这个区别的意义在于:当你想通过多线程提升性能时,要先确认你的机器是不是多核,如果只有单核,线程再多也只是增加上下文切换的开销,性能反而会更差。
1.2 线程的生命周期:六种状态一张图记牢
Java线程的状态在Thread.State这个枚举里定义得很清楚,一共六种:
- NEW:刚new出来,还没调用start()。
- RUNNABLE:调用start()之后,线程处于可运行状态,包括了操作系统层面的“正在运行”和“等待CPU时间片”两种情况。
- BLOCKED:等待获取monitor锁,比如进入synchronized代码块时锁被其他线程持有。
- WAITING:无限期等待,比如调用了Object.wait()、Thread.join()、LockSupport.park()。
- TIMED_WAITING:有限期等待,比如sleep(1000)、wait(1000)、join(1000)。
- TERMINATED:执行完毕,线程生命周期结束。
这里最容易被忽略的是BLOCKED和WAITING的区别。BLOCKED是被动地等别人释放锁,WAITING是主动地等别人来唤醒你。举个例子,两个线程同时抢一把锁,没抢到的那一方就是BLOCKED;而线程调用了wait()之后,主动让出CPU并进入等待队列,等待其他线程notify(),这就是WAITING。搞清楚这两种状态,后面看jstack线程快照时会很有帮助,一眼就能判断线程到底是被锁阻塞还是单纯在等唤醒。
1.3 上下文切换:多线程开销的真正来源
很多新手以为线程越多性能越强,这是误解。CPU在每个时刻只能执行一个线程,如果线程数超过CPU核数,操作系统就得频繁切换执行的线程,每次切换都要保存当前线程的执行状态(寄存器、程序计数器、栈指针等),加载下一个线程的状态,这个过程就是上下文切换(Context Switch)。上下文切换是要消耗CPU时间的,有测试数据表明一次上下文切换大概耗费几微秒,看起来不多,但如果切换频繁,累积起来相当可观。
所以多线程的性能调优,很多时候不是无脑增加线程数,而是减少不必要的上下文切换。比如使用无锁并发结构(CAS)、减少锁竞争、合理设置线程池大小、尽量让线程执行时间均匀而不是频繁阻塞唤醒,这些都是围绕“减少切换开销”这个核心在做文章。
2. 创建线程的四种姿势与选型思路
Java创建线程表面上有很多种方式,底层其实都绕不开Thread类。搞明白每种方式适用什么场景,比单纯记住怎么写更重要。
2.1 直接继承Thread或实现Runnable:最简单的入口
// 方式一:继承Thread class MyThread extends Thread { @Override public void run() { System.out.println("线程运行中:" + Thread.currentThread().getName()); } } new MyThread().start(); // 方式二:实现Runnable Runnable task = () -> System.out.println("Runnable运行中:" + Thread.currentThread().getName()); new Thread(task, "业务线程").start();这两种方式本质上没区别,Thread本身也实现了Runnable接口。但我强烈建议优先用实现Runnable的方式,原因有三个:Java是单继承,继承Thread之后你这个类就不能继承其他业务类了;Runnable把任务逻辑和线程执行机制解耦,任务可以复用;配合线程池使用时,Runnable/Callable是任务的载体,而Thread是执行载体,用Runnable语义上更清晰。
2.2 用Callable获取返回值:终结“共享变量传结果”的脏套路
Runnable的run()方法没有返回值,这在需要获取线程执行结果的场景里非常憋屈。早期的解决办法是搞一个共享变量,线程往里面塞结果,主线程再去读,这很容易出现可见性问题。正确姿势是用Callable配合FutureTask:
Callable<Integer> callable = () -> { // 模拟耗时计算 Thread.sleep(1000); return 1 + 1; }; FutureTask<Integer> futureTask = new FutureTask<>(callable); new Thread(futureTask).start(); // 主线程可以干别的事,最后再取结果 Integer result = futureTask.get(); // 阻塞直到线程返回 System.out.println(result);这里有个关键点:futureTask.get()是阻塞方法,如果任务一直不结束,主线程会一直等下去。可以设置超时时间:get(3, TimeUnit.SECONDS),超时抛出TimeoutException。还有一个细节是FutureTask的get()支持中断,如果另一个线程调用了持有futureTask的线程的interrupt(),get()会抛出InterruptedException,所以不要随便忽略这个异常。
2.3 直接new Thread的坏处:存量代码里为什么总有阴影
直接new Thread写起来确实爽,一行代码就能跑,但在真实项目中要非常谨慎。每new一个Thread就是创建一个独立的线程对象,线程执行完就销毁,如果任务频繁,大量线程反复创建销毁,CPU开销全耗在线程生命周期管理上。另一个隐藏问题是线程缺乏统一管理,没有上限,一旦请求量上来,线程数可以膨胀到把内存耗尽或者把CPU打满。我见过一个真实案例,某系统里有一段定时任务代码,每次执行就new一个Thread处理批量数据,某次数据量暴增,瞬间创建上千个线程,直接把服务拖垮。
结论是:日常开发中new Thread只适合写写Demo、测试简单逻辑,生产环境的代码应该全部走线程池。这不是矫枉过正,而是用钱买来的教训。
3. 线程安全的核心:锁、原子性与可见性
线程安全是并发编程的“心脏”,这块搞不透,后面写什么并发代码都是埋雷。Java里保证线程安全的武器很多,但核心思想无非三种:互斥同步、非阻塞同步、无同步方案。互斥同步典型代表是synchronized和ReentrantLock;非阻塞同步主要是CAS;无同步方案比如ThreadLocal和不可变对象。
3.1 synchronized的锁升级:从一个Java程序员都该懂的优化说起
很多初学synchronized的同学,以为它就是一个“重量级锁”,性能不行。但其实在JDK 6之后,synchronized经过大量优化,性能已经不输ReentrantLock,原因就是它引入了锁升级机制。synchronized锁一共有四种状态,按顺序升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
偏向锁的核心逻辑是:如果只有一个线程访问同步块,那这个线程记录自己的线程ID在锁对象头里,下次进入就无需任何CAS操作,直接通过。这就像图书馆的座位,没人跟你抢,你坐上去可以不用锁门。一旦有第二个线程来竞争,偏向锁撤销并升级为轻量级锁。轻量级锁通过CAS自旋来获取锁,如果自旋成功就拿到锁,自旋失败或者自旋次数超限,就膨胀为重量级锁,此时线程会进入阻塞状态,操作系统层面的线程挂起唤醒,开销最大。
理解了锁升级,你就明白为什么说“synchronized没那么重”。它会在低竞争场景下自动选择开销最小的实现方式。这里有个实践启发:如果你能确定并发竞争不激烈,用synchronized其实是很香的,代码简洁,还不用担心忘记释放锁。
3.2 synchronized和ReentrantLock,到底怎么选
很多面试官喜欢问“synchronized和ReentrantLock的区别”,但实际开发中怎么选才是真正要命的。我自己的经验是这样:
- 锁的灵活性:synchronized是隐式的,进代码块自动加锁,出代码块自动解锁;ReentrantLock需要手动lock()和unlock(),但换来的是
tryLock()、lockInterruptibly()、公平锁这些高级能力。比如tryLock(2, TimeUnit.SECONDS),抢不到锁就返回false而不是死等,这在处理超时场景时非常有用。 - 公平性:synchronized是非公平锁,ReentrantLock默认也是非公平的,但可以通过构造参数
new ReentrantLock(true)创建公平锁。非公平锁的好处是吞吐量更大,坏处是可能出现线程饥饿;公平锁按先来后到,但代价是性能稍差。实际项目中,除非你有强需求保证顺序,否则用非公平锁就够了,公平锁很容易成为性能瓶颈。 - 组合使用:ReentrantLock可以和
Condition配合实现精准唤醒,比如ArrayBlockingQueue内部的实现就是Lock + 两个Condition(notEmpty和notFull),分别控制队列非空和队列未满。synchronized的wait/notify只能配合单个隐式条件,控制粒度不够细。
代码示例:
ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition(); // 生产者线程 lock.lock(); try { while (queue.size() == capacity) { notFull.await(); // 队列满,等待被唤醒 } queue.add(item); notEmpty.signal(); // 唤醒一个消费者 } finally { lock.unlock(); }注意那个finally里unlock,这是手写锁的铁律。很多人写ReentrantLock时不习惯在finally里解锁,一旦中间抛出异常,锁就永远不释放,直接造成死锁。synchronized之所以人见人爱,很大一个原因就是它帮你自动做了这件事。
3.3 CAS与ABA问题:无锁方案的魔力与陷阱
CAS(Compare And Swap)是Java并发包的基石,一句话解释就是:先把内存中的旧值取出来,操作之前再对比一下,如果旧值没变就写入新值,如果变了就重新尝试。这个操作是CPU指令级别的原子操作,不需要加锁,所以叫无锁编程。AtomicInteger、ConcurrentHashMap、各种并发队列的底层都用到了CAS。
CAS有个著名的ABA问题:线程A读到值是1,准备修改成2,但在此之前线程B把值从1改成3又改回1,A此时对比发现还是1,CAS成功。表面没问题,但如果“值从1变成3又变回1”这个中间过程携带了业务含义,那A的修改就可能造成错误。比如一个链表,头结点被删掉又换了一个新节点但恰好地址相同,CAS就发现不了变化。解决方案是加版本号或时间戳,AtomicStampedReference就是这个思路。
另外,CAS有一个自旋开销问题:如果竞争特别激烈,线程一直在自旋循环里重试,CPU占用会很高。所以高并发下无锁结构不一定比有锁更快,最好做压测来验证。道理很简单:锁是“等别人让路”,CAS是“不断尝试插队”,路况太差的时候,插队动作本身也会造成拥堵。
3.4 volatile:可见性,但不背原子性的锅
volatile是最容易被误解的关键字之一。它的核心作用是保证多线程之间的可见性和禁止指令重排序。当一个变量被volatile修饰时,线程读取时强制从主内存读,写入时强制刷新回主内存,这样就不会出现一个线程改了值、另一个线程却看不到的情况。经典的例子是状态标记:
volatile boolean running = true; // 线程A while (running) { // 业务逻辑 } // 线程B running = false; // 让A线程退出循环如果没有volatile,线程A很可能一直读不到running的最新值,陷入死循环。这是可见性问题。另外volatile还有一个重要特性是禁止指令重排序,典型应用就是单例模式的双重检查锁(DCL)单例中修饰单例实例变量,防止对象引用提前暴露。
但要记住一个铁律:volatile不保证原子性。count++这种“读-改-写”操作,volatile完全管不了。多个线程同时执行count++,最后结果依然可能小于预期。要保证原子性,要么用AtomicInteger,要么用synchronized。这两者的区别就是一句话:volatile只管“看到最新值”,不管“操作过程不被打断”。
4. 线程池:生产环境最常用的并发利器
如果你在工作中只记住一个多线程知识点,那我觉得应该是线程池。项目里的异步任务、定时任务、请求处理,十有八九都离不开它。线程池用法太老太常见,这里不堆基础,直接讲生产级的关键细节。
4.1 七个核心参数逐个拆解
用ThreadPoolExecutor创建线程池,构造方法里有7个参数,每一个都有明确含义:
| 参数 | 含义 | 经验建议 |
|---|---|---|
| corePoolSize | 核心线程数 | 即使空闲也保留的线程数 |
| maximumPoolSize | 最大线程数 | 线程数的上限 |
| keepAliveTime | 空闲线程存活时间 | 超过该时间回收多余线程 |
| unit | 时间单位 | 通常用TimeUnit.SECONDS |
| workQueue | 任务队列 | 核心线程满时,任务先入队 |
| threadFactory | 线程工厂 | 建议自定义,给线程起有意义的名字 |
| handler | 拒绝策略 | 队列满且线程数达到最大时的策略 |
执行流程是这样的:新任务提交时,如果当前线程数小于核心线程数,直接创建新线程执行;如果大于等于核心线程数,尝试放入任务队列;如果队列也满了,且当前线程数小于最大线程数,创建新线程执行;如果线程数已经达到最大值,执行拒绝策略。
4.2 拒绝策略:四种策略的真实使用场景
JDK自带了四种拒绝策略:
- AbortPolicy(默认):直接抛
RejectedExecutionException。适合对任务丢失零容忍的场景,但你要有兜底逻辑去接住这个异常。 - CallerRunsPolicy:不抛异常,而是让提交任务的那个线程自己执行。这个策略很巧妙,既降低了任务提交速率(因为调用者也在干活),又保证了任务不丢失,很多对实时性要求不高的场景用这个很稳。
- DiscardPolicy:静默丢弃任务,不抛异常。适合可以接受丢失的辅助任务,比如记录一些非关键日志。
- DiscardOldestPolicy:丢弃队列里最老的任务,然后重试提交当前任务。适合不处理旧数据、只关心最新任务的场景,比如实时推送。
我个人的经验是,最推荐的是CallerRunsPolicy,因为它优雅地实现了“削峰填谷”的效果,不会因为瞬间的大量请求把系统打崩,代价仅仅是调用线程会慢一点,但系统整体是稳定的。如果你需要监控,可以在拒绝时通过日志记录被拒绝的任务数量,方便后续扩容参考。
4.3 核心线程数设置:两大公式和真实经验
线程数设置是线程池调优的经典话题。业界流传的公式大致分两类:
- CPU密集型任务:核心线程数 = CPU核数 + 1。因为CPU密集型任务线程很少阻塞,太多线程只会增加上下文切换。
- IO密集型任务:核心线程数 = CPU核数 * 2,或者更精细的公式:
CPU核数 / (1 - 阻塞系数),阻塞系数通常在0.8到0.9之间。IO密集型任务线程大部分时间在等待IO,多开线程能提高CPU利用率。
但这是理论起点,真实环境还要结合压测来调整。我见过一个非常反直觉的例子:某个IO密集型的批处理任务,把线程池从20个线程调到5个,性能反而提升了一倍。原因就是后面有数据库连接池限制,20个线程同时抢5个连接,大量线程都堵在等待连接上,上下文切换和锁竞争开销比多跑几个线程的收益大得多。所以,设置线程数时一定要考虑下游资源(数据库连接、外部接口QPS限制)的容量。
4.4 为什么我不建议直接使用Executors的快捷方法
很多教科书和网上教程会推荐Executors.newFixedThreadPool()、Executors.newCachedThreadPool(),代码确实简洁,但坑也埋在这简洁里。
newFixedThreadPool()使用无界队列LinkedBlockingQueue,任务可以无限排队,这意味着最大线程数参数形同虚设。如果任务积压速度大于处理速度,队列会越积越长,最终可能OOM。newCachedThreadPool()的线程数上限是Integer.MAX_VALUE,SynchronousQueue不缓存任何任务,每次提交都会直接创建新线程,高并发下同样有OOM风险。newScheduledThreadPool()同样是Integer.MAX_VALUE的线程上限。
生产环境里,我建议一律手动通过ThreadPoolExecutor构造函数创建线程池。两个额外好处:一是你可以自定义ThreadFactory给线程起有辨识度的名字,排查问题时用jstack一看线程名就知道是哪个池子的线程;二是可以传入拒绝策略,保证行为可控。自定义线程工厂很简单:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("batch-worker-" + count.getAndIncrement()); t.setDaemon(false); return t; } };设置线程名这个习惯,在线上排查问题的时候能帮你省下大把时间。有一次我们查一个连接数异常的问题,jstack一抓,一眼看到线程名是httpclient-pool-xxx,马上定位到是HTTP客户端连接池的问题,而不是业务代码里的线程捣乱。
5. 实战场景:异步编排与并行任务处理
写后端接口的时候,经常会遇到需要同时调多个远程服务、然后汇总结果的场景。如果用同步方式一个个调,耗时会累加。多线程的实战价值在这里体现得最直接。
5.1 CompletableFuture:终结回调地狱
Java 8引入的CompletableFuture是我现在处理异步编排的首选。它最核心的价值是支持声明式组合异步任务,不用再写一层套一层的回调。举一个最常见的场景:查用户详情需要同时调用户服务、积分服务、订单服务,三个服务相互独立,然后聚合结果。
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(userId), executor); CompletableFuture<IntegralInfo> integralFuture = CompletableFuture.supplyAsync(() -> integralService.getIntegral(userId), executor); CompletableFuture<OrderInfo> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getLatestOrder(userId), executor); CompletableFuture.allOf(userFuture, integralFuture, orderFuture).join(); UserInfo user = userFuture.get(); IntegralInfo integral = integralFuture.get(); OrderInfo order = orderFuture.get(); // 聚合返回几个实用注意点:
supplyAsync()可以传入自定义线程池,如果不上传,默认用的是ForkJoinPool.commonPool()。而commonPool是全局共享的,一旦有任务的阻塞时间过长,会拖累其他使用commonPool的线程,所以正式代码务必传入自己的线程池。get()和join()的区别:get()抛出受检异常,join()不抛受检异常,所以在特定场景下代码更简洁。但两者都是阻塞等待,如果不想阻塞,可以用whenComplete()或者thenApply()注册回调。
5.2 并行处理集合数据的几种方案对比
对一个大集合做批量处理(比如给10万个用户补全信息),常见方案有几种:
- parallelStream:最简单,一个方法调用搞定,但默认使用commonPool,无法细粒度控制线程数,也不建议在并行流里做阻塞操作。
- 手动线程池 + CountDownLatch:可控性最强,但代码量偏多。
- CompletableFuture + 自定义线程池:目前我个人最推荐,结合流式API可以写得很优雅:
List<CompletableFuture<Result>> futures = userList.stream() .map(user -> CompletableFuture.supplyAsync(() -> processUser(user), executor)) .collect(Collectors.toList()); List<Result> results = futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());每个用户一个任务可能任务粒度太细,实际可以按batch拆分,每个任务处理一批用户,减少线程调度开销。分批的思想在数据库批量操作场景里尤其重要,比如批量插入时,一批一批插入比一条一条插入性能高很多,根本原因就是减少了IO交互次数。
5.3 ThreadLocal:线程隔离的正确打开方式
ThreadLocal是面试高频,实战也常用,它解决的核心问题是线程内部的数据隔离。每个线程都有自己的ThreadLocalMap,往ThreadLocal里塞数据,其他线程完全看不到。经典的实用场景是链路追踪:在拦截器里生成一个traceId写入ThreadLocal,后续日志打印时统一带上这个traceId,排查问题时按traceId串起整条链路。
ThreadLocal最大的坑是内存泄漏。ThreadLocalMap的key是弱引用(WeakReference),如果ThreadLocal对象只被这个key强引用着,一旦业务代码不再持有ThreadLocal对象,key会被GC回收,但value依然是强引用。如果线程是常驻的(比如线程池里的线程),ThreadLocalMap里残留的value永远无法回收,时间长了就可能OOM。解决办法很简单:用完记得调用remove(),在finally块里执行。
try { threadLocal.set(traceId); // 业务逻辑 } finally { threadLocal.remove(); }6. 常见故障排查实录
理论学了再多,最后还是要落到“线上出了问题怎么办”这件事上。这一节分享几个我真实遇到过的故障和排查过程,每个都很有代表性。
6.1 死锁:用线程快照一眼锁定
场景是这样的:某服务突然接口大面积超时,重启后恢复,过一段时间又超时。用JDK自带的线程转储工具(jstack)抓了一次线程快照,发现两个业务线程都处于BLOCKED状态,互相等待对方持有的锁:
"thread-A" #23 prio=5 os_prio=0 tid=0x... nid=0x... waiting for monitor entry [0x...] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.BizA.methodA(BizA.java:34) - waiting to lock <0x000000076b2c9168> (a java.lang.Object) at com.example.BizB.methodB(BizB.java:56) - locked <0x000000076b2c9760> (a java.lang.Object) "thread-B" #24 ... - locked <0x000000076b2c9168> - waiting to lock <0x000000076b2c9760>两头一对照,很明显两个线程一个拿到锁A想等锁B,另一个拿到锁B想等锁A,典型的循环等待死锁。修复方案是全局统一锁顺序:所有线程获取多个锁时,都按同一个顺序(比如先A后B),破坏循环等待条件。另外,如果用的是ReentrantLock,可以用tryLock()配合超时时间来避免无限阻塞,这样即使发生锁竞争也不会死等,还会返回失败让业务走降级逻辑。
6.2 CPU飙高:抓线程快照定位热点代码
另一个经典问题是某节点CPU持续100%,重启好一阵又复发。排查步骤一般是这样的:
top命令找到CPU占用最高的Java进程PID。top -H -p PID找到该进程内CPU占用最高的线程PID(注意这是十进制线程ID)。- 用
printf '%x\n' <线程PID>转成十六进制。 - 用jstack抓取线程快照,在快照里搜索这个十六进制线程ID(nid),就能定位到具体线程和当前执行的代码。
那次定位到的代码是一个日志打印方法,里面做了一个非常耗时的正则匹配,而且该日志在高并发下几乎每个请求都会打印。性能问题的根源不是多线程本身,而是某个热点逻辑太慢,被多线程并发放大。优化了日志级别和正则写法之后,CPU直接降下来了。这个案例想强调一个点:别一遇到CPU高就怪线程池配置,先用线程快照定位到具体代码,再决定怎么调优。
6.3 偶发的数据错乱:多半是可见性或原子性出了问题
有段时间系统会偶发地出现账务数据错乱,但概率很低,日志里也看不出明显异常。当时我先排查数据库层面的并发问题,发现某张表的更新操作没有加版本号,多个线程并发更新同一行时,后提交的覆盖了先提交的。这是典型的**“读-改-写”非原子问题**,即使每次更新都走了单条SQL,也没法避免两个线程分别读取旧值再各自写回。
修复思路有三种:数据库层面使用乐观锁(版本号字段,update时where version=旧值);或者使用AtomicInteger/AtomicLong配合CAS操作;或者在业务代码里对关键路径加分布式锁。实践中,乐观锁是最简单有效的方案,代码改动小且并发性能好。另外也顺手排查了缓存和主从库同步,确认不是数据源层面的问题。这类偶发问题最忌瞎猜,一定要先理清操作的时序,画出并发执行的路径,再逐条验证可能出错的环节。
7. 经验与心得
最后聊几句掏心窝的话。多线程这个主题,内容很多很深,新特性也是层出不穷(比如虚拟线程在Java 21里的正式登场),但核心的并发基础从来不会过时:锁、原子性、可见性、线程池、异步编排,这些底层原理是理解一切上层框架的钥匙。
我个人在实际项目里的几个铁律,分享给大家:
第一,能用现成并发组件解决问题,就不要自己造轮子。比如用ConcurrentHashMap而不是给HashMap加锁,用AtomicInteger而不是自己写synchronized计数,用线程池而不是new Thread。JDK并发包是无数大佬的经验结晶,踩过的坑比我们看过的代码都多。
第二,先保证正确性,再考虑性能。并发代码的正确性验证非常难,大概率bug还特别难复现。写之前多考虑数据竞争、死锁、可见性这些风险点,代码宁可慢一点也不要冒风险。压测是验证并发代码的必要手段,单测在并发场景下几乎无能为力。
第三,善于利用排查工具。jstack、线程快照、jstat这些工具一定要熟练使用,线上出问题的时候,工具能帮你把排查时间从几小时压缩到十几分钟。不要全靠“猜”,要让证据说话。
第四,理解业务再设计并发策略。多线程的参数不是数学公式,它和业务模型、下游依赖强相关。数据量多大、下游接口能承受多大QPS、任务的阻塞时间分布如何,这些才是决定线程数、队列策略的关键。脱离业务谈并发调优,都是耍流氓。
Java多线程的学习路线很长,但走通它之后,你再看一些中间件源码、排查线上并发问题、做接口性能优化,都会有一种豁然开朗的感觉。希望这篇文章能把你在进阶路上的一些迷雾拨开,剩下的,就靠多写、多踩坑、多总结了。