HashMap源码解析

HashMap在工作中比较常用,而且面试中经常被问到,所以我特意把它列出来进行分析。

注意:本文基于JDK8的源码进行分析。以下是常见的面试题:

1. HashMap底层是如何实现的?
2. HashMap数组长度怎么设置?
3. HashMap是怎么存放元素的?
4. HashMap是怎么减少冲突的?
5. HashMap怎么获取元素的?
6. HashMap的扩容机制是怎么样的?
7. HashMap的链表什么时候转化为红黑树,为什么要转化为红黑树?
8. HashMap和HashTable的区别?
9. JDK7和JDK8的HashMap设计有什么区别?JDK8为什么要改进?

10.HashMap为什么线程不安全?

本文试图从源码开始进行分析,然后再逐一解答上述问题。

基础知识

  • java 中的 << , >> , >>>

      <<   左移,低位补0,例如:二进制1 << 4 结果为10000,十进制为 2^4=16;
      >>   右移, 正数高位补0 , 负数高位补1;
      >>>  无符号右移,不论正负,高位补0。
    
  • serialVersionUID

    关于serialVersionUID

源码分析

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   //序列化版本ID号
    private static final long serialVersionUID = 362498820763181265L;
  //数组默认初始化大小为 1左移4位,即二进制10000,十进制位16
  //he default initial capacity - MUST be a power of two.
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  //数组的最大容量:2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;
   //默认的加载因子为0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
   //表示当链表长度为8时,链表可能转为红黑树存储
   static final int TREEIFY_THRESHOLD = 8;
   //当红黑树节点数为6时,红黑树可能转为链表存储
    static final int UNTREEIFY_THRESHOLD = 6;
    //桶可能被转化为红黑树的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;

DEFAULT_INITIAL_CAPACITY :数组默认初始化大小为16;(为2的次方);
MAXIMUM_CAPACITY :数组的最大容量为:2^30;(为2的次方);
DEFAULT_LOAD_FACTOR :默认的加载因子:0.75, 即数组中元素个数 >= 数组长度 x 加载因子 时,进行扩容;
TREEIFY_THRESHOLD : 表示当链表长度为8时,链表可能转为红黑树存储;
UNTREEIFY_THRESHOLD:当红黑树节点数为6时,红黑树可能转为链表存储;
MIN_TREEIFY_CAPACITY :桶可能被转化为红黑树的最小容量。

内部类Node

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V 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;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
...

可以看出HashMap中的每个结点由四部分组成:hash值,key,value, next 引用

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

hash函数可以称为 扰乱函数

  • 传入的key值为null时,直接返回0;
  • 否则,将 key 的 hashCodekey 的 hashCode无符号右移16位的结果进行异或,返回异或后的结果。
    该步相当于将hashCode的高位和低位进行异或操作,可以减少冲突

tableSizeFor()设置数组大小

 static final int tableSizeFor(int cap) {
        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;
    }

该函数的目的在于保证数组大小为2的次方:当用户传入的数组大小不是2的次方时,该函数可以使数组大小变成2的次方。如用户给构造函数传入数组大小为11,则实际的数组大小为16。


先来假设n的二进制为01xxx…xxx,

对n右移1位:001xx…xxx,再与01xxx…xxx位或得:011xx…xxx

对n右移2为:00011…xxx,再与011xx…xxx位或得:01111…xxx

对n右移4为:000001111…xxx,再与01111xx…xxx位或得:011111111…xxx

当右移8位时,我想应该都知道得到什么结果了吧,

综上可得,该算法让最高位的1后面的位全变为1。

最后再让结果n+1,即得到了2的整数次幂的值了。

现在回来看看第一条语句:

int n = cap - 1;

让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。

该函数的举例解释来自于https://blog.csdn.net/weixin_41565013/article/details/93070794


transient Node<K,V>[] table;

由上面代码可以看到HashMap底层是一个table数组,数组中的每个元素是链表

