【知识总结】HashMap

HashMap

HashMap用于存储键值对,在JDK1.8之前是基于数组+链表实现的,JDK1.8加入了红黑树结构,提高了查询效率。(让再说一说就 线程不安全,扩容问题)

  • 数组的作用?链表的作用?

数组用于存储键值对,链表是处理hash冲突的一种方法。

  • 什么时候创建数组?

数组并不是在创建map对象的时候创建的,是在put数据的时候创建的。

  • 什么时候形成链表?

如果键值对在数组中,位置相同的情况下,发现了hash冲突,就形成了链表。(链表查找的时间复杂度是O(n)

  • 为什么要在put的时候创建数组?

数组在内存里面是一段连续的内存空间,占用内存很大。如果创建了map对象,没有使用就浪费掉了。

  • 数组的扩容

如果数组的位置差不多满了,形成链表的几率就大大增加了,这就涉及到数组的扩容

所以当数组容量使用超过 16*0.75=12 的时候,就会进行数组扩容,目的就是减少hash冲突,提高查询效率。虽然扩容了,但是链表依然存在。

红黑树

  • 为什么引入红黑树?

    1. JDK1.7的时候,链表如果太长,导致查询效率由常数阶变成线性阶。因此希望提高查询效率。引入红黑树就是为了提高查询效率
    2. 先线程安全的情况下,HashMap也会导致服务器的Dos(拒绝服务)。访问域名的Http参数属性?name=hce,sex=1...是存储在hashMap中的,key的hash值相等,会冲突而形成链表,如果受到黑客攻击,可能会生成超长的链表。服务器访问参数时,就要遍历链表,效率就会极低,占据大量CPU。

      例如:“Aa”,“BB”,“C#”字符串的哈希值都是2112。 在String里面hashcode是重写了的,计算规则是明明白白可以查看的,就能反推出来相同哈希值的字符串。
  • 为什么是红黑树?

    • 为什么不是平衡二叉树呢?平衡二叉树是大的往右放,如果数据是递增的,那就一直放右子节点,那么这就是tmd链表啊!
    • 折中的方案:红黑树,本质是二叉排序树。原则就是:max <= 2 * min,红黑树里面的旋转是降低树的高度,而且保证在最坏情况下也是 O(lgn)

      【知识总结】HashMap_第1张图片

  • 红黑树的时间复杂度:查询和插入时间复杂度都是O(logN)
  • 什么情况才会用到红黑树?

    当数组的大小大于64且链表的大小大于8的时候才会将链表改为红黑树,当红黑树大小为6时,会退化为链表。

HashMap中的算法

Hash算法的流程

往一个hashmap里放key-value,根据key计算Hash值,然后根据hash值求数组下标。如果冲突了就形成链表。

Hash计算(jdk1.8)

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

/*解析*/
int h = key.hashCode();            //32位
int temp = h>>>16;                //向右位移16位(除法)
int newHash = h ^ temp;            //异或运算
HashMap中hashcaode的改进

【知识总结】HashMap_第2张图片

为什么要进行异或运算呢?

【知识总结】HashMap_第3张图片

只有异或运算的均衡的运算,0和1出现的概率是相同的。这样做的好处可以增加了随机性,减少了碰撞冲突的可能性。

数组下标计算

  • 数组下标的计算方法:i = (n - 1) & hash。n代表数组长度,数组容量16,最大索引就是 n-1 为15。

    为什么使用与运算呢?与运算能一次解决,而模运算需要多次运算,与运算效率更高。
  • 且因为数组长度只能是2的幂次方长度,(n - 1)的二进制位数都是 1。因为是与运算,如果有一位数是0,那无论和谁相与都是0,空间浪费很大,并且增加了hash冲突的几率。
  • 一句话总结:Hash算法和数组定位的算法,都是为了避免产生hash冲突

HashMap中的参数深入

/*几个参数要了解*/

//这是位运算,1向左移位4位, 10000(二进制) = 1*2^4 = 16
Static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;        //初始默认容量

Static final int MAXIMUM_CAPACITY = 1 << 30;            //数组最大容量 1*^30

Static final float DEFAULT_LOAD_FACTOR = 0.75f;            //默认加载因子(控制数组的扩容)

Static final int TREEIFY_THRESHOLD = 8;            //判断链表是否要转换位红黑树(>=8)    
Static final int UNTREEIFY_THRESHOLD = 6;        //判断红黑树是否要转换为链表            

Static final int MIN_TREEIFY_CAPACITY = 64;        //用来判断链表转换红黑树 还是 数组扩容

/*如果数组的长度没有大于64,不会进行链表的转换,会进行扩容*/

扩容因子为什么是0.75?

  • 加载因子是表示Hash表中元素的填满的程度。决定着哈希表的扩容和哈希冲突。
  • 加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。

树化的参数为什么是8?

  • 如果hashcode分布良好,很少出现链表很长的情况,那么红黑树这种形式是很少会被用到的。在理想情况下,链表长度符合泊松分布,当链表长度为8的时候,概率是非常小非常小的。所以通常情况下,并不会发生从链表向红黑树的转换。
  • 如果平时开发中发现HashMap 或是 ConcurrentHashMap 内部出现了红黑树的结构,这个时候往往说明我们的哈希算法出了问题,并对hashcode()方法进行改进。

方法分析

查找元素的过程

先根据 keyhashcode 计算得到hash值,然后利用 (n-1) & hash 计算其在数组的位置,并在该位置中看,该位置存的是链表还是红黑树,然后再到链表或者红黑树中查找。

【知识总结】HashMap_第4张图片

添加元素的过程(put操作)

  • 先根据 keyhashcode 计算得到hash值,然后利用 (n-1) & hash 计算其在数组的位置。如果数组索引位置为空,直接插入。
  • 如果数组不为空,先判断 key 是否重复,如果重复则修改value值,且返回原有值。
  • 继续判断如果map中存储的是红黑树,则调用 putTreeVal() 方法,按红黑树的方式插入结点
  • 继续判断如果map中存储的是链表,则将新结点插入到链表的末尾,插入后判断链表长度是否大于阈值,如果大于则需要将链表转为红黑树。
  • 最后判断数组容量是否大于阙值,判断是否需要扩容。

扩容机制resize

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。

  • 扩容后数组会重新定位。要么原来位置,要么原有位置加上原有的数组长度。

    因为扩容一倍后,进行异或运算的位数就多了一位,而位置就取决于这一位是0还是1,如果是1就相当于加了原有数组长度。

    【知识总结】HashMap_第5张图片

用HashMap存1w条数据,构造时传10000会触发扩容吗?

// 预计存入 1w 条数据,初始化赋值 10000,避免 resize。
HashMap map = new HashMap<>(10000);
// for (int i = 0; i < 10000; i++)

我们先看看,HashMap 初始化时,指定初始容量值都做了什么?

  • 在 HashMap 中,提供了一个指定初始容量的构造方法 HashMap(int initialCapacity),这个方法最终会调用到 HashMap 另一个构造方法,其中的参数 loadFactor 就是默认值 0.75f。
  • 其中的成员变量 threshold 就是用来存储,触发 HashMap 扩容的阈值。也就是说,当 HashMap 存储的数据量达到 threshold 时,就会触发扩容。

    this.threshold = tableSizeFor(initialCapacity);
  • 从构造方法的逻辑可以看出,HashMap 并不是直接使用外部传递进来的 initialCapacity,而是经过 tableSizeFor() 方法的处理,再赋值到 threshold 上。

    【知识总结】HashMap_第6张图片

因此,当我们从外部传递进来 1w 时,实际上经过 tableSizeFor() 方法处理之后,就会变成 2^14^ = 16384,再算上负载因子 0.75f,实际在不触发扩容的前提下,可存储的数据容量是 12288(16384 * 0.75f)

HashMap线程不安全

HashMap不是线程安全的,在多线程环境下,HashMap有可能会有数据丢失和获取不了最新数据的问题,比如说:线程A put 进去了,线程B get 不出来。我们想要线程安全,可以使用ConcurrentHashMap。

HashMap在JDK7到8版本的区别

  • 数据结构不同
    • 1.7中的HashMap是数组+链表的结构
    • 1.8中的HashMap是数组+链表+红黑树的结构
  • 链表插入方式不同
    • 1.7中使用的是头插法,头插法在进行扩容时存在线程安全问题导致链表死循环
    • 1.8中使用的是尾插法
  • 扩容后重新计算索引方式不同
    • 1.7将会使用扩容后的大小重新与hash计算索引
    • 1.8会判断之前hash中需要加入计算索引位置是 0 还是 1,是 0 则保持原位,1 则在现在索引的基础上加上原有数组长度

ConcurrentHashMap

HashTable也是线程安全的,但是使用了synchronized关键字,效率相对较低。

ConcurrentHashMap是线程安全的Map实现类,它在 juc 包下的。

JDK7 的 ConcurrentHashMap 原理

ConcurrentHashMap 用于解决 HashMap 的线程不安全和 HashTable 的并发效率低,HashTable 之所以效率低是因为所有线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器的部分数据,那么多线程访问容器不同数据段的数据时,线程间就不会存在锁竞争,从而有效提高并发效率,这就是 ConcurrentHashMap 的锁分段技术。

首先将数据分成 Segment 数据段,然后给每一个数据段配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。

【知识总结】HashMap_第7张图片

JDK8 的 ConcurrentHashMap 原理

主要对 JDK7 做了三点改造:

① 取消分段锁机制,进一步降低冲突概率。

② 引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。

③ 使用了更加优化的方式统计集合内的元素数量。具体优化表现在:在 putresize size 方法中设计元素总数的更新和计算都避免了锁,使用 CAS 代替。

get 同样不需要同步,put 操作时如果没有出现哈希冲突,就使用 CAS 添加元素,否则使用 synchronized 加锁添加元素。

  • synchronized只锁定当前链表或红黑树的首结点,这样只要hash不冲突,就不会产生并发,提高效率

【知识总结】HashMap_第8张图片

你可能感兴趣的:(javahashmap)