前言
Map接口实现类包括:HashMap、TreeMap、Hashtable、LinkedHashMap、ConcurrentHashMap。根据key的hashcode值来存储数据,可以根据key获取value。
实现类之间的区别:
HashMap
- 不保存每个元素的插入顺序,无序存放(存放顺序与key的hash值有关)
 
- 具有很快的访问速度
 
- 最多只允许一条记录的key值为null(多条会覆盖)
 
- 允许多条记录的value为null
 
- 非同步;非线程安全
 
 
TreeMap
- 是通过红黑树实现的
 
- 默认key升序排序存放,可以写Comparator接口改变排序方式
 
- 不允许key的值为null
 
- 非同步
 
 
HashTable
- 不允许key、value的值为null
 
- 同步;线程安全(很多方法加了synchronized锁)
 
- 由于引入了线程安全,处理效率没有HashMap高
 
 
LinkedHashMap
- 基于HashMap和双向链表,有序存放
 
- 具有很快的访问速度
 
- 最多只允许一条记录的key值为null(多条会覆盖)
 
- 允许多条记录的value为null
 
- 非同步;非线程安全
 
 
ConcurrentHashMap
- 线程安全,读写快,底层实现是一种以空间换时间的结构,创建的时候直接分了16个segment,每个segment实际上存储的还是HashTable,写入的时候先找到对应的segment,然后对segment加锁,写完,解锁。锁segment的时候其他segment还可以继续工作。
 
- 使用场景:当项目中的全局变量有多线程操作时需要用ConcurrentHashMap
 
 
下面重点介绍HashMap原理。
数据结构
JDK1.8之前,HashMap是数组+链表存储。JDK1.8及其之后,先用数组+链表,当链表长度>=8时,就是数组+红黑树(原链表元素组成)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
   | public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable { 	private static final long serialVersionUID = 362498820763181265L; 	 	static final int DEFAULT_INITIAL_CAPACITY = 16;  	 	static final int MAXIMUM_CAPACITY = 1073741824;  	 	static final float DEFAULT_LOAD_FACTOR = 0.75F;  	 	static final int TREEIFY_THRESHOLD = 8;  	 	static final int UNTREEIFY_THRESHOLD = 6;  	 	static final int MIN_TREEIFY_CAPACITY = 64; 	 	transient Node<K, V>[] table;  	 	transient Set<Map.Entry<K, V>> entrySet; 	 	transient int size;  	 	transient int modCount;  	 	int threshold;  	 	final float loadFactor; 	 	 	 	static class Node<K, V> implements Map.Entry<K, V> { 		final int hash; 		final K key; 		V value; 		Node<K, V> next; 		 	} 	 	 	static final class TreeNode<K, V> extends LinkedHashMap.Entry<K, V> { 		TreeNode<K, V> parent; 		TreeNode<K, V> left; 		TreeNode<K, V> right; 		TreeNode<K, V> prev; 		boolean red; 		 	} }
  | 
 
