精华内容
下载资源
问答
  • hash底层实现原理

    2019-12-13 13:26:45
  • 简单说下HashMap的实现原理: 首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被...

    简单说下HashMap的实现原理:

    首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一各链表上的Hash值是相同的,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。

    1.哈希表结构的优势?

    哈希表作为一种优秀数据结构
    本质上存储结构是一个数组,辅以链表和红黑树
    数组结构在查询和插入删除复杂度方面分别为O(1)和O(n)
    链表结构在查询和插入删除复杂度方面分别为O(n)和O(1)
    二叉树做了平衡 两者都为O(lgn)
    而哈希表两者都为O(1)

     

    2.哈希表简介

    哈希表本质是一种(key,value)结构
    由此我们可以联想到,能不能把哈希表的key映射成数组的索引index呢?
    如果这样做的话那么查询相当于直接查询索引,查询时间复杂度为O(1)
    其实这也正是当key为int型时的做法 将key通过某种做法映射成index,从而转换成数组结构

    3.数据结构实现步骤

    1.使用hash算法计算key值对应的hash值h(默认用key对应的hashcode进行计算(hashcode默认为key在内存中的地址)),得到hash值
    2.计算该(k,v)对应的索引值index 
      索引值的计算公式为 index = (h % length) length为数组长度
    3.储存对应的(k,v)到数组中去,从而形成a[index] = node<k,v>,如果a[index]已经有了结点
    即可能发生碰撞,那么需要通过开放寻址法或拉链法(Java默认实现)解决冲突

    哈希算法

    h 通过hash算法计算得到的的一个整型数值 
    h可以近似看做一个由key的hashcode生成的随机数,区别在于相同的hashcode生成的h必然相同
    而不同的hashcode也可能生成相同h,这种情况叫做hash碰撞,好的hash算法应尽量避免hash碰撞
    (ps:hash碰撞只能尽量避免,而无法杜绝,由于h是一个固定长度整型数据,原则上只要有足够多的输入,就一定会产生碰撞)
    关于hash算法有很多种,这里不展开赘述,只需要记住h是一个由hashcode产生的伪随机数即可
    同时需要满足key.hashcode -> h 分布尽量均匀(下文会解释为何需要分布均匀)
    可以参考https://blog.csdn.net/tanggao1314/article/details/51457585

    解决碰撞冲突

    由上我们可以知道,不同的hashcode可能导致相应的h即发生碰撞
    那么我们需要把相应的<k,v>放到hashmap的其他存储地址

    解决方法1:开放寻址法

    通过在数组以某种方式寻找数组中空余的结点放置
    基本思想是:当关键字key的哈希地址p=H(key)出现冲突时
    以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p1为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,

    解决方法2:链地址法

    通过引入链表 数组中每一个实体存储为链表结构,如果发生碰撞,则把旧结点指针指向新链表结点,
    此时查询碰撞结点只需要遍历该链表即可
    在这种方法下,数据结构如下所示
    int类型数据 hashcode 为自身值

    在JAVA中几个细节点

    1.为什么需要扩容?扩容因子大还是小好?

    由于数组是定长的,当数组储存过多的结点时,发生碰撞的概率大大增加,此时hash表退化成链表

     过大的扩容因子会导致碰撞概率大大提升,过小扩容因子会造成存储浪费,在Java中默认为0.75

    2.当从哈希表中查询数据时,如果key对应一条链表,遍历时如何判断是否应该覆盖?

    当遍历链表时,如果两个key.hashcode的h一致会调用equals()方法判断是否为同一对象,equal的默认实现是比较两者的内存地址

    因此为什么Java强调当重写equals()时需要同时重写hashcode()方法,假设两个不同对象,在内存中的地址不同分别为a和b,那么重写equals()以后a.equals(b) =true 开发者希望把a,b这两个key视作完全相等
    然而由于内存地址的不同导致hashcode不同,会导致在hashmap中储存2个本应相同的key值

    这里提供一个范例
    public class Student {
        //学号
        public int student_no;
        //姓名
        public String name;
        
        @Override
        public boolean equals(Object o) {
            Student student = (Student) o;
            return student_no == student.student_no;
        }
        
    }

    通常情况下我们像上图一样期望通过判断两个Student的学号是否是否为同一学生
    然而在使用map或set集合时产生出乎意料的结果

    当我们重写hashcode()时

    @Override
        public int hashCode() {
            return Objects.hash(student_no);
        }

    可以看到现在可以正常使用集合框架中的一些特性

    3.为什么在HashMap中数组的长度length = 2^n(初始值为16) ?

    当计算索引值index = h % length 由于计算机的取余操作速度很慢,而计算机的按位取余 & 的操作非常快,又因为 h%length = h & (length-1) (需要满足length = 2^n) 因此规定了length = 2^n 加快index的计算速度 上式的证明参考http://yananay.iteye.com/blog/910460

    4.HashMap的红黑树在哪里体现呢?

    红黑树是JDK8中对hashmap作的一个变更,在JDK7之前,HashMap采用数组+链表的形式存储数据,我们知道优秀的hash算法应避免碰撞的发生,但假如开发者使用了不合适的hash算法,O(1)级别的数组查询会退化到O(n)级链表查询,因此在JDK8中引入红黑树的,当一个结点的链表长度大于8时,链表会转换成红黑树,提高查询效率,而链表长度小于6时又会退化成链表

    5.扩容是如何触发的?

    当hashmap中的size > loadFactory * capacity即会发生扩容,size 也是数组结点和链表结点的总和,要明确扩容是一个非常耗费性能的操作,因为数组的长度发生改变,需要对所有结点的索引值重新进行计算,而在JDK8中对这部分进行了优化,详细可以参考https://blog.csdn.net/aichuanwendang/article/details/53317351,在扩容完后减轻了碰撞产生的影响

    在正常的Hash算法下,红黑树结构基本不可能被构造出来,根据概率论,理想状态下哈希表的每个箱子中,元素的数量遵守泊松分布:

    P(X=k) = (λ^k/k!)e^-λ,k=0,1,...

    当负载因子为 0.75 时,上述公式中 λ 约等于 0.5,因此箱子中元素个数和概率的关系如下:(参考https://blog.csdn.net/Philm_iOS/article/details/81200601),下述分布来自源码文档

    > * Because TreeNodes are about twice the size of regular nodes, we
         * use them only when bins contain enough nodes to warrant use
         * (see TREEIFY_THRESHOLD). And when they become too small (due to
         * removal or resizing) they are converted back to plain bins.  In
         * usages with well-distributed user hashCodes, tree bins are
         * rarely used.  Ideally, under random hashCodes, the frequency of
         * nodes in bins follows a Poisson distribution
         * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
         * parameter of about 0.5 on average for the default resizing
         * threshold of 0.75, although with a large variance because of
         * resizing granularity. Ignoring variance, the expected
         * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
         * factorial(k)). The first values are:
         *
         * 0:    0.60653066
         * 1:    0.30326533
         * 2:    0.07581633
         * 3:    0.01263606
         * 4:    0.00157952
         * 5:    0.00015795
         * 6:    0.00001316
         * 7:    0.00000094
         * 8:    0.00000006
         * more: less than 1 in ten million

    转载自https://www.jianshu.com/p/67b825e08d17

     

     

    展开全文
  • HashMap 底层实现原理

    2021-01-19 10:11:49
    HashMap 底层实现原理1、HashMap的底层数据结构2、Java7和Java8的区别3、HashMap的主要参数都有哪些4、默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?5、hash的计算规则6、HashMap的存取原理 1、HashMap...

    1、HashMap的底层数据结构

    HashMap 的底层实现原理,1.71.8 是有区别的
    1.7 及之前的版本,底层是 数组 + 链表 Entry
    1.8 及之后的版本,底层是 数组 + 链表 + 红黑树 NOde,引入红黑树主要是因为当 Hash 冲突较多时,链表就会变成长链表,操作效率会变慢。
    // TREEIFY_THRESHOLD = 8 树化阈值 
    // UNTREEIFY_THRESHOLD = 8 取消树化阈值
    引入红黑树的原则是,当链表长度大于 8 时,链表会变成红黑树;当红黑树节点小于 6 时,红黑树又会变成链表。
    底层就是一个数组,数组里面的元素是 Node<K,V> 链表
    transient Node<K,V>[] table;
    // Node 数据格式
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    
        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }
    
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
    
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
    }
    

    2、Java7和Java8的区别

    1、数据结构不同
        1.71.7 之前的版本是:数组 + 链表
        1.81.8 之后的版本是:数组 + 链表 + 红黑树(为了解决Hash冲突,当Hash冲突较多时,链表长度过长,
        会影响数据操作效率,降低时间复杂度,由O(n) -> O(logn)2、存储数据时插入方法不一样
        1.71.7 之前采用的是:头插法
        1.81.8 之后采用的是:尾插法
        头插法,在多线程操作下,扩容的时候可能会产生环形链表,取值的时候,就会出现 Infinite Loop。
        尾插法就不会出现这样的问题,永远放在链表的尾部,扩容的时候,不会改变链表的顺序,不会出现环形链表。  
    总结:
    Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
    Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。     
    

    3、HashMap的主要参数都有哪些

    // initialCapacity 初始容量 不传的话 默认为16  1 << 4
    // loadFactor 负载因子 不传的话 默认为0.75
    public HashMap(int initialCapacity, float loadFactor);
    // 构造方法有3个
    // 无参构造方法 初始容量 为 16 负载因子为 0.75
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    // 一个参数构造方法 初始容量为传入容量 会转成 大于等于传参的最小的2的幂,负载因子为 0.75
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    // 两个参数构造方法,初始容量 和 负载因子
    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);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    

    4、默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?

    // 默认初始化大小为 16 必须为2的幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    // 最大容量为 1 << 30  10 7374 1824,如果初始化时传参大于最大容量,则使用该容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 为啥是 2 的幂, tableSizeFor方法,对传入的容量做了以下操作
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1; // n 和 n >>> 1 做 或 操作
        n |= n >>> 2; // n 和 n >>> 2 做 或 操作
        n |= n >>> 4; // n 和 n >>> 4 做 或 操作
        n |= n >>> 8; // n 和 n >>> 8 做 或 操作
        n |= n >>> 16; // n 和 n >>> 16 做 或 操作
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    // 这样一番操作下来,结果只能是 2 4 8 16 32 ... 2^n,都是 2 的幂
    // 使用 2 的幂的好处 --> 尽可能实现均匀分布
    // 因为 HashMap 的下标算法为:index = HashCode(Key) & (Length- 1)
    // 使用 2 的幂的时候,Length- 1 的二进制就都是1,和 HashCode(Key)与操作的时候,index的结果就等于 HashCode(Key)的后面几位
    // 只要输入的 HashCode(Key)本身分布均匀,算出来的 index 就是均匀的,可以实现均匀分布。
    

    5、hash的计算规则

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

    6、HashMap的存取原理

    // HashMap 存储数据原理 --> put
    // 参数 hash: key 的hash值 key: key value: 存入的value 
    // onlyIfAbsent: 是否覆盖当前 key 对应的已存在的value值,如果为true时,表示不覆盖,false 表示覆盖,默认为 false
    // evict: 
    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 为空,没有初始化
        if ((tab = table) == null || (n = tab.length) == 0)
        // 调用resize() 方法,初始化table,长度为默认长度 16,负载因子为默认负载因子 0.75
            n = (tab = resize()).length;
        // tab[i = (n - 1) & hash] 是通过hash求出下标,如果当前下标的元素为 null,直接生成一个链表放在下标所在的位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 如果下标所在的链表,头部已经有元素,执行下面的操作 
        else {
            Node<K,V> e; K k;
            // 如果当前key 已存在,把p赋给e,临时存储
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果 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;
                    }
                    // 如果当前key已存在,直接返回
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    // 元素递归操作    
                    p = e;
                }
            }
            // 如果当前 key 已存在,判断是否要元素覆盖
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // HashMap 结构调整的次数
        ++modCount;
        // 如果长度大于阈值,需要扩容
        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;
        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;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    
    // HashMap 的 读取原理
    // 读取通过 key 
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
        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;
    }
    
    展开全文
  • HashMap底层实现原理

    万次阅读 多人点赞 2018-09-21 17:14:18
     哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要...

    HashMap实现原理及源码分析

      哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。本文会对java集合框架中的对应实现HashMap的实现原理进行讲解,然后会对JDK7的HashMap源码进行分析。

    目录

      一、什么是哈希表

      二、HashMap实现原理

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

      四、重写equals方法需同时重写hashCode方法

      五、总结

    一、什么是哈希表

      在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

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

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

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

      哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

      我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

      比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

            存储位置 = f(关键字)

      其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:

      

      查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

      哈希冲突

      然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

    二、HashMap实现原理

     HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

    //HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

     Entry是HashMap中的一个静态内部类。代码如下

     

        static class Entry<K,V> implements Map.Entry<K,V> {
            final K key;
            V value;
            Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
            int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
    
            /**
             * Creates new entry.
             */
            Entry(int h, K k, V v, Entry<K,V> n) {
                value = v;
                next = n;
                key = k;
                hash = h;
            } 

    复制代码

     所以,HashMap的整体结构如下

      

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

    其他几个重要字段//实际存储的key-value键值对的个数 transient int size; //阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为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

    我们看下其中一个

    复制代码

    public HashMap(int initialCapacity, float loadFactor) {
         //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
            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);
    
            this.loadFactor = loadFactor;
            threshold = initialCapacity;
         
            init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
        }

    复制代码

      从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组

      OK,接下来我们来看看put操作的实现吧

    复制代码

        public V put(K key, V value) {
            //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
            if (table == EMPTY_TABLE) {
                inflateTable(threshold);
            }
           //如果key为null,存储位置为table[0]或table[0]的冲突链上
            if (key == null)
                return putForNullKey(value);
            int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
            int i = indexFor(hash, table.length);//获取在table中的实际位置
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
            addEntry(hash, key, value, i);//新增一个entry
            return null;
        }    

    复制代码

     先来看看inflateTable这个方法

    复制代码

    private void inflateTable(int toSize) {
            int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
            threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
            table = new Entry[capacity];
            initHashSeedAsNeeded(capacity);
        }

    复制代码

      inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

    复制代码

     private static int roundUpToPowerOf2(int number) {
            // assert number >= 0 : "number must be non-negative";
            return number >= MAXIMUM_CAPACITY
                    ? MAXIMUM_CAPACITY
                    : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
        }

    复制代码

    roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.

    hash函数

    复制代码

    //这是一个神奇的函数,用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
    final int hash(Object k) {
            int h = hashSeed;
            if (0 != h && k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
    
            h ^= k.hashCode();
    
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

    复制代码

    以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

    复制代码

      /**
         * 返回数组下标
         */
        static int indexFor(int h, int length) {
            return h & (length-1);
        }

    复制代码

    h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为

            1  0  0  1  0
        &   0  1  1  1  1
        __________________
            0  0  0  1  0    = 2

      最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

    所以最终存储位置的确定流程是这样的:

    再来看看addEntry的实现:

    复制代码

    void addEntry(int hash, K key, V value, int bucketIndex) {
            if ((size >= threshold) && (null != table[bucketIndex])) {
                resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
                hash = (null != key) ? hash(key) : 0;
                bucketIndex = indexFor(hash, table.length);
            }
    
            createEntry(hash, key, value, bucketIndex);
        }

    复制代码

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

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

    我们来继续看上面提到的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];
            transfer(newTable, initHashSeedAsNeeded(newCapacity));
            table = newTable;
            threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
        }

    复制代码

    如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法

    复制代码

    void transfer(Entry[] newTable, boolean rehash) {
            int newCapacity = newTable.length;
         //for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
            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);
              //将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                }
            }
        }

    复制代码

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

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

      

     还有,数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:

      我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。

      如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

    get方法

    复制代码

     public V get(Object key) {
         //如果key为null,则直接去table[0]处去检索即可。
            if (key == null)
                return getForNullKey();
            Entry<K,V> entry = getEntry(key);
            return null == entry ? null : entry.getValue();
     }

    复制代码

    get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法

    复制代码

    final Entry<K,V> getEntry(Object key) {
                
            if (size == 0) {
                return null;
            }
            //通过key的hashcode值计算hash值
            int hash = (key == null) ? 0 : hash(key);
            //indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
            for (Entry<K,V> e = table[indexFor(hash, table.length)];
                 e != null;
                 e = e.next) {
                Object k;
                if (e.hash == hash && 
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            }
            return null;
        }    

    复制代码

      可以看出,get方法的实现相对简单,key(hashcode)-->hash-->indexFor-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。

    四、重写equals方法需同时重写hashCode方法

      关于HashMap的源码分析就介绍到这儿了,最后我们再聊聊老生常谈的一个问题,各种资料上都会提到,“重写equals时也要同时覆盖hashcode”,我们举个小例子来看看,如果重写了equals而不重写hashcode会发生什么样的问题

    复制代码

    /**
     * Created by chengxiao on 2016/11/15.
     */
    public class MyTest {
        private static class Person{
            int idCard;
            String name;
    
            public Person(int idCard, String name) {
                this.idCard = idCard;
                this.name = name;
            }
            @Override
            public boolean equals(Object o) {
                if (this == o) {
                    return true;
                }
                if (o == null || getClass() != o.getClass()){
                    return false;
                }
                Person person = (Person) o;
                //两个对象是否等值,通过idCard来确定
                return this.idCard == person.idCard;
            }
    
        }
        public static void main(String []args){
            HashMap<Person,String> map = new HashMap<Person, String>();
            Person person = new Person(1234,"乔峰");
            //put到hashmap中去
            map.put(person,"天龙八部");
            //get取出,从逻辑上讲应该能输出“天龙八部”
            System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
        }
    }

    复制代码

    实际输出结果:

    结果:null

      如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)-->hash-->indexFor-->最终索引位置 ,而通过key取出value的时候 key(hashcode1)-->hash-->indexFor-->最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

      所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。

    五、总结

      本文描述了HashMap的实现原理,并结合源码做了进一步的分析,也涉及到一些源码细节设计缘由,最后简单介绍了为什么重写equals的时候需要重写hashCode方法。希望本篇文章能帮助到大家,同时也欢迎讨论指正,谢谢支持!

    展开全文
  • List、Set、Sorted Set、Hash 底层实现原理 SDS 的设计到底有多牛逼 Redis 使用 C 语言编写,但是并没有直接使用 C 语言自带的字符串,而是使用了 SDS 来管理字符串。接下来就来探讨下为什么 Redis 使用了 SDS ...
  • hashmap底层实现原理

    2021-04-11 16:02:29
    hashmap底层实现原理 k=key; v=value; hashcode和equals方法在Object类中默认是内存地址需要重写 哈希表在链表中完成增删,查询只需扫描部分,效率高 一.put(k,v)实现原理 将(k,v)封装到Node对象当中。 它的底层...
  • Hash底层存储原理及优化Redis中big Hash的一些建议 Hash 是 Redis 中出现最为频繁的复合型数据结构,除了 dict 结构的数据会用到Hash外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局Hash,还有带过期时间的...
  • 文章目录ConcurrentHashMap的底层实现原理1、HashMap线程不安全2、HashTable效率低下3、ConcurrentHashMap的底层实现原理3.1JDK1.7中的ConcurrentHashMap3.2JDK1.8中的ConcurrentHashMap ConcurrentHashMap的底层...
  • Map集合哈希表哈希表概述HashMap底层实现原理HashMap底层的源代码map.put(k,v), v=map.get(k)两方法的实现原理HashMap集合Key特点hashmap扩容机制二、HashTableHashTable与HashMap区别 哈希表 哈希表概述 哈希表是一...
  • HashMap的底层实现原理

    2020-03-14 19:20:05
    文章目录HsahMap的实现原理简要概括HashMap的存取实现有关知识的具体解析一、Map的几种类型二、什么是哈希表?三、什么是哈希算法?四、什么是红黑树?五、HashMap 和 Hashtable 有什么区别?如何解决hash冲突产生...
  • Redis的五种数据结构的底层实现原理

    千次阅读 2021-01-31 02:49:22
    Redis的五种数据结构的底层实现原理: 1、String底层实现方式:动态字符串sds 或者 long; 2、Hash底层实现方式:压缩列表ziplist 或者 字典dict; 3、List在Redis3.2之前的底层实现方式:压缩列表ziplist 或者 双向...
  • Java——HashMap底层实现原理HashMap的实现原理一、JDK1.8中的涉及到的数据结构1、位桶数组2、数组元素NodeHashMap的实现原理 首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时...
  • MySQL索引的底层实现原理一、前言二、索引类型1、Hash索引2、BTree索引和B+Tree索引(1)BTree索引(2)B+Tree索引(3)B+Tree对比BTree有点:3、全文索引 一、前言 MySQL支持诸多存储引擎,而各种存储引擎对索引的...
  • Java面试绕不开的问题: Java中HashMap底层实现原理(JDK1.8)源码分析 这几天学习了HashMap的底层实现,但是发现好几个版本的,代码不一,而且看了Android包的HashMap和JDK中的HashMap的也不是一样,原来他们没有指定...
  • ConcurrentHashMap在jdk1.7是基于分段锁实现,但我们重点讨论jdk1.8的版本,也就是使用数组加链表加红黑树,分段锁改为基于cas和Synchronized实现hash冲突造成链表长度等于8,且nodes数组长度大于64时,链表结构...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 1,038
精华内容 415
关键字:

hash底层实现原理