更正一下:之前对于hashmap初始容量这一块的知识,我在理解上出了问题,初始容量应该指的是哈希表中能存放的元素的数量,而并非是hashmap实例创建时哈希表中数组的长度。
目录
2、HashMap源码分析(使用的是JDK1.7版本的源码)
1、HashMap简介
1.1、HashMap的继承关系
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
这里有一个疑问,HashMap继承自AbstractMap,而AbstarctMap已经实现过Map接口,那为什么HashMap还要实现Map接口呢???Stackoverflow上面有一个答案是这么说的stackoverflow链接,大意就是说Josh Bloch(Java集合框架的创造者)自己也认为这是一个错误,并且会在未来的版本中修复它。
1.2、HashMap的数据结构
HashMap是由哈希表实现的,哈希表又是由链表和数组构成的,先来了解下哈希类集合的三个基本存储概念,如下图表所示:
名称 | 说明 |
table | 存储所有节点数据的数组 |
slot | 哈希槽,即table[i]这个位置 |
bucket | 哈希桶,即table[i]上所有元素形成的表或树的集合 |
- table:table是一个数组。(黄色部分的数组长度就是table.length)
- slot:哈希槽是一个位置标识,对应于数组的下标。
- bucket:虚线框内的哈希桶是包含头结点在内,在哈希槽上形成的链表或树上(JDK8中在HashMap中加入了红黑树,当链表的长度大于8的时候,会将链表转化为红黑树的数据结构进行存储)的所有元素的集合。(所有哈希桶的元素总和即为HashMap的size)
HashMap的实例有两个参数影响它的性能:
- 初始容量:初始容量就是HashMap实例被创建时数组的初始长度,初始容量为16。
- 负载因子:是哈希表在其容量自动增加以前能够达到多满的一种尺度,默认的负载因子是0.75。(设置成0.75是在时间和空间成本上的一种折中,负载因子过高虽然减少空间开销,但同时增加了查询成本,从get和put操作中都反映了这一点)
每当HashMap中保存的元素数量达到threshold(HashMap的阀值,用于判断是否需要扩容)=16 * 0.75的时候,就会自动执行rehash方法,让数组的长度翻倍,数组的长度翻倍以后,会将原数组中的元素重新计算下标并且添加到新数组中,这个扩容的过程是一个非常“消耗”的过程,所以如果我们在定义HashMap的时候,就清楚地知道我们有多少个元素要保存进去的话,就能极大程度的提高存储效率。
在每个哈希桶中都是用一个链表来存储数据元素,链表中的节点Entry就是真正存放键值对<K,V>的地方,请看下面JDK1.7中Entry的源码(在JDK1.8中,Entry改成了Node,但是类的构造大同小异):
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;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
2、HashMap源码分析(使用的是JDK1.7版本的源码)
2.1、HashMap的四种构造函数
//默认构造函数
HashMap(){
}
//指定容量大小的构造函数
HashMap(int capacity){
}
//指定容量大小和负载因子的构造函数
HashMap(int capacity, float LoadFactor){
}
//包含子Map的构造函数
HashMap(Map<? extends K, ? extends V>map){
}
2.2、HashMap新增元素的过程
public V put(K key, V value){
//通过键值得到hash值
int hash = hash(key);
//通过indexFor()方法来计算Entry应该保存在数组中哪个位置,i就是数组中保存位置的下标
int i = indexFor(hash, table.length);
//此循环通过hashCode返回值找到对应的数组下标位置
//如果equals结果为真,则覆盖原值,如果窦唯false,则添加元素
for(Entry<K, V> e = table[i]; e != null; e = e.next){
Object k;
//如果Key的hash是相同的,那么再进行如下判断
//Key是同一个对象或者key.equals(e.key)返回为真,则覆盖原来的Value
if (e.hash == hash && ((k == e.key) == key || key.equals(k))){
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
//还没添加元素就进行modCount++,将为后续留下很多隐患
//modCount记录了某个list改变大小的次数,如果modCount改变的不符合预期,就抛出异常
//modCount与fail-fast机制相关(快速失败原则是jdk在面对迭代遍历的时候为了避免不确定性而采取的一种措施)
modCount++;
//添加元素,注意最后一个参数i是table数组的下标
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V vlaue, int bucketIndex){
//如果元素的个数达到threshold的扩容阀值且数组下标位置(我认为应该说的是哈希槽的位置)已经存在元素,则进行扩容
if ((size >= threshold) && (null != table[bucketIndex])){
//扩容两倍,size是实际存放元素的个数,而length是数组的容量大小,即数组的长度
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){
//不管原来的数组对应的下标元素是否为null,都作为Entry的bucketIndex的next值
Entry<K, V> e = table[bucketIndex]; //第一处
//即使原来是链表,也把整条链都挂在新插入的节点上
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
总结一下,新增元素的过程大致分为以下几个步骤:
- 计算Key的hash值,并通过indexFor(hash,table.length)方法(是将hash值与数组长度的二进制数进行位运算)确定元素在数组中保存位置的下标 i;
- 在table[i]的这个哈希桶中遍历其中已经存在的元素,如果有Key相同的元素,则用新的value替换掉旧的value;
- 如果没有,则增加一个新的Entry元素,增加新元素时,如果元素的数量已经到了阀值且哈希槽的位置不为空,就进行扩容,扩容后将进行数据迁移;
- 在链表的头部添加新元素,size+1。
注意:如果两个线程同时执行到代码中第一处时,那么一个线程的赋值就会被另一个覆盖掉,这是造成对象丢失的原因之一。
2.3、HashMap扩容
先来熟悉下与扩容有关的几个概念:
名称 | 说明 |
length | table数组的长度 |
size | 成功通过put方法添加到HashMap中的所有元素的个数 |
hashCode | Object.hashCode()返回的int值,尽可能地离散均匀分布 |
hash | Object.hashCode与当前集合的table.length进行位运算的结果,以确定哈希槽的位置 |
理想的哈希集合对象的存放应该符合以下条件:
- 只要对象不一样,hashCode就不一样;
- 只要hashCode不一样,得到的hashCode与hashSeed位运算的hash就不一样;
- 只要hash不一样,存放在数组上的slot就不一样。
引用码出高效中给的一个例子:公司开个圆桌会议,有12把椅子,必须按照某种规则,把12个位置坐满。如果hashCode按照公司职务来计算,但公司只设置了P1-P8八个等级,则百分之百会有碰撞;如果hashCode按工号来计算,虽然hashCode是唯一的,但是以员工号与12进行取模后,工号为1和工号为13的员工恰好会被分配到同一把椅子上。哈希碰撞的概率取决于hashCode计算方式和空间容量大小。
如果公司给12个员工安排100把椅子行不行呢?这当然可以解决哈希碰撞的问题,但是同时是不是又造成了极大的空间资源浪费呢?那么到底准备多少把椅子合适呢?负载因子就是用来权衡利用率与分配空间的系数。默认的负载因子是0.75,即12个人的会议,相当于需要12 / 0.75 = 16把椅子,mod16比mod12的冲突概率要小一些,也不会像mod100那样浪费资源。随着会议范围变大,参加会议的人数越来越多,当人数 > (椅子数量 x 负载因子 )的时候就要进行扩容。在HashMap中,每次进行resize操作都会将容量扩充为原来的2倍。
下面是HashMap中非常重要的resize()和非常重要的transfer()数据迁移源码:
void resize(int newCapacity){
//定义一个新的Entry数组newTable
Entry[] newTable = new Entry[newCapacity];
//JDK8中移除了hashSeed计算,因为计算时会调用Random.nextInt(),存在性能问题
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//在此步骤完成之前,旧表上依然可以进行元素的增加操作,这就是对象丢失的原因之一
table = newTable;
//注意.MAX是 1<<30,如果1<<31则成Integer的最小值:-2147483648
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//从旧表迁移数据到新表,寥寥几行,但是极为重要
void transfer(Entry[] newTable, boolean rehash){
//外部参数传入时,指定新表的大小为:2 * oldTable.length
int newCapacity = newTable.length;
//使用foreach方式遍历整个数组下标
for (Entry<K,V> e : table){
//如果此slot上存在元素,则进行遍历,直到e==null,退出循环
while(null != e){
Entry<K, V> next = e.next;
//当前元素总是直接放在数组下标的slot上,而不是放在链表的最后
if(rehash){
e.hash == null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//把原来slot上的元素作为当前元素的下一个
e.next = newTable[i];
//新迁移过来的节点直接放置在slot位置上
newTable[i] = e;
//链表继续向下遍历
e = next;
}
}
}
总结一下,扩容的过程大致分为以下几个步骤:
- 定义一个新的Entry数组newTable,调用数据迁移方法transfer()进行数据迁移;
- 遍历整个table数组下标,如果table[i]的哈希槽slot上存在元素则对该哈希桶进行遍历,直到e == null退出循环;
- 遍历table[i]的哈希桶时,当前元素总是要放在数组下表的哈希槽slot上。最先遍历的元素最后会被放在链表的尾部,而最后遍历的元素最后会被当做链表的头结点,放置在哈希槽slot上;
- 等遍历完成以后,当所有的元素都被复制迁移到newTable上,再令table = newTable;
- 最后更新阀值threshold。
JDK7的扩容条件是:
(size >= threshold) && (null != table[bucketIndex])
即达到阀值,并且当前需要存放元素的slot上不为空。从代码上看,JDK7是先扩容再进行新增元素操作,而JDK8是增加元素之后再扩容。
2.4、HashMap获取元素
//通过key寻找对应的Entry<K,V>
public V get(Object key) {
//如果key为null,则返回getForNullKey()的返回值
if (key == null)
return getForNullKey();
//key不为null的时候,调用getEntry()方法查找对应的Entry对象
Entry<K,V> entry = getEntry(key);
//查找出来的Entry对象为空返回null,不为空则返回Entry对象的value
return null == entry ? null : entry.getValue();
}
//遍历table[0]上的所有元素,如果有元素的key为null,则返回该元素的value
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
//计算key的hash值
int hash = (key == null) ? 0 : hash(key);
//通过key的hash值找到对应的存储位置table[i],并遍历table[i]
for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {
Object k;
//当hash值相等且key.equals(e.key)为true,则返回该Entry对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
3、HashMap存在的问题
HashMap不是线程安全的,码出高效中,作者的原话是:“除局部方法或绝对线程安全的情形外,优先使用ConcurrentHashMap。两者的性能相差无几,但后者解决了高并发下的线程安全问题。”
3.1、数据丢失
HashMap在高并发场景中,新增对象丢失的原因如下:
- 并发赋值时被覆盖:在createEntry()方法中,新添加的元素直接放在slot槽上,可以使新添加的元素在下次提取时更快地被访问到。如果两个线程同时执行到第一处时,那么一个线程的赋值就会被另一个覆盖掉,这是对象丢失的原因之一。
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++; }
-
已遍历区间新增元素会丢失:tranfer()数据迁移方法在数组非常大时会非常消耗资源,当线程迁移过程中,其他线程新增的元素有可能会落在已经遍历过的哈希槽上,在遍历完成以后,table数组引用指向了newTable,这时新增元素就会丢失,被无情地垃圾回收。(代码可参见扩容那一段的源码)
-
“新表“”被覆盖:如果resize完成,执行了table = newTable,则后续的元素就可以在新表上进行插入操作。但是如果多个线程同时执行resize,每个线程又都会new Entry[newCapacity],这时线程内的局部数组对象,线程之间是不可见的。前已完成之后,resize的线程会赋值给table线程共享变量,从而覆盖其他线程的操作,因此在“新表”中进行插入操作的对象会被无情地丢弃。(代码可参见扩容那一段的源码)
-
迁移丢失。若数据迁移过程中,有并发时,next被提前置成null
3.2、死链问题