1、HashMap概述
在JDK1.8之前,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的节点都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。
下图中代表jdk1.8之前的hashmap结构,左边部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。
jdk1.8之前hashmap结构图
jdk1.8之前的hashmap都采用上图的结构,都是基于一个数组和多个单链表,hash值冲突的时候,就将对应节点以链表的形式存储。如果在一个链表中查找其中一个节点时,将会花费O(n)的查找时间,会有很大的性能损失。到了jdk1.8,当同一个hash值的节点数不小于8时,不再采用单链表形式存储,而是采用红黑树,如下图所示。
从上图中可以很清楚的看到,HashMap的数据结构是数组+链表+红黑树(红黑树since JDK1.8)。我们常把数组中的每一个节点称为一个桶。当向桶中添加一个键值对时,首先计算键值对中key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这种现象称为碰撞,这时按照尾插法(jdk1.7及以前为头插法)的方式添加key-value到同一hash值的元素的后面,链表就这样形成了。当链表长度超过8(TREEIFY_THRESHOLD)时,链表就转换为红黑树。
二、涉及到的数据结构:处理hash冲突的链表和红黑树以及位桶
1、链表的实现
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。来看具体代码:
//Node是单向链表,它实现了Map.Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
//key的哈希值
final int hash;
// final:说明节点的key不可变
final K key;
V value;
//指向下个节点的引用
Node<K,V> next;
// 构造函数Hash值 键 值 下一个节点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {// 同时考虑键和值
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
可以看到,node中包含一个next变量,这个就是链表的关键点,hash结果相同的元素就是通过这个next进行关联的。
2、红黑树
HashMap中的红黑树,同时也是一个双链表,parent、left、right和red字段构建了红黑树信息,而prev、next(next是基类HashMap.Node的字段)构建了双链表信息。树的root节点同时也是双链表的头结点,放在桶位数组上。这种双结构,使得红黑树操作比纯粹红黑树要复杂一些。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左孩子
TreeNode<K,V> right; // 右孩子
TreeNode<K,V> prev; // 前一个节点
boolean red; // true为红链接,false为黑链接
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
红黑树比链表多了四个变量,parent父节点、left左节点、right右节点、prev上一个同级节点。
putTreeVal 方法
HashMap的putVal方法会调用树方法putTreeVal来插入新的键值对。
该方法首先会查找树中是否有这个键,有的话就返回这个节点的引用,但注意,这里没有马上更新Value。查找的过程中,首先比较hash值,其次再比较Key,应该是考虑到比较int类型的速度更快。没有找到的话,会新建一个树的叶子节点,再调用树方法balanceInsertion插入新节点到红黑树中。
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;;) { // p为当前和新元素比较的节点
int dir, ph; K pk;
// 根据hash值比较大小
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// hash值相等时,如果键指向同一地址,查找结束
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// hash值相等,比较两个键
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 键不可比较,或者比较结果为0时,在节点的左右子树查找该Key,找到就结束
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;
// 将当前节点插入到p节点之后,注意这里的前后是双链表的前后
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
// 当前节点和p节点建立父子关系,这里是在树结构中
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;
}
}
}
treeify 方法
HashMap的treeifyBin
方法会调用树方法treeify
,来将双链表构建为红黑树。这个方法的主体和putTreeVal
方法类似(不同的是,它不会遇到重复Key值),向只含有一个元素的红黑树上不断添加新的节点。
// 在一个双链表中,将此节点之后的节点构建成红黑树结构,返回树根节点
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; // 初始化树节点
// 初始化根节点,根节点为黑链接
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
// 将每一个节点插入树中
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
// 根据hash值比较
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// hash值相等时,比较键
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// 如果节点的键不是Comparable类,
// 或者两个节点键比较的结果为相等,就强行让比较结果不为0
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
// 根据比较的结果判断在左子树还是右子树
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 子树为null,查找结束,将节点作为树叶子节点
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 从新添加的元素往上更新红黑树结构,见红黑树操作
root = balanceInsertion(root, x);
break;
}
}
}
}
// 确保红黑树的根节点,同时也是桶位的第一个节点,详见其他方法
moveRootToFront(tab, root);
}
红黑树操作:插入新节点
// 往红黑树中平衡插入节点x
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;;) {
// x为根节点
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
// xp为黑节点,或者xp为根节点时
// 根节点也是黑节点
// 黑节点的孩子可以是红,所以更新结束
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 下面的情况中父节点都为红----------------------------
// xp在xpp的左子树时
if (xp == (xppl = xpp.left)) {
// xp和xppr都为红
// 直接上浮颜色即可,见示意图1,x = xpp继续迭代
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp; // 上浮节点为xpp
}
// xp是红,xppr是黑时
// 见示意图2,最终转化为父节点为黑,所以下一轮就结束
else {
// 当前节点为右孩子时,左旋父节点
// 转变为xppl、xppll都为红的模式
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
// 转变为xp的左右孩子都为红的模式
if (xp != null) {
xp.red = false; // xp改为黑色
if (xpp != null) {
xpp.red = true; // xpp改为红色
root = rotateRight(root, xpp); // 右旋xpp
}
}
}
}
// xp在xpp的右子树时
else {
// xp和xppl都为红
// 直接上浮颜色即可,类似示意图1,x = xpp继续迭代
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp; // x上浮到xpp
}
// xp是红,xppl是黑时
// 见示意图3,最终转化为父节点为黑,所以下一轮就结束
else {
// 当前节点为左孩子时,右旋父节点
// 转变为xppr、xpprr都为红的模式
if (x == xp.left) {
root = rotateRight(root, x = xp); // 右旋父节点
xpp = (xp = x.parent) == null ? null : xp.parent;
}
// 转变为xp的左右孩子都为红的模式
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true; // xpp改成红色
root = rotateLeft(root, xpp); // 左旋xpp
}
}
}
}
}
}
}
红黑树操作:左旋
// 节点p左旋转,注意这里没有改变颜色
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
// 当前节点以及右孩子不为空时
if (p != null && (r = p.right) != null) {
if ((rl = p.right = r.left) != null)
rl.parent = p;
if ((pp = r.parent = p.parent) == null)
(root = r).red = false;
else if (pp.left == p)
pp.left = r;
else
pp.right = r;
r.left = p;
p.parent = r;
}
return root;
}
红黑树操作:右旋
// 节点p右旋转,没有改变颜色
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> l, pp, lr;
// 当前节点以及左孩子不为空时
if (p != null && (l = p.left) != null) {
if ((lr = p.left = l.right) != null)
lr.parent = p;
if ((pp = l.parent = p.parent) == null)
(root = l).red = false;
else if (pp.right == p)
pp.right = l;
else
pp.left = l;
l.right = p;
p.parent = l;
}
return root;
}
辅助方法
// 获得树的根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
// 确保树的根节点是桶中的双链表的第一个节点
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash; // 找到桶位
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
if (root != first) { // 当根节点不是桶中第一个元素时
Node<K,V> rn;
tab[index] = root; // 根节点放在桶的第一位
TreeNode<K,V> rp = root.prev; // 根的前一个节点
// 将根节点从双表中抽出,原来的位置前后链接
if ((rn = root.next) != null)
((TreeNode<K,V>)rn).prev = rp;
if (rp != null)
rp.next = rn;
// 根节点放在双链表的首位
if (first != null)
first.prev = root;
root.next = first;
root.prev = null;
}
assert checkInvariants(root);
}
}
// 从this树节点查找hash值为h,Key为k的节点
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q; // 当前节点的左右孩子
if ((ph = p.hash) > h) // hash值小的从左子树迭代查找
p = pl;
else if (ph < h) // hash值大的从右子树迭代查找
p = pr;
// hash值相等,且键地址相同或都为空时,查找成功
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// hash值相等,但键不相同,且节点没有左子树,就从右子树查找
else if (pl == null)
p = pr;
// hash值相等,但键不相同,且节点没有右子树,就从左子树查找
else if (pr == null)
p = pl;
// 比较两个Key
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
// Key不可比较或比较结果为0时,先在右子树中查找
else if ((q = pr.find(h, k, kc)) != null)
return q;
// 右子树查找不到时
else
p = pl;
} while (p != null);
return null;
}
// 从根节点查找hash值为h,Key为k的节点
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
// 强行比较两个对象,结果为-1或1
static int tieBreakOrder(Object a, Object b) {
int d;
// a和b都不为空时比较它们的类名
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
// a为null,或b为null,或类名也相等时,比较它们的内存地址
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
/**
* Returns a list of non-TreeNodes replacing those linked from
* this node.
*/
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
树形结构修剪 split()
HashMap 中, resize() 方法的作用就是初始化或者扩容哈希表。当扩容时,如果当前桶中元素结构是红黑树,并且元素个数小于链表还原阈值 UNTREEIFY_THRESHOLD (默认为 6),就会把桶中的树形结构缩小或者直接还原(切分)为链表结构,调用的就是 split():
//参数介绍
//tab 表示保存桶头结点的哈希表
//index 表示从哪个位置开始修剪
//bit 要修剪的位数(哈希值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//如果当前节点哈希值的最后一位等于要修剪的 bit 值
if ((e.hash & bit) == 0) {
//就把当前节点放到 lXXX 树中
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
//然后 loTail 记录 e
loTail = e;
//记录 lXXX 树的节点数量
++lc;
}
else { //如果当前节点哈希值最后一位不是要修剪的
//就把当前节点放到 hXXX 树中
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
//记录 hXXX 树的节点数量
++hc;
}
}
if (loHead != null) {
//如果 lXXX 树的数量小于 6,就把 lXXX 树的枝枝叶叶都置为空,变成一个单节点
//然后让这个桶中,要还原索引位置开始往后的结点都变成还原成链表的 lXXX 节点
//这一段元素以后就是一个链表结构
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//否则让索引位置的结点指向 lXXX 树,这个树被修剪过,元素少了
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
//同理,让 指定位置 index + bit 之后的元素
//指向 hXXX 还原成链表或者修剪过的树
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。
1.分类
指定位置、指定范围,让指定位置中的元素 (hash & bit) == 0
的,放到 lXXX 树中,不相等的放到 hXXX 树中。
2.根据元素个数决定处理情况
符合要求的元素(即 lXXX 树),在元素个数小于 6 时还原成链表,最后让哈希表中修剪的痛 tab[index] 指向 lXXX 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶;
不符合要求的元素(即 hXXX 树)也是一样的操作,只不过最后它是放在了修剪范围外 tab[index + bit]。
3、位桶
transient Node<k,v>[] table;//存储(位桶)的数组
HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个Node的数组。
有了以上3个数据结构,只要有一点数据结构基础的人,都可以大致联想到HashMap的实现了。首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。
HashMap是Map接口基于哈希表的实现。这种实现提供了所有可选的Map操作,并允许key和value为null(除了HashMap是unsynchronized的和允许使用null外,HashMap和HashTable大致相同。)。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
此实现假设哈希函数在桶内适当地分布元素,为基本实现(get 和 put)提供了稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。如果遍历操作很重要,就不要把初始化容量initial capacity设置得太高(或将加载因子load factor设置得太低),否则会严重降低遍历的效率。
HashMap有两个影响性能的重要参数:初始化容量initial capacity、加载因子load factor。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。initial capacity*load factor就是当前允许的最大元素数目,超过initial capacity*load factor之后,HashMap就会进行rehashed操作来进行扩容,扩容后的的容量为之前的两倍。
通常,默认加载因子 (0.75) 在时间和空间成本上寻求一种折中。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生rehash 操作。
如果很多映射关系要存储在 HashMap 实例中,则相对于按需执行自动的 rehash 操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效地存储。
注意,此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(…));由所有此类的“collection 视图方法”所返回的迭代器都是fail-fast 的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的remove方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测bug。
此类是 Java Collections Framework 的成员。
@author Doug Lea
@author Josh Bloch
@author Arthur van Hoff
@author Neal Gafter
@see Object#hashCode()
@see Collection
@see Map
@see TreeMap
@see Hashtable
@since 1.2
hashMap:
- 底层:HashMap是Map接口基于哈希表的实现。
- 是否允许null:HashMap允许key和value为null。
- 是否有序:HashMap不保证映射的顺序,特别是它不保证该顺序恒久不变。
- 何时rehash:超出当前允许的最大容量。initial capacity*load factor就是当前允许的最大元素数目,超过initial capacity*load factor之后,HashMap就会进行rehashed操作来进行扩容,扩容后的的容量为之前的两倍。
- 初始化容量对性能的影响:不应设置地太小,设置地小虽然可以节省空间,但会频繁地进行rehash操作。rehash会影响性能。总结:小了会增大时间开销(频繁rehash);大了会增大空间开销(占用了更多空间)和时间开销(影响遍历)。
- 加载因子对性能的影响:加载因子过高虽然减少了空间开销,但同时也增加了查询成本。0.75是个折中的选择。总结:小了会增大时间开销(频繁rehash);大了会也增大时间开销(影响遍历)。
- 是否同步:HashMap不是同步的。
- 迭代器:迭代器是fast-fail的。
定义
先来看看HashMap的定义:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
从中我们可以了解到:
- HashMap<K,V>:HashMap是以key-value形式存储数据的。
- extends AbstractMap<K,V>:继承了AbstractMap,大大减少了实现Map接口时需要的工作量。
- implements Map<K,V>:实现了Map,提供了所有可选的Map操作。
- implements Cloneable:表明其可以调用clone()方法来返回实例的field-for-field拷贝。
- implements Serializable:表明该类是可以序列化的。
静态全局变量
// 默认初始长度为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 桶数组的最大长度为2^30
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时,才允许桶位树化,否则只是扩容
// 至少为4 * TREEIFY_THRESHOLD以避免resize和树化的冲突
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希桶数组,数组中保存的是链表或树节点。长度总是2的整数幂。
transient Node<K,V>[] table;
// HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能
transient Set<Map.Entry<K,V>> entrySet;
// Map中的键值对个数
transient int size;
// 结构性修改的次数,和迭代器的使用有关,对应fail-fast
transient int modCount;
// 扩容阈值,当table大小超过阈值时要扩容为2倍
int threshold;
// 负载因子,用来计算当前table长度下的容量扩容阈值:threshold = loadFactor * table.length
final float loadFactor;
构造方法:
// 生成一个空的HashMap,容量大小使用默认值16,负载因子使用默认值0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// 生成一个空的HashMap,并指定其容量大小,负载因子使用默认的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 生成一个空的HashMap,并指定其容量大小和负载因子
public HashMap(int initialCapacity, float loadFactor) {
// //保证初始容量大于等于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// //保证初始容量不大于最大容量MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// loadFactor小于0或为无效数字
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 负载因子
this.loadFactor = loadFactor;
// 下次扩容大小
this.threshold = tableSizeFor(initialCapacity);
}
// 根据指定的map生成一个新的HashMap,负载因子使用默认值,
// 初始容量大小为Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,DEFAULT_INITIAL_CAPACITY)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//获取该map的实际长度
int s = m.size();
if (s > 0) {
//判断table是否初始化,如果没有初始化
if (table == null) { // pre-size
/**求出需要的容量,因为实际使用的长度=容量*0.75得来的,+1是因为小数相除,基本都不会是整数,
容量大小不能为小数的,后面转换为int,多余的小数就要被丢掉,所以+1,
例如,map实际长度22,22/0.75=29.3,所需要的容量肯定为30,
有人会问如果刚刚好除得整数呢,除得整数的话,容量大小多1也没什么影响**/
float ft = ((float)s / loadFactor) + 1.0F;
//判断该容量大小是否超出上限。
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
/**对临界值进行初始化,tableSizeFor(t)这个方法会返回大于t值的,且离其最近的2次幂,例如t为29,则返回的值是32**/
if (t > threshold)
threshold = tableSizeFor(t);
}
//如果table已经初始化,则进行扩容操作,resize()就是扩容。
else if (s > threshold)
resize();
//遍历,把map中的数据转到hashMap中。
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
说明:tableSizeFor(initialCapacity)返回大于initialCapacity的最小的二次幂数值。
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;
}
|
是或运算符,比如说0100 | 0011 = 0111
,>>>
是无符号右移,忽略符号位,空位都以0补齐,比如说0100 >>> 2 = 0001
,现在来说一下这么做的目的:
首先>>>
和|
的操作的目的就是把n从最高位的1以下都填充为1,以010011为例,010011 >>> 1 = 001001
,然后001001 | 010011 = 011011
,然后再把011011无符号右移两位:011011 >>> 2 = 000110
,然后000110 | 011011 = 011111
,后面的4、8、16计算过程就都省去了,int类型为32位,所以计算到16就全部结束了,最终得到的就是最高位及其以下的都为1,这样就能保证得到的结果肯定大于或等于原来的n且为奇数,最后再加上1,那么肯定是:大于且最接近输入值的2的整数次幂的数。
那么为什么要先cap - 1
呢,我们可以先思考以下,如果传进来的本身就是2的整数幂次,比如说01000
,10进制是8,那么如果不减,得到的结果就是16,显然不对。所以先减1的目的是cap如果恰好是2的整数次幂,那么返回的也是本身。
合起来得到这个tableSizeFor()方法的目的:返回大于或等于最接近输入参数的2的整数次幂的数。另外,笔者特意回去看了JDK1.7的源码,发现1.7用的是roundUpToPowerOf2()
方法,里面用到里了>>
以及减操作,性能上来说肯定还1.8的高。
添加元素:
在讲解put方法之前,先看看hash方法,看怎么计算哈希值的。
hash算法
static final int hash(Object key) {
int h;
/**先获取到key的hashCode,然后进行移位再进行异或运算,
为什么这么复杂,不用想肯定是为了减少hash冲突**/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1)首先获取对象的hashCode()值,然后将hashCode值右移16位,然后将右移后的值与原来的hashCode做异或运算,返回结果。(其中h>>>16,在JDK1.8中,优化了高位运算的算法,使用了零扩展,无论正数还是负数,都在高位插入0)。
(2)在putVal源码中,我们通过(n-1)&hash获取该对象的键在hashmap中的位置。(其中hash的值就是(1)中获得的值)其中n表示的是hash桶数组的长度,并且该长度为2的n次方,这样(n-1)&hash就等价于hash%n。因为&运算的效率高于%运算。
putVal方法执行过程可以通过下图来理解:
①.判断键值对数组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,如果超过,进行扩容。
具体源码如下:
public V put(K key, V value) {
/**四个参数,第一个hash值,第四个参数表示如果该key存在值,
如果为null的话,则插入新的value,最后一个参数,在hashMap中没有用,可以不用管,使用默认的即可**/
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab 哈希数组,p 该哈希桶的首节点,n hashMap的长度,i 计算出的数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//获取长度并进行扩容,使用的是懒加载,table一开始是没有加载的,等put后才开始加载
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**如果计算出的该哈希桶的位置没有值,则把新插入的key-value放到此处,此处就算没有插入成功,也就是发生哈希冲突时也会把哈希桶的首节点赋予p**/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//发生哈希冲突的几种情况
else {
// e 临时节点的作用, k 存放该当前节点的key
Node<K,V> e; K k;
//第一种,插入的key-value的hash值,key都与当前节点的相等,e = p,则表示为首节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第二种,hash值不等于首节点,判断该p是否属于红黑树的节点
else if (p instanceof TreeNode)
/**为红黑树的节点,则在红黑树中进行添加,如果该节点已经存在,则返回该节点(不为null),该值很重要,用来判断put操作是否成功,如果添加成功返回null**/
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三种,hash值不等于首节点,不为红黑树的节点,则为链表的节点
else {
//遍历该链表
for (int binCount = 0; ; ++binCount) {
//如果找到尾部,则表明添加的key-value没有重复,在尾部进行添加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判断是否要转换为红黑树结构
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//如果链表中有重复的key,e则为当前重复的节点,结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
//有重复的key,则用待插入值进行覆盖,返回旧值。
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
//到了此步骤,则表明待插入的key-value是没有key的重复,因为插入成功e节点的值为null
//修改次数+1
++modCount;
//实际长度+1,判断是否大于临界值,大于则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
//添加成功
return null;
}
tab即是table,n是map集合的容量大小,hash是上面方法的返回值。因为通常声明map集合时不会指定大小,或者初始化的时候就创建一个容量很大的map对象,所以这个通过容量大小与key值进行hash的算法在开始的时候只会对低位进行计算,虽然容量的2进制高位一开始都是0,但是key的2进制高位通常是有值的,因此先在hash方法中将key的hashCode右移16位在与自身异或,使得高位也可以参与hash,更大程度上减少了碰撞率。
下面举例说明下,n为table的长度。
HashMap的数据存储实现原理
流程:
1. 根据key计算得到key.hash = (h = k.hashCode()) ^ (h >>> 16);
2. 根据key.hash计算得到桶数组的索引index = key.hash & (table.length - 1),这样就找到该key的存放位置了:
① 如果该位置没有数据,用该数据新生成一个节点保存新数据,返回null;
② 如果该位置有数据是一个红黑树,那么执行相应的插入 / 更新操作;
③ 如果该位置有数据是一个链表,分两种情况一是该链表没有这个节点,另一个是该链表上有这个节点,注意这里判断的依据是key.hash是否一样:
如果该链表没有这个节点,那么采用尾插法新增节点保存新数据,返回null;如果该链表已经有这个节点了,那么找到该节点并更新新数据,返回老数据。
注意:
HashMap的put会返回key的上一次保存的数据,比如:
HashMap<String, String> map = new HashMap<String, String>();
System.out.println(map.put("a", "A")); // 打印null
System.out.println(map.put("a", "AA")); // 打印A
System.out.println(map.put("a", "AB")); // 打印AA
辅助方法:tableSizeFor 方法
该方法用来将桶位数组需要的容量扩充到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;
}
辅助方法:treeifyBin 函数
HashMap中添加元素时会调用putVal
方法,当桶中单链表结构元素总数超过8时,调用treeifyBin
方法,将桶中的链表转化为红黑树,或者只是扩容桶位数组(会将单链表拆分为两半,达到减小单链表长度的目的)。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 桶数组为空,或者长度小于MIN_TREEIFY_CAPACITY,不符合树化条件
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); // 扩容
// 符合树化条件,而且桶位数组对应位置的桶不为null
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null; // 红黑树的头尾节点
do {
// 把普通节点转化为树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p; // 首先确定树的头结点
else { // 树节点同时也是双向链表
p.prev = tl;
tl.next = p;
}
tl = p; // 循环的最后确定了树的尾节点
} while ((e = e.next) != null);
// 将桶中的双链表转化成红黑树,详细见树节点内部方法
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
辅助方法:扩容 resize
- 初始化或者扩容桶位数组,空数组的话就根据threshold来计算初始化容量
- 分配到新的散列表时,每个桶位中的元素,要么不动,要么后移新数组长度的一半
final Node<K,V>[] resize() {
//把没插入之前的哈希数组做为oldTal
Node<K,V>[] oldTab = table;
//old的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//old的临界值
int oldThr = threshold;
//初始化new的长度和临界值
int newCap, newThr = 0;
//oldCap > 0也就是说不是首次初始化,因为hashMap用的是懒加载
if (oldCap > 0) {
//大于最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//临界值为整数的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
//标记##,其它情况,扩容两倍,并且扩容后的长度要小于最大值,old长度也要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//临界值也扩容为old的临界值2倍
newThr = oldThr << 1;
}
/**如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,
如果是首次初始化,它的临界值则为0
**/
else if (oldThr > 0)
newCap = oldThr;
//首次初始化,给与默认的值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
//临界值等于容量*加载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//此处的if为上面标记##的补充,也就是初始化时容量小于默认值16的,此时newThr没有赋值
if (newThr == 0) {
//new的临界值
float ft = (float)newCap * loadFactor;
//判断是否new容量是否大于最大值,临界值是否大于最大值
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
table = newTab;
//此处自然是把old中的元素,遍历到new中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//临时变量
Node<K,V> e;
//当前哈希桶的位置值不为null,也就是数组下标处有值,因为有值表示可能会发生冲突
if ((e = oldTab[j]) != null) {
//把已经赋值之后的变量置位null,当然是为了好回收,释放内存
oldTab[j] = null;
//如果下标处的节点没有下一个元素
if (e.next == null)
//把该变量的值存入newCap中,e.hash & (newCap - 1)并不等于j
newTab[e.hash & (newCap - 1)] = e;
//该节点为红黑树结构,也就是存在哈希冲突,该哈希桶中有多个元素
else if (e instanceof TreeNode)
//把此树进行转移到newCap中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { /**此处表示为链表结构,同样把链表转移到newCap中,就是把链表遍历后,把值转过去,在置位null**/
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;//将Node结点的next赋值给next
if ((e.hash & oldCap) == 0) {//如果结点e的hash值与原hash桶数组的长度作与运算为0
if (loTail == null)//如果loTail为null
loHead = e;//将e结点赋值给loHead
else
loTail.next = e;//否则将e赋值给loTail.next
loTail = e;//然后将e复制给loTail
}
else {//如果结点e的hash值与原hash桶数组的长度作与运算不为0
if (hiTail == null)//如果hiTail为null
hiHead = e;//将e赋值给hiHead
else
hiTail.next = e;//如果hiTail不为空,将e复制给hiTail.next
hiTail = e;//将e复制个hiTail
}
} while ((e = next) != null);//直到e为空
if (loTail != null) {//如果loTail不为空
loTail.next = null;//将loTail.next设置为空
newTab[j] = loHead;//将loHead赋值给新的hash桶数组[j]处
}
if (hiTail != null) {//如果hiTail不为空
hiTail.next = null;//将hiTail.next赋值为空
newTab[j + oldCap] = hiHead;//将hiHead赋值给新的hash桶数组[j+旧hash桶数组长度]
}
}
}
}
}
//返回扩容后的hashMap
return newTab;
}
删除元素:
public V remove(Object key) {
//临时变量
Node<K,V> e;
/**调用removeNode(hash(key), key, null, false, true)进行删除,第三个value为null,表示,把key的节点直接都删除了,不需要用到值,如果设为值,则还需要去进行查找操作**/
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**第一参数为哈希值,第二个为key,第三个value,第四个为是为true的话,则表示删除它key对应的value,不删除key,第四个如果为false,则表示删除后,不移动节点**/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
//tab 哈希数组,p 数组下标的节点,n 长度,index 当前数组下标
Node<K,V>[] tab; Node<K,V> p; int n, index;
//哈希数组不为null,且长度大于0,然后获得到要删除key的节点所在是数组下标位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//nodee 存储要删除的节点,e 临时变量,k 当前节点的key,v 当前节点的value
Node<K,V> node = null, e; K k; V v;
//如果数组下标的节点正好是要删除的节点,把值赋给临时变量node
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不再是数组下标的节点,而是要删除结点的上一个结点**/
p = e;
} while ((e = e.next) != null);
}
}
//找到要删除的节点后,判断!matchValue,我们正常的remove删除,!matchValue都为true
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果删除的节点是红黑树结构,则去红黑树中删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果是链表结构,且删除的节点为数组下标节点,也就是头结点,直接让下一个作为头
else if (node == p)
tab[index] = node.next;
else /**为链表结构,删除的节点在链表中,把要删除的下一个结点设为上一个结点的下一个节点**/
p.next = node.next;
//修改计数器
++modCount;
//长度减一
--size;
/**此方法在hashMap中是为了让子类去实现,主要是对删除结点后的链表关系进行处理**/
afterNodeRemoval(node);
//返回删除的节点
return node;
}
}
//返回null则表示没有该节点,删除失败
return null;
}
获取元素:
public V get(Object key) {
Node<K,V> e;
//也是调用getNode方法来完成的
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
//first 头结点,e 临时变量,n 长度,k key
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//头结点也就是数组下标的节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果是头结点,则直接返回头结点
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;
}
HashMap线程不安全问题产生原因及过程:https://segmentfault.com/a/1190000038989240