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 为什么一般不加锁?

ConcurrentHashMapget 操作一般不加锁。

因为 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 解决的是高并发计数时热点变量竞争问题。

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

复制代码
减少冲突,降低竞争,提高并发性能。
相关推荐
菜菜的顾清寒1 小时前
力扣HOT100(35)回溯-全排列
算法·leetcode·职场和发展
Gauss松鼠会1 小时前
GaussDB(DWS) SQL性能问题案例集
java·数据库·经验分享·spring boot·后端·sql·gaussdb
weixin_468466851 小时前
目标识别算法落地实战:从选型到部署的全流程指南
图像处理·人工智能·python·算法·目标检测·机器视觉·目标识别
MicroTech20251 小时前
微算法科技(NASDAQ :MLGO)量子启发进化算法(QEA)与区块链(BC)集成技术:构建高可靠去中心化创新方案
科技·算法·量子计算
CQU_JIAKE1 小时前
5.27【A】
算法
江屿风1 小时前
C++OJ题经验总结(竞赛)3
开发语言·c++·笔记·算法
NiceCloud喜云1 小时前
Anthropic 发布 Project Glasswing:未公开模型 Mythos 已挖出 10000+ 漏洞,含 OpenBSD 27 年老 bug
android·java·数据库·c++·python·docker·bug
鬼才血脉1 小时前
IDEA中集成Tomcat后重新部署、重启服务器、更新资源、更新类和资源的使用
java·服务器·intellij-idea
手写码匠1 小时前
从零手写 SQL 查询引擎:解析器、优化器与执行器实战
人工智能·深度学习·算法·aigc