Java HashMap 与 ConcurrentHashMap 核心原理总结:从 Hash 冲突到 LongAdder

一、Hash 冲突是什么?

Hash 表的核心思想是:通过 hash 算法,把一个 key 映射到数组中的某个位置。

例如:

复制代码
int index = hash(key) % table.length;

但是不同的 key 经过 hash 计算之后,可能得到相同的数组下标。

这种情况就叫:

复制代码
Hash 冲突

比如:

复制代码
key1 -> index 3
key2 -> index 3

这两个不同的 key 都想放到数组下标 3 的位置,这就发生了 Hash 冲突。

二、Hash 冲突的解决方案

常见的 Hash 冲突解决方案有几种。

1. 链地址法

链地址法也叫拉链法。

数组的每个位置不只存一个元素,而是存一个链表。

复制代码
数组下标 0 -> null
数组下标 1 -> A -> B -> C
数组下标 2 -> null
数组下标 3 -> D

如果多个 key 都落到同一个桶里,就把它们挂成链表。

Java 的 HashMap 主要采用的就是这种方式。

2. 开放地址法

开放地址法的思路是:

复制代码
当前位置被占了,就继续找下一个空位置。

常见方式有:

复制代码
线性探测:index + 1, index + 2 ...
二次探测:index + 1², index + 2² ...
双重 hash:使用另一个 hash 函数重新定位

3. 再哈希

如果冲突比较严重,可以使用新的 hash 函数重新计算位置。

4. 扩容

当 HashMap 中元素越来越多时,数组容量不够,就会扩容。

比如:

复制代码
16 -> 32 -> 64 -> 128

扩容后,元素会重新分布,Hash 冲突可能会减少。

5. 链表转红黑树

JDK 1.8 之后,HashMap 在链表过长时,会把链表转成红黑树。

链表查询效率是:

复制代码
O(n)

红黑树查询效率是:

复制代码
O(log n)

所以当冲突严重时,红黑树可以提高查询效率。

三、HashMap 的扩容机制

HashMap 扩容主要由负载因子决定。

默认负载因子是:

复制代码
0.75

扩容判断公式是:

复制代码
元素数量 > 数组容量 * 负载因子

比如默认数组容量是 16:

复制代码
16 * 0.75 = 12

当元素数量超过 12 时,就会扩容:

复制代码
16 -> 32

所以负载因子的作用就是:

复制代码
决定 HashMap 什么时候扩容。

四、为什么 HashMap 默认负载因子是 0.75?

负载因子太大:

复制代码
数组利用率高,但是 Hash 冲突变多,查询变慢。

负载因子太小:

复制代码
Hash 冲突少,查询快,但是浪费空间。

所以 0.75 是一个折中值:

复制代码
既保证空间利用率,又尽量减少 Hash 冲突。

五、HashMap 为什么链表长度到 8 才转红黑树?

JDK 1.8 之后,HashMap 有几个重要阈值:

复制代码
TREEIFY_THRESHOLD = 8;
UNTREEIFY_THRESHOLD = 6;
MIN_TREEIFY_CAPACITY = 64;

意思是:

复制代码
链表长度 >= 8,并且数组长度 >= 64,才会转红黑树。

为什么是 8?

因为正常情况下,hash 分布应该是比较均匀的。

如果一个桶里的链表长度都达到 8 了,说明这个桶的 Hash 冲突已经比较严重了。

继续用链表,查询效率会变差。

所以达到一定长度后,就考虑转红黑树。

六、为什么数组长度小于 64 时不转红黑树?

这是一个很重要的面试点。

如果数组长度还很小,比如:

复制代码
16 或 32

这时候发生链表过长,不一定是 hash 算法太差,也可能只是因为数组太小,桶太少,元素挤在一起了。

所以这时候更好的方案不是转红黑树,而是:

复制代码
优先扩容。

扩容后,元素会重新分布,原来一个桶里的链表可能会被拆散。

所以规则是:

复制代码
数组长度 < 64:
优先扩容

数组长度 >= 64:
链表还很长,才转红黑树

注意:

复制代码
64 不是扩容条件,而是是否允许链表转红黑树的条件。

HashMap 正常扩容还是看:

复制代码
元素数量 > 数组容量 * 负载因子

七、红黑树什么时候退回链表?

