HashMap是基于哈希表实现的Map容器,存储的元素是键值对映射。继承自AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。是非线程安全的集合而且容器中存储的键值对映射是无序的,HashMap容许键和值都为null这点与HashTable相反,HashTable是线程安全的且键和值均不能为null。java
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { }
HashMap继承了AbstractMap,AbstractMap提供了Map操做的一些基本实现,实现了Map接口由于父类AbstractMap已经实现了Map接口此处只是起到相似文档标识的做用,这种应用在jdk中还有不少,此外HashMap还实现了Cloneable和Serializable接口支持对象拷贝和序列化。node
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { private static final long serialVersionUID = 362498820763181265L; //容器默认初始容量16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //容器最大容量2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //阈值,容器保存hash冲突节点的桶上链表节点数超过这个值就会转成红黑树结构存储 static final int TREEIFY_THRESHOLD = 8; //阈值,当桶的链表数小于这个值时,存储hash冲突节点的红黑树会转回链表存储结构 static final int UNTREEIFY_THRESHOLD = 6; //树的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; //存储键值对节点的数组,采用了拉链法解决Hash冲突,Node对象其实是单链表或者红黑树,老是2的倍数,为何要这样设置 //分析后续的方法源码就能够知道 transient Node<K,V>[] table; //键值对映射集合 transient Set<Map.Entry<K,V>> entrySet; //键值对个数 transient int size; //容器结构修改计数器 transient int modCount; //临界值,会进行扩容 int threshold; //填充因子 final float loadFactor; }
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
HashMap类提供了三个构造函数,无参构造函数HashMap()很简单就是初始化对象的负载因子为默认的负载因子。HashMap(int initialCapacity)在方法内部调用了HashMap(int initialCapacity, float loadFactor)方法,所以咱们能够直接分析该构造方法,该构造方法首先对初始容量initialCapcity和加载因子loadFactor作了合法性校验,若是初始容量大于Hashmap容量最大限制2的30次方,设置为最大容量,初始化根据方法指定参数初始化加载因子,调用tableSizeFor计算临界值,跟进该方法源码,tableSizeFor方法源码以下:数组
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
该方法经过一系列移位和逻辑运算保证计算出的临界值是最小的大于方法指定初始化容量cap的2的指数次方,至于为何临界值要设置为2的指数次方,咱们后续会讲到。安全
由于HashMap底层是基于hashtable实现的,容器的各类操做包括元素插入、删除、修改和查询都须要调用hash函数计算key对应的hash值进而定位元素所属槽(bucket)在哈希表(table数组)中索引,hash函数做为重点,咱们首先进行分析,方法源码以下:数据结构
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
查看源码可知,在HashMap中key的hash值计算逻辑为:key为null结束计算返回0,不然调用key.hashCode方法计算key的哈希值,把key的哈希值做为底数,key哈希值右移16位做为指数作幂运算,返回运算结果。app
方法源码:函数
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
方法内部核心逻辑在getNode(hash(key), key)),继续跟进该方法源码源码分析
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //哈希表判空,(n-1) & hash计算得出key所在槽的在哈希表中的索引位置,获取到的多是链表表头也多是红黑树树 //根 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //找到key对应的键值对映射,返回对应的键值对节点 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //若是当前节点的后续节点不为空 if ((e = first.next) != null) { //如果树节点即当前槽保存的hash冲突节点个数超过8个,在红黑树中查找key对应的键值对节点 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); //不然继续遍历链表,若是链表中存在匹配指定key的键值对节点(equals且hashCode相等),结束返回节点 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
查看源码咱们大致了解了get方法的逻辑。它首先根据key计算hash而后与哈希表长度table.length-1作与运算获取该key所在槽的头节点根据该槽hash冲突状况的不一样可能返回的是红黑树的树根也多是链表的头节点,这里咱们知道了为何node数组table的长度总要设置为2的指数次方,由于2的指数次方-1,的二进制位是一连串1,HashMap中(n-1) & hash的计算结果更加分散,能下降Hash冲突的几率提高查询效率。性能
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
方法内部调用putVal进行键值对插入,继续跟进该方法源码优化
/** * Implements Map.put and related methods * * @param hash key的hash计算值 * @param key 键值对中的键 * @param value 键值对中的值 * @param onlyIfAbsent 若是为true,当容器中已存在该key对应的键值对,不进行插入 * @param evict 若为false,table处于建立模式 * @return 返回key所在键值对被覆盖的前一个value,若是没有返回null */ 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.若是table为空即HashMap中不存在任何键值对映射,则为table分配内存 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //2.(n-1) & hash获取键值对在table中的索引位置,若索引所在的节点为空则说明当前日期不存在与指定键值对key的 //hash值相等的键值对节点,直接插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //不然指定插入键值对存在hash冲突 else { Node<K,V> e; K k; //若hash冲突槽第一个节点hash值相等且key值相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //若hash冲突槽以红黑树数据结构存储,在红黑树中插入键值对节点 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //不然冲突槽的数据结构为单链表,若当前容器不存在key对应的键值对直接基于指定的键值对建立一个新节点在链表 //尾插入,若插入键值对后超过链表树化阈值则将存储hash冲突节点的链表转化为红黑树结构, //不然获取当前容器中hash相等且key相等的节点用于后续操做 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //若当前HashMap中存在key对应的键值对节点e if (e != null) { // existing mapping for key V oldValue = e.value; //若onlyIfAbsent设置为false即容许覆盖容器中键值对节点的value值或旧value值为null,则设置为 //新值value if (!onlyIfAbsent || oldValue == null) e.value = value; //在HashMap中是空实现留给子类作扩展 afterNodeAccess(e); return oldValue; } } //3.HashMap插入键值对映射没有Hash冲突时执行后续代码 //容器结构修改计数器递增 ++modCount; //table中即哈希表中有存储键值对映射的槽的个数大于阀值threadshold则调用resize方法扩容 if (++size > threshold) resize(); //空实现 afterNodeInsertion(evict); return null; }
该方法是HashMap中put和相关方法如putIfAbsent的底层实现方法,方法流程逻辑整理以下:
1)判断存储键值对节点数组table是否为空,若为空调用resize方法进行扩容,咱们看下该方法源码:
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) { //若数组table容量大于等于最大容量限制MAXIMUM_CAPACITY if (oldCap >= MAXIMUM_CAPACITY) { //将扩容临界值设置为Integer的最大值,再也不进行扩容 threshold = Integer.MAX_VALUE; return oldTab; } //若扩容2倍以后容量小于HashMap最大容量限制且原来的容量大于初始容量则进行2倍扩容且临界值*2 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //若容器为空,且临界值大于0,则容器扩容后的容量设置为临界值 else if (oldThr > 0) newCap = oldThr; //临界值和容器初始容量均为0,则为临界值和容器初始容量分别分配一个默认值,临界值为初始容量乘以默认负载因子 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //若扩容后的临界值等于0根据负载因子和扩容后从新计算扩容后的临界值,若计算后临界值大于等于最大容量限制则须要重 //置为Integer.MAX_VALUE if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; //为table从新一个新的节点数组,数组长度为扩容后的容器容量newCap @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //若是老table不为空,原来容器中存在键值对节点,须要在新的节点数组table中填入原来的数据 if (oldTab != null) { //循环容器原来的节点数组table,将键值对数据填充到新数组table for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //table当前下标位置保存hash相同(也可说冲突)节点的槽不为空 if ((e = oldTab[j]) != null) { //释放槽中的节点对象 oldTab[j] = null; //槽中只包含单个节点,基于hash & (newCap-1)获取该节点在新数组table的下标位置,在数组中对应 //位置保存该节点 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //若槽的数据存储结构是红黑树,则也为新table当前槽建立红黑树存储槽中的键值对数据 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; }
这一步的话由于判断的是原容器为空的状况,主要作的实际上是为table分配一个默认初始容量的数组。
2)(n-1) & hash基于key的hash与容器长度n-1的二进制位作&运算,获取插入键值对在哈希表table中的下标位置,由于n为2的指数故n-1的二进制是一系列1,key的hash与n-1作&运算能使键值对分散更随机均匀,有利于提升查询效率,若是哈希表table中存储键值对节点的槽为null那么直接为指定键值对建立新节点,并在table对应下标保存新节点引用;
3)若是插入key存在Hash冲突,须要根据保存Hash冲突节点的槽的数据存储结构是红黑树仍是链表作处理,如果链表且插入指定节点后超出树化阀值则须要将链表转化为红黑树保存hash冲突节点;
4)若容器发生结构修改(指的是插入的键值对节点未发生hash冲突新占用了数组table的空间),modCount(结构修改计数器)++,判断是否超过临界值超过须要调用resize进行扩容,这个方法以前已经分析过了,能够回过头看下方法实现逻辑,通常状况下是扩容2倍。
HashMap性能消耗比较严重的主要有两个过程,第一个是当发生Hash冲突时,table中存储单个节点的槽会退化为链表查询时须要额外遍历这个链表,Hash冲突越剧烈查询性能越低,尽管JDK1.8对此做了优化当链表节点数超过8会转化为红黑树存储,查询花费的时间复杂度下降到O(logn),下降Hash冲突咱们须要作的包括为Key对象类型选择一个合理的hashCode函数,合理规划HashMap的初始容量(table数组长度)让插入的键值对基于Hash和初始容量计算出的数组table下标尽可能分散;第二个是当容器中节点占用的槽的个数(也就是数组table中被占用的数据项个数)超过临界值时会进行扩容,扩容须要将原HashMap中存储的键值对数据填充到新的数组table中,过程当中须要从新遍历HashMap中的键值对数据,并从新定位他们在新节点数组table中的位置,涉及Hash冲突须要重构链表、红黑树,效率极低。针对第二点咱们能够经过调整负载因子(loadFactor)和容器初始容量去减小扩容次数。通常状况下不建议去修改loadFactor的默认值,咱们能够在使用HashMap前预估插入键值对的个数,经过调整初始容量initialCapcity大小,使threadshold=initialCapcity*loadFactor大于预估节点个数,或者调整到一个较为合理的值,防止扩容或下降插入过程当中的扩容次数。