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

本文主要是从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时,链表转换为红黑树时,效率也变得更高效.

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

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}

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

  1. HashMap 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 {

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

说明

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

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 HashMap 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 HashMap 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 HashMap 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 IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)	//大于 2^30
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))		//小于0或者不是一个小数
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;	//检查完毕,局部变量传给成员变量
        this.threshold = tableSizeFor(initialCapacity);//放进去10,threshold为16,照理应该是12,之后put()会修改
    }

说明:对于 this.threshold = tableSizeFor(initialCapacity); 疑问解答:

tableSizeFor(initialCapacity)判断指定的初始化容量时候为2的n次幂,如果不是则变为最小的离它最近的那个2的n次幂.这点前面已经讲过.

但是注意,在taleSizeFor内部将计算后的数据直接返回赋给threshold,有人觉得应该这么写:

this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;

这样才符合threshold的意思(当HashMap的Size达到threshold这个阈值时会扩容).

但是注意, 在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中**,在put方法会对threshold重新计算**,put方法的具体实现下面会继续讲解

4, 包含另一个Map的构造函数

public HashMap(Map<? extends K, ? extends V> m) { //将原来的集合m内容放在新的集合里面
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {	//如果有数据,才进行拷贝
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F; //思考这里为啥要 +1 
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold) //这里threshold为0
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

面试题: float ft = ((float)s / loadFactor) + 1.0F; //思考这里为啥要 +1f?

s/loadFactor 结果是小数,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能保证更大容量,加大容量能够保证减少resize的调用次数. 所有+1.0f是为了获得更大的容量.

例如:原来集合的元素是6个,那么6/0.75是8,是2的n次幂,那么新的数组的大小就是8了. 然后原来数组的数据就会存储到长度为8的新的数组中,这样会导致在存储元素的时候, 容量不够,还得继续扩容, 那么性能降低了; 而如果+1呢, 数组长度直接变为16了,这样可以减少数组的扩容.

4.3 HashMap的成员方法

4.3.1 增加方法 put

主要步骤:

  1. 通过hash值计算出key映射到哪个桶;
  2. 如果桶上没冲突,直接插入;
  3. 如果出现冲突,则需要冲突处理:
    1. 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据
    2. 否则如果采用传统的链式方法插入. 如果链的长度达到临界值,则把链转换为红黑树;
  4. 如果桶中存在重复的键,则为该键替换新的值value
  5. 如果size大于阈值threshold,则进行扩容;

具体方法如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}


/**
1) key=null:可以看出当key为null时hash值为0
1) key不等于null:
	首先计算出key的hashCode赋值给h, 然后与h无符号右移16位的二进制进行按位异或^ 得到最后的hash值	
*/
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
	...
     if ((p = tab[i = (n - 1) & hash]) == null)		//这里就是上面所说的求摸策略,这里的n表示数组长为16
	...
}         

上面可知HashMap是支持key为空的,而hashTable是直接用key来获取HashCode所有key为空会抛异常.

同时上面也就解释了 HashMap的长度为什么要是2的幂 .因为HashMap使用的方法很巧, 它通过hash & (table.length-1)来得到该对象的保存位,前面说过HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化. 当length总是2的n次方时, hash & (length-1) 运算等价于对length取模,也就是hash%length,但是&比%具有更高的效率. 比如n%32 = n & (32 - 1)

下面是一些位运算解析: 注意:异或运算^: 相同为0,不同为1

(key.hashCode()) ^ (h>>>16)
i = (n - 1) & hash

1111 1111 1111 1111 1111 0000 1110 1010	h = key.hashCode()
0000 0000 0000 0000 1111 1111 1111 1111 h >>>16
----------------------------------------------- 异或操作
1111 1111 1111 1111 0000 1111 0001 0101 返回给: hash
0000 0000 0000 0000 0000 0000 0000 1111 n = 16-1
------------------------------------------------ 与操作
0000 0000 0000 0000 0000 0000 0000 0101 i=5 = i = (n - 1) & hash

