HashMap知识(源码)

Posted 夜尽天明89

tags:

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

hashMap普通功能使用并不复杂,如:(Kotlin写法)

var hMap: HashMap<Int?, String> = hashMapOf()
hMap.put(1, "a")
//或 hMap[1] = "a"

注意:我们在使用 HashMap 时,最好选择不可变对象作为 key。如:String、Integer 等不可变类型作为 key。不要使用可变对象作为key
参考

HashMap存储数据是非有序的,且是非线程安全的。如果需要线程安全,去看下ConcurrentHashMap

HashMap的底层是 数组+链表+红黑树(JDK1.8 增加了红黑树部分)。HashMap增删改查等常规操作,都有不错的执行效率,是ArrayList和LinkedList等数据结构的一种折中实现。创建一个HashMap,如果没有指定初始大小,默认底层hash表数组的大小为16。

其实,HashMap的底层hash表数组的大小,都是2的幂。这是因为,要进行hash运行,得到hash值时,有位运算(位运算的效率高)

HashMap的核心元素有:
1、size:用于记录 HashMap 实际存储元素的个数;
2、loadFactor:负载因子。默认 0.75;
3、threshold:扩容的阈值,达到阈值便会触发扩容机制 resize;
4、Node<K,V>[] table; 底层数组,充当哈希表的作用,用于存储对应 hash
位置的元素 Node<K,V>,此数组长度总是 2 的 N 次幂
5、Node<K,V>:元素节点,单链表结构:

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;
	
	......
	忽略其他
	......

基本理论概念,就先说到这里了。下面,开始一些问题、源码的说明。

1、在创建HashMap的时候,有构造函数,可以自由设置容量(及扩展因子)。上面说了,底层数组(充当hash表)的长度,一定是2的N次幂。但是我们随便设置容量的时候,是没有报错的,为什么会这样。我们随便传进去的容量值,hashMap做了什么处理么?

示例代码:

var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)
hMap.put(1, "a")

看下源码:

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

传进去的容量值,最后通过方法tableSizeFor,变成了阈值 threshold