还有一个阈值:

复制代码
UNTREEIFY_THRESHOLD = 6;

意思是:

复制代码
当红黑树中的节点数量减少到 6 左右时,可能会退回链表。

为什么不是 8?

这是为了避免频繁转换。

如果 8 转树,8 又退回链表,那么节点数量在 7、8 之间变化时,就会频繁地链表和红黑树互相转换。

所以设计成:

复制代码
8:链表转红黑树
6:红黑树退回链表

中间留了一个缓冲区。

八、ConcurrentHashMap 是干什么的?

普通的 HashMap 是线程不安全的。

在多线程环境下,如果多个线程同时 put、remove,可能会导致数据异常。

所以 Java 提供了线程安全的 Map:

复制代码
ConcurrentHashMap

它的作用是:

复制代码
在多线程环境下安全地操作 Map,同时尽量保证较高性能。

九、JDK 1.7 的 ConcurrentHashMap 设计

JDK 1.7 的 ConcurrentHashMap 使用的是:

复制代码
Segment 分段锁

整体结构大概是:

复制代码
ConcurrentHashMap
 ├── Segment 0
 ├── Segment 1
 ├── Segment 2
 ...
 └── Segment 15

每个 Segment 都类似一个小型的 HashMap。

结构可以理解成:

复制代码
Segment = HashEntry 数组 + 链表 + ReentrantLock

每个 Segment 自己有一把锁。

写操作时:

复制代码
1. 根据 key 的 hash 定位 Segment
2. 锁住这个 Segment
3. 在这个 Segment 里面进行 put 操作

好处是:

复制代码
不同 Segment 可以并发操作。

比如:

复制代码
线程 A 操作 Segment 1
线程 B 操作 Segment 7

它们可以同时执行。

但是问题是:

复制代码
如果两个线程操作的是同一个 Segment,即使它们操作的不是同一个桶,也要竞争同一把锁。

所以 JDK 1.7 的锁粒度还是比较大。

十、ReentrantLock 是什么?

ReentrantLock 是 Java 并发包里的可重入锁。

包路径是:

复制代码
java.util.concurrent.locks.ReentrantLock

它的作用是:

复制代码
保证同一时间只有一个线程进入某段代码。

常见写法:

复制代码
ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // 需要线程安全的代码
} finally {
    lock.unlock();
}

为什么一定要写 finally?

因为 ReentrantLock 需要手动释放锁。

如果忘记 unlock(),其他线程可能永远拿不到锁。

可重入是什么意思?

可重入的意思是:

复制代码
同一个线程已经拿到这把锁后,可以再次拿这把锁,不会把自己卡死。

比如:

复制代码
public void methodA() {
    lock.lock();
    try {
        methodB();
    } finally {
        lock.unlock();
    }
}

public void methodB() {
    lock.lock();
    try {
        System.out.println("methodB");
    } finally {
        lock.unlock();
    }
}

同一个线程进入 methodA() 后,又进入 methodB(),可以再次获得同一把锁。

十一、synchronized 是什么锁?

synchronized 是 Java 自带的内置锁,也叫:

复制代码
对象锁
监视器锁
互斥锁
可重入锁

它可以修饰方法,也可以修饰代码块。

普通同步方法:

复制代码
public synchronized void test() {
    
}

锁的是:

复制代码
当前对象 this

静态同步方法:

复制代码
public static synchronized void test() {
    
}

锁的是:

复制代码
当前类的 Class 对象

同步代码块:

复制代码
synchronized (obj) {
    
}

锁的是:

复制代码
括号里的 obj 对象

synchronized 也是可重入锁。

十二、synchronized 和 ReentrantLock 的区别

它们都可以保证线程安全,也都是可重入锁。

核心区别是:

复制代码
synchronized 简单,自动释放锁;
ReentrantLock 灵活,但是需要手动释放锁。
对比 synchronized ReentrantLock
类型 Java 关键字 JUC 包下的类
释放锁 自动释放 手动 unlock
是否可重入 是 是
公平锁 不支持手动设置 支持
尝试加锁 不支持 支持 tryLock
可中断等待 不灵活 支持 lockInterruptibly
条件队列 一个等待队列 多个 Condition

简单理解:

复制代码
synchronized 是自动挡;
ReentrantLock 是手动挡。

