一篇文章彻底读懂HashMap之HashMap源码解析(上)

       就身边同学的经历来看,HashMap是求职面试中名副其实的“明星”,基本上每一加公司的面试多多少少都有问到HashMap的底层实现原理、源码等相关问题。 
      在秋招面试准备过程中,博主阅读过很多关于HashMap源码分析的文章,漫长的拼凑式阅读之后,博主没有看到过一篇能够通俗易懂、透彻讲解HashMap源码的文章(可能是博主没有找到)。秋招结束后,国庆假期抽空写下了这篇文章,用一种通俗易懂的方式向读者讲解HashMap源码,并且尽量涵盖面试中HashMap的所有考察点。希望能够对后面的求职者有所帮助~

这篇文章将会按以下顺序来组织:
HashMap源码分析(JDK8,通俗易懂)
HashMap面试“明星”问题汇总,以及明星问题答案

下面是JDK8中HashMap的源码分析,在下文源码分析中:

    注释多少与重要性成正比

    注释多少与重要性成正比

    注释多少与重要性成正比

(1)HashMap的成员属性源码分析

public class HashMap<K,V> 
   extends AbstractMap<K,V>
   implements Map<K,V>,
   Cloneable, Serializable {

    private static final long serialVersionUID
                   = 362498820763181265L;

    //HashMap的初始容量为16,HashMap的
    //容量指的是存储元素的数组大小,
    //即桶的数量
    static final int DEFAULT_INITIAL_CAPACITY 
                     = 1 << 4; 

    //HashMap的最大的容量
    static final int MAXIMUM_CAPACITY
                         = 1 << 30;  
 //下面有详细解析
static final float DEFAULT_LOAD_FACTOR
                     = 0.75f;
//当某一个桶中链表的长度>=8时,链表结构会转换成
//红黑树结构,其实还要求桶的中数量>=64,后面会提到
static final int TREEIFY_THRESHOLD = 8;

//当红黑树中的节点数量<=6时,红黑树结构会转变为
//链表结构
static final int UNTREEIFY_THRESHOLD = 6;

//上面提到的:当Node数组容量>=64的前提下,如果
//某一个桶中链表长度>=8,则会将链表结构转换成
//红黑树结构
static final int MIN_TREEIFY_CAPACITY = 64;
} 

DEFAULT_LOAD_FACTOR:HashMap的负载因子,影响HashMap性能的参数之一,是时间和空间之间的权衡,后面会看到HashMap的元素存储在Node数组中,这个数组的大小这里称为“桶”的大小。另外还有一个参数size指的是我们往HashMap中put了多少个元素。当size>桶的数量*DEFAULT_LOAD_FACTOR的时候,这时HashMap要进行扩容操作,也就是桶不能装满。DEFAULT_LOAD_FACTOR是衡量桶的利用率:

DEFAULT_LOAD_FACTOR较小时(桶的利用率较小),这时浪费的空间较多(因为只能存储桶的数量DEFAULT_LOAD_FACTOR个元素,超过了就要进行扩容),这种情况下往HashMap中put元素时发生冲突的概率也很小,所谓冲突指的是:多个元素被put到了同一个桶中;冲突小时(可以认为一个桶中只有一个元素)put、get等HashMap的操作代价就很低,可以认为是O(1);
DEFAULT_LOAD_FACTOR很大时,桶的利用率较大的时候(注意可以大于1,因为冲突的元素是使用链表或者红黑树连接起来的),此时空间利用率较高,这也意味着一个桶中存储了很多元素,这时HashMap的put、get等操作代价就相对较大,因为每一个put或get操作都变成了对链表或者红黑树的操作,代价肯定大于O(1),所以说DEFAULT_LOAD_FACTOR是空间和时间的一个平衡点;
DEFAULT_LOAD_FACTOR较小时,需要的空间较大,但是put和get的代价较小;DEFAULT_LOAD_FACTOR较大时,需要的空间较小,但是put和get的代价较大)。扩容操作就是把桶的数量*2,即把Node数组的大小调整为扩容前的2倍,至于为什么是两倍,分析扩容函数时会讲解,这其实是一个trick,细节后面会详细讲解。Node数组中每一个桶中存储的是Node链表,当链表长度>=8的时候并且Node数组的大小>=64,链表会变为红黑树结构(因为红黑树的增删改查复杂度是logn,链表是n,红黑树结构比链表代价更小)。

