Java|HashMap源码解读

1 相关知识

1.1 Hash

任意长度的输入通过一种算法(Hash),变成固定长度的输出,这个输出值就是散列值,属于压缩映射,容易产生Hash冲突

解决冲突的方法有:
1. 开放寻址
2. 再散列
3. 链地址法

md4,md5,sha其实都是Hash算法/摘要算法/指纹算法,而并非加密算法。

1.2 位运算

<< 有符号左移 >> 有符号右移
<<< 无符号左移 >>> 无符号右移

a << n 等价于 a * 2 ^ n

a % (2 ^ n) 等价于 a & (2 ^ n-1)

位运算在权限控制设置属性(eg.商品属性非常多时)有广泛的应用。
一个int可以保存32个属性。

2 HashMap

2.1 属性

	// 缺省初始化容量,必须是2的幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 即 16

    // 最大容量,为什么是1 << 30后面说
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 缺省装载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 由链表转为红黑树的阈值
    static final int TREEIFY_THRESHOLD = 8;

    // 由红黑树转为链表的阈值
    static final int UNTREEIFY_THRESHOLD = 6;
    
     // 结构转化为红黑树对应的数组的最小大小,如果当前容量小于它,就不会将链表转化为红黑树,而是用resize()代替
     // 树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然后为了避免(resizing 和 treeification thresholds) 设置成64
    static final int MIN_TREEIFY_CAPACITY = 64;

     // 存储数据的数组, 大小为2的幂
     // 第一次使用时被初始化
    transient Node<K,V>[] table;

    // 存储数据的数组, 大小为2的幂
    transient Set<Map.Entry<K,V>> entrySet;

    // 键值对的数量(注意这个不等于数组长度)
    transient int size;

     // HashMap 发生结构化改变的次数。
     // 结构化改变包括改变键值对数量或rehash
     // 用于fail-fast机制
    transient int modCount;

    // 临界值 当实际大小(容量*装填因子)超过临界值时,会进行扩容
    int threshold;

    // 哈希表的装填因子
    final float loadFactor;

装填因子

  • 表示Hash表中元素的填满的程度。a=n/m 其中 n 为关键字个数,m 为表长。
  • 装填因子越大,填满的元素越多,空间利用率高,但冲突的机会加大;反之,装填因子越小,填满的元素越少,冲突的机会减小,但空间浪费多。冲突的机会越大,则查找的成本越高;反之,查找的成本越小,因而查找时间就越少。

在设置其初始容量时,应考虑Map的预期条目数及其装填因子,以便最小化扩容成本。 如果初始容量大于最大条目数除以负载因子,则不会发生重新排列操作。

最大容量为什么是1 << 30

  1. HashMap内部由Node[]数组构成
  2. 规定了数组的长度必须为2的幂
  3. Java的数组下标是由int(32位,-2^31 —— 2^31-1)表示

所以对于HashMap来说其最大的容量应该是不超过int最大值的一个2的指数幂,而最接近int最大值的2的指数幂用位运算符表示就是 1 << 30。

为什么不是1 << 31?
int 的最高位是符号位,1 << 31 就变成负数了。

为什么转红黑树的阈值是8?

2.2 结构

HashMap主要是通过hashCode类似计算hash值的。只要hashCode相同,计算出的hash值就一样。当出现hash冲突时,HashMap通过拉链法解决hash冲突。

HashMap的底层是一个存储Node<K,V>的数组,数组中的元素是单链表
在这里插入图片描述
JDK1.8考虑到链表过长时,查找性能很差(O(n)),所以在链表结点数量不小于8时,把该链会被调整为一颗红黑树
在这里插入图片描述
Map.Entry的实现类Node作为Map的嵌套内部类。其是一个单链表(具有next项)。

可以看到重写了hashCode()和equals(Object)方法,其中equals(Object)方法判断两者的key和value如果都相同,则为true。hashCode()方法对key和value做异或。能够保证equals(Object)相等则两者hashCode()相等。

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