简单来看就是:

  • 高16bit不变,低16bit和高16bit做了一个异或(得到的hashcode转化为32位二进制,前16位和后16位做了个异或)

为啥是这样操作呢?

如果当n即数组长度很小,假设是16的话,那么n-1为1111,这样的值与hashCode()直接按位与操作,实际上只使用了哈希值的后四位.如果hash值高位变化很大,低位变化很小,这样就很容易造成hash冲突了,所以这里吧高低位都利用起来,从而解决了一个问题.如下

1111 1111 1111 1111 1111 0000 1110 1010	h1 = key.hashCode()
1010 1011 0001 1111 1111 0000 1110 1010	h2 = key.hashCode() //高位变化很大
此时如果不进行右移16位再异或操作,而是直接和数字长度进行按位与, 则会h1和h2冲突

现在来详细看putVal()方法,看它到底做了什么

主要参数:

  • hash: 即key的hash值, 通过hashCode()方法产生的结果再与右移16位进行异或操作得到
  • key: 原始key
  • value: 要存放的值
  • onlyifAsent: 如果true表示 不更改现有的值
  • evict: 如果false表示table为创建状态
/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)//table为null或者长度为0, 初始扩容
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)		//这里就是上面所说的求摸策略,这里的n表示数组长为16
        tab[i] = newNode(hash, key, value, null);	//tab[i]为空,直接创建节点
    else {	//此处已有节点
        Node<K,V> e; K k;
        if (p.hash == hash && //tab[i]处的已存在节点处的hash == 新插入数据的hash
            ((k = p.key) == key || (key != null && key.equals(k)))) //并且 (比较两者地址是否相等 或者 key内容相等) 
            e = p;		
        else if (p instanceof TreeNode) 
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) { //用for循环找到最后一个节点
                if ((e = p.next) == null) {			//如果p的后继e为null
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // 加之前是否比阈值8小1,也就是说现在节点数是否等于8
                        treeifyBin(tab, hash);	//链表转为红黑树
                    break;
                }
                if (e.hash == hash &&  //表名p的后继不为null
                    ((k = e.key) == key || (key != null && key.equals(k)))) //比较地址 或者 内容
                    break;
                p = e; //p后移, 遍历链表
            }
        }
        if (e != null) { // existing mapping for key  替换策略
            V oldValue = e.value; //得到旧的值
            if (!onlyIfAbsent || oldValue == null)//如果 可更改 并且旧值不为null 
                e.value = value;	//将新值赋到该处的value
            afterNodeAccess(e);
            return oldValue;	//返回旧值
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

接下来来看看结点数大于等于8转换为红黑树的函数(上面30行,真的会转换吗?)

4.3.2 增加方法 put

/**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
final void treeifyBin(Node<K,V>[] tab, int hash) { //传入数组tab,和待插入元素结点的hash
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //  MIN_TREEIFY_CAPACITY=64
        resize(); //不转化,而是扩容
    else if ((e = tab[index = (n - 1) & hash]) != null) { //此时n=64, 根据hash获得桶中元素,赋给e
        TreeNode<K,V> hd = null, tl = null; //头结点hd,尾节点tl
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null); //将链表结点变为树节点p
            if (tl == null)	//第一次,p赋给头结点
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null); //循环至链表的最后一个结点
        if ((tab[index] = hd) != null)
            hd.treeify(tab); 
    }
}

下面我结合图进行说明上述代码完成了怎样的一些操作:

  1. 查看9,10,12,13,14 行, 初次操作,第一次循环:
    [Java系列]搞懂HashMap,看这篇足够!!(万字长文/详细深入)_第4张图片

    此时头结点hd,尾节点tl 同时指向tab[index]转换的结点p

  2. 如果e.next != null,继续循环,此时e指向下一个结点,

    回到12 行,又将其变成p

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

接下来执行15~17行:

    p.prev = tl; //P的前驱节点指向tl
    tl.next = p;//tl的后继指向现在的p
}
tl = p;	//尾节点变为p

经过上述操作变为如下:

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

  1. e继续向后寻找下一个结点, 重复刚刚的动作,得到: (蓝色是本次操作)

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

好了,当e后面没有结点后将不再继续,从而执行到最后一行:

        if ((tab[index] = hd) != null)
            hd.treeify(tab); //旋转等一些操作

在此之前将链表上的每一个结点都转换为了TreeNode(不过left和right指针都没用), 同时相邻两个结点相互指向, 形式上来看更像是双向链表.

而最后一行, hd.treeify(tab); 就是构造红黑树的关键了.

/**
 * Forms tree of the nodes linked from this node.
 * @return root of tree
 */
final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) { //将调用函数的hd命名为x,next指向下一个结点, 再依次向下进行遍历链表
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;	//x的left和right置空
        if (root == null) {			//第一次,表名根节点
            x.parent = null;
            x.red = false;		// 当前节点的红色属性设为false(把当前节点设为黑色)
            root = x;			//现在这个 hd = x = root了, 即根节点指向到当前节点
        }
        else {	//已经存在根节点了
            K k = x.key; 	//当前链表节点的key
            int h = x.hash; //取得当前链表节点的hash
            Class<?> kc = null;// 定义key所属的Class
            for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
                // GOTO1
                int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
                K pk = p.key;// 当前树节点的key
                if ((ph = p.hash) > h)  // 如果当前树节点hash值 大于 当前链表节点的hash值
                    dir = -1;// 标识当前链表节点会放到当前树节点的左侧
                else if (ph < h)
                    dir = 1;// 右侧
                
                /*
                 * 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
                 * 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
                 * 如果还是相等,最后再通过tieBreakOrder比较一次
                 */
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);
                TreeNode<K,V> xp = p;// 保存当前树节点
                
                /*
                 * 如果dir 小于等于0 : 
                 *	当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。
                 * 如果dir 大于0 : 
                 *	当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右孩子,也可能是右孩子的左孩子 或者 更深层次的节点。
                 * 如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点  再从GOTO1 处开始 重新寻找自己(当前链表节点)的位置
                 * 如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点的左或者右侧了。
                 * 挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。
                 */

                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;  // 当前链表节点 作为 当前树节点的子节点
                    if (dir <= 0)
                        xp.left = x; // 作为左孩子
                    else
                        xp.right = x; // 作为右孩子
                    root = balanceInsertion(root, x); //重新平衡
                    break;
                }
            }
        }
    }
    
    // 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的
    // 因为我们要基于树来做查找,所以就应该把 tab[N] 得到的对象一定根是节点对象,而目前只是链表的第一个节点对象,所以要做相应的处理。
    //把红黑树的根节点设为  其所在的数组槽 的第一个元素
    //首先明确:TreeNode既是一个红黑树结构,也是一个双链表结构
    //下面这个方法里做的事情,就是保证树的根节点一定也要成为链表的首节点
    moveRootToFront(tab, root);
}

