前言
HashMap是我们日常学习或者简单的coding过程中常用的一种数据结构,也是找工作面试中常常问到的知识点,其重要性不言而喻。从jdk1.7到jdk1.8,java开发者对HashMap进行了一系列优化(后文会讲到),甚至是在j.u.c包下线程安全的ConcurrentHashMap也有与HashMap相通之处,所以我们应该从jdk1.7的HashMap学起,再不断深入学习ConcurrentHashMap以及jdk1.8的HashMap。
通过这篇文章,我将对阅读jdk1.7中HashMap源码的过程中所学的原理以及知识点进行总结,作为应对秋招面试的积累,也希望能为各位像我一样正在不断学习中的小白提供帮助。如有错误或者表述不清晰的地方欢迎评论留言,我会尽快进行修改或者删除。
基本知识
HashMap其本质可以视为一种容器,用于存储一个个key-value键值对及其映射关系。在jdk1.7中HashMap底层使用的数据结构是数组 + 链表。我们创建一个HashMap时可以选择性地传入初始容量大小,但是HashMap所能存储的元素个数并不会受到这个初始容量的影响,它会不断对自身的数组进行扩容,从而使得自己的容量越来越大。==底层数据结构==如图所示。
HashMap==类图及其实现接口关系==如图所示。
接下来就是HashMap中==存储元素==的数据结构,见如下代码片段。
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;
}
}
了解了如上基本知识后,下面将会从源码入手,边看源码边总结其中的知识点。
常量和成员变量
//默认初始化容量,16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大初始化容量,2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子,0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认初始化table的空数组
//HashMap采用的是一种延迟加载的机制:当HashMap被创建时table被初始化为一个空数组,只有当其被使用
//时,才创建一个非空数组。
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* The table, resized as necessary. Length MUST Always be a power of two.
* HashMap底层数组结构中的数组,在必要时可以进行扩容,但是数组的length必须为2的整数次幂
*
*
* (Question1:为什么数组的长度必须是2的整数次幂?)
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap中存储的key-value键值对的数量,也就是存储的Entry的数量
transient int size;
//扩容阈值,同时也代表刚创建HashMap时的initialCapacity
int threshold;
//扩容因子,与HashMap扩容有关,threshold = loadFactor * capacity
final float loadFactor;
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
/**
* modCount用来记录HashMap发生结构性修改的次数,比如添加元素、删除元素等(在阅读后面的方法时我们会记
* 录一下哪些情况算是结构性修改,Question2),该变量是用于HashMap集合视图中的迭代器的fail-fast策
* 略。
*
* 在创建迭代器时,会将modCount赋值给一个名为expectedModCount的变量。在当前线程使用迭代器的过程
* 中,会不断地校验modCount与expectedModCount是否相等。如果二者值不相等,根据fail-fast策略,会
* 立即抛出ConcurrentModificationException,从而实现不让其他线程对HashMap进行结构性的修改。
* 可以参考内部类HashIterator的代码。
*
* fail-fast策略是一种错误检测策略,但无法避免错误。它是java集合中的一种错误机制,当多个线程同时对
* 一个集合进行修改时,就会发生ConcurrentModificationException。所以在并发环境下,还是建议使用
* j.u.c包下的组件。
*/
transient int modCount;
了解了HashMap中的常量和成员变量时,遗留下来两个问题:
Q1:为什么table的长度必须是2的整数次幂?
Q2:哪些情况算是对HashMap的结构性修改?
为了解决这两个问题,我们开始阅读HashMap方法的源码。
方法
在该部分我们不仅仅是针对某一方法进行解释,而是由一个方法引出与其相关的其他方法,并且解释。
构造方法
//无参构造方法
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//带一个参数的构造方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//传入初始容量以及扩容因子的方法
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;
//刚创建HashMap时将初始化容量记录到threshold中
threshold = initialCapacity;
//空方法,LinkedHashMap中使用到
init();
}
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);
}
put方法
put方法是HashMap中十分复杂也是十分重要的一个方法。put方法是用于向HashMap中添加Key-Value键值对的方法。若要添加的Key已存在于HashMap中,用传入的value值覆盖原来的oldValue,并将oldValue返回;若不存在,则直接添加,并返回null。
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*/
//向HashMap中添加元素的方法
public V put(K key, V value) {
//当第一次调用put方法时才对table进行初始化
if (table == EMPTY_TABLE) {
//创建table
inflateTable(threshold);
}
//由此可见,jdk1.7版本下的HashMap支持Key为null的键值对
//如果要put元素的key为null,则直接将该元素存储到table[0]链表中
if (key == null)
return putForNullKey(value);
//根据key散列出hash值
int hash = hash(key);
//根据hash值和table的长度计算出该元素应插入的链表在table中的下标i
int i = indexFor(hash, table.length);
//在table[i]中寻找与插入元素key相同的元素
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
/**
* 注意此处HashMap是如何判断key相等的:
* e.hash == hash && ((k = e.key) == key || key.equals(k))
* 计算hash时也使用到了key的hashcode方法
*
* 所以,当key的类型是自定义类型,如果重写了equals方法,那么同时也要重写hashCode方法
* 防止出现key相同,但是经过hashCode方法散列后的hash不同的情况
*/
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//方法执行到此处时,说明原链表中不存在与插入元素key相同的元素,那么,就需要创建一个Entry并插入
//向HashMap添加一个元素时,modCount需要自增
modCount++;
//添加Entry
addEntry(hash, key, value, i);
return null;
}
//putForNullKey方法是进行key为null的情况下的插入操作
private V putForNullKey(V value) {
//没有求hash,也没有求i,直接从table[0]中查找是否有Key相同的元素
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;
}
inflateTable方法
inflateTable方法是根据创建HashMap时传入的初始容量或者默认初始容量来创建数组,并初始化table。
//方法参数toSize就是HashMap初始容量
private void inflateTable(int toSize) {
// roundUpToPowerOf2是根据初始容量计算出一个值capacity,作为table的长度
// 该值满足:capacity >= toSize,并且capacity为2的整数次幂
int capacity = roundUpToPowerOf2(toSize);
// 重新计算扩容阈值:threshold = capacity * loadFactor
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
//该方法是求出i的最高位,比如9对应的2进制为:1001,经过该运算后,求出结果为1000
public static int highestOneBit(int i) {
//该方法是通过多次或运算,将i的低位全都变成1,最后再进行右移再相减,就只保留了最高位的1
//如:1001,经过五次或运算,变成1111,最后一步为1111 - 0111 = 1000
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
hash方法
hash方法是根据key计算出对应的hash值,这个hash值在定位插入链表在table中的下标(indexFor)时会使用到。
//HashMap中的hash算法要求算法散列性尽可能的高
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);
}
indexFor方法
indexFor方法就是根据hash值和table长度计算出插入链表在table中的下标i。
static int indexFor(int h, int length) {
/**
* 计算下标i,可以使用取模%操作,也可以使用按位&操作,但是计算机底层运算实际上还是2进制的位运
* 算,所以按位&操作效率会更高。
*
*
* 此处就可以解释Q1:为什么table的长度必须为2的整数次幂?
* 因为我们此处求下标i使用的是按位&操作,如果length - 1中某一位为0,
* 则该位上按位&操作必然为0,如:length为1011
* length - 1:1010,
* 则进行按位与操作时,数组上的有些位置将永远访问不到,造成空间的浪费,而且也增加了
* hash冲突的可能性。而如果length满足2的整数次幂,那么put操作时要插入的元素可以被散列到数组的所
* 有位置。
*/
return h & (length-1);
}
通过该方法,我们解决了Q1。因为当length为2的整数次幂时,待插入元素散列到数组中任一位置的几率一样,也就是有机会可以被散列到table中的任一位置,可以有效利用数组空间,也可以减少hash冲突的可能性。
addEntry方法
addEntry方法执行时,需要先判断数组是否需要扩容,再进行元素添加。
void addEntry(int hash, K key, V value, int bucketIndex) {
//jdk1.7版本HashMap的扩容条件:(size >= threshold) && (null != table[bucketIndex])
//扩容条件:1、当前HashMap中Entry个数 >= threshold 2、要插入位置的链表不为空
//jdk1.7和1.8中HashMap的扩容条件有一些差异,需要注意!!!
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容,新数组的长度为原数组的2倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
//扩容后需要重新计算index
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
通过该方法,我们可以知道jdk1.7下HashMap扩容的两个条件,以及HashMap扩容后的数组长度为原数组的2倍。
resize方法
resize方法是对HashMap进行扩容,并将原table中的元素转移到新table中。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建新数组
Entry[] newTable = new Entry[newCapacity];
//将原table中的元素转移到新table中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
//重新计算扩容阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//转移元素
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方法可能会导致死循环链表。死循环链表的形成过程见文末图解。
createEntry方法
createEntry方法是真正进行创建Entry并插入链表操作的方法。该方法中将新创建的Entry通过头插法插入到链表中。jdk1.7版本下HashMap插入时采取的是头插法。java开发者认为新插入的Entry可能会更多地被访问,所以为了方便以后的存取,将新添加的元素插入到链表头部。但是该插入策略会使得在 扩容后的transfer方法中可能会产生死循环链表,所以在jdk1.8开始就改成了尾插法。
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++;
}
后面其他方法比较简单,就不作过多阐述,贴出来方便日后查阅。
get相关方法
get相关的方法在内部主要将具体的get操作委托给了getEntry方法。
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
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;
}
size方法
size方法返回HashMap中存储的Entry数量。看过该方法代码后可以方便区分字符串的length()方法、数组的length、集合的size()方法。
public int size() {
return size;
}
remove相关方法
remove、clear相关操作可能会对HashMap产生结构性的修改,modCount值会自增。
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
final Entry<K,V> removeMapping(Object o) {
if (size == 0 || !(o instanceof Map.Entry))
return null;
Map.Entry<K,V> entry = (Map.Entry<K,V>) o;
Object key = entry.getKey();
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
if (e.hash == hash && e.equals(entry)) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
//在日常编码时快速填充数组可以学习该技巧Arrays.fill
public void clear() {
modCount++;
Arrays.fill(table, null);
size = 0;
}
综上方法我们可以总结出,在put、remove系列的方法中,可能会对HashMap造成结构性的修改,会导致modCount自增。
总结
HashMap添加元素的过程:
1、判断是否需要初始化数组,如果需要则创建数组
2、判断key是否为空,如果key为空则遍历table[0]链表查找key为null的Entry,找到则使用newValue覆盖oldValue并返回oldValue;否则直接插入到链表头部
2、以传入的key作为方法参数,调用hash方法计算hash散列值
3、对hash值和(length - 1)进行按位与操作,计算出该键值对插入的链表在table中的下标i
4、遍历table[i]查找与插入元素key相同的元素,找到则使用newValue覆盖oldValue并返回oldValue
5、判断是否需要扩容,如果需要扩容则对数组进行扩容,并将原数组中的元素转移到新数组中
6、根据hash和(新数组长度-1)进行按位与求新的下标newIndex
7、创建Entry对象,并插到table[newIndex]链表头部
死循环链表图解:
某一时刻,主内存中HashMap的table以及某条链表下的a、b、c节点如图所示。
假设此时有两个线程ThreadA、ThreadB都要向a、b、c所在的位置插入节点,并且此时已满足扩容条件。
第一次CPU调度中,ThreadA得到CPU时间片,ThreadA将主内存中table的副本复制到ThreadA的工作内存中。当transfer方法for循环中e指向table[i],接着执行while循环,执行到“Entry<K,V> next = e.next;” 时,next指向b。如果此时ThreadA获得的CPU时间片使用完毕,ThreadA工作内存中对共享变量的修改会被同步到主内存中,而此次任务中并没有对table进行修改,所以主内存共享变量不变。
第二次CPU调度,ThreadB得到CPU时间片,ThreadB将主内存中table的副本复制到ThreadB的工作内存中。ThreadB完成将table[i]中的节点转移到newTable[i]中后,此时ThreadB工作内存table和newTable如图所示。
在将newTable赋值给table前,若ThreadB获得的CPU时间片使用完毕,则将工作内存中对table和a、b、c的修改同步到主内存中,此时工作内存中table和a、b、c的指向关系就如ThreadB工作内存中的一样。
第三次CPU调度中,ThreadA得到CPU时间片。此时ThreadA工作内存如图所示。此时e指向a节点,next指向b节点。
首先将e指向的节点头插到newTable[i]中,并将next赋值给e。如图所示。
当再次进行while内部的循环时,执行next = e.next,此时e指向b,next指向a,将e头插到newTable[i]中,并将next赋值给e,如图所示。
下一次执行next = e.next时,next指向null,将e头插到newTable[i]中,并将next赋值给e,下一次进入循环时,e==null,则跳出循环,table[i]转移完成。此时table和newTable如图所示。
当ThreadA获得的CPU时间片使用完毕,将工作内存中的table同步到主内存中。此时a节点和b节点之间就构成了一个环,同时c节点无法被访问,造成了c节点数据的丢失。当HashMap的方法中遍历到table[i]链表时,由于存在环,就会造成无限死循环。