01.02 Java基础篇|核心数据结构速查
核心知识架构
List:顺序容器的性能与源码洞察
-
ArrayList
- 底层动态数组,扩容公式
newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍增长。 - 读取 O(1)、尾插平均 O(1),中间插入需
System.arraycopy,最坏 O(n)。 - 默认懒初始化,只有首次
add才分配实际数组。 RandomAccess标记优化 for 循环场景;迭代时若modCount不一致触发 Fail-Fast。- 非线程安全,可通过
Collections.synchronizedList或CopyOnWriteArrayList适配并发读多写少。 - 源码分析 :
add(E e)会调用ensureCapacityInternal(size + 1)检查容量,内部若minCapacity > elementData.length则触发grow(),新容量 =old + old >> 1;插入时通过System.arraycopy将尾部整体右移。
javapublic boolean add(E e) { ensureCapacityInternal(size + 1); elementData[size++] = e; // 写入之后 size 自增 return true; } private void ensureCapacityInternal(int minCapacity) { if (minCapacity - elementData.length > 0) { grow(minCapacity); } } - 底层动态数组,扩容公式
-
LinkedList
- 基于双向链表的
Node<E>结构,维护first/last,可作为Deque使用。 - 任意位置插入/删除 O(1),但按索引查找需遍历 O(n),内存额外存储前驱/后继引用。
- 同样非线程安全,可用同步包装或并发队列替代。
- 源码分析 :索引定位使用
node(index)判断靠前或靠后,遍历次数约为min(index, size-index);插入调用linkBefore(E e, Node<E> succ),仅更新前后指针。
- 基于双向链表的
-
选型指南
- 高频随机读写 →
ArrayList - 头尾频繁插入、需双端队列 →
LinkedList - 迭代过程中插入:优先
ListIterator+LinkedList
- 高频随机读写 →
Map & Set:哈希与树结构融合
HashMap(JDK 8+)深度源码解析
核心数据结构:
java
// HashMap 核心字段
transient Node<K,V>[] table; // 哈希桶数组
transient int size; // 键值对数量
int threshold; // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 负载因子,默认 0.75
static final int TREEIFY_THRESHOLD = 8; // 树化阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化容量
static final int UNTREEIFY_THRESHOLD = 6; // 反树化阈值
Node 节点结构:
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 链表下一个节点
// 红黑树节点(TreeNode 继承 Node)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // 双向链表
boolean red;
}
}
put 方法完整流程源码分析:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// hash 方法:扰动函数,降低哈希碰撞
static final int hash(Object key) {
int h;
// 高16位与低16位异或,充分利用 hashCode 的所有位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
// 1. 如果表为空或长度为0,初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算桶索引:(n-1) & hash 等价于 hash % n,但位运算更快
i = (n - 1) & hash;
p = tab[i];
// 3. 桶为空,直接插入
if (p == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
// 4. 检查首节点是否匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 5. 如果是树节点,调用树插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 6. 链表遍历查找
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 尾插法(JDK 8 改为尾插,避免死循环)
p.next = newNode(hash, key, value, null);
// 链表长度 >= 8 且容量 >= 64,树化
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 7. 更新已存在的值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 8. 检查是否需要扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
扩容机制源码分析:
java
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大容量,不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也翻倍
}
// ... 初始化逻辑
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历旧表,迁移节点
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 单节点,直接计算新位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 树节点,调用树分裂
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表节点,优化:无需重新计算 hash
// 节点只可能落在原位置或原位置+oldCap
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 关键:通过 (e.hash & oldCap) == 0 判断
// 如果为0,留在原位置;否则移到新位置(原位置+oldCap)
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
为什么扩容时节点只可能落在两个位置?
- 假设旧容量为 16(二进制:10000),新容量为 32(二进制:100000)
- 旧索引计算:
hash & (16-1) = hash & 1111(取低4位) - 新索引计算:
hash & (32-1) = hash & 11111(取低5位) - 如果
hash & 10000 == 0(第5位为0),新索引 = 旧索引 - 如果
hash & 10000 != 0(第5位为1),新索引 = 旧索引 + 16
红黑树化条件:
java
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index;
Node<K,V> e;
// 只有容量 >= 64 才树化,否则先扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 转换为红黑树
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
ConcurrentHashMap(JDK 8)深度源码解析
📌 锁机制深入学习 :ConcurrentHashMap 使用了 CAS、synchronized 等并发控制机制,关于锁的详细原理(synchronized 锁升级、ReentrantLock、AQS 等)请参考 01.02.01 Java并发编程|并发模型全景.md 中的"锁机制"和"AQS"章节。
核心设计理念:
- 分段锁 → 桶锁:JDK 7 使用 Segment 分段锁,JDK 8 改为对每个桶加锁
- CAS + synchronized:首节点插入使用 CAS,冲突时使用 synchronized
- 读操作无锁 :通过
volatile保证可见性
核心字段:
java
transient volatile Node<K,V>[] table;
private transient volatile Node<K,V>[] nextTable; // 扩容时的临时表
private transient volatile int sizeCtl; // 控制标识符
// sizeCtl > 0: 扩容阈值
// sizeCtl = -1: 正在初始化
// sizeCtl < -1: -(1 + 参与扩容的线程数)
put 方法源码分析:
java
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); // 再次哈希,降低碰撞
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f;
int n, i, fh;
// 1. 表为空,初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 2. 桶为空,CAS 插入首节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 3. 检测到正在扩容,帮助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 4. 对首节点加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 链表处理
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
} else if (f instanceof TreeBin) {
// 树处理
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 5. 检查是否需要树化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
关键方法:tabAt、casTabAt(Unsafe 操作):
java
// 获取数组元素(volatile 读)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// CAS 设置数组元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
size 计算优化(LongAdder 思想):
java
// ConcurrentHashMap 使用 CounterCell 数组分段计数
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
// 最终 size = baseCount + sum(counterCells)
LinkedHashMap:LRU 缓存实现
核心机制:
java
public class LinkedHashMap<K,V> extends HashMap<K,V> {
// 双向链表头尾节点
transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;
// accessOrder = true: 按访问顺序;false: 按插入顺序
final boolean accessOrder;
// 访问节点时,移动到尾部
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
}
LRU 缓存实现示例:
java
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true); // accessOrder = true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize; // 超过容量时移除最老的
}
}
Set 家族实现原理
HashSet 源码:
java
public class HashSet<E> extends AbstractSet<E> {
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object(); // 虚拟值
public boolean add(E e) {
return map.put(e, PRESENT) == null; // 元素作为 key,PRESENT 作为 value
}
}
TreeSet 源码:
java
public class TreeSet<E> extends AbstractSet<E> {
private transient NavigableMap<E,Object> m; // TreeMap
public boolean add(E e) {
return m.put(e, PRESENT) == null;
}
}
树与索引结构深度解析
二叉搜索树(BST)
基本概念:
- 性质:左子树所有节点值 < 根节点 < 右子树所有节点值
- 查找:O(log n) 平均,O(n) 最坏(退化为链表)
- 插入/删除:O(log n) 平均,O(n) 最坏
退化问题:
java
// 有序插入导致退化为链表
BST tree = new BST();
for (int i = 1; i <= 10; i++) {
tree.insert(i); // 退化为右斜树,查找变为 O(n)
}
解决方案:使用自平衡树(AVL、红黑树)
平衡二叉树(AVL)
基本概念:
- 平衡因子:左右子树高度差不超过 1
- 旋转操作:左旋、右旋、左右旋、右左旋
- 查找:O(log n) 严格保证
- 维护成本:插入/删除可能需要多次旋转
旋转示例:
java
// 右旋(以 y 为轴)
Node rightRotate(Node y) {
Node x = y.left;
Node T2 = x.right;
// 执行旋转
x.right = y;
y.left = T2;
// 更新高度
y.height = max(height(y.left), height(y.right)) + 1;
x.height = max(height(x.left), height(x.right)) + 1;
return x; // 新的根节点
}
适用场景:查找频繁、插入删除较少
红黑树(Red-Black Tree)
红黑树是 JDK 中 TreeMap、HashMap(JDK 8+)的核心数据结构。
五大性质:
- 节点是红色或黑色
- 根节点是黑色
- 叶子节点(NIL)是黑色
- 红色节点的子节点必须是黑色(不能有连续红色节点)
- 从任意节点到其每个叶子的路径包含相同数量的黑色节点(黑高相同)
为什么选择红黑树而不是 AVL?
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡性 | 严格平衡(高度差 ≤ 1) | 近似平衡(最长路径 ≤ 2×最短路径) |
| 查找性能 | O(log n) 严格保证 | O(log n) 平均 |
| 插入/删除 | 可能需要多次旋转 | 最多 3 次旋转 |
| 维护成本 | 高 | 低 |
| 适用场景 | 查找为主 | 插入删除频繁 |
红黑树在 HashMap 中的应用:
java
// HashMap 树化条件
static final int TREEIFY_THRESHOLD = 8; // 链表长度 >= 8
static final int MIN_TREEIFY_CAPACITY = 64; // 容量 >= 64
// 树化过程
final void treeifyBin(Node<K,V>[] tab, int hash) {
// 1. 容量 < 64,优先扩容
if (tab.length < MIN_TREEIFY_CAPACITY)
resize();
// 2. 转换为红黑树
else {
TreeNode<K,V> hd = null, tl = null;
// 将链表转换为双向链表
for (Node<K,V> e = tab[index]; e != null; e = e.next) {
TreeNode<K,V> p = replacementTreeNode(e, null);
// ... 构建双向链表
}
// 树化
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
红黑树旋转操作:
java
// 左旋(以 x 为轴)
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) {
if ((rl = p.right = r.left) != null)
rl.parent = p;
if ((pp = r.parent = p.parent) == null)
(root = r).red = false;
else if (pp.left == p)
pp.left = r;
else
pp.right = r;
r.left = p;
p.parent = r;
}
return root;
}
// 右旋(以 y 为轴)
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) {
TreeNode<K,V> l, pp, lr;
if (p != null && (l = p.left) != null) {
if ((lr = p.left = l.right) != null)
lr.parent = p;
if ((pp = l.parent = p.parent) == null)
(root = l).red = false;
else if (pp.right == p)
pp.right = l;
else
pp.left = l;
l.right = p;
p.parent = l;
}
return root;
}
红黑树插入修复:
java
// 插入后的修复(简化版)
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) {
x.red = true; // 新节点总是红色
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
// 情况1:父节点是黑色,无需修复
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 情况2:父节点是红色,需要修复
if (xp == (xppl = xpp.left)) {
// 叔叔节点是红色:变色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
// 叔叔节点是黑色:旋转
else {
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
// 对称情况...
}
}
B 树(B-Tree)
基本概念:
- 多路平衡树:每个节点可以有多个子节点(通常 100-1000 个)
- 节点结构 :
[key1, key2, ..., keym]+[child1, child2, ..., childm+1] - 平衡性:所有叶子节点在同一层
- 磁盘友好:减少磁盘 I/O 次数
B 树性质:
- 根节点至少 2 个子节点(除非是叶子节点)
- 非根非叶节点至少有 ⌈m/2⌉ 个子节点(m 为阶数)
- 所有叶子节点在同一层
- 节点中关键字有序
B 树结构示例(3 阶 B 树):
[50]
/ \
[20,30] [70,80]
/ | \ / | \
[10][25][40][60][75][90]
B 树查找:
java
// 伪代码
BTreeNode search(BTreeNode root, int key) {
BTreeNode node = root;
while (node != null) {
int i = 0;
// 在节点中查找
while (i < node.keys.length && key > node.keys[i])
i++;
if (i < node.keys.length && key == node.keys[i])
return node; // 找到
// 继续在子节点中查找
node = node.children[i];
}
return null; // 未找到
}
适用场景:
- 文件系统:ext3、ext4、NTFS
- 数据库索引:MySQL InnoDB(使用 B+ 树变种)
B+ 树(B+ Tree)
B+ 树是 B 树的变种,广泛用于数据库索引。
B+ 树 vs B 树:
| 特性 | B 树 | B+ 树 |
|---|---|---|
| 数据存储 | 内部节点和叶子节点都存储数据 | 只有叶子节点存储数据 |
| 叶子节点 | 不形成链表 | 形成有序链表 |
| 查找 | 可能在内部节点找到 | 必须到叶子节点 |
| 范围查询 | 需要回溯 | 通过链表顺序遍历 |
| 内部节点 | 存储数据 + 指针 | 只存储索引(指针) |
B+ 树结构示例:
内部节点(索引)
[50, 80]
/ | \
[20,30] [60,70] [90,100]
/ | \ / | \ / | \
叶子节点(数据,形成链表)
[10,20][25,30][40,50] → [60,70][75,80] → [90,100]
B+ 树优势:
- 范围查询高效:叶子节点形成链表,顺序遍历即可
- 内部节点更小:只存储索引,可容纳更多关键字
- 磁盘 I/O 更少:树高更低,减少磁盘访问
- 数据局部性好:数据集中在叶子节点
MySQL InnoDB 中的 B+ 树:
sql
-- InnoDB 使用 B+ 树作为索引结构
CREATE TABLE user (
id INT PRIMARY KEY, -- 主键索引(聚簇索引,B+ 树)
name VARCHAR(50),
age INT,
INDEX idx_age (age) -- 二级索引(非聚簇索引,B+ 树)
);
-- 聚簇索引:叶子节点存储完整行数据
-- 二级索引:叶子节点存储主键值,需要回表查询
B+ 树插入示例:
java
// 插入 key=25
// 1. 查找插入位置(叶子节点)
// 2. 如果节点未满,直接插入
// 3. 如果节点已满,分裂节点
// 3.1 将中间关键字提升到父节点
// 3.2 分裂后的节点作为父节点的子节点
B+ 树删除示例:
java
// 删除 key=25
// 1. 查找并删除
// 2. 如果节点关键字数 < ⌈m/2⌉,需要合并或借用
// 2.1 向兄弟节点借用
// 2.2 与兄弟节点合并
树结构对比总结
| 树类型 | 查找 | 插入/删除 | 适用场景 | 典型应用 |
|---|---|---|---|---|
| BST | O(log n) 平均,O(n) 最坏 | O(log n) 平均,O(n) 最坏 | 教学、简单场景 | - |
| AVL | O(log n) 严格 | O(log n),可能多次旋转 | 查找为主 | - |
| 红黑树 | O(log n) 平均 | O(log n),最多 3 次旋转 | 插入删除频繁 | TreeMap、HashMap(JDK 8+) |
| B 树 | O(log n),树高低 | O(log n) | 文件系统 | ext3、ext4 |
| B+ 树 | O(log n),树高低 | O(log n) | 数据库索引 | MySQL InnoDB、PostgreSQL |
选型建议:
- 内存数据结构:红黑树(JDK 集合框架)
- 文件系统:B 树
- 数据库索引:B+ 树
- 严格平衡需求:AVL 树
实战案例
案例 1:日志采集系统容器选型
- 需求:接收高并发日志写入,按应用维度聚合,定期归档。
- 实现:三层缓存结构
java
class LogAggregator {
private final ConcurrentHashMap<String, CopyOnWriteArrayList<LogEntry>> buffer = new ConcurrentHashMap<>();
private final LinkedBlockingQueue<List<LogEntry>> archiveQueue = new LinkedBlockingQueue<>(1024);
public void collect(LogEntry entry) {
buffer.computeIfAbsent(entry.appId(), id -> new CopyOnWriteArrayList<>()).add(entry);
if (buffer.get(entry.appId()).size() > 1000) {
flush(entry.appId());
}
}
private void flush(String appId) {
List<LogEntry> snapshot = new ArrayList<>(buffer.get(appId));
buffer.get(appId).clear();
archiveQueue.offer(snapshot);
}
}
- 数据结构原理 :
ConcurrentHashMap保证在高并发写入下仍可并行扩容,computeIfAbsent利用 CAS 初始化桶节点。CopyOnWriteArrayList读多写少场景友好,写入时复制数组,保证读线程无锁访问。LinkedBlockingQueue提供有界阻塞队列,避免归档线程过载。
- 面试亮点(示范回答) :
- 问:为什么高并发日志缓冲选用
CopyOnWriteArrayList?
答:日志读取是多线程实时消费、写入量远低于读取量,CopyOnWriteArrayList写时复制避免读锁,保证读线程遍历一致视图;写入虽然存在复制成本,但通过批量flush降低频度,并且日志单条体积小,复制成本可接受。 - 问:如何消除热点应用导致的内存膨胀?
答:使用LongAdder每次写入累加 QPS,用LinkedHashMap<String, AppStat>(accessOrder=true)维护最近活跃应用,当size超阈值时淘汰访问最少的 Key。同时设置守护线程定期把冷数据切到磁盘或对象存储。 - 问:若写入突增导致
CopyOnWriteArrayList频繁复制怎么办?
答:为热点应用单独切换策略:落地ConcurrentLinkedQueue收集日志,使用定时批量搬运至ArrayList统一入库,减轻复制开销,同时通过CompletableFuture异步回写,兼顾读写性能与内存占用。
- 问:为什么高并发日志缓冲选用
案例 2:Spring Bean 单例池与本地缓存
- 需求:快速定位 Bean,支持热点数据快速命中,淘汰策略可控。
- 源码观察 :
DefaultSingletonBeanRegistry以ConcurrentHashMap<String, Object>保存单例;创建流程通过三级缓存避免循环依赖。 - 轻量 LRU 实现:
java
class LocalCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LocalCache(int maxSize) {
super(16, 0.75f, true); // 按访问顺序
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}
- 面试亮点展开(示范回答) :
- 问:
LinkedHashMap如何保证 LRU 顺序?
答:构造时传入accessOrder=true,每次get/put会把节点移动到链表尾部,removeEldestEntry在插入后触发判断,满足size > maxSize时淘汰头节点,实现最近最少使用策略。 - 问:如何降低本地缓存的锁竞争?
答:将热点 Key 按业务维度拆分到多个段:Map<String, LocalCache<String, Object>> segments = new ConcurrentHashMap<>();,先在ConcurrentHashMap里定位 segment,再在单个LocalCache上加锁,锁粒度缩小,避免单个 LRU 全局锁。 - 问:分布式部署如何保持缓存一致性?
答:本地 LRU 只做最近热点加速,变更时发送 MQ 或 Redis Pub/Sub 通知其他节点清理;对强一致需求,落地 Redis + 本地缓存双层结构,写入先更新 Redis,再发布失效事件,监听器调用cache.remove(key),保证多实例同步。
- 问:
高频面试问答(深度解析)
1. HashMap 的扩容流程是什么?为什么是 2 的幂次?
标准答案:
- 触发条件 :
size > threshold(threshold = capacity × loadFactor) - 扩容操作:容量翻倍(newCapacity = oldCapacity × 2)
- 节点迁移 :节点只可能落在原位置或
原位置 + oldCapacity
深入追问与回答思路:
Q: 为什么容量必须是 2 的幂次?
java
// 计算桶索引:(n-1) & hash
// 如果 n 是 2 的幂次,n-1 的二进制全为 1
// 例如:n=16, n-1=15=1111,hash & 1111 等价于 hash % 16
// 位运算比取模运算快得多
// 如果不是 2 的幂次,例如 n=15, n-1=14=1110
// hash & 1110 会丢失最后一位,导致分布不均匀
Q: 扩容时如何优化性能?
- JDK 8 优化 :通过
(e.hash & oldCap) == 0判断节点位置,无需重新计算 hash - 链表拆分:将链表拆分为两个链表(lo 和 hi),分别放在原位置和新位置
- 并发优化:ConcurrentHashMap 支持多线程协助扩容
Q: 负载因子为什么默认是 0.75?
- 权衡空间和时间 :
- 太小(如 0.5):空间利用率低,频繁扩容
- 太大(如 1.0):链表/树过长,查找性能下降
- 0.75 是经验值,在大多数场景下表现最佳
Q: 扩容会导致什么问题?
- 性能抖动:扩容时需要重新计算所有节点的位置
- 内存占用:扩容时同时存在新旧两个数组
- 优化方案:预估容量,避免频繁扩容
2. JDK 7 与 JDK 8 HashMap 的主要差异?
标准答案:
- 链表插入方式:JDK 7 头插法,JDK 8 尾插法
- 树化机制:JDK 8 引入红黑树,解决链表过长问题
- 扩容优化:JDK 8 优化了节点迁移逻辑
深入追问与回答思路:
Q: 为什么 JDK 7 头插法会导致死循环?
java
// JDK 7 头插法代码(简化)
void transfer(Entry[] newTable) {
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; // 保存下一个节点
e.next = newTable[i]; // 头插
newTable[i] = e;
e = next;
}
}
}
// 并发场景下的问题:
// 线程 A 执行到 e.next = newTable[i] 时被挂起
// 线程 B 完成扩容,链表顺序反转
// 线程 A 恢复后,可能导致循环链表
Q: JDK 8 如何解决这个问题?
- 尾插法:新节点插入链表尾部,保持原有顺序
- 但仍有问题:并发环境下仍可能出现数据丢失
- 正确做法:使用 ConcurrentHashMap 或加锁
Q: 红黑树化的条件是什么?
- 链表长度 >= 8(TREEIFY_THRESHOLD)
- 表容量 >= 64(MIN_TREEIFY_CAPACITY)
- 如果容量 < 64,优先扩容而不是树化
Q: 为什么反树化阈值是 6 而不是 8?
- 避免频繁树化和反树化:如果都是 8,在 8 附近会频繁切换
- 6 和 8 之间有缓冲:减少不必要的转换开销
3. ConcurrentHashMap 如何保证线程安全?
标准答案:
- CAS + synchronized:首节点插入使用 CAS,冲突时对首节点加 synchronized
- volatile 保证可见性:数组和节点值使用 volatile
- 分段锁思想:锁粒度缩小到单个桶
深入追问与回答思路:
Q: JDK 7 和 JDK 8 ConcurrentHashMap 的区别?
| 特性 | JDK 7 | JDK 8 |
|---|---|---|
| 锁粒度 | Segment(16个) | 单个桶 |
| 数据结构 | 数组+链表 | 数组+链表+红黑树 |
| 锁机制 | ReentrantLock | synchronized + CAS |
| 读操作 | 需要加锁 | 无锁(volatile) |
Q: 为什么 JDK 8 改用 synchronized?
- 性能提升:JDK 6 之后 synchronized 性能大幅提升(锁升级机制)
- 代码简化:synchronized 由 JVM 优化,无需手动管理锁
- 减少内存占用:synchronized 比 ReentrantLock 占用更少内存
📌 深入学习 :关于 synchronized 锁升级机制(偏向锁 → 轻量级锁 → 重量级锁)、ReentrantLock 原理、AQS 实现等详细内容,请参考 01.02.01 Java并发编程|并发模型全景.md。
Q: ConcurrentHashMap 的 size() 方法如何实现?
java
// JDK 8 使用分段计数(类似 LongAdder)
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
// baseCount + 所有 CounterCell 的值
final long sumCount() {
CounterCell[] as = counterCells;
long sum = baseCount;
if (as != null) {
for (CounterCell a : as)
if (a != null)
sum += a.value;
}
return sum;
}
Q: ConcurrentHashMap 支持 null 值吗?
- 不支持 :
put(key, null)会抛出NullPointerException - 原因:无法区分"key 不存在"和"key 存在但值为 null"
- 替代方案 :使用
Optional<V>或特殊标记值
Q: 如何保证读操作的线程安全?
- volatile 数组引用 :
transient volatile Node<K,V>[] table; - volatile 节点值 :
volatile V val; - volatile 读 :使用
tabAt()方法(Unsafe.getObjectVolatile)
4. HashMap vs Hashtable vs ConcurrentHashMap?
对比表格:
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是 | 是 |
| 锁机制 | 无 | 方法级 synchronized | CAS + synchronized |
| 性能 | 高 | 低 | 中等(读高,写中等) |
| null 支持 | Key/Value 都可为 null | 都不支持 | 都不支持 |
| 迭代器 | Fail-Fast | Fail-Safe | Fail-Safe |
| 版本 | JDK 1.2 | JDK 1.0 | JDK 1.5+ |
深入追问与回答思路:
Q: 为什么 Hashtable 性能差?
- 方法级同步 :所有方法都加
synchronized,锁粒度太大 - 串行执行:多线程环境下完全串行,无法并发
- 已过时:JDK 官方建议使用 ConcurrentHashMap
Q: 什么时候用 HashMap,什么时候用 ConcurrentHashMap?
- HashMap:单线程环境或只读多线程环境
- ConcurrentHashMap:多线程读写环境
- Collections.synchronizedMap():需要同步的遗留代码
Q: Fail-Fast 和 Fail-Safe 的区别?
java
// Fail-Fast:快速失败,检测到并发修改立即抛出异常
Map<String, String> map = new HashMap<>();
map.put("a", "1");
for (String key : map.keySet()) {
map.put("b", "2"); // 抛出 ConcurrentModificationException
}
// Fail-Safe:安全失败,使用快照迭代
Map<String, String> map = new ConcurrentHashMap<>();
map.put("a", "1");
for (String key : map.keySet()) {
map.put("b", "2"); // 不会抛出异常,但不保证能看到新元素
}
5. 如何实现 LRU 缓存?有哪些优化方案?
标准答案:
- LinkedHashMap 实现 :继承 LinkedHashMap,设置
accessOrder=true,重写removeEldestEntry - 手动实现:HashMap + 双向链表
深入追问与回答思路:
Q: LinkedHashMap 实现 LRU 的完整代码?
java
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true); // accessOrder = true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}
Q: 手动实现 LRU(面试常考)?
java
public class LRUCacheManual<K, V> {
class Node {
K key;
V value;
Node prev;
Node next;
}
private final int capacity;
private final Map<K, Node> cache;
private final Node head; // 虚拟头节点
private final Node tail; // 虚拟尾节点
public LRUCacheManual(int capacity) {
this.capacity = capacity;
this.cache = new HashMap<>();
this.head = new Node();
this.tail = new Node();
head.next = tail;
tail.prev = head;
}
public V get(K key) {
Node node = cache.get(key);
if (node == null) return null;
moveToHead(node); // 移动到头部
return node.value;
}
public void put(K key, V value) {
Node node = cache.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
if (cache.size() >= capacity) {
removeTail(); // 移除尾部(最久未使用)
}
node = new Node();
node.key = key;
node.value = value;
cache.put(key, node);
addToHead(node);
}
}
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
private void addToHead(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void removeTail() {
Node last = tail.prev;
removeNode(last);
cache.remove(last.key);
}
}
Q: 如何优化 LRU 缓存的性能?
- 分段锁:将缓存分成多个段,减少锁竞争
- 异步淘汰:使用后台线程定期清理过期元素
- 二级缓存:热点数据放在本地,冷数据放在远程(Redis)
- 预取策略:预测可能访问的数据,提前加载
Q: LRU 的变种算法有哪些?
- LFU (Least Frequently Used):淘汰使用频率最低的
- LRU-K:考虑最近 K 次访问
- ARC (Adaptive Replacement Cache):自适应替换缓存
6. 红黑树的性质和旋转操作?
标准答案 :
红黑树是自平衡二叉搜索树,满足以下 5 条性质:
- 节点是红色或黑色
- 根节点是黑色
- 叶子节点(NIL)是黑色
- 红色节点的子节点必须是黑色(不能有连续红色节点)
- 从任意节点到其每个叶子的路径包含相同数量的黑色节点
深入追问与回答思路:
Q: 为什么选择红黑树而不是 AVL 树?
- 平衡性:AVL 树更严格平衡,但维护成本高
- 性能:红黑树插入/删除更快,查找略慢
- 应用场景:HashMap 需要频繁插入删除,红黑树更适合
Q: 红黑树的旋转操作?
java
// 左旋(以 x 为轴)
Node leftRotate(Node x) {
Node y = x.right;
x.right = y.left;
if (y.left != null) y.left.parent = x;
y.parent = x.parent;
if (x.parent == null) root = y;
else if (x == x.parent.left) x.parent.left = y;
else x.parent.right = y;
y.left = x;
x.parent = y;
return y;
}
// 右旋(以 y 为轴)
Node rightRotate(Node y) {
Node x = y.left;
y.left = x.right;
if (x.right != null) x.right.parent = y;
x.parent = y.parent;
if (y.parent == null) root = x;
else if (y == y.parent.left) y.parent.left = x;
else y.parent.right = x;
x.right = y;
y.parent = x;
return x;
}
Q: HashMap 中红黑树何时退化为链表?
- 删除节点后,如果树节点数 < 6(UNTREEIFY_THRESHOLD),退化为链表
- 扩容时,如果树节点数 < 6,也会退化为链表
高频算法题型提纲
数组与字符串
- 双指针:对撞指针、快慢指针、滑动窗口。
- 示例:两数之和(有序数组)、最长无重复子串。
- 思路 :
- 对撞指针:左右端向中间收缩,常用于有序数组求和。
- 滑动窗口:维护窗口内计数,实现 O(n) 扫描。
- 示例代码(最长无重复子串):
java
int lengthOfLongestSubstring(String s) {
Map<Character, Integer> index = new HashMap<>();
int left = 0, ans = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (index.containsKey(c)) {
left = Math.max(left, index.get(c) + 1);
}
index.put(c, right);
ans = Math.max(ans, right - left + 1);
}
return ans;
}
链表
- 虚拟头节点、快慢指针定位目标节点。
- 示例:删除倒数第 N 个节点、链表反转。
- 思路 :
- 快慢指针先行
n步,慢指针再与快指针同步前进,快指针到尾时慢指针指向目标前驱。
- 快慢指针先行
- 示例代码(删除倒数第 N):
java
ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0, head);
ListNode fast = dummy, slow = dummy;
for (int i = 0; i < n; i++) fast = fast.next;
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
栈与队列
- 单调栈解决"下一个更大元素""每日温度"。
- 队列配合 BFS 实现层序遍历。
- 思路:单调栈维护单调性,当前元素入栈前弹出不满足条件的元素并计算答案。
- 示例代码(每日温度):
java
int[] dailyTemperatures(int[] T) {
int n = T.length;
int[] ans = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && T[i] > T[stack.peek()]) {
int prev = stack.pop();
ans[prev] = i - prev;
}
stack.push(i);
}
return ans;
}
二叉树
- 前中后序递归模板与层序遍历。
- 二叉搜索树验证、中序遍历有序性。
- 思路 :利用递归模板
traverse(node) { traverse(node.left); process(node); traverse(node.right); };BST 校验时维护区间上下界。
动态规划
- 五步法:定义状态 → 转移方程 → 初始条件 → 计算顺序 → 空间优化。
- 经典题:爬楼梯、最长公共子序列。
- 思路示例(爬楼梯) :
dp[i] = dp[i-1] + dp[i-2],可滚动数组优化为 O(1) 空间。 - 示例代码:
java
int climbStairs(int n) {
int a = 1, b = 1;
for (int i = 2; i <= n; i++) {
int sum = a + b;
a = b;
b = sum;
}
return b;
}
回溯与图论
- 回溯模板:做选择 → 递归 → 撤销选择;全排列示例。
- 图遍历:BFS/DFS 模板,维护
visited集合。 - 思路:回溯通过路径记录 + used 数组剪枝;图遍历需警惕环,BFS 适合求最短路径。
- 示例代码(全排列):
java
List<List<Integer>> permute(int[] nums) {
List<List<Integer>> ans = new ArrayList<>();
boolean[] used = new boolean[nums.length];
backtrack(nums, new ArrayList<>(), used, ans);
return ans;
}
void backtrack(int[] nums, List<Integer> path, boolean[] used, List<List<Integer>> ans) {
if (path.size() == nums.length) {
ans.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.add(nums[i]);
backtrack(nums, path, used, ans);
path.remove(path.size() - 1);
used[i] = false;
}
}
延伸阅读
- 官方文档:Oracle Java Collections Framework Guide、JDK 源码。
- 推荐书籍:《Effective Java》《Java 并发编程的艺术》《算法(第四版)》。
- 工具建议:使用 IntelliJ IDEA
Analyze Data Flow阅读源码;结合jvisualvm或 Arthas 观察数据结构在运行时的表现。
小贴士:后续博文可按照"理论讲解 → 可运行示例 → 常见面试问答 → 延伸挑战"的结构发布,读者易于跟随,也便于长期维护版本更新。