2.2 如何决定使用HashMap还是TreeMap?
- HashMap:插入,删除,定位一个元素快
- TreeMap:有序遍历集合快
2.3 HashMap实现原理
- HashMap基于Hash算法实现的,当我们通过put存储,get获取时,HashMap会根据key.hashCode()计算hash值,根据hash值将value保存在bucket中。当计算出hash值有相同时,即产生了hash冲突,HashMap是通过链表和红黑树来存储相同的hash值得value,如果冲突个数较少使用链表,否则红黑树
2.4 ArrayList与LinkedList的区别
- ArrayList:查找快
- LinkedList:增删比较快。
2.5 在Queue中poll()和remove()有何区别?
- 相同点:都会返回第一个元素。并在队列中删除返回的元素
- 不同点:
- 没有元素poll()返回null
- 没有元素remove()直接抛出NoSuchElementException异常
2.6 LinkedHashMap有什么特点?
LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,用迭代器遍历会先遍历先插入的。
2.7 HashMap的底层实现原理?(高频)
-
从结构上来讲,HashMap是由数组+链表+红黑树(jdk1.8)来实现的
-
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是一个映射(键值对)。HashMap类中有一个非常重要的字段,就是Node[] table,即哈希桶数组。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //⽤用来定位数组索引位置 final K key; V value; Node<K,V> next; //链表的下⼀一个node Node(int hash, K key, V value, Node<K,V> next) { … } public final K getKey(){ … } public final V getValue() { … } public final String toString() { … } public final int hashCode() { … } public final V setValue(V newValue) { … } public final boolean equals(Object o) { … } }
-
当我们执行下面代码
map.put("美团","小美");
系统会调用这个key的hashCode方法,得到hashCode值,然后通过Hash算法运算(之后讲)定位到该节点要对应的存储位置,如果两个key定位到相同的位置,表示发生了Hash碰撞。所以我们希望hash计算的结果分散的均匀,则碰撞概率就小。我们希望哈希桶数组占用空间较小,但是Hash碰撞的概率也要小。要做到这些则需要
- 好的Hash算法
- 好的扩容机制
HashMap的默认构造函数对下面几个字段进行了初始化
int threshold; // 所能容纳的key-value对极限 final float loadFactor; // 负载因⼦子 int modCount; int size;
- Node[] table的初始化长度length(默认是16),loadFactor为负载因子(默认为0.75)。
- threshold=length*loadFactor,HashMap元素数目超过了这个数量,就会发生扩容。
- modCount:用来记录HashMap内部结构发生变化的次数。但是put新的键值对覆盖了旧的键值对不算结构变化。
- size:目前HashMap实际存储的键值对大小
-
HashMap的扩容方案
-
table的length会在第一次往HashMap里put元素时会初始化
- 默认初始化为16
- 若指定了容量参数,HashMap会把这个容量修改为2的倍数(这个数不小于指定容量参数),然后创建对应长度的table。
-
table会在达到threshold时进行扩容,扩容的时候length翻倍
//当指定的容量不是2的倍数,则通过这个方法可以将容量修改为2的倍数 static final int tableSizeFor(int cap) { //防止本身就是2的倍数,经过以下循环变成又扩大了一倍。 int n = cap - 1; //无符号右移,将所有是1的位置它的下一位变为1。 n |= n >>> 1; //无符号右移,将所有是1的位置它的下下为变为1. n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; //最后肯定变成一排的1 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
-
如果要往HashMap中放1000个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048,设为1024不行,因为元素添加到七百多的时候达到threshold还是会继续扩容。
-
为什么要将HashMap的容量设置为2的n次方?
- 常规设计应该是把桶的大小设计为素数,因为素数导致冲突相对概率小于合数。
- 桶大小设置为素数在扩容之后不能保证还是素数。所以采用了这种非常规设计。
- 采用这种非常规的设计主要是在取模和扩容时做了优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
-
-
Hash算法:通过调用下面两个方法实现
方法⼀: static final int hash(Object key) { //jdk1.8 & jdk1.7 int h; // h = key.hashCode() 为第⼀一步 取hashCode值 // h ^ (h >>> 16) 为第二步 高位参与运算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 方法二: static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个⽅方法,但是实现原理是一样的 return h & (length-1); //第三步 取模运算 }
-
这个算法本质就是三步:取key的hashCode值,高位运算、取模运算
-
这里的取模运算其实是通过按位与运算完成的,通过取模运算会使元素分布相对均匀,但是模运算消耗还是比较大的。
-
高位运算作用是考虑到高地bit都参与到了Hash的计算中。
-
put方法原理
public V put(K key, V value) { // 对key的hashCode()做hash return putVal(hash(key), key, value, 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; //步骤1:tab为空则创建 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //步骤2:定位index,若节点没发生冲突直接存入。 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //发生了冲突 else { Node<K,V> e; K k; //步骤3;节点key存在,直接覆盖value if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //步骤4:若该链是红黑树 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //步骤5:若该链是链表 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //链表长度大于8转为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //key已经存在直接覆盖value if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //如果不是修改了值得情况,就mod++ ++modCount; //步骤6:超过最大容量,就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
扩容机制(jdk7,因为jdk8融入了红黑树,较为复杂,暂时不讨论)
void resize(int newCapacity) { //传⼊入新的容量量 Entry[] oldTable = table; //引⽤用扩容前的Entry数组 int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组⼤大⼩小如果已经达到最⼤(2^30)了了 threshold = Integer.MAX_VALUE; //修改阈值为int的最⼤大值(2^31-1),这样以后就不不会扩容了了 return; } Entry[] newTable = new Entry[newCapacity]; //初始化⼀一个新的Entry数组 transfer(newTable); //!!将数据转移到新的Entry数组里 table = newTable; //HashMap的table属性引⽤用新的Entry数组 threshold = (int)(newCapacity * loadFactor);//修改阈值 }
void transfer(Entry[] newTable) { Entry[] src = table; //src引⽤用了了旧的Entry数组 int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素 if (e != null) { src[j] = null;//释放旧Entry数组的对象引⽤用(for循环后,旧的Entry数组不再引⽤用任何对象) do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置 e.next = newTable[i]; //标记[1] newTable[i] = e; //将元素放在数组上 e = next; //访问下⼀一个Entry链上的元素 } while (e != null); } } }
-
采用了单链表的头插入方式。同一位置上新元素总会被放到链表的头部位置;与JDK8有所区别
-
旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到新数组的不同位置上。
-
举列:table的size=2,key=3,7,5,;put顺序依次为5,7,3;mod2以后都冲突在table[1]这里,这里假设负载因子loadFactor=1;即当键值对的实际大小size大于table的实际大小时进行扩容,接下来的三个步骤就是哈希桶数组resize为4,然后所有的Node重写rehash的过程
-
JDK1.8做的优化
-
每次扩容为原来的2倍,所以元素要么是在原来位置,要么是在原来位置在移动2二次幂的位置,如下图,n为table的长度。
-
元素在重新计算hash后,因为n变为原来的2倍,那么n-1的mask范围就再高位多1bit(红色),因此新的index就会发生这样的变化:
-
因此我们在扩充HashMap的时候,不需要像jdk7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是0还是1,是0不变,是1则索引变为“原索引+oldCap”,如下图
-
这个设计既省去了重新计算hash值得时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此,resize的过程,均匀地吧之前的冲突的节点分散到新的bucket了。此外jdk8不会将链表元素倒置。
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; } //没超过就扩充为原来的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //计算新的resize上限 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) { //把每个bucket都移动到新的buckets中 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 { // preserve order //链表优化重hash的代码块 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; } //原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); //原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //原索引+oldCap放到bucket里。 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
-
红黑树的优化还没说,那个太难了,暂时不讲。
-
-
2.8 HashMap并发安全的问题
-
并发的多线程使用HashMap可能会造成死循环。代码例子如下(便于理解使用的是jdk7的环境)
public class HashMapInfiniteLoop { private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f); public static void main(String[] args) { map.put(5, "C"); new Thread("Thread1") { public void run() { map.put(7, "B"); System.out.println(map); }; }.start(); new Thread("Thread2") { public void run() { map.put(3, "A"); System.out.println(map); }; }.start(); } }
-
其中map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1;也就是说当put第二个key的时候,map就需要进行resize
-
通过设置断点让线程1与线程2同时debug到transfer方法的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next=e.next”这一行;然后放开线程2的断点,让线程2进行resize,结果如下
-
注意,thread1的e指向了key(3),而next指向了key(7),其在线程2 rehash后,指向线程2重组后的链表。
-
线程1被调度回来执行,先是执行newTable[i]=e,然后e=next,导致e指向了key(7),而下一次循环的next=e.next导致next指向了key(3)。
-
e.next=newTable[i]导致key[3].next指向了key(7)。注意:此时key(7).next已经指向了key(3),环形链表就这样出现了。
-
于是,当我们用线程1调用map.get(11)时,就会出现无限循环。(jdk8解决了这个问题,但是还是有其他线程不安全的情况。
2.9 HashMap注意事项
- 扩容是非常消耗性能的操作。所以能够预估到HashMap的大小的时候就给一个特定大小的初试值。避免hashmap频繁扩容。
- 负载因子也可以大于1,但是不建议轻易更改
- HashMap是线程不安全的。多线程并发环境下建议使用ConcurrentHashMap
- JDK1.8引入红黑树很大程度上优化了HashMap的性能