1.HashMap的结构和底层原理
jdk1.7 数组加链表
jdk 1.8 数据加链表加红黑树
数组里面每个地方都存了Key-Value这样的实例,在Java7里面这样的键值对叫Entry,在Java8中叫Node
Tip:因为jdk1.7只有数组加链表,用Entry很合适,jdk1.8有了红黑树,形容树还是节点Node比较合适
每一个节点会保存自身的hash、key、value、以及下个节点,Node的源码如下:
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;
}
....
2. 为什么需要链表
我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,就是不同的值去hash有一定的概率会一样,这就是Hash冲突,为了解决Hash冲突,用链表或者红黑树去存储Hash值一样的value。
3.Entry节点在插入链表时是怎么插入的
java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。
但是,在java8之后,都是所用尾部插入了。
4. 为什么会采用尾部插入呢?
1.HashMap头插扩容死循环问题
2.jdk1.8引入红黑树之后,反正每次插入元素都要判断这条链有没有超过长度8,在判断长度的时候顺便就插到尾部了
3.还有就是在jdk1.7的时候,就会遍历链表了,在比较有没有相同key的时候。所以不管怎样都会遍历链表的,而且尾插还可以避免死循环,所以就用尾插了
HashMap头插扩容死循环问题:
参考链接: 详解跳转链接:https://coolshell.cn/articles/9606.html
jdk1.7因为头插。同一个槽位的元素会倒置,jdk1.8转变之后那些链表的顺序也不会变,所以头插有循环,尾插没有循环
5.HashMap的初始容量为什么为16
大部分说法,16是一个经验值
6.HashMap的容量为什么是2的幂次方
jdk1.7 indexFor源码
static int indexFor(int h, int length) {
return h & (length-1);
}
只有容量为2的幂次方,才可以用&运算取模,得到最终的数组的下标。用&运算快。还有在jdk1.8里面,只有容量为2的幂时候,扩容才可以使用那个规律(下面有讲到)。(我觉得就是因为计算机是二进制的,直接使用二进制效率会快很多,所以有关于计算都想往二进制靠)
在jdk1.7中hash的时候为什么要异或,异或之后为什么又要右移
jdk1.7中hash源码:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
异或的原因(个人看法):如果选用&的话,只要当前为是0,高位不管是1还是0,都得是0,高位对结果没有影响。同样,如果是|的话,只要当前是1,结果都是1,和高位的值没有关系。只有^,不管当前是1还是0,高位都能对结果产生影响
右移的原因:indexFor函数取余的时候,高位没有参与运算,也就是尽管高位不同,hash值也可能相同,hash碰撞的概率太大了,右移之后,高位就可以影响我们index的结果了,减小了Hash冲突,让hash值的分布更均匀了
顺便附上jdk1.8hash源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-------------以下主要是jdk1.7源码分析----------------
1.比较重要的常用变量
//默认的初始容量,必须是2的n次幂,16也许是个经验值
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/*
最大容量为1<<30,超过不会再扩容。为什么是2^30,因为int的范围小于等于2^31-1.
且要求容量是2的幂次,所以最多只能左移30位
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子,容量*加载因子得到需要扩容的阈值threshold
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//一个空数组,用来比较table是不是为空的
static final Entry<?,?>[] EMPTY_TABLE = {};
//用来存放Entry的数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//map中实际键值对个数
transient int size;
//数组扩容阈值,达到这个值才会扩容
int threshold;
//加载因子,一般用默认加载因子
final float loadFactor;
/*
每次结构改变时,都会自增,fail-fast机制,这是一种错误检测机制。
当迭代集合的时候,如果结构发生改变,则会发生 fail-fast,抛出异常。
*/
transient int modCount;
//替代哈希使用的默认扩容阀值(什么东西??***)
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
2.jdk1.7中的构造函数们
HashMap一共有四种构造方法
- public HashMap(int initialCapacity, float loadFactor),其他三种构造方法都是调用的这个方法
- public HashMap(int initialCapacity)
- public HashMap()
- public HashMap(Map<? extends K, ? extends V> m)
第一种构造方法:(指定了初始容量和加载因子)
Tip:看完这个构造方法,你会发现它并没有根据传入的initialCapacity去新建一个Entry数组,此时的哈希表依然是个空表。HashMap在构造时不会新建Entry数组,而是在put操作的时候先检查当前hash表是不是个空表,如果是,就调用inflateTable方法进行初始化。inflateTable方法主要就是把初始容量变成2的幂次方,给数组开辟空间,重新设置阈值,还有初始化hashseed,下面put具体讲了
public HashMap(int initialCapacity, float loadFactor) {
//如果初始容量<0就抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果初始容量大于最大容量(1<<30),使用最大容量作为初始容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//如果负载率小于等于0或负载率不是浮点数,则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//设置加载因子的值
this.loadFactor = loadFactor;
//设置阈值为初始容量
/*
初始阈值为初始容量,与JDK1.8中不同,JDK1.8中调用了
tableSizeFor(initialCapacity)得到大于等于初始容量的一个
最小的2的指数级别数,比如初始容量为12,那么threshold为16,;
如果初始容量为5,那么初始容量为8
*/
threshold = initialCapacity;
//空实现,交由子类实现,比如LinkedHashMap
init();
}
第二种构造方法:(指定了初始容量)
//没有指定加载因子,所以是默认的加载因子0.75f
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
第三种构造方法:(空的,什么都没指定)
//默认初始容量1<<4,默认加载因子0.75f
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
第四种构造方法:(使用与指定Map相同的映射,来构造一个新的HashMap。创建该HashMap时,使用默认的装载因子(0.75)和足以容纳指定Map中的映射的初始容量。)
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
应用场景大概是这样的:
public static void main(String[] args)
{
Map<String,Integer>map=new HashMap<>();
map.put(null,2);
map.put("zs",4);
Map<String,Integer>mo=new HashMap<>(map);
System.out.println(mo);
}
打印结果:
3.jdk1.7中的put方法详解
public V put(K key, V value) {
//transient Node<K,V>[] table; table是一个Node类型的数组
//1.判断table这个数组是不是一个空的数组,如果是就初始化,详情见下文
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//2.对key为空进行处理,如果key为空,就putForNullKey(value),详情见下文
if (key == null)
return putForNullKey(value);
//3.获得hash值
int hash = hash(key);
//4.进行取余运算,获得index数组下标
int i = indexFor(hash, table.length);
//5.遍历链表,在遇到相同的key的时候,用新值覆盖掉旧value,并把旧的value返回
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//这里把hash值写在前面是有小技巧的,见下文
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value; //记录下oldValue
e.value = value; //用新值覆盖oldValue
e.recordAccess(this); //在HashMap里面没用,在LinkedHashMap才有用
return oldValue; //返回oldValue
}
}
// 6.
modCount++;
//7. 添加一个节点,详情见下文
addEntry(hash, key, value, i);
return null; //还是返回的oldValue
}
1.inflateTable(threshold)方法源码:
初始化操作:
- 初始化容量为大于等于的最接近的2的幂次
- 初始化阈值
- 初始化 table
- 初始化 hashseed
private void inflateTable(int toSize) {
//capacity一定是2的次幂,得到大于等于toSize的2的幂,比如5会得到8,9会得到16
int capacity = roundUpToPowerOf2(toSize);
/*
此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1
的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,
除非loadFactor大于1
我的小问题:为什么这里是最大容量加1啊,最大容量不好吗
*/
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
roundUpToPowerOf2(toSize)方法:
//这个方法是将你希望的数组长度输入,经过计算返回一个2的幂次的数组长度
/*
1. 如果希望的数组长度达到了最大容量,就返回最大容量作为数组的长度
2. 否则判断希望的数组长度是否大于1,如果大于1,就返回最接近且大于等于number的2次幂
3. 如果希望的数组长度小于等于1,就返回1
*/
/*
* 把1排除在外,是因为他(number - 1) << 1) 这个运算会得到0,1是个特判
* number-1是因为直接number<<1得到是大于number的2次幂,
如果number是2的次幂,比如number=4,就会得到8,我们想要的是4,所以减掉1个
得到3,再得到大于3的2次幂就刚好是4了
*/
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
//这个方法得到的是i的二进制表示里面,最高位的1。比如5(101),就会得到100
/*
程序步骤:
先把i变成全1,比如5(101),就先变成 (111),然后把111无符号右移,变成011,
再用111-011就可以得到100了
解释:
i |= (i >> 1)一直到i |= (i >> 16);就是把这个数变成全1的过程
如果我们用普通方法去把一个数变成全1,是不是只需要将i一直右移直到为0,
就可以把i的每一位和最高位的那个1或一遍。
这个方法只是在普通做法优化了一下。假设x位是最高位,第一次x|=x>>1,这样第x位
和x-1位就都是1了,有两位为1,所以右移两位可以让x-2,x-3变成1,
现在从x,x-1,x-2,x-3,这四位都是1,那么就一起移动这四位,就可以让前8位为1,
前8位再一起移,可以让前16位为1,前16位再一起移,就32位了,
int最多32位,就结束了。
要得到最高位的1,只需要减去除最高位的所有1,所以无符号右移了一个,让他变成011111这样的,相减,就可以得到最高位的1了
*/
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
initHashSeedAsNeeded方法:遗留问题
hashSeed的初始值在定义的时候就赋值为0了,这个函数是给hashSeed重新赋值
只找到这一篇博客:https://blog.youkuaiyun.com/qq_30447037/article/details/78985216
博客说hashSeed最后的值还是为0,感觉不可能的吧。。为0的话要这个函数干嘛
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
2.判断key=null时候的方法putForNullKey(value)
Tip:因为HashMap里面key是不能重复的,所以null作为key,永远只有一个null。根据如下代码,可知null一直存在数组的第0个位置的
private V putForNullKey(V value) {
//如果之前有过key=null的,就覆盖,并返回oldValue
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
//在HashMap里面没用,在LinkedHashMap才有用
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
3.获得hash值
int hash = hash(key);
前面有讲过这个hash
Tip:这里用到了hashSeed
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
4.进行取余运算,获得index数组下标
int i = indexFor(hash, table.length);
因为length始终是2的幂,本来这句话应该是h%length,最后的结果范围是0~length-1,2^k-1有什么特点,比如2 ^ 3-1,就是(0111),后面全是1。&的特点就是同为1才取1,所以,只要h为1,答案对应就是1,就达到取模的效果了
static int indexFor(int h, int length) {
return h & (length-1);
}
5.遍历链表,在遇到相同的key的时候,用新值覆盖掉旧value,并把旧的value返回。
Tip:
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
把hash判断写在前面的原因:
hash是int类型的,好比较,不像字符串需要一个个对比,很费时间
hash值不等,那他们一定不相等(hash值相等,也可能不等,所以有后面的判断)
6.modCount++;
详解:https://blog.youkuaiyun.com/weixin_39800144/article/details/80613738
7.HashMap 在jdk1.7中是先扩容,再添加的元素。jdk1.8反着来的,先添加元素,再扩容
void addEntry(int hash, K key, V value, int bucketIndex) {
/*数组的大小大于了阈值且数组上是没有值,才会进行扩容。
为什么数组上index位置没有值才会扩容呢?
答:如果有值的话直接加在链表头就好了,占用的是数组的同一个位置,
只有为null,才会占用一个新的位置,才需要扩容
为什么要扩容呢?
答:如果不扩容的话,链表的长度会很长,查找效率会很低
*/
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); //见下文
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length)
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
//头插
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
resize方法相关:
Tip:在把旧数组元素转移到新数组的时候,hashcode值不需要重新计算,但是数组下标的位置index需要重新计算。因为index的计算方式是h & (length-1),和数组的长度是息息相关的
其实,扩容前的数组下标和扩容后的数组下标是有规律的,所以jdk1.8就是运用的这个规律进行的新旧数组的转移。
规律:
原文链接:https://www.jianshu.com/p/bdfd5f98cc31
由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值与左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引就不变,1 的话索引变成原索引加上扩容前数组
//扩容机制:
void resize(int newCapacity) {
// 保存旧的数组
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 判断数组的长度是不是已经达到了最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
// 创建一个新的数组
Entry[] newTable = new Entry[newCapacity];
// 将旧数组的内容转换到新的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
// 计算新数组的扩容阀值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//transfer方法,将旧数组中的内容转移到新的数组
//jdk1.7同一个槽位的元素会倒置,jdk1.8转变之后那些链表的顺序也不会变
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历旧数组得到每一个key再根据新数组的长度重新计算下标存进去,如果是一个链表,则链表中的每个键值对也都要重新hash计算索引
for (Entry<K,V> e : table) {
// 如果此slot上存在元素,则进行遍历,直到e==null,退出循环
while(null != e) {
Entry<K,V> next = e.next;
// 当前元素总是直接放在数组下标的slot上,而不是放在链表的最后,所以
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 把原来slot上的元素作为当前元素的下一个
e.next = newTable[i];
// 新迁移过来的节点直接放置在slot位置上
newTable[i] = e;
e = next;
}
}
}
jdk 1.7中get方法详解
public V get(Object key) {
// 如果key为null,直接去table[0]处去检索即可
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key); // 根据key去获取Entry数组
return null == entry ? null : entry.getValue();
}
getForNullKey()方法:
private V getForNullKey() {
if (size == 0) {
return null;
}
//直接在table第0个位置找对应的value
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
getEntry(key)方法:
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 根据key的hashCode重新计算hash值
int hash = (key == null) ? 0 : hash(key);
// 获取查找的key所在数组中的索引,然后遍历链表,通过equals方法对比key找到对应的记录
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
entry.getValue()方法:
public final V getValue() {
return value;
}
---------------------以下主要是jdk 1.8------------------
jdk1.8主要就是加了个红黑树
红黑树查询快,插入慢。
链表插入快,查询慢。
1.jdk 1.8为什么选择红黑树而不是其他数据结构
引入RB-Tree是功能、性能、空间开销的折中结果。(具体不知道)
2.什么时候树化,什么时候把树改成链表
当链表长度达到8时树化,为6时树转成链表
3.为什么是6的时候把树变成链表呢
因为树化是需要时间的,6和8之间有个中间值,可以避免树和链表频繁的转换
4.为什么有了红黑树还要保留链表呢?
(不知道对不对)
红黑树查询快,插入慢。
链表插入快,查询慢。
5.Node
//Node是单向链表,它实现了Map.Entry接口
static class Node<k,v> implements Map.Entry<k,v> {
final int hash;
final K key;
V value;
Node<k,v> next;
//构造函数Hash值 键 值 下一个节点
Node(int hash, K key, V value, Node<k,v> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
....
6.TreeNode,红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
7.比较重要的变量
//下面三个变量是比jdk1.7多出来的
//树化的门槛
static final int TREEIFY_THRESHOLD = 8;
//桶中节点等于这个数,树转变成链表
static final int UNTREEIFY_THRESHOLD = 6;
//树化的另一个判断条件,数组容量大于等于这个数且达到树化的门槛,才进行树化
static final int MIN_TREEIFY_CAPACITY = 64;
8.jdk1.8中的构造方法们
依然是四种
- public HashMap(int initialCapacity, float loadFactor),其他三种构造方法都是调用的这个方法
- public HashMap(int initialCapacity)
- public HashMap()
- public HashMap(Map<? extends K, ? extends V> m)
第一种:(其他三种构造方法和jdk1.7一样)
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;
//jdk1.7和jdk1.8就这句话不同
//jdk1.7 threshold = initialCapacity;
//jdk1.7只是让阈值等于初始容量,然后put的时候去处理的
//jdk1.8在构造的时候把阈值设置成最接近初始容量的那个2的幂次
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor方法:
相当于jdk1.7里面inflateTable(threshold)中的roundUpToPowerOf2(toSize)中的highestOneBit(int i)方法,不过jdk1.7里面是>>,jdk1.8里面是>>>(无符号右移),
前面那部分都差不多。就最后一句话,jdk1.7是先进行(cap-1)<<1的,jdk1.8这个相当于算完了之后再处理,比如5(101),算完之后是全111,再加1就得到1000了
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;
}
9.jdk1.8中的put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
jdk1.8中的hash方法:
Tip:相比较jdk 1.7的hash方法简化了很多,1.7之所以那么复杂是为了提高哈希的散列性。提高散列性的主要原因就是为了提高hashMap的查询效率,1.8之后加了红黑树,红黑树查询效率高,所以可以稍微简化一下。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal(hash(key), key, value, false, true)方法:
这部分参考:https://blog.youkuaiyun.com/weixin_42493179/article/details/88650348
图解:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab是那个数组,n是数组长度,i是数组下标,p用来保存当前节点,尾插要用
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是不是为空,为空进行初始化。jdk1.8里面扩容和初始化都写在resize函数里面的
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n-1)&hash 和jdk1.7里面的indexFor是一样的,在取模求数组下标
/*如果value值为null,说明table[i]还没有被占用过,
那就直接创建一个新节点放到table[i]的位置。和jdk1.7的区别,
这里是先添加的元素,后面再进行扩容的*/
if ((p = tab[i = (n - 1) & hash]) == null)
//newNode里面直接调用了构造方法
tab[i] = newNode(hash, key, value, null);
else {
//e用来,k用来保存key
Node<K,V> e; K k;
//判断如果有相同的key就进行覆盖,和jdk1.7一样的。然后最下面还是返回了oldValue的
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 {
//binCount就是在记录链表的长度,为8要树化那些的
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { //判断是不是链表的尾节点
//如果是尾节点,说明前面也没有遇到key相同的,直接插入就好了
p.next = newNode(hash, key, value, null);
//TREEIFY_THRESHOLD=8,链表长度大于8树化,暂时减1,因为这里还没++size
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//判断有没有相等的key ,要不要进行覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//存在重复的key,返回oldValue
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize(); //扩容
afterNodeInsertion(evict);
return null; //oldValue为空
}
Tip:阈值>=8的时候也不一定会树化,数组长度小于 MIN_TREEIFY_CAPACITY的时候会扩容。也就是说:链表长度>=8 且 数组容量>=64才会树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//MIN_TREEIFY_CAPACITY=64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
......
resize方法:
①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
②.每次扩展的时候,都是扩展2倍;
③.扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。前面讲的那个规律
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化后table为Null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;//默认构造器的情况下为0
int newCap, newThr = 0;
if (oldCap > 0) {//table扩容过
//当前table容量大于最大值得时候返回当前table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//table的容量乘以2,threshold的值也乘以2
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//使用带有初始容量的构造器时,table容量为初始化得到的threshold
newCap = oldThr;
else { //默认构造器下进行扩容
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {
// help gc
oldTab[j] = null;
if (e.next == null)
// 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
// 扩容都是按照2的幂次方扩容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof HashMap.TreeNode)
// 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的高度小于等于UNTREEIFY_THRESHOLD则转成链表
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 把当前index对应的链表分成两个链表,减少扩容的迁移量
HashMap.Node<K,V> loHead = null, loTail = null;
HashMap.Node<K,V> hiHead = null, hiTail = null;
HashMap.Node<K,V> next;
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) {
// help gc
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// help gc
hiTail.next = null;
// 扩容长度为当前index位置+旧的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
10.jdk1.8 get方法
Tip :
HashMap同样并没有直接提供getNode接口给用户调用,而是提供的get方法,而get方法就是通过getNode来取得元素的。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode方法:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// table已经初始化,长度大于0,根据hash寻找table中的项也不为空
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;
}
总结:jdk1.7和jdk1.8的区别
-
最重要的一点是底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构;
-
构造方法里,给threshold赋的值不一样,jdk1.7是初始容量,jdk1.8是tableSizeFor(initialCapacity)
-
put方法中,jdk1.7中当哈希表为空时,会先调用inflateTable()初始化一个数组;而1.8则是直接调用resize()扩容,1.8初始化和扩容都是resize方法;
-
插入键值对的put方法的区别,1.8中会将节点插入到链表尾部,而1.7中是采用头插;
-
jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值,而1.8中则是采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀;
-
扩容时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序;而1.8HashMap初始化后首次插入数据时,先发生resize扩容再插入数据,之后每当插入的数据个数达到threshold时就会发生resize,此时是先插入数据再resize。1.7则是在元素插入前;
-
jdk1.8扩容时transfer方法有那个规律(前面讲的),jdk1.7还要每次进行重新计算index,用 h&(length-1)