普通场景用 synchronized 就够了。

复杂并发控制,比如需要公平锁、尝试加锁、可中断锁、多个条件队列时,可以用 ReentrantLock。

十三、公平锁和非公平锁的区别

公平锁:

复制代码
谁先等待,谁先获得锁。

就像排队买东西,先来先服务。

非公平锁:

复制代码
允许线程插队,谁抢到算谁的。

ReentrantLock 默认是非公平锁:

复制代码
ReentrantLock lock = new ReentrantLock();

等价于:

复制代码
ReentrantLock lock = new ReentrantLock(false);

公平锁写法:

复制代码
ReentrantLock lock = new ReentrantLock(true);

公平锁优点:

复制代码
线程不容易饥饿。

公平锁缺点:

复制代码
性能相对低,因为要维护排队顺序。

非公平锁优点:

复制代码
性能更高。

非公平锁缺点:

复制代码
可能导致某些线程等待很久。

一句话总结:

复制代码
公平锁讲顺序,非公平锁讲效率。

十四、JDK 1.8 的 ConcurrentHashMap 设计

JDK 1.8 对 ConcurrentHashMap 做了很大改动。

JDK 1.7 使用的是:

复制代码
Segment 分段锁

JDK 1.8 改成了:

复制代码
CAS + synchronized + Node 数组 + 链表 + 红黑树

底层结构大概是:

复制代码
ConcurrentHashMap
 └── Node[] table
       ├── 桶 0 -> 链表 / 红黑树
       ├── 桶 1 -> null
       ├── 桶 2 -> 链表 / 红黑树
       └── ...

也就是说,JDK 1.8 不再主要使用 Segment,而是直接对数组桶进行并发控制。

核心思想是:

复制代码
能不加锁就不加锁;
必须加锁时,只锁当前桶。

十五、JDK 1.8 的 put 流程

执行:

复制代码
map.put("张三", 18);

大概流程是:

复制代码
1. 根据 key 计算 hash
2. 根据 hash 找到数组下标
3. 如果桶为空,用 CAS 插入
4. 如果桶不为空,用 synchronized 锁住桶头节点
5. 如果链表过长,转红黑树
6. 如果元素过多,触发扩容

1. 桶为空:使用 CAS

如果当前位置是空的:

复制代码
table[i] == null

说明这个桶没有元素。

这时候 JDK 1.8 会使用 CAS 直接插入。

可以理解成:

复制代码
如果 table[i] 还是 null,就把新节点放进去;
如果 table[i] 已经不是 null,说明别的线程先插入了,就重试。

CAS 的好处是:

复制代码
不需要加锁,性能更高。

2. 桶不为空:使用 synchronized 锁桶头节点

如果桶不为空:

复制代码
table[i] -> A -> B -> C

说明发生了 Hash 冲突。

这时候多个线程可能同时修改这个桶里的链表或红黑树,所以必须加锁。

JDK 1.8 锁的是当前桶的头节点:

复制代码
synchronized (f) {
    // 操作当前桶
}

这里的 f 就是桶的第一个节点。

这不是锁整个 Map,也不是锁整个数组,而是:

复制代码
只锁当前桶。

所以叫:

复制代码
桶级别加锁。

这样如果两个线程操作不同桶,就可以并发执行。

十六、为什么 JDK 1.8 用 synchronized,不用 ReentrantLock?

主要有三个原因。

第一,JDK 1.8 锁粒度变小了。

JDK 1.7 锁的是 Segment,JDK 1.8 锁的是桶。

锁的范围更小,不需要给每个桶都单独维护一个 ReentrantLock 对象。

第二,synchronized 已经被优化过了。

JDK 1.6 之后,synchronized 做了很多优化,比如:

复制代码
偏向锁
轻量级锁
自旋锁
锁膨胀

所以性能已经不错。

第三,使用更简单。

直接锁桶头节点即可:

复制代码
synchronized (f) {
    
}

不需要创建额外的锁对象,也不需要手动释放锁。

十七、JDK 1.8 的 get 为什么一般不加锁?

ConcurrentHashMap 的 get 操作一般不加锁。

因为 get 只是读操作:

复制代码
1. 计算 hash
2. 找到桶
3. 遍历链表或红黑树
4. 返回 value

