HashMap、ConcurrentHashMap、CopyOnWrite详解分析
1、HashMap
数组 + 链表 + (红黑树 jdk >= 8)
HashMap重要属性
- 默认初始容量:DEFAULT_INITIAL_CAPACITY = 1 << 4
- 最大Hash表容量:MAXIMUM_CAPACITY = 1 << 30
- 负载因子:DEFAULT_LOAD_FACTOR = 0.75f
- 负载因子过大会导致哈希冲突明显增加;
- 负载因子过小会导致哈希表频繁扩容;
- 默认0.75是一个经过计算(牛顿二项式)得出的值,即能保证哈希冲突不会太严重也能保证效率不会低;
- 实际容量:int threshold = DEFAULT_LOAD_FACTOR * tab.length;
- 树化阈值:TREEIFY_THRESHOLD = 8;
- 索引下标对应的链表长度达到阈值8并且当前哈希表长度达到64才会树化,否则只是调用resize()方法进行哈希表扩容。resize()—扩容为原先数组的2倍
- 解除树化阈值:UNTREEIFY_THRESHOLD = 6;
- 链表转红黑树时hash表长度最小容量阈值,达不到优先扩容:MIN_TREEIFY_CAPACITY = 64;
HashMap、TreeMap、LikedHashMap的区别
- 需要基于排序的统计使用TreeMap
- 需要快速增删改查的存储功能使用HashMap,LikedHashMap
- 需要快速增删改查而且需要保证遍历和插入顺序一致的存储功能使用LikedHashMap
HashMap与HashTable的区别
- HashMap 是非线程安全的,HashTable 是线程安全的,因此HashMap 要比 HashTable 效率⾼;
- HashMap 中,null 可以作为键,这样的键只有⼀个,可以有⼀个或多个键所对应的值为 null;
- HashTable 中 put 的键值为null,直接抛出 NullPointerException;
- 创建时如果不指定容量初始值,Hashtable 默认的初始⼤⼩为11,之后每次扩充,容量变为原来的2n+1。
- HashMap 默认的初始化⼤⼩为16,之后每次扩充容量变为原来的2倍;
- 创建时如果指定了容量初始值,那么 Hashtable 会直接使⽤你给定的⼤⼩,⽽ HashMap 会将其扩充为2的幂次⽅大小;
HashMap解决Hash冲突的方法
- 哈希冲突:
- 用一个数去模运算,取得余数就是所要存储数组数据的下标,但是余数可能会存在一样的,这样就导致一样余数的数据无处存储,所以就造成了哈希冲突。
- 解决哈希冲突的方法有三种,分别是:
- 开放地址法:寻找下一个为空的数组下标,而后将冲突元素存储
- 再散列法(二次哈希法):再次使用一个不同的哈希算法再计算一次 (第一次 %16,再换另一个数进行%运 算)
- 链地址法(拉链法):将所有冲突元素按照链表存储,冲突后时间复杂度变为 O(1+n) n为冲突元素个数(hashMap就是用这种方法)
JDK8 中 HashMap要引入红黑树
- 当链表长度过长时,会将哈希表查找的时间复杂度退化为O(n),树化会保证即便在哈希冲突严重时,查找的时间复杂度也为O(logn);
- 当红黑树结点个数在扩容或者删除元素时减少为6以下,在下次resize()过程中会将红黑树退化为链表,节省空间;
- 链表长度阈值设置成8,这个阈值的设定符合泊松分布,当这个阈值是8的时候,它的概率就很接近于0了,设置更大会导致链表长度过长影响查找速度 。
HashMap线程不安全
- JDK1.7采用头插法,线程1和线程2同时进行 put 操作,线程1 和 线程2 的 key 同时都指向同一个数组下标 table[i]。线程1先获取table[i]的头节点,将自己插入的节点作为新头节点准备插入时,时间片使用完,轮到线程2 执行插入完成。这时候再轮到线程1插入,就会覆盖线程2插入的节点,从而导致数据覆盖。
- JDK7的HashMap在put的时候,在多线程下进行put操作时,如果此时发生扩容,可能造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
- JDK1.8采用尾插法,多个线程同时获取到尾节点 tail,线程1插入后没来得及将插入节点A改为尾节点,就被线程2获取到时间片。将节点B插入到尾节点tail,然后线程1再次获得时间片将节点A改为尾节点,这时就出现了一个孤立的节点B。
Jdk7—扩容死锁分析
-
死锁问题核心在于下面代码,多线程扩容导致形成的链表环,去掉了一些冗余的代码, 层次结构更加清晰了。
-
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next;//第一行,记录oldhash表中e.next if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity);//第二行,rehash计算出数组的位置(hash表中桶的位置), e.next = newTable[i];//第三行,e要插入链表的头部,所以要先将e.next指向new hash表中的第一个元素 newTable[i] = e;//第四行,将e放入到new hash表的头部 e = next;//第五行,转移e到下一个节点,继续循环下去 } } }
单线程扩容
- 假设:hash表长度为2,如果不扩容, 那么元素key为3,5,7按照计算(key % table.length)的话都应该碰撞到table[1]上。
- 扩容:hash表长度会扩容为4重新hash,key=3 会落到table[3]上(3%4=3),当前e.next为key(7), 继续while循环重新hash,key=7 会落到table[3]上(7%4=3), 产生碰撞, 这里采用的是头插入法,所以key=7的Entry会排在key=3前面(这里可以具体看while语句中代码)当前e.next为key(5), 继续while循环重新hash,key=5 会落到table[1]上(5%4=3), 当前e.next为null, 跳出while循环,resize结束。如下图所示
多线程扩容
-
假设这里有两个线程同时执行了put()操作,并进入了transfer()环节,线程1执行第一行被挂起。
-
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next;//第一行,记录oldhash表中e.next //--------线程1执行到此被调度挂起------------ if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity);//第二行,rehash计算出数组的位置(hash表中桶的位置), e.next = newTable[i];//第三行,e要插入链表的头部,所以要先将e.next指向new hash表中的第一个元素 newTable[i] = e;//第四行,将e放入到new hash表的头部 e = next;//第五行,转移e到下一个节点,继续循环下去 } } }
-
此时状态为:
- 从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。
- 然后线程1被唤醒了:
- 执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null,
- 执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
- 执行e = next,将 e 指向 next,所以新的 e 是 key(7)
- 然后该执行 key(3)的 next 节点 key(7)了:
- 现在的 e 节点是 key(7),首先执行Entry<K,V> next = e.next,那么 next 就是 key(3)了
- 执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)
- 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
- 执行e = next,将 e 指向 next,所以新的 e 是 key(3)
- 此时状态为:
- 然后又该执行 key(7)的 next 节点 key(3)了:
- 现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null
- 执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
- 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
- 执行e = next,将 e 指向 next,所以新的 e 是 key(7)
- 此时状态为:
Jdk8—扩容
- 原理:HashMap内部实现是一个数组,每个位置存放着一个链表的头结点。其中每个结点存储的是一个键值对整体( Entry ),HashMap采用拉链法解决哈希冲突,JDK1.8后当链表长度大于阈值时,将链表转化为红黑树,以减少搜索时间。
- 扩容时机:当 map 中包含的 Entry 的数量大于等于 threshold = loadFactor * capacity 的时候,且新建的Entry 刚好落在一个非空的索引上,此刻触发扩容机制,将其容量扩大为2倍。
- HashMap 扩容在Jdk8修复了 Jdk7 扩容的坑,采用高低位拆分转移方式,避免了链表环的产生。
- jdk1.8中新增加了两组指针,一高位指针和低位指针
- 高低位是取决扩容前该节点上的key的hash值和oldsize与运算的值。0为低位,1为高位。
- 低位指针部分就留在new table原来的位置,高位指针就迁移到new table(原来位置+oldsize)的位置上。
HashMap的扰动函数(hash)
- 实现过程:
- 使用 key.hashCode() 计算hash值并赋值给变量h;
- 将变量h和向右移16位的h做异或运算(二进制位相同为0,不同为1);
- 右移16位正好为32bit的一半,自己的高半区和低半区做异或,混合原始哈希吗的高位和低位,来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,使高位的信息也被保留下来。
HashMap的put过程
- 如果是第一次添加,就会创建长度为16的Node数组;
- 如果键值对数量超过实际容量或某一个索引的链表长度超过8且数组长度没有超过64,那么就执行数组( 容量扩大为2倍 )的扩容操作;
- 如果没有超过实际容量( threshold = loadFactor * capacity ),那么可以通过链表的方式存储元素;
- HashMap使用Key.hashCode()和哈希算法来找出存储键值对的索引。如果索引处为空,则直接插入到对应索引位置。
- 否则,判断链表的长度是否超过8且数组的长度是否超过64,将链表转为红黑树,转成功之后再插入。
2、ConcurrentHashMap
ConcurrentHashMap由三部分构成, table+ 链表 + 红黑树
ConcurrentHashMap重要属性
- LOAD_FACTOR:负载因子,默认0.75f;
- TREEIFY_THRESHOLD:树化阈值,默认8;
- 索引下标对应的链表长度达到阈值8并且当前哈希表长度达到64才会树化,否则只是调用resize()方法进行哈希表扩容。resize()—扩容为原先数组的2倍
- UNTREEIFY_THRESHOLD:解除树化阈值,默认6;
- MIN_TRANSFER_STRIDE:table扩容时,每个线程最少迁移 table 的槽位个数,默认16;
- MOVED:值为-1,当Node.hash为MOVED时,代表着table正在扩容;
- TREEBIN:置为-2,代表此元素后接红黑树。
- nextTable:table迁移过程临时变量,在迁移过程中将元素全部迁移到nextTable上。
- sizeCtl:用来标志table初始化和扩容的,不同的取值代表着不同的含义:
- 0:table还没有被初始化
- -1:table正在初始化
- 小于-1:实际值为 resizeStamp(n) << RESIZE_STAMP_SHIFT + 2,表明table正在扩容
- 大于0:初始化完成后,代表table最大存放元素的个数,默认为0.75 * n
- transferIndex:table容量从n扩到2n时,是从索引n->1的元素开始迁移,transferIndex代表当前已经迁移的元素下标
- ForwardingNode:一个特殊的Node节点,其hashcode = MOVED,代表着此时table正在做扩容操作。扩容期间,若table某个元素为null,那么该元素设置为ForwardingNode,当下个线程向这个元素插入数据时,检查hashcode=MOVED,就会帮着扩容。
ConcurrentHashMap线程安全
HashTable![image-20220512110828952](https://i-blog.csdnimg.cn/blog_migrate/3271b806a4b6520ce6101f765870bc20.png)
ConcurrentHashMap—Jdk7
ConcurrentHashMap—Jdk8
三者区别
- 底层数据结构:
- Hashtable 底层采用数据结构:数组 + 链表;
- ConcurrentHashMap-Jdk7 底层采用数据结构:分段的数组 + 链表 实现;
- ConcurrentHashMap-Jdk8 底层采用数据结构:数组+ 链表 / 红黑二叉树;
- Hashtable:Hashtable 底层采用数据结构:数组+链表;使用 synchronized锁整个table来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
- ConcurrentHashMap-Jdk7:首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
- ConcurrentHashMap-Jdk8:取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构:Node 数组 + 链表 / 红黑树。Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。
ConcurrentHashMap的put操作
- 首先根据key计算对应的数组下标 i,如果该位置没有元素,则通过自旋的方法去向该位置赋值;
- 如果该位置有元素,则synchronized会加锁;
- 加锁成功之后,在判断该元素的类型;
- 如果是链表节点则进行添加节点到链表中
- 如果是红黑树则添加节点到红黑树
- 添加成功后,判断是否需要进行树化
- addCount,这个方法的意思是ConcurrentHashMap的元素个数加1,但是这个操作也是需要并发安全的,并且元素个数加1成功后,会继续判断是否要进行扩容,如果需要,则会进行扩;
- 同时一个线程在put时如果发现当前ConcurrentHashMap正在进行扩容则会去帮助扩容;
ConcurrentHashMap扩容
- 扩容检查主要发生在插入元素(putVal())的过程:
- 一个线程插完元素后, 检查table使用率, 若超过阈值, 调用transfer进行扩容
- 一个线程插入数据时, 发现table对应元素的hash=MOVED, 那么调用helpTransfer()协助扩容。
扩容transfer
- 通过计算 (CPU核心数 + table长度) 得到每个线程平均要迁移桶的数量,这个数量不会小于 MIN_TRANSFER_STRIDE = 16
- 检查nextTab是否完成初始化, 若没有的话, 说明是第一个迁移的线程, 先初始化nextTab, size是之前table的2倍。初始化transferIndex为 table长度。
- 通过transferIndex和 stride计算出当前线程需要迁移桶的区间,从该区间从右往左迁移,然后修改transferIndex为下一个线程从哪里开始迁移。
- 若该元素为空或当前索引的链表已经迁移完成, 则头节点会被设置成fwd节点。当别的线程向这个索引写数据时, 根据这个标志符知道了table正在被别的线程迁移, 就会调用helpTransfer帮着迁移。如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。
- 迁移过程原理:
- 链表迁移:类似Jdk8中HashMap的高低指针,锁住数组上的Node节点,然后和HashMap1.8中一样,将链表拆分为高位链表和低位链表两个部分,然后复制到新的数组中。
- 红黑树迁移:锁住数组上的Node节点,已链表方式遍历红黑树,将链表拆分为高位链表和低位链表两个部分,迁移后不满足树化条件的退化为链表,满足树化的条件的转为红黑树。
- 迁移过程原理:
- 第一个线程开始迁移时, 设置了sizeCtl= resizeStamp(n) << RESIZE_STAMP_SHIFT+2, 此后每个新来帮助迁移的线程都会sizeCtl=sizeCtl + 1, 完成迁移后,sizeCtl-1, 那么只要有一个线程还处于迁移状态, 那么 sizeCtl > resizeStamp(n) << RESIZE_STAMP_SHIFT+2一直成立, 当只有最后一个线程完成迁移之后, 等式两边才成立。
总结:table扩容过程就是将table元素迁移到新的table上, 在元素迁移时, 可以并发完成, 加快了迁移速度, 同时不至于阻塞线程。所有元素迁移完成后, 旧的table直接丢失, 直接使用新的table。
参考博客:ConcurrentHashMap底层详解(图解扩容)(JDK1.8)
协助扩容helpTransfer
- 检查是否扩容完成
- 对sizeCtrl = sizeCtrl+1, 然后调用transfer()进行真正的扩容。
3、CopyOnWrite机制
- 核心思想:读写分离,空间换时间,避免为保证并发安全导致的激烈的锁竞争。
- CopyOnWrite机制即写时复制的容器。往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后向新的容器里添加元素。添加元素后,再将原容器的引用指向新的容器。
- 重点:
- CopyOnWrite适用于读多写少的情况,最大程度的提高读的效率;
- CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据;
- 并发的读,而不需要加锁,因为当前容器不会添加任何元素。写的时候不能并发写,需要对写操作进行加锁;
CopyOnWriteArrayList
- CopyOnWriteArrayList是写时复制,他通过ReentrantLock实现加锁,并且在写入数据前都会复制原数组,写入数据成功后在将旧的数组替换掉,get()是无锁的;
- CopyOnWriteArrayList的写操作性能较差,而多线程的读操作性能较好;