HashMap相关知识点

参考文章:https://blog.csdn.net/qq_38685503/article/details/88430788

https://www.cnblogs.com/yanzige/p/8392142.html

https://www.cnblogs.com/ylspace/p/12726672.html

https://www.jianshu.com/p/e694f1e868ec

分为JDK1.7和1.8.  前半部分1.7,后半部分1.8.

-----------------------------------------------------------------------------------------------
HashMap底层数据结构,以及解决hash碰撞的方法:
  数组+链表,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
jdk1.7中的hash函数对哈希值的计算直接使用key的hashCode值
jdk1.7中发生hash碰撞时候采用的是头插法,也就是插入链表的头部。因此每次扩容后,链表顺序都会发生倒序。
jdk1.7中当哈希表为空时,会先调用inflateTable()初始化一个数组;
首先讲解初始容量和负载因子:
初始容量(initialCapacity):默认16,代表了哈希表中桶的初始数量;table 数组的长度虽然依赖 initialCapacity,但是每次都会通过 roundUpToPowerOf2(initialCapacity) 方法来保证为 2 的幂次。
负载因子(loadFactor):默认为0.75,是哈希表在其容量自动增加之前可以达到多满的一种饱和度百分比,其衡量了一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。
饱和度 = entry存储个数 / 数组长度
HashMap 中默认的负载因子为 0.75,默认情况下第一次扩容阀值是 12(16 * 0.75)。
但是并不代表hashmap中键值对的个数达到12就一定会发生扩容。
jdk1.7发生扩容满足两个条件:
1、 存放新值的时候当前已有元素的个数必须大于等于阈值
2、 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)。
因此存在下面两种情况,仍旧不会出现扩容:
  1.连续16个键值对都未发生hash碰撞,直到第17个进来才会触发扩容机制。
  2.前11个全部都是hash碰撞,也就是说前11个存在数组的同一个位置的链表中,后续15次都未发生hash碰撞,因此,最大达到26个键值对,仍未发生扩容,直到第27个进入才会发生扩容。
另外,jdk1.7是在扩容完成后才会进行插入操作。
讲完了hashMap的扩容时机,接下来讲讲hashMap的死锁问题:
  众所周知,hashMap是线程不安全的,因此在多线程下,对hashMap进行扩容,会出现一系列问题,面试中最容易被问到的就是死锁问题。
假如存在线程A,B。存在HashMap数组的某个链表下为3->7->null.
当触发扩容后,线程A开始执行,3.next = 7;然后暂停。线程B开始扩容,线程B扩容完毕后,结果为7->3->null,此时线程A继续工作,3.next = 7执行完毕后,发现7.next = 3,此时,形成一个环,也就是死锁。
 

1、map.put(k,v)实现原理
第一步首先将k,v封装到Node对象当中(节点)。第二步它的底层会调用K的hashCode()方法得出hash值。第三步通过哈希表函数/哈希算法,将hash值转换成数组的下标,下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
2、map.get(k)实现原理
         第一步:先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。第二步:通过上一步哈希算法转换成数组的下标之后,在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
3、为何随机增删、查询效率都很高的原因是?
原因:增删是在链表上完成的,而查询只需扫描部分,则效率高。
 
4、为什么放在hashMap集合key部分的元素需要重写equals方法?
因为equals默认比较是两个对象内存地址
 

 

----------------------------------------------------------------------------------------------
JDK1.8
对应的,hashMap采用数组+链表+红黑树的结构。
当链表中键值对达到了8个(代码是>=7,从0开始,及第8个开始判断是否转化成红黑树),如果数组的长度还小于64的时候,则会扩容数组。
发生hash碰撞的时候采用的是尾插法,因此也就不会出现死锁的问题。
当哈希表为空时,会直接调用resize()扩容;
1.8中hash函数对哈希值的计算采用key的hashCode异或上key的hashCode进行无符号右移16位的结果,避免了只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,使元素分布更均匀;
Java 8 中Hashmap扩容机制:
只需要满足一个条件:当前存放新值(注意不是替换已有元素位置时)的时候已有元素的个数大于等于阈值(已有元素等于阈值,下一个存放后必然触发扩容机制)。
总结:
  (1)Java 8 在新增数据存入成功后进行扩容
  (2)扩容会发生在两种情况下(满足任意一种条件即发生扩容):
      a 当前存入数据大于阈值即发生扩容
      b 存入数据到某一条链表上,此时数据大于8,且总数量小于64即发生扩容
  (3)此外需要注意一点java7是在存入数据前进行判断是否扩容,而java8是在存入数据库在进行扩容的判断。
