一 Map概述:
从上图可以看出,常见的Map有
HashMap,HashTable:HashMap继承Map接口,Hashtable实现Map,Dictionary接口。
ConcurrentHashMap:采用分段锁技术提高并发度,不在同一段的数据相互不影响,多个线程对多个不同段的操作是不会相互影响的。每个段使用一把锁。需要线程安全使用ConcurrentHashMap,否则使用HashMap。虽然Hashtable也是线程安全的,但是效率太低,不建议使用。
LinkedHashMap:是HashMap的子类,与HashMap相比它里面的数据是有序的,原因是底层采用了一个双向链表。
TreeMap:实现了SortedMap接口,TreeMap有能力对插入的记录根据key排序,默认按照升序排序,也可以自定义比较强,在使用TreeMap的时候,key应当实现Comparable。
二 HashMap的实现:
java7的map底层结构是数组加链表,到了java8后增加了红黑树这个结构。使桶里查找数据的时间复杂度由o(n)下降到o(logn),大大提高了查询的效率。
HashMap的实现使用了一个数组,每个数组项里面有一个链表的方式来实现,因为HashMap使用key的hashCode来寻找存储位置,不同的key可能具有相同的hashCode,这时候就出现哈希冲突了,也叫做哈希碰撞,为了解决哈希冲突,有开放地址方法,以及链地址方法。HashMap的实现上选取了链地址方法,也就是将哈希值一样的entry保存在同一个数组项里面,可以把一个数组项当做一个桶,桶里面装的entry的key的hashCode是一样的。
上面的图片展示了我们的描述,其中有一个非常重要的数据结构Node<K,V>,这就是实际保存我们的key-value对的数据结构,下面是这个数据结构的主要内容:
final int hash;
final K key;
V value;
Node<K,V> next;
一个Node就是一个链表节点,也就是我们插入的一条记录,明白了HashMap使用链地址方法来解决哈希冲突之后,我们就不难理解上面的数据结构,hash字段用来定位桶的索引位置,key和value就是我们的数据内容,需要注意的是,我们的key是final的,也就是不允许更改,这也好理解,因为HashMap使用key的hashCode来寻找桶的索引位置,一旦key被改变了,那么key的hashCode很可能就会改变了,所以随意改变key会使得我们丢失记录(无法找到记录)。next字段指向链表的下一个节
HashMap的初始桶的数量为16,loadFact为0.75,当桶里面的数据记录超过阈值的时候,HashMap将会进行扩容则操作,每次都会变为原来大小的2倍,直到设定的最大值之后就无法再resize了。
- resize机制
HashMap的扩容机制就是重新申请一个容量是当前的2倍的桶数组,然后将原先的记录逐个重新映射到新的桶里面,然后将原先的桶逐个置为null使得引用失效。后面会讲到,HashMap之所以线程不安全,就是resize这里出的问题。
三 几个关键点
- 如何计算下标
通过key的hash值和桶的长度作&运算得到在桶中的索引
HashMap中的代码如下:
int hash = hash(key);
int i = indexFor(hash, table.length);
计算 hash值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
取模
static int indexFor(int h, int length) {
return h & (length-1);
}
因为hashmap的容量为16,每次扩容为2倍扩容,所取模运算相当于位运算,即效率更高
-
hashmap如何解决hash冲突:
hashmap采用链地址法,将hash值一样的entry保存在同一个数组项里面。每个数组项当成一个桶,每个桶的entry的key的hash值是一样的。根据桶的长度和hash值计算在桶的索引,达到无hash冲突的存储。
-
hashmap为什么要从链表转化为红黑树,且数字必须是8
因为链表的时间复杂度为o(n),红黑树的时间复杂度为o(logn),这样查找效率会更高。
至于为什么是8,因为在map源码里面关于桶的频率问题,当桶的长度达到8时概率已经非常小了。所以选择8这个数字作为链表转化为红黑树的下标号
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million链表与红黑树对比:
如果元素小于8个,查询成本高,新增成本低
如果元素大于8个,查询成本低,新增成本高 -
为什么转成红黑树,而不转换为其他二叉树,如二叉搜索树,平衡二叉搜索树(AVL树)。
二叉搜索树:如果用二叉搜索树的话可能会造成深度过大,因为没有没有平衡机制,最后可能导致成为一个链表,这样就失去意义。
AVL树:红黑树牺牲了一些查找性能 但其本身并不是完全平衡的二叉树。因此插入删除操作效率高于AVL树AVL树用于自平衡的计算牺牲了插入删除性能,但是因为最多只有一层的高度差,查询效率会高一些。
红黑树:在插入之前是平衡的,插入之后通过左旋,右旋,着色等操作后又恢复平衡
- hashmap为什么不是线程安全的
数据不一致:
如果有A,B两个线程操作一个map,当A通过key计算完hash值并且得到桶索引,但是还未进行插入操作前,这时B线程来了,CPU把时间片分给了B,A处于阻塞状态,B通过key计算出的hash值得到的通索引与A的一致,然后B在指定的索引插入数据,B插入完成后,轮到A进行了,但这时A不知道之前的通索引
已经被占据了,然后毅然插入进去,导致B的数据就被覆盖了。因此,这就产生了数据不一致问题。
另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环:
下面的代码是resize的核心内容:
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;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。
假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。