尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java多线程核心知识总结:从生命周期、锁机制到线程池与并发工具

Java多线程核心知识总结:从生命周期、锁机制到线程池与并发工具 老实说学多线程最怕的不是知识点难而是学完了还是散的。这个系列写到第12篇从创建线程、synchronized、锁、线程池到并发工具差不多把Java多线程的主干都过了一遍。今天这篇不做新功能做一次系统性小结——把前面那些点按自己的理解重新串起来讲清楚每个知识点在真实场景里到底解决什么问题以及面试和线上排障时它们会怎么考你、怎么坑你。这篇文章适合三类人正在学Java多线程、想整理知识体系的自学者准备Java面试、尤其是中高级岗位的候选人以及写过不少并发代码但线上出问题时总有些心虚的业务开发。我不会按章节标题罗列概念而是带着问题去看生命周期里最容易被误解的状态是哪几个锁到底锁住了什么线程池参数背下来没用得知道变化规律这些内容串起来才是多线程的真正骨架。1. 线程生命周期这关卡住了多少人的面试先说一个我面试别人时经常问的问题Thread.sleep()和Object.wait()有什么区别这个问题看着基础但能把线程生命周期彻底讲清楚的人不多。多数人只会背sleep不释放锁wait释放锁再往深一点就卡住了。1.1 六种状态不等于六级阶梯Java的线程状态用Thread.State枚举定义了六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多初学者以为线程从创建到销毁会依次走完这几个状态这是第一个误解。实际上这六种状态不是一条直线而是几个可以来回切换的房间。NEW是new完Thread对象但还没调用start()的状态。此时线程还没真正分配OS资源只存在于Java堆里。start()之后进入RUNNABLE这里要注意Java的RUNNABLE合并了操作系统里的运行中和就绪两个状态它表示线程可以被CPU执行但到底有没有在跑Java层面看不出来。这也是为什么很多面试官问RUNNABLE状态的线程一定在运行吗答案是不一定。TERMINATED是run()方法正常返回或抛出未捕获异常后的状态。线程一旦终止不能再次调用start()否则会抛IllegalThreadStateException。这个限制背后是OS线程和Java线程的绑定关系不是Java故意为难你。真正容易出问题的是BLOCKED、WAITING、TIMED_WAITING这三个等待类状态。面试里经常有人把BLOCKED和WAITING混为一谈其实它们的触发条件和退出条件完全不同。1.2 BLOCKED和WAITING的分水岭谁在等你BLOCKED状态只有一个来源线程在进入synchronized代码块或方法时monitor锁被其他线程持有它进不去只能停在锁池里等着。这个状态是被动的——不是你自己决定要等的是门被锁了进不去。一旦持有锁的线程释放锁JVM会从锁池里选一个线程让它变为RUNNABLE注意不是直接给它锁而是让它重新参与竞争。WAITING和TIMED_WAITING则是线程主动去等的。调用wait()不带超时、join()、LockSupport.park()会进入WAITING调用sleep(time)、wait(timeout)、join(timeout)、parkNanos()会进入TIMED_WAITING。两者的区别就一个字有没有超时时间。有一个边界情况值得记住Thread.sleep()和Object.wait()的对比。两者都会让线程暂时让出CPU区别在于sleep不会释放任何锁而wait在进入等待前会释放当前持有的monitor锁。这个设计是必然的——wait存在的意义就是让其他线程有机会进入同步块去改变条件如果你抱着锁睡大觉其他线程根本进不来条件永远变不了死锁就出现了。而sleep单纯是暂停一会儿和锁没有关系。还有个高频考点是yield()。它让当前线程从运行状态退回就绪状态让同优先级的其他线程有机会执行。但注意yield()只是礼貌性让路Java规范不保证它一定生效线程可能在让出CPU后立刻又被调度回来。我在写自旋锁控制逻辑时用过它效果不稳定后来全换成LockSupport.park()了。1.3 用jstack反推线程状态从理论到现实上面这些状态别看抽象线上排查时都能直接对上。用jstack pid抓线程快照输出里有这么一行java.lang.Thread.State: WAITING (parking)parking说明线程是通过LockSupport.park()进入等待的这在线程池里最常见——池里的空闲线程就是park在这里等任务。看到TIMED_WAITING (sleeping)就知道有线程在调sleep()。看到BLOCKED (on object monitor)就说明有个线程堵在synchronized入口等锁。我习惯在系统出问题时第一件事抓线程dump不是因为高级而是因为线程状态直接告诉你线程在干什么、卡在哪里。有一次线上接口大量超时dump一看几十个线程全是BLOCKED (on object monitor)全堵在一个第三方SDK的加锁方法上。那玩意儿内部用了synchronized静态方法做token刷新QPS一上来就排队。找到源头比拍脑袋调线程池参数有用得多。2. 锁机制synchronized的底气与Lock的自由很多Java面试题喜欢把synchronized和ReentrantLock放在一起比较这背后其实不只是API之别而是两套设计哲学。我用了几年synchronized之后才真正理解这东西的底牌。2.1 synchronized背后的锁升级从乐观到悲观synchronized在JDK 1.6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁三档升级路径。这里我尽量用生活化的方式讲。当线程要进入同步块时JVM会先看对象头里的Mark Word。初始状态下没有线程竞争锁处于无锁状态。第一个线程来了JVM会尝试用CAS把线程ID写进Mark Word这就是偏向锁——意思是我认为只有这一个线程会用这个锁先偏袒它不加真正的同步开销。如果第二个线程也来竞争偏向锁就撤销升级为轻量级锁线程在自己的栈帧里创建一个锁记录用CAS把对象头替换成指向锁记录的指针。如果CAS成功锁就被当前线程拿到如果失败说明真有竞争了锁会继续膨胀为重量级锁——这才是传统意义上阻塞其他线程的synchronized它依赖操作系统的互斥量实现。为什么JDK 15之后开始默认禁用偏向锁因为偏向锁的撤销本身需要STW暂停所有线程在竞争不激烈的场景里它确实省CAS费用但在高并发、高竞争场景下偏向锁频繁撤销带来的停顿可能比它省下的还多。我去年排查过一个线上停顿问题虽然没有实锤偏向锁导致但架构组后来明确发过规范新项目JVM参数里禁用偏向锁代码里也尽量不写无谓的synchronized。面试时如果你想显得有深度可以补一句锁消除和锁粗化JIT编译器在分析后发现某个锁只被单线程访问会直接消除加锁操作反之如果发现一系列连续的加锁解锁能合并就粗化成一个大锁减少重复开销。这些不是手册上会注明的点但解释实际性能差异时很有用。2.2 Lock接口显式的代价与收益ReentrantLock是java.util.concurrent.locks.Lock接口的代表实现相比synchronized它把加锁和解锁从语言的语法糖变成了程序员的显式责任。lock()和unlock()必须手动配对通常搭配try/finally保证解锁——一旦忘了在异常路径上解锁锁永远不会释放比synchronized的隐式释放难排查得多。那为什么还要用它因为有些场景synchronized确实给不了超时获取锁tryLock(2, TimeUnit.SECONDS)可以在等不到锁时中断等待逻辑而不是无限期阻塞。这在调用外部接口、控制总耗时的场景里非常有用。可中断获取锁lockInterruptibly()允许在等待锁时响应中断。公平锁new ReentrantLock(true)让等待时间最长的线程优先获得锁。注意公平锁有性能代价不是默认选项。多个条件队列synchronized一个锁只能关联一个隐式条件wait/notify而Lock可以newCondition()出多个Condition分别管理不同的等待队列。记得刚用tryLock改造一个文件导出功能的锁时效果立竿见影——原来synchronized方法在并发高峰时会导致大量线程堵在锁上白白占着线程池资源改成tryLock后抢不到锁的线程直接返回系统繁忙稍后重试用户体验反而更好线程池也不再被打满。2.3 到底怎么选我的实践原则这个问题没有标准答案但可以给一套可落地的选型逻辑默认用synchronized理由很实在代码简洁、没有unlock遗漏风险、JVM持续优化。如果必须加锁的业务范围很小、但锁竞争激烈考虑用ReentrantLock tryLock来快速失败。如果需要多个条件队列比如一个缓冲区需要不满和不空两个条件用ReentrantLock的Condition更优雅。如果只是在业务方法上做简单互斥synchronized足够别为炫技引入复杂度。还有一点分段锁的思路值得理解。JDK 7的ConcurrentHashMap用Segment数组分段加锁本质就是不要让所有线程都抢同一把锁。现在很多人用LongAdder替代AtomicLong也是类似思想内部用Cell数组分散热点最后sum时合并。理解这个套路后你写业务代码时也会下意识想能不能用更细粒度的锁甚至无锁方案3. 线程池参数背下来没用得知道变化规律线程池是Java多线程里最实用的工具没有之一。但我面试时发现能背出七个参数的人不少能讲清楚核心线程满之后任务先进队列而不是先开新线程的人就少很多而理解这个顺序才是真正会用线程池的起点。3.1 七个参数背后的一场调度ThreadPoolExecutor的七个参数是corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime非核心线程空闲存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。任务提交后的流程是这样的当前工作线程数小于corePoolSize直接创建新线程执行任务即使有空闲的核心线程也一样。这是很多人的误解点——线程池不是先看看有没有空人而是人不够就先加人保证核心线程慢慢涨到下限。当前工作线程数大于等于corePoolSize后新任务会被放进workQueue排队而不是立刻创建新线程。队列满了之后才开始把线程数从corePoolSize往maximumPoolSize扩展。线程数已经到maximumPoolSize队列也满了新任务触发拒绝策略。把这个顺序画成一句话就是先加核心线程再加队列再加非核心线程最后拒绝。 为什么这么设计核心线程是为了扛常驻流量队列是为了缓冲突发流量扩展到最大线程是应对持续的阻塞拒绝策略则是兜底保护。顺序背后是资源成本的递增核心线程为什么是核心就是因为它应该稳定存在而不是频繁地创建销毁。3.2 队列选型决定吞吐和延迟队列没有绝对的好坏只有和业务匹配不匹配。我用过的几种LinkedBlockingQueue默认无界。适合任务量比较平稳的场景但无界队列有一个隐患——积压的任务会占用大量内存最终可能OOM。而且当最大线程数和队列配合不好时maximumPoolSize直接形同虚设因为队列永远不满根本不会触发新增线程。很多人用Executors.newFixedThreadPool()踩的坑就在这。ArrayBlockingQueue有界需要指定容量。这是我最推荐线上使用的队列类型因为容量限制给了系统一个明确的水位线一旦队列满调度流程就会继续往下走到新增线程和拒绝策略资源能被有效调控。SynchronousQueue不存储任务每个入队的操作必须等待另一个线程来取走相当于直传。配合maximumPoolSize可以做到线程数按需增长然后回收适合短小任务、大量并发的情况。Executors.newCachedThreadPool()用的就是它。PriorityBlockingQueue按优先级出队适合有明确优先级需求的调度任务。注意PriorityBlockingQueue是无界的。选队列的核心思路是你愿意接受排队还是宁可多开线程也不排队如果任务入队成本低、后续执行快用无界队列没问题如果任务需要及时响应、不允许长期堆积必须用有界队列并配置好拒绝策略。3.3 拒绝策略的坑静默丢弃是灾难当线程池饱和时handler决定任务怎么处理。四种策略AbortPolicy默认策略直接抛RejectedExecutionException。适合告知调用方线程池扛不住了你得自己想办法。CallerRunsPolicy任务会回退给提交任务的线程执行。听起来贴心但有个隐形代价如果提交者是业务线程它自己去跑任务就会阻塞在那反而拖慢提交速度形成一种天然的反压保护。DiscardPolicy直接把任务丢掉。这个策略最坑人它会静默丢弃任务看起来没有任何异常但你的数据或业务就这样无声无息地没了。线下测试偶尔用线上我强烈不推荐。DiscardOldestPolicy丢掉队列里最老的任务腾出位置给新任务。适合某些只关心最新状态、旧状态没意义的场景比如位置上报、心跳信息。我去年接手过一个订单系统定时任务疯狂往线程池丢订单状态同步任务某天凌晨有大量订单同步失败。查日志发现没有任何异常最后看线程池监控发现用了DiscardPolicy队列满了直接丢任务同步请求没了但大家对账数据一直对不上。改成CallerRunsPolicy之后至少任务不会丢最多是提交线程慢一点。这个改动很小但确实是那种踩过才知道疼的坑。3.4 线程池大小怎么定给一个能落地的估算方法线程池大小没有唯一正确答案但有一个业界常用的估算思路区分CPU密集型和IO密集型。CPU密集型任务理论上是CPU核心数 1加1是为了在线程偶尔缺页中断或IO等待时有个替补线程能利用CPU。IO密集型任务公式大致是CPU核心数 * (1 平均等待时间/平均计算时间)。比如一个任务平均有80%时间在等网络响应、20%在计算8核机器上线程数可以设到8 * (1 4) 40。这个公式的价值不是精确值而是告诉你一个方向IO密集型的线程数应该比CPU密集型大得多因为线程大部分时间在等多个线程能更好地利用CPU的空闲窗口。线上环境我一般会再结合压测调整先按公式起一个初始值再用ThreadPoolExecutor的监控指标活跃线程数、队列积压数观察一段时间微调corePoolSize和maximumPoolSize。还有两个容易被忽略的参数threadFactory和keepAliveTime。threadFactory务必设置成能命名线程的比如用new ThreadFactoryBuilder().setNameFormat(order-sync-%d)否则线上遇到线程相关问题时dump出来全是一堆pool-3-thread-1根本分不清是哪个业务线的线程。keepAliveTime只作用于非核心线程如果任务波动大、高峰期明显可以设置allowCoreThreadTimeOut(true)让核心线程也能在空闲时回收降低资源占用。4. 并发工具与通信模型从wait/notify到CompletableFuture线程池解决的是谁来执行的问题而线程之间的协作和通信是并发编程的另一大半。这一节我想把几个并发工具放在一起说因为它们的适用场景经常被搞混。4.1 CountDownLatch和CyclicBarrier一个拆墙一个赛跑CountDownLatch是一个一次性计数器。场景是主线程启动多个子线程执行任务然后主线程调用await()等这些子线程全部完成后再继续。比如批量导入Excel时把每个Sheet的数据处理任务丢给线程池主线程等待全部Sheet处理完后统计结果并入库。CyclicBarrier正好相反——它不是等待全部完成而是等待所有人到齐然后一起出发。N个线程互相等待当最后一个线程到达屏障后N个线程同时被释放进入下一轮协作。这个屏障可以循环使用适合分阶段并行计算的场景比如数值迭代计算里每轮迭代需要所有线程的中间结果对齐后再进入下一轮。区分要点就一句话CountDownLatch是等事件发生CyclicBarrier是等线程汇合。前者等的是计数器归零后者等的是人数到齐。Semaphore也是常用工具它管的是允许N个线程同时访问某资源类似限流令牌桶。比如一个接口只允许3个连接同时请求下游可以用Semaphore(3)来控制并发放行超过的线程在acquire()上排队等待。4.2 生产者消费者模型的三种实现从原始到现代这个模型是理解线程通信的经典案例我在系列里单独写过一篇。这里只把三种实现的取舍讲清楚第一种是wait/notify。生产者线程和消费者线程共享一个缓冲区生产者在缓冲区满的时候wait()消费者在缓冲区空的时候wait()每次修改完状态后notifyAll()唤醒对方。问题在于要用一个synchronized块包住整个while判断加修改逻辑否则会出现虚假唤醒而且不好控制唤醒的粒度效率偏低。第二种是BlockingQueue。ArrayBlockingQueue、LinkedBlockingQueue本身就支持阻塞读写生产者put()满了就等消费者take()空了就等连条件变量都不用自己处理。这是我最推荐的实现方式代码最少、最容易写对。第三种是基于Condition的精确唤醒。ReentrantLock可以创建两个ConditionnotFull和notEmpty。生产者用notFull.await()等空位消费者用notEmpty.await()等数据唤醒时精确调用对应的signal()气氛比notifyAll精准。这个适合需要精细控制等待队列的场景但对绝大多数业务来说BlockingQueue已经够用了。4.3 CompletableFuture把异步编排从地狱拉回人间Future.get()有个尴尬之处它阻塞等待结果而且多个Future组合起来只能手动get代码就碎成一地。CompletableFuture的出现让异步编排变得像写业务逻辑一样自然我用得最多的几个APIthenApply前一个阶段的结果作为输入执行一个同步转换函数返回新结果。thenCompose前一个阶段结果传给下一个异步任务用来串联多个异步操作避免写一堆嵌套future。thenCombine把两个独立异步结果合并处理适合并行调用两个接口后做汇总。allOf等待多个CompletableFuture全部完成然后统一处理结果列表。这个做并发RPC批量查询非常顺手。有一次我做多平台价格聚合接口原来要串行调四个上游接口每个200ms总耗时800ms。改成CompletableFuture.allOf并发调用后总耗时直接降到最大的那个值——200ms出头效果非常直观。但要记住CompletableFuture默认用的是ForkJoinPool.commonPool()共享线程池在容器环境里容易被其他任务干扰。如果业务量大最好自定义线程池作为第二个参数把异步任务的执行隔离出独立线程池避免互相拖累。4.4 ThreadLocal便利背后的内存泄漏风险ThreadLocal的原理是每个Thread对象内部有一个ThreadLocalMapkey是这个ThreadLocal对象实际是一个弱引用Entryvalue是你set进去的对象。它能让变量在线程内是全局的同时天然线程隔离是处理SimpleDateFormat、数据库连接、用户上下文的好工具。但ThreadLocal有个著名的坑内存泄漏。ThreadLocalMap中的key是弱引用WeakReferenceThreadLocal当外部没有强引用指向ThreadLocal时key会被GC回收但value是强引用如果线程一直存活比如线程池里的常驻线程这些value就永远不会被清除。具体表现就是线程池跑得越久每个Thread的ThreadLocalMap里堆积了越来越多的无效value最终可能OOM。线上用过Spring事务管理的同学应该见过类似报错一个Tomcat线程处理完请求后没有清理ThreadLocal下一个请求复用该线程时读到了上一个请求的数据。这就是为什么很多框架里都有请求结束必须remove的强调。我自己的规范是用ThreadLocal的地方要么用try/finally强制remove()要么不放在线程池的线程里否则排查数据串了的问题会让人崩溃。5. 可见性和有序性volatile为什么救不了所有并发问题讲完锁和工具必须回过头来说JMMJava内存模型这是多线程进阶绕不开的底层。几乎每一个线上诡异问题最后都能追溯到可见性或重排序上。5.1 JMM三要素原子性、可见性、有序性原子性一个操作或多个操作要么全部执行且不被任何因素打断要么全不执行。i看着一步操作实际是读-改-写多线程下就丢了更新。可见性一个线程修改共享变量后其他线程要能立刻看到。现代CPU有L1/L2/L3缓存线程操作变量时不一定直接操作主内存可能先改缓存再异步刷回。没有同步机制时其他线程读到的可能是旧值。有序性代码编译和CPU执行时可能发生指令重排。在单线程下重排不影响结果但多线程下就可能抢跑。volatile解决的是可见性和有序性不解决原子性。它有两个语义写入volatile变量时JVM会插入内存屏障强制把该线程本地内存中的值刷新到主内存读取volatile变量时强制从主内存重新读取。同时它禁止指令重排序保证修饰变量的读写顺序不会被乱排。5.2 happens-before让内存可见性有章可循happens-before规则是JMM的核心推理工具它规定了一组前一个操作的结果对后一个操作可见的规则。不需要全背但必须理解主要的几条程序次序规则同一个线程中前面的操作happens-before后面的操作但JVM仍可重排只要结果一致。管程锁定规则一个unlock操作happens-before后面对同一个锁的lock操作。这是synchronized可见性的来源。volatile变量规则对volatile变量的写happens-before对它的读。线程启动规则Thread.start()之前的操作happens-before该线程的所有操作。线程终止规则线程中的操作happens-before其他线程检测到该线程终止join返回之后。传递性如果A happens-before BB happens-before C则A happens-before C。这些规则的好处是你不需要去深究CPU缓存到底怎么同步只要你的两个操作在happens-before链路上建立起了关系结果就有保障。举个例子为什么在锁内修改的变量出锁后其他线程能读到因为解锁和加锁之间建立了happens-before关系解锁前的所有写操作在解锁后对其他获得同一把锁的线程可见这比逐变量加volatile简单得多。5.3 双重检查锁为什么必须有volatile双重检查锁Double-Checked LockingDCL是面试和老代码里常客class Singleton { private static volatile Singleton instance; static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }外层判断instance null是为了避免每次都进synchronized内层判断是为了防止多个线程同时通过外层判断后重复创建。那为什么instance必须加volatile关键在于new Singleton()并不是原子操作它可以分为三步(1) 分配内存空间(2) 在内存上初始化对象即调用构造函数(3) 把内存地址赋值给instance。在JIT编译优化或CPU流水线乱序下第3步可能先于第2步执行。也就是说另一个线程可能看到一个非null的instance但对象里的字段还没初始化完。它拿这个半成品直接用就会出现奇怪的NPE或字段值不对。加了volatile之后禁止了赋值引用和初始化对象之间的重排序才保证了另一个线程拿到的对象是完全构建好的。这就是volatile禁重排特性在一个非常具体场景里的价值。顺带提一个相关的经验在高并发热点代码里如果某个共享标志位只是用来控制循环是否继续比如停止标记用volatile是合适的但如果是计数器累加volatile int count就是错的必须用AtomicInteger或加锁因为volatile不保证原子性。我见过有人把volatile当成万能同步工具写排行榜计数结果数据比真实值少了不少就是没分清各自管什么。6. 排查线上问题jstack是手艺人最后的底气最后把我踩过和帮人排查过的几个典型问题整理一下。这些不是教科书上会讲的内容但在线上真实遇到时能救一命。6.1 一次死锁定位过程死锁的经典场景是线程A持有锁1想拿锁2线程B持有锁2想拿锁1。死锁只发生在程序不主动释放但又不满足继续执行条件的时候所以解决思路要么是锁超时要么是加锁顺序全局一致。我还记得一次排查经历系统里有个报表模块突然一动不动重启后过一会儿又卡住。我用jstack抓了线程快照文件里直接有Found one Java-level deadlock: Thread-7: waiting to lock monitor 0x00007f8b2c001d80 (object 0x00000000ff2ab3e0, a java.lang.String), which is held by Thread-8顺着往下看两个线程分别在两个不同的对象上加锁然后互等对方持有的对象。代码是两个同事各写一个模块共享了同一个字符串常量作为锁对象加锁顺序反了。修复方式是把A、B两个模块的加锁顺序统一并且锁对象都换成各自的私有对象不要用公共字符串。这里值得注意的一点是不要用字符串常量做锁因为JVM字符串池会让不同地方的相同字符串指向同一个对象极易产生非预期的锁竞争。6.2 线程池中的异常吞掉问题线程池里跑的任务抛异常时行为比普通线程复杂得多。如果是通过execute()提交的任务任务异常会被ThreadPoolExecutor.Worker.run()捕获后交给UncaughtExceptionHandler默认handler只是打印到System.err而且这个工作线程会被回收线程池会再造一个新线程来替代它。也就是说异常不会被调用方感知到但线程池默默重建了线程。如果是通过submit()提交的任务异常会被捕获后存进返回的Future对象里等调用future.get()时才抛出来。所以如果你提交任务后从来不get()异常就永远藏在Future里日志里什么都没有。这解释了为什么很多异步任务没反应——不是没执行而是执行挂了但没人知道。我的建议是异步任务内部务必用try/catch把所有可能异常包装好再配合自定义的UncaughtExceptionHandler记录完整堆栈或者在任务内部把异常信息打到专门的日志文件。否则排查问题时只能靠猜效率低得让人抓狂。6.3 我自己踩过的并发坑锁的范围太大也是一种病有一次我把一个方法直接加了synchronized里面包含一次远程调用和一次数据库更新耗时小几百毫秒。并发一上来所有请求全堵在这个方法上吞吐量灾难式下滑。后来把锁范围缩小到只包住需要互斥的那几行代码——也就是数据库更新的竞态条件那一小段远程调用放外面性能恢复了几十倍。这个教训总结成一句话加锁的最小化原则不是技术规范而是一种成本意识。锁范围越大等待锁的线程就越多系统吞吐就越差。同理读多写少的场景与其用synchronized锁住读操作不如考虑ReentrantReadWriteLock的读锁或者干脆用ConcurrentHashMap、CopyOnWriteArrayList这类并发容器把问题设计掉。还有一次是ThreadLocal跨线程传递的坑子线程通过new Thread()启动时不会继承父线程的ThreadLocal值因为每个线程的ThreadLocalMap是独立的。如果想让父子线程共享上下文要么显式传参要么用InheritableThreadLocal。但注意线程池场景下InheritableThreadLocal也会失效因为线程池复用线程第一次提交任务时继承的父线程值会留在线程里第二次提交时就串了。现在一般都用框架提供的上下文传递组件或者自己用拦截器在任务提交前捕获上下文、执行后恢复。这个问题排查起来特别隐蔽因为只有偶发才能看到这个线程怎么带上了别人的用户ID。写到这里这个系列的多线程主干算是完成了最后一次梳理。如果你能把生命周期、锁机制、线程池调度顺序、并发工具、JMM可见性和路障排查这几块串成一个整体再动手跑几个线程dump、写几个生产者消费者demoJava多线程的基本盘就稳了。接下来不管是看并发包的源码还是研究锁升级和AQS的细节都会顺很多。最后留个习惯给大家搭一个自己的压力测试小项目往线程池里丢不同类型的任务再用jstack观察状态变化要比任何面试题都管用。
返回列表