【Java集合夜话】第3篇:Map家族,一次深入内核的探险之旅

在上一章中,我们深入探索了Collection家族的核心成员,了解了它们的特性和应用场景。🌹
今天,让我们踏上探索Map家族的旅程。我们将从源码层面解析HashMap的演进,深入ConcurrentHashMap的并发机制,探讨TreeMap的红黑树实现,以及其他Map实现的独特之处。这不仅是对数据结构的探讨,更是对Java设计智慧的致敬。🌹
如有描述不准确之处,欢迎大家指出交流。🌹

文章目录


沉淀

Map接口家族

HashMap:最常用的键值对集合
ConcurrentHashMap:并发编程中的核心类
LinkedHashMap:有序的HashMap
TreeMap:排序的Map实现
Hashtable:线程安全的Map实现
Properties:特殊的Map类型
WeakHashMap:弱引用Map
EnumMap:枚举类型专用Map
IdentityHashMap:使用引用相等的Map

在Map接口的众多实现中,我们将重点关注以下三个最重要的实现:

  1. HashMap: 作为最常用的Map实现,它采用数组+链表+红黑树的数据结构,在面试中经常被问到。

  2. ConcurrentHashMap: 作为并发编程中的核心类,它通过分段锁等机制实现了高效的线程安全。通过与HashMap的对比学习,可以深入理解Java并发编程的设计理念。

  3. TreeMap: 基于红黑树实现的有序Map,是唯一可以按键排序的Map实现,在需要排序的场景下具有重要作用。
    本文将深入剖析这三个关键实现的原理、特性及最佳实践。

HashMap:最常用的键值对集合

【引入问题】

“为什么HashMap是最受欢迎的Map实现?它解决了什么问题?”

【浅层回答】

  • HashMap提供了键值对的存储方式,可以通过键快速查找值
  • 它的查询效率很高,时间复杂度为O(1)
  • 不要求键值对有序,也不保证顺序不变
  • 允许null键和null值
  • 非线程安全

【深入探讨】

1. 数据结构演进

JDK1.7和1.8的HashMap在实现上有很大的区别,主要体现在数据结构和操作流程上:

1.1 JDK1.7:数组 + 链表
  • 存储结构:Entry数组 + 链表

  • 插入元素:

    1. 通过key.hashCode()获取hashCode,再进行扰动计算得到hash值
    2. 将hash值与数组长度-1进行按位与运算,得到数组下标
    3. 遍历对应位置的链表,如果key存在则更新值,不存在则用头插法插入新节点
    4. 插入后如果元素个数超过阈值(capacity * loadFactor),则触发扩容
  • 查询元素:

    1. 通过key计算hash值,与数组长度-1进行按位与运算定位数组位置
    2. 在链表中通过equals()方法逐个比对key,找到目标元素
  • 扩容过程:

    1. 创建一个容量是原来2倍的新数组
    2. 遍历原数组中的每个位置的链表
    3. 对每个元素重新计算在新数组中的位置:hash & (newCapacity - 1)
    4. 使用头插法将元素迁移到新数组,这可能导致链表顺序颠倒
1.2 JDK1.8:数组 + 链表 + 红黑树
// 节点结构
static class Node<K,V> implements Map.Entry<K,V> {
   
   
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

// 红黑树节点结构
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
   
   
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
}
  • 插入元素:

    1. 对key的hashCode()进行扰动计算:(h = key.hashCode()) ^ (h >>> 16)
    2. 通过(n - 1) & hash计算数组下标,n为数组长度
    3. 如果目标位置为空,直接放入新节点
    4. 如果该位置是红黑树节点,按照红黑树的方式插入
    5. 如果是链表,则遍历链表:
      • 找到相同的key则更新值
      • 未找到则在链表尾部插入新节点
      • 插入后如果链表长度达到8,则检查是否需要树化
    6. 判断是否需要扩容
  • 查询元素:

    1. 计算hash值,通过(n - 1) & hash定位数组位置
    2. 判断节点类型:
      • 如果是链表节点,遍历链表比对key
      • 如果是红黑树节点,按照红黑树的查找方式进行查找(时间复杂度O(logn))
  • 扩容过程:

    1. 创建一个容量翻倍的新数组
    2. 遍历原数组,对于每个节点:
      • 如果是单节点,直接计算新位置
      • 如果是红黑树,则拆分红黑树
      • 如果是链表,则根据(hash & oldCap)将链表分成两部分,分别放入新数组的不同位置
    3. 节点迁移时保持原有顺序,避免链表倒置
