Hashmap的数据结构
HashMap介绍
先看看HashMap类头部的源码:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
此实现假定哈希函数将元素适当地分布在各个桶(数组元素)之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将负载因子设置得太低)。
HashMap 的实例有两个参数影响其性能:初始容量和负载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。负载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
通常,默认加载因子 (.75) 在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数HashMap 类的操作中,包括get 和put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生rehash 操作。
如果很多映射关系要存储在 HashMap 实例中,则相对于按需执行自动的 rehash 操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效地存储。
注意,此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(...));
由所有此类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的remove 方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不会在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。
2.1.2 HashMap存储结构图
这里先给出HashMap的存储结构,在后面的源码分析中,我们将更加详细的对此作介绍。HashMap采取数组加链表的存储方式来实现。亦即数组(散列桶)中的每一个元素都是链表,如下图:
图2-1
说明:下面针对HashMap的源码分析中,所有提到的桶或散列桶都表示存储结构中数组的元素,桶或散列桶的数量亦即表示数组的长度,哈希码亦即散列码。
2.1.3 属性分析
先来看看HashMap有哪些属性,HashMap没有从AbstractMap父亲中继承任何属性,下面这些都是HashMap的属性:
static final int DEFAULT_INITIAL_CAPACITY = 16;
DEFAULT_INITIAL_CAPACITY是HashMap默认的初始化桶数量,如图2-1中所示。对于HashMap中桶数量的值必须是2的N次幂,而且这个是HashMap强制规定的。这样做的原因就是因为计算机进行2次幂的运算是非常高效的,仅通过位移操作就可以完成2的N次幂的运算。
static final int MAXIMUM_CAPACITY = 1 << 30;
MAXIMUM_CAPACITY是HashMap中散列桶数量的最大值,从上面的代码可知这个最大值为2的32次幂,即1073741824。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
默认的负载因子,如果在在创建HashMap的构造函数中没有指定负载因子,则指定该HashMap的默认负载因子为0.75,这意味着当HashMap中条目的数量达到了条目数量75%时,HashMap将进行resize操作以增加桶的数量。对于桶的扩展,等分析到下面的具体时会作更详细的介绍。
transient Entry<K,V>[] table;
table就是HashMap的存储结构,显然这是一个数组,数组的每一个元素都是一个条目(Entry),Entry是HashMap中的一个内部类,它有如下4个属性:final K key;V value;Entry<K,V> next;int hash。分别为键、值、指向下一个链表结点的指针、散列(哈希)值。这就是图2.1中HashMap存储结构的代码实现。
transient int size;
size表示HashMap中条目(即键-值对)的数量。
int threshold;
threshold是HashMap的重构阈值,它的值为容量和负载因子的乘积。在HashMap中所有桶中条目的总数量达到了这个重构阈值之后,HashMap将进行resize操作以自动扩容。
final float loadFactor;
loadFactor表示HashMap的负载因子,它和容量一样都是HashMap扩容的决定性因素。
transient int modCount;
modCount表示HashMap被结构化更新的次数,比如插入、删除、清空等会更新HashMap结构的操作次数。
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT
= Integer.MAX_VALUE;
ALTERNATIVE_HASHING_THRESHOLD_DEFAULT表示在对字符串键(即key为String类型)的HashMap应用备选哈希函数时HashMap的条目数量的默认阈值。备选哈希函数的使用可以减少由于对字符串键进行弱哈希码计算时的碰撞概率。
transient boolean useAltHashing;
useAltHashing表示是否要对字符串键的HashMap使用备选哈希函数。
transient final int hashSeed = sun.misc.Hashing.randomHashSeed(this);
hashSeed表示一个与当前实例关联并且可以减少哈希碰撞概率应用于键的哈希码计算的随机种子。
2.1.4 构造分析
HashMap提供了4个构造方法,按照它们在源码中的位置顺序从上至下列出:
HashMap(int initialCapacity, float loadFactor)
HashMap(int initialCapacity)
HashMap()
HashMap(Map<? extends K, ? extends V> m)
(1) 我们先来分析第一个同时传递初始化容量参数和负载因子参数的源码,因为其它的3个构造方法都会调用这个构造方法,下面给出这个方法的代码及分析:
public HashMap(int initialCapacity, float loadFactor) {
//部分构造参数容错处理的源码已省略...
/**
* 根据传入的初始化容量计算该HashMap的容量(即桶的数量)
* 算法为:将capacity进行不断的左移,直至capacity大于或等于初始化容量
*/
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
//负载因子初始化
this.loadFactor = loadFactor;
/**
* 条目阈值的计算
* 算法:超出条目最大容量前取容量与负载因子的乘积作为条目阈值
*/
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建数组(散列桶)
table = new Entry[capacity];
//计算是否对字符串键的HashMap使用备选哈希函数
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();//调用初始化方法,默认情况下什么也没做
}
(2) 下面是只传初始化容量参数的构造方法:
public HashMap(int initialCapacity) {
//初始化容量传入,加载因子为默认值0.75f
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
(3) 下面是无参构造方法:
public HashMap() {
//初始化容量为默认值16,加载因子也为默认值0.75f
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
(4) 下面是根据已有Map构造新HashMap的构造方法:
public HashMap(Map<? extends K, ? extends V> m) {
/**
* 取下面两个值的较大的值作为当前要构造的HashMap的初始容量
* 第1个值:用传入的Map的条目数量除以默认加载因子再加上1
* 第2个值:默认的初始化容量
*/
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
/**
* 把传入的map里的所有条目放入当前已构造的HashMap中
* 关于putAllForCreate方法后面会作分析
*/
putAllForCreate(m);
}
2.1.5 hash方法
hash方法的源码及分析如下:
final int hash(Object k) {
int h = 0;
/**
* 如果useAltHashing的值为true
* 并且键的类型为String,则对字符串键使用备选哈希函数
* 否则,返回用于对键进行哈希码计算的随机种子hashSeed
* 关于hashSeed在2.1.3.1小节中已介绍过,这里不再赘述
*/
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
/**
* 对h和键的哈希码进行抑或并赋值运算
* 等价于h = h ^ k.hashCode();
*/
h ^= k.hashCode();
//下面两步的运算过程如图2-2所示
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
假设h=0x7FFFFFFF,则上面最后两步对h的运算过程如下图:
图2-2
2.1.6 indexFor方法
/**
* h表示通过hash(Object k)方法计算得来的哈希码
* length表示桶的数量(即数组的长度)
*/
static int indexFor(int h, int length) {
/**
* 将哈希码和length进行按位与运算
* 所有的h值都会在映射在闭区间[0,length-1]内
* 不同的h值可能映射到闭区间[0,length-1]内同一个值上
*/
return h & (length-1);
}
2.1.7 put方法
/**
* 在HashMap中存储一个键值对,若指定的键已经存在于HashMap中
* 则将新的值替换掉旧值,否则新添加一个条目来存储这个键值对
* @param key 指定的键
* @param value 指定的值
* @return 若该键已经存在则返回该键对应的旧值,否则返回null
*/
public V put(K key, V value) {
if (key == null)
/**
* 若键为null,则调用putForNullKey方法进行插入
* putForNullKey的源码这里不再分析,读者有兴趣可以自行分析它的源码
*/
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方法添加向桶中添加新结点
* addEntry方法下一小节将会详细介绍
*/
addEntry(hash, key, value, i);
return null;
}
2.1.8 addEntry方法
/**
* 向HashMap的指定桶中添加一个新的键对值
* 若要对HashMap扩容(即增加桶的数量),则下面的方法可能会修改传入的桶索引
* @param hash 指定键对应的哈希码
* @param key 指定键
* @param value 指定值
* @param bucketIndex 桶索引
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//如果HashMap中条目的数量达到了重构阈值且指定的桶不为null,则对HashMap进行扩容(即增加桶的数量)
/**
* 调用resize方法对HashMap进行扩容
* 对于resize方法,下面会有专门的一小节来作介绍,这里先不介绍
*/
resize(2 * table.length);
//扩容后,桶的数量增加了,故需要重新对键进行哈希码的计算
hash = (null != key) ? hash(key) : 0;
//根据新的键哈希码和新的桶数量重新计算桶索引值
bucketIndex = indexFor(hash, table.length);
}
/**
* 在指定的桶中创建一个新的条目以存储我们传入的键值对
* 对于createEntry方法,读者若有兴趣可以自行阅读其源码
*/
createEntry(hash, key, value, bucketIndex);
}
2.1.9 resize方法
/**
* 重新调整HashMap中桶的数量
* @param newCapacity 新的桶数量
*/
void resize(int newCapacity) {
/**
* 下面的这段代码对新值进行判断
* 如果新值超过了条目(Entry)数量的最大值
* 则新int最大值赋值给重构阈值然后,然后直接返回而不会进行扩容
*/
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//若newCapacity合法,则新建一个桶数组。
Entry[] newTable = new Entry[newCapacity];
//计算是否需要对键重新进行哈希码的计算
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
/**
* 将原有所有的桶迁移至新的桶数组中
* 在迁移时,桶在桶数组中的绝对位置可能会发生变化
* 这就是为什么HashMap不能保证存储条目的顺序不能恒久不变的原因
* 读者若有兴趣,可以自行阅读transfer方法的源码
*/
transfer(newTable, rehash);
//将新的桶数组的引用赋值给旧数组
table = newTable;
//像构造方法中一样来重新计算重构阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
2.1.10 get方法
/**
* 根据指定键获取该键对应的值
* @param key 指定键
* @return 若该键存在于HashMap中,则返回该键对应的值,否则返回null
*/
public V get(Object key) {
if (key == null)
//若键为null,则返回null键对应的值
return getForNullKey();
//根据键获取条目,下一小节会单独介绍getEntry方法
Entry<K,V> entry = getEntry(key);
//返回条目的值,若条目为null,则返回null
return null == entry ? null : entry.getValue();
}