HashMap底层原理详解

HashMap底层原理详解及ConcurrentHashMap并发优化

引言

在Java集合框架中,HashMap无疑是使用最广泛的键值对存储结构。它基于哈希表实现,提供了平均O(1)的查找和插入性能。本文将深入剖析HashMap的底层实现原理,包括数据结构、哈希计算、冲突解决、扩容机制等核心内容,并通过图文结合的方式帮助理解。同时,我们还将探讨线程安全的ConcurrentHashMap实现,分析其如何在保证线程安全的同时实现比Hashtable更高的并发效率。

一、HashMap底层数据结构

1.1 JDK 7及之前的实现:数组+链表

早期的HashMap采用数组+链表的复合结构(也称为"拉链法")。数组(称为"哈希桶")是主体,每个数组元素都是一个链表的头节点。当发生哈希冲突时,冲突的元素会被添加到链表中。

1.2 JDK 8及之后的优化:数组+链表+红黑树

JDK 8对HashMap进行了重大优化,引入了红黑树数据结构。当链表长度超过阈值(默认为8)且数组长度不小于64时,链表会转换为红黑树,以提高查询效率(从O(n)优化到O(log n))。
在这里插入图片描述

HashMap数据结构示意图,蓝色表示数组,绿色表示链表节点,红色表示红黑树节点

二、哈希函数与索引计算

2.1 哈希值计算

HashMap通过以下步骤计算键的哈希值:

  1. 调用键的hashCode()方法获取初始哈希值
  2. 对初始哈希值进行扰动处理(JDK 8中简化为一次右移16位并异或):
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    扰动处理的目的是将高位信息混入低位,减少哈希冲突。

2.2 数组索引计算

通过哈希值计算数组索引的公式为:

index = (n - 1) & hash

其中n是数组长度(必须是2的幂)。这种计算方式等价于hash % n,但位运算效率更高。

三、哈希冲突解决方法

3.1 链地址法

HashMap采用链地址法(拉链法)解决哈希冲突:当两个不同的键计算出相同的索引时,将它们存储在同一个索引位置的链表(或红黑树)中。

在这里插入图片描述

两个不同的键(Key1和Key2)计算出相同索引,通过链表存储在同一位置

3.2 红黑树转换条件

当满足以下两个条件时,链表会转换为红黑树:

  1. 链表长度超过阈值(TREEIFY_THRESHOLD = 8)
  2. 数组长度不小于最小树化容量(MIN_TREEIFY_CAPACITY = 64)

如果数组长度小于64,会先进行扩容而非树化,以避免在数组较小时频繁进行树化操作

四、扩容机制

4.1 扩容触发条件

当HashMap的实际元素数量超过阈值(threshold) 时,会触发扩容。阈值计算公式为:

threshold = capacity * loadFactor
  • capacity:数组容量(默认为16,必须是2的幂)
  • loadFactor:负载因子(默认为0.75)

4.2 扩容过程

扩容(resize()方法)的主要步骤:

  1. 计算新容量(原容量的2倍)
  2. 创建新的数组(大小为新容量)
  3. 将原数组中的元素迁移到新数组中
  4. 更新阈值和容量

JDK 8对迁移过程进行了优化:无需重新计算哈希值,而是通过高位运算(hash & oldCap)判断元素在新数组中的位置(原位置或原位置+oldCap)。
在这里插入图片描述

HashMap扩容过程,数组大小从8扩容到16,元素根据高位信息迁移到新位置

4.3 扩容示例

假设原数组容量为8(n=8),某个元素的哈希值为hash = 0b10101100

  • 原索引:(8-1) & hash = 0b00000111 & 0b10101100 = 0b00000100 = 4
  • 扩容后容量为16(n=16)
  • 新索引:(16-1) & hash = 0b00001111 & 0b10101100 = 0b00001100 = 12
  • 也可通过hash & oldCap(0b10101100 & 0b00001000 = 0b00001000 != 0)判断应迁移到原索引+oldCap位置

五、常用方法解析

5.1 put方法流程

  1. 计算键的哈希值和数组索引
  2. 如果数组为空,先进行初始化
  3. 如果目标位置为空,直接插入新节点
  4. 如果目标位置不为空:
    • 如果是红黑树节点,调用红黑树的插入方法
    • 如果是链表节点,遍历链表查找是否存在相同键:
      • 存在则替换值
      • 不存在则添加到链表尾部,并检查是否需要树化
  5. 检查是否需要扩容

5.2 get方法流程

  1. 计算键的哈希值和数组索引
  2. 检查目标位置的第一个节点:
    • 如果是红黑树节点,调用红黑树的查找方法
    • 如果是链表节点,遍历链表查找匹配的键
  3. 找到则返回对应值,否则返回null

六、HashMap的线程不安全问题

HashMap不是线程安全的,在多线程环境下可能出现以下问题:

6.1 数据覆盖

当多个线程同时执行put操作,且计算出相同索引时,可能导致后插入的数据覆盖先插入的数据。

6.2 JDK 7中的死循环问题

