[Java系列]搞懂HashMap,看这篇足够!!(万字长文/详细深入)

本文详述了HashMap在Java 8中的实现原理,包括数据结构从数组+链表到数组+链表+红黑树的变化,以及如何通过哈希表解决冲突。介绍了HashMap的构造方法、成员变量,如容量、负载因子、阈值和红黑树转换条件。文章还探讨了扩容机制、put和get操作的实现,并讨论了初始化HashMap时容量设置的建议。
摘要由CSDN通过智能技术生成

本文主要是从jdk源码入手, 结合常用操作, 图文并茂, 探讨Java中HashMap的一些设计与实现原理.

1.HashMap集合简介(初探)

HashMap基于哈希表的Map接口实现,是以key-value存储形式存,及主要用来存放键值对. HashMap的实现不是同步的,这意味着它不是线程安全的. 它的key,value都可以为null.此外,HashMap中的映射不是有序的.

  • jdk1.8之前HashMap由数组+链表组成, 数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(采用"拉链法解决冲突")
  • jdk1.8之后在解决哈希冲突时有了较大的变化, 当链表长度大于阈值(或者红黑树的边界值, 默认值为8) 并且 当前数组的长度大于64时, 此时此索引位置上的所有数据改为使用红黑树存储.

补充: 将链表转换成红黑树前会判断, 即使阈值大于8, 但是数组长度小于64, 此时并不会将链表变为红黑树. 而是选择进行数组扩容.

这样做的目的是因为数组比较小, 尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行 左旋,右旋, 变色 这些操作来保持平衡. 同时数组长度小于64时, 搜索时间要相对快些.

所以综上所述为了提高性能和减少搜索时间, 底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树.具体可以参考 treeifyBin 方法.

当然虽然增了红黑树作为底层数据, 结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变得更高效.

在这里插入图片描述

HashMap特点:

  1. 存取无序
  2. 键和值都可以是null,但是键位置只能是一个null
  3. 键位置是唯一的,底层的数据结构控制键的
  4. jdk1.8 之前数据结构是: 链表+数组
    jdk1.8之后是: 链表+数组+红黑树
  5. 阈值 > 8 and 数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了更高效地查询

2. HashMap集合底层的数据结构

2.1 数据结构

  • jdk1.8之前 HashMap由 数组+链表 数据结构组成
  • jdk1.8之后 HashMap由 数组+链表+红黑树 数据结构组成

2.2 HashMap 底层的数据结构存储数据的过程

pulic static void main(String[] args){
   
    //创建HashMap集合对象
    HashMap<String, Integer> hm = new HashMap<>();
    hm.put("柳岩",18);
    hm.put("杨幂",28);
    hm.put("刘德华",40);
    //hm.put("柳岩",18);
    hm.put("柳岩",20);
    System.out.println(hm);
}
{杨幂=28, 柳岩=20, 刘德华=40}

在这里插入图片描述

  1. HashMap<String,Integer> hm = new HashMap<>();

    当创建HashMap集合对象的时候.

    • jdk8之前: 构造方法中创建一个长度为16的Entry[] table 用来存储键值对数据的.
    • jdk8之后: 构造方法中不创建数组了,而是在第一次调用put方法时创建的数组 Node[] table 用来存储键值对数据的
  2. 假设向hm中存储柳岩-18 数据,根据柳岩调用String类中重写hashCode()方法计算出值, 然后结合数组长度采用某种算法(散列算法)计算出向Node数组中存储数据的空间的索引值.

    如果计算出的索引空间没有数据,则直接将柳岩-18存储到数组中, 举例:计算出的索引位3

    面试题: 哈希表底层采用何种算法计算hash值? 还有哪些算法可以计算出hash值?
    
    底层采用的key的hashCode方法的值结合数组长度进行无符号右移(>>>),按位异或^,按位与& 计算出索引号
    还可以采用: 平方取中法,取余数,伪随机法
    10%8 ==> 2, 11%8 ==>3
    
    
  3. 向哈希表中存储数据刘德华-40 ,假设"刘德华"计算出的hashCode方法结合数组长度计算出的索引值为3,那么此时数组空间不是null,此时底层会比较"柳岩""刘德华"的hash值是否一致, 若不一致,则在此空间上划出一个结点来存储键值对数据刘德华-40 (拉链法)

  4. 假设向哈希表中存储数据柳岩-20,那么首先根据柳岩调用hashCode方法结合数组长度计算出的索引肯定是3. 此时比较后存储的数据柳岩 和已经存在的数据的hash值是否相等, 如果hash值相等,此时发生哈希碰撞
    那么底层会调用柳岩所属类String 的equals方法比较两个内容是否相等:

    相等: 则将后面添加的数据的value覆盖之前的value
    不相等: 那么继续向下和其他的数据的key进行比较,若都不相等, 则划出一个结点存储数据

    哪怕string不同也有可能hashCode方法值相等:

    String a = "重地";
    String b = "通话";
    System.out.println(a.hashCode()+ " " + b.hashCode());	
    System.out.println(a.equals(b));
    /*
    1179395 1179395
    false
    */
    