树结点,还没仔细看,里面的相关方法太多了。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
}

2.3 构造方法

    // 指定初始容量和装填因子,构造一个空的HashMap
    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;
        // tableSizeFor方法计算临界值,put数据的时候如果超出该值就会扩容
		// 指定的初始容量没有保存下来,只用来生成了一个临界值
		// tableSizeFor()方法保证总是返回大于cap并且是2的幂的值,比如传入999 返回1024
        this.threshold = tableSizeFor(initialCapacity);
    }
    
	public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    // 使用与指定Map相同的映射构造一个新的HashMap。 使用默认加载因子(0.75)创建HashMap,初始容量足以保存指定Map中的映射。
   	// @param是要将映射放在此映射中的映射
  	// @throws NullPointerException如果指定的映射为null
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

Float.isNaN()
Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是java语言中的异常,请勿与IEEE 754规范中的浮点异常相互混淆,IEEE 754的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示,所有使用NaN值作为操作数的算术操作,结果都返回NaN。Java中的Double和Float中都有isNaN函数,判断一个数是不是NaN,其实现都是通过上述 v != v 的方式,因为NaN是唯一与自己不相等的值,NaN与任何值都不相等。

在构造方法HashMap(int initialCapacity, float loadFactor)中:

  • 用户指定的装填因子 loadFactor 被保存到 HashMap 的成员变量loadFactor 中
  • 而用户指定的初始化容量 initialCapacity 则没有被保存下来。
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);

tableSizeFor()

initialCapacity被传入函数 tableSizeFor()中,返回一个大于输入参数且最近的2的整数次幂的数。比如10,则返回16。该算法源码如下:

     // 返回大于cap的且离cap最近的 2的幂次方
    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;
    }

先来分析有关n位操作部分:先来假设n的二进制为01xxx…xxx。接着
对n右移1位:001xx…xxx,再位或:011xx…xxx
对n右移2为:00011…xxx,再位或:01111…xxx
此时前面已经有四个1了,再右移4位且位或可得8个1
同理,有8个1,右移8位肯定会让后八位也为1。
综上可得,该算法让最高位的1后面的位全变为1。
最后再让结果n+1,即得到了2的整数次幂的值了。

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

int n = cap - 1;

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

知道了tableSizeFor()到底做了什么事情,我们看看为什么这个返回值赋给了threshold。

根据前面的属性介绍,threshold它是一个临界值,当实际值超过这个临界值就会扩容。
这一点在扩容部分讲。

总的来说:

  • HashMap(int initialCapacity, float loadFactor)是核心的构造方法,除了HashMap(Map<? extends K, ? extends V> m),其他构造方法都调用这个构造方法。
  • HashMap 的构造方法,提供给用户可调整的参数是 负载因子loadFactor 和 初始化容量initialCapacity。
  • 提供负载因子使得用户可以根据需要控制HashMap数组的满的情况。
  • 而提供初始化容量是为了方便用户在已知元素大致数量的时候,减少扩容。
    关于这一点,可参考文章:https://www.hollischuang.com/archives/3545
    本文后面也会介绍扩容的相关内容。

2.4 put()方法

  1. 如果table还没初始化,先初始化它
  2. 根据(n - 1) & hash计算key应存放的位置
  3. 如果该位置没有元素就直接放进去
  4. 如果该位置有元素,如果他们的hash和key都相等,说明是修改操作
  5. 否则根据是此链是树还是链表进行不同的处理
  6. 判断是否需要扩容
