HashMap,可以说是Java中最重要的集合之一,从jdk1.7到jdk1.8,经历了翻天覆地的变化,这其中发生了什么?我们从jdk1.7中的HashMap看起,一步步揭开其设计的面纱.相信看完会对你有一定帮助.

首先我们要认清HashMap诞生的意义:数组访问快插入慢,链表访问慢插入快(这里专指头插或尾插等),而HashMap提供访问和插入均衡的O(1),实际上并不能完全做到O(1)而是一个较小的常数复杂度。

JDK1.7中的HashMap

我们从jdk1.7源码出发一步步解析HashMap的原理。其中包括HashMap的put,get,扰乱机制(HashSeed),扩容机制,重哈希等等.
HashMap中核心的几个属性:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子
//HashMap中的键值对对象数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size; //HashMap中元素的个数(包括数组与链表)
final float loadFactor;    //加载因子
int threshold; //扩容阈值,其实就是capacity * loadFactor
transient int modCount; //修改次数(与并发有关)

相信大多数人都知道HashMap底层的结构,就是数组加链表(红黑树是1.8才有的),HashMap中put的大致流程就是:先根据Key算出Hash值再取余将范围缩小至数组长度确定下标,然后放入数组(如果当前位置有元素就通过equals和hashCode方法判断是否应该插入,具体可以看我的另一篇文章),get的大致流程就是通过Key算出Hash值,找到对应数组下标位置,然后遍历该位置上的链表找到Key相同的返回,这些烂大街的八股文想必大家已经听腻了,接下来将会介绍一些容易被忽略的细节,正是这些细节,造成了HashMap从1.7到1.8的大换血.

头插法or尾插法??

在put时,如果发现要put的位置上已经有元素,会通过equals和hashCode判断是否应该插入,如果需要,就将要插入的元素插到链表上.但不知道有没有人注意过,此处是头插还是尾插?有人说,这不就是一个简单的性能问题吗,二者无非是相差了一点点性能,殊不知,正是因为这一举动,造成了1.7中HashMap出现了巨大的问题.
学过数据结构的都知道,单向链表,头插法效率最高,因为如果要尾插的话还需要去遍历找到最后一个元素再去插入,但在这里注意,我们put元素的时候,发现该下标上有元素,我们就会遍历整个链表并挨个判断equals和hashCode,遍历一遍发现没有相等元素,这时才会选择插入新元素,说白了就是,我反正都要遍历的,不管头插尾插我都要遍历一遍链表,所以不存在性能上的差异.在jdk1.7中,HashMap选择了头插法,核心代码如下

    //传入hash值,key,value和应该放的数组下标
    void createEntry(int hash, K key, V value, int bucketIndex) {
        //获取到链表头
        Entry<K,V> e = table[bucketIndex];
        //头插,插入进来的元素成为新头
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

头插法造成了1.7的HashMap隐藏了巨大的问题,具体什么问题,等我们说完扩容再来看.

扰乱机制

什么是扰动机制,实际上就是想办法去让HashMap中的元素更加分散,可以理解为对哈希算法的一种增强,具体可以看下面这段代码

final int hash(Object k) {
        int h = hashSeed;
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

可以看到引入了一个hashSeed,并通过异或,移位等操作去尽量打乱hash值,具体为什么是右移7位/4位等等,这就交给搞数学的人去研究吧.

扩容机制

对于数组我们知道是无法扩容的,所谓的扩容就是再申请一个更大的数组,然后把数据全部转移过去,那这里边就会涉及到两个问题:什么时候扩容?怎么转移数据?

扩容时机

何时扩容?我们在前面提到,HashMap中一个重要属性:

int threshold; //扩容阈值,其实就是capacity * loadFactor

扩容阈值,很多人可能会觉得顾名思义,不就是size超过阈值就扩容吗?实际上1.7不是这样做的,我们来看源码:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //扩容条件
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }

我们可以看到,要同时满足size >= threshold和null != table[bucketIndex](实际上就是要put的位置有元素)才会扩容,即就算size超过阈值了,但我要put进的数组下标此时还没有元素(说白了就是有些位置有元素或者很长的链表,但有些下标位置一个元素都还没有)
我也不会扩容,但是之后1.8的HashMap就取消了这个条件.

indexFor方法

这里要单独讲一下这个方法,因为与扩容机制有关

static int indexFor(int h, int length) {
        return h & (length-1);
    }

前面说到,indexFor方法是根据哈希值h和数组长度length去计算下标位置,目的是尽量使元素分散,我们想到的都是取余,但实际上的源码是用了与运算
我们知道,HashMap不管是初始化或者是扩容时,都会保证数组的大小为2的整数次幂,有二进制基础的应该知道,像4,8,16,32这种数字的二进制有一个特点,就是只有一位是1,比如4是0100,8是1000,所以这里的length就是这样的数,那length-1是什么效果呢?....0000011111,就是这种效果,前面全是0,后面全是1,而这时h & (length-1),会导致高位全部为0,低位全部保留h原本的数据,比如说原来的长度是32,即00100000,减1后变成00011111,这时和h进行&运算,得到结果为000abcde,而因为h已经尽量保证随机且分散了,所以这里的000abcde的取值范围就是00000000~00011111,恰好是下标的范围0~31

在后面扩容的情境下,传进来的参数是2倍的length,即length<<1,这里还以32举例,这次传进来的是64,即01000000,减1后变成00111111,实际上就是高位比32多了一个1,所以在和h进行&运算时,得到的结果只有两种,001abcde和000abcde,所以算出来的下标要么和原来相同,要么是原来的下标+32即原来下标+oldLength

数据转移

重点来了,如何把数据从旧数组中转移到新数组中呢?难道是直接拷贝吗?我们回到本文开头,我们提到,HashMap诞生就是要让插入,查找都变成O(1),但想一想,如果我们直接把老数组的数据拷贝到新数组(就像数组拷贝那样),就会造成下图的结果,扩容后数据分布极不均匀,破坏散列性
image.png
那我们应该怎么解决呢?很容易想到,我们对于旧数组中的每一个元素,重新拿出来计算一个下标放入新数组中即可,源码中也是这样做的

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;
            }
        }
    }

