HashMap是我们非常常用的数据结构,由数组和链表组合构成的数据结构。数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
Java 7 中的HashMap的底层是一个数组+链表的设计,每个hash值的第一个值放在数组里,之后经过hash运算得到相同的hash值锁定数组下标,数组中的每一个元素都是一个单向链表(碰撞,列表)
我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身是通过Hash函数计算出来的,结果就存在概率性,两个不同的key,hash有一定的概率会一样,那就形成了链表。每一个节点都会保存自身的hash、key、value、以及下个节点。
java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。但是,在java8之后,都用尾部插入了
Java 7 中的HashMap的底层是一个数组+链表的设计,每个hash值的第一个值放在数组里,之后经过hash运算得到相同的hash值锁定数组下标,数组中的每一个元素都是一个单向链表(碰撞,列表)
在插入时采用尾插法(1.7是头插法),在并发场景下导致链表成环的问题。而在jdk1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
什么是头插法和尾插法
头插法带来的环状
通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大。
Collections.synchronizedMap是使用 Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。
我们一般都会使用HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap。
HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。
ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现 赋值操作,多线程操作只会锁住当前操作索引的节点。 如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。
是16,因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。 只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。 这是为了实现均匀分布。
capacity:当前数组容量,始终保持 $2^n$,可以扩容,扩容后数组大小为当前的 2 倍。
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capacity * loadFactor。
Java 8 中的HashMap的底层是数组+链表+红黑树的方式实现。
改进的是在数据量较大的情况下(Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树)单向链表的时间复杂是O(N),采用红黑树将时间复杂度降到O(logN)。
那么, 如果我们在删除容器中的元素的时候,删到多少才使得红黑树的存储结构转为链表呢?答案是6
也就是说,在JDK8之后,创建HashMap对象=>添加数组中同一个位置元素超过8个=>该位置链表转为红黑树=>删除数组中同一个位置元素少于6个=>该位置红黑树转为列表。
最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树), 否则,若桶内元素太多时,则直接扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。
也就是说,当数组中某个桶中的元素大于8,小于64时,使用容量进行两倍扩容(其实就是用扩容代替链表转红黑树操作)。当数组中某个桶中的元素大于8且大于64时,链表转红黑树操作。
hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换
hashMap的红黑树不一定小于等于6的时候就会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD(6) 进行转换
Java中HashMap是利用“拉链法”处理HashCode的碰撞问题。
在调用HashMap的put方法或get方法时,都会首先调用hashcode方法,去查找相关的key,当有冲突时,再调用equals方法。hashMap基于hasing原理,我们通过put和get方法存取对象。
当我们将键值对传递给put方法时,他调用键对象的hashCode()方法来计算hashCode,然后找到bucket(哈希桶)位置来存储对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。
HashMap使用链表来解决碰撞问题,当碰撞发生了,对象将会存储在链表的下一个节点中。hashMap在每个链表节点存储键值对对象。当两个不同的键却有相同的hashCode时,他们会存储在同一个bucket位置的链表中。
键对象的equals()来找到键值对。
一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为大于k的2的整数次方,例如如果传10,大小为16
hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。
这个也叫扰动函数,这么设计有二点原因:
一定要尽可能降低hash碰撞,越分散越好;
算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;
因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。
int值范围为-2147483648~2147483647, 前后加起来大概40亿的映射空间。
只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。
但问题是一个40亿长度的数组,内存是放不下的。
因为如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。
源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比%运算要快。
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
HashMap与运算
HashMap碰撞
1. 数组+链表改成了数组+链表或红黑树;
2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的 后继节点,1.8遍历链表,将元素放置到链表的最后;
3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;
A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如上图所示
扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?
这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?
扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。
因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
HashMap中的与运算
是无序的,根据hash值随机插入
LinkedHashMap 和 TreeMap
LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。