区别小结:
1、JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法
2、扩容后数据存储位置的计算方式也不一样:
3、JDK1.7的时候使用的是数组+ 单链表的数据结构。JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构
HashMap<String,String> objectObjectHashMap = new HashMap<String,String>();
流程
1、put操作:第一次进来,空的创建HashMap,根据加载因子,计算到达多少扩容=容量*加载因子
2、如果hash对应的table[hash…]=null,那么直接存进去。 3、如果hash里面对应的table!=null
,那么就要判断key是否一样,如果一样就替换。 4、接着3,如果key不一样就拿到next
,判断next等于null,直接追加在后面,如果next不等于null,就拿到这个next的key对比,如果key相同,就把value替换。
5、如果这个链表的数量超过大于等于8,那么就要判断,table的length的长度是否小于64,这个长度是指tab纵向的长度,也就是16、32,不包含链表上的,数组是空的也会算进去。
6、如果小于64,触发扩容,也就是扩容的第二个2个条件。一个是链表总个数超过加载因子对应的扩容量,单个链表长度等于或者超过8个,并且length小于64也会扩容。
7、如果tab也就是数组的长度等于或者超过64,,并且单个链表上元素个数大于等于8,才会把链表的里面的元素转成TreeNode。
扩容
1、首先是长度*2
2、当前tab[i]不为空,判断这个元素的next是否等于null,如果等于,直接拿元素的【hash & 32减1】其实就是重行计算下标。然后存到里面去
3、如果next不是null, 取next这个元素,判断是否等于0还是等于1,如果等于0挨个的放到loHead里面,如果等于1挨个的放到hiHead,再去next的next元素,依次放到loHead的next和hiHead.next里面去,最后把2个新的链表,loHead和hiHead,分别存到新的数组的新的位置,那么新的位置怎么摆放。newTab[原来该链表所在的下标] = loHead 这个是等于0的; newTab[原来的下标+16] = hiHead 这个是等于1的;
4、如果next不是空的且当前这个链表是一个红黑树。也是使用相同的方式,判断是否等于0和等于1,只不过红黑树的node是TreeNode类型,他多了左节点和右节点的属性而已。
注意
1、oldCap << 1 表示乘以2的意思。
2、当【e.hash&oldCap】等于0时,e在新旧数组中的索引位置不变:【(2oldCap-1)& e.hash】 = 【(oldCap -1) & e.hash】 【e.hash&oldCap】不等于0的元素,e在新数组中的索引位置,是其在旧数组中索引位置的基础上,再加上旧数组长度个偏移量: 【(2oldCap-1)& e.hash】 = 【(oldCap -1) & e.hash + oldCap】
3、oldCap表示tab的长度,16、32、64.。。。
4、一开始计算下标是这样计算的 [hash & 16-1],当扩容的时候,如果之前的元素的next=null的时候,那么这个老元素在新的tab的位置是,[hash & 32-1]
当next不是空的时候,那么拿到这个next元素,判断(e.hash & 16) == 0 这表示什么? 表示这个元素是否在数组中的索引位置不变。 注意这里的16没有减1,如果等于0
为什么是8才转红黑树
性能优化:
在链表中查找元素的时间复杂度是O(n),其中n是链表长度。
红黑树的查找性能是O(log n),相对于链表更为高效,特别是在元素数量较多时。
将链表转换为红黑树,可以在查找时提高性能,尤其是对于大型的 HashMap。
避免过度优化:
转换为红黑树是一种相对较为复杂的操作,包括旋转等。因此,只有在链表长度足够大的情况下才值得进行这样的优化。对于小型的HashMap,
链表的性能可能更好,因为转换为红黑树带来的额外开销可能超过了它的好处
首先是在时间复杂度上,树形查找相比于链表的线性查找,具有更好的性能那么为什么不直接转成二叉树呢?太长转二叉树也不太好,因为太长,
就要涉及到数的旋转,较为复杂。
如果在链表的长度不够长,直接转红黑树,那就需要更多的空间。因为作为链表,只需要next元素的指针就可以,但是作为红黑树,
他就需要next和head节点。
【所以结合下来】:
1、一个是数据太小时,链表是比二叉树要快的,但是使用二叉树在费空间。可以去看下他们的线形图,O(n)和Log(n)是有一个交际,后面越来越远。
2、如果数据太大,链表查询性能是没有二叉树好的,那么二叉树有很多,比如二叉搜索树,平衡二叉树,红黑树,为什么要用红黑树,
这是因为二叉搜索树的极端情况成线性,平衡二叉树需要旋转。
3、选择8作为阈值,可以在空间和时间之间做出平衡。
红黑树是一种自平衡的二叉搜索树,它的查找、插入和删除操作的时间复杂度是O(log n),其中n是树的节点数,默认是链表长度达到 8 就转成红黑树,
而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想.
中间有个差值7可以防止链表和树之间频繁的转换
一、HashMap属性
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
transient Node<K,V>[] table;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
}
static class Node<K,V>{
final int hash;
final K key;
V value;
Node<K,V> next;
//next只有遇到冲突的时候,才会有值。没有冲突的时候,都是存在key和value里面的。可以看newNode方法在赋值的时候,next首次给的是null
}
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
注意:判断是否变成红黑树的时候,是判断tab[i] == 红黑树 不是tab[i].next == 红黑树
二、put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//put
final V putVal(K key, V value) {
if (tab==null || tab.length == 0)
tab = resize();//扩容或者创建tab
if (tab[hash(key)]==null)//通过hash计算i的下标
tab[hash(key)] = newNode(hash, key, value, null); //把Node加入到Tab里面去
else {
//这里是处理Hash碰撞问题
Node p = tab[hash(key)]
where(true){
p = p.next();
//判断这个key和原来存进去的key是不是一个key,注意这里是equals,面试会问为什么重写hashCode和equals
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 {
//走到这说明,key不相等,且也不是树结构,那就转成树
Node e = p.next;//注意这里取的是链表
if(e==null){
p.next = newNode(hash, key, value, null);break; //找到目标桶Node,追加到该Node的链表中
}else if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){
break;//已经在链表中
}else{
p = e;//继续遍历p
}
}
}
}
if (++size > threshold){
resize();//扩容或者创建tab
}
}
三、扩容或者创建tab
final Node<K,V>[] resize() {
省略部分代码.....
//newCap是int,表示需要创建hashMap的大小,第一次进入是16,第二次是32依次乘2
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//第一次进来oldTab是null,第二次进来oldTab就是第一次进来创建的tab[16]
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
if ((e = oldTab[j]) != null) {
oldTab[j] = null; //并且会把老的桶置空
if (e.next == null){
newTab[e.hash & (newCap - 1)] = e;//如果next下面没有值,说明没有冲突,值就只存在key和value上面。
}
else if (e instanceof TreeNode){//说明Node是树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
}else {//说明e.next是有值,就需要把next后面的链表放到新的Node.next下面去。
do {
Node next = e.next;
....
} while (next != null);
}
}
}
}
}
四、获取Get
final Node<K,V> getNode(int hash, Object key) {
if (tab != null && tab.length > 0 && first != null) {
if (first.hash == hash && (first.key == key || key.equals(k))){
return first;
}
//说明上面没找到,再从链表里面找
if (first.next != null) {
if (first instanceof TreeNode){//如果是红黑树结构
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
}
do {
if (e.hash == hash && (e.key == key || (key != null && key.equals(k)))){
return e;
}
} while (e.next != null);
}
}
return null;
}
五、扩容区别
Jdk1.7和Jdk1.8扩容的区别
区别小结
1、JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的
纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能
够避免出现逆序且链表死循环的问题。
2、扩容后数据存储位置的计算方式也不一样:1.7是重行Hash一次,JDK1.8判断Hash值的参与运算的位是0还是1迅速
计算出了扩容后的储存位置
3、JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构,
注意转红黑树的2个条件。桶的长度超过8的概率非常非常小
1、扩容条件
1.已经存在的key-value mappings的个数大于等于阈值
2.底层数组的bucketIndex坐标处不等于null
参数说明
hash-当前key生成的hashcode
key-要添加到HashMap的key
value-要添加到 HashMap 的value
bucketIndex-桶,也就是这个要添加 HashMap里的这个数据对应到数组的位置下标
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//扩容之后,数组长度变了
hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢?
bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。
}
createEntry(hash, key, value, bucketIndex);
}
/**
* 这地方就是链表出现的地方,有2种情况
* 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦
* 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
HashMap.Entry<K, V> e = table[bucketIndex];
table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);
size++;
}
2、扩容:
先看JDK1.7
参数说明
newCapacity-新的数组最大可以装的数量
threshold-扩容的阈值
loadFactor-加载因子一般都是12/16也就是0.75
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
transfer(newTable) 使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
static int indexFor(int h, int length) {
return h & (length - 1);
}
大致流程:
- 遍历数组Entry里面所有的元素,每个元素可能是单个元素也可能是链表
- 如果是链表循环链表里的所有元素,分别重行计算新的下标位置
- 链表中头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)
- 在旧数组中的所有的Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上
说一下头插入方式,比如下面代码:
e.next = newTable[i];newTable[i] = e;新链表已有的元素newTable[i] 放在新元素的后面e.next
3、扩容过程
扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。
JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
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 { // 链表优化重hash的代码块
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
先略过红黑树的情况,描述下简单流程,在JDK1.8中发生hashmap扩容时,遍历hashmap每个bucket里的链表,每个链表可能会被拆分成两个链表,不需要移动的元素置入loHead为首的链表,需要移动的元素置入hiHead为首的链表,然后分别分配给老的buket和新的buket。
HashMap扩容时红黑树的表现
扩容时,如果节点是红黑树节点,就会调用TreeNode的split方法对当前节点作为跟节点的红黑树进行修剪
参考:https://blog.csdn.net/pange1991/article/details/82347284