Jdk1.8下的HashMap源码分析

目录结构
一、面试常见问题
二、基本常量属性
三、构造方法
四、节点结构
       4.1 Node类
       4.2.TreeNode
五、put方法
       5.1 key的hash方法
       5.2 resize() 扩容方法
六、get方法

一、面试常见问题

Q1: HashMap底层数据结构是什么?

jdk1.7是数组+链表; jdk1.8 采用数组+ 链表+红黑树  

详见1.7源码分析

Q2:为什么要引入红黑树,有什么优势,解决什么问题?

红黑树的引入是为了提高查询效率,哈希冲突可以减少(元素key均匀散列程度和通过扩容),不可避免,
如果冲突元素较多,会导致链表过长,而链表查询效率是O(n), 当链表长度超过8后,且数组长度(length)超过64才转化为红黑树, 这点易忽略(后源码证明),
不是链表长度一超高8个就将其树化为红黑树,此时扩容解决冲突效果更好。(注:红黑树的特性需要了解掌握)

Q3: 在一条链表上新增节点,什么方式插入?

1.8 是尾插法,1.7是采用头插法,为什么1.7需要头插法?头插法效率高?

Q4:放入HashMap中元素需要什么特性?

需要重写hashCode和 equals()方法 ,不重写会有什么问题? 

Q5: HashMap是线程安全的吗?你列举其他线程安全的,特性?

不是, 因为它的方法没有同步锁,HashTable 是线程安全的,每个方法有加synhronized关键字,线程安全,但也会导致效率变低;
一般多线程环境使用 ConcurrentHashMap,其原理是采用了分段锁机制,每一段(Segment)中是一个HashMap,每个Segment中操作是加锁的,
即保证线程操作某一个Segment是排他的,但不同线程在不同Segment是可以同时操作的,即保证了线程安全,又提高了并发效率。
(此处不详细展开ConcurrentHashMap,面试问题是环环相套的,好的面试官会步步引导,目的是为了全面考察面试者的技术水平)。

Q6: 1.7中 HashMap存在什么问题?

在多线程环境下,1.7中Map可能会导致cpu使用率过高,是因为存在环形链表了,HashMap中 链表是单链表结构,怎么会有环?
是链表中元素指向下一个元素的指针next,指向了前面的元素,导致了环,所以在遍历链表时,
程序一直死循环无法结束。在多个线程放置元素时,resize()方法中导致(后源码证明)。

Q7: 1.8是先插入新值再判断是否扩容,还是先扩容在插入新值?

1.8是先插入元素,在判断容量是否超过阈值,扩容,1.7是先扩容再插入新值

Q8: HashMap是延迟初始化?

是的,创建的Map对象,如果有指定容量大小,会记录下来;没有指定会使用默认16,在第一次put元素时才初始化数组,1.7,1.8都是如此,可以节省内存空间。

Q9: HashMap允许放置空键(null),空值(null)吗?

允许放置,null 键是放置在数组第一个位置的,因此在判断某个key是否存在时,不能通过该get() 方法获取value为null判断,
这时键值对可能是 null:null,此时是存在Node对象的,可以通过containsKey(key)判断 ,key为null,Node对象不为null,如下图1所示       

对比HashTable ,HashTable 不允许 null键,null值.

Q10 :简要讲述一下HashMap放置元素过程,即put()方法。

put方法流程如下图2所示

二、基本常量属性
//默认初始容量 16 
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 *最大容量,如果在构造函数中指定大于该值,会使用该值作为容量大小,2^30
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 * 默认加载因子75%,好比地铁满载率,受疫情影响地铁满载率不超过30%
 * 1,加载因子设置较大,随着数组中元素装的越多,发生的冲突的概率越大,即对应的链表
 * 越长,影响查找效率。
 * 2,加载因子设置较小,元素很容易达到设定的阈值,发生扩容操作,数组空间还有很大部分          
 * 没有利用上,造成空间浪费。
 * 因此在时间与空间上的权衡考虑
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 树化阈值:在链表元素超过8个了,将链表转化为红黑数结果,提高查找效率
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 取消树化阈值:在红黑树中节点数量小于6个,将其转化为链表结构
 */
static final int UNTREEIFY_THRESHOLD = 6;
/**
 * 最小树化容量,容易忽视
 * 链表树化红黑树条件:
 * 1,链表中元素数量超过(>)8个;
 * 2, 满足map中元素个数(size)大于等于64否则会先扩容,扩容对解决hash冲突更有效。 
 */
