原文传送门:https://blog.youkuaiyun.com/carson_ho/article/details/79373026 非常感谢作者!
1、文章基于
JDK 1.7
,即Java 7
2、关于JDK 1.8
,即Java 8
,具体请看文章
目录:
1、简介
- 1.1 类定义
public class HashMap<K, V>
extends AbstractMap<K, V>
implements Map<K, V>, Clonable, Serializable
- 1.2 主要介绍
2、数据结构
2.1 具体描述
HashMap
采用的数据结构 = 数组(主) + 单链表(副)
2.2 示意图
2.3 存储流程
2.4 数组元素 & 链表节点的实现类
HashMap
中的数组元素 & 链表节点采用Entry
类实现,如下图示:
1、
HashMap
的本质 = 1 个存储Entry
类对象的数组 + 多个单链表
2、Entry
对象本质 = 1 个映射(键-值对),属性包括:键(key
)、值(value
)& 下1节点(next
) = 单链表的指针 = 也是一个Entry
对象,用于解决hash
冲突
源码如下:
/**
* Entry类实现了Map.Entry接口
* 即 实现了getKey()、getValue()、equals(Object o)和hashCode()等方法
**/
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
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;
}
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;
}
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
3、具体使用
3.1 主要使用 API(方法、函数)
V get(Object key); // 获得指定键的值 V put(K key, V value); // 添加键值对
void putAll(Map<? extends K, ? extends V> m); // 将指定Map中的键值对 复制到 此Map中
V remove(Object key); // 删除该键值对
boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true
boolean containsValue(Object value); // 判断是否存在该值的键值对;是 则返回true
Set<K> keySet(); // 单独抽取key序列,将所有key生成一个Set
Collection<V> values(); // 单独value序列,将所有value生成一个Collection
void clear(); // 清除哈希表中的所有键值对
int size(); // 返回哈希表中所有 键值对
3.2 遍历 HashMap
方式一: 使用 entrySet()
Map<String, Object> map = new HashMap<>();
Iterator iter = map.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry entry = (Map.Entry)iter.next();
Object key = entry.getKey(); // 获取key值
Object value = entry.getValue(); // 获取value值
}
也可以使用 foreach
循环的形式遍历
Map<String, Object> map = new HashMap<>();
for (Map.Entry<String, Object> entry: map.entrySet()) {
entry.getKey();
entry.getValue();
}
**方式二:**使用 keySet()
Map<String, Object> map = new HashMap<>();
Iterator iter = map.keySet().iterator();
while (iter.hasNext()) {
Object key = iter,next();
Object value = map.get(key);
}
也可以使用 foreach
循环的形式遍历
Map<String, Object> map = new HashMap<>();
for (String key: map.keySet()) {
map.getKey(key); // 获取value值
}
两种方式效率对比:
keySet
方式其实是遍历了 2 次,一次是转为 iterator
, 一次是从HashMap
中取出 key
对应的 value
,而 entrySet
方式只遍历了 1次,把 key
和 value
都放到了 entry
中
4、基础知识:HashMap 中的重要参数
HashMap
中的主要参数:容量、加载因子、扩容阈值- 源码分析:
// 1. 容量(capacity): HashMap中数组的长度
// a. 容量范围:必须是2的幂 & <最大容量(2的30次方)
// b. 初始容量 = 哈希表创建时的容量
// 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量 = 2的30次方(若传入的容量过大,将被最大值替换)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
// a. 加载因子越大、填满的元素越多 = 空间利用率高、但冲突的机会加大、查找效率变低(因为链表变长了)
// b. 加载因子越小、填满的元素越少 = 空间利用率小、冲突的机会减小、查找效率高(链表不长)
// 实际加载因子
final float loadFactor;
// 默认加载因子 = 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量)
// a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
// b. 扩容阈值 = 容量 x 加载因子
int threshold;
// 4. 其他
// 存储数据的Entry类型 数组,长度 = 2的幂
// HashMap的实现方式 = 拉链法,Entry数组上的每个元素本质上是一个单向链表
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
// HashMap的大小,即 HashMap中存储的键值对的数量
transient int size;
- 参数示意图
- 详细说明加载因子
5、源码分析
- 主要内容:
步骤1:声明 1 个 HashMap
的对象
4 个构造函数
/**
* 源码分析:主要是HashMap的构造函数 = 4个
* 仅贴出关于HashMap构造函数的源码
*/
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable{
/**
* 构造函数1:默认构造函数(无参)
* 加载因子 & 容量 = 默认 = 0.75、16
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 构造函数2:指定“容量大小”的构造函数
* 加载因子 = 默认 = 0.75 、容量 = 指定大小
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 构造函数3:指定“容量大小”和“加载因子”的构造函数
* 加载因子 & 容量 = 自己指定
*/
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);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
/**
* 构造函数4:包含“子Map”的构造函数
* 即 构造出来的HashMap包含传入Map的映射关系
* 加载因子 & 容量 = 默认
*/
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
}
步骤2:向 HashMap
添加数据
- 添加数据流程如下:
- 源码分析:
public V put(K key, V value) {
(分析1)// 1. 若 哈希表未初始化(即 table为空)
// 则使用 构造函数时设置的阈值(即初始容量) 初始化 数组table
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
(分析2)// 2. 判断key是否为空值null
// 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;
(分析4)// 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++;
(分析5)// 3.2 若 该key不存在,则将“key-value”添加到table中
addEntry(hash, key, value, i);
return null;
}
/**
* 键值为 null,调用函数将 null 值存入
*/
private V putForNullKey(V value) {
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;
}
对应的流程图:
- 根据上述流程的 5 个分析点进行详细讲解
分析1:初始化哈希表
即初始化数组(table
)、扩容阈值(threshold
)
/**
* 函数使用原型
*/
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
/**
* 源码分析:inflateTable(threshold);
*/
private void inflateTable(int toSize) {
// 1. 将传入的容量大小转化为:>传入容量大小的最小的2的次幂
// 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
int capacity = roundUpToPowerOf2(toSize);->>分析1
// 2. 重新计算阈值 threshold = 容量 * 加载因子
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 3. 使用计算后的初始容量(已经是2的次幂) 初始化数组table(作为数组长度)
// 即 哈希表的容量大小 = 数组大小(长度)
table = new Entry[capacity]; //用该容量初始化table
initHashSeedAsNeeded(capacity);
}
真正初始化哈希表(初始化存储数组 table
)是在第一次添加键值对时,即第一次调用 put
方法
分析 2:当 key ==null时,将该 key-value 的存储位置规定为数组table
中的第 1 个位置,即 table [0]
/**
* 函数使用原型
*/
if (key == null)
return putForNullKey(value);
/**
* 源码分析:putForNullKey(value)
*/
private V putForNullKey(V value) {
// 遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对
// 1. 若有:则用新value 替换 旧value;同时返回旧的value值
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++;
// 2 .若无key==null的键,那么调用addEntry(),将空键 & 对应的值封装到Entry中,并放到table[0]中
addEntry(0, null, value, 0);
// 注:
// a. addEntry()的第1个参数 = hash值 = 传入0
// b. 即 说明:当key = null时,也有hash值 = 0,所以HashMap的key 可为null
// c. 对比HashTable,由于HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null
// d. 此处只需知道是将 key-value 添加到HashMap中即可,关于addEntry()的源码分析将等到下面再详细说明,
return null;
}
说明:
HashMap
的键key
可为 null(区别于Hashtable
的key
不可为 null)HashMap
的键key
可为 null 且只能为 1 个,但值value
可为 null 且为多个
分析 3:计算存放数组 table
中的位置(即 数组下标 or 索引)
/**
* 函数使用原型
* 主要分为2步:计算hash值、根据hash值再计算得出最后数组位置
*/
// a. 根据键值key计算hash值 ->> 分析1
int hash = hash(key);
// b. 根据hash值 最终获得 key对应存放的数组Table中位置 ->> 分析2
int i = indexFor(hash, table.length);
/**
* 源码分析1:hash(key)
* 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
* JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
* JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
*/
// JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
// 1. 取hashCode值: h = key.hashCode()
// 2. 高位参与低位的运算:h ^ (h >>> 16)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// a. 当key = null时,hash值 = 0,所以HashMap的key 可为null
// 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null // b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行 扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制
}
/**
* 函数源码分析2:indexFor(hash, table.length)
* JDK 1.8中实际上无该函数,但原理相同,即具备类似作用的函数
*/
static int indexFor(int h, int length) {
return h & (length-1);
// 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)
}
- 总结 计算存放在数组
table
中的位置(即数组下标、索引)的过程
在了解如何计算存放数组 table
中的位置,下面 3 个问题讲解为什么要这样计算:
1、为什么不直接采用经过 hashcode()
处理的哈希吗作为存储数组 table
的下标位置?
2、为什么采用哈希码与运算(&)(数组长度-1)计算数组下标?
3、为什么在计算数组下标前,需要对哈希码进行二次处理:扰动处理?
记住核心思想:
所有处理的根本目的,都是为了提高存储 key-value 的数组下标位置的随机性 & 分布均匀性,尽量避免
hash
值冲突,即:对于不同 key,存储的数组下标位置要尽可能不一样
问题1:为什么不直接采用经过 hashcode()
处理的哈希吗作为存储数组 table
的下标位置?
- 结论:容易出现哈希码与数组大小范围不匹配的情况,即计算出来的哈希码可能不在数组大小范围内,导致无法匹配存储位置
- 原因描述:
- 为了解决 “哈希码与数组大小范围不匹配” 的问题,
HashMap
给出了解决方案:哈希码 与运算(&) (数组长度-1);
问题2:为什么采用哈希码与运算(&)(数组长度-1)计算数组下标?
- 结论:根据
HashMap
的容量大小(数组长度),按需取哈希码一定数量的低位作为存储的数组下标位置,从而解决“哈希码与数组大小范围不匹配”的问题 - 具体解决方案描述:
问题3:为什么在计算数组下标前,需要对哈希码进行二次处理:扰动处理?
- 结论:加大哈希码低位的随机性,使得分布均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少
Hash
冲突 - 具体描述
分析4:若对应的key
已存在,则使用新value
替换旧value
注:当发生
Hash
冲突时,为了保证键key
的唯一性,哈希表不会马上在链表中插入新数据,而是先查找该key
是否存在,若存在,替换即可
// 1. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
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++;
// 2.2 若 该key不存在,则将“key-value”添加到table中
addEntry(hash, key, value, i);
return null;
替换流程:
初始状态:
分析5:若对应的 key
值不存在,则将该“key-value”添加到数组 table
对应的位置中
源码如下:
/**
* 源码分析:addEntry(hash, key, value, i)
* 作用:添加键值对(Entry )到 HashMap中
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
// 参数3 = 插入数组table的索引位置 = 数组下标
// 1. 插入前,先判断容量是否足够
// 1.1 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); // a. 扩容2倍 --> 分析1
hash = (null != key) ? hash(key) : 0; // b. 重新计算该Key对应的hash值
bucketIndex = indexFor(hash, table.length); // c. 重新计算该Key对应的hash值的存储数组下标位置
}
// 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中--> 分析2
createEntry(hash, key, value, bucketIndex);
}
/**
* 分析1:resize(2 * table.length)
* 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
*/
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];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
// 5. 将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1
transfer(newTable, rehash);
// 6. 新数组table引用到HashMap的table属性上
table = newTable;
// 7. 重新设置阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* 分析1.1:transfer(newTable);
* 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
* 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
*/
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;
}
}
}
/**
* 分析2:createEntry(hash, key, value, bucketIndex);
* 作用: 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
// 1. 把table中该位置原来的Entry保存
Entry<K,V> e = table[bucketIndex];
// 2. 在table中该位置新建一个Entry:将原头结点位置(数组上)的键值对 放入到(链表)后1个节点中、将需插入的键值对 放入到头结点中(数组上)-> 从而形成链表
// 即 在插入元素时,是在链表头插入的,table中的每个位置永远只保存最新插入的Entry,旧的Entry则放入到链表中(即 解决Hash冲突)
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
需要注意:键值对的添加方式 & 扩容机制
1、键值对的添加方式:单链表的头插法
- 即 将该位置(数组上)原来的数据放在该位置的(链表)下 1 个节点中(
next
)、在该位置(数组上)放入需插入的数据从而形成链表 - 如下示意图
2、扩容机制
-
流程如下:
-
扩容过程中的转移数据示意图如下
在扩容resize()
过程中,将旧的数组上的数组转移到新数组上时,转移操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况
扩容前 = 1 -> 2 ->3, 扩容后 = 3 -> 2 -> 1
- 此时若(多线程)执行
put()
操作,一旦出现扩容情况,则容易出现环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop
),即 死锁的状态 = 线程不安全
总结
- 向
HashMap
添加数据(成对放入键-值对)的全流程
- 示意图
步骤3:从 HashMap
中获取数据
- 假如理解了上述
put()
函数的原理,那么get()
函数非常好的理解,二者的过程原理几乎相同 get()
函数流程如下:
- 具体源码如下:
/**
* 函数原型
* 作用:根据键key,向HashMap获取对应的值
*/
map.get(key);
/**
* 源码分析
* */
public V get(Object key) {
// 1. 当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键
if (key == null)
return getForNullKey(); --> 分析1
// 2. 当key ≠ null时,去获得对应值 -->分析2
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
/**
* 分析1:getForNullKey()
* 作用:当key == null时,则到 以哈希表数组中的第1个元素(即table[0])为头结点的链表去寻找对应 key == null的键
*/
private V getForNullKey() {
// 遍历以table[0]为头结点的链表,寻找 key==null 对应的值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
// 从table[0]中取key==null的value值
if (e.key == null)
return e.value;
}
return null;
}
/**
* 分析2:getEntry(key)
* 作用:当key ≠ null时,去获得对应值
*/
final Entry<K,V> getEntry(Object key) {
// 1. 根据key值,通过hash()计算出对应的hash值
int hash = (key == null) ? 0 : hash(key);
// 2. 根据hash值计算出对应的数组下标
// 3. 遍历 以该数组下标的数组元素为头结点的链表所有节点,寻找该key对应的值
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// 若 hash值 & key 相等,则证明该Entry = 我们要的键值对
// 通过equals()判断key是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
关于“向 HashMap 获取数据分析完毕”
步骤4:对 HashMap
的其它操作
HashMap
除了核心的put()
、get()
函数,还有以下主要的函数方法
void clear(); // 清除哈希表中所有键值对
int size(); // 返回哈希表中所有 键值对的数量 =数组中的键值对 + 链表中的键值对
boolean isEmpty(); // 判断 HashMap 是否为空:size == 0 时 表示为空
void putAll(Map<? extends K, ? extends V> m); // 将制定的 Map 中的键值对复制到 Map 中
V remove(Object key); // 删除该键值对
boolean containsKey(Object key); // 判断是否存在该键值对:是 则返回 true
boolean containsValue(Object value); // 判断是否存在该值得键值对: 是 则返回 true
对应源码就不具体分析了
6、源码总结
下面,用 3 个图总结整个源码内容:
总结内容 = 数据结构、主要参数、添加 & 查询数据流程、扩容机制
- 数据结构 & 主要参数
-
添加 & 查询数据流程
-
扩容机制
7、与 JDK 1.8
的区别
HashMap
的实现在 JDK 1.7
和 JDK 1.8
差别较大,具体区别如下:
JDK 1.8
的优化目的主要是:减少Hash
冲突 & 提高哈希表的存、取效率
7.1 数据结构
7.2 获取数据时
7.2 扩容机制
8、额外补充:关于 HashMap
的其它问题
- 几个小问题
8.1 哈希表如何解决 Hash
冲突
8.2 为什么 HashMap
具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化
-
HashMap
线程不安全的一个重要原因:多线程下容易出现resize()
死循环
本质 = 并发 执行put()
操作导致触发 扩容行为,从而导致 环形链表,使得在获取数据遍历链表时形成死循环,即Infinite Loop
-
扩容的源码分析
resize()
/**
- 源码分析:resize(2 * table.length)
- 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
*/
void resize(int newCapacity) {
// 1、保存旧数组
Entry[] oldTable = table;
// 2、保存旧容量(old capacity),即数组长度
int oldCapacity = oldTable.length;
// 3、若旧容量已经是系统默认的最大容量了,那么将阈值设置成整型的最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 4、新建一个数组 table,容量为原来的 2 倍
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
// 5、将旧数组上的数据(键值对)转移到新 table 中,从而完成扩容
transfer(newTable, rehash);
// 6、新数组 table 引用到 HashMap 的 table 属性上
table = newTable;
// 7、重新设置阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
- 分析1.1:transfer(newTable);
- 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
- 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
*/
void transfer(Entry[] newTable, boolean rehash) {
// 1、获取数组大小 = 获取新容量大小
int newCapacity = newTable.length;
// 2、通过遍历旧数组,将旧数组上的数据(键值对)转移到新数组中
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);
}
// 2.1 重新计算每个元素的存储位置
int i = indexFor(e.hash, newCapacity);
// 2.1 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中,即扩容之后,可能出现逆序,按旧链表的正序遍历链表、在新链表的头部依次插入
e.next = newTable[i];
newTable[i] = e;
// 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
e = next;
}
}
}
分析:从上面可以看出:在扩容 resize()
过程中,在将旧数组上的数据 转移到 新数组上时,转移数据操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况
- 此时若(多线程)并发执行
put()
操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop
),即 死锁的状态,具体请看下图:
注:由于JDK 1.8
转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。
但
JDK 1.8
还是线程不安全,因为无加同步锁保护
8.3 为什么 HashMap
中 String
、Integer
这样的包装类适合作为 key
键
8.4 HashMap
中的 key
若 Object
类型, 则需实现哪些方法?