HashMap 介绍
HashMap在我们的日常开发中非常常见,其本质就是基于数组和链表组合实现。它提供了很方便的key-value的存取接口,通过对key进行Hash计算散列存储位置和快速查找,HashMap允许key和value为null。HashMap并不是线程安全的,如果存在多线程存取操作容易出现注明的ConcurrentModificationException异常。其存储方式可以由下图表示:
HashMap 的实现
在前面的介绍中说道HashMap是基于数据和链表的,我们先看看它的构造方法中,这里我们从无参构造方法开始了解它的代码实现:
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
这里我们看到无参构造方法调用的是另外一个重载的构造方法,其中两个参数传入的分别是:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 4;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
我们再看调用的构造方法的实现原理
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) {
initialCapacity = MAXIMUM_CAPACITY;
} else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
initialCapacity = DEFAULT_INITIAL_CAPACITY;
}
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// Android-Note: We always use the default load factor of 0.75f.
// This might appear wrong but it's just awkward design. We always call
// inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
// to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
// the load factor).
threshold = initialCapacity;
init(); // 是一个空方法,忽略
}
这里先解释下两个参数的意义:
- initialCapacity 是HashMap存储数据的初始化大小,从传入的值可以知道默认的初始化大小是4。注意:为了方便做位运算,数组的大小都是大于且最接近指定初始化大小值的2的幂次方。例如,在使用中直接调用上面的有参构造函数,initialCapacity传入的是5,那么在构造时默认的数组大小将是8(这里初始化4,最终构造数组时大小也是8)
- loadFactor 是HashMap 存储数组的扩容阀值比例,当数组的容量超过initialCapacity * loadFactor时,这里默认是0.75,即数组put进第3个元素时,数组将自动扩容。此后扩容的阀值一直等于当下的数组大小乘于loadFactor。具体扩容的原理稍后会讲到
接下来分析下HashMap的put方法,put方法不仅是容器的插入操作,并存在HashMap动态扩容的逻辑,其流程图大致如下:
再结合插入源码
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold); // 初始化表
}
if (key == null) // 允许key为空
return putForNullKey(value);
int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key); // 计算key的哈希值,利用雪崩效应
int i = indexFor(hash, table.length);// 通过key的hash值查找在数组上的散列位置
for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {// 如果为同一个key,则覆盖原来的value
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++;// 需提防concurrentModificationException
addEntry(hash, key, value, i); // 插入一个新的元素,并存在数组扩容的逻辑
return null;
}
这里我们重点看下初始化数组和插入数组或者链表的实现
- 数组的初始化
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);// 获取初始化大小
// Android-changed: Replace usage of Math.min() here because this method is
// called from the <clinit> of runtime, at which point the native libraries
// needed by Float.* might not be loaded.
float thresholdFloat = capacity * loadFactor;
if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
thresholdFloat = MAXIMUM_CAPACITY + 1;
}
threshold = (int) thresholdFloat;
table = new HashMapEntry[capacity];
}
上面就是一个数组容器和扩容阀值的的初始化过程,代码比较易懂,主要关注roundUpToPowerOf2(int) 方法,它确保了传入的初始化大小不大于MAXIMUM_CAPACITY = 1 << 30 的数值,且不能为负数,且是一个大于并最接近传入数值的2的幂次方数值(为了方便做位运算),代码如下:
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
int rounded = number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (rounded = Integer.highestOneBit(number)) != 0
? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
: 1;
return rounded;
}
为方便理解,我用一个流程图并用一个实际数值举例上面的实现
- Hash值的计算
针对Key的Hash值的计算由方法:
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
//
// Based on commit 1424a2a1a9fc53dc8b859a77c02c924.
public static int singleWordWangJenkinsHash(Object k) {
int h = k.hashCode();
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
这是很重要的一步,通过再Hash算法运算,减少Hash冲突,能让元素尽可能地分散在数组中不至于链表过长影响效率,具体Hash过程可以参考:
http://www.infoq.com/cn/articles/ConcurrentHashMap/
- 插入元素流程
首先要定位元素插入的位置,从上面的put方法中知道通过以下接口实现:
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);
}
由于对key的hash计算非常得散列,而在之前构造数组大小时已经决定是2的幂次方。此时hash值和数组长度减一的数值(有效位数全是1)做&运算既可以保证散列性,有可以保证元素的位置可以落在数组的范围内不至于越界。
接着我们查看插入元素的方法实现:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) { // 超过阀值并且该位置已经有元素占据
resize(2 * table.length); // 扩容,并且重新定义其他元素的位置
hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);// 加入元素
}
扩容方法resize(int)中,重要的一点是重新定义旧元素的新位置,通过以下方法实现:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(HashMapEntry[] newTable) {
int newCapacity = newTable.length;
for (HashMapEntry<K,V> e : table) {
while(null != e) {
HashMapEntry<K,V> next = e.next;
// 将链表上的数据放到数组当中,元素重新移动的概率是50%,取决与Hash值的与数组长度减一后的二进制最高位做&运算那一位是0还是1
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
由于是个链表结构,需要通过双层循环去重新排布元素的位置,性能是比较糟糕的。在java8当中已经改为红黑树结构。对上面的注释稍作下解释,假如当前数组的长度为4,其中某个元素A的hash值是110,则他在当前数组的位置110 & 011 = 010,即是第2位。然而当数组扩容到8时,A的位置应该是110 & 111 = 110,就被重新分配到第6位。而如果A的hash值是010,则扩容前后的位置仍然是2