CurrentHashMap1.7中segement的扩容过程

JDK 1.7 ConcurrentHashMap 的 Segment 扩容:通俗详解

一、先打个比方:一排带锁的抽屉柜

想象你有一排 16 个带独立锁的抽屉柜(这就是 16 个 Segment):

┌─────────┬─────────┬─────────┬─────────┐
│ 柜子 0  │ 柜子 1  │ 柜子 2  │  ...   │
│  🔒    │  🔒    │  🔒    │         │
│ [][ ]  │ [][ ]  │ [][ ]  │         │  ← 每个柜子里有若干小格子(HashEntry 数组)
│ [][ ]  │ [][ ]  │ [][ ]  │         │
└─────────┴─────────┴─────────┴─────────┘

每个柜子独立上锁、独立管理。一个柜子满了,只换这个柜子,其他柜子该干嘛干嘛。


二、Segment 里面到底是什么?

每个 Segment 本质上就是一个缩小版的 HashMap

// 简化版结构
static final class Segment<K,V> extends ReentrantLock {
    transient volatile HashEntry<K,V>[] table;  // 小数组,存数据
    transient int count;                         // 这个 Segment 里有多少元素
    transient int threshold;                     // 扩容阈值 = 容量 × 负载因子
    final float loadFactor;                      // 负载因子(默认 0.75)
}

关键点:每个 Segment 有自己的 table 数组、自己的 count、自己的 threshold


三、什么时候触发扩容?

假设柜子 3 的配置是:

  • 小格子数量(容量):4 个
  • 负载因子:0.75
  • 扩容阈值:4 × 0.75 = 3
柜子 3 的内部:
┌─────┬─────┬─────┬─────┐
│ [A] │ [B] │null │ [C] │   ← 已经有 3 个元素了
└─────┴─────┴─────┴─────┘

这时候线程甲往柜子 3 里再塞一个元素 D:

count = 4 > threshold = 3  →  触发扩容!

注意:只有柜子 3 满了,柜子 0、1、2、4...15 完全没感觉。


四、扩容的具体过程(只锁这一个 Segment)

第一步:抢锁

// 线程甲要对 Segment[3] 扩容,先拿到这个 Segment 的独占锁
segment.lock();  // 其他想操作柜子 3 的线程阻塞等待

其他线程:

  • 想操作柜子 3 的 → 排队等锁
  • 想操作柜子 0、1、2、4...的 → 直接进去,完全不受影响

第二步:创建新数组(容量翻倍)

旧数组:4 个格子
新数组:8 个格子(2 倍)

第三步:Rehash + 迁移数据

这是最关键的一步。每个元素要重新算位置,因为数组长度变了,hash 取模的结果也变了。

旧数组(4格)                    新数组(8格)
┌─────┬─────┬─────┬─────┐      ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ [A] │ [B] │null │ [C] │  →   │ [A] │null │ [B] │null │null │null │ [C] │null │
└─────┴─────┴─────┴─────┘      └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

假设:
- A 的 hash % 4 = 0,hash % 8 = 0  → 还在 0 号位
- B 的 hash % 4 = 1,hash % 8 = 2  → 搬到 2 号位
- C 的 hash % 4 = 3,hash % 8 = 6  → 搬到 6 号位

迁移时是倒序摘链:

旧位置 0: A → D → null
         ↓
新位置 0: D → A → null   (头插法,顺序反转)

第四步:替换引用,释放锁

table = newTable;      // 把旧数组指向新数组
threshold = newThr;    // 更新阈值
segment.unlock();      // 释放锁,其他线程可以进来操作了

五、一张图看完全过程

扩容前:Segment[3] 满了,其他 Segment 正常运作
┌─────────┬─────────┬─────────────────┬─────────┐
│ 柜子 0  │ 柜子 1  │     柜子 3 🔒    │ 柜子 7  │
│ 正常读写 │ 正常读写 │   【正在扩容】   │ 正常读写 │
│         │         │  旧: [A][B][_][C]│         │
│         │         │  新: [A][_][B][_] │         │
│         │         │      [_][_][C][_] │         │
└─────────┴─────────┴─────────────────┴─────────┘
         ↑
    其他 Segment 的线程完全不知道发生了扩容,
    它们连锁都不用抢,直接读写自己的 Segment。

六、为什么 1.7 这样设计的好处?

设计 效果
Segment 独立扩容 不会"牵一发而动全身",只动一个柜子
只锁一个 Segment 其他 15 个 Segment 并发度不受影响
每个 Segment 自己的阈值 按需扩容,不会浪费内存
Rehash 范围小 只迁移一个 Segment 的数据,不是全表迁移

七、和 1.8 的对比(加深理解)

JDK 1.7 JDK 1.8
结构 Segment 数组 → 每个 Segment 里有 HashEntry 数组 直接一个大 Node 数组
锁整个 Segment(可能包含多个 bucket) 锁单个 bucket 的头节点
扩容 单个 Segment 独立扩容 整个数组一起扩容(全表迁移)
并发度 最多 16(默认 Segment 数) 理论上数组长度就是并发度

1.7 的"劣势":

  • Segment 数组大小一旦定死(默认 16),并发度就上限了。
  • 如果数据都集中在一个 Segment 里,那跟单线程 HashMap 差不多。

1.8 为什么改成全表扩容?

  • 因为 1.8 锁粒度细到了 bucket 级别,扩容时可以用 ForwardingNode 做渐进式迁移,配合 transferIndex 多线程协作,反而比 1.7 的 Segment 扩容更高效。

八、一句话总结

JDK 1.7 的 Segment 扩容就像:一排带锁的储物柜,某个柜子塞满了,管理员只打开那一个柜子的锁,把里面的东西搬到更大的新柜子里。隔壁柜子的人该存存、该取取,完全不受影响。每个柜子有自己的"满了吗"标准,满了就各自独立换大柜子。

posted @ 2026-07-20 14:46  ruo_feng  阅读(7)  评论(0)    收藏  举报
-->