一文读懂HashMap, JDK1.8

HashMap

20201216

21:15

 

目录

-

- 1. 基本原理

    - 1.1 底层数据结构

    - 1.2 hash机制

        - 1.2.1 下标计算

        -

        - 1.2.2 hash函数部分

        - 1.2.3 loadFactor

    - 1.2 扩容机制

- 2. 源码实现

    - 2.1 构造方法

    - 2.2 get和put

    - 2.3 resize

概述

map,dict用了这么久,到底实现是怎么样的?

  • 底层数据结构是什么?
  • hash策略和扩容策略是什么?容量为何要是2的倍数?
  • resize如何避免重hash?

 

1. 基本原理

1.1 底层数据结构

HashMap基本的原理,即底层数据结构是数组加链表,数组以hash的方式获得下表,拉链法解决hash冲突的问题。jdk 1.8之后,HashMap作了一定的优化,当链表长度大于8时,改链表为转化为红黑树,减少检索时间。

红黑树原理可以参考红黑树。

HashMap本身实现并不算复杂,有几处hash寻下标的细节比较有意思。

内部类基础是两个节点Node和TreeNode的,都非常常规。

注意:HashMap的容量默认是2的倍数-1,即使你指定大小,在后文详细介绍。

1.2 hash机制

在计算机和数学上,hash有很多研究,主要是两个方面,hash函数设计,以及hash空间大小与冲突性能研究。

1.2.1 下标计算

基本公式是:

    1 (n - 1) & hash

n是初始容量,注意n默认会被设置为2的倍数,于是这个公式其实就是直接对n-1求模。

 

1.2.2 hash函数部分

其中hash函数的设计,理念就是尽可能减少hash冲突,细节嘛,不需要知道。

    1 static final int hash(Object key) {

    2      int h;

    3      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

    4 }

1.2.3 loadFactor

hash空间与冲突性能方面,主要是基于一种事实,当hash空间被填满到一定程度的时候,hash冲突就会剧烈上升。所以,一般使用一个loadFactor来控制HashMap的容量大小。thredhold的原理即是:

    1 thredhold = loadFactor * capacity

超过thredhold就扩容。

1.2 扩容机制

扩容很简单,每次都会直接左移一位,也就是容量翻倍。

有意思是的初始容量设置,一旦设置了容量,它默认都会转化成2的整数倍(取最小的),例如指定100,实际上初始化结果是128。代码如下,细节不重要。

    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     }

2. 源码实现

2.1 构造方法

都很简单,唯一需要注意的是指定容量的构造方法,默认会调用上面的tableSizeFor

    1     public HashMap(int initialCapacity, float loadFactor) {

    2         if (initialCapacity < 0)

    3             throw new IllegalArgumentException("Illegal initial capacity: " +

    4                                                initialCapacity);

    5         if (initialCapacity > MAXIMUM_CAPACITY)

    6             initialCapacity = MAXIMUM_CAPACITY;

    7         if (loadFactor <= 0 || Float.isNaN(loadFactor))

    8             throw new IllegalArgumentException("Illegal load factor: " +

    9                                                loadFactor);

   10         this.loadFactor = loadFactor;

   11         this.threshold = tableSizeFor(initialCapacity);

   12     }

2.2 getput

实际上put才是核心,但也很简单,就是需要注意链表和红黑树之间的转化。这里只保留put的逻辑,其实非常简单

1. 判断底层table是否为空

2. hash计算下标

    无元素,插入

    树节点,插入红黑树

    普通节点插入,或者key相同替换

3. 是否扩容

    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     // 1. 判断是否为空

    5     if ((tab = table) == null || (n = tab.length) == 0)

    6         n = (tab = resize()).length;

    7     // 2. 定位为空直接插入

    8     if ((p = tab[i = (n - 1) & hash]) == null)

    9         tab[i] = newNode(hash, key, value, null);

   10     // 2. 定位非空

   11     else {

   12         Node<K,V> e; K k;

   13         // key相同,替换之

   14         if (p.hash == hash &&

   15             ((k = p.key) == key || (key != null && key.equals(k))))

   16             e = p;

   17         // 树节点,红黑树插入

   18         else if (p instanceof TreeNode)

   19             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

   20         // 普通节点,遍历尾插入,大于8则转为红黑树

   21         else {

   22             for (int binCount = 0; ; ++binCount) {

   23                 if ((e = p.next) == null) {

   24                     p.next = newNode(hash, key, value, null);

   25                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

   26                         treeifyBin(tab, hash);

   27                     break;

   28                 }

   29                 // key相同替换之

   30                 if (e.hash == hash &&

   31                     ((k = e.key) == key || (key != null && key.equals(k))))

   32                     break;

   33                 p = e;

   34             }

   35         }

   36         if (e != null) { // existing mapping for key

   37             V oldValue = e.value;

   38             if (!onlyIfAbsent || oldValue == null)

   39                 e.value = value;

   40             afterNodeAccess(e);

   41             return oldValue;

   42         }

   43     }

   44     ++modCount;

   45     if (++size > threshold)

   46         resize();

   47     afterNodeInsertion(evict);

   48     return null;

   49 }

 

2.3 resize

resize涉及到扩容,复制原表,再hash的过程。所以要尽可能的避免。扩容之后,正常来说节点的下标要改变(hash % cap)。jdk1.8之后,人们发现,当容量是2的倍数,扩容之后下标要么是原来的值,要么是原来的值 +原容量。

其实并不是人们发现,而是java对于HashMap的实现本身就是这么设计的,容量是2的倍数 + hash取下标的方式,目的是取消resize的时候重hash。

看不懂上面的说法没有关系,下面会从1.7的机制开始讲起,回头看一遍就懂了。

假设了我们的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的过程。

来自 <https://tech.meituan.com/2016/06/24/java-hashmap.html>

有没有发现key=5的不变之外,其他都变成了 原下标+原容量。为什么呢?

从二进制码的角度就非常好理解了。

 

a,b分别是hash1和hash2两个值,在4容量和16容量下的下标计算,注意计算公式是(n - 1) & hash,所以就特别好懂了。11111比起 1111多出了1位,所以有的hash值就会变成原下标 + 原容量(idx + n,注意上述1111是n-1).

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值