HashMap多是咱们最常常用的Map接口的实现了。话很少说,咱们先看看HashMap类的注释:html
基于哈希表的Map接口实现。java
这个实现提供了全部可选的映射操做,并容许空值和空键。(HashMap类与Hashtable大体至关,只是它是不一样步的,而且容许为null)node
这个类对映射的顺序不作任何保证;特别是,它不保证顺序将随着时间的推移保持不变。
这个实现为基本操做(get和put)提供了恒定的时间性能,假设hash函数在bucket中适当地分散了元素。集合视图上的迭代所需的时间与HashMap实例的“容量”(bucket的数量)加上其大小(键值映射的数量)成比例。所以,若是迭代性能很重要,那么不要将初始容量设置得过高(或者负载系数过低),这一点很是重要。
HashMap的实例有两个影响其性能的参数:初始容量和负载因子。capacity是哈希表中的bucket数,初始容量就是建立哈希表时的容量。加载因子是一个度量哈希表在容量自动增长以前能够达到的完整程度。当哈希表中的条目数超过加载因子与当前容量的乘积时,哈希表将从新哈希(即重建内部数据结构),使哈希表的存储桶数大约为原来的两倍。
通常来讲,默认的负载系数(.75)在时间和空间成本之间提供了很好的折衷。较高的值会减小空间开销,但会增长查找开销(反映在HashMap类的大多数操做中,包括get和put)。在设置初始容量时,应考虑地图中的预期条目数及其荷载系数,以尽可能减小再灰化操做的次数。若是初始容量大于最大入口数除以负载系数,则不会发生再吹灰操做。
若是要在一个HashMap实例中存储许多映射,那么以足够大的容量建立它将使映射的存储效率更高,而不是让它根据须要执行自动从新缓存以增长表。请注意,使用具备相同hashCode()的多个键确定会下降任何哈希表的性能。为了改善影响,当键是可比较的时,这个类可使用键之间的比较顺序来帮助打破联系。
请注意,此实现不是同步的。若是多个线程同时访问一个哈希映射,而且至少有一个线程在结构上修改了该映射,则它必须在外部同步。(结构修改是指添加或删除一个或多个映射的任何操做;仅更改与实例已包含的键相关联的值不是结构修改。)这一般是经过对天然封装映射的对象进行同步来完成的。若是不存在这样的对象,则应该使用集合.synchronizedMap方法。最好在建立时执行此操做,以防止意外的不一样步访问映射:算法
Map m = Collections.synchronizedMap(new HashMap(...));
数组注意,迭代器的fail-fast行为不能获得保证,由于通常来讲,在存在不一样步的并发修改时,不可能作出任何明确保证。Fail fast迭代器在尽最大努力的基础上抛出ConcurrentModificationException。所以,编写一个依赖这个异常来保证其正确性的程序是错误的:迭代器的fail-fast行为应该只用于检测bug。缓存
如下是HashMap的类关系:安全
HashMap实现了Map接口,并继承 AbstractMap 抽象类,其中 Map 接口定义了键值映射规则。和 AbstractCollection抽象类在 Collection 族的做用相似, AbstractMap 抽象类提供了 Map 接口的骨干实现,以最大限度地减小实现Map接口所需的工做。数据结构
对于HashMap,咱们关注六个问题:多线程
既然HashMap叫这个名字,那他的实现必然是基于哈希表的,关于哈希表我在数据结构与算法(十):哈希表已有介绍。简而言之,哈希表就是一种结合数组与链表的一种数据结构,借助哈希算法快速获取元素下标以实现高效查找。并发
关于HashMap的底层的数据结构,咱们须要了解两个成员变量以及一个内部类:
transient Node<K,V>[] table;
:桶容器Node<K,V>
:entrySet
使用的,基于Map.Entry<K,V>
接口实现的节点类,也就是同容器中的链表画图描述一下就是:
咱们知道哈希表解决哈希冲突的方式有开放地址法和分离链表法,这里很明显使用的是分离链表法,也就是俗称的拉链法。
当咱们存储一个键值对的时候,会经过哈希算法得到key对应的哈希值,经过哈希值去找到在桶中要存放的位置的下标,而有时候不一样的key会计算出相同的哈希值,也就是哈希碰撞,那么节点就会接在第一个节点的身后造成一条链表。当查找的时候先经过key计算获得哈希值找到链表,而后再遍历链表找到key,所以若是哈希碰撞严重,会致使链表变的很长,会影响到查找效率。
按这角度思考,若是桶数组很大,那么一样的哈希算法能获得的位置就更多,换句话说就是发生哈希碰撞的几率就越小,可是过大的桶数组又会浪费空间,因此就后面提到的扩容算法来动态的调整容量。
另外,咱们知道在JDK7中HashMap底层实现只是数组+链表,而到了JDK8就变成了数组+链表+红黑树。
红黑树是一种复杂的树结构,这里咱们简单的理解为一种具备二叉排序树性质和必定平衡二叉树性质(不要求绝对平衡以免频繁旋转)的二叉树。
咱们知道发生哈希碰撞的节点会在桶中造成链表,而当链表上的元素超过8个的时候就会转变成红黑树。这是由于一样深度的状况下,树能够储存比链表更多的元素,而且同时能保证良好的插入删除和查找效率。当元素小于6个的时候又会转回链表。
那么为何会选择8和6这两个数字呢?
效率问题:
红黑树的平均查找长度是lg(n),而链表是n/2。按这个计算,lg(8)=3,6/2=3 -> lg(4)=2, 4/2=2,咱们能够看见当越小于8的时候红黑树和链表查找效率就越差很少,加上转化为红黑树还须要消耗额外的时间和空间的状况下,因此不如直接用链表。
防止频繁的转换:
8和6之间隔了一个7,若是转换为树和转换为链表的阈值是直接相邻,那么极可能出现频繁在树和链表的结构件转换的现象。
咱们先来看看有关HashMap构建中可能涉及的成员变量:
transient int size
:实际存储的key-value键值对的个数;
int threshold
:要调整大小的下一个大小值。
通常是容量 * 负载系数,可是构造函数执行后大小等于初始化容量,只有第一次添加元素后才会初始化;
final float loadFactor
:负载因子,表明了table的填充度有多少,默认是0.75。
加载因子存在的缘由,仍是由于减缓哈希冲突,若是初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。 因此加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32;
transient int modCount
:HashMap被改变的次数。
因为HashMap非线程安全,在对HashMap进行迭代时, 若是期间其余线程的参与致使HashMap的结构发生变化了(好比put,remove等操做), 须要抛出异常ConcurrentModificationException
构造一个具备指定初始容量和负载因子的空HashMap。
这里提到的负载因子,负载因子衡量的是一个散列表的空间的使用程度。
public HashMap(int initialCapacity, float loadFactor) { //初始容量必须大于0 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); }
这里调用的tableSizeFor()
方法是个位运算,他的做用是:
对于给定的目标容量,返回2的幂
换而言之,初始化容量必须是2的n次方,这个地方与HashMap如何向集合高效添加元素的需求是直接相关的。
具体的分析能够参考:HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)。
接着咱们能够看到初始容量处理后直接给了threshold
,不直接使用initialCapacity
而是这样作的缘由是一开始的时候map的底层容器table还没有初始化,这个操做被放到了第一次put上,因此当咱们第一次添加元素的时候,才会根据指定的初始大小去初始化容器。
构造一个具备指定初始容量和默认负载因子(0.75)的空HashMap。
public HashMap(int initialCapacity) { //直接调用 HashMap(int initialCapacity, float loadFactor)构造方法 this(initialCapacity, DEFAULT_LOAD_FACTOR); }
构造一个具备指定初始容量(16)和默认负载因子(0.75)的空HashMap。
public HashMap() { //所有使用默认值 this.loadFactor = DEFAULT_LOAD_FACTOR; }
使用与指定Map相同的映射构造一个新的HashMap。使用默认的负载因子(0.75)和足以将映射保存在指定Map中的初始容量建立HashMap。
public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
这里调用了putMapEntries()
方法,咱们待会再细说,如今先简单里理解为根据一个已经存在的Map集合去建立一个新Map集合,有点相似于Arrays.copyOf()
方法。
咱们从上文能够知道,当构造函数执行完毕之后,并无真正的开辟HashMap的数据存储空间,而是等到第一次put的时候才会为table分配空间。
HashMap中有一个put()
方法:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
它的注释是这样描述的:
将指定值与该映射中的指定键相关联。若是该映射先前包含该键的映射,则将替换旧值。
简单的来讲,就是两个功能:
咱们能够看到,实际上这个方法经过hash()
和putVal()
两个方法来实现。
桶容器下标经过三个步骤来计算:获取哈希值,异或运算混合高低位获得新哈希,新哈希和长度与运算获取下标。
咱们看看hash()
方法的源码:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这里的hashCode()
方法是一个Native方法,原理是将对象的内存地址转为一个整数以获取对象哈希值。
这一个方法先调用了一个 key.hashCode()
方法获取了key的哈希值,而后将哈希值与哈希值的高16位作异或运算。
而在下面的putVal()
方法中,又经过相似下面三行代码进行取模:
//n为新桶数组长度 n = (tab = resize()).length; //进行与运算取模 (n - 1) & hash
从网上看到一张很形象的图:
咱们来理解一下:
咱们先看与运算取模。一方面位与运算运算快;另外一方面因为长度必然是2的幂,因此转二进制有效位必然全是1,与运算的时候能够充分散列表。
异或运算混合高低位:为了将哈希值的高位和低位混合,以增长随机性。
好比数组table的长度比较小的时候(好比图中的长度就只有4),也能保证考虑到哈希值的高低位都参与计算中。
为了更明确的说明长度取2的幂有助于充分散列避免哈希碰撞,这里举个特别明显的例子:
当HashMap的容量是16时,它的二进制是10000,(n-1)的二进制是01111,与hash值得计算结果以下:
上面四种状况咱们能够看出,不一样的hash值,和(n-1)进行位运算后,可以得出不一样的值,使得添加的元素可以均匀分布在集合中不一样的位置上,避免hash碰撞。
下面就来看一下HashMap的容量不是2的n次幂的状况,当容量为10时,二进制为01010,(n-1)的二进制是01001,向里面添加一样的元素,结果为:
能够看出,有三个不一样的元素进过&运算得出了一样的结果(01001),严重的hash碰撞了。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //判断键值对数组table[i]是否为空或为null,不然执行resize()进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //判断插入位置是否为空,是就插入新节点 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //若是直接找到相同节点存在就直接覆盖 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; } //若是在链表找到相同阶段就覆盖 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; }
咱们看看get()
方法的注释和源码:
返回指定键所映射到的值;若是此映射不包含键的映射关系,则返回null。更正式地讲,若是此映射包含从键k到值v的映射,使得(key == null?k == null:key.equals(k)),则此方法返回v;不然,返回v。不然返回null。 (最多能够有一个这样的映射。)返回值null不必定表示该映射不包含该键的映射;它的返回值为0。映射也可能将键显式映射为null。 containsKey操做可用于区分这两种状况。
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
咱们能够看到实际上调用了getNode()
方法:
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //确保table不为空,而且计算获得的下标对应table的位置上有节点 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) { //红黑树上查找 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { //链表上查找 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
首先,不被本来的的hashCode和equals是这样的
咱们回顾一下上文,能够看到不管put()
仍是get()
都会有相似这样的语句:
p.hash == hash && (key != null && key.equals(k))
当咱们试图添加或者找到一个key的时候,方法会去判断哈希值是否相等和值是否相等,都相等的时候才会判断这个key就是要获取的key。也就是说,严格意义上,一个HashMap里是不容许出现相同的key的。
当咱们使用对象做为key的时候,根据本来的hashCode和equals仍然能保证key的惟一性。可是当咱们重写了equals方法而不重写hashCode()方法时,可能出现值相等可是由于地址不相等致使哈希值不一样,最后致使出现两个相同的key的状况。
咱们举个例子:
咱们如今有一个类:
/** * @Author:CreateSequence * @Date:2020-08-14 16:15 * @Description:Student类,重写了equals方法 */ public class Student { String name; Integer age; /** * 重写了equals方法 * @param obj * @return */ @Override public boolean equals(Object obj) { Student student = (Student) obj; return (this.name == student.name) && (this.age == student.age); } public Student(String name, Integer age) { this.name = name; this.age = age; } @Override public String toString() { return "Student{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
而后咱们试试看:
public static void main( String[] args ) { HashMap<Student,Integer> map = new HashMap(16); Student student1 = new Student("小明", 21); map.put(student1, 1); Student student2 = new Student("小明", 21); System.out.println("这个key已经存在了吗?"+map.containsKey(student2)); System.out.println(map.get(student2)); } //输出结果 这个key已经存在了吗?false null
能够看到,由于hashCode()
获得的值不一样,在map中他们被当成了不一样的key。
而当咱们重写了Student类的hashCode()
方法之后:
@Override public int hashCode() { return age; }
执行结果就变成:
这个key已经存在了吗?true 1
可见重写equals还要重写hashcode的必要性。
参考:
HashMap初始容量为何是2的n次幂及扩容为何是2倍的形式;
](https://blog.csdn.net/qq_40574571/article/details/97612100)
话很少说,咱们直接源码
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; } //不然就扩容到原来的两倍 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); } //从新下一次计算扩容大小 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) { 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 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; //哈希值与原长度进行与运算,若是多出来那一位是0,就保持原下标 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //若是多出来那一位是1,就移动到(原下标+原长度)对应的新位置 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; }
咱们知道,若是桶数组扩容了,那么数组长度也就变了,那么put和get的时候根据长度与哈希进行与运算的时候计算出来的下标就不同。在JDK7扩容移动旧容器的数据的时候,会进行重哈希得到新索引,而在JDK8进行了优化。
由于桶数组长度老是2的幂,因此扩容之后翻倍,转换为二进制的时候就会比原来多一位,若是咱们假设桶数组为n,则有:
n = 16 -> 10000; (n-1) - > 1111; n = 32 -> 100000; (n-1) - > 11111; n = 64 -> 1000000; (n-1) - > 111111; n = 128 -> 10000000; (n-1) - > 1111111;
咱们举例子验证一下,以下图:
(a)是n=16时,key1与key2跟(n-1)与运算获得的二进制下标;(b)是扩容后n=32时,key1与key2跟(n-1)与运算获得的二进制下标。
咱们能够看到key2进了一位,多出来这一位至关于多了10000,转为十进制就是在原基础上加16,也就是加上了原桶数组的长度,反映到代码里,就是 newTab[j + oldCap] = hiHead;
这一句代码;
如今在看看key1,咱们看到key1的索引并无移动,由于key多出来的那一位是0,因此与运算后仍是0,最后获得的下标跟原来的同样。
因此咱们能够总结一下:
这样作的好处除了不须要从新计算哈希值之外;因为哈希值多处来的一位数多是0也多是1,这样就让本来在同一条链表的上元素有可能能够在扩容后移动到新位置,有效缓解了哈希碰撞。
咱们知道HashMap是线程不安全的,线程安全的Map集合是ConcurrentHashMap。事实上,HashMap的线程不安全在JDK7和JDK8表现不一样:
在JDK7中,错误出如今扩容方法transfer
中,其代码以下:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { //遍历链表,当前节点为e while(null != e) { //获取当前节点的下一个节点next Entry<K,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; } } }
从代码中咱们能够看到,扩容后从新计算了元素的下标,并采用头插法将表元素移插到新链表上。
举个例子:
假设线程A线程B同时对下图集合扩容:
1.A先执行,在newTable[i] = e
前时间片耗尽被挂起,此时e = 1,e.next = null,next = 2
2.线程B执行数组扩容,扩容完之后对于线程A就是如今这样,此时next.next = 1,e.next = null,next = 2:
3.接着线程B挂起,线程A继续执行 newTable[i] = e
之后的代码,执行完毕后e = 2,next = 2,e.next = 1:
4.线程A接着下一次循环,因为e.next = 1
,因而next = 1
,头插法把2插入newTable[i]中,执行完毕之后e = 1,next = e.next = null:
5.线程A执行最后一次循环,此时因为e.next = newTable[i]
,因此e.next = 2,而后接着 newTable[i] = e
,也就是说1又被插回newTable[i]的位置:
这个时候最危险的事情发生了:e = 1,e.next =2 ,e.next.next = 1,说明2和1已经造成了一个环形链表:
在此以后会无线循环1和2的头插,形成死循环。
DK7中也有这个问题。
咱们知道put()
方法在插入时会对插入位置进行非空判断,若是两个线程都判断同一个位置为空,那么先执行插入的数据就会被后一个覆盖。