Java集合篇———Map

25 阅读55分钟

第1章:Map集合概述

1.1 Map接口体系架构

1.1.1 Map接口定义与核心方法

Map是Java集合框架中用于存储**键值对(Key-Value)**的接口,它提供了从键到值的映射关系。与Collection接口不同,Map存储的是成对的数据。

Map接口的核心特点
  • 键值对映射: 每个元素由键(Key)和值(Value)组成
  • 键唯一性: 每个键最多只能映射到一个值
  • 值可重复: 不同的键可以映射到相同的值
  • 无序性: 大多数Map实现不保证元素的顺序(TreeMap和LinkedHashMap除外)
核心方法概览
public interface Map<K, V> {
    // 基本操作
    V put(K key, V value);              // 添加键值对
    V get(Object key);                  // 根据键获取值
    V remove(Object key);               // 删除指定键的映射
    boolean containsKey(Object key);    // 是否包含指定键
    boolean containsValue(Object value); // 是否包含指定值
    
    // 集合视图
    Set<K> keySet();                    // 返回所有键的Set视图
    Collection<V> values();             // 返回所有值的Collection视图
    Set<Map.Entry<K, V>> entrySet();   // 返回所有键值对的Set视图
    
    // 批量操作
    void putAll(Map<? extends K, ? extends V> m); // 批量添加
    void clear();                       // 清空所有映射
    
    // 查询操作
    int size();                         // 返回键值对数量
    boolean isEmpty();                  // 是否为空
}

1.1.2 Map vs Collection:设计哲学差异

存储方式差异

Collection接口:

  • 存储单个元素
  • 元素之间是独立的
  • 通过索引或迭代器访问

Map接口:

  • 存储键值对
  • 通过键来访问值
  • 键是访问值的唯一标识
使用场景对比
// Collection:存储学生姓名列表
List<String> studentNames = new ArrayList<>();
studentNames.add("张三");
studentNames.add("李四");

// Map:存储学生ID到姓名的映射
Map<Integer, String> studentMap = new HashMap<>();
studentMap.put(1001, "张三");
studentMap.put(1002, "李四");

// 通过ID快速查找姓名
String name = studentMap.get(1001); // "张三"

1.1.3 Map接口的继承体系

Map (接口)
├── SortedMap (接口) - 有序Map
│   └── NavigableMap (接口) - 可导航的有序Map
│       └── TreeMap (实现类) - 红黑树实现
│
├── HashMap (实现类) - 哈希表实现
│   └── LinkedHashMap (实现类) - 有序的HashMap
│
├── Hashtable (实现类) - 线程安全的哈希表(已淘汰)
│   └── Properties (实现类) - 配置文件专用
│
└── ConcurrentHashMap (实现类) - 线程安全的HashMap
主要实现类特点
实现类数据结构有序性线程安全null键值时间复杂度
HashMap数组+链表/红黑树无序允许O(1)平均
LinkedHashMap数组+链表+双向链表有序允许O(1)平均
TreeMap红黑树有序不允许O(log n)
Hashtable数组+链表无序不允许O(1)平均
ConcurrentHashMap数组+链表/红黑树无序不允许O(1)平均

1.2 Map集合分类

1.2.1 按有序性分类

无序Map
  • HashMap: 不保证元素的顺序
  • Hashtable: 不保证元素的顺序
  • ConcurrentHashMap: 不保证元素的顺序
有序Map
  • LinkedHashMap: 维护插入顺序或访问顺序
  • TreeMap: 根据键的自然顺序或Comparator排序
// HashMap:无序
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("C", 3);
hashMap.put("A", 1);
hashMap.put("B", 2);
System.out.println(hashMap); // 输出顺序不确定

// LinkedHashMap:保持插入顺序
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("C", 3);
linkedMap.put("A", 1);
linkedMap.put("B", 2);
System.out.println(linkedMap); // {C=3, A=1, B=2}

// TreeMap:按键排序
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("C", 3);
treeMap.put("A", 1);
treeMap.put("B", 2);
System.out.println(treeMap); // {A=1, B=2, C=3}

1.2.2 按线程安全分类

非线程安全
  • HashMap: 性能最好,单线程场景首选
  • LinkedHashMap: 有序的HashMap
  • TreeMap: 有序的Map
线程安全
  • Hashtable: 使用synchronized,性能较差,已淘汰
  • ConcurrentHashMap: 使用CAS+synchronized,性能优秀,推荐使用
// 非线程安全:性能好
Map<String, Integer> map = new HashMap<>();

// 线程安全:使用ConcurrentHashMap(推荐)
Map<String, Integer> safeMap = new ConcurrentHashMap<>();

// 线程安全:使用Collections.synchronizedMap(不推荐)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

1.2.3 按null值支持分类

允许null键值
  • HashMap: 允许一个null键和多个null值
  • LinkedHashMap: 允许一个null键和多个null值
不允许null键值
  • TreeMap: 不允许null键(会抛出NullPointerException)
  • Hashtable: 不允许null键和null值
  • ConcurrentHashMap: 不允许null键和null值
// HashMap:允许null
Map<String, Integer> map = new HashMap<>();
map.put(null, 1);        // 允许
map.put("key", null);    // 允许

// TreeMap:不允许null键
Map<String, Integer> treeMap = new TreeMap<>();
// treeMap.put(null, 1); // 抛出NullPointerException

1.3 Map核心概念

1.3.1 Key-Value映射关系

Map的核心是键值对映射,每个键(Key)唯一对应一个值(Value)。

映射关系的特点
  • 一对一映射: 一个键只能映射到一个值
  • 键唯一性: 如果put相同的键,会覆盖旧值
  • 值可重复: 不同的键可以映射到相同的值
Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
map.put("A", 3);  // 覆盖之前的A=1,现在A=3

System.out.println(map.get("A")); // 3
System.out.println(map.size());   // 2(只有A和B)

1.3.2 哈希冲突与解决策略

什么是哈希冲突?

当两个不同的键计算出相同的哈希值时,就发生了哈希冲突

// 假设hashCode()计算出的值相同
String key1 = "Aa";
String key2 = "BB";
// 如果它们的hashCode()相同,就会发生冲突
解决哈希冲突的策略

1. 链地址法(拉链法)

  • HashMap、Hashtable、ConcurrentHashMap使用
  • 在冲突位置维护一个链表
  • JDK8中,链表长度超过8时转为红黑树

2. 开放地址法

  • 线性探测、二次探测等
  • Java的Map实现不使用此方法

3. 再哈希法

  • 使用多个哈希函数
  • 复杂度较高,Java不使用

1.3.3 负载因子与扩容机制

负载因子(Load Factor)

负载因子是衡量哈希表填充程度的指标:

负载因子 = 元素数量 / 容量

默认负载因子:0.75

  • 为什么是0.75?
    • 空间与时间的权衡
    • 0.75是经过大量测试得出的最佳值
    • 太小:浪费空间,频繁扩容
    • 太大:哈希冲突增多,性能下降
扩容机制

当元素数量超过 容量 × 负载因子 时,触发扩容:

// 默认容量16,负载因子0.75
// 当元素数量 > 16 × 0.75 = 12 时,触发扩容
// 扩容后容量变为 16 × 2 = 32

扩容特点:

  • 容量翻倍(2倍扩容)
  • 重新计算所有元素的位置
  • 性能开销较大,应尽量避免频繁扩容

📊 本章总结

核心要点:

  1. Map存储键值对,键唯一,值可重复
  2. 主要实现类:HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap
  3. 哈希冲突通过链地址法解决
  4. 负载因子0.75是空间与时间的最佳平衡点

选择建议:

  • 一般场景:使用HashMap
  • 需要有序:使用LinkedHashMap或TreeMap
  • 需要线程安全:使用ConcurrentHashMap
  • 需要排序:使用TreeMap

第2章:HashMap深度剖析

2.1 HashMap基础原理

2.1.1 数据结构演进

JDK7:数组 + 链表

JDK7中的HashMap使用数组+链表的结构:

数组索引:  0    1    2    3    4
          ↓    ↓    ↓    ↓    ↓
          null 链表  null 链表  null
                ↓
              Node1 -> Node2 -> Node3

特点:

  • 数组存储链表的头节点
  • 哈希冲突时,在对应位置形成链表
  • 链表采用头插法(新节点插入链表头部)
JDK8:数组 + 链表/红黑树

JDK8中引入了红黑树优化:

数组索引:  0    1    2    3    4
          ↓    ↓    ↓    ↓    ↓
          null 链表  null 红黑树 null
                ↓
              Node1 -> Node2

优化点:

  • 链表长度超过8时,转为红黑树
  • 红黑树节点数小于6时,退化为链表
  • 链表采用尾插法(新节点插入链表尾部)

为什么引入红黑树?

当哈希冲突严重时,链表会变得很长,查找性能从O(1)退化为O(n)。红黑树可以将查找性能保持在O(log n)。

2.1.2 核心参数详解

默认初始容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16

为什么是16?

  • 2的幂次方,便于位运算优化
  • 经过测试,16是一个平衡点
  • 太小:频繁扩容
  • 太大:浪费内存

为什么必须是2的幂?

// 计算数组下标:使用位运算,性能高
int index = (n - 1) & hash;

// 如果n是2的幂,n-1的二进制全是1
// 例如:16-1=15,二进制是1111
// 这样&运算可以均匀分布
负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

