xiaobenchi

导航

HashMap的实现

HashMap的底层实现

HashMap的存储结构

​ HashMap的存储结构在JDK1.7之前是采用数组+链表的方式。为了兼顾查询效率,JDK1.8在设计的时候,当链表长度达到一定的阈值之后就将其转换为红黑树。

红黑树的特点:

  1. 每个节点只有两种颜色:红色或者黑色
  2. 根节点必须是黑色
  3. 每个叶子节点(NIL)都是黑色的空节点
  4. 从根节点到叶子节点,不能出现两个连续的红色节点
  5. 从任一节点出发,到它下边的子节点的路径包含的黑色节点数目都相同
  6. 查询时间复杂度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

头插法,多线程的时候可能会产生两个元素相互指向的情况,也就是出现了死循环。

posted on 2022-04-17 12:52  小迟在努力  阅读(43)  评论(0)    收藏  举报