本文来自:我的博客,原文地址:https://blog.csdn.net/silentljh/article/details/80444216,转载请注明。
HashMap可以说是Java中最常用的集合类框架之一,是Java语言中非常典型的数据结构,我们总会在不经意间用到它,很大程度上方便了我们日常开发。
注:以下分析全部基于JDK1.7,不同版本之间会有较大的改动,读者需要注意。
HashMap概述
HashMap是一种基于哈希表实现的Map,它通过键的hashCode来快速的存取元素
HashMap允许插入null键和null值,允许多条记录的值为null,但只允许一条记录的键为null
HashMap不是线程安全的,在并发环境下,可能会引起死循环
HashMap中的元素是无序的,无法保证遍历时的顺序是固定不变的
HashMap在不考虑哈希碰撞的情况下,插入和查询的时间复杂度可以达到O(1)
HashMap的数据结构
HashMap是基于哈希表实现的,哈希表是由数组和链表共同构成的一种结构,其中数组保存着每个单向链表的头结点,链表保存着具有相同hash值的不同元素,它是用来解决哈希冲突(Hash Collision)的。一个好的哈希函数应该尽量使元素在数组中均匀分布,减少哈希冲突,从而缩短链表的长度。链表的长度越长,意味着在查找时需要遍历的结点越多,哈希表的性能也就越差。
HashMap中的数组是一个Entry数组,数组存放的每个Entry都是单向链表的头结点。HashMap使用Entry类来表示Key-Value型的元素,一个Entry对象就是一个键值对,里面包含了key,value,hash值以及指向下一个Entry对象的引用。
static class Entry
final K key;
V value;
Entry
int hash; // 元素的hash, 其实就是key的hash值
}
HashMap源码剖析
1、常量和属性解析
// 默认初始化容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap允许的最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载率 75%
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 空的哈希表
static final Entry, ?>[] EMPTY_TABLE = {};
// 实际使用的哈希表
transient Entry
// HashMap的大小,即存储的key-value的数量
transient int size;
// 扩容的阀值,当HashMap的size达到阀值时,就开始扩容 threshold=length*threshold
int threshold;
// 负载率
final float loadFactor;
// 修改次数, 用于fail-fast机制
transient int modCount;
// 替代哈希使用的默认扩容阀值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
// 随机的哈希种子, 有助于减少发生哈希碰撞的几率
transient int hashSeed = 0;
2、构造方法
HashMap提供了一下4种构造方法, 但最终都会调用HashMap(int initialCapacity, float loadFactor)方法。如果使用无参构造函数创建HashMap,其内部是通过调用HashMap(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR)实现。下面我们来分析一下这个构造方法。
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);
}
// 设置负载率
this.loadFactor = loadFactor;
// 设置阀值为初始容量
threshold = initialCapacity;
// 空实现, 交由子类实现
init();
}
看完这个方法,我们发现这个构造方法并没有根据传入的initialCapacity去新建一个Entry数组,此时的哈希表依然是一个空表。HashMap在构造时不会新建Entry数组,而是在put操作时会先检查当前哈希表是否是个空表,如果是空表就调用inflateTable方法进行初始化。上面贴出了这个方法的代码,可以看到方法内部会重新计算Entry数组的容量,因为在构造HashMap时传入的初始化大小可能不是2的幂,因此要将这个数转换成2的幂再去根据新的容量新建Entry数组。初始化哈希表时再次重新设置阀值,阀值一般是capacity*loadFactor。此外,在初始化哈希表时还会去初始化hashSeed,这个hashSeed用于优化哈希函数,默认为0是不使用替代哈希算法,但是也可以自己去设置hashSeed的值,以达到优化效果。具体下面会讲到。
3、put方法
public V put(K key, V value) {
// 如果哈希表没有初始化就进行初始化
if (table == EMPTY_TABLE) {
// 初始化哈希表
inflateTable(threshold);
}
// 当key为null时,调用putForNullKey方法,保存null于table的第一个位置中,这是HashMap允许为null的原因
if (key == null) {
return putForNullKey(value);
}
// 计算key的hash值
int hash = hash(key);
// 根据key的hash值和数组的长度定位到entry数组的指定槽位
int i = indexFor(hash, table.length);
// 获取存放位置上的entry,如果该entry不为空,则遍历该entry所在的链表
for (Entry
Object k;
// 通过key的hashCode和equals方法判断,key是否存在, 如果存在则用新的value取代旧的value,并返回旧的value
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不存在,则创建一个新的Entry,并添加到HashMap中
addEntry(hash, key, value, i);
return null;
}
put方法执行流程:
检查哈希表是否是个空表,如果是空表就调用inflateTable方法进行初始化
判断key是否为null,如果为null,就调用putForNullKey方法, 将key为null的key-value存储在哈希表的第一个位置中
如果key不为null,则调用hash方法计算key的hash值
根据hash值和Entry数组的长度定位到Entry数组的指定槽位i
判断Entry数组指定槽位的值e是否为null, 如果e不为null, 则遍历e指向的单链表, 如果传入的key在单链表中已经存在了, 就进行替换操作, 否则就新建一个Entry并添加到单链表的表头位置
如果e为null, 就新建一个Entry并添加到指定槽位
从put方法的执行流程我们发现, 在发生哈希碰撞的情况下, 插入key-value会遍历指定槽位的单链表, 如果key已经存在于单链表中了, 就会用value覆盖旧的值, 如果key不存在, 则会将key-value插入单链表的表头. 基于这个逻辑, put方法的算法复杂度就从O(1)变成了O(n), 因此优化hash函数, 减少哈希碰撞的发生, 就可以使得put方法的算法复杂度接近O(1).
4、get方法
public V get(Object key) {
// 如果key为null,调用getForNullKey方法从entry数组第一个位置获取key对应的value
if (key == null) {
return getForNullKey();
}
Entry
return null == entry ? null : entry.getValue();
}
final Entry
if (size == 0) {
return null;
}
// 计算hash值
int hash = (key == null) ? 0 : hash(key);
// 通过hash值和数组长度计算出Entry数组的指定槽位
for (Entry
Object k;
// 通过hash值和equals判断key是否存在,如果存在则返回对应的value
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { return e; }
}
return null;
}
get方法执行流程:
判断key是否为null,如果为null,就调用getForNullKey方法, 从哈希表的第一个位置获取
如果key不为null,调用hash方法计算key的Hash值
根据hash值和Entry数组的长度定位到Entry数组的指定槽位i
判断Entry数组指定槽位的值e是否为null, 如果是则返回null
如果e不为null, 则遍历e指向的单链表, 如果传入的key在单链表中已经存在了,
5、哈希表是如何初始化的?
private void inflateTable(int toSize) {
// 寻找大于toSize的,最小的,2的n次方作为新的容量
int capacity = roundUpToPowerOf2(toSize);
// 阀值=容量*负载因子, 如果容量*负载因子>最大容量时, 阀值=最大容量
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 按新的容量创建一个新的数组
table = new Entry[capacity];
// 重新初始化hashSeed
initHashSeedAsNeeded(capacity);
}
6、HashMap是如何通过key的hash值来定位到Entry数组的指定槽位的?(size为什么必须是2的整数次幂?)
static int indexFor(int h, int length) {
// 对hash值和length-1进行与运算来计算索引
return h & (length - 1);
}
indexFor方法是根据hash值和entry数组的长度来计算出在数组中对应的下标。我们可以看到在这个方法内部使用了与(&)操作符。与操作是对两个操作数进行位运算,如果对应的两个位都为1,结果才为1,否则为0。与操作经常用于去除操作数的高位值,例如:01011010 & 00001111 = 00001010。我们继续回到代码中,看看h&(length-1)做了些什么。
已知传入的length是Entry数组的长度,我们知道数组下标是从0开始计算的,所以数组的最大下标为length-1。如果length为2的幂,那么length-1的二进制位后面都为1。这时h&(length-1)的作用就是去掉了h的高位值,只留下h的低位值来作为数组的下标,h的低位值肯定不会比length-1大,所以可以保证数组不会越界。由此可以看到Entry数组的大小规定为2的幂就是为了能够使用这个算法来确定数组的下标。
7、哈希函数是怎样计算Hash值的?
final int hash(Object k) {
int h = hashSeed;
// 如果hashSeed不为0且key是字符串对象,则调用系统内部提供的hash算法计算hash值
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值的算法,计算hash值的算法被称为扰动函数,所谓的扰动函数就是把所有东西杂糅到一起,可以看到这里使用了四个向右移位运算。目的就是将h的高位值与低位值混合一下,以此增加低位值的随机性。在上面我们知道定位数组的下标是根据hash值的低位值来确定的。key的hash值是通过hashCode方法来生成的,而一个糟糕的hashCode方法生成的hash值的低位值可能会有很大的重复。为了使得hash值在数组上映射的比较均匀,扰动函数就派上用场了,把高位值的特性糅合进低位值,增加低位值的随机性,从而使散列分布的更加松散,以此提高性能。下图举了个例子帮助理解。
8、HashMap在什么时候判断扩容?是怎么进行扩容的?
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果键值对的总数大于等于阀值,且当前要插入的key-value没有发生hash碰撞,则进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容到原来容量的两倍
resize(2 * table.length);
// 扩容后重新计算hash值
hash = (null != key) ? hash(key) : 0;
// 扩容后重新确定Entry数组的槽位
bucketIndex = indexFor(hash, table.length);
}
// 创建一个Entry对象,并添加到Entry数组的指定位置中
createEntry(hash, key, value, bucketIndex);
}
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];
// initHashSeedAsNeeded(newCapacity) 确定是否重新进行hash计算
// 将旧数组中的元素逐个重新计算hash和index,然后全部转移到新的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
// 将阀值设置为新的容量*负载率
threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
在调用put方法添加一个键值对时,如果集合中没有存在的key就去调用addEntry方法新建一个Entry。看到上面贴出的addEntry代码,在新建一个Entry之前会先判断当前集合元素的大小是否超过了阀值,如果超过了阀值就调用resize进行扩容。传入的新的容量是原来哈希表的两倍,在resize方法内部会新建一个容量为原先的2倍的Entry数组。然后将旧的哈希表里面的元素全部迁移到新的哈希表,其中可能会对旧的元素重新进行哈希运算,根据initHashSeedAsNeeded方法计算的值来确定是否重新计算哈希。完成哈希表的迁移之后,将当前哈希表替换为新的,最后再根据新的哈希表容量来重新计算HashMap的阀值。由此可见,HashMap的扩容操作时非常低效的,我们在创建HashMap对象时,可以先预估一下容量,然后指定一个初始容量,来减少扩容的频率,提高程序运行的效率
9、替代哈希是怎么回事?
hash方法中首先会将hashSeed赋值给h。这个hashSeed就是哈希种子,它是一个随机的值,作用就是帮助优化哈希函数。hashSeed默认是0,也就是默认不使用替代哈希算法。那么什么时候使用hashSeed呢?首先需要设置开启替代哈希,在系统属性中设置jdk.map.althashing.threshold的值,在系统属性中这个值默认是-1,当它是-1的时候使用替代哈希的阀值为Integer.MAX_VALUE。这也意味着可能你永远也不会使用替代哈希了。当然你可以把这个阀值设小一点,这样当集合元素达到阀值后就会生成一个随机的hashSeed,以此增加hash函数的随机性。为什么要使用替代哈希呢?当集合元素达到你设定的阀值之后,意味着哈希表已经比较饱和了,出现哈希冲突的可能性就会大大增加,这时对再添加进来的元素使用更加随机的散列函数能够使后面添加进来的元素更加随机的分布在散列表中。
10、Fail-Fast机制:
我们知道HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。
private abstract class HashIterator
Entry
int expectedModCount; // For fast-fail
int index; // current slot
Entry
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
public final boolean hasNext() {
return next != null;
}
final Entry
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
public void remove() {
if (current == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Object k = current.key;
current = null;
HashMap.this.removeEntryForKey(k);
expectedModCount = modCount;
}
}
modCount是volatile变量,保证了线程之间修改的可见性。在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map。
在HashMap的API中指出:
由所有HashMap类所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对Map进行修改,除了通过迭代器本身的 remove 方法之外,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不承担在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误
11、HashMap在并发环境下有可能会出现死循环是怎么回事?
HashMap是线程不安全的,如果被多个线程共享的操作,将会引发不可预知的问题,据sun的说法,在扩容时,会引起链表的闭环,在get元素时,就会无限循环,后果是cpu100%。
https://coolshell.cn/articles/9606.html/comment-page-1
12、key为Null的键值对是如何存储和查找的?
13、为什么HashMap常用String, Integer对象作为Key
14、如何正确使用HashMap?
a.不要在并发场景中使用HashMap,如果要使用,请换成CurrentHashMap
b.最好在初始化时,给HashMap设定一个合理的容量值
本文来自:我的博客,原文地址:https://blog.csdn.net/silentljh/article/details/80444216,转载请注明。
————————————————
版权声明:本文为CSDN博主「Jeffrey0527」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/silentljh/article/details/80444216