HashMap三百问

喜你入骨 提交于 2019-11-28 06:13:37

文章目录:

一、JDK1.7之HashMap

二、JDK1.8之HashMap

三、Hashtable

JDK1.7之HashMap

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在该处没有元素,则直接保存。

这个过程看似比较简单,其实深有内幕。有如下几点:

  1. 先看迭代处。此处迭代原因就是为了防止存在相同的key值,若发现两个hash值(key)相同时,HashMap的处理方式是用新value替换旧value,这里并没有处理key,这就解释了HashMap中没有两个相同的key。

  1. 在看(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       }

 

此处的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出现的问题

  1. 多线程put操作后,get操作导致死循环导致cpu100%的现象。主要是多线程同时put时,如果同时触发了rehash操作,会导致扩容后的HashMap中的链表中出现循环节点进而使得后面get的时候,会死循环。

  2. 多线程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的冰山一角。

 

参考:

  1. HashMap和HashTable底层原理以及常见面试题

  2. jdk1.7_HashMap的实现原理

  3. jdk1.8_重看HashMap

 

3. 其他若干问题

3.1. 为什么用数组+链表?

数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到.链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。

这里的hash值并不是指hashcode,在Java1.8中是将hashcode高低十六位异或过的。

 

3.2. 解决Hash冲突的方法?

主要的有四种:(1)开放定址法(2)链地址法(3)再哈希法(4)公共溢出区域法

详见:

  1. 专门介绍拉链法

  2. java 解决Hash(散列)冲突的四种方法-

  3. 优缺点

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       }
View Code

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。

  1. Hash值的使用不同:

    • HashMap重新计算了Hash值,而且用&代替%求模;

    • Hashtable直接使用对象的hashcode;

  2. 判断是否含某个键的方式

  • HashMap中只能用containskey()来判断是否含键,不能用get()方法:因为HashMap中可以存放null(只有一个键为null,肯有一个或多个value为null)。当get()方法返回null值时,既可以表示HashMap中没有该键,也可以表示该键所对应的值为null。因此,在HashMap中不能用get()方法来判断HashMap中是否存在某个键。

  • Hashtable的键值都不能为null,所以可以用get()方 法来判断是否含有某个键。

  

Over...

 

【参考】

向文章中列出的链接致谢!

 

 

    

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!