超详细的HashMap讲解与知识点总结

 

1、为什么用HashMap?

 

  • HashMap 是一个散列桶(数组和链表),它存储的内容是键值对 key-value 映射
  • HashMap 采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
  • HashMap 是非 synchronized,所以 HashMap 很快
  • HashMap 可以接受 null 键和值,而 Hashtable 则不能(原因就是 equlas() 方法需要对象,因为 HashMap 是后出的 API 经过处理才可以)
 

2、HashMap 的工作原理是什么?

 
类的属性:
public c1mplements Map<K,V>, Cloneable, Serializable {
   // 序列号
   private static final long serialVersionUID = 362498820763181265L;    
   // 默认的初始容量是16
   static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
   // 最大容量
   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的幂次倍
   transient Node<k,v>[] table;
   // 存放具体元素的集
   transient Set<map.entry<k,v>> entrySet;
   // 存放元素(键值对)的个数,注意这个不等于数组的长度。
   transient int size;
   // 每次扩容和更改map结构的计数器
   transient int modCount;  
   // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
   int threshold;
   // 加载因子
   final float loadFactor;
}

Node定义:

Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //用来定位数组索引位置
        final K key;
        V value;
        Node<K,V> next;   //链表的下一个node
 
 
        Node(int hash, K key, V value, Node<K,V> next) { ... }
        public final K getKey(){ ... }
        public final V getValue() { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}

  (1) HashMap 的底层具体存储方式

  HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。例如程序执行下面代码:

map.put("美团","小美");
    系统将调用"美团"这个key的方法适用于每个h ashCode()方法得到其hashCode 值(该 Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
   如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
    (2) HashMap 的几个重要字段
   在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子,默认0.75
int modCount;
int size; // 存放元素(键值对)的个数,注意这个不等于数组的长度。
  •  loadFactor

        加载因子是控制 组存放数据的疏密程度,loadFactor越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加(因为采用的是拉链式解决哈希碰撞,你存入的key-value对多了,自然链表也会增长),loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
  • threshold

      HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍(因为 table的长度length大小必须为2的n次方)。
  •   size
      HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。
  • modCount
     主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
    在HashMap中, 哈希桶数组table的长度length大小必须为2的n次方(一定是合数) ,这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考 http:// blog.csdn.net/liuqiyao_ 01/article/details/14475159 ,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
    这里存在一个问题,即·使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。本文不再对红黑树展开讨论,想了解更多红黑树数据结构的工作原理可以参考

 


 

(3)以下是具体的 put 过程(JDK1.8)

 
 
public V put(K key, V value) {
   return putVal(hash(key), key, value, false, true);
}
 
 
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未初始化或者长度为0,进行扩容
   if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;
   // (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;
       // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
       if (p.hash == hash &&
           ((k = p.key) == key || (key != null && key.equals(k))))
               // 将第一个元素赋值给e,用e来记录
               e = p;
       // hash值不相等,即key不相等;为红黑树结点
       else if (p instanceof TreeNode)
           // 放入树中
           e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
       
       // 为链表结点
       else {
           // 在链表最末插入结点
           for (int binCount = 0; ; ++binCount) {
               // 到达链表的尾部
               if ((e = p.next) == null) {
                   // 在尾部插入新结点
                   p.next = newNode(hash, key, value, null);
                   // 结点数量达到阈值,转化为红黑树
                   if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                       treeifyBin(tab, hash);
                   // 跳出循环
                   break;
               }
               // 判断链表中结点的key值与插入的元素的key值是否相等,以及val是否相等
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   // 相等,跳出循环
                   break;
               // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
               p = e;
           }
       }
       // 表示在桶中找到key值、hash值与插入元素相等的结点,则用新值替换旧值
       if (e != null) {
           // 记录e的value
           V oldValue = e.value;
           // onlyIfAbsent为false或者旧值为null
           if (!onlyIfAbsent || oldValue == null)
               //用新值替换旧值
               e.value = value;
           // 访问后回调
           afterNodeAccess(e);
           // 返回旧值
           return oldValue;
       }
   }
   // 结构性修改
   ++modCount;
   // 实际大小大于阈值则扩容
   if (++size > threshold)
       resize();
   // 插入后回调
   afterNodeInsertion(evict);
   return null;
}
 
// 在转换成树之前,先判断数组table长度是否大于64,小于的话则是直接扩容
final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) <  MIN_TREEIFY_CAPACITY)
            // 扩容,不转换
            resize();
        else if ((e = tab[index = (n - 1) & hash]) !=  null) {
            // 转化成红黑树节点
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e,  null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
 
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,(注意: 在转变成树之前,还会有一次判断,只有键值对数量大于 64(M IN_TREEIFY_CAPACITY : ) 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。) 在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

(4)以下是具体 get 过程

 
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) {
      
      // 数组元素相等
       if (first.hash == hash && // always check first node
           ((k = first.key) == key || (key != null && key.equals(k))))
           return first;
       // 桶中不止一个节点
       if ((e = first.next) != null) {
           // 在树中get
           if (first instanceof TreeNode)
               return ((TreeNode<K,V>)first).getTreeNode(hash, key);
           // 在链表中get
           do {
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   return e;
           } while ((e = e.next) != null);
       }
   }
   return null;
}
 
 
考虑特殊情况:如果两个键的 hashcode 相同,你如何获取值对象?
 
