什么是哈希表?
什么是哈希表参考博客
在讨论哈希表之前,先大概了解下数组和链表结构在新增、查找操作执行性能
数组:采用一段连续的存储单元来存储数据,用于储存多个相同类型数据的集合
- 指定下标查找,时间复杂度为O(1)
- 指定值查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n)
- 对于有序数组,指定值查找,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn)
- 对于插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
链表:在内存中通过节点记录内存地址而相互链接形成一条链的储存方式
- 对于链表的新增、删除操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1)
- 查找操作需要遍历链表逐一进行比对,复杂度为O(n)
哈希表:通过数组+链表+红黑树的组成的存储结构
- 相比数组和链表,哈希表中在进行添加、删除、查找等操作时,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)
- 在有哈希冲突的情况下,根据查找操作需要遍历链表逐一对比
时间复杂度解释
- O后面的括号中有一个函数,指明某个算法的耗时/耗空间与数据增长量之间的关系,其中的n代表输入数据的量。
- O(n)代表数据量增大几倍,耗时也增大几倍,比如常见的遍历算法
- O(n^2),代表数据量增大n倍时,耗时增大n的平方倍,比如冒泡排序
- O(logn),当数据增大n倍时,耗时增大logn倍
- 这里的log是以2为底的,比如,当数据增大256倍时,耗时只增大8倍,是比线性还要低的时间复杂度
- 二分查找就是O(logn)的算法,每找一次排除一半的可能,256个数据中查找只要找8次就可以找到目标
- O(nlogn)同理,就是n乘以logn,当数据增大256倍时,耗时增大256*8=2048倍。这个复杂度高于线性低于平方。归并排序就是O(nlogn)的时间复杂度。
- O(1)就是最低的时空复杂度了,也就是耗时/耗空间与输入数据大小无关,无论输入数据增大多少倍,耗时/耗空间都不变。 哈希算法就是典型的O(1)时间复杂度,无论数据规模多大,都可以在一次计算后找到目标(不考虑冲突的话)
HashMap底层结构
HashMap在jdk1.8之后是数组+链表+红黑树组成的数据结构(在jdk1.8之前是数组+链表)
数组结构: HashMap里面定义了一个内部类Node(jdk1.8之前叫做Entry,jdk1.8之后叫做Node),这个Node就是HashMap中数组结构的数据类型,意思就是说我们的key和value是存储在HashMap里的内部类Node中,而Node就存储在HashMap里的数组结构中,先看看Node类的源码,主要看里面定义的属性
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
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;
}
}
可以看到Node类里不仅定义了Key和Value,它里面还定义了一个属性hash,还有指定下一个Node类的节点,所以Node节点实现了链表机构,每一个Node节点都是一个链表,当链表长度大于8且数组长度大于64的时候会转化为红黑树,转化为红黑树之后,长度小于6之后,又会转换为链表
再看看源码里定义的HashMap里的Node数组,也就是HashMap的数组结构
transient Node<K,V>[] table;
再看看HashMap里的主要参数
- 容量(capacity)
必须是2的幂
//默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量2的3次方
static final int MAXIMUM_CAPACITY = 1 << 30;
- 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
// 实际加载因子,可以在初始化HashMap是自定义
final float loadFactor;
// 默认加载因子 = 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 扩容阈值(threshold):值等于容量 x 加载因子,当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表,进行resize操作
int threshold;
- 数组相关
//Node类型数组,长度 = 2的幂
transient Node<K,V>[] table;
// HashMap的大小,即 HashMap中存储的键值对的数量
transient int size;
- 桶的树化阈值,即当链表长度超过这个值,会将链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
- 红黑树还原为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
- 最小树形化容量阈值:即当哈希表中的容量 大于该值时,才允许将链表转换成红黑树,如果桶内元素达到,而没有哈希表的容量没有达到这个阈值的话,不转换成红黑树,而是直接扩容,
static final int MIN_TREEIFY_CAPACITY = 64;
- 红黑树的实现类
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
//代码太长,省略
}
put方法原理
初始化一个HashMap时,Node数组里的每一个元素的初始值都会Null,所以数据结构图如下:
注: 数组默认长度是16,这里方便演示,只画了8个
假设执行一个put(“a”,“1”)的操作
put方法源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可知当调用put方法时,会调用putVal方式,并且会把key进行hash操作,再把值传入到putVal中
hash源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//首先判断数组是否为空,为空则用resize方法初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**通过i = (n - 1) & hash]计算出数组的下标,如果为空,即不存在hash冲突
则直接newNode创建Node并赋值给tab[i]
从这里也可以看到数组的下标位置还需要(n - 1) & hash]运算*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//判断table[i]的key是否与需要插入的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 {
/**死循环遍历Node类的next节点,直至找出Node的下一个节点为Null的时候打破循环
这里的binCount设计得精妙,可以在元素插入之后,判断链表长度是否大于树化阈值
来决定是否要将当前链表转化成红黑树*/
for (int binCount = 0; ; ++binCount) {
//判断当前节点的下一个Node引用为空,说明链表在这里到头了,则直接插入
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//插入之后判断链表长度是否大于树化阈值,这里就是binCount定义得精妙之处
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//判断要插入的key是否和当前节点的key相等,相等则在这里打破循环,在下面的操作中会覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把e的值赋值给p,下一次遍历继续遍历p,相当于更新遍历了e
p = e;
}
}
//若key相同直接用新值覆盖旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//插入成功后,判断实际存在的键值对数量是否大于最大容量threshold,若是,则需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
假定hash(“a”)的结果是2,那么就会将存储key为“a”,value为“1”的Node存储到数组中,结构图如下:
当插入越来越多的时候,总有可能会发生hash冲突,发生hash冲突之后,会判断当前节点是红黑树还是链表,是链表的话会调用equals方法对比key的值,如果相等就覆盖,如果不相等就会在链表的尾部插入进去,jdk1.8之后采用是尾插法,在jdk1.8之前采用的是头插法,稍后会有解释
假定现在执行put(“b”,2)操作时,发生了hash冲突,现在结构图如下:
继续执行put(“b”,3)操作,因为一之前执行了一个put(“b”,2),所以执行put(“b”,3)会覆盖以前的值,执行之后结构图如下:
流程图:
get方法原理
get方法源码:
public V get(Object key) {
Node<K,V> e;
/**通过getNode方法去查找数据,三目运算符判断是否为null
为null则返回null,不为null,则返回key的value属性值*/
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
同样会对key进行hash运算,再把值传入getNode方法中,返回的值再进行三目运算,如果是null就返回null,否则返回他的值,再看看getNode源码:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断数据不为null的话,在根据hash&数组长度-1的值计算出数组下标
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果当前数组下标的hash值相等,并且equals对比也相等,则直接返回当前下标的值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//判断当前节点的next值是否为空
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;
}
流程图 :
resize方法原理
jdk1.8源码
//两种情况会使用这个方法:1.当前数组容量过小 2.初始化hashmap时,即在put操作时,首先会判断table数组是否为null
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;
}
//若元数组长度增大一倍之后的值仍然小于最大阈值,则扩充为原来的2倍
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);
}
//计算新的resize上限
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"})
//创建新的数组,长度为newCap
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;
/**确认当前为链表结构,循环给取出链表的值并赋值给newTab
*/
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;
}
jdk1.7源码
/**
* 源码分析:resize(2 * table.length)
* 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
*/
void resize(int newCapacity) {
/**将table的值赋值给oldTable, table为旧数组的值
下面都是操作oldTable,即也是操作旧数组*/
Entry[] oldTable = table;
// 保存旧数组长度的值
int oldCapacity = oldTable.length;
// 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 根据新的数组长度值newCapacity,创建新数组newTable
Entry[] newTable = new Entry[newCapacity];
// 将扩容后的新数组,传给transfer方法,在transfer方法里面完成对newTable的重新赋值
transfer(newTable);
// 将新数组newTable,也是resize完成后的数组的值赋值给table,这里即完全完成了hashmap的resize过程
table = newTable;
// 重新设置阈值
threshold = (int)(newCapacity * loadFactor);
}
/**
* 分析1.1:transfer(newTable);
* 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
* 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
*/
void transfer(Entry[] newTable) {
// src引用了旧数组
Entry[] src = table;
// 获取新数组的容量大小
int newCapacity = newTable.length;
// 遍历旧数组,将旧数组上的数据(键值对)转移到新数组中
for (int j = 0; j < src.length; j++) {
// 取得旧数组坐标为j的元素,赋值给e
Entry<K,V> e = src[j];
//判断e不等于null,即开始转移当前Entry数据到新数组newTable
if (e != null) {
// 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
src[j] = null;
//jdk1.7数据结构没有红黑树,所以这里直接循环遍历节点,再保存数据到新数组
do {
/**保存下个结点的到next,在本次循环结束的时候赋值给e
相当于会再给e的下一个节点重新插入到新数组,以此往复*/
Entry<K,V> next = e.next;
// 重新计算每个元素的存储位置
int i = indexFor(e.hash, newCapacity);
/**这里最难理解,这里是标准的头插法实现方式
e.next = newTable[i]是将当前节点e的next属性指向newTable[i],
newTable[i]如果为null,那么当前e就是第一个插入当前数组下标的元素,他不会指向别的的Entry节点
newTable[i]不为null的话,当前e的next属性就是指向当前数组下标的元素,这时链表结构就完成了
接着马上执行newTable[i] = e,即把当前数组下标的引用指向了当前e
那么旧的newTable[i]就不在数组结构里了,会以链表的形式和当前e链接在一起*/
e.next = newTable[i];
newTable[i] = e;
//将刚刚e的next属性赋值给e,下面又会重新对e进行操作
e = next;
//循环遍历链表,直至当前e的为null,即上面的next属性为null,即链表遍历到头了
} while (e != null);
}
}
}
为什么HashMap线程不安全?
- 在jdk1.7中,因为是采用的头插法,多个线程对HashMap进行put操作并且需要扩容时,可能会导致链表成环状态
-
假设A线程、B线程都put成功后的结构图如下,且两个线程都走到了resize方法,A线程走到了newTable[i] = e; 这一步,此时e=Entry3,next=Entry2,A线程cpu时间片耗尽,B线程开始执行,并且直接完成了resize过程
-
B线程完成resize过程后的结构图
-
此时A线程继续执行,这时A线程是以新的数组为原数组进行遍历了,此时e=Entry3,next=Entry2,A线程执行一步后的机构如下
-
相当于Entry3和Entry2换了一个位置,因为是头插法,这时因为在旧数组里面Entry2是只想Entry3的,所以这里又会遍历一遍Entry3,在新数组里面本身Entry3的next就是只想Entry2的,所以在这一步进行遍历后就出现了如下结构图
-
所以这时环状出现了,如果执行get操作时,根据key得到的数组下标是当前数组下标,而且这个key在链表里面不存在的话,就会出现死循环
- jdk1.8中,如果A、B线程同时进行put操作,并且两个线程的key计算出来的数组下标是一样的,而且这个数组下标里没有元素,两个线程同时走到了判断当前数组下标是否有元素的判读,此时A线程停下来了,B线程直接把元素插入进去了,之后A线程继续走的时候,直接就把自己元素插入进入了,会导致了B线程插入的值被覆盖了。
key为null存放到哪?
jdk1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
jdk1.7
if (key == null)
// key为null调用putForNullKey(value)
return putForNullKey(value);
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
所以当key为null的时候,数组下标都是0,再进行操作
HashMap的长度为什么要是2的n次方
计算数组下标的方法是:hash&(length-1),实际上和取模运算hash%length的结果是一样,这里使用&运算是因为计算机底层本身就是二进制,所以使用&运算的快一些,前提也就是长度需要为2的n次方,另外也是为了减少hash冲突
转载:https://blog.csdn.net/weixin_44906271/article/details/105857040