构造函数

//含有数组初始大小和负载因子的构造函数
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);
}

//只含初始大小的构造函数,可以看出它的负载因子是默认的值0.75
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//不传参数时,默认负载因子为0.75 
public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

 public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

当只传入initialCapacity或者不传参数时,装载因子为默认的0.75。

4. get方法查找元素

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

get()调用了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 && // always check first node
                ((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 或者 定位到的索引位置无元素,返回null;
  • 否则,如果数组不为null且数组大小不为0且我们要找的数组的索引位置有元素,然后进行查找,如果key的hash相等,且只要key的值相等,就直接返回该结点
  • 如果发现第一个结点不是我们要找的,则查看第二个结点:首先判断是否为红黑树,是则交给红黑树去查找,否则循环进行查找链表,只要找到传入的key的hash值和该结点的hash值相等且者key值也相等,则返回

put方法添加元素

 public V put(K key, V value) {
        return putVal(hash(key), key, value, 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; //数组长度为0时,调用resize()扩容
        if ((p = tab[i = (n - 1) & hash]) == null)//如果该索引到的位置为null,则直接存储到数组中
            tab[i] = newNode(hash, key, value, null);
        else { 
            Node<K,V> e; K k;
            if (p.hash == hash &&  //如果桶中第一个结点key的值与传入的key值相等,且hash也等,将e指向该结点;
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)// 如果是树,则根据树查找,将e指向找到的结点
                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);
                        //判断桶内的节点是否大于8
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //这个方法里还会判断总节点数大于64则会转换为红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash && //如果找到数组中某个结点key的值与传入的key值相等,对应的hash也等
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;//p指向e结点
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value; //在此处才修改value的值
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)//判断书否需要进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;//否则返回空
    }

当HashMap进行put元素时,

  • 如果数组为null,调用resize()进行扩容;
  • 如果该桶中没有元素,则直接将结点存入;
  • 否则,查看第一个结点的key值和hash值是否和传入的key值和hash值相等,如果相等,则覆盖;
  • 然后看若为红黑树,进行红黑树的遍历并找到存放结点的位置进行存放;若为链表,则进行遍历, 当遍历到链表末尾时,直接插入结点,并检查是否需要转化为红黑树:该桶里链表中结点的个数是否大于8并且总的结点个数大于64,则链表转为红黑树。如果遍历过程中发现某一结点的key值和hash值和传入的key值和hash值相等,则进行覆盖。
    在这里插入图片描述图片来自于互联网

注意:这里是怎么定位到数组的下标的:
(数组大小 - 1) & hash; 数组大小为2的n次方,(数组大小 - 1)二进制表示全是1和key的hash进行“与”,结果刚好所在数组的范围内。这样设计效率比较高。

resize方法进行扩容
重新创建新数组,将新数组大小变为原来的2倍,然后再将原数组中的值依次存到新数组中。

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //oldTab指向旧数组
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//oldCap为旧数组的容量
        int oldThr = threshold;//旧的threshold,决定什么时候扩容
        int newCap, newThr = 0;//新数组容量,新数组的threshold为0
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {//如果旧数组容量>2^30,此时不扩容,只修改threshold
                threshold = Integer.MAX_VALUE;//threshold设置为2^31
                return oldTab;//直接返回
            }
            //oldCap < MAXIMUM_CAPACITY,newCap为oldCap的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 为原来threshold的2倍
        }
        else if (oldThr > 0)//oldCap = 0 且 oldThr > 0 即数组未初始化时
            newCap = oldThr;//初始化容量为threshold
        else {//oldCap = 0 且 oldThr = 0 
            newCap = DEFAULT_INITIAL_CAPACITY; 
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        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;//table指向新数组
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {//开始逐个进行复制
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                 /** 这里注意, table中存放的只是Node的引用,这里将oldTab[j]=null只是清除旧表的引用, 
                   * 但是真正的node节点还在, 只是现在由e指向它
                   */
                    oldTab[j] = null;
                    if (e.next == null)//如果e的下个节点(即第二个节点)为null,则只需要将e转移到新的哈希桶中
                        newTab[e.hash & (newCap - 1)] = e;
                        //如果哈希桶内的节点为红黑树,则交给TreeNode进行转移
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { //如果是链表
                         //loHead表示低位的头结点,loTail表示低位的尾结点
                        Node<K,V> loHead = null, loTail = null;
                        //hiHead表示高位的头结点,hiTail表示高位的尾结点
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;//临时变量
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) { //如果hash & oldCap为0,则新的位置为原来的位置
                                if (loTail == null)//如果低位尾结点尾null,说明低位为空
                                    loHead = e;//低位头结点指向e结点
                                else//如果低位尾结点不为空
                                    loTail.next = e;//则将e插入低位链表尾部
                                loTail = e;//尾结点指针移动到新的尾结点
                            }
                            else {//如果hash & oldCap为1,则新的位置为原来的位置+原来的数组容量
                                if (hiTail == null)//如果高位的尾结点为null,说明高位为null
                                    hiHead = e; //高位头结点指向e
                                else//如果高位尾结点不为空
                                    hiTail.next = e;//高位尾结点插入e
                                hiTail = e;//高位尾结点指向新的尾结点
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {//如果低位尾结点不为null
                            loTail.next = null;//低位尾结点的next指向null
                            newTab[j] = loHead;//最后将newTab[j]指向低位头结点
                        }
                        if (hiTail != null) {//高位尾结点不为null
                            hiTail.next = null;//高位尾结点指向null
                            newTab[j + oldCap] = hiHead;//最后将newTab[j+oldCap]指向高位头结点
                        }
                    }
                }
            }
        }
        return newTab;
    }

