HashMap

本文详细解析HashMap的数据结构(数组+链表/红黑树)、Put和Get操作原理,探讨高并发下Rehash策略,以及ConcurrentHashMap的锁分段与Size计算机制。涵盖了HashMap的扩容与线程安全解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap是由数组+链表组成的
JDK1.8之后是数组+链表+红黑树

HashMap的实现原理

HashMap是一个用于存储Key-Value键值对的集合, 每一个键值对也叫做Entry. 这些个键值对(Entry)分散存储在一个数组当中, 这个数组就是HashMap的主干.

HashMap数组每一个元素的初始值都是Null.

对于HashMap, 我们最常使用的是两个方法:Get和Put.

Put方法的原理

调用Put方法的时候发生了什么?

比如调用hashMap.put(“apple”,0), 插入一个Key为"apple"的元素. 这时我们需要利用一个哈希函数来确定Entry的插入位置 index=Hash(“apple”)

最后计算出的index是2, 那么将这个键值对插入到下标为2的数组当中.

但是, 因为HashMap的长度是有限的, 当插入的Entry越来越多时, 再完美的Hash函数也会产生index冲突(哈希冲突).

这时候就利用链表来解决.

HashMap数组的每一个元素不止是一个Entry对象, 也是一个链表的头结点.

每一个Entry对象通过Next指针指向它的下一个Entry节点. 当新的Entry映射冲突的数组位置时, 只需要插入对应的链表即可.

需要注意的是, 新来的Entry节点插入链表时, 使用的是"头插法".

Get方法的原理

使用Get方法根据Key来查找Value的时候, 发生了什么呢?

首先会把输入Key做一次Hash映射, 得到对应的 index=Hash(“apple”)

由于可能会存在哈希冲突, 同一个位置有可能匹配到多个Entry, 这时候就需要顺着对应链表的头结点, 一个一个向下来查找. 假设我们要查找的Key是"apple":

第一步, 查看头结点Entry3, Entry3的Key是banana, 显然不是要找的结果.

第二步, 查看的Next节点Entry1, Entry1的Key是apple, 完成.

之所以使用"头插法", 是因为HashMap的发明者认为, 后插入的Entry被查找的可能性更大.

HashMap默认初始长度是16, 并且每次启动扩展或是手动初始化时,长度必须是2的幂.

初始长度16是为了服务于Key映射到index的Hash算法.

之前说过, 从Key映射到HashMap数组的对应位置, 会用到一个Hash函数: index=Hash(“Key”)

如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算.

为了实现高效的Hash算法, HashMap的发明者采用了位运算的方式
(Length是HashMap的长度)

index=HashCode(Key)&(Length-1)

下面我们以值"book"的Key来演示整个过程:

  1. 计算book的hashcode, 结果为十进制的3029737,二进制的10 1110 0011 1010 1110 1001
  2. 假定HashMap长度是默认的16, 计算Length-1的结果为十进制的15, 二进制的1111.
  3. 把以上两个结果做与运算,10 1110 0011 1010 1110 1001 & 1111=1001,十进制是9, 所以index=9.
    可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位.

这样做的在效果上不仅等同于取模, 而且还大大提高了性能. 长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值. 只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的.

高并发下的HashMap

Rehas是HashMap在扩容时候的一个步骤.

HashMap的容量是有限的, 当经过多次元素插入, 使得HashMap达到一定饱和度时, Key映射位置发生冲突的几率会逐渐提高.

这时候,HashMap需要扩展它的长度, 也就是Resize

影响Resize的因素有两个:
Capacity: HashMap的当前长度(2的幂)
LoadFactor: HashMap扩容因子, 默认值为0.75f

衡量HashMap是否进行Resize的条件如下: HashMap.Size>=Capacity * LoadFactor

Resize需要经过两个步骤:

  1. 扩容
    创建一个新的Entry空数组, 长度是原数组的两倍.

  2. ReHash
    遍历原Entry数组, 把所有的Entry重新Hash到新数组. 重新Hash的原因是因为长度扩大后, 哈希的规则也随之改变了.

Hash公式:index=HashCode(Key)&(Length-1)
当原数组长度为8时, Hash运算和111B做与运算; 新数组长度为16, Hash运算是和1111B做与运算. Hash结果显然不同.

Rehash的java源码:

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

但是, HashMap并非线程安全的
ReHash在并发的情况下可能会形成链表环!

线程安全的Hash容器

线程安全的集合类: Vocter、HashTable、Collections.synchronizedMap.

HashTable、Collections.synchronizedMap,但是这两者有共同的性能: 性能.

无论读操作还是写操作, 它们都会给整个集合加锁, 导致同一时间的其他操作为之阻塞.