/**
* Returns a power of two size for the given target capacity.
* 返回给定目标容量的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;

把这个 tableSizeFor 方法复制出来,跑一些测试数据:

Log.e("tableSizeFor = 1 =", "$MyUtils.tableSizeFor(1)")
Log.e("tableSizeFor = 3 =", "$MyUtils.tableSizeFor(2)")
Log.e("tableSizeFor = 5 =", "$MyUtils.tableSizeFor(3)")
Log.e("tableSizeFor = 12 =", "$MyUtils.tableSizeFor(12)")
Log.e("tableSizeFor = 13 =", "$MyUtils.tableSizeFor(13)")
Log.e("tableSizeFor = 16 =", "$MyUtils.tableSizeFor(16)")
Log.e("tableSizeFor = 17 =", "$MyUtils.tableSizeFor(17)")

tableSizeFor = 1 =: 1
tableSizeFor = 3 =: 2
tableSizeFor = 5 =: 4
tableSizeFor = 12 =: 16
tableSizeFor = 13 =: 16
tableSizeFor = 16 =: 16
tableSizeFor = 17 =: 32

结论:创建HashMap时,随便传进去的容量值,HashMap会进行对应的转换,即便不是2的N次幂,也会变成2的N次幂对应的值

至此,初始化

var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)

这句话,我们看完了。
接下来,看第二句:

hMap.put(1, "a")

put下的源码:

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) // 1
		n = (tab = resize()).length;
	......
	这里是讲解初始化方法,其他代码先忽略,后面讲增删改查,会详细说明
	......


因为最开始初始化的时候,没有创建数组,所以,1 那里的判断条件,是true,会走 resize()

resize方法源码: 现在是初始化的情况。第一次走到这个方法里

final Node<K,V>[] resize() 
        Node<K,V>[] oldTab = table; //初始化,table是null,所以,oldTab=null
        int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldCap=0
        int oldThr = threshold; //传进来12,经过处理,已经变成了16
        int newCap, newThr = 0;
        if (oldCap > 0)  // 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) // oldThr=16,会走到这里
            newCap = oldThr; // newCap = 16
        else                // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        
        if (newThr == 0)  //上面没对newThr处理,所有,它是0
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        
        threshold = newThr; //经过上面的计算,newThr = 容量值*扩展因子
        @SuppressWarnings("rawtypes","unchecked")
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
       ......
     	忽略
       ......
        return newTab;
    

上面的代码中,我加了注释。

结论就是:
1、如果用 var hMap: HashMap<Int?, String> = hashMapOf() 创建,容量是默认的16,扩展因子是默认的 0.75,扩展阈值是 16 * 0.75 = 12;
2、如果用 var hMap: HashMap<Int?, String> = HashMap(12, 0.5f) 创建,容量是16(传进来的容量,会被转化为2的N次幂),扩展因子是 0.5f,扩展阈值,是 16 * 0.5 = 8

===============================
这里详细说下增(put)、查方法(get)。删除(remove)、替换(replace)方法都并不复杂,其中,remove方法中,注意下链表的节点变换即可。至于替换,我个人用的不多,因为put方法中有覆盖值的功能,我更多的,是用put(没有就存,有就覆盖)

增:put
源码:

    public V put(K key, V value) 
        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;
        if ((tab = table) == null || (n = tab.length) == 0)  // ==1
            n = (tab = resize()).length;
            
        if ((p = tab[i = (n - 1) & hash]) == null)  // ==2
            tab[i] = newNode(hash, key, value, null);
        else 
        	 // ==3
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))  // ==4
                e = p;
            else if (p instanceof TreeNode)  // ==5
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else 
            	 // ==6
                for (int binCount = 0; ; ++binCount) 
                    if ((e = p.next) == null)   // ==7
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))  // ==8
                        break;
                    p = e;
                
            
            if (e != null)  // existing mapping for key   // ==9
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            
        
        ++modCount;
        if (++size > threshold)  // ==10
            resize();
        afterNodeInsertion(evict);
        return null;
    

注意源码中添加是注释 1-10。下面,会对应做说明
源码说明:
1、判断底层数组(hash表)是否为空,如果为空,就走 resize方法,里面会创建;
2、根据插入的键值 key 的 hash 值,通过 (n-1) & hash (hash表长度-1 & 当前元素的hash值),计算出存储位置 table[i](一个节点)。如果这个存储位置没有元素存放,则将新增节点存储在此位置 table[i]
3、走到这里,说明key算出来的hash位置上,已经有值了。
4、要存储的位置有值,且这个值的key及key对应的hash值,和当前传进来的操作元素一致,就保存下来,做保存操作;
5、当前存储位置即有元素,有不和当前操作元素一致,则证明此位置 table[i] 已经发生了hash冲突。判断头节点是否是 treeNode,如果是,则证明此位置的结构是红黑树,以红黑树的方式新增节点。
6、当前存储位置即有元素,有不和当前操作元素一致,则证明此位置 table[i] 已经发生了hash冲突。且,当前位置的结构,不是红黑树,是一个普通的单链表。
7、链表中不存在操作元素,将新元素结点放置此链表的最后一位, 然后判断链接个数,满足一定条件,就去进行“树”相关操作
8、如果链表中已经存在对应的 key,则覆盖 value
9、 已存在对应 key,如果允许修改,则修改 value 为新值
10、当前HashMap中,个数是否大于等于阈值,如果满足条件,就扩容。

注意:
put的源码中 ,有这么一句

if (binCount >= TREEIFY_THRESHOLD - 1) 
	treeifyBin(tab, hash);

翻译过来就是:链表中的节点个数大于等于8,就走“树”的方法。
再去 treeifyBin 看下

MIN_TREEIFY_CAPACITY = 64;

final void treeifyBin(Node<K,V>[] tab, int hash) 
	int n, index; Node<K,V> e;
	if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
		resize();
	else if (...)

	......
	......

总结下:这个“树”相关的方法中,先进行判断,如果满足一定条件(数组长度小于一定范围),优先进行扩容。

也就是说,扩容有2个情况会触发:
1、HashMap中,元素个数大于阈值;
2、链表中元素个数大于等于8,且底层数组长度小于64

这就奇怪了,链表的时间复杂度是O(n),红黑树的时间复杂度O(logn),很显然,红黑树的复杂度是优于链表的。为什么,在调用“树”相关方法的时候,还是要优先去扩容呢?为什么不直接用树去替代链表呢?

继续翻看源码。在一段注释说明中找到了如下信息:

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.  Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

翻译:
因为树形节点大约是常规节点的两倍大,所以我们
只有当箱子包含足够的节点时才使用它们
(见TREEIFY_THRESHOLD)。当它们变得太小(由于
移除或调整大小)它们被转换回普通的箱子。在
使用分布良好的用户哈希码,树箱是
很少使用。理想情况下,在随机哈希码下,的频率
bin中的节点遵循泊松分布
(http://en.wikipedia.org/wiki/Poisson_distribution)
参数大约0.5的平均默认大小
阈值为0.75,尽管由于
调整粒度。忽略方差,期望
列表大小k的出现次数为(exp(-0.5) * pow(0.5, k) /
factorial (k))。第一个值是:

**忽略部分数字
8:    0.00000006 (千万分之6)
更多:不到千万分之一

源码中说的很清楚了,树,需要的节点更多,占的空间较大,需要有足够的空间下,才去使用。典型的空间换性能。在性能差异不大的情况下,当然是使用空间越少越好了。

链表中元素到达8个的情况非常少,如果真到了那个时候,说明表已经“不堪重负”了,性能上会有影响,在那种特殊情况下,不得已,用空间换取性能,即:把链表换成红黑树


查:get

hMap.get(1)

对应源码

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

查的代码很好理解了:
1、先调用 hash(key)方法计算出 key 的 hash 值
2、判断hash数组是否是空,或者存储位置是否有值,如果为空或者没有值,就返回null
3、不为空,且有值的时候,就去判断存储位置链表的头结点,如果头结点是符合条件的,就返回头结点。
4、如果头结点不是符合要求的,就进行后续的判断:
4.1:是否是红黑树,如果是,就走红黑树逻辑;
4.2:不是红黑树,就是单链表形式,注意,有个 do…while,就是去“遍历”。如果找到,就返回,如果没有找到,就返回 null

以上是关于HashMap知识(源码)的主要内容,如果未能解决你的问题,请参考以下文章

HashMap源码剖析

HashMap源码剖析

JDK源码解析---HashMap源码解析

HashMap和Hashtable的区别 源码分析

HashMap源码学习

[JavaSE 源码分析] 关于HashMap的个人理解