当我们调用 get() 方法,HashMap 会使用键对象的 hash() 找到 bucket 位置,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。(下图中应该是hash)

 

 

 
(5)以下是具体 reszie(扩容) 过程
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。
重新计算每个Entry新元素在新数组中的位置 () ,在旧数组中同一条链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上
 
1 void resize(int newCapacity) {   //传入新的容量
2     Entry[] oldTable = table;    //引用扩容前的Entry数组
3     int oldCapacity = oldTable.length;         
4     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
5         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6         return;
7     }
8  
9      Entry[] newTable = new Entry[newCapacity];   //初始化一个新的Entry数组
10     transfer(newTable);                         //!!将数据转移到新的Entry数组里
11     table = newTable;                           //HashMap的table属性引用新的Entry数组
12     threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }
 
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
 

 

1 void transfer(Entry[] newTable) {
2     Entry[] src = table;                   //src引用了旧的Entry数组
3     int newCapacity = newTable.length;
4     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
6         if (e != null) {
7             src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8             do {
9                  Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个Entry新元素在新数组中的位置 (),在旧数组中同一条链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
11                 e.next = newTable[i]; //标记[i] ,11行和12行相当于在newTable[i]这个单链表上将新元素插入链头,如果原来数组位置为null,则next指向的是nulljdk1.8中有区别
12                 newTable[i] = e;      //将元素放在数组上
13                 e = next;             //访问下一个Entry链上的元素
14             } while (e != null);
15         }
16     }
17 }
 
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod (取余)一下表的大小(也就是数组的长度)。其中的哈希桶数组table.size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
 
下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
 
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
 
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
 
 
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别, JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置 。有兴趣的同学可以研究下JDK1.8的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 (oldCap > 0) {
       // 超过最大值就不再扩充了,就只好随你碰撞去吧
       if (oldCap >= MAXIMUM_CAPACITY) {
           threshold = Integer.MAX_VALUE;
           return oldTab;
       }
       // 没超过最大值,就扩充为原来的2倍
       else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
           newThr = oldThr << 1; // double threshold
   }
   else if (oldThr > 0) // initial capacity was placed in threshold
       newCap = oldThr;
   else {
       // signifies using defaults
       newCap = DEFAULT_INITIAL_CAPACITY;
       newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
   }
   // 计算新的resize上限
   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) {
       // 把每个bucket都移动到新的buckets中
       for (int j = 0; j < oldCap; ++j) {
           Node<K,V> e;
           if ((e = oldTab[j]) != null) {
               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);
               else {
                   Node<K,V> loHead = null, loTail = null;
                   Node<K,V> hiHead = null, hiTail = null;
                   Node<K,V> next;
                   do {
                       next = e.next;
                       // 原索引
                       if ((e.hash & oldCap) == 0) {
                           if (loTail == null)
                               loHead = e;
                           else
                               loTail.next = e;
                           loTail = e;
                       }
                       // 原索引+oldCap
                       else {
                           if (hiTail == null)
                               hiHead = e;
                           else
                               hiTail.next = e;
                           hiTail = e;
                       }
                   } while ((e = next) != null);
                   // 原索引放到bucket里
                   if (loTail != null) {
                       loTail.next = null;
                       newTab[j] = loHead;
                   }
                   // 原索引+oldCap放到bucket里
                   if (hiTail != null) {
                       hiTail.next = null;
                       newTab[j + oldCap] = hiHead;
                   }
               }
           }
       }
   }
   return newTab;
}
 

 


3、有什么方法可以减少碰撞?

(1)扰动函数(也就是计算hash值的时候)可以减少碰撞
原理是如果两个不相等的对象返回不同的 hashcode 的话,那么碰撞的几率就会小些。这就意味着存链表结构减小,这样取值的话就不会频繁调用 equal 方法,从而提高 HashMap 的性能(扰动即 Hash 方法内部的算法实现,目的是让不同对象返回不同 hashcode)。
(2)使用不可变的、声明作 final 对象,并且采用合适的 equals() 和 hashCode() 方法,将会减少碰撞的发生
不可变性使得能够缓存不同键的 hashcode,这将提高整个获取对象的速度,使用 String、Integer 这样的 wrapper 类作为键是非常好的选择。

为什么 String、Integer 这样的 wrapper 类适合作为键?

