HashMap底层存储结构
HashMap是一个用于存储Key-Value键值对的集合,每一个键值对其实就是HashMap内部的Entry类对象。
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;
}
Entry是HashMap的内部类,用来保存我们的键值,next指向下一个节点,hash用来保存key值的哈希码
HashMap它底层是基于数组和链表实现的数据存储结构。
HashMap在初始化时会创建一个默认长度为16的数组,当我们添加元素时它会根据key值的哈希码和数组长度取余得到元素在数组的存储位置。但是存在的问题就是不同的key值在经过计算之后可能会映射到相同的位置上,当插入一个元素时,发现该位置已经被占用,这时候就会产生冲突,也就是所谓的哈希冲突,所以HashMap结合链表正是解决了位置冲突问题。
HashMap 设置数组的每一个元素对应一个链表的头结点。当位置发生冲突时就往该链表的头部插入新的节点,新的节点指向旧的头结点。
HashMap存储数据的流程:
如上图: 当添加一个新的元素时先计算出元素在数组的存储下标,如果位置是空的直接插入到数组,如果位置不为空判断key值是否相等,相等则覆盖value值,不相等则历遍链表。历遍链表结束后key还是没找到则往链表的头部插入新的节点。
像上图数组第一个位置存放着一个Entry对象,当插入新Entry对象计算出的位置也是数组的第一个位置,这时候发生哈希冲突了。系统会把新的Entry插入到数组的第一个位置,并且新的Entry.next属性指向旧的Entry对象。
HashMap数据查找流程:
因为HashMap在内部维护这一个数组table,数组的每个位置保存着每个链表的表头结点,查找元素时,先通过hash函数得到key值对应的hash值,再根据hash值和数组长度计算得到在数组中的索引位置,拿到对应的链表的表头,最后去遍历这个链表,得到对应的value值。
put()方法源码解析:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
//计算key的哈希值
int hash = hash(key);
//根据key哈希值和数组长度计算出存储下标
int i = indexFor(hash, table.length);
//历遍table[i]整个链表,如果出现key重复的则覆盖value值,然后return结束程序
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++;
//上面的for循环结束后没有发现key没有重复则会执行这个方法
addEntry(hash, key, value, i);
return null;
}
private V putForNullKey(V value) {
//获取数组的第一个位置元素,历遍链表找到key为null的键值对然后覆盖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++;
//上面的for循环结束后没有发现key为null的元素则会执行这个方法
addEntry(0, null, value, 0);
return null;
}
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);
}
//创建节点
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
//根据bucketIndex获取数组指定位置的元素,e 是链表的头结点
Entry<K,V> e = table[bucketIndex];
//创建节点放到数组中,这时候该节点成为头结点,同时它的next指向上一个头结点e
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
先是判断key是否为null,是则执行putForNullKey()
,putForNullKey历遍table[0]整个链表,如果有key等于null的元素时则覆盖value值,然后结束程序(因此haspMap只能有一个key为null的元素)。如果table[0]整个链表没有key等于null的元素则执行 addEntry(0, null, value, 0)
,addEntry调用createEntry()
,createEntry就是将table[0]的元素取出来,然后把新的元素放到table[0]中,同时新的元素指向旧的元素,链表size++
当key不为null时,先根据key的哈希值和数组长度计算出存储的下标位置,历遍table[i]的整个链表看有没有key重复,如果出现key重复的则覆盖value值,然后return结束程序。否则执行addEntry()
,addEntry调用createEntry()
,createEntry就是将table[i]的元素取出来,然后把新的元素放到table[i]中,同时新的元素指向旧的元素,链表size++
get()方法源码解析:
public V get(Object key) {
//先判断key是否为null,是则执行getForNullKey
if (key == null)
return getForNullKey();
//key不等于null,执行getEntry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
//因为key=null的元素hashMap都是存放在table[0]指向的链表中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//历遍找到key=null的元素
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
//计算key的哈希值
int hash = (key == null) ? 0 : hash(key);
//indexFor(hash, table.length)是根据key哈希值和table长度计算元素在数组的索引,然后for循环历遍整个table[i]
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//比较hash值和key值,找到元素后返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
先是判断key是否为null,是则调用getForNullKey()
,getForNullKey内部则会历遍table[0]指向的链表。
若key不等于null,则调用getEntry()
,getEntry根据计算算出数组下标i,然后历遍table[i],找不到元素返回null。
最后声明一点,这是基于jdk1.7的源码分析。1.8后对hashMap进行了优化。
1.7采用数组+单链表,1.8在单链表超过一定长度后改成红黑树存储
1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。
1.7插入元素到单链表中采用头插入法,1.8采用的是尾插入法。
因此引申俩个问题:
1.为啥1.7之前元素添加是采用头插入法
因为hashMap设计者们认为新加的数据被访问的几率大于旧的数据,所以放在前面访问更快。
2.为啥1.8之后元素添加改为了尾插入法
HashMap在jdk1.7之前采用头插入法,在扩容时会导致链表的顺序倒置,在线程并发的情况下扩容容易导致链表死循环(即俩个节点的next节点相互指向对方),并且新加的数据被访问的几率大于旧的数据这个说法并不成立,而尾插法在扩容的时候节点顺序不会打乱。
jdk1.8之后HashMap为何从头插入改为尾插入
3.1.8之后对计算元素索引进行了优化。未扩容前HashMap通过哈希值的二进制和数组长度-1的二进制进行按位与运算得到的结果就是下数组的索引,(图是网上复制的)
&是二进制“与”运算,参加运算的两个数的二进制按位进行运算,运算的规律是:
0 & 0=0
0 & 1=0
1 & 0=0
1 & 1=1
例如:一个key的哈希值二进制是 0001 1010 ,数组长度是n=16,二进制:10000,n-1二进制是1111
哈希值和n-1进行与运算得到二进制:1010 转成十进制就是10,即索引就是在table[10]
当数组扩容后n=32 ,二进制是:100000,n-1的二进制是:11111(n-1的最高位和旧数组的最高位相同
),当然我们依旧通过上诉的计算也是可以得到每个元素的索引,但是没必要。你会发现当n-1的最高位对应的哈希值二进制数是0的话计算出来的索引不变,对应的是1则计算出来的结果是原位置+旧数组长度。
例如下图:扩容后n-1的最高位是1(往左数第五个数),最高位对应hash1的二进制数是1,因此计算出来的结果是26,最高位对应hash2的二进制数是0,因此索引保持不变。又因为旧数组的最高位和n-1的最高位是一样的
,因此扩容的时候HashMap通过(e.hash & oldCap) == 0判断节点是否为新位置节点,等于1则移动到原位置+旧数组长度的索引(数组长度永远是2的次幂,二进制只有最高位是1其他是0,因此不管谁和它进行&算要么得1要么得0)
现在推荐使用 ConcurrentHashMap,它是Java中的一个线程安全且高效的HashMap实现。