JDK 7中,多线程扩容时可能导致链表形成环形结构,进而在get操作时陷入死循环。这是因为JDK 7的扩容迁移过程采用头插法,可能导致链表反转。JDK 8改用尾插法解决了此问题,但HashMap仍然不是线程安全的。

七、ConcurrentHashMap介绍

ConcurrentHashMap是HashMap的线程安全版本,专为高并发场景设计。它提供了比Hashtable更高的并发性,同时保证了线程安全。

7.1 与HashMap、Hashtable的对比

特性HashMapHashtableConcurrentHashMap
线程安全
锁机制全表锁(synchronized)分段锁(JDK 7)/CAS+节点锁(JDK 8)
并发性能
支持null键值

7.2 JDK 7实现:分段锁(Segment)

JDK 7的ConcurrentHashMap采用分段锁(Segment) 机制,将整个哈希表分为多个Segment(每个Segment是一个独立的哈希表),每个Segment拥有自己的锁。不同Segment上的操作可以并发进行,从而提高并发性。

在这里插入图片描述

JDK 7的ConcurrentHashMap结构,多个Segment独立加锁,减少锁竞争

7.3 JDK 8实现:CAS+synchronized+红黑树

JDK 8对ConcurrentHashMap进行了彻底重构,取消了Segment,采用CAS+synchronized+红黑树的实现:

  • 底层数据结构与HashMap类似(数组+链表+红黑树)
  • 使用CAS操作实现无锁化修改
  • 仅对冲突的节点使用synchronized加锁
  • 读操作无需加锁,通过volatile保证可见性

ConcurrentHashMap JDK 8节点锁示意图

图5:JDK 8的ConcurrentHashMap实现,仅对冲突节点加锁,读操作无锁

八、ConcurrentHashMap的线程安全机制

8.1 CAS操作

CAS(Compare-And-Swap)是一种无锁化同步机制,用于原子性地更新值。ConcurrentHashMap在以下场景使用CAS:

  • 初始化数组
  • 添加第一个节点
  • 扩容时迁移数据
  • 计数操作(size()方法)

8.2 synchronized节点锁

当需要修改已有节点时,ConcurrentHashMap使用synchronized关键字锁定链表或红黑树的首节点。这种节点级别的细粒度锁大大减少了锁竞争,提高了并发性。

8.3 读操作无锁

读操作无需加锁,通过以下机制保证可见性:

  • 节点的val和next字段使用volatile修饰
  • 数组引用使用volatile修饰,保证扩容时的可见性
  • 红黑树的根节点使用volatile修饰

九、为什么ConcurrentHashMap比Hashtable效率高

9.1 锁粒度的差异

  • Hashtable:使用synchronized修饰方法,实现全表锁。任何线程操作Hashtable时都需要获取整个对象的锁,导致多线程环境下严重的锁竞争。
  • ConcurrentHashMap
    • JDK 7:分段锁,将锁粒度减小到Segment级别
    • JDK 8:节点锁,进一步将锁粒度减小到哈希桶的首节点

锁粒度越小,并发性能越好。

9.2 读写分离

ConcurrentHashMap支持读多写少的并发场景:

  • 读操作无需加锁,通过volatile保证可见性
  • 写操作仅锁定冲突的节点
  • 多个读操作可以完全并发执行

而Hashtable的读操作也需要获取锁,导致读操作之间也存在竞争。

9.3 高效的扩容机制

ConcurrentHashMap的扩容过程支持并发扩容

  • 扩容时,原数组和新数组同时存在
  • 多个线程可以同时参与数据迁移
  • 迁移期间,读操作可以访问原数组或新数组
  • 写操作会协助完成迁移

这种设计大大提高了扩容效率,减少了扩容对性能的影响。

十、总结与应用场景

10.1 HashMap总结

  • 底层数据结构:JDK 7为数组+链表,JDK 8为数组+链表+红黑树
  • 哈希冲突解决:链地址法,长链表转换为红黑树优化查询
  • 扩容机制:2倍扩容,JDK 8优化了数据迁移算法
  • 特点:查询和插入效率高(平均O(1)),但线程不安全
  • 适用场景:单线程环境下的键值对存储

10.2 ConcurrentHashMap总结

  • 线程安全机制:JDK 7为分段锁,JDK 8为CAS+synchronized
  • 特点:高并发、线程安全、支持部分原子操作
  • 与Hashtable对比:锁粒度更小,读写分离,并发性能更高
  • 适用场景:多线程环境下的高并发读写操作,如缓存、共享数据存储

总结

哈希桶数组(Table):初始长度为 16(DEFAULT_INITIAL_CAPACITY),每个元素称为一个 “桶”(Bucket)。
数组长度始终为 2 的幂次方(如 16、32、64…),通过位运算优化哈希冲突。
链表:当多个键的哈希值冲突时,冲突的元素以链表形式存储在同一个桶中。
JDK 8 之前采用头插法(新节点插入链表头部),JDK 8 改为尾插法(避免多线程扩容时的死循环)。
红黑树:当链表长度超过 8(TREEIFY_THRESHOLD)且数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)时,链表转换为红黑树。
红黑树的查询、插入、删除时间复杂度为 O (log n),避免链表过长导致性能下降。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值