(2)HashMap内部类——Node源码分析

//Node是HashMap的内部类
static class Node<K,V> 
     implements Map.Entry<K,V> {
        final int hash; 
        final K key;//保存map中的key
        V value;//保存map中的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;
        }

HashMap的内部类Node:HashMap的所有数据都保存在Node数组中那么这个Node到底是个什么东西呢?
Node的hash属性:保存key的hashcode的值:key的hashcode ^ (key的hashcode>>>16)。这样做主要是为了减少hash冲突当我们往map中put(k,v)时,这个k,v键值对会被封装为Node,那么这个Node放在Node数组的哪个位置呢:index=hash&(n-1),n为Node数组的长度。那为什么这样计算hash可以减少冲突呢?如果直接使用hashCode&(n-1)来计算index,此时hashCode的高位随机特性完全没有用到,因为n相对于hashcode的值很小,计算index的时候只能用到低16位。基于这一点,把hashcode高16位的值通过异或混合到hashCode的低16位,由此来增强hashCode低16位的随机性。

(3)HashMap hash函数分析

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

HashMap允许key为null,null的hash为0(也意味着HashMap允许key为null的键值对),非null的key的hash高16位和低16位分别由由:key的hashCode高16位和hashCode的高16位异或hashCode的低16位组成。主要是为了增强hash的随机性减少hash&(n-1)的随机性,即减小hash冲突,提高HashMap的性能。所以作为HashMap的key的hashCode函数的实现对HashMap的性能影响较大,极端情况下:所有key的hashCode都相同,这是HashMap的性能很糟糕!

(4)HashMap tableSizeFor函数源码分析

static final int tableSizeFor(int cap) {
    //举例而言:n的第三位是1(从高位开始数), 
    int n = cap - 1;

    n |= n >>> 1; 
    n |= n >>> 2; 
    n |= n >>> 4; 
    n |= n >>> 8; 
    n |= n >>> 16; 

    return (n < 0) ? 1
         : (n >= MAXIMUM_CAPACITY) 
         ? MAXIMUM_CAPACITY : n + 1;
}

在new HashMap的时候,如果我们传入了大小参数,这是HashMap会对我们传入的HashMap容量进行传到tableSizeFor函数处理:这个函数主要功能是:返回一个数:这个数是大于等于cap并且是2的整数次幂的所有数中最小的那个,即返回一个最接近cap(>=cap),并且是2的整数次幂的数。
具体逻辑如下:一个数是2的整数次幂,那么这个数减1的二进制就是一串掩码,即二进制从某位开始是一 串连续的1。所以只要对的对应的掩码,掩码+1一定是2的整数次幂,这也是为什么n=cap-1的原因。
举例而言,假设:
n=00010000_00000000_00000000

n |= n >>> 1;//执行完后
//n=00011000_00000000_00000000

n |= n >>> 2;//执行完后
//n= 00011110_00000000_00000000

n |= n >>> 4;//执行完后
//n= 00011111_11100000_00000000

n |= n >>> 8;//执行完后
//n= 00011111_11111111_11100000

n |= n >>> 16;//执行完后
//n=00011111_11111111_11111111

返回n+1,(n+1)>=cap、为2的整数次幂,并且是与cap差值最小的那个数。最后的n+1一定是2的整数次幂,并且一定是>=cap。
整体的思路就是:如果n的二进制的第k为1,那么经过上面四个‘|’运算后[0,k]位都变成了1,即:一连串连续的二进制‘1’(掩码),最后n+1一定是2的整数次幂(如果不溢出)。
(5)HashMap成员属性源码分析

/我们往map中put的(k,v)都被封装在Node中,
//所有的Node都存放在table数组中
transient Node<K,V>[] table;

//用于返回keySet和values
transient Set<Map.Entry<K,V>> entrySet;

//保存map当前有多少个元素
    transient int size;

//failFast机制,在讲解ArrayList
//和LinkedList一文中已经讲过了
transient int modCount;

(6)threshold属性分析

int threshold;//下面有详细讲解

