Java源码剖析02:HashMap源码浅剖

HashMap特性
  1. 允许空键和空值(但空键只有一个,且放在第一位)
  2. 元素是无序的,而且顺序会不定时改变
  3. key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法。
  4. 底层实现是数组+链表,JDK 8 后又加了红黑树。
  5. 实现了 Map 全部的方法
类的继承关系
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable

可以看到HashMap继承自父类(AbstractMap),实现了Map、Cloneable、Serializable接口。

  • Map接口定义了一组通用的操作;
  • Cloneable接口则表示可以进行拷贝,在HashMap中,实现的是浅层次拷贝,即对拷贝对象的改变会影响被拷贝的对象;
  • Serializable接口表示HashMap实现了序列化,即可以将HashMap对象保存至本地,之后可以恢复状态。
类的属性
//jdk8
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // 序列号
    private static final long serialVersionUID = 362498820763181265L;    
    // 默认的初始容量是16,即初始数组长度为16,为什么写1<<4而不是16?可能是为了提醒容量必须是2的n次幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   -->16
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;   -->2^30
    // 默认的填充因子/加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当桶(bucket)上的结点数大于这个值且容量>64时会转成红黑树  ---->
    static final int TREEIFY_THRESHOLD = 8; 
    // 当桶(bucket)上的结点数小于这个值时树转链表---->
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中结构转化为红黑树对应的table的最小大小
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存储元素的数组,总是2的幂次倍
    transient Node<k,v>[] table; 
    // 存放具体元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;   
    // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容   存储3/4即扩容
    int threshold;
    // 填充因子
    final float loadFactor;
}
底层原理
  1. HashMap在jdk8之前是数组+链表结构;在jdk8后,当链表长度>8且整个hashMap容量>64时,长度过长的链表结构转换为红黑树,即变为数组+红黑树或者数组+(短)链表+红黑树

  2. HashMap的扩容:当HashMap的元素个数>=总容量*扩容因子(0.75)时,总容量会扩容,变成之前的2倍

  3. 什么是哈希碰撞/哈希冲突

    答:对应不同的关键字可能获得相同的hash地址,即 key1≠key2,但是f(key1)=f(key2)。则称为哈希碰撞/哈希冲突。对于这种情况,HashMap中采用链地址法处理冲突,即每个哈希地址(数组下标)对应的一个线性表,将地址相同的记录按序写入链表,而当链表过长时为提高效率在符合一定条件下把链表替换为红黑树.

  4. HashMap的工作原理

    答:HashMap 底层是hash数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry接口)实现,HashMap 通过 put & get 方法存储和获取。

    • 存储对象时,将 K/V 键值传给 put() 方法:

      1. 调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算的数组下标;
      2. 调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);
        1. 如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;
        2. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;
        3. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 且容量>64时,就把链表转换成红黑树)
    • 获取对象时,将 K 传给 get() 方法:

      1. 调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;
      2. 顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。
常用方法
  1. 添加数据 put(key,value)
//jdk8
public V put(K key, V value) {
    // 对 key 进行哈希操作并调用putVal()
    return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n, i;
    // 校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
    // (n-1) & hash 【注1】
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果 table[i] 等于 null,则直接插入
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e;
        K k;
        // 注意p在之前已经被赋值:p = tab[i = (n - 1) & hash]
        // 判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
        else if (p instanceof TreeNode)
            // 红黑树直接插入键值对
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
            for (int binCount = 0; ; ++binCount) {
                // 如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
                if ((e = p.next) == null) {
                    //把添加的键值对添加为链表下一个元素
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度大于等于8,调用treeifyBin方法将链表节点转为红黑树节点
                    //treeifyBin方法中会判断数组长度是否<64,小于会扩容
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //  如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
        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;
}
  • 解析:
    1. 【注1】(n-1) & hash
      • 作用:得到数组下标
      • 按位取并,作用上相当于取模%(mod)
      • 例:一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。同时也意味着数组下标相同,并不表示hashCode相同。
      • 这里为什么使用&而不是mod或其他?
        答:1)因为容量被固定为2的n次幂,故这样取模时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。2)同时在扩容容量变为2倍时,一样使用&运算结果判断数组下标位置,大大提高效率
  1. 获取数据 get(key)
