HashMap源码深度剖析


1 HashMap数据结构

目标:
HashMap 概念、数据结构回顾(JDK8和JDK7) & 为什么1.8使用红黑树?
概念:
HashMap 是一个利用散列表(哈希表)原理来存储元素的集合,是根据Key value而直接进行访问的数
据结构
在 JDK1.7 中,HashMap 是由 数组+链表构成的。
在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成
回顾: 数组、链表(优势和劣势)
在这里插入图片描述
数组: 优势:数组是连续的内存,查询快(o1) 劣势:插入删除O(N)
链表: 优势:不是连续的内存,随便插入(前、中间、尾部) 插入O(1) 劣势:查询慢O(N)
思考?
为什么是JDK1.8 是数组+链表+红黑树?
HashMap变化历程
1.7的数据结构:链表变长,效率低了!
在这里插入图片描述
1.8的数据结构:

数组+链表+红黑树
在这里插入图片描述
链表–红黑(链长度>8、数组长度大于64)
总结:
JDK1.8使用红黑树,其实就是为了提高查询效率
因为,1.7的时候使用的数组+链表,如果链表太长,查询的时间复杂度直接上升到了O(N)

2 HashMap继承体系

目标:梳理map的继承关系
在这里插入图片描述
总结
HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口那为什么HashMap还要在实现Map接口呢?
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。
在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些
地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值
得去修改,所以就这样存在下来
Cloneable 空接口,表示可以克隆
Serializable 序列化
AbstractMap 提供Map实现接口

3 HashMap源码深度剖析

目标:
通过阅读HashMap(since1.2)源码,我们可以知道以下几个问题在源码是如何解决的
(1)HashMap的底层数据结构是什么?
(2)HashMap中增删改查操作的底部实现原理是什么?
(3)HashMap是如何实现扩容的?
(4)HashMap是如何解决hash冲突的?
(5)HashMap为什么是非线程安全的?

测试代码如下

package com.mmap;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public class MMapTest {
  public static void main(String[] args) {
    HashMap<Integer, String> m = new HashMap<Integer, String>();//尾插
    //断点跟踪put
    m.put(1, "001");
    m.put(1, "002");
    m.put(17, "003");//使用17可hash冲突(存储位置相同)
    //断点跟踪get
    System.out.println(m.get(1));//返回002(数组查找)
    System.out.println(m.get(17));//返回003(链表查找)
    //断点跟踪remove
    m.remove(1);
 }
}

下面源码看到16是默认容量1和17数组长度取余得到的值相同,所以会产生hash冲突

3.1 成员变量与内部类

目标:理解map的成员变量代表的意思

两大部分

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16,默认容量:左位移4位,即2的4次幂
  static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量:即2的30次幂
  static final float DEFAULT_LOAD_FACTOR = 0.75f;//负载因子:扩容使用,统计学计算出的最合理的
  static final int TREEIFY_THRESHOLD = 8;//链表转红黑树阈值
  static final int UNTREEIFY_THRESHOLD = 6;//当链表的值小<6, 红黑树转链表
..transient Node<K,V>[] table;//HashMap中的数组,中间状态数据;不参与序列化(重点)
  transient Set<Map.Entry<K,V>> entrySet;//用来存放缓存,中间状态数据;
  transient int size;//size为HashMap中K-V的实时数量(重点),中间状态数据;
  transient int modCount;//用来记录HashMap的修改次数
  int threshold;//扩容临界点;(capacity * loadFactor)(重点)
  final float loadFactor;//负载因子 DEFAULT_LOAD_FACTOR = 0.75f赋值

1 << 4 表示1左移四位 ,推理公式
简单记忆:
如果 1 << 4 、1 << 5、1 << 20
等于
2^4、2^5、2^20
左移其实就是 X(对应1)乘以2的N次方
注意,前导(第一位)为1 不适用
下面会详细讲
静态内部类(1.8前是Entry)

static class Node<K,V> implements Map.Entry<K,V> {//数组(哈希桶):1.8前是Entry
    final int hash;//扰动后的hash
    final K key;//map的key
    V value;//map的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;//链表下一个
     
     
     ....transient Node<K,V>[] table;//HashMap中的数组,不参与序列化(重点)
transient int size;//size为HashMap中K-V的实时数量(重点)
transient int modCount;//用来记录HashMap的修改次数
int threshold;//扩容临界点;(capacity * loadFactor)(重点)
final float loadFactor;//负载因子 DEFAULT_LOAD_FACTOR = 0.75f赋值
     ....

总结:
1、上面的成员变量大体有个印象,后面源码分析我们会用到
2、位运算计算比较麻烦,可以按照上面的快速记忆计算

3.2 HashMap构造器

目标:学习下面的三个构造器,它们都干了哪些事情?

