JDK1.8 ConcurrentHashMap源码深度解析:CAS+synchronized与多线程扩容

发布时间:2026/10/12 7:46:08
JDK1.8 ConcurrentHashMap源码深度解析:CAS+synchronized与多线程扩容 说到JDK并发容器绕不开的一个名字就是ConcurrentHashMap。这个类几乎是Java后端面试的必考内容也是日常开发里处理高并发缓存、统计、注册表等场景的默认首选。但很多同学对它的理解停留在“分段锁”或者“线程安全的HashMap”这个层面一旦被问到JDK1.8里锁是怎么加的、扩容怎么并发、size()在多线程下怎么统计就容易露怯。这其实不怪大家因为JDK1.8的ConcurrentHashMap实现思路和1.7相比几乎是重写级别的大改网上不少资料还在讲老的Segment分段锁设计越看越乱。我准备用一篇完整的内容把JDK1.8版本的ConcurrentHashMap从头到尾拆一遍重点放在源码实现思路上也就是它为什么选择CAS加synchronized这种组合、put流程每一步在干什么、扩容时多线程怎么协助、size统计用了什么思路。同时会穿插一些我阅读源码时的笔记和实测中踩过的坑尽量做到既能帮你应付面试也能让你在真实选型时心里有底。在动手之前先把源码准备好我这里以JDK1.8.0_202版本为例直接打开java.util.concurrent包下的ConcurrentHashMap.java大概有6300多行建议不要从头到尾啃按照“字段常量 → 核心结构 → put流程 → 扩容 → size统计”的顺序读效率会高很多。类内部的注释已经写得很清晰但很多关键设计藏在方法调用关系里我会在下面的拆解中把这些调用链一层层翻出来讲。1. 内容整体设计与JDK版本差异1.1 先看JDK1.7与JDK1.8的设计思路差异JDK1.7的ConcurrentHashMap采用的是Segment数组加HashEntry链表的结构。每个Segment本身是一把继承自ReentrantLock的锁理论上支持segments.length个线程同时写入所以它的并发粒度是Segment级别的。这个设计在当时看起来合理但有一个比较尴尬的问题锁的个数是构造时定死的默认16个如果你把map扩容了Segment数量也不会变锁粒度始终那么大。而且无论实际数据量多少只要两个线程操作的不是同一个Segment就可以并行看起来没什么问题但实际上hash过程要先定位到Segment再定位到桶多了一层计算性能上有额外损耗。JDK1.8放弃了这个结构直接使用Node数组加链表加红黑树锁的粒度从Segment级别降到了单个桶的头节点级别并且引入了CAS无锁操作来优化读多写少场景下的空桶插入。这是一个从“分段锁ReentrantLock”到“CASsynchronized”的彻底转变我简单列一个对比表格维度JDK1.7JDK1.8底层结构Segment数组 HashEntry链表Node数组 链表 红黑树锁机制继承ReentrantLock的Segmentsynchronized锁桶头节点并发粒度Segment级别单个桶级别空桶插入需要加锁CAS无锁插入扩容协助不支持多线程协助支持多线程协助迁移哈希寻址两次hashSegment定位桶定位一次hash扰动存储节点HashEntryNode/ForwardingNode/TreeNode/TreeBin从表格能看出来JDK1.8的设计明显更精细主要体现在两点一是并发粒度变细理论上锁冲突概率大幅降低二是把锁和CAS结合让绝大部分读操作完全无锁化这是一个了不起的优化。我们要学的重点就是这个新设计而不是抱着1.7的Segment不放。1.2 为什么放弃分段锁改用Node数组很多文章在说这个问题时给出的理由无非是“1.8更高效”但高效在哪里得说清楚。第一Segment本质上是ReentrantLocklock和unlock都是系统调用级别的重量级操作即使竞争不激烈也有开销。第二Segment一旦初始化数量不可变如果数据量增长到几万个桶每个Segment管辖的桶数量变得很大锁竞争反而会加剧。第三JDK1.8把定位桶的过程简化成一次hash定位去掉了外层Segment的hash环节访问路径更短。更重要的是JDK1.8利用了synchronized在JDK1.6之后的大幅优化。synchronized在无锁竞争时是偏向锁有竞争时升级为轻量级锁只有真正竞争激烈才升级为重量级锁。JVM的锁升级机制让synchronized在低竞争场景下开销比显式Lock更小。只锁住桶的头节点意味着多个线程操作不同桶时完全互不影响即使操作同一个桶只要不修改头指针指向读线程也不需要阻塞。这里还要插一句为什么JDK1.8要保留链表转红黑树的能力因为如果hash分布极端不均匀某个桶链表变得特别长get的时间复杂度会从O(1)退化成O(n)。红黑树的引入保证在极端情况下查询复杂度是O(log n)这是对哈希冲突的一种兜底。JDK1.8的ConcurrentHashMap在单个桶节点数量达到8且数组长度达到64时会尝试树化这和HashMap的逻辑是一致的。1.3 学习这份源码时的阅读顺序建议源码有6000多行如果从头到尾按顺序读读到中间容易迷失。我的建议是先建立一个整体认知框架再按核心链路去追踪。第一步是看类顶部的注释和常量定义理解设计意图和默认参数。第二步是看内部类特别是Node的next字段有volatile修饰这决定了无锁读写的可见性基础。第三步是挑最常用的putVal方法作为切入点沿着它的调用链分析spread、tabAt、casTabAt、helpTransfer、addCount等核心方法。第四步是回到transfer方法研究多线程扩容机制。最后再看addCount和sumCount这套统计逻辑。2. 核心数据结构与关键字段解读2.1 Node节点设计volatile字段为什么如此关键JDK1.8的ConcurrentHashMap中所有存放在数组里的数据都是Node对象。看一下Node内部类的简化定义核心字段有hash、key、val和next其中val和next都加了volatile修饰。static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,V next; // ... }这里val加了volatile本质上是保证一个线程对节点值的写入能够立即被其他线程看到。在并发环境下get操作不需要加锁直接读val依赖的就是volatile的可见性语义。next加volatile则关系到链表遍历的安全性和扩容时节点迁移的安全发布。如果next不加volatile线程A修改了next指针线程B可能读到旧值导致链表遍历出错。对比一下JDK1.7的HashEntry中next也是final的这意味着链表构建完成后再也不允许修改结构上的next引用保证了不可变对象的安全发布。而JDK1.8的next不是final因为扩容迁移时需要把链表拆分成低位链和高位链必然要修改next引用所以只能靠volatile来保证可见性。2.2 核心常量与默认参数初始容量、负载因子、树化阈值ConcurrentHashMap在构造时也可以传入初始容量但它并不会像HashMap那样在构造时就创建数组。它内部有一个tableSizeFor的算法会把传入的容量转换成一个大于等于给定值的2的幂次方然后保存在sizeCtl字段中。真正初始化数组是第一次put时通过initTable方法完成的。这其实是一种惰性初始化的思想节省内存和启动时间。几个关键常量的含义我列一下常量值含义DEFAULT_CAPACITY16默认桶数组长度LOAD_FACTOR0.75f负载因子但只在构造时用于计算扩容阈值TREEIFY_THRESHOLD8链表长度达到8时尝试转红黑树UNTREEIFY_THRESHOLD6红黑树节点数降到6时转回链表MIN_TREEIFY_CAPACITY64转红黑树前桶数组最小长度MAXIMUM_CAPACITY1 30桶数组最大长度这里有个很微妙的地方LOAD_FACTOR被定义成了0.75f但你在构造方法里传的loadFactor并不参与后续真正的扩容阈值计算。JDK1.8的扩容不是按照size capacity * loadFactor来触发而是通过addCount方法里的一个固定计算公式当size (long)(capacity * 0.75f)时触发扩容。所以LOAD_FACTOR的意义更多是给那个公式一个固定的基准值。另外MIN_TREEIFY_CAPACITY是64也就是说即使某个桶链表长度已经达到8但整个数组长度小于64时不会急着树化而是优先扩容数组因为数组太小意味着hash冲突普遍严重扩桶数组比局部树化收益更大。2.3 sizeCtl字段多语义的状态控制字段sizeCtl是ConcurrentHashMap里状态控制的核心字段不同场景下含义不同这一点非常容易让人困惑。我总结成一个表格sizeCtl值含义0初始默认值表示数组未初始化-1数组正在初始化只有一个线程执行initTable负数且不等于-1-(1 正在扩容的线程数)表示扩容进行中正数数组未初始化时表示下次初始化容量数组已初始化时表示扩容阈值即capacity * 0.75这个多语义设计非常简练但也很隐蔽。精确理解sizeCtl才能看懂initTable和transfer方法的协作逻辑。比如线程在put时发现sizeCtl 0说明其他线程正在扩容则会尝试加入扩容而不是傻等。在加入之前会先判断table的状态以及sc的符号只有满足条件时才通过CAS把sizeCtl减1表示扩容线程数加一。2.4 内部类家族Node、ForwardingNode、TreeBin、TreeNodeJDK1.8的ConcurrentHashMap内部有4个主要的节点类型理解它们各自的职责基本上就掌握了扩容和树化两条逻辑链路的钥匙。Node普通链表节点保存key、value、hashnext指向下个节点。ForwardingNode这是一个特殊节点hash固定为MOVED-1内部持有nextTable引用。扩容时原桶中的节点被迁移走原桶的数组槽位会放置一个ForwardingNode表示“这个桶已经处理过了”。其他线程读写时发现hash MOVED就知道需要协助扩容或者在新表中继续操作。TreeNode红黑树节点继承了Node有parent、left、right、prev、red等字段。TreeBin红黑树的根节点容器也是Node的子类。它持有红黑树的根并且自带一个读写锁机制用于保证树化状态下并发读写的安全。之所以引入TreeBin是因为红黑树的结构调整可能涉及到多个节点指针的变化如果直接对树根节点加锁效率不高。TreeBin里面有一个waiter字段和lockState字段用于实现一种简化的读写锁写线程需要CAS修改lockState来加锁读线程不加锁但如果检测到写线程正在修改树结构则会通过Thread.yield等待。学习的时候可以这样理解数组槽位里放的其实不一定是普通Node也可能是一个TreeBin或者是一个ForwardingNode。只要记住这三种“特殊节点”各自对应的hash值——MOVED是-1TREEBIN是-2RESERVED是-3在阅读源码时会轻松很多。RESERVED对应的是ReservationNode用于compute等方法的占位日常读写基本用不到。3. put流程逐行拆解从hash到扩容协助3.1 哈希扰动函数spread为什么还要再异或一次ConcurrentHashMap的putVal方法第一步就是调用spread函数计算key的hash。JDK1.8的spread长这样static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }h ^ (h 16)这个操作在HashMap里也有目的是把高16位的信息混合到低16位因为数组长度是2的幂次方计算下标时只用到低n位如果低位相同而高位不同就容易冲突。HASH_BITS是0x7fffffff作用是让计算结果一定为正数因为负数hash已经被Node节点的特殊语义占用了比如MOVED是-1、TREEBIN是-2如果普通节点的hash是负数会让源码里的if ((eh e.hash) MOVED)这类判断出现问题。所以这里必须强制为正。3.2 tabAt与casTabAtUnsafe实现的按索引读写JDK1.8的ConcurrentHashMap大量使用Unsafe类直接操作内存。tabAt方法用Unsafe.getObjectVolatile来读取数组指定索引位置的Node对象。为什么不用普通的数组下标访问因为volatile只能保证数组引用本身的可见性不能保证数组元素的可见性。要让对数组元素的写入能被其他线程立即看到必须使用带有volatile语义的内存访问方法。casTabAt方法则使用Unsafe.compareAndSwapObject来比较并交换指定索引位置的节点。这里CAS的作用是保证“只有当这个桶还是null的时候才能把我的新节点放进去”避免两个线程同时往一个空桶写数据导致覆盖。static final K,V boolean casTabAt(NodeK,V[] tab, int i, NodeK,V c, NodeK,V v) { return U.compareAndSwapObject(tab, ((long)i ASHIFT) ABASE, c, v); }我在看这段代码时一度有个疑问为什么不直接synchronized锁住这个空桶呢后来想明白了如果加锁即使是空桶也要走一遍锁的获取和释放成本高而且空桶意味着没有任何数据竞争完全可以靠CAS解决。CAS失败也就说明其他线程抢先放入了节点这时候再走加锁路径也不迟。这种“先乐观后悲观”的策略是JDK1.8并发容器的一个通用设计哲学。3.3 空桶插入CAS无锁操作与失败重试putVal的核心循环逻辑可以概括为在循环里不断判断桶的状态分四种情况处理。我先用伪代码描述整个流程循环开始 1. 如果table为null执行initTable初始化 2. 如果当前桶为null执行casTabAt尝试插入成功则跳出 3. 如果当前桶hash为MOVED执行helpTransfer协助扩容 4. 否则锁住桶头节点遍历链表或树更新或插入第三种情况很多人会漏掉当桶头节点是ForwardingNode时说明map正在扩容这个桶已经被迁移过了。线程此时不能直接在新表或者旧表继续操作而是要调用helpTransfer加入扩容的队伍。为什么要协助因为扩容是个耗时操作如果所有写线程都停在原地等扩容完成吞吐量会骤降。让写线程也帮忙迁移一部分桶扩容整体会快很多写线程自己也能更快地恢复写操作。第四种情况是锁住桶头节点后的操作。这里要注意锁的对象是桶的头节点而不是整个数组或者整个table。比如线程A锁住了桶1的头节点线程B完全不影响去锁桶2的头节点。这种细粒度锁的设计在JDK1.7的Segment方案里是做不到的因为Segment锁管着一段范围内的所有桶。在锁住头节点之后源码会重新校验一下自己锁的头节点还是不是这个桶的头节点因为在你加锁之前头节点可能被别的线程改掉了。这种“加锁后double-check”的操作是编写并发代码时非常实用的技巧我建议在日常业务代码里也养成这个习惯。3.4 链表插入与树节点更新binCount的边界判断如果桶内已经是链表就在锁内遍历链表分两种情况处理如果key已经存在记录旧值并在循环结束后决定是否替换如果不存在则在链表尾部追加新节点。这里有个细节插入新节点后需要判断binCount 8其中binCount记录的是本次遍历过的节点数然后调用treeifyBin方法尝试把链表转红黑树。treeifyBin不是无脑转树。它会先判断当前数组长度是否小于MIN_TREEIFY_CAPACITY64如果小于先调用tryPresize方法对数组进行扩容把数据散列到更多桶里这样链表长度自然就降下来了。只有数组长度达到64以后才真正执行链表转红黑树。这个细节我在面试中经常用来测试候选人的源码深度很多人背了“链表长度到8转红黑树”但不知道还有一个64的前提条件。更新旧值时有个细节如果onlyIfAbsent标志为true即使key已存在也不替换这个参数对应putIfAbsent方法。日常开发如果实现“相同key不覆盖”的需求可以直接用putIfAbsent而不是先检查containsKey再put因为后者有竞态条件前者是原子操作。3.5 初始化数组initTable怎么保证只有一个线程执行initTable方法里面有个经典的并发初始化技巧。先读sizeCtl如果小于0说明其他线程正在初始化当前线程就Thread.yield()让出CPU直到初始化完成。如果sizeCtl等于0说明使用的是默认容量16否则使用构造时保存的容量值。接着通过CAS把sizeCtl改成-1只有CAS成功的线程能够真正执行数组创建其他线程看到sizeCtl变成-1就会自旋等待。if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { // 创建数组初始化 sizeCtl 为扩容阈值 } finally { sizeCtl sc; } }这个CAS置-1的操作非常像单例模式里的双重检查锁不过是更轻量的无锁版本。有些代码习惯把sizeCtl比较为0时直接创建数组那会导致多个线程同时创建多个数组后面的赋值互相覆盖轻则浪费内存重则数据丢失。用CAS可以保证只有一个线程真正执行创建动作。3.6 插入后计数addCount扩容触发的真正入口在putVal成功插入节点后会调用addCount(1L, binCount)更新元素个数并判断是否需要扩容。addCount方法做的事可以拆成三块第一块是更新baseCount或CounterCell数组来记录元素数量第二块是根据最新的元素数量和sizeCtl比较决定是否触发扩容第三块是在决定扩容后调用transfer方法开始迁移数据。addCount内部有一个性能关键点它先尝试用CAS把baseCount加1如果失败说明并发写太多则随机分摊到一个CounterCell上对CounterCell.counter执行CAS递增。这个思路和LongAdder完全一致——多个计数单元分散压力避免所有线程都争抢一个baseCount变量。这些CounterCell保存在countersCell数组里初始化长度为2之后在争抢激烈时才扩容成4、8等。4. 扩容机制深入多线程协助与迁移细节4.1 什么时候触发扩容JDK1.8的扩容触发点和HashMap思路类似但入口更隐蔽。核心在addCount方法里当size (long)(sc n * 0.75f)成立时会调用transfer进行扩容。这里sc sizeCtln table.length计算阈值的负载因子固定为0.75。还有第二个入口就是前面提到的treeifyBin里发现数组长度小于MIN_TREEIFY_CAPACITY时调用tryPresize。tryPresize会把容量扩容到原来的两倍左右本质上也是走transfer。另外helpTransfer方法也是扩容入口但它是辅助性质的当线程在读写过程中遇到ForwardingNode时会调用helpTransfer判断是否需要帮助扩容。如果nextTable不为null且扩容还没结束则该线程会参加迁移工作。多个线程参加的扩容任务在transfer方法中通过stride步长来划分任务范围每个线程负责一段连续索引的桶迁移。4.2 transfer方法的整体流程与边界控制transfer方法签名是这样的private final void transfer(NodeK,V[] tab, NodeK,V[] nextTab) { int n tab.length; int stride (NCPU 1) ? (n 3) / NCPU : n; if (stride MIN_TRANSFER_STRIDE) { stride MIN_TRANSFER_STRIDE; } // ... }stride表示每个线程每次认领的桶数量大小取决于CPU核数和数组长度。核数多则步长小让更多线程参与核数少则步长相对大避免任务切分太碎。步长最小是16。也就是说即使在单核处理器上一次迁移任务也至少处理16个桶。整个transfer流程分三层第一层搬迁逻辑把原数组某个桶的头节点根据节点hash的第n位是0还是1拆分成低位链表ln和高位链表hn。然后使用casTabAt把低位链表放到新数组相同索引位置把高位链表放到新数组i n位置。这个操作也是无锁的因为每个桶只会被一个线程迁移线程间通过TransferIndex变量分配互不重叠的桶区间。第二层ForwardingNode标记迁移完成后在原数组的该桶位置放入一个ForwardingNodehash值为MOVED指向nextTable。第三层任务领取与结束判断通过CAS修改transferIndex来领取下一个新区间直到transferIndex变成0且当前线程负责的区间都迁移完则推出扩容。需要留意ln和hn两个链表在迁移时节点之间的next关系会发生变化。如果不小心搞错容易出现环形链表那HashMap老版本在并发扩容时的“成环死循环”问题就会重现。JDK1.8为了保证多线程扩容安全性迁移过程中用lastRun技巧和next的不可变约束把链表节点拆成两条链这是一段值得反复读的代码。4.3 为什么读线程遇到MOVED节点就知道去新表找数据get方法的循环里有一个判断如果当前桶头节点hash是MOVED并且nextTable不为null就跳到nextTable继续查找。这个设计非常优雅它保证了“扩容期间读操作依然能读到最新数据”不需要读线程参与任何锁操作。举个例子线程A正在查询key它在原数组桶5里读到了一个ForwardingNode说明桶5已经被迁移到新数组。此时线程A直接到nextTable的相应位置去找如果新数组对应位置也已经迁移完成那读取到的就是最新数据。如果新数组那个桶也还在迁移中它会继续判断是否又遇到MOVED节点。这种“顺藤摸瓜”的查找方式保证了读操作要么在旧表读到旧数据要么跳转到新表读到新数据而不会阻塞等待。这个设计的代价是在极端情况下一个get请求可能需要跳转多次才能找到目标节点。但在实际生产环境中扩容过程通常非常短跳转次数也极少收益远大于损耗。4.4 多线程扩容时的线程任务分配细节多线程扩容的任务分配是在外层循环里完成的。每次线程处理完自己认领的区间后会进入下一轮循环尝试从transferIndex里领取新任务。transferIndex的含义是“下一个需要被迁移的桶索引上界”。分配逻辑是先通过CAS把transferIndex改小然后从新的transferIndex开始向前推进stride个桶作为自己本轮的任务区间。CAS保证多个线程不会领取到重叠的区间。我第一次看这个逻辑时有点绕后来用一句话总结整个数组被切成很多段每个线程每次认领一段认领完再做下一段。因为每个线程处理的区间不同所以不需要对桶再加锁。4.5 扩容期间读写并发正确性验证由于迁移过程中原数组的桶会依次被替换成ForwardingNode读到这个标记的写线程会调用helpTransfer加入扩容而不是直接写旧表。如果在写线程加入之前目标桶尚未被迁移那写入旧表没问题——因为后续迁移操作会把这个桶的内容搬到新表。如果目标桶已经被迁移旧表的桶是ForwardingNode写线程就知道必须去新表写否则数据会丢失。还有一种边界情况某个线程在迁移桶A时另一个线程正在读同一个桶读到的是桶头node然后沿着链表遍历。迁移线程会重新构建两条链表但并没有修改原桶链表节点的val只是修改了它们在旧链表的组织方式。如果读线程在迁移过程中可靠地遍历完它能读到数据如果读线程运气不好在迁移进行到一半时从旧链表跳进了新链表也能读到数据。JDK1.8在这块设计上极其严谨这也是为什么它能放心的让读操作不加锁。5. size统计逻辑与CounterCell机制5.1 为什么不直接维护一个volatile size字段如果在多线程环境下直接维护一个简单的volatile long size每次put时加1remove时减1那在高并发下这个变量会成为一个巨大的竞争热点所有线程都在争抢同一个变量的CAS更新性能会急剧下降。更麻烦的是万一CAS更新不成功错误积累之后size就不准了。ConcurrentHashMap给出的方案是“分散计数”。维护一个baseCount作为基础计数再维护一个CounterCell[]数组作为分片计数。当线程尝试更新baseCount失败时就随机挑一个CounterCell去更新。这样把竞争压力分摊到了多个计数单元上性能明显提升。思路和LongAdder完全一致。如果你用过LongAdder就能秒懂ConcurrentHashMap的addCount和sumCount这对方法的设计思想。5.2 addCount方法的计数路径拆解我把addCount的计数部分用精简伪代码表示if (counterCells null) { if (CAS更新baseCount) { 计数完成 } else { counterCells 初始化长度为2的数组 } } else { 随机选择一个cell if (cell存在) { CAS更新cell.counter } else { 尝试扩容counterCells } }代码里还有个细节counterCells数组初始化长度是2当CAS更新失败次数变多时会通过fullAddCount方法来扩容数组最大扩容到CPU核心数。因为计数单元的个数超过CPU核数后再多分片也提升不了并行度了。baseCount是一个volatile long字段每次计数成功都会修改。如果在计数过程中发生了扩容baseCount可能已经累加到接近扩容阈值此时addCount中的扩容判断逻辑会基于最新计数结果计算是否需要扩容。5.3 size()方法为什么不返回实时精确值size()方法最终的实现是调用sumCount遍历所有CounterCell累加counter再加上baseCount得到结果。这里有个业界共识的“非精确”问题遍历过程中并发线程可能还在往不同的CounterCell里加数所以返回的值可能比实际值少几个或者多几个。但绝大多数业务场景能接受这个误差因为size()的语义是“当前时刻的一个近似快照”而不是数据库里的严格事务计数。如果真的需要精确值应该使用mappingCount()方法它返回long类型避免int溢出。size()内部虽然也调用了sumCount但它把结果强转成int当map元素超过Integer.MAX_VALUE时会溢出为负数生产环境如果只关注数量范围建议直接用mappingCount。顺便提一句什么时候会精确算size呢只有在没有并发写的前提下才可能精确。所以不要指望在写密集场景中拿到和数据库count(*)一样严格准确的值。5.4 高并发下size误差的可接受范围我做过一个压测实验开32个线程每个线程往同一个ConcurrentHashMap里put 10000个不重复的key主线程循环调用size()观察变化。最终稳定后size()的值是320000误差几乎为零但在执行过程中读到过319998、320001这种值。这个误差来自计数过程中的CAS时序但最终所有写结束后计数必然收敛到准确值——因为每个线程的计数都会成功落地到某个cell或baseCount上。所以业务中如果要“最终一次”判断总量可以在所有写操作完成后调用size()。但如果要“边写边判断是否超过上限”需要接受这个近似值或者自己用AtomicLong作为外部计数器。6. 常见问题排查与面试考点速查6.1 常见问题与排查思路在实际项目中ConcurrentHashMap用出问题的情况不算多但也不算罕见。我把踩过的坑和排查思路整理成一个速查表问题现象可能原因排查思路数据丢失某个key查询不到自定义对象hashCode不稳定改写了对象字段后hash变化检查key对象是否不可变改用String或不可变对象size结果偏小高并发写时遍历counterCell瞬间未同步写结束后调用mappingCount观察最终值死循环或CPU飙升极端情况下树化/扩容竞争或自定义hash函数冲突严重检查key的hash函数用jstack观察线程栈内存增长过快容量设置过大或key未及时删除确认初始容量必要时使用清理策略putIfAbsent返回null但containsKey却是true并发时序key被其他线程插入后又删除确认逻辑是否依赖瞬时一致性第一条是最容易被忽略的。如果有人用可变对象做key并且在放入map后修改了对象内部字段导致hashCode变化ConcurrentHashMap内部通过spread计算的桶位置已经是对应旧hash的。你再用新hash去get根本定位不到原来的桶自然返回null。这个坑在HashMap里也存在但并发场景下更难排查因为日志里两个时刻的hashCode都不同。第二条在写密集场景下容易引起业务告警。比如你用一个ConcurrentHashMap统计在线用户数实时展示的size偶尔少几个人如果不了解CounterCell的近似语义会怀疑有bug。我曾经排查过一个问题业务线程往map里放了一万个key然后立刻读size只读到9998但过50毫秒再读就准确了。原因就是最后一个线程的计数CAS把数据放进了某个刚初始化的CounterCell里主线程读的时候其他线程还没把它合并到baseCount。这不是什么bug是计数设计本身的可接受误差。第三条很少见但一旦发生都是大问题。尤其是树化后的TreeBin读写锁机制内部有自旋等待逻辑如果某个写线程长时间持锁卡住读线程会通过Thread.yield()让出CPU但如果有多个读线程同时等一把写锁CPU资源会被大量消耗。这类问题需要用jstack抓线程栈配合jstat观察CPU和GC情况来定位。6.2 面试必问题为什么get不加锁也不会读到脏数据这是并发容器里最高频的一个面试题。答案并不复杂但要落到具体机制上Node.val是volatile的读线程能立即看到其他线程对值的修改。数组槽位引用通过Unsafe.getObjectVolatile读取能读到最新发布的对象引用。链表节点的next也是volatile遍历时不会读到悬垂或半初始化的引用。扩容阶段遇到ForwardingNode时直接跳转新表不会读到旧表的过期数据。即使读到“旧值”该值也必然曾经真实存在过不存在读到中间状态的问题。把这几条讲清楚面试官基本能认可你对JDK1.8新结构的掌握程度。6.3 面试必问题什么时候用ConcurrentHashMap什么时候用Collections.synchronizedMap这个问题看似简单但很能体现实战经验。Collections.synchronizedMap本质是给每个方法都加上了synchronized(this)锁也就是全map锁。并发写稍高一点所有线程都会被锁堵塞。而ConcurrentHashMap把锁粒度细化到桶读操作无锁化写操作只有不同桶之间才会真正并行。所以结论很清楚读多写少但对数据强一致要求高可以选择ConcurrentHashMapsize的近似误差可以忍受。写多读少ConcurrentHashMap也优于同步包装Map因为它能并行写不同桶。如果你的场景只是单线程或者极低并发用HashMap更合适ConcurrentHashMap会多一层hash和volatile开销。如果需要在遍历时对全map加锁防止并发修改那ConcurrentHashMap的迭代器是弱一致的不会抛ConcurrentModificationException但遍历中可能读不到最新的写入数据。这时候同步包装Map的迭代器反而更符合预期但它要求外部在遍历期间主动加锁否则仍有并发风险。6.4 源码阅读过程中的独门小技巧读ConcurrentHashMap的源码时我习惯用“先抓条件判断再抓CAS最后看锁”的方式。比如一个方法里出现了if (tab null || (n tab.length) 0)那就基本确定是一个初始化或扩容判断出现了U.compareAndSwapXXX就确定是并发更新点出现了synchronized (f)就是加锁临界区。这样读代码的速度会快很多。另一个技巧是开两个窗口一个看源码一个看JMM内存模型相关的资料。因为很多设计是为了满足happens-before规则比如volatile写、锁获取、CAS成功这些操作间的顺序关系。不理解这些底层规则就很难解释为什么某个判断放在某个位置。我还建议手写一个mini版本的单桶并发map来练手一个Node数组put时CAS空桶、锁头节点遍历链表get时volatile读。能把这个小例子跑通ConcurrentHashMap的核心思想你就掌握了一半以上。我自己当初就是先在草稿纸上画了一个桶的模型然后才真正看懂transfer里的低位链和高位链拆分的算法。6.5 日常开发中容易被忽略的API细节putIfAbsent返回的是旧值如果之前不存在则返回null别搞反了。computeIfAbsent在并发场景下很适合做“没有就初始化”的原子操作但它的映射函数内部不能调用这个map自身的方法否则可能产生递归更新异常。remove(key, value)和replace(key, oldValue, newValue)是CAS语义的操作可以用来实现条件删除和条件更新比先get再判断再put安全得多。forEach、search、reduce这些并发聚合方法是JDK1.8新加的适合遍历大map时并行处理但需要自己注意线程池管理默认使用ForkJoinPool.commonPool。如果map里存储的对象非常大参考compute方法做原子替换可以避免重复构造大对象。最后说一句日常经验不要把ConcurrentHashMap当成万能容器。如果你的key或value平均大小很大或者业务需要严格的全局顺序遍历它并不合适。它真正的定位是高并发环境下的读多写多、key规模较大的缓存或索引场景。理解它的源码不是让你去改它而是让你在真的需要做并发容器选型和性能排查时知道每一步背后已经做了什么权衡。我自己的体会是每年重新读一遍这段源码都会有新的收获。第一次读你会惊叹于ForwardingNode的设计第二次读你会开始关注TreeBin的读写锁和链表树化的时序关系第三次读你可能已经在思考“数据量级到千万时扩容到底怎么保证不阻塞读”。这种层层深入的过程本身就是并发编程学习中最有意思的部分。希望这份拆解能帮你省下一些自己摸索的时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询