目录
2.8 jdk1.7中HashMap多线程下扩容死锁演示与分析
3.8 为什么要在链表的长度刚好达到8以后就把它转成红黑树?
1.概述
- HashMap 主要用来存放键值对,它基于哈希表的Map接口实现,是常用的Java集合之一。
- JDK1.8 之前 HashMap 由 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).
- JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于8时,将链表转化为红黑树,以减少搜索时间。
2.JDK1.7源码分析
- JDK1.7HashMap 底层是数组和链表 结合在一起使用也就是链表散列。
2.1 底层存储的对象
HashMap1.7底层数组中存储的是Entry<key,value>对象
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; //键
V value; //值
Entry<K,V> next; //指向的下一个节点
int hash; //哈希值
/**
* 构造一个Entry节点
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
2.2 成员变量
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
/**
* 默认容量,即默认的数组的大小
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16
/**
* 数组最大容量
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的加载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 一个空的数组实例常量,
* 类似于ArrayList中的 private static final Object[] EMPTY_ELEMENTDATA = {};
* <p>
* 用于在初始化HashMap的时候赋予它一个空数组
*/
static final Entry<?, ?>[] EMPTY_TABLE = {};
/**
* 真正存储键值对的数组,
* <p>
* 准确的说,数组中元素存放的是它每一个位置对应的链表的头节点
* <p>
* 默认初始化为一个空数组
*/
transient Entry<K, V>[] table = (Entry<K, V>[]) EMPTY_TABLE;
/**
* HashMap中的键值对的个数
*/
transient int size;
/**
* 扩容阈值
*
* 大多数情况下threshold = capacity * load factor(除过数组的容量已经扩充到很大的时候,具体见下文代码)
*
* 如果数组table是空数组EMPTY_TABLE,并且扩容阈值threshold=inialCapacity,
* 那么数组会在函数inflated中被创建
*
*/
int threshold;
/**
* 加载因子
*/
final float loadFactor;
/**
* HashMap被操作的数量
*/
transient int modCount;
/**
*
*/
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
/**
* A randomizing value associated with this instance that is applied to
* hash code of keys to make hash collisions harder to find. If 0 then
* alternative hashing is disabled.
*/
transient int hashSeed = 0;
}
- loadFactor 加载因子
- 问题:为什么HashMap的加载因子要设置在0.75?
- 加载因子是表示Hsah表中元素的填满的程度。
- 加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。也就是链表中的长度就会增加
- 反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
- 冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
- 因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。而loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
- 给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以使用HashMap时尽量预估自己的数据量来设置初始值。
- 扩容阈值threshold
-
threshold = capacity * loadFactor,当size>=threshold的时候,那么就要考虑对数组的扩容了,也就是说,扩容阈值的意思就是衡量数组是否需要扩容的一个标准。
-
2.3 构造函数
/**
* 传入自己的容量和加载因子的构造函数
*
* 实际上函数内除过对我们输入参数做了范围检查,
* 只是将我们输入的加载因子赋值给成员变量loadFactor
* 将我们传入的初始容量赋值给threshold,
* 而这时threshold这个值是为了在put方法中调用inflateTable方法初始化HashMap的数组的时候
* 传入该参数来计算数组的容量的
*
*
*/
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;
threshold = initialCapacity;
/**
* 空方法,HashMap中并没有实现它
*/
init();
}
/**
* 我们自己只传入初始容量,加载因子采用默认的0.75
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 初始容量采用默认值16,加载因子采用默认的0.75
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 通过已经存在的Map创建HashMap
*/
public HashMap(Map<? extends K, ? extends V> m) {
/**
* 构造一个HashMap
* 容量为m.size() / DEFAULT_LOAD_FACTOR) + 1与16中的最大值
* 加载因子为0.75
*/
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
/**
* 根据容量创建数组
*/
inflateTable(threshold);
/**
* 将m数组中的
*/
putAllForCreate(m);
}
/**
* 遍历m,将它的键值对放入到我们的新创建的数组中去
*
*/
private void putAllForCreate(Map<? extends K, ? extends V> m) {
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putForCreate(e.getKey(), e.getValue());
}
/**
* This method is used instead of put by constructors and
* pseudoconstructors (clone, readObject). It does not resize the table,
* check for comodification, etc. It calls createEntry rather than
* addEntry.
*/
private void putForCreate(K key, V value) {
//传入的键值对的hash值
int hash = null == key ? 0 : hash(key);
//计算传入的键值对所对应的数组索引值
int i = indexFor(hash, table.length);
/**
* 遍历该数组索引位置处的链表,判断是否该键已经存在,存在的话直接返回
*/
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
e.value = value;
return;
}
}
/**
* 创建一个新的节点插入到数组索引位置的链表中(头插法)
*/
createEntry(hash, key, value, i);
}
/**
* 创建一个新的键值对,并采用头插法插入到数组索引值为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++;
}
总结:
- jdk1.7中HashMap在初始化的时候,和ArrayList一样,采用的是延迟创建,在构造方法中只是初始化loadFactor和threshold,而实际数组空间的创建是在put方法中
2.4 添加键值对put
使用示例:
package HashMapKeyDemo;
import java.util.HashMap;
public class HashMapTest {
public static void main(String[] args) {
HashMap<String,String> map= new HashMap<>();
map.put("1","2");
//由于已经存在该key,则会覆盖原来的值,覆盖后会返回旧值
String value = map.put("1","3");
System.out.println(value);
System.out.println(map.get("1"));
}
}
简单讲解put的思路:
- int hashCode = key.hashCode() //根据key得到一个hashCode
- int index = hashCode % table.length //根据hashCode取余计算出数组的索引值
- table[index] = new Entry(key,value,table[index]) //将key,value封装成一个节点,插入到该数组该索引位置的头节点的位置,原来头节点就是table[index],所以让新节点的next域指向原来的头节点table[index],然后让头节点指向新节点,即完成了头部的插入
源码分析:
/**
* 插入
*/
public V put(K key, V value) {
/**
* HashMap创建第一次put,会进入此判断,初始化数组
*/
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
/**
* 对于我们插入的key为null的情况,做特殊处理
*
* 固定插入到数组的索引为0处的链表中
*/
if (key == null)
return putForNullKey(value);
/**
* key不为null的话
*/
//求出key对应的hash值
int hash = hash(key);
//根据hash值和数组长度求出key数组的索引值
int i = indexFor(hash, table.length);
//遍历该索引处的链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//如果该键已存在,覆盖已有的值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//该键不存在该链表中,头插法插入到该链表中
addEntry(hash, key, value, i);
return null;
}
/**
* 初始化数组
*/
private void inflateTable(int toSize) {
// 找出大于等于toSize的最小的2的次方数
// 保证容量不管我们传入多少的容量值,都能保证数组容量为2的幂次方
int capacity = roundUpToPowerOf2(toSize);
// 计算新的扩容阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建新的容量的数组
table = new Entry[capacity];
//计算新的hashSeed
initHashSeedAsNeeded(capacity);
}
/**
* 传入容量比最大容量还大,就为最大容量
* 传入容量等于1,就为1,即2的0次方
* 否则,返回一个大于等于number的最小的2的次方数
*
*/
private static int roundUpToPowerOf2(int number) {
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
/**
* 此方法专门对于key为null的情况做出处理
*/
private V putForNullKey(V value) {
/**
* 遍历数组0位置的链表,查找它当中是否已经有以null为key的键值对
* 有的话,覆盖它的value,直接返回
* 循环完都没有的话,才会执行到循环后面的addEntry
*/
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
/**
* 将key=null,value=value的键值对插入到数组的第一个位置的链表中(头插法)
*/
addEntry(0, null, value, 0);
return null;
}
/**
* 将键值对采用头插法插入到数组对应位置的链表中
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
/**
* 元素个数size大于扩容阈值threshold并且要插入的数组索引位置不为null的话,进行扩容
*
*/
if ((size >= threshold) && (null != table[bucketIndex])) {
//将数组扩容为原来的两倍
resize(2 * table.length);
//这时需要重新计算hash值,因为jdk1.7中hash函数中用到了hashSeed,
// 而hashSeed在resize扩容时发生了变化,所以需要重新计算hash
hash = (null != key) ? hash(key) : 0;
//重新计算数组的索引值
bucketIndex = indexFor(hash, table.length);
}
//创建Entry并通过头插法插入到数组的bucketIndex处的链表中去
createEntry(hash, key, value, bucketIndex);
}
总结:
- 1.通过上述源码可以发现实际在jdk1.7中扩容的条件并不只是HashMap中的元素个数size大于扩容阈值threshold,而是有两个条件:
- 1.HashMap中的元素个数size大于扩容阈值threshold
- 2.要插入的键值对中key对应的数组索引位置不为null
- 2.jdk1.7的对扩容方法的调用实际是在addEntry方法中
- 3.jdk1.7中对于Entry节点的插入采用头插法
- 4.不管我们传入的初始容量是多少,最终都会被转化为2的幂次方,至于原因见下文
2.5 从key到数组索引值的计算
问题1:为什么HashMap在初始化的时候的容量要被转化为2的幂次方?
我们先来看一下HashMap的实现中是如何计算数组的地址值的,
- 1.首先,每个key对象都有自己的hashCode方法,会返回一个32位int的hashCode,
- 2.然后hashCode通过扰动函数(即HashMap的hash(Object)方法)得到一个处理后的hash值
/**
* jdk1.7 的扰动函数
*/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* jdk1.8 的扰动函数
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 扰动函数的作用见下文问题2
- 3.通过该hash值然后计算数组的索引地址
jdk1.7的地址值计算:
jdk1.8的地址值计算:
可以发现,在jdk1.7和jdk1.8中,通过hash值计算数组的索引值的方式是相同的,都是
index = (table.length-1) & hash;
我们接下来通过示例来分析以下这个计算方法:
HashMap的容量我们就以HashMap的默认容量为16为例吧,hash值我们就随机找一个数字吧
table.length:
- 16:0000_0000 0000_0000 0000_0000 0001_0000
table.length - 1:
- 15:0000_0000 0000_0000 0000_0000 0000_1111
hash:
- 0110_1001 0010_0001 0000_0010 0000_0111
(table.length - 1)& hash:
- 0000_0000 0000_0000 0000_0000 0000_1111
- & 每一位相与,都为1时得1,否则得0
- 0110_1001 0010_0001 0000_0010 0000_0111
- 0000_0000 0000_0000 0000_0000 0000_0111
- 可以发现结果很特殊,table.length只要是2的幂次方,然后减1的话,它的前面的高位都会变成全0,后面均为1,而后面的位数能表示的范围正好是[0,
-1],即刚好是数组的索引范围,相与后,左边前32-n位都为0,右边n位的结果是hash的对应的n位的值(如示例中就是hash的后4位0111,即最终的索引值计算出来为7)
总结:
- 所以HashMap在初始化的时候的容量必须是2的幂次方是为这里计算数组的索引做铺垫,确保最终能通过与运算来计算数组的索引值
- 其实在jdk早期实现中,数组的索引值的计算是通过取模来完成,但是在一些平台上运行效率比较慢,由于位运算比四则和取模运算的效率要快,所以此处采用这种与操作计算数组的索引值效率更快
- 其实在jdk1.8中2的幂次方还有另外i一个作用,见下文jdk1.8源码的扩容讲解
问题2:为什么在key自身得到hashCode之后还要通过hash()进行右移处理?
- 1.通过上述我们可以看到,如果不右移,其实最终真正参与到决定数组索引值的是数组的低n-1位,而其他高位对结果没有任何影响,通过右移将高位移到了低位,让高位参与到数组索引值的计算当中,防止更多的碰撞
- 2.如果我们自身的hashCode函数写得不好产生很多碰撞(即按照我们hashCode函数返回的值直接计算数组索引值有许多相同时),通过这样的右移操作能减少这种碰撞
2.6 获取键值对get
/**
* 根据key查询值,
*/
public V get(Object key) {
//对key为null用专门的函数去获取
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
//返回获取到的值
return null == entry ? null : entry.getValue();
}
/**
* 获取key为null的函数的值
*/
private V getForNullKey() {
if (size == 0) {
return null;
}
//遍历数组0位置处的链表,寻找key为null的值
// (因为key为null的键值对,在put的时候就确定被放置在table[0]中的链表中)
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
/**
* 获取非null的key对应的值
*/
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//计算key对应的hash值
int hash = (key == null) ? 0 : hash(key);
//遍历key对应的数组索引处的链表
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;
}
//未找到该键就返回null
return null;
}
2.7 扩容resize
/**
* 扩容核心方法
*/
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));
//将新的数组赋值给当前HashMap对象的table数组引用
table = newTable;
//计算新的扩容阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* 将原始数组中的数据转移到新的数组中去
*/
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;
//判断是否需要再次hash,对于一些节点再哈希,会将它移动到其他的桶当中,这样也可以将原来的链表的长度减少
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;
}
}
}
- 可以发现,在扩容时转移元素的过程中,数组中每个链表的节点的顺序发生了反转
2.8 jdk1.7中HashMap多线程下扩容死锁演示与分析
扩容在多线程下产生死锁的代码块如下:
其实以上代码while循环中是对数组中某一索引位置中链表的遍历转移,我们简单使用如下的图解进行演示死锁(此处只把数组画了2个只供演示死锁,真实情况数组的容量并非2个哟)
图解:
- 1.假设两个线程t1和t2都执行到while循环当中,如下,它们在循环中都会有临时变量e指向的是当前节点,临时变量next指向下一个节点,我们姑且对线程t1用e1,next1,对线程t2用e2,next2来表示它们,并且开始e1和e2都指向
,空白即为null
- 2.当线程t1先执行到
发生阻塞
- 3.此时线程t2开始执行,执行了第一轮循环
- 4.线程t2执行了第二轮循环
t2执行完毕
- 5.此时线程t1继续执行完一轮循环
- 6.线程t1执行第二轮循环
- 7.线程t1执行第三轮循环
可以发现此时便形成了环,形成了死锁,而死锁的形成的原因就是因为在扩容时转移元素的过程中,数组中每个链表的节点的顺序发生了反转
3.jdk1.8源码分析
JDK1.8HashMap 底层是数组+链表+红黑树
3.1 底层存储的对象
jdk1.8底层存储的是Node节点,当链表长度小于8的时候,数组中存放的是Node本身,但是当转变为红黑树的时候,数组中存放的是Node的孙子类节点TreeNode
链表节点:Node节点
/**
* jdk1.8中HashMap底层存放的对象是Node
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //哈希值
final K key; //键
V value; //值
Node<K,V> next; //下一个节点
//构造函数
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
红黑树节点:TreeNode节点
说明:由于红黑树节点的源码中有大量的操作,此处只截取了源码的一部分,关于红黑树原理还有在此类中详细源码实现解析,见我的另一篇博客:待补充
/**
* 红黑树节点
*
* 其实LinkedHashMap.Entry<K,V>是继承自HashMap.Node的
*
* static class Entry<K,V> extends HashMap.Node<K,V> {
*
*
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K, V> parent; //父节点
TreeNode<K, V> left; //左节点
TreeNode<K, V> right; //右节点
TreeNode<K, V> prev; // needed to unlink next upon deletion
boolean red; //节点的颜色
TreeNode(int hash, K key, V val, Node<K, V> next) {
super(hash, key, val, next);
}
/**
* 返回红黑树的根节点
*/
final TreeNode<K, V> root() {
for (TreeNode<K, V> r = this, p; ; ) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
}
3.2 成员变量
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
/**
* 序列号
*/
private static final long serialVersionUID = 362498820763181265L;
/**
* 默认容量
*/
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;
/**
* 当桶(bucket)上的结点数大于这个值时会转成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当桶(bucket)上的结点数小于这个值时树转链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 桶中结构转化为红黑树对应的table的最小大小
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 存储元素的数组,总是2的幂次倍
*
* 通过此也可以看到jdk1.8之后table存放的是Node
*/
transient Node<K,V>[] table;
/**
* 存放具体元素的集合
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
* HashMap中键值对的数量
*/
transient int size;
/**
* HashMap被操作的数量
*/
transient int modCount;
/**
* 扩容阈值
*/
int threshold;
/**
* 加载因子
*/
final float loadFactor;
}
3.3 构造函数
/**
* 与jdk1.7中差不多,只是将计算2的幂次方的容量提前到了此方法
*/
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);
}
/**
* 同jdk1.7
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 只赋予加载因子,未对容量进行初始化
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
/**
* 使用已存在的Map构造新的HashMap
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
/**
* 遍历Map中的Entry对象的键值对,存放到当前的HashMap对象的数组中
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s为m的实际元素个数
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 已初始化,并且m元素个数大于阈值,进行扩容处理
else if (s > threshold)
resize();
// 将m中的所有元素添加至HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
3.4 添加键值对put
/**
* 提供给外界的put方法
*
* key存在返回的时覆盖的旧值
* 不存在的话返回的是null
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* put核心方法
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//定义一个临时的数组引用用于接收原始数组
Node<K,V>[] tab;
//定义一个临时节点
Node<K,V> p;
//定义一个临时变量n接收数组的长度,临时变量i接收计算后的数组索引值
int n, i;
/**
* 第一次put的时候,数组进行初始化
*
* jdk1.8将初始化数组的方法和扩容方法结合在了一起为resize()
*/
if ((tab = table) == null || (n = tab.length) == 0)
//此处使用临时数组引用tab接收了初始化后的数组,临时变量n接收了初始化数组后,数组的长度
n = (tab = resize()).length;
/**
* 如果key对应的数组索引位置为没有任何键值对,即为null,就新建一个节点(即新建该位置链表头节点),放置在该位置
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//该数组索引处已经有键值对存在
else {
Node<K,V> e; K k;
//如果数组该索引位置处存放的第一个Node直接是该key对应的键值对,则直接覆盖该处的值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果数组该索引位置处存放的第一个节点是红黑树节点,直接使用红黑树结点的putTreeVal方法进行put
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则,数组该索引位置处存放的就是一个长度小于等于8的链表
else {
//遍历数组该索引位置处的链表
for (int binCount = 0; ; ++binCount) {
//如果循环到链表尾节点
if ((e = p.next) == null) {
//将新节点插入链表的尾部,通过这种方式使链表的节点不会发生反转
p.next = newNode(hash, key, value, null);
//如果这循环第8次,表明此时插入的节点为第9个节点,即链表的节点个数超过了8,
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//将链表转换为红黑树
treeifyBin(tab, hash);
break;
}
//判断当前节点e是否为key对应的键值对,是的话就退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//迭代
p = e;
}
}
//当我们在原来的链表中找到了该key对应的键值对,直接覆盖该key对应的值,并且返回该值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//在找到对应的key之后进行覆盖之后做一些事情,这里默认也为空实现,我们可以重写该方法
afterNodeAccess(e);
//返回原始值
return oldValue;
}
}
++modCount;
//如果新增添加了一个节点后,HashMap中键值对的个数大于扩容阈值的话,进行扩容
if (++size > threshold)
//扩容
resize();
//在插入一个节点后做一些事情,这里是空实现,我们可以重写去实现
afterNodeInsertion(evict);
//如果是插入节点,返回的是null
return null;
}
总结:
- 1.在jdk1.8中扩容是在新添加了节点之后进行判断,并且判断只有一个条件即新增节点后的HashMap的长度大于阈值
- 2.jdk1.8的对扩容方法的调用就在putVal中
- 3.jdk1.8中对于链表中Node节点的插入采用尾插法
- 4.链表的长度大于8的时候,将链表节点转化为红黑树来提高查询效率,从O(n)提升到O(lgn)
3.5 将链表转换为红黑树的方法treeifyBin
待补充
3.6 获取键值对get
/**
* 提供给用户调用的根据键获取值的方法
*
* 存在该键对应的节点,就返回值
* 不存在就返回null
*/
public V get(Object key) {
Node<K,V> e;
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;
//确保数组已经被初始化并且数组中该索引位置头节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//数组第一个索引位置处的头节点是就是该key对应的键值对,就直接返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果头节点后存在下一个节点
if ((e = first.next) != null) {
//头节点是红黑树节点,直接调用它的getTreeNode查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//数组该索引位置是链表,循环查找该key对应的键值对
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.7 扩容方法resize
/**
* 扩容与初始化结合的方法
*/
final Node<K,V>[] resize() {
//保存原始的数组
Node<K,V>[] oldTab = table;
//获取原始的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取原始的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
/**
* 以下if-else判断仅是通过原始容量和扩容阈值
* 得到新的容量和扩容阈值
*/
//原始容量大于0,代表原始数组已经经过了初始化
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容为原来两倍(左移1位即乘以2)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/**
* 初始化数组的
* 原始数组容量为0,扩容阈值大于0
*
* 即HashMap中使用以下两个函数构造
* public HashMap(int initialCapacity, float loadFactor)
* public HashMap(int initialCapacity)
*/
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
/**
* 原始数组容量为0,扩容阈值也为0
*
* 即HashMap中使用默认函数构造
* public HashMap()
*
*/
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
/**
* 如果新的扩容阈值仍然为0,计算新的扩容阈值
*/
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
/**
* 将计算后的扩容阈值置为当前对象的扩容阈值
* 创建新容量的节点数组
*/
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
/**
* 原始数组是已经经过初始化的话
*/
if (oldTab != null) {
//遍历原始数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//原始数组索引位置j处有节点
if ((e = oldTab[j]) != null) {
//将原始数组该索引位置置空,以让GC来回收
oldTab[j] = null;
/**
* 疑问:为什么在对数组的迁移时,不直接将数组的头节点指向链表的头节点
*
* 数组索引处只有头节点一个节点
*/
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
/**
* 数组索引处存放的是红黑树
*/
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
/**
* 数组中存放的是长度大于1,小于等于8的链表
*/
else { // preserve order
//定义四组指针,高低位的头尾指针
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//循环该索引位置处的链表
do {
next = e.next;
/**
* e.hash & oldCap的解释见代码后
*
* 通过e.hash & oldCap的结果将原始的链表划分为高低位两个链表,
* 低位存放到新数组的相同位置,高位链表存放到相同位置+原始容量的位置
*
* 通过上述将链表进行了拆分,将数组变得松散,
* 并且不至于让数组一半空间空置
*
* 避免了rehash,
*
* e.hash & (oldCap + oldCap-1)
*
*/
//结果为0
if ((e.hash & oldCap) == 0) {
//将当前节点插入低位链表(尾插法)
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//结果非0
else {
//将当前节点插入高位链表(尾插法)
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//将低位链表存放到新数组与原始数组相同索引位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//将高位链表存放到新数组与原始数组相同索引位置+原始容量的索引位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
以下分析e.hash & oldCap这极为精妙的位运算的设计:
在jdk1.8中HashMap在初始化的时候的容量要被转化为2的幂次方的另一个原因:
oldCap
- 16:0000_0000 0000_0000 0000_0000 0001_0000
e.hash:
- 0110_1001 0010_0001 0000_0010 0000_0111
e.hash & oldCap:
- 0110_1001 0010_0001 0000_0010 0000_0111
- & 每一位相与,都为1时得1,否则得0
- 0000_0000 0000_0000 0000_0000 0001_0000
- 0000_0000 0000_0000 0000_0000 0000_0000
- 可以发现上述保证容量为2的n次方,只有n+1位影响结果,该式计算下来只有两种结果,要么为0,要么就为原始容量
- 所以通过就可以通过if-else来区分高低位链表
问题:为什么我们将高位链表的节点存放到原始数组相同索引位置+原始容量()的索引位置后不用重新计算hash值?
- 通过上述我们可以知道能加入高位链表的都是hash值的n+1位为1
- 当我们再次通过key计算数组索引值的时候,是通过n&(newCap-1)来计算的,而此时newCap实际就是2oldCap
- 我们再用具体数值来举例演示:
oldCap
- 16:0000_0000 0000_0000 0000_0000 0001_0000
e.hash:(此处由于if-else的判断中对e.hash & oldCap保证了高位链表中节点的hash值的n+1位为1)
- 0110_1001 0010_0001 0000_0010 0001_0111
newCap
- 32:0000_0000 0000_0000 0000_0000 0010_0000
newCap-1:(通过+oldCap,刚好保证了newCap-1的第n+1位也为1)
- 32:0000_0000 0000_0000 0000_0000 0001_1111
e.hash & (newCap-1):(所以就保证了它们相与的结果第n+1为必为1)
- 0110_1001 0010_0001 0000_0010 0001_0111
- & 每一位相与,都为1时得1,否则得0
- 0000_0000 0000_0000 0000_0000 0001_1111
- 0000_0000 0000_0000 0000_0000 0001_0111
- 所以刚好计算结果就是原始数组相同索引位置+原始容量,从而避免rehash
3.8 为什么要在链表的长度刚好达到8以后就把它转成红黑树?
问题:为什么要在链表的长度刚好达到8以后就把它转成红黑树?
- 因为当我们put一个键值对的时候,产生哈希碰撞,放在同一个桶里的概率服从泊松分布
-
在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时上面给出了桶中元素个数和概率的对照表。从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个的概率是很小的
-
虽然很小,但在数据很大的时候,还是会发生,转化为红黑树能够提高查询效率,但由于转化为红黑树的概率是很小的,实际上jdk1.8比jdk1.7的HashMap的性能上只提高了8%-10%
关于泊松分布的解释如下
说明:此处使用了此链接中的解释http://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html#comment-356111
泊松分布
日常生活中,大量事件是有固定频率的。
- 某医院平均每小时出生3个婴儿
- 某公司平均每10分钟接到1个电话
- 某超市平均每天销售4包xx牌奶粉
- 某网站平均每分钟有2次访问
它们的特点就是,我们可以预估这些事件的总数,但是没法知道具体的发生时间。已知平均每小时出生3个婴儿,请问下一个小时,会出生几个?
有可能一下子出生6个,也有可能一个都不出生。这是我们没法知道的。
泊松分布就是描述某段时间内,事件具体的发生概率。
上面就是泊松分布的公式。
等号的左边,
- P 表示概率,
- N表示某种函数关系,
- t 表示时间,
- n 表示数量,
- 1小时内出生3个婴儿的概率,就表示为 P(N(1) = 3) 。
等号的右边,
- λ 表示事件的频率。
接下来两个小时,一个婴儿都不出生的概率是0.25%,基本不可能发生。
接下来一个小时,至少出生两个婴儿的概率是80%。
泊松分布的图形大概是下面的样子。
可以看到,在频率附近,事件的发生概率最高,然后向两边对称下降,即变得越大和越小都不太可能。每小时出生3个婴儿,这是最可能的结果,出生得越多或越少,就越不可能。
一句话总结:泊松分布是单位时间内独立事件发生次数的概率分布
请注意是"独立事件",泊松分布的前提是,事件之间不能有关联,否则就不能运用上面的公式。
4.jdk1.8与jdk1.7相比的变化总结
变化1:
- jdk1.8在构造函数中就完成了对容量转化为2的幂次方,通过tableSizeFor方法来计算
- jdk1.7是在第一次put的时候调用inflateTable方法时,在其中通过roundUpToPowerOf2方法来计算
变化2:
- jdk1.7默认构造函数会被赋予数组默认容量和默认加载因子,并且此时数组引用table指向空数组
- jdk1.8默认构造函数只会赋予默认加载因子,并且此时数组引用table为null
变化3:
- jdk1.7中HashMap在创建后在第一次初始化数组是通过inflateTable方法,扩容通过resize()方法
- jdk1.8将初始化数组的方法和扩容方法结合在了一起为resize()
变化4:
- jdk1.7中链表的节点采用头插法
- jdk1.8中链表的节点采用尾插法
变化5:
- jdk1.7中在插入节点以前就进行扩容判断,扩容的条件为两个
- 1.HashMap中的元素个数size大于扩容阈值threshold
- 2.要插入的键值对中key对应的数组索引位置不为null
- jdk1.8中在插入节点以后才进行扩容判断,扩容的条件为一个:
- 新增节点后HashMap中的元素个数size大于扩容阈值threshold
变化6:
- jdk1.7中hash方法比较复杂
- jdk1.8中对hash方法进行了简化
变化7:
- jdk1.7中使用数组+链表
- jdk1.8中使用数组+链表+红黑树
变化8:
- jdk1.7中容量初始化为2的幂次方,只有一个作用
- 通过
计算数组的索引值
- 通过
- jdk1.8中容量初始化为2的幂次方,有3个作用
- 1.通过
计算数组的索引值
- 2.在扩容时通过
判断要将该节点加入高位链表还是低位链表
- 3.通过
与
以及
这几个运算的巧妙结合,避免了rehash
- 1.通过
变化9:
- jdk1.7中需要进行rehash
- jdk1.8中不需要进行rehash
5.HashMap实现中对组合模式的应用
见我的另一篇博客:https://blog.csdn.net/qq_34805255/article/details/98480704
6.HashMap中常用方法使用
import java.util.Collection;
import java.util.HashMap;
import java.util.Set;
public class HashMapDemo {
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<String, String>();
// 键不能重复,值可以重复
map.put("fei", "张飞");
map.put("yun", "赵云");
map.put("yu", "关羽");
map.put("zhang", "张飞");
map.put("zhang", "张飞2");// 张飞
map.put("zhong", "黄忠");
System.out.println("-------直接输出hashmap:-------");
System.out.println(map);
/**
* 遍历HashMap
*/
// 1.获取Map中的所有键
System.out.println("-------foreach获取Map中所有的键:------");
Set<String> keys = map.keySet();
for (String key : keys) {
System.out.print(key+" ");
}
System.out.println();//换行
// 2.获取Map中所有值
System.out.println("-------foreach获取Map中所有的值:------");
Collection<String> values = map.values();
for (String value : values) {
System.out.print(value+" ");
}
System.out.println();//换行
// 3.得到key的值的同时得到key所对应的值
System.out.println("-------得到key的值的同时得到key所对应的值:-------");
Set<String> keys2 = map.keySet();
for (String key : keys2) {
System.out.print(key + ":" + map.get(key)+" ");
}
/**
* 另外一种不常用的遍历方式
*/
// 当我调用put(key,value)方法的时候,首先会把key和value封装到
// Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取
// map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来
// 调用Entry对象中的getKey()和getValue()方法就能获取键值对了
Set<java.util.Map.Entry<String, String>> entrys = map.entrySet();
for (java.util.Map.Entry<String, String> entry : entrys) {
System.out.println(entry.getKey() + "--" + entry.getValue());
}
/**
* HashMap其他常用方法
*/
System.out.println("after map.size():"+map.size());
System.out.println("after map.isEmpty():"+map.isEmpty());
System.out.println(map.remove("fei"));
System.out.println("after map.remove():"+map);
System.out.println("after map.get(zhong):"+map.get("zhong"));
System.out.println("after map.containsKey(yu):"+map.containsKey("yu"));
System.out.println("after containsValue(张飞2):"+map.containsValue("张飞2"));
System.out.println(map.replace("yun", "赵云2"));
System.out.println("after map.replace(yun, 赵云2):"+map);
}
}