//负载因子,见上面对DEFAULT_LOAD_FACTOR
//参数的讲解,默认值是0.75
final float loadFactor;

threshold也是比较重要的一个属性:
创建HashMap时,该变量的值是:初始容量(2的整数次幂),之后threshold的值是HashMap扩容的门限值:即当前Nodetable数组的长度* loadfactor。举个例子而言,如果我们传给HashMap构造器的容量大小为9,那么threshold初始值为16,在向HashMap中put第一个元素后,内部会创建长度为16的Node数组,并且threshold的值更新为160.75=12。具体而言,当我们一直往HashMap put元素的时候,如果put某个元素后,Node数组中元素个数为13,此时会触发扩容(因为数组中元素个数>threshold了,即13>threshold=12),具体扩容操作之后会详细分析,简单理解就是,扩容操作将Node数组长度2;并且将原来的所有元素迁移到新的Node数组中。

(7)HashMap构造器源码分析

//构造器:指定map的大小,和loadfactor
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);
       //保存loadfactor
       this.loadFactor = loadFactor;

    /*注意,前面有讲tableSizeFor函数,
    该函数返回值:>=initialCapacity、
    返回值是2的整数次幂,并且得是满足
    上面两个条件的所有数值中最小的那个数。
     */
    this.threshold = 
         tableSizeFor(initialCapacity);
}
/*
只指定HashMap容量的构造器,
loadfactor使用的是
默认的值:0.75
   */
public HashMap(int initialCapacity) {
    this(initialCapacity
          , DEFAULT_LOAD_FACTOR);
}

//无参构造器,默认loadfactor:0.75,
//默认的容量是16
public HashMap() {
    this.loadFactor 
          = DEFAULT_LOAD_FACTOR; 
}
//其他不常用的构造器就不分析了

从构造器中我们可以看到:HashMap是“懒加载”,在构造器中值保留了相关保留的值,并没有初始化table数组,当我们向map中put第一个元素的时候,map才会进行初始化!
(8)HashMap的get函数源码分析

//入口,返回对应的value
public V get(Object key) {
    Node<K,V> e;
        
    //hash函数上面分析过了
    return (e = getNode(hash(key), key))
            == null
            ? null : e.value;
    }

get函数实质就是进行链表或者红黑树遍历搜索指定key的节点的过程;另外需要注意到HashMap的get函数的返回值不能判断一个key是否包含在map中,get返回null有可能是不包含该key;也有可能该key对应的value为null。HashMap中允许key为null,允许value为null。
(9)getNode函数源码分析

//下面分析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) {
        if (first.hash == hash&&  
           ((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;//没找到,返回null
}

注意getNode返回的类型是Node:当返回值为null时表示map中没有对应的key,注意区分value为null:如果key对应的value为null的话,体现在getNode的返回值e.value为null,此时返回值也是null,也就是HashMap的get函数不能判断map中是否有对应的key:get返回值为null时,可能不包含该key,也可能该key的value为null!那么如何判断map中是否包含某个key呢?见下面contains函数分析。getNode函数细节分析:
(n-1)&hash:当前key可能在的桶索引,put操作时也是将Node存放在index=(n-1)&hash位置。
getNode的主要逻辑:如果table[index]处节点的key就是要找的key则直接返回该节点; 否则:如果在table[index]位置进行搜索,搜索是否存在目标key的Node:这里的搜索又分两种:链表搜索和红黑树搜索,具体红黑树的查找就不展开了,红黑树是一种弱平衡(相对于AVL)BST,红黑树查找、删除、插入等操作都能够保证在O(lon(n))时间复杂度内完成,红黑树原理不在本文范围内,但是大家要知道红黑树的各种操作是可以实现的,简单点可以把红黑树理解为BST,BST的查找、插入、删除等操作的实现在之前的文章中有
BST java实现讲解,红黑树实际上就是一种平衡的BST。
(10)contains函数源码分析:

public boolean containsKey(Object key) {
    //注意与get函数区分,我们往map中put的
    //所有的<key,value>都被封装在Node中,
    //如果Node都不存在显然一定不包含对应的key
    return getNode(hash(key), key) != null;
}   

猜你喜欢

转载自blog.csdn.net/u010651249/article/details/83897866