为什么是0.75?

这是空间与时间的权衡

  • 0.75的数学依据:

    • 泊松分布计算得出
    • 在0.75时,哈希冲突的概率较低
    • 空间利用率较高
  • 如果设置为1.0:

    • 空间利用率最高
    • 但哈希冲突增多,性能下降
  • 如果设置为0.5:

    • 哈希冲突少,性能好
    • 但空间浪费,频繁扩容
链表转红黑树阈值:8
static final int TREEIFY_THRESHOLD = 8;

为什么是8?

基于泊松分布的概率计算:

当负载因子为0.75时,链表长度为8的概率约为0.00000006

这意味着:

  • 正常情况下,链表长度很少超过8
  • 如果超过8,说明哈希冲突严重,需要红黑树优化
红黑树转链表阈值:6
static final int UNTREEIFY_THRESHOLD = 6;

为什么是6而不是8?

防止频繁转换:

  • 如果阈值也是8,在8附近会频繁转换
  • 设置为6,提供2的缓冲区间
  • 避免在临界值附近频繁转换
最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

为什么是2^30?

  • int类型的最大值是2^31-1
  • 2^30是最大的2的幂次方
  • 保证容量始终是2的幂

2.2 HashMap核心方法实现

2.2.1 hash()方法:扰动函数

实现原理
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扰动函数的作用:

将key的hashCode()进行扰动,减少哈希冲突。

为什么需要扰动?

问题:

  • hashCode()可能分布不均匀
  • 如果直接使用,低位可能相同,导致冲突

解决方案:

  • 将高16位与低16位进行异或运算
  • 让高位也参与计算,使分布更均匀

示例:

// 假设hashCode() = 0x12345678
int h = 0x12345678;

// 右移16位:0x00001234
int high = h >>> 16;

// 异或运算:0x12345678 ^ 0x00001234 = 0x1234444C
int hash = h ^ high;

// 这样高位和低位都参与了计算

2.2.2 put()方法完整流程

put()方法执行步骤
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

完整流程:

  1. 计算hash值

    int hash = hash(key);
    
  2. 定位数组下标

    int index = (n - 1) & hash;
    
  3. 检查数组位置

    • 如果为空:直接插入
    • 如果不为空:处理冲突
  4. 处理哈希冲突

    • 如果是链表:遍历查找,找到则更新,否则插入
    • 如果是红黑树:在树中查找或插入
  5. 检查是否需要扩容

    • 如果元素数量 > 容量 × 负载因子:触发扩容
  6. 检查是否需要转红黑树

    • 如果链表长度 >= 8:转为红黑树
流程图
put(key, value)
    ↓
计算hash值
    ↓
定位数组下标: (n-1) & hash
    ↓
数组位置是否为空?
    ├─ 是 → 直接插入Node
    └─ 否 → 检查Node类型
            ├─ 链表 → 遍历查找/插入
            └─ 红黑树 → 树中查找/插入
    ↓
检查是否需要扩容
    ↓
检查是否需要转红黑树
    ↓
完成

2.2.3 get()方法实现

get()方法流程
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

查找步骤:

  1. 计算hash值
  2. 定位数组下标
  3. 检查第一个节点
    • 如果匹配:直接返回
    • 如果不匹配:继续查找
  4. 在链表或红黑树中查找

时间复杂度:

  • 最好情况:O(1) - 数组位置直接命中
  • 平均情况:O(1) - 链表长度较短
  • 最坏情况:O(log n) - 红黑树查找

2.2.4 resize()扩容机制

扩容触发条件
if (++size > threshold) {
    resize();
}

触发条件:

  • size > capacity × loadFactor
  • 例如:16 × 0.75 = 12,当元素数量超过12时触发
扩容大小
newCap = oldCap << 1;  // 容量翻倍

扩容特点:

  • 容量变为原来的2倍
  • 例如:16 → 32 → 64 → 128
JDK8扩容优化:高位低位链表拆分

JDK7的问题:

  • 扩容时需要重新计算所有元素的位置
  • 所有元素都要重新hash

JDK8的优化:

  • 利用hash值的特性
  • 将链表拆分为高位链表低位链表
  • 只需要判断hash值的某一位即可

优化原理:

// 假设原容量是16,扩容后是32
// 16的二进制:10000
// 32的二进制:100000

// 判断hash值的第5位(从右往左)
// 如果为0:在新数组的相同位置(低位)
// 如果为1:在新数组的 原位置+16 位置(高位)

int newIndex = (hash & oldCap) == 0 ? oldIndex : oldIndex + oldCap;

优势:

  • 不需要重新计算hash值
  • 只需要判断一位即可
  • 性能提升明显

2.3 JDK7 vs JDK8实现差异

2.3.1 数据结构差异

特性JDK7JDK8
数据结构数组+链表数组+链表/红黑树
链表转树不支持长度>8时转红黑树
性能优化红黑树优化查找

性能提升:

  • 正常情况:性能相同
  • 哈希冲突严重时:JDK8性能更好(O(log n) vs O(n))

2.3.2 插入方式差异

JDK7:头插法
// 新节点插入链表头部
newNode.next = table[index];
table[index] = newNode;

问题:

  • 在多线程环境下可能导致死循环
  • 扩容时链表顺序反转
JDK8:尾插法
// 新节点插入链表尾部
Node last = table[index];
while (last.next != null) {
    last = last.next;
}
last.next = newNode;

优势:

  • 避免死循环问题
  • 保持插入顺序

2.3.3 扩容机制优化

JDK7扩容问题
  • 需要重新计算所有元素的hash值
  • 所有元素都要重新定位
  • 性能开销大
JDK8优化策略
  • 高位低位链表拆分
  • 不需要重新计算hash值
  • 只需要判断一位即可
  • 性能提升明显

2.4 线程安全问题

2.4.1 JDK7死链问题详解

问题场景

在多线程环境下,两个线程同时进行扩容操作时,可能导致死循环

问题原因

头插法导致的问题:

  1. 线程A和线程B同时检测到需要扩容
  2. 两个线程都开始扩容
  3. 在扩容过程中,链表被反转
  4. 形成循环链表,导致死循环

示例:

原链表:A -> B -> C

线程A扩容:C -> B -> A
线程B同时操作,形成循环:A -> B -> C -> A(死循环)
如何避免?
  • 使用线程安全的Map:ConcurrentHashMap
  • 使用Collections.synchronizedMap()
  • 单线程环境下使用HashMap

2.4.2 JDK8数据覆盖问题

JDK8修复了死循环,但仍不安全

JDK8的问题:

  1. 数据覆盖:

    // 线程A和线程B同时put相同的key
    // 可能只有一个线程的值被保存,另一个被覆盖
    
  2. 数据不一致:

    // 线程A put,线程B get
    // 可能读到不一致的数据
    
为什么HashMap线程不安全?
  • 没有同步机制: 多线程同时修改会导致数据不一致
  • 非原子操作: put操作不是原子性的
  • 可见性问题: 没有volatile保证可见性
如何保证线程安全?

方案1:使用ConcurrentHashMap(推荐)

Map<String, Integer> map = new ConcurrentHashMap<>();

方案2:使用Collections.synchronizedMap()

Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

方案3:使用Hashtable(不推荐,性能差)

Map<String, Integer> map = new Hashtable<>();

📊 本章总结

核心要点:

  1. HashMap使用数组+链表/红黑树结构
  2. 默认容量16,负载因子0.75
  3. 链表长度>8转红黑树,<6退化为链表
  4. JDK8优化了扩容机制,性能更好
  5. HashMap线程不安全,多线程使用ConcurrentHashMap

关键参数记忆:

  • 初始容量:16
  • 负载因子:0.75
  • 转树阈值:8
  • 退链阈值:6
  • 最大容量:2^30

第3章:ConcurrentHashMap深度剖析

3.1 JDK7分段锁实现

3.1.1 Segment数组结构

分段锁的设计思想

JDK7的ConcurrentHashMap使用**分段锁(Segment)**来实现线程安全:

ConcurrentHashMap
    ↓
Segment数组(16个Segment)
    ↓
每个Segment内部是一个HashMap

结构示意:

Segment[0] -> HashMap (锁0)
Segment[1] -> HashMap (锁1)
Segment[2] -> HashMap (锁2)
...
Segment[15] -> HashMap (锁15)
Segment继承ReentrantLock
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // 每个Segment内部维护一个HashMap
    transient volatile HashEntry<K,V>[] table;
    // ...
}

特点:

  • 每个Segment是一个独立的锁
  • 不同Segment的操作可以并发进行
  • 同一个Segment的操作需要加锁

3.1.2 核心方法实现

put()方法:分段加锁
public V put(K key, V value) {
    Segment<K,V> s;
    // 计算key属于哪个Segment
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE);
    
    // 对Segment加锁
    s.lock();
    try {
        // 在Segment内部的HashMap中put
        // ...
    } finally {
        s.unlock();
    }
}

执行流程:

  1. 计算key的hash值
  2. 确定key属于哪个Segment
  3. 对Segment加锁
  4. 在Segment内部的HashMap中操作
  5. 释放锁
get()操作的无锁实现
public V get(Object key) {
    Segment<K,V> s;
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    
    // 使用volatile读,不需要加锁
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        // 在Segment中查找
        // ...
    }
    return null;
}