源码中使用了e的hash值重新计算其应该在新数组中的下标,indexFor函数,就像取余一样,能够保证数据在新数组中尽量分散(上面提到了,重新计算下标的结果是有规律的,只可能出现在两个位置),但另一个问题出现了,源码中出现的rehash是什么?

重哈希rehash

什么是重哈希?通俗点讲,就是重新进行哈希,但我们明明知道,对于hash的计算,传入的数据一样,算出的结果就一样,那重哈希有什么用呢?我们前面埋的一个伏笔到这可以揭示了:hashSeed,我们看到在计算hash值时,不仅仅是对数据算了hashCode,还和hashSeed进行了异或等等的扰乱计算,既然数据是一样的,那我们只要修改hashSeed就可以得到完全不同的hash值,也就做到了重哈希.至于如何修改hashSeed的值,源码中写的比较绕,其实就是我们可以配一个JVM参数,指定当HashMap的容量超过多大时就进行重哈希,如果我们不配JVM参数,就永远不会进行重哈希.
至于为什么要进行重哈希,大概是重新打乱数据,让数据更加分散吧.

坑在哪里???

说完了扩容,可以回答前面提出的问题了,头插法带来了什么隐藏的巨大问题?
我们举一个例子
image.png
现在假设这个HashMap需要扩容,我们根据源码来走一遍数据转移的过程

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;
            }
        }
    }

我们会先遍历到1:1,对它重新计算下标后放入新数组,之后是2:2,之后是3:3,依次进行,我们前边提到了,indexFor重新计算下标这个方法,是有规律的,它计算出来的新下标只会有两个结果,一个是原来的下标i,一个是另一个结果A,所以123这三个元素转移后还出现在同一个位置的概率是很高的,但我们在将元素转移到新数组时使用的是头插法,这就导致了123到新元素中变成了321,链表的顺序颠倒了,这在并发的情境下,会造成巨大的问题,我们来看
假设现在有两个线程同时向HashMap中put元素,两个线程都发现HashMap需要扩容,于是同时开始转移元素,现在假设线程1已经给e和next指针赋值成功,如图
image.png
然后,此时操作系统将资源分配给线程2执行,线程2进行了完整的transfer,此时变成(这里做了简化,假设所有元素转移后仍处于原来下标位置),图中的e和next代表线程1的指针现在的位置
image.png
问题出现了,e和next的位置完全颠倒,而且出现在了不该出现的位置,后续按照上面的transfer,线程1继续执行,会出现循环链表,程序会出现无法预知的错误.而这一切的一切,归根结底是使用了头插法导致链表反转造成的,后续我们来看1.8中如何解决这种问题

modCount

关于HashMap的机制基本都说完了,还有最后一个前面挖的坑,就是modCount属性是什么?
学过并发的应该都知道,这是一种fast-file快速失败容错机制,作用就是在出现并发修改等异常的时候提前终止接下来的操作,我们都知道HashMap是线程不安全的,但开发者还是为我们提供了modCount这种手段去看到底有没有并发异常出现,因为操作系统调度的问题,并发异常也不是每次都会出现,所以如果能够接受并发问题,就可以使用HashMap并且通过modCount去看系统什么时候出现了并发异常,那HashMap线程不安全的解决办法是什么?大名鼎鼎的ConcurrentHashMap应运而生了,关于ConcurrentHashMap我们下次再来介绍.


Echo
1 声望0 粉丝