一、HashMap数据结构
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
HashMap间接实现了Map接口,还实现了 Cloneable和Serializable接口,说明可以被拷贝和序列化,HashMap是使用键值对保存数据,每一个键值对就是一个节点,也就是其静态内部类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;
}
hash和key属性使用了final修饰,说明初始化后不可更改,value没有使用final修饰,是因为当插入节点的key已经存在时,需要将value进行更新。
为了更方便的遍历,HashMap里面还不止Node这个内部类。
例如:KeySet,间接实现了Set接口,可以通过HashMap的实例获得该类的实例,里面保存了键值对的键,但并不是保存了两份键,引用的是同一个对象。
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
.....
}
我们可以插入<aa,new A()>验证,然查看HashMap的实例对象map中的aa,和KeySet实例对象set1中的aa,发现其实是一样。
还有Values,间接实现了Collection接口,保存的是值,和上面一样,不是保存了两份值,引用的是同一个对象。
final class Values extends AbstractCollection<V> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<V> iterator() { return new ValueIterator(); }
public final boolean contains(Object o) { return containsValue(o); }
public final Spliterator<V> spliterator() {
return new ValueSpliterator<>(HashMap.this, 0, -1, 0, 0);
}
...
}
还有EntrySet,键和值都保存了,间接实现了Set接口,用Set保存Entry<K,V>节点。
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
存储结构在JDK1.8里面使用的是数组+单链表+红黑树
为什么要用单链表?
List集合是可以存储重复元素的,并且是逐个顺序存储的,所以仅仅使用数组就足够。Map集合规定不能添加重复key,并且利用hash值通过计算得出下标再插入,这样难免会遇到不同对象计算得出同样的下标发生hash冲突不能插入,因为不同的hash值通过同样的算法是可以得到相同下标的,所以就加入了链表,让hash值相同却是不同对象的节点排成一条链表。
为什么要使用红黑树?
链表的遍历是非常慢的,还不及数组直接通过下标获取,于是又在1.8里加入了红黑树,红黑树其实就是一种平衡排序二叉树,即任意一个节点的左孩子要么为空要么比自己小,右孩子要么为空要么比自己大(关于排序二叉树可以参考我的往期博客:排序二叉树),且根节点的左子树和右子树高度之差的绝对值不超过1,平衡排序二叉树查找类似于折半查找,效率非常高。
为什么叫红黑树?
我觉得其实就是通过定义红黑相关的规则,只要满足这个规则就一定会生成平衡排序二叉树,而普通的平衡排序二叉树为了达到平衡每次插入都需要进行检查,这样浪费时间和资源。
因此可以得出:如果没有发生hash冲突,那就是一个数组,发生了hash冲突但不严重,结构就为数组+链表,发生了严重的hash冲突也就是有些链表节点数目达到8且数组长度达到64,那么节点数目达到8的链表就会转变成红黑树,于是结构就为数组+链表+红黑树。
如图:
二、初始化
HashMap提供了四种构造方法,可以不传入参数,可以传入加载因子和初始容量以及Map对象。
通过加载因子和容量相乘可以得到容量阈值,当存储的数据达到阈值后就会进行扩容。
为什么要设置阈值?
我认为主要是为了减少hash冲突,当一个容器里数据越多的时候hash冲突的概率是越大的,就像容量16,当已经加了15个元素后发生hash冲突的概率是非常非常大,于是就可以设置阈值提前增大容量减小hash冲突概率。
HashMap的容量都是2的次方数,如果不设置初始容量默认为16,加载因子默认为0.75,0.75是官方综合查找效率和利用率给出的一个较好的值,但如果自定义容量不是2的倍数呢?
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;
}
通过上面这一套算法下来就会将容量设置为大于自定义容量的最小的2的次方数。
在new一个HashMap对象的时候,HashMap中的数组还没有初始化,只是确定了数组的初始大小和加载因子以及阈值。在往里面添加第一个元素的时候开始初始化。
三、put操作过程
我们可以使用put方法往集合里添加元素,假设本次第一次使用,分析源码。
put里面调用putVal方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在传参的过程中还调用了hash方法计算key的hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hashcode返回的是一个int型的数,所以hash值的计算方法其实就是hashcode高16位和低16位相与。
为什么要采用这种方法,有一种说法是:数组长度一般不会很大,table.length-1的长度不会超过16位
而高16位就会被浪费, 所以采用这种方式。
计算出hash值后传给putVal方法,这里只显示本次运行的代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否为null或者长度为0
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);
....
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首先会判断数组是否为null或者长度为0,第一次加入元素的时候肯定是为null,于是调用resize方法。
这里只显示本次运行的代码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
....
else if (oldThr > 0) // 如果初始化的时候没有指定容量那么okdThr为0,
//否则okdThr就为大于自定义容量的最小的2的次方数
newCap = oldThr;
else { // 没有指定容量运行这里
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
//阈值为16*0.75=12
}
threshold = newThr;//新的容量
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//通过容量new一个数组
table = newTab;//赋给全局变量table
.....
return newTab;//返回table
}
流程大概是这样的:
返回table再次回到putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否为null或者长度为0
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);
....
++modCount;//修改次数加1
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
想要获得一个不越界的下标,就得获得一个比数组长度小的数,那么就可以采取一个数向数组长度取余,这样获得的数一定是小于数组长度的,但是cpu取余运算相比位运算慢很多,所以就改成位运算,但其实就是取余运算。
hash%n — > i = (n - 1) & hash
如果计算出下标发现下标对应的数组元素为null,那么就插到该位置,因为这次是第一次调用所以肯定为null,直接插到该位置。
然后修改次数加1,判断插入后数据里是否大于了阈值,这里是第一次插入所以不会大于,最后返回null,返回null代表插入成功。
下面模拟第n次插入(n大于1小于阈值)
依旧是putVal方法,这次删除了第一次调用运行的代码
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 ((p = tab[i = (n - 1) & hash]) == null)//计算得出下标判断是否为null
tab[i] = newNode(hash, key, value, null);//为null直接插入
else {//不为null运行这里
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//hash相等的情况下判断key本身相不相等
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);
//判断链表长度是否达到7,binCount是插入之前的数,
//之前为7那么插入后就等于8了,就开始转变成树。
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);//树化
break;
}
//中途找到了hash值相等key对象本身也相等的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;//跳出循环
p = e;
}
}
if (e != null) {
//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;
}
在这个方法中,运行流程大概就是这样的
这里就涉及到了一个面试题:为什么重写equals方法后还要重写hashcode方法? 我觉得主要分为两点
①如果不重写hashcode,就不能保证HashMap中键唯一,我们知道String重写了equals和hashcode方法,如果他没有重写hashcode,用String做键插入集合中时,就会发生,明明通过equals判断是相等的两个对象作为键时,插入了同一个集合。
例如:
String s = "aa"; String ss = new String("aa");
因为重写了equals方法,所以这个用equals比较是相等的,如果没有重写hashcode那么hashcode是不相等,因为一个保存在常量池一个保存在堆里地址不一样,hashcode不相等那么大概率就不会发生hash冲突就会插入成功,违背了HashMap的特性,所以得重写hashcode方法自定义规则。
②为了高效率,在HashMap中,有了hashcode就能得到hash值,通过hash值可以直接定位下标,如果没有重写hashcode就得利用equals方法逐个比较,浪费时间。
插入数组很简单直接赋值即可,插入链表也很简单,让链表的最后一个节点的next属性指向新节点即可,那么怎么插入树呢?
首先研究链表怎么形成树
假设该条件成立
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
查看treeifyBin源码
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果数组为null或者数组长度小于64不满足树化条件
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();//扩容
else if ((e = tab[index = (n - 1) & hash]) != null) {
//否则先判断新节点hash值对应下标的元素是否为null
TreeNode<K,V> hd = null, tl = null;
//开始遍历
do {
//Node节点转换成树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;//先定义个头节点
else {
p.prev = tl;//将第n(n>1)个节点的前驱节点设置为第n-1个节点
tl.next = p;//将第n-1个节点的后继节点设置为第n个节点
}
tl = p;//将n个节点变成n-1个节点,也就是新一轮循环中新节点变成旧节点
} while ((e = e.next) != null);//直到为null
if ((tab[index] = hd) != null)//判断头节点是否为null
hd.treeify(tab);
}
上诉代码过程简单概括就是:将Node节点变成树节点,然后将树节点连成双向链表。
为什么要转换节点?
因为属性不同。
Node节点:
final int hash;//hash值
final K key;//键
V value;//值
Node<K,V> next;//后继
树节点:
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left;//左孩子
TreeNode<K,V> right;//右孩子
TreeNode<K,V> prev; //前驱 一个只是在双向链表的时候用
boolean red;//是否是红色
其实看源代码可以发现,树节点是Node节点的孙子,所以树节点不用定义后继节点next也能使用next属性。
继续回到变成树的过程,最后不出意外的话调用该方法:
hd.treeify(tab);
查看源代码:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;//根节点
for (TreeNode<K,V> x = this, next; x != null; x = next) {//开始遍历链表
next = (TreeNode<K,V>)x.next;//获取下一个节点
x.left = x.right = null;//先让左右孩子为null
if (root == null) {//如果还没有根节点运行这个
x.parent = null;//根节点没有父节点
x.red = false;//根节点为黑色
root = x;//把这个节点变成根节点
}
else {//有根节点运行这个
K k = x.key;//获取key
int h = x.hash;//获取hash值
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)//如果根节点的hash值大于要插入节点的hash值
dir = -1;
else if (ph < h)//如果根节点的hash值小于要插入节点的hash值
dir = 1;
//如果相等的话就得去比其他的了
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//根据dir判断要插到左边还是右边,且判断左边和右边是否为null,
//因为只有为null才能插入
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;//插到左边
else
xp.right = x;//插到右边
root = balanceInsertion(root, x);//使二叉树平衡
break;//插入成功了跳出循环否则进入他的孩子节点继续遍历
}
}
}
}
moveRootToFront(tab, root);
}
关于排序二叉树怎么插入可以参考我的往期博客:排序二叉树。
这里又有一个方法调用,使得二叉树平衡。
前面说过,只要让二叉树遵守规则插入,就一定是平衡排序二叉树也就是红黑树。
规则如下:
- 每个节点要么是黑色要么是红色
- 根节点是黑色
- 每个叶子节点是黑色
- 每个红色节点的两个子节点一定都是黑色,不能有两个红色节点相连
- 任意一节点到每个叶子节点的路径都包含数量相同的黑节点
- 每个新节点初始状态为红色
比如:
这里叶子节点没画出来,因为都是null。
每次插入一个新节点不一定是满足这些规定得,所以就有三种基本操作来使得该树满足这些规定:
- 变颜色
- 左旋
- 右旋
查看源代码可以发现就是按这三种方法来操作的
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true;
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
return root;
if (xp == (xppl = xpp.left)) {
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);//右旋
}
}
}
}
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);//左旋
}
}
}
}
}
}
具体怎么操作这里不再深究。
刚刚讲的是满足条件后转变成树,那么已经有树了怎么插入树呢?
看源代码:
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;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;//如果在树中发现了该节点已经插入那就返回该节点
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
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);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.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;
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
这个过程跟链表树化差不多,只是多了一个检测,判断数中是否已经存在了该节点。
内容过多以下内容可查看:深入源码谈HashMap(二)