为什么get()不需要加锁?

  • 使用volatile保证可见性
  • 读操作不会修改数据
  • 多线程读是安全的
size()方法:分段统计
public int size() {
    final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow;
    long sum;
    long last = 0L;
    int retries = -1;
    
    try {
        for (;;) {
            // 尝试不加锁统计
            // 如果统计过程中有修改,重试
            // ...
        }
    } finally {
        // ...
    }
}

特点:

  • 先尝试不加锁统计
  • 如果统计过程中有修改,重试
  • 多次失败后,对所有Segment加锁统计

3.1.3 优缺点分析

分段锁的优势
  • 并发度高: 不同Segment可以并发操作
  • 锁粒度小: 只锁住一个Segment,不是整个Map
  • 性能好: 在并发场景下性能优于Hashtable
分段锁的局限性
  • 复杂度高: Segment数组增加了复杂度
  • 内存占用: 每个Segment都需要维护一个HashMap
  • 锁竞争: 如果所有操作都在同一个Segment,性能会下降

3.2 JDK8 CAS+synchronized优化

3.2.1 数据结构变化

抛弃Segment,使用Node数组

JDK8的ConcurrentHashMap抛弃了Segment,直接使用Node数组,结构更接近HashMap:

// JDK8的ConcurrentHashMap结构
transient volatile Node<K,V>[] table;

与HashMap的相似性:

  • 都使用数组+链表/红黑树
  • 都使用Node节点
  • 结构几乎相同

关键区别:

  • ConcurrentHashMap使用volatile保证可见性
  • 使用CAS和synchronized保证线程安全

3.2.2 线程安全机制

CAS实现无锁化插入

CAS(Compare And Swap):

  • 无锁的原子操作
  • 性能优于synchronized
  • 适合并发度高的场景
// 使用CAS尝试插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
    break;

CAS的优势:

  • 无锁操作,性能好
  • 适合并发插入
  • 失败后可以重试
synchronized锁链表头/树根节点

当CAS失败时(位置已有节点),使用synchronized加锁:

synchronized (f) {
    // f是链表头节点或红黑树根节点
    // 在锁内进行操作
}

为什么只锁节点?

  • 锁粒度更小
  • 不同位置的节点可以并发操作
  • 性能更好
volatile保证可见性
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;        // volatile保证可见性
    volatile Node<K,V> next; // volatile保证可见性
}

volatile的作用:

  • 保证多线程之间的可见性
  • get()操作不需要加锁
  • 性能优化

3.2.3 核心方法实现

put()方法流程

完整流程:

  1. 计算hash值
  2. 定位数组下标
  3. CAS尝试插入
    • 如果位置为空:CAS插入
    • 如果成功:完成
    • 如果失败:继续
  4. synchronized加锁插入
    • 锁住链表头或树根
    • 在锁内插入或更新
  5. 检查是否需要转红黑树
  6. 检查是否需要扩容

流程图:

put(key, value)
    ↓
计算hash值
    ↓
定位数组下标
    ↓
位置是否为空?
    ├─ 是 → CAS插入 → 成功?
    │              ├─ 是 → 完成
    │              └─ 否 → synchronized加锁插入
    └─ 否 → synchronized加锁插入
    ↓
检查转红黑树
    ↓
检查扩容
    ↓
完成
get()方法实现
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 检查第一个节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 在链表或红黑树中查找
        // ...
    }
    return null;
}

为什么get()不需要加锁?

  • volatile保证可见性: Node的val和next都是volatile
  • 读操作安全: 读操作不会修改数据
  • 性能优化: 无锁读操作性能好
size()方法实现
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

LongAdder思想:

  • baseCount: 基础计数
  • CounterCell[]: 分段计数数组
  • 多线程并发计数: 每个线程更新自己的CounterCell
  • 最终统计: 将所有CounterCell的值相加

为什么是"近似准确"?

  • 多线程并发更新时,统计可能有延迟
  • 但最终会收敛到准确值
  • 性能优于加锁统计

3.2.4 扩容机制

sizeCtl字段的作用和状态
private transient volatile int sizeCtl;

sizeCtl的含义:

  • 正数: 表示扩容阈值(容量 × 负载因子)
  • -1: 表示正在初始化
  • 负数(-N): 表示有N-1个线程正在扩容
ForwardingNode节点的作用
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }
}

ForwardingNode的作用:

  • 标记节点: 表示该位置的数据已经迁移到新数组
  • 转发查找: get操作遇到ForwardingNode时,到新数组查找
  • 协助扩容: 其他线程看到ForwardingNode时,可以协助扩容
多线程协助扩容(transfer)

扩容流程:

  1. 初始化新数组: 容量为原来的2倍
  2. 分配任务: 将数组分成多个段,每个线程负责一段
  3. 迁移数据: 将旧数组的数据迁移到新数组
  4. 标记完成: 迁移完成后,用ForwardingNode标记

协助扩容机制:

  • 当线程发现正在扩容时,可以协助扩容
  • 提高扩容效率
  • 减少扩容时间

📊 本章总结

核心要点:

  1. JDK7使用分段锁,JDK8使用CAS+synchronized
  2. JDK8结构更简单,性能更好
  3. get()操作无锁,性能优秀
  4. size()使用LongAdder思想,近似准确
  5. 多线程可以协助扩容,提高效率

选择建议:

  • 多线程场景:使用ConcurrentHashMap
  • 高并发读:ConcurrentHashMap性能最好
  • 高并发写:ConcurrentHashMap优于Hashtable

第4章:LinkedHashMap深度剖析

4.1 LinkedHashMap基础原理

4.1.1 数据结构

继承HashMap

LinkedHashMap继承自HashMap,所以它拥有HashMap的所有特性:

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

继承关系:

  • 拥有HashMap的所有功能
  • 在此基础上增加了顺序维护
双向链表维护顺序

LinkedHashMap在HashMap的基础上,增加了双向链表来维护顺序:

HashMap结构:
数组 + 链表/红黑树

LinkedHashMap结构:
数组 + 链表/红黑树 + 双向链表(维护顺序)

双向链表结构:

head ← → Node1 ← → Node2 ← → Node3 ← → tail
Entry节点的扩展
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;  // 双向链表的指针
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

特点:

  • 继承HashMap的Node
  • 增加了before和after指针
  • 维护双向链表

4.1.2 顺序模式

插入顺序(默认)
Map<String, Integer> map = new LinkedHashMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 迭代顺序:C -> A -> B(插入顺序)

特点:

  • 按照元素插入的顺序维护
  • 先插入的元素在前面
  • 默认模式
访问顺序(accessOrder=true)
Map<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);

map.get("A");  // 访问A后,A移动到链表尾部
// 迭代顺序:C -> B -> A(访问顺序)

特点:

  • 按照元素访问的顺序维护
  • 最近访问的元素在链表尾部
  • 适合实现LRU缓存
两种模式的区别
模式accessOrder维护顺序应用场景
插入顺序false(默认)插入顺序需要保持插入顺序
访问顺序true访问顺序LRU缓存

4.2 核心方法实现

4.2.1 put()方法

调用父类HashMap的put()

LinkedHashMap的put()方法直接调用父类HashMap的put():

public V put(K key, V value) {
    return super.put(key, value);
}
维护双向链表

HashMap的put()方法在插入节点后,会调用以下方法(LinkedHashMap重写了):

// 节点插入后调用
void afterNodeInsertion(boolean evict) {
    // 可能移除最老的节点(LRU缓存)
}

// 节点访问后调用
void afterNodeAccess(Node<K,V> e) {
    // 在访问顺序模式下,将节点移到链表尾部
}

维护链表的逻辑:

  • 插入新节点时,添加到链表尾部
  • 访问节点时(accessOrder=true),移到链表尾部
  • 删除节点时,从链表中移除

4.2.2 get()方法

访问顺序模式下的处理
public V get(Object key) {
    Node<K,V> e;
    if ((e = getNode(hash(key), key)) == null)
        return null;
    
    // 如果是访问顺序模式,将节点移到链表尾部
    if (accessOrder)
        afterNodeAccess(e);
    return e.value;
}

afterNodeAccess()方法:

void afterNodeAccess(Node<K,V> e) {
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        // 将节点e移到链表尾部
        // 1. 从原位置移除
        // 2. 添加到链表尾部
    }
}

作用:

  • 在访问顺序模式下,访问节点后将其移到尾部
  • 实现LRU(最近最少使用)策略

4.2.3 removeEldestEntry()方法

实现LRU缓存的关键
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;  // 默认不删除
}

何时调用?

在afterNodeInsertion()方法中:

void afterNodeInsertion(boolean evict) {
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

作用:

  • 插入新节点后,检查是否需要删除最老的节点
  • 重写此方法可以实现LRU缓存

4.3 LRU缓存实现

4.3.1 实现原理

重写removeEldestEntry()
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;  // 超过容量时删除最老的节点
    }
}
设置accessOrder=true
// 第三个参数设置为true,启用访问顺序模式
Map<String, Integer> cache = new LinkedHashMap<>(16, 0.75f, true);
完整的LRU缓存代码
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    
    public LRUCache(int capacity) {
        // 初始容量16,负载因子0.75,访问顺序模式
        super(16, 0.75f, true);
        this.capacity = capacity;
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当元素数量超过容量时,删除最老的节点
        return size() > capacity;
    }
    
    // 使用示例
    public static void main(String[] args) {
        LRUCache<String, Integer> cache = new LRUCache<>(3);
        
        cache.put("A", 1);
        cache.put("B", 2);
        cache.put("C", 3);
        System.out.println(cache); // {A=1, B=2, C=3}
        
        cache.get("A");  // 访问A
        cache.put("D", 4);  // 添加D,B被删除(最老的)
        System.out.println(cache); // {C=3, A=1, D=4}
    }
}

