1 概述
Map是我们常使用得一种用于存放键值对的数据结构,现在我们来分析一下Map的实现类HashMap的具体实现,我们直到JDK1.8实现HashMap是使用数组+链表的数据结构,而JDK1.8为了提高查询效率改进了数据结构,加入了红黑树的数据结构来实现。现在在我们分析JDK1.8的HashMap之前先来看一下JDK1.7的实现。
2 JDK1.7实现
2.1 数据结构图
我们首先来看一下JDK1.7中HashMap的数据结构图。
JDK1.7的HashMap是基于数组和链表来实现的,并且我们可以看出,HashMap允许键值为空。
2.2 内部类
这里我们直接来看比较重要的内部类,Entry的实现,这个类主要是用于来存储键值的。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; //键
V value; //值
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
//构造方法
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
... ...
从上面的数据存储的实体类我们可以看出HashMap(jdk1.7)中的链表是单向链表,并且通过key的hash值来判断是否重复,至于具体是如何实现的,我们后面分析源码的时候讲解。
2.3 属性
//默认初始化化容量,即16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量,即2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态
static final Entry<?,?>[] EMPTY_TABLE = {};
//空的存储实体
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//实际存储的key-value键值对的个数
transient int size;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
//默认的threshold值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
从上面的注释我们可以看出,HashMap的初始容量为16,并且当我们的容量到达threshold(阀值 = 初始容量 * 负载因子)的时候就需要进行扩容,而扩容操作会进行rehash和数据复制,比较消耗性能,所以在我们使用HashMap的时候最好能够预估数据的大小,进行固定容量的初始化。
2.4 构造函数
上面我们分析了HashMap(JDK1.7)的属性,这里我们来看一下具体的构造函数。
HashMap(JDK1.7)拥有四个构造函数,但是这里面最基本的构造函数就只有一个,所以这里我们仅仅来看一个最基本的构造函数就可以了。
//通过初始容量和负载因子构造HashMap
public HashMap(int initialCapacity, float loadFactor) {
//检查参数有效性
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacityinitialCapacity");
//初始容量不能大于最大容量(2的30次方),如果大于则直接设置成最大容量。
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//参数有效性检查
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
init();
}
可以看出,HashMap(JDK1.7)的初始化其实就是设置负载因子和阀值。
从上面这段代码我们可以看出,在常规构造器中,并没有马上为数组table分配内存空间(有一个入参为指定Map的构造器例外),事实上是在执行第一次put操作的时候才真正构建table数组。
2.5 核心函数
针对HashMap(JDK1.7)的函数分析,我们这里就不分析所有函数了,仅仅分析比较常用的get和put函数。
2.5.1 put函数
首先我们来看一下这个函数的调用顺序。
首先我们来看一下put函数的源码:
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);//分配数组空间
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
//对key的hashcode进一步计算,确保散列均匀
int hash = hash(key);
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);//调用value的回调函数,其实这个函数也为空实现
return oldValue;
}
}
//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
modCount++;
addEntry(hash, key, value, i);//新增一个entry
return null;
}
HashMap(JDK1.7)一开始并不分配内存空间,当第一次向HashMap(JDK1.7)中存放数据的时候才初始化容器,分配内存空间。
我们来看一看是如何初始化的table数据的。
private void inflateTable(int toSize) {
//capacity一定是2的次幂
int capacity = roundUpToPowerOf2(toSize);
//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//分配空间
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);//选择合适的Hash因子
}
我们通过roundUpToPowerOf2函数可以保证capacity为大于或等于toSize最接近toSize的二的次幂。
在对数组进行空间分配完成之后,将计算key的hash值。最后又根据hash值和table的大小来求得添加的值在table中的实际位置,我们来看看这两部的操作。
//用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {//这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
我们可以看见上面的操作采用了大量的位运算,这是因为位运算的效率要高很多。获得key的hash值后,我们就需要取得数组下标值。
//返回数组下标
static int indexFor(int h, int length) {
//保证获取的index一定在数组的范围内
return h & (length-1);
}
得到存储位置后,就会调用addEntry函数来将键值对加入HashMap(JDK1.7)中。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
}
//把元素放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
//创建元素
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++;//元素个数+1
}
通过上面的源码我们知道当发生hash冲突(通过key计算出来的index位置的数组已经存在值)并且size大于等于阀值的时候就要进行扩容。扩容数组的长度将变成原数组长度的二倍,并且进行原数组所有数据的复制,所以可以看出扩容是一个相当耗费性能的操作,所有我们尽量在初始化HashMap(JDK1.7)的时候能够指定其容量大小。
上面就是对put函数的分析,接下来我们看一看在HashMap(jdk1.7)扩容的过程中做了什么。
//按新的容量扩容Hash表
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];
transfer(newTable, initHashSeedAsNeeded(newCapacity));//将老的表中的数据拷贝到新的结构中
table = newTable;//修改HashMap的底层数组
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阀值
}
从上面看出扩容其实就做了两件事:修改数组大小,复制数据。
通过源码可以发现,hashMap的数组长度一定保持2的次幂,这样做有什么好处呢?
如果length为2的次幂,其二进制表示就是100….0000;则length-1 转化为二进制必定是0111….11的形式,在于h的二进制与操作效率会非常的快,而且空间不浪费;如果length不是2的次幂,比如length为15,则length-1为14,对应的二进制为1110,再于h与操作,
最后一位都为0,所以0001,0011,0101,1001,1011,0111,1101这几个位置永远都不会存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
如果length为2的次幂,其二进制表示就是100….0000;则length-1 转化为二进制必定是0111….11的形式,在于h的二进制与操作效率会非常的快,而且空间不浪费;如果length不是2的次幂,比如length为15,则length-1为14,对应的二进制为1110,再于h与操作,
最后一位都为0,所以0001,0011,0101,1001,1011,0111,1101这几个位置永远都不会存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
2.5.2 get函数
上面我们分析了put函数,现在我们就来看一看get函数的源码到底是怎么回事。
//获取key值为key的元素值
public V get(Object key) {
if (key == null)//如果Key值为空,则获取对应的值,这里也可以看到,HashMap允许null的key,其内部针对null的key有特殊的逻辑
return getForNullKey();
Entry<K,V> entry = getEntry(key);//获取实体
return null == entry ? null : entry.getValue();//判断是否为空,不为空,则获取对应的值
}
//获取key为null的实体
private V getForNullKey() {
if (size == 0) {//如果元素个数为0,则直接返回null
return null;
}
//key为null的元素存储在table的第0个位置(所以在table 0 及其链表上查找)
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)//判断是否为null
return e.value;//返回其值
}
return null;
}
我们可以看出最终需要调用getEntry来获取Entry实体,可以想到在这个函数里面才是get函数实现的重点。
//获取键值为key的元素
final Entry<K,V> getEntry(Object key) {
if (size == 0) {//元素个数为0
return null;//直接返回null
}
int hash = (key == null) ? 0 : hash(key);//获取key的Hash值
for (Entry<K,V> e = table[indexFor(hash, table.length)];//根据key和表的长度,定位到Hash桶
e != null;
e = e.next) {//进行遍历
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//判断Hash值和对应的key,合适则返回值
return e;
}
return null;
}
从上面我们可以看出判断值是否是需要寻找的值,主要是依据hash和key是否相同。
2.6 总结
通过上面对put和get的分析,我们看出HashMap(jdk1.7)充分使用了hash散列来使数据均布,并且在查询数据的时候也使用了hash值来加入判断(是否获取到)。
而采用数组和链表的结构主要就是降低了复杂度。我们知道数组的查询复杂度位O(1),而插入和删除的时候,由于要移动所有的元素,所以复杂度位O(N)。而针对链表,插入的时候复杂度位O(1),而查询的时候复杂度位O(N)。所以我们将这两种数据结构组合起来,可以提高查询和插入的效率。