• 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了,而对应的那些数组位置也就被白白浪费了

  • 相关阅读:
    iOS 面向对象
    iOS 构建动态库
    iOS 单例
    iOS 操作系统架构
    iOS 编译过程原理(1)
    Category
    CoreText
    dyld
    block
    (CoreText框架)NSAttributedString 2
  • 原文地址:https://www.cnblogs.com/lijinshan950823/p/9476569.html
Copyright © 2020-2023  润新知