在这里插入图片描述
这张图中index=2的桶中有四个节点,在未扩容之前,它们的 hash& cap 都等于2。在扩容之后,它们之中2、18还在一起,10、26却换了一个桶。这就是这句代码的含义:选择出扩容后在同一个桶中的节点。

 if ((e.hash & oldCap) == 0)

我们这时候的oldCap = 8,2的二进制为:0010,8的二进制为:1000,0010 & 1000 =0000
10的二进制为:1010,1010 & 1000 = 1000,
18的二进制为:10010, 10010 & 1000 = 0000,
26的二进制为:11010,11010 & 1000 = 1000,
从与操作后的结果可以看出来,2和18应该在同一个桶中,10和26应该在同一个桶中。

所以lo和hi这两个链表的作用就是保存原链表拆分成的两个链表。

上图和所配文字引用自https://blog.csdn.net/weixin_41565013/article/details/93190786

解答

1. HashMap底层是如何实现的?

 HashMap底层是使用数组 + 链表 + 红黑树实现的,键和值都可以为空。

2. HashMap数组长度怎么设置?

 数组长度默认为2的次方,如果我们传入的长度不是2的次方,HashMap会调用相关函数将长度
 设置成2的次方。

3. HashMap是怎么存放元素的?

1.看数组是否为null,如果为null,进行初始化;

2. 不为null,根据 (length - 1)& hash 确定元素所在的桶,如果该位置没有元素,创建结点并存入桶table[length - 1)& hash]中;

3. 如果桶不为null,则检查hash值和key的值是否相等,如果相等,用value值覆盖原来的值;

4. 如果不相等,且结点为树结点,创造树结点并插入红黑树中;

5. 如果结点是链表结点,则创建普通结点并加入链表中,判断链表长度是否大于8,
    大于的话转为红黑树;
    
6. 看数组中元素的个数是否超过  数组大小 * 负载因子,如果超过,则扩容。

4. HashMap是怎么取元素的?

 根据key的hash值和可以进行取值操作,先根据hash判断桶的位置,然后用equals判断key的值
 是否相等,相等就返回该结点,不等的,如果第二个结点是树结点,则在树中进行查找,否则,
 在链表中进行查找。

