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知识(源码)的主要内容,如果未能解决你的问题,请参考以下文章