 public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 负载因子DEFAULT_LOAD_FACTOR =0.75f
 }
public HashMap(int initialCapacity) {
      this(initialCapacity, DEFAULT_LOAD_FACTOR);
   }
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
      throw new IllegalArgumentException("Illegal initial capacity: " +
                       initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
      initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
      throw new IllegalArgumentException("Illegal load factor: " +
                       loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
 }

总结:
可以发现上面是三个构造器都么有在内存上创建对象,只是针对容量和负载因子做了大量的判断和赋值

3.3 HashMap插入方法

目标:图解+代码+断点分析put源码
插入流程如下
在这里插入图片描述
插入主方法
当我们调用put方法添加元素时,实际是调用了其内部的putVal方法,第一个参数需要对key求hash值,
为了减少hash碰撞

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);//调用Map的putVal方法
 }

生成hash,干扰(扰动)函数
为了防止一些实现比较差的 hashCode() 方法,减少碰撞,尽可能使元素更散列地存储

 static final int hash(Object key) {//干扰(扰动)函数;通过key计算hash值,仅仅是
hash值,不是存储位置
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//hash后对右
    16(空位补0)后进行异或,key为null,表示HashMap是支持Key为空的,
 }

核心逻辑
在resize时会丢数据,线程不安全的,1.7叫做transfer函数功能相同

HashMap
1.在JDK1.7中,当并发执⾏扩容操作时会造成环形链和数据丢失的情况。
2.在JDK1.8中,在并发执⾏put操作时会发⽣数据覆盖就是丢数据的情况。

这是jdk1.8中HashMap中put操作的主函数, 注意代码,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入这行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
         boolean evict) {//onlyIfAbsent:true不更改现有值;evict:false表
示table为创建状态
    Node<K,V>[] tab; Node<K,V> p; int n, i;//临时变量
    if ((tab = table) == null || (n = tab.length) == 0)//数组是否null或者==0,1次put为空
      n = (tab = resize()).length;//初始化数组(or扩容)!!!!!!!!!!!!!
    if ((p = tab[i = (n - 1) & hash]) == null)//寻址:(n - 1) & hash重要,16-1
按位与hash,为null表示没有值
      tab[i] = newNode(hash, key, value, null);//等空,直接插入
    else {
      Node<K,V> e; K k;//1、key(hash)相等 2、hash冲突 (链表) 3、红黑树
      if (p.hash == hash &&//如果与第一个元素(数组中的结点)的hash值相等,key相等
       ((k = p.key) == key || (key != null && key.equals(k))))
        e = p;//将第一个元素赋值给e,用e来记录;跳到646Line
      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为空表明处于链表的尾部,1、生成
链表 2、已经是链表
            p.next = newNode(hash, key, value, null);// 直接创建
            if (binCount >= TREEIFY_THRESHOLD - 1) //链表长度如果>8转红
黑树(or 扩容),-1是因为binCount从0开始
              treeifyBin(tab, hash);//树化;还需要判断是否大于64,否则扩break;
         }
          if (e.hash == hash &&
           ((k = e.key) == key || (key != null &&
key.equals(k))))//对链表节点中数据进行覆盖判断
            break;// 如果key相同,break跳出for循环,执行后面的逻辑
          p = e;
       }
     }
     if (e != null) { // key已经存在
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
          e.value = value;// 用新的value值去覆盖老的value值
        afterNodeAccess(e);
        return oldValue;// 返回覆盖前的value值,put的返回值
     }
   }
    ++modCount;//用来记录HashMap的修改次数
    if (++size > threshold)//map数量是否大于容量
      resize();//如果size大于threshold,就需要进行扩容
    afterNodeInsertion(evict);
    return null;
 }


重点(寻址计算):
计算存放到数组的位置,如果为null表示没值,有值,表示hash冲突(or key相等)
if ((p = tab[i = (n - 1) & hash]) == null)
寻址公式:
(n - 1) & hash
(16-1) & 1122
0000 0000 1111B
0100 0110 0010B
0010B 开始按位与(都是1则为1,反之0)
思考,为什么不用模运算?(面试常问的问题)
与运算:(n - 1) & hash
模运算:15%2
都可以得到0-15之内的值
为什么不用mod(模运算)进行寻址?