在并发环境下, 如何能够兼顾线程安全和运行效率呢?这时候ConcurrentHashMap就诞生了.

相比较HashMap,ConcurrentHashMap多了一个Segment.
Segment本身就相当于一个HashMap对象.

同HashMap一样, Segment包含一个HashEntry数组, 数组中的每一个HashEntry既是一个键值对, 也是一个链表的头节点.

单一Segment结构:
在这里插入图片描述
像这样的Segment对象, 在ConcurrentHashMap集合中有2的N存放个, 共同保存一个名为segments的数组当中.

ConcurrentHashMap结构:
在这里插入图片描述
可以说,ConcurrentHashMap是一个二级哈希表. 在一个总的哈希表下面,有若干个子哈希表.

这样的二级结构,和数据库的水平拆分有些相似.

ConcurrentHashMap优势就是采用了[锁分段技术], 每一个Segment就好比一个自治区,读写操作高度自治,Segment之间互不影响.

ConcurrentHashMap并发

情况1: 不同Segment的并发写入
在这里插入图片描述
不同Segment的写入是可以并发执行的.

情况2: 同一Segment的一写一读:
在这里插入图片描述
同一Segment的写和读是可以并发执行的.

情况3: 同一Segment的并发写入
在这里插入图片描述
Segment的写入是上锁的, 因此对同一Segment的并发写入会被阻塞.

由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁. 在保证线程安全的同时降低了锁的粒度,让并发操作效率更高.

ConcurrentHashMap读写过程
  • Get方法
  1. 为输入的Key做Hash运算,得到hash值.
  2. 通过hash值,定位到对应的Segment对象
  3. 再次通过hash值,定位到Segment当中数组的具体位置.
  • put方法
  1. 为输入的Key做Hash运算,得到hash值.
  2. 通过hash值,定位到对应的Segment对象.
  3. 获取可重入锁
  4. 再次通过hash值,定位到Segment当中数组的具体位置.
  5. 插入或覆盖HashEntry对象.
  6. 释放锁.

ConcurrentHashMap在读写时都需要二次定位. 首先定位到Segment, 之后定位到Segment内的具体数组下标.

既然每一个Segment都各自加锁, 那么在调用Size方法的时候, 怎么解决一致性问题.

Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来.

但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?

ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:

  1. 遍历所有的Segment.
  2. 把Segment的元素数量累加起来.
  3. 把Segment的修改次数累加起来.
  4. 判断所有Segment的总修改次数是否大于上一次的总修改次数. 如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是. 说明没有修改,统计结束.
  5. 如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计.
  6. 再次判断所有Segment的总修改次数是否大于上一次的总修改次数. 由于已经加锁,次数一定和上次相等.
  7. 释放锁,统计结束.

官方源代码如下:

public int size() {
    // Try a few times to get accurate count. On failure due to
   // continuous async changes in table, resort to locking.
   final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {
        for (;;) {
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

为什么这样设计呢?这种思想和乐观锁悲观锁的思想如出一辙.

为了尽量不锁住所有Segment,首先乐观地假设Size过程中不会有修改. 当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性.

本篇ConcurrentHashMap原理和代码,都是基于Java1.7的。在Java8中会有些许差别.

ConcurrentHashMap在对Key求Hash值的时候,为了实现Segment均匀分布,进行了两次Hash.

关于容量的一些问题

为什么hashmap只支持可扩容增, 而不能动态减容积
hashmap为什么要缩容:1.程序员需要主动缩容比如程序初始化的时候刷新了所有用户的缓存,然后这个map就必须缩小到一个非常小的容量,用来做缓存。这种情况下,还是new一个HashMap,然后foreach添加到新的hashmap中比较好。反正也是O(n)的复杂度。2.系统内存紧张,需要释放内存很遗憾,JVM中目前没有让用户代码感知到内存紧张并主动释放内存的机制。当然软引用应该也算一个,但是如果用软引用来实现动态缩容的话,效果就是:我FullGC结束了,你才执行释放内存的操作。第一太晚了,第二耗时也比较严重。3.hashmap中的数据量少的时候,自动缩容这个有两个实现方式。第一,在remove操作中做缩容操作。那就非常怪异了,remove操作居然有可能是O(n)的复杂度,很多时候这个是不能接受的。。第二,合适的时候通知单独的线程来做缩容操作。众所周知,hashmap不是线程安全的。。。所以你看,不管怎么样,这个缩容都是鸡肋的操作,所以不如没有它。当然,再拔高一点:Java在大部分情况下都是用空间换时间的,缩容这种不符合Java的哲学。(来自知乎的回答)

redis有自动缩容的特性

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值