当我们在聊HashMap

作者: 天一方蓝 | 来源:发表于2018-05-30 09:54 被阅读99次

    本文出自:https://blog.csdn.net/DT235201314/article/details/80452010

    一丶概述

    最近研究了下HashMap(Android开发确实用的少),不难理解为什么面试经常问HashMap,太有意思了。

    二丶目录概述图

    image

    三丶正文

    1.HashMap数据结构

    先看两张图:

    image image

    这里Entry和Node是一个东西,jdk1.8给Entry换了个马甲改为Node

    HashMap数据结构可以看做是:数组+单链表+红黑树(jdk1.8新增),数组存储链表头。

    2.实现思路

    上面说了HashMap数据结构,下面就是说说怎么增删改查存数据

    几个概念:

    K:key用于计算hash值,可用于查找,不可相同,相同时替换

    V: 值,我们要的东西

    Entry <K, V>next ,链表上的值,next指向下一个,没有时指向null

    hashcode:特定算法分配Node<K,V>在数组中的index位置,可以相同

    hash:K.hash得到的值。

    扩容:跟ArrayList一样,HashMap的扩容是当size大于阀值 *2,

    阀值:size(当前大小)*loadFactor(负载因子,默认0.75),过小浪费空间,过大影响查找速度

    rehash:扩容后重新计算hash值分配占位

    add添加值:

    1.根据hashcode分配数组上的占位;

    2.碰撞:分配位置相同,存入链表头后(1.7是存表头);

    3.链表长度大于8转为红黑树 提升查找速度;

    4.扩容,扩容后大小 x2,重新分配占位

    put方法流程图(图片移到上面来,先看图方便理解):

    image image

    3.JDK1.7源码分析

    对应上面结构,源码实现

    //HashMap里的数组
    transient Entry<K, V>[] table = (Entry<K, V>[]) EMPTY_TABLE;
    //Entry对象,存key、value、hash值以及下一个节点
    static class Entry<K, V> implements Map.Entry<K, V> {
        final K key;
        V value;
        Entry<K, V> next;
        int hash;
    }
    //默认数组大小,二进制1左移4位为16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    //负载因子默认值
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //当前存的键值对数量
    transient int size;
    //阀值 = 数组大小 * 负载因子
    int threshold;
    //负载因子变量
    final float loadFactor;
    
    //默认new HashMap数组大小16,负载因子0.75
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap( int initialCapacity){
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    //可以指定数组大小和负载因子
    public HashMap( int initialCapacity, float loadFactor){
        //省略一些逻辑判断
        this.loadFactor = loadFactor;
        threshold = initialCapacity;
        //空方法
        init();
    }
    

    再看看put方法(本文只说put方法)

    public V put(K key, V value) {
        //数组为空时创建数组
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //key为空单独对待
        if (key == null)
            return putForNullKey(value);
        //①根据key计算hash值
        int hash = hash(key);
        //②根据hash值和当前数组的长度计算在数组中的索引
        int i = indexFor(hash, table.length);
        //遍历整条链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //③情况1.hash值和key值都相同的情况,替换之前的值
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                //返回被替换的值
                return oldValue;
            }
        }
    
        modCount++;
        //③情况2.坑位没人,直接存值或发生hash碰撞都走这
        addEntry(hash, key, value, i);
        return null;
    }
    

    先看上面key为空的情况,执行 putForNullKey() 方法单独处理,会把该键值对放在index0,null的hash值也为“0”,所以HashMap中是允许key为空的情况。再看下主流程:
    步骤①.根据键值算出hash值 — > hash(key)
    步骤②.根据hash值和当前数组的长度计算在数组中的索引 — > indexFor(hash, table.length)

    static int indexFor(int h, int length) {
        //hash值和数组长度-1按位与操作,听着费劲?其实相当于h%length;取余数(取模运算)
        //如:h = 17,length = 16;那么算出就是1
        //&运算的效率比%要高
        return h & (length-1);
    }
    
    image

    步骤③情况1.hash值和key值都相同,替换原来的值,并将被替换的值返回。

    步骤③情况2.坑位没人或发生hash碰撞 — > addEntry(hash, key, value, i)

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //当前hashmap中的键值对数量超过阀值
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //扩容为原来的2倍
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            //计算在新表中的索引
            bucketIndex = indexFor(hash, table.length);
        }
        //创建节点
        createEntry(hash, key, value, bucketIndex);
    }
    

    如果put的时候超过阀值,会调用 resize() 方法将数组大小扩大为原来的2倍,并且根据新表的长度计算在新表中的索引(如之前17%16 =1,现在17%32=17),看下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
        table = newTable;
        //步骤④修改阀值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    

    上面的重点是步骤②,看下它具体的转移操作

    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;
            }
        }
    }
    

    这段for循环的遍历会使得转移前后键值对的顺序颠倒(Java7和Java8的区别),画个图就清楚了

    image

    最后再看下创建节点的方法

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
    

    创建节点时,如果找到的这个坑里面没有存值,那么直接把值存进去就行了,然后size++;如果是碰撞的情况,

    image

    put流程图(见上图)

    4.JDK1.8源码

    换马甲

    //定义长度超过8的链表转化成红黑树
    static final int TREEIFY_THRESHOLD = 8;
    //换了个马甲还是认识你!!!
    static class Node<K, V> implements Map.Entry<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next;
    }
    

    put源码

    public V put(K key, V value) {
        //根据key计算hash值
        return putVal(hash(key), key, value, false, true);
    }
    
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
    boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //步骤1.数组为空或数组长度为0,则扩容(咦,看到不一样咯)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //步骤2.根据hash值和数组长度计算在数组中的位置
        //如果"坑"里没人,直接创建Node并存值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //步骤3."坑"里有人,且hash值和key值都相等,先获取引用,后面会用来替换值
            if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
                //步骤4.该链是红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                //步骤5.该链是链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //步骤5.1注意这个地方跟Java7不一样,是插在链表尾部!!!
                        p.next = newNode(hash, key, value, null);
                        //链表长度超过8,转化成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //步骤5.2链表中已存在且hash值和key值都相等,先获取引用,后面用来替换值
                    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;
        //步骤6.键值对数量超过阀值,扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    对比和Java7的区别,Java8一视同仁,管你key为不为空(null,hashcode为0放在index0)的统一处理,多了一步链表长度的判断以及转红黑树的操作,并且比较重要的一点,新增Node是插在尾部而不是头部。当然上面的主角还是扩容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;
            }
            //扩容咯,这里采用左移运算左移1位,也就是旧数组*2
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                //同样新阀值也是旧阀值*2
                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);
                        //链表长度大于1,小于8的情况,下面高能,单独拿出来分析
                    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;
    }
    

    下面我们讲解下JDK1.8做了哪些优化。
    经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1(5)和key2(21)两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

    image

    图a中key1(5)和key(21)计算出来的都是5,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    image

    图b中计算后key1(5)的位置还是5,而key2(21)已经变成了21,因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

    回来看下源码

    else { // preserve order
         //定义两条链
         //原来的hash值新增的bit为0的链,头部和尾部
         Node<K,V> loHead = null, loTail = null;
         //原来的hash值新增的bit为1的链,头部和尾部
         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;
         }
     }
    

    假设一个hash算法刚好算出来的的存储是这样的,在存第13个元素时要扩容

    image

    那么流程应该是这样的(只关注5号坑键值对的情况)

    image image image

    两条链找出来后,最后转移一波,大功告成

    //扩容前后位置不变的链
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    //扩容后位置加上原数组长度的链
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }
    
    image

    1.8 put流程图(见上图)

    5.1.7和1.8对比

    1.发生hash冲突时,Java7会在链表头部插入,Java8会在链表尾部插入
    2.扩容后转移数据,Java7转移前后链表顺序会倒置,Java8还是保持原来的顺序
    3.关于性能对比可以参考美团技术博客,引入红黑树的Java8大程度得优化了HashMap的性能

    四丶面试题加深

    问题1:HashMap是基于key的hashcode的存储的,如果两个不同的key产生的hashcode一样取值怎么办?

    看了上面的分析,你肯定知道,再数组里面有链表结构的Entry来实现,通过遍历所有的Entry,比较key来确定到底是哪一个value;
    问题2:HashMap是基于key的hashcode的存储的,如果两个key一样产生的hashcode一样怎么办?
    在put操作的时候会遍历所有Entry,如果有key相等的则替换。所以get的时候只会有一个
    问题3、我们总是习惯用一个String作为HashMap的key,这是为什么呢?其它的类可以做为HashMap的key吗?
    这里因为String是不可以变的,并且java为它实现了hashcode的缓存技术。我们在put和get中都需要获取key的hashcode,这些方法的效率很大程度上取决于获取hashcode的,所以用String的原因:1、它是不可变的。2、它实现了hashcode的缓存,效率更高。
    问题4:可变的对象能作为HashMap的key吗?
    可变的对象是可以当做HashMap的key的,只是你要确保你可变变量的改变不会改变hashcode。比如以下代码

    public class TestMemory {
    
        public static void main(String[] args) {
            HashMap hashMap = new HashMap();
            TestKey testKey = new TestKey();
            testKey.setAddress("sdfdsf");//line3
            hashMap.put(testKey,"hello");
            testKey.setAddress("sdfsdffds");//line5
            System.out.println(hashMap.get(testKey));
        }
    }
    
    public class TestKey {
        String name;
        String address;
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public String getAddress() {
            return address;
        }
    
        public void setAddress(String address) {
            this.address = address;
        }
    
        @Override
        public int hashCode() {
            if (name==null){
                return 0;
            }
            return name.hashCode();
        }
    }
    

    上面的代码line3到line5对象里的address做了改变,但是由于hashCode是基于name来生成的,name没变,所以依然能够正常找到value。但是如果把setAdress换成name,get就会返回null。这就是为什么我们选择String的原因。

    参考文章:

    Java 8系列之重新认识HashMap

    图解HashMap(一)

    Java你可能不知道的事(3)HashMap

    相关文章

      网友评论

      本文标题:当我们在聊HashMap

      本文链接:https://www.haomeiwen.com/subject/cmqtsftx.html