HashMap基本原理总结

在日常开发工作中,HashMap经常被使用到,作为一个有探索精神的程序员肯定得搞清楚其基本原理的吧。

说到HashMap必须的说一下哈希算法,散列算法(Hashing)是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法。有了这个哈希算法,我们就可以将一个输入key值输出为一个int索引值了。这个索引值有什么用呢?这就的从HashMap的组成结构说起了

1、HashMap的数据结构(JDK1.8)

jdk1.8中HashMap的数据结构是由数组+链表+红黑树组成
《HashMap基本原理总结》
有了这个HashMap的数据结构,我们就可以通过以下代码往里面添加数据了

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

从从添加数据过程可以看出首先会通过hash算法获取一个hash值,这个hash值实际上还并不是我们的index,比如我们上图中的table的index最大只能为7,而这里获取的hash值有可能是比7大的数值,在HashMap中通过(n - 1) & hash(这里的n是table的长度)来确保最后的index不会超过table长度。

上图中table数组长度有限,当加入的数据过多时总会出现有些key值生成的hash值相同的情况,也就是说不同key值可能会对应同样的index,这种情况就叫做哈希冲突

HashMap使用链表和红黑树避免哈希冲突(相同hash值),当链表长度大于TREEIFY_THRESHOLD(默认为8)时,将链表转换为红黑树,当然小于UNTREEIFY_THRESHOLD(默认为6)时,又会转回链表以达到性能均衡。

2、添加以及读取数据

HashMap作为一个数据容器,其主要的作用就是添加和读取数据了,添加数据的方法对应put,而读取数据方法对应get,这里就来分别说说这两个方法

2.1 添加数据

添加数据通过第一节的put方法来实现,实际调用的是如下方法,这里基本就用一文读懂HashMap文章里的对应方法注释了,也还算是清楚了

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为空或者长度为0,则resize()
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //确定插入table的位置,算法是(n - 1) & hash,在n为2的幂时,相当于取摸操作。
    ////找到key值对应的槽并且是第一个,直接加入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //在table的i位置发生冲突,有两种情况,1、key值是一样的,替换value值,
    //2、key值不一样的有两种处理方式:2.1、存储在i位置的链表;2.2、存储在红黑树中
    else {
        Node<K,V> e; K k;
        //第一个node的hash值即为要加入元素的hash
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //2.2
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //2.1
        else {
            //不是TreeNode,即为链表,遍历链表
            for (int binCount = 0; ; ++binCount) {
            ///链表的尾端也没有找到key值相同的节点,则生成一个新的Node,
            //并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树。
                if ((e = p.next) == null) {
                     // 创建链表节点并插入尾部
                    p.next = newNode(hash, key, value, null);
                    ////超过了链表的设置长度8就转换成红黑树
                    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;
            }
        }
        //如果e不为空就替换旧的oldValue值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

针对这个方法说几个关键点吧
1、方法最后会将table的size和treshold进行对比,当前者大于后者时会进行扩容操作,扩容实际上就是讲table的容量增大一倍,并且将原来的数据添加新的HashMap中去,这个就不详细展开,感兴趣的可以参考一文读懂HashMap这篇文章,说的比较清楚了
2、这里的putVal方法中添加到链表中的新的Node是添加到链表的尾部的,这个和JDK1.7添加新Node到链表的开始不一样

2.2 获取数据

HashMap通过get方法获取数据

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

实际上还是通过getNode方法来获取数据

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) {
		//table数组中根据hash值找到对应的Node则返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
			//如果对应的是红黑树,则根据hash和key值在红黑树中查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
				//不是红黑树则对应的是链表Node,根据hash和key值遍历链表查找
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

实际上getNode还是很简单,依次在数组、红黑树以及链表中通过hash值和key值来搜索对应的Node,实际上我们根据hash值和key值可以完全定位到Node在HashMap中的位置,搜索完成都没有找到的话,就返回null。

关于第二节需要说明的是这里没有针对红黑树的操作展开说明,实际上这个对我们从宏观上理解HashMap的原理影响不大,感兴趣的童鞋可以自己找源码看看

3、HashMap和HashTable的区别

HashMap和HashTable都是针对Map接口的实现,所以它们的功能是差不多的,它们的主要区别在线程安全这一块,先说结论:HashMap线程不全,而HashTable则是线程安全的

HashMap的线程不安全主要表现在一下两点:

1、put的时候导致的多线程数据不一致

比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引座标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆蓋了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

2、resize可能会引起死循环(仅针对JDK 1.7)

这种情况发生在HashMap自动扩容时,当两个个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize()进行扩容,两个线程同时修改一个链表结构会产生一个环形链表(JDK1.7中,会出现resize前后元素顺序倒置的情况),也就是可能出现A.next=B,B.next=A的情况。接下来再想通过get()获取某一个元素,就可能会出现死循环。关于resize可能引起死循环的详细原因可以参考HashMap这篇文章。

而HashTable是线程安的原因其实很简单,也就是通过synchronized关键字实现的,如下

public synchronized V put(K key, V value) 
public synchronized V get(Object key)

关于synchronized关键字的详细认识可以参考我的另外一篇博客关于synchronized关键字的认识

另外需要说明的是,由于HashTable为了实现线程安全使用了synchronized关键字,它的执行速度会比HashMap慢,所以在不存在并发的时候优先考虑使用HashMap,而存在并发的情况下为了保证线程安全,建议使用HashTable

4、参考文献

1、一文读懂HashMap
2、HashMap原理深入理解
3、HashMap
4、关于synchronized关键字的认识

点赞