【Java常用容器】HashMap源码分析

阅读提醒:将本文结合源码一起使用味道更佳哦!~

前言

上一章写到ArrayList的源码分析,而本篇将会对同样是最常用的容器之一的HashMap来进行分析。之前面试求职者,经常会问到HashMap的底层数据结构的部分。很多人只回答出是“哈希表”。因此特意引用网上哈希表的定义:

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。

由定义可以肯定的是HashMap是哈希表,而哈希表到底是怎么实现的呢?本篇将会带领你一步一步拆解

哈希碰撞

在了解HashMap源码之前,我们还需要了解一个概念,就是哈希碰撞。

对于这个概念通常有几个疑问

  • 什么是哈希碰撞(what)
  • 如何解决哈希碰撞(how)
  • 哈希碰撞是怎么出现的(why)

1.什么是哈希碰撞:

哈希值是作为一种数据”指纹“而存在,广泛的运用在查找与存储方面。一般情况大家都希望它是具有唯一性的。而事实上,所有产生hash值的hash算法都不能避免哈希值完全不相同。当多个数据的hash值相同的时候,就称为哈希碰撞。

打个比方,你(key)出生了,被爸妈取名(hash算法)为刘伟,后来发现全国有好多个叫刘伟的人。于是你哭了,我要怎么证明我是我?

2.如何解决哈希碰撞:

常见的解决哈希碰撞的思路有:

  • 开放地址法:通过使用与地址、表长相关的算法再计算值;
  • 再哈希法:通过一定的规则多次hash;
  • 建立一个公共溢出区:通过一个表来记录冲突的hash数据
  • 拉链法:将hash冲突的值存在同一个链表中;

(想要了解更多,可点击传送门)

上面的4个方法主要解决的其实就是:在全国有那么个叫刘伟的情况下,还能找到你。

而以上的方法中被HashMap使用的就是拉链法(我还是不太理解哪里像拉链了),这点下文会介绍到。

3.哈希碰撞是怎么出现的(why)

如1所说,所有的hash算法不存在hash一次就能保证生成的结果一定是唯一的。而一个好的hash算法就是将出现hash碰撞的概率降到最低。

类定义

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable 
  • AbstractMap:实现了Map接口,实现了Map的get,container等方法,不过很多都被HashMap重写

  • Cloneable:标记接口,声明HashMap重写clone方法。这里HashMap实现的是浅拷贝,即复制内部元素

  • Serializable:标记接口,可被默认的序列化机制序列化与反序列化

主要内容

《【Java常用容器】HashMap源码分析》

数据结构

HashMap的数据结构在 JDK1.8比之于JDK1.7有一个重大的升级:增加了红黑树结构。

  • 在 JDK1.7 中对于发生hash碰撞的值,会通过链表的方式插入:
    《【Java常用容器】HashMap源码分析》
  • 在JDK1.8中为了解决当链表过长而导致查询速度变慢的问题,在原有的结构上引用了红黑树。
    《【Java常用容器】HashMap源码分析》

这里我们先了解一下红黑树优点:在插入、删除、查询的时候时间复杂度是O(log n),在应付数据量较大的时候有着良好的性能。

回到JDK1.7,没有红黑树的情况下对链表的查询、删除、插入将永远是O(n)。当n值较大的时候,所耗费的时间也会随之增大,也正是为了性能的优化,JDK1.8中引入了红黑树。

关于红黑树的内容,这里先不多加赘述。附带一个学习地址

Hash算法

HashMap的哈希值也经历了1.7到1.8的升级过程。

  • JDK1.8的哈希算法:一次位运算,一次异或;
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • JDK1.7的哈希算法:4次位运算,5次异或;
    static final int hash(Object key) {
        int h;
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4)
    }

以上的代码运用到了很多的逻辑运算,忘记的同学可以复习一下。传送门

两个版本间的hash算法的变化,出于两个目的:
1. 减少计算次数,提高效率;
2. 降低hash碰撞的概率;

这里得出hash值根据以下公式生成索引

 int index = hash(key) & (length -1) 

问题1:为什么求index的值要是要用这种算法?

因为这样的与运算可以可以达到类似取余(取模)的效果,将结果均匀分布在0 ~ length-1上。

以下我们来验证一下,

hash(key)     = 1011 1011 1101 0010
length:2^4-1  = 0000 0000 0000 1111
result        = 0000 0000 0000 0010

因为length的高于8的位都是0,所以进行与运算之后都会置为0。因此范围一定落在0 ~ length-1之间。

问题2:为什么length的值要是2倍数?

