前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【Java】HashMap源码

【Java】HashMap源码

作者头像
JuneBao
发布2022-10-26 14:46:35
5540
发布2022-10-26 14:46:35
举报
文章被收录于专栏:JuneBao

Life is not a ridiculous number of life, the meaning of life lies in life itself

HashMap源码

散列集

数组和链表可以保持元素插入的顺序,对数组来说,他的优点是拥有连续的存储空间,因此可以使用元素下标快速访问,但缺点在于如果要在数组中第n位删除或插入一个新元素,就需要移动n后面的所有元素,比如在ArrayList中删除某个元素就是调用系统的arraycopy方法将数组要删除的元素后面的所有元素向前复制一位得到的。

代码语言:javascript
复制
private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        // 将elementData中从index + 1开始的numMoved长度的元素复制到elementData的index位置
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}

并且定义数组时必须指定容量,如果需要扩容就得重新申请一个更大的数组,然后把原来的数据复制到新数组中,

这就导致数组查询很快,但增删性能不高。

而对链表来说,它的内存空间不是连续的,也就不需要考虑容量问题,但这就导致链表的查询需要逐个遍历LinkedList中虽然可以通过索引来get元素,但也是从头部开始遍历的(如果索引大于size/2就从尾部遍历),效率很低。

散列集(hash table)可以说是数组与链表的组合,

往散列集中添加元素时,通过hash函数可以得到一个该元素的一个哈希值,Java中哈希值的范围在-2147483648~2147483647之间,Object类的hashCode()方法可以返回对象的哈希值,通过hashCode可以确定将该元素存到哪一个数组中,

不能直接使用hashCode,因为它的范围将近40亿,不可能有这么大的数组空间,所以需要对hashCode值做一定的处理,使之在数组容量范围内,最简单的办法是对数组容量取余,但取余有效率问题,所以Java使用了&操作,

如果key是null, 就返回0,否则返回原来哈希值与哈希值右移16位后的结果

比如一个元素的hashCode经过运算得到的值是5,他就会被放在第六个数组中。

应为数组容量是有限的,就一定存在运算后得到同样索引值的情况,称为哈希碰撞,解决哈希碰撞有两种方法:开放地址法拉链法 ,开放地址法是指如果当前的数组已经有元素了,就通过别的算法算出一个新位置插入,像python中dict的实现就使用了开放地址法;而Java中则使用了后者——拉链法,他的思路是如果当前位置有元素了,就把新元素链到旧元素上。

jdk 1.7 以及之前拉链使用一个链表实现,每次有冲突的新元素过来就会把新元素放到数组中,原来的旧链链接到新元素后面【头插法】;

jdk 1.8 开始加入了红黑树,如果数组某个位置的长度超过8并且数组容量超过32就会把链表转换为红黑树,如果红黑树经过删除节点数小于6,就会把树重新转换回链表,以此来提高效率。

JDK 1.7 中的实现

jdk 1.7 以及之前那个数组是Entry类型的,里面封装了key和value,也就是链表的一个节点。

代码语言:javascript
复制
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}

基本属性

代码语言:javascript
复制
// 数组的默认大小,必须是2的倍数, 默认16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 数组最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子,如果数组中75%被占满,就要扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// hashMap中数据的数量
transient int size;

// 与快速失败有关
transient int modCount;

put方法

代码语言:javascript
复制
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        // 初始化一个数组
        inflateTable(threshold);
    }
    // key为null的情况
    if (key == null)
        return putForNullKey(value);
    // 正常其他情况
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

如果当前table是空的的时候(实例化后第一次执行put),需要通过inflateTable()对哈希表进行初始化

代码语言:javascript
复制
private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    // 计算实际的数组大小
    int capacity = roundUpToPowerOf2(toSize);

    // 计算出扩容的临界值threshold
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    // 实例化一个新数组
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}

由于数组容量要求是2的倍数,所以这个方法会先通过roundUpToPowerOf2()根据我们指定的数组容量计算出真实的数组容量capacity,然后实例化一个capacity大小的Entry数组。最后这个initHashSeedAsNeeded()允许你配置一个哈希种子,来手动影响散列结果。

初始化后,由于HashMap允许null作为key值,所以如果key是null,就执行putForNullKey()方法把null: value存入哈希表.

代码语言:javascript
复制
private V putForNullKey(V value) {
    // 遍历数组0位的链表
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        // 如果数组0位链表某个节点key也是null,就替换该节点的值,返回旧值。
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            // 空方法
            e.recordAccess(this);
            return oldValue;
        }
    }
    // 如果0位没有key为null的节点,就创建新节点并加入链表
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}
代码语言:javascript
复制
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果HashMap中元素的数量大于临界值并且发生了冲突,就扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    // 创建新的Entry对象
    createEntry(hash, key, value, bucketIndex);
}
代码语言:javascript
复制
void createEntry(int hash, K key, V value, int bucketIndex) {
    // 原来的链表
    Entry<K,V> e = table[bucketIndex];
    // 实例化一个新的Entry对象,next指向旧的链表e
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    // 元素个数加一
    size++;
}

  1. HashMap允许null作为key,并且这个元素始终放在数组第0位

回到正常情况,key是null就确定它存放在数组0位,但其他的key就需要通过计算得到index值,jdk1.7中首先在hash()方法中对对象原本的hashCode做一系列移位操作后,再在indexFor()方法中与数组长度做与运算得出对象最终应该被放在数组的哪一位。