4.3.2 应用场景

缓存系统
  • Web缓存: 缓存最近访问的页面
  • 数据库缓存: 缓存最近查询的结果
  • 对象缓存: 缓存最近使用的对象
最近访问记录
  • 浏览历史: 记录最近访问的页面
  • 搜索历史: 记录最近搜索的关键词
  • 操作历史: 记录最近的操作
性能优化
  • 减少数据库查询: 缓存热点数据
  • 提高响应速度: 快速访问缓存数据
  • 节省内存: 自动淘汰不常用的数据

📊 本章总结

核心要点:

  1. LinkedHashMap继承HashMap,增加双向链表维护顺序
  2. 支持插入顺序和访问顺序两种模式
  3. 访问顺序模式适合实现LRU缓存
  4. 重写removeEldestEntry()可以实现自动淘汰

使用建议:

  • 需要保持插入顺序:使用默认的LinkedHashMap
  • 需要LRU缓存:设置accessOrder=true并重写removeEldestEntry()

第5章:TreeMap深度剖析

5.1 TreeMap基础原理

5.1.1 数据结构

红黑树实现

TreeMap使用红黑树作为底层数据结构:

红黑树特点:
- 自平衡二叉搜索树
- 保证最坏情况下的查找性能为O(log n)
- 插入、删除、查找都是O(log n)

红黑树结构:

        Root
       /    \
    Left   Right
    /  \    /  \
   ...  ... ... ...
有序Map的特点
  • 按键排序: 所有元素按照键的顺序排列
  • 可导航: 提供导航方法(ceilingKey、floorKey等)
  • 范围查询: 支持范围查询(subMap方法)
Entry节点的设计
static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;
    V value;
    Entry<K,V> left;   // 左子节点
    Entry<K,V> right;  // 右子节点
    Entry<K,V> parent; // 父节点
    boolean color = BLACK; // 节点颜色(红/黑)
}

5.1.2 排序规则

自然排序(Comparable)

如果键实现了Comparable接口,使用自然排序:

// String实现了Comparable接口
TreeMap<String, Integer> map = new TreeMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 自动按键的自然顺序排序:{A=1, B=2, C=3}
定制排序(Comparator)

可以通过Comparator指定排序规则:

// 按字符串长度排序
TreeMap<String, Integer> map = new TreeMap<>(
    Comparator.comparing(String::length)
);
map.put("AAA", 1);
map.put("B", 2);
map.put("CC", 3);
// 排序结果:{B=2, CC=3, AAA=1}(按长度)
排序规则的优先级
  1. 如果提供了Comparator: 使用Comparator
  2. 如果键实现了Comparable: 使用自然排序
  3. 否则: 抛出ClassCastException

5.2 核心方法实现

5.2.1 put()方法

红黑树插入
public V put(K key, V value) {
    Entry<K,V> t = root;
    if (t == null) {
        // 第一个节点作为根节点
        root = new Entry<>(key, value, null);
        size = 1;
        modCount++;
        return null;
    }
    
    // 在红黑树中查找插入位置
    int cmp;
    Entry<K,V> parent;
    Comparator<? super K> cpr = comparator;
    if (cpr != null) {
        // 使用Comparator比较
        do {
            parent = t;
            cmp = cpr.compare(key, t.key);
            if (cmp < 0)
                t = t.left;
            else if (cmp > 0)
                t = t.right;
            else
                return t.setValue(value);  // 找到相同key,更新值
        } while (t != null);
    } else {
        // 使用自然排序
        // ...
    }
    
    // 插入新节点
    Entry<K,V> e = new Entry<>(key, value, parent);
    if (cmp < 0)
        parent.left = e;
    else
        parent.right = e;
    
    // 红黑树平衡调整
    fixAfterInsertion(e);
    size++;
    modCount++;
    return null;
}
平衡调整

插入节点后,需要进行红黑树平衡调整

  • 左旋: 调整不平衡的子树
  • 右旋: 调整不平衡的子树
  • 变色: 调整节点颜色

时间复杂度:O(log n)

5.2.2 get()方法

红黑树查找
public V get(Object key) {
    Entry<K,V> p = getEntry(key);
    return (p==null ? null : p.value);
}

final Entry<K,V> getEntry(Object key) {
    if (comparator != null)
        return getEntryUsingComparator(key);
    if (key == null)
        throw new NullPointerException();
    @SuppressWarnings("unchecked")
    Comparable<? super K> k = (Comparable<? super K>) key;
    Entry<K,V> p = root;
    while (p != null) {
        int cmp = k.compareTo(p.key);
        if (cmp < 0)
            p = p.left;
        else if (cmp > 0)
            p = p.right;
        else
            return p;
    }
    return null;
}

查找流程:

  1. 从根节点开始
  2. 比较key与当前节点的key
  3. 小于:向左子树查找
  4. 大于:向右子树查找
  5. 等于:找到,返回
  6. 为null:未找到

时间复杂度:O(log n)

5.2.3 导航方法

ceilingKey():大于等于key的最小key
public K ceilingKey(K key) {
    return keyOrNull(getCeilingEntry(key));
}

示例:

TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "A");
map.put(20, "B");
map.put(30, "C");

map.ceilingKey(15);  // 返回20(大于等于15的最小key)
map.ceilingKey(20);  // 返回20(等于20)
floorKey():小于等于key的最大key
public K floorKey(K key) {
    return keyOrNull(getFloorEntry(key));
}

示例:

map.floorKey(25);  // 返回20(小于等于25的最大key)
map.floorKey(20);  // 返回20(等于20)
higherKey()和lowerKey()
map.higherKey(20);  // 返回30(大于20的最小key)
map.lowerKey(20);   // 返回10(小于20的最大key)
firstKey()和lastKey()
map.firstKey();  // 返回10(最小的key)
map.lastKey();   // 返回30(最大的key)

📊 本章总结

核心要点:

  1. TreeMap使用红黑树实现,保证O(log n)的性能
  2. 支持自然排序和定制排序
  3. 提供丰富的导航方法
  4. 适合需要有序性的场景

使用建议:

  • 需要有序:使用TreeMap
  • 需要范围查询:使用TreeMap
  • 需要导航方法:使用TreeMap
  • 只需要快速查找:使用HashMap

第6章:HashMap vs Hashtable对比

6.1 线程安全性差异

6.1.1 HashMap:非线程安全

特点:

  • 性能最好
  • 单线程场景首选
  • 多线程环境下不安全

使用场景:

  • 单线程环境
  • 局部变量
  • 线程封闭的场景

6.1.2 Hashtable:线程安全(synchronized)

实现方式:

public synchronized V put(K key, V value) {
    // 所有方法都使用synchronized
}

public synchronized V get(Object key) {
    // 所有方法都使用synchronized
}

特点:

  • 所有方法都使用synchronized
  • 锁住整个Hashtable对象
  • 性能较差

问题:

  • 锁粒度太大
  • 并发性能差
  • 已被ConcurrentHashMap替代

6.1.3 性能对比分析

场景HashMapHashtableConcurrentHashMap
单线程最快较快
多线程读不安全最快
多线程写不安全最快

6.2 null值处理差异

6.2.1 HashMap:允许null键值

Map<String, Integer> map = new HashMap<>();
map.put(null, 1);        // 允许
map.put("key", null);    // 允许

设计原因:

  • 灵活性更高
  • 某些场景需要null值

6.2.2 Hashtable:不允许null键值

Map<String, Integer> map = new Hashtable<>();
// map.put(null, 1);     // 抛出NullPointerException
// map.put("key", null);  // 抛出NullPointerException

设计原因:

  • 早期设计,考虑不够完善
  • 多线程环境下,null值处理复杂

6.3 性能对比

6.3.1 单线程性能

HashMap:

  • 性能最好
  • 无锁开销
  • 推荐使用

Hashtable:

  • 性能较差
  • synchronized有开销
  • 不推荐使用

6.3.2 多线程性能

HashMap:

  • 不安全,不能使用

Hashtable:

  • 安全但性能差
  • 锁粒度太大

ConcurrentHashMap:

  • 安全且性能好
  • 推荐使用

6.3.3 为什么Hashtable被淘汰?

主要原因:

  1. 性能差: synchronized锁住整个对象,并发性能差
  2. 设计过时: 早期设计,没有考虑现代并发场景
  3. 有更好的替代: ConcurrentHashMap性能更好
  4. API设计: 方法命名不符合Java规范(没有驼峰命名)

替代方案:

  • 单线程:使用HashMap
  • 多线程:使用ConcurrentHashMap

6.4 继承体系差异

6.4.1 HashMap继承AbstractMap

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

特点:

  • 继承AbstractMap
  • 实现Map接口
  • 符合Java集合框架设计

6.4.2 Hashtable继承Dictionary

public class Hashtable<K,V> extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, Serializable

特点:

  • 继承Dictionary(已废弃)
  • 早期设计,不符合现代Java规范

