java知识补充四:HashMap细节回顾

HashMap的存储方式

一般情况下,HashMap是以数组加链表的形式存储的,和数据结构中的散列表的概念基本相同。

在java的HashMap中,每一对key-value键值对被看做一对Entry.

java会根据entry中的key计算hash值。根据这个hash值计算出哈希表中要存储的哈希桶中,如果计算出不同key的哈希值相同,那么此时就产生了哈希碰撞(哈希冲突)那么在同一哈希桶中以链表形式存储这两个entry。不同的jdk版本存储的方式不同,jdk7中是头插法,即在距离数组近的一端插入新来的元素。jdk8在远离数组一端插入新来的元素。

大致图解如下:

HashMap的查找复杂度和插入、删除复杂度概念上都是O(1),可以说是非常高效的一种存储方式。

HashMap的实例化

HashMap的实例化源码:
构造器为空时:

public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
        //new HashMap();未指定容量时,初始容量16,DEFAULT_LOAD_FACTOR为0.75
    }

空参构造器调用了两个参数的构造器:
我们先来看这两个定值的多少:

static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

可以看出,未指定参数时,默认会创建一个大小为16,负载因子为0.75的HashMap。
下面我们来看这个有两个参数的构造器:

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)//初始容量小于零,报错
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)//初始容量大于了最大容量常量1073741824
            initialCapacity = MAXIMUM_CAPACITY;//将容量设为最大容量常量1073741824
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;  //这里创建的容量永远为2的整数次方
                            //比如new HashMap(15) 实际上new了大小为16的HashMap

        this.loadFactor = loadFactor;//0.75---加载因子,下面让16乘加载因子等于临界值(12)
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];//即填超过12个数的时候就开始扩容,而不是到16以上才扩容
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        init();
    }

如果手动填入了这两个参数:

  • 对于HashMap的大小,这一段代码很重要:
int capacity = 1;
while (capacity < initialCapacity)
    capacity <<= 1;  //这里创建的容量永远为2的整数次方
//比如new HashMap(15) 实际上new了大小为16的HashMap

这里的左移其实就是乘二,为什么要左移是因为java底层中移位操作效率比直接乘要高。
将capacity 容量设置为1,不断左移乘2,直到得到的结果比填入的参数大为止。
可以看出,HashMap底层初始化的真实容量:
1.在没有填入参数时,容量为16.
2.在填入了参数大小时,容量为比这个填入的参数大的最小2的整数次方。
3.hashmap的扩容永远是两倍两倍的扩的。

  • 对于负载因子:
 this.loadFactor = loadFactor;//0.75---加载因子,下面让16乘加载因子等于临界值(12)
 threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
 table = new Entry[capacity];//即填超过12个数的时候就开始扩容,而不是到16以上才扩容

负载因子的意思就是,当hashmap的元素数量超过了 容量 * 负载因子 的时候,hashmap会自动扩容,而不是等hashmap满了之后再扩容。

为什么负载因子是0.75?

jdk源码中设计0.75位负载因子是因为,0.75是对hashmap的动态扩容时的时间效率和空间效率上面的一个平衡。负载因子越高,空间占用效率就越高,但是可能会造成链表过长,导致查询效率低下。负载因子太低,会有比较多的空间浪费。之所以定在0.75是因为:由于泊松分布,同一哈希桶内有8个以上的概率极小,基本可以忽略。所以0.75作为负载因子可以让出现8个元素的链表的概率变得极小。

HashMap的put操作(jdk7)

 public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);//可以放null的key
        int hash = hash(key);//计算key的哈希值
        int i = indexFor(hash, table.length);//return hash & (table.length-1)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {//遍历链表的循环
            Object k;
            //如果哈希值相等,且key相等,用新定义的entry替换原有的entry
            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);//没有进去的情况下,直接在空位置放进去k,v即可
        return null;
    }

我们重点看源码中怎样将entry要放置的位置计算出来的,关键在于这两句:

int hash = hash(key);//计算key的哈希值
int i = indexFor(hash, table.length);//return hash & (table.length-1)

我们继续看indexFor这个静态方法:

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

h & (length-1)
这个就是计算entry的放置位置index的方法。

为什么HashMap的容量始终为2的整数次方?

