文章目录
前言
本文主要讲解JDK1.8下HashMap的源码,并且会比较JDK1.7和JDK1.8的不同,我们来看一下有关HashMap的问题,让我们带着问题学HashMap。
- HashMap1.7和1.8的区别?
- HashMap是如何计算下标值?
- HashMap数组的大小为什么规定是2的次方幂?
- HashMap插入元素的过程是怎么样的?
- HashMap如何扩容?
- HashMap线程安全吗?
继承关系
HashMap继承了AbstractMap
,实现了Cloneable
和Serializable接口
。
存储结构
JDK1.7
HashMap底层是基于哈希表(散列表)实现的,哈希表其实就是数组+链表的形式,拥有数组快速随机访问的特性,然后使用链地址法来解决哈希冲突,所以对于JDK1.8之前HashMap的存储结构如下图。
当哈希冲突很严重的时候,对于哈希表来说有可能退化成一个长的链表,那么查找元素的时间复杂度就会变成O(N),N为链表的长度。
JDK1.8
在JDK1.8中对HashMap的存储结构做了优化,增加了红黑树(自平衡的二叉排序树),当链表的节点数大于8并且HashMap中的元素个数大于64的时候,就会将链表转换为红黑树,如图所示。
成员变量
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;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
DEFAULT_INITIAL_CAPACITY
:HashMap的默认容量为16。
MAXIMUM_CAPACITY
:HashMap的最大容量为
2
30
2^{30}
230。
DEFAULT_LOAD_FACTOR
:默认的负载因子为0.75,扩容的时候需要使用。
TREEIFY_THRESHOLD
:树化的阈值为8。
UNTREEIFY_THRESHOLD
:由红黑树转换为链表的阈值为6。
MIN_TREEIFY_CAPACITY
:最小的树化容量,元素个数达到64才可以树化。
table
:Node类型的数组。
size
:HashMap的元素个数。
modCount
:记录HashMap结构变化次数。
threshold
:扩容的阈值,threshold=size*loadFactor。
loadFactor
:负载因子。
构造方法
带参构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
HashMap(int initialCapacity)
传入的是初始化容量参数,然后调用HashMap(int initialCapacity, float loadFactor)
方法,负载因子使用默认的负载因子。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
HashMap(int initialCapacity, float loadFactor)
传入了初始化容量参数和负载因子参数,然后通过tableSizeFor()
方法进行初始化,tableSizeFor()
保证了数组的长度是2的次方幂,如下
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
无参构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
put()方法
JDK1.8中的put方法的流程图如下
- 如果是第一次插入键值对,那么会调用
resize()
扩容方法来进行初始化。 - 计算键值对的下标值。
- 判断下标位置是否为空,如果为空,则直接赋值。
- 如果不为空,则判断key是否相等,如果相等,选择覆盖。
- 如果不相等,则判断该位置是否是一颗红黑树,如果是,则执行红黑树插入操作。
- 如果不是红黑树,则遍历链表,在链表中找到相同的则覆盖,否则采用尾插法的方式插入键值对,然后判断链表的长度是否大于8,HashMap中元素个数是否大于等于63,如果是则转化为红黑树。
- modCount++,并且判断是否需要扩容。
详细代码可以参考下面的图
Q:如何计算下标值?
- 首先会通过扰动函数hash()计算出key对应的哈希值,JDK1.8中的扰动函数如下
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
将key的hashCode的高16位与低16位进行一个异或操作,扰动了2次,而JDK1.8之前是扰动了9次。
- 将第一步计算出来的哈希值与数组长度-1进行与运算得到下标值。
if ((p = tab[i = (n - 1) & hash]) == null)
Q:为什么数组的大小要是2的次方幂?
- 首先不能直接用key的hashcode作为下标,这是因为hashcode是Integer类型(4字节),大约由40亿个数,所以HashMap是放不下的,所以一般的做法就是将hashcode跟数组的长度取余,这样可以将hashcode均匀的分布在数组中,
而数组的大小位2的次方幂时,(n-1)&hash等同于hash%n,将取余运算改成位运算,提高性能
。 - 如果数组的长度是2的次方,那么n-1的结果最后一位肯定是1,那么&hash的结果的最后一位可能是0,也可能是1,但是如果不是2的次方,那么n-1的结果的最后一位是0,&hash的结果的最后一位就是0,所以只能存放在偶数下标的位置,浪费了一半的空间。
get()方法
public V get(Object key) {
Node<K,V> e;
//1. 计算键值对对应的下标值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//2. 如果数组不为空,并且下标值位置也不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//则判断hash和key是否跟下标值位置的相同
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//判断下标值位置是不是红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
- 计算键值对对应的下标值。
- 如果数组不为空,并且下标值位置也不为空,则判断hash和key是否跟下标值位置的相同,如果是,则直接返回,如果不是执行3。
- 判断下标值位置是不是红黑树,如果是,则执行红黑树的查找方法,如果不是,则遍历链表。
扩容机制
JDK1.7中的扩容
扩容机制主要讲解JDK1.7扩容机制和存在的死循环问题,然后会讲JDK1.8中对扩容的优化。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
JDK1.7中当需要扩容的时候,新容量是旧容量的2倍。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//判断旧容量是否达到最大容量,如果是,将扩容阈值更改为Integer所能表示的最大值,返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//定义一个新的数组
Entry[] newTable = new Entry[newCapacity];
//将旧数组中的元素复制到新数组中
transfer(newTable);
//更新数组为新数组
table = newTable;
//更新扩容阈值
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
//src代表旧数组
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
//重新计算旧数组中每一个非空元素的下标值
int i = indexFor(e.hash, newCapacity);
//采用头插法的方式插入到新数组中
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
JDK1.7的扩容方法主要是申请新的数组,容量是旧容量的两倍,然后重新计算旧数组中每个元素在新数组的下标值,然后复制到新数组中。
在JDK1.7中,在多线程进行扩容的情况下,会出现环链,导致get()操作陷入死循环的问题。
JDK1.8中对扩容的改进
- 在JDK1.8中插入元素采用的
尾插法
,可以避免了环链的问题。 - 在JDK1.8中
不需要重新计算key的哈希值
,因为数组的大小是2的次方,所以元素在新数组中的位置,要么是原位置,要么是原位置+2的次方,取决于元素hash值新增的那一位,如果是0,则在原位置,如果是1则在原位置+2的次方。
HashMap的遍历
public static void main(String[] args) {
HashMap<String,String> hashMap = new HashMap<>();
hashMap.put("key1","value1");
hashMap.put("key2","value2");
hashMap.put("key3","value3");
//第一种遍历方式:使用Map.entrySet
for(Map.Entry<String,String> entry : hashMap.entrySet()) {
System.out.println("key:"+entry.getKey()+",value:"+entry.getValue());
}
//第二种遍历方式:使用迭代器
Iterator iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String,String> entry = (Map.Entry<String,String>)iterator.next();
System.out.println("key:"+entry.getKey()+",value:"+entry.getValue());
}
//第三种遍历方式:通过Map.keySet,二次取值
for (String key : hashMap.keySet()) {
System.out.println("key:"+key+",value:"+hashMap.get(key));
}
//第三种遍历方式:通过Map.values遍历
for (String value:hashMap.values()) {
System.out.println("value:"+value);
}
}
HashMap的线程问题
死循环
:JDK1.7中在扩容的过程中,因为采用头插法,所以在多线程的情况下,会产生环链
,导致get的时候出现死循环
。数据丢失
:在JDK1.8中虽然解决了环链的问题,但是依然存在线程安全的问题,那就是在put操作的时候,如果有两个线程A和B同时执行put操作,两条不同数据的在数组中的下标值相同,并且下标处为空,这个时候线程A挂起,然后线程B执行完put操作,这个时候线程A继续执行,不用再判断该位置是否为空,而是直接覆盖了线程B的值,造成了数据丢失。
小结
JDK1.8相对于JDK1.7的变化是
底层数据结构
:1.7使用数组+链表,1.8新增了红黑树,提高查询效率。元素插入的方式
:1.7使用的是头插法,1.8使用尾插法,再扩容的时候避免了出现环链。扩容机制
:1.7扩容需要重新计算hash值然后采用头插法,1.8不需要重新计算hash值,并且采用尾插法。hash()方法
:1.7采用了9次扰动,1.8只有2次扰动,就可以降低哈希碰撞概率。