6.4.3 接口实现差异

相同点:

  • 都实现Map接口
  • 都支持基本的Map操作

不同点:

  • HashMap继承AbstractMap(更现代)
  • Hashtable继承Dictionary(已废弃)

📊 本章总结

核心要点:

  1. HashMap非线程安全,性能最好
  2. Hashtable线程安全但性能差,已淘汰
  3. 多线程场景使用ConcurrentHashMap
  4. HashMap允许null,Hashtable不允许

选择建议:

  • 单线程:HashMap
  • 多线程:ConcurrentHashMap
  • 不要使用:Hashtable

第7章:Map集合性能优化与最佳实践

7.1 性能优化策略

7.1.1 合理设置初始容量

问题:频繁扩容影响性能
// 不好的做法:频繁扩容
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
    map.put("key" + i, i);  // 可能触发多次扩容
}
优化:预分配容量
// 好的做法:预分配容量
Map<String, Integer> map = new HashMap<>(1000);
for (int i = 0; i < 1000; i++) {
    map.put("key" + i, i);  // 避免扩容
}

容量计算:

// 如果知道大概的元素数量
int expectedSize = 1000;
// 考虑负载因子,容量应该是 expectedSize / 0.75
int capacity = (int)(expectedSize / 0.75f) + 1;
Map<String, Integer> map = new HashMap<>(capacity);

7.1.2 选择合适的负载因子

默认负载因子:0.75
// 大多数场景使用默认值即可
Map<String, Integer> map = new HashMap<>();  // 负载因子0.75
特殊场景调整
// 如果内存充足,可以降低负载因子,减少冲突
Map<String, Integer> map = new HashMap<>(16, 0.5f);

// 如果内存紧张,可以提高负载因子,减少空间
Map<String, Integer> map = new HashMap<>(16, 1.0f);

建议:

  • 一般场景:使用默认0.75
  • 特殊场景:根据实际情况调整

7.1.3 实现良好的hashCode()

好的hashCode()实现
public class Student {
    private String name;
    private int age;
    
    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + age;
        return result;
    }
}

原则:

  • 使用31作为乘数(经验值)
  • 包含所有参与equals()的字段
  • 保证相等的对象hashCode()相同
不好的hashCode()实现
// 不好的做法:所有对象hashCode()相同
@Override
public int hashCode() {
    return 1;  // 所有对象hashCode()相同,导致严重冲突
}

问题:

  • 所有元素都在同一个位置
  • 退化为链表,性能O(n)

7.1.4 选择合适的Map实现

选择指南
场景推荐实现原因
一般场景HashMap性能最好
需要有序LinkedHashMap或TreeMap维护顺序
需要排序TreeMap按键排序
多线程ConcurrentHashMap线程安全且性能好
LRU缓存LinkedHashMap支持访问顺序

7.2 常见陷阱与注意事项

7.2.1 可变对象作为key的问题

问题示例
Map<List<String>, Integer> map = new HashMap<>();
List<String> key = new ArrayList<>();
key.add("A");
map.put(key, 1);

key.add("B");  // 修改key
// 现在无法通过key找到值了
Integer value = map.get(key);  // 返回null

原因:

  • 修改key后,hashCode()改变
  • 无法找到原来的位置
  • 导致内存泄漏
解决方案
  • 使用不可变对象作为key: String、Integer等
  • 如果必须使用可变对象: 确保不修改key

7.2.2 重写equals()必须重写hashCode()

问题示例
public class Student {
    private String name;
    
    @Override
    public boolean equals(Object o) {
        // 只重写了equals()
    }
    // 没有重写hashCode()
}

Map<Student, Integer> map = new HashMap<>();
Student s1 = new Student("张三");
Student s2 = new Student("张三");

map.put(s1, 1);
map.get(s2);  // 返回null(因为hashCode()不同)

原因:

  • equals()返回true,但hashCode()不同
  • HashMap使用hashCode()定位,无法找到
解决方案

必须同时重写equals()和hashCode():

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Student student = (Student) o;
    return Objects.equals(name, student.name);
}

@Override
public int hashCode() {
    return Objects.hash(name);
}

7.2.3 并发场景下的选择

错误做法
// 多线程环境下使用HashMap(不安全)
Map<String, Integer> map = new HashMap<>();
// 多线程操作map,可能导致数据丢失或错误
正确做法
// 使用ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
// 线程安全,性能好

7.2.4 null值处理

HashMap允许null
Map<String, Integer> map = new HashMap<>();
map.put(null, 1);  // 允许
ConcurrentHashMap不允许null
Map<String, Integer> map = new ConcurrentHashMap<>();
// map.put(null, 1);  // 抛出NullPointerException

原因:

  • 多线程环境下,null值处理复杂
  • 无法区分"不存在"和"值为null"

7.3 最佳实践

7.3.1 根据场景选择Map实现

选择流程图:

需要Map?
    ↓
需要线程安全?
    ├─ 是 → ConcurrentHashMap
    └─ 否 → 需要有序?
            ├─ 是 → 需要排序?
            │       ├─ 是 → TreeMap
            │       └─ 否 → LinkedHashMap
            └─ 否 → HashMap

7.3.2 性能测试与调优

性能测试
// 测试不同Map实现的性能
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
    map.put("key" + i, i);
}
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
调优建议
  • 预分配容量: 避免频繁扩容
  • 实现好的hashCode(): 减少冲突
  • 选择合适的实现: 根据场景选择
  • 避免不必要的操作: 减少性能开销

7.3.3 代码规范建议

推荐做法
// ✅ 预分配容量
Map<String, Integer> map = new HashMap<>(expectedSize);

// ✅ 使用接口类型
Map<String, Integer> map = new HashMap<>();

// ✅ 使用isEmpty()而不是size() == 0
if (map.isEmpty()) {
    // ...
}
不推荐做法
// ❌ 不预分配容量
Map<String, Integer> map = new HashMap<>();

// ❌ 使用具体类型
HashMap<String, Integer> map = new HashMap<>();

// ❌ 使用size() == 0
if (map.size() == 0) {
    // ...
}

📊 本章总结

核心要点:

  1. 合理设置初始容量,避免频繁扩容
  2. 实现良好的hashCode(),减少冲突
  3. 根据场景选择合适的Map实现
  4. 注意常见陷阱,避免错误

优化建议:

  • 预分配容量
  • 好的hashCode()实现
  • 选择合适的实现类
  • 避免可变对象作为key

第8章:Map集合大厂高频面试题精选

8.1 HashMap核心面试题(30道)

8.1.1 基础原理类(10道)

面试题1:请你说出HashMap的底层数据结构(JDK1.8前后)

答案:

JDK7:数组 + 链表

  • 使用数组存储,每个数组位置是一个链表
  • 哈希冲突时,在对应位置形成链表
  • 链表采用头插法

JDK8:数组 + 链表/红黑树

  • 基本结构与JDK7相同
  • 当链表长度超过8时,转为红黑树
  • 当红黑树节点数小于6时,退化为链表
  • 链表采用尾插法

为什么引入红黑树?

  • 当哈希冲突严重时,链表会变得很长
  • 查找性能从O(1)退化为O(n)
  • 红黑树可以将查找性能保持在O(log n)
面试题2:详细描述一次HashMap put(key, value)方法的执行流程

答案:

完整流程:

  1. 计算hash值

    int hash = hash(key);  // 扰动函数
    
  2. 定位数组下标

    int index = (n - 1) & hash;  // n是数组长度
    
  3. 检查数组位置

    • 如果位置为空:创建新节点,直接插入
    • 如果位置不为空:处理哈希冲突
  4. 处理哈希冲突

    • 如果是链表:遍历查找,找到则更新值,否则插入尾部
    • 如果是红黑树:在树中查找或插入
  5. 检查是否需要转红黑树

    • 如果链表长度 >= 8:转为红黑树
  6. 检查是否需要扩容

    • 如果元素数量 > 容量 × 负载因子:触发扩容

流程图:

put(key, value)
    ↓
计算hash值
    ↓
定位数组下标: (n-1) & hash
    ↓
数组位置是否为空?
    ├─ 是 → 直接插入Node
    └─ 否 → 检查Node类型
            ├─ 链表 → 遍历查找/插入
            └─ 红黑树 → 树中查找/插入
    ↓
检查是否需要转红黑树
    ↓
检查是否需要扩容
    ↓
完成
面试题3:HashMap的hash()方法(扰动函数)是怎么设计的?为什么要这样做?

答案:

实现代码:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

设计原理:

  1. 获取hashCode(): 调用key的hashCode()方法
  2. 右移16位: 将高16位右移到低16位
  3. 异或运算: 将高16位与低16位进行异或

为什么需要扰动?

  • 问题: hashCode()可能分布不均匀,如果直接使用,低位可能相同,导致冲突
  • 解决: 通过扰动,让高位也参与计算,使分布更均匀
  • 效果: 减少哈希冲突,提高性能

示例:

// 假设hashCode() = 0x12345678
int h = 0x12345678;
int high = h >>> 16;  // 0x00001234
int hash = h ^ high;   // 0x1234444C
// 这样高位和低位都参与了计算
面试题4:HashMap如何根据key的hash值计算数组下标?为什么容量必须是2的幂?

答案:

计算方式:

int index = (n - 1) & hash;