小结一下putVal() 其主要完成了这几个事情:

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
  3. 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
  4. 判断键值对数量是否大于阈值,大于的话则进行扩容操作

(关于`红黑树数据结构的一些操作,之后会补上)

4.3.3 扩容方法: resize

扩容机制

想要了解HashMap的扩容机制要有这两个问题

  1. 什么时候才需要扩容
  2. HashMap的扩容是什么

1.什么时候才需要扩容

当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAUTL_LOAD_FACTOR)是.75. 也就是说,默认情况下,数组大小为16, 那么当HashMap中的元素个数超过 16 * .75 = 12(这个值就是阈值或者边界值threshold)的时候,就把数组的大小扩展为2*16=32 , 然后重新计算每个元素在数组中的位置, 而这是个非常耗时的操作,所以如果我们已经预知HashMap中元的个数,那么是能够有效地提高HashMap性能的.

补充:

当HashMap其中的一个链表对象个数如果达到了8个, 此时如果数组长度没有达到64,那么HashMap会先扩容解决; 如果已经达到了64, 那么这个链表会变成红黑树,节点类型由Node变为TreeNode类型.当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数小于6, 也会再次把树转换为链表

综上::

  • 当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,会进行数组扩容
  • 当一个链表对象个数如果达到了8个, 此时如果数组长度没有达到64,也会进行扩容

2.HashMap的扩容是什么?

进行扩容, 会伴随着一次**重新hash分配,**并且会遍历hash表中所有的元素,是非常耗时的. 在编写程序中,尽量避免resize

HashMap在进行扩容时,不需要重新计算hash值,1.8使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的(n-1) & hash 的结果相比,只是多了一个bit位,所以节点要么就在**原来的位置,**要么就被分配到"**原位置+旧容量"**这个位置

原数组长度: n=16
(n-1) & hash
			0000 0000 0000 0000 0000 0000 0000 1111 15
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5

			0000 0000 0000 0000 0000 0000 0000 1111 15
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101            
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5


数组长度扩容==> 16*2=32 
(n-1) & hash
			0000 0000 0000 0000 0000 0000 0001 1111 31
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5

			0000 0000 0000 0000 0000 0000 0001 1111 31
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101            
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0001 0101 索引:5+16=21

现在可以很好理解上面那句话了.

							00101 --> 5
0101 ==resize扩容(16*2)=> 
							10101 --> 5+16(oldCap)

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

搞懂了核心机制后,下面来看看源代码:

  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) { //oldCap不为空
            if (oldCap >= MAXIMUM_CAPACITY) { //大于最大容量 2^30
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            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 {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        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) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) { //j位置不空,将其赋给e
                    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 { // preserve order
                        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) { //高位处为0,放在原位置不动(标记lo和hi)
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                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;//放在j+oldCap位置上
                        }
                    }
                }
            }
        }
        return newTab;
    }

4.3.4 删除方法(remove)

理解了put方法之后, remove方法已经没有什么难度, 重复的内容不做详细介绍

删除先是找到元素的位置,如果是链表就遍历链表找到元素后删除,如果用红黑树遍历后找到之后删除,树小于6的时候要转回成链表

    //方法的具体实现在removeNode方法中, 所以重点看removeNode方法
	public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

removeNode方法:

    /**
     * Implements Map.remove and related methods.
     *
     * @param hash 	hash for key
     * @param key 	the key
     * @param value 	the value to match if matchValue, else ignored
     * @param matchValue 	if true only remove if value is equal
     * @param movable 	if false do not move other nodes while removing
     * @return the node, or null if none
     */
    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 && //table不空 且 长度大于0 
            (p = tab[index = (n - 1) & hash]) != null) { //该索引赋给index, 该处节点赋给p,不能为空
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash && //p的hash 等于 传入的hash:
                ((k = p.key) == key || (key != null && key.equals(k))))//比较key内容相等
                node = p; //表明tab[index]即为我们所要删除的:  node 存放tab[index]
            else if ((e = p.next) != null) { //tab[index]不是我们要删的
                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;
                    } while ((e = e.next) != null); //遍历链表,找出key内容相等
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value || //node不空
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)//是红黑树节点,红黑树删除
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)//tab[index]是需要删除的节点
                    tab[index] = node.next;
                else	//需要删除的节点在链表中
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

4.3.5 查找元素方法(get)

查找方法,通过元素的Key找到Value

代码如下:

    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) { //first 存放所求到的元素
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))//hash等 并且 key内容也等
                return first;
            if ((e = first.next) != null) { //如果fist不为所求,但还有后继
                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;
    }

下面来关注下树的中寻找的getTreeNode(hash,key):

        final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);
        }

又调用了find方法:

        /**
         * Finds the node starting at root p with the given hash and key.
         * The kc argument caches comparableClassFor(key) upon first use
         * comparing keys.
         */
        final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
            TreeNode<K,V> p = this;
            do {
                int ph, dir; K pk;
                TreeNode<K,V> pl = p.left, pr = p.right, q;
                if ((ph = p.hash) > h)//往左边找
                    p = pl;
                else if (ph < h) //往右边找
                    p = pr;
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))//找到了
                    return p;
                else if (pl == null)//左边为空
                    p = pr;
                else if (pr == null)//右边为空
                    p = pl;
                else if ((kc != null ||
                          (kc = comparableClassFor(k)) != null) &&
                         (dir = compareComparables(kc, k, pk)) != 0)
                    p = (dir < 0) ? pl : pr;
                else if ((q = pr.find(h, k, kc)) != null //递归调用(整个查找方式类似折半查找)
                    return q;
                else
                    p = pl;
            } while (p != null);
            return null;
        }

小结:

1.get方法实现的步骤:

  1. 通过hash值获取该key映射到的桶

  2. 桶上的key就是要查找的key,则直接找到并返回

  3. 桶上的key不是要找的key,则查看后续的节点:

    a: 如果后续节点是红黑树节点,通过调用红黑树的方法根据key获取value

    b:如果后续节点是链表节点,则通过遍历链表根据key获取value

3.查找红黑树.由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找

4.这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等直接返回; 不相等就从子树递归查找

  • 若为树,则在树中通过key.equals(k)查找, O(logn)
  • 若为链表,则在链表中通过key.equals(k)查找, O(n)

4.3.6 遍历HashMap集合的几种方式

创建实验用例

    public static void main(String[] args) {
        HashMap<String,Integer> hm = new HashMap<>();
        hm.put("aaa",1000);
        hm.put("bbb",1200);
        hm.put("ccc",1400);
        hm.put("aaa",1400);
   		...
    }
  1. 分别获取Key和Values

    简言之: hashmap.keySet()获取keys, hashmap.values() 获取values

        /**
         * 分别获取Keys和Values
         * @param hm
         */
        private static  void method1(HashMap<String, Integer> hm){
            //获取所有key
            Set<String> keys = hm.keySet();
            for (String key : keys)
                System.out.println(key);
    
            //获取所有value
            Collection<Integer> values = hm.values();
            for (Integer value : values)
                System.out.println(value);
    
        }
    
  2. 通过iterator获取

    简言之:先通过hashmap.entrySet获取键值对集合entries,再用Iterator逐一遍历

        /**
         * 使用iterator迭代器迭代
         *
         */    
    	private static void method2(HashMap<String,Integer> hm){
            Set<Map.Entry<String , Integer>> entries = hm.entrySet();
            for (Iterator<Map.Entry<String,Integer>> it = entries.iterator(); it.hasNext();){
                Map.Entry<String,Integer> entry = it.next();
                System.out.println(entry.getKey() + "---" + entry.getValue());
            }
        }
    
  3. 通过get(key)方式 (不建议使用–两次使用迭代器,不建议使用)

    简言之:先用keySet()获取所有的key,在通过hashmap.get(key)获得value

    keySet 其实是遍历2次,一次转为Iterator对象,另一次从hashmap中去除key所对应的value. 而entrySet只是遍历了一次就把key和value都放在了entry中.

        /**
         * 通过get(key)
         */
        private static  void method3(HashMap<String , Integer> hm){
            Set<String> keys = hm.keySet();
            for (String key : keys){
                Integer value = hm.get(key);
                System.out.println(key + "===" value);
            }
        }
    
  4. jdk8 以后使用Map接口中的默认方法forEach(BiConsumer action)

    使用很简单:

        /**
         * jdk8 以后使用Map接口中的默认方法
         */
        private static void method4(HashMap<String, Integer> hm) {
            hm.forEach((key,value)->{	//函数式接口
                System.out.println(key + "----" + value);
            });
        }
    

    进入forEach中看看:

    //HashMap.java
        @Override
        public void forEach(BiConsumer<? super K, ? super V> action) {
            Node<K,V>[] tab;
            if (action == null)
                throw new NullPointerException();
            if (size > 0 && (tab = table) != null) {
                int mc = modCount;
                for (int i = 0; i < tab.length; ++i) {
                    for (Node<K,V> e = tab[i]; e != null; e = e.next)
                        action.accept(e.key, e.value);
                }
                if (modCount != mc)
                    throw new ConcurrentModificationException();
            }
        }
    

    BiConsumer是个啥东东? 和Consumer有什么关系? 进去看看.

    @FunctionalInterface
    public interface BiConsumer<T, U> {
    
        /**
         * Performs this operation on the given arguments.
         *
         * @param t the first input argument
         * @param u the second input argument
         */
        void accept(T t, U u);
    
        default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
            Objects.requireNonNull(after);
    
            return (l, r) -> {
                accept(l, r);
                after.accept(l, r);
            };
        }
    }
    

    和Consumer很像,其实是Consumer相关的函数式接口,Consumer中有一个核心方法:

    void accept(T t); //对给定的参数T执行定义的操作
    

    说白了Comsumer就是给定义一个参数,对其进行(消费)处理,处理的方式可以是任意操作.(抽象方法嘛)

    这里BiConsumer莫不是给定两个参数进行操作.

    void accept(T t, U u);
    

    关于这个函数式接口再说两句, java.util.function中 Function, Supplier, Consumer, Predicate和其他函数式接口广泛用在支持lambda表达式的API中。这些接口有一个抽象方法,会被lambda表达式的定义所覆盖.

    回到forEach中可以看到, K,V或其父类, 经过一系列操作,将逐一打印出key和value; 换句话说, 传入给forEach函数的两个参数,即是每个entry的key和value.

    补充一点: ,前者表示任何T泛型的父类或者T(T是下限),后者表示任何U泛型子类或者就是U(U是上限). 上面的函数实现参数BiConsumer action使用super,表示K和V就是其下限(子类),能对K和V进行的操作就一定能不会蹦(子类能满足,父类也能行).
    关于这一点, 有机会会专门写一写诸如>这样的…

5 关于HashMap初始化再谈

5.1 HashMap初始化问题描述

如果我们确切知道有多少个键值对要进行存储,那么我们在初始化HashMap的时候就应该指定它的容量,以防止HashMap自动扩容,影响使用效率.

默认情况下HashMap的容量为16.但是若用户通过构造函数指定了一个数字作为其容量,那么其会选择大于该数字的第一个2的幂作为容量(3–>4 , 16–>32), 这是前面已经谈过的.

<阿里巴巴Java开发手册>建议我们设置HashMap的初始化容量

[推荐]集合初始化时,指定集合初始值大小

​ 说明: HashMap使用HashMap(int initialCapacity)初始化.

为啥?

HashMap的扩容机制,就是当达到扩容条件时会进行扩容. HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时会自动扩容. threshold = loadFactor *capacity

so, 如果我们没有设置初始化容量大小,随着元素不断增加,HashMap可能会发生多次扩容,而HashMap中的扩容机制会在每次扩容是进行拷贝, 重新hash,很影响性能的.

不过, 设置初始化容量时,设置的数值不同也会很影响性能,那么当我们已知HasMap中即将存放KV个数时,容量设置为多少较好呢? 比如我们有20个KV时,是直接给20么?

5.2 HashMap中容量的初始化

正例: initialCapacity = (需要存储的元素个数 / 负载因子) + 1 . 负载因子loadFactor默认为0.75

仔细想想, 假如我们就有7个KV, 然后我们设置HashMap(7), 经过jdk处理后,会被设置成8. 但是,这个HashMap在元素个数达到8*0.75=6的时候就会扩容了, 这不是我们希望看到的, 我们应该尽量减少扩容.

也即是说, 如果我们通过 initialCapacity/0.75 + 1.0计算: 7 / 0.75 + 1 =10, 经过jdk处理后, 会被变成16 , 这便大大减少了扩容几率.

简单说就是: 你有7个元素需要HashMap操作, 通过计算使用HashMap(10), 这时内部自动帮你扩容到16,threshold为12. 当然这么操作会牺牲一些内存.

你可能感兴趣的:(Java基础)