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

Posted 菜鸟名企梦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一篇文章彻底读懂HashMap之HashMap源码解析(上_)相关的知识,希望对你有一定的参考价值。

优化了排版

就身边同学的经历来看,HashMap是求职面试中名副其实的“明星”,基本上每一加公司的面试多多少少都有问到HashMap的底层实现原理、源码等相关问题。 

在秋招面试准备过程中,博主阅读过很多关于HashMap源码分析的文章,漫长的拼凑式阅读之后,博主没有看到过一篇能够通俗易懂、透彻讲解HashMap源码的文章(可能是博主没有找到)。秋招结束后,国庆假期抽空写下了这篇文章,用一种通俗易懂的方式向读者讲解HashMap源码,并且尽量涵盖面试中HashMap的所有考察点。希望能够对后面的求职者有所帮助~

      

这篇文章将会按以下顺序来组织:

  1. HashMap源码分析(JDK8,通俗易懂)

  2. HashMap面试“明星”问题汇总,以及明星问题答案


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

        注释多少与重要性成正比

        注释多少与重要性成正比

        注释多少与重要性成正比

文末有资料分享  

文末有资料分享 

  • HashMap的成员属性源码分析

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

    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,红黑树结构比链表代价更小)。


  • 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位的随机性。


  • 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的性能很糟糕!


  • 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的整数次幂(如果不溢出)。


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


  • 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的值更新为16*0.75=12。具体而言,当我们一直往HashMap put元素的时候,如果put某个元素后,Node数组中元素个数为13,此时会触发扩容(因为数组中元素个数>threshold了,即13>threshold=12),具体扩容操作之后会详细分析,简单理解就是,扩容操作将Node数组长度*2;并且将原来的所有元素迁移到新的Node数组中。

  

  • 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<Node>数组,当我们向map中put第一个元素的时候,map才会进行初始化!


  • 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。


  • 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。


  • contains函数源码分析:

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


HashMap源码解析(下)。

下篇主要内容:put、resize等重点方法源码解析以及HashMap常见面试问题汇总及其答案。


推荐阅读:


点击“阅读原文”获取更多面试技术分享

以上是关于一篇文章彻底读懂HashMap之HashMap源码解析(上_)的主要内容,如果未能解决你的问题,请参考以下文章

看完彻底了解 HashMap 源码

JDK源码那些事儿之HashMap.TreeNode

一文读懂HashMap

手撕源码之HashMap

手撕源码之HashMap

HashMap详解之一文读懂