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 > 0newCapacity = 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 的幂?

  1. 取模优化hash % capacity 可用 hash & (capacity - 1) 替代,位运算远快于取模
  2. 均匀分布: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 倍
// 这个代价太大了!
posted @ 2026-06-16 17:07  deyang  阅读(11)  评论(0)    收藏  举报