如果使用奇数作为长度,则会使得HashMap只会在上分布不均,只在偶数数个排列数据。

拿上面的栗子来看

hash(key)     = 1011 1011 1101 0010
length:9-1   = 0000 0000 0000 1000
result        = 0000 0000 0000 0010

这样的情况会导致,结果元素只分布在偶数索引上。

问题3:HashMap.put(null,”test”)成立么?

可以,空的key的hash值为空。所以计算出的索引一直会在数组的第一个

结构

对于一个容器来说,数据结构尤其重要。

以下代码节选出一些HashMap中比较重要的参数。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认大小
static final int MAXIMUM_CAPACITY = 1 << 30;  //最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //扩容因子

transient Node<K,V>[] table; //数组
transient int size; //元素的个数
transient int modCount; //操作数
int threshold;  //阈值

static class Node<K,V> implements Map.Entry<K,V> { //节点类
        final int hash; //节点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;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

//JDK1.8新增:
static final int TREEIFY_THRESHOLD = 8; //链表转树最小值
static final int UNTREEIFY_THRESHOLD = 6; //树转链表最小值
static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<K,V> //树化的节点类

以上比较重要的就是扩容因子与阈值,它们间的计算公式如下

int threshold = table.length * DEFAULT_LOAD_FACTOR

其次就是Node类,HashMap中将所有传进来的key和value 都通过Node来包装一层。除了key和value这些基础数据之外。当hash碰撞后,将会由Node类中的next成员变量作为持有下一个节点的引用形成链表(单向链表)。而1.8中TreeNode是在数化之后变换的节点类型,感兴趣的可以自己深入

问题4:阈值与扩容因子的作用?

阈值的作用是判断当前是否需要扩容的,当size超过阈值就需要扩容。因此扩容因子直接决定了阈值的大小。而从更深层次的角度上看,扩容因子影响了HashMap的空间利用率。

当扩容因子较小的时候,size很容易就超过阈值,引发扩容。这个时候table的空间还相对充分,元素分布较为分散。因此空间利用率较低。正是因为这样的特性,扩容因子较小的时候也不容易产生哈希碰撞。

反之,扩容因子越大,元素分布越紧密,空间利用率越高,而容易发生哈希碰撞。

某天公司同事小杰宝问了我一个问题——”为什么扩容因子是0.75f“,我一时语塞。网上查完资料后才知道。在理想情况下,经过大量数据概率验证后扩容因子为0.75f的时候,链表长度几乎不超过8。

而HashMap扩容的部分,我放在后面插入的部分讲解。

数据操作

以下以源码均以JDK1.8的为例,其实两者的代码差异不多。只是JDK1.8增加了其中对树的操作

查询操作

    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;
        //1.根据 (n - 1) & hash 方式获取数组上的索引和其上的元素
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //2. 判断第一个元素的key和value是否就是所需的节点,如果是就直接返回节点
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                //3. 判断第一个节点是否是树节点,如果是树,就调用树的查询方式
                if (first instanceof TreeNode)
                    //3.1 返回树化节点
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //4. 当前的节点是链表节点,通过for循环查找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

以上代码主要做的就是以下步骤:
1. 通过key的hash找到索引;
2. 在如果不在数组中,则在树或者链表中遍历查找;
3. 返回结果;

为了更好的了解这个流程,可以看以下的流程图增加一下印象
《【Java常用容器】HashMap源码分析》

问题5:hashMap查询的时间复杂度?

在HashMap的优点之一是查询速度快,在哈希不碰撞的情况下 时间复杂度是O(1),而在hash碰撞的时候有两种情况,树化情况下,时间复杂度O(logn),链表化的情况下,时间复杂度是O(n)。

插入操作

和看ArrayList不同,hashMap我们只需要看一个方法。

