ConcurrentHashMap要解决的核心问题不是让Map变得“有序”而是让它在多线程读写下仍然保持结构安全并尽量减少线程之间的互相阻塞。普通HashMap本身不保证遍历顺序。它的目标是根据 key 快速定位 value而不是维护插入顺序或排序顺序。如果需要插入顺序通常使用LinkedHashMap如果需要按 key 排序通常使用TreeMap。ConcurrentHashMap继承的是哈希表的访问模型因此它也不保证遍历顺序它关注的是并发访问下的安全性和吞吐量。一、HashMap 并发写为什么不安全HashMap的底层可以先理解成一个数组数组中的每个位置称为一个桶。多个 key 经过 hash 计算后会落到不同桶中如果多个 key 落到同一个桶就会形成链表或红黑树。单线程下put的过程大致是计算 key 的 hash定位数组下标再根据桶的状态插入新节点或覆盖旧 value。问题出现在多线程同时修改同一个桶时。假设table[1]原来只有节点A两个线程同时向这个桶插入节点B和C它们可能都先看到同一个旧状态如果没有并发控制两个线程都会基于A构造自己的新链表然后分别写回table[1]。后写回的线程可能覆盖先写回的结果导致其中一个新节点丢失。这类问题的本质不是“多线程不能访问HashMap”而是多个线程同时修改数组、链表或红黑树时可能破坏内部结构或造成数据丢失。所以并发 Map 首先要解决的是多个线程同时写入时不能让它们无序地修改同一份内部结构。二、Hashtable 为什么安全但性能差最直接的办法是给整张表加一把锁。Hashtable的很多方法都是synchronized的可以把它理解成所有get、put、remove都必须先抢同一把锁。这种方式能保证线程安全但锁的粒度太粗。比如两个线程分别操作完全不同的桶它们本来没有结构冲突却仍然必须串行执行。这里的关键词是粗粒度锁。它表示锁保护的范围太大而不是锁实现本身一定是“重量级锁”。Hashtable的问题可以概括为用一把全局锁保护整张表安全性足够但并发性能差。ConcurrentHashMap的设计方向正是从这里引出的不要锁整张表只锁真正可能发生冲突的那一小部分。三、从 Segment 到桶级锁JDK 1.7 的ConcurrentHashMap使用Segment分段锁。可以把整张 Map 拆成多个小区域每个Segment内部像一个小型HashMap并且每个Segment有自己的锁。一个segment包含有多个table[0]到table[i]这种方式比Hashtable好因为不同线程如果落到不同Segment就可以并发执行。但它仍然不是最细的粒度同一个Segment里有多个桶如果两个线程操作的是同一个Segment中的不同桶它们仍然要竞争同一把锁。JDK 1.8 进一步缩小了加锁范围不再把Segment作为主要并发控制单位而是直接在数组桶上做并发控制桶状态写入方式桶为空CAS 放入新节点桶非空锁住桶头节点再修改桶内结构所以 JDK 1.8 的核心思路可以概括为能用 CAS 完成的写入就不加锁必须修改桶内链表或红黑树时只锁当前桶。四、空桶为什么可以用 CAS当线程执行put时如果定位到的桶是空的table[i] null这时写入只需要做一件事把table[i]从null改成新节点。这个动作可以用 CAS 完成。如果两个线程同时向同一个空桶插入只有一个线程的 CAS 能成功另一个线程会失败并重新判断桶状态。这样不会出现两个线程互相覆盖的问题。空桶插入的关键是对外可见的结构变化只有一次引用更新。只要这次更新是原子的就能避免丢失写入。五、非空桶为什么锁桶头如果桶已经不是空的例如再插入新节点C时就不只是修改table[i]这个数组引用了。线程需要遍历链表判断 key 是否已经存在存在则覆盖 value不存在则追加新节点。这个过程由多个步骤组成不能简单用一次 CAS 解决。JDK 1.8 的做法是锁住当前桶的头节点synchronized(f){// f is table[i]// search, update, or append node}这里锁住的是table[i]对应的桶头而不是整张表。其他线程如果修改的是不同桶就不会被这把锁阻塞。这就是桶级并发控制的核心写同一个桶时串行不同桶之间尽量并行。六、为什么 get 大多数时候不用加锁get不修改结构只是沿着已经发布出来的结构向下查找它不插入节点、不删除节点、不调整链表也不触发结构变化。因此它不需要像put一样保护一个修改过程。能无锁读取还依赖节点字段的设计。JDK 1.8 中的节点可以简化理解为staticclassNodeK,V{finalinthash;finalKkey;volatileVval;volatileNodeK,Vnext;}key和hash初始化后不再改变val和next通过volatile保证可见性。写线程修改 value 或发布新节点后读线程能够沿着可见的引用读取到相对安全的结构。这里要注意get不保证一定读到全局最新的一瞬间状态。比如一个线程正在向链表尾部追加C并发的get(C)可能发生在C发布之前也可能发生在发布之后。因此它可能返回null也可能返回C对应的 value。两种结果都允许。ConcurrentHashMap保证的是读线程不会读到被破坏的半截结构而不是保证读到某个绝对最新的全局快照。七、尾插为什么不会让读线程看到半成品非空桶新增节点时JDK 1.8 通常是在链表尾部追加而不是头插。假设原链表是要追加C线程会先构造好完整的C节点然后在锁内执行一次关键发布动作B.nextC;对读线程来说结构只有两种可能或者不会出现原因是没有先破坏旧链路也没有把半成品节点暴露出去。新节点在局部变量中构造完成后才通过一次volatile next写接入共享链表。这和 Copy-On-Write 有相似的思想先准备好新数据最后通过一次引用更新发布。但ConcurrentHashMap的尾插不是严格意义上的 Copy-On-Write因为它没有复制整条旧链表而是在原链表尾部接入新节点。如果使用头插就必须特别注意发布顺序。错误顺序是table[i] C C.next A读线程可能在两步之间看到C - null。正确顺序必须先完成C.next A再发布table[i] C。这说明并发容器的关键不是把多个赋值强行变成一个原子操作而是把对外可见的发布动作放到最后。八、删除节点时为什么也不会破坏链表继续沿用前面的链表假设桶中节点为如果要删除B关键动作不是先把B.next断开而是让前驱节点直接跳过它A.nextC;删除后从table[i]出发的新路径变成如果读线程在删除之前已经走到了B它的局部变量仍然可以继续指向B这里不是把A也删除了而是读线程手里已经拿到了旧节点引用。Java 对象不会因为从链表主路径中被绕过就立刻消失只要某个线程的局部变量还引用它它就仍然可达。因此删除也遵循同一个原则不先破坏旧结构而是通过一次引用更新改变新的可见路径。并发读线程可能看到删除前的路径也可能看到删除后的路径但不会看到断裂链表。九、扩容为什么需要 oldTable 和 nextTable哈希表的数组长度是固定的创建之后不能原地变大。当元素越来越多时桶会变挤冲突增多链表变长查找和写入成本都会上升。因此需要创建一个更大的数组并把旧数组中的节点重新分散过去。扩容后会创建新数组节点不是简单复制到相同下标而是根据新数组长度重新分配。因为长度变了同一个 hash 对应的新下标也可能变化。在扩容过程中旧表和新表会同时存在ConcurrentHashMap不能长时间停住所有线程等一个线程慢慢搬完整张表。因此旧表中的桶会被逐步迁移到新表。十、ForwardingNode 如何引导到新表某个旧桶迁移完成后旧表对应位置会被放入一个特殊节点ForwardingNode。它可以理解成一个路标。ForwardingNode保存的是整张新表的引用而不是保存某一个新下标。线程在旧表中遇到它后会拿着 key 的 hash到nextTable中根据新数组长度重新计算下标。这里说“重新定位”更准确不是重新调用一次key.hashCode()而是使用已经计算好的 hash结合新数组长度重新计算数组下标。由于扩容通常是容量翻倍旧桶中的节点迁移后只会去两个位置之一原下标i或者i oldCapacity。所以一个旧桶迁移时会被拆分成两组分别放入新表的两个位置。搬完后旧桶位置变成ForwardingNode后续读写线程就不会再把它当作普通桶处理。十一、多个线程如何协助扩容ConcurrentHashMap的扩容不是后台新开一个专门线程完成的而是由正在执行put等操作的业务线程顺手参与迁移。假设旧表有多个桶一个线程触发扩容后会创建nextTable并开始迁移部分桶。其他线程如果在put时发现当前 Map 正在扩容也可以领取一段旧桶参与搬迁。为了避免两个线程搬同一个桶ConcurrentHashMap会维护迁移进度可以简化理解成一个任务分配指针。线程通过 CAS 领取一段桶领取成功后再迁移这段范围。每个桶迁移完成后都会被标记为ForwardingNode。这个标记有两个作用一是告诉读写线程去新表继续操作二是告诉迁移线程这个桶已经处理过了。因此协助扩容的核心不是多个线程随便搬而是通过 CAS 分配迁移范围再用ForwardingNode标记完成状态。十二、扩容期间 put 写旧表还是新表扩容期间不能简单说put一定写旧表或者一定写新表。它取决于当前 key 落到的旧桶是否已经迁移。如果put落到还没迁移的普通桶例如[1]这个桶仍然属于旧表。线程可以锁住桶头在旧桶上完成写入。之后该桶迁移时新写入的节点会一起搬到新表。如果put落到已经迁移完成的桶例如[0]线程会看到ForwardingNode。这说明旧桶已经不能继续写入线程需要转向扩容流程并基于新表重新定位。所以扩容期间的规则是未迁移桶仍然可以在旧表处理已迁移桶通过ForwardingNode转向新表。扩容完成后最后一个完成迁移的线程会把主表引用切换到新表tablenextTable;table是volatile字段这次写入会把新表安全发布给后续线程。即使某个线程在切换前已经拿到了旧表引用旧表中迁移完成的桶也会通过ForwardingNode引导到新表。十三、size 为什么不是普通 int普通单线程 Map 可以用一个int size记录元素个数新增时size删除时size--。但在并发环境下size不是原子操作它至少包含读取、加一、写回三步。多个线程同时更新同一个计数变量时可能发生计数丢失。给计数器加一把全局锁也能解决问题但会让所有新增和删除都竞争同一个计数点抵消桶级并发控制带来的性能收益。JDK 1.8 的ConcurrentHashMap使用类似LongAdder的分散计数思路低竞争时线程尽量更新baseCount竞争激烈时更新压力会被分散到多个CounterCell中。统计总数时再把这些值加起来因此ConcurrentHashMap的内部计数更新是线程安全的不会像普通int size那样随意丢失更新。但size()在并发修改时不是强一致快照。它统计时其他线程可能仍然在put或remove所以结果只能代表统计过程附近观察到的状态。同理isEmpty()也不能作为并发流程中的强一致控制条件。它可以用于观察、监控或日志但不能用来保证后续业务逻辑中 Map 仍然为空。十四、为什么不允许 nullConcurrentHashMap不允许null key也不允许null value。其中更关键的是null value。如果允许 value 为null那么Vvaluemap.get(key);返回null时就有两种可能key 不存在或者 key 存在但 value 本身就是null。在单线程HashMap中可以再调用containsKey(key)区分。但在并发环境下这两次观察之间 Map 可能已经被其他线程修改。例如线程 1 先执行get(key)返回null随后线程 2 插入或删除了这个 key线程 1 再执行containsKey(key)第二次观察到的状态已经不能解释第一次get返回null的原因。所以ConcurrentHashMap直接禁止null value让get(key) null的含义保持唯一当前没有找到这个 key 对应的 value。null key的问题略有不同哈希表定位桶需要基于 key 计算 hashHashMap为null key做了特殊处理而ConcurrentHashMap选择不保留这条特殊通道使 key 和 value 都保持非 null避免并发 API 的语义变复杂。十五、复合操作要使用原子方法ConcurrentHashMap能保证单个get、put、remove的线程安全但它不能自动保证用户写出的多步业务逻辑是原子的。下面这种写法在并发下有问题if(!map.containsKey(key)){map.put(key,value);}两个线程可能同时判断 key 不存在然后都执行put。这种“先判断再修改”的逻辑应该使用原子复合方法方法语义putIfAbsent(key, value)key 不存在时才放入remove(key, value)当前 value 匹配时才删除replace(key, oldValue, newValue)当前旧值匹配时才替换computeIfAbsent(key, function)key 不存在时计算并放入非 null 结果缓存场景中常见的写法是Valuevaluemap.get(key);if(valuenull){valueloadFromDb(key);map.put(key,value);}并发下多个线程可能同时发现缓存不存在然后重复加载。更合适的方式是Valuevaluemap.computeIfAbsent(key,k-loadFromDb(k));它把“检查是否存在、计算新值、放入 Map、返回结果”封装成对同一个 key 更安全的复合操作。如果计算函数返回null则不会建立映射关系因为ConcurrentHashMap不允许null value。不过computeIfAbsent的计算函数不应该过慢或包含不可控的复杂逻辑。它虽然方便但为了保证复合语义可能影响同一个桶或同一个 key 上的其他操作。轻量、可控的初始化适合放进去耗时很长的远程调用要谨慎处理。十六、遍历是弱一致的ConcurrentHashMap的遍历不会像普通 fail-fast 迭代器那样在并发修改时依赖ConcurrentModificationException暴露问题。它的遍历是弱一致的遍历过程中如果其他线程执行put或remove当前遍历可能看到这些变化也可能看不到。这和前文的get、size()语义是一致的它保证并发访问时结构安全不保证提供某一瞬间的强一致快照。因此遍历适合用于统计、扫描、监控等场景但不适合表达“遍历期间 Map 完全不变”这种业务假设。总结ConcurrentHashMap的设计可以从HashMap的并发写问题开始理解普通哈希表在多个线程同时修改内部结构时可能丢失数据因此需要并发控制如果像Hashtable那样锁住整张表安全性有了但并发能力被全局锁限制。于是 JDK 1.7 先用Segment把锁拆小JDK 1.8 又进一步把并发控制下沉到桶级别。桶级控制之所以能成立是因为写操作尽量把对外可见的结构变化压缩成一次安全发布空桶用 CAS 发布桶头非空桶锁住桶头后修改桶内结构新增节点先构造完成再接入链表删除节点通过改变可见路径绕过旧节点。这样读线程不需要参与加锁也能在旧状态和新状态之间安全地观察结构。当数组容量不足时问题从单个桶扩大到整张表迁移。ConcurrentHashMap没有暂停所有线程等待单线程扩容而是让多个业务线程协助迁移并用ForwardingNode把旧桶变成通往新表的路标。计数、遍历和size()则继续沿用这种并发容器的基本取舍保证结构安全和操作可用但不承诺强一致快照。真正需要“判断 修改”同时成立的业务场景应交给putIfAbsent、computeIfAbsent、remove(key, value)这类原子复合方法完成。