package com.cmap;
public class CMod {
  public static void main(String[] args) {
    bit();
    mod();
 }
  public static void bit() {
    int num = 10000 * 10;
    int a = 1;
    long start = System.currentTimeMillis();
    for (int i = num; i > 0; i++) {
      a &= i;
//      a = a&i;
   }
    long end = System.currentTimeMillis();
    System.out.println("BIT耗时>>" + (end - start));
 }
  public static void mod() {
    int num = 10000 * 10;
    int a = 1;
    long start = System.currentTimeMillis();
    for (int i = num; i > 0; i++) {
      a %= i;
//      a = a%i;
   }
    long end = System.currentTimeMillis();
    System.out.println("MOD耗时>>" + (end - start));
 }
}

输出
在这里插入图片描述
结论:
mod运算是与运算的20倍,效率太低下
总之,一切为了性能
那么,&运算为什么这么高?操作的是二进制

3.4 HashMap扩容方法

目标:图解+代码(map扩容与数据迁移)
注意:扩容复杂、绕、难
图解前提: 按8和16的长度讲(图片不容易展示)
迁移前:长度8 扩容临界点6(8*0.75)
在这里插入图片描述
迁移过程
在这里插入图片描述
在这里插入图片描述
核心源码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;
     }
      else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&//扩容2倍:oldCap
<< 1,左移1位,相当于oldCap乘以21次方
          oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1; // 扩容2倍:将阈值threshold*2得到新的阈值
   }
    else if (oldThr > 0) // HashMap(int initialCapacity, float loadFactor)调
用
      newCap = oldThr;
    else {        // zero initial threshold signifies using defaults
      newCap = DEFAULT_INITIAL_CAPACITY;//第一次
put!!!!!!!!!!!!!!!!!!
      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//扩充
阈值
   }
    if (newThr == 0) {//如果新阈值为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) {
      for (int j = 0; j < oldCap; ++j) {  //如果旧的数组中有数据,循环
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
          oldTab[j] = null;//gc处理
          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;//低位链表(原位置i)
            Node<K,V> hiHead = null, hiTail = null;//高位链表(i+n位
置)
            Node<K,V> next;
            do {
              next = e.next;
              if ((e.hash & oldCap) == 0) {// 如果为0,元素位置在扩容后
数组中的位置没有发生改变(低位)
                if (loTail == null)
                  loHead = e;// 头节点
                else
                  loTail.next = e;
                loTail = e;
             }
              else {//不为0,元素位置在扩容后数组中的位置发生了改变,新的下
标位置是(原下标位置+原数组长)
                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、扩容就是将旧表的数据迁移到新表
2、迁移过去的值需要重新计算hashCode,也就是他的存储位置
3、关于位置可以这样理解:比如旧表的长度8、新表长度16
旧表位置4有6个数据,假如前三个hashCode是一样的,后面的三个hashCode是一样的
迁移的时候;就需要计算这6个值的存储位置
4、如何计算位置?采用低位链表和高位链表;如果位置4下面的数据e.hash & oldCap等于0,
那么它对应的就是低位链表,也就是数据位置不变
e.hash & oldCap不等于0呢?就要重写计算他的位置也就是j + oldCap(4+8);这个12,就是高位链表
位置(新数组12位置)

3.5 HashMap获取方法

目标:图解+断点分析get源码
获取流程
在这里插入图片描述
get主方法

  public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;//先计算
hashcode
 }

getNode方法

  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 && // 数组查找!!!!!
       ((k = first.key) == key || (key != null && key.equals(k))))
        return first;//返回查找的数据
      if ((e = first.next) != null) {//桶中不止一个节点
        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);
     }
   }
    return null;
 }

总结:
查询思路比较简单,如果是数组直接返回、如果是红黑实例,就去树上去找,最后,去做链表循环查找

3.6 HashMap移除方法

目标:图解+断点分析remove源码
移除流程
在这里插入图片描述
tips:
两个移除方法,参数上的区别
走的同一个代码
移除方法:一个参数

 public V remove(Object key) {
    Node<K,V> e; 定义一个节点变量,用来存储要被删除的节点(键值对
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
      null : e.value;
 }

移除方法:二个参数

  @Override
  public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
 }