Node与TreeNode之间的继承关系: Map.Entry<K, V> –> HashMap.Node<K, V> –> LinkedHashMap.Entry<K, V> –> HashMap.TreeNode<K, V> 。所以Node是TreeNode的间接父类。
红黑树和MySQL索引结构B+树,都属于平衡二叉树。区别在于,红黑树一个节点只含一条数据,而B+树是B树的变种,B树一个节点含多条数据,用于查找的key和数据都在节点上,B+树的非叶子节点不再保存数据,而只保存key,叶子节点保存数据,并且所有的叶子节点按顺序使用链表进行连接。
相关博客:
红黑树的规则和原理,包括左旋右旋:https://www.cnblogs.com/LiaHon/p/11203229.html
负载因子0.75的作用:http://baijiahao.baidu.com/s?id=1656137152537394906&wfr=spider&for=pc
HashMap部分源码:https://itimetraveler.github.io/2017/11/25/%E3%80%90Java%E3%80%91HashMap%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%EF%BC%88JDK1.8%EF%BC%89/#get-%E6%96%B9%E6%B3%95
PUT
put(key,value)时,HashMap先用key的hashcode计算出一个新的hash值,再用hash值和数组长度计算出一个数组下标。如果该数组位上为空,则直接添加key-value并返回NULL。如果该数组位上已经有元素,那么就遍历该数组位上的所有元素,比较要添加的key和现有元素是否相等,若相等就覆盖旧元素,并返回旧元素的value。若没有相等的,就直接添加进去。
向链表中添加元素时,用的是“尾插法”(JDK1.7中用的是“头插法”)。
比较key是否相同时,用的是equals方法,因为equals方法默认比较的是内存地址,所以如果用自定义类的对象做key,这个类需要重写equals方法。
关于hash值和数组下标的计算可以看这篇博客:https://www.cnblogs.com/eycuii/p/12015283.html 。理解了hash值和数组下标的原理,我们可以回答一个常见的面试题:
HashMap数组长度为什么都是2的k次方?
首先,对于计算机来说,使用像左移、右移、与或非这样的位运算,效率比人类习惯的加减乘除以及取模更高,所以源码里计算数组下标都是用位运算。用位运算,就要把数组长度值转为二进制,任何数都能转为二进制,为什么偏偏指定2的k次方?因为计算数组下标时,公式是i = hash & (length - 1),当length=2^k时,length - 1就是在低k位连续分布的1,这样在做“与”运算时,能够完全利用hash的低k位的所有值,使得多个hash的“与”结果不同,达到了分散数组位的目的。
(2^k) - 1 = 2^(k-1) + 2^(k-2) + … + 1,这是由等比数列求和公式得到的。
put方法源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
   | public V put(K paramK, V paramV) { 	return (V) putVal(hash(paramK), paramK, paramV, false, true); }
  final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { 	Node<K,V>[] tab; Node<K,V> p; int n, i; 	if ((tab = table) == null || (n = tab.length) == 0)  		n = (tab = resize()).length;  	if ((p = tab[i = (n - 1) & hash]) == null)  		tab[i] = newNode(hash, key, value, null);
  	 	else { 		Node<K,V> e; K k;
  		 		if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) 			e = p;
  		 		else if (p instanceof TreeNode) 			 			e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  		 		else { 			for (int binCount = 0; ; ++binCount) {  				if ((e = p.next) == null) { 					p.next = newNode(hash, key, value, null);  					 					 					if (binCount >= TREEIFY_THRESHOLD - 1)  					treeifyBin(tab, hash); 					break; 				} 				 				if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) 				break; 				p = e; 			} 		}
  		 		if (e != null) { 			V oldValue = e.value; 			if (!onlyIfAbsent || oldValue == null) 				e.value = value; 			afterNodeAccess(e); 			return oldValue; 		} 	} 	++modCount;   	if (++size > threshold) 		resize();  	afterNodeInsertion(evict); 	return null; }
  | 
 
RESIZE
resize()方法为私有,调用点都在HashMap内部。具体在:
- put(key,value),具体见上一节源码
 
- new HashMap(map2)或putAll(map2),批量添加map元素
 
- put(key,value)中链表转红黑树时treeifyBin()方法
 
- computeIfAbsent()方法内
 
- compute()方法内
 
- merge()方法内
 
resize()方法是JDK1.8和1.7的区别比较大的地方。区别在于:
- JDK1.8引入了红黑树的处理
 
- 重新安排链表元素时,计算新下标的方式。虽然方式不同,但结果不变
 
- 元素插入链表的方式。1.7用头插法,1.8用尾插法
 
下面借助两个版本的resize()源码讲讲这些区别。
JDK1.7的resize方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
   | void resize(int newCapacity) { 	Entry[] oldTable = table; 	int oldCapacity = oldTable.length;   	if (oldCapacity == MAXIMUM_CAPACITY) { 		threshold = Integer.MAX_VALUE; 		return;   	}    	Entry[] newTable = new Entry[newCapacity]; 	transfer(newTable);  	table = newTable; 	threshold = (int) (newCapacity * loadFactor); }
  void transfer(Entry[] newTable) { 	Entry[] src = table; 	int newCapacity = newTable.length; 	for (int j = 0; j < src.length; j++) { 		Entry<K, V> e = src[j]; 		if (e != null) { 			src[j] = null;  			do { 				Entry<K, V> next = e.next;   				int i = indexFor(e.hash, newCapacity);  				e.next = newTable[i];  				newTable[i] = e; 				e = next; 			} while (e != null); 		} 	} }
  static int indexFor(int h, int length) { 	return h & (length - 1); }
  | 
 