因为 String 是 final,而且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。
 

4、HashMap 中 hash 函数怎么是实现的?以及重写hashcode()方法时的规则

 
我们可以看到,在 hashmap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。
 
前面说过,hashmap 的数据结构是数组和链表的结合,所以我们当然希望这个 hashmap 里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个。那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 所以,我们首先想到的就是把 hashcode 对数组长度取模运算。这样一来,元素的分布相对来说是比较均匀的。
 
但是“模”运算的消耗还是比较大的,能不能找一种更快速、消耗更小的方式?我们来看看 JDK1.8 源码是怎么做的(被楼主修饰了一下)
 
 
static final int hash(Object key){
    if (key == null){
        return0;
    }
    int h;
    h = key.hashCode();返回散列值也就是hashcode
    // ^ :按位异或
    // >>>:无符号右移,忽略符号位,空位都以0补齐
    //其中n是数组的长度,即Map的数组部分初始化长度
    return (n-1)&(h ^ (h >>> 16));
}
 
 
 

 

简单来说就是:
 
  • 高16 bit 不变,低16 bit 和高16 bit 做了一个异或(得到的 hashcode 转化为32位二进制,前16位和后16位低16 bit 和高16 bit 做了一个异或)
  • (n·1) & hash = -> 得到下标
 
重写 hashCode函数
在重写hashCode函数时,有以下规则:
hashCode函数的通项如下:
public int hashCode () {
int result = 17 ; //任意素数
result = 31 *result +c1; //c1,c2是什么看下文解释
result = 31 *result +c2;
return result;
}
其中c1,c2是我们生成的你要计算在内的字段的代码,生成规则如下:
  • 如果字段是boolean 计算为(f?1:0);
  • 如果字段是byte,char,short,int则计算为 (int)f;
  • 如果字段是long 计算为 (int)(f^(f>>32));
  • 如果字段是float 计算为 Float.floatToLongBits(f);
  • 如果字段是一个引用对象,那么直接调用对象的hashCode方法,如果需要判空,可以加上如果为空就返回0;
 

5、拉链法导致的链表过深,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树?为什么是6和8

 
      之所以选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。引入红黑树就是为了查找数据快,解决链表查询深度的问题。
     
       我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
      
      为什么是6和8

     1.TreeNodes占用空间是普通Nodes的两倍,为了空间和时间的权衡

     2.节点的分布频率会遵循泊松分布,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,举个例子说明,HashMap默认的table[].length=16,在长度为16的HashMap中放入12(0.75*length)个数据,某一个bin中存放了8个节点的概率是0.00000006,扩容一次,16*2=32,在长度为32的HashMap中放入24个数据,某一个bin中存放了8个节点的概率是0.00000006,再扩容一次,32*2=64,在长度为64的HashMap中放入48个数据,某一个bin中存放了8个节点的概率是0.00000006。

      至于为什么转化为红黑树的阈值8和转化为链表的阈值6不一样,是为了避免频繁来回转化

6、说说你对红黑树的见解?

 

 

  1. 每个节点非红即黑
  2. 根节点总是黑色的
  3. 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
  4. 每个叶子节点都是黑色的空节点(NIL节点)
  5. 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
 
 

7、解决 hash 碰撞还有那些办法?

 

开放定址法

 
当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。
 
下面给一个线性探查法的例子:
 
