HashMap(1.7之前?)底层由数组+链表实现
如果发生哈希冲突时,插入链表时使用头插法,因为一般认为后插入的元素被查找的可能性更大
HashMap的初始长度为16,且每次自动扩容或手动指定初始化长度必须为2的整数次幂
这是因为计算做key的hash运算时能更好的平均分布
公式为:index = HashCode(Key) & (Length - 1)length为HashMap长度 &运算:全为1为1,其余为0
例:HashCode为1001,若长度为16,二进制位1111,做与运算为1001,结果为9
HashCode为1101,若长度为16,二进制位1111,做与运算为1101,结果为13
结果完全取决于HashCode的值
若长度为10,1001&1001,结果为9
1111&1001,结果还是9,所以结果不均匀,有一些数组索引位置上永远不会有值!
HashMap的几个重要字段:如下
实际存储的key-value键值对的个数
transient int size;
阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;
负载因子,代表了table的填充度有多少,默认是0.75 加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,
等到满16个元素才扩容,某些桶里可能就有不止一个元素了。 所以加载因子默认为0.75,也就是说大小为16的HashMap,
到了第13个元素,就会扩容成32。
final float loadFactor;
HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException
transient int modCount;
最大容量为2的30次幂,如果指定初始容量不是2的整数次幂,会通过roundUpToPowerOf2方法计算出大于初始容量的2的整数次幂值使用
JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高
JDK1.8在进行元素插入时使用的是尾插法。
如果发现两个hash值(key)相同时,HashMap的处理方式是用新value替换旧value,这里并没有处理key,这正好解释了 HashMap 中没有两个相同的 key。
对NULL键的特别处理:putForNullKey()
/**
* Offloaded version of put for null keys
*/
private V putForNullKey(V value) {
// 若key==null,则将其放入table的第一个桶,即 table[0]
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) { // 若已经存在key为null的键,则替换其值,并返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++; // 快速失败
addEntry(0, null, value, 0); // 否则,将其添加到 table[0] 的桶中
return null;
}
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中。
HashMap 中键值对的添加:addEntry()
/**
* Adds a new entry with the specified key, value and hash code to
* the specified bucket. It is the responsibility of this
* method to resize the table if appropriate.
*
* Subclass overrides this to alter the behavior of put method.
*
* 永远都是在链表的表头添加新元素
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
//获取bucketIndex处的链表
Entry<K,V> e = table[bucketIndex];
//将新创建的 Entry 链入 bucketIndex处的链表的表头
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
//若HashMap中元素的个数超过极限值 threshold,则容量扩大两倍
if (size++ >= threshold)
resize(2 * table.length);
}
HashMap 总是将新的Entry对象添加到bucketIndex处,若bucketIndex处已经有了Entry对象,那么新添加的Entry对象将指向原有的Entry对象,并形成一条新的以它为链头的Entry链;但是,若bucketIndex处原先没有Entry对象,那么新添加的Entry对象将指向 null,也就生成了一条长度为 1 的全新的Entry链了。HashMap 永远都是在链表的表头添加新元素。此外,若HashMap中元素的个数超过极限值 threshold,其将进行扩容操作,一般情况下,容量将扩大至原来的两倍。
HashMap是非线程安全的
1.Hashmap在插入元素过多的时候需要进行Resize,Resize的条件是
HashMap.Size >= Capacity * LoadFactor。
2.Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在并发的情况下可能会形成链表环。
HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。
线程不安全原因分析:
线程不安全发生的主要原因是在自动扩容时进行重新hash计算时,调用了transfer函数,源码如下:
void transfer(Entry[] 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;
}
}
}
假如现在有一个HashMap结构如下:
(3、7、5为数组下标为1位置上的三个元素,因hash冲突生成了链表)
现在分别有线程A、B对HashMap进行扩容操作
当A执行到标红的代码时(此时未执行标红代码本行),CPU时间片用完,线程被挂起
此时A中:e.next = null, e = 3, next = 7
B线程正常执行,执行完扩容操作后HashMap结构为:
根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table都是最新的,也就是说
7.next = 3 , 3.next = null
然后继续执行线程A的后续代码,变为:
newTable[i] =3,e = 7
继续执行下一轮循环,此时7.next=3,newTable[i]=7,next=3
继续执行循环,3.next=null,newTable[i]=3,next=null,执行后停止循环
到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
参考链接:
https://blog.youkuaiyun.com/justloveyou_/article/details/62893086
https://juejin.cn/post/6844903518331994119
https://blog.youkuaiyun.com/swpu_ocean/article/details/88917958
HashMap在JDK1.8前后的变化
参考:https://blog.youkuaiyun.com/weixin_44141495/article/details/108402128