注:本文只是简单的对hashMap部分常用源码解析,并未深入算法等方面
HashMap数据结构
java7:数组+链表(头插法)
java8:数组+链表+红黑树(尾插法)
加入红黑树是为了提升在 hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)
HashMap基本字段属性
// 默认初始化容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表节点转换红黑树节点的阈值, 9个节点转
static final int TREEIFY_THRESHOLD = 8;
// 红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;
// 负载因子
final float loadFactor;
// HashMap的链表数组
transient Node<K,V>[] table;
// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ... ...
}
// 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
// ...
}
HashMap初始化
//无参数创建HashMap
Map<String,Object> map = new HashMap<>();
//HashMap无参数构造器
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//创建HashMap,传入初始化容量大小
Map<String,Object> map = new HashMap<>(12);
public HashMap(int initialCapacity) {
//调用双参数构造器
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//双参数构造器
public HashMap(int initialCapacity, float loadFactor) {
//判断传入的初始化大小是否<0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//判断传入的初始化大小是否>最大容量值MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//判断负载因子是否<=0 || Float.isNaN(loadFactor)非法类型
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//HashMap会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 12,阀值为16.
//未初始化容量值,在第一次put的时候,会进行resize()
//在resize()里,会将threshold赋值给Capacity,等于这里初始化的threshold就是initialCapacity且都是2的N吃饭
this.threshold = tableSizeFor(initialCapacity);
}
//对于 |= 运算符,我们可以看做是 += 列如 a+=1 =====> a = a+1 同理 n |= n >>> 1 ====> n = n | n>>>1
//对于 >>>(无符号右移)运算符
//例如 a >>> b 指的是将 a 向右移动 b 指定的位数,右移后左边空出的位用零来填充,移出右边的位被丢弃.
//假设 n=12 二进制是 0000 1100
//那么 n >>> 1则是 0000 0110
// n | n >>> 1则是 0000 1110
static final int tableSizeFor(int cap) {
//为了处理 cap 本身就是 2 的N次方的情况
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//注意上面n-1后,最后返回n+1
//结合下面的栗子理解为什么tableSizeFor(cap)只会返回2的n次方的值
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
假设cap=13则n = 12其n二进制为 0000 1100
n 0000 1100
n>>>1 0000 0110
n|n>>>1 0000 1110
|| n = n | n>>>1
n 0000 1110
n>>>2 0000 0011
n|n>>>2 0000 1111
|| n = n | n>>>2
n 0000 1111
n>>>3 0000 0001
n|n>>>3 0000 1111
.......................
最后得出n = 0000 1111 = 15 return n+1 就是16
然后我们再用20和60做测试
规律:(栗子中的tableSizeFor(cap)是源码里最终计算出来的n,并非返回结果,返回结果是n+1)
cap cap-1 (cap-1)二进制 (cap-1)最高位1 tableSizeFor(cap)二进制 tableSizeFor(cap)十进制
13 12 0000 1100 4 0000 1111 15(2的4次方-1)
21 20 0001 0100 5 0001 1111 31(2的5次方-1)
61 60 0011 1100 6 0011 1111 63(2的6次方-1)
...........................................................................................
结论:
tableSizeFor(cap)最终会得到1个比cap大的2的n次方 n就是cap的最高位1
同理可证:只要n为2的N次方,其n-1则低位全是1的值.
由此可以得出:
当我们新建 HashMap (无参数)对象时, 只初始化了负载因子loadFactor,第一次插入节点时,才会对 table 进行初始化,将threshold和Capacity赋值为默认值。
当我们新建 HashMap (一个参数:initialCapacity)对象时,第一次插入节点时,才会对 table 进行初始化,将threshold赋值给Capacity,避免不必要的空间浪费。
为什么HashMap 的容量必须是 2 的 N 次方?
计算索引位置的公式为:(n - 1) & hash 【下面会详细解释为什么是这样计算索引下标】,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时任何值跟 n - 1 进行 & 运算会等于其本身,达到了和取模同样的效果,实现了均匀分布。
如果 n 不为 2 的 n 次方时,hash 冲突的概率明显增大。
长度为9(非2 的 n 次方)
3&(9-1)=0 2&(9-1)=0 ,下标都是0,碰撞了;
长度是8(2 的 n 次方)
3&(8-1)=3 2&(8-1)=2 ,下标不一样,不碰撞;
hash冲突:hashMap通过hash算法计算两个key的索引位置相同简称hash冲突,列如put 10个元素,如果hash冲突概率大,就不能实现均匀分布,会导致大多元素都在一个头结点上,查询效率低。
为什么HashMap 的默认初始容量是 16,而不是8或者32?
其实这个只要是2的n次方就行,可能是16的更符合大多数情况,最终取决于实际使用情况。
HashMap如何通过hash算法定位下标
HashMap大致数组结构
HashMap在调用put方法插入的数据时候会根据key的hash去计算一个index值,来确定放在哪个位置。
【hash 算法】求得元素下标index的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。
比如:put(“name”,“大白”);HashMap会通过哈希函数计算出插入的位置(下面会介绍如何计算数组下标index),假设计算出来index是2那结果如下图所示。
table就是HashMap的底层链表数组
对于put任意的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。HashMap底层采用 hash 值对 table 进行位(&)运算来得到数组下标index【tab[(table.length - 1) & hash]
】,这样一来,元素的分布相对来说是比较均匀的。
因为模(%)运算消耗是比较大的,所以采用计算机比较快的运算=>位(&)运算
HashMap如何定位数组下标源码分析
//根据key通过hash算法获取key的hash值
static final int hash(Object key) {
int h;
// 先拿到key的hashCode值; 2.将hashCode的高16位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public V put(K key, V value) {
//将key的hash的值传入putVal里
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)
n = (tab = resize()).length;
// index = (table.length - 1) & hash 获取下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//..........................
}
通过上面源码分析我们能知道HashMap如何定位出元素下标的步骤
- 拿到 key 的 hashCode 值(
int hash = key.hashCode()
) - 将 hashCode 的高位参与运算,重新计算 hash 值(
(h = key.hashCode()) ^ (h >>> 16)
) - 将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
(1):n = tab.length;
//获取hashMap容量大小
(2):i = (n - 1) & hash]
//获取当前下标下的节点
出现hash冲突情况,HashMap就会通过链表来存储数据
由于数组长度有限,put进的对象key的hashcode容易重复,导致最后计算出来的index也可能出现相同的(这就是所谓的hash冲突),那么index相同的就采用链表方式进行存储(如下图)。列如我再put(“Name”,“大白2”) 假设name和Name最终计算出的index都是2
如果是java7结果就是
对于为什么java8之后就采用尾插法而不继续使用java7的头插法是有原因的(大致如下)
在多线程情况下操作HaspMap
1.Java7的HashMap可能会引起死循环,原因是resize()后前后链表顺序倒置,在扩容过程中修改了原来链表中节点的引用关系。
2.Java8的HashMap则不会出现死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。
因为每次HashMap插入元素时,都会进行判断是否要resize(),具体resize()源码详解在后面
如果执行put()时进行了resize(),
由于resize()会将旧的hashMap的所有元素重新hash定位一下,如果就定位和新定位的index相等(说明位置没变),
hashMap会将此链上的元素从头遍历
如果是java7头插法就可能出现
开始放入AB元素,由于头插法链表就是BA排列(扩容前),进行扩容后从旧链表取数,链表取数是从头遍历,重新放入新数组链表后就成了AB
扩容前BA (B.next = A、A.next = null)
扩容后AB (A.next = B、B.next = A 多线程可能出现死循环(如下图)) || (A.next = B、B.next = null 正常)
注:java8的HashMap多线程情况虽然不会出现链表死循环,但是并不能说明HashMap能在多线程中使用不出现问题
因为HashMap的get和put方法都没有synchronized,在多线程环境下容易出现的就是:无法保证上一秒put的值,
下一秒get的时候还是原值,所以线程安全还是无法保证。
HashMap扩容
因为数组长度是有限的,对于HashMap来说,数据多次插入的,到达一定的数量就会进行扩容,也就是会调用resize()方法
什么时候会触发resize()方法呢?
主要就是
1:HashMap当前的容量 Capacity
2:负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;
3:阀值threshold = Capacity * 负载因子
假设当前的HashMap容量大小为100,当你put第76个的时候,就会触发resize()方法
HashMap具体如何扩容如下(源码解析)主要步骤
- 创建一个新的Node空数组,长度是原数组的2倍。
- 遍历原Node数组,把所有的Node重新Hash到新数组。
resize方法源码解析
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //table就是旧HashMap,用oldTab替代
int oldCap = (oldTab == null) ? 0 : oldTab.length; //旧HashMap的容量大小
int oldThr = threshold; //旧HashMap的扩容阀值(超过就触发resize())
int newCap, newThr = 0; //初始化新HashMap的容量和阀值(新HashMap就是扩容后的HashMap)
// 1.判断老HashMap的容量不为0
if (oldCap > 0) {
// 1.1 判断老HashMap的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老HashMap,不能继续扩容
// 1.1 此时oldCap>Integer.MAX_VALUE大,超出容量最大值,因此无法进行重新分布,只是单纯的将阈值扩容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 newCap = oldCap << 1;将旧容量值左移一位=====值*2(这里可以得出结论,每次扩容都是扩大原来的2倍)
// 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 2.老HashMap的容量为0, 且阈值大于0, 是因为初始容量被放入阈值
else if (oldThr > 0)
newCap = oldThr; //将新HashMap的容量0设置为老表的阈值
// 3.老HashMap的容量为0, 且阈值为0,这种情况是没有传初始容量的new方法创建的空表
else {
//将阈值和容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
//阀值 = 容量大小 * 负载因子(解释上面为什么是容量为100,当达到76的时候就会触resize()方法)
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
threshold = newThr;
// 初始化一个新的Node用来扩容
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 6.如果老HashMap不为空,则需遍历所有节点,将节点赋值给新Node
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { // 获取旧HashMap的容量大小,进行循环
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 将老HashMap索引值为j的头节点临时赋值给e
oldTab[j] = null; // 将老HashMap的节点设置为空, 以便垃圾收集器回收空间
// 7.如果e.next为空, 则代表老HashMap的该位置只有1个节点,计算新HashMap的索引位置, 直接将该节点放在该位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 9.如果是普通的链表节点,则进行普通的重hash分布
else {
Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
Node<K,V> next;
do {
next = e.next; //下一节点
// 9.1 如果e的hash值与老HashMap的容量进行与运算为0,则扩容后的索引位置跟老HashMap的索引位置一样
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
}
// 9.2 如果e的hash值与老HashMap的容量进行与运算为1,则扩容后的索引位置为:老HashMap的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e;// 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
}
} while ((e = next) != null);
// 10.如果loTail不为空(说明老HashMap的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
// 的next设为空,并将新HashMap上索引位置为“原索引位置”的节点设置为对应的头节点
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 11.如果hiTail不为空(说明老HashMap的数据有分布到新HashMap上“原索引+oldCap位置”的节点),则将最后
// 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回扩容后的Node
return newTab;
}
对于为什么要将所有的元素通过hash算法重新定位一遍?
是因为长度扩大以后,Hash的规则也随之改变
还记得上文说到数组index是如何通过hash算法得到吗,int index = hash(key) & (table.length - 1)
扩容后table.length
的大小不一样,得到的index的结果也不一样。
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) {
// tab 可以看做 调用此方法的HashMap(原因:下面判断语句里将tab = table)
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断tab是否为空或者length等于0,否则调用resize方法进行初始化tab
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 判断i下标下的节点是否存在值(存在沿着该节点链表继续查找,不存在就索引位置新增一个节点(目标节点赋值进去)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 判断当前下标位置的key和hash是否和目标的key和对应的hash(key)相等
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
//hash和key都相等,说明HashMap的keyName相等,p节点就是目标节点(将目标节点赋值给e节点)
e = p;
// 判断p节点是否为TreeNode(红黑树结构链表)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 走到这代表目标节点在普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 判断p节点的下一节点是否为null
// null:新增一个节点并插入(尾插法)
// not null: 进行下面的if判断
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 判断节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点
// TREEIFY_THRESHOLD-1是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 判断p的下一节点e的hash和key是否和put进来的hash(key)和key相等
// 相等:e就是目标节点,跳出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
// 将e赋值给p(为了循环获取e的下一个节点)
p = e;
}
}
// 如果e节点不为空,则代表keyname相同,只需要将newValue覆盖oldValue,并返回oldValue
if (e != null) {
V oldValue = e.value;
// put(K key, V value) 调用 putVal(hash(key), key, value, false, true);
// 可以看出!onlyIfAbsent 为 true
if (!onlyIfAbsent || oldValue == null)
// 将新value覆盖oldValue
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 判断插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get方法源码解析
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;
//1:判断table不为null
//2:判断table.length > 0
//3:通过hash计算出要查询的key对应的下标index 判断table[index]不为null
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//1:判断头结点的hash值是否与传入key的hash值相等
//2:判断头结点的key是否和传入的key相等
// 都满足情况下说明已找到目标,就返回头结点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//说明头结点不是目标节点,继续判断头结点的下一节点是否为null
if ((e = first.next) != null) {
//如果不为null,则判断头结点是否为红黑树节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//循环遍历此头结点链表
do {
//以下判断与上面判断头结点是否为目标节点类似
//判断下一节点的hash值是否与传入key的hash值相等
//判断下一节点的key值是否与传入key值相等
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
//满足返回此目标节点
return e;
//将下一节点赋给e,依次遍历链表
} while ((e = e.next) != null);
}
}
//说明找不到符合的key
return null;
}
HashMap遍历
//第一种遍历方式效率快
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while(iterator.hasNext()){
Map.Entry<String, Object> next = iterator.next();
System.out.println("key:"+next.getKey()+"====value:"+next.getValue());
}
System.out.println("==================================================");
//第二种遍历方式效率慢不推荐使用
Iterator<String> iterator2 = map.keySet().iterator();
while (iterator2.hasNext()){
String key = iterator2.next();
System.out.println("key:"+key+"====value:"+map.get(key));
}
- 后期完善ing