HashMap:
JDK1.8对HashMap有较大的优化,底层实现由之前的“数组+链表”改为“数组+链表+红黑树”。
JDK 1.8 的 HashMap 的数据结构如下图所示,当链表节点较少时仍然是以链表存在,当链表节点较多时(大于8)会转为红黑树,当红黑色节点数量小于6时又转换为链表。
注:并不是说只要链表中节点大于8就会转换为红黑树,而是还要满足table的大小要大于64,当table的length小于64时,直接进行扩容操作。
基本属性:
initialCapacity初始容量:
默认为16。HashMap的数组长度一定保持为2的次幂。即使指定initialCapacity时,未设置为2的次幂,也会找大于或等于该值的最接近的2次幂。
loadFactor负载因子:
负载因子,默认值是0.75。负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是此时索引效率高。负载因子为0.75时,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
构造HashMap的时候并没有初始化数组容量,而是在第一次put元素的时候才进行初始化的。
Hash(散列函数):
把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。
hash值:
我们希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。
h = key.hashCode()表示 h 是key对象的hashCode返回值;
h >>> 16 是 h 右移 16 位,因为 int 是 4 字节,32 位,所以右移 16 位后变成:左边 16 个 0 + 右边原 h 的高 16 位;
最后把这两个进行异或(一样取0,不一样取1)返回。
整个过程本质上就是三步:
1.拿到key的hashCode值
2.将hashCode的高位参与异或运算,重新计算 hash 值
3.将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
hash值计算为什么不直接使用hashcode()方法(JDK1.7的方式),而是用它的高16位进行异或计算新的hash值?
h 右移 16 位,因为 int 是 4 字节,32 位,所以右移 16 位后变成:左边 16 个 0 + 右边原 h 的高 16 位。然后在与原来的hashcode值进行异或,这样相当于是hashcode值的高16位与0进行异或,低16位与高16位进行异或,让高16位也参数了寻址计算(进行扰动),这样后面算出来的hash值比较均匀。
寻址为什么不直接hash % n 进行取模,而是(n-1)& hash?
由于计算机相对于取模(%),与(&)运算更快。所以为了效率考虑。而且HashMap中规定了哈希表的长度为2的k次方(转成二进制就是第一位为1,其余k位为全为0),而 2^k-1 转为二进制就是 k 个连续的 1,那么 hash & (k
个连续的 1)
返回的就是 hash 的低 k 个位,该计算结果范围刚好就是 0 到 2^k-1,即 0 到 length - 1,跟取模结果一样。
也就是说,哈希表长度 length 为 2 的整次幂时, hash & (length - 1)
的计算结果跟 hash % length
一样,而且效率还更好。
经过以上计算出来的数组索引不容易出现hash冲突,但是不容易出现冲突不等于不会出现冲突,所以就需要解决hash值冲突:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法。
基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存入哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
put(K key, V value)方法:
可以看到底层其实是调用了putVal
方法。
上图中table: transient Node[] table;
1. 通过hash函数计算key的hash值,然后进入putVal方法。
2. 如果hash表(Node数组)为空,调用resize()方法创建一个hash表。
3. 根据hash值索引hash表中中对应下标的位置,判断该位置是否有hash碰撞。
(1)没有碰撞,直接插入hash表中。
(2)有碰撞,遍历桶中节点
1)第一个节点匹配,记录该节点value值。
2)第一个节点没有匹配,桶中结构为红黑树结构,按照红黑树结构添加数据,记录返回值。
3)第一个节点没有匹配,桶中结构是链表结构。遍历链表,如果找到key映射节点,记录,退出循环,如果没有找到则在链表尾部添加节点。插入后判断链表长度是否大于转换为红黑树要求,满足则转为红黑树结构。
4.用于记录的值判断是否为null,不为null则说明需要插入的节点key在hash表中原来有,替换值,返回旧值,putValue方法结束。
5.如果map的容量(存储元素的数量)大于阈值则进行扩容,扩容为之前容量的2倍。
发生冲突时节点插入链表的链头还是链尾呢?JDK7是头插法,JDK8采用尾插法。
和 JDK7 稍微有点不一样的地方就是,JDK7 是先扩容后插入新值的,JDK8 先插值再扩容。
扩容流程:
HashMap扩容就是就是先计算新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。
JDK1.7中因为是头插法,在多线程情况下,进行扩容时,rehash可能会导致Entry链形成环。一旦Entry链中有环,势必会导致在同一个桶中进行插入、查询、删除等操作时陷入死循环。
具体说明可以看这篇博客:jdk1.7HashMap与jdk1.8concurrenthashmap出现的死循环问题_sjs_caomei的博客-优快云博客
get(Object key)方法:
get()的方法就相对来说要简单一些了,它最重要的就是找到key是存放在哪个位置:
先根据key计算hash值。
然后(n-1) & hash确定元素在数组中的位置。
判断数组中第一个元素是否是需要找的元素。
节点如果是树节点,则在红黑树中寻找元素,否则就在链表中寻找对应的节点(遍历查找)。
Fail-Fast:
我们知道 java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出 ConcurrentModificationgException,这就是所谓 fail-fast 策略。
fail-fast 机制是java集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生fail-fast 事件。
对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount,在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map。
HashMap为什么非线程安全?
JDK1.7的线程不安全问题主要表现在扩容引起的死循环、数据丢失问题,以及数据覆盖问题,而JDK1.8中的线程不安全问题主要表现在数据覆盖问题(进行put()操作时没有加锁导致)。
JDK1,7扩容导致的死循环和数据丢失问题主要是因为JDK1.7中采用了头插法(头插法会将链表的顺序翻转),而JDK1.8采用的尾插法来解决这个问题。
参考博客:JDK1.7和JDK1.8中HashMap为什么是线程不安全的?_张先森的博客-优快云博客_hashmap为什么是线程不安全的。
如何确保线程安全:ConcurrentHashMap。
ConcurrentHashMap:
ConcurrentHashMap是J.U.C(java.util.concurrent包)的重要成员,它是HashMap的一个线程安全的、支持高效并发的版本。在默认理想状态下,ConcurrentHashMap可以支持16个线程执行并发写操作及任意数量线程的读操作。
JDK1.8的实现:
摒弃了Segment分段锁机制,采用Node+CAS+Synchronized来保证并发安全, 采用table数组+链表+红黑树的存储结构。以table数组元素作为锁,利用CAS+Synchronized来保证并发更新的安全,从而实现了对每个数组元素(Node)进行加锁,进一步减少并发冲突的概率。
Node:
最核心的内部类,它包装了key-value键值对,所有插入ConcurrentHashMap的数据都包装在这里面。它与HashMap中的定义很相似,但是但是有一些差别它对value和next属性设置了volatile,它不允许调用setValue方法直接改变Node的value域,它增加了find方法辅助map.get()方法。
TreeNode:
继承自Node,当数据结构换成了红黑树的数据的存储结构,用于存储数据。
但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNode在ConcurrentHashMap集成自Node类,而并非HashMap中的集成自LinkedHashMap.Entry类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。
TreeBin:
这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。在构造TreeBin节点时,仅仅指定了它的hash值为TREEBIN常量,这也就是个标识。
初始化
ConcurrentHashMap的初始化其实是一个空实现,并没有做任何事
默认的初始化操作并不是在构造函数实现的,而是在put操作中实现
其他构造函数则参考HashMap即可。
put方法:
核心方法:
tabAt : 获得在i位置上的Node节点
casTabAt:利用CAS算法设置i位置上的Node节点
putValue函数,首先调用spread函数,计算hash值,之后进入一个自旋循环过程,直到插入或替换成功,才会返回。如果table未被初始化,则调用initTable进行初始化。之后判断hash映射的位置是否为null,如果为null,直接通过CAS自旋操作,插入元素成功,则直接返回,如果映射的位置值为MOVED(-1),则直接去协助扩容,排除以上条件后,尝试对链头Node节点f加锁,加锁成功后,链表通过尾插遍历,进行插入或替换。红黑树通过查询遍历,进行插入或替换。之后如果当前链表节点数量大于阈值,则调用treeifyBin函数,转换为红黑树。最后通过调用addCount,执行CAS操作,更新数组大小,并且判断是否需要进行扩容。
执行过程:
利用spread方法对key的hashcode进行一次hash计算,来确定这个值在table中的位置。
如果没有初始化就先调用initTable()
方法来进行初始化过程。
如果相应位置的Node还未初始化,则通过CAS插入相应的数据;
如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁(锁的是数组中的头节点)。
如果该节点的hash>0,则得到的结点就是hash值相同的节点组成的链表的头节点,则遍历链表更新节点或向后遍历,直到链表尾插入新节点。
如果该节点是TreeBin类型的节点,说明是红黑树结构,则调用红黑树的插值方法putTreeVal插入新节点;
如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount(addCount()方法尝试使用CAS操作,将BASECOUNT加1,操作失败,则说明有其他线程在进行加一操作,发生冲突);
问题:
ConcurrentHashMap扩容过程中如何保证线程安全?协助扩容具体是做什么操作。
HashMap 在 JDK 1.8 有什么改变?
1.JDK1.7底层实现 “数组+链表”, JDK1.8中改为“数组+链表+红黑树”,JDK1.8中当同一个hash值的节点数大于8时,将不再已链表形式存储,会被调整为一颗红黑树。链表查找的时间复杂度为O(n),而红黑树一直是O(logn),这样会提高HashMap的效率。
2.hash值计算方式不一样。JDK1.7中用key的hashcode与数组长度取模来决定当前key所在数组的位置。JDK1.8中进行了优化。
3.出现hash冲突时,JDK1.7是头插,JDK1.8是尾插。
4.JDK1.7在扩容时,resize()过程中,采用单链表的头插入方式,在将旧数组上的数据转移到新数组上时,按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况。JDK 1.8 转移数据操作时,按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表逆序、倒置的情况,故不容易出现环形链表的情况。
CocurrentHashMap 在 JDK 1.8 有什么改变?
1.底层数据结构
JDK1.7中数据结构:数组(Segment) + 数组(HashEntry) + 链表(HashEntry节点)。底层一个Segments数组,存储一个Segments对象,一个Segments中储存一个Entry数组,存储的每个Entry对象又是一个链表头结点。
2.保障线程安全方式不同
JDK1.7:分段锁。对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
JDK1.8:优化的synchronized 关键字同步代码块 和 cas操作了维护并发。
经常用到哪些 Map?
LinkedHashMap, CocurrentHashMap , HashMap ,TreeMap,HashTable。
这几种 Map 的区别?
CocurrentHashMap 是HashMap 的线程安全类。
TreeMap底层采用红黑树存储数据,会对传入的key进行排序。非线程安全。
HashTable线程安全,每个方法上加锁,简单粗暴,效率低下。