为什么容量必须是2的幂?

  1. 位运算优化: 如果n是2的幂,n-1的二进制全是1
    n = 16: 二进制 10000
    n-1 = 15: 二进制 1111
    
  2. 均匀分布: (n-1) & hash 等价于 hash % n,但位运算更快
  3. 性能提升: 位运算比取模运算快得多

如果不是2的幂会怎样?

  • 使用tableSizeFor()方法,将容量调整为大于等于指定值的最小2的幂
  • 例如:指定13,实际容量为16
面试题5:HashMap的get()方法是如何实现的?时间复杂度是多少?

答案:

实现流程:

  1. 计算hash值
  2. 定位数组下标
  3. 检查第一个节点
    • 如果匹配:直接返回
    • 如果不匹配:继续查找
  4. 在链表或红黑树中查找

时间复杂度:

  • 最好情况: O(1) - 数组位置直接命中
  • 平均情况: O(1) - 链表长度较短
  • 最坏情况: O(log n) - 红黑树查找

代码示例:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
面试题6:HashMap的remove()方法实现原理

答案:

实现流程:

  1. 计算hash值,定位数组下标
  2. 在链表或红黑树中查找节点
  3. 找到后,从链表或红黑树中移除
  4. 如果红黑树节点数 < 6,退化为链表
  5. 更新size,返回被移除的值

时间复杂度: O(1)平均,O(log n)最坏(红黑树)

面试题7:HashMap的containsKey()和containsValue()有什么区别?

答案:

containsKey():

  • 通过hash值快速定位,时间复杂度O(1)
  • 在对应位置的链表或红黑树中查找

containsValue():

  • 需要遍历所有元素,时间复杂度O(n)
  • 性能较差,不推荐频繁使用
面试题8:HashMap的keySet()、values()、entrySet()有什么区别?

答案:

keySet(): 返回所有键的Set视图,修改会影响原Map

values(): 返回所有值的Collection视图,修改会影响原Map

entrySet(): 返回所有键值对的Set视图,修改会影响原Map

共同特点:

  • 都是视图,不复制数据
  • 修改视图会影响原Map
  • 支持迭代和删除操作
面试题9:HashMap的clear()方法如何实现?

答案:

实现方式:

  • 遍历数组,将所有位置置为null
  • 将size重置为0
  • 不改变容量

时间复杂度: O(n)

面试题10:HashMap的clone()方法是深拷贝还是浅拷贝?

答案:

浅拷贝:

  • 只复制Map结构,不复制元素
  • 新Map和原Map共享元素对象
  • 修改元素会影响两个Map

深拷贝需要:

  • 手动遍历并复制每个元素
  • 或使用序列化/反序列化

8.1.2 关键参数类(8道)

面试题11:HashMap的默认初始容量、负载因子、最大容量是多少?

答案:

  • 默认初始容量: 16
  • 负载因子: 0.75
  • 最大容量: 2^30 (1 << 30)
面试题12:负载因子为什么默认是0.75?如果设置为1会怎样?

答案:

为什么是0.75?

  • 空间与时间的权衡
  • 基于泊松分布计算得出
  • 在0.75时,哈希冲突概率较低,空间利用率较高

如果设置为1:

  • 空间利用率最高
  • 但哈希冲突增多,性能下降
  • 链表变长,查找变慢
面试题13:链表转红黑树的条件是什么?阈值为什么是8?

答案:

转树条件:

  • 链表长度 >= 8
  • 数组长度 >= 64(否则先扩容)

为什么是8?

  • 基于泊松分布:当负载因子0.75时,链表长度8的概率约为0.00000006
  • 正常情况下很少超过8
  • 如果超过8,说明冲突严重,需要红黑树优化
面试题14:红黑树退化为链表的条件是什么?为什么是6?

答案:

退化条件: 红黑树节点数 < 6

为什么是6而不是8?

  • 提供2的缓冲区间,防止频繁转换
  • 避免在临界值8附近频繁转换
  • 提高性能稳定性
面试题15:为什么初始容量必须是2的幂?如何保证?

答案:

为什么必须是2的幂?

  • 使用位运算 (n-1) & hash 代替取模,性能更好
  • 保证均匀分布

如何保证?

  • 使用tableSizeFor()方法
  • 将容量调整为大于等于指定值的最小2的幂
面试题16:tableSizeFor()方法是做什么的?它是如何保证容量为2的幂的?

答案:

作用: 将容量调整为大于等于指定值的最小2的幂

实现原理:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

原理: 通过位运算,将最高位之后的所有位都置为1,然后+1得到2的幂

面试题17:如果指定初始容量不是2的幂会怎样?

答案:

  • HashMap会自动调用tableSizeFor()调整
  • 调整为大于等于指定值的最小2的幂
  • 例如:指定13,实际容量为16
面试题18:HashMap的最大容量为什么是2^30?

答案:

  • int类型的最大值是2^31-1
  • 2^30是最大的2的幂次方
  • 保证容量始终是2的幂,便于位运算优化

8.1.3 扩容机制类(6道)

面试题19:HashMap什么时候会触发扩容?扩容的大小是多少?

答案:

触发条件:

  • 元素数量 > 容量 × 负载因子
  • 例如:16 × 0.75 = 12,当元素数量超过12时触发

扩容大小:

  • 容量翻倍:newCap = oldCap << 1
  • 例如:16 → 32 → 64 → 128
面试题20:描述一下resize()扩容的过程

答案:

扩容流程:

  1. 创建新数组,容量为原来的2倍
  2. 重新计算所有元素的位置
  3. 将元素迁移到新数组
  4. 更新threshold(扩容阈值)

JDK8优化:

  • 高位低位链表拆分
  • 不需要重新计算hash值
  • 只需要判断hash值的某一位
面试题21:JDK1.8在扩容时做了什么优化?(高位低位链表拆分)

答案:

优化原理:

// 判断hash值的第5位(从右往左)
// 如果为0:在新数组的相同位置(低位)
// 如果为1:在新数组的 原位置+16 位置(高位)
int newIndex = (hash & oldCap) == 0 ? oldIndex : oldIndex + oldCap;

优势:

  • 不需要重新计算hash值
  • 只需要判断一位即可
  • 性能提升明显
面试题22:为什么扩容是2倍,并且是2的幂次方?

答案:

为什么是2倍?

  • 保证容量始终是2的幂
  • 便于位运算优化
  • 扩容后重新计算位置更简单

为什么是2的幂?

  • 使用位运算代替取模
  • 性能更好
  • 分布更均匀
面试题23:扩容时如何重新计算元素位置?

答案:

JDK8优化方式:

  • 不需要重新计算hash值
  • 使用 (hash & oldCap) == 0 判断
  • 为0:位置不变(低位)
  • 为1:位置 = 原位置 + oldCap(高位)
面试题24:扩容对性能的影响有多大?

答案:

影响:

  • 扩容需要重新计算所有元素的位置
  • 性能开销较大
  • 应尽量避免频繁扩容

优化建议:

  • 预分配容量
  • 根据预期元素数量计算初始容量

8.1.4 线程安全类(6道)

面试题25:为什么说HashMap是线程不安全的?具体表现有哪些?

答案:

不安全的体现:

  1. 数据覆盖: 多线程put相同key,可能只有一个值被保存
  2. 数据丢失: 多线程put不同key,可能丢失数据
  3. 死循环(JDK7): 并发扩容可能导致死循环
  4. 数据不一致: get操作可能读到不一致的数据

原因:

  • 没有同步机制
  • 非原子操作
  • 没有volatile保证可见性
面试题26:详细解释JDK1.7中HashMap并发扩容可能导致死循环的问题

答案:

问题场景:

  • 两个线程同时进行扩容
  • 在扩容过程中,链表被反转
  • 形成循环链表,导致死循环

原因:

  • 头插法导致链表反转
  • 多线程并发操作
  • 没有同步机制

解决方案:

  • 使用ConcurrentHashMap
  • 或使用Collections.synchronizedMap()
面试题27:JDK1.8修复了死循环问题,那HashMap就是线程安全的了吗?

答案:

不是:

  • JDK8修复了死循环问题(改为尾插法)
  • 但仍存在数据覆盖和数据不一致问题
  • 仍然不是线程安全的

多线程场景应使用:

  • ConcurrentHashMap(推荐)
  • Collections.synchronizedMap()
面试题28:如何保证HashMap的线程安全?

答案:

方案1:使用ConcurrentHashMap(推荐)

Map<String, Integer> map = new ConcurrentHashMap<>();

方案2:使用Collections.synchronizedMap()

Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

方案3:使用Hashtable(不推荐,性能差)

Map<String, Integer> map = new Hashtable<>();
面试题29:Collections.synchronizedMap()是如何实现线程安全的?

答案:

实现方式:

  • 使用synchronized锁住整个Map对象
  • 所有方法都加锁
  • 性能较差,锁粒度太大

不推荐原因:

  • 性能不如ConcurrentHashMap
  • 锁粒度太大
面试题30:为什么HashMap在多线程环境下会出现数据丢失?

答案:

原因:

  • put操作不是原子性的
  • 多线程同时修改可能导致数据覆盖
  • 没有同步机制保护

示例:

  • 线程A和线程B同时put不同的key
  • 可能只有一个被保存,另一个丢失

8.2 ConcurrentHashMap核心面试题(20道)

8.2.1 版本对比类(4道)