JDK1.8的resize方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
   | final Node<K,V>[] resize() {     Node<K,V>[] oldTab = table;     int oldCap = (oldTab == null) ? 0 : oldTab.length;     int oldThr = threshold;     int newCap, newThr = 0;     if (oldCap > 0) {                  if (oldCap >= MAXIMUM_CAPACITY) {             threshold = Integer.MAX_VALUE;             return oldTab;         }         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&                  oldCap >= DEFAULT_INITIAL_CAPACITY)              newThr = oldThr << 1;      }     else if (oldThr > 0)         newCap = oldThr;     else {         newCap = DEFAULT_INITIAL_CAPACITY;         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);     }     if (newThr == 0) {         float ft = (float)newCap * loadFactor;         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?                   (int)ft : Integer.MAX_VALUE);     }     threshold = newThr;
      @SuppressWarnings({"rawtypes","unchecked"})     Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];     table = newTab;     if (oldTab != null) {                  for (int j = 0; j < oldCap; ++j) {             Node<K,V> e;             if ((e = oldTab[j]) != null) {                  oldTab[j] = null;                                  if (e.next == null)                     newTab[e.hash & (newCap - 1)] = e;                                  else if (e instanceof TreeNode)                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);                                  else {                     Node<K,V> loHead = null, loTail = null;                     Node<K,V> hiHead = null, hiTail = null;                     Node<K,V> next;                     do {                         next = e.next;                         if ((e.hash & oldCap) == 0) {                              if (loTail == null)                                 loHead = e;                             else                                 loTail.next = e;                             loTail = e;                         }                         else {                             if (hiTail == null)                                 hiHead = e;                             else                                 hiTail.next = e;                             hiTail = e;                         }                     } while ((e = next) != null);                     if (loTail != null) {                          loTail.next = null;                         newTab[j] = loHead;                     }                     if (hiTail != null) {                          hiTail.next = null;                         newTab[j + oldCap] = hiHead;                     }                 }             }         }     }     return newTab; }
  | 
 
区别一
区别1:JDK1.8引入了红黑树的处理。
在TreeNode.split()方法中,会把当前树拆分成两个子链表,然后判断两个子链表的节点数,节点数<=6,就按照链表存储,否则转为红黑树,即“树化”。树化时,遍历树的所有节点的时间复杂度是O(n),插入树节点的时间复杂度是O(logn),所以创建红黑树的总时间是O(nlogn)。
顺便一提,resize处理链表时,是一边遍历一边插入,遍历链表的时间复杂度为O(n),插入的时间复杂度是O(1),所以创建链表的总时间是O(n)。
附上TreeNode.split()方法源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
   | final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {     TreeNode<K,V> b = this;          TreeNode<K,V> loHead = null, loTail = null;     TreeNode<K,V> hiHead = null, hiTail = null;     int lc = 0, hc = 0;
      for (TreeNode<K,V> e = b, next; e != null; e = next) {         next = (TreeNode<K,V>)e.next;         e.next = null; 			                  if ((e.hash & bit) == 0) {              if ((e.prev = loTail) == null)                 loHead = e;             else                 loTail.next = e;             loTail = e;             ++lc;         }         else {             if ((e.prev = hiTail) == null)                 hiHead = e;             else                 hiTail.next = e;             hiTail = e;             ++hc;         }     }
      if (loHead != null) {         if (lc <= UNTREEIFY_THRESHOLD)              tab[index] = loHead.untreeify(map);         else {             tab[index] = loHead;             if (hiHead != null) 	  	loHead.treeify(tab);         }     }     if (hiHead != null) {         if (hc <= UNTREEIFY_THRESHOLD)             tab[index + bit] = hiHead.untreeify(map);         else {             tab[index + bit] = hiHead;             if (loHead != null)                 hiHead.treeify(tab);         }     } }
  | 
 