static final int MIN_TREEIFY_CAPACITY = 64;
三、构造方法
//传有初始容量和加载因子
1.HashMap(int initialCapacity, float loadFactor)
//有初始容量,加载因子默认
2.HashMap(int initialCapacity)
//初始容量, 加载因子默认
3.HashMap()
//传递的一个Map子集
4.HashMap(Map<? extends K, ? extends V> m)
四、节点结构
  • 1.数组和链表节点对象为HashMap内部类 Node.

  • 2.红黑树节点 TreeNode,继承LinkedHashMap.Entry , 而 Entry继承Node,因此 TreeNode 实际是 Node孙子.

  • 3.Node类,重写了hashCode和 equals方法,记录了当前key, value, key的hash值,以及指向后一个元素指针.

4.1 Node类
//实现Map集合的Entry类
static class Node<K,V> implements Map.Entry<K,V> {
    //记录当前节点key的hash
    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; }
    //重写了hashcode 
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
     //重写了equals
    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;
    }
}
4.2.TreeNode

什么是红黑树?特点?

性质1. 节点是红色或黑色.

性质2. 根节点是黑色.

性质3.所有叶子都是黑色.(叶子是NUIL节点)

性质4. 每个红色节点的两个子节点都是黑色.(从每个叶子到根的所有路径上不能有两个连续的红色节点)

性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点.

// 继承LinkedHashMap.Entry  继承>> HashMap.Node
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {

TreeNode<K,V> parent;  // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;    // needed to unlink next upon deletion
//颜色是否为红色
boolean red;
五、put方法
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
5.1 key的hash方法
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash(key)方法:

1.hashMap支持 key 为null,放在数组索引为0的位置

2.为了保证key的hash值分布均分、散列,减少冲突

 (h = key.hashCode()) ^ (h >>> 16) 

等于 key的hash值 异或于 其hash值的低 16位

例:"水果"的 hashCode()

h = 11011000000111101000

h>>>16=00000000000000001101

两者异或,不同为1,相同为0

1101 10000001 11101000 
^ 
0000 00000000 00001101 
---------------------- 
1101 10000001 11100101

put核心方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
       //1,数组为空,初始化操作,此处resize() 待解析1
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
     /**2,通过key的计算出的hash值与数组长度-1与运算,算出在数组下标位置
       * 若该位置为空则创建新节点封装元素值,放在该位置
       */
        tab[i] = newNode(hash, key, value, null);
    else {
      // 若数组下标位置已经有值
        Node<K,V> e; K k;
        //3,首先判断数组位置两个key是否相同,e用来指向存在相同key的节点。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
  //4,key不相同的情况下,判断数组该下标位置连接的是链表还是树节点
 //若是树结构,就将该值放入树中(放入树中操作,也会遍历树判断是否存在相同的key的节点,若无相同会以新节点插入树中)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        //数组该下标位置只有1个节点或者链表:这里统一为链表形式
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                //5,没有找到相同key的元素,在链表后插入新节点
                    p.next = newNode(hash, key, value, null);
                /**此处为什么要>=7,因为bincount从0开始递增,当bincount=7时,
                *此时for循环执行了7次,加上新增加的节点,以及数组下标位置节点
                *共7+1+1= 9了,即该数组下标位置链表长度大于8了,需要转化为红黑树
                */
                 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //遍历链表比较判断是否存在两个key相同元素
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
  //有上面 e= p.next,这里 p有重新被赋值为e,即继续遍历链表下一个元素    
                p = e;
            }
        }
        // 存在相同的key的元素,可能在数组、链表、树上 
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
       //6,覆盖旧值,返回旧值,也可设置仅仅当旧value存在,不为null情况才覆盖原值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //修改次数+1
    ++modCount;
    //7,判断整个map中元素个数是否大于阈值,大于则扩容,由此可见是先插入元素再判断容量大小是否扩容,区别1.7先扩容再插入新值
    if (++size > threshold)
        resize();
    //该方法为空,不用理会
    afterNodeInsertion(evict);
    return null;
}
5.2 resize() 扩容方法

该方法有个比较有意思的是将旧数组的元素移到扩大为原来容量2倍的新数组中时,原来数组中链表需要进行拆链,非常巧妙,下见。

