最近研究了下HashMap(Android开发确实用的少),不难理解为什么面试经常问HashMap,太有意思了。
二丶目录概述图先看两张图:
这里Entry和Node是一个东西,jdk1.8给Entry换了个马甲改为Node
HashMap数据结构可以看做是:数组+单链表+红黑树(jdk1.8新增),数组存储链表头。
上面说了HashMap数据结构,下面就是说说怎么增删改查存数据
几个概念:
K:key用于计算hash值,可用于查找,不可相同,相同时替换
V: 值,我们要的东西
Entry
hashcode:特定算法分配Node
hash:K.hash得到的值。
扩容:跟ArrayList一样,HashMap的扩容是当size大于阀值 *2,
阀值:size(当前大小)*loadFactor(负载因子,默认0.75),过小浪费空间,过大影响查找速度
rehash:扩容后重新计算hash值分配占位
add添加值:
1.根据hashcode分配数组上的占位;
2.碰撞:分配位置相同,存入链表头后(1.7是存表头);
3.链表长度大于8转为红黑树 提升查找速度;
4.扩容,扩容后大小 x2,重新分配占位
put方法流程图(图片移到上面来,先看图方便理解):
对应上面结构,源码实现
//HashMap里的数组 transient Entry, V>[] table = (Entry , V>[]) EMPTY_TABLE; //Entry对象,存key、value、hash值以及下一个节点 static class Entry<K, V> implements Map.Entry<K, V> { final K key; V value; Entry<K, V> next; int hash; } //默认数组大小,二进制1左移4位为16 static final int DEFAULT_INITIAL_CAPACITY = 1 <<4; //负载因子默认值 static final float DEFAULT_LOAD_FACTOR = 0.75f; //当前存的键值对数量 transient int size; //阀值 = 数组大小 * 负载因子 int threshold; //负载因子变量 final float loadFactor; //默认new HashMap数组大小16,负载因子0.75 public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } public HashMap( int initialCapacity){ this(initialCapacity, DEFAULT_LOAD_FACTOR); } //可以指定数组大小和负载因子 public HashMap( int initialCapacity, float loadFactor){ //省略一些逻辑判断 this.loadFactor = loadFactor; threshold = initialCapacity; //空方法 init(); }
再看看put方法(本文只说put方法)
public V put(K key, V value) { //数组为空时创建数组 if (table == EMPTY_TABLE) { inflateTable(threshold); } //key为空单独对待 if (key == null) return putForNullKey(value); //①根据key计算hash值 int hash = hash(key); //②根据hash值和当前数组的长度计算在数组中的索引 int i = indexFor(hash, table.length); //遍历整条链表 for (Entry先看上面key为空的情况,执行 putForNullKey() 方法单独处理,会把该键值对放在index0,null的hash值也为“0”,所以HashMap中是允许key为空的情况。再看下主流程:,V> e = table[i]; e != null; e = e.next) { Object k; //③情况1.hash值和key值都相同的情况,替换之前的值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); //返回被替换的值 return oldValue; } } modCount++; //③情况2.坑位没人,直接存值或发生hash碰撞都走这 addEntry(hash, key, value, i); return null; }
static int indexFor(int h, int length) { //hash值和数组长度-1按位与操作,听着费劲?其实相当于h%length;取余数(取模运算) //如:h = 17,length = 16;那么算出就是1 //&运算的效率比%要高 return h & (length-1); }
步骤③情况1.hash值和key值都相同,替换原来的值,并将被替换的值返回。
步骤③情况2.坑位没人或发生hash碰撞 — > addEntry(hash, key, value, i)void addEntry(int hash, K key, V value, int bucketIndex) { //当前hashmap中的键值对数量超过阀值 if ((size >= threshold) && (null != table[bucketIndex])) { //扩容为原来的2倍 resize(2 * table.length); hash = (null != key) ? hash(key) : 0; //计算在新表中的索引 bucketIndex = indexFor(hash, table.length); } //创建节点 createEntry(hash, key, value, bucketIndex); }
如果put的时候超过阀值,会调用 resize() 方法将数组大小扩大为原来的2倍,并且根据新表的长度计算在新表中的索引(如之前17%16 =1,现在17%32=17),看下resize方法
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, initHashSeedAsNeeded(newCapacity)); //步骤③将新数组的引用赋给table table = newTable; //步骤④修改阀值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }上面的重点是步骤②,看下它具体的转移操作
void transfer(Entry[] newTable, boolean rehash) { //获取新数组的长度 int newCapacity = newTable.length; //遍历旧数组中的键值对 for (Entry这段for循环的遍历会使得转移前后键值对的顺序颠倒(Java7和Java8的区别),画个图就清楚了,V> e : table) { while(null != e) { Entry ,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; } } }
最后再看下创建节点的方法
void createEntry(int hash, K key, V value, int bucketIndex) { Entry创建节点时,如果找到的这个坑里面没有存值,那么直接把值存进去就行了,然后size++;如果是碰撞的情况,,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
put流程图(见上图)
换马甲
//定义长度超过8的链表转化成红黑树 static final int TREEIFY_THRESHOLD = 8; //换了个马甲还是认识你!!! static class Node<K, V> implements Map.Entry<K, V> { final int hash; final K key; V value; Node<K, V> next; }put源码
public V put(K key, V value) { //根据key计算hash值 return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node对比和Java7的区别,Java8一视同仁,管你key为不为空(null,hashcode为0放在index0)的统一处理,多了一步链表长度的判断以及转红黑树的操作,并且比较重要的一点,新增Node是插在尾部而不是头部。当然上面的主角还是扩容resize操作,V>[] tab; Node ,V> p; int n, i; //步骤1.数组为空或数组长度为0,则扩容(咦,看到不一样咯) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //步骤2.根据hash值和数组长度计算在数组中的位置 //如果"坑"里没人,直接创建Node并存值 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node ,V> e; K k; //步骤3."坑"里有人,且hash值和key值都相等,先获取引用,后面会用来替换值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //步骤4.该链是红黑树 else if (p instanceof TreeNode) e = ((TreeNode ,V>)p).putTreeVal(this, tab, hash, key, value); //步骤5.该链是链表 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //步骤5.1注意这个地方跟Java7不一样,是插在链表尾部!!! p.next = newNode(hash, key, value, null); //链表长度超过8,转化成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //步骤5.2链表中已存在且hash值和key值都相等,先获取引用,后面用来替换值 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; } } ++modCount; //步骤6.键值对数量超过阀值,扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
final Node下面我们讲解下JDK1.8做了哪些优化。,V>[] resize() { //旧数组的引用 Node ,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; } //扩容咯,这里采用左移运算左移1位,也就是旧数组*2 else if ((newCap = oldCap <<1) = DEFAULT_INITIAL_CAPACITY) //同样新阀值也是旧阀值*2 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); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //更新阀值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) //创建新数组 Node ,V>[] newTab = (Node ,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j ; ++j) { Node ,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 ,V>)e).split(this, newTab, j, oldCap); //链表长度大于1,小于8的情况,下面高能,单独拿出来分析 else { // preserve order Node ,V> loHead = null, loTail = null; Node ,V> hiHead = null, hiTail = null; Node ,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; }
图a中key1(5)和key(21)计算出来的都是5,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
图b中计算后key1(5)的位置还是5,而key2(21)已经变成了21,因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
回来看下源码
else { // preserve order //定义两条链 //原来的hash值新增的bit为0的链,头部和尾部 Node假设一个hash算法刚好算出来的的存储是这样的,在存第13个元素时要扩容,V> loHead = null, loTail = null; //原来的hash值新增的bit为1的链,头部和尾部 Node ,V> hiHead = null, hiTail = null; Node ,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; } }
那么流程应该是这样的(只关注5号坑键值对的情况)
两条链找出来后,最后转移一波,大功告成
//扩容前后位置不变的链 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //扩容后位置加上原数组长度的链 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
1.8 put流程图(见上图)
1.发生hash冲突时,Java7会在链表头部插入,Java8会在链表尾部插入
2.扩容后转移数据,Java7转移前后链表顺序会倒置,Java8还是保持原来的顺序
3.关于性能对比可以参考美团技术博客,引入红黑树的Java8大程度得优化了HashMap的性能
问题1:HashMap是基于key的hashcode的存储的,如果两个不同的key产生的hashcode一样取值怎么办?
看了上面的分析,你肯定知道,再数组里面有链表结构的Entry来实现,通过遍历所有的Entry,比较key来确定到底是哪一个value;public class TestMemory { public static void main(String[] args) { HashMap hashMap = new HashMap(); TestKey testKey = new TestKey(); testKey.setAddress("sdfdsf");//line3 hashMap.put(testKey,"hello"); testKey.setAddress("sdfsdffds");//line5 System.out.println(hashMap.get(testKey)); } } public class TestKey { String name; String address; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } @Override public int hashCode() { if (name==null){ return 0; } return name.hashCode(); } }
上面的代码line3到line5对象里的address做了改变,但是由于hashCode是基于name来生成的,name没变,所以依然能够正常找到value。但是如果把setAdress换成name,get就会返回null。这就是为什么我们选择String的原因。
参考文章:Java 8系列之重新认识HashMap
图解HashMap(一)
Java你可能不知道的事(3)HashMap