文章目录
Map
Map 集合是有 Key 和 value 的(键值对),Collection 集合是只有 value;Map 接口并不是 Collection 下的;
-
TreeMap:基于红黑树实现;
-
HashMap:基于哈希表(数组+链表+红黑树(1.8))实现;
-
HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高;
-
LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序;
HashMap
HashMap 允许键/值为空对象(null),不保证数据的有序并且元素不能重复,而且它是线程不安全的;
存储结构
HashMap 采用的数据结构 = 数组(主) + 单链表(副),这种数据结构也称为拉链法;
Entry 结点
Entry 类实现了 Map.Entry 接口(Map 接口中的 Entry 接口);即,实现了 getKey() , getValue() , equals(Object o )和 hashCode() 等方法;
static class Entry<K,V> implements Map.Entry<K,V> {
//键
final K key;
//值
V value;
//后继,从而形成用来解决 hash 冲突的单链表
Entry<K,V> next;
//每个结点的 hash 值
int hash;
/*构造方法
参数:哈希值 h ,键 k ,值 v,下一个结点 n
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
//返回与此结点对应的键
public final K getKey() {
return key;
}
//返回与此结点对应的值
public final V getValue() {
return value;
}
//存放值,并获取旧值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
/*
判断2个 Entry 结点是否相等,必须 key 和 value 都相等,才返回 true
*/
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
//计算 hash 值
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
//toString
public final String toString() {
return getKey() + "=" + getValue();
}
}
基本属性
//初始容量 16(1左移4位)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//实际加载因子
final float loadFactor;
//扩容阈值(threshold),当哈希表的大小size ≥ 扩容阈值threshold时,就会扩容哈希表(即扩充 HashMap 的容量)
//扩容:对哈希表进行 resize 操作(即重建内部数据结构),从而哈希表将具有原来两倍的桶数
//threshold 扩容阈值 = 容量 * 加载因子
int threshold;
//空的 Entry 类型数组,参数未知
static final Entry<?,?>[] EMPTY_TABLE = {};
//存储数据的 Entry 类型数组,长度是 2 的幂
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap 存储的结点数量
transient int size;
构造器
//传入初始容量和加载因子
public HashMap(int initialCapacity, float loadFactor) {
//小于 0 直接抛出异常
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();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//空参构造器,传入默认初始容量16和默认加载因子0.75
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//传入一个Map集合并转化为该HashMap
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);
putAllForCreate(m);
}
put 方法
该方法采用的是头插法,详细的看下面代码:
public V put(K key, V value) {
//如果哈希表未初始化,则使用构造器设置的阈值(即初始容量) 初始化数组table
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//键为空时单独处理
if (key == null)
return putForNullKey(value);
//计算key的 hash 值
int hash = hash(key);
//根据hash值和当前数组的长度确认 key 对应存放的数组 table 中位置
int i = indexFor(hash, table.length);
//找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
//遍历链表,以该数组索引为 i 的元素为头结点的链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//若 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;
}
}
modCount++;
//若键为 key 的键值对不存在,那么就插入该键值对
addEntry(hash, key, value, i);
return null;
}
putForNullKey 方法
HashMap 允许插入键为 null 的键值对,但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放;HashMap 使用第 0 个桶存放键为 null 的键值对。
private V putForNullKey(V value) {
//遍历以数组索引为 0 的链表,寻找是否存在 key == null 对应的键值对
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//若存在则用新 value 替换旧 value ,并返回旧的 value
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//否则,就直接插入数组 table[0] 的位置,key 值为空并且 hash 值为0
addEntry(0, null, value, 0);
return null;
}
addEntry 和 createEntry 方法
void addEntry(int hash, K key, V value, int bucketIndex) {
//判断当前的数据个数,如果大于等于阈值(开始的时候为 16 * 0.75 = 12)
//并且数组table的索引为bucketIndex的位置不为空时,就会进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容为原来的2倍
resize(2 * table.length);
//获取传入的key键值的hash值
hash = (null != key) ? hash(key) : 0;
//计算扩容后的存放位置
bucketIndex = indexFor(hash, table.length);
}
//创建存储结点
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
//获取出当前数组的索引为bucketIndex的结点元素赋值给e
Entry<K,V> e = table[bucketIndex];
//将上一步获取的结点e作为新插入结点的后继结点(创建Entry结点在上面的内容中)
//也就是头插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
//长度+1
size++;
}
inflateTable 和 initHashSeedAsNeeded 方法分别是第一次添加元素时进行数组的初始化(阈值 threshold 开始时其实为16,经过该方法后变才成为 12 )和 判断是否需要进行rehash(重新计算 hash 值,用于扩容);
确定桶下标(重点)
HashMap 中很多操作都会先确定一个键值对所在的桶下标;(1.8和1.7虽然代码不一样,但是思想是一致的)
//根据键值 key 计算 hash 值
int hash = hash(key);
//根据 hash 值,获得 key 对应存储的数组 table 中的位置
int i = indexFor(hash, table.length);
计算 hash 值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//键值key使用hashCode()计算出值再与h进行异或运算
h ^= k.hashCode();
//哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
上述代码是求出键值 key 的 hashCode 值,然后会进行 4 次位运算和 5 次异或运算(该处理也被称为扰动处理);最后会将运算的结果返回并参与确定桶下标的处理;
确定下标(为什么这样处理)
确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算,如下:
static int indexFor(int h, int length) {
//根据上一步计算出的数值
return h & (length-1);
}
确定下标的“为什么”
- 为什么不直接采用经过 hashCode() 处理的哈希码作为存储结点元素的数组 table 的下标位置?
- 为什么采用哈希码和 数组长度-1 的与运算(&) 计算数组下标?
- 为什么在计算数组下标前,需对哈希码进行二次处理?
所有处理的根本目的,都是为了提高 存储
key-value
的数组下标位置的随机性 & 分布均匀性,尽量避免出现hash值冲突。即:对于不同key
,存储的数组下标位置要尽可能不一样。
问题1,为什么不直接采用经过 hashCode() 处理的哈希码作为存储结点元素的数组 table 的下标位置?
容易出现 哈希码(hash值) 与 数组大小范围不匹配的情况,即,计算出来的哈希码可能不在数组大小范围内,从而导致无法匹配存储位置;
为了解决“哈希码与数组大小范围不匹配”的问题,HashMap 给出了解决方案:哈希码 &(数组长度-1);
问题2,为什么采用哈希码和 数组长度-1 的与运算(&) 计算数组下标?
在此之前先要了解,为什么 HashMap 的底层数组的长度被要求为 2 的幂?
如果长度设计为素数(素数导致的 hash 冲突会降低)时,如 HashTable 初始数组大小为 11 ,但扩容后不能够保证长度还是素数;
原因:
保证了哈希码的均匀性(实现均匀分布),同时减少了哈希碰撞,如:
数组长度 = 2 的幂 = 100…00 的形式(二进制),其首位是 1 ,最后一位是 0
1.算出的下标值就会集中于某几位,这样增大了 hash 冲突的可能性;
2.数组长度为偶数,最后一位是 0,& 出结果肯定为偶数,这样浪费了一半空间,而且也增大了hash冲突的可能性;
数组长度-1 = 0111…11 的形式,其首位是 0 ,最后一位是 1
这样 & 出的结果,就会由 hash 值的后几位来决定,并且最后一位为 1 ,& 出的结果是奇数还是偶数,由 hash 值的最后来决定;
并且使用 & 运算可以提高运算效率
令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
x : 00010000
x-1 : 00001111
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的:
y : 10110010
x : 00010000
y%x : 00000010
位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能;
引用 CyC2018
问题3为什么在计算数组下标前,需对哈希码进行二次处理?
因为一般数组长度只会对应 hash 码的后几位,这样求出的结果也会易造成 hash 冲突,说白了就是,经过移位运算,得到的 hash 码更加均匀,提高了数组索引的随机性和均匀性;
总结上述问题
问:数组长度为什么是 2 的幂(初始为 16)并且为什么要进行位与运算?
答:长度为 2 的幂时,length-1 的值的所有二进制位都是 1 ,该情况下,函数 indexFor() 的结果等同于 hash 值后几位值,只要输入的 hash 值本身分布均匀,那么该 Hash 算法的结果就是均匀的,这就是为了 实现均匀分布 。使用位于运算是为了提高运算效率。
扩容以及环形链表问题(重点)
多线程下可能也会出现数据丢失的问题。
与扩容有关的参数如下:
参数 | 含义 |
---|---|
capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方 |
size | 键值对数量 |
threshold | size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作 |
loadFactor | 加载因子,table 能够使用的比例,threshold = (int)(capacity * loadFactor)。(容量*阈值,初始后为12 = 16 * 0.75) |
void resize(int newCapacity) {
//保存原来的数组 table
Entry[] oldTable = table;
//保存原来数组的长度
int oldCapacity = oldTable.length;
//如果长度等于最大容量那么阈值直接赋值为整数最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//否则,创建一个长度为newCapacity(2的容量)的数组 newTable
Entry[] newTable = new Entry[newCapacity];
//将原来数组上的数据(键值对)转移到新 table 中完成扩容
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//扩容后,新数组 table 引用到 HashMap 的table属性上
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) {
/*
如果数组当前位置的元素 e 不为空时,就继续向下遍历
也就是如果存在链表就系统链表中的元素
e 也就是用来进行维护遍历的指针
*/
while(null != e) {
//保存元素 e 的下一个结点
Entry<K,V> next = e.next;
//判断是否对 hash 值重新进行计算(效率稍低)
//rehash的值是根据方法initHashSeedAsNeeded计算出的
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算新的存放元素的数组索引 i
int i = indexFor(e.hash, newCapacity);
/*
采用头插法进行移动:先将新数组的newTable[i] 赋值给e的后继(e.next指向newTable[i],第一次时newTable[i]为null),
接着将元素e赋值给newTbale[i](进行了元素的转移,因此新数组中的元素e的后继就为null),最后将next元素结点赋值给e(即,e指向了next的结点元素)
*/
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
在扩容时直接按原来的方法计算 hash 值并且计算扩容后需要插入的位置(不计算添加的元素数据),因此效率较低;而且扩容完成后链表中的元素结点是逆序状态(头插法导致);
在方法 transfer 中进行元素的移动时采用的是头插法,并且在多线程并发下可能会产生环形链表的问题;
如下图所示,e其实也就是维护链表的指针,目前指向如下;
接着会计算新的 hash 值 int i = indexFor(e.hash, newCapacity) ,然后将newTable[i] 引用赋值给 e.next(目前e 的 next 为 7,被赋值后 e 的 next 就为 null):
然后就会执行 newTable[i] = e 语句,也就是将 e 的引用赋值 给 newTable[i] ,接着就会执行 e = next ,将 next 的引用赋值给 e(e 指向了原表中的 7,但 e 的值也就是 3 已经赋值给了 newTable[i]了):
接着就继续移动元素 7,当移动 7 完成后,next 就会指向 null ,然后再次循环时就不满足条件 while(null != e) 最终退出循环,移动完成;
但是,它在多线程的情况下是如何产生环形链表的呢?
示例:两个线程 A 和 B ,都要执行 put 操作,即向表中添加元素,即线程 A 和线程 B 都会进行扩容(目前容量为 2 ,那么扩容后为 4);
如果是线程 A 执行并且执行到 transfer 方法的 1 处(Entry<K,V> next = e.next)然后被 A 被挂起(每个线程有自己的方法栈,目前这个都在 A 线程的方法栈中保存着):
A 被挂起后,接着 B 来继续执行,并且 B 线程将会全部执行完毕并写入内存中(A 和 B 两个线程有各自的方法栈,方法栈是私有的。但是注意!!这里的移动都是通过引用完成的而不是复制元素本身的值),B 线程全部执行完毕:
这时,A 线程的 e 指针还是指向元素 3 ,next 还是指向 7 ;
这时,A 再次进行移动元素,先处理元素 3 , 将 3 放入线程 A 自己栈的新 table 中,但由于 B 线程已经修改了 7 的 next ( B 线程在移动完后,7 元素的 next ,也就是 7.next 指向的是元素 3 ,而不再是旧表中的 null 了(虽然方法栈都是私有的,但它们修改的是引用)),因此, 7.next 就指向 3 :
当 A 线程再次对 7 元素进行移动时,会执行代码 Entry<K,V> next = e.next ,那么会发现,这时的 next 指向的就不是 null 了,而是元素 3 ,接着继续执行代码 e.next = newTable[i] ,就会看见如下图所示:
然后,继续执行代码 newTable[i] = e 和 e = next (此时e.next = 7,也就是3.next = 7):
现在 e 指向的是 3 ,然后进入下一轮 while 循环,会有 Entry<K,V> next = e.next ,那么,3.next = next = 7 ,就成为如下图所示:
最终,陷入死循环中。
get 方法
上述的 put() 方法的原理和 get() 方法两者的原理几乎相同;
public V get(Object key) {
//如果 key 值为空,就和put 类似
//以数组中的第 1 个元素(即table[0])为头结点的链表去寻找对应 key == null 的键
if (key == null)
return getForNullKey();
//否则就另作处理
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
if (size == 0) {
return null;
}
//遍历以 table[0] 为头结点的链表,寻找 key==null 对应的值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//根据 key 值,通过 hash() 计算出对应的 hash 值
int hash = (key == null) ? 0 : hash(key);
//根据 has h值计算出对应的数组下标
//遍历以该数组下标的数组元素为头结点的链表所有结点,寻找该 key 对应的值
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
HashMap1.7 总结
HashMap1.7 底层主要是 数组+链表
的存储方式,初始化时默认数组长度为 16 ,加载因子为 0.75 ,阈值 = 数组长度 * 加载因子 = 12 。
put 元素
- 如果是第一次添加元素,那么先会去初始化数组 16,并且计算出初始的阈值 12 ;
- 如果添加的元素的 key 值为 null ,那么就会对 key 值做单独的处理:将其存储在数组的第一个位置 table[0] ,如果过该位置有元素,那么就向下遍历链表;并且每天添加元素都会判断是否需要扩容,用来保证 HashMap 的性能;
- 否则,计算 hash 值进行对应的存储;
- 存储时如果该位置已经存在元素,那么就先去判断:先用 hash 值判断,如果 hash 值相等再使用 equals 去判断,如果两个都是相等的,说明存入的元素和该元素就是正真的相等
扩容
- 如果添加元素时,HashMap 中的元素的个数 大于等于 阈值 并且目前添加元素的位置也不为空,那么就进行扩容,扩容为原来的 2 倍;
- 然后先创建新的数组并调用 transfer 方法进行元素的移动,而在这之前也会先去判断是否需要重新计算 hash 值;
- 扩容时采用的是头插法,而这样的方法在多线程环境下容易出现环形链表;
问:数组长度为什么是 2 的幂(初始为 16)并且为什么要进行位与运算?
答:长度为 2 的幂时,length-1 的值的所有二进制位都是 1 ,该情况下,函数 indexFor() 的结果等同于 hash 值后几位值,只要输入的 hash 值本身分布均匀,那么该 Hash 算法的结果就是均匀的,这就是为了 实现均匀分布 。使用位于运算是为了提高运算效率。
问:加载因子为什么会是 0.75 而不是其它的?(主要是在1.8时做出判断,决定是否要转变为红黑树)
答:这是考虑到 “哈希冲突” 和 “空间利用率” 矛盾的一个折衷选择;
- 加载因子越大,填满的元素越多,空间利用率越高,但与此同时冲突的机会加大(阈值 = 长度 * 加载因子);
- 反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费又会增多;
HashMap(1.8重点)
存储结构
HashMap1.8 采用的数据结构 = 数组(主) + 单链表(副) + 红黑树;
这种结构,提高了 HashMap 的性能(解决了发生哈希冲突后,链表过长从而导致索引效率变慢的问题),时间复杂度从 O(n) 降低到了 O(logn);
Node 结点
之前 1.7 是 Entry 结点,1.8 则是 Node 结点,其实相差不大,因为都是实现了 Map.Entry (Map 接口中的 Entry 接口)接口,即,实现了 getKey() , getValue() , equals(Object o )和 hashCode() 等方法;
static class Node<K,V> implements Map.Entry<K,V> {
//hash 值
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; }
//hash 值
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//判断2个Entry是否相等,必须key和value都相等,才返回true
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;
}
}
红黑树结点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; //父节点
TreeNode<K,V> left;//左子树
TreeNode<K,V> right;//右子树
TreeNode<K,V> prev;//删除辅助结点(删除后需要取消链接)
boolean red;//颜色
//构造函数
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
//返回当前节点的根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
// ......后面是红黑树的方法
}
基本属性
主要是存储结构的变化导致核心属性的改变,属性中多了关于链表到红黑树转化的阈值,红黑树又转化到链表的阈值等;
//初始容量 16(1左移4位)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//实际加载因子
final float loadFactor;
//扩容阈值(threshold),当哈希表的大小size ≥ 扩容阈值threshold时,就会扩容哈希表(即扩充 HashMap 的容量)
//扩容:对哈希表进行 resize 操作(即重建内部数据结构),从而哈希表将具有原来两倍的桶数
//threshold 扩容阈值 = 容量 * 加载因子
int threshold;
//存储数据的 Node 类型数组,长度是2的幂
transient Node<K,V>[] table;
//存储数据的 Entry 类型数组,长度是 2 的幂
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap的大小,即 HashMap 中存储的键值对的数量
transient int size;
关于红黑树的相关参数属性
//桶的树化阈值:也就是链表转成红黑树的阈值,在存储数据时当链表长度大于该值时,则将链表转换成红黑树
//需要配合下面的属性使用
static final int TREEIFY_THRESHOLD = 8;
//最小树形化容量阈值:当哈希表中的容量大于该值时,则将链表转换成红黑树
//否则,若桶内元素太多时,则会直接扩容,而不是转换为红黑树
//为了避免进行扩容,树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
//桶的链表还原阈值:红黑树转为链表的阈值,当在扩容(resize())时
//(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,
//当原有的红黑树内数量小于6时,则将红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
构造器
//和1.7区别不大
//无参构造器,加载因子默认为0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//指定容量大小的构造器,但调用了双参的构造器,加载因子0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//全参构造器
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//HashMap 的最大容量只能是 MAXIMUM_CAPACITY,哪怕传入的数值大于最大容量,也按照最大容量赋值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//加载因子必须大于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//设置扩容阈值和1.7类似,目前该阈值不是正真的阈值
this.threshold = tableSizeFor(initialCapacity);
}
//将传入的子Map中的全部元素逐个添加到HashMap中
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
和1.7类似,真正初始化(初始化存储数组table)是在第一次添加键值对的时候,即第一次调用 put() 时;
put 方法
1.8的 put 方法,采用尾插法,并且一定要注意扩容和转换红黑树的过程;
public V put(K key, V value) {
//先对传入的key值进行hash值的计算,然后调用putVal方法
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//申明tab 和 p 用于操作原数组和结点
Node<K,V>[] tab; Node<K,V> p;
int n, i;
//如果原数组是空或者原数组的长度等于0,那么通过resize()方法进行创建初始化
if ((tab = table) == null || (n = tab.length) == 0)
//获取到创建后数组的长度n
n = (tab = resize()).length;
//通过key的hash值和 数组长度-1 计算出存储元素结点的数组中位置(和1.7一样)
//并且,如果该位置为空时,则直接创建元素结点赋值给该位置,后继元素结点为null
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//否则,说明该位置存在元素
Node<K,V> e; K k;
//判断table[i]的元素的key是否与添加的key相同,若相同则直接用新value覆盖旧value
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 {
//遍历table[i],并判断添加的key是否已经存在,和之前判断一样,hash和equals
//遍历完毕后仍无发现上述情况,则直接在链表尾部插入数据
for (int binCount = 0; ; ++binCount) {
//如果遍历的下一个结点为空,那么直接插入
//该方法是尾插法(与1.7不同)
//将p的next赋值给e进行以下判断
if ((e = p.next) == null) {
//直接创建新结点连接在上一个结点的后继上
p.next = newNode(hash, key, value, null);
//如果插入结点后,链表的结点数大于等7(8-1,即大于8)时,则进行红黑树的转换
//注意:不仅仅是链表大于8,并且会在treeifyBin方法中判断数组是否为空或数组长度是否小于64
//如果小于64则进行扩容,并且不是直接转换为红黑树
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;
//否则,让p指向下一个元素结点
p = e;
}
}
//接着上面的第二个break,如果e不为空,直接用新value覆盖旧value并且返回旧value
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(第一次时为12)
if (++size > threshold)
//若大于,扩容
resize();
//添加成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
return null;
}
该方法和 1.7 的区别就是,将头插法改为尾插法,并且在是否转换为红黑树做了判断;
hash(key) 方法
1.8 计算 hash 的方法和 1.7 有点差别:
1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算;
而 1.8 只做了2次扰动 = 1次位运算 + 1次异或运算;
为什么使用这样计算,并且为什么计算存储位置 i 时是 i = (n - 1) & hash ,这些“为什么”在1.7讲解时已经做了详细说明;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扩容方法
//该函数有两种使用情况:初始化哈希表或前数组容量过小,需要扩容
final Node<K,V>[] resize() {
//获取原数组
Node<K,V>[] oldTab = table;
//获取到原数组的容量oldCap
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取原扩容阈值
int oldThr = threshold;
//新的容量和阈值目前都为0
int newCap, newThr = 0;
if (oldCap > 0) {
//如果原数组容量大于等于最大容量,那么不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//而没有超过最大容量,那么扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩容为原2倍
newThr = oldThr << 1; // double threshold
}
//经过上面的if,那么这步为初始化容量(使用有参构造器的初始化)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//否则,使用的无参构造器
//那么,容量为16,阈值为12(0.75*16)
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算新的resize的上限
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
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)
//如果无链表,确定元素存放位置,
//扩容前的元素位置为 (oldCap - 1) & e.hash ,所以这里的新的位置只有两种可能:1.位置不变,
//2.变为 原来的位置+oldCap,下面会详细介绍
newTab[e.hash & (newCap - 1)] = e;
//判断是否是树结点,如果是则执行树的操作
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//否则,说明该元素上存在链表,那么进行元素的移动
//根据变化的最高位的不同,也就是0或者1,将链表拆分开
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//最高位为0时,则将节点加入 loTail.next
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//最高位为1,则将节点加入 hiTail.next
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//通过loHead和hiHead来保存链表的头结点,然后将两个头结点放到newTab[j]与newTab[j+oldCap]上面去
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容中重新计算 hash 值
对于代码 newTab[e.hash & (newCap - 1)] ,是计算扩容后的新的存放元素的索引位置,而扩容前则是 table[e.hash & (oldCap - 1)] ,因此,这里就会出现两种情况,一是地址不变,二是变为 原位置+oldCap ;
示例:
如果 oldCap = 16 ,那么二进制就是 0001 0000 ,oldCap -1 = 15 二进制是 0000 1111 ,
如果 e1.hash = 10 二进制是 0000 1010 ,e2.hash = 26 二进制是 0101 1010,
因此 e1 在扩容前的位置是: e1.hash & oldCap -1 ,结果是:0000 1010而 e2 在扩容前是: e2.hash & oldCap-1 ,结果是 0000 1010 ,那么,e1 和 e2 在扩容前在同一个链表上;
而扩容后, newCap = 32 ,再次计算索引位置 e1.hash & newCap - 1(0000 1010 & 0001 1111)的结果是 0000 1010 ,和之前完全一致;
而 e2.hash & newCap - 1(0101 1010 & 0001 1111)结果是 0001 1010 ,该结果就是 扩容前位置 + oldCap,即 10 + 16 = 26;
引用https://blog.csdn.net/qq_37113604/article/details/81353626
因此,这样的计算就是判断倒数第五位是 0 还是 1 ,如果是 0 ,那么位置不会改变,若为 1 ,则位置改变为 扩容前的位置 + 原来数组的容量;
1.7 与 1.8 的扩容区别
左1.8 ,右 1.7
在 JDK1.7 中的话,是先进行扩容后进行插入的,就是当你发现你插入的桶是不是为空,如果不为空说明存在值就发生了hash冲突,那么就必须得扩容,但是如果不发生Hash冲突的话,说明当前桶是空的(后面并没有挂有链表),那就等到下一次发生Hash冲突的时候在进行扩容,但是当如果以后都没有发生hash冲突产生,那么就不会进行扩容了,减少了一次无用扩容,也减少了内存的使用;
总结
HashMap1.8 底层主要是 数组+链表+红黑树
的存储方式,使用无参构造器初始化时默认数组长度为 16 ,加载因子为 0.75 ,树化阈值为 8,最小树形化容量阈值为 64 ,阈值 = 数组长度 * 加载因子 = 12 ;
put 元素
- 第一次添加元素时会先计算 key 的 hash 值;
- 如果 table 没有初始化那么调用 resize() 方法初始化,并设置默认容量 16 ,扩容阈值 12;
- 若不是第一次添加,并且计算出的数组的索引位置为空,那么直接创建结点添加进去,否则就是 Hash 冲突,那么就会判断添加的 key 是否与当前的元素 key 相同,如果相同直接用新的 value 替换旧的 value;而如果不相同则继续判断;
- 接上步,继续判断当前结点是树结点还是一个链表,如果是树结点,那么在红黑树中添加或替换 value 值,否则就会遍历链表进行判断;
- 遍历 table[i] ,判断添加的 key 是否已经存在(同时判断 hash 值和 equals 是否为 true),如果不存在则使用尾插法添加,若存在则替换 value 值;
- 若此时链表长度 > 8 ,并且数组长度大于 64 时,才会进行树化,否则只是扩容;
- 最后添加完成,会判断当前 HashMap 中的元素个数 size 是否大于了扩容阈值(threshold),若大于,则扩容;
扩容
- 扩容前先判断是否需要初始化,当前容量是否大于了最大容量(如果大于则不扩容),接着创建新的数组(为原来数组长度的 2 倍);
- 保存原来的数组,并且遍历旧数组的每个元素,然后重新计算新的索引位置;
- 然后将原数组中的元素逐个转移到新数组中(包含需要添加的元素);
- 1.7与1.8的区别:1.7是先扩容在进行添加元素,那么不管当前插入的元素的 key 是不是空,都先进行扩容,会造成无效扩容;而1.8是先添加再扩容,这时会在 put 元素时会发现当前 key 是否为空,即是否发生 hash 冲突,如果发生了就扩容,不发生就等下一次插入冲突或大于阀值时扩容。
问题
问:那 1.8 添加时采用尾插法不会出现环形链表的问题,那它就是线程安全的吗?
答:不是。
if ((p = tab[i = (n - 1) & hash]) == null)
//----------------> 1
tab[i] = newNode(hash, key, value, null);
注意 putVal 的标注的代码,如果没有 hash 冲突则会直接插入元素。如果线程 A 和线程 B 同时进行 put 操作,刚好这两条不同的数据hash 值一样,并且该位置数据为 null,所以这线程 A、B 都会进入该行代码中。假设一种情况,线程 A 进入后还未进行数据插入时挂起,而线程 B 正常执行,从而正常插入数据,然后线程 A 获取 CPU 时间片,此时线程 A 不用再进行 hash 判断了,问题出现:线程 A 会把线程 B 插入的数据给覆盖,发生线程不安全。
问:为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是 8 ,而不是其它的值呢?
答:在源码上可以看出,在理想状态下,受随机分布的 hashCode 影响,链表中的节点遵循泊松分布,而且根据统计,链表中节点数是 8 的概率已经接近千分之一,而且此时链表的性能已经很差了,所以在这种比较罕见和极端的情况下,才会把链表转变为红黑树。
//Java中解释的原因
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 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