对于HashMap的认识

HashMap的认识

前言

HashMap作为Java集合中一个老生常谈的内容,有着重要的地位。我们从源码入手,来剖析一下HashMap的结构和原理

HashMap的数据结构

  • 简单来讲,HashMap是数组+链表的组合,再JDK1.8后,成为了数组+链表红黑树的组合
    对于HashMap的认识_第1张图片

HashMap的构造方法

  1. 无参数的HashMap构造方法会默认构造一个初始容量为16,负载因子为0.75的HashMap
  2. 有参数的构造方法,源码在下面
    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);
    }
  • 先来看其中几个参数:
    • initialCapacity:顾名思义,这是HashMap数组容量的初始值,默认为16,且都为2的整数次幂(这是为什么,下面会提到),假如你将该值设置为17,实际初始化的数字容量为32,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;
    }
    

对于HashMap的认识_第2张图片
- loadFactor:负载因子,默认为0.75
- threshold:threshold = initialCapacity * loadFactor,当HashMap的size到达这个数值时,需要进行resize()进行扩容
这里有一个疑问this.threshold = tableSizeFor(initialCapacity); 为什么不是this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
这是因为table并不是再构造方式中初始化,而是再put()方法中初始化,这样的话,每次put()之后,会重新计算这个threshold选择是否扩容

HashMap中的hash函数

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 从源码中可以看到,这里的hash值是又key的hashCode和hashCode右移16位进行异或操作得到,这样做的好处是什么呢?通过查阅资料,好处是,这样得到的hash值随机性更高,降低了hash冲突的可能性
  • 但是这个数字同样位数很高,如何把他转化为数组的下标呢?
tab[i = (n - 1) & hash] //截取一段put()方法中的源码,n为数组长度-1

这里n为2的整数次幂的好处就体现出来了,n-1的结果是在低位全为1,仅为位运算很方便,这样就可以避免取模这样的复杂运算

HashMap中插入元素

由于在JDK1.8中加入了红黑树结构,所以插入元素要考虑以下几点:数组是否为空,是否存在hash冲突,应该使用链表还是红黑树,是否需要扩容,咱们慢慢道来
对于HashMap的认识_第3张图片

  • 元素计算hash后,插入时计算得到数组下标,如不存在hash冲突,就构造一个Node节点
  • 如存在hash冲突,判断该节点是否为树形节点,如是树形节点,构造树形节点插入红黑树
  • 如不是树形节点,创建Node加入链表,判断链表的长度与TREEIFY_THRESHOLD(默认为8)的大小,大于时转化为红黑树结构,小于UNTREEIFY_THRESHOLD(默认为6)时,转回链表达到性能均衡
  • 还要比较当前Hashmap的size与threshold大小,判断是否需要扩容,这里很关键
  • 这里不得不提到JDK1.8的优化,在1.8之前扩容时,需要把数组中元素重新hash定位在新数组中的位置,在1.8之后,一切都不一样了。
    对于HashMap的认识_第4张图片
  • 这里数组容量为2的整数次幂的好处又体现出来了,由于扩容都是在原本的容量上扩大一倍,其实也就是在最高位多加了一个1而已,只需要判断这一位时0还是1,如果是0,则元素位置不变,如果是1,则下标加上原本数组的大小即可

线程安全

  • HashMap不是线程安全的
  • 关于线程安全问题在JDK1.8也进行了一定优化,1.7插入元素发生hash冲突时采用头插法,1.8改用尾插法
  • 在1.7中头插法在多线程环境下可能会产生环
    对于HashMap的认识_第5张图片
  • 在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,比如当某一结点为空时,A线程和B线程都向该节点放数据,A先放,在判断为空后挂起,这时B线程也添加了节点,A线程恢复后就会覆盖B线程的数据
  • 解决方案:
    1. HashTable:通过sychronized关键字上锁,但是粒度较大
    2. ConcurrentHashMap使用分段锁,降低了锁粒度,提高并发度
      • ConcurrentHashMap使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点
      • 添加节点时通过CAS的方式添加
      • 会检查内部是否在进行扩容,若是,则帮助其扩容
      • 对每一个节点的操作是用sychronized实现的

其他Map结构

  • HashMap拥有很高的查询效率,但是由于hash值的随机性,内部是无序的
  • 有序Map的实现包括LinkedHashMap和TreeMap

LinkedHashMap

类似于HashMap,但是存在一个双向链表来维护keySet,但是迭代遍历时,取得“键值对”的顺序时插入的顺序,或者是最近最少使用的次序

TreeMap

基于红黑树的实现,在查看键或者键值对时,会依据Comparator的规则对key进行排序

总结

JDK1.8的优化

上面提到了很多关于JDK1.8的优化,我们来总结一下

  1. 当链表长度过长时,改用黑红树结构来提高查询性能
  2. 扩容机制的不同,减少了扩容对原本元素的操作,还有一点不同是1.7在插入时先判断是否需要扩容,1.8先进行插入,完成后再判断是否需要扩容
  3. 1.7插入元素发生hash冲突时采用头插法,1.8改用尾插法,提高线程安全性

位操作与位运算

HashMap中大量使用位运算,极大提高了性能

  1. 从hashCode得到hash
  2. 从hash得到数组下标
  3. 初始化数组容量

数组容量为2的整数次幂的好处

  1. 在由hash值得到数组下标的计算中,可以使用位运算避免复杂的取模运算
  2. 在jdk1.8的优化中,扩容时对数组中已存在元素的重排序变得更简单

结语

只有真正的去看源码,才知道jdk内部每一块代码都是这么优美且高效
参考资料:

  • 《Java编程思想》第四版
  • 一个HashMap跟面试官扯了半个小时
  • 一文读懂HashMap

你可能感兴趣的:(数据结构与算法,基础知识,hashmap,数据结构,java)