5. HashMap怎么设置数组初始大小?

 我们不设置大小时,HashMap默认大小为16,我们自己设的话一般设为2的次方,否则,HashMap
 会将我们设的大小改成大于数组大小的最小的2的次方。

6. 为什么数组长度要设置成2的次方?

为了减少冲突,进而可以提高效率。(length - 1)& hash 确定桶位置时,如果length为2的
次方,这样会减少hash冲突,效率也特别高。如果不为2的次方,那么length - 1 表示的二进制
位中就可能有包含0的位,再进行&的时候,该位的结果为0,这样就有可能使不同的hash对应同一个
桶中,冲突较大;
扩容后,省去重新计算 hash 的时间。

7.HashMap的哈希函数怎么设置的?有什么好处?

  hash函数中,h = key.hashCode()) ^ (h >>> 16)来让key的hashCode的高位和低位
  进行异或,这样高低位都进行运算,减小了hash冲突
  好处:高低位都进行运算,减小了hash冲突; 位运算比取余效率高,提高了效率。

8. HashMap什么时候进行扩容?

  put元素时,发现数组未初始化,初始化时进行扩容;
  数组中元素个数 > 负载因子 * 数组长度;
  某个桶中一个链表中的结点个数>8 且 数组大小 <= 64 扩容。

9. JDK8和JDK7的HashMap有什么区别?为什么要进行优化?

  数组 + 链表 改为 数组 + 链表 + 红黑树;’
  头部插入改为尾部插入;
  扩容时,jdk7需要重新定位桶的位置,jdk8则只需要将 hash & oldLength,观察最高位,
  为0桶位置不变,为1,桶位置变为table[oldLength + 原来的桶位置];
  插入时,jdk7先判断是否扩容再进行插入,jdk8则先插入再看需不需要扩容
  
  如果链表过长查找效率会很低,所以转为红黑树,将时间复杂度由O(n)降为O(logn);

10.HashMap的链表什么时候转化为红黑树,为什么要转化为红黑树?

   桶中链表长度大于8时,且数组长度大于64时,桶中的链表转为红黑树,数组长度小于64时,
   则进行扩容。
   如果链表过长查找效率会很低,所以转为红黑树,将时间复杂度由O(n)降为O(logn)。

11. HashMap和HashTable的区别?

   1. 线程安全。 HashMap是线程不安全的,而HashTable是线程安全的 ;
   2. 效率。HashMap比HashTable效率高,原因在于HashTable的方法通过synchronized修饰
   后,并发的效率会降低;
   3. 允不允许null。HashMap运行只有一个key为null,可以有多个null的value。
   而 HashTable不允许key,value为null。

12. HashMap线程安全吗?

 jdk7的HashMap在多线程下扩容时用头插法会形成环;还会有数据覆盖问题;
 链接https://www.jianshu.com/p/3ef6e9d26ef8)
 而jdk8的尾插法也会产生数据覆盖问题。

13. 怎么解决HashMap线程不安全的问题?

  1. 改用HashTable,和HashMap实现差不多,用synchronized锁住整个数组,锁的粒度很大;
  2. Collections.synchronizedMap(new HashMap<String,String>());
  3. java.util.current concurrent包下的的ConcurrentHashMap。

14. 为什么String, Interger这样的wrapper类适合作为HashMap的键?

因为这些类都是用final修饰的,为不可变类,我们将键放入map中,如果可变的话,放入时
的HashCode和获取时的HashCode不同,那么取值得时候会取不到。 
Map<List<Integer>,Object> map = new HashMap<>();
List<Integer> list = new ArrayList<>();
list.add(1);
map.put(list, new Object());
map.get(list);//1.
list.add(2);
map.get(list);//2.

在这里插入图片描述
15. 关于equals()和HashCode()?


HashMap底层用设计的特别巧妙,值得我们去学习借鉴。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值