HashMap
基础
散列表:数组+链表;
整合了数组快速索引和链表快速插入扩容的特性。
散列表–>哈希
哈希:也称散列,哈希对应的英文都是hash,基本原理就是把任意长度的输入,通过哈希算法变成固定长度的输出。这个压缩映射规则就是对应的哈希算法,而原始的数据映射后的二进制串就是哈希值。
Hash特点:
- 从hash值不可以反向推导hash原始的数据;
- 输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值;
- 哈希算法的执行效率要高效,长的文本也能快速的计算出哈希值;
- hash算法的冲突概率要小;
抽屉原理:一定存在不同的输入映射到相同输出的情况。
散列函数–>散列过程(散列)–>散列表;碰撞;
均匀散列函数:若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。
Hash算法也被称为散列算法,Hash算法虽然被称为算法,但实际上它更像是一种思想。Hash算法没有一个固定的公式,只要符合散列思想的算法都可以被称为是Hash算法。
常用散列函数:
- 直接**(线性)**寻址法:H(key)=key或H(key) = a·key + b;
- 数字分析法:找规律找冲突概率小的,比如生日中年月冲突大,月日冲突小;
- 平方取中法:取关键字平方后的中间即为作为散列地址;
- 随机数法:取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合;
- 除留余数法: H(key) = key MOD p,p<=m。对p的选择很重要,一般取素数或m,若p选的不好,容易产生碰撞。
冲突处理策略:
-
开放寻址法:Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),di为增量序列
- di=1,2,3,…,m-1,称线性探测再散列;
- di=12,-12,22,-22,32,…,**±k2**,(k<=m/2)称二次探测再散列;
- di=伪随机数列,称伪随机探测再散列。
-
再散列法:Hi=RHi(key),i=1,2,…,k RHi均是不同的散列函数;
-
拉链法;
-
建立一个公共溢出区。
原理
继承体系
HashMap是Map接口的非同步实现类
Node
底层数据结构
put
Hash碰撞,链化
JDK8引入红黑树
解决jdk1.7链化严重的问题。
扩容 (核心考点)
数组容量变大,桶位更多,查询效率提高。
源码
-
HashMap除了树化以外,很多地方都进行了优化,尤其是方法都偏向于集中了,而不是各种套娃,估计换了一个很有实力的的团队。
-
HashMap的代码行数是1.7的一倍以上,阅读源码,我们发现HashMap提供了多种转换方式以及内部类,比如keySet,EntrySet,HashIterator等,这里我们要灵活运用
常量分析
树化的另一个参数:当哈希表中的所有元素个数超过64时候,才允许树化。
static final int MIN_TREEIFY_CAPACITY = 64;
哈希表
什么时候初始化?懒加载,第一次赋值的时候才会初始化。
transient Node<K,V>[] table;
//哈希桶的大小
int threshold;
扩容阈值=当前哈希表的大小(初始16)x负载因子(默认0.75)
当哈希表中的元素超过阈值时触发扩容。
JDK1.8 HashMap底层数据结构增加一种红黑二叉树,在极限情况下11条变成红黑二叉树。
11条8(16) => 9(162=>32) => 10(322=>64) => 11(Tree)。
构造方法
4个构造方法。
- 默认构造方式
- 自定义容量构造方式
- 自定义容量,加载因子构造方式
//threshold需要是2的整数次幂
this.threshold = tableSizeFor(initialCapacity);
//将传进的容量的最后一个1到第一个1全部置然后+1就是要扩的容量
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;
}
- 传入Map构造
put方法
路由寻址公式:(table.length-1)&node.hash
当开始table比较小的时候,很明显hash的高16位是无法参与路由运算的。
解决?
hash:扰动函数
作用:让key的hash值的的高16也参与路由运算。
核心方法:
**putIfAbsent **: true表示如果没有哈希中没有key,就插入,没有就插,一般默认false。
**evict **:表示是否是创建过程,因为Map构造和readObject都是put已经写好的元素了!
//tab:引用当前hashMap的散列表
//p:表示当前散列表的元素
//n:散列表数组的长度
//i:表示路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//延迟初始化逻辑,在第一次调用putval时才会初始化hashmap对象中最消耗内存的散列表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
第1种情况:寻址找到的桶位刚好是null,这个时候直接讲当前k-v封装成Node丢进去即可
//i=(n - 1) & hash 路由寻址算法:哈希桶大小-1与上经过扰动函数处理得到的哈希值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
否则的话:
Node<K,V> e; K k;//临时变量
第2中情况:刚好有1个数据
//该桶位元素的键值key刚好与传入的键值key相等并且hash值也一致
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//找到键值key一致的元素e,用于后序替换
第3种情况:是一颗红黑树
//桶位元素节点已经树化为红黑树节点,此种情况比较复杂在后续讲解
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
第4中情况:是一个链表
else {
//在链表上迭代进行比较
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {//找到末尾也没有找到key值一致的元素
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;//找到key值一致元素e,直接结束后续替换
p = e;
}
}
树化的两个条件条件:
-
链表长度>=7 ( binCount >= TREEIFY_THRESHOLD - 1)
-
**且桶数组>=64 **
哈希扩容条件也有两个:
- 哈希容量大于阈值(++size > threshold)
- 树化函数中树化条件不满(哈希桶数组<=64)足也会触发扩容(if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY))
if (e != null) { // 存在的话就进行替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)//原来的值不存在
e.value = value;
afterNodeAccess(e);
return oldValue;
}
++modCount;//表示散列表被修改的次数,替换不算
if (c)//哈希容量大于阈值回触发扩容
resize();
afterNodeInsertion(evict);
return null;
①.判断键值对数组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,如果超过,进行扩容。
resize方法(核心)
为什么需要扩容?
为解决哈希冲突导致散列表的链化严重,影响查询效率。
final Node<K,V>[] resize() {
//oldTab:引用扩容前的哈希表
Node<K,V>[] oldTab = table;
//oldCap:表示扩容之前table数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//oldThr:表示扩容之前触发扩容的的扩容阈值
int oldThr = threshold;
//扩容之后的table数组大小,下次触发扩容的阈值
int newCap, newThr = 0;
给newCap, newThr这两个变量赋值。
//hashMap中的散列表已经初始化过了,是一次正常的扩容
if (oldCap > 0) {
//已经达到最大阈值了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;//不能再扩容了
return oldTab;
}
//正常扩容,oldCap << 1,扩大2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//且扩容前阈值>=16
newThr = oldThr << 1; //阈值也扩大2倍
}
//oldCap==0的两种情况,散列表未初始化
//1. public HashMap(int initialCapacity, float loadFactor)
//2. public HashMap(int initialCapacity)
//3. public HashMap(Map<? extends K, ? extends V> m) 并且map是有数据的
else if (oldThr > 0) //构造方法中传有一个容量参数
newCap = oldThr;
else { //构造方法没有传任何参数
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
if (newThr == 0) {//一般new的时候传参数比如map和设置容量时候,需要自己设定newThr阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//扩容
table = newTab;
扩容关键方法:
链表节点的扩容:hash&(16-1)=1111 虽然链表的后四位相同,但是再往前不一定相同。
所以再&(32-1)=11111就不一样了。
if (oldTab != null) {//扩容之前,table不为null
for (int j = 0; j < oldCap; ++j) {//处理桶位中的元素
Node<K,V> e;//处理的当前元素
if ((e = oldTab[j]) != null) {//桶位元素不为空
oldTab[j] = null;//置空方便JVM在GC时进行回收内存
//1.单个数据,从未有碰撞
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;//路由选择找到新的索引
//2.已经树化,将红黑树的时候再讲
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//3.桶位已经形成链表
else {
//低位链表:存放扩容之后的数组的下标位置与当前数组的下标一致
Node<K,V> loHead = null, loTail = null;
//高位链表:新位置为当前数组下标位置+扩容之前数组的长度
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// hash->...1 1111 高位链中
// hash->...0 1111 低位链中
//oldCap-> 1 0000
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;
get方法
final Node<K,V> getNode(int hash, Object key) {
//tab:引用当前hashmap的散列表
//first:桶位元素
//e:当前元素
//n:桶数组的长度
//k:当前元素的k值
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//桶不为空并且所需要的查找(路由寻址)的hash桶位元素不为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//桶位元素与查找的元素hash值和key值一致,查找成功
if (first.hash == hash &&
((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;
}
remove方法
key值一致
key值和value值都要一致才能删除
核心方法都是:removeNode
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
//tab:引用当前hashMap中散列表
//p:桶位当前元素或者链表的上一元素
//n:表示散列表数组长度
//index:路由寻址索引
Node<K,V>[] tab; Node<K,V> p; int n, index;
//哈希桶数组不为空且路由选址得到的桶位元素不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//node:临时变量存储要删除的节点
//e:当前元素
//k:当前元素的key值
//v:当前元素的value值
Node<K,V> node = null, e; K k; V v;
//桶位元素hash值和key值一致,找到要删除的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//桶位元素已经树化
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//桶位元素是链表节点,迭代查找
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//找到的要删除的节点且非空并且判断是否需要value值也一致
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//删除的节点分3种情况
//1.红黑树中的节点,因为对于树化节点会有反树化成链表(<6),所以先判断
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//2.桶位中p就是当前元素,桶位元素删除,将下一个元素,放到桶位元素中
else if (node == p)
tab[index] = node.next;
//3.链表中,p是node的上一个元素
else
p.next = node.next;
++modCount;//修改次数加1
--size;//哈希大小减1
afterNodeRemoval(node);
return node;
}
}
return null;
}
replace方法
核心是调用get方法中的getNode这个查找方法:
//要key值和oldValue值一致才能替换
public boolean replace(K key, V oldValue, V newValue) {
//e:当前元素
//v:当前元素的值
Node<K,V> e; V v;
//查找到的hash值和key值一致的元素不为空且与传入的oldValue相等
if ((e = getNode(hash(key), key)) != null &&
((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
e.value = newValue;
afterNodeAccess(e);
return true;
}
return false;
}
//key值一致替换
public V replace(K key, V value) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;
e.value = value;
afterNodeAccess(e);
return oldValue;
}
return null;
}
面试题
- HashMap的实现原理?
结构:
-
常量:
- 最大容量2^30,
- 哈希容量(默认16),
- 阈值(默认12),
- 负载因子(默认0.75,为什么是0.75因为0.75是一个折中的值,由此得出的阈值,0.75满足泊松分布发生哈希碰撞的概率小哈希桶数组又能有效利用,4个构造方法中有1个可以改负载因子,但是一般不建议改),
- 树化条件8,
- 反链化6(容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值。)
-
散列表(桶数组)(最耗费内存,懒加载,第一次插入树的时候才会初始化)
-
Node结构(hash,key,value,next)
-
TreeNode(left,right,parent,red,pre) (继承LinkedHashMap,这优势HashMap子类,所以hashMap底层数据结构本质还是Node)
插入:
-
取hashcode,经过扰动函数hash让高16位参与运算将性能做到极致(如果两个哈希值的低位差异极小而高位差异很大,导致这两个哈希值计算出来的桶位比较接近,会插入到HashMap的两个位置比较相邻的位置,这样哈希碰撞的概率就变高了!)(hashcode右移16位异或)
-
路由选择寻址(hash&(tab.length-1))找到桶位,哈希不冲突,哈希碰撞冲突,分4种情况:
-
- null
-
- 有数据(hash和key值相同直接替换)
-
- 树化(插入树节点,按BST(key值)查找(每次筛选1半)O(logN)红黑树找到插入(封装,红插)节点,最多需要2次旋转即可维持平衡,分8中情况维持树的黑色平衡:
-
- 根节点null,黑色
- 插入节点已经存在,根据传入的参数决定是否替换(除此其他情况都是插入null节点)
- 父节点黑色,无需自平衡
- 父节点红色(肯定有爷爷结点),叔叔节点不为空红色,变色,爷爷结点继续
- 父节点红色且左,叔叔节点为null或者黑色:LL型先变色后右旋,LR型先左旋后变LL型
- 父节点红色且右,叔叔节点为null或者红色**:RR型**先变色后左旋,RL型先右旋后变RR型
)
-
- 链表,按key值迭代查找,key值相同替换,否则尾插,判断树化条件(>=7),树化之前判断是否因桶数组过小(<=64)太小而引起的链化严重,是的话进行扩容,否则将链表节点先换为TreeNode节点,在树化
-
-
扩容resive,判断size是否超过阈值,超过则扩容(比如链表元素分为高位链低位链(… 1 1111 …0 1111),高位链更新同位元素即再原来的桶位置基础上加上桶增加的长度,低位链不变)
补:为什么采用红黑树而不是AVL树,他们都是BBST,他们算法时间复杂度相同
- AVL树高度差<=1,平衡要求严格,查询效率高,插入也只需最多两次旋转(4种情况,LL,LR,RR,RL)即可,但是删除操作可能需要频繁旋转O(N),
- 红黑树具有良好的稳定性(黑色平衡,只要满足红黑树5个性质,可以近似看做趋于平衡)和整体性能,插入和删除都是不超过2次旋转即可,
- 所以从增删查性能折中的考虑来说,最优还是选择红黑树。(tips:当然实际应用中如果查询次数远远大于增改次数,还是可以选择AVL树的,但是我们的hashMap有扩容机制,所以红黑树的节点也至于非常大,所以其查询效率其实更AVL树差不了很多)。
- 那HashMap为什么桶数大于8要树化,大于9,大于10,有什么不好的地方?
第1次扩容的时候(哈希桶数组16)链表9,第2次是(哈希桶32)10,第3次(哈希桶64)11,后面的话都是8;
因为需要防止链化严重是将使得hashMap的查找效率严重降低,O(1)–>O(N)。
哈希碰撞攻击:有人恶意的向服务器发送一些hash值计算出来一样,TreeNode大概是普通的2倍,所以我们转换成树结构时会加大内存开销的。选择6进行树化,比8大了一千倍,遇到组合Hash攻击时(让你每个链表都进行树化),也会遇到性能下降的问题
- HashMap的hash算法为什么使用异或?可以用%取余运算吗?
异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向0靠拢,采用 | 运算计算出来的值会向1靠拢。用逻辑运算符&直接在cpu进行移位比算术运算符%效率高10倍。
- 初始容量如果不是2的次方呢?
tableSizeFor(initialCapacity):将传进的容量的最后一个1到第一个1全部置1然后+1就是要扩的容量
具体算法:一次疑惑5个数,1,2,4,8,16即可达到目的。
- 为什么树化之后,当长度减至6的时候,还要进行反树化?
- 长度为6时我们查询次数是6,而红黑树是3次,但是消耗了一倍的内存空间,所以我们认为,转换回链表是有必要的。
- 维护一颗红黑树比维护一个链表要复杂,红黑树有一些左旋右旋等操作来维护顺序,而链表只有一个插入操作,不考虑顺序,所以链表的内存开销和耗时在数据少的情况下是更优的选择。
选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
- hash算法可以优化吗?
hash散列种子可以优化。
线程安全
源码
JDk1.7的hashMap在多线程环境下,扩容的时候可能会形成环状链表导致死循环。
散列表:
transient Node<K,V>[] table;
JDK1.8采用尾插法解决问题,但是也有线程安全问题,在多线程情况下需要采取安全策略。
散列表:
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
扩容:
void addEntry(int hash, K key, V value, int bucketIndex) {
//判断是否需要扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容
resize(2 * table.length);
//重新计算hash值
hash = (null != key) ? hash(key) : 0;
//计算所要插入的桶的索引值
bucketIndex = indexFor(hash, table.length);
}
//执行新增Entry方法
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//达到最大值,无法扩容
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
//将数据转移到新的Entry[]数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
//覆盖原数组
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer:首先是遍历table数组,如果遍历到Entry不为空,我们进入while循环,每次操作结束都将进入循环的e用e.next覆盖,直至链表到达尾部,即e!=null 但是 e.next==null。
头插法:多线程时候导致死循环
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
//1.标记下一个节点
Entry<K,V> next = e.next;
//这两个方法时决定插入位置,和链表操作无关,我们重点看链表操作的过程
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//2.改变next指向
e.next = newTable[i];
//3.覆盖原值
newTable[i] = e;
//4.下一次插入
e = next;
}
}
}
链表死环
多个线程操作一个Map的时候可能出现同时扩容,transfer元素搬家之后才会修改threshold,就会导致多个线程触发扩容。
//假设进程1执行完这个标记后被挂起
Entry<K,V> next = e.next;
//进程2执行整个流程,
Entry<K,V> next = e.next;
...
原来标记的Entry都跑到了新的数组,安全问题已经出现了
此时还没更新全局table
线程1重新执行:头插:指向(将上一个节点挤下去),覆盖(就是头咯)
table是全局共享的,所以,在线程2中改变了table的链表结构其实在线程1中也就被改变了,当然除临时变量外;
处理被调度前,存储的next节点:
**最终成环:甚至还会丢失数据。**被覆盖了
1.8尾插
尾插法解决JDK1.7链表出现死环的问题。
但是也同样有线程安全问题:覆盖。
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的第38行处有个**++size**,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11准备写回主内存然后又被A抢占执行,然后线程A再次拿到CPU并继续执行(此时size的值仍为10脏数据),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所以说还是由于数据覆盖又导致了线程不安全。
解决
线程安全出现在修改数据的情况下比如put,remove。
比如线程A执行if(p=tab[i=(n-1)&hash]==null)后挂起,然后线程B正常执行并在这个位置插入数据,然后线程A又继续在该位置插入数据,这样的话B进程的数据就被覆盖了。
解决方法:使用并发环境安全的集合框架
- HashTable
- 强化HashMap
- ConcurrentHashMap
- 正确使用HashMap(只给需要的全局变量上锁,减少锁的开销)
HashTable
给每个修改数据方法加上synchronized关键字保证线程安全。
强化HashMap
这个方法无非就是原来的方法进一步包装,多了一个synchronized关键字,和Hashtable原理一样。
//使用Collections工具类的synchronizedMap方法,将HashMap改造为一个线程安全的Map。
Collections.synchronizedMap(new HashMap<String,String>());
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
ConcurrentHashMap
分页的思想:ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
分区:Segment
每个Segment片段就类似一个HashMap
- 哈希表table
- 哈希桶HashBucket
- 表头节点HashEntry
为什么说ConcurrentHashMap的性能要比HashTable好?
HashTables是用全局同步锁,而ConurrentHashMap采用的是分段锁,每一个Segment就好比一个自治区,读写操作高度自治,Segment之间互不干扰。不同segment区可以并发执行,相同segment去支持同时读写或者写操作(有1者阻塞),高并发性能良好。
使用大量的volatile关键字来保证哈希常量的可见性。
使用synchronized来保证会出现线程问题的方法的线程安全,不过多了一些条件判断。
remove方法:
put方法:
4种遍历方式
- Set集合遍历
- Colletions遍历
- 迭代器遍历
- Spliterator迭代器
//1. Set集合遍历方式
final class KeySet extends AbstractSet<K>
final class EntrySet extends AbstractSet<Map.Entry<K,V>>
//2. Colletions遍历方式
final class Values extends AbstractCollection<V>
//3. 迭代器遍历方式
abstract class HashIterator
final class KeyIterator extends HashIterator implements Iterator<K>
final class ValueIterator extends HashIterator
final class EntryIterator extends HashIterator
//4. spliterator遍历方式
static final class KeySpliterator<K,V>
static final class ValueSpliterator<K,V>
static final class EntrySpliterator<K,V>
**tips:**记得不要用Map map=new HashMap(),因为有些内部类Map是没有的。
HashMap<String,String> map=new HashMap<String,String>();
-
Set集合遍历:key值唯一
-
Colletions遍历:values值不唯一
-
迭代器遍历
-
spliterator遍历:了解即可 lambda表达式的函数接口输出
Spliterator<Map.Entry<String, String>> spliterator = map.entrySet().spliterator(); //lambda表达式 spliterator.forEachRemaining((x)-> System.out.println("key="+x.getKey()+"value="+x.getValue())); Spliterator<String> spliterator = map.keySet().spliterator(); spliterator.forEachRemaining((x)-> System.out.println("key="+x+"value="+map.get(x))); Spliterator<String> spliterator = map.values().spliterator(); spliterator.forEachRemaining((x)-> System.out.println("value="+x));
7和8区别
- 正常情况:
- 有扩容机制,单条链表能达到长度为8的概率是相当低的,除非Hash攻击或者HashMap容量过大出现某些链表过长导致性能急剧下降的问题,红黑树主要是为了解决这种问题。
- 在正常情况下,JDK1.7和1.8的HashMap效率相差并不大。
节点区别
1.7的散列表是Entry数组:
- hash是可变的,因为有rehash的操作。
1.8的散列表时Node数组(实现Map.Entry接口,所以本质还是Entry数组):
- hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。
- 新增了一个TreeNode节点,为了转换为红黑树。
哈希算法区别
1.7会先判断这Object是否是String,
如果是,则不采用String复写的hashcode方法,处于一个Hash碰撞安全问题的考虑。
1.8计算出来的结果只可能是一个,所以hash值设置为final修饰。
对Null处理的区别
Jdk1.7中,对null这个特殊值值做了单独的处理:
putForNullKey:
- 遍历数组的下标为0的链表;
- 循环找key=null的键,如果找到则替换
- 如果当前数组下标为0的位置为空,即e==null,那么直接执行添加操作,key=null,插入位置为0。
Jdk1.8中,由于Hash算法中会将null的hash值计算为0,插入时0&任何数都是0,插入位置为数组的下标为0的位置,所以我们可以认为,1.8中null为键和其他非null是一样的,也有hash值,也能替换。只是计算结果为0而已。
初始化区别
Jdk1.8是懒加载,真的是这样吗? 构造方法中没有对table进行任何初始化。
构造方法时已经计算好了新的容量位置(大于等于给定容量的最小2的次幂)。
table是resize扩容方法时创建的。
transient Node<K,V>[] table;
Jdk1.7中,table在声明时就初始化为空表。
在第一次put元素时初始化和计算容量。
table是单独定义的inflateTable初始化方法创建的。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
扩容区别
Jdk1.7:
transfer ,头插法(tips:正常插入也是头插法)
Jdk1.8:
- 每次操作一个桶,直至遍历到链表尾部;
- 如果正在操作的桶是空的直接下一次循环,否则进行一系列操作;
- 判断是否只有一个数据,如果是的,我们直接插入到新数组;
- 判断是否是树节点,如果是的,调用树的操作方法,如果不是,走do-while循环处理链表;
- 根据e.hash& oldCap==0来区分高位链表和低位链表循环前标记2个头节点,两个尾节点,表示插入到新位置但不改变下标和插入到新位置改变下标。最后do-while结束,将不为空的hoHead和hiHead插入到新数组。
红黑树
BST
树概念
树(Tree)是一种抽象数据类型(ADT),用来模拟具有树状结构性质的数据集合。
满二叉树:每一层的节点数都达到最大值,2^k-1个节点;
完全二叉树:最后一行可能不完整,缺失的三角形且缺的是右边部分,2(k-1)-1<N<2k-1;
节点的深度:从上往下看,根到节点的路径长度;
节点的高度:从下往上看,节点到叶子结点的路径长度;
二叉树:每个节点最多只能由两个节点;
BST插入:
1. 查找;
2. 小于根节点则和根节点的左子树比较;
3. 大于等于根节点则和根节点的右子树比较;
4. 直到**找到空节点**,则插入到相应的null的位置。
BST的前,中(递增序列),后序遍历;
BST的最小(最左边的节点)最大(最右边的节点)值;
BST删除:分3种情况(最后一种比较复杂)
- 有0个子节点; 直接将父节点指向该节点的引用改为null即可,该节点游离态等待GC回收。
- 有1个子节点; 直接将父节点指向该节点的引用指向该节点的子节点即可。
- 有2个子节点; 中序遍历后继节点(一定是叶子结点,又回到情况1)代替删除的节点。
既然删除操作复杂,删除有必要吗?
可以不删除,TreeNode节点添加一个isDelete标记,不过查询时就要多一个条件判断了。
时间复杂度分析:O(logN)
有序数组二分查找算法? O(logN)
缺陷:强制依赖有序数组
而数组又有缺陷:插入时间复杂度O(N),不能快速扩容。
解决:链表插入O(1),任意扩容。
如何有一种结构拥有有序数组二分查找的性能又能像链表一样灵活呢?–>BST
BST致命缺陷:极端情况下,退化为单链表,查找性能变为O(N)。
解决:引入平衡二叉树AVL树,红黑树…
插入和删除元素时树可以通过左旋和右旋自动调整两边平衡。
AVL树的操作比红黑树复杂,这里就不做详细介绍。
AVL和RB-Tree对比
虽然两者都贵为B-BST,实现算法复杂度相同,但是为什么hashmap底层采用的是红黑树而不是AVL自平衡树?
红黑树复衡效率更高,具有良好的稳定性和完整的性能,是对增删查效率的最好折中。
-
红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低(为节点增加颜色,任何不平衡都会在三次旋转之内解决),虽然牺牲了一些查找性能但因此提高了删除效率;
-
AVL树通过更复杂的自平衡的计算使得左右子树严格平衡,虽然查询效率会高一些但却因此删除时间复杂度变高。
-
插入->失衡,AVL和RB-Tree都是最多两次树旋转来实现复衡O(1)
-
删除->失衡,AVL旋转的量级为O(logN),RB-Tree最多只需要旋转3次O(1)
实际应用中,若搜索的次数远远大于插入和删除,那么选择AVL,否则选择RB-tree。
结合我们hashmap,其红黑树的节点个数都不会太大,因为哈希桶数组会扩容,而相对来说插入和删除较为频繁啦。
AVL
简介
- 具有BST的全部特性;
- 每个节点的左子树和右子树的高度差<=1;
树的不同类型旋转也是根据平衡因子来判断的,平衡因子=左子树高度-右子树高度
查找和正常的BST是类似。
插入和删除的时候树的高度发生变化,需要旋转维持平衡。
插入
共有4种
需要旋转的情况。
- LL型----R
- RR型----L
- LR型----LR
- RL型----RL
平衡调整的步骤:
- 找平衡因子大于1的节点;
- 找插入新节点后失去平衡的最小子树;
- 距离插入节点最近;
- 平衡因子绝对值大于1的节点作为根;
- 平衡调整
插入:只需最多两次的旋转即可。
四种情况:
例题:
以7为根,11为支,左旋。
依次删除16,15,11
与BST的删除类似,不过当出现不平衡时比较复杂,需要O(N)时间复杂度。
原理
简介
只要满足以上性质,红黑树就是趋近平衡的。
红黑树并不是完美平衡二叉树,但左子树和右子树的黑色节点是相等的(性质5),简称黑色完美平衡。
查找和BST一样O(logN)
插入和删除,通过左旋,右旋,变色来保持平衡。
插入
红插。红色在父节点(如果存在)为黑节点时,不需要做平衡,如果是红色,需要做自平衡。黑插的话肯定都会破坏黑色平衡。
- 查找插入的位置
- 插入后自平衡
一共8种情况。
情况1:红黑树为空
直接插入作为根节点(性质2:注意此时根节点为黑色)
情景2:插入节点key已存在
直接更新节点的值
情景3:插入节点的父节点为黑节点
直接插入即可,无需做自平衡;
情景4:插入节点的父节点为红节点
4.1叔叔节点存在,并且为红节点
红黑树性质4可知红色节点不能相连=》祖父节点肯定为黑色;
因为不可以同时存在2个相连的红节点。
黑红红==》红黑红
- 将p和u改为黑色;
- 将pp改为红色;
- 将pp设置为当前结点进行后续处理;
- 如果pp父节点是黑色,则已经自平衡无需处理;
- 如果pp父节点是红色,则按照1-3思路继续进行自平衡处理。
- 若最后爷爷结点是root还没有平衡,root节点必须为黑色的
4.2叔叔节点不存在或黑节点,并且插入节点的父节点在左侧
单从插入来看,叔叔节点非红即空,否则破坏性质5黑高。
插入节点在左边(LL红)
LL型:先变色(黑红红==》红黑红),再右旋
插入节点在右边(LR红)
LR型:先左旋,变色(黑红红==》红黑红),再右旋
情况4.3是情况4.2的相反情况。
4.3叔叔节点不存在或黑节点,并且插入节点的父节点在右侧
同样的单从开始插入来看,叔叔节点非红即空(插入后续调整就不一定了),否则破坏性质5黑高。
插入节点在右边(RR红)
RL型:先变色(黑红红==》红黑红),再左旋
插入节点在左边(RL红)
RL型:先右旋,变色(黑红红==》红黑红),再左旋
例题讲解:
手写红黑树
-
创建RB_Tree类
-
创建静态内部类RBNode
-
辅助方法定义:parentOf(node),isRed(node),setBlack(node),inOrderPrint()
-
左旋方法定义:leftRotate(node)
-
右旋方法定义:rightRotate(node)
-
公开插入接口方法定义:insert(K key,V value)
-
内部插入接口方法定义:insert(RBNode node)
-
修正插入导致红黑树失衡的方法定义:insertFixUop(RBNode node)
-
测试红黑树正确性
package Test;
import java.util.HashMap;
public class RB_Tree<K extends Comparable<K>,V>{//实现泛型
//静态内部类
static final class RBNode<K extends Comparable<K>,V>{
RBNode left;
RBNode right;
RBNode parent;
boolean red;
K key;
V value;
public RBNode() {
}
public RBNode(RBNode left, RBNode right, RBNode parent, boolean red, K key, V value) {
this.left = left;
this.right = right;
this.parent = parent;
this.red = red;
this.key = key;
this.value = value;
}
public RBNode getLeft() {
return left;
}
public void setLeft(RBNode left) {
this.left = left;
}
public RBNode getRight() {
return right;
}
public void setRight(RBNode right) {
this.right = right;
}
public RBNode getParent() {
return parent;
}
public void setParent(RBNode parent) {
this.parent = parent;
}
public boolean isRed() {
return red;
}
public void setRed(boolean red) {
this.red = red;
}
public K getKey() {
return key;
}
public void setKey(K key) {
this.key = key;
}
public V getValue() {
return value;
}
public void setValue(V value) {
this.value = value;
}
}
//根节点
private RBNode root;
//获取当前结点的父节点
public RBNode parentOf(RBNode node){
return node==null?null:node.parent;
}
//获取当前结点的颜色
public boolean isRed(RBNode node){
return node != null && node.isRed();
}
//设置红色
public void setBlack(RBNode node){
node.setRed(false);
}
//设置黑色
public void setRed(RBNode node){
node.setRed(true);
}
//中序打印
public void inOrderPrint(){
inOrderPrint(this.root);
}
private void inOrderPrint(RBNode root){
if (root==null) return;
inOrderPrint(root.left);
String color=" ";
if (root.isRed()) color="R";
else color="B";
System.out.print(" [ key:"+root.key+ " color:"+color+" ] ,");
inOrderPrint(root.right);
}
/*RR型:左旋: 根为x,支点为y
1.x的右子节点指向y的左子节点,y的左子节点的父节点指向x
2.y的父节点指向x的父节点(若不为空),x的父节点的(左或右)子节点指向y
3.x的父节点指向y,y的左子节点指向x
*/
public void leftRotate(RBNode x){
RBNode y=x.right;
x.right=y.left;
if (y.left!=null) y.left.parent=x;
if (x.parent!=null){
y.parent=x.parent;
if (x==x.parent.left){
x.parent.left=y;
}else{
x.parent.right=y;//x为根节点,更新y为根节点
}
}else {
this.root=y;
}
x.parent=y;
y.left=x;
}
/*LL型:右旋: 根为x,支点为y
1.x的左子节点指向y的右子节点,y的右子节点(若不为空)的父节点指向x
2.y的父节点指向x的父节点(若不为空),x的父节点的(左或右)子节点指向y
3.x的父节点指向y,y的右子节点指向x
*/
public void rightRotate(RBNode x){
RBNode y=x.right;
x.left=y.right;
if (y.right!=null) y.right.parent=x;
if (x.parent!=null){
y.parent=x.parent;
if (x==x.parent.left){
x.parent.left=y;
}else{
x.parent.right=y;//x为根节点,更新y为根节点
}
}else {
this.root=y;
}
x.parent=y;
y.right=x;
}
//插入
public void insert(K key,V v){//公开插入接口
RBNode node=new RBNode();
node.setKey(key);
node.setValue(v);
node.setRed(true);//一定是红色
insert(node);
}
private void insert(RBNode node){
//情况1
if (this.root==null){
this.root=node;
root.setRed(false);
return;
}
//1.查找当前node节点的父节点
RBNode parent=null;
RBNode x=this.root;
while (x!=null){
parent=x;//后续x==null就不方便通过x的父节点得到了
int cmp=node.key.compareTo(x.key);
if (cmp>0) x=x.right;
else if (cmp<0) x=x.left;
else{
x.setValue(node.getValue());//情况2
return;
}
}
node.parent=parent;
//node是左子节点还是右子节点
int cmp=node.key.compareTo(parent.key);
if (cmp>0) parent.right=node;
else parent.left=node;
//剩下的6中情况,插入之后可能会影响红黑树的黑色平衡
insertFixUp(node);
}
/*
情况3:父节点为黑色,不需要处理
情况4:父节点为红色且叔叔节点红色,黑红红=》红黑红,并以爷爷结点为当前结点进行处理,
情况5:父节点红色且为左子节点,叔叔节点为null或者黑色
5.1:LL型 变色,右旋
5.2:LR型 先左旋,变色,右旋
情况6:父节点红色且为右子节点,叔叔节点为null或者黑色
6.1:RR型 变色,左旋
6.2:RL型 先右旋,变色,左旋
* */
public void insertFixUp(RBNode node){
if (node==this.root) {
setBlack(this.root);
return;
}
RBNode parent=parentOf(node);
RBNode pParent=parentOf(parent);
//情况4开始处理,如果父节点是红色那一定有爷爷结点
if (parent!=null&&isRed(parent)){
RBNode uncle=null;
if (parent==pParent.left) {
uncle=pParent.right;
//5.1
if (uncle==null||!uncle.isRed()){
if (node==parent.left){
setBlack(parent);
setBlack(uncle);
setRed(pParent);
rightRotate(pParent);
}
//5.2
else {
leftRotate(pParent);
setBlack(parent);
setBlack(uncle);
setRed(pParent);
rightRotate(pParent);
}
}
}
else{
uncle=pParent.left;
//6.1
if (uncle==null||!uncle.isRed()){
if (node==parent.right){
setBlack(parent);
setBlack(uncle);
setRed(pParent);
leftRotate(pParent);
}
//6.2
else {
rightRotate(pParent);
setBlack(parent);
setBlack(uncle);
setRed(pParent);
leftRotate(pParent);
}
}
}
//4.
if (uncle!=null&&isRed(uncle)){
setBlack(parent);
setBlack(uncle);
setRed(pParent);
insertFixUp(pParent);
}
}
}
}
测试:
package Test;
import java.util.Scanner;
public class TestRB {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
RB_Tree<String, Object> rbT = new RB_Tree<>();
while (true){
String key=scanner.next();
rbT.insert(key,null);
rbT.inOrderPrint();
System.out.println();
}
}
}