原文首发于:语雀面试资料共享, 欢迎各位大佬前来贡献。
HashMap 底层是基于 数组 + 链表
组成的,通过 链地址法
不过在 jdk1.7 和 1.8 中具体实现稍有不同。
JDK 1.7
1.7 中的数据结构图: 先来看看 1.7 中的实现。 这是 HashMap 中比较核心的几个成员变量;看看分别是什么意思?
- 初始化桶大小为16,因为底层是数组,所以这是数组默认的大小 。
- 桶最大值 1<<30。
- 默认的负载因子(0.75)
- table 真正存放数据的数组。
- Map 存放数量的大小。
- 桶大小,可在初始化时显式指定。
- 负载因子,可在初始化时显式指定。
初始化值
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public 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);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
传入的初始化值 initialCapacity
会自动转换成离该值最近 & 大于该值的2的N次方。JDK1.7使用一个循环,而JDK1.使用tableSizeFor
方法代替了循环,这个方法也很巧妙。
负载因子
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12
就需要将当前 16 的容量进行扩容,扩容后的大小是当前的2倍。而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能,因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。
根据代码可以看到其实真正存放数据的是
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
这个数组,那么它又是如何定义的呢?
Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:
- key 就是写入时的键。
- value 自然就是值。
- 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
- hash 存放的是当前 key 的 hashcode。
知晓了基本结构,那来看看其中重要的写入、获取函数。
put方法
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
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;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
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++;
}
- 判断当前数组是否需要初始化。
- 如果 key 为空,则 put 一个空值进去。
- 根据 key 计算出 hashcode
(h = key.hashCode()) ^ (h >>> 16)
。 - 根据计算出的 hashcode 定位出所在桶。
- 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
- 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
- 当调用 addEntry 写入 Entry 时需要判断是否需要扩容。如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。而在
createEntry
中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。
get方法
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(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;
}
- 首先也是根据
key
计算出hashcode
,然后定位到具体的桶中。 - 判断该位置是否为链表。
- 不是链表就根据
key
的hashcode
是否相等来返回值。 - 为链表则需要遍历直到
key
及hashcode
相等时候就返回值。 - 啥都没取到就直接返回
null
。
hashCode 和 equals 是否要一起重写?
由上面 put
方法 可知,先计算 key 的 hashCode()
方法,决定 key 存在哪一个数组下标中,才会调用 equals()
方法判断是否是同一个 key ,决定是否替换值。
所以必须要一起重写,才能保证在 HashMap
中数组下标相同,如果不同,根本就不会调用 equals()
方法了。
JDK 1.8
jdk1.8的实现和1.7大部分一样。 但是jdk1.7的缺陷是:
当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为
O(N)
。
所以1.8的实现是:当链表长度超过 8 时,会变成红黑树。 结构图: 先来看看几个核心的成员变量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
和 1.7 大体上都差不多,还是有几个重要的区别:
TREEIFY_THRESHOLD=8
用于判断是否需要将链表转换为红黑树的阈值。- HashEntry 修改为 Node。
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next
等数据。
再来看看核心方法。
put方法
源码github.com/openjdk/jdk… 看似要比 1.7 的复杂,我们一步步拆解:
- 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
- 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
- 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的
key
的hashcode
与写入的key
是否相等,相等就赋值给e
,在第 8 步的时候会统一进行赋值及返回。 - 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
- 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
- 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
- 如果在遍历过程中找到 key 相同时直接退出遍历。
- 如果
e != null
就相当于存在相同的 key,那就需要将值覆盖。 - 最后判断是否需要进行扩容。
get方法
public V get(Object key) {
Node<K,V> e;
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;
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;
}
get 方法看起来就要简单许多了。
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回 null 。
- 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)
。
并发不安全
HashMap 原有的问题也都存在。
public static void main(String[] args) throws InterruptedException {
int N = 100000;
final HashMap<String, String> map = new HashMap<String, String>();
//final HashMap<String, String> map = new HashMap<String, String>(N); //指定容量,避免扩容
Thread[] ts = new Thread[N];
for (int i = 0; i < N; i++) {
ts[i] = new Thread(new Runnable() {
@Override
public void run() {
map.put(UUID.randomUUID().toString(), "");
}
});
}
for (int i = 0; i < ts.length; i++) {
ts[i].start();
}
for (Thread t : ts) {
t.join();
}
System.out.println("end");
}
上面代码多执行几遍,会发生死循环。 如果在JDK1.8及其以上版本,
- 可能会发生死循环
- 还有可能发生
java.util.HashMap$Node cannot be cast to java.util.HashMap$TreeNode
大部分博主只提到了死循环,而没有提到类型强壮异常
死循环是如何发生
是扩容引起的。
源码分析
由上面可知 put方法
流程
- 判断key是否已经存在,
key
存在,替换。不存在,插入新元素 - 插入新元素后,检查
(size++ >= threshold)
会发生扩容,新容量是现在的二倍。 - 扩容调用
resize()
方法。这里会新建一个更大的数组,并通过transfer方法,移动元素,移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。
resize()
方法源码
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
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;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
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);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
案例分析
假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容(为了验证效果,假设负载因子是 1 )。
假设此时有两个线程,线程1和线程2同时在插入第三个节点, resize()
方法就会同时执行,两个线程都会新建一个新的数组。
假设 线程2 在执行到Entry<K,V> next = e.next;
之后,cpu时间片用完了,这时变量 e
指向节点 a
,变量 next
指向节点 b
。
线程1继续执行,很不巧, a
、 b
、 c
节点 rehash
(即调用 indexFor
方法) 之后又是在同一个位置 7
,开始移动节点
第一步,移动节点a
第二步,移动节点b
第三步,移动节点c
这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:
这时,在 线程2 中,变量 e
指向节点 a
,变量 next
指向节点 b
,开始执行循环体的剩余逻辑。
执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系
变量 e
又重新指回节点 a
,只能继续执行循环体,这里仔细分析下:
1、执行完Entry<K,V> next = e.next;
,目前节点 a
没有 next
,所以变量 next
指向 null
;
2、e.next = newTable[i];
其中 newTable[i]
指向节点 b
,那就是把 a
的 next
指向了节点 b
,这样 a
和 b
就相互引用了,形成了一个环;
3、newTable[i] = e
把节点a放到了数组i位置; 4、e = next;
把变量 e
赋值为 null
,因为第一步中变量 next
就是指向 null
;
所以最终的引用关系是这样的:
节点a和b互相引用,形成了一个环,只要遍历这个数组下面的该环形链表,就会发生死循环。
JDK1.7可以通过设置初始容量避免扩容,从而避免死循环发生。
但是JDK1.8避免了死循环发生,但是无法避免下面的类 类型强转异常发生
类型强转异常是如何发生
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
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 {
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;
}
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
两个线程1和线程2同时插入。
- 线程1插入第2个元素执行到上面源码14行被CPU挂起了
- 线程2已经插入到第8个元素了,已经把链表变成了红黑树。
- 线程1被唤醒,类型强转失败。
参考: