Java集合框架整理6--Map体系HashMap的超详细源码解析及常见问题整理
前言
Map体系中常用的有HashMap、TreeMap以及线程安全的ConcurrentHashMap、ConcurrentSkipListMap,不同场景可以使用不同的Map实现类,比如单线程无序的可以采用HashMap,需要有序的就可以使用TreeMap,需要线程安全的就可以使用并发包中提供的ConcurrentHashMap或ConcurrentSkipListMap,本文就通过源码来分别解析这几种Map中的HashMap的实现原理。
一、HashMap源码解析
HashMap是最常用的一种Map,单线程下效率很高,底层数据通过数组+链表的方式存储数据。
1.1、HashMap的属性
1 /** 默认初始容量,值为 1<<4 = 16*/ 2 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 3 4 /** map最大容量,值为 1<<30 = 2的30次方*/ 5 static final int MAXIMUM_CAPACITY = 1 << 30; 6 7 /** 装载因子,当 存储数据数量/总容量 >= 装载因子 时,就会触发扩容操作 */ 8 static final float DEFAULT_LOAD_FACTOR = 0.75f; 9 10 /** 转红黑树阈值, 当数组中链表长度大于8时,就会将链表转化成红黑树*/ 11 static final int TREEIFY_THRESHOLD = 8; 12 13 /** 解红花数阈值, 当红黑树数据个数小于等于6时,就会将红黑树转化成链表*/ 14 static final int UNTREEIFY_THRESHOLD = 6; 15 16 /** 最小树行化阈值, 当数据个数大于64时才会转红黑树,否则不转红黑树而是之间扩容*/ 17 static final int MIN_TREEIFY_CAPACITY = 64; 18 19 /** 存储数据的数组, 存储元素为Node<K,V> */ 20 transient Node<K,V>[] table; 21 22 /** 23 * Map转化成Set的集合 24 */ 25 transient Set<Map.Entry<K,V>> entrySet; 26 27 /** Map存储的数据总个数 */ 28 transient int size; 29 30 /** Map被修改的次数*/ 31 transient int modCount; 32 33 /** 当前的阈值*/ 34 int threshold; 35 36 /** 37 * 装载因子 38 * final类型修饰不可被修改,初始化之后就不可被修改,默认值为0.75f*/ 39 final float loadFactor;
HashMap内部定义了多个静态final类型的变量,主要定义了HashMap操作相关的各种默认值或阈值,而HashMap的对象属性核心的只有 table、threshold、loadFactor这几个。
table是HashMap存储数据的数据结构,Node类型的数组,所以HashMap本质就是一个数组;
threshold表示每次扩容的阈值,一旦size达到threshold时就需要进行扩容了,所以threshold是一直变化的。
loadFactor是装载因子,表示当size达到总容量的多少比例时开始进行扩容,所以得出 threshold = 当前总容量 * loadFactor, loadFactor是不可变的,HashMap初始化之后就会定好值,默认是0.75,也就是 3/4。
所以假设当前的table大小为16,loadFactor值为0.75,则threshold就等于12,则当size =12 时就会进行扩容了。
1.2、HashMap的内部类
HashMap内部定义了很多内部类,最核心的主要有Node和TreeNode,基本属性分别如下:
1 /** 数组中链表节点对象 */ 2 static class Node<K,V> implements Map.Entry<K,V> { 3 final int hash;//节点hash值 4 final K key;//节点的key 5 V value;//节点的value 6 Node<K,V> next;//下一个节点的引用 7 8 Node(int hash, K key, V value, Node<K,V> next) { 9 this.hash = hash; 10 this.key = key; 11 this.value = value; 12 this.next = next; 13 } 14 } 15 16 /** 红黑数节点对象*/ 17 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { 18 TreeNode<K, V> parent; // 父节点引用 19 TreeNode<K, V> left; //左子节点引用 20 TreeNode<K, V> right; //右子节点引用 21 TreeNode<K, V> prev; //上一个节点的引用 22 boolean red;//红黑树标识; true表示是红色;false表示是黑色 23 24 TreeNode(int hash, K key, V val, Node<K, V> next) { 25 super(hash, key, val, next); 26 } 27 } 28 29 /** LinkedHashMap中节点对象*/ 30 static class Entry<K,V> extends HashMap.Node<K,V> { 31 Entry<K,V> before, after; 32 Entry(int hash, K key, V value, Node<K,V> next) { 33 super(hash, key, value, next); 34 } 35 }
可以看出Node主要包含的属性有 hash值、key、value以及下一个节点的引用,而LinkedHashMap中的Entry继承了Node类,额外加了before和after节点的引用,TreeNode又继承之Entry类,又额外多了 parent、left、right、prev和red等字段。
1.3、HashMap的初始化
1 /** 默认无参构造函数 */ 2 public HashMap() { 3 //设置装载因子为默认装载因子, 值为0.75 4 this.loadFactor = DEFAULT_LOAD_FACTOR; 5 } 6 7 /** 8 * @param initialCapacity:初始化容量 9 * */ 10 public HashMap(int initialCapacity) { 11 this(initialCapacity, DEFAULT_LOAD_FACTOR); 12 } 13 14 /** 15 * @param initialCapacity:初始化容量 16 * @param loadFactoer:自定义装载因子的值 17 * */ 18 public HashMap(int initialCapacity, float loadFactor) { 19 if (initialCapacity < 0) 20 throw new IllegalArgumentException("Illegal initial capacity: " + 21 initialCapacity); 22 //如果初始化容量超过最大容量,则按最大容量算 23 if (initialCapacity > MAXIMUM_CAPACITY) 24 initialCapacity = MAXIMUM_CAPACITY; 25 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 26 throw new IllegalArgumentException("Illegal load factor: " + 27 loadFactor); 28 this.loadFactor = loadFactor; 29 this.threshold = tableSizeFor(initialCapacity); 30 } 31 32 /** 33 * 计算数组的长度 34 * @param cap: 期望的容量 35 * @return 比cap大的最小的2的幂次方值 36 * */ 37 static final int tableSizeFor(int cap) { 38 int n = cap - 1; 39 n |= n >>> 1; 40 n |= n >>> 2; 41 n |= n >>> 4; 42 n |= n >>> 8; 43 n |= n >>> 16; 44 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 45 }
HashMap有多个构造函数,但是从源码可以看出,构造方法的处理逻辑都是设置了HashMap的属性loadFactor和threshold的值,初始化时的threshold的值实际就是通过计算数组长度的tableSizeFor方法计算出来的
这里有一个tableSizeFor方法,该方法的作用是根据传入参数计算比参数大的最近一个2的幂次方的值。比如传入10,那么比10大的最近的2的幂次方值就是16;传入31就返回32;传入33就返回64,这个tableSizeFor方法是用于每次HashMap进行扩容时计算扩容的容量的,每次扩容都需要调用这个方法来计算扩容后的容量,所以HashMap的容量肯定是2的幂次方值。至于为什么这么设计以及tableSizeFor的实现逻辑,下文的Extra部分会详细分析。
1.4、HashMap的put方法源码解析
源码如下:
1 /** 2 * 插入<key,value>的值 3 * */ 4 public V put(K key, V value) { 5 //调用私有方法 6 return putVal(hash(key), key, value, false, true); 7 } 8 9 /** 10 * 计算key的hash值 11 * */ 12 static final int hash(Object key) { 13 int h; 14 //key的hashCode进行高16位和低16位作异或运算 15 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 16 } 17 18 /** 19 * 实际真正的put操作 20 * */ 21 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 22 boolean evict) { 23 /** 24 * 1.新建数组tab; Node对象p; int变量 n,i 25 * */ 26 Node<K,V>[] tab; Node<K,V> p; int n, i; 27 /** 28 * 2.将table赋值给tab,如果tab为空或者tab的长度为0,则执行resize()方法进行扩容,将扩容后的数组长度赋值给n 29 * */ 30 if ((tab = table) == null || (n = tab.length) == 0) 31 n = (tab = resize()).length; 32 /** 33 * 3.计算key对应在数组的位置,位置的值 = (数组长度n-1) & key的hash值 34 * 取数组对应位置的Node对象赋值给p 35 * 如果p为空,则表示当前的key不存在,则直接创建新节点 36 * */ 37 if ((p = tab[i = (n - 1) & hash]) == null) 38 tab[i] = newNode(hash, key, value, null); 39 else { 40 /** 41 * 4.此时表示p不为空,也就是key当前需要存储的数组中的位置已经存放了数据 42 * 定义Node节点e表示根据key查询出来的节点 43 * */ 44 Node<K,V> e; K k; 45 /** 46 * 5.如果当前位置数据的hash值、key都和传入的key相同,则表示需要覆盖原数据 47 * */ 48 if (p.hash == hash && 49 ((k = p.key) == key || (key != null && key.equals(k)))) 50 e = p; 51 /** 52 * 6.如果当前位置节点是TreeNode类型,则表示当前需要插入的位置已经是一个红黑树了 53 * */ 54 else if (p instanceof TreeNode) 55 //将节点插入到红黑树中 56 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 57 else { 58 /** 59 * 7.如果当前位置节点的key不是插入的key,且又不是红黑树,则肯定就是一个链表了 60 * */ 61 for (int binCount = 0; ; ++binCount) { 62 /** 63 * 8.将p.next赋值给e,如果为空,则表示当前链表中只有一个节点,且这个节点的key和插入的key不相同 64 * 则直接新建节点插入到链表的尾部 65 * */ 66 if ((e = p.next) == null) { 67 p.next = newNode(hash, key, value, null); 68 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 69 /** 9.如果链表长度大于链表的最大长度,则调用treeifyBin方法将链表转化成红黑树*/ 70 treeifyBin(tab, hash); 71 break; 72 } 73 /** 74 * 10.如果链表中某个节点的key和插入的key相同,则跳过循环直接返回该节点 75 * */ 76 if (e.hash == hash && 77 ((k = e.key) == key || (key != null && key.equals(k)))) 78 break; 79 p = e; 80 } 81 } 82 /** 83 * 11.当e不为空时,表示key对应的节点在数组中已经存在了,则直接将节点的value替换成新的value 84 * */ 85 if (e != null) { // existing mapping for key 86 V oldValue = e.value; 87 if (!onlyIfAbsent || oldValue == null) 88 e.value = value; 89 afterNodeAccess(e); 90 return oldValue; 91 } 92 } 93 ++modCount; 94 /** 95 * 12.如果数组长度size 大于扩容阈值threshold,则进行一次扩容操作 96 * */ 97 if (++size > threshold) 98 resize(); 99 afterNodeInsertion(evict); 100 return null; 101 }
HashMap的put方法主要流程整理如下:
1、先根据key计算hash值
2、根据hash值找到该key位于数组中对应的位置,计算方式是(数组长度-1)&hash值,实际就是将hash值对数组长度进行取余操作
3、插入之前判断一次数组是否为空,如果为空就需要进行初始化扩容;如果不为空则查询出当前位置中的节点
4、判断当前位置的节点是否为空,如果为空,则直接构造新节点插入
5、如果当前位置的节点不为空,则需要进行以下三步的判断
5.1、判断当前节点的key是否和put的key一致,如果一致则直接将原节点的value覆盖;否则继续往下
5.2、判断当前节点是否是红黑树结构,如果是红黑树结构,则调用putTreeVal()方法将新节点加入红黑树中(如果红黑树中是替换);否则继续往下
5.3、当数组元素不相等且不是红黑树结构,则表示数组当前位置是一个链表结构,则将节点加入到链表中(如果链表中存在就是替换)
1.5、HashMap扩容方法resize()源码解析
HashMap初始化时容量为空的,只有当put数据时会进行扩容操作,第一次put数据会进行一次扩容,然后put之后会判断当前的size是否达到了扩容阈值threshold,如果达到了再进行扩容。另外当数组中的某一位中的链表需要转化成红黑树时,如果当前Map的size小于最小转化红黑树的值MIN_TREEIFY_CAPACITY = 64,这个时候是不会转化成红黑树的,而是直接调用resize方法进行扩容。
resize方法源码如下:
1 /** 2 * HashMap扩容方法 3 * */ 4 final Node<K,V>[] resize() { 5 /****************** 第一步 开始 *****************************/ 6 //1.新建Node数组对象oldTab并将当前数组赋值给oldTab 7 Node<K,V>[] oldTab = table; 8 //2.获取旧数组的长度 9 int oldCap = (oldTab == null) ? 0 : oldTab.length; 10 //3.获取旧的扩容阈值threshold 11 int oldThr = threshold; 12 //4.定义newCap表示新的容量;newThr表示新的扩容阈值 13 int newCap, newThr = 0; 14 if (oldCap > 0) { 15 if (oldCap >= MAXIMUM_CAPACITY) { 16 //5.当旧的容量超过了最大容量,则不进行扩容直接返回 17 threshold = Integer.MAX_VALUE; 18 return oldTab; 19 } 20 //6.如果没有超过最大容量,则新的容量扩容为旧的容量的两倍 21 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 22 oldCap >= DEFAULT_INITIAL_CAPACITY) 23 newThr = oldThr << 1; //扩容两倍 24 } 25 else if (oldThr > 0) // initial capacity was placed in threshold 26 //7.当旧容量为空,表示此时是第一次扩容;则新容量值为旧的threshold,因为初始化HashMap时设置了threshold值为HashMap的默认大小16 27 newCap = oldThr; 28 else { 29 //8.当阈值为0时则容量和阈值都设置成默认值 30 newCap = DEFAULT_INITIAL_CAPACITY; 31 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 32 } 33 //8.设置新的扩容阈值 = (新的容量 * 加载因子) 34 if (newThr == 0) { 35 float ft = (float)newCap * loadFactor; 36 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 37 (int)ft : Integer.MAX_VALUE); 38 } 39 //9.直接修改扩容阈值 40 threshold = newThr; 41 /** 第一步计算完成新的容量值 newCap、新的扩容阈值 threshold */ 42 /****************** 第一步 结束 *****************************/ 43 44 /****************** 第一步 开始 *****************************/ 45 @SuppressWarnings({"rawtypes","unchecked"}) 46 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 47 /** 48 * 1.新建数组,大小的扩容后的容量,并且赋值给table,相当于是直接将旧的数组替换了 49 * tip: 此处在多线程情况下会有线程安全问题 50 * 第二步的整体逻辑是新建新的数组赋值给table,再将扩容之前的旧的数据oldTab全部存入该数组中。 51 * 但是在此时oldTab只是旧的数据,在执行 oldTab = table之后; table = newTab之前的操作中,如果有其他线程执行了put操作.就会导致其他线程put的数据全部丢失 52 * */ 53 table = newTab; 54 if (oldTab != null) { 55 //2.遍历旧的数组,将旧数组的数据存入新数组中 56 for (int j = 0; j < oldCap; ++j) { 57 Node<K,V> e; 58 //3.当旧数组的某个位置数据不为空时,则重新存入新数组中 59 if ((e = oldTab[j]) != null) { 60 //4.先将旧数组该位置数据置为空 61 oldTab[j] = null; 62 //5.判断e是否包含next节点,如果不包含则表示当前位置只有一个节点数据,则直接重新计算新位置插入新数组中 63 if (e.next == null) 64 newTab[e.hash & (newCap - 1)] = e; 65 else if (e instanceof TreeNode) 66 //6.将红黑树进行重新put到table中 67 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 68 else { // preserve order 69 //7.将链表元素重新put到table中 70 Node<K,V> loHead = null, loTail = null; 71 Node<K,V> hiHead = null, hiTail = null; 72 Node<K,V> next; 73 do { 74 //8.找到链表到下一个节点 75 next = e.next; 76 /** 77 * oldCap为旧的数组长度,二进制为格式为 100000...格式 78 * newCap为新到数组长度,二进制数据相当于比oldCap多一个0 79 * 80 * 计算(e.hash & oldCap)的值,如果等于0则表示hash值比oldCap值小,如果值大于0则表示比oldCap值大 81 * */ 82 83 /**9.将链表中的节点按hash和oldCap值进行比较,大的和小的节点分别创建两个子链表 */ 84 //9.1.当hash值比oldCap值小时 85 if ((e.hash & oldCap) == 0) { 86 if (loTail == null) 87 loHead = e; 88 else 89 loTail.next = e; 90 loTail = e; 91 } 92 //9.2.当hash值比oldCap值大时 93 else { 94 if (hiTail == null) 95 hiHead = e; 96 else 97 hiTail.next = e; 98 hiTail = e; 99 } 100 } while ((e = next) != null); 101 /** 102 * 10. 103 * 将hash比oldCap值小的子链表放入链表原先的位置 104 * 将hash比oldCap值大的子链表放入链表原先的index+oldCap的位置 105 * */ 106 if (loTail != null) { 107 loTail.next = null; 108 newTab[j] = loHead; 109 } 110 if (hiTail != null) { 111 hiTail.next = null; 112 newTab[j + oldCap] = hiHead; 113 } 114 } 115 } 116 } 117 } 118 /** 119 * 第二步完成了数组的真正扩容操作 120 * */ 121 /****************** 第二步 结束 *****************************/ 122 return newTab; 123 }
HashMap的resize方法源码主要逻辑如下:
1、获取旧的数组长度、扩容阈值,临时保存旧数组中的元素
2、计算新的数组长度(扩容为原先的两倍)、扩容阈值
3、根据新长度创建新数组,并直接赋值给table属性
4、遍历旧数组中的所有元素,存入新数组中,每个位置的元素分成三种情况
4.1、单节点:则直接计算节点存放的新位置,放入数组中
4.2、红黑树:将红黑树重新put到数组中
4.3、链表:遍历链表,将链表中的所有节点按需要移动位置和不需要移动位置的节点分成两个子链表,不需要移动的链表存入原先位置,需要移动的链表存入(原位置+原数组大小)的位置
在第一步会保存旧数组中的元素,第三步创建新数组并直接赋值给table,如果在这之前有其他线程向旧数组中put数据则会丢失,因为新数组扩容后的数据还是按照扩容之前的数据进行恢复的。
1.6、HashMap的get方法源码解析
get方法源码如下:
1 /** 根据key获取Map中存储的value */ 2 public V get(Object key) { 3 Node<K,V> e; 4 return (e = getNode(hash(key), key)) == null ? null : e.value; 5 }
方法比较简洁,但是执行的逻辑是比较独有的,第4行实际就包含了多个逻辑,分别如下:
1、调用hash(key)方法计算key的hash值
2、调用getNode(hash, key)方法获取key对应的Node节点
3、判断Node节点是否为空,如果为空则返回null,如果不为空则返回Node的value值
hash方法和put的时候一致,根据key计算对应的hash值,主要的逻辑就是getNode方法来根据hash值和key获取对应的Node节点,源码如下:
1 /** 2 * 根据key的hash值和key获取Node节点 3 * hash用于找到数组对应的位置 4 * key用于判断Node的key是否一致 5 * */ 6 final Node<K,V> getNode(int hash, Object key) { 7 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; 8 //遍历数组,根据hash值找到数组对应位置的Node节点,赋值给first变量 9 if ((tab = table) != null && (n = tab.length) > 0 && 10 (first = tab[(n - 1) & hash]) != null) { 11 //1.如果first的hash和key都和参数一致,则直接返回first节点 12 if (first.hash == hash && // always check first node 13 ((k = first.key) == key || (key != null && key.equals(k)))) 14 return first; 15 //2.如果first不是对应的节点,则表示该位置的节点是红黑树或者是链表 16 if ((e = first.next) != null) { 17 //2.1.如果是红黑树,则从红黑树中获取数据 18 if (first instanceof TreeNode) 19 return ((TreeNode<K,V>)first).getTreeNode(hash, key); 20 //2.2.如果是链表,则遍历链表从链表中获取hash和key都一致的Node节点 21 do { 22 if (e.hash == hash && 23 ((k = e.key) == key || (key != null && key.equals(k)))) 24 return e; 25 } while ((e = e.next) != null); 26 } 27 } 28 return null; 29 }
总结:
1、根据hash值找到数组对应位置的Node节点first,如果为空直接返回null,如果不为空则有三种情况
2.1、如果first节点的hash和key和参数一致则直接返回first节点
2.2、如果first节点是红黑树,则调用getTreeNode方法从红黑树中获取节点
2.3、如果都不是则就是链表,遍历链表节点,返回hash和key都一致的节点
二、Extra
1、为什么HashMap的默认装载因子为0.75?
首先加载因子的作用是当数组存储数据个数达到 (数组总大小 * 加载因子 )时则进行扩容操作,所以加载因子的取值大小就决定了数组扩容的时机。
假设现在数组的大小为16
如果加载因子为1,则表示数组数据个数达到16时进行扩容,此时hash碰撞的概率比较大,就会造成数组元素为链表或红黑树的概率增大,从而增加了查询成本
如果加载因子为0.5,则表示数组数据个数达到8时就进行扩容,此时虽然hash碰撞的概念较小,但是数组的空间利用率比较低,一半的位置是空余的情况下就扩容了。
选择0.75是一个折中的选择,即不会造成大量空间的浪费,又不至于hash碰撞的概念太高。
另外HashMap源码中这些写到:
1 * Because TreeNodes are about twice the size of regular nodes, we 2 * use them only when bins contain enough nodes to warrant use 3 * (see TREEIFY_THRESHOLD). And when they become too small (due to 4 * removal or resizing) they are converted back to plain bins. In 5 * usages with well-distributed user hashCodes, tree bins are 6 * rarely used. Ideally, under random hashCodes, the frequency of 7 * nodes in bins follows a Poisson distribution 8 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a 9 * parameter of about 0.5 on average for the default resizing 10 * threshold of 0.75, although with a large variance because of 11 * resizing granularity. Ignoring variance, the expected 12 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / 13 * factorial(k)). The first values are: 14 * 15 * 0: 0.60653066 16 * 1: 0.30326533 17 * 2: 0.07581633 18 * 3: 0.01263606 19 * 4: 0.00157952 20 * 5: 0.00015795 21 * 6: 0.00001316 22 * 7: 0.00000094 23 * 8: 0.00000006 24 * more: less than 1 in ten million
意思就是使用随机hash值,出现在hash桶中的位置遵循泊松分布,同时给出了每个位置中出现元素个数和概率的参考表,
根据泊松分布算法,同一个位置出现8个元素的概率为 0.00000006,出现的概率约等于 6/1亿,也就是小于 千万分之一。
3、为什么HashMap的默认大小为16?
首先16是2的幂次方,而2的幂次方4和8数字太小,会导致HashMap立马就需要进行扩容,而16即不会太大造成内存浪费,也不会立马就导致数组进行扩容操作
3、为什么HashMap每次扩容都必须是原容量的2倍?
首先HashMap的核心是根据key来计算hash值的算法,以及根据hash值找到数组中的位置的算法
HashMap采用的是取余算法,将hash值和数组的长度-1进行取余操作。
2的幂次方的二进制格式为 10000...,所以 2的幂次方-1 的二进制格式就是 11111111....,将hash值和111111...进行与操作,得到的值完全是取决于hash值的。
假设不是2的幂次方,比如初始化大小为10,那么计算时数组长度-1就等于9,二进制为 1001 此时有两个hash值分布为3(二进制为0010) 和 6(二进制为 0110),此时分别将两个hash值进行与运算
1001 & 0010 = 0010 = 0;1001 & 0110 = 0010 = 0,很显然当基数为10时,很容易就导致了hash碰撞的情况,并且有些值永远也不会得到,比如0111,1001不管与任何值计算都不会得到0111的值,
所以就会导致数组中有些位置始终为空,而其他位置的值hash碰撞的情况。
总结:只有2的幂次方-1的二进制是所有位置都为1,与运算时可以得到任何值,而不是2的幂次方-1的情况下,2进制就至少有一个位置的值是0,那么此位置不管是与对应位置的1还是0进行与操作的结果就永远是0,
所以就会导致为0的对应位置出现hash碰撞的概率翻倍,而有些值是永远也不会计算得到。从而破坏了hash算法均匀分布的原则。
4、为什么HashMap的最大容量为 1<< 30?
HashMap的容量采用的是int来计数的,int由4个字节也就是32位来表示
相当于有一个32位的二进制来表示如下图
X | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
当二进制中最高一位是表示符号的,也就是上图中X的位置不能计算值,只能当作符号计算,0表示是正数,1表示是负数,所以只有31位可以表示数据的大小,
这也就得到了int类型的取值范围为 (-2的31次方) ~ (2的31次方-1)
再回到HashMap的容量,HashMap的容量必须是2的幂次方,也就是二进制中只有一个1,其他位都是0,从上图可以看出最左位是X,最右位是1,将最右边的1往左移动,最多只能移动30位,所以最大容量为 1<<30也就是2的30次方
总结:int的取值范围的-2的31次方 ~ 2的31次方-1,正数部分也就是0 ~ 2的31次方-1,由于HashMap大小需要为2的幂次方,所以最大就是2的30次方
5、HashMap的tableSizeFor方法实现逻辑?
HashMap的tableSizeFor方法的功能是计算大于等于参数值的最小的一个2的幂次方的值,源码如下:
1 /** 2 * 计算大于cap的最小的2幂次方的值 3 * 比如 4 * cap = 10,大于等于10的最小的2幂次方值为 16 5 * cap = 17,大于等于17的最小的2幂次方值为 32 6 * */ 7 static final int tableSizeFor(int cap) { 8 //1.计算cap-1的值 9 int n = cap - 1; 10 //2.将n 和 n无符号右移1位进行或运算 11 n |= n >>> 1; 12 //3.将n 和 n无符号右移2位进行或运算 13 n |= n >>> 2; 14 //3.将n 和 n无符号右移4位进行或运算 15 n |= n >>> 4; 16 //3.将n 和 n无符号右移8位进行或运算 17 n |= n >>> 8; 18 //3.将n 和 n无符号右移16位进行或运算 19 n |= n >>> 16; 20 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 21 }
JDK很多源码中涉及到数值计算的方法都是通过二进制运算代替的,所以直接看代码很难直观的理解是如何实现的,接下来通过一个例子来一步步来捋一捋该方法的实现逻辑。
假设现在传入的cap值为60,则通过该方法计算得到值应该是64,计算过程如下:
1 定义cap = 60 2 第一步: n = cap - 1 = 59 3 二进制: 0000 0000 0000 0000 0000 0000 0011 1011 4 5 第二步: 6 n>>>1 0000 0000 0000 0000 0000 0000 0001 1101 7 n|n>>>1 0000 0000 0000 0000 0000 0000 0011 1111 8 9 第三步: 10 n>>>2 0000 0000 0000 0000 0000 0000 0000 1111 11 n|n>>>2 0000 0000 0000 0000 0000 0000 0011 1111 12 13 第四步、第五步结果一样 14 计算结果为 0000 0000 0000 0000 0000 0000 0011 1111 = 63 15 16 最后一步 17 n+1 0000 0000 0000 0000 0000 0000 0100 0000 = 64
大致过程如下:
1、先得到一个原始数值n,转化为二进制格式,有一个最高位的1
2、执行一次n|n>>>1之后,相当于就将这个1右移1位再或运算,则得到最高位和第二高位的数字都是1
3、执行一次n|n>>>2之后,相当于将最高2位的1再次右移动2位再或运算,得到最高的4位都是1
4、同上逻辑得到最高8位都是1
5、同上逻辑得到最高16位都是1
6、同上逻辑得到最高32位都是1
7、执行n+1操作,则得到一个最高位是1,后面全是0的值,也就是一个2的幂次方的值
所以相当于就是传入的参数值的最高位的1左移1位,其他位置都置为0的操作。
6、HashMap线程不安全体现在哪?
1、多线程put操作覆盖
1 if ((p = tab[i = (n - 1) & hash]) == null) 2 tab[i] = newNode(hash, key, value, null);
如上代码示,是判断hash是否冲突时put的逻辑,当第一行判断hash没有冲突时,就会直接创建一个新的Node放入tab[i]的位置,此时如果有多个线程都执行put操作,且刚好hash冲突了
比如A线程执行完第1步之后,线程B执行第1步,发现hash没有冲突,则直接新建节点放在tab[i]的位置,此时线程A执行第2步也新建Node节点直接将线程B保存的数据直接给覆盖了。而线程安全的效果应该是两个数据都保存,且生成一个链表。
2、扩容期间put的数据丢失
1 Node<K,V>[] oldTab = table; 2 //省略 3 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 4 table = newTab; 5 if (oldTab != null) { 6 //遍历将oldTab中的数据存入table中 7 }
如上代码示,当执行第1行到第4行之间,如果有其他线程执行了put操作新增修改删除了table中的数据,是不会生效的,因为扩容后的数据还是扩容之前的数据副本,所以扩容期间的增删改的数据都会丢失
7、HashMap为什么当链表长度为8时,才自动转化成红黑树?为什么红黑树大小为6时需要还原成链表?
首先,如问题1中HashMap源码所说,当加载因子为 0.75时,hash冲突元素个数达到8的概率为 一亿分之6,概率是很小的,所以设置长度为8时才转化,基本上可以保证正常情况链表转化成红黑树的概率极低。
另外红黑树的平均查找复杂度为 o(lgn), 链表的平均查找复杂度 n/2,所以可以来比较下:
当n=4时,log2(4)=2, 4/2=2
当n=6时,log2(6)<3, 6/2=3
当n=8时, log2(8)=3, 8/2=4
当n=10时, log2(10)<4, 10/2=5
可以发现,当n=8时,采用红黑树查询数据的平均效率是高于链表的,而当n<8时,红黑树和链表的查询效率差不多,但是转化红黑树会消耗一定时间,所以没有必要。
所以当链表的长度达到8时会转化成红黑树,一个是超过8的概率很小,二是超过8之后红黑树的查询效率要高于链表
至于为什么当红黑树数据个数小于6时会还原成链表,首先如上所述,当个数小于等于6时,实际上红黑树和链表的查询复杂度基本上差不多,个数越少链表更有优势,因为红黑树还有转化的消耗。
在6和8之间还有一个中间值7是为了提供容错性,因为如果大于8就转成红黑树,小于8就还原成链表,如果不停的插入和删除元素,链表大小一直在8左右徘徊,就会频繁的进行链表和树的转化,会消耗一定的性能。
所以当红黑树大小小于等于6时还原成链表,一个是小于6时链表效率更高,二是避免链表和红黑树的频繁互相转换
8、HashMap为什么使用红黑树而不是AVL树(平衡二叉树)?
详细可参考另一篇文章,红黑树和AVL树的比较?
9、HashMap1.7版本和1.8版本的区别?
JDK8在JDK7的基础之上改动不小,既然是高版本,肯定是需要对低版本的问题进行升级优化,主要区别如下:
JDK7 | JDK8 | |
存储数据结构 | 数组+链表 | 数组+链表+红黑树 |
计算2的幂次方算法,传入参数n | 先将n*2,再计算n*2的值的最高位的1,其他位置全部置位0 | 计算n最高位的1,将此位置低的位置全部设置为1,则+1 |
计算hash的算法 | 4次移位运算,5次异或运算 | 1次移位运算,1次异或运算(hashCode的高16位和低16位进行异或运算,保证32位都参与运算) |
扩容顺序 | 先创建新数组,将旧数据存入新数组,则赋值给table | 先创建新数组,赋值给table,再将旧数据存入新数组 |
链表插入方法 | 头插法,新插入数据插入链表头部 | 尾插法,新插入数据插入链表尾部 |
put的流程 |
先判断是否需要扩容,扩容之后再put | 先put数据,再判断是否需要扩容 |
总结:
1、最主要的变化是引入了红黑树,不再是单纯的链表结构,因为当链表过长时,查询效率没有红黑树高
2、put数据时,如果需要扩容,JDK7是先进行扩容,再将数据put到扩容后到数组;JDK8是先将数据put到数组之后才进行扩容。
好处:扩容的时间比put数据的时间肯定要长,假如两个线程同时put数据,此时都需要进行扩容操作,
JDK7的方式:两个线程都进行扩容,线程A扩容之后put数据A,线程B扩容完put数据B,但是线程B扩容之后线程Aput的数据就会丢失
JDK8的方式:两个线程都进行扩容,风险和JDK7差不多,都可能会丢失数据,但是JDK8是先将数据put之后才进行扩容操作,就算两个线程同时扩容,扩容的时候另一个线程的数据已经put到数组中了,
所以两个线程就算执行了两次扩容操作,也不会丢失数据,唯一丢失数据的可能是在线程A执行put数据之后,线程B执行了put操作并且执行了扩容操作,此时线程A再进行扩容,那么会丢失B的数据,但是这个时间间隔是相当小的,相比于JDK的先扩容的方式很显然
JDK8丢失数据的概率要小很大。
3、扩容顺序,JDK7先移动旧数据再赋值给新数组;JDK8是先赋值给新数组再移动旧数据
好处:移动旧数据的过程是比较耗时的,JDK7中再移动旧数据的过程中,其他线程put数据会全部丢失;JDK8中只有再创建新数组到赋值table之间的其他线程put操作才会丢失,而移动数据的过程不会影响其他线程。
降低了多线程扩容时丢失数据的可能性。
4、JDK7链表采用头插法,JDK8采用尾插法
头插法:不需要遍历链表,只需要将头节点信息更新即可,且最新插入的数据在头部,更容易被找到,但是会有死循环的问题
尾插法:需要遍历链表找到尾节点才能插入,由于使用红黑树,所以无法使用头插法,不会有死循环的问题
5、JDK7扩容的时候有死循环的问题,JDK8不会出现此问题
10、HashMap的key适合哪些类型?
实现了Object的hashCode()和equals()方法且这两个方法实现需要恰当,hashCode需要保证规则固定,降低hashCode碰撞的概率,equals方法比较需要能确保两个值相等才可以通过
因为hashCode需要用于查找数组位置的运算;equals是作为key是否一致的判断。
11、HashMap如何解决hash冲突问题?
核心思路:预防hash碰撞、解决hash碰撞
HashMap预防hash碰撞
1、hashCode算法
2、hash计算进行扰动处理,JDK8是一次位移运算,再加一次异或运算(相当于高16位和低16位进行异或运算,保证32位数字都参与hash运算)
3、数组大小为2的幂次方
4、良好的扩容机制,达到扩容阈值时就及时进行扩容,降低hash碰撞概率
HashMap解决Hash碰撞
JDK8采用链表+红黑树结构来解决,链表采用的是尾插法;JDK7采用的是链表+头插法
12、HashMap1.7多线程扩容的死循环的完整流程
在HashMap1.7中扩容时都涉及到一个方法transfer,这个方法的作用是将旧数组中的元素全部转移到新数组中,源码如下:
1 /** 将旧数组中的数据转移到新数组中 */ 2 void transfer(Entry[] newTable, boolean rehash) { 3 //新数组的长度 4 int newCapacity = newTable.length; 5 //遍历旧数组 6 for (Entry<K,V> e : table) { 7 while(null != e) { 8 //next为当前节点的下一个节点 9 Entry<K,V> next = e.next; 10 if (rehash) { 11 //重新计算hash值 12 e.hash = null == e.key ? 0 : hash(e.key); 13 } 14 //这里根据刚刚得到的新hash重新调用indexFor方法计算下标索引 15 int i = indexFor(e.hash, newCapacity); 16 //假设当前数组中某个位置的链表结构为a->b->c;women 17 //(1)当为原链表中的第一个结点的时候:e.next=null;newTable[i]=e;e=e.next 18 //(2)当遍历到原链表中的后续节点的时候:e.next=head;newTable[i]=e(这里将头节点设置为新插入的结点,即头插法);e=e.next 19 //(3)这里也是导致扩容后,链表顺序反转的原理(代码就是这样写的,链表反转,当然前提是计算的新下标还是相同的) 20 //将当前数组该位置的元素放入到e的下一个节点 21 e.next = newTable[i]; 22 newTable[i] = e; 23 e = next; 24 } 25 } 26 }
这这里主要是遍历旧数组,然后依次将旧数组中的元素重新计算hash移动到新数组中,当时当数组中的元素是个链表时,且扩容之后该链表的元素计算的hash值全部一样时,就会出现死循环现象。完整流程如下:
首先,如上图最核心的步骤一共有四步,其他代码可以忽略,然后假设数组中有一位是一个长度为3的链表,如下图示:
此时链表的指向关系为 A -> B -> C -> null
接下来开始扩容操作,假设有两个线程X和Y同时对这个数组进行扩容,分别执行上面标红代码的4步
12.1、线程Y从节点A开始遍历,执行完第一步之后,结果为:
12.2、此时线程Y的CPU时间片用完,暂停执行,线程X开始执行扩容,也是从节点A开始遍历,执行完一轮的所有四步之后,结果为:
12.2、此时e = B不为null,继续执行,线程X执行第二轮之后,结果为:
12.3、此时e = C不为null,继续执行,线程X执行第三轮之后,结果为:
此时线程X的tranfer操作就遍历完成的,可以看出链表和扩容前的链表结果方向变反了,此时的链表指向关系为 C -> B -> A -> null
12.4、当线程X遍历完成之后,暂停执行,线程Y继续执行剩下的逻辑,当线程Y执行第一轮剩下的3步之后,结果为:
12.5、此时可以看出,B和C节点由于是和线程X公用的,B和C节点的next指向已经变成了线程X扩容之后的结果了,此时e=B不为null,继续执行,线程Y执行完第二轮之后,结果为:
12.6、此时虽然扩容的数组结果和线程X看着差不多,但是此时的e 和 next变量已经不再是C了,而是变成了A了,此时e=A不为null,继续执行第三轮之后,结果为:
此时由于e=null,所以循环结束,线程Y扩容之后链表的结构为 A <->B, 也就是出现了 A.next = B, B.next=A的死循环现象,并且节点C已经被丢失了
12.7、线程Y扩容结束将数组赋值给HashMap的数组,此时虽然没有异常出现,当时实际上节点C数据已经丢失了,且A和B已经出现了死循环,当下一次执行get操作时,比如根据C来查询数据时,一旦发现A不是要找的数据时,就会从节点A开始遍历查询,此时就会一直进入死循环了。
总计:HashMap在JDK7下死循环的原因主要是因为当Hash冲突时采用头插法将数据插入到链表头部,而扩容时需要将链表反转,从而容易出现多个线程下数据异常,比如线程1的数据为A->B->C,而线程2的数据为C->B->A,在扩容写数据时就会出现死循环的现象