核心方法removeNode

  /**
  * Implements Map.remove and related methods
  *
  * @param hash 扰动后的hash值
  * @param key 要删除的键值对的key,要删除的键值对的value,该值是否作为删除的条件取决于
matchValue是否为true
  * @param value key对应的值
  * @param matchValue 为true,则当key对应的值和equals(value)为true时才删除;否则不关
心value的值
  * @param movable 删除后是否移动节点,如果为false,则不移动
  * @return 返回被删除的节点对象,如果没有删除任何节点则返回null
  */
  final Node<K,V> removeNode(int hash, Object key, Object value,
               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
     (p = tab[index = (n - 1) & hash]) != null) {
      Node<K,V> node = null, e; K k; V v;
      if (p.hash == hash &&
       ((k = p.key) == key || (key != null && key.equals(k))))
        node = p;//寻址后的值给node
      else if ((e = p.next) != null) {
        if (p instanceof TreeNode)
          node = ((TreeNode<K,V>)p).getTreeNode(hash, key);//红黑查找
        else {
          do { //链表查找
            if (e.hash == hash &&
             ((k = e.key) == key ||
              (key != null && key.equals(k)))) {
              node = e;
              break;
           }
            p = e;// 走到这里,说明e也没有匹配上,P-->E;表示p是e的父节点
         } while ((e = e.next) != null);//如果e存在下一个节点,那么继续去匹
配下一个节点
       }
     }//如果node不为空,说明根据key匹配到了要删除的节点
      if (node != null && (!matchValue || (v = node.value) == value ||
                (value != null && value.equals(v)))) {
        if (node instanceof TreeNode)
         ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);//红
黑删除
        else if (node == p)// 如果该节点不是TreeNode对象,node == p 的意思是该
node节点就是首节点
          tab[index] = node.next;//由于删除的是首节点,那么直接将节点数组对应
位置指向到第二个节点即可
        else
          p.next = node.next; 如果node节点不是首节点,此时p是node的父节
点,由于要删除node,所有只需要把p的下一个节点指向到node的下一个节点即可把node从链表中删除了
        ++modCount;//HashMap的修改次数递增
        --size;// HashMap的元素个数递减
        afterNodeRemoval(node);//子类扩展
        return node;//返回删除后的节点
     }
   }
   return null;//找不到删除的node,返回null
 }

总结:
移除和查询路线差不多,找到后直接remove
注意他的返回值,是删除的那个节点的值

4 哈希最常见面试题

4.1 为什么要从JDK1.8之前的链表设计,修改为链表或红黑树的设计?

当某个链表比较长的时候,查找效率还是会降低。
如果table[index]的链表的节点的个数比较少,(8个或以内),就保持链表。如果超过8个,那么
就要考虑把链表转为一棵红黑树。

4.2 什么时候树化?

table[index]下的结点数一达到8个就树化吗?
如果table[index]的节点数量已经达到8个了,还要判断table.length是否达到64,如果没有达到
64,先扩容

4.3 什么时候从树–>链表

当你删除结点时,这棵树的结点个数少于6个,会反树化,变为链表

4.4 初始容量是16,为什么是2的指数次幂?

1、方便计算
2、扩容与数据迁移(重点)
因为2的幂次方在二进制的表达中可以保证后几位均为1,为什么要这样做呢?举一个例子就很清楚了,
基于按位与操作的思想,两数都为1才为一,若数组长度为15,(15-1)即14表示的话就是1110,
1110&1110和1110&1111的结果是相同的,这不就增加了哈希碰撞的概率了,因此,保持n-1的后几位
为1,就可以根据hash值的后几位来判别应该放在哪个桶里了

4.5 为什么加载因子是0.75呢?

其实是通过泊松分布来计算的
选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择
总结:
通过统计学和概率计算得出来的结果

4.6 什么是哈希冲突(碰撞)?,如何解决

如果两个不同的元素(key、value、hash不同),通过哈希函数得出的实际存储地址相同
也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被
其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞
该如何解决呢?链表

4.7 获取hash 值,为什么不用hashCode直接取呢?左移、右移和异或怎么计算?

让hash分布、散列更加均匀,减少hash碰撞的概率。
tips:(h = key.hashCode()) ^ (h >>> 16)
是如何计算的?
异或:右移
举例:
key.hashCode() int转32二进制为0000010101001010 0101001011110001
右移16位,高位不足进行补零(直接在前面加16个0)
0000010101001010 0101001011110001(移动前)
0000000000000000 0101001011110001 (移动后)
开始异或(相同为0.不同为1)
0000010101001010 0101001011110001
0000000000000000 0101001011110001
异或后的(相同为0,不同为1)
0000010101001010 0000000000000000

关于左移
在这里插入图片描述
2^0:在二进制中表示1
在这里插入图片描述
注意:上面前导为1,计算出来的就是负数

关于右移
在这里插入图片描述
tips
以上没有按照实际32位作图,只是举例,图片放不下

4.8 JDK1.8相比1.7主要优化的地方?

jdk1.8是链表+数组+红黑树;
jdk1.7链表采用头插法,头插法比较快,但容易造成死循环;jdk1.8是尾插法;

5 HashMap常问面试题

HashMap常问面试题

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

赵广陆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值