ARTICLE DETAIL

资讯详情

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

Java多线程进阶:线程状态、锁升级与线程池实战全解析

Java多线程进阶:线程状态、锁升级与线程池实战全解析 1. 为什么要啃多线程这一块学不会Java后端基本走不远先聊点实在的。多线程在Java里属于那种“看着不难、一用就崩、面试必考、线上必出问题”的知识点。很多朋友学到后面会陷入一个困境八股文背得滚瓜烂熟synchronized和volatile的区别能默写出来线程池七大参数倒背如流但一到自己写代码还是不知道该在什么场景用哪把锁更别提遇到线上CPU飙高、接口偶尔超时这种问题时的排查能力。我自己的体会是多线程不是一门“学了就会”的知识而是一门“踩坑才会”的知识。你只有真正把线程状态、锁机制、内存模型、线程池这些底层逻辑串起来再配合线上问题反推原理才算真正入了门。这也是我把这篇学习笔记分享出来的原因希望给正在进阶Java的读者一条更清晰的路线而不是继续在碎片化的知识点里打转。这篇内容适合三类人准备跳槽、正在刷Java多线程面试题的朋友写业务代码时遇到过并发问题、但不知道怎么定位的新手以及想系统梳理一遍并发知识、构建自己技术体系的中级开发。我会尽量用实际场景 原理拆解的方式来讲不只是背结论而是把“为什么”讲清楚。2. 线程的生命周期与状态切换一张状态图背后的底层逻辑2.1 六种线程状态以及它们之间到底怎么跳转很多同学对线程状态的理解停留在“新建、就绪、运行、阻塞、死亡”这五个老状态这是以前教材里的说法对应的是操作系统层面的线程模型。但Java线程状态的官方定义来自Thread.State这个枚举一共六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里有个容易误解的点Java里的RUNNABLE其实把操作系统层面的“就绪”和“运行”合并了。因为对JVM来说线程只要不是阻塞、等待、超时等待这三种情况它就处于可运行状态至于CPU有没有真正分给它时间片JVM并不关心。所以你在jstack里看到线程是RUNNABLE不代表它正在执行也可能是在排队等CPU。状态切换的路径大概是这样的NEW → RUNNABLE调用start()之后线程才真正进入可运行状态。注意是start()不是run()直接调run()只是在当前线程里同步执行一段代码完全没有新开线程。RUNNABLE → BLOCKED进入synchronized代码块或方法时如果锁被其他线程持有线程会进入BLOCKED状态等待 monitor lock。RUNNABLE → WAITING调用wait()、join()、LockSupport.park()时进入WAITING这个状态不会被自动唤醒必须等其他线程调用notify()/notifyAll()或者被unpark()。RUNNABLE → TIMED_WAITING调用sleep(ms)、wait(timeout)、join(timeout)、parkNanos()等带超时参数的方法时进入。WAITING/TIMED_WAITING → RUNNABLE被唤醒后重新参与CPU调度。BLOCKED → RUNNABLE拿到锁之后。任何状态 → TERMINATEDrun()方法执行完毕或者抛出未捕获异常导致线程终止。2.2 面试常翻车的细节wait和sleep的区别不止“释放锁”wait()和sleep()的差别是面试高频题但很多人只回答“wait释放锁sleep不释放锁”这还不够。从底层看wait()是Object类的方法它的语义是“当前线程释放对象监视器并进入等待”必须配合synchronized使用否则直接抛IllegalMonitorStateException。而sleep()是Thread类的静态方法它的语义是“当前线程暂停执行指定毫秒数”不涉及锁的释放也不需要持有任何监视器。还有一个非常容易被忽略的细节sleep()被中断时会抛出InterruptedException并清除中断标志位wait()被唤醒后需要重新竞争锁才能继续执行。所以从wait()返回并不意味着你马上就能往下走中间可能还有锁竞争的过程。再看yield()和join()。yield()是“让出CPU调度”当前线程从RUNNABLE回到RUNNABLE只是主动放弃本次CPU时间片让同优先级的其他线程有机会执行。但调度器可能下一秒又把它调度回来所以yield()不保证任何事。join()则会让当前线程进入WAITING直到被join的线程执行完毕才恢复这在等待子任务完成时很常用底层也是依赖wait/notify机制实现的。2.3 线程模型的实际场景为什么“多线程一定更快”是错的很多人学完状态切换之后会产生一个错觉用多线程就是为了提高速度线程越多越快。真实场景里完全不是这么回事。线程的创建和销毁有开销上下文切换有开销锁竞争有开销。如果任务本身是CPU密集型的而且已经占满所有核心多加线程只会增加上下文切换的损耗如果任务涉及大量IO等待线程在BLOCKED/WAITING状态的时间占比高这时候才适合用更多的线程去填补等待空档。我吃过一次亏一个内部批量处理接口逻辑很简单从数据库查出数据调第三方接口再写回数据库。刚开始用单线程跑一个批次3000条耗时大约40秒。后来我想当然地改成线程池核心线程直接给了200结果CPU没飙高接口反而变慢了一批跑了一分多钟。用jstack一抓发现大量线程都阻塞在第三方接口的socket读取上加上线程切换频繁整体吞吐反而下降。后面把线程数调到30一批稳定在20秒左右。这就是线程状态分析在实际调优里的价值——如果你不理解WAITING状态意味着什么就会瞎调参数。3. synchronized的锁升级从偏向锁到重量级的完整路径3.1 锁对象头里的Mark Word到底存了什么synchronized在JDK 1.6之前是纯重量级锁直接依赖操作系统的互斥量mutex实现每次加锁解锁都要从用户态切到内核态性能很差。HotSpot团队后来做了大量优化引入了偏向锁、轻量级锁、自旋锁、锁消除、锁粗化等机制让synchronized在多数场景下的性能甚至不输显式Lock。要理解锁升级得先知道Java对象在内存里的布局。一个普通对象在HotSpot虚拟机中的结构包含对象头Mark Word 类型指针数组还有长度、实例数据、对齐填充三部分。Mark Word是多变的它内部用不同的位来记录不同状态下的信息无锁状态下存对象的hashCode和分代年龄偏向锁状态下存持有偏向锁的线程ID轻量级锁状态下存指向栈中锁记录的指针重量级锁状态下存指向管程Monitor的指针。锁升级的路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。但这里有个细节偏向锁是延迟到JVM启动后几秒才开启的可以通过-XX:BiasedLockingStartupDelay0关掉延迟也可以直接用-XX:-UseBiasedLocking关闭偏向锁。在某些高并发场景下关闭偏向锁反而能减少撤销带来的开销。3.2 锁升级的触发过程什么情况下从偏向变成轻量级偏向锁设计的目标是一个线程反复进入同一个同步块锁的获取没有任何竞争。当第一个线程进入synchronized块时JVM会把对象头里的线程ID设为当前线程之后该线程再次进入时只需要检查Mark Word里的线程ID是不是自己如果是直接执行不需要任何CAS操作。一旦出现另一个线程尝试竞争这把锁偏向锁就失败了JVM需要撤销偏向锁。这个撤销过程要等待全局安全点SafePoint也就是所有用户线程都暂停执行的时刻然后判断原持有线程是否还在执行同步块如果已经退出就把对象头恢复成无锁状态再重新走轻量级锁的流程。轻量级锁的获取方式是CAS线程在栈帧中创建锁记录Lock Record然后把对象头Mark Word复制到锁记录里这份拷贝叫Displaced Mark Word再用CAS把对象头的Mark Word替换成指向锁记录的指针。如果CAS成功说明抢锁成功如果失败说明锁被其他线程持有了锁会膨胀成重量级锁线程进入BLOCKED状态。3.3 什么时候用synchronized什么时候换Locksynchronized的优点很明显用法简单、不用手动释放锁、JVM帮忙优化、异常时自动释放不会死锁。缺点是锁获取和释放的时机不够灵活不能中断一个正在等待锁的线程不能设置超时时间非阻塞地尝试获取锁也不支持。ReentrantLock则提供了更细的APIlockInterruptibly()支持响应中断tryLock(timeout)支持超时获取newCondition()支持多个条件队列。另外ReentrantLock还分公平锁和非公平锁两种模式。非公平锁是默认的意思是一个线程在释放锁之后新来的线程可能直接插队成功而不是去唤醒等待队列里最前面的线程公平锁则严格按照FIFO顺序。我的经验是常规业务代码能用synchronized就用synchronized不用为了炫技去换Lock需要超时控制、可中断、多条件队列这类场景再考虑ReentrantLock。另外读写场景有明显的不对称性时比如读多写少可以考虑ReentrantReadWriteLock或StampedLock。读锁是共享的写锁是排他的读多写少时能显著提升并发吞吐量但要注意写锁饥饿的问题——如果有大量读线程持续持有读锁写线程可能一直拿不到锁。4. volatile和Java内存模型为什么int不是原子操作4.1 JMM的抽象结构主内存与工作内存之间发生了什么Java内存模型Java Memory ModelJMM规定所有变量存储在主内存中每条线程还有自己的工作内存工作内存是寄存器或CPU缓存的抽象线程对变量的所有操作都必须先在工作内存中进行再把新值写回主内存。这就是“可见性”问题产生的根源线程A在工作内存里修改了变量x但还没写回主内存线程B读到的仍然是旧值或者线程A写回了但线程B的工作内存里缓存了旧值读到的还是旧值。JMM主要解决三件事原子性、可见性、有序性。synchronized和Lock能同时保证这三者而volatile只保证了可见性和有序性不保证原子性。很多人背了“volatile保证可见性不保证原子性”这句话但问他为什么不保证原子性就说不清楚了。volatile int count的例子最能说明问题。两个线程同时执行count这行代码在字节码层面实际是四步从主内存读到工作内存、在工作内存加1、写回工作内存、把工作内存的值刷回主内存。不加volatile时两个线程可能同时读到count0然后各自加1最终写回的是1而不是2。加了volatile呢它能保证写回主内存后对其他线程立即可见但“读取-加1-写回”这三步之间仍然可能被打断。线程A读到了0还没写回线程B也读到了0也加1写回线程A再写回自己的1。结果还是1。所以volatile解决不了复合操作的原子性问题要保证count的正确性得用AtomicInteger或者synchronized。4.2 volatile的实现原理内存屏障与缓存一致性volatile之所以能保证可见性背后是内存屏障Memory Barrier在起作用。JVM在生成字节码时会在volatile变量的写操作后面插入一个StoreStore屏障和一个StoreLoad屏障在读操作前面插入LoadLoad屏障和LoadStore屏障。这些屏障的作用是禁止编译器重排序、禁止CPU乱序执行相关的读操作和写操作并确保volatile变量写完之后强制把缓存中的最新值刷回主内存读之前强制从主内存或其他核心的缓存中读取最新值。从硬件层面看现代CPU都有缓存一致性协议如MESI协议核心之间通过总线嗅探机制感知对方对缓存行的修改。volatile写操作会触发缓存行的失效广播其他核心感知到对应的缓存行失效后会重新从主内存加载。这也是为什么volatile写操作比普通变量写操作要“慢”一些——因为它需要额外的缓存同步开销。4.3 指令重排与Happens-Before规则除了可见性volatile还解决了一部分有序性问题。编译器、CPU为了优化性能可能会对指令做重排序只要单线程内的执行结果不变就可以。但在多线程环境下重排序可能导致另一个线程看到“还没执行完”的代码状态。经典的案例是单例模式的双重检查锁DCLpublic 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; } }instance new Singleton()这一步在字节码层面拆成三步分配内存、调用构造函数初始化对象、把引用赋值给instance。如果不加volatile第三步可能先于第二步执行另一个线程看到的instance不为null但对象还没初始化完成拿去用就会出问题。加了volatile之后通过内存屏障禁止了“初始化”和“赋值”之间的重排序保证赋值完成时对象一定已经初始化完毕。JMM里定义了一组Happens-Before规则用来判断两个操作是否存在“先于”关系包括程序次序规则、volatile变量规则、锁规则、传递性规则等。简单说如果A操作Happens-Before B操作那么A执行的结果对B是可见的。volatile规则是说对一个volatile变量的写操作Happens-Before于后续对这个变量的读操作。这些规则不用死记理解成“内存可见性的法律条文”就行写并发代码时心里要有这根弦。5. 线程池实战核心参数不是背出来的是算出来的5.1 线程池的七个参数与执行流程提到多线程线程池是绕不开的工程化工具。直接new Thread的坏处很多线程创建销毁开销大、线程数量不可控、容易OOM、不方便统一管理。所以要规范地使用线程池。先看ThreadPoolExecutor最完整的构造方法一共七个参数corePoolSize核心线程数线程池会保持这么多线程一直存活除非设置allowCoreThreadTimeOut。maximumPoolSize最大线程数线程数量上限。keepAliveTime unit非核心线程的空闲存活时间。workQueue任务队列核心线程满了之后新任务先进队列。threadFactory线程工厂用来创建线程可以自定义线程名。handler拒绝策略队列也满了、线程数也达到上限新任务怎么处理。执行流程是这样的提交任务时先判断当前线程数是否小于corePoolSize小于则直接创建核心线程执行任务大于等于corePoolSize则尝试把任务放入workQueue队列满了则尝试创建非核心线程执行任务线程数达到maximumPoolSize则执行拒绝策略。这个流程面试官非常爱问而且经常追问什么时候会创建非核心线程答案是核心线程满 队列满这两个条件同时满足时。这里很容易混淆的一点是很多资料说“核心线程数满则放入队列”但JDK源码里的逻辑是先判断工作线程数是否小于corePoolSize是就创建核心线程否则走workQueue.offer(command)入队失败才尝试创建非核心线程。所以“核心线程满之后再入队”这个说法虽然结果上差不多但理解得不够精细。5.2 拒绝策略怎么选四种内置策略的适用场景线程池内置了四种拒绝策略都实现RejectedExecutionHandler接口AbortPolicy默认直接抛RejectedExecutionException打断调用方的提交动作。CallerRunsPolicy不抛异常直接在调用者线程里执行被拒绝的任务。好处是天然实现了限流因为调用者线程被任务占住了后面再提交的任务就会阻塞。DiscardPolicy静默丢弃不抛异常不执行。DiscardOldestPolicy丢弃队列里最老的任务然后重新提交当前任务。实际业务里我见过最多的是AbortPolicy和CallerRunsPolicy配合使用。对于不允许丢消息的核心链路一般用AbortPolicy然后在catch里做告警或补偿对于可以容忍一定延迟的异步场景CallerRunsPolicy能起到“慢下来”的效果。DiscardPolicy和DiscardOldestPolicy少用为好除非你真的明确知道丢任务没有影响否则线上出现不可解释的数据缺失时排查方向都很难找。另外阿里巴巴开发规范里有一条建议不推荐用Executors.newFixedThreadPool()和Executors.newCachedThreadPool()这两个快捷方法。原因是FixedThreadPool的等待队列是LinkedBlockingQueue默认容量是Integer.MAX_VALUE任务积压时可能堆积海量请求导致OOMCachedThreadPool的maximumPoolSize也是Integer.MAX_VALUE极端情况下线程数无限增长也会OOM。所以还是老老实实用new ThreadPoolExecutor(...)把参数显式写出来。5.3 线程池调参与自定义别忽略这两个细节关于核心线程数的估算有一个很多人都在传的公式CPU密集型任务核心线程数设为CPU核数 1IO密集型任务设为CPU核数 * 2。这个公式可以当作起点但不能当真理。更靠谱的方法是压测用不同的线程数跑同一批任务观察吞吐量和P99延迟找到拐点。如果想在理论上估算可以用Brian Goetz在《Java并发编程实战》里提出的公式线程数 CPU核数 * CPU利用率 * (1 等待时间 / 计算时间)如果任务是IO密集型的等待时间远大于计算时间比如等待时间耗时100ms计算耗时10ms那么(1 100/10) 11说明每个CPU核心大约可以跑11个线程。这个公式比“核数*2”更合理但等待时间和计算时间需要你结合业务自行估算。再补充两个工程上特别容易踩的坑。第一个是线程池的线程命名。如果不自定义ThreadFactory默认线程名是“pool-1-thread-1”这种线上排查看不到业务信息非常痛苦。自定义ThreadFactory很简单给线程起名为“order-async-pool-thread-1”之类的格式配合jstack查问题时一眼就能定位是哪个线程池在搞事情。第二个坑是异常吞没。线程池中任务执行抛出异常时如果是execute()提交的异常会直接抛给当前线程如果Thread的默认UncaughtExceptionHandler没处理异常会打印到控制台然后线程销毁如果是submit()提交的异常会被封装在Future里如果你没有调用future.get()异常会被静默吞掉。这个坑很隐蔽线上经常出现“任务好像没执行”、“数据对不上”的问题一查是异常根本没暴露出来。6. 并发Bug的典型场景从死锁定位到原子性问题排查6.1 死锁的四个必要条件与定位方式死锁是并发编程里最经典的问题。产生死锁有四个必要条件互斥、持有并等待、不可剥夺、循环等待。四个条件缺一不可所以在设计上打破任意一个就能预防死锁。实际工程里死锁最常见的发生场景是多个锁交叉获取。两个线程各自持有一个锁又去获取对方的锁互相等着谁也不让然后程序就卡死了。比如线程A持有了锁1等待锁2线程B持有了锁2等待锁1。这种死锁一旦发生最明显的现象是接口超时、服务无响应线程栈里能看到明显的“waiting to lock”线索。线上定位死锁的步骤可以直接套用这个链路先用jps -l找出目标Java进程的PID。用jstack pid或者jcmd pid Thread.print导出线程栈。在输出里搜索Found one Java-level deadlock字样JDK的线程转储会自动检测并打印死锁信息指明哪些线程持有哪些锁、等待哪些锁。根据类名和方法名回代码里找对应的锁使用场景修复锁顺序或者改用超时锁。jstack输出里会有类似这样的片段Found one Java-level deadlock: thread-B: waiting to lock monitor 0x000000001a6a3d80 (object 0x00000000ec4d0c58, a java.lang.Object), which is held by thread-A thread-A: waiting to lock monitor 0x000000001a6a3d80 (object 0x00000000ec4d0c60, a java.lang.Object), which is held by thread-B看到这种信息问题基本就锁定了。关键动作是把代码里所有加锁的顺序统一。比如所有要同时拿A和B锁的地方都按“先A后B”的顺序拿就能破坏循环等待条件。6.2 那些“看着没毛病”的并发Bug除开死锁实际开发中还有很多隐蔽的并发Bug我挑几个经常遇到的问题说一下。先是非原子复合操作。前面提到的count就是典型。解决办法可以是加锁也可以用AtomicInteger。但注意AtomicInteger的incrementAndGet()虽然本身是原子的但如果你在业务方法里先读、再判断、再调用某个原子方法这整个流程仍然不是原子的该加锁还是要加锁。然后是错误的单例写法。双重检查锁的问题前面提过不加volatile的DCL是典型错误写法。还有一个更普遍的坑用synchronized修饰一个非静态方法的锁对象问题。非静态方法是锁this静态方法是锁Class对象两者不是同一把锁。如果同一个类里有的方法锁this、有的方法锁Class并发控制就会失效。还有一处是懒加载导致的线程安全问题。比如private MapString, String configMap; public MapString, String getConfig() { if (configMap null) { configMap loadFromDB(); } return configMap; }两个线程同时进来都发现configMap为null各自加载了一份后写的数据覆盖先写的数据更糟的是调用方可能拿到一个“正在构建中”的半成品对象。这类问题的标准解法是DCL volatile或者直接用ConcurrentHashMapcomputeIfAbsent也可以换用静态内部类单例模式。6.3 并发压测的观测指标与排查思路写多线程代码验证环节不能靠“感觉跑通了就行”。我说一下自己压测时一般看哪些指标。QPS/TPS每秒事务数衡量吞吐能力。平均耗时与P99耗时平均响应时间容易掩盖长尾问题P99能反应极端延迟。CPU使用率如果多线程没有把CPU打满先怀疑线程数是否够再怀疑是否有锁竞争或IO瓶颈。活跃线程数与阻塞线程数通过jstack多次采样观察BLOCKED和WAITING状态的线程占比。如果压测发现CPU不高但QPS上不去大概率是锁竞争或者IO等待。锁竞争的话jstack里能看到大量线程处于BLOCKED状态IO等待的话大量线程会在socket read或数据库等待上。前者考虑缩小锁粒度、用并发工具替代后者考虑加大线程数、引入缓存或异步化。还有一个很实用的排查手段是开启-XX:PrintGCDetails和JFRJava Flight Recorder录制。JFR不仅能记录CPU热点还能看到锁竞争、分配率、文件IO等细节对定位并发性能问题帮助很大。我在排查一个接口的瞬时卡顿时就是靠JFR的Lock Instances视图直接发现了某个ConcurrentHashMap的并发computeIfAbsent触发了大量缓存计算导致CPU高、锁等待长后来优化了缓存预热的逻辑问题才解决。7. 最后聊聊我的多线程学习路线很多读者问多线程应该怎么系统学我总结一下自己的进阶路线供参考。第一步把线程的基础概念啃扎实线程的创建方式、生命周期、wait/notify、sleep/yield/join、中断机制。这一阶段不需要背太多拿几个小程序跑一跑配合jstack观察状态变化。第二步啃同步工具synchronized的锁升级、ReentrantLock、ReadWriteLock、Semaphore、CountDownLatch、CyclicBarrier。重点不是会API调用而是理解“用什么工具解决什么场景的什么问题”。第三步啃JMM核心概念内存模型、可见性、有序性、Happens-Before、final语义。这一阶段可以把《Java并发编程的艺术》相关章节反复读几遍。第四步啃JUC源码AQS的底层、ConcurrentHashMap的演进、线程池的执行流程、阻塞队列的实现。读源码不用逐行抠抓住核心结构和关键流程即可。第五步回到实战和面试题试着解释线上遇到过的问题用线程转储去分析把自己踩过的坑整理成笔记。面试时能结合项目讲出“遇到过什么并发问题、怎么排查、为什么这样解决”远比背一百道八股文有说服力。我在实际学习里最受益的一个方法是遇到一个并发问题先不看答案自己用jstack、JFR、压测工具去定位再回书上找原理。几次下来那些抽象的概念就都变成脑子里具体的画面了。比如说到“轻量级锁膨胀”你马上能想到压测时jstack里那一片BLOCKED的线程长什么样。这种从现象推原理的过程比单纯看书牢靠得多。多线程这条路没有捷径但也没有想象中那么陡。把基础打牢、把工具用熟、把问题踩透你会慢慢发现那些曾经背不下来的结论其实都是某个真实场景下的必然结果。
返回列表