HashMap存取原理之JDK8

前言

哈希表(hash table)也叫散列表,是一种非常重要的数据结构
应用场景之一:缓存技术(比如memcached的核心其实就是在内存中维护一张大的哈希表)

目录

一、哈希表

数据结构:

1、数组

用一段连续的存储单元来存储数据。
知道下标进行查找,时间复杂度为O(1)。
知道value值进行查找,时间复杂度为O(n),因为需要遍历数组,逐一比对给定关键字和数组元素。
对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn)。
插入、删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)。

2、线性链表

新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1)。
查找操作需要遍历链表逐一进行比对,复杂度为O(n)。

3、二叉树

一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

4、哈希表

不考虑哈希冲突的情况下,添加,删除,查找等操作,仅需一次定位即可完成,时间复杂度为O(1)。

数据结构的物理存储结构:

1、顺序存储结构
2、 链式存储结构

哈希表:

哈希表的主干就是数组。利用了数组的特性----根据下标查找某个元素一次定位就可以找到。
在新增或查找某个元素时,我们通过把当前元素的关键字传给哈希函数,然后映射到数组中的某个位置,最后通过数组下标一次定位就可完成操作。

存储位置 = f(关键字)
这个函数的设计好坏会直接影响到哈希表的优劣。

插入、查找操作,如图:

哈希冲突:

如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?
好的哈希函数会尽可能地保证 计算简单散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。

哈希冲突的解决方案:

1、开放定址法
2、再散列函数法
3、链地址法

HashMap即是采用了链地址法,也就是数组+链表的方式。

二、HashMap实现原理

JDK 8 中,HashMap的主干是一个Node数组。

//该table在第一次使用时初始化,并在必要时进行调整。当分配时,长度总是2的幂。
transient Node<K,V>[] table;

Node是HashMap中的一个静态内部类

//HashMap.Node是LinkedHashMap.Entry的父类
//LinkedHashMap.Entry是HashMap.TreeNode的父类
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
    final K key;
    V value;
    Node<K,V> next;//存储指向下一个Node的引用,单链表结构

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

HashMap的整体结构如下:

HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前Node的next为null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组位置包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

HashMap的几个重要属性

//实际存储的key-value键值对的个数
transient int size;  

//阈值;
//当table分配内存空间后,threshold一般为 capacity*loadFactory
//HashMap在进行扩容时需要参考threshold
int threshold;  

//负载因子,代表了table的填充度有多少,默认是0.75,超过了负载,就开始扩容
final float loadFactor;  

//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

HashMap有4个构造器,如果用户没有给构造器传入initialCapacity 和loadFactor这两个参数,会使用默认值 initialCapacity默认为16,loadFactory默认为0.75。

在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。

map.put("2","ljs");

public V put(K key, V value) {
    return putVal(hash("2"), "2", "ljs", false, true);
}

hash("2")

static final int hash(Object key) {
    int h;
    //key.hashCode()该对象自己的hashcode
    //HashMap的哈希函数:(hashcode) ^ (hashcode >>> 16)
    //hashcode 与 向右无符号移动16位的自己 异或,一般都等于hashcode的值
    // >>> 与 >> 都是右移,>>> 是会把符号位也一起移动,就是说负数用 >>> 后,会成为正数
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

putVal(hash("2"), "2", "ljs", false, true);

/**
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果table数组为空数组{},为table分配实际内存空间;----resize()
    //在构造器中没有指定threshold的话,就是默认的threshold,16
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // (n - 1) & hash key的哈希值 和 数组长度做 与运算,计算出在table数组中的具体下标位置
    //该位置没有数据,就直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //该位置有数据,遍历该数组下标的单链表
    //找到hash、key相同的,执行覆盖操作。用新value替换旧value,并返回旧value
    //没有hash、key相同的,插入到链表尾部
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //覆盖操作
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

resize()

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //不是第一次resize(),扩容----Threshold * 2
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //双倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //第一次resize(),为table分配内存空间,newCap = 16 ; newThreshold = 0.75*16 = 12
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

通过以上代码能够得知,当size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Node数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。

存储位置的确定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最终索引位置,找到对应位置table[i]。

map.get("2")

public V get("2") {
    Node<K,V> e;
    return (e = getNode(hash("2"), "2")) == null ? null : e.value;
}

hash("2")

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

getNode(hash("2"), "2")

final Node<K,V> getNode(50, "2") {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

取值位置的确定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。

注:存数据需要hashcode(),取数据需要equals();hashcode()、equals()是Object的方法,可以按照自己的需求,重写对象的hashcode() 和 equals() 方法。

三、为何HashMap的数组长度一定是2的次幂?

数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index:

将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash函数运算后,再通过和 length-1进行与运算。

1、保证得到的新的数组索引和老数组索引一致

16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h & (length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。

2、获得的数组索引index更加均匀

数组长度保持2的次幂,length-1的低位都为1

3、唯一性

&运算,高位是不会对结果产生影响的,所以只关注低位,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了

猜你喜欢

转载自www.cnblogs.com/lijinshan950823/p/9476569.html