ARTICLE DETAIL

资讯详情

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

Java并发编程实战指南:核心概念、线程池与锁的深度解析

Java并发编程实战指南:核心概念、线程池与锁的深度解析 如果有人问我Java里最值得反复琢磨的内容是什么我的答案大概率不是Spring也不是微服务那套东西而是Java并发编程。这个领域表面上看门槛不高new几个线程跑起来谁都会可一旦线上出现数据错乱、接口超时、CPU飙升排查到最后往往都指向并发层面的一两个细节。我自己就被这些坑教育过很多次所以这篇指南不讲虚的把并发编程里最核心的概念、最常用的工具以及我踩过的坑一次讲清楚。文章面向Java后端开发、打算系统学习并发的读者也适合面试前快速过一遍重点。读完你手里应该能有一套可以直接落到代码里的并发处理思路而不是一堆飘在空中的概念。1. 多线程到底难在哪里先看三个坑并发编程的所有问题归根结底逃不出三个维度原子性、可见性、有序性。我习惯把这三个特性比作餐厅后厨的操作规范——原子性保证下单、做菜、出餐是一个完整动作中间不能被打断可见性保证改完菜单后所有厨师都能看到新版本而不是各看各的旧菜单有序性保证你按“先热锅再倒油”的顺序执行不会被乱七八糟的优化打乱。只要其中一环出了问题程序就会出现诡异行为而且越诡异越难复现。1.1 原子性一个操作真的“不可分割”吗Java里最经典的原子性坑就是i。表面上这是一行代码但字节码层面它分成了“读取变量、计算加一、写回变量”三步。两个线程同时执行完全可能都读到旧值结果只加了一次。我当年在一个统计接口里用AtomicInteger替代int修掉了一个累计值不准的bug改动不大但排查过程折腾了大半天。所以现在养成了一个习惯看到多线程共享的可变计数第一反应就是检查有没有使用原子类或加锁保护。如果写一段多线程安全的计数逻辑我的优先级大概是AtomicLong/AtomicInteger能解决就先用原子类解决不了再上锁不要一上来就 synchronized 包一个方法。原子类基于CAS实现在高并发下比加锁开销小得多适合“读改写”这种短操作。当然CAS也有ABA问题简单场景用版本号即可解决后面讲锁的时候我会再提到。实操心得不要迷信“一行赋值就是原子操作”。判断一个操作是否原子要去想它是否可能被拆成多步任何“读-改-写”组合都要警惕。这也是面试里爱问i是否线程安全的原因它考察的其实是你能不能看穿字节码层面的真实动作。1.2 可见性线程之间真的“共享”变量吗第二个坑是可见性。每个线程都有自己的工作内存可以简单理解为CPU缓存线程读到的值可能不是主内存里的最新值。经典案例是while循环里的退出标志主线程改了标志位子线程却一直跑不停。这时候给标志位加volatile就能解决它的语义是每次读取都强制从主内存拿最新值同时写操作会刷新到主内存。但有个认知必须纠正volatile只保证可见性和有序性不保证原子性。它适合“一个线程写、其他线程读”的状态标志场景不适合复合操作。我见过有人用volatile修饰计数器结果统计还是错这就是没分清volatile和原子类的使用边界。你可以在代码里把volatile当成一个轻量级的同步信号灯但别把它当成万能锁。再补充一点Java内存模型JMM定义了一组 happens-before 规则比如锁的解锁先于后续加锁、volatile写入先于后续读取、线程start()先于其内部操作。这些规则不需要背得滚瓜烂熟但理解它们能帮你判断一个并发问题到底是不小心破坏了可见性还是破坏了原子性排查方向会清晰很多。1.3 有序性指令重排造成的“反常识”现象有序性这个点虚拟机和CPU为了优化性能会调整指令顺序单线程里感觉不到多线程下却可能出现匪夷所思的现象。最典型的例子就是双重检查锁DCL实现单例如果instance字段没有用volatile修饰一个线程可能拿到一个“分配了内存但还没完成构造”的半成品对象。这个问题教科书里讲烂了但实际项目里依然有人写错尤其是翻老代码的时候经常能看到。解决办法很简单DCL单例的实例字段用volatile修饰或者直接用枚举/静态内部类实现。我个人不太推荐在项目里手写这个看似精妙的并发技巧问题在于太容易出细节纰漏。从工程角度讲简单可靠的方案才是好方案没有炫技的必要。再延伸一下很多看起来“不相关”的字段错乱根子都在指令重排上。如果你怀疑是有序性问题别急着改业务代码先看看共享字段有没有正确的内存屏障手段volatile、锁、原子类都自带屏障语义。知道“哪里加了屏障”比背诵屏障指令有意义得多。2. 线程与线程池从手动 new 到自动管理理解了三个核心问题接下来看看实际工作中我们怎么跟线程打交道。很多初学者喜欢直接new Thread起一个线程跑任务这在小demo里没有错但放到生产环境里就是灾难频繁创建销毁线程开销巨大线程数量一旦失控系统直接卡死。这也是为什么线程池几乎是每个Java后端项目都绕不开的基础设施。2.1 理解线程状态排查一半问题都在这里线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。我排查线上问题时第一步几乎都是看线程卡在哪个状态。比如大量线程处于BLOCKED大概率是锁竞争激烈大量线程处于WAITING可能是在等某个条件通知比如调用了wait()或join()TIMED_WAITING 则是带超时地等待比如sleep()或await()。我见过很多同事一上来就看业务代码绕了半天才发现是锁没释放。其实先看一眼线程状态方向马上就有了。这里有个容易忽略的点RUNNABLE不代表线程一定在跑它可能是等待CPU调度的就绪状态也可能正卡在操作系统层面的IO上。所以看到大量RUNNABLE也不能直接断定没问题。2.2 线程池核心参数每一个选择都要有依据线程池我推荐直接用ThreadPoolExecutor它的七个参数每调一个都有讲究核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。先记住线程池的执行流程核心线程全忙任务进队列队列满了才创建非核心线程线程总数到最大上限再来的任务走拒绝策略。很多人背不下来其实只要想清楚一个道理——线程池的每一步都是把压力从核心线程往队列、往临时线程、最后往拒绝策略传导。核心线程数的设置业界有个粗略经验CPU密集型任务可以设成“CPU核数1”让线程尽量少切换IO密集型任务可以设大一些比如“CPU核数x2”因为线程大部分时间在等IO。但这个公式只能做起点真正准不准要靠压测。最大线程数要结合任务队列长度一起看如果队列用的是无界队列那么最大线程数基本没有意义因为任务永远不会触发创建非核心线程的逻辑。拒绝策略默认是AbortPolicy直接抛异常。实际项目里我更偏向结合业务做降级比如记录日志、把任务写入延迟队列稍后重试或者用CallerRunsPolicy让提交任务的线程自己执行这样能起到天然限流的作用。不要小看拒绝策略线上突发流量时它就是系统最后的保护网。经验之谈Executors 提供的几个快捷方法newFixedThreadPool、newCachedThreadPool等在demo里用很方便但生产环境我不推荐直接用。它们要么使用无界队列导致OOM风险要么允许创建无限线程把事情越拖越糟。手写ThreadPoolExecutor并没有多几行代码却能把边界攥在自己手里。2.3 线程池关闭与监控线上崩溃的前置防线线程池关闭也是个细节活。shutdown()会等已提交任务执行完再关闭shutdownNow()则尝试中断正在执行的任务并返回未执行的任务列表。很多线上事故就是“服务重启时线程池被强杀任务丢了一批”。如果你的业务对任务完整性有要求重启前一定要走优雅关闭流程并且给已经跑着的任务留出足够的时间。监控方面我建议把线程池的几个指标接进告警activeCount活跃线程数、queue.size()队列积压量、completedTaskCount完成任务数、largestPoolSize历史最大线程数。队列积压持续上涨而活跃线程已经打满基本就是线程池容量不够的信号这时候再想临时改参数就晚了。另外还有个坑通过submit()提交的任务如果里面抛了异常你没有调用Future.get()去取结果异常会被吞掉日志里什么都看不到。所以要么用execute()配合自定义异常处理器要么提交任务后一定要记得处理Future返回的结果。3. 锁的正确打开方式synchronized与Lock锁是并发编程里最常用的工具也是问题最多的工具。Java里有两套锁体系一套是synchronized关键字一套是java.util.concurrent.locks包下的Lock接口实现。很多初学者纠结“哪个性能更好”其实两者在JDK较新版本里性能已经没啥本质差距真正的区别在于功能灵活性和使用场景。3.1 synchronized 其实比你想象中聪明很多人对synchronized的印象还停留在“重量级锁”上那是老黄历了。JDK 6之后对内置锁做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级过程。简单理解一个锁一开始偏向于第一个获取它的线程如果一直没有竞争就保持在偏向状态一旦有其他线程来竞争会先尝试用CAS自旋升级为轻量级锁自旋失败且竞争激烈才膨胀为重量级锁进入操作系统层面的阻塞唤醒。所以大部分低竞争场景下synchronized的开销比想象中小得多。使用synchronized时最重要的是锁对象的选择。锁的是实例对象还是Class对象效果完全不同锁粒度越小并发度越高。我见过有人把整个方法都加上synchronized在方法里又做了耗时很长的外部调用结果并发能力被锁拖死。正确姿势是尽量缩小同步块的覆盖范围只把真正需要保护的共享变量操作放进锁里。3.2 Lock 在什么时候必须上ReentrantLock的优势体现在几个场景需要尝试获取锁而不是死等tryLock带超时、需要可中断的加锁lockInterruptibly、需要公平锁、需要多个条件队列。其中tryLock是我认为最实用的能力。比如两个线程同时抢一个资源抢不到就做别的而不是无限阻塞这种设计能大大降低死锁风险。Condition也是Lock体系里非常实用的东西。用synchronized时只能有wait和notifyAll而且被唤醒的线程并不一定在等自己该等的条件典型就是生产者消费者模型下所有线程都被叫醒又被没满足条件的消费者继续挂起白白浪费一堆唤醒开销。ReentrantLock可以创建多个Condition生产者等“不满”条件消费者等“不空”条件各等各的精准唤醒代码也更清晰。3.3 锁粒度的取舍少用锁嵌套多用无锁方案聊到锁就不得不提粒度问题。我的实践原则是能不用锁就不用锁能用小锁不用大锁能用无锁数据结构就不用锁。写操作非常多的场景下锁竞争不可避免但我们可以减少锁内代码量把耗时操作挪到锁外面执行。锁嵌套是死锁的高发区。两个线程各自持有一把锁又都在等对方手里的锁两边都动弹不得。排查这类问题最直观的方法是看代码里是否存在“锁的顺序一致性问题”——如果所有线程都按同一个顺序获取多把锁死锁理论上就能避免。但更稳妥的做法还是尽量避免一次持有两把以上的锁通过设计层面消解嵌套。另外像StampedLock这种乐观读锁也值得了解它的读操作在无竞争时完全不加锁只有在写操作抢锁时才退化适合读多写少但对一致性要求高的场景。还有一个很多人忽略的点synchronized、ReentrantLock这些都是单机进程内的锁微服务架构下多个服务实例共享资源时是锁不住的。这时候要么引入分布式锁要么用数据库乐观锁要么用Redis等中间件的原子操作。我在项目里见过有人拿本地锁去控多实例任务的唯一性结果线上两个实例同时把任务跑了一遍最后还得靠数据库唯一索引兜底。4. 并发容器的选型清单JDK 在java.util.concurrent包里提供了一大堆并发容器很多人记不住、也不会选。我的经验是先搞清楚“线程安全”到底意味着什么再按场景去挑容器而不是所有地方都无脑上ConcurrentHashMap。4.1 ConcurrentHashMap 为什么快又为什么不万能ConcurrentHashMap是并发编程里出场率最高的容器。JDK 7 时代它用分段锁提升并发度JDK 8 之后改成了更细的粒度数组头节点用CAS保证可见性哈希冲突的链表或红黑树节点用synchronized加锁。读操作几乎不加锁所以在读多写少的场景下性能非常可观。但ConcurrentHashMap不等于“所有操作都线程安全”。我用它踩过一个经典的坑先containsKey判断不存在再put插入这两个操作合在一起并不是原子的。两个线程可能同时判断不存在然后各自插入直接覆盖数据。这种“判断操作”的复合步骤哪怕在并发容器里也必须要加锁或者改用putIfAbsent这类原子方法。记住并发容器解决的是单步操作的线程安全问题不是业务逻辑的原子性问题。4.2 按场景选容器读多写少、队列、延迟任务读多写少的场景CopyOnWriteArrayList值得了解一下。它的原理是写操作时复制一份新数组写完再替换引用读操作在旧数组上跑根本不需要加锁。代价是每次写操作都要整数组复制写频繁时性能反而不好。所以它适合“配置项列表、监听器列表”这种改动极少、读取极多的场景。生产者消费者场景最常用的是BlockingQueue体系。ArrayBlockingQueue有界且公平性可配适合做线程池队列LinkedBlockingQueue默认无界容易堆积任务SynchronousQueue根本不存任务直接交给线程适合“不缓冲”的直传模式DelayQueue里的元素到期才能被取出可以拿来设计延迟任务。选哪个队列本质上是选“缓冲多少、是否堆积、取走的时机”要结合业务对数据的实时性要求来定。4.3 容器之外容易忽略的线程安全问题容器选对了别忘了一些基础类的并发隐患。最典型的就是SimpleDateFormat它内部用了Calendar对象多个线程共用时会抛异常或产生错乱日期。我通常在项目里直接禁止共享SimpleDateFormat要么用ThreadLocal包一层要么直接用 Java 8 的DateTimeFormatter它本身就是线程安全的。再一个是老版本的HashMap并发put会导致 CPU 100% 的问题JDK 7 里扩容时形成环形链表get操作就会死循环。JDK 8 之后结构变了死循环问题大幅缓解但并发下HashMap丢数据、覆盖数据的问题依然存在。所以只要是会被多线程操作的非线程安全集合一律换成并发容器不要抱有侥幸心理。另外并发容器的迭代器是弱一致性的迭代过程中可能看不到某些最新修改这个特性在业务上要注意别拿“遍历时立即看到全部修改”当预期。5. 实战复盘从设计到问题排查聊完理论、工具和容器最后用两个真实场景串一串。这些案例都是我实际处理过的并发问题浓缩出来的照着这个思路去设计很多坑是可以提前规避的。5.1 库存扣减的三种写法先看一个经典题秒杀场景下库存超卖怎么防。最笨的写法是先查库存、判断大于0、再扣减三步之间没有原子性并发请求一来库存就超了。第一种方案是把整个扣减包进synchronized或数据库行锁里简单但性能一般适合小并发单体应用。第二种方案是数据库乐观锁也就是更新语句里带上库存条件UPDATE stock SET count count - 1 WHERE id ? AND count 0靠数据库行锁和受影响行数判断是否成功。这种方式吞吐量比纯应用层锁好也是我目前在实际项目里比较推荐的单体方案。第三种方案是引入Redis原子操作做预扣减再异步同步到数据库适合超高并发的场景但引入了数据一致性的复杂度需要做补偿。这里要说清楚没有“绝对正确”的选型只有“适合当前业务”的选型。如果公司流量还在起步阶段为了炫技上Redis预扣减反而会让系统多出很多不必要的维护成本。5.2 死锁排查现场一张线程快照搞定死锁问题的排查我一般用应急三板斧jps找到目标Java进程的PIDjstack打印线程堆栈然后在日志里搜索deadlock关键字。有一次线上接口响应越来越慢我拉完线程堆栈很快就看到两个线程互相持锁等待对方释放堆栈里清清楚楚地标着“Found one Java-level deadlock”。解决方式也很直接调整代码里获取两把锁的顺序让所有线程都按相同顺序加锁问题就没了。排查线程问题是个熟练活除了jstack我还常用jstat看GC是否频繁、jmap看堆内存分布。如果发现大量线程阻塞在同一个锁上再用jstack连续抓几次对比哪些线程一直卡着不动基本就能锁定是谁占着锁不释放。这里有个小技巧线上出问题时先保留现场再重启慌乱之下重启机器会把最关键的证据丢掉这是很多团队踩过的坑。5.3 流量高峰下线程池的真实状态高峰流量下线程池的表现更像是一场压力测试。有一次活动上线后我通过监控看到线程池的活跃线程数打满队列积压持续攀升但接口调用方却在疯狂报超时。当时我第一反应不是直接调大最大线程数而是先确认机器CPU和内存是否还有余量。盲目调大线程数只会让CPU频繁切换线程反而把吞吐量拖得更低。后来我先排查了任务里是否有慢调用比如外部接口或数据库慢查询把瓶颈修复后线程池自然就恢复健康了。从那次之后我建立了一个习惯线上线程池指标必须提前接入监控并且设定好队列积压阈值告警。线程池不是一配了之的东西它需要配合业务流量做动态调整。JDK 提供了setCorePoolSize和setMaximumPoolSize方法热更新参数本身是可行的但前提是你在调整之前清楚知道当前瓶颈是什么。我个人在实际操作中的体会是Java并发编程最难的从来不是某个API不会用而是面对一个看起来随机出现的线上问题时能不能快速判断它属于原子性、可见性、有序性里的哪一类然后对症下药。平时写代码前多想一想共享什么、在哪里并发、用什么机制保护边界可能比出了问题再去翻一堆资料有效得多。多线程这个东西平时给它足够多的敬畏线上它就会少给你添麻烦。
返回列表