jdk8关于红黑树和链表的知识:
  第一次添加元素的时候,默认初期长度为16,当往map中继续添加元素的时候,通过hash值跟数组长度取“与”来决定放在数组的哪个位置,如果出现放在同一个位置的时候,优先以链表的形式存放,在同一个位置的个数又达到了8个(代码是>=7,从0开始,及第8个开始判断是否转化成红黑树),如果数组的长度还小于64的时候,则会扩容数组。如果数组的长度大于等于64的话,才会将该节点的链表转换成树。在扩容完成之后,如果某个节点的是树,同时现在该节点的个数又小于等于6个了,则会将该树转为链表。
 
另外:为什么HashMap会在链表到达8的时候进行扩容?
  当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值(树化门槛)。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
 
 
 ----------------------------------------------------------
hashMap是线程不安全的,因此面试经常会被问到,如何线程安全的使用hashMap?
也就是说HashMapHashtableConcurrentHashMapsynchronized Map的原理和区别:
先说hashMap为何不安全:
  1.如果线程A在执行for循环,遍历hashMap,线程B在执行remove,那就会导致程序异常报错。
  2.如果两个线程同时put,并且put的key计算出来的hashMap一致,会出现覆盖问题......
那么,如何线程安全的使用HashMap。这个无非就是以下三种方式:
  • Hashtable
  • ConcurrentHashMap
  • Synchronized Map
//Hashtable
Map<String, String> hashtable = new Hashtable<>();
  
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
  
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

首先聊聊hashtable,源码中是使用synchronized来保证线程安全的:

public synchronized V get(Object key) {
       
    }
public synchronized V put(K key, V value) {
    
    }

当一个线程访问HashTable的同步方法时,其他线程如果也要访问同步方法,会被阻塞住。当一个线程使用put方法时,另一个线程不但不可以使用put方法,连get方法也不可以。

ConcurrentHashMap

Java7的ConcurrentHashMap里有多把锁,每一把锁用于其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而提高并发访问效率。这就是“锁分离”技术。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(继承了ReentrantLock),在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。
数据结构:
一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构,Segment数组中每个Segment里包含一个HashEntry数组,一个HashEntry数组中的每个hashEntry对象是一个链表的头结点,每个链表结构中包含的元素才是Map集合中的key-value键值对。
因此:一个Segment对应(锁住)一个HashEntry数组。
 jdk1.7: 

put实现

当执行put方法插入数据时,根据key的hash值,在Segment数组中找到相应的位置,如果相应位置的Segment还未初始化,则通过CAS进行赋值,接着执行Segment对象的put方法通过加锁机制插入数据,实现如下:

场景:线程A和线程B同时执行相同Segment对象的put方法

1、线程A执行tryLock()方法成功获取锁,则把HashEntry对象插入到相应的位置;
2、线程B获取锁失败,则执行scanAndLockForPut()方法,在scanAndLockForPut方法中,会通过重复执行tryLock()方法尝试获取锁,在多处理器环境下,重复次数为64,单处理器重复次数为1,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B;
3、当线程A执行完插入操作时,会通过unlock()方法释放锁,接着唤醒线程B继续执行;

size实现

因为ConcurrentHashMap是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个Segment的元素个数时,已经计算过的Segment同时可能有数据的插入或则删除。先采用不加锁的方式,连续计算元素的个数,最多计算3次:

1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;

 

---------------------------------------------------------------------------- 
JAVA8中的ConcurrentHashMap:
数组+链表+红黑树。        并发原理:cas乐观锁+synchronized锁。
加锁对象: 数组每个位置的头节点
锁住的一个某一个数组元素table[i](头节点,该头结点类型是链表头结点或红黑树的头结点),而Java7中segment锁住的是一个HashEntry数组,相当于锁住了多个数组元素;所以Java8中ConcurrentHashMap多线程环境下 put效率更高。
 
put方法:
先根据key的hash值定位桶位置,然后cas操作获取该位置头节点,接着使用synchronized锁锁住头节点,遍历该位置的链表或者红黑树进行插入操作。
稍微具体一点:
1.根据key的hash值定位到桶位置
2.判断if(table==null),先初始化table。
3.判断if(table[i]==null),cas添加元素。成功则跳出循环,失败则进入下一轮for循环。
4.判断是否有其他线程在扩容table,有则帮忙扩容,扩容完成再添加元素。进入真正的put步骤
5.真正的put步骤。桶的位置不为空,遍历该桶的链表或者红黑树,若key已存在,则覆盖;不存在则将key插入到链表或红黑树的尾部(
  1、如果相应位置的Node还未初始化,则通过CAS插入相应的数据;
  2、如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;
  3、如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;
  4、如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
  5、如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount
)。
并发问题:假如put操作时正好有别的线程正在对table数组(map)扩容怎么办?
     答:暂停put操作,先帮助其他线程对map扩容。
 
