
一开始定位问题我翻到线程Dump主线程卡在一个列表接口的读取上堆栈半天不动。页面渲染的事全部排队等着偏偏同一个线程在等一把锁而锁又被另一个子线程攥着不放。顺着锁找下去底层其实是一棵HashMap的红黑树在并发写的时候结构被写坏读的时候进了死循环。问题不在UI不在业务逻辑而在一个用错了的数据结构上。如果当时用的是ConcurrentHashMap这次事故根本不会发生。这篇不是贴源码做翻译而是站在Android开发的视角把ConcurrentHashMap这套并发数据结构讲透。先讲为什么Android多线程场景离不开它再拆它的核心设计与并发原理然后用Android Studio实测一次性能与内存最后把面试里常见的连环追问和项目里的坑一起收掉。适合两类人看一类是准备Android面试需要把并发容器讲清楚的同学另一类是线上偶发闪退、ANR怀疑HashMap不背锅也找不到证据的朋友。1. 为什么Android开发要掌握ConcurrentHashMap1.1 一次ConcurrentModificationException的自查经历有段时间我们App的日志上报模块总在低端机上偶发闪退Bugly抓到的堆栈几乎都指向同一个位置java.util.ConcurrentModificationException: null。堆栈里看不到业务逻辑只有一个我们封装的缓存类里面用HashMap存了“消息ID到回调对象”的映射。当时百思不得其解因为这个缓存类所有方法都用synchronized包了一遍看起来已经做了同步。后来仔细看调用链路才发现网络框架用的线程池有8个线程在同时回调onMessage而UI线程又在遍历同一个HashMap去更新红点。synchronized保证的是方法内部的原子性但HashMap在遍历期间如果被其他线程修改迭代器会检测到modCount变化直接抛ConcurrentModificationException。这个异常不是“数据可能不对”而是“数据结构已经处于不一致状态”HashMap内部的红黑树和链表指针都可能错乱。换成ConcurrentHashMap之后问题消失。原因在于ConcurrentHashMap的迭代器是弱一致性的不会因为结构被修改就抛异常。它允许遍历过程中看到部分旧数据但保证不会把容器本身搞坏。这个特性在Android这种“多个线程共享一个容器”的场景里太重要了。1.2 Android中常见的并发读写容器场景很多人觉得Android应用不像后端服务那样有高并发ConcurrentHashMap用不上。这个想法是错的。Android的并发不是“每秒几万请求”而是“主线程和其他线程在毫秒级别共享数据”任何一个加锁阻塞都可能卡UI。我在项目里见过最典型的几类场景埋点SDK的本地缓冲子线程写入事件上报线程批量取出并清除两个线程都碰同一个Map。网络响应缓存OkHttp回调线程把解析结果写入内存缓存UI线程在onCreate里读取展示。图片加载框架的元数据表多个加载线程根据URL更新下载状态主线程需要实时查询。线程池内的任务状态表任务成功、失败、取消多个工作线程同时更新主线程轮询汇总。这些场景有一个共同点读操作往往发生在主线程写操作发生在后台线程。数据量不大但读写冲突频繁。如果用普通HashMap偶发问题会藏在线上如果全部用synchronized包一层主线程读的时候照样会被写线程的锁卡住而ConcurrentHashMap的设计目标就是让读操作尽可能不抢锁从而降低主线程被阻塞的概率。1.3 选型对比Hashtable、synchronizedMap、ConcurrentHashMap很多老项目里还在用Hashtable或者Collections.synchronizedMap(new HashMap())。这三个容器在“线程安全”这个前提下性能和适用场景差别很大容器锁粒度读并发性写并发性迭代行为适用场景Hashtable整表一把锁读写互斥读写互斥fail-fast会抛异常老代码兼容不推荐新写synchronizedMap整表加锁读写互斥但锁在包装层读写互斥fail-fast会抛异常简单场景可读性尚可但性能有限ConcurrentHashMap桶级CAS synchronized不加锁直接读只锁单个桶或空桶CAS弱一致不抛异常高读写竞争、共享容器、需要并发读写的场景Hashtable的问题是全表锁任何读操作都要拿同一把锁多个线程同时读都会互相阻塞。synchronizedMap本质是在每个方法外层加synchronized虽然没有改变HashMap内部结构但锁的还是整个Map对象并发度依然很低。这两种方案在Android低端机上一旦主线程读、子线程写都可能出现几十毫秒到几百毫秒的阻塞。ConcurrentHashMap走的是另一种思路读操作不加锁写操作只用CAS尝试失败才锁住单个桶。这样设计之后多个线程同时读完全无竞争写入时也只有hash到同一个桶的线程才会彼此等待。后面我会把它的结构拆开讲。2. ConcurrentHashMap的核心数据结构与线程安全设计2.1 从分段锁到CAS synchronizedJDK 7到JDK 8的演进逻辑早期JDK 7的ConcurrentHashMap不是现在这个结构它用分段锁Segment整个Map分成16个Segment每个Segment独立拥有一条锁写操作只锁对应的Segment读操作在Segment内不加锁。这种设计在当时已经能提供不错的并发度但问题也不少Segment数量固定扩容时要整体搬迁内存占用偏高而且锁粒度还是“一段覆盖多个桶”线上压力大的时候同一段内的写操作仍会互相等待。JDK 8把Segment拿掉了直接对table数组里的单个桶做并发控制。插入时如果桶是空的用CASCompare And Swap把新节点放进去不需要加锁如果桶非空再synchronized锁住桶内的头节点防止同桶并发写入。锁粒度从“一段”细化到了“一个桶”这就像一个停车场从“整栋楼一把锁”改成“每个车位单独开关”对抗激烈程度完全不同。另一个原因是JDK 8时代synchronized本身经过优化不再是重量级锁膨胀到一定竞争程度才升级和ReentrantLock的性能差距已经很小。用synchronized还省掉了Segment和锁对象的额外内存锁数量也从固定16把变成“有桶冲突才有一把”设计明显更干净。这些演进你不需要背源码但要知道核心逻辑锁越来越细读越来越轻内存越来越省。这个逻辑放在Android上也成立App内存本来就紧张一个后台容器多占几十KB都值得在意。2.2 数组链表红黑树ConcurrentHashMap到底长什么样ConcurrentHashMap的内部结构可以想象成一栋楼楼体是Node[] table也就是哈希桶数组长度一定是2的幂。每个楼层桶有三种可能状态空、链表、红黑树。链表节点是Node每个Node保存key、value、hash值和next指针。当链表长度超过阈值链表会转成TreeNode组成的红黑树查询复杂度从O(n)降到O(log n)。用文字画出来的话是这样Node[] table ├─ null空桶 ├─ Node - Node - Node链表长度 8 └─ TreeNode 组成的红黑树链表长度 8 后转换有三个关键阈值面试也爱考TREEIFY_THRESHOLD 8链表长度达到8时尝试转成红黑树。UNTREEIFY_THRESHOLD 6红黑树节点数降到6时退回链表。MIN_TREEIFY_CAPACITY 64整个table长度至少64才允许树化否则先扩容。为什么要等链表长到8才树化源码注释里用泊松分布算过。在负载因子0.75、hash分布足够随机的情况下单个桶链表长度超过8的概率是千万分之几。链表真长到8说明hash散列严重不均匀这时候转红黑树能缓解查询退化如果哈希分布正常根本不会触发。退化阈值用6而不是8是为了防止元素在阈值附近反复横跳时频繁转换6和8之间留了缓冲。为什么还要先看table长度是否到64因为如果数组只有16个桶少量元素集中在个别桶里很可能是key的hash低位重了而不是整体散列失败。这时候优先扩容重新把元素分散到更多桶里比急着转红黑树更合理。2.3 put流程拆解从hash扰动到协助扩容ConcurrentHashMap的put流程我建议你在源码里自己走一遍这里把每个关键节点列出来计算key的hash值(h key.hashCode()) ^ (h 16)让高16位也参与散列减少低位冲突。定位桶(table.length - 1) hash用位运算代替取模。桶为空直接CAS把新Node放进去整个过程无锁。桶非空synchronized锁住桶内头节点然后判断头节点是普通Node还是ForwardingNode。头节点是ForwardingNode说明正在扩容当前线程先协助扩容再重新插入。普通链表则遍历链表找到相同key就覆盖value找不到就追加到尾部。插入完成后如果链表长度达到8且table长度达到64调用treeifyBin转红黑树。最后通过addCount更新元素数量并判断是否需要触发扩容。这几步里有几个值得注意的设计点第一synchronized锁的是头节点而不是整个Map。其他桶的写入不受影响读操作根本不需要碰锁。这和Hashtable的“全表锁”有本质区别。第二为什么桶为空时用CAS而不是直接锁因为CAS是CPU指令级的原子操作开销比锁小得多。多数场景下桶本来就是空的CAS一次就成功了根本不需要升级到锁。第三链表插入是“尾插法”不是JDK 8 HashMap里的“尾插法”吗其实ConcurrentHashMap一直是尾插不会因为扩容产生环形链表这也是它比HashMap在并发下更安全的原因之一。第四扩容是一个“多人协作”的过程。ConcurrentHashMap支持多线程同时迁移不同桶每个线程“认领”一段区间把迁移完成的桶替换为ForwardingNode。这样扩容不会把整个Map锁死其他线程读和写还能继续。2.4 get为什么可以不加锁ConcurrentHashMap的get方法从头到尾没有一行锁代码它依赖的是volatile和final两个关键字。具体来说table数组本身是volatile的意味着数组引用的更新对所有线程立即可见。数组里的Node节点的val字段和next字段也都是volatile这意味着读线程能看到其他线程对value和链表结构的最终写入结果。节点在放入数组或者链表时构造过程保证了必要的happens-before关系所以get在大多数情况下能读到最新值。但这不代表ConcurrentHashMap是强一致的。假如线程A执行put线程B同时执行getB可能在一个很短的时间窗口里读到旧值。比如正好读到链表的旧头节点而A已经把新节点插入到头部。这种“弱一致性”是刻意的设计选择换来的是get完全没有加锁和等待性能极高。在Android场景里弱一致通常可以接受因为UI线程一帧是16ms数据晚几毫秒到最多导致一次刷新延迟不会造成业务错误。如果你非要强一致可以在业务层加锁或者用同步的容器但代价是性能下降。另一个和弱一致性配合的特性是迭代器。HashMap在遍历时检测到修改会抛异常ConcurrentHashMap的迭代器基于“弱一致”策略不会抛异常也不承诺遍历结果绝对实时。比如某个线程正在扩容迭代器可能看到部分新桶部分旧桶但不会看到结构损坏。这对Android的埋点上报、列表刷新这种“遍历中偶尔有写”的场景特别友好。3. Android场景下的实操从Debug到Profiler的一次观测3.1 准备一个并发写入测试Demo纸上谈兵聊再多不如跑一次实际测量。我在Android Studio里搭了一个测试Demo目标是模拟多个线程同时往同一个Map里写入数据看线程安全性、耗时和内存的差异。先给出测试代码的核心部分int threadCount 8; int opsPerThread 100_000; CountDownLatch startLatch new CountDownLatch(1); CountDownLatch doneLatch new CountDownLatch(threadCount); ConcurrentHashMapInteger, String map new ConcurrentHashMap(); for (int i 0; i threadCount; i) { int seed i; new Thread(() - { try { startLatch.await(); for (int j 0; j opsPerThread; j) { int key seed * opsPerThread j; map.put(key, value- key); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { doneLatch.countDown(); } }).start(); } long start SystemClock.elapsedRealtime(); startLatch.countDown(); doneLatch.await(); long cost SystemClock.elapsedRealtime() - start;为什么要用CountDownLatch因为我想让8个线程“同时”开始写把锁竞争尽量集中在put上而不是被线程创建时间差稀释。否则测出来的数据更多反映的是线程调度开销不是容器本身的并发能力。把ConcurrentHashMap替换成HashMap、Hashtable、Collections.synchronizedMap就能得到一个简单的对比。注意这里只测写入读的差异后续可以用工具观察。真机上建议多跑几轮取中间值不要用模拟器数据下结论模拟器的线程调度和真机差异太大。3.2 四个容器的实测对比我在一台骁龙中端真机上跑了上述Demo8个线程、每个线程10万次put大致数据如下不同机型差异明显这里看趋势容器写入耗时结果HashMap较低线程不安全测试中途出现异常或数据缺失Hashtable最高数据完整但全表锁导致耗时最长Collections.synchronizedMap次高数据完整锁开销仍然很高ConcurrentHashMap中等数据完整线程安全前提下耗时最低解释一下为什么HashMap耗时最低它根本没有锁也根本不保证并发安全。并发写大量数据时它的内部链表和红黑树结构会被破坏要么抛出异常要么丢失数据甚至进入死循环。所以这个“最低”没有任何意义。Hashtable耗时不低因为每个操作都拿同一把全局锁8个线程排队写入吞吐上不去。synchronizedMap好一点但锁本质还是全局的性能提升有限。ConcurrentHashMap在线程安全的容器里表现最好原因是大多数put发生在空桶上CAS直接成功不需要加锁只有hash到同一个桶的线程才会短暂竞争。这个场景下写冲突比例不高所以性能接近无锁。如果你的key分布很差太多key撞到同一个桶ConcurrentHashMap的性能优势会被锁竞争削弱所以外面再套一层好的hash算法也算一种优化习惯。读性能我没放进同一个实验因为并发读的差异在线程很多时更明显。只要记住一个结论ConcurrentHashMap的读没有任何锁等待性能接近纯HashMap读远好于Hashtable和synchronizedMap。3.3 用Memory Profiler观察内存占用除了性能Android开发者还需要关注内存。我用Memory Profiler在同样100万条数据的情况下对比HashMap和ConcurrentHashMap的保留内存发现ConcurrentHashMap会多占用一部分内存多出来的部分主要来自这几个地方Node节点多了volatile相关的字段和更复杂的结构单个节点开销比HashMap的Node略大。红黑树节点TreeNode比链表节点Node体积更大存储的元素更多时差异更明显。内部维护了CounterCell数组用于高并发下统计size这个数组虽然小但也是额外对象。扩容相关的ForwardingNode等临时对象在扩容期间存在扩容结束后被回收。我用真机跑下来的典型结果是同样100万条目ConcurrentHashMap的retained size比HashMap高大约20%到40%。这个差距绝对值得关注尤其App里如果缓存几百万个小对象内存压力会增加不少。所以结论很清楚不要因为它是“线程安全Map”就把所有共享数据都塞进去。如果结构在初始化后保持不变用Collections.unmodifiableMap(new HashMap(...))更省内存如果写少读多且数据量可控普通HashMap配合Lock也可以。ConcurrentHashMap最优的定位是“高频读写、读写竞争激烈、不能容忍主线程长时间阻塞”的共享容器。4. Android面试与源码连环追问4.1 为什么ConcurrentHashMap不允许null值和null键面试官问这个问题时很多人只背答案说“为了并发安全”但深问一层就卡住。我帮你把背后的逻辑理顺。HashMap允许一个null键和多个null值因为单线程场景下get返回null只有两种可能要么这个key不存在要么value本来就是null。你能根据业务逻辑判断。但ConcurrentHashMap用在多线程场景get返回null之后你再调用putIfAbsent(key, value)时会出现一个严重问题你没法区分“key不存在我可以插”和“key存在但value是null不需要插”。putIfAbsent的语义是“如果key不存在才插入”如果value允许为null这个判断会产生二义性并发环境下再多的锁也解决不了语义不清晰的问题。另一个原因和源码实现有关。ConcurrentHashMap内部用reservationNode等占位对象做compute之类的原子操作如果value允许null一些CAS语义就不好实现了。为了保持操作的无歧义和内部结构的清晰JDK直接禁止null键和null值任何试图put null的行为都会抛出NullPointerException。在Android面试里可以这样回答HashMap的null语义在单线程下没问题但在并发条件下null值会使“判断key是否存在”和“判断value是否为null”混在一起ConcurrentHashMap为了让putIfAbsent、computeIfAbsent这类复合操作具备明确语义从结构上禁止null。4.2 扩容时如何保证线程安全ConcurrentHashMap扩容的细节是源码里最有意思的部分面试官问起也不意外。简单说扩容不是“暂停所有读写复制完再继续”而是用多线程协作的方式边扩容边服务。整个扩容由transfer方法驱动关键状态放在sizeCtl字段里。sizeCtl为负数时表示当前正在初始化或扩容值为-1表示初始化值为-N表示有N-1个线程正在参与扩容。当一个线程发现某个桶的节点是ForwardingNode即该桶已迁移完成它会主动调用helpTransfer加入扩容而不是傻等着。迁移单个桶时原链表的Node会根据新数组长度重新散列拆成“低位链”和“高位链”分别挂到新数组的对应位置。迁移完成后旧桶被替换为一个hash值为-1的ForwardingNode。之后任何读操作遇到ForwardingNode会顺着它的nextTable指针跳到新数组继续查写操作遇到ForwardingNode也会先协助迁移再完成写入。这个设计的精髓在于扩容期间没有全表锁每个桶的迁移是独立的读写请求不会被整体阻塞。不过要注意的是扩容期间get旧值的情况比平时更明显因为有些桶可能还没迁完。但只要迁移完成读写立刻走新数组最终会收敛到一致状态。4.3 get读到旧值可不可以接受ConcurrentHashMap的get不加锁意味着它走的是“最终可见”而不是“立即可见”的路子。线程A往桶里put了一个新节点线程B的get可能还是沿着之前的next路径读到旧链表直到volatile写被其他线程看到。这个时间窗口非常短但在面试里经常被追问“你用它做缓存万一读到旧值怎么办”我的回答一般是三个层次第一ConcurrentHashMap保证读取永不崩溃、永不抛ConcurrentModificationException结构安全是底线第二get在绝大多数情况下都能读到最新值弱一致窗口只在扩容或刚写入的极短时间内存在第三如果你的业务对“读到旧值”零容忍那就不能用它做强一致缓存需要业务层自己加锁。放到Android场景里很多业务天然容忍小时间窗口的旧数据。比如“未读消息数”这种UI指标晚刷新一帧用户根本感知不到。真正需要强一致的是跨进程共享的数据那种场景请换数据库或专门的同步机制而不是指着一个Map谈强一致。4.4 面试连环追问速查表我把Android面试中关于ConcurrentHashMap的高频追问整理成一张速查表省得在考场上现想追问方向核心回答思路为什么链表长度到8才转红黑树泊松分布下概率极低8说明hash分布异常转树缓解查询退化为什么退化阈值是6不是8避免在8和7之间反复横跳浪费转换开销为什么table长度到64才树化数组太小说明整体容量不足先扩容分散数据比转树更合理CAS失败之后怎么办空桶CAS失败说明有其他线程先插入当前线程重新读桶失败路径走synchronized锁头节点为什么JDK8改用synchronizedJDK8的synchronized已优化且锁粒度是桶级别比JDK7的Segment更细size()是怎么计算的通过baseCount加CounterCell数组累加得到高并发下的size是估量值而非精确值get为什么不加锁table、Node的val和next都声明为volatile读操作依靠可见性保证不依赖锁Android的API Level对它有影响吗Android的Java实现随系统版本走源码实现与JDK 8基本一致低版本可能基于JDK 7的分段锁实现但API用法相同这些追问背后考的是“你有没有真读过源码”不是“记不记得结论”。所以我在准备面试时至少要在Android Studio里用源码导航把putVal和transfer两个方法完整看一遍别人问起来才接得住。5. 我在项目里踩过的几个坑5.1 把ConcurrentHashMap当全局缓存内存直接吓一跳犯过一次比较幼稚的错误做一个“用户资料缓存”图省事写了一个静态的ConcurrentHashMapString, UserInfo所有线程都往里放。单测没问题上线后功能也正常但内存Profile一看缓存对象加上ConcurrentHashMap内部结构占了几十MB。数据量大概几十万条每多一个UserInfo对象周围的辅助节点也跟着膨胀。后在低端机上页面切换越来越慢我才意识到这不是“线程安全的缓存”的正确写法。如果要的只是缓存应该用LruCache加上线程同步或者直接用一个带容量上限的实现把长期不用的数据淘汰掉。ConcurrentHashMap本身没有淘汰策略所有放进去的对象都会一直被引用直到你手动移除。这个教训让我记住一句话ConcurrentHashMap是“并发安全的Map”不是“缓存”需要淘汰策略请另造轮子。5.2 putIfAbsent和computeIfAbsent的语义差别很多同事在需要“初始化一个key对应的对象”时习惯写putIfAbsent(key, new Value())这有一个隐藏开销不管key存不存在那个new出来的对象都会先被创建白白浪费一次构造。并发高的时候多个线程同时putIfAbsent只有一个线程的Value会被写入其他线程创建出来的对象全部被丢弃浪费更多。更好的写法是computeIfAbsent(key, k - new Value())它只在key不存在时才执行后面的函数。在Android里这个语义很适合做“按需加载”的场景图片地址到解码结果的缓存、接口请求的Future缓存、数据库连接或者昂贵对象的懒加载。但这里有个重要注意点在computeIfAbsent的函数体里不要再对同一个ConcurrentHashMap进行写操作尤其不要递归地调用另一个computeIfAbsent否则可能抛IllegalStateException或RecursiveUpdate异常。我在一次实现嵌套缓存时踩过当时逻辑上觉得“写进去就不会再来一次”结果并发请求一上来就抛异常。这个坑属于“正常单测测不出来压测立刻暴露”的类型。5.3 序列化与Binder传输的边界ConcurrentHashMap实现了Serializable很多人就直接把它塞进Intent或者Bundle里结果在高版本Android上要么遇到TransactionTooLargeException要么序列化耗时严重ANR。问题本质上和ConcurrentHashMap无关任何大对象跨进程传输都可能踩坑。但ConcurrentHashMap这种大容器的序列化成本尤其高因为内部节点结构复杂而且弱一致的迭代器序列化时也会变相遍历所有内容。我现在的习惯是跨进程只传不可变的轻量结构先转成普通的ArrayList或者小体量的HashMap再放进Bundle。如果只是为了传一个“当前状态快照”完全不需要迁移整个ConcurrentHashMap。如果你确实需要在一个Activity和多个线程之间共享同一个Map那是同进程内共享直接持有引用就行不要序列化。Android的Binder传输只解决跨进程问题不解决并发问题。5.4 一个带过期时间的简易ConcurrentHashMap包装最后一个经验是关于“过期缓存”的。我们App里有一个需求短时间缓存用户操作结果30秒后失效。标准做法可以用LruCache加上时间戳但如果希望多个线程都能安全并发写我会直接基于ConcurrentHashMap做一层轻量包装public class ExpiringCacheK, V { private final long ttlMillis; private final ConcurrentHashMapK, Long expireAtMap; private final ConcurrentHashMapK, V valueMap; public ExpiringCache(long ttlMillis) { this.ttlMillis ttlMillis; this.expireAtMap new ConcurrentHashMap(); this.valueMap new ConcurrentHashMap(); } public V get(K key) { Long expireAt expireAtMap.get(key); if (expireAt null || expireAt System.currentTimeMillis()) { return null; } return valueMap.get(key); } public void put(K key, V value) { long expireAt System.currentTimeMillis() ttlMillis; expireAtMap.put(key, expireAt); valueMap.put(key, value); } public void remove(K key) { expireAtMap.remove(key); valueMap.remove(key); } }这个实现是惰性过期也就是“读取时才检查是否过期”不会专门开一个线程做清理。优点是简单、无额外唤醒开销适合条目不多的场景缺点是过期条目在下次访问前仍占内存。如果数据量大可以再挂一个AlarmManager或Handler定时清理已过期的key。要注意的是过期判断是在业务层做的ConcurrentHashMap本身并不知道也没有义务处理过期逻辑。做这个包装时有一个容易忽略的点为什么用两个Map而不是一个ConcurrentHashMapK, EntryV, Long因为如果你把过期时间放在value的内部类里读取时就要先拿Entry再拿V多一层对象逃逸和内存占用。拆成两个Map之后valueMap负责纯净的值存储expireAtMap负责时间戳语义更清爽内存不见得更差代码也更好懂。我在实际项目里把这个包装用在“手势解锁状态的本地保护缓存”上多个线程同时更新状态主线程读取时判断是否过期跑了两三个版本没出过问题。这种小工具比直接裸写HashMap加synchronized要稳得多。这几年的体会是ConcurrentHashMap不是银弹但它确实解决了Android多线程场景里最让人头疼的“共享Map并发读写”问题。面试的时候别只背结论动手打开源码把putVal、transfer、get几个方法走一遍再在Android Studio里跑一次我上面给的Demo感受会完全不同。下次线上再出现摸不着头脑的多线程闪退你至少能第一时间怀疑到容器上去。