如果结点个数(链表长度)大于阈值8并且数组长度大于64 则将链表变为 红黑树.

2.  当两个对象的hashCode相等会怎么样?
产生冲突(哈希碰撞),如key值内容相同则替换就得value值,不然连接到链表后面,链表长度超过阈值8就转换为红黑树存储.

3.  何时发生哈希碰撞和什么是哈希碰撞?
只要两个元素的key计算的hashcode相同就会发生冲突.
jdk8前使用链表解决哈希碰撞.jdk8后使用链表+红黑树解决

4.   如果两个键的hashcode相同,如何存储键值对?
	hashCode相等. 通过equals方法比较内容是否相等.
	相同: 则新的value覆盖老的value值
	不想同: 则将新的键值对添加到哈希表中.

在不断地添加数据的过程中, 会涉及到扩容的问题, 当超出临界值(且要存放的位置非空时)时,扩容 .默认的扩容方法为: 扩容为原来容量的2倍,并将原有的数据复制过来.

通过上述描述,当位于一个链表中元素众多,即hash值相等但是内容不等的元素较多时,通过key值依次查找的效率较低. 而jdk1.8中,哈希表存储在链表长度大于8并且数组长度大于64时将链表转换为红黑树.jdk8在hash表中引入红黑树主要是为了 查找效率更更高.

传统HashMap的缺点,1.8为什么引入红黑树? 这样结构不就变得更麻烦了嘛? 为何阈值大于8才换成红黑树?

