Java 集合扩容规律梳理
目录
Java 集合扩容规律梳理
一、List 扩容规律
1. ArrayList
| 属性 | 值 | 说明 |
|---|---|---|
| 默认初始容量 | 10 | 无参构造时,首次添加元素才分配容量(懒初始化) |
| 扩容比例 | 1.5 倍 | newCapacity = oldCapacity + (oldCapacity >> 1) |
| 扩容阈值 | 无独立阈值 | 每次元素数量达到当前容量时触发扩容 |
扩容细节:
- 无参构造
new ArrayList():内部数组初始为空{},第一次add()时扩容到 10 - 带参构造
new ArrayList(int initialCapacity):直接分配指定大小 - 扩容计算:
int newCapacity = oldCapacity + (oldCapacity >> 1),即 旧容量 × 1.5 - 若 1.5 倍仍不够(如批量
addAll),则直接取min(1.5倍, 实际需要大小) - 新容量超过
MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8时,取Integer.MAX_VALUE
扩容序列示例(无参构造):
10 → 15 → 22 → 33 → 49 → 73 → 109 → 163 → 244 → 366 → ...
2. Vector
| 属性 | 值 | 说明 |
|---|---|---|
| 默认初始容量 | 10 | 无参构造时直接分配 |
| 扩容比例 | 2 倍(默认) | 可通过构造参数自定义扩容增量 |
| 扩容阈值 | 无独立阈值 | 每次元素数量达到当前容量时触发扩容 |
扩容细节:
- 无参构造
new Vector():容量为 10,capacityIncrement = 0 - 带参构造
new Vector(int initialCapacity):指定初始容量,capacityIncrement = 0 - 带参构造
new Vector(int initialCapacity, int capacityIncrement):同时指定增量 - 扩容逻辑:
- 若
capacityIncrement > 0:newCapacity = oldCapacity + capacityIncrement - 若
capacityIncrement <= 0(默认):newCapacity = 2 * oldCapacity(即 2 倍扩容)
- 若
- 同样有
MAX_ARRAY_SIZE上限检查
扩容序列示例(无参构造,capacityIncrement=0):
10 → 20 → 40 → 80 → 160 → 320 → 640 → 1280 → ...
3. ArrayList vs Vector 对比
| 对比项 | ArrayList | Vector |
|---|---|---|
| 默认初始容量 | 10(懒初始化) | 10(立即分配) |
| 扩容比例 | 1.5 倍 | 2 倍(默认) |
| 扩容增量可配置 | ❌ 不可 | ✅ 可通过 capacityIncrement 配置 |
| 线程安全 | ❌ 不安全 | ✅ 安全(方法加 synchronized) |
| 性能 | 更优(扩容更保守,减少浪费) | 略差(锁开销 + 扩容更激进) |
二、Map 扩容规律
1. HashMap
| 属性 | 值 | 说明 |
|---|---|---|
| 默认初始容量 | 16 | 必须为 2 的幂 |
| 负载因子(loadFactor) | 0.75 | 容量与负载因子的乘积为扩容阈值 |
| 扩容阈值(threshold) | 12 | threshold = capacity × loadFactor = 16 × 0.75 = 12 |
| 扩容比例 | 2 倍 | 新容量 = 旧容量 × 2 |
| 树化阈值 | 8 | 单个桶链表长度 ≥ 8 时可能转为红黑树 |
| 树退化阈值 | 6 | 红黑树节点 ≤ 6 时退化为链表 |
| 最小树化容量 | 64 | 桶总数 < 64 时优先扩容而非树化 |
扩容触发条件:
size > threshold(即 size > capacity × loadFactor)
扩容细节:
- 无参构造
new HashMap():初始容量 16,负载因子 0.75,threshold = 12 - 带参构造
new HashMap(int initialCapacity):自动计算最近的 2 的幂作为实际容量- 例:传入 10 → 实际容量 16;传入 17 → 实际容量 32
- 带参构造
new HashMap(int initialCapacity, float loadFactor):自定义负载因子 - 容量始终为 2 的幂,通过
tableSizeFor()方法保证:static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } - 扩容时:
newCapacity = oldCapacity << 1(即 2 倍),newThreshold = newCapacity × loadFactor - 最大容量:
MAXIMUM_CAPACITY = 1 << 30(2^30)
扩容序列示例(默认 loadFactor=0.75):
| 容量(capacity) | 扩容阈值(threshold) | 触发扩容时的 size |
|---|---|---|
| 16 | 12 | size > 12 |
| 32 | 24 | size > 24 |
| 64 | 48 | size > 48 |
| 128 | 96 | size > 96 |
| 256 | 192 | size > 192 |
为什么容量必须是 2 的幂?
- 取模优化:
hash % capacity可用hash & (capacity - 1)替代,位运算远快于取模 - 均匀分布:2 的幂 - 1 的二进制全是 1(如 15 = 0b1111),保证 hash 低位全部参与映射,减少碰撞
2. LinkedHashMap
| 属性 | 值 | 说明 |
|---|---|---|
| 默认初始容量 | 16 | 与 HashMap 完全一致 |
| 负载因子 | 0.75 | 与 HashMap 完全一致 |
| 扩容阈值 | 12 | 与 HashMap 完全一致 |
| 扩容比例 | 2 倍 | 与 HashMap 完全一致 |
核心结论:LinkedHashMap 的扩容规则与 HashMap 完全一致
- LinkedHashMap 继承自 HashMap,扩容逻辑完全复用父类
- 额外维护的双链表(head/tail)不影响扩容判定
- 扩容时,双链表节点顺序不变,仅桶数组重新分配
- 唯一区别:遍历顺序由双链表决定(访问顺序或插入顺序),与扩容无关
3. Hashtable
| 属性 | 值 | 说明 |
|---|---|---|
| 默认初始容量 | 11 | 不要求为 2 的幂 |
| 负载因子(loadFactor) | 0.75 | 默认值 |
| 扩容阈值 | 8 | threshold = 11 × 0.75 = 8.25 → 取整 8 |
| 扩容比例 | 2 倍 + 1 | newCapacity = oldCapacity × 2 + 1 |
扩容触发条件:
size >= threshold(注意:是 >=,比 HashMap 的 > 更早触发)
扩容细节:
- 无参构造
new Hashtable():初始容量 11,负载因子 0.75 - 带参构造
new Hashtable(int initialCapacity):指定初始容量,负载因子 0.75 - 带参构造
new Hashtable(int initialCapacity, float loadFactor):自定义负载因子 - 扩容计算:
int newCapacity = (oldCapacity << 1) + 1(即 2倍+1) - 容量不要求为 2 的幂,取模使用传统的
hash % capacity - 最大容量:
Integer.MAX_VALUE - 8
扩容序列示例(默认 loadFactor=0.75):
| 容量(capacity) | 扩容阈值(threshold) | 触发扩容时的 size |
|---|---|---|
| 11 | 8 | size ≥ 8 |
| 23 | 17 | size ≥ 17 |
| 47 | 35 | size ≥ 35 |
| 95 | 71 | size ≥ 71 |
| 191 | 143 | size ≥ 143 |
4. HashMap vs LinkedHashMap vs Hashtable 对比
| 对比项 | HashMap | LinkedHashMap | Hashtable |
|---|---|---|---|
| 默认初始容量 | 16 | 16 | 11 |
| 容量要求 | 必须为 2 的幂 | 必须为 2 的幂 | 无要求 |
| 负载因子 | 0.75 | 0.75 | 0.75 |
| 默认扩容阈值 | 12 | 12 | 8 |
| 扩容比例 | 2 倍 | 2 倍 | 2 倍 + 1 |
| 扩容触发 | size > threshold | size > threshold | size ≥ threshold |
| 线程安全 | ❌ | ❌ | ✅(全方法 synchronized) |
| 取模方式 | 位运算 hash & (n-1) |
位运算 | 取模 hash % n |
| 额外结构 | 无 | 双链表(维护顺序) | 无 |
| null 键/值 | ✅ 允许 | ✅ 允许 | ❌ 不允许 |
三、总结速查表
List 速查
| 雏合 | 默认初始容量 | 扩容比例 | 为什么选这个比例 | 这个比例的弊端 |
|---|---|---|---|---|
| ArrayList | 10 | 1.5 倍 | ① 空间与时间的折中:1.5 倍相比 2 倍浪费更少(最大浪费约 33% vs 50%),相比更小倍数又不会频繁扩容;② 内存复用:1.5 倍扩容后,旧数组释放的内存空间足以容纳新数组中增长的部分(GC 友好,减少内存碎片);③ 均摊 O(1):几何级数增长保证 n 次 append 的均摊时间复杂度 O(1);④ 整数运算友好:old + (old >> 1) 纯整数位运算,无浮点误差 |
① 增长速度偏慢,极端大量 add 场景下扩容次数多于 2 倍策略;② 1.5 倍无法形成 2 的幂序列,对后续内存分配对齐无帮助 |
| Vector | 10 | 2 倍(可配置增量) | ① 减少扩容次数:2 倍增长更激进,极端场景下扩容次数比 1.5 倍少约 30%;② 简单直觉:2 倍是最直觉的翻倍策略,代码实现简洁 2 * oldCapacity;③ 历史兼容:Vector 是 JDK 1.0 遗留类,当时设计追求简单可靠而非精细化 |
① 空间浪费严重:每次扩容后约 50% 为空闲,内存利用率低;② GC 压力大:2 倍扩容时旧数组大小与新数组增量差距大,旧内存难以被新数组复用,容易产生内存碎片;③ 固定增量模式(capacityIncrement > 0)若配置不当,可能线性增长导致频繁扩容 |
Map 速查
| 雏合 | 默认初始容量 | 负载因子 | 扩容阈值 | 扩容比例 | 触发条件 | 为什么选这个比例 | 这个比例的弊端 |
|---|---|---|---|---|---|---|---|
| HashMap | 16 | 0.75 | 12 | 2 倍 | size > threshold | ① 桶迁移优化:2 倍扩容时,新容量 = 旧容量 × 2,每个元素的旧索引 hash & (oldCap-1) 只需看新增的高位 bit 是 0 还是 1,0 则留在原桶,1 则移到 原索引+oldCap,无需重新计算 hash % newCap;② 保持 2 的幂:2 倍 × 2 的幂 = 仍是 2 的幂,保证 hash & (n-1) 位运算取模始终有效;③ lo/hi 双链拆分:JDK 8 将每个桶拆成低位链(原地)和高位链(偏移),避免逐元素重算索引 |
① 对 hash 质量差的 key(低位相同),2 的幂容量加剧桶聚集;② 2 倍扩容瞬时内存翻倍,短时间同时持有旧数组+新数组,内存峰值高 |
| LinkedHashMap | 16 | 0.75 | 12 | 2 倍 | size > threshold | 完全继承 HashMap 的 2 倍策略,原因同上;额外优势:扩容时双链表顺序天然保持不变(Entry 对象不变,仅桶位置迁移),遍历顺序不受扩容影响 | 同 HashMap;额外:Entry 多两个指针(before/after),2 倍扩容瞬时内存翻倍的压力更大 |
| Hashtable | 11 | 0.75 | 8 | 2 倍+1 | size ≥ threshold | ① 保持奇数/质数:2 倍+1 确保扩容后容量仍为奇数(11→23→47→95→191…),奇数作为取模基数能更均匀地分散 hash 值,避免偶数容量与常见 hash 模式产生周期性聚集;② 兼容遗留设计:Hashtable 是 JDK 1.0 的类,当时取模用 hash % n,2 的幂没有优势,选择奇数序列是传统 hash 表的经典做法 |
① 无法用 hash & (n-1) 位运算替代取模,性能劣于 HashMap;② 扩容时每个元素必须重新 hash % newCap 计算新索引,无高位拆分优化,迁移更慢;③ 2 倍+1 不保证质数(如 95 非质数),分散效果不如真正的质数容量 |
负载因子 0.75 的选择原因
- 过低(如 0.5):空间浪费严重,约一半桶永远为空
- 过高(如 1.0):碰撞频繁,链表/红黑树过长,查找退化为 O(n)/O(log n)
- 0.75:在数学上满足泊松分布下单个桶链长 ≥ 8 的概率 < 0.00000006(百万分之一),是空间与时间的黄金折中点
- 该值在 JDK 源码注释中明确标注为经验统计最优值
为什么 HashMap 用 2 倍?
1. 位运算替代取模运算
- HashMap 计算元素位置时,需要 hash % capacity,但取模运算很慢。当容量是 2 的幂时,可以用位运算优化:
// ❌ 慢:取模运算
index = hash % capacity;
// ✅ 快:位运算(仅当 capacity 是 2 的幂时成立)
index = hash & (capacity - 1);
// 示例:capacity = 16 (二进制 10000)
// hash = 25 (二进制 11001)
//
// 取模:25 % 16 = 9
// 位运算:25 & 15 = 11001 & 01111 = 01001 = 9 ✓
// 为什么capacity为2的倍数时:hash % capacity = hash & (capacity - 1)
// 十进制类比
1234 % 1000 = 234 // 保留后 3 位
1234 % 100 = 34 // 保留后 2 位
1234 % 10 = 4 // 保留后 1 位
// 二进制同理
hash % 16 = hash % 2^4 = 保留低 4 位
hash % 32 = hash % 2^5 = 保留低 5 位
hash % 64 = hash % 2^6 = 保留低 6 位
// 位运算
hash & (16 - 1) = hash & 15
= hash & 1111
= 保留低 4 位,高位清零
= hash % 16
-
性能对比:
- 取模运算:~10-20 CPU 周期
- 位运算:1 CPU 周期
- 提升 10-20 倍!
-
注意:hashmap里面容量指的是桶的数量,size才是实际的值的数量。
- 理论上,如果不扩容,初始时的16 个桶能装无限多的元素(链表无限长)。但这会让 HashMap 退化成LinkedList,失去 O(1) 查找的优势,所以 JVM 会通过扩容机制避免这种情况。
-
hashmap里面的主要运用
- 计算桶索引(最核心的位运算)
- 扩容时分组判断:hashmap扩容时重新分布元素
hashmap扩容时重新分布元素详细解释
// 源码:HashMap的resize方法里面
// 假设旧容量 = 16 = 0b10000
// 新容量 = 32 = 0b100000
// 扩容前索引计算:index = hash & 15 (0b01111)
// 扩容后索引计算:index = hash & 31 (0b11111)
// 区别在于第 5 位(bit 4):
// - 如果 hash 的第 5 位是 0 → 索引不变
// - 如果 hash 的第 5 位是 1 → 索引 = 原索引 + 16
// e.hash & oldCap 正好检测这一位:
// oldCap = 16 = 0b10000
// 如果结果为 0 → 第 5 位是 0 → 低位组
// 如果结果不为 0 → 第 5 位是 1 → 高位组
// 实例
// 节点1:hash = 25 = 0b011001
// 节点2:hash = 41 = 0b101001
// 旧容量 16,扩容到 32
// 节点1:
// 25 & 16 = 0b011001 & 0b10000 = 0 → 低位组
// 旧索引:25 & 15 = 9
// 新索引:25 & 31 = 9 (不变)
// 节点2:
// 41 & 16 = 0b101001 & 0b10000 = 16 ≠ 0 → 高位组
// 旧索引:41 & 15 = 9
// 新索引:41 & 31 = 25 (9 + 16 = 25)
// 优势
// 无需重新计算 hash:只需检测一位
// 保持顺序:链表节点相对顺序不变
// 均匀分布:元素分散到原位置和原位置+旧容量两处
//这是 JDK 8 对 HashMap 扩容的重大优化,避免了重新哈希所有元素。
重新哈希是hash&新容量-1,你这个也是hash&旧容量。不都需要一次与操作吗?有什么区别
- 虽然都是与操作,但本质不同:
- JDK 7:hash & (newCap-1) 计算完整索引,逐个移动
- JDK 8:hash & oldCap 只判断 1 位,分组后批量移动
- 优势不在于减少与操作次数,而在于:
- 减少内存写入(2 次 vs N 次)
- 保持链表顺序(避免反转)
- 线程安全(避免死循环)
这是典型的用数学洞察换性能的优化案例。
// 从表面上看都是位运算,但关键区别在于是否需要重新计算每个元素的新位置并移动。
// 假设一个桶中有 100 个节点
// JDK 7:
// - 100 次 hash & 31 计算
// - 100 次 newTable[index] 写入
// - 100 次指针调整
// JDK 8:
// - 100 次 hash & 16 判断(同样 100 次与操作)
// - 但只有 2 次 newTable[index] 写入(loHead 和 hiHead)
// - 链表内部指针在遍历时已维护好
// 实测提升:约 20-30%(主要来自减少内存写入)
// JDK 7:逐个迁移
// 旧数组 [5]: node1 → node2 → node3
// 步骤1: 计算 node1 新索引 = 5
// newTable[5] = node1
// 步骤2: 计算 node2 新索引 = 21
// newTable[21] = node2
// 步骤3: 计算 node3 新索引 = 5
// newTable[5] = node3 → node1 (反转了!)
// 结果:
// newTable[5]: node3 → node1
// newTable[21]: node2
// JDK 8:分组迁移
// 旧数组 [5]: node1 → node2 → node3
// 遍历判断:
// node1: hash & 16 == 0 → 低位组 loHead
// node2: hash & 16 != 0 → 高位组 hiHead
// node3: hash & 16 == 0 → 低位组 loTail
// 一次性移动:
// newTable[5]: node1 → node3 (loHead,保持原序)
// newTable[21]: node2 (hiHead)
// 结果:顺序不变!
为什么 ArrayList 用 1.5 倍?
// ArrayList 不需要 2 的幂
// 它使用简单的数组索引:array[index]
// 不需要 hash & (capacity - 1) 这种优化
// 所以可以选择更保守的 1.5 倍
// 优点:内存利用率更高
// 缺点:扩容更频繁
为什么 HashMap 不用 1.5 倍?
// 如果用 1.5 倍,就无法使用位运算优化
// 必须用取模运算,性能下降 10-20 倍
// 这个代价太大了!
浙公网安备 33010602011771号