一. 底层存储结构
HashMap底层是由数组+链表构成的,HashMap会通过hashcode()为待插入的元素计算存储到的数组下标,在插入到数组中时,会把元素拼装成Entry对象,并构建一个链表。如果同一个数组下标已经存放了Entry,则将后来者插入到链表的头结点上。
横向被称作table[] 数组
纵向被称作bucket哈希桶,实际上就是由Entry组成的链表。
二. 源码分析
1. 成员变量
/**
* HashMap底层容器(数组)的默认初始容量 必须是2的幂次方 默认16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* HashMap底层容器(数组)的最大初始容量 默认2的30次方
* 用户可以在构造函数中显示的传入数组的最大初始容量,只不过不能超过2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子 默认0.75 计算公式: 负载因子 = 当前元素个数/容器容量
* 用户可以在构造函数中显示的指明
* 当插入数据时导致负载因子大于设定值时,HashMap会对自身容器进行扩容
* 数值越小看,hash碰撞的可能性就越小,但扩容的频率就越高
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 初始化一个Entry空数组
*/
static final java.util.HashMap.Entry<?,?>[] EMPTY_TABLE = {};
/**
* 将初始化好的空数组赋值给table table才是真正存储数据的地方
* 序列化时忽略本属性
*/
transient java.util.HashMap.Entry<K,V>[] table = (java.util.HashMap.Entry<K,V>[]) EMPTY_TABLE;
/**
* HashMap容器中实际存储元素的个数
*/
transient int size;
/**
* 下一个需要调整容器大小的阈值
* 计算公式: 阈值 = 当前容量 * 负载因子
*/
int threshold;
/**
* 负载因子 //TODO 这个干什么用的?为什么有两个负载因子?
*/
final float loadFactor;
/**
* HashMap的内部结构被修改的次数 modCount用于迭代器
*/
transient int modCount;
/**
* hash计算时阈值的默认值
*/
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
/**
* hash计算时使用到的一个因子
*/
transient int hashSeed = 0;
2. 构造函数
/**
* 构造一个空的HashMap,指定容器初始化大小和负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
/**
* 构造一个空的HashMap 指定容器初始化大小 使用默认负载因子0.75
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 构造一个空的HashMap,默认容器初始化大小为16,默认负载因子0.75
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 创建一个与传入Map具有相同数据结构的Map,初始容量大小为原Map的容量大小+1,如果比16小,则取16。
*/
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
3. 成员方法
1. roundUpToPowerOf2( ) 输入期望的数组长度,返回经过计算得出的合理的数组长度,确保数组长度是2的N次幂。
1. 数组长度最大不得超过MAXIMUM_CAPACITY,也就是2的30次方。
2. Integer.highestOneBit(int n):将n转换成二进制数,只取最高位(非符号位),其余位数补0,计算出新的值。
比如9的二进制为1001,取最高位并补0后为1000,也就是8。
3. 如果这个数的二进制数为0,则返回1。
4. Integer.bitCount(int n): 将n转换成二进制数,返回1的个数。比如7的二进制为0111,,因此返回3。
5. 不难注意到,2的N次幂比如2,4,8,16,它们转换成二进制数后都只会出现一个1,比如8->1000,16->10000。如果期望数组长度不是2的N次幂,那么会将其取最高位,其余位数补0后,再乘以2,否则直接返回期望数组长度。
本方法实际上就是在确保数组长度为2的幂,如果期望的长度不是2的幂,就去找到比它大且最邻近的2的幂。比如期望的数组长度为9,对应二进制数1001,由于9不是2的幂,且在2的3次方和2的4次方之间,因此会返回2^3 * 2 = 16。再比如8,由于满足了上述所有约束要求,因此直接返回8。
private static int roundUpToPowerOf2(int number) {
int rounded = number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (rounded = Integer.highestOneBit(number)) != 0
? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
: 1;
return rounded;
}
2. 初始化容器
通过观察initHashSeedAsNeeded( )方法不难发现,绝大部分情况下hashSeed的值都为0,有可能促使hashSeed重新初始化的因素只有useAltHashing。也就是说,除非人为的在jvm中手动设置可选阈值jdk.map.althashing.threshold,并且要比预期初始化容器的长度小,才可能会重新计算hashSeed的值。(因为在默认情况下,ALTERNATIVE_HASHING_THRESHOLD的值为Integer.MAX_VALUE,capacity一般都比它小,因此useAltHashing=false)
计算hashSeed的方法是: sun.misc.Hashing.randomHashSeed(Object var0)
private void inflateTable(int toSize) {
// 找到一个>=toSize且是2的N次幂的数
int capacity = roundUpToPowerOf2(toSize);
// 重新计算阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 重新初始化table
table = new java.util.HashMap.Entry[capacity];
initHashSeedAsNeeded(capacity);
}
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= java.util.HashMap.Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
3. hash算法
HashMap使用hash算法来计算某一个元素被存放的位置(数组的下标)。前文提到,HashMap是由数组和链表构成的,为了使元素均匀分布,在理想情况下,希望数组中一个下标中只存放一个元素,换句话说,每一条链表中至多只会有一个元素,这样一来,如果想得知某一个元素被存放的位置,只需要计算它的hash值,就可以直接定位到元素,而不需要再去遍历该下标上对应的链表。
1. 只有在hashSeed不为0,且k的类型为string时,才会使用stringHash32算法,这个算法是JDK7新引入的。
2. indexFor(int h, int length): 传入hascode值和数组长度,返回hashcode对应的数组下标。除非length=1,否则此方法一定不会返回0。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
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);
}
4. put( )
1. 当Hash值相同,key的内存地址和内容都相同,但value不相同时,后者覆盖前者,这个操作不会改变HashMap的数据结构,不会使modeCount++
2. key为null的元素只能存储在table[0]中,但并不意味着table[0]只能存null。当table的长度为1时,唯一的元素只能存储在table[0]当中。
public V put(K key, V value) {
// 如果底层容器为空,则重新初始化容器
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果key为null,则取出数组中的第一个bucket,也即table[0],这个bucket对应的链表专门用于存放key为null的元素
// 只有一种情况下,table[0]装载着非null元素,那就是容器本身的初始化长度为1。
if (key == null)
return putForNullKey(value);
// 计算key对应的hash值
int hash = hash(key);
// 计算元素应当存储的数组下标
int i = indexFor(hash, table.length);
// 遍历table[i]上的Entry对象,判断该链表中是否有相同key值的元素存在
for (java.util.HashMap.Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// Hash值相同、equals相同或key的内存地址相同
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 鸠占鹊巢 后来者的value覆盖前者的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// HashMap数据结构修改的次数加一
modCount++;
// 目前的table中不存在与待新增的key相同的元素,因此将当前元素添加到table[i]中
addEntry(hash, key, value, i);
return null;
}
put()操作中,HashMap会使用hash()将key计算成hash值,再通过indexFor()找到待新增元素将被存储到数组中的下标,也即找到目标bucket。
如果不同的key对应的hash值放入indexFor()计算出的bucket下标相同,则说明产生冲突。jdk1.7使用单链表来解决冲突。冲突时,HashMap分成两种情况来解决问题:
1. bucket下标相同,key值相同(内存地址或者equals相同)。处理方式: 后者的value将覆盖前者的value。
2. bucket下标相同,key值不同。处理方式: 将本次put()的key和value组装成Entry对象,从当前bucket内已有的Entry链表的头结点处添加Entry对象。
5. Entry
Entry是HashMap数据结构中的最小单元,用于存储数据信息。Entry有四个属性:
1. final K key; 键
2. V value; 值
3. Entry<K, V> next 指向下一个Entry的指针
4. int hash; 当前元素的hash值
通过以上属性,我们不难得知两条信息: 1. Entry是一个单向链表 2. 同一个bucket内,同一条Entry链表中的hash值一定相同。
值得注意的方法:
1. addEntry( )
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果新增后容器内存放元素的个数大于等于阈值,并且要待新增的bucket从未添加过元素
if ((size >= threshold) && (null != table[bucketIndex])) {
// 对数组进行扩容 期望扩大为原先两倍的容量大小
resize(2 * table.length);
// 由于数组的长度发生了变化,因此需要重新计算待新增元素的hash值
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
2. createEntry( )
Step1: 找到table[i]位置对应的bucket上原先存在的Entry对象A。
Step2: 通过hash值、key、value,构造一个新的Entry对象,将它的next指针指向对象A,这样一来,一个新的头结点就构造完毕了,并且旧数据得以保留。
Step3: 让table[i]指向将新的Entry。
Step4: HashMap存储的元素个数加1。
void createEntry(int hash, K key, V value, int bucketIndex) {
java.util.HashMap.Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new java.util.HashMap.Entry<>(hash, key, value, e);
size++;
}
3. Entry(int h, K k, V v, Entry<K,V> n)
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
// next指针指向了旧链表的表头节点
next = n;
key = k;
hash = h;
}
6. get(Object key)
首先,通过hash算法计算出待查询key的hash值,接着通过indexFor找到目标bucket在数组容器中的位置(下标)。如果目标bucket中的Entry链表为null,则返回null,否则循环遍历链表中的每一个Entry对象,直到某个Entry对应的key值与待查询的key相等(equals或者内存地址相同)时,返回当前Entry对应的value值。
这种 bucket单向链表的数据结构的缺陷在查询时被暴露的玲离尽致: 如果待查询的key恰好在单向链表的尾端,并且这个key对应的hash碰撞问题非常严重,那么在极端情况下,数组+单向链表的数据结构将退化成普通的单向链表,时间复杂度由对数阶变成了线性阶,极大地影响了查询性能。
public V get(Object key) {
// 若key为null,遍历table[0]处的链表(实际上要么没有元素,要么只有一个Entry对象),取出key为null的value
if (key == null)
return getForNullKey();
// 若key不为null,用key获取Entry对象
Entry<K,V> entry = getEntry(key);
// 若链表中找到的Entry不为null,返回该Entry中的value
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 计算key的hash值
int hash = (key == null) ? 0 : hash(key);
// 计算key在数组中对应位置,遍历该位置的链表
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// 若key完全相同,返回链表中对应的Entry对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
// 链表中没找到对应的key,返回null
return null;
}
7. 扩容
当存放元素的个数大于或等于容器容量与负载因子的乘积时,HashMap就会触发扩容(默认情况下是 16 * 0.75 = 12)。通过调用resize()方法,将数组的容量扩展为原来的两倍,并对原先table中存放的元素重新计算hash值,最终迁移到新的table中。以上过程又被称作rehash,因为它为元素重新调用hash方法,计算hash值以及存储在新数组中的位置。
1. resize()
1. 每一次扩容,都会给GC带来压力。因为旧数组失去引用后,会被GC在合适的时机回收。
2. threshold = Integer.MAX_VALUE; 这局代码看似写的有问题, 数组的最大容量是MAXIUM_CAPACITY=2的30次方,而阈值threshold居然能够达到Integer.MAX_VALUE=2的31次方-1,HashMap难道不怕数组越界吗?其实这种担心是多余的,因为这是Entry[]数组,每一个元素实际上是一条链表,又可以挂载多个元素。threadshold阈值希望限制的不是数组内元素的个数,而是HashMap容器中,key(键)的个数。
这样想来,回过头来看看公式: 负载因子 = 元素个数 / 数组容量,不难发现,负载因子的值实际上是可以大于1的。
void resize(int newCapacity) {
java.util.HashMap.Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 若旧数组的容量已经达到上限,则把阈值提高到Integer.MAX_VALUE
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 根据新传入的容量创建新数组
java.util.HashMap.Entry[] newTable = new java.util.HashMap.Entry[newCapacity];
// 为每一个旧元素重新计算hash值,分配存储位置,并将旧元素迁移至新数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// table指针指向新数组,旧数组由于失去引用,因此会在合适的时机被GC回收
table = newTable;
// 重新计算阈值 数组容量 * 负载因子 最大不能超过MAXIMUM_CAPACITY + 1
// 这里设置成MAXIMUM_CAPACITY + 1也是有原因的,按照最理想的情况下,元素在数组中均匀分布,每一个元素至占用一个数组空位
// 此时元素的个数(size)为MAXIMUM_CAPACITY,接下来再新增元素时,会调用addEntry()方法,其中的条件表达式size >= threshold
// 会使得数组触发扩容机制,尽量避免出现hash值冲突,形成链表的情况。
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
2. transfer()
首先遍历旧数组中的每一个bucket,接着遍历bucket对应链表中的每一个Entry节点。扩容并非意味着一定要为每一个旧的 key-value重新计算hash值,当且仅当initHashSeedAsNeed()==true,新数组的预期容量比可选阈值还要大时,才需要重新 计算hash值(前文提到,可选阈值默认为Integer.MAX_VALUE)。但扩容一定会为每一个key-value重新计算存储在新数组中的位置(使用indexFor())。
注意: 如果不重新计算key的hash值,那么在使用indexFor()重新计算准备存储的bucket下标后,产生的结果只有两种可能: 原下标或原下标+oldCapacity。
原因在于indexFor()的内部实现为hash & (capacity-1),由于capacity本身保证是2的N次幂,因此在二进制下,capacity减1后除最高位外,其余全为1。大家都知道,操作数无论是0还是1,在和1做与运算时,结果仍等于操作数本身。经过扩容后,数组的容量会变成原来的两倍,因此newCapacity-1会比oldCapacity-1除符号位以外多出一个1,这样一来,重新计算indexFor()的关键就在于在二进制下,hash对应oldCapacity最高位N的数字到底是0还是1,如果是0,则计算出的结果不发生变化,如果是1,则转换到十进制后,新值比旧值多出一个oldCapacity。
比如hash值为44,与31做与运算: (0)101100 & (0)011111 = (0)001100 -> 12
扩容后,44 & (64-1) => (0)101100 & (0)111111 = (0)101100 -> 32+12 = 44
hash对应oldCapacity除符号位以外的最高位(第6位)恰好为1,因此最终计算出的值要比旧值多出2的5次方。
void transfer(java.util.HashMap.Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (java.util.HashMap.Entry<K,V> e : table) {
while(null != e) {
java.util.HashMap.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;
}
}
}
三. 为什么是0.75
为什么HashMap默认的负载因子的值为0.75,而不是0.7,0.8?0.75究竟有什么神奇之处呢?
关键词: 泊松分布。在理想情况下,使用默认的负载因子和随机的hash码能使得每一个bucket中的节点数量遵循泊松分布,从泊松分布表中可以得知,当某个bucket中存放达到8个元素时,再向容器添加新的元素,几乎不会存放到这个bucket中。也就是说,hash冲突中的单向链表内的节点个数几乎不可能超过8个,不会让链表变得过于长,尽可能的避免HashMap的查询时间复杂度从O(1)变成O(n)。
四.性能问题
1. 从扩容的问题中,我们可以看到HashMap要不断的调用indexFor(),为元素计算存储在新数组中的下标,这个过程非常消耗性能。因此,我们在使用HashMap之前需要评估可能存储元素的规模,为HashMap底层容器指定初始化大小,尽可能的避免扩容。
2. 避免尾部遍历。当遇到hash冲突时,HashMap需要把元素添加到链表中,链表可能已经存在多个元素,如果从链表的尾端插入元素,则需要从链表的头结点开始不停地遍历,直到尾节点,最后修改next指针指向新的元素。这么做效率太低了,所以HashMap从头节点开始插入。
五. 线程安全问题
并发下,扩容时可能产生死循环,根本原因是插入元素时为了避免尾部遍历,采用了头部插入的方式。此处参考链接,这篇文章最后死循环的逻辑似乎有问题。
截取transfer部分代码:
java.util.HashMap.Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
假设有两个线程,同时执行put()操作,此时都需要对HashMap进行扩容。indexFor的过程很简单,使用key对数组长度取模即可。下图所示,key为3,7,5的元素经过hash与indexFor后,都被存储在数组下标为1的链表中。
Step1: 线程1率先执行,代码执行到Entry next = e.next; 被调度器挂起了。此时,线程一中,e指向key=3,next指向key=7
线程2开始执行,它把扩容工作全部做完了。由于单链表LIFO的特性,key7插在了key3的前面。
注意: Entry[] table不是一个线程私有的对象,它被所有线程共享。但e和next都是方法内部定义的,因此是线程私有 的。不难注意到,虽然线程1中的e和next虽然指向的元素没有发生变化,但元素本身在链表中存放的顺序却被反 转了。
Step2: 线程1恢复执行,首先执行int i = indexFor(e.hash, newCapacity); 显然,计算的结果与线程二一定相同,都为3。
接着,执行e.next = newTable[i]; 要知道,此时的newTable[3]中已经寄存了key=7,因此next=>key(7),也即
元素: table[3] -> key(7) -> key(3) 指针: next指向key(7),e指向key(3)
然后,执行newTable[i] = e; 也即
元素: table[3] -> key(3) 且 key(7)->key(3) 指针: next指向key(7),e指向key(3)
最后,执行e = next; 也即
元素: table[3] -> key(7) -> key(3) 指针: next指向key(7),e指向key(7)
Step3: 进入下一轮循环
执行next = e.next; 由于e.next指向key(7),而key(7).next指向key(3),因此:
table[3] -> key(7) -> key(3) 指针: next指向key(3),e指向key(7)
然后,执行e.next = new Table[i]; 由于new Table[i] == key(7) ,而e指向key(7),因此这句话执行后会导致 key(7).next指向key(7) ,造成了死循环。比如用户希望查询key(3)的值,首先需要先找到数组下标为3的单链条的头 结点key(7),接着循环遍历单链表,由于key(7)的next是key(7)本身,永远也找不到key(3),因此就会报出Infinite Loop 无限循环的问题。