应为面试中数据结构方面问的最多的就是hashMap,所以今天对hashMap做了一个总结。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。
Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:
key 就是写入时的键。
value 自然就是值。
开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
hash 存放的是当前 key 的 hashcode。
HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。
数组:存储区间连续,占用内存严重,寻址容易,插入删除困难;
链表:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;
Hashmap综合应用了这两种数据结构,实现了寻址容易,插入删除也容易。
hashMap结构
1.7实现
PUT方法:
判断当前数组是否需要初始化。
如果 key 为空,则 put 一个空值进去。
根据 key 计算出 hashcode。
根据计算出的 hashcode 定位出所在桶。
如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置
GET方法:
首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
判断该位置是否为链表。
不是链表就根据 key、key 的 hashcode 是否相等来返回值。
为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
啥都没取到就直接返回 null 。
1.7缺点:
当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。
1.8 修改之前在发生严重hash碰撞时候,在链表达到一定的阈值,会把链表转化为红黑树进行存储,修改为红黑树之后查询效率直接提高到了 O(logn)
PUT方法:
1.判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
2.根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
3.如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
4.如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
5.如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
6.接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
7.如果在遍历过程中找到 key 相同时直接退出遍历。
8.如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
9.最后判断是否需要进行扩容。
GET方法:
首先将 key hash 之后取得所定位的桶。
如果桶为空则直接返回 null 。
否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
如果第一个不匹配,则判断它的下一个是红黑树还是链表。
红黑树就按照树的查找方式返回值。
不然就按照链表的方式遍历匹配返回值。
但是这两种方式还是会有问题,那就是在并发场景下使用时容易出现死循环。
原因:HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。
二叉查找树
左子节点比根子节点小 右子节点比跟子节点大 这样就可以控制查找在logn之内
红黑树
红黑树是二叉查找树的变体
根节点为黑色节点 叶子节点都是黑色 且都为null 跟节点到叶子节点的最长路径不能大于最短路径的两倍
ConcurrentHashMap
1.7版本
Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
它的核心成员变量
和 HashMap 非常类似,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。
原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
1.尝试自旋获取锁。
2.如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功
PUT方法
1.将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
2.遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
3.不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
4.最后会解除在 1 中所获取当前 Segment 的锁。
GET方法:
只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
缺点:虽然解决了并发,但是查询遍历链表效率太低。
1.8版本:CAS + synchronized 来保证并发安全性
val next 都用了 volatile 修饰,保证了可见性。
cas:基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果 这个葛城就是自旋
不断循环计算table(散列表)的每个桶位(slot)的散列值i ,直到找到tab[i] 为空的桶位,casTabAt将put(增加)的节点Node 放到空仓(empty bin)中,如果在put 的过程中,别的线程更改了tab[i],导致tab[i] 不为空,那么casTabAt返回false,继续循环找tab[i]== null的桶位。整个put 过程没加锁,利用table 是volatile 的特性,保证在多线程并发更新的过程中table 对所有线程是一致的,Unsafe 可以直接操作内存中的对象
PUT方法:
1.根据 key 计算出 hashcode 。
2.判断是否需要进行初始化。
3.f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
4.如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
5.如果都不满足,则利用 synchronized 锁写入数据。
6.如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
GET方法:
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果是红黑树那就按照树的方式获取值。
就不满足那就按照链表的方式遍历获取值。
其实这块也是面试的重点内容,通常的套路是:
谈谈你理解的 HashMap,讲讲其中的 get put 过程。
1.8 做了什么优化?
是线程安全的嘛?
不安全会导致哪些问题?
如何解决?有没有线程安全的并发容器?
ConcurrentHashMap 是如何实现的? 1.7、1.8 实现有何不同?为什么这么做?
详细put操作:
https://blog.csdn.net/the_one_and_only/article/details/81665098