它能不加锁,是因为内部很多字段使用了 volatile。

例如 Node 中的:

复制代码
volatile V val;
volatile Node<K,V> next;

volatile 可以保证可见性。

简单理解:

复制代码
一个线程 put 进去的数据,另一个线程 get 的时候能够看到。

所以 JDK 1.8 的 ConcurrentHashMap 是:

复制代码
读操作基本无锁;
写操作只锁当前桶。

十八、JDK 1.8 的扩容机制

ConcurrentHashMap 是并发容器,扩容时不能简单让所有线程都等待。

所以 JDK 1.8 设计了:

复制代码
多线程协助扩容。

意思是:

复制代码
线程 A 发现需要扩容,开始迁移数据;
线程 B 进来后发现正在扩容,不是傻等,而是一起帮忙迁移;
线程 C 也可以帮忙。

就像搬家:

复制代码
一个人搬很慢,多个人一起搬更快。

扩容过程中,如果某个桶已经迁移完成,旧数组对应位置会放一个特殊节点:

复制代码
ForwardingNode

它的作用是:

复制代码
告诉其他线程:这个桶已经迁移到新数组了,不要再操作旧桶。

如果线程看到 ForwardingNode,就知道当前正在扩容,可以去新数组查找,或者帮助扩容。

十九、ConcurrentHashMap 怎么统计 size?

普通 HashMap 可以简单使用:

复制代码
size++;

但是 ConcurrentHashMap 不能这么做。

因为在多线程环境下,size++ 不是原子操作。

它大概分三步:

复制代码
读取 size
加 1
写回 size

多个线程同时执行时,可能会丢失更新。

所以 JDK 1.8 的 ConcurrentHashMap 使用类似 LongAdder 的思想:

复制代码
baseCount + CounterCell[]

低并发时:

复制代码
直接 CAS 修改 baseCount。

高并发时:

复制代码
如果多个线程同时修改 baseCount,竞争很严重,就把计数分散到 CounterCell[] 中。

最后统计 size 时:

复制代码
size = baseCount + 所有 CounterCell 的值

比如:

复制代码
baseCount = 10

CounterCell[0] = 3
CounterCell[1] = 5
CounterCell[2] = 2

size = 10 + 3 + 5 + 2 = 20

本质就是:

复制代码
把一个热点计数变量拆成多个小计数器,减少多线程竞争。

你可以理解成:

复制代码
人少时,一个收银台就够了;
人多时,开多个收银台;
最后把所有收银台的钱加起来。

二十、LongAdder 是干什么的?

LongAdder 是 JDK 1.8 提供的一个高并发计数器。

它主要用来解决:

复制代码
很多线程同时对一个数字做累加时,AtomicLong 竞争太激烈的问题。

比如统计访问次数:

复制代码
LongAdder count = new LongAdder();

count.increment();
count.add(5);

long result = count.sum();

AtomicLong 的问题是:

复制代码
所有线程都抢同一个变量。

高并发下会出现大量 CAS 失败和重试。

而 LongAdder 的思想是:

复制代码
把一个计数器拆成多个小计数器。

它内部大概是:

复制代码
base

Cell[0]
Cell[1]
Cell[2]
Cell[3]

低并发时,直接更新 base。

高并发时,多个线程分散更新不同的 Cell。

最后调用:

复制代码
sum()

把它们加起来:

复制代码
总数 = base + Cell[0] + Cell[1] + Cell[2] + ...

所以:

复制代码
AtomicLong 是大家抢一个计数器;
LongAdder 是把一个计数器拆成多个小计数器,最后再求和。

LongAdder 和 AtomicLong 的区别

对比 AtomicLong LongAdder
底层思想 一个变量 CAS base + Cell 数组
并发低 性能很好 也可以
并发高 CAS 竞争严重 分散竞争,性能更好
获取值 get() sum()
适合场景 精确更新 高并发统计

LongAdder 的缺点是:

复制代码
sum() 不是强一致的瞬间快照。

因为调用 sum() 的时候,其他线程可能还在修改 Cell。

所以它更适合统计类场景,比如:

复制代码
访问量统计
点赞数统计
接口调用次数
QPS 统计

二十一、JDK 1.7 和 JDK 1.8 ConcurrentHashMap 对比

