文章目录
- 一、哈希表的简介
- 二、JDK1.7 HashMap
- 1、构造方法
- 2、添加方法
- put()方法
- addEntry()方法
- 3、存在的问题
- 三、JDK1.8 HashMap
- 1、红黑树TreeMap
- 2、属性
- 3、存储的结构
- 4、构造方法
- 5、添加方法
- put(K, V)方法
- resize扩容方法
- 5、putAll()方法
- 6、移除方法
- remove(Object key)
- 8、查找方法
- get(Object key)方法
- 9、HashMap中JDK1.8的树化方法
可以参考视频:
Java 7/8 HashMap源码详解
一、哈希表的简介
- 核心就是:基于哈希值的桶和链表。
- 一般就是用数组来实现桶
- 发生碰撞的时候,用链表来链接发生碰撞的元素
假设我们hash(x) = x % 16
, 对下面所有元素进行hash并放入到hash表中。
-
O(1)
的平均查找、插入、删除时间. -
致命缺点是哈希值的碰撞(
collision
)- 哈希碰撞:元素通过哈希函数计算后,会被映射到同一个桶中。上面的26, 126就发生了哈希碰撞。
二、JDK1.7 HashMap
对于HashMap
的基本概念和在Java
中的继承关系,相信都有一定的了解。
下面我只是对JDK 1.7
中不足的地方做简单分析。
1、构造方法
无参构造方法
// 默认初始化容量 2^4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子 0.75,用于计算阈值,进行扩容操作。选择0.75是一种在时间和空间上的折中选择
// 如果当前哈希表中的元素个数 >= 容量 × 负载因子,就会进行扩容,否则可能就会发生严重的哈希碰撞
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 此处调用了含容量和负载因子的构造方法来进行初始化操作
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
含容量的构造方法
public HashMap(int initialCapacity) {
// 调用了含容量和负载因子的构造方法来进行初始化操作,其中容量为传入的容量,负载因子为默认负载因子0.75
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
含容量和负载因子的构造方法
public HashMap(int initialCapacity, float loadFactor) {
// 如果传入的初始化容量小于0,则抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果传入的容量大于最大容量,就初始化为最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 如果负载因子小于0,或者是非法的浮点数,抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 根据传入的负载因子给负载因子赋值
this.loadFactor = loadFactor;
// int threshold
// 阈值,容量×负载因子。目前大小为initialCapacity(还未扩容)
// 超过阈值进行扩容操作
threshold = initialCapacity;
// 此处的init()方法是一个空方法,在向哈希表添加元素之前,不会真正地创建哈希表(以免占用过多的内存)
init();
}
// 空方法
void init() {
}
可见,无论调用哪种构造函数来初始化HashMap
,最终调用的都是含容量和负载因子的构造方法,并且都没有真正的开辟出需要的内存空间。
2、添加方法
put()方法
// 空集合,用于判断表是否为空。Entry为hashMap的静态内部类
static final Entry<?,?>[] EMPTY_TABLE = {};
public V put(K key, V value) {
// 如果表是空的,就通过inflateTable()方法进行扩容
if (table == EMPTY_TABLE) {
// 等到真正向哈希表中添加元素时,才开辟内存空间
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
// 计算要插入元素的哈希值
int hash = hash(key);
// 根据哈希值来判断插入元素应该放在哪个桶中
// 该方法决定了为什么哈希表的容量是2的幂
int i = indexFor(hash, table.length);
// 遍历哈希表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 看待插入元素在哈希表中是否已近存在了,如果存在了,就进行覆盖操作
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 真正的添加操作,采用头插法,下面会单独来说
addEntry(hash, key, value, i);
return null;
}
// 哈希表扩容函数
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
// 让容量向上舍入变为2的幂。比如toSize = 10 就会变为 16。
int capacity = roundUpToPowerOf2(toSize);
// 阈值,向上取整后的容量×负载因子 或 最大容量+1,取其中的较小值
// 该变量在第一次放入操作时不会用到
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 根据capacity创建哈希表
table = new Entry[capacity];
// 创建了一个哈希种子,重构String的hash算法,在后面的潜在安全漏洞会谈到
initHashSeedAsNeeded(capacity);
}
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
// 如果容量大于最大容量,就返回最大容量。
// 否则调用Integer.highestOneBit()方法让其向上舍入为2的幂
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
// 通过一系列移位操作与异或操作获得元素的哈希值。 JDK 8 中已不再使用该方法
final int hash(Object k) {
int h = hashSeed;
// 如果哈希种子存在,并且进行哈希的元素的String类型
if (0 != h && k instanceof String) {
// 就让String使用另一种hash算法
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
为什么哈希表大小一定是2的幂?
要回答这个问题,我们需要先看看哈希表中一个重要的方法:static int indexFor(int h, int length)
,该方法会根据插入元素的哈希值决定该元素应该被放在桶中。
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
// 将传入的哈希值与其长度-1进行按位与操作,并返回其结果
return h & (length-1);
}
对于哈希表,其实最致命的缺点就是哈希碰撞,也就是多个值会被放置在同一个桶中。最极端的碰撞如下:
想要避免发生哈希碰撞,就需要分别分到不同的桶中,对于indexFor
函数中的h & (length - 1)
就是这样一个操作,根据元素的哈希值和哈希表的长度-1来按位与,并且与运算的速度快,而且效率高。
如何体现出来呢?这和普通的 hash value = x % 10
的优势在哪里?
当哈希表的大小长度为2的幂时,他的二进制表示是10000
,让其中的长度-1之后就是1111。
当一个二进制与全为1的数进行按位与时,其结果就是等于 该数本身并且小于等于桶的最大数量,这样以来,只要数不同,那么他们按位与下来的值也就不同了,所以我们需要哈希表的容量为2的幂,这样可以提高计算效率。
如果是2的幂,可以理解为是一种截断,假设我们哈希长度还是16 ,按照indexFor
运算。199
的hash
值是:
为什么使用位运算,而不是直接取模?
位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
addEntry()方法
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果哈希表中的元素个数超过了阈值,并且该元素应该放入的桶中已经有了元素
if ((size >= threshold) && (null != table[bucketIndex])) {
// 进行扩容,扩容大小为原大小的2倍,以保证扩容后容量仍为2的幂
// 并将扩容前哈希表中的元素全部重新计算哈希值,并放入到扩容后的桶中
resize(2 * table.length);
// 重新计算哈希值
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 创建节点,采用头插法将其放在对应的桶中
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 扩容为新容量
Entry[] newTable = new Entry[newCapacity];
// 重新计算元素哈希值,再放入到扩容后的哈希表中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
//重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历原来哈希表中的元素
for (Entry<K,V> e : table) {
// 如果桶中元素不为空,就重新计算起哈希值,然后放入到扩容后的哈希表中
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
// 扩容转移时使用头插法
newTable[i] = e;
e = next;
}
}
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 将新节点放在桶的第一个位置,也就是采用头插法进行插入
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
3、存在的问题
1 容易发生死锁
因为HashMap
本身是线程不安全的,所以在多线程环境下,可能会发生死锁问题。JAVA HASHMAP多线程下的死循环
2 非常大的安全问题
和上文所提过的那样,HashMap
可能会退化成一个单链表。
而且我们知道,HashMap
是通过indexFor
对对象计算出的hashcode
值作为输入然后输出哈希表桶的位置,之后对桶后链表进行遍历,调用对象的equals
方法,判断是要新建一个entry
,还是在链表中存在相同的entry
(当然,这里是通过key
来判断的)。
String
的哈希算法很容易就能产生多个哈希值相同字符串。所以,很可能很多的字符串就会放到同一个桶中,也就大概率会退化成单链表。
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
但是,为了避免这种攻击,在 JDK 7 的哈希表中,重构了String类型计算哈希值的方法。
三、JDK1.8 HashMap
极端情况下, 有可能HashMap
会退化成链表的形状 。这就违背了查询的高效性。而今天HashMap
使用红黑树
就是为了解决这个问题。
JDK1.8 HashMap
不再使用数组 + 链表
的方式,而是使用数组+ 链表 + 红黑树
的方式。
1、红黑树TreeMap
基本介绍
一种二叉查找树,但在每个节点增加一个存储位表示节点的颜色,可以是红或黑**(非红即黑)。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍**,因此,红黑树是一种弱平衡二叉树(由于是弱平衡,可以看到,在相同的节点情况下,AVL树的高度低于红黑树),相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,我们就用红黑树。
性质
- 每个节点非红即黑
- 根节点(root)是黑的
- 不能有两个红色的节点连在一起(黑色可以)
- 每个叶节点(叶节点即树尾端NULL指针或NULL节点)都是黑的
- 如果一个节点是红的,那么它的两儿子都是黑的
- 对于任意节点而言,其到叶子点树NULL指针的每条路径都包含相同数目的黑节点
- 每条路径都包含相同的黑节点
下面是HashMap
中的结构
为什么用到了红黑树
让我们来看看注释
/*
HashMap 底层是基于散列算法实现,散列算法分为散列再探测和拉链式。HashMap 则使用了拉链式的散列算法,并在 JDK 1.8 中引入了红黑树优化过长的链表
实现注意事项。
链表结构(这里叫 bin ,箱子)
Map通常充当一个binned(桶)的哈希表,但是当箱子变得太大时,它们就会被转换成TreeNodes的箱子,每个箱子的结构都类似于java.util.TreeMap。
大多数方法都尝试使用普通的垃圾箱,但是在适用的情况下(只要检查一个节点的实例)就可以传递到TreeNode方法。
可以像其他的一样遍历和使用TreeNodes,但是在过度填充的时候支持更快的查找
然而,由于大多数正常使用的箱子没有过多的填充,所以在表方法的过程中,检查树箱的存在可能会被延迟。
树箱(bins即所有的元素都是TreeNodes)主要是由hashCode来排序的,但是在特定的情况下,
如果两个元素是相同的“实现了Comparable接口”,那么使用它们的比较方法排序。
(我们通过反射来保守地检查泛型类型,以验证这一点——参见方法comparableClassFor)。
使用树带来的额外复杂,是非常有价值的,因为能提供了最坏只有O(log n)的时间复杂度当键有不同的散列或可排序。
因此,性能降低优雅地在意外或恶意使用hashCode()返回值的分布很差,以及许多key共享一个hashCode,只要他们是可比较的。
(如果这两种方法都不适用,同时不采取任何预防措施,我们可能会在时间和空间上浪费大约两倍的时间。
但是,唯一已知的案例源于糟糕的用户编程实践,这些实践已经非常缓慢,这几乎没有什么区别。)
因为TreeNodes大小大约是普通节点的两倍,所以只有当容器包含足够的节点来保证使用时才使用它们(见treeifythreshold)。
当它们变得太小(由于移除或调整大小),它们就会被转换回普通bins。
在使用良好的用户hashcode的用法中,很少使用树箱。
理想情况下,在随机的hashcode中,箱子中节点的频率遵循泊松分布(http://en.wikipedia.org/wiki/Poisson_distribution),
默认大小调整阈值为0.75,但由于调整粒度的大小有很大的差异。
忽略方差,list的长度 k=(exp(-0.5) * pow(0.5, k) / factorial(k))
第一个值是:
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
more: less than 1 in ten million 如果再多的话,概率就小于十万分之一了
树箱(tree bin很难翻译啊!)的根通常是它的第一个节点。
然而,有时(目前只在Iterator.remove)中,根可能在其他地方,但是可以通过父链接(方法TreeNode.root())恢复。
所有适用的内部方法都接受散列码作为参数(通常由公共方法提供),允许它们在不重新计算用户hashcode的情况下调用彼此。
大多数内部方法也接受一个“标签”参数,通常是当前表,但在调整或转换时可能是新的或旧的。
当bin列表被树化、分割或取消时( treeified, split, or untreeified),我们将它们保持在相同的相对存取/遍历顺序(例如
字段Node.next)为了更好地保存位置,并稍微简化对调用迭代器的拆分和traversals的处理(splits and traversals that invoke iterator.remove)。
当在插入中使用比较器时,为了在重新平衡中保持一个总排序(或者在这里需要的接近),我们将类和标识符码作为连接开关。
由于子类LinkedHashMap的存在,普通(plain)与树模型(tree modes)之间的使用和转换变得复杂起来。
请参阅下面的hook方法,这些方法在插入、删除和访问时被调用,允许LinkedHashMap内部结构保持独立于这些机制。
(这还要求将Map实例传递给一些可能创建新节点的实用方法。)
concurrent-programming-like SSA-based编码风格有助于避免在所有扭曲的指针操作中出现混叠错误。
*/
所以总结一下是:
之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢
而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了** 查找数据快O(log n),解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当桶中元素大于8并且桶的个数大于64**的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
链表转红黑树体现了空间换时间的思想
2、属性
JDK1.8 中HashMap
的属性
// 初始容量 16 (2的4次方)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子,默认为0.75,用于和容量一起决定扩容的阈值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 使用红黑树的阈值,当桶中的元素个数(链表长度)大于8时,才会使用红黑树进行存储
static final int TREEIFY_THRESHOLD = 8;
// 使用链表的阈值,当桶中的元素个数小于6个时,就会由红黑树转变为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小的红黑树化的桶数,当桶的个数大于64个时,就会使用红黑树进行存储
static final int MIN_TREEIFY_CAPACITY = 64;
// Node数组,也就是桶
transient Node<K, V>[] table;
// 缓存entrySet
transient Set<Map.Entry<K,V>> entrySet;
// map中k-v键值对的个数
transient int size;
// 修改访问标记
transient int modCount;
// 扩容阈值,等于 容量 × 负载因子,初始化时值为向上取2的幂
int threshold;
// 负载因子
final float loadFactor;
3、存储的结构
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;
}
}
4、构造方法
无参构造方法
public HashMap() {
// 初始化了负载因子
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
HashMap(int initialCapacity)构造方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
HashMap(int initialCapacity, float loadFactor)方法
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);
}
// 容量向上取整为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;
}
5、添加方法
put(K, V)方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的
// 那么这种处理就可以有效避免类似情况下的哈希碰撞
static final int hash(Object key) {
int h;
// 如果key不为null,就让key的高16位和低16位取异或
// 和Java 7 相比,hash算法确实简单了不少
// 使用异或尽可能的减少碰撞
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 放入元素的操作
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab相当于哈希表
Node<K,V>[] tab;
Node<K,V> p;
// n保存了桶的个数
// i保存了应放在哪个桶中
int n, i;
// 如果还没初始化哈希表,就调用resize方法进行初始化操作
// resize()方法在后面分析
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 这里的 (n - 1) & hash 相当于 Java 7 中的indexFor()方法,用于确定元素应该放在哪个桶中
// (n - 1) & hash 有以下两个好处:1、放入的位置不会大于桶的个数(n-1全为1) 2、用到了hash值,确定其应放的对应的位置
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果桶中没有元素,就将该元素放到对应的桶中
// 把这个元素放到桶中,目前是这个桶里的第一个元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
// 创建和键类型一致的变量
K k;
// 如果该元素已近存在于哈希表中,就覆盖它
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 {
// binCount用于计算桶中元素的个数
for (int binCount = 0; ; ++binCount) {
// 找到插入的位置(链表最后)
if ((e = p.next) == null) {
// 尾插法插入元素
p.next = newNode(hash, key, value, null);
// 如果桶中元素个数大于阈值,就会调用treeifyBin()方法将其结构改为红黑树(但不一定转换成功)
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果size大于阈值,就进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return 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;
// 原容量大于0(已近执行了put操作以后的扩容)
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 原容量扩大一倍后小于最大容量,那么newCap就为原容量扩大一倍,同时新阈值为老阈值的一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 调用了含参构造方法的扩容
// 原容量小于等于0,但是阈值大于0,那么新容量就位原来的阈值(阈值在调用构造函数时就会确定,但容量不会)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 调用了无参构造方法的扩容操作
else {
// zero initial threshold signifies using defaults
// 如果连阈值也为0,那就调用的是无参构造方法,就执行初始化操作
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新阈值为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) {
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
// lo 和 hi 分别为两个链表,保存了原来一个桶中元素被拆分后的两个链表
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;
}
图解扩容对链表的重构
比如哈希表中桶的个数是4
个,其中0、4、8、12
因为低两位都是0
,与 4-1=3(11)
进行按位与后,都被放在了第一个桶中。
然后开始了扩容操作。将元素哈希值按位与旧容量(不是旧容量-1)为0的放在lo链表中,不为0的放在hi链表中。
do {
next = e.next;
// 当前元素的hash值和容量进行按位与,决定被分配到哪个链表中
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);
遍历链表,将lo中的放在原来的桶中,hi中的放在增加的桶中
// 通过头指针直接将链表放入桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
总结:
-
先将原桶中的元素的hash值与旧容量进行按位与操作
- 如果结果为0,就放入lo链表中
- 如果结果不为0,就放入hi链表中
-
lo链表中的元素继续放在新的哈希表中原来的位置
-
hi链表中的元素放在新的哈希表中,扩容后相对于原来的位置上(j+oldCap)
- 两个桶之间的间隔数就为增加原来哈希表的容量
好处
- 两个桶之间的间隔数就为增加原来哈希表的容量
-
顺序放入,减少了发生死锁的几率
-
使得元素相对均匀地存在于哈希表中
5、putAll()方法
public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获得待插入表的大小
int s = m.size();
// 如果待插入表中有元素
if (s > 0) {
// 如果当前哈希表为空
if (table == null) { // pre-size
// 容量调整过程,如果超过阈值,就调用tableSizeFor()方法扩容(直接扩容,因为此时哈希表为空)
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
// 超过阈值,就调用resize()方法扩容
else if (s > threshold)
resize();
// 把元素依次放入到哈希表中
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);
}
}
}
这里用到了hash()
,具体可以研究:知乎:hash()分析的最透彻的文章
6、移除方法
remove(Object key)
public V remove(Object key) {
Node<K,V> e;
// 调用removeNode()方法,返回其返回的结果
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;
// 桶中有元素, p保存了桶中的首个元素
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;
// 找到对应的元素,保存在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 = 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;
}
8、查找方法
get(Object key)方法
public V get(Object key) {
Node<K,V> e;
// 通过key的哈希值和key来进行查找
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;
// 如果第一个元素不是,就继续往后找。找到就返回,没至找到就返回null
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;
}
9、HashMap中JDK1.8的树化方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果当前哈希表中桶的数目,小于最小树化容量,就调用resize()方法进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 当桶中元素个数大于8,且桶的个数大于64时,进行树化
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);
}
}
链表变为红黑树的条件:元素个数大于8同时桶的个数大于64
推荐阅读:Hashmap链表长度为8时转换成红黑树,你知道为什么是8吗?
当某个桶中的元素个数大于8时,会调用 treeifyBin()
方法,但并不一定就会变为红黑树
当哈希表中桶的个数大于64时,才会真正进行让其转化为红黑树
为什么桶中元素多于了8个,桶的个数小于64,调用resize()方法就可以进行调整?
因为调用resize()
方法进行扩容时,会让同一个桶中的元素进行桶的重新分配。一部分会被放新哈希表中在原来的位置上,一部分会被放在扩容后的位置上。
为什么桶一定要大于64
以上就是文章的全部:
推荐阅读:
HashMap源码分析
【动图演示】重点!多图演示红黑树的概念及插入操作(附考研模拟题)