HashMap的结构和底层原理:
HashMap是我们非常常用的数据结构,是由 数组和链表组合构成的数据结构。
大概如下,数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry,在Java8中叫Node。
因为他本身所有的位置都为null,在put插入的时候会根据key的hash算法,去计算一个index值。 就比如我 put(”帅丙“,220),我插入了为”Hello“的元素,这个时候我们会通过哈希算法计算出插入的位置 index,计算出来 index 是2,那结果如下:
hash(“帅丙”)= 2
刚才我们提到了还有链表,为啥需要链表,链表又是怎么样子的呢?
我们知道数组长度是有限的,在有限的长度里面我们使用哈希,而哈希本身就存在概率性,就是帅丙和丙帅的hash值有一定的概率会一样:
hash(“丙帅”)= 2
那就形成了下面这样的链表:
每一个Node节点都会保存自身的 hash、key、value、以及下个Node节点,Node源码如下:
说到链表,那大家知道新的 Entry 节点 (Node),在插入链表的时候,是怎么插入的吗?
java8之前是 头插法,就是说新来的值会取代头部原有的值,原有的值就顺推到链表中去,就像上面的例子一样,因为设计者认为后来的最新值被查找的可能性更大一点,也为了提升查找的效率。
但是,在java8之后,改成了尾部插入了。
那为啥改成尾部插入呢?
首先我们看下HashMap的扩容机制:
前面提到过了,数组容量是有限的,数据多次插入后,到达一定的数量就会进行扩容,也就是resize。
resize扩容的时机是?
有两个因素:
- Capacity:HashMap当前长度。
- LoadFactor:负载因子,默认值0.75f。
怎么理解呢,就比如当前的数组容量大小为100,当你存进第76个的时候,判断发现达到了LoadFactor的额定值了,此时就需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。
扩容?它是怎么扩容的呢?
分为两步:
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
- ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组中。
为什么要重新Hash呢,直接复制过去不香么?
是因为新的Entry数组长度扩大以后,Hash的规则也会随之改变。
Hash的公式: index = HashCode(Key) & (Length - 1)
原来数组长度(Length)是8,你位运算出来的值是2 ,新的数组长度是16你位运算出来的值明显不一样了。
扩容前:
扩容后:
说完扩容机制了,那我们回答上面的问题,为啥之前用头插法,java8之后改成尾插了呢?
举个例子:
现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在resize之前打个断点,那就意味着数据都插入了,但是还没执行 resize,那扩容前可能是这样的:
我们可以看到链表的指向 A->B->C:
使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置。旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上:
有可能B的下一个指针指向了A:
一旦几个线程都调整完成,就可能出现环形链表:
如果这个时候去取值,悲剧就出现了——无限循环。
上面举的这个例子,是要说明 1.7 之前的头插法,在并发场景下,有致命的问题,就是可能会形成数据循环,get 数据时发生死循环。(虽然 HashMap 是线程不安全的)
而在 1.8 之前处理 hash 冲突的方式是用链表存放数据来解决的,使用头插法可以提升一定效率。
但是在 1.8 之后这个效率提升就可有可无了,因为链表长度超过 7 就要考虑升级红黑树了,所以哪怕进行尾插遍历次数也会很有限,效率影响不大。
其次就是因为 1.8 之后数据结构的变动,当链表长度达到阈值,升级为红黑树后头插法就不适用了,因为构建红黑树需要进行比对更新序列,也就不能去说是头插法还是尾插了。
使用头插会改变链表上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
就是说原本是A->B,在扩容后那个链表还是A->B:
Java1.7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
Java1.8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。
那是不是意味着Java8就可以把HashMap用在多线程中呢?
我认为即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
那么HashMap的默认初始化长度是多少?
我记得我在看源码的时候初始化大小是16。
那为啥是16呢?
在JDK1.8的 236 行有1<<4,就是16。
为啥用位运算表示?直接用16不好吗?
主要是位运算的性能好,位与运算比算数计算的效率高的多,为啥位运算性能就好,那是因为位运算直接操作内存,不需要进行进制转换,要知道计算机可是以二进制的形式做数据存储啊。
那为啥用16,而不用别的数呢?
想要知道为啥是16,我们得从HashMap的数据存放特性上来说。
举个例子,key为”帅丙“的十进制为766132,那二进制就是 10111011000010110100,
我们再看下index的计算公式:
Hash公式: index = HashCode(Key) & (Length- 1)
从而得出 index = 10111011000010110100 &(16 -1);
从而得出 index = 10111011000010110100 & 15 ;
而 15 的二进制是1111,那么:
index = 10111011000010110100 & 1111 = 0100 = 4。
之所以用位与运算,其效果与取模一样,性能会提高不少!
可以说Hash算法最终得到的 index 结果,完全取决于Key的 Hashcode 值 (二进制) 的最后几位。
而因为在使用 是2的幂 的数字作为初始大小的时候,(Length-1) 的值的所有二进制位是都为1的,这种情况下,index的结果等同于HashCode (二进制) 后几位的值。
所以只要输入的HashCode本身分布均匀,就能得到一个均匀分布的 hash,换言之,也就能尽量减少Hash冲突。
那为啥是16嘞?难道不是2的整数次幂都行嘛?理论上是都行,但是如果是2,4或者8会不会有点小,添加不了多少数据就会扩容,也就是会频繁扩容,这样岂不是影响性能,那为啥不是32或者更大,那不就浪费空间了嘛,所以16就作为一个非常合适的经验值保留了下来。
再来一个问题,为啥我们重写equals方法的时候需要重写hashCode方法呢?
请用HashMap给我举个例子?
因为在java中,所有的对象都是继承于Object类的。Ojbect类中有两个方法 equals、hashCode,这两个方法都是用来比较两个对象是否相等的。
在没有重写equals方法时,我们是继承了object 中 equals 的默认实现,内部的 equals 是比较两个对象的内存地址,显然我们 new 了2个对象,它们的内存地址肯定不一样。
- 对于值对象,== 比较的是两个对象的值
- 对于引用对象,比较的是两个对象的内存地址
哈希码的特点是:
对于同一个对象如果没有被修改(使用equals比较返回true)那么无论何时它的hashcode值都是相同的
对于两个对象如果他们的equals返回false,那么他们的hashcode值也有可能相等
当向HashMap中添加数据的时候,存在两种情况:
- 当前数组索引的地方是空的,这种情况很简单,直接将元素放进去就好了。
- 已经有元素占据了该索引的位置,这种情况下我们需要判断一下该位置的元素和当前元素是否相等,使用equals来比较。
如果使用默认的规则,是比较两个对象的地址。也就是两者需要是同一个对象才相等,当然我们也可以重写equals方法来实现我们自己的比较规则最常见的是通过比较属性值来判断是否相等。
如果两者相等则直接覆盖,如果不等则在原元素下面使用链表的结构存储该元素。
那么当我们将自定义对象作为key时,重写 equals 方法的同时,为什么还要重写 hashCode 方法呢?
举个例子来看一下(从Hashmap的角度来讲),如果重写了equals而不重写hashcode会发生什么样的问题:
public class MyTest {
private static class Person{
int idCard;
String name;
public Person(int idCard, String name) {
this.idCard = idCard;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()){
return false;
}
Person person = (Person) o;
//两个对象是否等值,通过idCard来确定
return this.idCard == person.idCard;
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<Person, String>();
Person person = new Person(1234,"乔峰");
//put到hashmap中去
map.put(person,"天龙八部");
//get取出,从逻辑上讲应该能输出“天龙八部”
System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
}
}
实际输出结果:null
如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)–>hash–>indexFor–>最终索引位置 ,而通过key取出value的时候 key(hashcode2)–>hash–>indexFor–>最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null。
所以,在重写equals的方法的时候,必须注意重写hashCode方法;同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。
jdk1.8 中HashMap中的链表什么时候变为红黑树?
Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表。
再来个问题,如果 HashMap 是线程不安全的,那你能跟我聊聊你们是怎么处理HashMap在线程安全的场景么?
在这样的场景,我们一般都会使用HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap。HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。
从Set的角度回答 重写 equals 必须重写 hashCode
讲一下 ConcurrentHashMap?
与 Hashtable相比,ConcurrentHashMap的并发度会更高,所以一般多线程操作时,基本都会选择 ConcurrentHashMap。
我们先来聊一下Hashtable?
跟HashMap相比Hashtable是线程安全的,适合在多线程的情况下使用,但是效率可不太乐观。
讲讲HashTable效率低的原因?
源码中,它在对数据操作的时候都会上锁,所以效率比较低下。
除了这个,Hashtable 跟HashMap还有不一样的点么?
Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null。
为啥 Hashtable 是不允许 KEY 和 VALUE 为 null, 而 HashMap 则可以呢?
因为Hashtable在我们put 空值的时候会直接抛空指针异常,但是HashMap却做了特殊处理:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
貌似还是没说清楚为啥Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null?
这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。
如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。
还有什么不同点吗?
-
实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。
貌似没人用过这个Dictionary,我也没用过。
-
初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
-
扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量的2倍,Hashtable 扩容规则为当前容量 2倍 + 1。
-
迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。
fail-fast是啥?
快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception 异常。
fail-fast的原理是啥?
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。
集合在被遍历期间如果内容发生变化,就会改变modCount的值。
每当迭代器使用 hashNext() / next() 遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
Tip:这里异常的抛出条件是检测到 modCount != expectedmodCount 这个条件。如果集合发生变化时修改modCount值,刚好又设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
说说 fail-fast 的场景?
java.util包下的集合类都是基于快速失败机制的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
Tip:安全失败(fail—safe)大家也可以了解下,java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
那接下来,我们聊聊 ConcurrentHashMap?
说说它的数据结构吧,以及为啥他并发度这么高?
ConcurrentHashMap 底层是基于 数组 + 链表
组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。我先说一下他在1.7中的数据结构吧:
如上图所示,ConcurrentHashMap 是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count; // 记得快速失败(fail—fast)么?
transient int modCount; // 大小
transient int threshold; // 负载因子
final float loadFactor;
}
HashEntry跟HashMap差不多的,但是不同点是,HashEntry使用volatile去修饰了他的数据Value还有下一个节点next。
volatile的特性是啥?
-
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
-
禁止进行指令重排序。(实现有序性)
-
volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
我就不大篇幅介绍了,多线程章节我会说到的,大家知道用了之后安全了就对了。
说说他并发度高的原因?
原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。
不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();//这就是为啥他不可以put null值的原因
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject
(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
他先定位到Segment,然后再进行put操作。
我们看看他的put源代码,你就知道他是怎么做到线程安全的了,关键句子我注释了。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
} else {
// 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//释放锁
unlock();
}
return oldValue;
}
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut()
自旋获取锁。
- 尝试自旋获取锁。
- 如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取,保证能获取成功。
那他get的逻辑呢?
get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
你有没有发现1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题?
是的,因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。
那jdk1.8中他的数据结构是怎么样子的呢?
其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized
来保证并发安全性。
跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。
他值的存取操作呢?以及是怎么保证线程安全的?
ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
你在上面提到CAS是什么?自旋又是什么?
CAS 是乐观锁的一种实现方式,是一种轻量级锁。
CAS 操作的流程如下图所示,线程在读取数据时不进行加锁。而在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
CAS就一定能保证数据没被别的线程修改过么?
并不是的,比如很经典的ABA问题,CAS就无法判断了。
什么是ABA?
就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。
但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。
那怎么解决ABA问题?
用版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。
CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?
synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。
那 ConcurrentHashMap 的 get 操作又是怎么样子的呢?
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。
小结:1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)
),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。