2. 容量与扩容机制
2.1 初始容量和负载因子
  • 默认初始容量为16,必须是2的幂
  • 默认负载因子为0.75
    • 为什么是0.75?这是空间和时间成本的一个折中:
      • 较高的负载因子会减少空间开销,但增加查找成本
      • 较低的负载因子会增加空间开销,但减少查找成本
      • 0.75提供了一个理想的折中点
2.2 为什么容量必须是2的幂?
  • 计算数组下标时使用 (n-1) & hash 操作
  • 当n为2的幂时,n-1的二进制表示全为1,如:
    n = 16 时:
    15 的二进制:0000 1111
    hash:        1101 0101
    & 运算结果:  0000 0101 (5)
    
  • 这样可以保证计算出的下标均匀分布在0到(n-1)之间
  • 如果n不是2的幂,某些位置可能永远不会被用到
2.3 扩容触发条件
  1. 当前元素个数超过阈值(capacity * loadFactor)
  2. 链表长度超过8且数组长度小于64时
2.4 扩容过程的优化(JDK1.8)
  • 原理:扩容后,元素要么在原位置,要么在原位置+旧容量的位置
  • 判断依据:hash & oldCap
    例如,oldCap = 16时:
    10100:hash & 16 = 0,元素位置不变
    10116:hash & 16 = 16, 新位置 = 原位置 + 16
    
  • 这种设计避免了重新计算hash值,提高了扩容效率
3. hash冲突解决
3.1 链表法
  1. 发生冲突时,将新元素链接到已有元素之后
  2. JDK1.7使用头插法:
    • 新元素插入到链表头部
    • 实现简单,但在并发环境下可能导致循环链表
  3. JDK1.8改用尾插法:
    • 新元素插入到链表尾部
    • 保持了元素的插入顺序
    • 避免了并发环境下的循环链表问题
3.2 树化条件
  1. 链表长度达到8(为什么是8?)
    • 根据泊松分布,链表长度达到8的概率约为0.00000006
    • 这个阈值既能避免频繁树化,又能在必要时提升性能
  2. 数组长度达到64
    • 小于64时优先选择扩容而不是树化
    • 因为在数组较小时,扩容的成本比树化更低
3.3 退化条件
  1. 红黑树节点数小于6时会退化为链表
  2. 为什么是6而不是8?
    • 设置一个缓冲区间(6-8)避免频繁的链表-树-链表转换
    • 类似于TCP的拥塞控制,这种滞后性有助于提高性能
3.4 hash算法优化

JDK1.8的hash计算:

