zoukankan      html  css  js  c++  java
  • HashMap源码赏析(1.8)

    一、简介

    HashMap源码看过无数遍了,但是总是忘,好记性不如烂笔头。

    本文HashMap源码基于JDK8。

    文章将全面介绍HashMap的源码及HashMap存在的诸多问题。

    开局一张图,先来看看hashmap的结构。

    二、历史版本

    再次声明一下本文HashMap源码基于JDK8。不同版本HashMap的变化还是比较大的,在1.8之前,HashMap没有引入红黑树,也就是说HashMap的桶(桶即hashmap数组的一个索引位置)单纯的采取链表存储。这种结构虽然简单,但是当Hash冲突达到一定程度,链表长度过长,会导致时间复杂度无限向O(n)靠近。比如向HashMap中插入如下元素,你会神奇的发现,在HashMap的下表为1的桶中形成了一个链表。

    1 map.put(1, 1);
    2 map.put(17,17);
    3 map.put(33,33);
    4 map.put(49,49);
    5 map.put(65,65);
    6 map.put(81,81);
    7 map.put(97,97);
    ...
    16^n + 1

    为了解决这种简单的底层存储结构带来的性能问题,引入了红黑树。在一定程度上缓解了链表存储带来的性能问题。引入红黑树之后当桶中链表长度超过8将会树化即转为红黑树(put触发)。当红黑树元素少于6会转为链表(remove触发)。

    在这里还有一个很重要的知识点,树化和链表化的阈值不一样?想一个极端情况,假设阈值都是8,一个桶中链表长度为8时,此时继续向该桶中put会进行树化,然后remove又会链表化。如果反复put和remove。每次都会进行极其耗时的数据结构转换。如果是两个阈值,将会形成一个缓冲带,减少这种极端情况发生的概率。

    上面这种极端情况也被称之为复杂度震荡

    类似的复杂度震荡问题ArrayList也存在。

    三、基础知识

    3.1,常量和构造方法

     1 // 16 默认初始容量(这个容量不是说map能装多少个元素,而是桶的个数)
     2 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
     3 // 最大容量值
     4 static final int MAXIMUM_CAPACITY = 1 << 30; 
     5 // 默认负载因子
     6 static final float DEFAULT_LOAD_FACTOR = 0.75f;
     7 //树化阈值 一个桶链表长度超过 8 进行树化
     8 static final int TREEIFY_THRESHOLD = 8;
     9 //链表化阈值 一个桶中红黑树元素少于 6 从红黑树变成链表
    10 static final int UNTREEIFY_THRESHOLD = 6;
    11 //最小树化容量,当容量未达到64,即使链表长度>8,也不会树化,而是进行扩容。
    12 static final int MIN_TREEIFY_CAPACITY = 64;
    13 //桶数组,bucket. 这个也就是hashmap的底层结构。
    14 transient Node<K,V>[] table;
    15 //数量,即hashmap中的元素数量
    16 transient int size;
    17 //hashmap进行扩容的阈值。 (这个表示的元素多少,可不是桶被用了多少哦,比如阈值是16,当有16个元素就进行扩容,而不是说当桶被用了16个)
    18 int threshold;
    19 //当前负载因子,默认是 DEFAULT_LOAD_FACTOR=0.75
    20 final float loadFactor;
    21 /************************************三个构造方法***************************************/
    22 public HashMap(int initialCapacity, float loadFactor) {//1,初始化容量2,负载因子
    23     if (initialCapacity < 0)
    24         throw new IllegalArgumentException("Illegal initial capacity: " +
    25                                            initialCapacity);
    26     if (initialCapacity > MAXIMUM_CAPACITY)// > 不能大于最大容量
    27         initialCapacity = MAXIMUM_CAPACITY;
    28     if (loadFactor <= 0 || Float.isNaN(loadFactor))
    29         throw new IllegalArgumentException("Illegal load factor: " +
    30                                            loadFactor);
    31     this.loadFactor = loadFactor;
    32     this.threshold = tableSizeFor(initialCapacity);//总要保持 初始容量为 2的整数次幂
    33 }
    34 public HashMap(int initialCapacity) {
    35     this(initialCapacity, DEFAULT_LOAD_FACTOR);
    36 }
    37 public HashMap() {
    38     this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    39 }

    3.2、桶的两种数据结构

    前面说了,JDK8 HashMap采用的是链表+红黑树。

    链表结构

     1 static class Node<K,V> implements Map.Entry<K,V> {
     2     final int hash;
     3     final K key;
     4     V value;
     5     Node<K,V> next;
     6 
     7     Node(int hash, K key, V value, Node<K,V> next) {
     8         this.hash = hash;
     9         this.key = key;
    10         this.value = value;
    11         this.next = next;
    12     }
    13 }

    红黑树结构

     1 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
     2     TreeNode<K,V> parent;  // red-black tree links
     3     TreeNode<K,V> left;
     4     TreeNode<K,V> right;
     5     TreeNode<K,V> prev;    // needed to unlink next upon deletion
     6     boolean red;
     7     TreeNode(int hash, K key, V val, Node<K,V> next) {
     8         super(hash, key, val, next);
     9     }
    10 }

    3.3、hash算法实现

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    计算桶下标方法

    (n - 1) & hash//n表示HashMap的容量。 相当于取模运算。等同于 hash % n。

    n其实说白了就是HashMap底层数组的长度。(n-1) & hash这个与运算,等同于hash % n。

    hash()方法,只是key的hashCode的再散列,使key更加散列。而元素究竟存在哪个桶中。还是 (n - 1) & hash 结果决定的。

    综合一下如下,在hashmap中计算桶索引的方法如下所示。

    public static int index(Object key, Integer length) {
        int h;
        h = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        return (length - 1) & h;
    }

    假设当前hashmap桶个数即数组长度为16,现在插入一个元素key。

     计算过程如上图所示。得到了桶的索引位置。

    在上面计算过程中,只有一步是比较难以理解的。也就是为什么不直接拿 key.hashcode() & (n - 1) ,为什么要用 key.hashcode() ^ (key.hashcode() >>> 16) 为什么要多一步呢?后面问题总结会详细介绍。

    四、HashMap put过程源码

     1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
     2                boolean evict) {
     3     Node<K,V>[] tab; Node<K,V> p; int n, i;
     4     //put1,懒加载,第一次put的时候初始化table(node数组)
     5     if ((tab = table) == null || (n = tab.length) == 0)
     6         //resize中会进行table的初始化即hashmap数组初始化。
     7         n = (tab = resize()).length;
     8     //put2,(n - 1) & hash:计算下标。// put3,判空,为空即没hash碰撞。直接放入桶中
     9     if ((p = tab[i = (n - 1) & hash]) == null)
    10         //将数据放入桶中
    11         tab[i] = newNode(hash, key, value, null);
    12     else {//put4,有hash碰撞
    13         Node<K,V> e; K k;
    14         //如果key已经存在,覆盖旧值
    15         if (p.hash == hash &&
    16             ((k = p.key) == key || (key != null && key.equals(k))))
    17             e = p;
    18         //put4-3:如果是红黑树直接插入
    19         else if (p instanceof TreeNode)
    20             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    21         else {//如果桶是链表,存在两种情况,超过阈值转换成红黑树,否则直接在链表后面追加
    22             for (int binCount = 0; ; ++binCount) {
    23                 //put4-1:在链表尾部追加
    24                 if ((e = p.next) == null) {
    25                     p.next = newNode(hash, key, value, null);
    26                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    27                         //put4-2:链表长度超过8,树化(转化成红黑树)
    28                         treeifyBin(tab, hash);
    29                     break;
    30                 }
    31                 if (e.hash == hash &&
    32                     //如果key已经存在,覆盖旧值
    33                     ((k = e.key) == key || (key != null && key.equals(k))))
    34                     break;
    35                 p = e;
    36             }
    37         }
    38         //put5:当key已经存在,执行覆盖旧值逻辑。
    39         if (e != null) { // existing mapping for key 
    40             V oldValue = e.value;
    41             if (!onlyIfAbsent || oldValue == null)
    42                 e.value = value;
    43             afterNodeAccess(e);
    44             return oldValue;
    45         }
    46     }
    47     ++modCount;
    48     if (++size > threshold)//put6,当size > threshold,进行扩容。
    49         resize();
    50     afterNodeInsertion(evict);
    51     return null;
    52 }

    其实上面put的逻辑还算是比较清晰的。(吐槽一下JDK源码,可读性真的不好,可读性真的不如Spring。尤其是JDK中总是在if或者for中对变量进行赋值。可读性真的差。但是逻辑是真的经典)

    总结一下put的过程大致分为以下8步。

    1,懒汉式,第一次put才初始化table桶数组。(节省内存,时间换空间)
    2,计算hash及桶下标。
    3,未发生hash碰撞,直接放入桶中。
    4,发生碰撞
        4.1、如果是链表,迭代插入到链表尾部。
        4.2、如果链表长度超过8,树化即转换为红黑树。(当数组长度小于64时,进行扩容而不是树化)
        4.3、如果是红黑树,插入到红黑树中。
    5,如果在以上过程中发现key已经存在,覆盖旧值。
    6,如果size > threshold。进行扩容。

    以上过程中,当链表长度超过8进行树化,只是执行树化方法 treeifyBin(tab, hash); 。但是在该方法中还有一步判断,也就是当桶数组长度<64。并不会进行树化,而是进行扩容。你想想,假如容量为16,你就插入了9个元素,巧了,都在同一个桶里面,如果这时进行树化,树化本身就是一个耗时的过程。时间复杂度会增加,性能下降,不如直接进行扩容,空间换时间。

    看看这个方法

     1 final void treeifyBin(Node<K,V>[] tab, int hash) {
     2     int n, index; Node<K,V> e;
     3     if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)//如果容量 < 64则直接进行扩容;不转红黑树。(你想想,假如容量为16,你就插入了9个元素,巧了,都在同一个桶里面,如果这时进行树化,时间复杂度会增加,性能下降,不如直接进行扩容,空间换时间)
     4         resize();
     5     else if ((e = tab[index = (n - 1) & hash]) != null) {
     6         TreeNode<K,V> hd = null, tl = null;
     7         do {
     8             TreeNode<K,V> p = replacementTreeNode(e, null);
     9             if (tl == null)
    10                 hd = p;
    11             else {
    12                 p.prev = tl;
    13                 tl.next = p;
    14             }
    15             tl = p;
    16         } while ((e = e.next) != null);
    17         if ((tab[index] = hd) != null)
    18             hd.treeify(tab);
    19     }
    20 }

    在put逻辑中还有最重要的一个过程也就是扩容。

    五、扩容

    5.1、扩容

     1 final Node<K,V>[] resize() {
     2     Node<K,V>[] oldTab = table;
     3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
     4     int oldThr = threshold;
     5     int newCap, newThr = 0;
     6     if (oldCap > 0) {
     7         // 大于最大容量,不进行扩容(桶数量固定)
     8         if (oldCap >= MAXIMUM_CAPACITY) {
     9             threshold = Integer.MAX_VALUE;
    10             return oldTab;
    11         }
    12         //扩容为原来的两倍,<< 位运算
    13         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
    14                  oldCap >= DEFAULT_INITIAL_CAPACITY)
    15             newThr = oldThr << 1; //threshold不在重新计算,同样直接扩容为原来的两倍
    16     }
    17     else if (oldThr > 0) // initial capacity was placed in threshold
    18         newCap = oldThr;
    19     else {               // zero initial threshold signifies using defaults
    20         newCap = DEFAULT_INITIAL_CAPACITY;
    21         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    22     }
    23     if (newThr == 0) {
    24         float ft = (float)newCap * loadFactor;
    25         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
    26                   (int)ft : Integer.MAX_VALUE);
    27     }
    28     threshold = newThr;
    29     @SuppressWarnings({"rawtypes","unchecked"})
    30         //创建新的桶(原来的两倍)
    31         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    32     table = newTab;
    33     if (oldTab != null) {
    34         for (int j = 0; j < oldCap; ++j) {//一共oldCap个桶
    35             Node<K,V> e;
    36             if ((e = oldTab[j]) != null) {//如果第j个桶没元素就不管了
    37                 oldTab[j] = null;
    38                 //只有一个元素,直接移到新的桶中(为什么不先判断是不是TreeNode?
    39                 //很简单,因为TreeNode没有next指针,在此一定为null,也能证明是一个元素。
    40                 //对于大多数没有hash冲突的桶,减少了判断,处处充满着智慧)
    41                 if (e.next == null)
    42                     //计算桶下标,e.hash & (newCap - 1)是newCap哦
    43                     newTab[e.hash & (newCap - 1)] = e;
    44                 else if (e instanceof TreeNode)
    45                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
    46                 else { // rehash  源码很经典
    47                     Node<K,V> loHead = null, loTail = null;//下标保持不变的桶
    48                     Node<K,V> hiHead = null, hiTail = null;//下标扩容两倍后的桶
    49                     Node<K,V> next;
    50                     do {
    51                         next = e.next;
    52                         if ((e.hash & oldCap) == 0) {//判断成立,说明该元素不用移动
    53                             if (loTail == null)//尾空,头插
    54                                 loHead = e;
    55                             else//尾不空,尾插
    56                                 loTail.next = e;
    57                             loTail = e;
    58                         }
    59                         else {//判断不成立,说明该元素要移位到 (j + oldCap) 位置
    60                             if (hiTail == null)
    61                                 hiHead = e;
    62                             else
    63                                 hiTail.next = e;
    64                             hiTail = e;
    65                         }
    66                     } while ((e = next) != null);
    67                     if (loTail != null) {
    68                         loTail.next = null;
    69                         newTab[j] = loHead;//j 即oldIndex
    70                     }
    71                     if (hiTail != null) {
    72                         hiTail.next = null;
    73                         newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
    74                     }
    75                 }
    76             }
    77         }
    78     }
    79     return newTab;
    80 }

    从以上源码总计一下扩容的过程:

    1,创建一个两倍于原来(oldTab)容量的数组(newTab)。
    2,遍历oldTab
        2.1,如果当前桶没有元素直接跳过。
        2.2,如果当前桶只有一个元素,直接移动到newTab中的索引位。(e.hash & (newCap - 1))
        2.3,如果当前桶为红黑树,在split()方法中进行元素的移动。
        2.4,如果当前桶为链表,执行链表的元素移动逻辑。

    在以上过程中,我们着重介绍链表的元素移动。也就是上述代码中的39-68行。

    首先,我们看其中

    1 Node<K,V> loHead = null, loTail = null;//下标保持不变的桶       
    2 Node<K,V> hiHead = null, hiTail = null;//下标扩容两倍后的桶

    loHead和loTail分别对应经过rehash后下标保持不变的元素形成的链表头和尾。

    hiHead和hiTail分别对应经过rehash后下标变为原来(n + oldIndex)后的链表头和尾。

    经过上面变量,我们不难发现,桶中的数据只有两个去向。(oldIndex和 n + oldIndex)

    接下来我们思考一个问题。为什么经过rehash,一个桶中的元素只有两个去向?

    以下过程很烧脑,但是看懂了保证会收获很多。 更会体会到源码之美

    大致画一下图,如下所示。

     

    HashMap的容量总是2的n次方(n <= 32)。

    假设扩容前桶个数为16。

     看扩容前后的结果。观察扩容前后可以发现,唯一影响索引位的是hash的低第5位。

    所以分为两种情况hash低第5位为0或者1。

    1 当低第5位为0:newIndex = oldIndex
    2 当低第5位为1:newIndex = oldIndex + oldCap

    以上过程也就说明了为啥rehash后一个桶中的元素只有两个去向。这个过程我看没有博客介绍过。为什么在这里详细介绍这个呢?因为这个很重要,不懂这个就看不懂以上rehash代码,也很难体会到JDK源码的经典之处。给ConcurrentHashMap rehash时的锁打一个基础。

    回到源码52行。

    if ((e.hash & oldCap) == 0)

    这个判断成立,则说明该元素在rehash后下标不变,还在原来的索引位置的桶中。为什么?

     我们先看一下 (e.hash & oldCap) 

     看结果,如果判断 if ((e.hash & oldCap) == 0) 成立,也就是说hash的低第5位为0。

    在上个问题我们推导桶中元素的两个去向的时候,发现低第5位的两种情况决定了该元素的去向。再观察上面问题推导中的hash的第一种情况当*为0;

     惊不惊喜,意不意外,神奇的发现,当hash低5位为0时,其新索引为依然为oldIndex。OK,你不得不佩服作者的脑子为何如此聪明。当然了这一切巧妙的设计都是建立在hashmap桶的数量总是2的n次方。

    回到源码,如下。很简单了,将新的两个链表分别放到newTab的oldIndex位置和newIndex位置。正如我们上面推导的那样

    1 if (loTail != null) {
    2     loTail.next = null;
    3     newTab[j] = loHead;//j 即oldIndex
    4 }
    5 if (hiTail != null) {
    6     hiTail.next = null;
    7     newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
    8 }

    以上resize过程就说完了。

    留一个问题,以上resize过程性能还能不能进一步优化呢?有兴趣的可以对比ConcurrentHashMap的这个rehash源码。你会神奇的发现JDK8的作者为了性能究竟有多拼。

    当然resize过程在并发环境下还是存在一定问题的。接下来继续往下看。

    5.2、JDK7并发环境扩容问题——循环链表

    先看源码

     1  //将当前所有的哈希表数据复制到新的哈希表
     2 void transfer(Entry[] newTable, boolean rehash) {
     3        int newCapacity = newTable.length;
     4        //遍历旧的哈希表
     5        for (Entry<K,V> e : table) {
     6            while(null != e) {
     7                //保存旧的哈希表对应的链表头的下一个结点
     8                Entry<K,V> next = e.next;
     9                if (rehash) {
    10                    e.hash = null == e.key ? 0 : hash(e.key);
    11                }
    12                //因为哈希表的长度变了,需要重新计算索引
    13                int i = indexFor(e.hash, newCapacity);
    14                //第一次循环的newTable[i]为空,赋值给当前结点的下一个元素,
    15                e.next = newTable[i];
    16                //将结点赋值到新的哈希表
    17                newTable[i] = e;
    18                e = next;
    19            }
    20        }
    21 }

    JDK7 hashmap采用的是头插法,也就是每put一个元素,总是插入到链表的头部。相对于JDK8尾插法,插入操作时间复杂度更低。

    看上面transfer方法。假设扩容前数组长度为2,扩容后即长度为4。过程如下。(以下几张图片来自慕课网课程)

    第一步:处理节点5,resize后还在原来位置。

    第二步:处理节点9,resize后还在原来位置。头插,node(9).next = node(5);

    第三步:处理节点11,resize后在索引位置3处。移动到新桶中。

    并发环境下的问题

    假设此时有两个线程同时put并同时触发resize操作。

    线程1执行到,只改变了旧的链表的链表头,使其指向下一个元素9。此时线程1因为分配的时间片已经用完了。

    紧接着线程2完成了整个resize过程。

    线程1再次获得时间片,继续执行。解释下图,因为节点本身是在堆区。两个线程栈只是调整链表指针的指向问题。

    当线程2执行结束后,table这个变量将不是我们关注的重点,因为table是两个线程的共享变量,线程2已经将table中的变量搬运完了。但是由于线程1停止的时间如上,线程1的工作内存中依然有一个变量next是指向9节点的。明确了这一点继续往下看。

    当线程2执行结束。线程1继续执行,newTable[1]位置是指向节点5的。如下图。

    如上图线程1的第一次while循环结束后,注意 e = next 这行代码。经过第一次循环后,e指向9。如下图所示。

    按理来说此时如果线程1也结束了也没啥事了,但是经过线程2的resize,9节点时指向5节点的,如上图。所以线程1按照代码逻辑来说,依然没有处理完。然后再将5节点插入到newTable中,5节点继续指向9节点,这层循环因为5.next==null,所以循环结束(自己看代码逻辑哦,e是在while之外的,所以这里不会死循环)。如下图所示,循环链表形成。

    然后在你下一次进行get的时候,会进入死循环。

    最后想一下JDK7会出现死循环的根源在哪里?很重要哦这个问题,根源就在于JDK7用的是头插法,而resize又是从头开始rehash,也就是在老的table中本来是头的,到新table中便成为了尾,改变了节点的指向。

    5.3、JDK8的数据丢失问题

    上面介绍了JDK7中循环链表的形成,然后想想JDK8中的resize代码,JDK8中的策略是将oldTab中的链表拆分成两个链表然后再将两个链表分别放到newTab中即新的数组中。在JDK8会出现丢失数据的现象(很好理解,在这里就不画图了,感兴

    趣的自己画一下),但是不会出现循环链表。丢数据总比形成死循环好吧。。。另外一点JDK8的这种策略也间接的保证了节点间的相对顺序。

    好吧,还是说说JDK8的丢数据问题吧。

     1 do {
     2     next = e.next;
     3     if ((e.hash & oldCap) == 0) {//判断成立,说明该元素不用移动
     4         if (loTail == null)//尾空,头插
     5             loHead = e;
     6         else//尾不空,尾插
     7             loTail.next = e;
     8         loTail = e;
     9     }
    10     else {//判断不成立,说明该元素要移位到 (j + oldCap) 位置
    11         if (hiTail == null)
    12             hiHead = e;
    13         else
    14             hiTail.next = e;
    15         hiTail = e;
    16     }
    17 } while ((e = next) != null);
    18 if (loTail != null) {
    19     loTail.next = null;
    20     newTab[j] = loHead;//j 即oldIndex
    21 }
    22 if (hiTail != null) {
    23     hiTail.next = null;
    24     newTab[j + oldCap] = hiHead; //j + oldCap即newIndex
    25 }

    假设两个线程,根据代码逻辑,线程1执行了4次循环让出时间片,如下图所示。

     此时链表table索引1位置的桶如下所示

     如果此时线程2也进行resize。此时线程2看到的oldTab是如上图所示的。很明显,接下来线程1执行完成,并顺利将两个链表放到了newTab中。

    此时线程2又获取时间片并继续执行以下操作相当于之前线程1的resize结果被线程2覆盖了。此时就发生了数据的丢失。

    终于介绍完了扩容过程,不容易啊。

    六、HashMap get过程源码

     1 public V get(Object key) {
     2     Node<K,V> e;
     3     return (e = getNode(hash(key), key)) == null ? null : e.value;//get1,计算hash
     4 }
     5 final Node<K,V> getNode(int hash, Object key) {
     6     Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
     7     if ((tab = table) != null && (n = tab.length) > 0 &&
     8         (first = tab[(n - 1) & hash]) != null) {// get2,(n - 1) & hash 计算下标
     9         if (first.hash == hash && // always check first node //get3-1,首先检查第一个元素(头元素),如果是目标元素,直接返回
    10             ((k = first.key) == key || (key != null && key.equals(k))))
    11             return first;
    12         if ((e = first.next) != null) {
    13             if (first instanceof TreeNode)//get3-2,红黑树
    14                 return ((TreeNode<K,V>)first).getTreeNode(hash, key);
    15             do {//get3-3,链表
    16                 if (e.hash == hash &&
    17                     ((k = e.key) == key || (key != null && key.equals(k))))
    18                     return e;
    19             } while ((e = e.next) != null);
    20         }
    21     }
    22     return null;
    23 }

    看完了put的源码,会发现get过程是何其简单,大致过程如下

    1,计算hash
    2,计算下标
    3,获取桶的头节点,如果头结点key等于目标key直接返回。
        3.1,如果是链表,执行链表迭代逻辑,找到目标节点返回。
        3.2,如果是红黑树,执行红黑树迭代逻辑,找到目标节点返回。

    关于remove方法,不介绍了,无非就是就是get过程+红黑树到链表的转化过程。不介绍了。

    七、问题总结

     7.1、为什么hashmap的容量必须是2的n次方。

    回顾一下计算下标的方法。即计算key在数组中的索引位。

    hash&(n - 1)

    其中n就是hashmap的容量也就是数组的长度。

    假设n是奇数。则n-1就是偶数。偶数二进制中最后一位一定是0。所以如上图所示, hash&(n - 1) 最终结果二进制中最后一位一定是0,也就意味着结果一定是偶数。这会导致数组中只有偶数位被用了,而奇数位就白白浪费了。无形中浪费了内存,同样也增加了hash碰撞的概率。

    其中n是2的n次方保证了(两个n不一样哦,别较真)hash更加散列,节省了内存。

    难道不能是偶数吗?为啥偏偏是2的n次方?

    2的n次方能保证(n - 1)低位都是1,能使hash低位的特征得以更好的保留,也就是说当hash低位相同时两个元素才能产生hash碰撞。换句话说就是使hash更散列。

    呃。。。个人觉得2在程序中是个特殊的数字,通过上文resize中的关于二进制的一堆分析也是建立在容量是2的n次方的基础上的。虽然这个解释有点牵强。如果大家有更好的解释可以在下方留言。

    两层含义:

    1,从奇偶数来解释。

    2,从hash低位的1能使得hash本身的特性更容易得到保护方面来说。(很类似源码中hash方法中 <<< 16的做法)

    7.2、解决hash冲突的方法

    hashmap中解决hash冲突采用的是链地址法,其实就是有冲突了,在数组中将冲突的元素放到链表中。

    一般有以下四种解决方案。详情度娘。

    1 链地址法
    2 开放地址法
    3 再哈希法
    4 建立公共溢出区

    7.3、HashMap、HashTable、ConcurrentHashMap区别

    HashMap是不具备线程安全性的。

    HashTable是通过Synchronized关键字修饰每一个方法达到线程安全的。性能很低,不建议使用。

    ConcurrentHashMap很经典,Java程序员必精通。下篇文章就介绍ConcurrentHashMap。该类位于J.U.C并发包中,为并发而生。

    7.4、如何保证HashMap的同步

    Map map = Collections.synchronizedMap(new HashMap());其实其就是给HashMap的每一个方法加Synchronized关键字。

    性能远不如ConcurrentHashMap。不建议使用。

    7.5、为什么引入红黑树

    这个问题很简单,因为红黑树的时间复杂度表现更好为O(logN),而链表为O(N)。

    为什么红黑树这么好还要用链表?

    因为大多数情况下hash碰撞导致的单个桶中的元素不会太多,太多也扩容了。只是极端情况下,当链表太长会大大降低HashMap的性能。所以为了应付这种极端情况才引入的红黑树。当桶中元素很少比如小于8,维护一个红黑树是比较耗时的,因为红黑树需要左旋右旋等,也很耗时。在元素很少的情况下的表现不如链表。

    一般的HashMap的时间复杂度用平均时间复杂度来分析。除了极端情况链表对HashMap整体时间复杂度的表现影响比较小。

    7.6、为什么树转链表和链表转树阈值不同

    其实上文中已经介绍了,因为复杂度震荡。详情请参考上文。

    7.7、Capacity的计算

    变相问一下这个问题就是当初始化hashMap时initialCapacity参数传的是18,HashMap的容量是什么?是32。

    1     static final int tableSizeFor(int cap) {
    2         int n = cap - 1;
    3         n |= n >>> 1;
    4         n |= n >>> 2;
    5         n |= n >>> 4;
    6         n |= n >>> 8;
    7         n |= n >>> 16;
    8         return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    9     }

    该方法大意:如果cap不是2的n次方则取大于cap的最小的2的n次方的值。当然这个值不能超过MAXIMUM_CAPACITY 。

    (这里对这个方法没怎么看懂,明白的大神们回应在留言区指教。)

    7.8、为什么默认的负载因子loadFactor = 0.75

     1      * Because TreeNodes are about twice the size of regular nodes, we
     2      * use them only when bins contain enough nodes to warrant use
     3      * (see TREEIFY_THRESHOLD). And when they become too small (due to
     4      * removal or resizing) they are converted back to plain bins.  In
     5      * usages with well-distributed user hashCodes, tree bins are
     6      * rarely used.  Ideally, under random hashCodes, the frequency of
     7      * nodes in bins follows a Poisson distribution
     8      * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     9      * parameter of about 0.5 on average for the default resizing
    10      * threshold of 0.75, although with a large variance because of
    11      * resizing granularity. Ignoring variance, the expected
    12      * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
    13      * factorial(k)). The first values are:
    14      *
    15      * 0:    0.60653066
    16      * 1:    0.30326533
    17      * 2:    0.07581633
    18      * 3:    0.01263606
    19      * 4:    0.00157952
    20      * 5:    0.00015795
    21      * 6:    0.00001316
    22      * 7:    0.00000094
    23      * 8:    0.00000006
    24      * more: less than 1 in ten million

    源码中有这么一段注释,重点就是  Poisson distribution 泊松分布。

    以上是桶中元素个数和出现的概率对照表。

    意思就是说当负载因子为0.75的时候,桶中元素个数为8的概率几乎为零。

    通过泊松分布来看,0.75是"空间利用率"和"时间复杂度"之间的折衷。关于这个请参考《为什么默认的负载因子是0.75》

    7.9、HashMap中为什么用位运算而不是取模运算

    主要是位运算在底层计算速度更快。

    简单证明一下

    1 long s1 = System.nanoTime();
    2 System.out.println(2147483640 % 16);//8
    3 long e1 = System.nanoTime();
    4 long s2 = System.nanoTime();
    5 System.out.println(2147483640 & 15);//8
    6 long e2 = System.nanoTime();
    7 System.out.println("取模时间:" + (e1 - s1));//取模时间:134200
    8 System.out.println("与运算时间:" + (e2 - s2));//与运算时间:15800

    题外话:还有一个刷leetcode题,二分法计算中心点。总结的经验,用除法会导致部分算法题超时。

    1 long s1 = System.nanoTime();
    2 System.out.println(1 + (2147483640 - 1) / 2);//1073741820
    3 long e1 = System.nanoTime();
    4 long s2 = System.nanoTime();
    5 System.out.println(1 + (2147483640 - 1) >> 1);//1073741820
    6 long e2 = System.nanoTime();
    7 System.out.println("除法时间:" + (e1 - s1));//除法时间:20100
    8 System.out.println("位运算时间:" + (e2 - s2));//位运算时间:15700

    注意:一般二分法用left + (right - left)/2;因为如果用(right+left)/2;right + left容易>Integer.MAX_VALUE;

      如有错误的地方还请留言指正。
      原创不易,转载请注明原文地址: https://www.cnblogs.com/hello-shf/p/12168181.html

       参考文献:

      https://www.jianshu.com/p/003256ce41ce

      https://www.cnblogs.com/morethink/p/7762168.html

  • 相关阅读:
    Git&GitHUP总结
    SpringBoot 与任务
    SpringBoot中Scheme的使用
    SpringBoot的错误处理机制
    thymeleaf公共页面元素抽取
    redis 配置文件介绍 redis.conf
    SpringBoot如何实现国际化
    AttributeError: 'str' object has no attribute 'lowerr' Python常见错误
    TypeError: 'list' object cannot be interpreted as an integer Python常见错误
    TypeError: myMethod() takes no arguments (1 given) Python常见错误
  • 原文地址:https://www.cnblogs.com/hello-shf/p/12168181.html
Copyright © 2011-2022 走看看