Map学习
Map是java中存储key-value数据的容器类,按照惯例我们将从最底层的Map接口开始学习,了解其该拥有哪些基本的方法。
一 顶层接口
1. Map接口
先从接口注释学起,
-
Map是一个存储key-value映射关系的容器,key不可重复,keyvalue是一一对应的关系。
-
可返回key集合、value集合、及映射关系集合,返回顺序取决于迭代器,或者根据子类特殊指定的顺序。
-
Map对象本身不能当做key,但可以作为value
public interface Map<K,V> {
// 最大返回值也只能是Integer.MAX_VALUE
int size();
boolean isEmpty();
// key 为null会报异常,key类型符合要也会抛ClassCastException
boolean containsKey(Object key);
boolean containsValue(Object value);
// 增删改查的逻辑
V get(Object key);
V put(K key, V value);
V remove(Object key);
void putAll(Map<? extends K, ? extends V> m);
void clear();
// 三个视图方法
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
boolean equals(Object o);
int hashCode();
}
以上是Map中拥有的基本方法,从中可以看出最初的设计思路,
- 作为一个容器类,需要考虑该容器存储什么类型的数组,这决定了增删改查方法的设计。
- 还需要对提供容器的判空、是否包含指定元素、容器之间的比较等方法。
- 最后容器类不仅关心容器内元素的进出,更需要考虑容器作为一个整体如何对外提供容器内数据展示,这就是三个视图方法的目的。
注意到上面entrySet方法返回的是一个Entry对象,这是一个键值对。
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
当然java8 在Map接口中新增了很多有默认实现的方法,这些方法在不修改子类的情况下扩展了Map的能力,下面将挑部分进行讲解。
getOrDefault
这是可以设置默认值的查询方法,当未找到key对于的value时返回默认值,而不是null
default V getOrDefault(Object key, V defaultValue) {
V v;
return (((v = get(key)) != null) || containsKey(key))
? v
: defaultValue;
}
forEach
内部遍历map,并对每个元素执行action的处理,
default void forEach(BiConsumer<? super K, ? super V> action) {
Objects.requireNonNull(action);
for (Map.Entry<K, V> entry : entrySet()) {
K k;
V v;
try {
k = entry.getKey();
v = entry.getValue();
} catch(IllegalStateException ise) {
// this usually means the entry is no longer in the map.
throw new ConcurrentModificationException(ise);
}
action.accept(k, v);
}
}
以上默认方法的实现都依赖于,接口中基础抽象方法,相当于把在外部经常使用map的一些模式提出来加入到Map中,方便了使用。
以前是砖块决定了楼层的搭建,现在是楼层搭建方式改变了砖块。
2.AbstractMap抽象类
AbstractMap中提供了Map接口中大部分的默认实现,但都依赖entrySet()方法获取kv集合。
二 具体实现
1.HashMap
先从注释看起
-
非线程安全,允许key 或 value为null,不保证返回顺序。
-
loadFactor 是加载因子,默认为0.75 该变量意义就是
实 际 数 组 大 小 > 数 组 容 量 ∗ l o a d F a c t o r 实际数组大小>数组容量*loadFactor 实际数组大小>数组容量∗loadFactor
就会分配进行扩容。
- 如果开始就有很多数据需要存储,则最好初始化HashMap时就指定一个较大的容量。
- 可以通过Collections中的synchronizedMap方法得到一个线程安全的Map。
- 和collection集合类一样,在迭代时发生结构性变化会终止迭代,抛出ConcurrentModificationException
HashMap主要做的工作提供合理的计算哈希值的函数,且尽可能减少哈希冲突概率。这样就能使元素均匀散列在不同的bucket上,最大化体现复合型数据结构的优势。
存储结构
通过查看类中的成员变量我们可以快速了解HashMap的存储结构。
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {
// 默认初始化容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 哈希表最大尺寸
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认装填因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当一个桶上链表长度大于8时会转换成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当一个桶上红黑树小于等于6时会转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 当数组容量大于等于64时,才会将桶中的符合条件的链表转换为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
// 实际存放数据的数组
transient Node<K,V>[] table;
// 键值对的副本
transient Set<Map.Entry<K,V>> entrySet;
// size实际大小
transient int size;
// 修改次数 版本号
transient int modCount;
// 数组扩容的阀值
int threshold;
// 装填因子
final float loadFactor;
}
打脸了,看了上面的代码也不清楚HashMap是怎么存储数据的,数据结构中的哈希表使用哈希算法计算得出索引,基本方式是对数组容量取余,那就看下hash算法源码。
hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 得出
index = (n-1) & (h = key.hashCode()) ^ (h >>> 16)
hash算法就是根据hash值生成一个数组索引。
index = node.hashCode % n
这种方式并不高效,采用实际采用的如下方式:
index = (n - 1) & hash(key);
首先我们的hash数组的length一定是2的幂,假设hashCode是51(0011 0011) 数组长度为16(0001 0000);由于length是2的幂则其二进制表示中肯定只有一个1,如:0000 1000 ,hashCode中高于这个1的位置的数据都能整除length,低于这个1的部分就是余数。
0011 0011 = 51
0001 0000 = 16
————————————————
0000 0011 = 3
通过位运算可以保留余数部分
0011 0011 & 0000 1111 = 0000 0011
而
0000 1111 = 0001 0000 - 0000 0001
所以 index = hashCode & (n-1) n为2的幂,得到了求余的方式。
上面根据取余的方式计算索引有个问题,当只有大于length的高位在变化时,求得的余数都是一样的。
即高位的变化影响不到余数,导致的结果就是冲突概率很高。
求解hash的部分,解决办法就是右移16位再与自身进行与运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
求解hash的部分,解决办法就是右移16位再与自身进行与运算,hashCode 是32位的,右移16位将
高位数据和低位数据拉到了一起,进行与运算产生最终的hashCode。
举例
hashCode1 = 0101 0011 = 83
hashCode2 = 0001 0011 = 19
n = 0001 0000 = 16
index1 = hashCode1 & n-1 = 0101 0011 & 0000 1111 = 0011 = 3
index2 = hashCode2 & n-1 = 0001 0011 & 0000 1111 = 0011 = 3
# 此处我们实例使用8位,所以用4,。
hashCode1 = hashCode1 ^ (hashCode1 >>> 4) = 0101 0011 ^ 0000 0101 = 0101 0110
hashCode2 = hashCode2 ^ (hashCode2 >>> 4) = 0001 0011 ^ 0000 0001 = 0001 0010
index1 = hashCode1 & n-1 = 0101 0110 & 0000 1111 = 0000 0110 = 6
index2 = hashCode2 & n-1 = 0001 0010 & 0000 1111 = 0000 0010 = 2
经过以上算法处理,产生的hash索引分到了不同的位置。
小结:
(1)原始hash值与无符号右移一半位置的hash值做异或操作,得到结合高低位的新hash值。
(2)新hash值与n-1 做与操作得到最终索引。
插入节点
在插入流程基本可以看到HashMap的存储结构。
putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
// tab是节点数组,i为hash对应下标,p为下标为i的位置对应节点,
// i下标可能存在多个节点,p会依次向下变动
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.判断节点数组长度,如果为空或者length为0,则进行初始化。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2.判断hash值对应坐标是否为空,如果为空则创建新节点并赋值。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//3.不为空则说明坐标冲突,则先判断hash值在进行key比较。
// e是与传入key相同的节点(暂定),k是相同节点对应key
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//4.判断节点是不是树节点,是则走树节点插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//5.循环遍历链表节点,尾部为null时插入新节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 6.如果链表节点长度大于等于8则将其链表转换为树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//7. 如果找到匹配节点则中断遍历。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//8. 没有找到匹配节点,p则向下移动
p = e;
}
}
//9. 找到匹配节点e,则返回旧节点值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 查询到节点后的回调方法。
afterNodeAccess(e);
return oldValue;
}
}
//10. 前面两个分支,当p节点不为空则前面就返回了。
// 当p节点为null时,插入了新节点(插入了数组中),对原有存储结构进行了变动(尺寸变动)。
++modCount;
//11. 当数组尺寸大于扩容阀值,则触发数组扩容。
if (++size > threshold)
resize();
// 在数组中插入元素后的回调方法
afterNodeInsertion(evict);
return null;
}
小结一下:
- 先判hashmap是否初始化过,否则进行首次扩容初始化。
- 判断Key的hash值计算得出的数组下标是否为null,如果为null则创建新节点插入数组。
- 如果不为空,则说明和已有节点索引冲突,需要将该节点插入到冲突节点字链表中或子树中。
- 如果是树结构,走单独插入逻辑。
- 如果是链表,则向下匹配,未找到则插入到链表末尾;判断链表长度,大于等于8则将其转换为树。
- 找到则暂存游标终止遍历,根据onlyIfAbsent决定是否覆盖,最后返回找到节点的旧值。
- 如果在数组中插入了新节点,需要判断是否进行扩容。
插入元素涉及三种数据结构,节点数组、节点链表、节点红黑树。插入元素先在数组中插入,下标有冲突则考虑插入链表中,链表长度>=8则转化为红黑树。
学习的小册中找到一张图,可以清晰的展示存储结构。
putTreeVal
普通插入算法中,如果是槽内根节点是红黑树,则需要走红黑树插入逻辑,putTreeVal是TreeNode中的方法,代码如下:
/**
* map 是要插入节点的hashmap
* tab 是存储节点的槽数组
* h k v 是要插入节点的hash值、key 和 value
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
Class<?> kc = null;
// 标记是否已经便利过一次树
boolean searched = false;
// 首先知道根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
// 自旋
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// p是遍历节点,ph是p节点hash值,dir是方向
if ((ph = p.hash) > h)
dir = -1;// 小于0说明要插入的h应该在p的左侧
else if (ph < h)
dir = 1;// 大于0说明要插入的h应该在p的右侧
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;// 匹配说明找到替换的节点了p,外层方法进行值替换操作。满足hash值相等和key相等两个条件
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 此时两种情况:(1)k没有实现comparable接口,(2)k实现了comparable接口并且和p节点pk相同。
// serarched标记p节点是否被遍历过,没有遍历则进入递归遍历逻辑。
if (!searched) {
// q是子树中找到的节点,ch是p的子节点。
TreeNode<K,V> q, ch;
searched = true;
// 红黑树是平衡二叉树,这边使用短路逻辑,先从左侧遍历,再从右侧遍历,先找到先终止。
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 没找到可替换节点,需要进行插入逻辑,这里计算从哪边开始插入。
dir = tieBreakOrder(k, pk);
}
// 这里首先根据插入方向dir对p进行更新,如果为空则进行新节点插入
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next; //获取更新前p节点的next
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);// 创建新的树节点
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//balanceInsertion 对红黑树进行着色或旋转,以达到更多的查找效率,着色或旋转的几种场景如下
//着色:新节点总是为红色;
//如果新节点的父亲是黑色,则不需要重新着色;
//如果父亲是红色,那么必须通过重新着色或者旋转的方法,再次达到红黑树的5个约束条件
//旋转: 父亲是红色,叔叔是黑色时,进行旋转
//如果当前节点是父亲的右节点,则进行左旋
//如果当前节点是父亲的左节点,则进行右旋
moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡
return null; // 返回null 说明插入了操作产生了一个新的节点。
}
}
}
扩容算法
上面流程中涉及到了数组的扩容,这里也学习一下。扩容需要先了解下需要扩多大,
- 初次扩容使用tableSizeFor方法就是用于计算容量,得出的值总是2的幂。
tableSizeFor
计算容量,返回的是结果肯定是2的幂。其中右移后进行或操作。这个方法可以计算出大于等于cap 的最近的那个2的幂。
static final int tableSizeFor(int cap) {
int n = cap - 1; // 假设n = 9 = 00001001
n |= n >>> 1;// n = 00001001 | 00000100 = 00001101 = 13
n |= n >>> 2;// n = 00001101 | 00000011 = 00001111 = 15
n |= n >>> 4;// n = 00001111 | 00000000 = 00001111 = 15
n |= n >>> 8;// ...
n |= n >>> 16;// ...
// 最后在对 n+1 得到 16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
- 使用resize()方法进行扩容,
final Node<K,V>[] resize() {
// 复制旧有的Node数组
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;
}
// 如果新容量和阀值都是旧容量和阀值的两倍,<<1
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
// oldCap = 0 但 oldThr > 0 ,这里应该是减小容量的逻辑。
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 如果oldCap=0则说明未初始化。
newCap = DEFAULT_INITIAL_CAPACITY;
// 使用装填因子*默认容量得到,初次扩容阀值。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
// 这里对应了oldCap = 0 但 oldThr > 0的情况,上面的逻辑没有对newThr赋值。
float ft = (float)newCap * loadFactor;
// 设置新的扩容阀值。
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 修改总体阀值
threshold = newThr;
// 创建新的容量的数组,并修改总体table。
@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) {
Node<K,V> e;
// 遍历获取元素并判空,源码中有很多相似逻辑
if ((e = oldTab[j]) != null) {
// 清空数组对应位置。
oldTab[j] = null;
if (e.next == null)
// 如果j位置只有一个节点(不是链表或树)
// 将旧节点填充到新数组相应位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果节点是树节点,将树节点拆分填充到新数组中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 如果节点是链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 取到节点的下一个元素
next = e.next;
// oldCap是2的幂,所以只有一位是1其他位都是0
// 这个算法将一个长链表拆分再连为两部分。
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;
}
// 对扩充后j + oldCap 的桶赋值,使用刚拆封出来的高位链表。
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
2.TreeMap
TreeMap是基于红黑树实现的,确保了其containsKey、get、put、remove 等操作的复杂度都是log(n)
public class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, java.io.Serializable{
// 可通过构造函数传入比较器,决定了map中key的顺序,否则使用元素原生的比较器。
private final Comparator<? super K> comparator;
// 红黑树根节点
private transient Entry<K,V> root;
// map内元素个数
private transient int size = 0;
// 结构性修改次数
private transient int modCount = 0;
// TreeMap的视图结构
private transient EntrySet entrySet;
private transient KeySet<K> navigableKeySet;
private transient NavigableMap<K,V> descendingMap;
}
小结:
- TreeMap中key的顺序取决于比较器,优先使用外部传入比较器,其次使用key自带的比较方法。
- TreeMap实现了NavigableMap接口,拥有一系列导航定位的方法。
- TreeMap实现了Cloneable接口和Serializable接口,可以被序列化和克隆。
存储结构
既然是红黑树,这里看下节点结构。
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;// 当前节点key
V value;// 当前节点value
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;// 节点默认颜色为黑色
}
红黑树是一种含有红黑结点并能自平衡的二叉查找树。它必须满足下面性质:
- 性质1:每个节点要么是黑色,要么是红色。
- 性质2:根节点是黑色。
- 性质3:每个叶子节点(NIL)是黑色。
- 性质4:每个红色结点的两个子结点一定都是黑色。
- 性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。
插入节点
put方法会将新节点插入树中,如有相同key则进行替换操作并返回旧value,如果是新增节点则返回null。
public V put(K key, V value) {
// 1.根节点如果为空则创建新节点
Entry<K,V> t = root;
if (t == null) {
compare(key, key); // 确保key不能为null
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
// 2.如果TreeMap自带比较器则使用自带比较器进行比较。
if (cpr != null) {
do { // 自旋遍历红黑树,依次进行比较,parnent 记录了未匹配到key,终止循环时的父节点
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
// key 小于t.key则说明匹配位置应该在左子树上
t = t.left;
else if (cmp > 0)
// key 大于t.key则说明匹配位置应该在右子树上
t = t.right;
else
// key相同则进行替换赋值
return t.setValue(value);
} while (t != null);
}
else {
// 使用元素自带比较器进行遍历
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 2.此时说明树中没有key相同节点,创建新节点。
Entry<K,V> e = new Entry<>(key, value, parent);
// 在最后的父节点中插入新节点
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 插入节点后进行旋转着色操作,维护平衡性。
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
小结:
- 首先是查找过程,利用红黑树特性快速查找与key匹配的节点,找到匹配节点则替换新值返回旧值,未找到则记录了可供插入的叶子节点的父节点(子节点为null的节点)。
- 插入过程,创建新节点插入到父节点中,着色旋转维护平衡性。
- TreeMap不允许使用null为key。
插入自平衡
private void fixAfterInsertion(Entry<K,V> x) {
// 首先将新插入节点置为红色
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK;
}
删除节点
删除节点使用的remove方法,内部实现是deleteEntry方法,这里可以了解一下:
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
// 情况一:p有两个子节点
// 当左右节点都不为null时,使用successor找到p节点对应的前驱或者后继节点。
// 当把红黑树上的节点投射到与叶子层平行的水平线上时,节点序列是按从左到右的顺序依次递增的。
// 前驱节点就是p的前一个相邻节点,后继节点就是p的后一个相邻节点。
// 删除p节点相当于把s节点移动到p的位置上,再在原有位置上删除s节点。
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
}
// 如果最开始p拥有两个子节点,经过上面过程,p已经被替换为前驱或后继节点s。
// 而前驱节点肯定没有right节点,后置节点肯定没有left节点。
// 所以:此时的p 只有一个子节点,另一个子节点为null
// 情况二:p只有一个子节点 ,replacement就是要替换p的子节点。
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {
// 将子节点replacement连接到parent节点上。
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;
// 如果p.color是RED,其子节点一定是黑色的,删除一个红色节点替换为黑色子节点,
// 对该子节点而言通往root路径上的黑色节点数量不变,平衡不变。
// 如果p.color是BLACK,删除一个黑色节点就需要调整平衡性。
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
// 情况三:p没有子节点
// p为黑色叶子节点,删除p需要调整平衡性。
if (p.color == BLACK)
fixAfterDeletion(p);
// p的left和right都是null了,此处要清空p和红黑树的关系。
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
删除自平衡
以上插入和删除操作都会涉及到自平衡方法fixAfterDeletion,它对删除后补位的元素进行调整,这里看下该方法的实现过程。
private void fixAfterDeletion(Entry<K,V> x) {
// x是被删除所在位置的节点,(有可能是补位的节点,也有可能是被删除的节点)
// 红黑树的平衡是从底部到顶部(根部)的过程
while (x != root && colorOf(x) == BLACK) {
// 情况1:被替换的节点是左节点
if (x == leftOf(parentOf(x))) {
// x的兄弟节点
Entry<K,V> sib = rightOf(parentOf(x));
// 1.1 兄弟节点为红色
if (colorOf(sib) == RED) {
setColor(sib, BLACK);// 将兄弟节点置为黑色
setColor(parentOf(x), RED);// 将父节点置为红色
rotateLeft(parentOf(x)); // 父节点的右侧黑色节点层数变多,以父节点为中心左旋调整。
sib = rightOf(parentOf(x));// 左旋后原来父节点right节点会变动,更新兄弟节点sib。
}
// 1.2 替换节点的兄弟节点是黑色(原本是黑色或经上一步改为黑色),
// 兄弟节点的子节点都是黑色
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);// 将兄弟节点改为红色
x = parentOf(x);// 将x指向x节点的父节点
} else {
if (colorOf(rightOf(sib)) == BLACK) {
// 1.3 兄弟节点的右子节点是黑色,左子节点是红色
setColor(leftOf(sib), BLACK);// 将左节点置为黑色
setColor(sib, RED);// 兄弟节点由黑转红
rotateRight(sib);// 兄弟节点的left黑色节点层数变多,右旋调整
sib = rightOf(parentOf(x));// 更新兄弟节点
}
// 1.4 兄弟节点左子节点
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { // symmetric
// 被替换的节点是右节点
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}
setColor(x, BLACK);
}
查找元素
查找元素逻辑和插入元素的逻辑类似,代码如下:
final Entry<K,V> getEntry(Object key) {
// 同样优先使用TreeMap中的比较器进行查找
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
// 从根节点进行遍历查找
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
3.LinkedHashMap
HashMap是无序的,TreeMap是按照Key的大小进行的排序,而LinkedHashMap是按照插入的顺序进行排序的,其最重要的两个特性是:
- 可以按照插入顺序进行访问
- 可以实现LRU
下面的学习主要关注以上两点的实现。
存储结构
public class LinkedHashMap<K,V> extends HashMap<K,V>implements Map<K,V>{
// 双向链表头结点,维护的是最早插入的节点
transient LinkedHashMap.Entry<K,V> head;
// 双向链表尾节点,维护的是最新插入的节点
transient LinkedHashMap.Entry<K,V> tail;
// 访问顺序标记符,false为按插入顺序方法
// true为按访问顺序,会调整节点顺序,将经常访问节点放到尾部
final boolean accessOrder;
// 扩展节点,增加了前驱节点before 和 后继节点after
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
}
LinkedHashMap继承HashMap,所以底层数据存储依旧是数组+链表+红黑树,不同的地方在于其扩展了节点,
按插入顺序维护了一个双向链表,这是实现按插入顺序进行访问的关键。
插入节点
上面可知LinkedHashMap中扩展的节点有两个新增属性,这两个属性是什么时候赋值的呢?LinkedHashMap自己没有实现插入方法,使用的是HashMap中的put方法,但覆写了newNode和newTreeNode方法,在里面完成了前驱和后继的连接过程。
调用过程如:put->putVal->newNode/newTreeNode->linkNodeLast
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);// 连接节点
return p;
}
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
linkNodeLast(p);// 连接节点
return p;
}
// 双向链表尾插法
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// 插入第一个节点时,头尾指向同一个节点
if (last == null)
head = p;
else {
// 完成p的前驱节点和链表尾节点连接
p.before = last;
last.after = p;
}
}
通过以上过程,每个新增节点都会被按插入顺序插入到链表中。
删除节点
删除节点的方法同样使用HashMap中的remove方法,但还需要从双向链表中删除该节点才算完全删除。
这里就用到了在HashMap中删除元素后的回调方法afterNodeRemoval,linkedHashMap覆写了这个方法,其中实现了从双向链表中删除节点的逻辑。
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// p是待删除节点,b是前驱节点,a是后继节点
p.before = p.after = null;
if (b == null)
head = a;// 前驱节点为null,则将head指向后继节点
else
b.after = a;// 前驱节点不为null,则将前驱节点和后继节点连起来
if (a == null)
tail = b;// 后继节点为null,则将tail指向前驱节点
else
a.before = b;// 后继节点不为null,则将后继节点和前驱节点连起来
}
顺序访问
通过LinkedHashIterator 可以依次访问LinkedHashMap中所有元素,访问过程如下:
LinkedHashIterator() {
next = head;// 头结点作为访问的第一个节点
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
因为在插入节点时,已经按照插入顺序将节点插入到双向链表当中,所以在遍历时只需要按顺序依次访问e.after就得到了节点插入顺序。
LRU实现
LRU是指最近最少使用,是一种页面置换算法,会将不经常使用的页面删除,经常用于设计缓存。Android中的LruCache其底层数据存储就是基于LinkedHashMap。
- 将访问过的节点放到队尾(队尾是最新插入的节点)
- 插入元素后如果满足删除策略,就删除最少访问的节点,新节点插入到队尾
下面使用一个demo来展示如何使用linkedHashMap来实现LRU
public static void testLru(){
LinkedHashMap<Integer,Integer> lruMap =
// 调用构造函数,指定accessOrder为true
new LinkedHashMap<Integer, Integer>(0,0.75f,true){
// 覆写删除策略,这里指定元素个数超过3则删除旧节点
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size()>3;
}
};
lruMap.put(1,1);
lruMap.put(2,2);
lruMap.put(3,3);
lruMap.put(4,4);
lruMap.put(5,5);
System.out.println(lruMap);
// {3=3, 4=4, 5=5} 插入了5个元素,根据删除策略,最早的两个节点被删除了
lruMap.get(5);
lruMap.get(4);
lruMap.get(3);
System.out.println(lruMap);
// {5=5, 4=4, 3=3} 最近访问的节点会被移动到队尾
}
问题1:插入节点时,何时删除了旧节点?
linkedHashMap 没有实现put方法,当调用父类HashMap的put方法时会调用afterNodeInsertion方法,通过覆写该方法实现删除逻辑。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 在允许删除并且删除策略为true时,删除头部节点
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
问题2:查询节点时,何时将访问节点移动到了队尾?
在调用get或getOrDefault 方法时都会回调afterNodeAccess方法,LinkedHashMap覆写了该方法,并在其中完成
public V get(Object key) {
Node<K,V> e;
// 使用HashMap中getNode方法获取节点
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)// 如果设置了按访问顺序读取
afterNodeAccess(e);// 将访问的节点放置到队列尾部
return e.value;
}
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return defaultValue;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
// p是要移动的节点,b是p前置节点,a是p后置节点
LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e
, b = p.before, a = p.after;
p.after = null;// 先断开p的后置连接
if (b == null)
head = a;// 如果前置节点b,不存在则将后置节点a设为头节点
else
b.after = a;// 否则将前置节点b和后置节点a连起来
if (a != null) // 后置节点a不为null则将后置节点a和前置节点b连起来
a.before = b;
else
last = b;// 后置节点a为null则将前置节点b设为尾节点
if (last == null)
head = p;// 尾节点为null则将p置为头节点
else {
p.before = last;// 尾节点不为null则将p节点插入到尾节点,
last.after = p;
}
tail = p;// 尾节点指向p
++modCount;
}
}
上面调整的过程可分为两部分,先把p节点摘下来,再将p节点插入到链表尾部。
三 总结
上面介绍了Java集合类中Map相关的代码,首先从Map接口讲起,Map集合用于存储Key-Value格式的数据,作为一个数据集合,提供了增删改查的接口规范以及描述集合整体数据的视图方法。具体实现部分先讲了HashMap,其底层数据存储是数组+链表+红黑树 ,围绕该存储结构讲解了hash算法和扩容算法。TreeMap底层是红黑树,节点按照key的大小进行了排序,这里的重点是插入删除节点后红黑树的自平衡。最后是LinkedHashMap是对Hashmap 的扩展,通过将节点维护成一个双向链表,保留了节点的插入顺序,通过覆写几个回调方法实现了LRU算法。