transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size; //实际存储的键值对个数
transient int modCount;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。
int threshold;
final float loadFactor; //负载因子,代表了table的填充度有多少,默认是0.75
在HashMap中有两个很重要的参数,容量(Capacity)和负载因子(Load factor)
Capacity
就是buckets
的数目,Load factor
就是buckets
填满程度的最大比例。如果对迭代性能要求很高的话不要把capacity
设置过大,也不要把load factor
设置过小。当bucket
填充的数目(即hashmap中元素的个数)大于capacity*load factor
时就需要调整buckets的数目为当前的2倍。
Hash算法
static final int hash(Object key) {
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
static int indexFor(int h, int length) {
return h & (length-1); //第三步 取模运算
}
indexFor是jdk1.7的源码,jdk1.8没有这个方法但是jdk1.8也是通过取模运算来计算的
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()
返回值相同,那么程序调用方法一所计算得到的Hash
码值总是相同的。我们首先想到的就是把hash
值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,这里我们用&位运算来优化效率。
这个方法非常巧妙,它通过h & (table.length -1)
来得到该对象的保存位,而HashMap
底层数组的长度总是2的n次方,这是HashMap
在速度上的优化。当length总是2的n次方时,h& (length-1)
运算等价于对length取模,也就是h%length
,但是**&比%**具有更高的效率。
在JDK1.8
的实现中,优化了高位运算的算法,通过hashCode()
的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以Node数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
扩容机制
扩容(resize)就是重新计算容量,向HashMap
对象里不停的添加元素,而HashMap
对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
当然Java
里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
当put
时,如果发现目前的bucket
占用程度已经超过了Load Factor
所希望的比例,那么就会发生resize
。在resize
的过程,简单的说就是把bucket
扩充为2倍,之后重新计算index
,把节点再放到新的bucket
中。
因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
例如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash
之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap
的时候,不需要重新计算hash
,只需要看看原来的hash
值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize
的过程,均匀的把之前的冲突的节点分散到新的bucket
了。
final Node<K,V>[] resize() {
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) { 如果容量超过Hash Map限定的最大值,将不再扩容
threshold = Integer.MAX_VALUE;
return oldTab;
} // 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 2倍
}
//数组未初始化,但阈值不为 0,为什么不为 0 ?
//构造函数根据传入的容量打造了一个合适的数组容量暂存在阈值中,这里直接使用
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { //数组未初始化并且阈值也为0,说明一切都以默认值进行构造
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newCap = oldThr 之后并没有计算阈值,所以 newThr = 0
// 重新计算下一次进行扩容的上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
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 = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { // 把每个bucket都移动到新的buckets中
Node<K,V> e;
if ((e = oldTab[j]) != null) { //获取头结点
oldTab[j] = null;
if (e.next == null) //说明链表或者红黑树只有一个头结点,转移至新表
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //如果 e 是红黑树结点,红黑树分裂,转移至新表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //这部分是将链表中的各个节点原序地转移至新表中
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 原索引
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 原索引+oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { // 原索引放到bucket里
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { // 原索引+oldCap放到bucket里
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
下面我们再来看看hashmap中的其他方法
构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
这是一个最基本的构造函数,需要调用方传入两个参数,initialCapacity 和 loadFactor。
程序的大部分代码在判断传入参数的合法性,initialCapacity 小于零将抛出异常,大于 MAXIMUM_CAPACITY 将被限定为MAXIMUM_CAPACITY。loadFactor 如果小于等于零或者非数字类型也会抛出异常。
整个构造函数的核心在对 threshold 的初始化操作:
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;
}
由以上代码可以看出,当在实例化HashMap
实例时,如果给定了initialCapacity
,由于HashMap
的capacity
都是2的幂次方,因此这个方法用于找到大于等于initialCapacity
的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。
下面分析这个算法:
首先,我们想一下为什么要对cap做减1操作?
int n = cap - 1
这是为了防止,cap已经是2的幂。如果cap已经是2的幂,又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。如果不懂,要看完后面的几个无符号右移之后再回来看看。
下面看看这几个无符号右移操作:
如果n这时为0了(经过了cap-1之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是1(最后有个n+1的操作)。
这里我们只讨论n不等于0的情况。
n |= n >>> 1;
由于n不等于0,则n的二进制表示中总会有一bit为1,这时考虑最高位的1。通过无符号右移1位,则将最高位的1右移了1位,再做或操作,使得n的二进制表示中与最高位的1紧邻的右边一位也为1,如000011xxxxxx。
n |= n >>> 2;
注意,这个n已经进行过 n |= n >>> 1; 操作。假设此时n为000011xxxxxx ,则n无符号右移两位,会将最高位两个连续的1右移两位,然后再与原来的n做或操作,这样n的二进制表示的高位中会有4个连续的1。如00001111xxxxxx 。
n |= n >>> 4;
这次把已经有的高位中的连续的4个1,右移4位,再做或操作,这样n的二进制表示的高位中会有8个连续的1。如00001111 1111xxxxxx 。
以此类推 。。。
注意,容量最大也就是32bit的正数,因此最后 n |= n >>> 16; 最多也就32个1,但是这时已经大于了MAXIMUM_CAPACITY ,所以取值到MAXIMUM_CAPACITY 。
下面我们通过一个图片来看一下整个过程:
HashMap 中还有很多的重载构造函数,但几乎都是基于上述的构造函数的。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
以上这些构造函数都没有直接的创建一个切实存在的数组,他们都是在为创建数组需要的一些参数做初始化,
所以有些在构造函数中并没有被初始化的属性都会在实际初始化数组的时候用默认值替换。
实际对数组进行初始化是在添加元素的时候进行的(即put方法)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
put方法
put
方法也是HashMap
中比较重要的方法,因为通过该方法我们可以窥探到 HashMap
在内部是如何进行数据存储的,所谓的数组+链表+红黑树的存储结构是如何形成的,又是在何种情况下将链表转换成红黑树来优化性能的。
put方法的大致实现过程如下:
-
对key的hashCode()做hash,然后再计算index;
-
如果没碰撞直接放到bucket里;
-
如果碰撞了,以链表的形式存在buckets后;
-
如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
-
如果节点已经存在就替换old value(保证key的唯一性)
-
如果bucket满了(超过load factor*current capacity),就要resize。
public V put(K key, V value) { // 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
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 ((tab = table) == null || (n = tab.length) == 0) // tab为空则创建(初次添加元素)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加
tab[i] = newNode(hash, key, value, null);
else { //如果对应的节点存在元素
Node<K,V> e; K k;
if (p.hash == hash && //判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) //判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历table[i],判断链表长度是否大于TREEIFY_THRESHOLD(默认值为8),大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;
// 遍历过程中若发现key已经存在直接覆盖value即可;
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;
}
} //e 不是 null,说明当前的 put 操作是一次修改操作并且e指向的就是需要被修改的结点
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get函数实现
在理解了put之后,get就很简单了。大致思路如下:
bucket里的第一个节点,直接命中;
如果有冲突,则通过key.equals(k)去查找对应的entry
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。
具体代码的实现如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object 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 && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未命中
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
remove方法
删除操作就是一个查找+删除的过程,相对于添加操作其实容易一些
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
根据键值删除指定节点,这是一个最常见的操作了。显然,removeNode 方法是核心。
final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
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<K,V> node = null, e; K k; V v;
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);
}
}
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;
afterNodeRemoval(node);
return node;
}
}
return null;
}
删除操作需要保证在表不为空的情况下进行,并且 p 节点根据键的 hash 值对应到数组的索引,在该索引处必定有节点,如果为 null ,那么间接说明此键所对应的结点并不存在于整个 HashMap 中,这是不合法的,所以首先要在这两个大前提下才能进行删除结点的操作。
第一步
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
node = p;
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
删除操作需要保证在表不为空的情况下进行,并且 p 节点根据键的 hash 值对应到数组的索引,在该索引处必定有节点,如果为 null ,那么间接说明此键所对应的结点并不存在于整个 HashMap 中,这是不合法的,所以首先要在这两个大前提下才能进行删除结点的操作。
第一步
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
node = p;
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-7SrMxnJN-1714853230846)]
[外链图片转存中…(img-5IswXEvY-1714853230846)]
[外链图片转存中…(img-sm19awqF-1714853230846)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!