一、HashMap源码分析(基于jdk1.7)
1. 数据结构
在jdk1.7中HashMap的基本数据结构是数组+链表的形式。
在HashMap中有一个内部类Entry,每添加一个新的<key, value>就将它们封装到一个Entry对象中。每一个Entry包含一个key-value键值对、一个hash值和一个指向下一个Entry的next指针。
static class Entry<K, V> implements Map.Entry<K, V> {
final K key;
V value;
Entry<K, V> next;//存储指向下一个Entry的引用,单链表结构。
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K, V> n) {
value = v;
next = n;
key = k;
hash = h;
}
...
}
2. 元素属性
// 默认数组容量,采用位运算提高速度,把16转化为二进制码相比于1 << 4要慢
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 数组的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 空数组,在数组进行初始化时会使用到
static final Entry<?,?>[] EMPTY_TABLE = {};
// 此table数组用来存储Entry对象,是HashMap保存数据最关键的一步
// transient关键字的作用是:不参与对象的序列化
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
// 表中已存在的<key, value>个数(数组+链表)
transient int size;
// 数组扩容的临界值:threshold = loadFactor * capacity
int threshold;
// 加载因子
final float loadFactor;
// 容器的修改次数:当增加新数据(不代表修改value),删除数据,或者清空使modCount++
// 补充部分具体讲解
transient int modCount;
// 映射容量的默认阈值,高于默认值时,散列度会降低,需要选择新的hash表
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
3. 构造方法
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值。initialCapacity默认为16,loadFactory默认为0.75。
//计算Hash值时的key
transient int hashSeed = 0;
//1、通过初始容量和状态因子构造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;
threshold = initialCapacity;
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}
//2、通过扩容因子构造HashMap,容量去默认值,即16
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//3、装载因子取0.75,容量取16,构造HashMap
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//4、通过其他Map来初始化HashMap,容量通过其他Map的size来计算,装载因子取0.75
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);//初始化HashMap底层的数组结构
putAllForCreate(m);//添加m中的元素
}
4. 核心方法
1、put(K key, V value)
public V put(K key, V value)
(分析1)// 1. 若 哈希表未初始化(即 table为空)
// 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 2. 判断key是否为空值null
(分析2)// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
// (本质:key = Null时,hash值 = 0,故存放到table[0]中)
// 该位置永远只有1个value,新传进来的value会覆盖旧的value
if (key == null)
return putForNullKey(value);
(分析3) // 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
// a. 根据键值key计算hash值
int hash = hash(key);
// b. 根据hash值 最终获得 key对应存放的数组Table中位置
int i = indexFor(hash, table.length);
// 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 3.1 若该key已存在(即 key-value已存在 ),则用 新value 替换 旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue; //并返回旧的value
}
}
// 修改次数加一
modCount++;
(分析4)// 3.2 若 该key不存在,则将“key-value”添加到table中
addEntry(hash, key, value, i);
return null;
}
put(K key, V value)流程:
1、首先,若 哈希表未初始化(即 table为空) 。则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table 。
2、判断key == null?如果为空,则将该键值对 存放到数组table 中的第1个位置,即table [0]。
3、key不为null,通过 (tab.length - 1) &hash得到当前元素存放的索引位置**( **tab.length指的是数组的长度)。
4、如果当前位置存在元素,即发生哈希冲突,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束。
5、如果没有相同的key,新元素插入到链表头中。这里要考虑扩容。
深入分析
分析1:inflateTable(threshold)
private void inflateTable(int toSize) {
// 取容量为大于等于toSize的2的指数次幂,原因在后面讲解
int capacity = roundUpToPowerOf2(toSize);
// 临界值最大只能取MAXIMUM_CAPACITY+1
// 如果未指定capacity和loadFactor,那么threshold=12
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
// 计算hashSeed,不超过MAXIMUM_CAPACITY就会一直保持为0,映射了最后一个属性
initHashSeedAsNeeded(capacity);
}
分析2:putForNullKey()
private V putForNullKey(V value) {
// 虽然这里对链表进行了遍历,但是在if判断中是e.key == null
// 可以得出结论,HashMap只能保存一个key=null的映射
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
分析3:indexFor(int h, int length)
static int indexFor(int h, int length) {
return h & (length-1);
}
HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置。table.length - 1相当于一个低位的掩码,和哈希值取与,可以得到最终的index,因为这其中运用到了位运算,所有效率较高。
分析4:addEntry(int hash, K key, V value, int bucketIndex)
涉及到扩容resize
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果<key, value>数量大于等于临界值 并且 当前数组下标不为空则进行数组扩容
// 注意:这里的size是映射数量(包括链表),并不是数组中存储的有效值的数量
if ((size >= threshold) && (null != table[bucketIndex])) {
// 因为原本的capacity是2的指数次幂,所以扩容将原capacity*2即可
resize(2 * table.length);
// 扩容后hash表可能产生了变化(超过最大值时),所以需要重新计算hash值
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 头插法
createEntry(hash, key, value, bucketIndex);
}
-
插入前,先判断容量是否足够(容量>阈值)。如果不够,进行扩容resize()。
-
首先保存旧数组,旧容量(数组长度);
-
如果旧数组已经是系统默认最大容量,则将阈值设置成整型的最大值。退出。
-
根据新容量,新建一个数组。新table[]。
-
将旧数组上的数据(键值对)转移到新table中。完成扩容。
转移到新table过程:transfer()。遍历原来table中每个位置的链表,并对每个元素进行找索引位置,在新的newTable找到归宿,头插法插入。
-
重新设置阈值。
-
如果容量足够,则创建一个新的数组元素(Entry)放入数组中。
从扩容条件之一的 size >= threshold 可以看出,临界值threshold关乎到是否需要扩容,又threshold = capacity * loadFactor,所以真正影响扩容的是加载因子loadFactor,默认的loadFactor = 0.75f,可能会有读者好奇,为什么要取这么一个值?默认情况下,size = 12时就应该扩容,那么容量为16的数组就会浪费4个存储空间(并不一定,理想情况下是如此),既然造成浪费,为什么不将加载因子设为1呢?原因是,数组查找的效率为O(1),若数组存储接近满状态才扩容,就极易提高哈希碰撞的概率,遍历链表会降低查找速度,而HashMap就因为其出色的查找性能而被广泛使用,所以就需要牺牲空间来换时间了。
2、resize() —多线程死循环问题
HashMap1.7中在多线程扩容时可能会出现死循环
void resize(int newCapacity) {
// 1. 保存旧数组(old table)
Entry[] oldTable = table;
// 2. 保存旧容量(old capacity ),即数组长度
int oldCapacity = oldTable.length;
// 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 4. 根据新容量(2倍容量)新建1个数组,即新table
Entry[] newTable = new Entry[newCapacity];
// 5. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 转移
transfer(newTable);
// 6. 新数组table引用到HashMap的table属性上
table = newTable;
// 7. 重新设置阈值
threshold = (int)(newCapacity * loadFactor);
}
transfer(Entry[] newTable, boolean rehash) 转移方法是出现死锁的元凶
//将老的表中的数据拷贝到新的结构中
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) {//如果是重新Hash,则需要重新计算hash值 。一般不用。重新hash是为了更加散列。jdk1.8没有rehash.
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//定位Hash桶
e.next = newTable[i];//元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
newTable[i] = e;//newTable[i]的值总是最新插入的值
e = next;//继续下一个元素
}
}
}
假设现在有A、B两个线程,两个线程都执行了Entry<K,V> next = e.next这条语句,则出现了下面这个画面,其中A.e -> A.next; B.e->B.next
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5eXsrlME-1623201380829)(HashMap_yuan.assets/image-20210609013522063.png)]
如果B线程刚执行完Entry<K,V> next = e.next后时间片段到了,则换A线程继续执行,直到transfer()方法执行完毕,会出现下面的情况(注意是前插法,所以顺序会颠倒)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aWHcv9hD-1623201380829)(HashMap_yuan.assets/image-20210609013547978.png)]
Entry2 -> Entry1,而B.e -> B.next,线程B会继续执行transfer()后面的方法,继续将B.e指向的Entry1前插到Entry2上,这样就已经形成了循环链表,线程B将永远处在transfer()方法中,形成死锁。
最后一步可以看出,jdk1.7中,HashMap中链表节点的增加使用的是前插法。
createEntry(int hash, K key, V value, int 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++;
}
至此,put()方法就已经基本讲解完毕了。小结:
- put(key,value)
- int hash = hash(key);
- int index = hashcode & (length - 1)
- 遍历index位置的链表,如果存在相同的key,则进行value覆盖,并且返回之前的value值
- 当达到数组的临界值,需要对数组进行扩容
- 将key,value封装为节点对象(Entry)
- 将节点插在index位置上的链表的头部
- 将链表头节点移动到数组上
3、get()
get()方法比较简单。
1、 当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键
2、key != null,hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置 i。遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。
5. modcount作用:fast-fail
modcount++; 代表修改次数,每修改一次就会+1。
为什么要有modcount?
产生快速失败的机制(fast-fail)。出现ConcurrentModificationException异常。
在多线程中使用HashMap都会遇到这个异常,出现这个异常的场合一般都是遍历keySet、values、entrySet时对HashMap进行了添加、删除等操作。
这里以entrySet作为代表分析。如果HashMap结构未发生变更,每次遍历的顺序都是一致,但都不是插入的顺序。下面来看遍历的相关核心实现:
entrySet遍历Map元素与并发更新fail fast原理
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
// 缓存modCount
int mc = modCount;
// 遍历tbale
for (int i = 0; i < tab.length; ++i) {
// 对每个桶根据next顺序进行遍历
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e);
}
// 如果遍历过程,Map被修改过,则抛ConcurrentModificationException
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
如果我们需要在遍历过程删除元素,需要使用Map的迭代器来进行遍历,否则在遍历过程检测到modCount发生变化,会抛出异常。
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
// ……省略其他代码
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
// ……省略其他代码
}
final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
小结:
二、HashMap源码分析(基于jdk1.8)
1. 数据结构
在jdk1.8中HashMap的基本数据结构是数组+链表/红黑树的形式。
变化: JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)且当前数组的长度>= 64时,将链表转化为红黑树,以减少搜索时间。
引入原因:提高hashmap的性能。解决了发生hash冲突时,链表过长而导致索引效率慢的问题。是利用红黑树快速增删改查的特点;时间复杂度从O(N)到O(logN)。
-
HashMap
中的数组元素 & 链表节点 采用Node
类 实现 -
HashMap
中的红黑树节点 采用TreeNode
类 实现
2. 元素属性
HashMap
中的主要参数 同JDK 1.7
,即:容量、加载因子、扩容阈值。- 但由于数据结构中引入了 红黑树,故加入了 与红黑树相关的参数。具体介绍如下:
/**
* 主要参数 同 JDK 1.7
* 即:容量、加载因子、扩容阈值(要求、范围均相同)
*/
// 1. 容量(capacity): 必须是2的幂 & <最大容量(2的30次方)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 = 2的30次方(若传入的容量过大,将被最大值替换)
// 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
final float loadFactor; // 实际加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子 = 0.75
// 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量)
// a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
// b. 扩容阈值 = 容量 x 加载因子
int threshold;
// 4. 其他
transient Node<K,V>[] table; // 存储数据的Node类型 数组,长度 = 2的幂;数组的每个元素 = 1个单链表
transient int size;// HashMap的大小,即 HashMap中存储的键值对的数量
/**
* 与红黑树相关的参数
*/
// 1. 桶的树化阈值:即 链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 2. 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
// 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
// 否则,若桶内元素太多时,则直接扩容,而不是树形化
// 为了避免进行扩容、树形化选择的冲突,这个值不能小于64
static final int MIN_TREEIFY_CAPACITY = 64;
3、核心方法
1、put
此处有2个主要讲解点:
- 计算完存储位置后,具体该如何 存放数据 到哈希表中。
- 具体如何扩容,即 扩容机制。
需进行多次数据结构的判断:数组、红黑树、链表。
1、判断hash表是否初始化。未初始化,resize进行初始化。
2、根据key的hash定位key应在table数组的索引位i和首节点p。table[i]桶为空,直接新建节点,插入。
3、桶不为空。
3.1、判断 table[i]的元素的key是否与 需插入的key一样,若相同则 直接用新value 覆盖 旧value。
3.2、继续判断。需插入的数据结构是否为红黑树 or 链表。优先判断是否红黑树。
3.2.1若是红黑树。在树中插入节点 或者更新节点。
3.2.2若是链表。在链表中插入节点 或者更新节点。
i:遍历table[i]。判断key是否已经存在。若已存在,则新value覆盖旧的;
ii:若到尾部了也没找到相同key。即key目前不存在。链表尾部插入数据。
插入节点后,若链表节点>=树阈值。链表转换为红黑树。树化。跳出循环。
3.3插入成功后,判断实际存在的键值对数量>最大容量(size > threshold)。
大于则扩容。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
// n是table长度,i是key在table的索引位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,通过resize进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中
if ((p = tab[i = (n - 1) & hash]) == null)
// 桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
tab[i] = newNode(hash, key, value, null);
else {
// 桶中已经存在元素
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)和传入key的hash值、引用地址或equals方法是否相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// hash值不相等,即key不相等;
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) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调(默认实现为空)。
afterNodeInsertion(evict);
return null;
}
2、treeifyBin()树化
在扩容的过程中,满足树化的要求
1.链表长度大于等于 TREEIFY_THRESHOLD 8
2.桶数组容量大于等于 MIN_TREEIFY_CAPACITY 64
- 考虑树化,加入第二个条件的原因在于:
1.当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。
2.容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。
- 这里值得讨论的是树化后,如何决定每个key-value的存储顺序,源码中的实现如下:
1.比较键与键之间 hash 的大小,设置比较值dir。更小dir =-1,大dir=1;如果 hash 相同,继续往下比较。
2.检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:
dir<=0,放左子树; >0,放右子树。
- 在源码中,链表的pre/next顺序是保留的,这也是TreeNode继承自Node类的必要性,也方便后续红黑树转化回链表结构。
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) //64
resize();
//当前找的链表第一个节点e不为空
else if ((e = tab[index = (n - 1) & hash]) != null) {
//TreeNode树节点。hd红黑树链表头,红黑树链表尾(
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放在数组上,且hd存在,将链表进行红黑树化
hd.treeify(tab);
}
}
2.1 treeify() 树节点生成红黑树
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) {//设置根结点
//如果根节点还没设置则当前节点设置为根节点root
x.parent = null;
//根节点一定是黑色的
x.red = false;
root = x;
} else { //往根结点下面插入
//获取当前循环节点的key和哈希值
K k = x.key;
int h = x.hash;
Class<?> kc = null; //k的class类型。
//每次都从根节点开始循环
TreeNode<K, V> p = root;
for (; ; ) { //遍历当前红黑树
int dir;
//获得p的hash值和key
int ph = p.hash;
K pk = p.key; //红黑树里面已经有的根结点
//1、比较hash值,然后根据比较值dir决定插入左边还是右边
if (ph > h) {//哈希值更小,往左走
dir = -1;
} else if (ph < h) {//往右走
dir = 1;
//2、哈希值相等则:检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
} else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:
dir = tieBreakOrder(k, pk);
}
TreeNode<K, V> xp = p;
p = (dir <= 0) ? p.left : p.right;
//仅当当前要插入的位置上没有节点时, 才进行插入。
if (p == null) {
//设置父节点
x.parent = xp;
//根据dir值设置为父节点的左右子节点
if (dir <= 0) {
xp.left = x;
} else {
xp.right = x;
}
//插入成功后平衡红黑树。新的根结点
root = balanceInsertion(root, x);
//跳出当前循环
break;
}
}
}
}
//确保当前的新的根结点root是直接落在table数组上的
moveRootToFront(tab, root);
}
3 、resize()扩容
在第一次触发初始化HashMap或扩容时都会触发resize函数调整。在进行扩容前,会根据不同触发条件,计算扩容后的阈值和容量,初始化新table,而后将旧table的元素迁移到新table中。整个扩容的核心算法在元素迁移部分。
迁移算法原理
迁移算法是:
1.遍历table,过滤出不为null的桶首节点。
2.如果没有后续节点,直接计算新的索引位置存放在新的table中
3.否则如果是树节点,则拆分树节点再进行重新映射:
4.最后是普通链表节点,则先分组再映射,具体实现是根据(e.hash & oldCap)是否为0分成两条链表:高位链表和低位链表。
final Node<K,V>[] resize() {
// 当前table保存
Node<K,V>[] oldTab = table;
// 保存table大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 保存当前阈值
int oldThr = threshold;
// 阈值=容量*loadFactor
int newCap, newThr = 0;
// 如果旧容量大于0,在没有超过最大容量,会对旧容量oldCab和阈 值oldThr进行翻倍,存储在newCap和newThr中
if (oldCap > 0) {
// 如果超过最大容量,规整为最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍,使用左移,效率更高
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值翻倍,如果能进来证明此map是扩容而不是初始化
newThr = oldThr << 1; // double threshold
}
// 旧容量为0,阈值大于0
else if (oldThr > 0)
newCap = oldThr;
// oldCap = 0并且oldThr = 0
else {
// 创建map时用的无参构造进入此if:
// 使用缺省值(如使用HashMap()构造函数,之后再插入一个元素会调用resize函数,会进入这一步)
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"})
// 初始化table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 之前的table已经初始化过。
if (oldTab != null) {
// 复制元素,重新进行hash,遍历旧table
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 数组元素首节点不为空才继续操作
if ((e = oldTab[j]) != null) {
// 先设为空
oldTab[j] = null;
// 每个桶仅有首节点,没有后续节点
if (e.next == null)
// 根据hash计算在新表的索引位置,存入新表中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 有子节点,且为树,进行树迁移操作
((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;
// 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成rehash
// 为0放在lo链表,不为0放在hi链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 尾为空,设置头部,第一次进入循环触发
loHead = e;
else // 接尾部
loTail.next = e;
loTail = e; // 更新尾部
}
else {//类似上面操作。索引与!=0
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;
}
从代码中我们可以看到,对于每个桶:
如果是链表节点,在经过扩容后,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中,但新表中两个桶的节点顺序并未发生改变。 如果是树节点,会对树进行拆分,再重新映射到新table中,具体实现在下一节分析。 每次进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,但经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。
4、split() 迁移
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
//低位的链表,高位的链表
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
/*
* 红黑树节点仍然保留了 双向链表的 next 引用,故仍可以按链表方式遍历红黑树。
* 下面的循环是对红黑树节点进行分组,与上面类似
*/
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// bit是旧table容量,这里同样根据是否(e.hash & bit) == 0切分到两个桶,切分同时对链表长度进行计数
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc; //节点个数
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc; //节点个数
}
}
if (loHead != null) {
// 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
if (lc <= UNTREEIFY_THRESHOLD)//<=6 。红黑树转为链表
tab[index] = loHead.untreeify(map);
else {//不用转红黑树
tab[index] = loHead;
/*
* hiHead == null 时,表明扩容后,
* 所有节点仍在原位置,树结构不变,无需重新树化
*/
if (hiHead != null) //高位也分到了节点。
loHead.treeify(tab); //低位要重新树化。
// 否则,什么也不做,是直接拿过去的,不用树化。
}
}
// 与上面类似
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从源码上可以看得出,重新映射红黑树的逻辑和重新映射链表的逻辑基本一致。
不同的地方在于,重新映射后,会将红黑树拆分成两条由 TreeNode 组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。
4、数据丢失(与内存可见性有关)或size不准确
在jdk1.8之前,resize操作时,当两个线程同时触发resize操作,基于头插法可能会导致链表节点的循环引用,在下次调用get操作查找一个不存在的key时,会在循环链表中出现死循环。
在jdk1.8中,发现找不到transfer函数,因为jdk1.8直接在resize函数中完成了数据迁移。另外说一句,jdk1.8在进行元素插入时使用的是尾插法。从上述分析可以看到,在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作,因而不再有多线程put导致死循环。但是依然有其他的弊端,比如数据丢失(与内存可见性有关)或size不准确。因此多线程情况下还是建议使用ConcurrentHashMap。