get方法:
根据key的hash值定位,遍历链表或者红黑树,获取节点。
具体一点:
1.根据key的hash值定位到桶位置。
2.map是否初始化,没有初始化则返回null。否则进入3
3.定位到的桶位置是否有头结点,没有返回nul,否则进入4
4.是否有其他线程在扩容,有的话调用find方法查找。所以这里可以看出,扩容操作和get操作不冲突,扩容map的同时可以get操作。
5.若没有其他线程在扩容,则遍历桶对应的链表或者红黑树,使用equals方法进行比较。key相同则返回value,不存在则返回null.
并发问题:假如此时正好有别的线程正在对数组扩容怎么办?
      答:没关系,扩容的时候不会破坏原来的table,遍历任然可以继续,不需要加锁。
 
扩容方法:
什么情况会导致扩容?
      1.链表转换为红黑树时(链表节点个数达到8个可能会转换为红黑树)。如果转换时map长度小于64则直接扩容一倍,不转化为红黑树。如果此时map长度大于64,则不会扩容,直接进行链表转红黑树的操作。
      2.map中总节点数大于阈值(即大于map长度的0.75倍)时会进行扩容。
如何扩容?
      1.创建一个新的map,是原先map的两倍。注意此过程是单线程创建的
      2.复制旧的map到新的map中。注意此过程是多线程并发完成。(将map按照线程数量平均划分成多个相等区域,每个线程负责一块区域的复制任务)
 
扩容的具体过程:
 整体思路:扩容是并发扩容,也就是多个线程共同协作,把旧table中的链表一个个复制到新table中。
      1.给多个线程划分各自负责的区域。分配时是从后向前分配。假设table原先长度是64,有四个线程,则第一个到达的线程负责48-63这块内容的复制,第二个线程负责32-47,第三个负责16-31,第四个负责0-15。
      2.每个线程负责各自区域,复制时是一个个从后向前复制的。如第一个线程先复制下标为63的桶的复制。63复制完了接下来复制62,一直向前,直到完成自己负责区域的所有复制。
      3.完成自己区域的任务之后,还没有结束,这时还会判断一下其他线程负责区域有没有完成所有复制任务,如果没有完成,则可能还会去帮助其它线程复制。比如线程1先完成了,这时它看到线程2才做了一半,这时它会帮助线程2去做剩下一半任务。
      4.那么复制到底是怎么完成的呢?线程之间相互帮忙会导致混乱吗?
      5.首先回答上面第一个问题,我们知道,每个数组的每个桶存放的是一个链表(红黑树也可能,这里只讨论是链表情况)。复制的时候,先将链表拆分成两个链表。拆分的依据是链表中的每个节点的hash值和未扩容前数组长度n进行与运算。运算结果可能为0和1,所以结果为0的组成一个新链表,结果为1的组成一个新链表。为0的链表放在新table的 i 位置,为1的链表放在 新table的 i+n处。扩容后新table是原先table的两倍,即长度是2n。
      6.接着回答上面第二个问题,线程之间相互帮忙不会造成混乱。因为线程已完成复制的位置会标记该位置已完成,其他线程看到标记则会直接跳过。而对于正在执行的复制任务的位置,则会直接锁住该桶,表示这个桶我来负责,其他线程不要插手。这样,就不会有并发问题了。
      7.什么时候结束呢?每个线程参加复制前会将标记位sizeCtl加1,同样退出时会将sizeCtl减1,这样每个线程退出时,只要检查一下sizeCtl是否等于进入前的状态就知道是否全都退出了。最后一个退出的线程,则将就table的地址更新指向新table的地址,这样后面的操作就是新table的操作了。
简单来说就是:
1.多线程使用cas乐观锁竞争tab数组初始化的权力。
2.线程竞争成功,则初始化tab数组。
3.竞争失败的线程则让出cpu(从运行态到就绪态)。等再次得到cpu时,发现tab!=null,即已经有线程初始化tab数组了,则退出即可。
 

SynchronizedMap

调用synchronizedMap()方法后会返回一个SynchronizedMap类的对象,而在SynchronizedMap类中使用了synchronized同步关键字来保证对Map的操作是线程安全的。

从性能上面看,

ConcurrentHashMap > SynchronizedMap > Hashtable

posted @ 2020-11-08 20:04  c++c鸟  阅读(303)  评论(0编辑  收藏  举报