Java集合框架底层拆解:HashMap、ArrayList扩容与并发容器选型
发布时间:2026/10/13 19:18:02 锦皓数字建站

写Java的同学几乎没有一个能绕开集合框架。我在做技术评审和排查线上问题的这几年里见过最多的Bug有相当一部分集中在这几个类HashMap、ArrayList以及各种被并发访问的集合。问题本身不难难的是很多人没把“底层数据结构”和“行为特性”对应起来。ArrayList为什么会扩容HashMap为什么查询快、删除快但偶尔又慢得像退化成了列表并发场景下到底该用谁这些问题只要搞懂一次后面写代码基本不会再犯错。这篇文章我想换个讲法不按API文档的顺序抄而是从底层实现出发把集合框架最核心的部分拆开看。先讲顶层接口的设计逻辑和equals/hashCode这些基本功再逐个拆解HashMap、ArrayList、LinkedList的实现细节和扩容规则然后聊聊线程安全集合的演进和选型最后给出一批能直接跑的Demo代码以及线上问题排查的速查清单。如果你曾经遇到过HashMap丢数据、集合被并发修改、迭代时删除抛异常或者被面试官问到底层扩容却答不上来这篇文章能帮你把这些根因理清楚。1. 集合框架的顶层设计与三个基本功1.1 Collection和Map两条主线怎么影响实现选择Java集合框架的顶层是两个接口Collection和Map。Collection之下又分成List、Set两条分支Map单独一条线负责存键值对。这个设计并不是为了好看而是为了贴合三种完全不同的数据访问方式List有序、可重复支持按索引访问。Set无序、不可重复重点做去重和快速判断存在与否。Map键到值的映射按键查找值键不可重复。我见过不少初学者在选类型时凭感觉来看到“一组数据”就拿ArrayList看到“键值对”就拿HashMap。这样不一定会出错但未必是最优解。真正应该先问自己的是我需要按下标随机访问吗我需要自动去重吗我需要按范围查询吗需要排序吗这些问题对应到数据结构其实就是数组、链表、哈希表、树这四类。数组适合随机访问链表适合频繁在中间插删哈希表适合按键快速查找树适合维护元素之间的相对顺序。顶层的接口行为差异本质上是底层结构差异的外露。所以学集合框架与其死记“哪个类扩容成几倍”不如先把数据结构这条线拉通。这也是我最建议的入门方式把ArrayList当动态数组把LinkedList当双向链表把HashMap当“数组加链表再加红黑树”把TreeMap当红黑树你会发现接口方法的行为几乎都能解释得通。1.2 equals/hashCode/compareTo集合正确性的地基很多人写自定义类只写getter/setter从不重写equals和hashCode。单看没问题一旦把这个类的对象放进HashSet或HashMapBug就来了。原因很直接HashSet判断两个对象是否“同一个元素”HashMap判断两个键是否“同一个键”第一步比的是hashCode第二步比的是equals。这里有一个硬性约定两个对象equals返回truehashCode必须相等。反过来不要求不同对象允许相同hashCode这叫哈希冲突。现实中频繁出现的情况是你明明觉得两个对象内容相同但因为没有重写hashCode它们被哈希函数分散到不同的桶里HashMap按内容查永远查不到。看个最简单的例子public class UserKey { private final String userId; public UserKey(String userId) { this.userId userId; } public String getUserId() { return userId; } } MapUserKey, String map new HashMap(); map.put(new UserKey(1001), 张三); System.out.println(map.get(new UserKey(1001))); // 输出 null同一个“1001”新构造的Key内容上一模一样但查出来的结果却是null。为什么会这样因为默认hashCode基于对象内存地址两个new出来对象地址不同hashCode也不同落到了不同的哈希桶里。解决办法简单明确重写equals时一定要重写hashCode并且participating字段要保持不变。public class UserKey { private final String userId; public UserKey(String userId) { this.userId userId; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof UserKey)) return false; UserKey that (UserKey) o; return Objects.equals(userId, that.userId); } Override public int hashCode() { return Objects.hash(userId); } }这里还有一个常被忽视的细节参与hashCode计算的字段应该是不可变的。如果你把一个对象放进HashMap后又修改了它的关键字段导致hashCode发生变化那么它在哈希表中的桶位置就变了可HashMap的数组里不会同步移动它。结果是这个键还在Map里但按新内容再也查不到了。这种问题很隐蔽几乎只能通过检查代码找出来。所以做Map键的类最好设计成不可变对象。Comparator和Comparable则负责排序语义。Comparable让类自身具备“默认排序”能力Comparator则是“动态指定排序规则”。常见的坑是compareTo的返回结果和equals不一致比如按金额排序时两个金额相同但UUID不同的对象被compareTo判为相等。在TreeMap和TreeSet里排序相等的对象会被当成“同一个元素”导致其中一个被覆盖。如果业务上需要“排序相等”和“业务相等”是两个概念切记不要用可排序字段直接充当equals的唯一依据。1.3 有序、可重复、线程安全先给需求定个性实际开发中频繁出现的选型错误多数是没把需求定性做透。比如“我有一批数据要展示需要先按创建时间倒序又需要按状态过滤”——这不是HashMap的活儿需要的是一个排序容器或外部排序再比如“一个订单号对应多个商品行”——这明显应该用MapString, List 而不是在Map的Value里反复覆盖。根据我的经验选型顺序应该是先确定单列还是键值对再确定是否需要排序再确定是否允许重复最后考虑并发读写比例。频繁增删查的数据放普通集合多线程共享并频繁写的数据放并发容器只读不写的数据用不可变集合或者防御性复制。把这几层条件一框答案基本就出来了。这也是为什么我特别强调“底层数据结构决定行为边界”的原因——你不知道数组和链表查找复杂度的差异就很难理解为什么ArrayList和LinkedList在真实代码里的表现差那么多。2. HashMap底层实现从哈希桶到红黑树逐层拆解2.1 数组桶、链表、红黑树三种结构怎样组合先给一个总体的内存布局JDK 8之后的HashMap不是一个简单的数组而是“Node数组 链表 红黑树”的复合结构。Node数组也叫table每个数组元素是一个“桶”桶里可能是一个Node也可能是链表头节点还可能是一棵红黑树的根节点。当put一个键值对时流程是这样的对key计算hash值。用hash值定位到table数组中的一个桶下标。如果这个桶是空的直接放一个Node进去。如果桶里有元素用equals一个一个比较找到相同key就更新值找不到就追加到链表尾部。如果链表长度超过阈值且table容量够大就把链表转成红黑树。为什么要在链表之外再引入红黑树因为哈希冲突严重时单个桶上的链表会不断变长查找复杂度会退化成O(n)。红黑树是一棵自平衡二叉搜索树平均查找复杂度O(log n)。最坏情况下红黑树上挂了几百个节点查找也只多做几次比较性能不会彻底崩掉。两个关键参数是链表转树的阈值TREEIFY_THRESHOLD8树转链表的阈值UNTREEIFY_THRESHOLD6。注意转树还有一个前置条件table数组长度不小于64。如果只有一个桶的链表很长但table长度还不到64HashMap不会急着转树而是先行扩容把数据重新散列碰撞自然减少。这里有一个容易被忽略的细节JDK 7和JDK 8在插入链表冲突元素时一个用头插法一个用尾插法。头插法在并发扩容时容易形成环形链表在线程相互抢占的场景下会引发CPU 100%的死循环这是当年著名的线上事故。JDK 8改成尾插法后环形链表问题基本被消除了但并不意味着HashMap在多线程环境下就安全了并发put依然会丢数据。后者我在第4章会展开讲。另外树化并不总是性能最优。红黑树的节点TreeNode比普通Node多维护了父子指针、颜色标记内存占用更大。所以当元素数量不多时链表反而是既简单又省内存的方案。HashMap在树化和反树化之间留了8和6两个阈值其实就是为了制造一段缓冲区间防止在临界点来回震荡避免频繁的结构转换带来额外开销。2.2 扰动函数、容量确认、扩容的全过程哈希计算的第一个环节是hash扰动。源码里是这样写的static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }把key.hashCode()的低16位和高16位做异或目的是让高16位的信息也参与到后续的寻址过程中。因为HashMap计算桶下标用的是(n - 1) hash在table长度不大时参与运算的主要是hash的低位。如果两个key的hashCode在高几位不同低位全一样直接按原始hashCode定位就很容易碰撞。高16位异或低16位之后低位混合了高位的信息分布就更均匀。这个操作不复杂但很值得一学因为它是“用位运算做均匀散列”的经典例子。桶下标计算公式是int index (n - 1) hash;其中n是table数组长度并且规范要求n必须是2的幂。为什么必须是2的幂因为hash % n的结果在数学上和(n - 1) hash等价的条件就是n为2的幂。位运算比取模快得多而且在n为2的幂时(n - 1)的二进制恰好是全1与hash做与运算相当于直接截取hash的低位能充分利用散列值碰撞概率更低。这也是面试必问的“为什么HashMap容量是2的幂”的根源。默认容量是16默认负载因子是0.75。负载因子的意思是当HashMap元素个数超过容量乘以负载因子这个阈值时就需要扩容。以默认情况为例capacity16threshold16*0.7512也就是说第13个元素放进去时HashMap会先把table扩大到32再重新散列所有已有元素。扩容源码里新容量大小是旧容量左移一位即翻倍。JDK 8扩容还有一个优化点因为容量总是翻倍旧链表上的节点在新数组里的位置只有两种可能——索引不变或者“原索引旧容量”。原因是在n变成2n后(n - 1) hash相当于在低位多取了一位。如果多出来的那一位是0索引不变如果是1索引就会在原位置基础上加上旧容量。这个规律让JDK 8在rehash时不需要反复重新计算每个key的hash只要看新增的那一位是0还是1就能决定节点去哪个桶。源码里用(e.hash oldCap) 0来判断就是这个目的。2.3 自定义Key的两个实战陷阱hashCode丢失与可变字段前面已经提到过自定义对象做Map键时如果equals/hashCode写错最直接的现象就是“存进去却取不出来”。这种问题在代码Review阶段很难一眼察觉因为它在单测里往往数据量很小碰巧没触发。我遇到过一个更隐蔽的变体键对象本身是实体类里面既有业务主键又有私有变更字段。开发同学重写hashCode时图省事把整对象所有字段都算了进去。然后这个对象在放入Map之后另一个服务又改了它的某个普通字段HashCode立刻变了。表面上查不出任何异常但后续再拿同一个主键去get返回null。排查了很久才发现是键对象可变导致的。这类问题的处理原则很明确Map的键必须是不变对象主要字段至少是final。如果不方便设计成不可变那就要在放入Map前复制出一个不可变的键对象。价格敏感的业务场景还要注意重写equals时避免用可排序字段充当唯一性判断比如金额、时间这类字段单独作为主键会误判重复。还有一个从实际代码里总结出来的经验当两个对象的hashCode相同但equals不同时它们会共享同一个桶形成链表。如果自定义hashCode写得太粗糙比如全部返回固定值那么HashMap就退化成了一个巨大的链表插入和查询都是O(n)。线上表现为“数据量一大缓存接口就变慢”排到后面才发现是hashCode分布灾难。这也是为什么hashCode要尽量分散而不是只保证“相等就返回相同值”。3. ArrayList与LinkedList扩容机制、底层差异与遍历删改3.1 ArrayList扩容默认容量10每次长大1.5倍ArrayList的本质是动态数组它内部用一个Object[] elementData来存数据。初始时如果创建的是空列表elementData指向一个空数组真正分配容量发生在第一次添加元素时默认容量是DEFAULT_CAPACITY10。扩容逻辑在grow方法里核心公式是int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1);翻译过来就是当前容量加上当前容量的一半也就是1.5倍。当单次扩容还不足以装下新增元素时newCapacity会被直接调整为所需最小容量。容量增长后会把旧数组里的数据复制到新数组复制的底层实现是Arrays.copyOf本质是System.arraycopy。想直观看到扩容过程可以用反射拿到elementData数组长度ListInteger list new ArrayList(); // 查看初始elementData长度是0 // 添加第1个元素后长度变成10 // 添加第10个元素仍然是10 // 添加第11个元素时会扩容到15这个验证方式建议你亲自跑一遍比死记“默认容量10、1.5倍扩容”要深刻得多。第一次扩容到10第二次到15第三次到22第四次到33不是每次都是正好1.5的整数倍因为代码里有int强转和最小容量兜底。搞清楚扩容机制后就能理解为什么“预估容量”这么重要。如果你往ArrayList里循环塞100万个元素过程中会发生很多次扩容每次扩容都涉及数组复制总耗时和内存浪费都不小。正确做法是在创建时就指定初始容量比如确定有100万条数据直接new ArrayList(1_000_000)或者先用ensureCapacity方法。同样的道理适用于HashMap创建时指定初始容量可以减少扩容次数。再补一个高频认知盲区ArrayList.subList返回的是原列表的视图不是独立副本。对视图的修改会直接反映到原列表上。很多人把subList当成“截取出来的新列表”改完视图再操作原列表最终出现数据错乱。如果确实需要独立副本应该new ArrayList(list.subList(m, n))。3.2 LinkedList的双向链表实现与适用边界LinkedList的底层是双向链表每个节点有prev和next两个引用分别指向前一个节点和后一个节点。它不需要连续内存空间添加元素时只是断开和重接两个引用所以不存在“扩容搬数据”这个概念。但链表也有代价。按索引访问get(i)时LinkedList不能直接跳到目标位置只能从头部或尾部往中间搜索复杂度是O(n)。Java底层做了优化让索引小于size/2的从前往后找否则从后往前找但本质上还是线性扫描。很多同学误以为LinkedList中间插入删除一定比ArrayList快。在真实业务里未必。因为add(int index, E element)这个方法先要找到index位置的节点而寻址本身就是O(n)。只有你已经有某个节点的引用再在这个节点前后插入或删除LinkedList才能发挥真正的O(1)优势。单纯在尾部追加、头部插入这类场景LinkedList确实优但ArrayList尾部追加同样是O(1)摊还而且CPU缓存更友好遍历时反而更快。所以我的选型建议是这样高频随机访问、尾部追加为主选ArrayList。需要频繁在头部或已知节点附近插删选LinkedList。大部分场景下兜底选ArrayList不会错因为它局部性好、遍历快、内存占用小。如果涉及队列或双端队列操作直接考虑ArrayDeque和LinkedList按官方推荐优先ArrayDeque。还有一个经典的容量细节LinkedList不需要预分配容量节点随存随建。这既是优点也是缺点每个节点还要额外存两个指针内存开销比ArrayList更大。数据量大时LinkedList的额外内存可能会让GC更吃力。3.3 集合遍历中删除元素为什么老抛ConcurrentModificationException先看这段代码很多人第一次跑就栽了ListString list new ArrayList(Arrays.asList(a, b, c)); for (String s : list) { if (s.equals(a)) { list.remove(s); // 运行时抛 ConcurrentModificationException } }原因是增强for循环里的迭代器维护了一个modCount字段副本。modCount是集合的结构修改次数每次add、remove都会自增。迭代器在每次next()时都会校验当前集合的modCount和迭代器创建时的期望值是否一致一旦发现不一致就立刻抛出ConcurrentModificationException这种机制叫fail-fast。这其实是一个保护机制它不保证在多线程并发修改中一定准确而是尽早检测到异常状态提示程序员代码有问题。单线程里“边遍历边删”也会触发所以它是很多新手最经常撞的坑。正确的删除姿势有三种。第一种用Iterator显式遍历并删IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.equals(a)) { it.remove(); } }Iterator.remove()在删除元素后会把expectedModCount同步成最新的modCount所以不会抛异常。第二种JDK 8之后的removeIflist.removeIf(s - s.equals(a));源码内部就是基于Iterator实现的写法最简洁。第三种倒序索引删除for (int i list.size() - 1; i 0; i--) { if (list.get(i).equals(a)) { list.remove(i); } }倒序遍历删除了后面的元素不会影响前面还没遍历到的索引位置所以不会出现“漏元素”的问题。但注意这个方法只适用于支持随机访问的ListLinkedList里用这个写法效率会很差。我特别强调一点普通正序索引删除存在漏元素问题因为删除某个元素后后面的元素会前移下标错位。用Iterator和removeIf则可以规避。这也是代码评审时我比较关注的“隐形Bug点”。4. 线程安全集合与并发容器选型、原理、坑位4.1 同步容器为何仍不安全方法加锁不等于操作安全Java早期提供线程安全集合的方式很粗暴给每个方法都加synchronized。典型代表是Vector和Hashtable后来又有Collections.synchronizedList、synchronizedMap这类包装器。这种方案在低并发下可用但有两个明显问题。第一锁粒度太大整个集合都是一个锁并发度很低。第二方法级加锁只保证单个操作原子组合操作依然不是原子的。如果要先检查再添加if (!list.contains(item)) { list.add(item); }两个线程完全可能同时通过contains检查又同时执行add结果产生重复元素。即使list是synchronizedList这个复合操作也必须由外部加锁才安全synchronized (list) { if (!list.contains(item)) { list.add(item); } }迭代容器同样有风险。synchronizedList的迭代器是fail-fast的如果迭代过程中有别的线程修改集合会抛ConcurrentModificationException。所以官方文档都建议迭代时也需要在外层锁住整个list对象。很多人一看到synchronizedList就觉得“线程安全了”于是放心大胆在多线程环境里用结果线上偶发异常。这类问题很难稳定复现恰巧又和“集合类”绑定在一起本质上还是因为混淆了“方法安全”和“操作安全”这两个维度。4.2 CopyOnWriteArrayList与ConcurrentHashMap的并发模型JDK并发包提供了更细粒度的方案。CopyOnWriteArrayList的核心思想是“写时复制”每次add、remove都会用Arrays.copyOf复制一份新的数组在副本上做修改然后通过volatile数组引用一次性切换而读操作直接读当前数组引用不需要加锁。因为读的是不可变的快照读线程永远不会看到写入过程中的中间状态也不需要阻塞写线程。这种设计特别适合读多写少、对一致性要求没有那么苛刻的场景比如配置缓存、监听器列表。要注意它的写操作非常重每次写都要复制整个数组。如果频繁写内存开销和GC压力会很大。我有一次在一个高频事件上报模块里用了CopyOnWriteArrayList结果线上老年代频繁上涨压力测试一跑才发现每次事件都要复制一次上百万大小的数组。后来把数据结构改成普通ArrayList加读写锁才压下去。CopyOnWriteArrayList还有一个细节它返回的迭代器是弱一致性的基于创建迭代器那一刻的快照后续修改对已创建的迭代器不可见也不会抛ConcurrentModificationException。这既是优点也是坑如果业务依赖“迭代时看到最新数据”那它就不合适。ConcurrentHashMap在JDK 8里做了一次重写彻底放弃了JDK 7的“分段锁”改用CAS和synchronized来实现。put流程中如果目标桶为空直接用CAS把新节点放进去整个过程没有加锁如果桶不为空对桶头节点加synchronized再处理链表或红黑树插入。锁的粒度从整表级别细到了单个桶级别不同桶的插入可以并行并发能力大幅提升。这里有个容易被误解的点ConcurrentHashMap的锁不是对方法加锁而是锁桶头节点。synchronized块的范围很小所以即使多个线程往不同桶里写互相不干扰。即便写同一个桶也只是那个桶内部串行。加上无锁读整体吞吐量比Hashtable高出好几个量级。size()方法也做了改动它尽量用并发计数器累加高并发下得到的是一个近似值。如果你的业务急需精确总量就要考虑追加CountedCompleter之类的外部方案或者接受它的近似语义。4.3 并发场景容器选型速查把常见业务场景和推荐方案放在一起看会更直观。场景推荐容器理由多线程共享Map读写都比较频繁ConcurrentHashMap桶级锁无锁读吞吐量高并发有序Map需要范围查询ConcurrentSkipListMap跳表实现天然有序多线程读一个List写操作极少CopyOnWriteArrayList读无锁写时复制多线程共享List读写各半实时性要求高普通List 读写锁写不复制数组性能更稳单线程或低竞争环境HashMap / ArrayList无额外锁开销实现最简单有些场景不需要并发容器却误用了并发容器最常见的就是“CopyOnWriteArrayList被塞进高频写路径”。记住并发容器的核心价值是让你少锁或不锁但它们都有各自的代价。选型时先评估容器里的数据量、读写比例、实时性要求再去挑实现方案会比一上来就套ConcurrentHashMap更合理。5. 高频面试题与编码规范避坑清单、容量调优和排查思路5.1 HashMap为什么默认容量16、负载因子0.75、树化阈值8这三个数字是面试高频三连问也是理解HashMap设计哲学的入口。默认容量16是2的4次幂。选2的幂主要是为了配合(n - 1) hash做取模运算同时保证平均分布。负载因子0.75是时间与空间的折衷太高会让哈希桶更拥挤增大碰撞概率太低会频繁扩容浪费内存。0.75是这两者的一个经验平衡点官方注释里也承认这是权衡后的结果。树化阈值为什么是8因为理想状态下如果hashCode足够随机HashMap里的每个桶元素个数遵循泊松分布。用负载因子0.75和容量翻倍的策略推算一个桶里出现超过8个节点的概率极低大概是千万分之几级别。也就是说只有哈希分布已经严重失衡或者恶意构造了大量相同hash的key时链表才会长到8以上。此时转红黑树是在用空间换时间防止最坏情况下的查询退化。反树化阈值6则是为了留缓冲区。如果树和链表来回切换的阈值只差1比如8和7那么系统在扩容或缩容时可能反复在7和8之间横跳频繁做结构转换白白消耗CPU。8和6之间差了2能有效避免这种抖动。还有一个容易考到的点为什么树化前一定要判断table.length 64因为如果table太小就算某个桶链表很长优先做扩容才是更划算的选择。扩容会把节点散列到更多桶里直接降低冲突没必要为了单个桶去建红黑树。只有table已经足够大仍然有桶累积到8个节点才说明分布很不均匀此时树化才有意义。5.2 业务代码中集合初始化的容量预估方法在业务代码里最常见的性能问题不是算法多复杂而是集合容量没拍准。ArrayList默认容量10增长方式是1.5倍如果明确知道数据量是1万那至少要预留初始容量避免中途多次复制。HashMap的容量预估和负载因子有关。默认负载因子0.75意味着如果你希望HashMap装下100个元素而不触发扩容初始容量至少是100 / 0.75f 1取整后大约是1341135实际会被对齐到下一个2的幂。所以严格写应该用下面的工具方法或者直接用Map.of等静态工厂。public static int initialCapacityForExpected(int expectedSize) { if (expectedSize 3) return expectedSize; int cap (int) (expectedSize / 0.75f) 1; return cap; }JDK 8之后有个静态工厂方法Map.ofEntries、Map.of它内部会按固定容量构造适合小的只读场景。如果是要往Map里大量put建议创建时传入预估容量减少扩容开销。还有集合不可变性的问题。业务系统里很多集合是作为内部对象暴露出去的外部代码拿到后随意clear或add最终导致数据被污染。常见做法是用Collections.unmodifiableList包裹后返回或者直接返回副本。JDK 9之后还有List.of、Set.of、Map.of等不可变集合工厂这类集合底层是紧凑数组读取很快但不支持任何结构性修改。如果能用它们就别再用new ArrayList包装后再返回。5.3 常见线上问题排查速查表我平时排查集合相关问题时基本会先看异常栈再确认集合的并发访问情况最后看键对象的可变性。下面这张表可以直接当速查手册用症状可能根因优先处理建议多线程put后数据丢失普通的HashMap被并发访问换成ConcurrentHashMap老版本JDK出现CPU100%JDK 7扩容头插法形成环形链表升级JDK 8并改用并发容器迭代时抛ConcurrentModificationException单线程遍历时修改集合结构用removeIf或迭代器removeHashMap查询偶发变慢hashCode分布差或链表过长检查自定义hashCode优化散列get返回null但数据明明存在键对象的hashCode或equals错误修正equals/hashCode键字段保持不可变ArrayList扩容频繁性能下降没有预分配容量创建时指定初始容量或调用ensureCapacitysubList操作导致原列表数据错乱把subList当成独立副本使用new ArrayList(list.subList(m, n))并发列表写频繁但用了CopyOnWriteArrayList写时复制开销过大改用并发容器或普通List加读写锁这张表看起来简单真到排查时非常有用。特别要注意第三行和第六行这类问题它们本身不是并发问题而是集合内部机制引发的。很多人上来就怀疑Java并发模型反而绕了远路。关于HashMap的容量对齐还有一个容易被忽略的细节当你用new HashMap(100)时Java并不会直接用100作为容量而会把它对齐到128。也就是说100个元素加入后threshold是128*0.7596实际放了100个元素时会触发扩容。如果真想避免扩容容量至少要设成134以上。这个数如果不经过计算很容易踩。5.4 一段健壮的实战代码并发环境下的HashMap替代方案最后放一段可以直接跑起来的并发环境代码。场景是模拟多个线程同时往同一个Map里累计订单数量分别用HashMap、Hashtable、ConcurrentHashMap对比效果import java.util.Collections; import java.util.HashMap; import java.util.Hashtable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ConcurrentMapDemo { private static final int THREAD_COUNT 16; private static final int LOOP_COUNT 10000; public static void main(String[] args) throws InterruptedException { // 用普通 HashMap 跑 MapString, Integer hashMap Collections.synchronizedMap(new HashMap()); runTask(hashMap, synchronizedMap(HashMap)); // 用 Hashtable 跑 MapString, Integer hashtable new Hashtable(); runTask(hashtable, Hashtable); // 用 ConcurrentHashMap 跑 MapString, Integer concurrentMap new ConcurrentHashMap(); runTask(concurrentMap, ConcurrentHashMap); } private static void runTask(MapString, Integer map, String name) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(THREAD_COUNT); for (int i 0; i THREAD_COUNT; i) { pool.submit(() - { try { start.await(); for (int j 0; j LOOP_COUNT; j) { map.compute(order, (k, v) - v null ? 1 : v 1); } } catch (InterruptedException ignored) { } finally { done.countDown(); } }); } start.countDown(); done.await(); pool.shutdown(); System.out.println(name result map.get(order)); } }这个例子里的compute是原子操作所以synchronizedMap和Hashtable也能得出正确结果。但普通HashMap绝不能在多线程下使用即使只用put或者compute也会存在数据覆盖、扩容死循环等风险。你把runTask里的synchronizedMap换回普通HashMap再跑一次就会看到结果经常小于16万这就是丢数据现象。实际项目中如果你拿到的集合直接暴露给多线程最稳妥的做法就是统一使用ConcurrentHashMap。不要试图用Collections.synchronizedMap去优化一个高并发读写场景它的锁粒度太大容易成为性能瓶颈。正确衡量容器、数据结构、锁粒度之间的关系远比背一堆类名更重要。我在实际排查中还有一个体会很多集合相关的问题不是集合本身有Bug而是使用者对变长结构、并发语义、容量规划缺少预期。HashMap和ArrayList都不是银弹它们都有明确的设计目标和适用边界。当你把底层结构、扩容机制、线程安全策略放到同一个思维模型里时遇到问题就能快速定位。这也是为什么我建议你亲手跑一遍上面的反射扩容Demo和并发Map对比Demo因为它们比任何文字描述都直观。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。