一、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 解决的是高并发计数时热点变量竞争问题。
这几个知识点其实都围绕一个核心思想:
减少冲突,降低竞争,提高并发性能。