第2章 Java集合框架深度解析
面试频率:★★★★★
集合框架是Java面试的"绝对主力"。HashMap源码、ConcurrentHashMap演进、ArrayList扩容------这些问题几乎出现在每一场Java面试中。本章不满足于"知道",而是带你"看透":从源码级剖析到面试连环追问,让你在面试中真正做到胸有成竹。
学习目标:
- 掌握HashMap从put到扩容的完整源码流程,理解红黑树化的触发条件
- 理解HashMap线程不安全的具体表现及ConcurrentHashMap的解决方案
- 熟悉ArrayList扩容机制与序列化优化
- 能手写LRU缓存,理解LinkedHashMap的访问序实现
- 掌握集合选型决策树,能根据场景选择合适的集合
2.1 集合体系总览------List/Set/Map/Queue四大家族
面试官怎么问
"说说Java集合框架的整体结构。"
核心体系图
Iterable
├── Collection
│ ├── List(有序可重复)
│ │ ├── ArrayList → 数组,随机访问O(1),插入删除O(n)
│ │ ├── LinkedList → 双向链表,随机访问O(n),头尾操作O(1)
│ │ └── Vector → 线程安全的ArrayList(已过时)
│ ├── Set(无序不可重复)
│ │ ├── HashSet → HashMap包装器
│ │ ├── LinkedHashSet → LinkedHashMap包装器,保持插入序
│ │ └── TreeSet → TreeMap包装器,排序
│ └── Queue(队列)
│ ├── PriorityQueue → 优先队列,堆实现
│ ├── ArrayDeque → 循环数组双端队列
│ └── LinkedList → 同时实现Deque接口
└── Map(键值对)
├── HashMap → 数组+链表+红黑树
├── LinkedHashMap → HashMap+双向链表(插入序/访问序)
├── TreeMap → 红黑树排序
├── ConcurrentHashMap → 线程安全HashMap
└── Hashtable → 全表锁线程安全(已过时)
关键接口对比
| 接口 | 核心契约 | 允许null | 线程安全 |
|---|---|---|---|
| List | 有序可重复 | 是 | 否(Vector除外) |
| Set | 唯一性 | HashSet允许一个null | 否 |
| Queue | FIFO/LIFO/优先级 | 否(PriorityQueue) | 否 |
| Map | key唯一 | HashMap允许null key/value | 否(CHM/Hashtable除外) |
2.2 ArrayList源码分析
面试官怎么问
"ArrayList的扩容机制是怎样的?和LinkedList怎么选?"
底层数据结构
java
public class ArrayList<E> {
// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;
// 空数组(无参构造时共享)
private static final Object[] EMPTY_ELEMENTDATA = {};
// 默认空数组(区分首次添加时扩容到10)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 底层数组------transient:自定义序列化
transient Object[] elementData;
// 元素数量
private int size;
}
扩容机制
java
// 扩容核心方法
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 新容量 = 旧容量的1.5倍(右移1位 = 除以2)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果1.5倍仍不够,直接用minCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果超过最大值,用Integer.MAX_VALUE
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 复制到新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
扩容时间线:
new ArrayList() → elementData = {}(空数组,size=0)
第1次add → 扩容到10
第11次add → 扩容到15(10 * 1.5)
第16次add → 扩容到22(15 * 1.5)
...
序列化优化
ArrayList重写了writeObject和readObject,只序列化实际元素而非整个数组:
java
private void writeObject(java.io.ObjectOutputStream s) throws IOException {
// 只写入size个元素,而非elementData.length个
s.writeInt(size);
for (int i = 0; i < size; i++) {
s.writeObject(elementData[i]);
}
}
面试追问:为什么用transient修饰elementData?
elementData的长度通常远大于size,如果直接序列化会浪费大量空间。自定义writeObject只序列化有效元素,节省I/O和存储。
RandomAccess标记
java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable { }
RandomAccess是一个标记接口,表示该集合支持高效随机访问。遍历时可根据是否实现此接口选择不同策略:
java
if (list instanceof RandomAccess) {
// 用for循环+get(i),O(1)随机访问
for (int i = 0; i < list.size(); i++) { list.get(i); }
} else {
// 用Iterator,避免每次get都是O(n)
for (E e : list) { ... }
}
2.3 LinkedList源码分析
底层数据结构
java
public class LinkedList<E> implements List<E>, Deque<E> {
// 双向链表节点
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
transient int size = 0;
transient Node<E> first; // 头节点
transient Node<E> last; // 尾节点
}
ArrayList vs LinkedList性能对比
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 随机访问get(i) | O(1) | O(n) |
| 头部插入add(0, e) | O(n)(数组移动) | O(1) |
| 尾部插入add(e) | O(1)均摊 | O(1) |
| 中间插入add(i, e) | O(n) | O(n)(查找O(n)+插入O(1)) |
| 内存占用 | 紧凑(连续数组) | 每个节点额外2个指针 |
面试追问:实际开发中LinkedList几乎不被使用,为什么?
①CPU缓存友好性:ArrayList连续内存,缓存命中率高;LinkedList节点分散,频繁cache miss。②即使头部插入,ArrayList在数据量不大时数组的native copy比链表指针操作还快。③LinkedList每个节点额外消耗16字节(prev+next指针)。
2.4 HashMap源码深度分析
面试官怎么问
"说说HashMap的底层原理。put一个键值对的完整流程是什么?"
这是集合面试的"必考题",必须能完整讲述put流程。
JDK7 vs JDK8核心差异
| 维度 | JDK7 | JDK8 |
|---|---|---|
| 底层结构 | 数组+单向链表 | 数组+链表+红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容链表顺序 | 反转 | 保持原顺序 |
| 扰动函数 | 9次扰动 | 1次异或 |
| 并发问题 | 死循环+数据丢失 | 数据覆盖+丢失 |
| 查询最坏 | O(n) | O(log n) |
核心成员变量
java
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子
static final int TREEIFY_THRESHOLD = 8; // 树化阈值
static final int UNTREEIFY_THRESHOLD = 6; // 反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化容量
transient Node<K,V>[] table; // 哈希桶数组
transient int size; // 键值对总数
int threshold; // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 负载因子
transient int modCount; // 修改计数(fail-fast)
hash方法------为什么异或高位
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
数组下标计算:index = (n - 1) & hash
当n=16时,n-1=15(二进制0000 1111),只取hash低4位。如果两个key的hashCode高位不同但低位相同,就会产生冲突。(h >>> 16) ^ h让高16位也参与低位计算,减少冲突概率。
put完整流程(源码级)
map.put(key, value) 的完整执行路径:
1. 计算hash:hash(key)
2. 定位桶:i = (n - 1) & hash
3. 桶为空?→ 直接插入newNode
4. 桶不为空:
4a. 头节点key相同?→ 覆盖value
4b. 头节点是TreeNode?→ 红黑树插入putTreeVal
4c. 遍历链表:
- 找到相同key?→ 覆盖value
- 到达尾部?→ 尾插新节点
- 链表长度≥8?→ 调用treeifyBin尝试树化
5. modCount++
6. size++ > threshold?→ resize()扩容
java
// 核心源码(简化版)
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. 初始化table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 桶为空,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 头节点key匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 链表遍历
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); // 尾插法
if (binCount >= TREEIFY_THRESHOLD - 1) // 链表长度≥8
treeifyBin(tab, hash); // 尝试树化
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // key已存在,覆盖value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
return null;
}
树化的两个条件
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) {
// 链表→红黑树转换...
}
}
为什么需要MIN_TREEIFY_CAPACITY=64?
- 数组小时,哈希冲突概率高,树化后很快又退化,白费功夫
- 扩容能把元素分散到更多桶中,从根本上减少冲突
- 经验值:数组≥64后,如果某桶仍有8+元素,说明确实需要树化
扩容机制
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) {
// 已有数据,容量翻倍
newCap = oldCap << 1; // 2倍扩容
newThr = oldThr << 1; // 阈值也翻倍
} else if (oldThr > 0) {
// 指定初始容量的构造器
newCap = oldThr;
} else {
// 无参构造,使用默认值
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// 迁移数据------JDK8优化
// 关键优化:e.hash & oldCap 判断节点在新数组中的位置
// 如果结果为0,留在原位置(index)
// 如果不为0,移动到新位置(index + oldCap)
}
JDK8扩容的巧妙优化:
扩容前:容量16,hash=21
index = 21 & 15 = 5(二进制0101)
扩容后:容量32
index = 21 & 31 = 21(二进制10101)或 5(二进制00101)
判断方法:21 & 16 = 16 ≠ 0 → 移动到 index + oldCap = 5 + 16 = 21
如果 hash & 16 == 0 → 留在原位index=5
这避免了重新计算hash,一次位运算即可确定位置!
JDK7并发扩容死循环
java
// JDK7 头插法 + 并发扩容 = 环形链表
// 线程A和线程B同时扩容,假设链表顺序为 A→B→null
// 线程A执行到:newTable[i] = e(指向A),e = e.next(指向B),被挂起
// 线程B完成扩容:头插法导致链表反转为 B→A→null
// 线程A恢复执行:
// 1. e=B, next=A(从原链表取的)
// 2. B插入新表头:B→A(头插法)
// 3. e=A, next=B(B已在步骤2中指向A)
// 4. A插入新表头:A→B
// 5. e=B, next=null → B.next=A(步骤4)
// 结果:A→B→A→B→... 环形链表!get时死循环!
面试追问:JDK8为什么改用尾插法?
尾插法保持链表原有顺序,扩容迁移时不会反转,因此不会产生环形链表。但JDK8的HashMap仍然是线程不安全的------并发put可能导致数据覆盖(两个线程同时判断桶为空,后写入的覆盖先写入的)。
负载因子为什么是0.75
这是空间利用率 与哈希冲突率的平衡点:
| 负载因子 | 空间利用率 | 哈希冲突率 | 适用场景 |
|---|---|---|---|
| 0.5 | 低(频繁扩容) | 低 | 查询频繁 |
| 0.75 | 适中 | 适中 | 通用场景 |
| 1.0 | 高 | 高 | 内存紧张 |
泊松分布计算:在负载因子0.75下,每个桶出现8个以上元素的概率约为0.00000006,这也是树化阈值选择8的统计学依据。
2.5 ConcurrentHashMap演进
面试官怎么问
"ConcurrentHashMap怎么保证线程安全?JDK7和JDK8有什么区别?"
JDK7:分段锁(Segment)
java
// JDK7 核心结构
public class ConcurrentHashMap<K, V> {
final Segment<K,V>[] segments; // 分段数组
static final class Segment<K,V> extends ReentrantLock {
transient HashEntry<K,V>[] table; // 每段独立的哈希表
}
}
// 默认16个Segment,并发度=16
// put时:先定位Segment(段级hash),再lock Segment,再操作HashEntry
// get时:HashEntry的value是volatile的,无需加锁
JDK7的缺点:
- 并发度固定为16,无法扩展
- Segment内仍然是全局锁,冲突严重时性能退化
- 每个Segment独立扩容,内存碎片化
- 代码复杂,维护成本高
JDK8:CAS + synchronized
java
// JDK8 核心结构
public class ConcurrentHashMap<K,V> {
transient volatile Node<K,V>[] table; // 单一桶数组
static class Node<K,V> {
final int hash;
final K key;
volatile V val; // volatile保证可见性
volatile Node<K,V> next; // volatile保证可见性
}
}
put流程(JDK8):
1. 计算hash,定位桶
2. 桶为空?→ CAS插入新节点(无锁!)
3. 桶正在扩容?→ 协助扩容
4. 桶不为空?→ synchronized锁定头节点
4a. 链表→遍历插入
4b. 红黑树→树插入
5. 判断是否需要扩容
java
// JDK8 put核心逻辑(简化)
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 不允许null key/value
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 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; // CAS插入成功
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 协助扩容
else {
synchronized (f) { // 锁定头节点
// 链表/红黑树插入...
}
}
}
addCount(1L, binCount); // 更新计数
return null;
}
size()方法的实现
java
// JDK8:LongAdder思想
// baseCount:基础计数器
// CounterCell[]:分散计数器(高并发时使用)
private transient volatile CounterCell[] counterCells;
private transient volatile long baseCount;
// size() = baseCount + 所有CounterCell的值
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] as = counterCells;
long sum = baseCount;
if (as != null) {
for (CounterCell a : as) {
if (a != null)
sum += a.value;
}
}
return sum;
}
为什么用LongAdder而不是AtomicLong?
- 高并发下AtomicLong的CAS竞争激烈,性能下降
- LongAdder将竞争分散到多个Cell,每个线程更新不同的Cell,最后求和
- 代价:size()不是强一致的(但并发场景下通常不需要强一致)
ConcurrentHashMap为什么不允许null
java
// 如果允许null value,会有歧义:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("key", null);
map.get("key"); // 返回null
// 这里的null有两个含义:
// 1. key存在,value是null
// 2. key不存在(在并发环境下,get和containsKey之间可能被其他线程修改)
// HashMap单线程下可以用containsKey区分,但CHM多线程下无法保证
// 所以直接禁止null,消歧义
2.6 TreeMap与红黑树
面试官怎么问
"TreeMap的底层是什么?什么场景用TreeMap?"
TreeMap基于红黑树实现,所有操作O(log n),key必须实现Comparable或传入Comparator。
java
// TreeMap核心操作
TreeMap<String, Integer> map = new TreeMap<>();
map.put("banana", 2);
map.put("apple", 1);
map.put("cherry", 3);
// 自动排序
map.firstKey(); // "apple"
map.lastKey(); // "cherry"
map.subMap("apple", "cherry"); // {"apple"=1, "banana"=2}
// 导航方法(面试亮点)
map.lowerKey("banana"); // "apple"(严格小于)
map.floorKey("banana"); // "banana"(小于等于)
map.higherKey("banana"); // "cherry"(严格大于)
map.ceilingKey("banana"); // "banana"(大于等于)
HashMap vs TreeMap选型:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 通用键值存储 | HashMap | O(1)查找,性能最优 |
| 需要排序遍历 | TreeMap | 自动排序 |
| 范围查询 | TreeMap | subMap/headMap/tailMap |
| 需要排名/TopK | TreeMap | 导航方法 |
2.7 LinkedHashMap------插入序/访问序/LRU缓存
面试官怎么问
"LinkedHashMap怎么实现LRU缓存?"
双向链表维护顺序
java
public class LinkedHashMap<K,V> extends HashMap<K,V> {
// 双向链表头尾
transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;
// 排序模式:true=访问序,false=插入序
final boolean accessOrder;
// 关键钩子方法:afterNodeAccess(访问后移到链表尾部)
// 关键钩子方法:afterNodeInsertion(插入后判断是否删除最老元素)
}
访问序模式
java
// accessOrder=true时,get/put会将访问的节点移到链表尾部
LinkedHashMap<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true);
lru.put("A", 1);
lru.put("B", 2);
lru.put("C", 3);
// 链表顺序:A→B→C
lru.get("A"); // A被访问,移到尾部
// 链表顺序:B→C→A
lru.put("D", 4); // D插入到尾部
// 链表顺序:B→C→A→D
手写LRU缓存
java
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当size超过capacity时,自动删除最老的entry(链表头部)
return size() > capacity;
}
}
// 使用
LRUCache<String, String> cache = new LRUCache<>(3);
cache.put("A", "valueA");
cache.put("B", "valueB");
cache.put("C", "valueC");
cache.get("A"); // A移到尾部:B→C→A
cache.put("D", "valueD"); // 超过容量3,删除B:C→A→D
cache.get("B"); // 返回null
面试追问:为什么LinkedHashMap能做LRU?
核心是accessOrder=true模式。每次get/put都把访问的节点移到链表尾部,而链表头部就是最久未访问的。重写removeEldestEntry在size超限时自动淘汰头部。这比手动维护双向链表+HashMap简洁得多。
2.8 HashSet/LinkedHashSet------HashMap的包装器
java
// HashSet源码:本质是HashMap的keySet
public class HashSet<E> extends AbstractSet<E> {
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object(); // 占位value
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}
所以HashSet的所有特性(扩容、树化、线程不安全)都来自HashMap。
2.9 集合选型决策树
需要键值对?──是──→ Map
│ ├── 需要线程安全?→ ConcurrentHashMap
│ ├── 需要排序?→ TreeMap
│ ├── 需要插入序/访问序?→ LinkedHashMap
│ └── 通用→ HashMap
│
否
↓
需要唯一性?──是──→ Set
│ ├── 需要排序?→ TreeSet
│ ├── 需要插入序?→ LinkedHashSet
│ └── 通用→ HashSet
│
否
↓
需要队列语义?──是──→ Queue/Deque
│ ├── 需要优先级?→ PriorityQueue
│ └── 双端队列→ ArrayDeque
│
否
↓
需要随机访问?──是──→ ArrayList
否
↓
频繁头部插入?──是──→ LinkedList
否
↓
ArrayList(默认选择)
面试连环追问实战
追问链1:HashMap
Q1:HashMap的hash方法为什么先异或高位?
定位桶只用hash低位((n-1)&hash),如果两个key高位不同但低位相同就会冲突。异或高位让高位信息参与低位计算,减少冲突。
Q2:为什么HashMap线程不安全?具体场景?
三个场景:①并发put数据覆盖(两个线程同时判断桶为空)②并发put+get数据不一致(一个线程put另一个get可能看到半完成状态)③JDK7并发扩容死循环。注意JDK8改为尾插法解决了死循环,但数据覆盖问题仍然存在。
Q3:HashMap的key可以是null吗?
可以。HashMap专门处理null key,hash值固定为0,存放在table0桶中。但ConcurrentHashMap不允许null key/value。
追问链2:ConcurrentHashMap
Q1:ConcurrentHashMap的size()方法是精确的吗?
不保证强一致。size()基于baseCount + CounterCell求和,在高并发下可能略有误差。但这在绝大多数场景下是可接受的------你通常只需要"大概多少条数据"而非精确值。
Q2:为什么JDK8用synchronized而不用ReentrantLock?
三个原因:①JDK6+对synchronized做了锁升级优化(偏向锁→轻量锁→重量锁),在细粒度短持有场景下性能不比ReentrantLock差 ②synchronized是JVM内置锁,不需要额外的对象头和AQS节点,内存更紧凑 ③synchronized在锁升级失败时自动膨胀,不需要手动控制。
常见坑点与最佳实践
坑点1:遍历时修改集合
java
// ConcurrentModificationException!
for (String key : map.keySet()) {
if (key.equals("remove_me")) {
map.remove(key); // fail-fast机制抛出异常
}
}
// 正确方式1:使用Iterator
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
if (it.next().equals("remove_me")) {
it.remove(); // Iterator的remove是安全的
}
}
// 正确方式2:使用removeIf(Java8+)
map.keySet().removeIf(key -> key.equals("remove_me"));
// 正确方式3:使用ConcurrentHashMap(弱一致迭代器)
坑点2:HashMap初始容量设置
java
// 如果你知道要放1000个元素,怎么设置初始容量?
// 错误:new HashMap<>(1000)
// 因为1000 * 0.75 = 750,在放入第751个元素时就会触发扩容
// 正确:计算 threshold > 1000 的最小2的幂
new HashMap<>(1000 / 0.75 + 1); // → 1334 → 取最近的2的幂2048
// 或者使用Guava:Maps.newHashMapWithExpectedSize(1000)
坑点3:subList的坑
java
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List<Integer> sub = list.subList(1, 3); // [2, 3]
// 坑1:subList是原List的视图,修改sub会影响原List
sub.set(0, 99);
// list: [1, 99, 3, 4, 5]
// 坑2:修改原List结构后使用subList会抛ConcurrentModificationException
list.add(6);
sub.get(0); // ConcurrentModificationException!
总结与复习题
核心知识图谱
Java集合框架
├── List
│ ├── ArrayList → 数组+1.5倍扩容+transient序列化优化
│ └── LinkedList → 双向链表+Deque接口
├── Set → HashMap包装器
├── Map
│ ├── HashMap → 数组+链表+红黑树
│ │ ├── hash扰动:h^(h>>>16)
│ │ ├── 树化条件:链表≥8 且 数组≥64
│ │ ├── 扩容:2倍 + e.hash&oldCap优化
│ │ └── JDK7头插法死循环 → JDK8尾插法
│ ├── LinkedHashMap → HashMap+双向链表(LRU)
│ └── ConcurrentHashMap → CAS+synchronized
│ ├── JDK7:Segment分段锁
│ ├── JDK8:CAS初始化+synchronized桶锁
│ └── size():LongAdder思想
└── Queue → ArrayDeque/PriorityQueue
复习题
- HashMap的put完整流程是什么?从计算hash到判断扩容,每一步都要说清楚。
- HashMap为什么线程不安全?JDK7和JDK8的具体问题分别是什么?
- ConcurrentHashMap JDK8的put流程是什么?CAS和synchronized分别用在哪些步骤?
- ArrayList的扩容机制是怎样的?为什么elementData用transient修饰?
- 如何用LinkedHashMap实现LRU缓存?为什么accessOrder=true能做到?
- HashMap的容量为什么必须是2的幂次方?扩容时如何高效确定节点新位置?
下一章预告:第3章将深入泛型、反射与动态代理------从类型擦除的底层机制到JDK Proxy和CGLIB的源码对比,再到Spring AOP如何利用动态代理,帮你彻底理解Java元编程的核心原理。