面试必备之HashMap原理浅析

197 阅读10分钟

看了不少面试的一些问题,基本都会有问HashMap的原理分析等等,可以说这是每个Android面试者必备的知识点了,所以,不管是为了自己真的去了解运用还是仅仅为了面试,还是非常有必要学习一波的,下面直接进入正题。(本文基于JDK1.8分析,分析面对一般的面试应该是够用了)

先明确一点,HashMap是基于 基于哈希表(数组+链表+二叉树(红黑树))实现的,加入红黑树是为了保证树的两边长短平衡,不至于一边过长。通常结构如下图:

 


我们一般是使用的时候都是直接new一个HashMap,那么在new的时候都发生了什么呢?进入源码看一下:

final float loadFactor; //负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认的负载因子
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认的容量大小,也就是16
static final int MAXIMUM_CAPACITY = 1 << 30;//极限值(超过这个值就将threshold修改为MAXIMUM_CAPACITY,表明不进行扩容了)    
static final int TREEIFY_THRESHOLD = 8;

public HashMap() {
//调用参构造时负载因子等于默认值0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);//默认负载因子也为0.75
}
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)         //可以通过修改loadFactor参数修改默认负载因子,一般不建议修改
        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);
}

解释下负载因子的含义:假如现在哈希表的容量有16个位置(默认大小数组就是16),如果现在有75%的位置都存有值(12个地方都存有值),那么现在这个数组的空间达到了需要扩充的地步了。这个负载因子是可以在创建的时候通过构造器的参数“loadFactor”去修改的,但是一般我们不会去修改这个值。如果想知道为什么负载因子是0.75的,可以去百度深挖一下,简单来说,这个值时间和空间成本的最佳折中,是专家经过多次计算出来的最恰当的值!下面两段源代码展示为什么默认大小是16和加载因子的默认值。

那么,哈希表是如何存储对象的呢?

查看源码中的存储put方法:

//存储put方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
//计算hash值
static final int hash(Object key) {
int h;
                           //计算key的hashcode值为h,然后高位参与运算,最后两个值异或得到一个整数
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

然后查看putVal方法,源码较多就只选择关键部分:

transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
//tab是一直Node数组
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; //第一次计算出length是16(未指定hashmap大小情况下)
  *****if ((p = tab[i = (n - 1) & hash]) == null)*****//这里解释了为什么数组下面从0开始到length-1了
    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; //hash值和key值都相等,先获取引用,后面会用来替换值
     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) { //注意这个地方跟Java7不一样,是插在链表尾部!java7是插在头部!
                        p.next = newNode(hash, key, value, null);                if(binCount>TREEIFY_THRESHOLD-1)     
               treeifyBin(tab,hash);     //binCount表示链表的长度,详细代码可查看源码;大于8转化为红黑树
               break;
}
    ...
}
    ...
}    

//扩容方法
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY; //没有设置hashmap大小时,第一次会走这里,最后返回默认大小
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
               ......

   return newTab
    }
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}

首先看tab,可以看到tab是一个Node数组,而hashmap中存在一个Node对象数组table,Node是HashMap中的一个静态内部类,实现了Map.Entry<K,V>接口,有个final int hash属性保存hash值,其实可以根据这个Node的代码看出这是一个链表的结构,我们存进hashmap的值就存在其中的key、value中了。(数组是Node数组,数组中的元素是Node,Node里面是链表)。代码中“*”包围的代码,计算出length长度后,i = (n - 1) & hash相当于key的hash值对length取余得到就是数组的下标i,这也解释了为什么下标是从0到length-1了,下面一行tab[i] = newNode(...);创建一个Node对象存在tab数组的i位上。

现在问题来了,如果多个对象计算得到的hashcode相同呢?这时会先判断tab[i]是否为null,为null就直接新建节点添加和上面所述一样,不为null时会tab[i]的首个元素是否和key完全一样(指hashcode和equals相等),一样就直接覆盖value,这时候所谓的链表结构就出现了,值会以链表的形式存起来,jdk1.8以前就是这样存储,而1.8之后就多了判断tab[i]是否为TreeNode if(p instanceof TreeNode){...},即是否为红黑树,如果是就直接在树中插入键值对,如果不是红黑树,就还是个链表,根据if (binCount >= TREEIFY_THRESHOLD - 1) 判断链表长度是否大于8(源码中可以看到TREEIFY_THRESHOLD默认值就是8),大于8就会调用treeifyBin(tab,hash)把链表转化为红黑树,在红黑树中进行插入操作,否则进行链表的插入操作,遍历过程发现key存在直接覆盖value即可。

现在再来回顾下put操作的存储过程:

  1. 判断Node数组tab是否为null或长度为0,否者执行resize()扩容操作;
  2. 根据key值计算hash()值,用这个后用这个hash值对数组的长度取余数(默认的长度是16),来决定这个key对象在数组中存储的索引位置i,如果tab[i]为null,就调用newNode(....)新建一个节点添加转向第六步,如果不为null,则执行第三步;
  3. 判断tab[i]的首个元素是否和key一样,一样直接覆盖value,不一样执行第四步(此处一样指的是hashcode相等以及equals返回true);
  4. 判断tab[i]是否为treeNode红黑树,如果是,直接在树中插入键值队,不是执行第五步;
  5. 遍历tab[i],判断链表长度是否大于8,大于8的话就把链表转化为红黑树(转化后取值会更快),在红黑树中执行插入操作,否则进行链表的插入操作,同时,遍历的过程中如果发现key一样就直接覆盖value值;
  6. 插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold(也就是MAXIMUM_CAPACITY ),超过的话进行扩容操作。

接下里看下resize()扩容操作,源码如下:

//扩容方法(初始化数组是也调用了)
final Node<K,V>[] resize() {
    //旧数组的引用
    Node<K,V>[] oldTab = table;
    //旧数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧数组阀值
    int oldThr = threshold;
    //新数组长度、新阀值
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //如果旧数组长度大于等于规定的最大长度(2的30次方)
        if (oldCap >= MAXIMUM_CAPACITY) {
            ///修改阈值为int的最大值(2^31-1),这样以后就不会扩容了,且直接返回原来的旧数组
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //如果旧数组大小乘2小于规定最大长度且大于默认的数组长度大小那就扩容,新数组大小就为旧数组*2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //同样新阀值也是旧阀值*2
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //初始化在这里
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
   // 计算新的resize上限
    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) {
       // 把每个bucket都移动到新的buckets中
        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);
                //链表优化重hash的代码块;链表长度大于1,小于8的情况
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                          //原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                      // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                      // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

这部分代码比较难理解,为了“保险起见”,下面引用美团技术博客的一段分析来梳理下上面的代码:

     下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。


key1(5)与key2(21)元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:


因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:


这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

同时需要注意的是,HashMap不是线程安全的,如果需要线程安全的操作,建议使用ConcurrentHashMap

再总结一波:

  1. 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
  2. HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
  3. JDK1.8引入红黑树大程度优化了HashMap的性能。


第一次写文章,写的不好各位轻喷!有问题欢迎指出!