面试题31:对比JDK1.7和JDK1.8中ConcurrentHashMap的实现原理

答案:

JDK7:分段锁(Segment)

  • 使用Segment数组,每个Segment是一个锁
  • 不同Segment可以并发操作
  • 结构复杂,内存占用大

JDK8:CAS + synchronized

  • 抛弃Segment,直接使用Node数组
  • 使用CAS实现无锁插入
  • 使用synchronized锁节点
  • 结构更简单,性能更好
面试题32:JDK1.7中的分段锁(Segment)机制是怎样的?有什么优缺点?

答案:

机制:

  • Segment数组,每个Segment是一个ReentrantLock
  • 不同Segment可以并发操作
  • 同一个Segment需要加锁

优点:

  • 并发度高
  • 锁粒度小

缺点:

  • 结构复杂
  • 内存占用大
  • 锁竞争可能影响性能
面试题33:为什么JDK1.8要抛弃Segment?

答案:

原因:

  • Segment结构复杂,内存占用大
  • 锁粒度仍然较大
  • CAS + synchronized性能更好
  • 结构更简单,更易维护
面试题34:JDK1.8相比JDK1.7有哪些性能提升?

答案:

性能提升:

  • 结构更简单,内存占用更小
  • CAS无锁插入,性能更好
  • synchronized锁节点,锁粒度更小
  • 多线程协助扩容,效率更高

8.2.2 JDK1.8实现类(8道)

面试题35:JDK1.8的ConcurrentHashMap是如何保证线程安全的?(CAS + synchronized)

答案:

机制:

  1. CAS实现无锁插入: 位置为空时,使用CAS插入
  2. synchronized锁节点: 位置不为空时,锁住链表头或树根
  3. volatile保证可见性: Node的val和next都是volatile

优势:

  • 锁粒度小,性能好
  • 不同位置的节点可以并发操作
面试题36:描述一下JDK1.8中ConcurrentHashMap的put()方法流程

答案:

流程:

  1. 计算hash值,定位数组下标
  2. CAS尝试插入(位置为空)
  3. 如果失败,synchronized加锁插入
  4. 检查是否需要转红黑树
  5. 检查是否需要扩容
面试题37:get()操作为什么不需要加锁?如何保证读到的是最新数据?(volatile)

答案:

为什么不需要加锁?

  • Node的val和next都是volatile
  • volatile保证可见性
  • 读操作不会修改数据

如何保证最新数据?

  • volatile保证多线程之间的可见性
  • 读操作总是读到最新值
面试题38:size()方法是如何实现的?为什么说它是"近似准确"的?(LongAdder思想)

答案:

实现方式:

  • baseCount + CounterCell[]
  • 每个线程更新自己的CounterCell
  • 最终统计时,将所有CounterCell相加

为什么是"近似准确"?

  • 多线程并发更新时,统计可能有延迟
  • 但最终会收敛到准确值
  • 性能优于加锁统计
面试题39:解释一下sizeCtl字段的作用和它的几种状态

答案:

sizeCtl的含义:

  • 正数: 表示扩容阈值(容量 × 负载因子)
  • -1: 表示正在初始化
  • 负数(-N): 表示有N-1个线程正在扩容
面试题40:ForwardingNode节点是什么?它在扩容中起什么作用?

答案:

ForwardingNode:

  • 标记节点,表示该位置的数据已经迁移到新数组
  • get操作遇到ForwardingNode时,到新数组查找
  • 其他线程看到ForwardingNode时,可以协助扩容
面试题41:多线程是如何协助进行扩容(transfer)的?

答案:

协助机制:

  • 当线程发现正在扩容时,可以协助扩容
  • 将数组分成多个段,每个线程负责一段
  • 提高扩容效率,减少扩容时间
面试题42:ConcurrentHashMap的remove()方法如何保证线程安全?

答案:

实现方式:

  • 使用synchronized锁住节点
  • 在锁内进行删除操作
  • 保证线程安全

8.2.3 设计与对比类(8道)

面试题43:为什么ConcurrentHashMap不允许null键和null值?

答案:

原因:

  • 多线程环境下,null值处理复杂
  • 无法区分"不存在"和"值为null"
  • 避免歧义
面试题44:ConcurrentHashMap和Collections.synchronizedMap(new HashMap<>())有什么区别?如何选择?

答案:

区别:

  • ConcurrentHashMap:CAS + synchronized,性能更好
  • synchronizedMap:synchronized锁整个Map,性能差

选择:

  • 高并发场景:使用ConcurrentHashMap
  • 低并发场景:可以使用synchronizedMap
面试题45:在极高并发的计数场景,用ConcurrentHashMap和LongAdder哪个更好?

答案:

LongAdder更好:

  • 专门为高并发计数设计
  • 性能优于ConcurrentHashMap
  • 使用分段计数,减少竞争
面试题46:你知道ConcurrentHashMap的computeIfAbsent方法吗?它有什么需要注意的坑?(避免递归计算)

答案:

computeIfAbsent:

  • 如果key不存在,计算value并放入
  • 如果key存在,直接返回value

注意:

  • 避免在计算函数中再次调用computeIfAbsent
  • 可能导致死锁或性能问题
面试题47:ConcurrentHashMap的key和value可以为null吗?为什么?

答案:

不可以:

  • key和value都不可以为null
  • 原因同面试题43
面试题48:ConcurrentHashMap的迭代器是fail-fast还是fail-safe?

答案:

fail-safe:

  • 迭代器不会抛出ConcurrentModificationException
  • 弱一致性,可能读到旧数据
  • 适合并发场景
面试题49:ConcurrentHashMap的并发度是如何控制的?

答案:

JDK8:

  • 不再使用并发度参数
  • 通过CAS和synchronized自然控制
  • 性能更好
面试题50:如何选择合适的Map实现?(HashMap vs ConcurrentHashMap)

答案:

选择指南:

  • 单线程:使用HashMap
  • 多线程:使用ConcurrentHashMap
  • 需要有序:使用LinkedHashMap或TreeMap
  • 需要排序:使用TreeMap

8.3 LinkedHashMap面试题(8道)

面试题51:LinkedHashMap和HashMap的主要区别是什么?它是如何维护顺序的?

答案:

主要区别:

  • LinkedHashMap继承HashMap,增加双向链表维护顺序
  • HashMap无序,LinkedHashMap有序

如何维护顺序:

  • 使用双向链表(before、after指针)
  • 插入时添加到链表尾部
  • 访问时(accessOrder=true)移到链表尾部
面试题52:accessOrder参数的作用是什么?

答案:

作用:

  • 控制LinkedHashMap的顺序模式
  • false(默认):插入顺序
  • true:访问顺序(适合LRU缓存)
面试题53:如何用LinkedHashMap实现一个LRU(最近最少使用)缓存?请写出核心代码

答案:

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    
    public LRUCache(int capacity) {
        super(16, 0.75f, true);  // accessOrder=true
        this.capacity = capacity;
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}
面试题54:LinkedHashMap的get()方法在访问顺序模式下有什么特殊处理?

答案:

特殊处理:

  • 访问节点后,调用afterNodeAccess()
  • 将节点移到链表尾部
  • 实现LRU策略
面试题55:LinkedHashMap的迭代顺序是什么?

答案:

插入顺序模式(默认):

  • 按照元素插入的顺序

访问顺序模式(accessOrder=true):

  • 按照元素访问的顺序
  • 最近访问的在尾部
面试题56:LinkedHashMap是线程安全的吗?

答案:

不是:

  • LinkedHashMap不是线程安全的
  • 多线程场景需要使用Collections.synchronizedMap()或ConcurrentHashMap
面试题57:LinkedHashMap和TreeMap在有序性上有何区别?

答案:

LinkedHashMap:

  • 维护插入顺序或访问顺序
  • 时间复杂度O(1)

TreeMap:

  • 按键排序(自然顺序或Comparator)
  • 时间复杂度O(log n)
面试题58:什么场景下应该使用LinkedHashMap?

答案:

适用场景:

  • 需要保持插入顺序
  • 需要实现LRU缓存
  • 需要记录最近访问的数据

8.4 TreeMap面试题(8道)

面试题59:TreeMap的底层数据结构是什么?put和get的时间复杂度是多少?

答案:

数据结构: 红黑树(自平衡二叉搜索树)

时间复杂度:

  • put:O(log n)
  • get:O(log n)
面试题60:TreeMap的排序规则是如何确定的?(Comparable 或 Comparator)

答案:

排序规则:

  1. 如果提供了Comparator:使用Comparator
  2. 如果键实现了Comparable:使用自然排序
  3. 否则:抛出ClassCastException
面试题61:TreeMap和HashMap在有序性上有何本质区别?

答案:

TreeMap:

  • 按键排序,有序
  • 红黑树实现
  • 时间复杂度O(log n)

HashMap:

  • 无序
  • 哈希表实现
  • 时间复杂度O(1)
面试题62:你知道ConcurrentSkipListMap吗?它和TreeMap有什么区别?(并发有序Map)

答案:

ConcurrentSkipListMap:

  • 线程安全的有序Map
  • 跳表实现
  • 时间复杂度O(log n)

区别:

  • TreeMap:非线程安全
  • ConcurrentSkipListMap:线程安全
面试题63:TreeMap是线程安全的吗?

答案:

