先说一下hashmap简单的概述:
HashMap基于Map接口实现,元素以键值对的方式存储,键值对可以为null,因为key不允许重复,因此只能有一个键为null。HashMap不能保证放入元素的顺序,它是无序的,和放入的顺序并不能相同。HashMap是线程不安全的。
JDK1.7版本
HashMap底层存储结构图
大家都知道hashmap底层基于数组+链表实现。
HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体,依次来解决Hash冲突的问题,因为HashMap是按照Key的hash值来计算Entry在HashMap中存储的位置的,如果hash值相同,而key内容不相等,那么就用链表来解决这种hash冲突。
下面看一下HashMap的一些参数:
- static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 初始化桶大小(底层数组默认长度)
- static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量
- static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子
- transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //
table
真正存放数据的数组 - transient int size // map存放数量的大小
- int threshold; // 桶大小,可在初始化时显式指定,用于判断是否需要扩容(threshold= 容量*负载因子)
- final float loadFactor; // 负载因子,初始化可显示指定
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12
就需要将当前 16 的容量进行扩容。
根据代码可以看到其实真正存放数据的是
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
static class Entry<K,V> implements Map.Entry<K,V>{
final K key;
V value;
Entry<K,V> next;
int hash;
}
Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:
- key 就是写入时的键。
- value 就是值。
- 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
- hash 存放的是当前 key 的 hashcode。
public V put(K key, V value) {
if (table == EMPTY_TABLE) { //是否初始化
inflateTable(threshold);
}
if (key == null) //放置在0号位置
return putForNullKey(value);
int hash = hash(key); //计算hash值
int i = indexFor(hash, table.length); //计算在Entry[]中的存储位置
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); //添加到Map中
return null;
}
在该方法中,添加键值对时,首先进行table是否初始化的判断,如果没有进行初始化(分配空间,Entry[]数组的长度)。然后进行key是否为null的判断,如果key==null ,放置在Entry[]的0号位置。计算在Entry[]数组的存储位置,判断该位置上是否已有元素,如果已经有元素存在,则遍历该Entry[]数组位置上的单链表。判断key是否存在,如果key已经存在,则用新的value值,替换点旧的value值,并将旧的value值返回。如果key不存在于HashMap中,程序继续向下执行。将key-vlaue, 生成Entry实体,添加到HashMap中的Entry[]数组中。
/*
* hash hash值
* key 键值
* value value值
* bucketIndex Entry[]数组中的存储索引
* /
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 往数组中添加新的key-value键值对
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
// 取出当前位置的元素,如果是新添加的key,则e为null,已经有的元素为不为空。
Entry<K,V> e = table[bucketIndex];
// 添加新的key-value值或构建链表
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进性扩容操作,扩充的容量为table长度的2倍。重新计算hash值,和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反。然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。(这种操作在高并发的环境下容易导致死锁,所以JDK1.8之后,新插入的元素都放在了链表的尾部。)
get方法:
- 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
- 判断该位置是否为链表。
- 不是链表就根据
key、key 的 hashcode
是否相等来返回值。 - 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
- 啥都没取到就直接返回 null 。
JDK1.8的改变
当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)
。
数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式
TREEIFY_THRESHOLD:用于判断是否需要将链表转换为红黑树的阈值。
HashEntry 修改为 Node
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next
等数据。
下面看核心方法put的改动:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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;
(判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化))
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
(根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。)
else { 如果当前桶有值( Hash 冲突)
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
(比较当前桶中的 key、key 的 hashcode
与写入的 key 是否相等,相等就赋值给 e
,在后面会统一进行赋值及返回。)
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);
(如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。)
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;
(如果在遍历过程中找到 key 相同时直接退出遍历)
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;
}
下面用一副流程图帮助理解上面的这段代码
get方法代码就不放了,简单介绍一下流程:
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回 null 。
- 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
hash碰撞和扩容导致的线程不安全问题
以1.7为例,
transfer方法做的事情:
1.遍历旧的table
2.将旧的table中每个元素重新计算hash值, 然后赋予新的table中
单线程扩容:
假设:hash算法就是简单的key与length(数组长度)求余。
hash表长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key%table.length)的话都应该碰撞到table[1]上
扩容:hash表长度会扩容为4
重新hash,key=3 会落到table[3]上(3%4=3), 当前e.next为key(7), 继续while循环
重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞, 这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句中代码)
当前e.next为key(5), 继续while循环
重新hash,key=5 会落到table[1]上(5%4=3), 当前e.next为null, 跳出while循环, resize结束
如题如图所示:
多线程扩容:
这里我们先把核心代码搬出来, 方便查看
while(null != e) {
Entry<K,V> next = e.next; //第一行
int i = indexFor(e.hash, newCapacity); //第二行
e.next = newTable[i]; //第三行
newTable[i] = e; //第四行
e = next; //第五行
}
去掉了一些冗余的代码, 层次结构更加清晰了。
第一行:记录odl hash表中e.next
第二行:rehash计算出数组的位置(hash表中桶的位置)
第三行:e要插入链表的头部, 所以要先将e.next指向new hash表中的第一个元素
第四行:将e放入到new hash表的头部
第五行: 转移e到下一个节点, 继续循环下去
核心代码如上所说, 下面就是多线程同时put的情况了, 然后同时进入transfer方法中:
假设这里有两个线程同时执行了put()
操作,并进入了transfer()
环节
-
while(null != e) {
-
Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了
-
e.next = newTable[i];
-
newTable[i] = e;
-
e = next;
-
}
那么现在的状态为:
从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。
然后线程1被唤醒了:
- 执行
e.next = newTable[i]
,于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null
, - 执行
newTable[i] = e
,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。 - 执行
e = next
,将 e 指向 next,所以新的 e 是 key(7)
然后该执行 key(3)的 next 节点 key(7)了:
- 现在的 e 节点是 key(7),首先执行
Entry<K,V> next = e.next
,那么 next 就是 key(3)了 - 执行
e.next = newTable[i]
,于是key(7) 的 next 就成了 key(3) - 执行
newTable[i] = e
,那么线程1的新 Hash 表第一个元素变成了 key(7) - 执行
e = next
,将 e 指向 next,所以新的 e 是 key(3)
这时候的状态图为:
然后又该执行 key(7)的 next 节点 key(3)了:
- 现在的 e 节点是 key(3),首先执行
Entry<K,V> next = e.next
,那么 next 就是 null - 执行
e.next = newTable[i]
,于是key(3) 的 next 就成了 key(7) - 执行
newTable[i] = e
,那么线程1的新 Hash 表第一个元素变成了 key(3) - 执行
e = next
,将 e 指向 next,所以新的 e 是 key(7)
这时候的状态如图所示:
由图可见,已经出现了环形链。所以在多线程情况下,会导致hashmap出现链表闭环,一旦进入了闭环get数据,程序就会进入死循环,所以导致HashMap是非线程安全的。
JDK8解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题.
所以多线程环境下, JDK 推出了专项专用的 ConcurrentHashMap ,该类位于 java.util.concurrent
包下,专门用于解决并发问题。