字面上看,HashMap由Hash和Map两个单词组成,Map表示映射关系,是一个接口,实现Map接口有多种方式,HashMap实现的方式利用了Hash。本文先分析Map接口,接着分析HashMap实现原理,最后总结分析HashMap的特点。
目录
Map接口
HashMap实现原理
内部结构
构造方法
保存键值对
图解HashMap
get方法
删除键值对
HashMap性能
Map接口
Map有键和值的概念。一个键映射到一个值,Map按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。使用Map可以方便地处理需要根据键访问对象的场景。
数组、ArrayList、LinkedList可以视为一种特殊的Map,键为索引,值为对象。
Map接口代码如下。Java 8增加了一些默认方法,如getOrDefault、forEach、replaceAIl、putlfAbsent、replace、computelfAbsent、merge等,Java9增加了多个重载的of方法,可以方便地根据一个或多个键值对构建不变的Map。
public interface Map<K, V> {
V put(K key, V value);
V get(Object key);
V remove(Object key);
int size();
boolean isEmpty();
boolean containsKey(Object key);
boolean containsValue(Object value);
void putAll(Map<? extends K, ? extends V> m);
void clear();
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
interface Entry<K, V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
boolean equals(Object o);
int hashCode();
}
Set是一个接口,表示的是数学中的集合概念,即没有重复的元素集合。Java 中的Set扩展了Collection,但没有定义任何新的方法,不过,它要求所有实现者都必须确保Set的语义约束,即不能有重复元素。
Map中的键是没有重复的,所以ketSet()返回了一个Set。keySet()、values()、entrySet()有一个共同的特点,它们返回的都是视图,不是复制的值,基于返回值的修改会直接修改Map自身。
HashMap实现原理
内部结构
HashMap内部有如下几个主要的实例变量:
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;
int threshold;
final float loadFactor;
size表示实际键值对的个数。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(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
其中,key和value分别表示键和值,next指向下一个Entry 节点,hash是key的hash值,待会我们会介绍其计算方法。直接存储hash值是为了在比较的时候加快计算。
table的初始值EMPTY_TABLE,是一个空表,具体定义:
static final Entry<?,?>[] EMPTY_TABLE = {};
当添加键值对后,table就不是空表了,它会随着键值对的添加进行扩展,扩展的策略类似于
ArrayList。添加第一个元素时,默认分配的大小为16,不过,并不是size大于16时再进行扩展,下次什么时候扩展与threshold有关。
threshold表示阈值,当键值对个数size大于等于threshold时考虑进行扩展。threshold是怎么算出来的呢?一般而言,threshold等于table length乘以loadFactor。比如,如果table.length为16,loadFactor为0.75,则threshold为12。loadFactor是负载因子,表示整体上table被占用的程度,是一个浮点数,默认为0.75,可以通过构造方法进行修改。
构造方法
HashMap默认构造方法的代码为:
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
DEFAULT_IINITIAL_CAPACITY 为16,DEFAULT_LOAD_FACTOR 0.75,默认构造方法调用的构造方法主要代码为:
public HashMap(int initialCapacity, float loadFactor) {
this.loadFactor = loadFactor;
threshold = initialCapacity;
}
保存键值对
下面,我们来看HashMap是如何把一个键值对保存起来的,代码为:
public V put(K key, V value) {
if(table == EMPTY_TABLE) {
inflateTable(threshold);
}
if(key == null)
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(hash, key, value, i);
return null;
}
如果是第一次保存,首先调用inflateTable()方法给table分配实际的空间,inflateTable的主要代码为:
private void inflateTable(int toSize) {
//Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
}
默认情况下,capacity的值为16,threshold会变为12,table会分配一个长度为16的Entry数组。接下来,检查key是否null,如果是,调用putForNullKey单独处理,我们暂时忽略这种情况。在key不为null的情况下,下一步调用hash方法计算key的hash值。hash方法的代码为:
final int hash(Object k) {
int h = 0
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
基于key自身的hashCode方法的返回值又进行了一些位运算,目的是为了随机和均匀性。有了hash值之后,调用indexFor方法,计算应该将这个键值对放到table的哪个位置,代码为:
static int indexFor(int h, int length) {
return h & (length-1);
}
HashMap中,length 为2的幂次方,h& (length-1)等同于求模运算h%length。找到了保存位置i,table[i]指向一个单向链表。接下来,就是在这个链表中逐个查找是否已经有这个键了,遍历代码为:
for (Entry<K,V> e = table[i]; e != null; e = e.next)
而比较的时候,是先比较hash值,hash相同的时候,再使用equals方法进行比较,代码为:
if(e.hash == hash && ((k = e.key) == key || key.equals(k)))
为什么要先比较hash呢?因为hash是整数,比较的性能一般要比equals高很多,hash不同,就没有必要调用equals方法了,这样整体上可以提高比较性能。如果能找到,直接修改Entry中的value即可。modCount++的含义与ArrayList和LinkedList中介绍一样,为记录修改次数,方便在迭代中检测结构性变化。如果没找到,则调用addEntry方法在给定的位置添加一条,代码为:
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果哈希表的大小超过阈值并且指定的桶索引位置已经存在元素,则进行扩容
if (size >= threshold && table[bucketIndex] != null) {
resize(2 * table.length); // 对哈希表进行扩容
hash = (key != null) ? hash(key) : 0; // 计算哈希值
bucketIndex = indexFor(hash, table.length); // 计算新的桶索引
}
// 创建条目,传入哈希值、键、值和桶索引作为参数
createEntry(hash, key, value, bucketIndex);
}
如果空间是够的,不需要resize,则调用createEntry方法添加。createEntry的代码为:
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++;
}
代码比较直接,新建一个Entry对象,插入单向链表的头部,并增加size。如果空间不够,即size已经要超过阈值threshold了,并且对应的table位置已经插入过对象了,具体检查代码为:
if((size >= threshold) && (null != table[bucketIndex]))
则调用resize方法对table进行扩展,扩展策略是乘2,resize的主要代码为:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
分配一个容量为原来两倍的Entry数组,调用transfer方法将原来的键值对移植过来,然后更新内部的table变量,以及threshold的值。参数rehash一般为false。这段代码遍历原来的每个键值对,计算新位置,并保存到新位置。transfer方法的代码为:
void transfer(Entry<K, V>[] 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;
}
}
}
以上就是保存键值对的主要代码,简单总结一下,基本步骤为:
1)计算键的哈希值
2) 根据哈希值得到保存位置(取模)
3) 插到对应位置的链表头部或更新已有值
4) 根据需要扩展table大小
图解HashMap
在通过new HashMap()创建一个对象后,内存中的结构如图
接下来执行保存键值对的代码,“hello"的hash值为96207088,模16的结果为0,所以插入table[0]指向的链表头部,内存结构变为下图
"world”的hash值为111207038,模16结果为14,所以保存完"world"后,内存结构如图
"position”的hash值为771782464,模16结果也为0,table[0]已经有节点了,新节点会插到链表头部,内存结构变为如图所示
get方法
HashMap根据键获取值的get方法的代码为:
public V get(Object key) {
if(key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
HashMap支持key为null,key为null的时候,放在table[O],调用getForNullKey()获取值;如果key不为null,则调用getEntry()获取键值对节点entry,然后调用节点的getValue()方法获取值。getEntry方法的代码是:
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) {
K k;
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
return e;
}
}
return null;
}
代码首先计算键的哈希值,然后根据哈希值找到哈希表中对应的链表。接着,在链表中遍历查找与给定键匹配的条目,通过快速比较哈希值和逐个比较键值来确定是否找到匹配的条目。
删除键值对
根据键删除键值对的代码为:
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return(e == null ? null : e.value);
}
removeEntryForKey代码为:
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;
}
首先计算hash,根据hash找到对应的table索引,接着遍历table[i],查找待删节点,使用变量prev指向前一个节点,next指向后一个节点,e指向当前节点。然后判断是否找到,依然是先比较hash值,hash值相同时再用equals方法比较。删除的逻辑就是让长度减小,然后让待删节点的前后节点链起来,如果待删节点是第一个节点,则让table i直接指向后一个节点。
HashMap性能
1)根据键保存和获取值的效率都很高,为O(1),每个单向链表往往只有一个或少数几个节点,根据hash值就可以直接快速定位;
2) HashMap中的键值对没有顺序,因为hash值是随机的。
如果经常需要根据键存取值,而且不要求顺序,那么HashMap就是理想的选择。如果要保持添加的顺序,可以使用HashMap的一个子类LinkedHashMap。Map还有一个重要的实现类TreeMap,它可以排序。
需要说明的是,HashMap不是线程安全的,Java中还有一个类Hashtable,它是Java最早实现的容器类之一,实现了Map接口,实现原理与Hashap类似,但没有特别的优化,它内部通过synchronized实现了线程安全。在HashMap中,键和值都可以为null,而在Hashtable中不可以。在不需要并发安全的场景中,推荐使用HashMap。在高并发的场景中,推荐使用ConcurrentHashMap。