HashMap存取原理之JDK8
前言
哈希表(hash table)也叫散列表,是一种非常重要的数据结构
应用场景之一:缓存技术(比如memcached的核心其实就是在内存中维护一张大的哈希表)
目录
一、哈希表
数据结构:
1、数组
用一段连续的存储单元来存储数据。
知道下标进行查找,时间复杂度为O(1)。
知道value值进行查找,时间复杂度为O(n),因为需要遍历数组,逐一比对给定关键字和数组元素。
对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn)。
插入、删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)。
2、线性链表
新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1)。
查找操作需要遍历链表逐一进行比对,复杂度为O(n)。
3、二叉树
一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
4、哈希表
不考虑哈希冲突的情况下,添加,删除,查找等操作,仅需一次定位即可完成,时间复杂度为O(1)。
数据结构的物理存储结构:
1、顺序存储结构
2、 链式存储结构
哈希表:
哈希表的主干就是数组。利用了数组的特性----根据下标查找某个元素一次定位就可以找到。
在新增或查找某个元素时,我们通过把当前元素的关键字传给哈希函数,然后映射到数组中的某个位置,最后通过数组下标一次定位就可完成操作。
存储位置 = f(关键字)
这个函数的设计好坏会直接影响到哈希表的优劣。
插入、查找操作,如图:
哈希冲突:
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?
好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。
哈希冲突的解决方案:
1、开放定址法
2、再散列函数法
3、链地址法
HashMap即是采用了链地址法,也就是数组+链表的方式。
二、HashMap实现原理
JDK 8 中,HashMap的主干是一个Node数组。
//该table在第一次使用时初始化,并在必要时进行调整。当分配时,长度总是2的幂。
transient Node<K,V>[] table;
Node是HashMap中的一个静态内部类
//HashMap.Node是LinkedHashMap.Entry的父类
//LinkedHashMap.Entry是HashMap.TreeNode的父类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
final K key;
V value;
Node<K,V> next;//存储指向下一个Node的引用,单链表结构
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
HashMap的整体结构如下:
HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前Node的next为null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组位置包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
HashMap的几个重要属性
//实际存储的key-value键值对的个数
transient int size;
//阈值;
//当table分配内存空间后,threshold一般为 capacity*loadFactory
//HashMap在进行扩容时需要参考threshold
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75,超过了负载,就开始扩容
final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
HashMap有4个构造器,如果用户没有给构造器传入initialCapacity 和loadFactor这两个参数,会使用默认值 initialCapacity默认为16,loadFactory默认为0.75。
存
在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组。
map.put("2","ljs");
public V put(K key, V value) {
return putVal(hash("2"), "2", "ljs", false, true);
}
hash("2")
static final int hash(Object key) {
int h;
//key.hashCode()该对象自己的hashcode
//HashMap的哈希函数:(hashcode) ^ (hashcode >>> 16)
//hashcode 与 向右无符号移动16位的自己 异或,一般都等于hashcode的值
// >>> 与 >> 都是右移,>>> 是会把符号位也一起移动,就是说负数用 >>> 后,会成为正数
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal(hash("2"), "2", "ljs", false, true);
/**
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
*/
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数组为空数组{},为table分配实际内存空间;----resize()
//在构造器中没有指定threshold的话,就是默认的threshold,16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash key的哈希值 和 数组长度做 与运算,计算出在table数组中的具体下标位置
//该位置没有数据,就直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//该位置有数据,遍历该数组下标的单链表
//找到hash、key相同的,执行覆盖操作。用新value替换旧value,并返回旧value
//没有hash、key相同的,插入到链表尾部
else {
Node<K,V> e; K k;
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);
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))))
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;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
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;
//不是第一次resize(),扩容----Threshold * 2
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;
//第一次resize(),为table分配内存空间,newCap = 16 ; newThreshold = 0.75*16 = 12
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
通过以上代码能够得知,当size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Node数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。
存储位置的确定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最终索引位置,找到对应位置table[i]。
取
map.get("2")
public V get("2") {
Node<K,V> e;
return (e = getNode(hash("2"), "2")) == null ? null : e.value;
}
hash("2")
static final int hash("2") {
int h;
return ("2" == null) ? 0 : (h = "2".hashCode()) ^ (h >>> 16);
}
getNode(hash("2"), "2")
final Node<K,V> getNode(50, "2") {
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;
}
取值位置的确定流程:
key.hashcode()-->hash()-->(length - 1) & hash-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。
注:存数据需要hashcode(),取数据需要equals();hashcode()、equals()是Object的方法,可以按照自己的需求,重写对象的hashcode() 和 equals() 方法。
三、为何HashMap的数组长度一定是2的次幂?
数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index:
将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash函数运算后,再通过和 length-1进行与运算。
1、保证得到的新的数组索引和老数组索引一致
16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h & (length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。
2、获得的数组索引index更加均匀
数组长度保持2的次幂,length-1的低位都为1
3、唯一性
&运算,高位是不会对结果产生影响的,所以只关注低位,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了