HashMap
HashMap用于存储键值对,在JDK1.8之前是基于数组+链表实现的,JDK1.8加入了红黑树结构,提高了查询效率。(让再说一说就 线程不安全,扩容问题)
- 数组的作用?链表的作用?
数组用于存储键值对,链表是处理hash冲突的一种方法。
- 什么时候创建数组?
数组并不是在创建map对象的时候创建的,是在put数据的时候创建的。
- 什么时候形成链表?
如果键值对在数组中,位置相同的情况下,发现了hash冲突,就形成了链表。(链表查找的时间复杂度是O(n)
)
- 为什么要在put的时候创建数组?
数组在内存里面是一段连续的内存空间,占用内存很大。如果创建了map对象,没有使用就浪费掉了。
- 数组的扩容
如果数组的位置差不多满了,形成链表的几率就大大增加了,这就涉及到数组的扩容
所以当数组容量使用超过 16*0.75=12
的时候,就会进行数组扩容,目的就是减少hash冲突,提高查询效率。虽然扩容了,但是链表依然存在。
红黑树
为什么引入红黑树?
- JDK1.7的时候,链表如果太长,导致查询效率由常数阶变成线性阶。因此希望提高查询效率。引入红黑树就是为了提高查询效率。
先线程安全的情况下,HashMap也会导致服务器的Dos(拒绝服务)。访问域名的Http参数属性
?name=hce,sex=1...
是存储在hashMap中的,key的hash值相等,会冲突而形成链表,如果受到黑客攻击,可能会生成超长的链表。服务器访问参数时,就要遍历链表,效率就会极低,占据大量CPU。例如:“Aa”,“BB”,“C#”字符串的哈希值都是2112。 在String里面hashcode是重写了的,计算规则是明明白白可以查看的,就能反推出来相同哈希值的字符串。
为什么是红黑树?
- 红黑树的时间复杂度:查询和插入时间复杂度都是
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的改进
为什么要进行异或运算呢?
只有异或运算的均衡的运算,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()
方法进行改进。
方法分析
查找元素的过程
先根据 key
的 hashcode
计算得到hash值,然后利用 (n-1) & hash
计算其在数组的位置,并在该位置中看,该位置存的是链表还是红黑树,然后再到链表或者红黑树中查找。
添加元素的过程(put操作)
- 先根据
key
的hashcode
计算得到hash值,然后利用(n-1) & hash
计算其在数组的位置。如果数组索引位置为空,直接插入。 - 如果数组不为空,先判断 key 是否重复,如果重复则修改value值,且返回原有值。
- 继续判断如果map中存储的是红黑树,则调用
putTreeVal()
方法,按红黑树的方式插入结点 - 继续判断如果map中存储的是链表,则将新结点插入到链表的末尾,插入后判断链表长度是否大于阈值,如果大于则需要将链表转为红黑树。
- 最后判断数组容量是否大于阙值,判断是否需要扩容。
扩容机制resize
创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。
用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
上。
因此,当我们从外部传递进来 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 数据段,然后给每一个数据段配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。
JDK8 的 ConcurrentHashMap 原理
主要对 JDK7 做了三点改造:
① 取消分段锁机制,进一步降低冲突概率。
② 引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。
③ 使用了更加优化的方式统计集合内的元素数量。具体优化表现在:在 put
、resize
和 size
方法中设计元素总数的更新和计算都避免了锁,使用 CAS 代替。
get 同样不需要同步,put 操作时如果没有出现哈希冲突,就使用 CAS 添加元素,否则使用 synchronized 加锁添加元素。
- synchronized只锁定当前链表或红黑树的首结点,这样只要hash不冲突,就不会产生并发,提高效率