这个问题现在就可以解释的通了,因为entry存储的位置是由 h & (length-1) 计算得到的,假设数组的长度始终为2的n次方,那么我们可以知道:2的n次方length底层的二进制数都是这样婶儿的

1000000000…
他们的首位始终为1,后面所有位均为0,那么如果将这个数字减1,就可以得到(length - 1)大致是这样婶儿的:

011111111…
他们的首位是0,但是后面所有的位均为1

我们知道按位与的操作是:两个数字对应位置上均为1,得到的结果才为1,我们别忘了计算index的公式是:
h & (length-1)
如果我们计算index的时候,length-1这个数字二进制有很多位上为0的话,那么这些位置上面与操作得到的结果永远永远不可能为1!。这样就会造成计算出来的存储位置重合性太高,有的哈希桶一直为空,不仅浪费空间,还容易造成链表过长,可谓是即浪费空间也浪费效率。可以说,将HashMap的长度设置为2的n次方是一个兼顾效率和空间的完美选择

hash值是怎么来的?

jdk7

final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {//默认false,进不来
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }

        h ^= k.hashCode();//得到Object k的hashCode再亦或

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12); //再进行一系列无符号右移加亦或
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

jdk7中,计算hash值的方法为,先得到hashCode并亦或,然后再通过一系列的无符号右移和亦或来得到最后的hash值。
为什么计算hash值需要这么多的操作?
无论jdk7 / 8, hashCode往往很长,但是length往往很短,这样的话容易造成如果计算hash值时,如果只取到了低位的一些信息,那么很容易发生哈希碰撞,所以需要不断的右移,将高位的信息补充进来,这样可以减少碰撞概率。

jdk8

jdk8中的计算hash值的方法被大大简化,

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

首先还是取得hashCode,然后**(h = key.hashCode()) ^ (h >>> 16)**
即hashCode的低16位和高16位进行亦或操作。这样也可以达到补充hashCode高位信息的效果。

addEntry()方法实现

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {//长度大于12且要放的位置不为空时,进行下面操作
            resize(2 * table.length);//扩容为原来的二倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);//不需要扩容
    }

可以看出如果加上一个元素之后元素数量超过了 threshold(capacity * load_factory),那么就会自动扩容。扩容之后重新计算hash值,并依据新的hash值计算index。
这里点进resize()这个扩容方法进去:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

进入调用的transfer方法:

    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.next = newTable[i];
                newTable[i] = e;
                e = next;

这三行代码完成了HashMap扩容之后的元素的头插法的操作。过程如下图:
在这里插入图片描述
jdk8中不再使用头插法。

HashMap的死锁问题

死锁问题的分析可以看这篇文章

https://coolshell.cn/articles/9606.html

总结来说就是多线程环境下,t1线程t2线程要扩容,t1在扩容时,t2进来并完成了扩容。t1继续完成扩容时,要添加元素指针指向了线程二已经扩容好的链表尾部,这样扩容之后再次进行查询操作就会形成环形链表,产生死锁。
jdk7中扩容会产生死锁问题在于头插法,头插法没有保证元素扩容前后的顺序保持不变。
而jdk8中,使用尾插法添加元素,并且保持扩容前后元素的顺序,减少了死锁的发生。

而且jdk8中,元素在扩容后新的哈希桶的位置总是在原来的位置或者:(原来的位置+原来map的容量) 处的哈希桶内。
我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。 看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例, 其中 hash1 是 key1 对应的哈希与高位运算结果。
在这里插入图片描述
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因 此新的 index 就会发生这样的变化:
在这里插入图片描述
及扩容前假如只取了4位,扩容后直接看最高位的数字是1还是0即可,0的话还是以前的位置,1的话向高位走一个旧容量的距离。

jdk8 HashMap的优化

1.以上提到的计算hash值的方法。
2.为了避免死锁放弃头插法,并且扩容时保持链表顺序,且大大简化了重新哈希的过程,只看最高位是1还是0即可确定新的位置。
3.链表长度超过8的时候转化为红黑树存储,提高效率,为什么设计成8个?因为哈希桶内的元素个数符合e=0.5的泊松分布,n>8时,概率约为十万分之一非常小。当红黑树元素少于6个的时候,再次退化为链表。

发布了16 篇原创文章 · 获赞 2 · 访问量 425

猜你喜欢

转载自blog.csdn.net/qq_31314141/article/details/104383301
今日推荐