    public V put(K key, V value) {
        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. 判断table是否为空,为空的话初始化(默认16)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //2. 是否有hash冲突,如果没有则直接创建新节点,发现冲突,获取第一个节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //3. 第一个节点的key是否与要传入的key相同,如果相同,则后面会直接个更新其value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //4. 如果为树节点,则进入树节点插入方法(里面也是要判断是否key相同)
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //5. 遍历链表
                for (int binCount = 0; ; ++binCount) {
                    //5.1 找到最后最后的节点插入
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //5.2 找到key相同的值,则后面会直接个更新其value
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //6 前面获取到key相同的节点更新值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                //6.1 空实现
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //7 判断是否需要扩容
        if (++size > threshold)
            resize();
        //8 空实现
        afterNodeInsertion(evict);
        return null;
    }

以上代码都是偏重一些细节的流程,我们可以简单的概括一下
1. 在数组,树节点,链表结构中判断当前的key是否存在
2. 如果存在更新数据,不存在在对应的数据结构中新建节点

如果还没有消化好,可以看看下面的流程图:
《【Java常用容器】HashMap源码分析》

问题5:hashMap能不能支持key的重复?

从上面的方法中,我们可以看到。无论是链表、树还是hash不冲突的情况都会判断是否已经存在。

扩容

以上还没有讲到的就是扩容,这里在JDK1.7和JDK1.8有一些变化

在JDK1.7中:

    void resize(int newCapacity) {  

        Entry[] oldTable = table;  

        int oldCapacity = oldTable.length; 
        //1. 判断是否已经扩容到最大
        if (oldCapacity == MAXIMUM_CAPACITY) { 
            threshold = Integer.MAX_VALUE;  
            return;  
        }  
        //2. 创建新数组
        Entry[] newTable = new Entry[newCapacity];  
        //3. 转移元素
        transfer(newTable); 

        table = newTable;  

        //更新阈值
        threshold = (int)(newCapacity * loadFactor); 
    } 

    void transfer (Entry[] newTable) {
        Entry[] src = table; 

        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { 
            Entry<K,V> e = src[j];           
            if (e != null) {
                //去掉原数组的元素引用,使其通过GC被回收
                src[j] = null; 
                do { 
                    Entry<K,V> next = e.next; 
                    //计算新的数组下的hash值。在分布(由上可知数组长度变了,hash值也会变)
                    int i = indexFor(e.hash, newCapacity); 
                    //重新排布在链表上的元素,如果发现hash仍是重复的采用倒插法
                    e.next = newTable[i]; 
                    newTable[i] = e;  
                    e = next;             
                } while (e != null);
            }
        }
    }

以上的流程很简单,做了两件事:
1. 创建扩容数组,更新阈值;
2. 迁移数据,这里链表的迁移需要重新hash,或者使用倒插;

现在我们再看看JDK1.8的代码:

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //1. 判断是否为空
        if (oldCap > 0) {
            //1.1判断是否已经为容量
            if (oldCap >= MAXIMUM_CAPACITY) {
                //1.2 设置不会再触发扩容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //1.2 对现有数组扩容 *2,并将阈值也 *2
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; 
        }
        else if (oldThr > 0) // 自定义
            newCap = oldThr;
        else {               // 默认红的初始化
            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
                        // 这里做的操作是将一条链表拆分成两条
                        // loHead、loTail 对应一条链表,hiHead、hiTail 对应另一条链表
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //判断新的元素的索引是否在oldCap中,不理解的同学可以画画位运算
                            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);
                        //loHead保存在原来的位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //hiTail的索引换成 index + oldCap
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

而在JDK1.8中,出现了两个变化:
* 增加树结构;
* 将链表的迁移做了优化,不再重新创建元素或者是使用倒插法,而是直接修改链表间的引用;

在我看来,对于JDK1.7和JDK1.8的差异我们不需要记住。因为本质上做的事情都是一样的。
1. 创建扩容后的数组,更新阈值;
2. 数据迁移,根据不同的数据结构进行差异的迁移;

删除

我们可以看看JDK1.8的删除部分的代码,会发现删除的流程其实和插入的流程及其类似。

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        //找到hash值对应的索引上的第一个元素;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            //如果为第一个则直接返回结果;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                //如果为树结构,则去查找对应树节点;
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    //循环遍历链表,获取对应的节点;
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }

            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                //如果是树结构,就删除树结构里的节点
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p) //如果是在数组上,就将下一个节点(有可能为空)赋值到数组上
                    tab[index] = node.next;
                else
                    p.next = node.next; //如果是在链表上,就将上一个节点的指针指向下一个节点
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

看完以上代码,里面只有删除两步:
1. 查找节点(这一步和查询是几乎一样的);
2. 删除节点,和查询一样,分为数组,树,链表做处理;

序列化与fast-fail

这两部分内容其实与ArrayList中的实现与思路是完全一样的,不再多做赘述,不了解的同学可以回头看看,传送门

总结

HashMap应该算是容器中源码难度较大的一种。其中有一些重要的点需要反复巩固的
1. 数据结构;
2. 如何生成索引;
3. 扩容因子、阈值等扩容情况;

看完HashMap让我对JDK的开发人员莫名的敬佩,能通过一些数学知识与数据结构持续将底层性能优化,也正是我要前进的方向。

    原文作者:走川
    原文地址: https://blog.csdn.net/zhang_yanye/article/details/81233597
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