static final int hash(Object key) {
   
   
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 将高16位与低16位异或
  • 充分利用了hash值的所有位
  • 减少了hash冲突的概率
4. 常见性能陷阱
4.1 不当的初始容量设置
  1. 容量太小导致频繁扩容

    • 示例:预计存入1000个元素,但初始容量使用默认16
    • 后果:需要多次扩容(16 -> 32 -> 64 -> 128 -> 256 -> 512 -> 1024)
    • 解决:根据预估数据量设置合适的初始容量:initialCapacity = 预估容量 / 0.75
  2. 容量设置过大浪费内存

    • 示例:预计存100个元素,但设置了初始容量10000
    • 后果:大量的数组空间被浪费
    • 建议:适度预留空间即可,避免过度预分配
4.2 自定义对象作为key的问题
  1. hashCode()实现不当
class User {
   
   
    private String name;
    private int age;
    
    @Override
    public int hashCode() {
   
   
        return 1; // 错误示例:所有对象hash值都是1
    }
}
  • 导致所有元素都在同一个桶中
  • 链表过长,退化成O(n)的查找性能
  1. equals()与hashCode()不一致
class Point {
   
   
    private int x, y;
    
    @Override
    public boolean equals(Object obj) {
   
   
        if (obj instanceof Point) {
   
   
            Point other = (Point) obj;
            return x == other.x && y == other.y;
        }
        return false;
    }
    
    @Override
    public int hashCode() {
   
   
        return x; // 错误示例:忽略了y值
    }
}
  • 可能导致相等的对象具有不同的hash值
  • 或不相等的对象具有相同的hash值
4.3 并发环境的问题
  1. 并发插入导致的数据丢失
// 错误示例
HashMap<String, Integer> map = new HashMap<>();
// 线程1和线程2同时执行
map.put("key", map.get("key") + 1);
  1. 并发环境下的扩容问题
  • JDK1.7中可能形成环形链表导致死循环
  • 应该使用ConcurrentHashMap替代

【实践建议】

1. 初始容量的选择
// 好的实践
int expectedSize = 1000;
// 设置初始容量为 expectedSize / 0.75 + 1
Map<String, String> map = new HashMap<>((int)(expectedSize / 0.75) + 1);
2. equals()和hashCode()的正确实现
class User {
   
   
    private String name;
    private int age;
    
    @Override
    public boolean equals(Object obj) {
   
   
        if (this == obj) return true;
        if (!(obj instanceof User)) return false;
        User user = (User) obj;
        return age == user.age && Objects.equals(name, user.name);
    }
    
    @Override
    public int hashCode() {
   
   
        return Objects.hash(name, age);
    }
}
3. 线程安全的替代方案
  1. 使用Collections.synchronizedMap()
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
  1. 使用ConcurrentHashMap
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
  1. 场景选择:
    • 读多写少:ConcurrentHashMap
    • 写多读少:synchronizedMap
    • 单线程:HashMap
    • 需要保证线程安全且有null键值:synchronizedMap
    • 需要保证线程安全但不允许null:ConcurrentHashMap

ConcurrentHashMap:并发编程中的核心类

【引入问题】

“为什么需要ConcurrentHashMap?它相比HashMap和HashTable有什么优势?”

【浅层回答】

  • ConcurrentHashMap是线程安全的HashMap实现
  • 相比HashTable的粗粒度锁,它使用了分段锁提升并发性能
  • 不允许null键和null值
  • 读操作不需要加锁,性能较好
  • 写操作仅锁定当前操作的Segment,不影响其他Segment

【深入探讨】

1. 数据结构演进

ConcurrentHashMap在不同JDK版本中的实现有较大变化:

1.1 JDK1.7:Segment + HashEntry数组
  • 存储结构:

    1. Segment数组 + HashEntry数组 + 链表
    2. Segment继承自ReentrantLock,每个Segment独立加锁
    3. 默认16个Segment,支持16个线程并发写
  • 并发控制:

    1. 读操作不加锁,通过volatile保证可见性
    2. 写操作对相应的Segment加锁
    3. size()需要统计所有Segment,可能需要重试
1.2 JDK1.8:Node数组 + CAS + synchronized
  • 存储结构:

    1. Node数组 + 链表 + 红黑树
    2. 去除了Segment,直接用Node数组保存数据
    3. 同HashMap一样,链表长度超过8会转为红黑树
  • 并发控制:

    1. 使用synchronized锁定链表或树的首节点
    2. 使用CAS操作实现无锁并发
    3. 使用volatile保证可见性
    4. size()方法使用baseCount+counterCells计数
2. 关键源码分析
2.1 重要的成员变量
// 存储数据的数组,使用volatile保证可见性
transient volatile Node<K,V>[] table;

// 基础计数值,在没有竞争时使用
private transient volatile long baseCount;

// 扩容相关的标识符
private transient volatile int sizeCtl;

// 用于辅助统计元素个数的数组
private transient volatile CounterCell[] counterCells;
2.2 put操作的实现
final V putVal(K key, V value, boolean onlyIfAbsent) {
   
   
    // 不允许null键和null值
    if (key == null || value == null) throw new NullPointerException();
    // 计算hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
   
   
        Node<K,V> f; int n, i, fh;
        // 初始化table
        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;
        }
        // 正在扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 更新或插入节点    
        else {
   
   
            synchronized (f) {
   
   
                if (tabAt(tab, i) == f) {
   
   
                    if (fh >= 0) {
   
   
                        // 链表操作
                    }
                    else if (f instanceof TreeBin) {
   
   
                        // 红黑树操作
                    }
                }
            }
        }
    }
    addCount(1L, binCount);
    return null;
}
3. 并发控制机制
3.1 CAS操作详解

ConcurrentHashMap大量使用CAS(Compare And Swap)操作来确保线程安全,主要体现在以下几个关键场景:

  1. 初始化table数组:
private final Node<K,V>[] ini
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值