final Node<K,V>[] resize() {

    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //1,原来数组容量>0情况
    if (oldCap > 0) {
        //如果数组容量已经为最大值了 2^30,那就仅仅是将扩容阈值修改
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
   //1.1 如果原来数组容量>=16,且其2倍小于最大容量(oldCap<<1 等价于 oldCap*2^1)
    // 阈值也扩容为原来2倍  
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
     //1.2 什么情况数组容量=0,阈值>0呢?
     //创建HashMap,指定了数组初始容量cap,会将其转换为大于等于cap的最小的2的幂次方的数赋值给threshold,这里即将数组容量赋值
         newCap = oldThr;
    else {         // zero initial threshold signifies using defaults
     //1.3原来数组容量和阈值都为0,即常用的无参构造方式,使用默认容量大小
     //阈值根据容量和负载因子算出   
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //2,newThr = 0情况出现在1.1中,没满足条件扩大为原来2倍
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
   //小于最大容量,则用容量和加载因子乘积,否则为 Integer最大值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    //3,创建新数组对象
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //4,原数组对象不为空,需要将原数组元素移到新数组中
    if (oldTab != null) {
       //遍历旧数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //数组下标位置有元素
            if ((e = oldTab[j]) != null) {
        //直接把该下标位置链表(或红黑树)取出来,1个元素也当做链表看待,原数组该位置置空,只要我们拿着链表头节点/树根节点(e)就行
                oldTab[j] = null;
                if (e.next == null)
              //该位置只有1个元素,直接与新数组长度hash,确定位置放入新数组
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                 //该位置是一颗红黑树,迁移到新数组
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
     //5,重点解析:这里将原数组链表拆分为2条链表放入新数组,那它是怎么拆的呢?
    // loHead,loTail(lo 理解为low缩写)分别记录放在新数组下标小的那一条链表的头节点和尾结点,
    // 同理hiHead,hiTail(hi理解为 hight缩写)放在新数组下标大的位置
                    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-1)求得是数组下标,e.hash & oldCap 
       // 是为了获取比oldCap-1更高的那位一是0还是1,是0的就留在原位,是1的话需要增加oldCap。这里不易理解,下面详解
                        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;
}

扩容拆链过程分析:

#1,扩容前位置
23&15= 7 
0001 0111
 &
0000 1111
---------
0000 0111
#2,扩容后位置 
23&31= 23
0001 0111
 &
0001 1111
---------
0001 0111
/**因为每次扩容都是old数组长度的2倍,那么在要计算在扩容后新数组位置,
那么只需要关心key的hash值左边新增计算的那位是0还是1,如上未扩容前23与15与运算,只需关心低4位值,高位无论其是否有1,与运算后都会为0; 而扩容后,15变为31,那么key的hash值影响计算结果位由4位变为5位,
因此 e.hash & oldCap的结果就是获取新增的位置,000X 0000,即X的值,0或者1;
0运算结果不变,放在原位,1与运算结果会增加一个原数组长度。
*/

如下图3所示:

7 & 7=7
0000 0111
0000 0111
----------
23 & 7=7
0001 0111
0000 0111
----------
31 & 7=7
0001 1111
0000 0111
----------
15 & 7=7
0000 1111
0000 0111
#可见第4位(从右向左)为1的有15,31,扩容后位置:原有下标+原数组长度

拆链位运算实现的巧妙:

1,扩容迁移原有数组中的元素,不用再重复计算原有元素key的hash值,提高效率.

2,扩容后拆链,会将链表长度缩短,减少hash冲突,提高查找效率.

六、get方法

根据键值对中的键(key)获取值

public V get(Object key) {
    Node<K,V> e;
    //获取节点赋值给e,e!=null, 返回该键值对的value值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

下见方法:getNode(int hash, Object key)

final Node<K,V> getNode(int hash, Object key) {
   //传入的hash值与put方法一样的方法,相同规则计算key的hash值
    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) {
        //1,获取数组下标位置的第一个节点,first
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && 
            key.equals(k))))
            //2,检查是否为相同的key,是直接返回该节点对象;不是则遍历下一个节点
            return first;
        if ((e = first.next) != null) {
            //3,下一个节点存在,需判断是红黑树,还是链表
            if (first instanceof TreeNode)
              //3.1红黑树则遍历树获取是否存在与该key相同的节点
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
               //3.2 链表则依次遍历,查找相同的key,找到则返回
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    //数组为空,或者没有找到返回空
    return null;
}
posted @ 2020-08-11 09:55  夕阳下飞奔的猪  阅读(457)  评论(0编辑  收藏  举报