不是:

  • TreeMap不是线程安全的
  • 多线程场景需要使用Collections.synchronizedMap()或ConcurrentSkipListMap
面试题64:TreeMap的key可以为null吗?

答案:

不可以:

  • TreeMap的key不能为null
  • 会抛出NullPointerException
面试题65:什么场景下应该使用TreeMap?

答案:

适用场景:

  • 需要按键排序
  • 需要范围查询
  • 需要导航方法(ceilingKey、floorKey等)
面试题66:TreeMap的subMap()方法如何使用?

答案:

使用方式:

// 获取范围 [fromKey, toKey) 的子Map
SortedMap<K, V> subMap = treeMap.subMap(fromKey, toKey);

特点:

  • 返回子Map视图
  • 修改子Map会影响原Map
  • 范围是左闭右开 [fromKey, toKey)

8.5 HashMap vs Hashtable对比面试题(6道)

面试题67:从线程安全、性能、null值、继承类等方面详细对比HashMap和Hashtable

答案:

特性HashMapHashtable
线程安全是(synchronized)
性能
null键值允许不允许
继承类AbstractMapDictionary(已废弃)
推荐使用否(已淘汰)
面试题68:为什么Hashtable被淘汰了?

答案:

原因:

  1. 性能差:synchronized锁整个对象
  2. 设计过时:早期设计,不符合现代Java规范
  3. 有更好的替代:ConcurrentHashMap性能更好
  4. API设计:方法命名不符合Java规范
面试题69:Hashtable的默认初始容量和负载因子是多少?

答案:

  • 默认初始容量: 11
  • 负载因子: 0.75
面试题70:Hashtable的扩容机制是怎样的?

答案:

扩容机制:

  • 触发条件:元素数量 > 容量 × 负载因子
  • 扩容大小:newCapacity = oldCapacity * 2 + 1
  • 不是2的幂,性能较差
面试题71:什么场景下还会使用Hashtable?

答案:

几乎不使用:

  • Hashtable已被淘汰
  • 单线程场景使用HashMap
  • 多线程场景使用ConcurrentHashMap
面试题72:Hashtable和ConcurrentHashMap有什么区别?

答案:

主要区别:

  • Hashtable:synchronized锁整个对象,性能差
  • ConcurrentHashMap:CAS + synchronized,性能好
  • ConcurrentHashMap是Hashtable的现代替代品

8.6 Map集合综合面试题(20道)

8.6.1 哈希冲突与Key设计类(8道)

面试题73:HashMap是如何解决哈希冲突的?

答案:

解决方式:链地址法(拉链法)

  • 在冲突位置维护一个链表
  • JDK8中,链表长度超过8时转为红黑树
  • 性能从O(n)优化到O(log n)
面试题74:如果两个不同的key有相同的hashCode()会怎样?如果hashCode()相同,equals()不同呢?

答案:

hashCode()相同:

  • 发生哈希冲突
  • 在同一个位置的链表中存储
  • 通过equals()区分

hashCode()相同,equals()不同:

  • 两个key都存储在链表中
  • get时通过equals()查找
  • 性能可能下降
面试题75:为什么重写equals()方法时必须重写hashCode()方法?在HashMap中会产生什么后果?

答案:

原因:

  • HashMap使用hashCode()定位,equals()比较
  • 如果equals()返回true但hashCode()不同,无法找到元素

后果:

  • 无法通过get()获取元素
  • 可能存储重复的key
  • 违反HashMap的约定
面试题76:可以使用可变对象作为HashMap的key吗?会有什么问题?

答案:

不推荐:

  • 修改key后,hashCode()改变
  • 无法找到原来的位置
  • 导致内存泄漏

建议:

  • 使用不可变对象作为key(String、Integer等)
面试题77:String、Integer这类包装类为什么适合作为HashMap的key?

答案:

原因:

  • 不可变:修改后创建新对象,不影响原key
  • 实现了equals()和hashCode()
  • 性能好,分布均匀
面试题78:如何设计一个好的hashCode()方法?

答案:

原则:

  • 使用31作为乘数(经验值)
  • 包含所有参与equals()的字段
  • 保证相等的对象hashCode()相同
  • 尽量分布均匀

示例:

@Override
public int hashCode() {
    int result = field1 != null ? field1.hashCode() : 0;
    result = 31 * result + field2;
    return result;
}
面试题79:哈希冲突对HashMap性能的影响有多大?

答案:

影响:

  • 冲突少:性能O(1)
  • 冲突多:性能O(n)或O(log n)
  • 严重冲突:性能大幅下降

优化:

  • 实现好的hashCode()
  • 合理设置负载因子
  • 预分配容量
面试题80:如何减少哈希冲突?

答案:

方法:

  1. 实现好的hashCode()方法
  2. 合理设置负载因子(默认0.75)
  3. 预分配容量,避免频繁扩容
  4. 使用扰动函数(HashMap已实现)

8.6.2 性能优化类(6道)

面试题81:如何优化HashMap的性能?(初始化容量、好的hashCode实现)

答案:

优化策略:

  1. 预分配容量:避免频繁扩容
  2. 实现好的hashCode():减少冲突
  3. 选择合适的负载因子:默认0.75
  4. 选择合适的Map实现:根据场景选择
面试题82:HashMap的get()操作时间复杂度一定是O(1)吗?在什么情况下会退化?

答案:

不一定:

  • 最好情况:O(1) - 数组位置直接命中
  • 平均情况:O(1) - 链表长度较短
  • 最坏情况:O(log n) - 红黑树查找

退化情况:

  • 哈希冲突严重,链表很长
  • 转为红黑树后,O(log n)
面试题83:HashMap和Hashtable的null键null值支持有何不同?

答案:

HashMap:

  • 允许一个null键和多个null值

Hashtable:

  • 不允许null键和null值
面试题84:如何选择合适的Map实现类?

答案:

选择指南:

  • 一般场景:HashMap
  • 需要有序:LinkedHashMap或TreeMap
  • 需要排序:TreeMap
  • 多线程:ConcurrentHashMap
  • LRU缓存:LinkedHashMap
面试题85:Map集合的性能测试与调优方法

答案:

测试方法:

  • 使用JMH进行性能测试
  • 测试不同场景下的性能
  • 分析瓶颈

调优方法:

  • 预分配容量
  • 实现好的hashCode()
  • 选择合适的实现类
  • 避免不必要的操作
面试题86:大数据量场景下如何优化Map性能?

答案:

优化策略:

  1. 预分配容量,避免频繁扩容
  2. 实现好的hashCode(),减少冲突
  3. 使用合适的负载因子
  4. 考虑使用ConcurrentHashMap(多线程)
  5. 分批处理,避免一次性加载所有数据

8.6.3 源码与设计类(6道)

面试题87:手写一个简化版的HashMap(至少实现put和get)

答案:

public class SimpleHashMap<K, V> {
    private Node<K, V>[] table;
    private int size;
    private static final int DEFAULT_CAPACITY = 16;
    
    static class Node<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next;
        
        Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
    
    public V put(K key, V value) {
        if (table == null) {
            table = new Node[DEFAULT_CAPACITY];
        }
        int hash = key.hashCode();
        int index = (table.length - 1) & hash;
        Node<K, V> node = table[index];
        if (node == null) {
            table[index] = new Node<>(hash, key, value, null);
            size++;
            return null;
        }
        while (node != null) {
            if (node.key.equals(key)) {
                V oldValue = node.value;
                node.value = value;
                return oldValue;
            }
            node = node.next;
        }
        table[index] = new Node<>(hash, key, value, table[index]);
        size++;
        return null;
    }
    
    public V get(K key) {
        if (table == null) return null;
        int hash = key.hashCode();
        int index = (table.length - 1) & hash;
        Node<K, V> node = table[index];
        while (node != null) {
            if (node.key.equals(key)) {
                return node.value;
            }
            node = node.next;
        }
        return null;
    }
}
面试题88:Map接口的设计模式有哪些?(模板方法、策略模式等)

答案:

设计模式:

  1. 模板方法模式: AbstractMap提供模板方法
  2. 策略模式: 不同的Map实现使用不同的策略
  3. 迭代器模式: entrySet()、keySet()返回迭代器
  4. 适配器模式: Collections.synchronizedMap()
面试题89:如何实现一个线程安全的Map?

答案:

方案:

  1. 使用ConcurrentHashMap(推荐)
  2. 使用Collections.synchronizedMap()
  3. 使用Hashtable(不推荐)
  4. 自己实现:使用synchronized或ReentrantLock
面试题90:Map集合的序列化机制是怎样的?

答案:

序列化:

  • 实现Serializable接口
  • 自定义writeObject()和readObject()
  • 只序列化实际元素,不序列化空位置
面试题91:Map集合的迭代器实现原理

答案:

实现原理:

  • entrySet()、keySet()、values()返回视图
  • 视图内部使用迭代器遍历
  • 修改视图会影响原Map
面试题92:Map集合的fail-fast机制是如何实现的?

答案:

实现方式:

  • 使用modCount记录修改次数
  • 迭代时检查modCount
  • 如果modCount改变,抛出ConcurrentModificationException

注意:

  • HashMap的迭代器是fail-fast
  • ConcurrentHashMap的迭代器是fail-safe

文档完成!

本文档全面覆盖了Java Map集合框架的所有核心知识点,包含92道大厂高频面试题,适合系统学习和面试准备。