1.8之前HashMap的实现是数组+链表, 即使哈希函数取得再好,也很难达到元素的百分百均匀分布.当HashMap中有大量的元素都放在同一个桶中时,这个桶下有一条长长的链表, 这个时候HashMap就相当于一个单链表, 假如单链表有n个元素, 遍历的时间就是O(n).
1.8为解决这一问题, 使用 `红黑树(查找时间复杂度为O(logn)) 来优化这个问题.当链表长度很小的时候,即使遍历,速度也很快,但是当链表长度不断变长,对查询也存在影响.

一些说明:

  • size 表示HashMap中K-V的实时数量, 注意这个不等于数组的长度.
  • threshold(临界值) = capacity(容量) * loaFactor(加载因子). 这个值是当前已占用数组长度的最大值. size 超过这个临界值就会重新 reszie . 扩容后的HashMap容量是之前容量的两倍.

3. HashMap的继承关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   

在这里插入图片描述

说明

  • Cloneable 空接口,表示可以克隆.创建并返回HashMap对象的一个副本.
  • Serializable 序列化接口. 属于标记性接口. HashMap对象可以被序列化和反序列化.
  • AbstractMap 父类提供了Map实现接口. 以最大限度地减少实现此接口所需要的工作.

补充: 为甚HashMap基础AbstractMap而AbstractMap类实现了Map接口, 那为啥HashMap还要去实现Map接口呢? 同样ArrayList也是如此.

这是一个失误. 最开始写Java框架时, 以为会有一些1价值, 直到其意识到毫无价值.

4 HashMap 集合类的成员

4.1 成员变量

1. 序列化版本号

private static final long serialVersionUID = 362498820763181265L;

由于实现了序列化接口, 所以需要一个默认的序列化版本号.

2. 集合的初始化容量(必须是2的n次幂)

/**
  * The default initial capacity - MUST be a power of two.
  */
// 1<< 4 相当于 1*(2^4)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

问题: 为啥是2的次幂?如果输入的值并非2的n次幂而是比如10 会怎样?

3. HashMap 构造方法还可以指定集合的初始化容量的大小:

public HashMap(int initialCapacity) //构造一个带指定初始容量和默认加载因子(0.75)的空HashMap

根据上述我们知道了, 当向HashMap中添加一个元素的时候, 需要根据key的hash值,去确定其在数组中的具体位置.HashMap 为了存取高效 ,要尽量较少碰撞,就是要尽量把数据分配均匀, 每个链表长度大致相同, 这个实现就在把数据存到哪个链表上的算法.

这个算法实际就是取模, hash % length, 计算机中直接求余的效率不如位运算. 所以源码中做了优化,使用 hash&(length -1), 而实际上 hash % length 等于hash&(length -1)的前提就是length是2的 n 次幂.

为什么这样能均匀分布减少碰撞呢?

  • 2的n次方实际就是 1后面n个0,
  • 2的n次方-1 实际就是n个1

举例:

说明: 按位与运算: 相同的二进制位上都是1的时候,结果才为1, 否则为0

例如长度为8:
    3	&	(8-1) = 3
    0000 0011
    0000 0111
    ----------
    0000 0011

    13	&	(8-1) = 5
    0000 1101
    0000 0111
    ---------
    0000 0101	

例如长度为9:
    3	&	(9-1)
    0000 0011
    0000 1000
    ---------
    0

    2	&	(9-1)
    0000 0010
    0000 1000
    ---------
    0			碰撞,而当length为8时不会

    13	&	(9-1)
    0000 1101
    0000 1000
    ---------
    0000 1000

如果不是2的n次幂,计算出的索引特别容易相同, 及其容易发生哈希碰撞,造成其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率较低

小结:

  1. 由上可看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会有数据,浪费数组空间,加大冲突的可能.

  2. 一般我们会想通过 % 取余来确定位置, 这样也行, 只不过性能不如 & 运算.而且当n是2的幂次方时: hash & (length-1) =hash % length

  3. 因此, HashMap容量为2的n次方的原因,就是为了数据的均匀分布,减少hasn冲突. 毕竟hash冲突也多,代表数组中的一个链的长度就会越大,这样的话会降低hashmap的性能.

  4. 如果创建的HashMap对象输入的数组长度不是2的n次方时,HashMap会通过移位运算和或运算得到2的n次方数, 并且是距离那个数最近的数字(比如输入10, 获得16), 源代码如下:

    public HashMap(int initialCapacity, float loadFactor) {
         
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)	//最大2^30
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    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;
    }
    

    说明:

    如果给定了initialCapacity(假设为10), 由于HashMap的capacity必须都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的次幂(此处为16),然后返回.下面分析这个算法:

    1. 为什么要对cap减1操作呢? int n = cap - 1;

      这是为了防止,cap本身就是2的n次幂, 若不进行此操作,则执行完该方法则会得到这个cap的二倍,比如输入8, 不进行-1的话返回16

    2. 现在来看这些个无符号右移. 若果n这时为0了(经过了cap-1),则经过后面几次无符号右移依然是0,最后返回capacity的值为1(最后有个n+1的操作). 这里讨论不为0的情况.

    3. 注意: | 按位或运算: 相同位置上都是0的时候才为0, 否则为1

      cap = 10
      int n =cap-1; == > 9
      n |= n >>> 1
      00000000 00000000 00000000 00001001	9 >>> 1 
      00000000 00000000 00000000 00000100	4
      --------------------------------------
      00000000 00000000 00000000 00001101 13 	 最高位右边相邻位为1
      
      n=13
      n |= n >>> 2
      00000000 00000000 00000000 00001101 13 >>>2
      00000000 00000000 00000000 00000011 3
      ---------------------------------------
      00000000 00000000 00000000 00001111 15  最高两位右边相邻两位为1 -- 此时最高4位为1
      
      n=15
      00000000 00000000 00000000 00001111 15 >>> 4
      00000000 00000000 00000000 00000000 0
      ----------------------------------------
      00000000 00000000 00000000 00001111 15  最高位有8个连续的1, 但是这里没有8位,不变...
      

      以此类推, 容量最大也就是32bit的正数, 最后一次 >>> 16 将变为连续的32个1(但这已经是负数了. 在执行tableSizeFor之前, 对initialCapacity做了判断, 如果大于MAXIMUM_CAPACITY = 2^30 ,则取MAXIMUM_CAPACITY.

      所以这里的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY. 30个1,加1后为2^30

      综上, 10 变成 16就是这样得到的~

4. 默认的负载因子, 默认值为0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

5. 集合最大容量

//集合最大容量的上限是: 2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

当链表的值超过8, 则会转红黑树(1.8之后)

//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8

面试题: 为什么Map桶中结点个数超过8 才转为红黑树 ?

8 这个阈值定义在HashMap中, 在源码注释中只说明了8是bin(bin就是bucket桶)从链转换成红黑树的阈值,但是并没有说为什么是8:

在HashMap中174行有一段说明

* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:

因为树的结点大约是普通结点的两倍(有指向), 我们只在箱子包含足够多结点时才使用树结点(参考 TREEIFY_THRESHOLD). 当他们变得太小(由于删除或者调整)时,就会被转换为普通的桶. 在使用分布良好的用户HashCodes时, 很少使用树箱.理想情况下,箱子中的结点的频率服从泊松分布

(http://en.wikipedia.org/wiki/Poisson_distribution) ,默认调整阈值为0.75,平均参数约为0.5 ,尽管由于调整粒度的差异很大.忽略方差,列表大小k的预期出现次数(exp(-0.5) * pow(0.5, k) / factorial(k)): 第一个值为:

     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million 

TreeNodes 占用空间是普通Nodes的两倍, 所以只有当bin包含足够多的结点时才会转成 TreeNodes , 而是否足够多就是TREEIFY_THRESHOLD 决定的. 当bin中结点变少时(长度降到6)就又转为普通bin.

这样就解释了为什么不是一开始就转换为TreeNodes, 而是需要一定结点数才转为TreeNodes,说白了就是权衡,空间和时间

这段内容还说: 当HashCode离散性很好时,树形bin用到的概率很小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度达到阈值. 但是在随机hashcode下,离散性可能会变差,然而jdk又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布.不过理想情况下随机hashCode方法下所有bin中结点分布频率满足泊松分布.可以看到,一个bin中链表长度达到8个元素的概率为0.00000006. 几乎是不可能事件.所以,之所以选择8,不是随便决定的,而是根据概率统计得到.

简而言之,选择8是因为符号泊松分布,超过8的时候,概率已经非常小了.所以选择8

另外还有如下说法:

红黑树的平均查找长度为log(n), 如果长度为8,平均查找长度为log(8)=3,链表平均查找长度为n/2,当长度为8时,平均查找长度为4,这才有转换为树的必要;链表长度若为小于等于6.6/2=3,而log(6)=2.6,虽然速度也快些,但转化为树和生成树的时间并不会太短.

6. 当链表的值小于6会从红黑树转回链表

//当桶bucket上的结点数小于这个值时树转换为链表
static final int UNTERRIFY_THRESHOLD = 6;

7.

当前Map里面的数量超过这个值时, 表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容,树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESOLD(8)

//桶中结构转化为红黑树对应的数组长度最小值
static final int MIN_TREEIFY_CAPACITY = 64

8. table用来初始化(必须是2的n次幂)

重点

//存储元素的数组
transient Node<K,V>[] table;

table 在jdk8中我们了解到HashMap是由数组加链表加红黑树来组成的结构. 其中tale就是HashMap中的数组,8之前为

Entry<K,V>类型. 1.8之后只是换乐观名字Node<K,V>,都实现一样的接口: Map.Entry<K,V>负责村村键值对数据.

9. 用来存放缓存 (不那么重要)

//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySets;

10. HashMap中存放元素的个数

重点

//存放元素的个数,注意这不等于数组的长度
transient int size;

size为HashMap中K-V的实时数量,不是table的长度.

11. 用来记录HashMap的修改次数

//每次扩容和更改HashMap的修改次数
transient int modCount;

12 . 用来调整大小下一个容量的阈值

计算方式为(容量 *负载因子)

//临界值 当实际大小([容量capatocy=16]*[负载因子0.75])超过临界值[threshold]时,会进行扩容(翻倍)
int threshold;

13. 哈希表的加载因子

重点

//加载因子
final float loadFactor;

说明:

  1. loadFactor 加载因子,是用来衡量HashMap的满的程度, 表示HashMap的疏密程度, 影响hash操作到同一个位置的概率,计算HashMap的实时加载因子的方法为: size/capacity, 而不是占用桶的数量去除以capacity. capacity是桶的数量,也即是table.length

    loadFactor太大导致查找元素效率低,太小导致数组利用率低,存放的数据会很分散. loadFactor的默认值0.75f是官方给出的比较好的临界值.

    当HashMap里面容纳的元素达到HashMap数组长度的0.75时,表示HashMap太挤,需要扩容,而这个过程涉及到rehash,数据复制等操作,非常消耗性能. 所以开发中尽量减少扩容次数,可以通过创建集合对象时指定初始容量来尽量避免.

    另外在HashMap构造器中也可以指定loadFactor

    面试题:为啥默认0.75的threshold啊?
    0.4 那么16*0.4 ---> 6  如果满6个就进行扩容会造成数组利用率太低
    0.9 那么16*0.9 ---> 14 那么这样导致链表有点多了,导查找元素效率低   
    
    • threshold计算公式: capacity(数组默认长度16)*loadFactor(负载因子默认0.75). 这个值是当前占用数组长度的最大值.当Size >= threshold时,那么就要考虑对数组进行扩容.也就是说,这个数用来衡量数组是否需要扩容的一个标准.

4.2 构造方法

HashMap中重要的构造方法如下:

1, 构造个空的HashMap,默认初始容量(16) 和默认负载因子(0.75)

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
   
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认因子0.75赋给loadFactor,并没有创建数组
    }

2, 构造一个具有指定的初始容量和默认loadFactor的HashMap

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
   	//指定容量
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

3, 构件一个具有指定初始容量和loadFactor的hashMap

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
   
        if (initialCapacity < 0)
            throw new 
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值