1. 定义
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,源码如下:
1 public class HashMap<K,V> 2 extends AbstractMap<K,V> 3 implements Map<K,V>, Cloneable, Serializable
HashMap是一种支持快速存取的数据结构。
2. 构造函数
HashMap提供了三个构造函数:
-
HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
-
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
-
HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。
在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。
对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
3. HashMap的数据结构
桶:bucket,就是下图中的table数组的每一个成员(橘色),数组的每个位置就叫一个桶,bucket的下标即table数组的下标。Entry<K,V>构成了table数组的项。
HashMap是一个“链表散列”。第一列是table数组,后面是链表。
见源码:下面代码中第23行,表示每创建一个HashMap,就会有一个新的table数组,并且table数组的元素为Entry节点。
1 public HashMap(int initialCapacity, float loadFactor) { 2 //初始容量不能<0 3 if (initialCapacity < 0) 4 throw new IllegalArgumentException("Illegal initial capacity: " 5 + initialCapacity); 6 //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30 7 if (initialCapacity > MAXIMUM_CAPACITY) 8 initialCapacity = MAXIMUM_CAPACITY; 9 //负载因子不能 < 0 10 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 11 throw new IllegalArgumentException("Illegal load factor: " 12 + loadFactor); 13 14 // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。 15 int capacity = 1; 16 while (capacity < initialCapacity) 17 capacity <<= 1; 18 19 this.loadFactor = loadFactor; 20 //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作 21 threshold = (int) (capacity * loadFactor); 22 //初始化table数组 23 table = new Entry[capacity]; 24 init(); 25 }
1 static class Entry<K,V> implements Map.Entry<K,V> { 2 final K key; 3 V value; 4 Entry<K,V> next; 5 final int hash; 6 7 /** 8 * Creates new entry. 9 */ 10 Entry(int h, K k, V v, Entry<K,V> n) { 11 value = v; 12 next = n; 13 key = k; 14 hash = h; 15 } 16 ....... 17 }
其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值,这是非常重要的,正是由于Entry才构成了table数组的项为链表。
HashMap添加节点的方法
1 /** 2 * HashMap 添加节点 3 * 4 * @param hash 当前key生成的hashcode 5 * @param key 要添加到 HashMap 的key 6 * @param value 要添加到 HashMap 的value 7 * @param bucketIndex 桶,也就是这个要添加 HashMap 里的这个数据对应到数组的位置下标 8 */ 9 void addEntry(int hash, K key, V value, int bucketIndex) { 10 //size:The number of key-value mappings contained in this map. 11 //threshold:The next size value at which to resize (capacity * load factor) 12 //数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈值 13 // 2.底层数组的bucketIndex坐标处不等于null 14 if ((size >= threshold) && (null != table[bucketIndex])) { 15 resize(2 * table.length);//扩容之后,数组长度变了 16 hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢? 17 bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。 18 } 19 createEntry(hash, key, value, bucketIndex); 20 } 21 22 /** 23 * 这地方就是链表出现的地方,有2种情况 24 * 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦 25 * 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了 26 */ 27 void createEntry(int hash, K key, V value, int bucketIndex) { 28 HashMap.Entry<K, V> e = table[bucketIndex]; 29 table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e); 30 size++; 31 }
4. HashMap的原理
看原理之前,先把第3个问题看看,了解他的数据结构及相关概念。
5. Hash冲突
就像上面的一个数组的位置上出现了一条链,即一个链表的出现,这就是所谓的hash冲突,即hash值相同。解决hash冲突,就是让链表的长度变短,或者干脆就是不产生链表,一个好的hash算法应该是让数据很好的散列到数组的各个位置,即一个位置存一个数据就是最好的散列,下面说的链地址法,说的就是在hashmap里面冲突的时候,一个节点可以存多个数据。
解决方法见JDK1.8中的3.2节
6. 存储实现:put(key,vlaue)
6.1. HashMap允许为null的原因
6.2. put中有迭代的原因
6.3. 为何数组长度是2^n呢,又为何可以是素数呢
6.4. 扩容问题
6.5. 为什么HashMap中元素的数量越来越多,查找速度越来越慢
6.6. 链表产生问题
6.7. 负载因子loadFactor是否可以大于1
6.8. 为什么要有HashMap的hash()方法,为什么不能直接使用KV中K原有的hash值
put操作的源码如下:
1 public V put(K key, V value) { 2 //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因 3 if (key == null) 4 return putForNullKey(value); 5 //计算key的hash值 6 int hash = hash(key.hashCode()); //------(1) 7 //计算key hash 值在 table 数组中的位置,即对length取模 8 int i = indexFor(hash, table.length); //------(2) 9 //从i处开始迭代 e,找到 key 保存的位置 10 for (Entry<K, V> e = table[i]; e != null; e = e.next) { 11 Object k; 12 //判断该条链上是否有hash值相同的(key相同) 13 //若存在相同,则直接覆盖value,返回旧value(其实还是已经更新的值) 14 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { 15 V oldValue = e.value; //旧值 = 新值 16 e.value = value; 17 e.recordAccess(this); 18 return oldValue; //返回旧值(其实还是已经更新的值) 19 } 20 } 21 //修改次数增加1 22 modCount++; 23 //将key、value添加至i位置处 24 addEntry(hash, key, value, i); 25 return null; 26 }
通过源码我们可以清晰看到HashMap保存数据的过程为:
-
首先判断key是否为null:
-
若为null,则直接调用putForNullKey方法;
-
若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置;
-
-
如果table数组在该位置处有元素,则通过比较是否存在相同的key:
-
若存在则覆盖原来key的value;
-
否则将该元素保存在链头(最先保存的元素放在链尾);
-
-
若table在该处没有元素,则直接保存。
这个过程看似比较简单,其实深有内幕。有如下几点:
-
先看迭代处。此处迭代原因就是为了防止存在相同的key值,若发现两个hash值(key)相同时,HashMap的处理方式是用新value替换旧value,这里并没有处理key,这就解释了HashMap中没有两个相同的key。
-
在看(1)、(2)处。这里是HashMap的精华所在。首先是hash方法,该方法为一个纯粹的数学计算,就是计算h的hash值。此处加入了高位运算(见下面代码第19行),避免了由于高位损失而带来的冲突,详见hashmap的hash计算。这也是为什么要有HashMap的hash()方法,难道不能直接使用KV中K原有的hash值的答案。
1 //此处h是hashcode值,Jdk1.7及以前 2 static int hash(int h) { 3 h ^= (h >>> 20) ^ (h >>> 12); 4 return h ^ (h >>> 7) ^ (h >>> 4); 5 }
注:h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。
1 2 //Jdk1.8 3 /** 4 * Computes key.hashCode() and spreads (XORs) higher bits of hash 5 * to lower. Because the table uses power-of-two masking, sets of 6 * hashes that vary only in bits above the current mask will 7 * always collide. (Among known examples are sets of Float keys 8 * holding consecutive whole numbers in small tables.) So we 9 * apply a transform that spreads the impact of higher bits 10 * downward. There is a tradeoff between speed, utility, and 11 * quality of bit-spreading. Because many common sets of hashes 12 * are already reasonably distributed (so don't benefit from 13 * spreading), and because we use trees to handle large sets of 14 * collisions in bins, we just XOR some shifted bits in the 15 * cheapest possible way to reduce systematic lossage, as well as 16 * to incorporate impact of the highest bits that would otherwise 17 * never be used in index calculations because of table bounds. 18 */ 19 static final int hash(Object key) { 20 int h; 21 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//高位参与运算 22 }
-
为什么要有HashMap的hash()方法:详见hashmap的hash计算
此处的hash方法和indexFor方法的参数的来源:
1 2 //计算key的hash值 3 int hash = hash(key.hashCode()); //------(1) 4 //计算key hash 值在 table 数组中的位置,即对length取模 5 int i = indexFor(hash, table.length);
我们知道对于HashMap的table而言,数据分布需要均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。计算hash值后,怎么才能保证table元素分布均与呢?我们会想到取模,但是由于取模的消耗较大,HashMap是这样处理的:调用indexFor方法。
1 //此处h是hash值,hash值与上(length-1),就相当于是对length取模!!!!太高明了!!! 2 static int indexFor(int h, int length) { 3 return h & (length-1); 4 }
这句话除了上面的取模运算外还有一个非常重要的责任:均匀分布table数据和充分利用空间。
这里我们假设length为16(是2^n形式)和15(不是2^n形式),h为5、6、7。结果如下图:
当length=15时,6和7的结果一样,这样表示他们在table存储的位置是相同的,也就是产生了碰撞冲突,6、7就会在一个位置形成链表,这样就会导致查询速度降低。诚然这里只分析三个数字不是很多,那么我们就看0-15。
从上面的图表中我们看到总共发生了8次碰撞,同时发现浪费的空间非常大,有1、3、5、7、9、11、13、15处没有记录,也就是没有存放数据。这是因为他们在与14进行&运算时,得到的结果最后一位永远都是0,即0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的,空间减少,进一步增加碰撞几率,这样就会导致查询速度慢。而当length = 16时,length – 1 = 15 即1111,那么进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。
因为2^n -1得到的二进制数的每个位上的值都为1,那么与全部为1的一一个数进行与操作,速度会大大提升。
所以:所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。
综上为什么数组长度是2^n呢?
-
完成取模操作,但是&速度比%更快(h&(length-1));
-
使得table数据均匀分布,非(2^n-1)总有某位是0,&操作后,该位会造成hash冲突;
-
充分利用空间,尽量没有hash冲突,尽可能地均匀分布;
另外,需要说明的是:在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考为什么一般hashtable的桶数会取一个素数 这篇文章,简单来说就是采用2^n方法,高位可能会失效。比如说取2^3=8,H( 11100(二进制) ) = H( 28 ) = 4,H( 10100(二进制) ) = H( 20 )= 4,就出现了冲突。这就是为什么Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(但是,Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。在这里我们暂不讨论HashMap和Hashtable的区别,后面会有详细介绍。
这里我们再来复习put的流程:当我们想一个HashMap中添加一对key-value时,系统首先会计算key的hash值,然后根据hash值确认在table中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等 ,则将该节点插入该链表的链头。具体的实现过程见addEntry方法,如下:
1 //bucketIndex即table数组下标 2 void addEntry(int hash, K key, V value, int bucketIndex) { 3 //获取bucketIndex处的Entry 4 Entry<K, V> e = table[bucketIndex]; 5 //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry 6 table[bucketIndex] = new Entry<K, V>(hash, key, value, e); 7 //若HashMap中元素的个数超过极限了,则容量扩大两倍 8 if (size++ >= threshold) 9 resize(2 * table.length); 10 }
在这里需要注意两个问题:
-
链的产生:
这是一个非常优雅的设计。系统总是将新的Entry对象添加到桶处即bucketIndex处。如果bucketIndex处已经有了对象(也就是说table[bucketIndex]这个可以取到对象),那么新添加的Entry对象将指向原有的Entry对象(这个也好理解,再去看看Entry,这个内部类的一个属性next,就是链表里面的指针不是嘛),形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是table[bucketIndex]==null,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。也就没有冲突啦,我上面又把这个代码补充到那个Entry代码的下面啦。我又贴到下面。
-
扩容问题:
随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度(为啥呢?原来是直接找到数组的index就可以直接根据key取到值了,但是冲突严重,也就是说链表长,那就得循环链表了,时间就浪费在循环链表上了,也就慢了),为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。该临界点在当【HashMap中元素的数量==table数组长度*加载因子】。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理(为啥呢?原来,扩容后数组长度变了,而数组的下标跟数组长度有关(h&(length-1)),故得重算。)。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。这个我也在上面链表产生的过程中写了详细的注释
另外就是,扩容的时候也是二倍扩容,也是因为这个道理,要满足数组长度是2^n,这就又回到上一个问题了。
1 2 /** 3 * HashMap 添加节点 4 * 5 * @param hash 当前key生成的hashcode 6 * @param key 要添加到 HashMap 的key 7 * @param value 要添加到 HashMap 的value 8 * @param bucketIndex 桶,也就是这个要添加 HashMap 里的这个数据对应到数组的位置下标 9 */ 10 void addEntry(int hash, K key, V value, int bucketIndex) { 11 //size:The number of key-value mappings contained in this map. 12 //threshold:The next size value at which to resize (capacity * load factor) 13 //数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈值 14 // 2.底层数组的bucketIndex坐标处不等于null 15 if ((size >= threshold) && (null != table[bucketIndex])) { 16 resize(2 * table.length);//扩容之后,数组长度变了 17 hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢? 18 bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。 19 } 20 createEntry(hash, key, value, bucketIndex); 21 } 22 23 /** 24 * 这地方就是链表出现的地方,有2种情况 25 * 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦 26 * 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了 27 */ 28 void createEntry(int hash, K key, V value, int bucketIndex) { 29 HashMap.Entry<K, V> e = table[bucketIndex]; 30 table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);//Entry.next=e; 31 size++; 32 }
-
负载因子loadFactor:
先看一下HashMap的几个字段:
1 int threshold; // 所能容纳的key-value对极限,取值threshold = length * LoadFactor 2 final float loadFactor; // 负载因子 3 int modCount; 4 int size; //HashMap中实际存在的键值对数量
首先,Entry[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * LoadFactor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改。除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。
而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
7. 读取实现:get(key)
相对于HashMap的存而言,取就显得比较简单了。通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。
1 public V get(Object key) { 2 // 若为null,调用getForNullKey方法返回相对应的value 3 if (key == null) 4 return getForNullKey(); 5 // 根据该 key 的 hashCode 值计算它的 hash 码 6 int hash = hash(key.hashCode()); 7 // 取出 table 数组中指定索引处的值 8 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { 9 Object k; 10 //若搜索的key与查找的key相同,则返回相对应的value 11 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 12 return e.value; 13 } 14 return null; 15 }
在这里能够根据key快速的取到value除了和HashMap的数据结构密不可分外,还和Entry有莫大的关系,在前面就提到过,HashMap在存储过程中并没有将key,value分开来存储,而是当做一个整体key-value来处理的,这个整体就是Entry对象。同时value也只相当于key的附属而已。在存储的过程中,系统根据key的hashcode来决定Entry在table数组中的存储位置,在取的过程中同样根据key的hashcode取出相对应的Entry对象。
8. 多线程下的HashMap出现的问题
-
多线程put操作后,get操作导致死循环导致cpu100%的现象。主要是多线程同时put时,如果同时触发了rehash操作,会导致扩容后的HashMap中的链表中出现循环节点进而使得后面get的时候,会死循环。
-
多线程put操作,导致数据丢失,也是发生在个线程对hashmap 扩容时。
JDK1.8之HashMap
JDK1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等;
1. 数据结构
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。
在Jdk1.7中存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。本文不再对红黑树展开讨论,想了解更多红黑树数据结构的工作原理可以参考初步了解红黑树
2. 功能与方法
2.1. 确定Hash桶数组索引位置
源码如下:
1 方法一: 2 static final int hash(Object key) { //jdk1.8 & jdk1.7 3 int h; 4 // h = key.hashCode() 为第一步 取hashCode值 5 // h ^ (h >>> 16) 为第二步 高位参与运算 6 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 7 } 8 方法二: 9 static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的 10 return h & (length-1); //第三步 取模运算 11 }
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
举例说明,其中n为table的长度。
补充点知识:
bucket 单词意思:桶;
Entry 内部类;
“>> 右移,高位补符号位” 这里右移一位表示除2;
“>>> 无符号右移,高位补0”; 与>>类似“;
<< 左移” 左移一位表示乘2,二位就表示4,就是2的n次方;
^ 异或:相同为0,不同为1
2.2. put()方法(含红黑树)
JDK1.8HashMap的put方法执行过程可以通过下图来理解:
这里对每一步进行一下解释:
-
①.判断键值对数组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方法源码如下:
1 public V put(K key, V value) { 2 //hash()方法在上面已经出现过了,就不贴了 3 return putVal(hash(key), key, value, false, true); 4 } 5 6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 7 boolean evict) { 8 Node<K, V>[] tab; 9 Node<K, V> p; 10 int n, i; 11 // 步骤①:tab为空则创建 12 if ((tab = table) == null || (n = tab.length) == 0) 13 n = (tab = resize()).length; 14 // 步骤②:计算index,并对null做处理 15 if ((p = tab[i = (n - 1) & hash]) == null) 16 tab[i] = newNode(hash, key, value, null); 17 else { 18 Node<K, V> e; 19 K k; 20 // 步骤③:节点key存在,直接覆盖value 21 if (p.hash == hash && 22 ((k = p.key) == key || (key != null && key.equals(k)))) 23 e = p; 24 // 步骤④:判断该链为红黑树 25 else if (p instanceof TreeNode) 26 e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value); 27 // 步骤⑤:该链为链表 28 else { 29 for (int binCount = 0; ; ++binCount) { 30 if ((e = p.next) == null) { 31 p.next = newNode(hash, key, value, null); 32 //链表长度大于8转换为红黑树进行处理 TREEIFY_THRESHOLD = 8 33 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 34 treeifyBin(tab, hash); 35 break; 36 } 37 // key已经存在直接覆盖value 38 if (e.hash == hash && 39 ((k = e.key) == key || (key != null && key.equals(k)))) 40 break; 41 p = e; 42 } 43 } 44 if (e != null) { // existing mapping for key 45 V oldValue = e.value; 46 if (!onlyIfAbsent || oldValue == null) 47 e.value = value; 48 afterNodeAccess(e); 49 return oldValue; 50 } 51 } 52 ++modCount; 53 // 步骤⑥:超过最大容量 就扩容 threshold:单词解释--阈(yu)值,不念阀(fa)值!顺便学下语文咯。 54 if (++size > threshold) 55 resize(); 56 afterNodeInsertion(evict); 57 return null; 58 }
2.3. 扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。
由于过程比较复杂,在这里我只说结论吧,具体过程可以参考Java 8系列之重新认识HashMap 。
结论就是:由于我们使用的是2次幂的扩展(指长度扩为原来2倍),经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置 ,即就是下图中红圈圈内+16的解释。oldCap表示原来的容量大小。
如图示:
【与Jdk1.7的区别】
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了(对应上图中红色字体0/1),是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
解释一下这个图:
这个图就是扩容前后的图的对比,以下标值15的链表为例,展现数组扩容之后,旧链表是如何在新数组上分配位置的。
扩容之后,上面的那个与运算(这个没体现出来,请参考原博文),最高位多个1,然后对应的hashcode值,会被多取一位。这一位要么是0,要么是1。0的话,还是在数组下标的老位置15,要是1的话,就在原来下标的位置上+个扩容的size,即15+16=31。
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
2.4. 线程安全性
HashMap是线程不安全的,在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。
同1.7中描述
小结:
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。
(5) 还没升级JDK1.8的,现在开始升级吧。HashMap的性能提升仅仅是JDK1.8的冰山一角。
参考:
3. 其他若干问题
3.1. 为什么用数组+链表?
数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到.链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。
这里的hash值并不是指hashcode,在Java1.8中是将hashcode高低十六位异或过的。
3.2. 解决Hash冲突的方法?
主要的有四种:(1)开放定址法(2)链地址法(3)再哈希法(4)公共溢出区域法
详见:
3.3. 用LinkedList代替数组行吗?行的话为什么不用?
源码中:Entry就是一个链表节点。
Entry[] table = new Entry[capacity];
那我用下面这样表示:
List<Entry> table = new LinkedList<Entry>();
是否可行?
答案是肯定的,必须是可以的。既然是可以的,为什么HashMap不用LinkedList,而选用数组?
因为用数组效率最高!因为,在HashMap中,定位桶的位置是利用元素的key的hash值对数组长度取模得到。此时,我们已得到桶的位置。显然数组的查找效率比LinkedList高。
那ArrayList,底层也是数组,查找也快啊,为啥不用ArrayList?
因为采用基本数组结构,扩容机制可以自己定义,HashMap中数组扩容刚好是2的次幂,在做取模运算的效率高。而ArrayList的扩容机制是1.5倍扩容 。
3.4.HashMap在什么条件下扩容?
如果bucket满了(超过load factor*current capacity),就要resize;load factor为0.75,为了最大程度避免哈希冲突;current capacity为当前数组大小。
3.5. 为什么扩容是2的次幂?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length。但是,大家都知道这种运算不如位移运算快。因此,源码中做了优化hash&(length-1)。也就是说hash%length==hash&(length-1)那为什么是2的n次方呢?因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。
3.6. 为什么为什么要先高16位异或低16位再取模运算?
上面已经介绍过了,Jdk1.8中源码:
1 static final int hash(Object key) { //jdk1.8 & jdk1.7 2 int h; 3 // h = key.hashCode() 为第一步 取hashCode值 4 // h ^ (h >>> 16) 为第二步 高位参与运算 5 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 6 }
HashMap这么做,只是为了降低hash冲突的几率。
3.7. 讲讲hashmap的get/put的过程?
这问题问的的好歹毒啊。
-
知道hashmap中put元素的过程是什么样么?
对key的hashCode()做hash运算,计算index;如果没碰撞直接放到bucket里;如果碰撞了,以链表的形式存在buckets后;如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树(JDK1.8中的改动);如果节点已经存在就替换old value(保证key的唯一性)如果bucket满了(超过load factor*current capacity),就要resize。
-
知道hashmap中get元素的过程是什么样么?
对key的hashCode()做hash运算,计算index;如果在bucket里的第一个节点里直接命中,则直接返回;如果有冲突,则通过key.equals(k)去查找对应的Entry;
需要注意的是:
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。
3.8. 你还知道哪些hash算法?
先说一下hash算法干嘛的,Hash函数是指把一个大范围映射到一个小范围。把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。即输入域无穷,输出域有限,这也是hash冲突的根本原因。
比较出名的有MurmurHash、MD4、MD5等等。
详情见哈希函数&MD5原理(比较冗长详细) 和hash算法原理之md5过程(精简)
3.9. 说说String中hashcode的实现?(此题很多大厂问过)
源码部分字段和hashCode()方法如下:
1 /** The value is used for character storage. */ 2 private final char value[]; 3 4 /** Cache the hash code for the string */ 5 private int hash; // Default to 0 6 7 /** 8 * Returns a hash code for this string. The hash code for a 9 * {@code String} object is computed as 10 * <blockquote><pre> 11 * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] 12 * </pre></blockquote> 13 * using {@code int} arithmetic, where {@code s[i]} is the 14 * <i>i</i>th character of the string, {@code n} is the length of 15 * the string, and {@code ^} indicates exponentiation. 16 * (The hash value of the empty string is zero.) 17 * 18 * @return a hash code value for this object. 19 */ 20 public int hashCode() { 21 int h = hash; 22 if (h == 0 && value.length > 0) { 23 char val[] = value; 24 25 for (int i = 0; i < value.length; i++) { 26 h = 31 * h + val[i]; 27 } 28 hash = h; 29 } 30 return h; 31 }
String类中的hashCode计算方法还是比较简单的,就是:以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。
哈希计算公式可以计为 s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1] ;
那为什么以31为质数呢?
主要是因为31是一个奇质数,所以 31*i=32*i-i=(i<<5)-i ,这种位移与减法结合的计算相比一般的运算快很多。
3.10. 为什么hashmap的在链表元素数量超过8时改为红黑树?
-
知道jdk1.8中hashmap改了啥么?
-
为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
-
我不用红黑树,用二叉查找树可以么?
-
那为什么阀值是8呢?
-
当链表转为红黑树后,什么时候退化为链表?
3.10.1. 知道jdk1.8中hashmap改了啥么?
-
由数组+链表的结构改为数组+链表+红黑树。
-
优化了高位运算的hash算法:h^(h>>>16)
-
扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。这一条是重点,因为最后一条的变动,hashmap在1.8中,不会在出现死循环问题。
3.10.2. 为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
3.10.3. 我不用红黑树,用二叉查找树可以么? 为什么有了二叉树、平衡二叉树还需要红黑树?
可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。
此处还可以问你二叉树、平衡二叉树以及红黑树之间的区别与联系。基本定义我就不说了,在这里直接上区别与联系,其实这三者是循循递进的关系,后一个的出现总是以解决前一个的缺点应运而生的。
简单滴说:
-
二叉树的缺点:出现极端情况:退化成链表,复杂度由O(logN)变成O(N),故,为了改进,出现了平衡二叉树;
-
平衡二叉树的缺点:虽然平衡树解决了二叉查找树退化为近似链表的缺点,能够把查找时间控制在 O(logn),不过却不是最佳的,因为平衡树要求每个节点的左子树和右子树的高度差至多等于1,这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
-
显然,如果在那种插入、删除很频繁的场景中,平衡树需要频繁着进行调整,这会使平衡树的性能大打折扣,为了解决这个问题,于是有了红黑树,由于红黑树的各种特点(自己查找),使得它能够在最坏情况下,也能在 O(logn) 的时间复杂度查找到某个节点。
与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因。
总结:单单在查找方面的效率的话,平衡树比红黑树快,也可以说,红黑树是一种不大严格的平衡树。
所以,最后的答案是,平衡树是为了解决二叉查找树退化为链表的情况,而红黑树是为了解决平衡树在插入、删除等操作需要频繁调整的情况。
3.10.4. 那为什么阀值是8呢?
不知道,等jdk作者来回答。这道题,网上能找到的答案都是扯淡。
jdk作者选择8,一定经过了严格的运算,觉得在长度为8的时候,与其保证链表结构的查找开销,不如转换为红黑树,改为维持其平衡开销。
3.10.5. 当链表转为红黑树后,什么时候退化为链表? 为什么是6而不是7?
为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换,作为缓冲。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
3.11. HashMap的并发问题?
-
HashMap在并发编程环境下有什么问题啊?
-
在jdk1.8中还有这些问题么?
-
你一般怎么解决这些问题的?
3.11.1. Jdk1.7中HashMap在并发编程环境下有什么问题啊?在jdk1.8中还有这些问题么?如何解决?
(1)、多线程扩容,引起的死循环问题
(2)、多线程put的时候可能导致元素丢失
(3)、put非null元素后get出来的却是null
在jdk1.8中还有这些问题么?在jdk1.8中,死循环问题已经通过在原位置再移动2次幂的位置 来解决。其他两个问题还是存在。
解决方法:使用ConcurrentHashmap,Hashtable等线程安全等集合类。
3.12. key可以为null嘛?你一般用什么作为HashMap的key?
-
键可以为Null值么?
-
你一般用什么作为HashMap的key?
-
我用可变类当HashMap的key有什么问题?
-
如果让你实现一个自定义的class作为HashMap的key该如何实现?
key为null的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置。
Jdk1.8源码如下:
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//高位参与运算 4 }
3.12.1. 我用可变类当HashMap的key有什么问题?
一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
-
(1)、因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
-
(2)、因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
当使用可变类时,hashcode可能发生改变,导致put进去的值,无法get出,如下所示:
1 HashMap<List<String>, Object> changeMap = new HashMap<>(); 2 List<String> list = new ArrayList<>(); 3 list.add("hello"); 4 Object objectValue = new Object(); 5 changeMap.put(list, objectValue); 6 System.out.println(changeMap.get(list)); 7 list.add("hello world");//hashcode发生了改变 8 System.out.println(changeMap.get(list));
输出如下:
1 java.lang.Object@74a14482 2 null
3.12.2. 如果让你实现一个自定义的class作为HashMap的key该如何实现?
此题考察两个知识点
-
重写hashcode和equals方法注意什么?
-
如何设计一个不变类;
针对问题一,记住下面四个原则即可
(1)两个对象相等,hashcode一定相等;
(2)两个对象不等,hashcode不一定不等;
(3)hashcode相等,两个对象不一定相等;
(4)hashcode不等,两个对象一定不等;
针对问题二,记住如何写一个不可变类
(1)类添加final修饰符,保证类不被继承。如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变;
(2)保证所有成员变量必须私有,并且加上final修饰通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足;
(3)不提供改变成员变量的方法,包括setter避免通过其他接口改变成员变量的值,破坏不可变特性;
(4)通过构造器初始化所有成员,进行深拷贝(deep copy)如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如:
1 public final class ImmutableDemo { 2 private final int[] myArray; 3 public ImmutableDemo(int[] array) { 4 this.myArray = array; // wrong 5 } 6 }
这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。
为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法:
1 public final class MyImmutableDemo { 2 private final int[] myArray; 3 public MyImmutableDemo(int[] array) { 4 this.myArray = array.clone(); 5 } 6 }
(5)在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。
参考:面试必考的HashMap
Hashtable
1. Hashtable原理
与HashMap基本一致;
2. HashMap与Hashtable的五点区别
-
-
Hashtable是线程安全的,方法是synchronized的,适合在多线程下使用,效率稍低;
-
HashMap是线程不安全的,方法不是synchronized的,适合在单线程下使用,效率稍高;
-
Hashtable效率低的原因?
在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访间HashTable的同步方法时,访问其他同步方法的线程就可能会进入阻塞或者轮训状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈改率越低。
-
-
HashMap的key和value都可以为null值;
-
Hashtable 的key和value都不允许为 Null值;
-
-
-
HashMap中数组的默认大小是16,而且一定是2的倍数,扩容后的数組长度是之前数组长度的2倍。
-
-
HashTable中数组默认大小是11,扩容后的数组长度是之前数组长度的2倍+1。
-
Hash值的使用不同:
-
HashMap重新计算了Hash值,而且用&代替%求模;
-
Hashtable直接使用对象的hashcode;
-
-
判断是否含某个键的方式
-
HashMap中只能用containskey()来判断是否含键,不能用get()方法:因为HashMap中可以存放null(只有一个键为null,肯有一个或多个value为null)。当get()方法返回null值时,既可以表示HashMap中没有该键,也可以表示该键所对应的值为null。因此,在HashMap中不能用get()方法来判断HashMap中是否存在某个键。
-
Hashtable的键值都不能为null,所以可以用get()方 法来判断是否含有某个键。
Over...
【参考】
向文章中列出的链接致谢!