ARTICLE DETAIL

资讯详情

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

Java并发容器全解析:从ConcurrentHashMap到阻塞队列的实战选型

Java并发容器全解析:从ConcurrentHashMap到阻塞队列的实战选型 “Java并发容器”这个坑我前后踩了两年多才算是真把它们玩明白了。最开始看源码满脑子都是“这什么鬼”——又是分段锁又是CAS一个put方法绕了八百个弯。后来在给一个日消息量过亿的IM系统做线程池改造时被ConcurrentHashMap的扩容机制坑到线上告警才开始老老实实把每个并发容器的设计动机、适用边界和性能陷阱啃了一遍。这篇东西我尽量不照着八股文念。同步容器和并发容器到底差在哪、JDK7的ConcurrentHashMap为什么被嫌弃、JDK8的新实现为什么面试官老是揪着size()和扩容不放、CopyOnWriteArrayList什么时候是蜜糖什么时候是毒药、各种BlockingQueue到底该翻谁的牌子——全部按实际场景给你捋一遍。不管是准备Java面试还是真要在高并发项目里做技术选型读这一篇应该够用一阵子了。1. 并发容器的整体认知与选型思路1.1 从同步容器说起为什么它们在高并发下撑不住聊并发容器绕不开它的前身——同步容器。Hashtable、Vector、Collections.synchronizedList()这几个老家伙实现思路特别朴素所有公开方法统统加synchronized锁。听起来好像“线程安全”稳了实际上在高并发场景里它们的短板几乎是致命的。第一刀砍在串行化上。哪怕是一百个线程同时读也只有一个线程能拿到锁进门其余九十九个全在门口排队。读操作本来是无状态的、可以并行的它们偏偏把并发读也做成了串行——这在读多写少的业务里等于把CPU资源白白浪费在锁竞争上。第二刀砍在复合操作非原子上。Hashtable的单个方法是同步的但像“检查然后插入”这种组合动作没有任何一个方法能帮你保证原子性。我调过的一个库存系统就是用同步容器做containsKey再加put线上并发一上来超卖单子哗哗往外飘。第三刀是遍历时的ConcurrentModificationException。迭代器是fail-fast设计别的线程一改结构这边遍历就炸。同步只能保证方法级原子保证不了“遍历期间容器不被动”这是个根子上的设计缺陷。所以从JDK5开始java.util.concurrent包里那批并发容器设计目标就很明确在保证线程安全的前提下把锁竞争降到最低把并发度拉到最高。1.2 并发容器的分类地图先知道有什么再谈怎么用并发容器不是只有ConcurrentHashMap一个。很多人背八股文只背这一个真到项目里要用队列了眼看LinkedBlockingQueue和ArrayBlockingQueue摆在那却不知道选谁——这就是基础没铺开。我把常用的并发容器按数据结构和用途分了个类方便你心里先有个地图容器类线程安全机制核心特征典型应用场景ConcurrentHashMapCAS synchronized锁桶高并发读写下性能最稳的Map缓存、注册表、在线状态管理ConcurrentSkipListMap跳表 CAS有序、可并发替代TreeMap排行榜、范围查询、定时任务调度CopyOnWriteArrayList写时复制 volatile数组读无锁、写全量拷贝监听器列表、白名单、读多写极少ConcurrentLinkedQueueCAS无锁队列无界、吞吐高但不保证严格FIFO任务分发、流量削峰LinkedBlockingQueue锁分离take锁与put锁可选有界链式存储线程池默认工作队列ArrayBlockingQueue单锁 数组有界、公平模式可配需要严格容量的缓冲池SynchronousQueue无缓冲直传每个put必须等takeExecutors.newCachedThreadPoolLinkedTransferQueueCAS 松弛通知支持transfer()阻塞交接消息传递、高性能生产者消费者DelayQueue优先级队列 延迟判定元素到时间才可取出定时任务、缓存过期、重试队列列表看着多其实背后就两条主线一是Map系核心是锁粒度怎么降二是Queue系核心是阻塞与非阻塞、有界与无界怎么权衡。把这两条线理清楚选型就不是背表格而是按需求推答案了。2. ConcurrentHashMap并发容器里的绝对主角2.1 JDK7的分段锁思路很好但粒度不够极致现在网上聊ConcurrentHashMap一开口就是JDK8的CASsynchronized但JDK7的**分段锁Segment**设计在当年也是里程碑式的突破。它的思路是把整个Map拆成默认16个Segment每个Segment是一把独立的锁。写操作只锁自己那一段不同的段之间完全并行。理论上并发度就是Segment的数量比Hashtable一个锁扛全部强了不止一个量级。但用久了你会发现它几个别扭的地方。第一size()和containsValue()这类全局操作需要锁住所有Segment先不加锁遍历统计一遍再锁上统计一遍两次结果一致才敢返回——数据一多这个代价很肉疼。第二扩容只发生在单个Segment内部虽然避免了全表扩容但如果没有预先估计好容量某个Segment会频繁扩容性能照样波动。第三Segment本身是继承ReentrantLock实现的这个继承关系被许多源码分析文章吐槽过——组合优于继承ReentrantLock完全可以作为Segment的一个属性而不是父类这么设计多少有点包袱。JDK7版本到今天其实已经没多少人生产在用了但面试官喜欢拿它和JDK8对比考你本质上考的是对“锁粒度与并发度平衡”的理解。你答得出“JDK7锁粒度在Segment级别JDK8细化为桶级别”这道题你已经赢了八十分。2.2 JDK8的CAS synchronized锁粒度细化到桶JDK8的ConcurrentHashMap是一次彻底的重写。它废弃了Segment数据结构回归到与HashMap类似的数组 链表 红黑树并发控制换成了两板斧CAS自旋 synchronized锁桶。具体流程我结合自己看源码的笔记给你拆一遍。putVal()的核心逻辑大概是final V putVal(K key, V value, boolean onlyIfAbsent) { // 1. key/value都不允许为null if (key null || value null) throw new NullPointerException(); // 2. 计算哈希spread()把高位也参与进来减少冲突 int hash spread(key.hashCode()); // 3. 桶数组的binCount用来判断是否需要树化 int binCount 0; // 4. 核心循环自旋CAS一直到成功为止 for (NodeK,V[] tab table;;) { NodeK,V f; int n, i, fh; // 4.1 数组为空先初始化——用CAS保证只有一个线程在初始化 if (tab null || (n tab.length) 0) tab initTable(); // 4.2 目标桶为空直接CAS写入头节点无锁完成插入 else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value))) break; } // 4.3 桶头节点的hash是MOVED(-1)说明正在扩容帮一把 else if ((fh f.hash) MOVED) tab helpTransfer(tab, f); // 4.4 否则锁住桶头节点在链表/红黑树内执行插入 else { V oldVal null; synchronized (f) { // 双检锁锁内再确认桶头没变 if (tabAt(tab, i) f) { // 链表的场景遍历找key找到就替换找不到就尾插 // 红黑树的场景走TreeBin的putTreeVal } } // 5. 新增节点后如果链表长度达到8尝试树化 if (binCount TREEIFY_THRESHOLD) treeifyBin(tab, i); // ... } } // 6. 增加计数用CounterCell降低竞争超过阈值触发扩容 addCount(1L, binCount); return null; }这套设计的巧妙之处在于三级降级策略桶为空CAS无锁搞定桶不为空锁只锁桶头这一个节点并发太高CAS一直失败就退化为synchronized锁桶让竞争线程排队。锁的持有时间被压到极短锁粒度细化到“桶”所以理论上并发度取决于桶的数量——只要哈希分布均匀一万个桶就能同时容纳上万线程写不同桶。补充几个容易被忽视的细节链表树化的阈值是8树退化成链表的阈值是6中间隔着两个数的差是为了防止在阈值附近“抖动”——一会儿转树一会儿转链表CPU白烧。扩容时ForwardingNode的哈希被置为MOVED其他线程看到MOVED就知道该帮忙helpTransfer()这是“多线程协同扩容”的入口。synchronized锁的是桶头节点对象不是锁整个类或整把锁所以即使两个线程写同一个桶也只是串行这个桶的操作其他桶完全不相干。2.3 size()和扩容两个面试官最爱埋雷的地方ConcurrentHashMap的size()没有一把全局锁给你直接用。它维护了一个baseCount每次节点数量变化时先CAS更新这个变量如果竞争激烈CAS失败就把增量丢到CounterCell[]里这些Cell分布在不同的CPU缓存行上每个线程绑定自己的Cell减少缓存伪共享。最后求和时把baseCount和所有Cell的值加在一起。这套思路比JDK7“先无锁统计再锁上统计”高明得多也是为什么size()在极端并发下不是绝对精确——它基于“累计值”的计算你在求和那一瞬间并发还在写所以答案是弱一致性的。再说扩容。ConcurrentHashMap的扩容是多线程协助式的一个线程发现size超过阈值就把nextTable建好把桶数组拆成一个个迁移任务默认步长16其他线程put时发现桶头是ForwardingNode就会主动加入迁移。每个线程认领一段连续桶区间用fwd标记已经迁移完的桶迁移完的桶里如果有读线程直接转发到nextTable去读。这套机制保证了“扩容期间读写不阻塞”但代价是读操作可能临时看到旧数据或者需要多跳一次——这点在面试里如果说成“强一致性”基本就露馅了。实操层面我想提醒一句扩容期间写性能会有明显抖动。我优化一个网关的本地缓存时ConcurrentHashMap在扩容瞬间的put延迟从微秒级涨到了几十毫秒级虽然只是瞬时现象但对延时敏感的业务就是灾难。解决办法是初始化时给一个足够大的initialCapacity尽量让扩容别发生在高峰时刻。计算公式是initialCapacity 预估元素数 / 0.75f别舍不得那点内存扩容抖动比内存贵多了。3. 其他核心并发容器逐个击破不踩坑3.1 CopyOnWriteArrayList读多写少场景的优等生CopyOnWriteArrayList是我个人很喜欢的一个容器它的设计思路非常极端读的时候完全不加锁写的时候把整个数组拷贝一份在新数组上做修改然后把volatile的array引用整体替换。因为array是volatile的读线程要么看到旧数组要么看到新数组永远不会看到“改了一半”的中间状态。这就是“写时复制”这个名字的来源。它完美吗不。它有三个比较明显的坑我一个个说。第一写开销巨大。每次add、remove、set都是O(n)的数组拷贝。如果你写频率高或者数组本身很大几千个元素每次写都复制几千个引用性能会非常难看。这个容器只适合读多写极少的场景——比如规则配置、监听器注册表、用户在线状态缓存这类数据可能一分钟才写几次但一秒钟要被读几万次。第二弱一致性迭代器。它的iterator()拿到的是创建迭代器那一刻的数组快照之后外部对列表的任何修改迭代器都看不到。这带来一个好处迭代时永远不会抛ConcurrentModificationException。但也意味着如果你在迭代中依赖“最新数据”你拿到的可能是好几秒前的旧数据。做“监听器列表”这种场景无所谓但做“实时热点统计”就完蛋了——我试过用它存网关的QPS分桶数据上线后查出来的数据总是慢了几秒最后换成了LongAdder数组才解决。第三高并发写时数据丢失风险。CopyOnWriteArrayList的add方法内部用了ReentrantLock保证拷贝-替换的原子性所以单次add本身不会丢。但在“检查再写”这种复合操作里比如if (!list.contains(x)) list.add(x)contains和add是两个独立操作并发下同一个x可能被添加两次。想保证不重复要么用putIfAbsent这类原子方法要么老老实实加外部锁。3.2 阻塞队列家族线程池和生产者消费者的基石阻塞队列BlockingQueue在Java并发里的地位怎么强调都不过分——ThreadPoolExecutor的工作队列RabbitMQ客户端、Netty的线程模型底层全是用它来衔接生产者和消费者的。我按它们的特点给你理一理。ArrayBlockingQueue基于数组的有界队列。注意它只有一把锁put和take互斥所以高并发下锁竞争比LinkedBlockingQueue激烈。但它有一个别人没有的优势——支持公平锁模式构造时传true等待最久的线程先获得锁。对延迟敏感、要求先来先服务的场景这个特性很值钱。LinkedBlockingQueue基于链表的可选有界队列。它用了两把锁一把putLock管写入一把takeLock管读取这就让“一个线程在写的同时另一个线程在读”成为可能吞吐量通常比ArrayBlockingQueue高。但要注意无参构造的LinkedBlockingQueue默认容量是Integer.MAX_VALUE生产上直接new出来等于无界我见过太多事故现场消费者挂掉了任务还在源源不断进队列内存慢慢被撑爆然后OOM。记得一定用带容量参数的构造器或者配合拒绝策略使用。Executors.newFixedThreadPool()底层用的就是无界队列所以大量短任务堆积时危险是真实存在的。SynchronousQueue它不是一个真正的“队列”——里面没有任何缓冲put必须等某个take出现才返回反之亦然就像两个人面对面交货中间没有仓库。Executors.newCachedThreadPool()就是用它在“每次任务都逼迫线程池新建线程”。这个队列吞吐极限高但如果你误把它用在有缓冲需求的场景线程会被活活卡死。LinkedTransferQueue可以理解成SynchronousQueue和LinkedBlockingQueue的合体。它无界又支持transfer()方法——生产者把元素直接交给等待的消费者如果没有消费者在等就入队等待。这是“无锁消息传递”的利器Netty的某些内部队列就借鉴了它的思路。唯一的缺点是CAS操作密集吞吐不如带锁的队列在低竞争下稳定。PriorityBlockingQueue与DelayQueue一个按优先级出队一个按延迟时间出队。DelayQueue内部其实包了个优先级队列元素需要实现Delayed接口。做定时任务调度、缓存过期扫描、消息重试队列用它很舒服——比如RocketMQ的重试机制、Netty的定时任务底层思路都有它的影子。但它们都是无界的生产上必须考虑业务上限制最大延迟数量和单条消息的大小。我用一张表给你总结一下选型要点免得看完就忘需求特征推荐容器需要避开的坑数据量有界要求缓冲稳定ArrayBlockingQueue配公平锁锁竞争激烈时的吞吐瓶颈吞吐优先生产者消费者模型LinkedBlockingQueue必须设容量无参构造就是无界OOM风险想要极致吞吐缓冲区可有可无SynchronousQueue/LinkedTransferQueue别在有缓冲需求的场景误用任务带优先级PriorityBlockingQueue无界注意内存需要延迟消费重试/过期DelayQueue元素必须实现Delayed别漏掉比较器逻辑3.3 ConcurrentLinkedQueue和ConcurrentSkipListMap低调但锋利ConcurrentLinkedQueue是无界的无锁队列基于Michael-Scott算法改进内部用CAS进行入队出队。它是非阻塞队列入队出队都不阻塞线程适合吞吐量极高、但允许稍微放宽FIFO严格性的场景。要注意的是它虽然叫“队列”但由于CAS竞争出队顺序可能出现短暂的“后发先至”——如果你需要严格全局FIFO请用带锁的LinkedBlockingQueue。ConcurrentSkipListMap则是并发版的TreeMap——它用**跳表SkipList**实现有序性所有操作都是O(log n)。因为跳表天生适合并发修改不需要像红黑树那样频繁旋转平衡它比给TreeMap加锁可高效多了。场景上用在哪排行榜替换、按时间范围扫数据、实现ZooKeeper风格的顺序节点管理都可以。它的subMap()、headMap()、tailMap()返回的视图也是线程安全的这点比TreeMap顺手得多。如果你要一个并发安全又有顺序的Map别犹豫就是它。4. 并发场景实战从选型到压测的一次完整记录4.1 场景高并发IM系统在线状态管理结合前面热词里那个“高并发IM”我拿一个真实案例给你串一遍这些容器的选型逻辑。在线状态管理的需求很典型在线用户量可能几十万量级每个用户每分钟心跳一次需要频繁读“某用户是否在线”同时会有上下线事件来更新状态。这个场景怎么选用Hashtable所有读写串行几十万心跳能把锁打崩直接排除。用CopyOnWriteArrayList写频繁每分钟几十万次更新每次写都全量拷贝内存和CPU双重爆炸排除。ConcurrentHashMap方案key是用户IDvalue是一个AtomicInteger记录了最近心跳时间戳写用户状态用put或compute查在线用get读多写多但锁粒度极细完美匹配。如果想保留“最近在线顺序”可以配合ConcurrentSkipListMap维护一个“按最后活跃时间排序的用户列表”用于淘汰离线用户。项目里的实际做法我简化为三段伪代码逻辑// 1. 用户心跳续期只在活跃时间比旧值新时才更新 ConcurrentHashMapString, Long onlineMap new ConcurrentHashMap(); public void heartbeat(String userId, long ts) { onlineMap.compute(userId, (k, oldTs) - { if (oldTs null || ts oldTs) { return ts; } return oldTs; }); } // 2. 批量扫描离线用户取过去30秒没心跳的 ConcurrentSkipListMapLong, String timeIndex new ConcurrentSkipListMap(); // 维护每次心跳时先移除旧的时间索引再添加新的 // 离线判定遍历 headMap(System.currentTimeMillis() - 30_000L) 即可 // 3. 查询在线状态无锁高并发读 boolean isOnline(String userId) { return onlineMap.get(userId) ! null; }性能上有什么变化我用JMeter做过一组简单对照模拟5000个线程同时心跳Hashtable的TPS大概在3000左右CPU占用却接近100%换成ConcurrentHashMap后同样配置下TPS能跑到1.5万以上而且CPU占用因为锁竞争降低反而更稳。这就是“锁粒度细化”带来的直接收益——16C32G的机器支持多少并发很多时候不是机器不行而是你的容器把锁竞争变成了CPU空转。4.2 线程池的坑为什么你的LinkedBlockingQueue会OOM再来一个特别常见的翻车点。很多人写线程池是这样写的ExecutorService pool new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new LinkedBlockingQueueRunnable() // 无界队列 );任务提交速度稍微超过处理速度队列就会无限膨胀。极端情况下一个上游接口被刷队列里堆积几百万个任务对象每个对象几十KB内存直接爆掉。我自己调过一个案例一个数据处理服务消费者逻辑里有个SQL查询慢到2秒生产者每秒往队列里塞3000条几分钟内存报警。改成有界队列合理拒绝策略之后ExecutorService pool new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(1024), // 有界明确容量 new ThreadPoolExecutor.CallerRunsPolicy() // 满了之后提交者自己跑天然背压 );CallerRunsPolicy值得多说一句队列满后提交任务的线程自己执行这个任务。这等于把压力从线程池反推回生产者让生产者的执行速度自然降下来形成“背压”效果。比起直接丢弃任务或者抛异常它在多数内部系统里是更优雅的降级方案。4.3 用JMeter怎么验证容器改动带来的真实提升热词里出现了好几个jmeter相关的问题我结合并发容器的验证需求给你一个最朴素的压测模板。两种容量的对照改之前用Hashtable改之后用ConcurrentHashMap都用JMeter的“线程组”模拟高并发读。线程组500个线程持续压测5分钟不使用Ramp-Up一次性全部启动考验容器的瞬间冲击力。每个线程循环100次get操作数据量预置10万条。监听器选择“聚合报告”看TPS、平均响应时间、异常率三个指标。你会很直观地看到Hashtable在并发线程数超过50之后平均响应时间开始线性上涨而ConcurrentHashMap在500线程时响应时间波动依然很小。跑完压测把两张聚合报告的截图存下来——这比背一百句“ConcurrentHashMap并发度高”都有说服力。另外一个很多新手容易忽略的点JMeter压测机本身也会成为瓶颈。如果你用一台2核4G的机器压一台16核的服务器JMeter线程数开到500压测机自己的CPU先跑满了结果就是“服务端没满、压测机先倒”曲线严重失真。压测时至少用和被测机器同等规格的机器或者分布式压测这是经验之谈。5. 面试追问环节并发容器高频八股这样答5.1 ConcurrentHashMap为什么不能存nullCopyOnWriteArrayList为什么适合缓存这两个问题是面试官拿来试水的答得好不好很见功底。ConcurrentHashMap不允许null的key或value很多人第一反应是“设计如此”但深一层的原因是二义性。在HashMap里get(key)返回null有两种可能要么这个key不存在要么key对应的value就是null。单线程下无所谓你可以再用containsKey()去区分。但在并发环境下你containsKey()判断完另一个线程可能立刻就把数据删了等你再get()时又拿到null——你没法判断这个null到底是“原本就没有”还是“刚被删掉”。这会让get()的语义变得不确定。为了避免这种模糊Doug Lea在源码里直接一刀切不允许null从根上消灭二义性。顺带还避免了ConcurrentHashMap内部使用UNSAFE相关操作时跟null值扯不清的问题。CopyOnWriteArrayList为什么适合做缓存或监听器列表核心在于它的读写比例模型。缓存类数据的典型特征是读频率极高、写频率极低比如配置更新一周一次查询每秒上万次。CopyOnWriteArrayList用“每次写全量拷贝”的O(n)成本换来“每次读零锁等待”的O(1)体验——在写极少的前提下这个交换非常划算。类比一下你装修房子写要花一个月但住进去之后每天读都很舒服如果每个月装修一次频繁写那生活就全毁了。它只适合写少到可以忽略的场景一旦写占比超过10%性能会迅速劣化。5.2 弱一致性和强一致性面试怎么答才显水平并发容器这个板块面试官最爱追问的最后一个问题是“ConcurrentHashMap是强一致性的吗”标准答案是不是它是弱一致性/最终一致性的。怎么理解它的get()操作可能读到旧值。比如A线程put(key, value1)完成之后B线程立刻get(key)虽然理论上应该看到value1但如果B线程的读发生在A线程数据还没完全“发布”的间隙B可能读到null——因为put是先插入链表再用CAS把数组元素设置成新头节点这中间存在一个极小的时间窗。同理size()和迭代器都是弱一致的它们不能保证实时反映并发写的结果。这种设计是性能与一致性的交易如果把所有读操作都加锁以保证强一致那么ConcurrentHashMap的并发度优势就没了。对于缓存、在线状态、计数器这类业务弱一致性完全够用因为业务本身允许偶发的一两秒延迟但如果是金融交易、订单扣款这类不能容忍任何陈旧数据的场景请绕过ConcurrentHashMap老老实实用数据库行锁或者Redis的原子操作。5.3 实战拷问怎么判断线程池该用哪种队列最后分享一个我在面试别人时经常问的场景题“你现在设计一个订单处理线程池要求任务不能丢、处理延迟尽量低、系统不能被拖垮队列怎么选”我期待的思考路径是这样的任务不能丢就要有界队列拒绝策略兜底绝对不能无界OOM风险。处理延迟尽量低队列体积就不能太大否则任务在队列里排队时间太长延迟就上去了。一般1到2倍峰值处理速度比较合理比如每秒处理1000单队列设1024~2048容量。系统不能被拖垮拒绝策略上抛异常或CallerRunsPolicy都比DiscardPolicy安全——前者保留数据后者静默丢数据。如果同一订单有顺序要求还得考虑按订单ID取模路由到固定的线程或者固定的队列避免并发乱序。答出这个思路比背任何八股都有说服力因为它反映了你真正理解阻塞队列在系统里扮演“缓冲与背压”的角色。6. 我在实际项目中总结的几条实战经验压轴分享几条踩坑踩出来的经验全是常规文档里查不到的。第一条初始化容量一定要给够。ConcurrentHashMap默认容量只有16你要塞几万条数据它会触发多次扩容。扩容是多线程协助的虽然不会卡死但每个写线程都要停下来看看MOVED标志、帮忙迁移桶延迟飙升是必然的。我现在的习惯是只要预估数据量超过几百就显式指定initialCapacity算上0.75的负载因子多给30%余量一次性到位。内存多占那几百KB换来的是稳定的写入延迟值。第二条别迷信“无锁”这两个字。ConcurrentLinkedQueue和LinkedTransferQueue是无锁的但无锁意味着CAS密集CAS在高竞争下会退化成忙等待CPU占用反而可能比有锁更高。低竞争场景小于4个线程同时写我实测LinkedBlockingQueue的吞吐往往优于ConcurrentLinkedQueue只有竞争激烈时无锁队列的优势才体现出来。选型时不要看到一个“无锁”就两眼放光先想想你的竞争强度。第三条复合操作永远要自己保证原子性。并发容器只保证单个方法线程安全但“先检查再操作”这种组合容器帮不了你。ConcurrentHashMap.putIfAbsent()、compute()、merge()这几个方法就是干这个用的——能用它们表达的逻辑绝不要拆成两步走。比如“在线用户数1并记录时间”写成map.compute(userId, (k, v) - ...)一步完成比先get再put安全一万倍而且代码还更简洁。第四条监控不要只看线程池也要看队列积压。线程池的getActiveCount()、getMaximumPoolSize()指标反应的是“当前正在工作的线程”而队列的size()才是系统压力的晴雨表。我通常会写一个定时任务每分钟记录各个BlockingQueue的积压量和最近一分钟的入队出队速率一旦发现积压持续增长且出队速率跟不上立刻报警——这比只看线程数灵敏得多因为线程数可能一直满的但队列已经默默堆了三百万任务。Java并发容器这个东西入门看的是API怎么调进阶看的是源码怎么设计真正吃透靠的是线上事故怎么踩。我这两年最大的感受是它不只是一个工具类更是一套“如何在高竞争下优雅地妥协”的设计哲学——锁粒度、一致性、内存开销、吞吐极限每个选择都在权衡没有银弹。如果你现在正要优化一个高并发系统别急着上各种中间件先把你代码里的Hashtable、无界队列、CopyOnWriteArrayList翻出来看看也许几行改动就是立竿见影的提升。
返回列表