刚带了个实习生,项目里写HashMap遍历删除元素,跑起来直接抛ConcurrentModificationException,他愣了半天没想明白。这场景我见得太多了,Java容器类看着是基础中的基础,但真要往深了问——ArrayList扩容到底是1.5倍还是两倍?HashMap为什么用红黑树不用普通链表?ConcurrentHashMap在JDK 8里是怎么干掉分段锁的?——很多写了三五年Java的人未必都能答得干净利落。
这篇文章我打算把Java容器类整个摊开揉碎讲一遍,从Collection和Map两大体系的宏观设计,一路拆到源码级别的实现细节,再配合实操选型和面试高频问题。不管你是刚学Java的学生,还是准备跳槽的开发者,或者工作中经常跟集合打交道的后端工程师,这篇文章都能帮你在脑海里建立一张完整的容器知识地图。
1. 容器类全景图:Collection与Map两大家族
1.1 两大接口体系的定位差异
Java容器类(也叫集合类)整体分成两大阵营:一个是Collection,一个是Map。很多人刚学的时候搞不清楚这两个东西到底有什么区别,其实用一句话就能说透:Collection是存单个元素的,Map是存键值对的。
这个差异看起来简单,但决定了后续所有使用场景的走向。Collection下面又分了List、Set、Queue三个子接口,它们各自解决不同的问题:List管有序可重复,Set管唯一去重,Queue管先进先出之类的排队逻辑。Map这边则不继承Collection,它自成体系,核心是“通过键去找值”,就像查字典——你报一个词条,我告诉你释义。
再往底层看一层,这些接口的实现类背后都是数据结构在撑腰。数组、链表、哈希表、红黑树、队列堆,Java容器基本把这些经典数据结构都封装了一遍。换句话说,把Java容器类学透了,等于把大学数据结构课本用工程化的方式重写了一遍,这也是为什么面试必考、笔试常考、工作中天天用的根本原因。
从JDK的类库设计来看,Collection和Map都位于java.util包下,但Map并不像很多人以为的那样实现了Collection接口。历史上JDK官方对这个设计有过解释,核心意思是两种抽象维度不同——一个是集合数学意义上的元素集合,一个是映射关系。千万别在面试时说“Map实现了Collection接口”,这句话一说出来基本就凉了。
1.2 List、Set、Queue的分工与底层预览
List:有序、可重复、有索引。List的实现类里,ArrayList是数组动态扩容,查询快、插入删除慢;LinkedList是双向链表,头尾操作快、随机访问慢;Vector是线程安全的ArrayList老前辈,但现在基本没人直接用。
Set:无序(大部分实现)、不可重复。HashSet底层是HashMap的key部分,LinkedHashSet在HashSet基础上加了双向链表维持插入顺序,TreeSet底层是红黑树,天然有序。
Queue:队列,核心操作是offer入队、poll出队、peek看一眼队头。LinkedList也实现了Queue接口,ArrayDeque是循环数组实现的双端队列,PriorityQueue是堆结构,出队按优先级来。
这三大类不是割裂的,它们之间有很强的联动关系:HashSet无非是包装了一个“value永远为固定对象的HashMap”,LinkedHashSet底层又复用LinkedHashMap,TreeSet底层就是TreeMap。理解了这层复用关系,你再看容器类的源码,会发现很多类根本不是从零写的,而是组合、委托、包装出来的。这也是Java容器类设计的精妙之处——用最小的核心代码撑起最丰富的功能面。
1.3 Map体系的结构与实现
Map是个顶层接口,下面常见实现有这么几个:
HashMap:哈希表+链表+红黑树,最常用的实现,乱序。LinkedHashMap:在HashMap基础上加双向链表,支持插入顺序或访问顺序遍历。TreeMap:红黑树实现,按键的自然顺序或自定义比较器排序。Hashtable:老古董,全方法加锁,性能差,基本被ConcurrentHashMap取代。ConcurrentHashMap:并发安全的HashMap,JDK 8后用CAS+synchronized锁桶实现。
很多人容易混淆Hashtable和HashMap的命名——前者中间的t是小写,因为它是小写t的专有名词,源自“hash table”两个词。这俩除了名字看起来像,内部设计思路完全不同,后面我会详细拆。
2. 核心实现原理拆解:从扩容到哈希冲突
2.1 ArrayList扩容机制:先算好账再动手
ArrayList底层就是个Object[]数组,构造时可以指定初始容量,如果不指定,默认是10。问题来了:数组是定长的,往里面塞元素塞满了怎么办?答案是扩容——新创建一个更大的数组,把旧数据用System.arraycopy拷过去。
JDK 8里扩容公式是:
int newCapacity = oldCapacity + (oldCapacity >> 1);也就是1.5倍扩容(oldCapacity + oldCapacity/2)。不是两倍,更不是固定加多少。为什么要取1.5这个数?如果扩容倍数太小(比如1.1倍),添加元素的均摊成本高,频繁触发复制;如果太大(比如两倍),内存浪费严重。1.5倍是一个在时间与空间之间平衡得比较好的经验值。
注意一下,oldCapacity >> 1表示右移一位,即除以2,所以oldCapacity + (oldCapacity >> 1)就是1.5倍。如果是从默认容量10开始涨,扩容序列是10 → 15 → 22 → 33 → 49……而不是规整的倍数增长。
实操中我会建议:如果你能估算出数据量,直接用new ArrayList<>(capacity)指定初始容量。比如从数据库查出10万条记录要封装成List,你明确知道会是10万条,那就直接给10万。这能省掉多次扩容的复制开销——虽然System.arraycopy是native方法很快,但大数据量下,多次扩容累积的开销依然不可忽略。
和ArrayList形成对比的是LinkedList。LinkedList底层是双向链表,每个节点是一个Node对象,存着数据、前驱引用、后继引用。它无所谓“扩容”,因为节点是随用随建。但代价是每个元素多存两个引用,内存占用比ArrayList高。这还没算上节点对象本身的头开销,在64位JVM上,一个Node对象可能占到24字节以上。所以如果有人跟你说“LinkedList插入快就用它”,你得反问一句:“你在哪个位置插入?头部?尾部?还是中间?”——中间插入照样要先遍历找位置,复杂度O(n)。
2.2 HashMap源码级拆解:哈希、冲突、树化
HashMap是Java容器里内容最丰富的一个类,也几乎是面试必考题。它底层是数组+链表+红黑树三合一的结构。数组的每一个格子叫bucket(桶),当两个键的哈希值落到同一个桶时,用链表把它们串起来。但如果同一个桶里的元素越来越多,链表查询复杂度退化成O(n),所以JDK 8做了一个重要优化——
当链表长度达到8,且数组长度达到64时,链表会转成红黑树,把查询效率从O(n)压到O(log n)。
为什么阈值偏偏是8?源码注释里有一段来自概率统计的解释:假设哈希函数均匀分布,一个桶里元素数量服从泊松分布,链表长度达到8的概率约为千万分之六。换句话说,正常场景下链表长度几乎不可能超过8,如果真超过了,说明哈希函数出现了严重的不均匀,或者有人恶意构造了哈希碰撞,这时候用红黑树兜底抵抗最坏情况。
红黑树不是Java发明的新东西,它本质上是一棵自平衡的二叉搜索树,通过颜色约束(根黑、叶黑、红节点的孩子必须黑、任意路径黑节点数相同)保证树高不超过2log(n+1)。正因为这个约束,查、插、删的复杂度都能维持在O(log n)级别。
HashMap默认初始容量16,加载因子0.75。0.75这个值的含义是:当元素个数到达容量*加载因子(16*0.75=12)时,触发扩容,数组加倍到32、64、128……容量始终保持2的幂。
为什么必须是2的幂?关键在设计上。HashMap定位桶的下标用的是(n - 1) & hash,而不是hash % n,因为位运算比取模快得多。但位运算是建立在容量为2的幂这个前提下的——n-1的二进制的低几位全是1,& hash等价于取hash值的低位,天然均匀,而且扩容时元素迁移特别方便:新位置要么在原位置,要么在原位置+旧容量处。这个特性让JDK 8的resize过程不需要重新计算每个元素的hash,只需看新增的那一位bit是0还是1,效率极高。
再说哈希本身的处理。JDK 8的hash()方法长这样:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }叫“扰动函数”,把高16位异或到低16位。为什么要这样做?因为计算桶下标用的是(n - 1) & hash,当n比较小时(比如16,n-1的低4位是1,高位全是0),hash只有低4位参与运算,高位的差异全被丢弃,非常容易碰撞。让高16位参与低16位的运算,等于把高位信息压缩到低位参与定址,能明显减少碰撞概率。这个细节面试的时候能主动讲出来,是加分项。
2.3 equals、hashCode与对象键的陷阱
HashMap的查找过程分两步:先用hashCode()定位到桶,再通过equals()在桶里的链表/红黑树上找精确匹配。所以有一个铁律:重写equals必须重写hashCode,且equals相等的两个对象hashCode必须相等。否则的话,两个逻辑上相等的对象可能被分到不同的桶,get永远找不到——这就像你把书放到了图书馆的A区,却在B区找它。
举个例子,如果不重写hashCode,两个“内容相同”的自定义对象各自继承Object.hashCode(),这个方法是基于对象内存地址生成的,所以两个对象的hashCode必然不同。哪怕它们equals是true也没用,因为HashMap根本不会拿它们去互相比较——它俩压根不在一个桶里。
所以在开发中用自定义对象做Map的key要格外小心。我见过最坑的案例:用一个可变对象做key,放进去之后又改了对象的内容,导致hashCode()结果变了,后来按原逻辑去找,怎么都找不到。更麻烦的是,那个“丢失”的键值对还在Map里,占据内存但永远无法访问。
最安全的做法:用String、Integer、Long这类不可变对象做key。它们hashCode稳定、equals实现正确、不可变,是HashMap最理想的伙伴。如果非要用自定义对象,要么保证对象不可变(所有字段final,不提供修改方法),要么给这个类设计一个稳定不变的hashCode逻辑。
3. 实战选型:数据结构和业务场景一一对应
3.1 从需求反推容器选择
容器选型不是靠感觉得来的,你得先弄清楚自己的业务到底有哪些访问模式。我总结了一下比较常见的需求场景和对应容器:
| 业务场景 | 推荐容器 | 理由 |
|---|---|---|
| 按下标随机访问多,几乎不做插入删除 | ArrayList | 底层数组,按下标访问O(1) |
| 频繁在头部/尾部插入删除元素 | ArrayDeque / LinkedList | 头尾操作O(1) |
| 按某个字段去重 | HashSet / LinkedHashSet | 哈希去重O(1),LinkedHashSet保序 |
| 需要按自然顺序或自定义规则遍历 | TreeSet / TreeMap | 红黑树天然有序,中序遍历输出升序 |
| 通过唯一键快速找对应对象(如用户ID查用户) | HashMap | 键值映射,哈希O(1)查值 |
| 要求遍历顺序和插入顺序一致 | LinkedHashMap | 双向链表维护插入序 |
| 需要“最近最少使用”淘汰的缓存 | LinkedHashMap(accessOrder=true) | 访问顺序+removeEldestEntry可实现LRU |
| 按优先级处理任务 | PriorityQueue | 堆结构,O(log n)出队最小/最大元素 |
这里要特别提醒一点:很多教科书上写“LinkedList适合频繁插入删除”,实际上现代工程里绝大多数场景用ArrayList反而更快。为什么?因为LinkedList每个节点分散在内存里,大量随机访问触发CPU缓存失效;而ArrayList内存连续,遍历时可以很好地利用CPU缓存预取。JDK开发团队也在官方文档里直言不讳地建议:“大多数场景ArrayList比LinkedList更合适,后者主要用于某些特定结构需求”。所以别迷信复杂度理论,工程上得看实测表现。
3.2 容量预估与性能优化
说到性能优化,容器这块最值得做的两件事:一是预估容量,二是避免无谓的自动装箱。
先讲容量。ArrayList的默认构造不会立刻创建一个容量10的数组——JDK 8里用的是懒加载,第一次add元素时才分配容量10的数组。同样,HashMap的构造方法里也不会立刻分配16个bucket,而是在第一次put时初始化。这个设计是为了省内存:如果你创建了一个空Map就扔在那不用,没必要占空间。
但如果你明确知道数据规模,提前给容量很重要。以HashMap为例,如果你要放100个元素,直接new HashMap<>()的话,它会在放第13个元素时触发第一次扩容(因为16*0.75=12),后续还得扩好几次。更推荐的做法是:写代码时按照“预计元素数/加载因子”来反推初始容量,然后赋值。比如你知道要放100个元素,那就用new HashMap<>(100 / 0.75f + 1),约等于134,HashMap会向上取2的幂到256,这样全程不需要扩容。
再讲自动装箱。HashMap<Integer, Integer>里如果put一堆int,Java会自动装箱成Integer对象。装箱不仅多出对象创建的开销,还意味着额外的内存。脏数据量一大的时候,这个开销是实打实的。Java没有原生的int键HashMap(除非用第三方库如fastutil),所以该装箱还是得装,但要心里有数——千万级数据量下,尽量少在容器里存包装类型做批量操作。
3.3 Stream与容器的高频组合
Java 8之后,Stream API几乎成了操作容器的主流写法。举几个高频场景:
把一个List转成Map:
Map<Long, User> idUserMap = userList.stream() .collect(Collectors.toMap(User::getId, Function.identity()));这段代码看着简洁,但有两个著名的坑。第一个坑:如果User::getId有重复值,会抛IllegalStateException: Duplicate key。第二个坑:如果value为null(Function.identity()返回null用户对象),Collectors.toMap默认不允许null value,会抛NPE。
解决方案是使用重载方法,提供合并函数:
Map<Long, User> idUserMap = userList.stream() .collect(Collectors.toMap(User::getId, Function.identity(), (oldVal, newVal) -> newVal));第三个参数(oldVal, newVal) -> newVal表示遇到重复key时保留后一个。如果你需要保留前一个,就(oldVal, newVal) -> oldVal。如果value可能是null但你又必须要null,那就只能换成Collectors.toMap的三参或四参版本,配合HashMap::new指定map工厂,同时value包装一下。
按某个字段分组也是高频操作:
Map<Integer, List<User>> usersByAge = userList.stream() .collect(Collectors.groupingBy(User::getAge));分组结果默认是HashMap,但groupingBy还支持传入下游收集器和Map工厂,比如你想得到有序Map:
Map<Integer, List<User>> usersByAge = userList.stream() .collect(Collectors.groupingBy(User::getAge, TreeMap::new, Collectors.toList()));Stream操作容器的好处是代码结构清晰,坏处是——如果能用普通for循环一趟搞定的事,没必要非套Stream。Stream本身也有对象创建和调用链开销,在超大数据量下不如传统写法快。写代码前先权衡:可读性高的同时也要关心性能,不是Stream万能。
4. 并发场景下的容器选择
4.1 为什么Vector已经“过时”了
早年间Vector是线程安全的List,它把所有方法都加上了synchronized,用起来确实不会并发出错。但问题也出在这里:每个方法调用都要抢同一把锁,方法与方法是独立的,组合操作却完全不安全。
举个例子,经典的“先检查再删除”:
if (!vector.isEmpty()) { vector.remove(0); }两个线程同时进来,一个判断非空,另一个同时删除,第一个线程的remove就可能抛异常。你可以说“那给这段代码自己也加锁嘛”,可一旦这么做了,Vector内部方法锁和外部代码锁是两码事,根本防不住并发。
还有一个更致命的问题:因为每个方法都要抢锁,Vector在高并发下的吞吐量极其难看。它存在了那么多年,更多是作为历史遗留被提到——现在如果你还在新项目里用Vector,基本可以被认定是“停留在JDK 1.1时代的老兵”。
替代方案也很明确:单线程或外部同步用ArrayList,读多写极少的并发场景用CopyOnWriteArrayList。
4.2 CopyOnWriteArrayList:读写分离的抄写员
CopyOnWriteArrayList的设计思路非常清奇——读操作完全不锁,写操作先复制一份新数组,在新数组上做修改,然后替换旧数组引用。
读操作读的是旧数组,写操作先复制再改再发布,读和写没有任何锁竞争,所以读性能极高,适合“遍历远多于修改”的场景。典型应用是事件监听器列表:一堆监听器注册在上面,每次发布事件就遍历它们,但注册/注销动作极少发生。
但它的缺点也摆在明面上:每次add都会完整复制底层数组,如果元素很多、写操作频繁,复制代价非常大。还有更隐性的一点——CopyOnWriteArrayList的迭代器是“弱一致性”的,迭代过程中如果别的线程做了修改,迭代器不会反映出来,也不会抛ConcurrentModificationException,因为它遍历的是迭代器创建时那一份快照。这在某些要求强一致性的业务里是致命的。
所以用CopyOnWriteArrayList之前先问自己两个问题:读多写少吗?写操作真的很低频吗?答案都是“是”才敢放心用。
4.3 ConcurrentHashMap:现代Java并发容器的顶梁柱
并发容器里最重要、也最值得研究的就是ConcurrentHashMap。
JDK 7时代的ConcurrentHashMap用的是分段锁:把整个Map分成默认16个Segment,每个Segment是一把独立的锁,不同线程操作不同Segment可以并行,整体并发度是16。但JDK 8之后彻底重写了实现,分段锁被丢弃,改为锁桶——每个桶(数组槽位)独立加锁,使用synchronized锁住链表/红黑树的头节点,配合CAS进行无锁插入。
JDK 8的put流程大致是:
- 计算hash,定位桶下标。
- 如果桶为空,用CAS把新节点放进去,无须加锁——这是无锁快速路径。
- 如果桶不空,
synchronized锁住桶头节点,然后往链表或红黑树里插入。 - 如果链表长度达到树化阈值,同样转红黑树。
用synchronized锁桶而不是用那种更复杂的锁机制,其实有个微妙的好处:synchronized在JDK 6以后引入了偏向锁、轻量级锁的优化路径,锁竞争很小时开销极低。而且锁粒度从Segment缩小到了bucket,两个线程操作不同桶时完全互不干扰,理论上并发度接近桶的数量。
ConcurrentHashMap还有几个面试高频考点:
它不允许null键和null值。为什么要禁?因为并发环境下无法分辨“值不存在”和“值为null”。HashMap可以get返回null表示没找到,但ConcurrentHashMap如果允许null,读线程会面临一个歧义:是没这个键,还是值本身就是null?为了避免这个混沌,作者直接不让put null。
它的size()不是精确值。因为高并发下很难拿到一个准确的size快照,JDK 8里size()返回的是一个估计值。如果你在做容量判断时依赖它,要注意这个特性。更骚的是mappingCount(),官方文档说它才是返回元素数量的推荐方法,虽然它也是估计值。
遍历时它也是弱一致性的,和CopyOnWriteArrayList类似的逻辑——迭代器不会抛并发修改异常,但不保证看到最新状态。如果业务要求“遍历期间必须看到所有已提交的元素”,ConcurrentHashMap并不合适,你得额外加锁或做快照。
4.4 Collections.synchronizedXXX:看似线程安全的陷阱
Collections.synchronizedMap(new HashMap<>())是很多人用来“给HashMap加锁”的方式。它确实让每个方法都加了synchronized,但这层同步和Vector有一样的毛病——复合操作为了安全必须自己再加锁。比如经典的:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>()); if (!map.containsKey("key")) { map.put("key", 1); }两个线程同时执行这段代码,完全可能发生一个线程put成功后,另一个线程拿之前的状态也执行put覆盖掉。要解决这个竞态,你必须在外部再加锁,把containsKey和put包成一个原子操作。可是既然都要外部加锁,那用不用synchronizedMap还有什么意义?
从性能上看,synchronizedMap的全方法锁粒度大、竞争激烈,吞吐量远不如ConcurrentHashMap。所以这个工具类现在的处境挺尴尬的,基本是个“看着安全、用着操心”的过渡方案。在面试时如果有人问你“synchronizedMap能用吗”,比较好的回答是“能用,但要么你的访问模式特别简单,要么你能在外部做好完整的加锁设计,否则不如换个更合适的并发容器”。
5. 高频面试题与避坑清单
5.1 面试官最爱问的容器类问题Top 10
结合我自己面试候选人和被面试的经验,整理了一份容器类高频考题,附带简要解析:
Q1:ArrayList和LinkedList的区别?
底层结构:数组 vs 双向链表。随机访问ArrayList O(1)、LinkedList O(n);头尾插入删除LinkedList O(1)、ArrayList尾部O(1)摊还但头部O(n)。实际工程中ArrayList用得多得多,因为内存连续性好,缓存利用率高,而且绝大多数场景是遍历和随机访问。
Q2:HashMap底层数据结构?
数组+链表,JDK 8后当链表长度≥8且数组长度≥64时转红黑树。查询平均O(1),最坏O(log n)。
Q3:HashMap扩容流程?
当size超过容量×加载因子时触发扩容,容量加倍,元素重新分布。JDK 8利用2的幂特性,元素在新数组中的位置要么原位置,要么“原位置+旧容量”,不需要重新计算每个hash。
Q4:为什么加载因子是0.75?
时间与空间的折中。太高(如1)空间利用率高但碰撞概率增大,太低的浪费内存。0.75是官方基于大量测试得出的相对均衡的值。
Q5:HashSet底层原理?
就是HashMap,value恒为一个固定Object对象。所以HashSet的元素就是HashMap的key,不重复、可null。
Q6:TreeSet和TreeMap有序性怎么来的?
底层红黑树,插入时按比较器(自然顺序或自定义Comparator)进行节点旋转与着色平衡,中序遍历得到升序序列。
Q7:HashMap和Hashtable的区别?
HashMap非线程安全、允许null键值、JDK 8引入红黑树优化;Hashtable线程安全(全方法锁)、不允许null、比较老。
Q8:如何用Map实现LRU缓存?
LinkedHashMap的accessOrder=true构造模式,配合重写removeEldestEntry,当元素数量超过阈值时自动删除最久未访问的条目。
Q9:什么情况下用CopyOnWriteArrayList?
读多写极少,并且能接受弱一致性。
Q10:ConcurrentHashMap为什么性能好?
JDK 8用CAS无锁插入空桶+锁桶头节点,锁粒度比全表锁细很多,减少竞争;volatile的读写让元素可见性有保障。
5.2 实务中的隐藏陷阱
以下这些坑我在实际开发和Code Review里都碰到过,整理成清单供你避开:
第一,遍历HashMap时删除元素。
用for-each循环遍历HashMap的同时调remove,会抛ConcurrentModificationException。正确姿势是用迭代器的remove()方法,或者用JDK 8的removeIf:
map.entrySet().removeIf(entry -> entry.getValue() < 0);第二,TreeSet元素必须可比较。
把对象丢进TreeSet前,要么对象实现了Comparable接口,要么在TreeSet构造时传入Comparator。否则运行时抛ClassCastException。注意,TreeSet判断“重复”依据的是compareTo返回0,而不是equals,两者不一致时容易翻车。
第三,自定义对象的去重问题。
HashSet去重依赖hashCode()和equals(),缺一不可。只重写equals不重写hashCode,会导致HashSet里出现两个equals相等但hashCode不同的对象,去重失败。这也是为什么IDE生成equals时总问你要不要一起生成hashCode。
第四,LinkedHashMap做LRU要开accessOrder。
默认构造是accessOrder=false,表示按插入顺序迭代;要变成访问顺序必须用new LinkedHashMap<>(16, 0.75f, true)。同时要重写removeEldestEntry方法,否则容量满了也不会自动淘汰。
第五,数组转List的坑。Arrays.asList()返回的是一个长度固定的List,底层还是那个数组,不是ArrayList。调用add或remove会抛UnsupportedOperationException。我见过太多人在这里栽跟头——把asList返回的List当普通List用,一调add就崩。要真想要一个灵活结构,可以用new ArrayList<>(Arrays.asList(...))。
第六,subList的视图陷阱。List.subList()返回的是原列表的视图,不是新列表。对subList做结构性修改会影响原列表,而且对原列表做结构性修改后再操作subList会抛ConcurrentModificationException。老实说这个API设计经常被吐槽,但既然在JDK里摆着,就按它的规矩来。
5.3 源码级追问:再深挖一层
这几题属于加分项,答得出来说明你真正读过源码、有自己的理解。
为什么HashMap的默认容量是16,不是10也不是20?
因为容量必须是2的幂,而16是2^4,是个漂亮的最小合理化值。太小了频繁扩容,太大了小Map浪费内存。16能在绝大多数场景下用最少的内存换来最多的map容量。
为什么JDK 8的resize里判断新位置是看(e.hash & oldCap) == 0?
因为容量oldCap是2的幂,扩容后新容量是oldCap的两倍,下标定位从(n-1)&hash变到(2n-1)&hash。新旧下标的差值恰好是oldCap。e.hash & oldCap这一位决定元素留在原位(0)还是迁移到原位置+oldCap(非0)。这段代码的逻辑让扩容过程极其高效,不需要重新计算hash值。
HashMap线程不安全具体指什么?
多线程同时put时,如果有两个线程同时触发了resize,旧版本(pre-JDK 8)可能出现链表环,导致get死循环。这个问题在JDK 8里通过引入红黑树和resize的优化基本不再出现,但并发下数据丢失、覆盖的竞态问题依然存在。所以多线程环境一次都别用HashMap,直接上ConcurrentHashMap。
fail-fast和fail-safe什么区别?fail-fast指的是迭代器在迭代过程中如果发现结构被修改(modCount变化),立即抛ConcurrentModificationException——ArrayList、HashMap的迭代器都是这个风格。fail-safe指的是迭代过程中“结构修改不抛异常,但迭代结果可能是旧快照”的风格,CopyOnWriteArrayList和ConcurrentHashMap的迭代器属于这一类。面试时这个表述干净利落地说出来,基本能镇住一半面试官。
5.4 我个人的容器类使用规范
最后分享一套我给自己定的书写规范,算不上标准答案,但至少踩坑少:
能用不可变集合就用不可变集合。Java 9之后有List.of、Map.of,构造完就不可变,既线程安全、还能防止代码里误改共享数据结构。以前我老看到有人把自己的ArrayList直接暴露出去给别人add,线上问题就是这么一层层闹出来的。
所有public方法返回集合前,先确认应该返回什么类型。如果调用方只读不写,返回Collections.unmodifiableList包装一下;如果调用方要顺序遍历,返回List而不是HashSet——HashSet的顺序不稳定,换一个JDK版本(或者不同哈希函数)顺序就会变,调用方如果按顺序处理数据,容易被坑。
做性能优化前先测量。不要在代码里“凭感觉”给ArrayList预分配超大容量、给HashMap设偏高初始容量。先跑一把真实数据量,看实际占用和耗时再调整。容器优化绝不是玄学,一切以JVM监控和profiler数据为准。
容器类是Java里最基础、最常用、也最值得深入研究的类库。它们不是孤立的知识点,而是和数据结构、并发、JVM内存模型都产生强关联的知识网络。把这套东西理解透,你写Java代码的稳定性和效率,都会明显上一个台阶。