我的hashmap理解

1,564 阅读5分钟

这篇文章是在网上看了别人的博客再结合自己的理解写的,仅供自己复习用

hashmap的构成

hashmap由一个数组(桶bucket)和桶上所挂的链表组成 

put()

当一个键值对放入hashmap时,首先会将key拿去得到一个hashcode从去寻找bucket

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

如果不存在bucket就将该hash值赋给一个bucket,如果存在就在,首先要与这个bucket上的entry进行==或.equals()。如果找到了相等的则覆盖,否则就在链表尾部插入一个新的entry节点

  if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
                e = p;
            } else if (p instanceof HashMap.TreeNode) {
                e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
            }
            //put操作

但是如果链表后一直新插入一个节点会很容易导致hash冲突以及搜索时间过长,所以引入了转红黑树。当一个bucket上的entry数量大于8了的话就会转为红黑树。当数量为6就会转换回去

while(true) {
                    if ((e = ((HashMap.Node)p).next) == null) {
                        ((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
                        if (binCount >= 7) {
                            this.treeifyBin(tab, hash);
                        }
                        break;
                    }

                    if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
                        break;
                    }

                    p = e;
                    ++binCount;
                }
            }
            //至于为什么是8我也没搞明白

get()

get()类似于put()。都是先把key转为hashcode然后去找对应的bucket,然后再去判断key是否和bucket上的entry相等,相等则拿到

hashmap扩展

引入两个重要概念,第一个是初始容量,第二个是装载因子

public HashMap(int initialCapacity, float loadFactor) ;

装载因子是一个当前bucket与最大值的比值,默认为0.75.
那么是在什么时候进行hashmap 的扩展呢?

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//计算键的hash值
        int i = indexFor(hash, table.length);//通过hash值对应到桶位置
        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))) {//注意这里的键的比较方式== 或者 equals()
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);//遍历单链表完毕,没有找到与键相对的Entry,需要新建一个Entry换句话说就是桶i是一个空桶;
        return null;
    }

还是这段代码,在put时如果没有对应的entry就会新建一个换句话说这个bucket是个空桶。既然是个空桶,新建这个entry必然是该bucket第一个节点,这就找到了扩容的时机

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {//当size大于等于某一个阈值thresholdde时候且该桶并不是一个空桶;
          /*这个这样说明比较好理解:因为size 已经大于等于阈值了,说明Entry数量较多,哈希冲突严重,那么若该Entry对应的桶不是一个空桶,这个Entry的加入必然会把原来的链表拉得更长,因此需要扩容;若对应的桶是一个空桶,那么此时没有必要扩容。*/
            resize(2 * table.length);//将容量扩容为原来的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);//扩容后的,该hash值对应的新的桶位置
        }

        createEntry(hash, key, value, bucketIndex);//在指定的桶位置上,创建一个新的Entry
    }

    /**
     * Like addEntry except that this version is used when creating entries
     * as part of Map construction or "pseudo-construction" (cloning,
     * deserialization).  This version needn't worry about resizing the table.
     *
     * Subclass overrides this to alter the behavior of HashMap(Map),
     * clone, and readObject.
     */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);//链表的头插法插入新建的Entry
        size++;//更新size
    }

上面两个重要的成员变量size和threshold

size记录的是map中包含的Entry的数量,而threshold记录的是需要resize的阈值 且 threshold = loadFactor * capacity ,capacity 其实就是桶的长度
上面的代码其实就对应一点,什么时候该扩容。当entry的数量大于阙值时就应该扩容。扩容的操作就是将数组的容量扩大两倍,为什么扩大两倍,后面会说。在扩大两倍只会重新把hash值放在新的桶的位置,并在桶后面跟上entry

扩容的过程

上面扩容的时候有个重要的函数

 resize(2 * table.length);//将容量扩容为原来的2倍

现在来看下这个函数

 void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {//最大容量为 1 << 30
            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;//是否再hash
        transfer(newTable, rehash);//完成旧表到新表的转移
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    /**
     * 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;//引用next
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);//找到新表的桶位置;原桶数组中的某个桶上的同一链表中的Entry此刻可能被分散到不同的桶中去了,有效的缓解了哈希冲突。
                e.next = newTable[i];//头插法插入新表中
                newTable[i] = e;
                e = next;
            }
        }
    }

capacity就是数组的长度,在扩展了数组的长度后,会判断是否要重新计算hashcode,同时要完成旧链表到新链表的转移,并且将容量阙值threshold更新。
更新链表在tansfer方法里,遍历数组中所有的桶,再将entry重新挂上,采用头插法

hashmap线程不安全的原因

从上面也可以看到,如果采用多线程编程,next可能会改变,就会产生本应接在后面的entry跑到其他地方去了,更严重的产生循环死链表,所以hashmap是线程不安全的,如果要在多线程中运用要用concurrenthashmap

为什么扩容要扩2的倍数

在resize的时候,为什么我们要扩2倍呢?

resize(2 * table.length);//将容量扩容为原来的2倍

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // Find a power of 2 >= initialCapacity 找到一个大于等于初始容量的且是2的幂的数作为实际容量
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        init();
    }

通过以上我们知道容量必须是2的幂,但为什么必须是2呢?我们在改变桶的定位时调用了一个indexfor()方法,我们去看一下

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

我们可以看到这里用的&,举例

        System.out.println(5&3);
        System.out.println(5%2);

这两个结果是一样的,因为如果除数为2的幂数和&2的幂数减1值是一样的,但&的运算速度比%快10倍,所以我们必须要扩容为2的倍数

rehash()

因为hash算法是将二进制低位拿来&操作,有时候会出现低八位&操作相等但值不相等的时候,这个时候就叫hash冲突。极端情况下,所有不同的key的hashcode都相等,为了防止这种情况,这种时候就要调用rehash