public V put(K key, V value) {
        // 取关键字key的哈希值
        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 还未被初始化,则初始化它
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //根据(n - 1) & hash找到该键对应到数组中存储的索引
    	//如果为 null,那么说明此索引位置并没有被占用
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else { // 如果哈希表当前位置上已经有节点的话,说明有hash冲突
            Node<K,V> e; 
            K k;
            //当前结点和将要插入的结点的 hash 和 key 相同,说明这是一次修改操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            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) {// 如果为空,构造链表上的新节点
                        p.next = newNode(hash, key, value, null);
                        //如果插入后链表长度大于等于 8 ,将链表裂变成红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //遍历的过程中,如果发现与某个结点的 hash和key,这依然是一次修改操作 
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 如果找到了节点,说明关键字相同,进行覆盖操作,直接返回旧的关键字的值
            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;
    }

put()方法比较复杂,因为其包含的内容比较多:

  • table的初始化在HashMap的构造函数里没有做,而是放在了第一次插入元素时做
  • 有两种链需要处理:链表和红黑树结构。
  • 需要确定是修改操作还是插入操作。
  • 需要确定是否扩容。

为什么JDK1.8中插入新结点用的尾插法,JDK1.8以前用的头插法?
头插法是操作速度最快的,找到数组位置就直接找到插入位置了,但是jdk8之前hashmap这种插入方法在并发场景下如果多个线程同时扩容会出现循环列表。jdk8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长度不断增加时,遍历一次的次数就会少很多很多(否则每次要遍历所有),相比头插法而言,尾插法操作额外的遍历消耗已经小很多了,也可以避免之前的循环列表问题。(同时如果变成红黑树,也不可能做头插法了)

2.5 get()方法

  1. 对key的hashCode()做hash,然后再计算index;
  2. 如果该 index 下没有值,则直接返回 null;
  3. index 下有值,判断 table 数组中该下标处第一个节点是否命中,命中则直接返回;
  4. 未命中,则判断去树结构中查找,或者去链表结构中查找,找到则返回,,否则返回null。
 // 找不到则返回null
 // 否则返回对应值
 public V get(Object key) {
        Node<K,V> e;
        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长度大于0 && key对应的桶不为空
        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;
    }

2.6 扩容机制 resize()

HashMap 外层是一个数组,数组容量是一定的,当需要装入更多元素时,就要对其进行扩容了。所谓扩容就是申请一个更大的空间,把原来空间的元素移入进去。可以看到,这个操作是非常耗时的。

所以我们在使用这样需要动态扩容的集合时,应该注意,在能够预知容量的情况下,尽量使用该容量进行初始化集合。

跑偏了,哈哈~根据上面介绍的扩容,自然而然地我们会思考如下几个问题:

  1. 什么时候需要扩容?有没有一个具体的值限定呢?
  2. 如果能够装的元素数量已经达到极限了怎么办?
  3. 具体怎么扩容?扩到多大?
  4. 万一后面把里面的元素给删掉了,会需要缩减容量吗?

好滴,带着这些问题我们来一探究竟。

先不写了。歇会。

threshold

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    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;
            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;
        if (oldTab != null) {
            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);
                    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;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

相关面试题

HashMap在多线程情况下为什么不安全?
HashMap在多线程环境下put操作会引起死循环,因为使得Entry链表产生环形的数据结构。

HashMap的实现原理?
数组+链表+红黑树。

HashMap什么时候会进行rehash?
HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。

那HashMap的初始容量设置成多少比较合适呢?
结合源码说说HashMap在高并发场景中为什么会出现死循环?
JDK1.8中对HashMap做了哪些性能优化?

HashMap和HashTable有何不同?

  • Hashtable 是一个线程安全的 Map 实现,HashMap 是线程不安全的实现,HashMap 比 Hashtable 性能高
  • Hashtable 不允许使用 null 作为 key 和 value,如果试图把 null 值放进 Hashtable 中,将会引发 NullPointerException 异常;但 HashMap 可以使用 null 作为 key 或 value。
    Hashtable 是从JDK1.0就开始有的古老的类,一般不推荐使用,即使需要线程安全的场景,也有其他方式可以使用,而不使用 Hashtable。

参考文章:

Float.isNaN(): https://www.cnblogs.com/big-xuyue/p/4106130.html
https://www.jb51.net/article/80443.htm
tableSizeFor():https://www.cnblogs.com/loading4/p/6239441.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值