//jdk8
public V get(Object key) {
    Node<K,V> e;
    // 对 key 进行哈希操作然后获取节点值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 对table进行校验:table不为空 && table长度大于0 && 
    // table索引位置(使用table.length - 1和hash值进行位与运算)的节点不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 判断第一个元素是否是要查询的元素,如果是则first即为目标节点,直接返回first节点
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 如果first不是目标节点,并且first的next节点不为空则继续遍历
        if ((e = first.next) != null) {
            // 如果第一节点是树结构,则使用 getTreeNode 直接获取相应的数据
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do { // 非树结构,循环节点判断
                // hash 相等并且 key 相同,则返回此节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 找不到符合的返回空
    return null;
}
  1. 容量扩容 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;
    // 老表(数组)的容量不为0,即老表不为空
    if (oldCap > 0) {
        // 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
        // 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //  将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
    else if (oldThr > 0)
        newCap = oldThr;
    else {
        // 老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
        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);
    }
    // 将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //开始扩容,将新的容量赋值给 table
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 如果老表不为空,则需遍历所有节点,将节点赋值给新表
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {  // 将索引值为j的老表头节点赋值给e
                oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
                // 如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
                else if (e instanceof TreeNode)
                    // 红黑树相关的操作
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    // 链表复制,JDK1.8扩容优化部分
                    // 如果是普通的链表节点,则进行普通的重hash分布
                    // loHead是用来保存新链表上的头元素的,loTail是用来保存尾元素的
                    Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
                    Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
                        //【注2】e.hash & oldCap
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
                                loHead = e; // 则将loHead赋值为第一个节点
                            else
                                loTail.next = e;    // 否则将节点添加在loTail后面
                            loTail = e; // 并将loTail赋值为新增的节点
                        }
                        // 如果e的hash值与老表的容量进行与运算为非0,则扩容后的索引位置为:老表的索引位置+oldCap
                        else {
                            if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
                                hiHead = e; // 则将hiHead赋值为第一个节点
                            else
                                hiTail.next = e;    // 否则将节点添加在hiTail后面
                            hiTail = e; // 并将hiTail赋值为新增的节点
                        }
                    } while ((e = next) != null);
                    // 如果loTail不为空(说明老表的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
                    // 的next设为空,并将新表上索引位置为“原索引位置”的节点设置为对应的头节点
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 如果hiTail不为空(说明老表的数据有分布到新表上“原索引+oldCap位置”的节点),则将最后
                    // 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 12.返回新表
    return newTab;
}
  • 解析
    1. 【注2】e.hash & oldCap
      从注【1】我们知道使用哈希值和数组长度-1做与(位)运算得到数组下标。那么这里的用(e.hash & oldCap)是什么作用呢?。因为数组长度扩容(*2)后,新的下标位置有两种可能:原位置;原位置+原数组长度。而判断e.hash & oldCap与(&)运算结果就可以得到其属于哪种情况。
      例:原数组长度16(0001 0000),扩容后变32(0010 0000)。假设有两个哈希值10(0000 1010)和26(0101 1010),当两个分别对原数组长度-1=15(0000 1111)做&运算时,结果都为0000 1010。当对原数组长度16做&运算时,结果分别为0000 0000 和 0001 0000,即为0和不为0。所以当扩容后,相当于10是原位置情况,26则变为原位置+原数组长度的情况
      附:&运算:两个位都为1时,结果才为1
常见问题
  1. 什么是加载因子?加载因子为什么是 0.75?

    加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。
    那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?
    这其实是出于容量和性能之间平衡的结果:
    当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
    而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
    为了提升扩容效率,HashMap的容量(capacity)有一个固定的要求,那就是一定是2的幂。所以,如果负载因子是3/4的话,那么和capacity的乘积结果就可以是一个整数。
    所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。

  2. 如果new HashMap(19),bucket数组多大?
    HashMap的bucket 数组大小一定是2的幂,如果new的时候指定了容量且不是2的幂,实际容量会是最接近(大于)指定容量的2的幂,比如 new HashMap<>(19),比19大且最接近的2的幂是32,实际容量就是32。

参考博文:

  1. HashMap实现原理分析
  2. 为什么面试要问hashmap 的原理
  3. 21个刁钻的HashMap面试题
  4. 史上最详细的 JDK 1.8 HashMap 源码解析
  5. Java学习(十二):HashMap的原理和特性
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值