上次面试腾讯,被问到Map一系列问题,觉得回答得很肤浅,没有深入到底层源码。这次上网看了很多人对Map的简介,才对Map有了一定的了解。为此整理一下Map的相关资料。
Map
(1)HashMap
1.数据结构
Jdk1.8由数组+链表+红黑树组成的,数组是 HashMap 的主体。1.7没有红黑树。
HashMap 里面是一个数组,然后数组中每个元素存的是单链表的头结点,一个单向链表(“拉链法”解决冲突,其他冲突处理方法)。链表的每个元素的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
红黑树:当链表长度大于8时,遍历查找效率较慢,故引入红黑树。红黑树相对于链表维护成本大,链路较短时,不适合用红黑树。解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构。
2.特点
存储的内容是键值对(key-value)映射;HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
HashMap是非synchronized,所以HashMap很快
3.求数组下标
Jdk1.7根据key的hash值来求得对应数组中的位置(hash算法), 算得key的hashcode值,然后跟数组的长度-1做一次“与”运算(&)。
hashmap的数组初始化大小都是2的次方大小的原因:当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,碰撞的几率小且不会有位置空置造成浪费,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
jdk1.8Hash()方法:
key.hashCode() ^ (key.hashCode() >>> 16)的逻辑就是先获得key的hashCode的值 h,然后 h 和 h右移16位 做异或运算。实质上就是把一个数的低16位与他的高16位做异或运算。
4.Put()与get()
Put():判断当前数组是否需要初始化;如果 key 为空,则 put 一个空值进去。
如果key对应的数组位置是null,直接新增一个 Entry 对象写入当前位置;
果key对应的数组位置不为null,先判断数组位置是链表还是红黑树,是链表:需要遍该链表判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值,不相等就添加一个链表元素,若添加后链表长度大于8且数组长度大于64就需要把该链表转为红黑树;是红黑树:调用树结构putTreeVal方法添加数据;
最后判断数组是否需要扩容;
Get()方法:
首先获取当前key对应的数组索引位置,然后判断该位置为空直接返回null;
若该位置的首节点是否是自己想要的值,根据key和key.hashCode()来判断,是直接返回value;
首节点如果不是的话,判断节点是链表还是红黑树,是红黑树通过调用getTreeNode()来实现get()方法,是链表:循环遍历链表,查询是否有自己想要的值;
如果上面的步骤都没有查询到数据,直接返回null。
5.hashcode方法与equals方法(存放自定义类需重写)
Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致。
如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素。 equals方法也是需要改写,都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。
6.扩容机制
当map中包含的Entry的数量大于等于threshold = loadFactor * capacity的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍。仅当size大于等于threshold的时候,并不一定会触发扩容机制。
HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍;创建时如果给定容量初始值,那HashMap 会将其扩充为 2 的幂次方大小;元素的位置要么在原来位置,要么在原位置移动2次幂,不需要重新计算hash值。
HashMap 的tableSizeFor方法做了处理,能保证数组长度n永远都是2次幂。
扩容时机:>=threshold: capacity * loadFactor,loadFactor:负载因子,默认为 0.75。
如果当前数组的长度小于 64,那么会选择先进行数组扩容;当链表中的元素超过了 8 个且数组的长度大于 64,会将链表转换为红黑树,时间复杂度变为 O(logN)
Resize()方法:
1.首先记录当前数组信息,当前数组、数组长度还有扩容阈值。
2.接着就到了一个if-elseif-else的代码块,这些就是用来判断当前是进行初始化操作还是扩容操作,如果是扩容操作则需要进行双倍扩容,如果是初始化数组则需要设置数组容量。
3.如果是扩容操作或者初始化的时候用户指定了初始容量,则要⽤新数组的大小重写计算扩容阈值
4.重新生成一个数组(无论是扩容操作还是初始化操作都需要)。
5.如果是初始化操作,到生成数组就已经结束了,但如果是扩容操作,则把⽼数组上的元素转移到新数组上。
转移的时候需要遍历旧数组的每⼀个位置,可能会发生四种情况:
1.如果该位置上没有元素,不做处理
2.如果该位置只有⼀个元素则直接转移到新数组上newTab[e.hash & (newCap - 1)] = e
3.如果该位置上的元素是TreeNode,则对这颗红⿊树进⾏转移 : ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
4.否则,该位置上是⼀个链表,则要转移该链表 ,最后会拆分为高位(原来的位置+旧数组的长度)和低位链表(在新数组中也是原来的位置)。判断元素在低位链表和高位链表的条件是:(e.hash & oldCap) == 0,如果这个值等于0就放在低位链表,如果不是则放在高位链表。
7.线程不安全(hash碰撞和扩容导致)
HashMap 非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
Hash碰撞导致:
A线程与B线程同时对一个数组位置调用addEntry,两个线程会同时得到该数组位置存放的链表头结点,然后A线程写入新的头结点后,B线程也写入新的头结点,那B的写入操作就会覆盖A的写入操作,造成A的写入操作丢失。
扩容导致:
当多个线程同时检测到数组元素数量超过阈值就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,最终只会有一个线程生成的数组赋给table变量,其他线程就会丢失(若其他线程已经完成赋值)
如果需要满足线程安全,可以用 Collections类的静态方法 :synchronizedMap 方法使HashMap 具有线程安全的能力,或者使用ConcurrentHashMap
8.插入空值
当添加key==null的Entry时会调用putForNullKey方法直接去遍历,寻找e.key==null的Entry或者没有找到遍历就调用addEntry方法添加一个key为null的Entry;若找到e.key==null就保存null对应的原value值,并用新null对应的value值覆盖。
HashMap 最多只允许一条记录的键为null,允许多条记录的值为null。
(2)ConcurrentHashMap
1.7版本:
线程安全
在JDK1.7由Segment 数组结构和 HashEntry 数组结构+链组成.
Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,实现了全局的线程安全。
Get()方法: 首先根据 key 计算出 hashcode,然后定位到segment。再根据 hash 值获取定位HashEntry 对象,判断该位置是否为链表;不是链表就根据 key、key 的 hashcode 是否相等来返回值;为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
没有锁,当获取的entry的value为null时,才加锁重读,该方法的共享变量都定义为volatile,保证线程之间的可见性,不会读到过期值。
Put()方法:
首先通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。
尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁;获取到锁后,将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry;遍历该 HashEntry的链表,如果不为空则判断传入的 key和hashcode 和当前遍历是否相等,相等则覆盖旧的 value,不相等就在该 HashEntry元素的链表添加新建的一个 HashEntry ;该 HashEntry位置为空则需要新建一个 HashEntry 并加入到该 Segment 中,同时会先判断是否需要扩容HashEntry数组。
最后会解除所获取当前 Segment 的锁。
1.8版本:
1.8中concurrentHashMap采用Node数组+链表+红黑树结构的方式存储,HashEntry改为Node,其中的 val和next 都用了 volatile 修饰,保证了可见性;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁(只需要锁住这个链表头节点(红黑树的根节点)。
Get()方法:
根据 key 计算出 hash 值,判断数组是否为空;
如果是首节点,就直接返回;如果是红黑树结构,就从红黑树里面查询;如果是链表结构,循环遍历判断。
Put()方法:
根据 key 计算出 hashcode ;判断是否需要进行初始化;f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功;如果当前位置的f.hash = MOVED = -1 ,说明其他线程在扩容,则需要进行扩容。如果都不满足,则利用 synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
ConcurrentHashMap 能保证复合操作的原子性吗?
复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
(3)HashTable
Hashtable:数组+链表组成的,数组是 Hashtable 的主体; Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1,加载因子为0.75.
HashTable中的key和value是不为空的。
线程安全:
任一时间只有一个线程能写Hashtable(所有访问HashTable的线程必须竞争同一把锁,效率慢)。
Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。
(4)LinkedHashMap
LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。
Entry增加了两个指针before和after,用于维护双向链表
保存了记录的插入顺序,在用Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的。
(5)TreeMap:红黑树(自平衡的排序二叉树)
能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap 时,得到的记录是排过序的。
在使用TreeMap 时,key 必须实现Comparable 接口或者在构造TreeMap 传入自定义的Comparator