本文基于JDK 1.7,认识HashMap。
HashMap是一个基于哈希表的 Map 接口的实现,以 key-value 的形式存储数据,从而达到高效地存储与获取数据元素。在 Java 中,HashMap 实现了 Map 接口,继承了 AbstractMap 抽象类:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
HashMap数据结构是一个“链表散列”,HashMap在 JDK 1.7中的实现是使用数组加链表的形式实现的,如下图。其中链表的每一个节点都是一个Entry
类的示例对象,每个对象内都有hash
、key
、value
、next
四个属性。
(1)构造函数,HashMap中提供了四个构造函数,如下:
- HashMap( ):构造一个具有默认初始容量(16)和默认加载因子(0.75)的空 HashMap;
- HashMap(int initialCapacity):构造一个带指定的初始容量(initialCapacity)和默认加载因子(0.75)的空 HashMap;
- HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量(initialCapacity)和指定加载因子(loadFactor)的空 HashMap;
- public HashMap(Map extends K, ? extends V> m) :传入一个Map接口的实现类,根据这个Map对象构造一个带有map中的元素的HashMap。
这里提到了两个关键的参数:
初始容量:在HashMap中容量即桶(Bucket)的数量或者说数组长度,初始容量就是HashMap被创建时数组的长度,默认为16,后面会提及为什么是16;
/**
* The default initial capacity - MUST be a power of two.
* 初始化容量,16起,每次扩容都是16*2的N次方
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
加载因子:默认为0.75,表示HashMap中元素数量达到数组长度的3/4后,就会自动进行扩容,扩容即增加桶的数量;
/**
* The load factor used when none specified in constructor.
* 加载因子系数,3/4的时候扩容。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
(2)capacity与size:
size( ):返回的是当前hashMap这一容器中Entry节点的数量;
capacity( ):返回的是当前桶的数量,即数组长度。
(3)增删改查
put(K key, V value):将指定的值与此映射中的指定键关联;
remove(Obbject key):如果存在一个键的映射关系,则将其从此映射中移除;
containsKey(Object key):如果此映射包含指定键的映射关系,则返回 true。
containsValue(Object value):如果此映射将一个或多个键映射到指定值,则返回true。
HashMap的初始化过程就是判断与赋值,以及初始化一个table数组,具体解析看以下源码:
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不能<0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: "
+ initialCapacity);
// 初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子不能 < 0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: "
+ loadFactor);
// 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
// 设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
threshold = (int) (capacity * loadFactor);
// 初始化table数组
table = new Entry[capacity];
init();
}
从上面代码我们可以知道,初始化HashMap的过程中需要去初始化化一个table数组,其中table数组中的元素为Entry节点,先来看一下table的定义以及Entry的源码:
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
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;
final int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
// other methods...
}
public V put(K key, V value) {
// 当key为null,调用 putForNullKey() 方法,将value保存在table中的第一个位置,这也是HashMap允许为key值为null的原因
if (key == null)
return putForNullKey(value);
// ===①:计算key的hash值
int hash = hash(key.hashCode());
// ===②:计算key hash 值在 table 数组中的位置
int i = indexFor(hash, table.length);
// 从i处开始迭代 e,判断key是否存在与第i号桶中,有就去找到 key 保存的位置
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
// 判断该条链上是否有hash值相同的(key相同)
// 若存在相同,则直接覆盖value,返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 声明一个oldValue将旧值保存下来
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
// 返回旧值
return oldValue;
}
}
// 修改次数增加1
modCount++;
// 将key、value添加至i位置处
addEntry(hash, key, value, i);
return null;
}
通过上面源码我们可以知道HashMap的存储过程:
首先判断传入的key的值是否为null,若为null,则调用putForNullKey()
方法将其存储在table数组中的第一个桶;
key值不为空的话就计算key的hash值,通过hash(key.hash)
方法计算出key的hash值;
hash(key.hash) 这个函数就是一个纯粹的数学计算,代码如下:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
利用上一步求得的hash值去计算Key在table中的索引位置,这里涉及到了indexFor()
方法,这是一个很精巧的函数,稍后介绍;
判断table的i号索引所对应的链表中是否有Key所对应的Entry对象,如果有,就覆盖旧值;
如果table的i号索引所对应的链表中没有Key所对应的Entry对象,就通过addEntry
方法添加一个新的节点,将其保存在链头(最先添加的元素是保存在链尾,之前作者认为后来的值被查找的可能性更大一点,提升查找的效率的,这叫头插法);
indexFor这个方法的代码量很少,如下,h代表key的hash值,length是table数组的长度;
static int indexFor(int h, int length) {
return h & (length-1);
}
按照我们的直接意识,得到对象的哈希值后要取得其在table数组的下标,直接h % length
使用取模操作就可以了,但是这样做的效率不高。JDK为了提高效率就采用&
这种位运算的方式,那么与length-1进行与操作呢?这里也涉及到上面写到的一个问题,hashMap的默认初始容量为16,并且每次扩容只能是2的n次方,下面就用表来演示一下:
我们现在假设length为16,那么length-1对应的二进制就是1111,hash值从0开始递增:
hash | (length-1)& hash | 结果 |
---|---|---|
0 | 1111 & 0000 | 0 |
1 | 1111 & 0001 | 1 |
2 | 1111 & 0010 | 2 |
3 | 1111 & 0011 | 3 |
4 | 1111 & 0100 | 4 |
5 | 1111 & 0101 | 5 |
… | … | … |
16 | 01111 & 10000 | 0 |
17 | 01111 & 10001 | 1 |
18 | 01111 & 10010 | 2 |
接下来这一次我们假设length为15,那么length-1对应的二进制就是1110,hash值从0开始递增:
hash | (length-1)&hash | 结果 |
---|---|---|
0 | 1110 & 0000 | 0 |
1 | 1110 & 0001 | 0 |
2 | 1110 & 0010 | 2 |
3 | 1110 & 0011 | 2 |
4 | 1110 & 0100 | 4 |
5 | 1110 & 0101 | 4 |
… | … | … |
16 | 01110 & 10000 | 0 |
17 | 01110 & 10001 | 0 |
18 | 01110 & 10010 | 2 |
这里我们可以看到,当length为15时,结果返回index就会出现不均匀现象,这样就会使得插入元素时出现不均匀插入,有些桶的链长度过长,而有些桶却一个节点都不存在的情况,降低了效率。当length为其他不是2的n次方的数时也会出现这种现象,这里就不演示了。
所以为什么HashMap中table的长度为2的n次方呢?答案就是为了减少哈希碰撞。
在判断完新添加的键值对并不存在于HashMap中后,就会调用addEntry()
方法往指定下标中插入一个新的节点元素,不过在这之前需要先判断是否需要扩容,我们下来看下源码:
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);
}
到这里我们可以知道addEntry()也不是真正添加新节点的函数,不过这个函数做了判断是否需要扩容的工作。其中threshold这个值是capacity * loadFactor
两个参数求得的,即阈值。接下来我们来看下resize()
函数:
void resize(int newCapacity) {
// 声明一个数组将原先table保存下来
Entry[] oldTable = table;
// 判断新的容量是否超过了hashMap的最大值(即2的30次方),超过或相等就不在扩容,返回
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 新建一个数组
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
// 将旧table中的数据重新装到newTable中
transfer(newTable, rehash);
// 将table指向newTable
table = newTable;
// 重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
我们顺便来看一下transfer()
中的代码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
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);
// 将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
在addEntry()函数中,判断完是否需要扩容后,就进入真正将元素添加到桶的方法createEntry()
,代码如下:
void createEntry(int hash, K key, V value, int bucketIndex) {
// 将桶中原来的头节点用e保存下来
Entry<K,V> e = table[bucketIndex];
// 新建一个节点,指向e(即原来的头结点),然后在赋予给table[bucketIndex],在这里可以看见使用的是头插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
从上面源码的解析中我们可以知道,put()函数可以进行添加与修改的操作,那么接下来我们来了解以下get(Object key)
函数。
public V get(Object key) {
// 如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
// 不为null则调用getEntry()函数检索
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
从上面代码中可以很直观的知道,当key为null时,hashMap会直接到第0号桶去检索,因为key值为null的键值对存储在table[0]中,具体可以回顾put( )
函数;如果不为空,那么就需要调用Entry()
函数去查找这个key对应的节点,获取节点后再返回对应的value,现在来看一下getEntry()
函数的源码:
final Entry<K,V> getEntry(Object key) {
// key不为空,取得key的hash值
int hash = (key == null) ? 0 : hash(key);
// 通过indexFor取得该hash值在数组table中的偏移量得到Entry类的单向链表
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;
}
可以知道getEntr()
中首先还是判断key是否为空,之后再通过indexFor()
计算key对应的在table中的下标,然后再通过循环比对table[下标]
这一条链表中是否有当前key对应的节点,有则返回,没有则最终返回null。
重写equals( )
方法的同时也需要重写hashCode()
方法,这是因为在put()方法中有这么一行代码:
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
...
}
这里会去判断两个节点的hash值是否相同,如果hash值不同则判定为不是同一对象。并且通过前面的源码我们知道,hash值是通过hash(key.hashCode())
这一函数计算的,所以当重写equals方法时没有重写hashCode方法,那么当equals方法判定两个对象相同时,两个对象的hash值也不一定相同,所以在插入时会出现bug。
高并发下的HashMap就是线程不安全,我们回顾一个扩容时的操作,即transfer()
函数,该函数将原来的数据重新定位桶的下标并采用头插法插入到新的数组中,这也是造成高并发下死循环的原因,我们再来回顾一下源码:
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;
}
}
}
我们将其中几行关键的代码提取出来,如下所示:
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
接下来我们利用图表的形式来演示一下并发产生死循环的过程:
首先假设现在有两个线程都在执行上面的那段代码,第一个线程称为Thread1,第二个线程称为Thread2,两个线程中都有e以及next两个引用指针,Thread1中的两个指针称为e1与next1,Thread2中的两个指针称为next2;
此时Thread1执行到Entry
这一行代码,那么此时e1指向A,next1指向B,之后Thread1线程被挂起;
Thread2线程开始运行,也执行到Entry
,那么此时e2也指向A,next2指向B,如下图所示:
接下来Thread2继续运行,首先e.next = newTable[i]
,那么此时A的下一个节点会去指向newTable[i],此时newTable[i]中没有元素,那么A的下一节点为null;
继续运行newTable[i] = e
,此时A被复制到了newTable[i]中,e1和e2两个指针依旧指向A,如下图所示:
接下下来Thread2进行下一次循环,重新运行到Entry
这一行代码,此时next2指向的是null;
继续运行到e.next = newTable[i]
,由于在上一次循环中A被复制到newTable[i]中,那么此时B就会指向A了,如下图所示:
Thread2继续运行到newTable[i]=e
,那么此时B会插入到newTable[i]中,由于采用的是头插法,所以B在A前面;
接下来Thread2继续执行e = next
,那么此时e2也会指向null,如下图所示:
OK,现在Thread2已经执行完毕了,接下来该Thread1继续执行了,之前Thread1在执行完Entry
后挂起,现在继续执行,执行到e.next = newTable[i]
,那么此时A的next指向newTable[i],newTable[i]为null,所以A的next指向的是null;
接下来Thread1继续运行到newTable[i] = e
那么此时A这个节点将被放置到newTable1的i下标出,如下图所示:
Thread1的第一次循环到此结束,接下来进行第二次循环,Thread1执行到Entry
;那么此时next1会指向A;
继续执行到e.next = newTable[i]
,B本来就指向A,所以不会发生任何变化;
继续执行到newTable[i] = e
那么此时B会插入到newTable1中,如下图所示:
接下来Thread继续执行第三次循环,执行到Entry
后,next1会指向A的下一节点,也就是null;
继续执行到e.next = newTable[i]
,此时e1还是指向A,e.next = newTable[i]
就是让A去指向newTable1中的B,那么此时就会出现循环引用,造成BUG,如下所示:
所以,高并发下的HashMap是线程不安全的,那么如何解决这个问题呢?可以使用ConcurrentHashMap,关于ConcurrentHashMap,可以观看这篇文章【Java集合】ConcurrentHashMap源码解析;
有错误请评论指出。