代码语言:javascript
复制
final int hash(Object k) {
    // 可以设置环境变量来提供一个哈希种子
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    // 这个种子会通过与对象原来的hashCode做异或从而影响最终散列效果
    h ^= k.hashCode();

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
代码语言:javascript
复制
static int indexFor(int h, int length) {
    return h & (length-1);
}

出于性能的考虑,在获得最终的index时,Java采用了&操作而不是更简单的取余,这就导致数组长度必须是2的倍数,同时hash()方法中多次移位和异或也是应为这样。

比如一个字符串 “重地” 通过 hashCode()方法得到它原先的hashCode值为 1179395,假设数组没扩容,哈希种子是默认值0,那它计算index的过程应该是: 与hashSeed做异或,得到的还是它本身 右移20位的结果与右移12位的结果做异或

代码语言:javascript
复制
h =         : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395)
a = h >>> 20: 0000 0000 0000 0000 0000 0000 0000 0001 (1)
b = h >>> 12: 0000 0000 0000 0000 0000 0001 0001 1111‬ (287)
----------------------------------------------------------------
a ^ b =     : 0000 0000 0000 0000 0000 0001 0001 1110 (286)
h =         : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395)
----------------------------------------------------------------
h =         : 0000 0000 0001 0001 1111 1110 0001 1101 (1179165)
c = h >>> 7 : 0000 0000 0000 0000 0010 0011 1111 1100
----------------------------------------------------------------
h ^ c =     : 0000 0000 0001 0001 1101 1101 1110 0001
d = h >>> 4 : 0000 0000 0000 0001 0001 1101 1101 1110
----------------------------------------------------------------
h ^ d =     : 0000 0000 0001 0000 1100 0000 0011 1111
len - 1     : 0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------------------------
index &     : 0000 0000 0000 0000 0000 0000 0000 1111

到最后发现,真正参与运算的只有低四位,之所以做多次位移和异或运算,就是为了把hashCode的高位也参与到最后的与运算中,让得到的index尽量分散,如果把最高位用A表示,可以看到经过上面的算法,最高位究竟影响了哪些位置:

代码语言:javascript
复制
h =         : A000 0000 0001 0001 1111 1111 0000 0011 (1179395)
a = h >>> 20: 0000 0000 0000 0000 000A 0000 0000 0001 (1)
b = h >>> 12: 0000 0000 0000 A000 0000 0001 0001 1111‬ (287)
----------------------------------------------------------------
a ^ b =     : 0000 0000 0000 A000 000A 0001 0001 1110 (286)
h =         : 0000 0000 0001 0001 1111 1111 0000 0011 (1179395)
----------------------------------------------------------------
h =         : 0000 0000 0001 A001 111A 1110 0001 1101 (1179165)
c = h >>> 7 : 0000 0000 0000 0000 001A 0011 11A1 1100
----------------------------------------------------------------
h ^ c =     : 0000 0000 0001 A001 110A 1101 11A0 0001
d = h >>> 4 : 0000 0000 0000 0001 A001 110A 1101 11A0
----------------------------------------------------------------
h ^ d =     : 0000 0000 0001 A000 110A 000A 00A1 11A1
len - 1     : 0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------------------------
index &     : 0000 0000 0000 A000 000A 000A 00A0 11A1

最高位最后影响了低四位。 为什么数组容量要是2的倍数 让与运算之后的结果分布在 0 ~ (len -1) 之间

算出index之后的代码逻辑就和putForNullKey差不多了,唯一的区别在于:

代码语言:javascript
复制
if (e.hash == hash && ((k = e.key) == key || key.equals(k))){...}

这样设计的原因在于:

  • 哈希值不同一定不是同一个对象
  • 同一个对象哈希值不一定相同
扩容

是否扩容的判断在addEntry方法中,如果满足扩容条件,是先扩容,再添加新元素

代码语言:javascript
复制
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            // 2倍扩容
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

扩容需要满足两个条件:

  1. HashMap中元素个数大于等于threshold
  2. 即将要新插入的元素发生了冲突

第一个条件 size是总元素个数,但threshold是根据数组容量算的。

代码语言:javascript
复制
 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

代码语言:javascript
复制
void resize(int newCapacity) {
    // 得到旧数组的引用
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 如果旧数组已经不能再长了,就不扩容了
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 创建一个2倍旧数组大小的新数组
    Entry[] newTable = new Entry[newCapacity];
    // 将旧数组的元素转移到新数组
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    // 重新计算扩容临界值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

扩容最核心的就是数据转移,也就是transfer()方法

代码语言:javascript
复制
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;
        }
    }
}

由于数组容量变了两倍,所以index也许需要重新计算,但计算中其实前面的步骤都一样,只不过最后一步时 length - 1 在最前面多了一个1,所以哪怕index值改变,变化后的index与原来的也是2的倍数关系(1.8中用到了这个规律)

扩容过程中出现的循环链表的情况

这是两个线程进入transfer后一开始的情况(两个线程现在都有了自己新的数组),如果线程1正常执行完成,线程2阻塞在Entry<K,V> next = e.next;之后,那结果就是:

然后线程2开始执行

就出现了循环链表的情况。

参考

参考2

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-4-8,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • HashMap源码
    • 散列集
      • JDK 1.7 中的实现
        • 基本属性
        • put方法
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档