☰
Java集合框架底层拆解:HashMap、ArrayList扩容与并发容器选型
2026/10/13 19:17:34 网站建设 项目流程

写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或HashMap,Bug就来了。原因很直接:HashSet判断两个对象是否“同一个元素”,HashMap判断两个键是否“同一个键”,第一步比的是hashCode,第二步比的是equals。

这里有一个硬性约定:两个对象equals返回true,hashCode必须相等。反过来不要求,不同对象允许相同hashCode,这叫哈希冲突。现实中频繁出现的情况是,你明明觉得两个对象内容相同,但因为没有重写hashCode,它们被哈希函数分散到不同的桶里,HashMap按内容查永远查不到。

看个最简单的例子:

public class UserKey { private final String userId; public UserKey(String userId) { this.userId = userId; } public String getUserId() { return userId; } } Map<UserKey, 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的活儿,需要的是一个排序容器或外部排序;再比如“一个订单号对应多个商品行”——这明显应该用Map<String, List >,而不是在Map的Value里反复覆盖。

根据我的经验,选型顺序应该是:先确定单列还是键值对,再确定是否需要排序,再确定是否允许重复,最后考虑并发读写比例。频繁增删查的数据放普通集合;多线程共享并频繁写的数据放并发容器;只读不写的数据用不可变集合或者防御性复制。把这几层条件一框,答案基本就出来了。这也是为什么我特别强调“底层数据结构决定行为边界”的原因——你不知道数组和链表查找复杂度的差异,就很难理解为什么ArrayList和LinkedList在真实代码里的表现差那么多。

2. HashMap底层实现:从哈希桶到红黑树逐层拆解

2.1 数组桶、链表、红黑树三种结构怎样组合

先给一个总体的内存布局:JDK 8之后的HashMap不是一个简单的数组,而是“Node数组 + 链表 + 红黑树”的复合结构。Node数组也叫table,每个数组元素是一个“桶”,桶里可能是一个Node,也可能是链表头节点,还可能是一棵红黑树的根节点。

当put一个键值对时,流程是这样的:

  1. 对key计算hash值。
  2. 用hash值定位到table数组中的一个桶下标。
  3. 如果这个桶是空的,直接放一个Node进去。
  4. 如果桶里有元素,用equals一个一个比较,找到相同key就更新值,找不到就追加到链表尾部。
  5. 如果链表长度超过阈值,且table容量够大,就把链表转成红黑树。

为什么要在链表之外再引入红黑树?因为哈希冲突严重时,单个桶上的链表会不断变长,查找复杂度会退化成O(n)。红黑树是一棵自平衡二叉搜索树,平均查找复杂度O(log n)。最坏情况下,红黑树上挂了几百个节点,查找也只多做几次比较,性能不会彻底崩掉。

两个关键参数是:链表转树的阈值TREEIFY_THRESHOLD=8,树转链表的阈值UNTREEIFY_THRESHOLD=6。注意转树还有一个前置条件:table数组长度不小于64。如果只有一个桶的链表很长,但table长度还不到64,HashMap不会急着转树,而是先行扩容,把数据重新散列,碰撞自然减少。

这里有一个容易被忽略的细节: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元素个数超过容量乘以负载因子这个阈值时,就需要扩容。以默认情况为例,capacity=16,threshold=16*0.75=12,也就是说第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_CAPACITY=10。

扩容逻辑在grow方法里,核心公式是:

int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1);

翻译过来就是当前容量加上当前容量的一半,也就是1.5倍。当单次扩容还不足以装下新增元素时,newCapacity会被直接调整为所需最小容量。容量增长后,会把旧数组里的数据复制到新数组,复制的底层实现是Arrays.copyOf,本质是System.arraycopy。

想直观看到扩容过程,可以用反射拿到elementData数组长度:

List<Integer> 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

先看这段代码,很多人第一次跑就栽了:

List<String> 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显式遍历并删:

Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("a")) { it.remove(); } }

Iterator.remove()在删除元素后会把expectedModCount同步成最新的modCount,所以不会抛异常。

第二种,JDK 8之后的removeIf:

list.removeIf(s -> s.equals("a"));

源码内部就是基于Iterator实现的,写法最简洁。

第三种,倒序索引删除:

for (int i = list.size() - 1; i >= 0; i--) { if (list.get(i).equals("a")) { list.remove(i); } }

倒序遍历删除了后面的元素,不会影响前面还没遍历到的索引位置,所以不会出现“漏元素”的问题。但注意,这个方法只适用于支持随机访问的List,LinkedList里用这个写法效率会很差。

我特别强调一点:普通正序索引删除存在漏元素问题,因为删除某个元素后,后面的元素会前移,下标错位。用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,取整后大约是134+1=135,实际会被对齐到下一个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或迭代器remove
HashMap查询偶发变慢hashCode分布差或链表过长检查自定义hashCode,优化散列
get返回null但数据明明存在键对象的hashCode或equals错误修正equals/hashCode,键字段保持不可变
ArrayList扩容频繁,性能下降没有预分配容量创建时指定初始容量或调用ensureCapacity
subList操作导致原列表数据错乱把subList当成独立副本使用new ArrayList<>(list.subList(m, n))
并发列表写频繁但用了CopyOnWriteArrayList写时复制开销过大改用并发容器或普通List加读写锁

这张表看起来简单,真到排查时非常有用。特别要注意第三行和第六行这类问题,它们本身不是并发问题,而是集合内部机制引发的。很多人上来就怀疑Java并发模型,反而绕了远路。

关于HashMap的容量对齐,还有一个容易被忽略的细节:当你用new HashMap<>(100)时,Java并不会直接用100作为容量,而会把它对齐到128。也就是说,100个元素加入后,threshold是128*0.75=96,实际放了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 跑 Map<String, Integer> hashMap = Collections.synchronizedMap(new HashMap<>()); runTask(hashMap, "synchronizedMap(HashMap)"); // 用 Hashtable 跑 Map<String, Integer> hashtable = new Hashtable<>(); runTask(hashtable, "Hashtable"); // 用 ConcurrentHashMap 跑 Map<String, Integer> concurrentMap = new ConcurrentHashMap<>(); runTask(concurrentMap, "ConcurrentHashMap"); } private static void runTask(Map<String, 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,因为它们比任何文字描述都直观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询