1. 为什么JAVA集合是绕不过去的一道坎
不管你是刚接触Java的新人,还是已经在写业务代码的初级工程师,集合框架迟早会找上你。我第一次面试的时候,被问了一个到现在都记得很清楚的问题:"ArrayList和LinkedList到底该用哪个,你说清楚底层我再让你过。"当时我只会背"数组和链表的区别",结果对方追了一句"那ArrayList扩容是几倍,为什么",我直接卡住了。
集合在你日常开发里出现的频率,高到你可能意识不到:从Controller接收一批参数,到数据库查询返回一堆记录,再到内存里做分组、去重、排序、缓存,几乎每一个环节都在跟集合打交道。数组不是不能用,但数组一旦定义长度就固定了,增删元素要自己写迁移逻辑,存取对象还得自己维护下标,真的很麻烦。集合框架干的事情,就是帮你把这些脏活累活封装好,你只需要关心"用哪个容器、怎么操作数据"。
这篇文章不打算搞那种又臭又长的文档翻译,而是按照我自己的学习路径和面试复盘,从底层原理到实际踩坑,把List、Map、Set一条线讲透。你如果能把底层结构、扩容逻辑、哈希冲突、线程安全这些关键点串起来吃透,以后不管是写代码还是应付面试,都会硬气很多。
2. 集合框架整体脉络:先搞清楚家族体系
JAVA集合不是几十个类随机堆在一起,它有一套非常清晰的设计逻辑。你要做的第一步,不是背类名,而是建立一张"家族图谱"。
2.1 两大顶级接口:Collection 和 Map
集合框架最顶层的两个接口,一个是Collection,一个是Map。它们俩的定位完全不同:
Collection:存放单列元素,也就是一个一个独立的数据。它下面又分三个主要分支:List、Set、Queue。Map:存放键值对,每个元素都是key -> value的组合,像字典、索引一样,通过key去查value。
这套设计最有价值的地方在于:它把"接口"和"实现"彻底分离。你写代码的时候往往只需要面向接口,比如声明一个List<String> list = new ArrayList<>(),以后想换成LinkedList,只改后面的构造器就行,不用动其他逻辑,这就是面向接口编程的核心价值。
2.2 Collection分支:List、Set、Queue
- List:有序、可重复,像一个排队列表,每个人有固定的下标(索引),你可以通过
get(0)、get(1)快速访问某个位置的数据。 - Set:无序(大部分实现)、不可重复,像一个装球的袋子,往里丢重复的球会被弹出来,取出来的时候顺序通常和放进去时不一致。
- Queue:先进先出或按优先级排队,比如
LinkedList实现了Deque接口,既可以当队列也可以当双端队列。
2.3 Map分支:键值对王国
Map自己是一套独立的家族,HashMap、LinkedHashMap、TreeMap、Hashtable、ConcurrentHashMap各有各的场景。你在开发里接触最多的基本就是HashMap,但它背后牵扯的哈希算法、数组扩容、红黑树,是面试里最密集的火力区。后面我用一整节来拆。
2.4 为什么Set和Map常常纠缠在一起
这里必须提前点破一个非常关键的底层事实:JAVA的HashSet底层其实就是一个HashMap,只不过Set只用了Map的key那一侧,value统一用一个固定的Object占位。也就是说,理解HashSet之前,你先把HashMap搞明白,后面会省一大半力气。同理,TreeSet底层是TreeMap,LinkedHashSet底层是LinkedHashMap。Set和Map从代码实现上是"亲戚关系",不是两个毫不相干的独立世界。
3. List详解:从ArrayList到LinkedList的底层真相
List接口的官方定义是"有序集合(也称为序列)",你可以精确定位每个元素。实际代码里90%以上用的都是ArrayList,但问面试题时LinkedList也躲不过去。这两个类代表的是两种完全不同的底层物理结构。
3.1 ArrayList:动态数组的扩容机制
ArrayList底层是一块连续的内存空间,本质就是Object[]数组。它为什么能做到"动态扩容"?因为它会在容量不够的时候自动申请一块更大的新数组,把旧数组里的元素全部拷贝过去,然后释放旧数组。
关键点来了:Java 8及以后,ArrayList的默认扩容倍率是1.5倍。具体逻辑在grow(int minCapacity)方法里:
int newCapacity = oldCapacity + (oldCapacity >> 1);这里的oldCapacity >> 1就是右移一位,相当于除以2,所以新容量是旧容量的1.5倍。
如果老数组长度是10,扩容后变15;如果长度是15,扩容后变22(注意int位数截断后可能不是精确的1.5倍)。
这里有一个很值得注意的设计动机:为什么是1.5倍而不是2倍?如果扩容太猛(比如倍数过大),会浪费大量内存;如果每次都只增加1个,会频繁触发拷贝,性能极差。1.5倍是一个折中方案,既避免了过多次扩容,又不会让内存浪费太夸张。我第一次读到这个细节的时候有种豁然开朗的感觉,因为很多人只会背"1.5倍",却从不去想背后的权衡。
初始容量默认是10,不过只有当第一次添加元素时才会实际创建这个数组,不是new的时候立刻分配10个空间,这也算一个容易记错的点。
3.2 LinkedList:双向链表没有扩容一说的原因
LinkedList底层是双向链表,每个节点(Node)里存着三样东西:当前元素的值、指向前一个节点的引用prev、指向后一个节点的引用next。所以你往中间插入或删除一个节点,理论上只需要修改前后两个节点的引用指向,不需要移动其他元素,这是它最大的优势。
但它的代价也很明显:不支持随机访问。你想拿get(100),它没法直接算地址,只能从头或从尾遍历过去。你可以说LinkedList的get(int index)是O(n),而ArrayList的get(int index)是O(1)。所以所谓"LinkedList增删快",严格讲是"在已知节点位置的情况下,增删操作本身很快";但如果通过下标去定位,反而会先付出一次遍历的成本。
其实在日常开发中,LinkedList用得并不多。ArrayList在随机访问、遍历、内存紧凑性上都更适合大多数场景。LinkedList更多出现在队列、双端队列、栈这类需要频繁头尾操作的场景里,因为它实现了Deque接口。
3.3 实际开发里的性能测试经验
我自己做过一次小测试,往100万条数据的ArrayList和LinkedList中间反复插入一条记录,结果LinkedList的理论优势只有在索引恰好接近头部或尾部时才比较明显,如果按size()/2逐次插入,因为定位麻烦,整体性能经常反而不如ArrayList。这里必须说明,这只是一个基于常见实现的直观体验,不同JDK版本和插入模式差异很大,但对绝大多数业务代码来说,ArrayList是默认选择。
3.4 ArrayList的序列化和transient关键字
有一个容易被忽略的细节:ArrayList内部存储元素的Object[] elementData被transient修饰了。也就是说,默认的序列化机制不会直接把这个数组整个写出去。
为什么?因为数组可能有预留的容量空间,比如实际只有5个元素,但数组长度是10,里面5个位置是空的null,如果整个数组序列化,会白白多写5个空位置。所以ArrayList自己实现了writeObject和readObject,只序列化实际存在的元素个数,反序列化时再按实际元素个数重建数组。这个设计在序列化大集合时能省不少存储和网络开销,面试里问到"ArrayList序列化为什么用transient"时,就是考这个点。
3.5 ArrayList和LinkedList怎么选
我给你的实用建议是:
- 绝大多数场景直接选
ArrayList,内存更紧凑、遍历更快、随机访问极快。 - 需要频繁在头部插入/删除,或者确定要当作先进先出队列使用时,再考虑
LinkedList或ArrayDeque。 - 如果数据量巨大又需要频繁按下标访问,ArrayList几乎是最优解。
- 不需要一上来就追求"性能最优",先写出正确、可读的代码,再结合真实数据量做优化。
4. Map深度拆解:HashMap的哈希原理与顺序问题
Map家族里,HashMap是绝对的主角。你不仅要会用,还必须理解它到底是怎么把key映射到value的。
4.1 HashMap的存储结构:数组+链表+红黑树
Java 8及以后的HashMap,底层是一个Node<K,V>[] table数组,每个下标位置叫一个"桶"(bucket)。当你往map里放一个键值对时,流程大概是:
- 先用
key的hashCode()计算出哈希值,再经过一次扰动处理。 - 用哈希值和数组长度计算出一个下标,决定这个节点落在哪个桶。
- 如果这个桶是空的,直接放入。
- 如果这个桶已经有元素了,就会发生哈希冲突,也就是两个不同key算出的桶下标相同。此时节点会以链表形式挂在桶后面。
- 当链表长度达到8且数组长度达到64时,链表会转成红黑树,把查找时间从O(n)降到O(log n)。
- 如果链表长度小于6且红黑树节点太少,红黑树会退化成链表。
这里"8"和"64"是两个经常考的数字,背后其实有统计学和工程权衡。链表转红黑树并不是为了快而快,而是为了在极端哈希冲突下不至于性能崩溃。
4.2 扰动函数:为什么hashCode要再处理一次
HashMap并不是直接用key.hashCode()去计算下标,而是:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }就是把高16位和低16位做异或。目的很简单:因为数组长度比较短的时候,高位根本参与不进下标计算,容易哈希分布不均匀。扰动一下,让高位信息也混入低位,冲突的概率就降低了。这个设计在你写自己的类作为Map的key时特别有启发——你的hashCode如果写得差,HashMap怎么扰动都救不回来。
4.3 扩容机制:为什么默认负载因子是0.75
HashMap默认的初始容量是16,负载因子DEFAULT_LOAD_FACTOR = 0.75f。当元素个数超过容量 * 负载因子(也就是16 * 0.75 = 12)时,就会触发扩容,容量翻倍变为32。
为什么是0.75?这是一个"空间"和"时间"的权衡:
- 负载因子太大(比如1.0),数组快满了才扩容,空间利用高,但哈希冲突会明显增多,链表变长,查找和插入效率下降。
- 负载因子太小(比如0.5),冲突变少,但数组经常还有一半空着就扩容,内存浪费严重。
0.75是官方在随机哈希下大量测试后得出的一个接近泊松分布最优的值。作为使用方,除非你有非常明确的需求,一般不要改这个默认值。
扩容时有个重要细节:数组长度变了,每个元素的下标是重新计算的,而不是简单地把旧数组元素平移到新数组。Java 8里对链表进行了优化,根据(e.hash & oldCap)是否为0,把链表拆成"低位链表"和"高位链表",再整体放到新数组的对应位置,避免在并发下形成环(Java 7的问题),但要注意就算这样,HashMap也不是线程安全的。
4.4 重写equals为什么必须同时重写hashCode
这是我见过最多人踩的坑。在HashMap和HashSet里,判断两个key是否相同,顺序是:
- 先比较
hashCode()是否相等,如果不等,直接认为两个key不同。 - 如果相等,再调用
equals()判断内容是否一致。
如果你只重写了equals()而没重写hashCode(),就可能出现:两个业务上完全相同的对象,equals返回true,但hashCode不一样,结果它们在HashMap里被当成两个不同的key,存进去之后你用其中一个查另一个,永远查不到。反过来,如果两个不同对象的hashCode巧合相同,它们会落在同一个桶,通过equals进一步区分,所以equals仍然必要。
这个规则不是我编的,它是Java Object规范里的约定:两个对象equals相等,则hashCode必须相等。所以自己定义实体类要作为Map的key或放进Set时,请一定用IDE自动生成hashCode和equals,别偷懒手写。
4.5 HashMap的key到底有序吗
这个问题在面试里被问烂了:"HashMap的key有序吗?"答案很明确:HashMap不保证任何顺序。因为哈希散列本身是随机的,元素的遍历顺序和插入顺序可能完全不同,而且扩容后顺序还会变。
如果你需要"保持插入顺序",用LinkedHashMap,它内部通过额外的双向链表维护了插入顺序,所以遍历顺序和插入顺序一致。
如果你需要"按key排序",用TreeMap,底层是红黑树,默认按key的自然顺序(比如String的字典序、Integer的大小)排序,也可以传入自定义的Comparator。
同样地,热搜里还出现了"list转分组后"这种问题,其实就是Java 8的Collectors.groupingBy,由分组后的Map默认是HashMap,顺序不固定,但如果你用groupingBy(Function, LinkedHashMap::new, Collectors.toList())这种三参重载,就能控制返回有序的Map。
4.6 Map线程安全:Hashtable、ConcurrentHashMap怎么选
HashMap不是线程安全的,多线程同时put可能造成数据覆盖、扩容死循环(Java 7容易出问题,Java 8修复了环链但依然有覆盖问题)。所以并发场景下不能直接用HashMap。
可选方案有三个:
- Hashtable:老古董了,所有方法都加了
synchronized,简单粗暴但并发效率极低,相当于对整个哈希表串行访问,基本不推荐。 - Collections.synchronizedMap:包装一层同步锁,也是锁整个Map,性能提升有限。
- ConcurrentHashMap:正确选择。Java 8之后它采用了CAS + synchronized锁桶的方式,锁的粒度从"整表"细化到"单个桶",并发度大幅提升。读操作几乎不加锁,写操作只锁住当前桶,不同桶之间的读写可以并行。
4.7 Map转字符串和JSON
开发中经常要把Map转成JSON,比如接口返回。常见做法有用Fastjson、Jackson、Gson。搜“map转json字符串工具”,本质是用JSON序列化库把Map转成字符串。需要注意的一点是:如果Map的key是自定义对象,序列化时容易出问题,建议key直接用String、Integer这种基础类型。还有,HashMap的字段顺序在JSON里可能不固定,接口返回的字段顺序不稳定,如果给前端展示有顺序要求,用LinkedHashMap。
5. Set详解:无序不重复背后的真相
Set家族看起来简单,就三个字"不重复",但真正问起来还是有很多门道。
5.1 HashSet:底层就是HashMap
前面已经点破,HashSet底层是HashMap。我们看源码,HashSet里有一个private transient HashMap<E,Object> map;,添加元素时:
public boolean add(E e) { return map.put(e, PRESENT) == null; }那个PRESENT是一个被所有元素共享的new Object()占位值。判断重复的逻辑,完全依赖HashMap的key去重机制:先比hashCode,再比equals。所以向HashSet里放自定义对象时,也要遵守equals和hashCode的约定,否则去重会失效。
HashSet不保证顺序,遍历结果跟插入顺序无关,因为底层哈希表的桶位置由hashCode决定。
5.2 LinkedHashSet:能记住插入顺序的Set
LinkedHashSet继承自HashSet,底层是LinkedHashMap。它在保证不重复的基础上,额外用双向链表维护了插入顺序。所以遍历LinkedHashSet时,输出顺序和添加顺序一致。
适合的场景:需要去重、又希望保留原始顺序,比如把一些列表去重后按原顺序输出,用LinkedHashSet就很合适。
5.3 TreeSet:自动排序去重
TreeSet底层是TreeMap(TreeMap本身是红黑树),添加的元素会按照某种规则排序。默认按自然顺序,比如Integer从小到大,String按字典序;也可以传Comparator自定义排序规则。
注意,TreeSet判断元素是否重复不是靠hashCode和equals,而是靠Comparator.compare(a, b) == 0或者Comparable.compareTo()返回0。所以如果你自定义排序规则时,"排序相等"和"业务相等"可能不一致,这一点要特别小心,见过有人在这里翻了车。
5.4 Set的几种去重方案对比
| 实现类 | 底层结构 | 顺序 | 是否允许null | 去重依据 |
|---|---|---|---|---|
| HashSet | HashMap | 无序 | 允许一个null | hashCode + equals |
| LinkedHashSet | LinkedHashMap | 保持插入顺序 | 允许一个null | hashCode + equals |
| TreeSet | TreeMap(红黑树) | 按比较器排序 | 取决于比较器 | compare / compareTo |
5.5 实际开发中的Set使用建议
- 单纯去重:直接HashSet,最快最简单。
- 去重并保持顺序:LinkedHashSet。
- 需要有序去重(如排行榜、字典排序):TreeSet。
- 大集合去重时,优先用HashSet,不要去搞复杂的双重for循环,O(n^2)在几万条数据下就能卡死你。
6. 集合综合实战:遍历、排序、分组与常见坑
6.1 遍历时删除元素最容易踩的坑
很多人写代码时喜欢这样删除集合中的元素:
for (String s : list) { if (s.equals("xxx")) { list.remove(s); } }这样写十有八九会抛出ConcurrentModificationException(并发修改异常)。原因很简单:foreach循环实际上是基于迭代器iterator来遍历的,循环里每次next()前都会检查expectedModCount和集合的modCount是否一致。你直接调list.remove(),modCount变了,迭代器立刻发现版本不一致,直接抛异常。
正确做法有三种:
// 方式一:使用 Iterator 的 remove,会让迭代器同步 modCount Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("xxx")) { it.remove(); } } // 方式二:使用 removeIf(Java 8+ 推荐) list.removeIf(s -> s.equals("xxx")); // 方式三:转化成新集合过滤 List<String> result = list.stream().filter(s -> !s.equals("xxx")).collect(Collectors.toList());removeIf是日常里最推荐的方式,一行搞定,也不用关心迭代器同步问题。我自己就在生产代码里见过有人因为忘了这个坑,遍历时删除元素导致线上偶发崩溃,排查半天还怪框架不稳定。
6.2 List转数组、数组转List的正确姿势
数组转List,常有人直接用Arrays.asList():
String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr);但很多人不知道,这个方法返回的List其实是Arrays内部的私有内部类ArrayList,它定长,不支持add、remove操作,你往里面添元素会直接抛UnsupportedOperationException。正确的"可变列表"用法是:
List<String> list = new ArrayList<>(Arrays.asList(arr));List转数组比较简单:
String[] arr = list.toArray(new String[0]);Java 8以后传一个长度为0的数组就行,源码里最终会按list大小生成新数组,这比提前按list.size()创建数组要更安全(避免并发下大小变化)。
6.3 Stream流式操作让集合处理像流水线
Java 8的Stream API极大改变了集合操作方式。几个高频场景:
- 排序:
list.stream() .sorted(Comparator.comparing(User::getAge).reversed()) .collect(Collectors.toList());- 分组:
Map<String, List<User>> byCity = users.stream() .collect(Collectors.groupingBy(User::getCity));- 去重:
List<Integer> distinctList = list.stream().distinct().collect(Collectors.toList());- 转Map:
Map<Integer, String> idToName = users.stream() .collect(Collectors.toMap(User::getId, User::getName, (oldValue, newValue) -> newValue));第三个参数是为了解决key冲突,如果不传,当出现相同key时会抛IllegalStateException: Duplicate key。这个坑也很常见,分组后的数据转Map时经常因为没有处理重复key直接报错。
6.4 Collections工具类的几种实用操作
Collections.sort(list):对List排序,底层调的是List自己的sort方法。Collections.reverse(list):倒序。Collections.shuffle(list):随机打乱,洗牌。Collections.unmodifiableList(list):返回一个只读视图,一旦有人尝试修改就会抛异常。把内部集合暴露给外部调用方时,用这个保护数据安全。Collections.emptyList():返回一个不可变的空List,避免分配内存,也避免了"返回null"的坏习惯。
这里要特别强调一个编程习惯:接口返回集合时,尽量不要返回null,返回空集合更好。空集合能让调用方直接放心地for循环遍历,而不用写一堆null判空。
6.5 不可变集合:从Java 9开始更优雅的写法
Java 9开始,List.of()、Set.of()、Map.of()可以快速创建不可变集合。它们的特点是:
- 元素不可增删改。
- 不接受null元素。
- 非常适合定义常量列表或作为方法默认返回值。
List<String> list = List.of("a", "b", "c"); Set<Integer> set = Set.of(1, 2, 3); Map<String, Integer> map = Map.of("k1", 1, "k2", 2);注意,Map.of()最多支持10个键值对。超过10个要用Map.ofEntries()。这在面试里偶尔也会问到,记住就好。
6.6 线程安全的 List:CopyOnWriteArrayList 与 Collections.synchronizedList
如果需要在多线程环境下使用List,最简单的方案是:
List<String> list = Collections.synchronizedList(new ArrayList<>());它对每个方法都加了同步锁,简单但并发性能一般。另一个选择是java.util.concurrent.CopyOnWriteArrayList,它的核心思想是:写操作(增删改)时复制一份新的底层数组,在新数组上修改后替换原数组;读操作不加锁,直接读老数组。所以它特别适合"读多写少"的场景,比如缓存配置、白名单列表。但如果写频繁,每次写都复制数组,代价会非常大,用之前要斟酌一下。
7. 面试高频提问链路与易错点集中盘点
集合是Java面试八股文的重灾区,但也是最好拿分的地方,因为它有标准答案。我把最容易考、最容易说错的问题集中列一遍。
7.1 高频面试问题速查
- ArrayList和LinkedList的区别是什么?
- ArrayList扩容机制是什么?为什么是1.5倍?初始容量多少?
- HashMap的底层数据结构是什么样的?链表什么时候转红黑树?
- 为什么HashMap负载因子是0.75?
- 为什么重写equals必须重写hashCode?
- HashMap的key有序吗?什么时候用LinkedHashMap,什么时候用TreeMap?
- HashMap和Hashtable、ConcurrentHashMap的区别?
- HashMap是线程安全的吗?JDK 7和JDK 8中并发下有什么不同?
- HashSet怎么保证元素不重复?
- TreeSet和HashSet的区别?
- 集合遍历删除元素为什么会抛ConcurrentModificationException,如何解决?
- Arrays.asList()得到的List能增删吗?
- List和Set的区别?
我先给一套面试思路,具体细节你按上面看过的内容自己组织。核心是:任何实现类的原理题,都要从底层数据结构、时间复杂度和为什么要这样设计三个维度答。比如HashMap的题,先说是数组加链表加红黑树,再说哈希冲突的解决思路,最后提扰动函数、负载因子、扩容,对面就知道你真的理解。
7.2 我对几道"烧脑题"的理解
第一道:HashMap的key如果是自定义对象,需要注意什么?
明确答案:必须重写hashCode和equals。重写hashCode时要有足够的散列性,避免大量对象落到同一个桶。重写equals时,要保证和业务上一一对应。如果这个对象将来可能被修改,最好用不可变对象作为key。String和Integer之所以完美适合做key,就是因为它们不可变。
第二道:List和Set相互转换需要注意什么?
List转Set能去重,但会丢失原本的顺序,除非用LinkedHashSet。Set转List会丢失无序特性,而且如果Set里是TreeSet,转出来的List已经排好序了。实际操作中,我经常用new ArrayList<>(new LinkedHashSet<>(list))来对列表去重且保持顺序。
第三道:怎么遍历Map最高效?
Map的遍历方式有很多,但要注意别在for循环里重复map.get(key)。推荐用entrySet():
for (Map.Entry<String, Integer> entry : map.entrySet()) { String k = entry.getKey(); Integer v = entry.getValue(); }如果只想要key或value,分别用keySet()和values()。Java 8的forEach也可以:
map.forEach((k, v) -> System.out.println(k + "=" + v));7.3 易错点集中盘点
- null值处理:HashMap允许一个null key和多个null value;Hashtable和ConcurrentHashMap不允许null存在。TreeMap是否允许null key取决于Comparator。
Arrays.asList()返回的不是java.util.ArrayList,而是Arrays内部类,不能增删。- 自定义对象放入HashSet不做去重,先自查有无重写hashCode和equals。
- 用
==比较Integer超过127时可能为false,因为Integer缓存范围是-128到127。集合里的Integer对象比较,一定要用equals。 - 使用
Collections.unmodifiableList包装后的集合,修改的是原引用不是抛异常,注意"视图"概念。 subList()返回的是原List的一个视图,不是副本,对subList做修改会影响原List,这也算一个经典坑。
7.4 学习路径:零基础怎么啃下集合
如果你真的是零基础,我建议不要一上来就背源码,按这个顺序走:
- 先会用:把ArrayList、HashMap、HashSet的基本操作写熟,包括增删改查和遍历。
- 再看源码:打开IDEA,对着ArrayList、HashMap的源码逐行读,不懂的方法就查文档,不用记每一行,记住关键方法和核心逻辑。
- 再做实验:写个main方法,打日志观察扩容时机、链表长度变化、TreeSet排序效果。
- 最后刷题背题:在会谈的基础上,把面试题用自己的话复述出来,不要背标准答案,要背"为什么"。
我个人见过很多小伙伴上来就啃HashMap红黑树源码,结果一周后全忘光,原因就是没有在"会用"阶段积累基础。先把代码跑起来,再深入理解,远比你硬啃源码有效得多。
我在实际调优项目里见过一个非常典型的反面案例:某服务的接口每次请求都把上万个对象塞进一个HashSet去重,结果响应时间越来越慢。排查后才发现,那个对象只重写了equals没重写hashCode,导致去重完全失效,甚至因为哈希冲突严重,某些桶拉出了巨长的链表,查询退化成近乎遍历。最后把equals和hashCode重写,性能立刻恢复正常。这种问题,面试题里写得再清楚都不如自己在线上遇到一次来得深刻。
集合这个东西,学的时候觉得零散,但等你在实战里踩过几次坑、翻过几次源码,你会发现它们全部围绕两个核心问题:数据存在哪里,查找快不快。搞明白了底层存储结构和哈希设计思路,剩下的都是围绕这两个问题展开的工程权衡。你现在把这些点吃透,以后再遇到什么CopyOnWriteArrayList、BlockingQueue、ConcurrentSkipListMap,只要顺着这个思路去看,都能很快上手。