Android HashMap核心原理与工程实践:从源码到性能优化
发布时间:2026/9/24 23:01:56来源:尧图网络
1. HashMap 为什么是 Android 面试和工程里的常客HashMap 在 Android 开发里的地位基本等同于新手第一道坎、老手照妖镜。面试问数据结构十有八九从 HashMap 开始线上排查内存和卡顿也经常和它打交道。我见过不少人能把数组加链表红黑树这几个词背得滚瓜烂熟但一问到为什么 JDK 8 要把链表转红黑树get 的时候到底什么时候走 equals Android 上初始化容量到底该给多少立刻就卡壳了。这个现象并不奇怪。HashMap 看似就 put 和 get 两个核心方法但背后牵扯到哈希函数设计、扩容阈值计算、链表和树的权衡、并发条件下的行为差异甚至 Android 系统自身对内存的敏感性。它就像数据结构领域的一个枢纽把散列表、链表、红黑树、位运算这些知识点全串在了一起。我写这篇东西不是为了把源码贴一遍完事而是想站在 Android 工程实践的角度把这几个问题讲透HashMap 底层到底怎么组织数据put 和 get 经历了哪些步骤什么情况下会用到 equals容量为什么要设置成 2 的幂次以及最关键的——它在 Android 开发里到底该怎么用才不会给自己挖坑。不管你是准备面试的初中级开发还是已经在做性能优化、想搞清楚崩溃和卡顿根因的工程人员这篇文章应该都能给你一些超出背八股的启发。2. 从数组加链表到红黑树HashMap 的整体设计思路拆解2.1 为什么是数组 链表 红黑树这个组合要理解 HashMap 的设计先把底层模型放在脑子里它本质上是一个数组数组的每个位置叫桶bucket桶里可能只有一个节点也可能挂着一串链表极端情况下会变成一棵红黑树。数组存在的意义是 O(1) 定位。我们调用map.put(key, value)时HashMap 先对 key 做哈希运算得到一个 int 值再通过(n - 1) hash这个位运算算出这个键值对应该落在数组的哪个下标。这就是散列的核心理想情况下每个 key 都对应一个不同的桶那么 put 和 get 的时间复杂度都是 O(1)。但哈希冲突是不可避免的。不同 key 算出来同一个下标怎么办最简单的办法就是链地址法在这个桶下面挂一个链表新来的节点接到链表尾部。数据量小的时候链表遍历的开销可以接受但如果很多 key 撞到同一个桶链表越来越长查找就从 O(1) 退化成了 O(n)这个性能衰减在 Android 的主线程上会非常致命——一次列表页滑动可能触发几十次get哪怕其中有几次遍历了很长的链表卡顿就已经发生了。所以 JDK 8 引入了红黑树。当单个桶的链表长度超过 8并且数组总长度达到 64 时这个桶的链表会转换成红黑树。红黑树是自平衡二叉搜索树查找复杂度是 O(log n)和 O(n) 相比在冲突极端严重的情况下是质的提升。这里有个容易忽略的细节为什么转红黑树有两个条件而不是链表一长就立刻转因为红黑树节点占用的内存比普通链表节点大不少如果数组本身很小说明 HashMap 整体的空间非常紧张这时候应该优先扩容而不是转树。换句话讲树化是在数组已经足够大、但个别桶仍然严重冲突这种场景下的最后手段。2.2 JDK 7 与 JDK 8 的差异以及 Android 上的实际版本情况很多 Android 开发者容易忽略一个问题Android 上的 HashMap 实现和标准 JDK 并不完全是一回事而且不同 API 级别对应的实现逻辑也不一样。JDK 7 的 HashMap 有几个著名的问题插入链表用的是头插法并发扩容时可能形成环形链表导致 get 的时候死循环CPU 直接飙到 100%。JDK 8 改成尾插法并且引入了红黑树环链问题在代码层面被修掉了但并发下数据丢失的问题依然存在。所以 HashMap 从来就不是线程安全的哪怕到了 JDK 17、21 也一样。Android 系统早期版本对应 API 25 左右的系统组件用的 HashMap 实现更接近 JDK 7也就是说你在老设备上如果多线程同时操作同一个 HashMap理论上仍然可能触发死循环。当然现在大部分 App 的最低 API 级别都已经到 24、26 甚至更高但兼容性测试如果覆盖老设备这个问题不能完全忽视。我在实际项目里遇到过类似情况一个图片加载库内部用 HashMap 做缓存在 Android 7.0 的真机上偶现 ANR抓线程快照ANR trace发现主线程卡在 HashMap.get 的循环里。后面查源码发现这个库用的是旧版实现而且多个异步线程会同时写入这个 map。后来我们统一改成ConcurrentHashMap问题就再也没出现过。这个教训在后面第 6 节还会详细说。2.3 Android 环境对 HashMap 的特殊约束Android 和纯后端 Java 环境最大的区别在于内存资源紧张而且 UI 渲染、触摸响应、动画回调都在主线程上跑。一个 HashMap 如果设计不当最直接的影响就是两个内存占用偏高、主线程耗时增加。内存方面HashMap 默认初始容量是 16如果只存几个键值对每个 Entry/Node 对象本身有额外的对象头、指针和 key、value 引用算下来空转的内存相当可观。Android 的ArrayMap和SparseArray就是针对这种情况设计的它们用数组加二分查找代替哈希表在数据量小于几百时内存优势非常明显。所以官方文档早就建议如果 key 是 int优先用SparseArray如果数据量不大用ArrayMap代替HashMap。这不是说 HashMap 在 Android 上就没用了。它的哈希查找时间复杂度稳定在 O(1)数据量大的情况下查找性能依然比 ArrayMap 好得多。我的原则很简单数据量小几百以内且 key 是整数用 SparseArray数据量大、或 key 是字符串等对象用 HashMap多线程并发读写的场景用 ConcurrentHashMap。搞清楚取舍的边界比背一个哪个好的结论重要得多。3. 核心细节解析hash 扰动、put 流程和 get 流程3.1 hash 函数的真实样子以及为什么要有扰动先说结论HashMap 并不是直接用key.hashCode()的结果当哈希值而是先做一次扰动把高位信息扩散到低位然后再参与数组下标计算。JDK 8 的扰动函数长这样static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这个函数做的事情是key 的 hashCode 记为 h把 h 无符号右移 16 位再和原来的 h 做异或。这样做的原因是HashMap 计算数组下标时用的公式是(n - 1) hash其中 n 是数组长度初始是 16n - 1 的二进制是 0000...00001111只有低 4 位参与了运算。如果直接用原始 hashCode 来算下标那么 hashCode 的高 16 位就完全浪费了。假设你有一批对象的 hashCode 高位不同、低位完全相同那它们全部会落到同一个桶里链表就会变得很长。扰动操作把高 16 位的信息混入低 16 位让低位的随机性增加从而降低冲突概率。举一个具体的例子两个字符串的 hashCode 分别是0x12340000和0xABCD0000数组长度是 16n - 1 15。不扰动时0x12340000 15 00xABCD0000 15 0两个 key 直接冲突扰动后0x12340000 ^ (0x12340000 16) 0x12341234和 15 做与运算得到 4另一个 key 会得到不同的下标。这就是扰动带来的实际收益。很多面试题问为什么 HashMap 的容量是 2 的幂次答案就藏在这个位运算里。因为当 n 是 2 的幂时n - 1的二进制就是全 1hash (n - 1)等价于hash % n而且位运算比取模快得多。同时只要 hash 的低位分布均匀桶的分布就能均匀。如果 n 不是 2 的幂比如 15n - 1 14二进制 1110最低位恒为 0那么所有奇数下标都永远不会被分配到有一半桶是空的哈希表的空间利用率直接减半。3.2 put 方法逐行拆解从寻址到树化put的流程是理解 HashMap 的核心我把关键节点列出来拆开讲第一步计算 key 的扰动哈希拿到hash值。如果 key 是 nullhash 就是 0所以 null key 会放到数组下标为 0 的桶。Android 开发里如果业务代码允许 key 为 null要特别小心因为后续对 key 的每一次读写都要走一遍哈希校验那个空 key 特判虽然快但如果一个 HashMap 大量 put null key其实是一种设计上的坏味道。第二步判断内部数组table是否为 null 或者长度为 0。如果是先执行resize()初始化。默认初始容量是 16负载因子是 0.75。这一步很多人容易忽略new HashMap()并不会马上分配 16 个桶的数组而是在第一次put时才真正创建。这也意味着如果你创建了很多 HashMap 但从不放数据它们不会立刻吃掉大量堆内存。第三步根据(n - 1) hash定位到数组下标取出该位置的节点 p。这里有四种情况需要分别处理如果 p 为 null说明这个桶是空的直接 new 一个 Node 放进去。如果 p 不为 null且 p 的 hash 和 key 完全匹配hash 相等key 用或equals判断相等说明是更新同一个 key直接覆盖 value。如果 p 是 TreeNode 类型说明这个桶已经被树化了走红黑树的插入逻辑。其余情况说明这个桶挂着链表需要遍历链表找是否已存在相同 key。如果找到覆盖 value如果没找到就 new 一个 Node 插到链表尾部。插入后如果链表长度超过 8调用treeifyBin尝试树化。这里有一个非常关键的相等判断逻辑也是面试官最爱问的p.hash hash ((k p.key) key || (key ! null key.equals(k)))。翻译成人话先比较哈希值哈希值相同再走引用相等或equals内容相等。为什么要先比哈希值因为equals是一个相对昂贵的操作尤其对 String 这种类型要逐字符比较。哈希值不同的话equals一定返回 false所以用 hash 可以快速过滤掉绝大多数不匹配的 key。这也是为什么hashCode和equals必须同时重写——如果两个对象equals相等但hashCode不同它们在 HashMap 里根本会被分到不同的桶永远找不到对方。第四步记录修改次数modCount这个字段用于在迭代时做快速失败fail-fast校验。如果遍历过程中 map 被结构性修改了增删节点不包括覆盖 value迭代器会抛出ConcurrentModificationException。第五步检查size 1是否超过threshold超过就resize()。threshold capacity * loadFactor默认是 16 * 0.75 12。也就是说存到第 13 个键值对时HashMap 会扩容。3.3 get 方法逐行拆解hash 定位、链表遍历、equals 决胜get比put简单一些但逻辑上有几条分支要理清public V get(Object key) { NodeK,V e; return (e getNode(hash(key), key)) null ? null : e.value; } final NodeK,V getNode(int hash, Object key) { NodeK,V[] tab; NodeK,V first, e; int n; K k; if ((tab table) ! null (n tab.length) 0 (first tab[(n - 1) hash]) ! null) { // 先检查第一个节点 if (first.hash hash ((k first.key) key || (key ! null key.equals(k)))) return first; // 第一个节点没匹配上看是红黑树还是链表 if ((e first.next) ! null) { if (first instanceof TreeNode) return ((TreeNodeK,V)first).getTreeNode(hash, key); do { if (e.hash hash ((k e.key) key || (key ! null key.equals(k)))) return e; } while ((e e.next) ! null); } } return null; }整个流程就是算出 hash用(n - 1) hash定位桶检查第一个节点是否命中没命中就继续遍历链表或走红黑树查找。每次比对都是先比 hash 再比或equals。这里隐含着一个实践上的要点如果你自定义对象作为 HashMap 的 keyequals方法的性能直接影响 get 的性能。比如一个对象里有一个大字符串字段equals的实现如果每次都从第一个字符开始比较当一个桶里挂了 5、6 个 keyget 的耗时就会成倍增加。我见过一个性能优化案例就是把一个对象的equals里比较顺序调整了一下先比 int 型 ID再比字符串内容性能提升了将近 30%。还有一点String 的equals内部有一个hashCode 缓存的优化但那是针对 String 自身的自定义对象的equals没有这种加速设计时要特别注意。3.4 resize 扩容机制为什么 2 倍扩容对性能影响最小resize()是 HashMap 中最重的操作之一它的本质是新数组长度变成原来的 2 倍然后重新计算每个节点在新数组中的位置并搬过去。这一步的时间复杂度是 O(n)如果 map 里存了几万、几十万个键值对一次性扩容可能造成肉眼可见的卡顿。但 JDK 8 对扩容的寻址逻辑做了一个巧妙优化。在旧数组长度为 oldCap、新数组长度为 oldCap * 2 的情况下节点在新数组中的位置只有两种可能要么下标不变要么下标 原下标 oldCap。原因还是位运算。index hash (newCap - 1)当 newCap 是 oldCap 的 2 倍时newCap - 1 相当于在 oldCap - 1 的基础上最高位又多了 1 个比特位。这个新增的比特位值是多少取决于 hash 在 oldCap 对应位上是 0 还是 1。如果是 0下标不变如果是 1下标在原基础上加上 oldCap。JDK 8 的源码里直接用(e.hash oldCap)来判断这个位为 0 就留在原桶为 1 就移到原下标 oldCap的新桶。这个技巧省去了重新计算 hash、重新取模的过程效率高了很多。同时扩容还顺手把原来一条链表分成了低位链表和高位链表两条使得新数组上的 bucket 分布更加均匀这也是为什么 JDK 8 扩容后性能普遍比 JDK 7 好的原因之一。从工程角度看resize 最大的坑在于如果你能预估数据量就应该在创建 HashMap 时指定初始容量避免中途多次扩容。比如你知道要往 map 里放 1000 个键值对按照负载因子 0.75 来算需要 capacity 1000 / 0.75 ≈ 1334HashMap 在构造时会把传入的容量向上取到最近的 2 的幂也就是 2048。这样全程只会初始化一次数组不会触发任何一次扩容。4. 实操过程Android 项目里 HashMap 的使用规范和性能优化4.1 初始化容量怎么算最合理我在 Android 开发里看过太多new HashMap()后疯狂 put 的写法。当一个 map 最终要装 1000 条数据时默认初始容量 16 意味着它至少要扩容 6 次16 → 32 → 64 → 128 → 256 → 512 → 1024每次扩容都要重新分配数组、重新计算所有已有节点的桶下标。在列表页加载、JSON 解析这类高频场景里这就是纯粹的浪费。正确做法是预估一个值然后按负载因子反推容量// 预估要放 1000 个键值对 int expectedSize 1000; // threshold capacity * 0.75所以 capacity expectedSize / 0.75 int capacity (int) (expectedSize / 0.75f) 1; MapString, Object map new HashMap(capacity);加 1 是为了防止刚好卡在边界值上。比如 expectedSize 12直接算12 / 0.75 16传进去 HashMap 会取最近的 2 次幂 16threshold 16 * 0.75 12那么放第 13 个元素时就会扩容等于没优化。加 1 之后再重新向上取整就会变成 32threshold 变成 24即使超过预估 12 个也没问题。如果你在用 Gson、Moshi 这类库做 JSON 解析对象结构里如果有 Map 字段可以在自定义的TypeAdapter里用这个方式初始化减少反序列化过程中的扩容开销。4.2 避免频繁 put 和 get 的隐式开销装箱、哈希计算和缓存Android 主线程上做高性能热点操作时HashMap 的隐式开销不容忽视。我总结过三种最常见的情况第一种使用包装类型做 key 时的装箱拆箱。比如用HashMapInteger, String去存 int 型数据每次 put 和 get 都会自动装箱成 Integer 对象。数据量大时会产生大量临时对象造成 GC 压力。更好的选择是SparseArray它的 key 直接用 int 基本类型不会有装箱开销底层用二分查找在几百条数据量级下性能反而更好。第二种字符串 key 的哈希计算不是免费的。每次 put、get 都要调用 String 的 hashCode这个函数的时间复杂度是 O(n)n 是字符串长度。如果 key 是一个长字符串比如 URL这个开销真的不小。String 对象内部对 hashCode 做了缓存首次计算后存起来同一个 String 实例后续就不用再算。但如果你每次 get 都用新的字符串实例比如从网络请求里新解析出来的 URL缓存根本没机会生效。这种情况下可以考虑用自定义的短 key或者对长字符串做一个短 hash 映射。第三种get 之后立刻又拿结果做其他耗时操作导致链路整体变长。这不算 HashMap 自身的问题但我在优化时习惯把HashMap 读写时间和用数据后的处理时间分开打点测量定位慢在哪一段不会一上来就怀疑 HashMap。4.3 自定义对象做 keyequals 和 hashCode 的正确姿势自定义对象做 key 是 Android 开发里的高频需求比如用Pair或DataHolder做复合键。这里有几个非常容易踩的坑第一equals和hashCode必须同时重写而且hashCode参与计算的字段要和equals比较的字段一致。如果hashCode只用 ID 字段equals却比较了 ID 和 name就可能出现两个对象 ID 相同、name 不同它们的 hashCode 相等但equals返回 false。这在 HashMap 里表现为同一个桶里挂了一个 key但你永远 get 不到本来应该匹配的那个值。第二尽量用不可变字段。如果 key 对象的hashCode依赖的字段可以被修改比如某对象的 name 字段可变一旦 hash 值变了HashMap 里这个 key 就永久找不到了——它还在原来的桶里但下次 get 会用它现在的 hash 重新寻址根本不会去那个旧桶。这是生产环境里非常迷惑的一个 bug明明 map 里有这个 key遍历能看到get 却返回 null。解决方法很简单key 用不可变对象或者至少保证参与 hashCode 的字段是 final 的。第三hashCode的实现选型。我推荐用 AndroidObjects.hash(Object... values)或者手动组合质数乘法。手动写的效率更高一些但要注意不要引入不必要的数组创建。实测下来对两三个字段的组合用result 31 * result field.hashCode()是性能和可读性的最佳平衡。下面是一个我在项目里常用的不可变 key 写法public final class BookKey { private final int id; private final String isbn; public BookKey(int id, String isbn) { this.id id; this.isbn isbn; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof BookKey)) return false; BookKey other (BookKey) o; return id other.id isbn.equals(other.isbn); } Override public int hashCode() { int result Integer.hashCode(id); result 31 * result isbn.hashCode(); return result; } }4.4 HashMap 在缓存场景下的内存隐患做一个内存缓存时很多人图省事直接用HashMapString, Bitmap或者HashMapString, Object结果就是 OOM 来得比想象中快。原因很简单HashMap 只负责存储映射关系不负责淘汰策略。Android 官方推荐在图片缓存这类场景用LruCache它内部基于LinkedHashMap实现了 LRU 淘汰。LinkedHashMap是 HashMap 的子类额外维护了一个双向链表来记录访问或插入顺序。当缓存大小超过阈值时会移除最久未使用的条目。在LruCache的基础上如果再套一层 HashMap 去索引数据内存压力会成倍增加。我看过一些项目把一张图片同时放进 LruCache 和另一个 HashMap两边各持有一份 strong reference看起来是两层缓存实际上内存占用翻倍而且 HashMap 那层没有任何淘汰机制不知不觉就把堆撑爆了。正确的做法是同一个数据只保存在一份容器里需要通过不同维度访问时用主存储 索引的方式但索引要控制数量级和生命周期。比如主存储用LruCacheString, Item用 ID 做 key再按分类维度访问时可以存一个HashMapString, ListStringvalue 存的是主存储的 key 而不是整个对象这样索引只占很小的开销。注意HashMap 不是缓存容器。它的职责是提供 O(1) 的增删改查一旦你开始往里面塞大量只增不减的数据就要立刻考虑淘汰策略或换用 LruCache。5. HashMap 的遍历方式全对比哪个最快哪个会踩坑5.1 四种主流遍历方式的原理与对比HashMap 的遍历方式面试里经常让说全但更重要的是在 Android 工程里选对。我把常见的几种列出来第一种entrySet()遍历。拿到Map.Entry集合直接访问 key 和 value。这是官方推荐的方式因为每次迭代只访问一次 Map.Entry不需要额外查找。for (Map.EntryString, String entry : map.entrySet()) { String key entry.getKey(); String value entry.getValue(); }第二种keySet()遍历。先拿所有 key再逐个map.get(key)。这种方式最大的问题是每取一个 value 都要在 HashMap 里重新做一次哈希定位。数据量大时这种方式比 entrySet 多出整整一轮 hash 计算和寻址。for (String key : map.keySet()) { String value map.get(key); }第三种values()遍历。只拿 value不需要 key 的时候用。效率上比 entrySet 略低要走一遍节点链表但比 keySet get 高因为它没有重复哈希定位。第四种Java 8 的forEach和stream()。写法简洁但如果只做简单遍历stream 的拆箱和中间对象开销更大Android 上尤其要注意。for 循环 entrySet 在绝大多数 Android 场景里是最优选。从 Android 性能角度我的对比结论是entrySet()和values()明显优于keySet()get()而stream除非你要做链式过滤、聚合操作否则没必要用。5.2 遍历时删除元素remove 还是迭代器遍历 HashMap 时删除元素有非常多的人在这里踩坑。直接看代码// 错误ConcurrentModificationException for (String key : map.keySet()) { if (shouldRemove(key)) { map.remove(key); // 结构性修改迭代器检测到 modCount 变化直接抛异常 } } // 正确使用迭代器的 remove 方法 IteratorMap.EntryString, String iterator map.entrySet().iterator(); while (iterator.hasNext()) { Map.EntryString, String entry iterator.next(); if (shouldRemove(entry.getKey())) { iterator.remove(); } } // Java 8 推荐removeIf map.entrySet().removeIf(entry - shouldRemove(entry.getKey()));原理是HashMap 的迭代器内部保存了expectedModCount每次迭代时检查modCount是否变化。map.remove()是结构性修改会把modCount加一迭代器一检查发现不一致立刻抛ConcurrentModificationException。而iterator.remove()会同步更新迭代器内部的expectedModCount所以不会触发。在 Android 的RecyclerView.Adapter的notifyDataSetChanged场景里如果你在列表数据更新逻辑中边遍历边从 HashMap 里删元素这个异常出现的概率非常高。我的习惯是同一份数据要么先收集需要删除的 key遍历结束后统一removeAll要么直接用removeIf一行搞定且不会写错。5.3 Android 平台上的遍历性能实测结论我在真机上用Trace做过简单的对比测试数据量在 1 万条左右keySet()遍历再get()比entrySet()遍历慢了大约 40%。在数据量几千条的常规场景下这个差距可能只有几毫秒感觉不出来但如果这个遍历是在主线程的列表绑定、动画回调里执行积累下来的耗时就很可观了。还有一个小细节entrySet()返回的是SetMap.EntryK, V但这个 Set 是视图还是快照实际上它是视图遍历过程中如果发生了结构性修改同样会抛ConcurrentModificationException。所以不要以为用 entrySet 就免疫了并发修改问题。在 Android 的onDraw或者getView这类高频方法里遍历 HashMap我强烈建议把遍历结果缓存成数组或者拷贝成 List不要在绘制或绑定过程中做实时遍历。HashMap 的节点是分散在链表/红黑树里的内存布局不连续CPU 缓存的命中率很低这种指针跳跃在循环量大的时候比连续数组遍历慢得多。6. 线程安全HashMap 并发写为什么会出问题ConcurrentHashMap 怎么救场6.1 并发 put 时的数据丢失与可见性问题HashMap 线程不安全这句话其实包含了几层意思。第一层是数据竞争多个线程同时 put 时可能同时发现桶位为空然后各自 new 一个 Node后写的覆盖先写的数据就丢了。第二层是可见性HashMap 内部数组和节点字段都没有 volatile 修饰JDK 8 的 table 数组是 volatile 的但节点内部的 next、value 不是线程 A 的修改对线程 B 不一定立即可见。第三层是结构性问题JDK 8 虽然修掉了 JDK 7 的环形链表死循环但并发扩容时仍然可能发生节点丢失、链表断裂等情况。比如两个线程同时发现 size 超过 threshold各自创建一个新数组一个线程的扩容结果覆盖了另一个线程的写入已经 put 进去的数据就凭空消失了。我在 Android 开发里见过不少偶发数据错乱的 bug查到最后都是某条代码路径在子线程往一个共享的 HashMap 里写了数据而主线程在另一个时机读取。最典型的场景是网络请求回调线程里更新了一个HashMapString, StringUI 线程在onCreate或onResume里读取。由于可见性没有保证UI 线程可能读到旧值或者 null表现就是有时候界面显示了最新数据有时候没有。6.2 线程安全的替代方案怎么选Android 上解决这个问题方案不少但各有适用边界ConcurrentHashMap适合并发读写频繁的场景内部用 CAS synchronized 锁桶 分段思想读操作基本无锁。在数据量大、并发量高的场景下是最优选择。Collections.synchronizedMap(new HashMap())给每个方法加一把全局锁简单粗暴但并发读也会串行化性能很差只在并发量极低时用。Hashtable远古遗留类全方法加锁性能比 synchronizedMap 还差不推荐。ArrayMap/SimpleArrayMap更适合单线程或极少写的场景。我通常的建议是如果你只是初始化时一次性填充数据之后只读那可以不用任何并发容器直接用HashMap但一定要保证写完后安全发布。比如通过静态 final 字段初始化或者用volatile引用指向一个不可变的 HashMap。如果数据在运行时会被多个线程读写直接用ConcurrentHashMap不要抱侥幸心理。补充一个 Android 特有的点ConcurrentHashMap在 API 21 可用如果你的 App 最低支持版本低于 21需要用兼容库或者自己做一个轻量级的锁封装。不过现在大多数项目的 minSdk 都大于等于 21这个限制基本可以忽略。6.3 并发场景下的遍历一致性即使换成ConcurrentHashMap遍历时也可能遇到一个语义上的惊喜size()返回的是近似值而不是准确值。因为并发情况下的 size 统计本身就没有强一致性。所以如果你用map.isEmpty()来判断还有没有任务没处理在高并发下可能有偏差。keySet()遍历 ConcurrentHashMap 时弱一致性的具体表现是遍历过程中插入的节点可能被包含也可能不被包含已经被删除的节点遍历器可能会路过它的旧引用。如果业务对一致性要求很高比如遍历的同时必须能精确删除建议先把 key 快照到 List 再处理或者用ConcurrentHashMap提供的forEach、reduce等接口。在 Android 的主线程 子线程模型中我见过一个实际案例一个下载任务队列用 ConcurrentHashMap 存任务 ID 和状态主线程定时遍历更新 UI 进度。因为弱一致性偶尔会出现某个任务明明已经完成了UI 还显示正在下载的情况多刷新两次又正常了。后来我们改为主线程持有任务状态的完整快照从快照渲染 UI问题就消失了。这类问题不是代码逻辑错了而是对并发容器的语义理解不到位。7. 常见问题与排查技巧实录7.1 面试高频问题速查表问题核心要点HashMap 的底层数据结构数组 链表 红黑树链表长度 8 且数组长度 64 时转红黑树put 的流程扰动哈希 - 寻址 - 空桶直接放 / 更新 / 链表尾插 / 树插入 - 判断扩容get 什么时候用 equalshash 相同且 key 引用不相等时用 equals 判断内容是否相等为什么容量是 2 的幂让(n - 1) hash等价于取模且分布均匀扩容时可用位运算判断移动为什么负载因子是 0.75空间和时间的折中过高浪费查找时间过低浪费内存空间HashMap 线程安全吗不安全存在数据丢失、可见性问题JDK 8 修复了死循环但仍有丢数据问题JDK 7 和 JDK 8 的区别头插法改尾插法、引入红黑树、扩容时高低位链表拆分遍历时能否 remove不能直接用 map.remove要用迭代器 remove 或 removeIf自定义对象做 key 要注意什么equals 和 hashCode 必须同时重写key 必须不可变HashMap 和 ArrayMap 怎么选数据量小于几百且 key 是 int 用 SparseArray内存敏感用 ArrayMap大量数据用 HashMap7.2 实战排坑一个get 永远返回 null的案例有一次一个同学给我发了一段代码说 HashMap 里明明有 key但get就是返回 null。代码如下public class Item { public int id; public String name; // 没有重写 equals 和 hashCode } MapItem, Integer priceMap new HashMap(); priceMap.put(new Item(1, apple), 5); Integer price priceMap.get(new Item(1, apple)); // 结果为 null原因其实前面已经讲透了每次new Item(1, apple)都是不同的对象实例hashCode默认是对象内存地址的某种映射两个实例的哈希值几乎不可能相同所以两次操作寻址到的桶都不一样自然永远 get 不到。解决办法就是重写equals和hashCode让同 id、同 name 的对象语义上相等。这个例子看起来很简单但很多团队里自定义对象做 key 的问题都是这样出现的。重构时如果改动了对象的字段一定要同步检查equals和hashCode是否还正确用 Android Studio 的Generate生成时注意选择哪些字段参与比较。7.3 通过 Android Studio 的 Profiler 定位 HashMap 相关性能问题Android Studio 自带的 CPU Profiler 和 Memory Profiler 可以用来验证 HashMap 相关性能问题。我的常用操作流程是先用 Memory Profiler 抓 Heap Dump然后按类名搜索 HashMap 或 LinkedHashMap看具体实例的 retained size 和元素数量。如果发现有 HashMap 的 retained size 特别大但业务上不应该有那么多数据说明这个 map 可能被当成了缓存来用而且没有淘汰策略。再看里面的 key 类型分布。如果 key 全是 String且内容高度重复可以检查是不是把一条条临时拼接的字符串当 key导致重复对象堆积。CPU 方面用 CPU Profiler 的 Method Trace 录制一段主线程操作重点看HashMap.get、HashMap.put、HashMap.resize的耗时占比。如果resize出现在高频路径上几乎可以断定初始化容量没给够。如果get耗时集中在某些特定业务的调用栈里再去定位是不是 equals 实现太重、key 是长字符串、或者桶分布严重不均。我一般会写一段本地单元测试模拟生产环境的 key 分布和数据量直接用Trace.beginSection打点对比优化前后的耗时数据。用数据说话总比靠感觉优化要靠谱得多。7.4 避坑清单Android 上 HashMap 的 10 条实战建议预估数据量并指定初始容量避免 resize。key 为 int 时优先用 SparseArraykey 为 long 时用 LongSparseArray。数据量几百以内且内存敏感时用 ArrayMap 替代 HashMap。多线程读写场景直接用 ConcurrentHashMap。自定义对象做 keyequals 和 hashCode 必须同时重写且 key 尽量不可变。遍历用 entrySet()不要用 keySet() 再 get()。遍历时删除用迭代器 remove 或 removeIf不要直接 map.remove。不要用 HashMap 做缓存没有淘汰策略会 OOM用 LruCache。一个 key 的 hashCode 如果依赖了可变字段赶紧改早晚出 bug。使用 Android Studio Profiler 定期检查 HashMap 实例数量和 retained size特别是列表页、数据解析这类高频模块。8. 最后的实操心得从源码到 Android 工程的最佳实践前面把原理、流程、并发、遍历和排查都铺开了最后聊一点我在 Android 项目里形成的实际习惯当作给准备动手排查或优化的人一个参考。我接到过一个线上反馈某页面滑动时偶发掉帧抓 Method Trace 后发现LinkedHashMap的get占了不少主线程时间。仔细看代码原来是每次创建页面时解析一大段配置数据存进了一个默认容量的 HashMap后续滑动过程中get要频繁处理一个很长的链表。最后根因是配置数据里大量 key 的 hash 值经过扰动后仍然集中在某几个桶里——这类极端分布不是靠容量给大就能解决的我们后来把配置解析改成按需加载避免一次性塞进一个大 map同时改用 ConcurrentHashMap 做读多写少的缓存掉帧问题就控制住了。这个案例想说明一个道理HashMap 的源码细节再熟落到 Android 工程里依然是内存、线程、主线程耗时这三件套。不要为了用 HashMap 而用 HashMap先想清楚数据量级、读写频率、并发模型、淘汰策略再决定用哪种容器先用 Profiler 量化问题再动手改代码。HashMap 没有并发安全没有淘汰策略也没有有序性它有且只有一个强项在哈希分布均匀的前提下提供 O(1) 的读写性能。我们在 Android 里使用它的正确姿势是把它的强项发挥出来同时主动避开那些看起来能用一压测就翻车的场景。这些东西靠背面试题是学不来的只有真刀真枪地在 deviced 上跑过、优化过才会形成肌肉记忆。希望这篇总结能帮你少走一些弯路。
网站建设高端定制企业官网