前言: HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK(Java Developmet Kit)版本的更新,JDK1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。最近恰好有时间,恰好把HashMap相关的内容和以前作惟品会网关的一些经验整理一下。html
HashMap的内存结构和原理,以及线程安全都是面试的热点问题。Java中的数据结构基本能够用数组+链表的解决。java
快速插入和删除的场景,不利于查找
。而HashMap就是综合了上述的两种数据结构的优势,HashMap由Entry数组+链表组成
,以下图所示:程序员
从上图咱们能够发现HashMap是由Entry数组+链表
组成的,一个长度为16的数组中,每一个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。通常状况是经过hash(key)%len
得到,也就是元素的key的哈希值对数组长度取模获得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108
以及140都存储在数组下标为12
的位置。面试
1.首先HashMap里面实现一个静态内部类Entry
,其重要的属性有 key , value, next
,从属性key,value咱们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean
,咱们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。算法
static class Entry<K,V> implements Map.Entry<K,V> { final K key;//Key-value结构的key V value;//存储值 Entry<K,V> next;//指向下一个链表节点 final int hash;//哈希值 }
2.既然是线性数组,为何能随机存取?这里HashMap用了一个小算法,大体是这样实现:数组
//存储时: // 这个hashCode方法这里不详述,只要理解每一个key的hash是一个固定的int值 int hash = key.hashCode(); int index = hash % Entry[].length; Entry[index] = value; //取值时: int hash = key.hashCode(); int index = hash % Entry[].length; return Entry[index];
到这里咱们轻松的理解了HashMap经过键值对实现存取的基本原理安全
3.疑问:若是两个key经过hash%Entry[].length获得的index相同,会不会有覆盖的危险?数据结构
这里HashMap里面用到链式数据结构的一个概念。上面咱们提到过Entry类里面有一个next属性,做用是指向下一个Entry。打个比方, 第一个键值对A进来,经过计算其key的hash获得的index=0,记作:Entry[0] = A。一会后又进来一个键值对B,经过计算其index也等于0,如今怎么办?HashMap会这样作:B.next = A,Entry[0] = B,若是又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样咱们发现index=0的地方其实存取了A,B,C三个键值对,他们经过next这个属性连接在一块儿。因此疑问不用担忧。也就是说数组中存储的是最后插入的元素。到这里为止,HashMap的大体实现,咱们应该已经清楚了。并发
固然HashMap里面也包含一些优化方面的实现,这里也说一下。好比:Entry[]的长度必定后,随着map里面数据的愈来愈长,这样同一个index的链就会很长,会不会影响性能?HashMap里面设置一个因素(也称为因子),随着map的size愈来愈大,Entry[]会以必定的规则加长长度。 app
put操做主要是判空,对key的hashcode执行一次HashMap本身的哈希函数,获得bucketindex位置,还有对重复key的覆盖操做。
在HashMap作put操做的时候会调用到如下的方法,addEntry和createEntry
public V put(K key, V value) { if (key == null) return putForNullKey(value); //获得key的hashcode,同时再作一次hash操做 int hash = hash(key.hashCode()); //对数组长度取余,决定下标位置 int i = indexFor(hash, table.length); /** * 首先找到数组下标处的链表结点, * 判断key对一个的hash值是否已经存在,若是存在将其替换为新的value */ for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //Hash碰撞的解决 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null; }
涉及到的几个方法:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } static int indexFor(int h, int length) { return h & (length-1); }
如今假如A线程和B线程同时进入addEntry
,而后计算出了相同的哈希值对应了相同的数组位置
,由于此时该位置还没数据,而后对同一个数组位置调用createEntry
,两个线程会同时获得如今的头结点,而后A写入新的头结点以后,B也写入新的头结点,那B的写入操做就会覆盖A的写入操做形成A的写入操做丢失。
①.判断键值对数组table[i]是否为空或为null,不然执行resize()进行扩容;
②.根据键值key计算hash值获得插入的数组索引i,若是table[i]==null,直接新建节点添加,转向⑥,若是table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key同样,若是相同直接覆盖value,不然转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是不是红黑树,若是是红黑树,则直接在树中插入键值对,不然转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操做,不然进行链表的插入操做;遍历过程当中若发现key已经存在直接覆盖value便可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,若是超过,进行扩容。
JDK1.8HashMap的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; // 步骤①:tab为空则建立 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步骤②:计算index,并对null作处理 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 步骤③:节点key存在,直接覆盖value 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); //链表长度大于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; } } ++modCount; // 步骤⑥:超过最大容量 就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
public V get(Object key) { if (key == null) return getForNullKey(); int hash = hash(key.hashCode()); /** * 先定位到数组元素,再遍历该元素处的链表 * 判断的条件是key的hash值相同,而且链表的存储的key值和传入的key值相同 */ for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null;
看一下链表的结点数据结构,保存了四个字段,包括key,value,key对应的hash值以及链表的下一个节点:
static class Entry<K,V> implements Map.Entry<K,V> { final K key;//Key-value结构的key V value;//存储值 Entry<K,V> next;//指向下一个链表节点 final int hash;//哈希值 }
扩容(resize)就是从新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组没法装载更多的元素时,对象就须要扩大数组的长度,以便能装入更多的元素。固然Java里的数组是没法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像咱们用一个小桶装水,若是想装更多的水,就得换大水桶。
仍是上面那个addEntry方法中,有个扩容的操做,这个操做会新生成一个新的容量的数组,而后对原数组的全部键值对从新进行计算和写入新的数组,以后指向新生成的数组。来看一下扩容的源码:
//用新的容量来给table扩容 void resize(int newCapacity) { Entry[] oldTable = table; //引用扩容前的Entry数组 int oldCapacity = oldTable.length; //保存old capacity // 若是旧的容量已是系统默认最大容量了(扩容前的数组大小若是已经达到最大(2^30)了 ),那么将阈值设置成整形的最大值,退出 , if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //初始化一个新的Entry数组 Entry[] newTable = new Entry[newCapacity]; //将数据转移到新的Entry数组里 transfer(newTable, initHashSeedAsNeeded(newCapacity)); //HashMap的table属性引用新的Entry数组 table = newTable; //设置阈值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()
方法将原有Entry数组
的元素拷贝到新的Entry数组
里。
那么问题来了,当多个线程同时进来,检测到总数量超过门限值的时候就会同时调用resize操做,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其余线程的均会丢失。并且当某些线程已经完成赋值而其余线程刚开始的时候,就会用已经被赋值的table做为原始数组,这样也会有问题。因此在扩容操做的时候也有可能会引发一些并发的问题。
//根据指定的key删除Entry,返回对应的value public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } //根据指定的key,删除Entry,并返回对应的value final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) //若是删除的是table中的第一项的引用 table[i] = next;//直接将第一项中的next的引用存入table[i]中 else prev.next = next; //不然将table[i]中当前Entry的前一个Entry中的next置为当前Entry的next e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
删除这一块可能会出现两种线程安全问题,第一种是一个线程判断获得了指定的数组位置i并进入了循环,此时,另外一个线程也在一样的位置已经删掉了i位置的那个数据了,而后第一个线程那边就没了。可是删除的话,没了倒问题不大。
再看另外一种状况,当多个线程同时操做同一个数组位置的时候,也都会先取得如今状态下该位置存储的头结点,而后各自去进行计算操做,以后再把结果写会到该数组位置去,其实写回的时候可能其余的线程已经就把这个位置给修改过了,就会覆盖其余线程的修改。
总之HashMap是非线程安全的,在高并发的场合使用的话,要用Collections.synchronizedMap进行包装一下。
https://zhuanlan.zhihu.com/p/21673805
http://www.importnew.com/7099.html
http://www.admin10000.com/document/3322.html
http://www.cnblogs.com/chenssy/p/3521565.html
http://xujin.org/java/hm01/