目录
1.前言
本文是基于JDK1.8的HashMap源码展开分析的。
2.使用和实现
2.1 基本使用
HashMap是一种数据结构:使用key-value的方式存取数据。具体使用方法如下:
Map<String,String> hashMap=new HashMap<String,String>();
hashMap.put("name","jasper");
String name=hashMap.get("name");
2.2 定义
HashMap扩展了AbstractMap类型,实现了Map,Cloneable,Serializable等接口。
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table;
HashMap的数据其实是存放在table数组的,table数组是一个Entry类型的数组。Entry类型的具体定义如下:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry类型定义了hash,key和value值,还有一个类型为Entry的next属性指向了下一个Entry对象。所以,Entry类型本质上是一个单向链表。
2.3 构造方法
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);
}
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);
// 找到第一个大于等于initialCapacity的2的平方的数
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
// HashMap扩容的阀值,值为HashMap的当前容量 * 负载因子,默认为12 = 16 * 0.75
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 初始化table数组,这是HashMap真实的存储容器
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 该方法为空实现,主要是给子类去实现
init();
}
initialCapacity是HashMap的初始化容量(即初始化table时用到),默认为16。loadFactor为负载因子,默认为0.75。
threshold是HashMap进行扩容的阀值,当HashMap存放的元素个数超过该值时,会进行扩容,它的值为HashMap的容量乘以负载因子。比如,HashMap的默认阀值为16*0.75,即12。
HashMap提供了指定HashMap初始容量和负载因子的构造函数,这时候会首先找到第一个大于等于initialCapacity的2的平方数,用于作为初始化table。至于为什么HashMap的容量总是2的平方数是有特殊的意义的
init是个空方法,提供给子类进行扩展。比如LinkedHashMap在init初始化头部节点,这里暂时先不介绍。
2.4 put方法
public V put(K key, V value) {
// 对key为null的处理
if (key == null)
return putForNullKey(value);
// 根据key算出hash值
int hash = hash(key);
// 根据hash值和HashMap容量算出在table中应该存储的下标i
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 先判断hash值是否一样,如果一样,再判断key是否一样
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(hash, key, value, i);
return null;
}
首先,如果key为null调用putForNullKey来处理,我们暂时先不关注,后面会讲到。然后调用hash方法,根据key来算得hash值,得到hash值以后,调用indexFor方法,去算出当前值在table数组的下标,我们可以来看看indexFor方法:
static int indexFor(int h, int length) {
return h & (length-1);
}
这是mod取余的一种替换方式,相当于h%(lenght-1),其中h为hash值,length为HashMap的当前长度。而&是位运算,效率要高于%。至于为什么是跟length-1进行&的位运算,是因为length为2的幂次方,即一定是偶数,偶数减1,即是奇数,这样保证了(length-1)在二进制中最低位是1,而&运算结果的最低位是1还是0完全取决于hash值二进制的最低位。如果length为奇数,则length-1则为偶数,则length-1二进制的最低位恒为0,则&位运算的结果最低位恒为0,即恒为偶数。这样table数组就只可能在偶数下标的位置存储了数据,浪费了所有奇数下标的位置,这样也更容易产生hash冲突。这也是HashMap的容量为什么总是2的平方数的原因。我们来用表格对比length=15和length=16的情况:
我们再回到put方法中,我们已经根据key得到hash值,然后根据hash值算出在table的存储下标了,接着就是这段for代码了:
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 先判断hash值是否一样,如果一样,再判断key是否一样
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
首先取出table中下标为i的Entry,然后判断该Entry的hash值和key是否和要存储的hash值和key相同,如果相同,则表示要存储的key已经存在于HashMap,这时候只需要替换已存的Entry的value值即可。如果不相同,则取e.next继续判断,其实就是遍历table中下标为i的Entry单向链表,找是否有相同的key已经在HashMap中,如果有,就替换value为最新的值,所以HashMap中只能存储唯一的key。
关于比较hash值和key需要注意以下2点:
- 为什么比较了hash值还需要比较key:因为不同对象的hash值可能一样
- 为什么不只比较equal:因为equal可能被重写了,重写后的equal的效率要低于hash的直接比较
假设我们是第一次put,则整个for循环体都不会执行,我们继续往下看put方法。
modCount++;
addEntry(hash, key, value, i);
return null;
这里主要看addEntry方法,addEntry方法把key和value封装成Entry,加入到table中。来看看它的方法体:
void addEntry(int hash, K key, V value, int bucketIndex) {
// 当前HashMap存储元素的个数大于HashMap扩容的阀值,则进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 使用key、value创建Entry并加入到table中
createEntry(hash, key, value, bucketIndex);
}
addEntry方法涉及到了HashMap的扩容。我们先不讨论扩容,后面会讲到。然后调用了createEntry方法,它的实现如下:
void createEntry(int hash, K key, V value, int bucketIndex) {
// 取出table中下标为bucketIndex的Entry
Entry<K,V> e = table[bucketIndex];
// 利用key、value来构建新的Entry
// 并且之前存放在table[bucketIndex]处的Entry作为新Entry的next
// 把新创建的Entry放到table[bucketIndex]位置
table[bucketIndex] = new Entry<>(hash, key, value, e);
// HashMap当前存储的元素个数size自增
size++;
}
createEntry方法根据hash、key、value以及table中下标为bucketIndex的Entry去构建一个新的Entry,其中table中下标为bucketIndex的Entry作为新Entry的next,这也说明了,当hash冲突时,使用合并列表的方法来解决hash冲突的,并且是把新元素是插入到单边表的表头。如下所示:
2.5 扩容
如果HashMap中存储的元素个数达到需要扩容的阀值,且要保存的Entry在table要存放的位置已经有Entry时,怎么处理的?我们再来看看addEntry方法中的扩容相关代码:
if ((size >= threshold) && (null != table[bucketIndex])) {
// 将table表的长度增加到之前的两倍
resize(2 * table.length);
// 重新计算哈希值
hash = (null != key) ? hash(key) : 0;
// 从新计算新增元素在扩容后的table中应该存放的index
bucketIndex = indexFor(hash, table.length);
}
resize方法具体逻辑如下:
void resize(int newCapacity) {
// 保存老的table和老table的长度
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建一个新的table,长度为之前的两倍
Entry[] newTable = new Entry[newCapacity];
// hash有关
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 这里进行异或运算,一般为true
boolean rehash = oldAltHashing ^ useAltHashing;
// 将老table的原有数据,从新存储到新table中
transfer(newTable, rehash);
// 使用新table
table = newTable;
// 扩容后的HashMap的扩容阀门值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
再来看看transfer方法是如何将把老table的数据转到扩容后的table:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历老的table数组
for (Entry<K,V> e : table) {
// 遍历老table数组中存储的每个单向链表
while(null != e) {
// 取出老table中每个Entry
Entry<K,V> next = e.next;
if (rehash) {
//重新计算hash
e.hash = null == e.key ? 0 : hash(e.key);
}
// 根据hash值计算出老table中的Entry应该在新table中存储的index
int i = indexFor(e.hash, newCapacity);
// 将老table取出的entry插入到了新table中index处单链表的表头
e.next = newTable[i];
newTable[i] = e;
// 继续取老talbe的下一个Entry
e = next;
}
}
}
从上面的代码内容可知,扩容需要先创建一个长度为原来2倍的新table,然后通过遍历的方式,将老table的数据重新计算hash,并存储到新table的适当位置,最后使用新的table,并重新计算HashMap的扩容阀值。
2.6 get方法
public V get(Object key) {
// 当key为null, 使用getForNullKey方法
if (key == null)
return getForNullKey();
// 根据key得到key对应的Entry
Entry<K,V> entry = getEntry(key);
//
return null == entry ? null : entry.getValue();
}
重点看下getEntry方法是如何通过key取到Entry的:
final Entry<K,V> getEntry(Object key) {
// 根据key算出hash
int hash = (key == null) ? 0 : hash(key);
// 先算出hash在table中存储的index,然后遍历table中下标为index的单向链表
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// 如果hash和key都相同,则把Entry返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
getEntry方法用key的hash值算出key对应的Entry所在链表在在table的下标。只要遍历单向链表就可以了,时间复杂度降低到O(n)。
2.7 使用entrySet取数据
HashMap提供了entrySet方法来遍历HashMap的方式取数据。如下:
Map<String, String> hashMap = new HashMap<String, String>();
hashMap.put("name1", "josan1");
hashMap.put("name2", "josan2");
hashMap.put("name3", "josan3");
Set<Entry<String, String>> set = hashMap.entrySet();
Iterator<Entry<String, String>> iterator = set.iterator();
while(iterator.hasNext()) {
Entry entry = iterator.next();
String key = (String) entry.getKey();
String value = (String) entry.getValue();
System.out.println("key:" + key + ",value:" + value);
}
HashMap重写了entrySet:
public Set<Map.Entry<K,V>> entrySet() {
return entrySet0();
}
private Set<Map.Entry<K,V>> entrySet0() {
Set<Map.Entry<K,V>> es = entrySet;
// 相当于返回了new EntrySet
return es != null ? es : (entrySet = new EntrySet());
}
EntrySet是HashMap的内部类,会默认持有外部类HashMap的对象。定义如下:
private final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
// 重写了iterator方法
public Iterator<Map.Entry<K,V>> iterator() {
return newEntryIterator();
}
Iterator<Map.Entry<K,V>> newEntryIterator() {
return new EntryIterator();
}
}
看看EntryIterator的定义:
private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
public Map.Entry<K,V> next() {
return nextEntry();
}
}
EntryIterator 继承了HashIterator类型。HashIterator的定义如下:
private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // 下一个要返回的Entry
int expectedModCount; // For fast-fail
int index; // 当前table上下标
Entry<K,V> current; // 当前的Entry
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
// 遍历table,找到table数组的第一个有值的Entry设置为next属性
while (index < t.length && (next = t[index++]) == null)
;
}
}
public final boolean hasNext() {
return next != null;
}
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
//保存需要返回的Entry
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
// 如果遍历到table上单向链表的最后一个元素时
if ((next = e.next) == null) {
Entry[] t = table;
// 继续往下寻找table上有元素的下标
// 并且把下一个talbe上有单向链表的表头,作为下一个返回的Entry next
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
}
nextEntry方法的流程如下:
由上图可知,HashMap的遍历,是先遍历table,然后再遍历table上每一条单向链表,如上述的HashMap遍历出来的顺序就是Entry1、Entry2....Entry6,但显然,这不是插入的顺序,所以说:HashMap是无序的。
2.8 Key为null的处理逻辑
putForNullKey的方法如下:
private V putForNullKey(V value) {
// 遍历table[0]上的单向链表
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
// 如果有key为null的Entry,则替换该Entry中的value
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 如果没有key为null的Entry,则构造一个hash为0、key为null、value为真实值的Entry
// 插入到table[0]上单向链表的头部
addEntry(0, null, value, 0);
return null;
}
key为null的put过程,跟普通key值的put过程很类似,区别在于key为null的hash为0,存放在table[0]的单向链表。
再来看看对于key为null的取值方法getForNullKey:
private V getForNullKey() {
// 遍历table[0]上的单向链表
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
// 如果key为null,则返回该Entry的value值
if (e.key == null)
return e.value;
}
return null;
}
2.9 remove方法
HashMap提供了remove方法,根据key移除HashMap中对应的Entry
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
首先调用removeEntryForKey方法把key对应的Entry从HashMap中移除,然后把移除的值返回。
removeEntryForKey方法如下:
final Entry<K,V> removeEntryForKey(Object key) {
// 算出hash
int hash = (key == null) ? 0 : hash(key);
// 得到在table中的index
int i = indexFor(hash, table.length);
// 当前结点的上一个结点,初始为table[index]上单向链表的头结点
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--;
// 如果是table上的单向链表的头结点,则直接让把该结点的next结点放到头结点
if (prev == e)
table[i] = next;
else
// 如果不是单向链表的头结点,则把上一个结点的next指向本结点的next
prev.next = next;
// 空实现
e.recordRemoval(this);
return e;
}
// 没有找到删除的结点,继续往下找
prev = e;
e = next;
}
return e;
}
先根据key算出hash,然后根据hash得到在table上的index,再遍历talbe[index]的单向链表,这时候需要看要删除的元素是否就是单向链表的表头,如果是,则直接让table[index]=next,即删除了需要删除的元素;如果不是单向链表的头,那表示有前面的结点,则让pre.next = next,也删除了需要删除的元素。
2.10 线程安全问题
前面HashMap的put和get方法分析可得,put和get方法真实操作的都是Entry[] table这个数组,而所有操作都没有进行同步处理,所以HashMap是线程不安全的。如果想要实现线程安全,推荐使用ConcurrentHashMap。
3 总结
- HashMap是基于哈希表实现的,用Entry[]来存储数据,而Entry中封装了key、value、hash以及Entry类型的next
- HashMap存储数据是无序的
- hash冲突是通过拉链法解决的
- HashMap的容量永远为2的幂次方,有利于哈希表的散列
- HashMap不支持存储多个相同的key,且只保存一个key为null的值,多个会覆盖
- put过程,是先通过key算出hash,然后用hash算出应该存储在table中的index,然后遍历table[index],看是否有相同的key存在,存在,则更新value;不存在则插入到table[index]单向链表的表头,时间复杂度为O(n)
- get过程,通过key算出hash,然后用hash算出应该存储在table中的index,然后遍历table[index],然后比对key,找到相同的key,则取出其value,时间复杂度为O(n)
- HashMap是线程不安全的,如果有线程安全需求,推荐使用ConcurrentHashMap。
参考网址:https://www.jianshu.com/p/8f4f58b4b8ab