介绍
- 哈希表实现,实现Map接口(这意味着以键/值对的形式存储数据)
- 键和值可以是任何类型,包括null
- 无序
- 非线程安全的
- 初始默认容量16
- 负载因子默认0.75
- 具有fail-fast机制
- 扩容为原容量2倍
继承关系 继承类介绍
- AbstractMap:继承AbstractMap,它实现了Map接口,提供了Map接口的基本实现
- Map:Map类集合的顶层接口,Map中存储的内容是键值对(key-value)
变量解析(所有代码都不是完整的源码,而是精简过后的源码!结合源码食用更佳)
//默认初始容量 16
//为什么是16? 如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//table的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子 0.75
// 默认值0.75是访问时间和存储容量之间的一个很好的权衡,过大会导致增加查询成本,过小会导致空间开销增加
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//把节点从链表节点转成树节点的阈值 为什么是8? 后续应该会写到 这是一道面试题
static final int TREEIFY_THRESHOLD = 8;
//从数转为链表的阈值 由于树节点的大小通常是普通节点的两倍,
//当容器中节点数量变小的时候需要被转换回普通的箱子
static final int UNTREEIFY_THRESHOLD = 6;
//当哈希表中的容量大于这个值时,表中的bucket才能进行树形化
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
//存放bucket的数组
transient Node<K, V>[] table;
//目前也没有发现有什么用
transient Set<Map.Entry<K, V>> entrySet;
//size表示table的实际元素数量 而CAPACITY则表示table的容量
transient int size;
//在结构上被修改的次数 用于支持fail-fast机制
transient int modCount;
//扩容临界值 = DEFAULT_INITIAL_CAPACITY * loadFactor
// 按照默认值来计算就是 16 * .75 = 12(临界值)
int threshold;
//加载因子 默认值是 DEFAULT_LOAD_FACTOR .75f
final float loadFactor;
HashMap相比较于List系列来说要复杂很多,毕竟链表+数组+红黑树远比List的数组或链表实现要复杂的多,一定要认真看
源码解析 常用方法解析
hash(Object key)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
①:得到key的hashCode值h
②:h异或自己右移16位的结果
③:h右移16位是为了防止h的高位不同,但低位相同的导致的hash冲突,即在后面的与运算中将只有低位参与运算,这个操作将尽量做到任何一位的变化都会对结果产生影响以降低碰撞的概率,这个操作称之为扰动
resize()
作用:扩容或初始化表
/**
* 1.初始化表
* 2.扩容表
*/
final Node<K, V>[] resize () {
//扩容前哈希表 下文简称旧表
Node<K, V>[] oldTab = table;
//获取旧表的容量 也可能是null null下面则初始化表
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取扩容阈值 注意第一次put时它并没有经过DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY计算 是构造中指定的值
int oldThr = threshold;
//新表容量 新表阈值
int newCap, newThr = 0;
//旧表容量大于0
if (oldCap > 0) {
//判断旧表容量是否大于表的max容量 如果大于则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新表容量为旧表容量的两倍
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
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如上面的else没走 这里需要计算新的阀值 只有oldThr容量>0 下面才会执行 否则使用上面else的默认值
if (newThr == 0) {
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;
//将旧表中的所有bucket移动到新表中
//如果某个bucket下的链表已转为红黑树,且树的节点少于UNTREEIFY_THRESHOLD将触发红黑树转链表
if (oldTab != null) {
put(K key, V value)
作用:将指定值与指定键关联,如果此映射中已经包含键的映射,则替换旧值
//如果哈希表为null或者长度为0就需要扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//计算index=(length - 1) & hash 获取该位置的节点 数组中不存在则新建节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果从index位置获取到节点不为Null, 即说明存在hash冲突
//判断是否是hash值相同或key相同 它必须要满足key也相同的判断 否则后续是不能直接替换value的
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//记录下这个节点 后续直接替换value
e = p;
//如果是红黑树(TreeNode) 存在两种情况
//1.key已经存在这个红黑树当中了,更新value
//2.从红黑树的root节点开始遍历,定位到要插入的叶子节点,插入新节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//遍历该链表的所有节点 如果长度>=8 则存在两种情况
//1.该链表长度>=8 哈希表长度<64 则执行扩容
//2.该链表长度>=8 哈希表长度>64 则执行链表转红黑树
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;
}
}
//如果上面的操作中存在key相同的情况 则value直接覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
}
}
//结构修改次数
++modCount;
//判断是否达到扩容临界值
if (++size > threshold)
//扩容
resize();
remove(Object key)
作用: 删除映射中键对应的映射,如果键存在!返回键对应的值,如果不存在返回null
//计算index (n - 1) & hash 获取表中index位置的元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K, V> node = null, e;
K k;
V v;
//hash一致key一致 将直接返回此node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//一个 桶 中有多个节点的情况 通过循环遍历再根据key获取到指定节点
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);
}
}
//返回上面获取到的节点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
//如果超过8 则已经树形化 通过红黑树的remove方法来删除此节点
//removeTreeNode还有一个额外的判断 如果树上的节点太少 红黑树将转为链表
((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
else if (node == p)
//桶中只有一个节点的情况下 使用null替换该位置
tab[index] = node.next;
else
//桶中有多节点情况 但尚未超过8:当node节点被获取后 node的下一个节点替换node节点的位置
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
getNode(int hash, Object key)
作用:从表中获取对应的node,是replace/get等方法的核心
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//计算index 从表中对应index位置获取node节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断该节点是否满足条件 满足则直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//上一步没有返回 说明桶中有多个节点 通过循环使用key判断获取到对应的节点返回
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;
replace(K key, V value)-1.8添加的方法
作用:替换指定键映射的值,如果键存在!相当于map.put(key, value)
Node<K,V> e;
//获取node
if ((e = getNode(hash(key), key)) != null) {
V oldValue = e.value;
//替换此节点的value字段
e.value = value;
afterNodeAccess(e);
//返回替换前的value
return oldValue;
}
return null;
get(Object key)
作用:返回指定键映射到的值
return (e = getNode(hash(key), key)) == null ? null : e.value;
…
entrySet()
使用entrySet()你需要了解:
- 它不是一个新的集合,没有自己的数据结构与状态,而是一个由HashMap支持的包装器,EntrySet的所有操作被委托给HashMap本身,因此EntrySet实际上并不包含任何内容
- 为什么使用Set而不是ArrayList其他集合?
- 设计思想不符合,Set更符合HashMap的思想,例如:无序,唯一等,如HashSet就是通过HashMap实现的,而ArrayList的设计是有序,可重复等,这和HashMap有设计上的冲突
作用:返回一个EntrySet对象,以便于操作HashMap迭代
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);
}
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
通过部分源码可以看出它的功能是由HashMap来支持的,重点只关注一个方法iterator(),EntryIterator继承了HashIterator,这个类在 keySet()、values()中返回的对象的迭代都继承自它,所以这里说一遍下面就简单了
#####变量
//下一个被返回的节点
Node<K, V> next; // next entry to return
//当前节点
Node<K, V> current; // current entry
//结构被修改次数
int expectedModCount; // for fast-fail
//当前当前节点在table数组中的索引
int index; // current slot
#####构造函数
HashIterator() {
expectedModCount = modCount;
//table也就是HashMap的table HashIterator是HashMap的非静态内部类
Node<K,V>[] t = table;
current = next = null;
//索引从0开始
index = 0;
if (t != null && size > 0) { // advance to first entry
//从索引0开始 依次遍历 直到获取第一个不为null的节点 停止while循环
do {} while (index < t.length && (next = t[index++]) == null);
}
}
nextNode() 此迭代器的核心方法作用是获取返回的节点的下一个节点
final Node<K, V> nextNode () {
Node<K, V>[] t;
Node<K, V> e = next;
//判断桶内是否是否有多个节点 通过next = (current = e).next可以依次获取桶内的节点返回
//如果桶内是单节点 则下面通过while循环查找到下一个不为null的节点 可能有点拗口 下面举个例子说明
//假设现在table=如下图,桶0是一个单链表,它有多个节点,
//桶1则只有一个节点,假设在上面的构造函数中 next= a,这里if里会变成 e= a,current = e = a, next = a.next = b
//很明显b不为null,这个判断也就不成立 下面直接return的是a 此时用户调用next()方法,这时 e= b,current = b next = c
//很明显c不为null,这个判断也就不成立 下面直接return的是b 此时用户调用next()方法,这时 e= c,current = c next = null
//此时桶0的所有节点都已经拿到,next为null判断成立,在while中获取下一个桶1
//....
if ((next = (current = e).next) == null && (t = table) != null) {
//桶 内不存在多个节点 执行while循环 直到获取下一个不为null的节点
do {
} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
keySet()
作用:返回HashMap所有键的Set视图
不贴代码了,写的我脑壳疼,说下和entrySet()方法的区别吧
return nextNode().key;
return nextNode();
如果你打开了源码,你应该可以看到不管是keySet还是entrySet它们返回的迭代器都继承自HashIterator,nextNode方法都是一致的,区别仅仅是一个直接返回一个是返回key,如果你非要使用keySet获取key再通过get(key)获取value,那么keySet的效率肯定要低于entrySet,或者说一般情况下我们使用entrySet就
values()
作用:返回HashMap所有值的Collection视图,注意和前两个方法不同,Values不继承自Set,而是Collection,也许你会有疑问(对没错,就是我自己)为什么不是Set了?因为Value的视图不符合Set的唯一思想,Value是可重复的,使用Set显然不再适合
return nextNode().value;
扩展知识点
- 网上有一个说法,如果bucket中的元素个数超过TREEIFY_THRESHOLD(8)就使用红黑树来替换链表,以降低遍历的复杂度,开始我是认同的,学习到MIN_TREEIFY_CAPACITY(64)变量时就懵逼了,不是满足超过8就可以链表转红黑树了吗,为什么又出来个哈希表容量超过64才可以转呢,实际上经过我的测试,将链表转为红黑树需要同时满足这两个条件才可以
- bucket中的元素个数>=8
- 哈希表的容量超过64
使用网上的表述方法不太准确,如果bucket中的元素个数超过TREEIFY_THRESHOLD(8),但是哈希表的容量没有超过64时是不会链表转红黑树的,而是扩容!(我也很怕自己以偏概全.遂反复通过Demo及源码验证.如有错误请提示下)
-
什么是哈希?
- 一般译为散列,也有音译为哈希的,就是把任意长度的输入通过散列算法变换为固定长度的输出,这个输出值就是散列值
-
什么是散列碰撞?
- 经过同一散列函数计算出的散列值不同,那么输入值肯定不同,但是经过同一散列函数计算出的散列值相同,输入值不一定相同,两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
-
如何解决散列碰撞?
- 使用链地址法,将所有散列值相同的元素构成一个链表,即每当发生冲突时把冲突元素链接在该链表尾部
- 使用hash函数降低hash冲突的概率
- 如果key为对象,我们应该重写它的hashCode和equals方法,以降低hash冲突的概率
- Java 8引入红黑树,降低遍历的时间复杂度,使遍历更快
-
为什么bucket中个数超过8才转为红黑树,为什么是8?
-
先要知道TreeNodes占用空间是普通Nodes的两倍,所以只有当链表的节点数量足够多时才会转换为红黑树,当链表的节点减少到6时再树转链表,过早就浪费空间开销增加,过晚会导致查询时间成本增加,在源码中有一段注释:
//在扩容阈值为0.75的情况下,遵循着参数平均为0.5的泊松分布 0: 0.60653066 1: 0.30326533 2: 0.07581633 3: 0.01263606 4: 0.00157952 5: 0.00015795 6: 0.00001316 7: 0.00000094 8: 0.00000006
可以看到一个链表的节点要达到8的概率为0.00000006,所以选择8是在衡量了时间与空间之后通过概率统计决定的值
-
-
为什么容量是2的幂次方?
- 看下图,左边使用15作为容量,hash值12与13的结果index冲突了,右边使用16作为容量则没有index冲突,这里随便拿了几个值举例,导致容量15的结果一致的原因是其二进制表现形式为:1110(15-1),任何hash数与1110与运算的时候最低位的0永远都是0,可变化的只有三位了,哈希碰撞的概率大大增加。且数组中0001,0011,0101,1001,1011,0111,1101这些位置也就用不到了
总结:使用2的幂次方可以降低index碰撞的概率,使分布更均匀,增加了查询效率(不需要遍历链表),减少了空间开销(不需要转红黑树),注意是降低而不是解决!
- 看下图,左边使用15作为容量,hash值12与13的结果index冲突了,右边使用16作为容量则没有index冲突,这里随便拿了几个值举例,导致容量15的结果一致的原因是其二进制表现形式为:1110(15-1),任何hash数与1110与运算的时候最低位的0永远都是0,可变化的只有三位了,哈希碰撞的概率大大增加。且数组中0001,0011,0101,1001,1011,0111,1101这些位置也就用不到了
-
为什么默认加载因子是.75f
- 默认的.75f在时间和空间成本之间提供了良好的权衡,过大会导致增加查询成本,过小会导致空间开销增加
-
HashMap中有哪些影响其性能的参数?
- 加载因子
- 初始容量
-
什么是桶?经常看到有文章里写桶神马的.桶在HashMap中扮演什么角色?
- 哈希表的容量代表桶数,即capacity就是哈希表的桶数
2019/6/17 23:20:57