对比点 JDK 1.7 JDK 1.8
核心结构 Segment + HashEntry 数组 Node 数组
锁机制 ReentrantLock CAS + synchronized
锁粒度 Segment 级别 桶级别
冲突处理 链表 链表 + 红黑树
空桶插入 锁 Segment CAS 插入
非空桶插入 锁 Segment synchronized 锁桶头
get 操作 一般不加锁 一般不加锁
扩容 Segment 内部扩容 多线程协助扩容
size 统计 分段统计 baseCount + CounterCell\[\]

二十二、面试总结版

如果面试官问 HashMap,可以这样回答:

复制代码
HashMap 底层是数组 + 链表 + 红黑树。Hash 冲突时,多个元素会放到同一个桶中形成链表。JDK 1.8 之后,当链表长度达到 8,并且数组长度达到 64 时,会转成红黑树,提高查询效率。如果数组长度小于 64,会优先扩容,因为此时冲突可能只是数组太小导致的。HashMap 的扩容由负载因子决定,默认负载因子是 0.75,当元素数量超过容量乘以负载因子时触发扩容。

如果面试官问 ConcurrentHashMap,可以这样回答:

复制代码
JDK 1.7 的 ConcurrentHashMap 使用 Segment 分段锁。每个 Segment 继承 ReentrantLock,内部维护 HashEntry 数组和链表。写操作时先定位 Segment,然后锁住 Segment。不同 Segment 可以并发,但是同一个 Segment 内部操作不同桶也会发生锁竞争,所以锁粒度比较大。

JDK 1.8 之后取消了 Segment,改成 Node 数组 + 链表 + 红黑树的结构,并使用 CAS + synchronized 实现线程安全。put 时,如果桶为空,使用 CAS 插入;如果桶不为空,使用 synchronized 锁住桶头节点,所以锁粒度从 Segment 级别降低到了桶级别。get 操作一般不加锁,依靠 volatile 保证可见性。扩容时支持多线程协助扩容,迁移完成的桶会用 ForwardingNode 标记。

如果面试官问 LongAdder,可以这样回答:

复制代码
LongAdder 是 JDK 1.8 提供的高并发计数器。AtomicLong 在高并发下多个线程竞争同一个变量,CAS 失败重试较多,性能会下降。LongAdder 使用分段计数思想,内部维护 base 和 Cell 数组。低并发时直接更新 base,高并发时把不同线程的更新分散到不同 Cell 中,最后 sum() 时再累加。它适合访问量、点赞数、QPS 这类高并发统计场景。

最核心的一句话

复制代码
HashMap 解决的是 Hash 冲突和查询效率问题;
ConcurrentHashMap 解决的是多线程安全和并发性能问题;
LongAdder 解决的是高并发计数时热点变量竞争问题。

这几个知识点其实都围绕一个核心思想:

复制代码
减少冲突,降低竞争,提高并发性能。
相关推荐
Zhou1411361 分钟前
MyBatisPlus_02_条件构造器与高级功能
java·开发语言·python
垚垚学技术_聚焦云原生5 分钟前
Ansible Role 生产环境标准目录结构与规范化实践
java·elasticsearch·ansible
霸道流氓气质6 分钟前
Spring AI Alibaba Graph Studio 入门指南:可视化Agent编排与调试平台
java·人工智能·spring
SimonKing9 分钟前
Stream-Nexus:我的轻量级推送中间件(sse、websocket)终于定下来了
java·后端·程序员
边境悍匪25 分钟前
蜗牛学苑 Java 智能体学习 Day49|贯穿项目 5 订单下单业务 思维导图复盘
java·开发语言·spring boot·学习·阿里云
鬼手点金29 分钟前
Claude Code示范案例-修复 Bug 工作流
java·服务器·前端·javascript·bug·openclaw
一条破秋裤29 分钟前
Linux 进程通信介绍:内存隔离与 IPC 分类
java·linux·服务器
Omics Pro32 分钟前
空间组学!医学代谢显微工具
数据库·人工智能·算法·机器学习·自然语言处理
Coder-magician37 分钟前
《代码随想录》刷题打卡day44:图论-part02
算法·深度优先·图论
克里斯蒂亚诺更新38 分钟前
理解机器学习的各种算法
人工智能·算法·机器学习