参考:
HashMap内部类TreeNode主要方法源码和解释:https://www.cnblogs.com/freeorange/p/10804332.html
红黑树的时间复杂度O(logn)的证明:https://blog.csdn.net/l_o_s/article/details/105703296 (红黑树查找、插入和删除操作的时间复杂度都是O(logn))
区别二
区别2:重新安排链表元素时,计算新下标的方式。虽然方式不同,但结果不变。
在1.7中,无论什么情况,新数组位 = e.hash & (newCap - 1)。在1.8中,当只有一个元素时,新数组位的计算和1.7一致;当是链表元素时,新数组位只有“原下标”和“原下标+oldCap”两种位置,而具体是哪个位置,就看(e.hash & oldCap)的值是否等于0。
乍一看,在1.8中重新安排链表元素时,因为只有两种选择,似乎会加大冲突概率。但实际上,这个做法和1.7的重新计算下标的结果,是完全一致的!!
我们举例看看在1.7中计算出的新下标的规律:
设:oldCap = 16, newCap = 32, key1.hash = 107 = (0110 1011), key2.hash = 123 = (0111 1011)
则,key1的原下标 = (key.hash) & (oldCap - 1) = (0110 1011) & (0000 1111) = (0000 1011) = 11
扩容后,key1的新下标 = (key.hash) & (newCap - 1) = (0110 1011) & (0001 1111) = (0000 1011) = 11
则,key2的原下标 = (key.hash) & (oldCap - 1) = (0111 1011) & (0000 1111) = (0000 1011) = 11
扩容后,key2的新下标 = (key.hash) & (newCap - 1) = (0111 1011) & (0001 1111) = (0001 1011) = 27 = 16 + 11
在1.7中,即使是重新计算下标,新下标依然只有两种结果:“原下标”和“原下标+oldCap”,这是因为(newCap - 1)只是比(oldCap - 1)多了一位高位1,这个高位1的值就等于oldCap(证明如下)。而这个高位1的值是否对“与”结果起作用,就是看与此高位1对应的key.hash的位上是0还是1,是1才起作用。这就和1.8的逻辑对应上了!
(newCap - 1) - (oldCap - 1)
= (2^(k+1) - 1) - (2^k - 1)
= (1 + … + 2^(k-1) + 2^k) - (1 + … + 2^(k-1))
= 2^k
= oldCap
所以,1.8并没有改变新下标的结果,只是代码写得更直接了。
区别三
区别3:元素插入链表的方式。1.7用头插法,1.8用尾插法。
1.7的头插法是HashMap线程不安全的一个原因,简单来说就是多线程会造成环形链表导致死循环。
假设原HashMap:
table[0]=null
table[1]=(3,a)->(7,b)
当在多线程进行扩容时,
线程A遍历到元素(3,a)->(7,b)->null停止,
线程B开始执行到扩容后:table[3]=(7,b)->(3,a)->null(先遍历到的是(3,a)键值对,先插入到新位置的也是(3,a),随后遍历到(7,b),(7,b)的新位置也在table[3],因为使用的是头插法,所以在(3,a)前面)
然后线程A继续执行,计算到(3,a)新位置在table[3],把(3,a)插入table[3]的链表,而此时table[3]=(7,b)->(3,a)->null,导致table[3]=(3,a)<=>(7,b),这两个元素互相指向,在遍历table[3]位置的链表时就会进入死循环。
这篇博客:https://www.cnblogs.com/developer_chan/p/10450908.html 中的1.1 扩容造成死循环分析过程用图片讲解,可参考。
因为改用了尾插法,所以1.8避免了1.7的环形链表的问题。但是,1.8的红黑树还是会出现死循环的情况,只是没人像1.7一样给出具体的过程,仅仅做了实验,实验博客:https://blog.csdn.net/gs_albb/article/details/88091808 。
1.8的优化
从源码来看,JDK1.8优化的主要是查询性能,因为1.8不会出现过长的链表(都转为红黑树了~),时间复杂度由链表的O(n)降低为红黑树的O(logn)。扩容方法resize优化了环形链表的问题,但性能看不出优化。先说简单的,计算新下标时,1.7直接用位运算得出新下标,1.8除了位运算还有加法运算,不会比1.7更快。然后,正如在区别一中所述,创建红黑树的时间复杂度比创建链表要大,所以1.8的resize性能肯定不会比1.7好。
线程安全问题
JDK1.7和1.8都是线程不安全的,其中一个原因是扩容时会导致死循环,另一个原因是put方法:
1 2 3 4 5 6 7 8 9 10
   | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {        Node<K,V>[] tab; Node<K,V> p; int n, i;        if ((tab = table) == null || (n = tab.length) == 0)  		n = (tab = resize()).length;  		        if ((p = tab[i = (n - 1) & hash]) == null)  		tab[i] = newNode(hash, key, value, null); 	 	    }
  | 
 
HashTable的线程安全