HashMap的实现
HashMap的底层实现
HashMap的存储结构
HashMap的存储结构在JDK1.7之前是采用数组+链表的方式。为了兼顾查询效率,JDK1.8在设计的时候,当链表长度达到一定的阈值之后就将其转换为红黑树。
红黑树的特点:
- 每个节点只有两种颜色:红色或者黑色
- 根节点必须是黑色
- 每个叶子节点(NIL)都是黑色的空节点
- 从根节点到叶子节点,不能出现两个连续的红色节点
- 从任一节点出发,到它下边的子节点的路径包含的黑色节点数目都相同
- 查询时间复杂度O(logn)
HashMap结构示意图:

常用变量
下面列出常用变量,和内部类表示链表节点和红黑树节点
//默认的初始化容量为16,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1<<4; //aka 16
//最大容量为2^30
static final int MAX_CAPACITY = 1 << 30;
//默认加载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表长度过长转换为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树过小转换为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
//链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化
//这是为了避免数组扩容和树化阈值之间的冲突
static final int MIN_TREEIFY_CAPACITY = 64;
//存放所有Node节点的数组
transient Node<K,V>[] table;
//存放所有的键值对
transient Set<Map.Entry<K,V>> entrySet;
//map中的实际键值对的个数,即数组中元素个数
transient int size;
//每次结构改变的时都会自增,fail-fast机制,这是一种错误检测机制
//当迭代集合的时候,如果结构发生改变,则会发生fail-fast,抛出异常
transient int modCount;
//数组扩容阈值
int threshold;
//加载因子
final float loadFactor;
//普通单向链表节点类
static class Node<K,V> implements Map.Entry<K,V>{
//Key的hash值,put和get的时候都需要用到它来确定元素在数组中的位置
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.val = value;
this.next = next;
}
//转化为红黑树的节点类
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;
//当前节点是红色或黑色的标识
boolean red;
TreeNode(int hash, K key, V value,Node<K,V> next){
super(hash,key,val,next);
}
}
}
构造函数
HashMap有四个构造函数
//默认无参构造,指定一个默认的加载因子
public HashMap(){
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//可指定容量的有参构造,但是需要注意当前我们指定的容量并不一定就是实际的容量
public HashMap(int initialCapacity){
//同样使用默认加载因子
this(initialCapcity,DEFAULT_LOAD_FACTOR);
}
//可指定容量和加载因子
public HashMap(int initialCapacity,float loadFactor){
if(initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity:" + initialCapacity);
if(initialCapacity > MAX_CAPACITY)
initialCapacity = MAX_CAPACITY;
if(loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load Factor:" + loadFactor);
this.loadFactor = loadFactor;
//这里就是把我们指定的容量改为一个大于它的最小的2次幂值,如传过来的容量是14,则返回
this.threshold = tableSizeFor(initialCapacity)
}
//可传入一个已有的map
public HashMap<Map<? extends K, ? extends V> m, boolean evict>{
int s = m.size();
if(s > 0){
if(table == null){
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY)?
(int)ft:MAXIMUM_CAPACITY);
if(t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for(Map.Entry<? extends K, ? extends V> e : m.entrySet()){
K key = e.getKey();
V value = e.getValue();
//put方法的具体实现
putVal(hash(key),key,value,false,evict);
}
}
}
//第三个构造函数中,调用了tableSizeFor方法
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;
}
put() 方法详解
//put方法,会先调用一个hash()方法
//姑且认为这里的hash方法是key.hashCode(),但其实并不是,过后会讲
public V put(K key, V value){
return putVal(hash(key),key,value,false,true);
}
//把hash值和当前的key,value传入进来
//这里的onliIfAbsent如果为true,表明不能修改已经存在的值,因此我们传入false
//不用关注evict这个参数
final V putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict){
Node<K,V>[] tab;
Node<K,V>[] p;
int n,i;
//判断table是否为空,如果空的话,会先调用resize扩容
if((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据当前key的hash值找到它在数组中的下标,判断当前下标位置是否已经存在元素
//若没有,则把key,value包装成Node节点,直接添加到此位置
//i = (n - 1)& hash 是计算下标位置的
if((p = tab[i = (n - 1) & hash]) == null){
tab[i] = newNode(hash,key,value,null);
}else{
//当前位置有值,分为三种情况
Node<K,V> e;
K k;
//1. 当前位置元素的hash值等于传过来的hash,并且它们的key值也相等
//则把p赋值给额,跳转到(1)处,后续需要做值的覆盖处理
if(p.hash == hash && (k = p.key) == key || (key != null && key.equals(k)))
e = p;
//2. 如果当前是红黑树结构,则把它加入红黑树
else if(p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,key,value);
else{
//3. 说明此位置已存在元素,并且是普通链表机构,则采用尾插法
for(int binCount = 0;;++binCount){
if((e = p.next) == null){
//如果头节点的下一个节点为空,则插入新节点
p.next = newNode(hash, key,value,null);
//如果在插入的过程中,链表长度超过了8,则转化为红黑树
if(binCount >= TREEIFY_THRSHOLD - 1)
treeifBin(tab,hash);
//插入成功后,跳出循环,跳转到(1)处
break;
}
//若在链表中找到了相同的key的话,直接跳出循环,跳转到(1)处
if(e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//(1)
//说明发生了碰撞,e代表的是旧值,因此节点位置不变,但是需要替换为新值
if(e != null){
V oldValue = e.value;
//用新值替换旧值,并返回旧值
if(!onlyIfAbsent || oldValue == null)
e.value = value;
//此处是空实现
afterNodeAccess(e);
return oldValue;
}
}
//fail-fast机制
++modCount;
//如果当前数组中元素中的个数超过阈值,则扩容
if(++size > threshold)
resize();
//同样的空实现
afterNodeInsertion(evict);
return null;
}
hash()计算原理
当前key是需要进行哈希处理的
static final int hash(Object key){
int h;
return (key == null) ?(h = hey.hashCode())^(h >>> 16);
}
若key非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并于原来的h进行异或处理
此举相当于我们把高十六位值和当前h的低16位进行了混合,这样可以尽量保留高16位的特征,从而降低哈希碰撞的概率。
异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。
(n-1)& hash 作用
//②
//这是 put 方法中用来根据hash()值寻找在数组中的下标的逻辑,
//n为数组长度, hash为调用 hash()方法混合处理之后的hash值。
i = (n - 1) & hash
给定某个数值,去找它在数组中的下标位置时,直接用模运算就可以了
而上述的程序,就是取模运算的位运算的形式
resize()扩容机制
在上面的put方法中,当数组为空的时候,会调用resize方法,当数组的size大于阈值的时候,也会调用resize方法。
接下来就来看一下resize方法
final Node<K,V>[] resize(){
//旧数组
Node<K,V>[] oldTab = table;
//旧数组的容量
int oldCap = (oldTap == null) ? 0 : oldTab.length;
//旧数组的扩容阈值,这里取当前对象的threshld值
int oldThr = threshold;
//初始化新数组的容量和阈值,分三种情况讨论
int newCap,newThr = 0;
//1.当旧数组的容量大于0时,说明在这之前肯定调用过resize扩容一次
if(oldCap > 0){
//容量达到了最大值
if(oldCap >= MAXIMUM_CAPACITY){
threshold = Integer.MAX_VALUE;
terurn oldTab;
}
//新数组容量和阈值都扩大为原来的2倍
else if((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
//到这里说明oldCap <=0, 并且oldThr(threshold) > 0,这就是map初始化的时候,第一
//次调用resize的情况,而 oldThr的值等于 threshold,此时的 threshold 是通过 tableSizeFor 方法得到的一个2的n次幂的值(我们以16为例)。
//因此,需要把 oldThr 的值,也就是 threshold ,赋值给新数组的容量 newCap,以保证数组的容量是2的n次幂。
//所以我们可以得出结论,当map第一次 put 元素的时候,就会走到这个分支,把数组的容量设置为正确的值(2的n次幂)
//但是,此时 threshold 的值也是2的n次幂,这不对啊,它应该是数组的容量乘以加载因子才对。别着急,这个会在③处理。
else if (oldThr > 0)
newCap = oldThr;
//3.到这里,说明 oldCap 和 oldThr 都是小于等于0的。也说明我们的map是通过默认无参构造来创建的
//于是,数组的容量和阈值都取默认值就可以了,即 16 和 12。
else{
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR*DEFAULT_INITIAL_CAPACITY);
}
//③ 这里就是处理第2种情况,因为只有这种情况 newThr 才为0
//因此计算 newThr(用 newCap即16 乘以加载因子 0.75,得到 12) ,并把它赋值给 threshold
if(newThr == 0){
float ft = (float)newCap*loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft <(float)MAXIMUM_CAPACITY?(int)ft:Integer.MAX_VALUE);
}
//赋予 threshold 正确的值,表示数组下次需要扩容的阈值(此时就把原来的 16 修正为了 12)。
threshold = newThr;
//我们可以发现,在构造函数时,并没有创建数组,在第一次调用put方法,导致resize的时候,才会把数组创建出来。这是为了延迟加载,提高效率。
Node<K,V>[] newTab = (Node<K,V>[]) new Node[newCap];
table = newTab;
//如果原来的数组不为空,那么我们就需要把原来数组中的元素重新分配到新的数组中
//如果是第2种情况,由于是第一次调用resize,此时数组肯定是空的,因此也就不需要重新分配元素。
if(oldTab != null){
for(int j = 0; j < oldCap; ++j){
Node<K,V> e;
//取到当前下标的第一个元素,如果存在,则分三种情况重新分配位置
if((e =oldTab(j)) != null){
oldTab[j] = null;
//1.如果当前元素的下一个元素为空,则说明此处只有一个元素
//则直接用它的hash()值和新数组的容量取模就可以了,得到新的下标位置。
if(e.next == null)
newTab[e.hash &(newCap - 1)] = e;
//2.如果是红黑树结构,则拆分红黑树,必要时有可能退化为链表
else if(e instanceof TreeNode)
((TreeNode<K,V>)e).split(this,newTab,j,oldCap);
//3.到这里说明,这是一个长度大于 1 的普通链表,则需要计算并
//判断当前位置的链表是否需要移动到新的位置
else{
//loHead 和 loTail 分别代表链表旧位置的头尾节点
Node<K,V> loHead = null, loTail = null;
Node<K,V> next;
do{
next = e.next;
//如果当前元素的hash值和oldCap做与运算为0,则原位置不变
if((e.hash & oldCap) == 0){
if(loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//否则,需要移动到新的位置
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;
}
一个非常重要的运算
//用于把原来的普通链表拆分为两条链表,位置不变或者放在新的位置
if((e.hash & oldCap) == 0) {} else{}
我们可以根据 (e.hash & oldCap == 0) 这个判断的真假来决定,当前元素应该在原来的位置不变,还是在新的位置(原位置 + 16)。
get()方法
public V get(Object key){
Node<K,V> e;
//如果节点为空,则返回null,否则返回节点的value。这也说明,hashMap是支持value为null的。
//因此,我们就明白了,为什么hashMap支持Key和value都为null
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;
//首先要确保数组不能为空,然后取到当前hash值计算出来的下标位置的第一个元素
if((tab = table) != null && (n = tab,length)>0 && (first = tab[(n-1)&hash]) != null){
//若hash值和key都相等,说明而欧美要找的就是第一个元素,直接返回
if(first.hash == hash && ((k = first.key)==key || (key != null && key.equals(k))))
return first;
//如果不是就是遍历当前链表(或红黑树)
if((e = first.next) != null){
//如果是红黑树结构,就要找到当前key所在节点的位置
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);
}
}
//否则,说明没找到,返回null
return null;
}
为什么HashMap链表会形成死循环
准确的讲应该是 JDK1.7 的 HashMap 链表会有死循环的可能,因为JDK1.7是采用的头插法,在多线程环境下有可能会使链表形成环状,从而导致死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。
hashmap添加元素的过程
//添加元素方法 -> 添加新节点方法 -> 扩容方法 -> 把原数组元素重新分配到新数组中
put() --> addEntry() --> resize() --> transfer()
出现问题的方法transfer
头插法,多线程的时候可能会产生两个元素相互指向的情况,也就是出现了死循环。
浙公网安备 33010602011771号