一、背景
在java中,HashMap是很常用的一种数据结构,最近重新温习了一下,这里以源码层面来分析总结一下HashMap,如有不合理或疑问的地方,欢迎沟通交流。
HashMap是Java中的一个容器,继承自AbstractMap抽象类,实现Map接口,简单画了一张Map家族的类图(只画了部分常用的接口和类):
在Map家族中的众多实现类里面,我们做最常用的操作就是实例化Map、map.put(key,value)、map.get(key),非常简单,但仔细分析过源码就会发现,这些类的设计有很多有趣的地方。下面以HashMap为例来梳理一下。
二、HashMap宏观结构
数组和链表是两种最基本的线性数据结构,在java中的代表就是java数组和引用变量,HashMap就是用数组和链表来存储数据的,HashMap中默认有一个长度为16的数组,数组的每个元素中存储一个链表的头结点,具体如下图:
(图片来自www.cnblogs.com/moonandstar…)
当执行Map map=new HashMap(); 时,map实例中初始化一个类型为Entry的空数组。
假如现在HashMap中的数据如上图所示,当再次向HashMap中put元素的时候,比如执行map.put(“key1”,”value1”); 会先计算出key1的hashCode,然后根据hashCode来计算存放该键值对(Entry)的数组的坐标位置,然后判断该位置是否已经存在元素,如果不存在,直接把新的元素保存在数组的对应位置即可,如果已经存在元素,判断存在元素与新元素key值是否相等,相等则用新的value替换就的value,否则需要把新元素的next指向旧的元素(当前链表的表头),然后把新元素保存在数组的对应位置,相当于新的元素来了之后插在最前面,把链表中的所有元素都向后挪一个位置,上图数组坐标为0的链表中Entry1插入的时候,就是把它自己的next指向Entry0,然后把自己存在数组中。
当根据key值获取HashMap中的元素时,会先计算key得hashCode,然后根据hashCode找出该元素存储链表所在的数组下标,遍历链表,找到key值相同的元素返回,这也是为什么HashMap在多次put相同key值不同value值时,之前的value会被最新的覆盖。
下面简单看一下HashMap类中的重要常量、变量:
● HashMap数组:transient Entry[] table
● 数组默认长度:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4
● 数组最大长度:static final int MAXIMUM_CAPACITY = 1 << 30
● 默认加载因子:static final float DEFAULT_LOAD_FACTOR = 0.75f
● 扩容临界值:private int threshold;(threshold=capcity*loadFactor)
其中Entry就是HashMap中的每一个节点,如下代码, key就是存储元素的key值,value就是存储元素的value值,next是下一个节点的“指针”,hash就是该key的hashCode:
class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
……
}
DEFAULT_INITIAL_CAPACITY 是数组默认的长度即16,MAXIMUM_CAPACITY 是数组最大的长度2的30次方,原因是2的31次方就超过Integer.MAX_VALUE(2147483647)了(也就是2^31-1),而数组的长度都必须是2的次方(原因稍后会解释),所以数组最大长度只能是2的30次方。
三、源码分析
上面简单介绍了HashMap在初始化、put元素、get元素的过程中都发生了什么,下面结合源码来深入理解一下每一步详细的过程,这里是以java7为例,不同版本的源码可能会稍有差异。
1、HashMap的初始化
HashMap有四个构造方法:
public HashMap() {……}
public HashMap(int initialCapacity){……}
public HashMap(int initialCapacity, float loadFactor){……}
public HashMap(Map<? extends K, ? extends V> m){……}
第一种和第二种方式最终都会调用第三个构造方法,第一种以默认的数组长度(DEFAULT_INITIAL_CAPACITY)和默认的加载因子(DEFAULT_LOAD_FACTOR)来调用第三种,第二种以自定义长度和自定义的加载因子来调用第三种构造方法。第三种构造方法的代码如下,前面在检查定义的数组长度和加载因子的合法性,接下来对加载因子进行赋值,在这里看到把数组长度initialCapacity赋值给扩容临界值threshold时可能会有所不理解,下面在put方法种就会找到原因。整个初始化过程并没有改变数组(table)的长度,所以其实new完Hashmap之后,table仍然是一个空数组。
public HashMap(int initialCapacity, float loadFactor) {
//如果数组长度小于0,抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果数组长度大于2^30,设置长度为2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//如果数组长度不大于0,或者为非数值型,抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//设置加载因子、扩容临界值
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
2、HashMap的put操作(put null、hash、扩容)
HashMap的put方法源代码如下,执行的操作主要有检查table是否为空、检查key是否为null、计算key的hash、根据hash计算数组下标、存储元素。
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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;
}
(1)当table为空时,就调用inflateTable方法扩大table的容量,inflateTable的源码如下,入参是要把table的扩大的容量大小,但由于table的容量只能是2的幂次方(原因稍后会解释),所以为了保证存储需求且最节省空间,需要先计算出大于等于且最接近2的幂次方,然后实例化table,数组容量为新计算出的长度capacity。
private void inflateTable(int toSize) {
// 返回大于等于且最接近2的幂次方的值
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
(2)如下面putForNullKey()方法源码所示,当put的元素key值为null时,会把该元素存储到HashMap的数组下标为0的链表上,先遍历该链表,找到key是null的节点,用新的value替换旧的value;如果没有找到key是null的节点,就直接在数组下标为0的链表上添加该元素(键值对)。
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;
}
(3)计算key的hash,根据hash计算数组下表
hash(key)方法是根据key值计算并返回一个整型的数,在Java7中,如果key为String,主要以sun.misc.Hashing.stringHash32((String) k)的方式计算,否则对hashSeed和key的hashCode进行异或运算,再进行无符号右移、异或计算,如下:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
在Java8中,直接(h = key.hashCode()) ^ (h >>> 16),如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
计算出hash值后,需要根据hash值来计算数组的下标,如下代码:
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
计算数组下标直接用hash值和数组长度-1进行与运算,因为计算出来的hash值是一个整型数据,范围在-2^31~2^31-1,大约有40多亿个数,怎么根据这40多亿中的一个数来决定数组的其中一个下标呢,为了便于理解来举个例子:
比如我们向HashMap中存入了两个键值对entry1(key1=”abc”,value1=”ABC”)、entry2(key2=”def”,value1=”DEF”),假设key1对应的hash值为18,二进制为10010,假设key2对应的hash值为27,二进制为11011。
其中会发现一个规律,如果数组长度为2的幂次方,那么数组长度-1的二进制每个位数的值都是1,与key的hash进行&运算之后的结果,除了超过数组长度-1数值的高位部分,低位部分都与key的hash值一致。
如果数组长度不是2的幂次方,比如15,结果会是什么样呢?如下图:
当数组长度是15的时候,其二进制末尾数值为0,计算结果的末尾肯定永远是0,所以永远不会有计算结果为00001、00011、00101、00111、01001、01011、01101、01111这几种末尾是1的情况,也就是数组下标为1、3、5、7、9、11、13、15这几个位置永远都不会存储数据,造成了严重的空间浪费,这就是HashMap中的数组长度必须是2的幂次方的原因。
(4)存储元素
找到数组下标之后,就要存储元素啦,有两种情况:1、put元素的key值已经存在;2、put元素的key值不存在。第一种情况会先遍历数组下标对应空间存储的链表,如果存在该key值的节点,就把对应新的value替换旧的value;第二种情况,需要调用addEntry(hash,key,value,数组下标)来新创建一个节点元素,创建节点元素之前,先进行扩容判断操作,如过需要扩容,就把数组空间扩大之后才创建新节点,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);
}
size就是HashMap中目前存储元素的个数,threshold的值是扩容临界值(数组长度*加载因子),当size大于等于threshold并且数组当前位置存储内容不为空时,就调用resize方法进行扩容操作(java1.6之前只要size大于等于threshold就会扩容),扩大后的容量为数组长度的2倍。扩容的核心逻辑在resize方法调用的transfer方法中,主要就是把原来table中的元素复制到扩容后的newTable中:
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;
}
}
}
为了比较直观地理解transfer方法这段代码,画了几张图,假如HashMap中现在有entry1、entry2、entry3、entry4四个元素,且他们在table中的存储位置如下:
for循环遍历的是table数组中的每一个元素,while循环遍历的是每一条链表上的节点。当第一次执行完Entry next = e.next;后,e指向的是enty1,next指向的是entry2;当第一次执行完int i = indexFor(e.hash, newCapacity);后,i的值就是e(entry1)在新表中计算出来的下标(这里假设i=2);当第一次执行完e.next = newTable[i];后,entry1的next就指向newTable[2],也就是null(这一步是重点,会导致在同一条链中新节点总会插入到链表头);当第一次执行完newTable[i] = e;后,entry1已经被转移到了新表数组下标是2的链表中,如下图:
第二次while循环后的结果是entry2节点被转移到新表下标是7的链表中,如下图:
第三次while循环时,执行完 Entry next = e.next; 后,e指向的是enty3,next指向的是null;当执行完int i = indexFor(e.hash, newCapacity);后,i的值就是e(entry3)在新表中计算出来的下标(这里假设i=2);当执行完e.next = newTable[i];后,entry3的next就指向newTable[2],也就是entry1(这一步是重点,会导致在同一条链中新节点总会插入到链表头);当执行完newTable[i] = e;后,entry3已经插入到新表数组下标是2的链表头部,如下图;当执行完e = next;,e的值为null,结束本次while循环。
下图是第二次for循环执行后的结果,entry4插入到新表下标为2的链表头部:
3、HashMap的get操作
get操作比较简单,源码如下:
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
如果key值为null,直接调用getForNullKey去数组下标为0的链表上遍历key值为null的节点元素并返回value值,否则调用getEntry方法获得value值。getEntry方法源码如下,如果size==0即HashMap中没有元素,直接返回null;否则计算key值的hash值,然后根据hash值计算出该元素存储链表所在的数组下标,最后遍历该链表,找到key值相等的元素并返回。
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;
}
四、其他
1、性能问题
HashMap性能的体现主要在扩容操作上,通过上文就可以了解到,当HashMap中的元素个数size大于等于数组容量和加载因子的乘积时,就会把数组容量扩大至原来的2倍,扩容时,要遍历HashMap中原来的数组中的元素并重新计算每个元素key的hash值和新数组的位置,相对来说是非常耗时的。
因此影响HashMap性能的主要因素有两个:初始容量capacity和加载因子loadFactor,capacity是指HashMap初始化时数组的长度,loadFactor是指HashMap在扩容之前数组空间利用率的一种尺度,理想状态下,HashMap中数组的每个位置都只存储一个元素(数组存满,且没有链表)时,查找效率和存储利用率是最高的,因为通过计算key的hash就可以直接定位到数组中的位置,不需要遍历链表,但这只是理想状态,现实操作中一旦出现hash碰撞就会产生链表,所以在初始化HashMap时,可以预估HashMap中会存储多少个元素,来设置HashMap的初始容量和加载因子的值,尽量避免HashMap扩容操作。
这里顺便说一下Hash碰撞攻击,可以通过构造大量key值hashcode相同的键值对来发起攻击,会使以hash表为主的结构退化成以链表为主的结构,数据量大时,对某一元素的定位时间复杂度由O(1)降为O(n)。
详细可以参考如下文章:
一种高级的DoS攻击-Hash碰撞攻击:www.jianshu.com/p/5b99ae1ba…
Hash碰撞拒绝服务攻击:ihyperwin.iteye.com/blog/152008…
HashMap之Hash碰撞冲突解决方案及未来改进:blog.csdn.net/Luo_da/arti…
2、线程安全
有经验的小伙伴们都知道HashMap是线程不安全的,那么线程不安全体现在哪呢?
(1)赋值不安全:如果两个线程同时想HashMap中存储键值对,假如这两个key值对应的数组下标一样,当两个线程同时执行到createEntry()方法中时,优先执行赋值操作的线程锁存储的键值对可能会被另外一个线程存储的键值对覆盖。
(2)多线程环境下对HashMap扩容的时候,每个线程都会执行扩容(resize方法)的代码,最后只有一个线程生成的新数组会赋值给table,其他线程的操作都会丢失;当某个线程已经完成扩容,其他线程刚开始扩容,可能会直接使用已经完成扩容的数组作为原始数组;大量线程对同一个HashMap进行put操作,在扩容的环节可能产生环装链表,导致get元素的时候出现死循环。
方案:
(1)在外部包装HashMap,实现同步机制
(2)使用Map map = Collections.synchronizedMap(new HashMap(…));实现同步(官方参考方案,但不建议使用,使用迭代器遍历的时候修改映射结构容易出错,对每一个方法增加了synchronized ,但并不保证put/get/contain之间的同步))
(3)使用java.util.HashTable,效率最低(同步锁整个table数组 ,效率较低,几乎被淘汰了)
(4)使用java.util.concurrent.ConcurrentHashMap,相对安全,效率高(同步锁每次只锁一个bucket(数组table的单个元素) ,可以多线程同时读写不同bucket ,也保证了put/get同一个桶的同步,建议使用)
3、java1.8中HashMap的改进
JDK1.8的HashMap源码实现和1.7是不一样的,有很大不同,其底层数据结构也不一样,引入了红黑树结构,如下图。
有网友测试过,JDK1.8HashMap的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表长度大于8的时候,HashMap会动态的将它替换成一个红黑树(JDK1.8引入红黑树大程度优化了HashMap的性能),这会将时间复杂度从O(n)降为O(logn),