文章目录
- HashMap
- HashMap介绍
- HashMap在 JDK1.7和 JDK1.8中的区别
- JDK1.7中HashMap头插法死循环的原因
- HashMap的底层原理
- HashMap的扩容机制
- 解决Hash冲突的方法
- 为什么在解决hash冲突的时候选择先用链表,再转红黑树?
- HashMap为什么线程不安全
- 一般用什么作为HashMap的key?
————————————————————————————————————
HashMap
HashMap介绍
特点
存储形式key value
无序不可重复,key值保障不会重复
初始化容量16,官网推荐为2的倍数,为了散列均匀,提交存取效率,默认加载因子0.75,到75%的时候会扩容,扩容:扩容之后是原容里2倍。0.75是对时间和空间上的一个平衡选择。
key 存储的时候会调用底层hashcode(),hashcode是一串数字,然后会进行取余操作
时间复杂度 每一次取数据O(1) , 大多数每一次插入数据O(1) ,理论上增删改查都是O(1)
HashMap在 JDK1.7和 JDK1.8中的区别
-
组成结构
-
在 JDK1.7 中,由"数组+链表"组成,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8中,则是由"数组+链表+红黑树"组成,当链表长度大于 8 并且 数组的长度大于 64 的时候,再向链表中添加元素链表就会转化成红黑树,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
-
-
JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,头插法就是能够提高插入的效率,但是在并发情况下也会容易出现循环链表问题。尾插法,能够避免出现循环链表的问题。
- 为什么从头插法改成尾插法?
- 在1.7中,是没有红黑树的 在并发的情况下单链表过长,会成环,发生死循环
- 在1.8中,尾插法就可以解决这个问题
- 为什么从头插法改成尾插法?
-
1.7中hash值是可变的,1.8中hash是final修饰,不可变,因为有rehash的操作。1.8中hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。而且为了转换为红黑树新增了一个TreeNode节点。
-
在JDK1.7中,HashMap存储的是Entry对象
在JDK1.8当中,HashMap存储的是实现Entry接口的Node对象
-
扩容机制
- 1.7中是先扩容后插入,1.8中是先插入再扩容
- 在JDK1.7的时候是先扩容后插入的,扩容过程中会将原来的数据,放入到新的数组中,但是会重新计算hash值进行分配,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容,但是在1.8的时候是先插入再扩容的,优点是可以减少1.7的一次无效的扩容,因为如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容
JDK1.7中HashMap头插法死循环的原因
在jdk1.7中HashMap底层使用的是数组加链表的形式,并且在数据插入的时候采用的是头插法,也就是说新插入的数据会从链表的头结点进行插入。比如说扩容之前是有ABC三个节点依次挂在一条链表上。
第一步:这时候有两个线程T1和T2都准备对HashMap进行扩容操作,此时T1和T2指向的都是链表的头结点A,而T1和T2的下一个节点分别是T1.next和T2.next,他们都指向B,
第二步:开始扩容,这个时候,假设T2的时间片正好用完,进入了休眠状态,而线程T1开始执行扩容操作,一直到T1扩容完成后,T2才被唤醒,这个时候T2.next的指向依然没有变。
因为HashMap采用的是头插法线程1执行后,链表中的节点顺序发生了变化,但是线程2对这一切还是不知道的,它的执行还是不变的,所以此时T1执行完成,T2开始执行,死循环就发生了。因为T1扩容完成后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之后的节点顺序是相反的。T1执行完之后是B到A,这样A和B两个节点就发生了死循环。
HashMap的底层原理
-
JDK1.8之前,HashMap由数组+链表组成,数组是主体,链表为了解决冲突问题,HashMap通过key的hashcode经过扰动函数处理得到hash值,然后经过计算(n-1)&hash判断存储位置,当前如果和当前位置的值相同则覆盖,不同则拉链法解决冲突。扰动函数可以减少碰撞。
-
JDK1.8之后,HashMap加了红黑树,当链表长度大于阈值(默认为8),数组长度大于64的时候,会执行链表转红黑树,来加快搜索速度。只有当数组长度大于等于64的时候,才会执行转换红黑树以减少搜索时间,否则通过resize()方法对数组进行扩容。
HashMap的扩容机制
我们创建一个HashMap的集合对象,底层node数组默认大小是16,当集合的存储容量达到一个临界值的时候会扩容,loadFactor * capacity (负载因子 * 容量),负载因子默认是0.75。
- 在jdk1.8中 ,一条链表的元素个数已经到8个,并且table数组长度达到64了,链表就会树化成红黑树
- 如果仅仅是链表元素个数到8 , 数组长度还没到64 , 那么就不会树化,而是数组长度会扩容为2倍 , 然后重新哈希,当然链表的长度可能超过8
- 但是如果红黑树的元素个数小于6 那么就会还原成链表, 当红黑树的元素个数不小于32的时候才会再次扩容
解决Hash冲突的方法
解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是拉链法
①:开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从 hash 表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。(基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,I p1=H§ ,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。)ThreadLocal 就用到了线性探测法来解决 hash 冲突的。
比如像这样一种情况,在 hash 表索引 1 的位置存了一个 key=name,当再次添加key=hobby 时,hash 计算得到的索引也是 1,这个就是 hash 冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的 key。
②:链式寻址法,这是一种非常常见的方法,简单理解就是把存在 hash 冲突的 key,以单向链表的方式来存储,比如 HashMap 就是采用链式寻址法来实现的。
③:再 hash 法,就是当通过某个 hash 函数计算的 key 存在冲突时,再用另外一个hash 函数对这个 key 做 hash,一直运算直到不再产生冲突。这种方式不易产生堆集,但是会增加计算时间,性能影响较大。
④:建立公共溢出区,就是把 hash 表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。
为什么在解决hash冲突的时候选择先用链表,再转红黑树?
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,红黑树搜索时间复杂度是O(logn),而链表是O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
HashMap为什么线程不安全
-
多线程下扩容可能造成死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的岀现,形成死循环。所以在 JDK1.8 改成了尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会岀现环形链表的问题。
-
多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算岀来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK 1.7和JDK 1.8中 都存在。
-
put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold(临界点进行扩容)而导致 rehash(重新计算哈希值),线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和JDK 1.8中都存在。
一般用什么作为HashMap的key?
一般用Integer、 String 这种不可变类当HashMap当key,而且String最为常用。
● 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就是HashMap中的键往往都使用字符串的原因。
● 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了hashCode(以及equals()方法。