Java集合------HashMap底层原理(1.7)
前言
在java集合中,Map是一种特殊的集合,原因在于这种集合容器并不是保存单个元素,而是保存一个一个的Key-Vaue键值对.HashMap是基于哈希表的Map接口的实现,在项目开发中使用广泛,下面就对HashMap的源码进行解析.
正文
HashMap的特点
- HashMap是基于哈希表的Map实现.
- HashMap底层采用的是Entry数组(1.7)和链表实现.
- HashMap是采用key-value形式存储,其中key是可以允许为null,但是只能有一个,并且key不能重复.
- HashMap是线程不安全的.
- HashMap存入数据的顺序和遍历的顺序有可能是不一样的.*
在HashMap中存在很多的方法,在此我们只对添加、删除、遍历等方法进行解析.以便了解其原理.
HashMap的数据结构
在数据结构中,有数组和链表来实现对数据的存储,但这两者基本上是两个极端.
- 数组: 数组储存区间是连续的,占用内存严重,故空间复杂度很大.但数组的二分查找时间复杂度小,为O(1);数组的特点是寻址容易,插入和删除困难
- 链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N).链表的特点是寻址困难,插入和删除容易
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是哈希表,哈希表即满足了数据查找的方便,同时不占用太多的内存空间,使用也十分方便.
HashMap底层使用的就是哈希表.
HashMap实际上是一个"链表"的数组,每个数组中的元素存放链表的头结点,在每一个头结点的中,包含着下一个节点的地址,即数组和链表的结合体.
更为详细的结构如下:
从上图可以看到,HashMap底层就是一个数组结构,数组中的每一项又是一个链表.
HashMap源码
在这里贴上HashMap的部分源码:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable{
//初始化桶大小,也就是数组的大小,默认大小为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//桶的最大值
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//存放数据的数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//存储key-value键值对的个数
transient int size;
//桶大小,在初始的时候可以显式指定(一定是2的次幂)
int threshold;
//负载因子,初始化时可以显式指定
final float loadFactor;
//修改次数,每次map集合变动一次,就加1
transient int modCount;
//真正存放数据的entry内部类
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...省略其他
}
相关的字段释义已经写在注释.
在这里,我们看一下这个内部类Map.entry:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...省略其他
}
- key其实就是写入数据的键
- value是指
- next变量正是实现链表结构的关键
- hash存放的是当前key的hashcode
到这里,我们应该有个概念:当我们创建了一个HashMap的时候,其实是创建了一个数组,此数组的默认大小是16,数组中的每一项都叫一个桶,数组中保存的数据正是Map.entry对象,它内部包含一个key-value键值对,还持有一个指向下一个元素的引用,还有他的key运算后的hashcode值.
再换句话说:
当系统开始初始化HashMap的时候,系统会创建一个长度为capacity 的Entry数组,这个数组里可以存储元素的位置被称为"桶(bucket)",每个桶都有自己的索引,系统可以根据这个索引快速访问该bucket里的元素.
无论何时,HashMap的每个桶只存储一个元素(一个Entry),由于Entry对象可以包含一个引用,用于指向下一个Entry,所以会出现这种情况:HashMap的桶中只有一个Entry,但是这个Entry指向另一个Entry,这样就形成了一个Entry链.
HashMap的构造函数
HashMap提供了四个构造函数:
HashMap()
:构造一个具有默认初始容量(16)和默认加载因子(0.75)的空HashMap
HashMap(int initialCapacity)
:构造一个带指定初始容量和默认加载因子的空HashMap.
HashMap(int initialCapacity, float loadFactor)
:构造一个带指定初始容量和指定负载因子的空HashMap.
HashMap(Map<? extends K, ? extends V> m)
:根据指定的map集合创建一个HashMap.
这里我们来看一下它两个参数的构造函数:
public HashMap(int initialCapacity, float loadFactor) {
//如果初始容量小于0,则抛出一个异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果指定的初始化大小大于最大值,则将容量置为最大值.
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//如果负载因子不是数字或者小于等于0,抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//让当前map的容器大小和加载因子等于指定的值
this.loadFactor = loadFactor;
threshold = initialCapacity;
//初始化方法,在HashMap中没有实现,其子类有具体实现.
init();
}
我们看到,在初始化的时候,没有为table数组分配内存空间,而是在put操作的时候才真正构建table数组.
初始容量和负载因子
在HashMap的属性中,有两个参数:初始容量,负载因子.
这两个参数是影响HashMap性能的重要参数.
其中容量表示哈希表中桶的数量,也就是数组的长度.初始容量是创建哈希表时的容量,如果不指定,默认是16.
加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它可以衡量一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之越小.
查看put方法的源码可知:当哈希表中数据的数量超出了当前容量*加载因子时,对该HashMap进行扩容,将容量扩充至两倍的桶数
HashMap的put方法
public V put(K key, V value) {
//如果table数组为空{},则为table初始化分配内存空间,入参为threshold,默认是16
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,保存null于table的第一个位置,也就是table[0]
if (key == null)
return putForNullKey(value);
//根据ket计算出hash值
int hash = hash(key);
//计算出该key所应该保存的桶位置,也就是在数组table中的位置
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//遍历该索引位置的桶链表,如果存在相同的key,用老值替换新值,返回老值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次数增加1
modCount++;
//根据key-value新增一个Entry对象写入当前位置
addEntry(hash, key, value, i);
return null;
}
通过源码我们可以很清晰的看到put方法的执行逻辑:
- 首先判断HashMap中的table表是不是为空,如果为空,调用
inflateTable(threshold)
方法为table分配内存空间 - 然后判断key是否为空,如果key为空,则调用
putForNullKey(value)
方法,将value放在数组的第一个位置上. - 若key不为空,则根据
hash(key)
方法计算出hash值,然后根据hash值,得到这个元素在table数组中的位置(下标),如果table在该位置已经存放了其他元素,则通过比较是否存在相同key,若存在则覆盖原来key的value,否则将该元素保存在链头. - 若table所在该处没有元素,那就直接将该元素放到此数组中的该位置上.
至此完成来了put方法的全过程.这个过程看似只有这么几个步骤,其实其中还有很多的东西,我们一个一个来分析.
首先我们看看inflateTable方法:
private void inflateTable(int toSize) {
//这里的capacity是桶大小,threshold是容量阈值,如果桶
//大小是16,那么容量超过16*0.75=16的时候,就要扩容了
//roundUpToPowerOf2方法确保桶大小是2的次幂,
//比如toSize=13,则capacity=16;
//to_size=16,capacity=16;to_size=17,capacity=32
int capacity = roundUpToPowerOf2(toSize);
//为容量赋值,在capacity * loadFactor和MAXIMUM_CAPACITY + 1中取一个小值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//初始化table数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
其次,因为在迭代map的逻辑中的判断,如果存在相同的key,则覆盖value,这就使得HashMap的不重复,要注意的是,这里判断key是否相同的逻辑:先比hashCode是否相同,hashCode相同再判断equals是否为true,这样就大大增加了效率.
我们再来看看一个比较重要的方法:hash(key)
/**
*这个方法中运用了很多的位运算、异或等方法,其目的是为了
*对于不同的key,获得一个唯一的数字值
*/
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);
}
关于详细的为什么要这样计算可以参考hash方法原理
计算出hash值以后,会调用indexFor()
方法来获得该数据在数组中的位置下标.我们知道对于HashMap的table而言,数据分布需要均匀(最好每一项都有元素),不能太紧也不能太松,太紧会导致查询慢,且hash碰撞的几率变大(不知道hash碰撞的同学自行百度),太松则浪费空间,所以怎么保证table元素分布均匀呢?那就是取模,在有些版本的源码中,indexFox方法是直接取模的,但是后来优化以后就变成了下面这样:
static int indexFor(int h, int length) {
return h & (length-1);
}
我们知道,HashMap的数组长度在roundUpToPowerOf2()方法的调用下,确保是2的次幂(在后面会详细讲为什么是2的n次方),当length为2的n次方时,h&(length-1)
就相当于对length取模,也就是h%length,但是&比%运算要快的多,这是HashMap在效率上的优化.
最后,调用addEntry()
方法在指定位置保存数据:
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果map中的key-value键值对的数量大于等于阈值,
//并且当前位置不为空时,数组需要进行2倍扩容.
//扩容以后重新计算hash以及在数组中的位置
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
//创建一个entry对象,并将原来的entry引用赋给新entry
createEntry(hash, key, value, bucketIndex);
}
在这里有两点:
- 正是因为在
createEntry
方法中,包含了旧的entry对象引用,才会产生entry链,这也是链形结构产生的原因. - 随着HashMap中元素的越来越多,发生碰撞的概率就越来越大,所以产生的链表就越来越长,所以在该方法总,如果容量超过了阈值,就会调用
resize
方法进行扩容,具体的扩容包含新数组的创建,数据的复制,是非常耗性能的,因此在使用的时候能预估HashMap的大小是最好的.
以上就是对HashMap中put方法的源码分析,在这里,我们再来总结一下:
当程序试图将一对key-value放入HashMap中时,首先会根据key的hash值计算出该Entry对象的存储位置,如果两个Entry的key的hash值计算后的返回值相同,那么他们存储的位置也是相同的.如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部。
为什么HashMap的数组长度一定是2的n次方?
关于这个问题感兴趣的小伙伴可以查看以下博文进行扩展:
为什么HashMap的负载因子默认是0.75?
关于这个问题感兴趣的小伙伴也可以看看下面这个文章…
HashMap的get方法
public V get(Object key) {
//如果key为null,直接去table[0]处检索.
if (key == null)
return getForNullKey();
//查找出map中对应key的entry对象
Entry<K,V> entry = getEntry(key);
//如果value不存在就返回null,否则返回对应的value
return null == entry ? null : entry.getValue();
}
在具体的getEntry()
方法中:
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//计算出key对应的hash值
int hash = (key == null) ? 0 : hash(key);
//获取最终数组中的索引,遍历链表,通过equals方法找出对应的entry对象返回.
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;
}
get方法相对比较简单:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。
总结
本文重点对HashMap的put方法和get方法进行了分析,尤其是put方法的理解,能帮助我们理解HashMap容器的底层原理.希望这篇文章能帮助各位同行,同时也欢迎讨论指正~~.