文章目录
- 一、了解常见的数据结构
- 二叉平衡树
- AVL树
- 红黑树
- 二、HashMap的实现原理
- HashMap的jdk1.7和jdk1.8有什么区别?
- 三、HashMap put的具体流程
- 四、HashMap的扩容机制
- 五、HashMap的寻址算法
- **第一步:** 计算对象的hashCode
- 第二步: 二次哈希
- 第三步:计算索引
- 为什么HashMap的数组长度一定是2的次幂
- HashMap在1.7情况下的死循环问题
- ConrurrentHashMap
- jdk1.7中
- jdk1.8中
一、了解常见的数据结构
二叉平衡树
- 若左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若右子树不为空,则右子树上所有节点的值都大于跟节点的值
- 它的左右子树也都是二叉平衡树
查找效率
- 最优情况,完全二叉树,log2N
- 最差情况,单只树,N/2
AVL树
- 左右子树都是AVL树
- 左右子树高度之差(平衡因子)的绝对值不超过1
查找效率
log2N
红黑树
红黑树是一种二叉搜索树,在每个结点增加一个存储位,表示颜色,可以是Red或Black。通过任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出两倍,因而是接近平衡的。
红黑树的性质
- 最长路径最多是最短路径的两倍
- 每个节点不是红色就是黑色
- 根节点是黑色
- 如果一个节点是红色,则它的两个孩子节点是黑色
- 对于每个节点,从该节点到其所有后代叶节点的简单路径上,均包含数量相同的黑色节点
- 每个叶子节点都是黑色的
二、HashMap的实现原理
HashMap的数据结构:底层使用hash表数据结构,即数组+链表或红黑树
- 当向HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组的下标
- 存储时,如果出现hash值相同的key,此时有两种情况
a. 如果key相同,则覆盖原值
b. 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中- 获取时,直接找到hash值对应的下标,再进一步判断key是否相同
HashMap的jdk1.7和jdk1.8有什么区别?
- JDK1.8之前采用的是:数组+链表。如果遇到哈希冲突,则将冲突的值添加到链表中
- JDK1.8中,当链表长度大于阈值(默认为8)并且数组的长度达到64时,则将链表转位红黑树,以减少搜索时间,当扩容时,红黑树拆分成树的结点小于等于临界值6个时,会退化成链表
三、HashMap put的具体流程
HashMap是懒惰加载,在创建对象时并没有初始化数组
在无参的构造函数中,设置了默认的负载因子为0.75
源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
流程图
- 判断键值对数组table是否为空或者为null,否则执行resize()进行扩容(初始化)
- 根据键值key计算hash值得到数组索引
- 判断table[i]==null,条件按成立,则直接新建节点天机
- 如果table[i]==null,不成立
4.1 哦按段table[i]的首个元素是否和key一样,如果相同则直接覆盖value
4.2 判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是,则直接在树中插入键值对
4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话将链表转换成红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在则直接覆盖value - 插入成功后,判断实际存在的键值对数量size是否多余最大容量threshold(数组长度*0.75),如果超过,则扩容
四、HashMap的扩容机制
扩容流程
- 添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次扩容都是达到扩容阈值(数组长度*0.75)
- 每次扩容时,都是扩容之前容量的2倍
- 扩容之后,会创建一个数组,需要把老数组中的数据挪动到新的数组中
– 没有hash冲突的节点,直接使用e.hash & (newCap -1) 计算数组的索引位置
– 如果是红黑树,走红黑树的添加
– 如果是链表,则需要遍历链表,可能需要拆分链表,判断e.hash & oldCap 是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
五、HashMap的寻址算法
第一步: 计算对象的hashCode
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第二步: 二次哈希
二次哈希
先将hash值右移16位,再与原来的hash值异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么这么麻烦,不直接使用hash值呢?
这样做是为了让hash值更加均匀,减少哈希冲突
第三步:计算索引
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
------
if ((tab = table) == null || (n = tab.length) == 0)
------
}
(n-1)&hash:得到数组的索引,代替取模,性能更好。数组长度必须是2的次幂
为什么HashMap的数组长度一定是2的次幂
- 计算索引时效率更高,如果是2的n次幂,可以使用位运算代替取模
- 扩容时重新计算索引效率更高:hash & oldCap 的元素留在原来位置,否则新位置 = 旧位置 + oldCap
HashMap在1.7情况下的死循环问题
在jdk1.7的hashmap中在数组进行扩容时,链表采用的是头插法,在数据迁移时,可能导致死循环
现在有两个线程
线程一: 读取到当前的hashmap数据,数据中一个链表在准备扩容时,线程二介入
线程二:也在读取hashmao,直接进行扩容,因为是头插法,链表的顺序会进行颠倒,比如原来是AB,扩容后是BA,线程二执行结束
线程一:继续执行时就会出现死循环
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B-》A-》B形成了循环。
JDK8将扩容算法进行了调整,使用尾插法,就避免了jdk7中的死循环问题
ConrurrentHashMap
ConrurrentHashMap是一种线程安全的Map集合
jdk1.7中
底层结构: 分段数组+链表
在计算得到在Segment数组中的下标位置后,,会进行加锁(ReentrantLock锁),然后再通过hash值定位在HashEntry数组中的位置, 性能比较低
jdk1.8中
底层结构: 数组+链表/红黑树
底层用到了CAS和Sunchronized来保证并发安全
- CAS控制数组节点的添加
- synchronzied只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,快就不会产生并发的问题,效率得到提升。锁的粒度更细