容器的源码(HashMap与Hashtable)
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景非常丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。本文首先介绍了一下哈希表这种数据结构,然后对JDK8的HashMap源码进行分析。
值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。
Map map = Collections.synchronizedMap(new HashMap());
在JDK1.8之前,HashMap采用数组+链表实现,使用链表来处理冲突,同一hash值的节点都存储在一个链表里。但是当hash值相等的元素越来越多时,链表也会越来越长,通过key值依次查找的效率也会越来越低。
为了解决链表过长导致查询效率变低而问题,在JDK1.8中,当链表长度超过阈值(8)时,会将链表转换为红黑树,这样解决了链表太长导致查询变慢的问题,大大减少了查找时间。
整个底层的数据结构和Redis中的字典非常像
- JDK1.8之后HashMap底层是数组+链表+红黑树
- HashMap线程不安全,我们可以使用Collections.synchronizedMap包装为线程安全HashMap或者使用HashTable,CurrentHashMap
- HashMap的默认初始容量为16,加载因子是0.75,填充度达到75%后,会扩容至原来的2倍
总结:1.8之后,同一个hash值的节点大于8时,会转为红黑树存储
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NxoX8nbB-1672825617902)(%E5%AE%B9%E5%99%A8%E7%9A%84%E6%BA%90%E7%A0%81%EF%BC%88HashMap%E4%B8%8EHashtable%EF%BC%89%20817362245ecc4cdc9a4ea6b9d8033e0a/Untitled.png)]
被 transient 所修饰 table 变量
如果大家细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?
这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject
两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对
。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:
- table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间
- 同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。
以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove
等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。
重要字段:
//实际存储的key-value键值对的个数
transient int size;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75,值越大,空间开销越小,查询成本越大
final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
构造方法:
HashMap()创建一个初始容量为16,默认加载因子为0.75的空HashMap
HashMap(int initialCapacity)创建一个默认加载因子为0.75,自定义容量的空HashMap
HashMap(int initialCapacity,float loadFactor)创建一个自定义加载因子,自定义容量的空HashMap
HashMap(Map<?extends K,?extends V> m)使用与指定Map相同的映射构造一个新的HashMap
HashMap有4个构造器,最后一个很少使用,这里就不讲了。其他构造器如果用户没有传入initialCapacity 或者loadFactor这两个参数,会使用默认值,initialCapacity默认为16,loadFactory默认为0.75。
但是有一点要注意,在进行初始化操作时,只是初始化了创建数组的相关参数,并没有真正创建动态数组。真正动态数组的创建是在第一次进行数据写入时引发的。
这样实际上是一种懒加载操作,防止了初始化后而不用的内存浪费。
我们在一般情况下,都会使用无参构造方法创建 HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值有时可能达不到我们的要求,这个时候我们就需要手动调参。在 HashMap 构造方法中,可供我们调整的参数有两个,一个是初始容量 initialCapacity,另一个负载因子 loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。但初始阈值 threshold 仅由 initialCapacity 经过移位操作计算得出。他们的作用分别如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gsfn2SRz-1672825617904)(%E5%AE%B9%E5%99%A8%E7%9A%84%E6%BA%90%E7%A0%81%EF%BC%88HashMap%E4%B8%8EHashtable%EF%BC%89%20817362245ecc4cdc9a4ea6b9d8033e0a/Untitled%201.png)]
publicHashMap(int initialCapacity,float loadFactor){
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(2^30)
//如果初始容量<0,直接抛异常
if(initialCapacity< 0)thrownew IllegalArgumentException("Illegal initial capacity: "+initialCapacity);//初始容量最大不能超过MAXIMUM_CAPACITY = 1<<30(2^30)
if(initialCapacity> MAXIMUM_CAPACITY)initialCapacity= MAXIMUM_CAPACITY;//如果加载因子<0或者不是浮点数,抛异常
if(loadFactor<= 0|| Float.isNaN(loadFactor))thrownew IllegalArgumentException("Illegal load factor: "+loadFactor);//对初始容量赋值
this.loadFactor= loadFactor;//对阔值赋值
this.threshold= tableSizeFor(initialCapacity);}
tableSizeFor()方法是JDK8出现的,它的作用是返回大于输入参数且最近的2的整数次幂的数
。比如输入3,则返回4;输入5,则返回8。这里的算法很是巧妙,对于性能有很大提升
/**
* Returns a power of two size for the given target capacity.
*/
staticfinalinttableSizeFor(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;}
对初始化容量的修改
说完了初始阈值的计算过程,再来说说负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键与键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。
put()
public V put(K key, V value) {
//首先对key进行了Hash,然后直接调用putVal()方法
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash 键的哈希值
* @param key 键
* @param value 值
* @param onlyIfAbsent 如果为true,不改变已经存在的值
* @param evict 如果为true,处于创建模式.
* @return 值
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
//申明变量 tab:临时数组 p:数组中的节点 n:存放老的容量 i:tab数组的下标
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//如果table为null或者长度为0,进行初始化分配大小
if ((tab = table) == null || (n = tab.length) == 0){
n = (tab = resize()).length;
}
//(n - 1) & hash 计算出下标,如果该位置为null 说明没有碰撞,将value封装为一个新的Node并赋值
if ((p = tab[i = (n - 1) & hash]) == null){
tab[i] = newNode(hash, key, value, null);
}else {
//反之,碰撞了
Node<K,V> e; K k;
//首先判断key是否存在,如果是,覆盖原来的值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
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) {
//将next指向新的节点
p.next = newNode(hash, key, value, null);
//binCount >= TREEIFY_THRESHOLD - 1 binCount>=7,链表长度为8时,转变为红黑树,结束循环
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果链表中已经存在该key,结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//将e赋值给p,此处没明白为什么,p变量后面没有在使用过
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) 根据规则选择是否覆盖value
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//fail-fast相关,迭代时会保存一份modCount,每次遍历都会比较该值和保存的值是否相等,不相等则抛出异常
++modCount;
//如果size >阔值,扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put()方法涉及的成员变量或成员方法
成员变量transient Node[] table
table是HashMap用来实际存放元素的数组,它在首次使用时会被初始化。它的长度始终是2的幂次方。在某些操作中长度可能为0。
*/**
* HashMap用来实际存放元素的数组
*/***transient** Node**<**K**,**V**>[]** table**;**
成员变量阔值threshold
/**
* 触发扩容的值 (容量 * 加载因子).
*
* @serial
*/
int threshold;
静态方法hash()
static final int hash(Object key) {
int h;
//先取key的hashCode,然后和其低16位进行异或操作
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们知道HashMap的容量是2的幂次方,那么newCap - 1
的高位应该全部为0
。如果e.hash
值只用自身的hashcode
的话,那么index
只会和e.hash
低位做&
操作。这样一来,index
的值就只有低位参与运算,高位毫无存在感,从而会带来哈希冲突的风险。所以在计算key
的哈希值的时候,用其自身hashcode
值与其低16
位做异或操作。这也就让高位参与到index
的计算中来了,即降低了哈希冲突的风险又不会带来太大的性能问题。
简单总结下put()方法做了哪些事情?
- 首先判断内部数组是否初始化,如果没有初始化进行初始化操作。
- 计算key的数组下标,判断是否发生Hash冲突,如果不冲突,塞值。进入步骤5。
- 如果发生Hash冲突,首先判断是否为红黑树,如果是红黑树,塞值。
- 如果不是红黑树,说明是链表,将值放入链表,然后判断链表长度,如果长度超过8,将链表转换为红黑树。
- modCount++,保障fail-fast
- 判断数组长度是否达到阔值,是则进行扩容操作。否结束方法。
向HashMap中写入数据的过程,简单总结起来分为这么几步:
- 计算要插入数据的Hash值,并根据该值确定元素的插入位置(即在动态数组中的位置)。
- 将元素放入到数组的指定位置
- 如果该数组位置之前没有元素,则直接放入
- 放入该位置后,数组元素超过扩容阈值,则对数组进行扩容
- 放入该位置后,数组元素没超过扩容阈值,写入结束
- 如果该数组位置之前没有元素,则直接放入
- 如果该数组位置之前有元素,则挂载到已有元素的后端
- 如果之前元素组成了树,则挂入树的指定位置
- 如果之前元素组成了链表
- 如果加入该元素链表长度超过8,则将链表转化为红黑树后插入
- 如果加入该元素链表长度不超过8,则直接插入
方法resize()
*/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/***final** Node**<**K**,**V**>[]** **resize()** **{***//保存一份老的数组*
Node**<**K**,**V**>[]** oldTab **=** table**;***//老数组的容量,如果老数组为null,则是0,否则取length*
**int** oldCap **=** **(**oldTab **==** **null)** **?** 0 **:** oldTab**.**length**;***//保存一份老的阔值*
**int** oldThr **=** threshold**;***//初始化新的容量和阔值*
**int** newCap**,** newThr **=** 0**;***//如果老的容量>0*
**if** **(**oldCap **>** 0**)** **{***//如果老的容量达到了最大值,不扩容,并且将阔值设置为了Integer的最大值2的31次方-1*
**if** **(**oldCap **>=** MAXIMUM_CAPACITY**)** **{**threshold **=** Integer**.**MAX_VALUE**;return** oldTab**;}else** **if** **((**newCap **=** oldCap **<<** 1**)** **<** MAXIMUM_CAPACITY **&&**oldCap **>=** DEFAULT_INITIAL_CAPACITY**)***//首先将老的容量*2赋值给新的容量,然后判断新的容量<MAXIMUM_CAPACITY 并且老的容量大于16,将阔值*2*
newThr **=** oldThr **<<** 1**;** *// double threshold*
**}else** **if** **(**oldThr **>** 0**)** *// 如果老的数组容量<=0,但是阔值>0,直接将阔值赋值给新的容量*
newCap **=** oldThr**;else** **{** *// 初始化*
*//新的容量为DEFAULT_INITIAL_CAPACITY 16*
newCap **=** DEFAULT_INITIAL_CAPACITY**;***//新的阔值为0.75 * 16 = 12*
newThr **=** **(int)(**DEFAULT_LOAD_FACTOR ***** DEFAULT_INITIAL_CAPACITY**);}if** **(**newThr **==** 0**)** **{***//防止阔值为0,比较好奇这种情况什么时候会出现,知道的同学还请不吝赐教*
**float** ft **=** **(float)**newCap ***** loadFactor**;**newThr **=** **(**newCap **<** MAXIMUM_CAPACITY **&&** ft **<** **(float)**MAXIMUM_CAPACITY **?(int)**ft **:** Integer**.**MAX_VALUE**);}***//将新的阔值赋值给成员变量*
threshold **=** newThr**;***//下面是将创建一个新的Node数组,并将老的数组里面的元素赋值到新的数组,这里不详细解读了。*
@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)**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**;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**;}if** **(**hiTail **!=** **null)** **{**hiTail**.**next **=** **null;**newTab**[**j **+** oldCap**]** **=** hiHead**;}}}}}return** newTab**;}**
Get()(查找)
相比于数据写入,数据读取操作要简单一些。总体过程总结为:
- 根据要取得key的值,hash出数组中的指定位置
- 取出指定位置的元素(这时,key的hash值是一样的)
- 如果key也完全一样,则返回该值,查找结束。
- 如果key不一样,判断其后面挂载的是树还是列表
- 如果是树,按照树的方法查找
- 如果是列表,按照列表的方法查找
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
使用getNode()方法取值,没有返回null
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断是否有元素,没有返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//每次都会check第一个元素是否命中,命中直接返回
if (first.hash == hash && // always check first node
((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;
}
Node数据节点解析
我们知道HashMap底层维护了一个Node数组,它是最基础的数据节点,接下来便揭开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;
}
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;
}
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类其实非常简单,维护了四个属性 key、value、key的Hash值和下一个节点。我们看下是怎么用的。
上面的在put()方法中已经提到过,当我们put一个key-value时,如果key不存在,或者说没有发生哈希冲突时,就会new一个新的节点。
看下newNode方法,非常简单就是调用了Node的构造函数
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
当发生hash碰撞的时候,首先是以链表的形式存放。实际上就是创建一个新的Node节点,然后复制给之前的Node元素的next属性。
p.next = newNode(hash, key, value, null);
当链表的长度大于8的时候,转化为红黑树,这个时候其实是把Node链表转变为另外一个数组结构ZreeNode。
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
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);
}
}
HashMap的初始容量应该如何指定
同ArrayList一样,我们在new Hashmap()的也最好能够指定它的初始容量大小,目的就是为了提升效率,也能在一定程度上节约内存,那么这个初始容量应该如何指定?看过源码后,相信应该已经知道答案。
在HashMap中有一个成员变量threshold,它的计算方式是初始容量*加载因子。当填充度大于threshold,则会进行扩容。所以如果我们在知道或者大致估计HashMap的存放数量之后,除以0.75,在选择大于此结果的最近的2的幂次方即可(这一步可忽略,因为HashMap会自动帮你完成)。
有的同学可能会有HashMap最小容量是16的错觉,其实并不是,16只是我们在没有指定初始容量后,第一次put元素时初始化的容量。我们完全可以将容量指定为2。
扩容操作
在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。
我们知道扩容分两步:
- 计算新桶数组的容量 newCap 和新阈值 newThr
- 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
- 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
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) {
// 如果长度过长,则不进行扩展
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);
}
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) {
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)
// 树元素重hash
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 略:链表元素重hash
}
}
}
}
return newTab;
}
第一步操作只需增加一块存储区域而已,而第二步操作则需要消耗巨大的计算资源。如果扩容前已经存在5万个元素,则需要把这5万个元素的hash值重新计算一遍,并根据新的结果移动它的位置。该操作叫做重哈希操作,是一次代价极高的操作。
因此,能提升重哈希的性能变得非常重要。
而数组大小必须是2^n,就可以提升重hash的性能。
Hash函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
最终,我们得到一个int数字。而这个数字最终要被映射到数组的某个位置i。
数组长度为n,则i的计算为:
tab[i = (n - 1) & hash]
该计算在HashMap源码中出现了多次。
(n-1)为数组中的最大位置,hash为哈希的结果,两者进行了逻辑与操作。我们到二进制中去理解,那逻辑与操作我们可以理解为hash值的二进制数在(n-1)的二进制数上的求交集操作,我们即为p。最终得到的结果肯定小于等于(n-1)。
那在扩容时,n变为原来的两倍大小,记为m。那在二进制上,m就相当于在n的二进制基础上高位增加了一个1。那么,hash的二进制数和m的二进制数求交集后结果记为q。则除了最高位以外,q和hash值的二进制数在(n-1)的二进制数上的求交集的结果p是一致的。因此,q要么等于p,要么等于p+n。
以上两段的结论简单说来就是:设原来table的长度为a,扩展后变为b,且b=2a。则会将原来table[i]中的元素,经过重hash后,会分拆到新的数组newTable[i]和newTable[i+a]这两个位置上。因此,这样就减少了计算和移动量。
删除
HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。相关源码如下:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
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 &&
// 1. 定位桶位置
(p = tab[index = (n - 1) & hash]) != null) {
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) {
// 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 2. 遍历链表,找到待删除节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 3. 删除节点,并修复链表或红黑树
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;
}
HashTable
Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。
Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。
Hashtable同样实现了Serializable接口,它支持序列化,实现了Cloneable接口,能被克隆。
针对Hashtable,我们同样给出几点比较重要的总结,但要结合与HashMap的比较来总结。
1、二者的存储结构和解决冲突的方法都是相同的。
2、HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。
3、Hashtable中key和value都不允许为null,而HashMap中key和value都允许为null(key只能有一个为null,而value则可以有多个为null)。但是如果在Hashtable中有类似put(null,null)的操作,编译同样可以通过,因为key和value都是Object类型,但运行时会抛出NullPointerException异常,这是JDK的规范规定的。
4、Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
5、Hashtable计算hash值,直接用key的hashCode(),而HashMap重新计算了key的hash值,Hashtable在求hash值对应的位置索引时,用取模运算,而HashMap在求位置索引时,则用与运算,且这里一般先用hash&0x7FFFFFFF后,再对length取模,&0x7FFFFFFF的目的是为了将负的hash值转化为正值,因为hash值有可能为负数,而&0x7FFFFFFF后,只有符号外改变,而后面的位都不变。
6.HashTable的所有方法都是synchronized修饰
7.实现方式:
HashMap用数组+链表+红黑树 <–>
HashTable用数组+链表