问题:已知一组关键字为 (26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。
解答:
为了减少冲突,通常令装填因子 α 由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为 (0,10,2,12,5,2,3,12,6,12)。
前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入 T[0]、T[10)、T[2]、T[12] 和 T[5] 中。
当插入第6个关键字15时,其散列地址2(即 h(15)=15%13=2)已被关键字 41(15和41互为同义词)占用。故探查 h1=(2+1)%13=3,此地址开放,所以将 15 放入 T[3] 中。
当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。
当插入第8个关键字12时,散列地址12已被同义词38占用,故探查 hl=(12+1)%13=0,而 T[0] 亦被26占用,再探查 h2=(12+2)%13=1,此地址开放,可将12插入其中。
类似地,第9个关键字06直接插入 T[6] 中;而最后一个关键字51插人时,因探查的地址 12,0,1,…,6 均非空,故51插入 T[7] 中。

 

8、如果 HashMap 的大小超过了负载因子(load factor)定义的容量怎么办?

 
HashMap 默认的负载因子大小为0.75。也就是说,当一个 Map 填满了75%的 bucket 时候,和其它集合类一样(如 ArrayList 等),将会创建原来 HashMap 大小的两倍的 bucket 数组来重新调整 Map 大小,并将原来的对象放入新的 bucket 数组中。这个过程叫作 rehashing
 
因为它调用 hash 方法找到新的 bucket 位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为 <原下标+原容量> 的位置。

 

9、重新调整 HashMap 大小存在什么问题吗?

 
重新调整 HashMap 大小的时候,确实存在条件竞争。
 
HashMap 的容量是有限的。当经过多次元素插入,使得 HashMap 达到一定饱和度时,Key 映射位置发生冲突的几率会逐渐提高。这时候, HashMap 需要扩展它的长度,也就是进行Resize。
 
  1. 扩容:创建一个新的 Entry 空数组,长度是原数组的2倍
  2. rehash:遍历原 Entry 数组,把所有的 Entry 重新 Hash 到新数组
 
 

10、为什么多线程会导致死循环,它是怎么发生的?

    因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来。因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。多线程的环境下不使用 HashMap。
    不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失
 
11、 为什么 哈希桶数组table的长度length大小必须为2的n次方?
  
   为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
    我们首先可能会想到采用%取余的操作来实现。但是,重点来了: “取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
 
 
 

 


12、HashTable

 
  • 数组 + 链表方式存储
  • 默认容量:11(质数为宜)
  • put操作:首先进行索引计算 (key.hashCode() & 0x7FFFFFFF)% table.length;若在链表中找到了,则替换旧值,若未找到则继续;当总元素个数超过 容量 * 加载因子 时,扩容为原来 2 倍并重新散列;将新元素加到链表头部
  • 对修改 Hashtable 内部共享数据的方法添加了 synchronized,保证线程安全

 

13、HashMap 与 HashTable 区别

 
  • 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  • 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  • 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  • 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

 


14、可以使用 CocurrentHashMap 来代替 Hashtable 吗?

 
  • 我们知道 Hashtable 是 synchronized 的,但是 ConcurrentHashMap 同步性能更好,因为它仅仅根据同步级别对 map 的一部分进行上锁
  • ConcurrentHashMap 当然可以代替 HashTable,但是 HashTable 提供更强的线程安全性
  • 它们都可以用于多线程的环境,但是当 Hashtable 的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。由于 ConcurrentHashMap 引入了分割(segmentation),不论它变得多么大,仅仅需要锁定 Map 的某个部分,其它的线程不需要等到迭代完成才能访问 Map。简而言之,在迭代的过程中,ConcurrentHashMap 仅仅锁定 Map 的某个部分,而 Hashtable 则会锁定整个 Map

 

15、CocurrentHashMap(JDK 1.7)

 
  • CocurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成
  • Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每个 HashEntry 一个链表结构的元素,利用 Hash 算法得到索引确定归属的数据段,也就是对应到在修改时需要竞争获取的锁。ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment
  • 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性
  • 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put 操作如下:
    1. 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
    2. 遍历该 HashEntry,如果不为空则判断传入的  key 和当前遍历的 key 是否相等,相等则覆盖旧的 value
    3. 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容
    4. 最后会解除在 1 中所获取当前 Segment 的锁
  • 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理
 
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁
 
  • 尝试自旋获取锁
  • 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁
 

15、CocurrentHashMap(JDK 1.8)

 
CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。其中的 val, next 都用了 volatile 修饰,保证了可见性。
 

最大特点是引入了 CAS

 
借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。Unsafe 借助 CPU 指令 cmpxchg 来实现。
 

CAS 使用实例

 
对 sizeCtl 的控制都是用 CAS 来实现的:
 
  • -1 代表 table 正在初始化
  • N 表示有 -N-1 个线程正在进行扩容操作
  • 如果 table 未初始化,表示table需要初始化的大小
  • 如果 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2))

 

CAS 会出现的问题:ABA

 
解决:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。

 

put 过程

 
  • 根据 key 计算出 hashcode
  • 判断是否需要进行初始化
  • 通过 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
  • 如果都不满足,则利用 synchronized 锁写入数据
  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树

 

get 过程

 
  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值
  • 如果是红黑树那就按照树的方式获取值
  • 就不满足那就按照链表的方式遍历获取值
 
 
ConcurrentHashMap 在 Java 8 中存在一个 bug 会进入死循环,原因是递归创建 ConcurrentHashMap 对象,但是在 JDK 1.9 已经修复了。场景重现如下:
 
publicclassConcurrentHashMapDemo{
    private Map<Integer,Integer> cache =new ConcurrentHashMap<>(15);
    publicstaticvoidmain(String[]args){
        ConcurrentHashMapDemo ch =    new ConcurrentHashMapDemo();
        System.out.println(ch.fibonaacci(80));        
    }
    publicintfibonaacci(Integer i){        
        if(i==0||i ==1) {                
            return i;        
        }
        return cache.computeIfAbsent(i,(key) -> {
            System.out.println("fibonaacci : "+key);
            return fibonaacci(key -1)+fibonaacci(key - 2);        
        });       
    }
}
 
 
 
 

 
参考:
 
美团技术讲HashMap:  https://zhuanlan.zhihu.com/p/21673805  
 
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值