首页 > 技术文章 > HashMap源码解析(只为吊打面试官)

shenhaha520 2019-12-23 14:25 原文

现在的面试当中,大家经常会被问到关于HashMap的问题,而且这个集合在开发中也经常会使用到,也相当的重要。

写这篇文章的期望:一文搞定HashMap的所有相关知识和面试问题,从此不再惧怕HashMap,和面试官大战三百回合。

本文是基于jdk1.8来分析的,篇幅较长。会循序渐进的往文章中添加或修改内容,有错误之处请指出,一起成长。

这篇文章,希望可以解决以下问题。

* 认识HashMap

* HashMap的底层数据结构及其演进过程

* 什么是红黑树

* HashMap如何存储一个元素

 

一、认识HashMap

HashMap最早是在jdk1.2中开始出现的,一直到jdk1.7都没有太大的变化。但是到了jdk1.8突然进行了一个很大的改动。

其中一个最显著的改动就是:之前jdk1.7的存储结构是数组+链表,到了jdk1.8变成了数组+链表+红黑树。

另外,HashMap是非线程安全的,也就是说在多个线程同时对HashMap中的某个元素进行增删改操作的时候,是不能保证数据的一致性的。

 

深入分析HashMap

二、HashMap的底层数据结构及其演进过程

  HashMap在jdk1.7时的存储结构图

 从上图我们可以看到,在jdk1.7中,首先是把元素放在一个个数组里面,后来存放的数据元素越来越多,于是就出现了链表,对于数组中的每一个元素,都可以有一条链表来存储元素。这就是有名的“拉链式”存储方法。

就这样用了几年,后来存储的元素越来越多,链表也越来越长,在查找一个元素时候效率不仅没有提高(链表不适合查找,适合增删),反倒是下降了不少,于是就对这条链表进行了一个改进。如何改进呢?就是把这条链表变成一个适合查找的树形结构,没错就是红黑树。于是HashMap的存储数据结构就变成了下面的这种。

注意:不是说变成了红黑树效率就一定提高了,只有在链表的长度不小于8,而且数组的长度不小于64的时候才会将链表转化为红黑树。

那么为什么不一下把整个链表变成红黑树呢?

这个问题也可以转化为为什么非要等到链表的长度大于等于8的时候,才将链表转化为红黑树?

这里从两方面来说明:

1 构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。

2 HashMap频繁的扩容,会造成底部红黑树不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。

 什么是红黑树

红黑树是一个自平衡的二叉查找树,也就是说红黑树的查找效率是非常的高,查找效率会从链表的o(n)降低为o(logn)。如果之前没有了解过红黑树的话,也没关系,你就记住红黑树的查找效率很高就OK了。

 

HashMap如何存储一个元素

HashMap存储元素的一种方式,测试代码:

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<String, Integer> map= new HashMap<>();
        //存储一个元素
        map.put("shenhaha", 18);
    }
}

在这里HashMap<String, Integer>,第一个参数是键,第二个参数是值,合起来叫做键值对。存储的时候只需要调用put方法即可。那底层的实现原理是怎么样的呢?这里还是先给出一个流程图

转换为文字步骤描述:

(1)第一步:调用put方法传入键值对

(2)第二步:使用hash算法计算hash值

(3)第三步:根据hash值确定存放的位置,判断是否和其他键值对位置发生了冲突

(4)第四步:若没有发生冲突,直接存放在数组中即可

(5)第五步:若发生了冲突,还要判断此时的数据结构是什么?

(6)第六步:若此时的数据结构是红黑树,那就直接插入红黑树中

(7)第七步:若此时的数据结构是链表,判断插入之后是否大于等于8

(8)第八步:插入之后不大于8,那么就直接插入到链表尾部即可

(9)第九步: 插入之后大于8了,就要先调整为红黑树,在插入

上面就是插入数据的整个流程,光看流程还不行,我们还需要深入到源码中去看看底部是如何按照这个流程写代码的。

 

 

鼠标聚焦在put方法上面,ctrl一下,进入put的源码。来看一下:

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

 

可以看到,put方法内部调用的是putVal方法。putVal方法有5个参数:  

(1)第一个参数hash:调用了hash方法计算hash值

(2)第二个参数key:就是我们传入的key值,也就是例子中的张三

(3)第三个参数value:就是我们传入的value值,也就是例子中的20

(4)第四个参数onlyIfAbsent:也就是当键相同时,不修改已存在的值

(5)第五个参数evict :如果为false,那么数组就处于创建模式中,所以一般为true。

 

下面我们来分析一下putVal方法的源码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //第一部分
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //第二部分
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //第三部分
        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;
        //第四部分
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  

 四 构造一个HashMap

他的构造方法一共有四个:

 

第一个:

public HashMap() {
     this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

第二个:
public HashMap(int initialCapacity) {
     this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
第三个:
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
第四个:
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);
    }
这四个构造方法很明显第四个最麻烦,我们就来分析一下第四个构造方法,其他三个自然而然也就明白了。上面出现了两个新的名词:loadFactor和initialCapacity。我们一个一个来分析:
(1)initialCapacity初始容量
官方要求我们要输入一个2的N次幂的值,比如说2、4、8、16等等这些,但是我们忽然一个不小心,输入了一个20怎么办?没关系,虚拟机会根据你输入的值,找一个离20最近的2的N次幂的值,比如说16离他最近,就取16为初始容量。
(2)loadFactor负载因子
负载因子,默认值是0.75。负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。 所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成烂费,但是此时索引效率高。

为什么默认值会是0.75呢?我们截取一段jdk文档:

 

 

 

 

 

英语不好的人看的我真是一脸懵逼,不过好在大概意思还能明白。看第三行Poisson_distribution这不就是泊淞分布嘛。而且最关键的就是

当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

 

推荐阅读