1.hashmap1.7结构图
总结:
简单来说,HashMap1.7由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
2.Entry的成员属性
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/**
* This method is invoked whenever the value in an entry is
* overwritten by an invocation of put(k,v) for a key k that's already
* in the HashMap.
*/
void recordAccess(HashMap<K,V> m) {
}
/**
* This method is invoked whenever the entry is
* removed from the table.
*/
void recordRemoval(HashMap<K,V> m) {
}
}
3.我们先看作者对hashmap1.7的解释
/**
* 基于哈希表的<tt>Map</tt> 接口实现。这个
* 实现提供了所有可选的地图操作,并允许
* <tt>null</tt> 值和 <tt>null</tt> 键。 (<tt>HashMap</tt>
* class 大致相当于<tt>Hashtable</tt>,除了它是
* 不同步并允许空值。)此类不保证
* 地图的顺序;特别是,它不保证订单
* 将随着时间的推移保持不变。
*
* <p>这个实现为基本的提供了恒定时间的性能
* 操作(<tt>get</tt> 和 <tt>put</tt>),假设散列函数
* 在桶中正确分散元素。迭代
* 集合视图需要与“容量”成正比的时间
* <tt>HashMap</tt> 实例(桶的数量)加上它的大小(数量
* 键值映射)。因此,不要设置初始值是非常重要的
* 如果迭代性能太低,容量太高(或负载因子太低)
* 重要的。
*
* <p><tt>HashMap</tt> 的一个实例有两个影响它的参数
* 性能:<i>初始容量</i>和<i>负载系数</i>。这
* <i>capacity</i> 是哈希表中的桶数,初始
* 容量只是创建哈希表时的容量。这
* <i>load factor</i> 是衡量哈希表允许达到多满的指标
* 在其容量自动增加之前获取。当数
* 哈希表中的条目超过负载因子和
* 当前容量,哈希表<i>重新哈希</i>(即内部数据
* 结构被重建),因此哈希表大约有两倍的
* 桶数。
*
* <p>作为一般规则,默认负载因子 (.75) 提供了一个很好的权衡
* 时间和空间成本之间。较高的值会减少空间开销
* 但增加了查找成本(体现在大部分操作
* <tt>HashMap</tt> 类,包括<tt>get</tt> 和<tt>put</tt>)。这
* 应采用地图中的预期条目数及其负载因子
* 在设置其初始容量时考虑在内,以尽量减少
* 重新哈希操作的次数。如果初始容量更大
* 比最大条目数除以负载因子,没有
* 重新哈希操作将永远发生。
*
* <p>如果许多映射要存储在一个 <tt>HashMap</tt> 实例中,
* 以足够大的容量创建它将使映射能够
* 存储比让它执行自动重新散列更有效
* 需要扩大表。
*
* <p><strong>请注意,此实现不是同步的。</strong>
* 如果多个线程同时访问一个哈希映射,并且至少有一个
* 线程在结构上修改地图,它<i>必须</i>
* 外部同步。 (结构修改是任何操作
* 添加或删除一个或多个映射;仅仅改变价值
* 与实例已经包含的键关联的不是
* 结构修改。)这通常由
* 同步一些自然封装地图的对象。
*
* 如果不存在这样的对象,则应使用
* {@link Collections#synchronizedMap Collections.synchronizedMap}
* 方法。这最好在创建时完成,以防止意外
* 对地图的非同步访问:<pre>
* Map m = Collections.synchronizedMap(new HashMap(...));</pre>
*
* <p>该类的所有“集合视图方法”返回的迭代器
* 是 <i>fail-fast</i>:如果地图在之后的任何时间进行了结构修改
* 迭代器以任何方式创建,除了通过迭代器自己的
* <tt>remove</tt> 方法,迭代器会抛出一个
* {@link ConcurrentModificationException}。因此,面对并发
* 修改,迭代器快速干净地失败,而不是冒险
* 在不确定的时间任意的、非确定性的行为
* 未来。
*
* <p>注意不能保证迭代器的快速失败行为
* 因为一般来说,不可能做出任何硬性保证
* 存在不同步的并发修改。快速失败迭代器
* 尽最大努力抛出 <tt>ConcurrentModificationException</tt>。
* 因此,编写依赖于此的程序是错误的
* 其正确性的例外:<i>迭代器的快速失败行为
* 应仅用于检测错误。</i>
*
* <p>这个类是
* <a href="{@docRoot}/../technotes/guides/collections/index.html">
* Java 集合框架</a>。
*
* @param <K> 此映射维护的键类型
* @param <V> 映射值的类型
*
* @author 道格利亚
* @author 乔什·布洛赫
* @作者亚瑟范霍夫
* @作者尼尔·加夫特
* @see Object#hashCode()
* @see 集合
* @见地图
* @see TreeMap
* @see 哈希表
* @自 1.2
*/
4.HashMap中定义的常量
/**
* 默认初始容量 - 必须是 2 的幂。
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* 最大容量,如果隐式指定了更高的值,则使用
* 通过任何一个带参数的构造函数。
* 必须是 2 的幂 <= 1<<30。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 在构造函数中未指定时使用的负载因子。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 表未创建时共享的空表实例。
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* 表格,根据需要调整大小。长度必须始终是 2 的幂。
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 此映射中包含的键值映射数。也就是有多少key-value对
*/
transient int size;
/**
* 要调整大小的下一个大小值(容量 * 负载因子)。
* 如果 table == EMPTY_TABLE 那么这是初始容量
* 表将在扩容resize()时创建。
*/
int threshold;
/**
* 哈希表的负载因子。
*/
final float loadFactor;
/**
* 这个HashMap结构被修改的次数
* 结构修改是指那些改变映射数量的修改
* HashMap 或以其他方式修改其内部结构(例如,
* 重新哈希)。 此字段用于在集合视图上制作迭代器
* HashMap 快速失败。 (请参阅 ConcurrentModificationException)。
*/
transient int modCount;
/**
* 地图容量的默认阈值,高于该阈值的替代散列是
* 用于字符串键。 替代哈希降低了发生率
* 由于字符串键的哈希码计算较弱而导致的冲突。
* <p/>
* 这个值可以通过定义系统属性来覆盖
* {@code jdk.map.althashing.threshold}。 {@code 1} 的属性值
* 强制始终使用替代散列,而
* {@code -1} 值确保永远不会使用替代散列。
*/
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
/**
* 与此实例关联的随机化值应用于
* 键的散列码使散列冲突更难找到。 如果 0 那么
* 替代散列被禁用。
*/
transient int hashSeed = 0;
5.构造方法
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
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);
//加载因子赋值0.75
this.loadFactor = loadFactor;
//扩容临界值为16
threshold = initialCapacity;
init();
}
/**
* 子类的初始化钩子。这个方法被调用
* 在所有构造函数和伪构造函数(clone, readObject)
* 在HashMap被初始化之后但在任何条目被插入之前
* 被插入。 (在没有这个方法的情况下,readObject会
* 需要对子类有明确的了解)。
*/
void init() {
}
6.put()方法解析
public V put(K key, V value) {
//表格为空
if (table == EMPTY_TABLE) {
//初始化数组table
inflateTable(threshold);
}
//key为null放在table[0]的位置
if (key == null)
return putForNullKey(value);
//计算当前key的hash值
int hash = hash(key);
//计算存储桶的位置
int i = indexFor(hash, table.length);
//判断table[i]是否已经有元素,如果有,就判断是否需要更新
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断hash值和key是否相同
//== 比较的是内存地址;equals比较的是内存中的值是否相同
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//如果key相同,就做更新操作
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
返回旧的值
return oldValue;
}
}
//修改次数+1
modCount++;
//添加Entry
addEntry(hash, key, value, i);
//如果不是更新,而是插入返回null
return null;
}
/**
* 检索对象哈希码并将补充哈希函数应用于
* 结果散列,它可以防止质量差的散列函数。 这是
* 关键,因为 HashMap 使用长度为 2 的哈希表,即
* 否则会遇到相同的 hashCode 冲突
* 在低位。 注意:空键总是映射到哈希 0,因此索引 0。
*/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* 初始化数组table
*/
private void inflateTable(int toSize) {
// 如果表格为空时,默认的哈希表容量时threshold临界值的两倍
int capacity = roundUpToPowerOf2(toSize);
//临界值为
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
//roundUpToPowerOf2(toSize);
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
/**
* hashmap是允许null键null值的,但是null值null键放在table[0]位置上,不会改变
*/
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//更新操作
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
/**
* 添加具有指定键、值和哈希码的新条目到
* 指定的桶。 这是这个的责任
* 调整表格大小的方法(如果合适)。
*
* 子类覆盖它以改变 put 方法的行为。
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果达到扩容的临界值就扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//重新定义数组,是原有数组的两倍
resize(2 * table.length);
//重新计算hash值
hash = (null != key) ? hash(key) : 0;
//重新计算新table中桶的位置
bucketIndex = indexFor(hash, table.length);
}
//如果不需要扩容,创建Entry对象,放在原有的table中
createEntry(hash, key, value, bucketIndex);
}
/**
* 将此映射的内容重新散列到一个新数组中
* 更大的容量。 这个方法在调用的时候自动调用
* 此映射中的键数达到其阈值。
*
* 如果当前容量为 MAXIMUM_CAPACITY,则此方法不
* 调整地图大小,但将阈值设置为 Integer.MAX_VALUE。
* 这具有防止将来调用的效果。
*
* @param newCapacity 新容量,必须是二的幂;
* 必须大于当前容量,除非当前
* 容量为 MAXIMUM_CAPACITY(在这种情况下值
* 无关紧要)。
*/
void resize(int newCapacity) {
//因为要扩容,所以把原有的table赋值给oldTable
Entry[] oldTable = table;
//原有table的容量
int oldCapacity = oldTable.length;
//如果容量已经达到最大值值了,不调整改tbale的大小直接返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建新的数组容量是newCapacity=2*oldCapacity
Entry[] newTable = new Entry[newCapacity];
//新的table有了,接下来就是转移旧table中的数据到新的table中oldTable->newTable
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* 返回哈希码 h 的索引。
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
/**
* 与 addEntry 类似,只是在创建条目时使用此版本
* 作为地图构建或“伪构建”(克隆、
* 反序列化)。 此版本无需担心调整表格大小。
*
* 子类覆盖它以改变 HashMap(Map) 的行为,
* 克隆和读取对象。
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
//把e指向table[bucketIndex]桶的位置
Entry<K,V> e = table[bucketIndex];
//这里就是jdk1.7头插法的体现,他把上一步的e放在新Entry的next属性上
table[bucketIndex] = new Entry<>(hash, key, value, e);
//当前table存储的size+1
size++;
}
/**
* 将所有条目从当前表转移到新表。
*/
void transfer(Entry[] newTable, boolean rehash) {
//新table的容量
int newCapacity = newTable.length;
//遍历旧的table,取出每一个元素
for (Entry<K,V> e : table) {
//这里用死循环,因为hashmap是数组+链表,所以这一步就是取出当前拉链上的每一个值
while(null != e) {
//!!!!!这里可能导致线程不安全也就是hashmap1.7循环链表的问题,后续讲解
//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]的位置
e.next = newTable[i];
//把e放在i位置上,这里其实就是向下移动的一个操作,后面画图更好理解
newTable[i] = e;
//把next赋值给e,继续循环,直到 while(null != e),也就是当前桶的链表的最后一个位置被取出为止
e = next;
}
}
}
总结:这里陈述了整个put()方法的过程,并且如何初始化table,达到临界值如何扩容,以及扩容后旧数组中的值迁移到新的table中,当然这个扩容不是线程安全的,后面剖析
7.get()方法解析
/**
* map.get()方法。
*/
public V get(Object key) {
//如果key=null默认就是table[0]位置的值
if (key == null)
return getForNullKey();
//根据key查询entry
Entry<K,V> entry = getEntry(key);
//返回值,如果没有返回null
return null == entry ? null : entry.getValue();
}
/**
* 返回与指定键关联的条目
* 哈希映射。 如果 HashMap 不包含映射,则返回 null
* 为钥匙。
*/
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//计算hash值
int hash = (key == null) ? 0 : hash(key);
//遍历当前位置的链表,直到找到对应key的值
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//判断哈希值,key是否相同,相同返沪当前的节点值e
//== 比较的是内存地址;equals比较的是内存中的值是否相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
/**
* 卸载版本的 get() 以查找空键。 空键映射
* 到索引 0。这个空情况被拆分成单独的方法
* 出于性能考虑,最常用的两种
* 操作(get 和 put),但与条件合并
* 其他。
*/
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
总结:这里陈述get()方法所执行的操作,根据key计算哈希值,计算存储的位置index,然后在拉链中或者单个桶的位置获取映射的值,如果找不到返回null
8.hashmap1.7高频面试题
8.1 什么是哈希?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的, Hash就是指使用哈希算法是指把任意长度 的二进制映射为固定长度的较小的二进制值,这个较小的二进制值叫做哈希值。
8.2 什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希 碰撞)。
8.3 如何解决哈希冲突?
hashmap中是采用链表法解决的。链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;
8.4 HashMap的长度为什么是2的幂次方?
-
为了能让 HashMap 存取高效,尽量减少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树 长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
-
这个算法应该如何设计?
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的 前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效 率,这就解释了 HashMap 的长度为什么是2的幂次方。
-
在hash函数中为什么要用那么多的位运算和异或运算?
这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置 的随机性&均匀性,最终减少Hash冲突,
8.5 能否使用任何类作为Map的key?
可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:
如果类重写了 equals() 方法,也应该重写 hashCode() 方法。
类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的 性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关 的问题了。
8.6 为什么HashMap中String、Integer这样的包装类适合作为K?
String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减 少Hash碰撞的几率
都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况
内部已重写了 equals() 、 hashCode() 等方法,遵守了HashMap内部的规范(不清楚可以 去上面看看putValue的过程),不容易出现Hash值计算错误的情况;
8.7 如果使用Object作为HashMap的Key,应该怎么办呢?
重写 hashCode() 和 equals() 方法
重写 hashCode() 是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中 排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;
重写 equals() 方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用 值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
8.8 HashMap为什么不直接使用hashCode()处理后的哈希值直接作 为table的下标?
hashCode() 方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空 间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最 大值的,并且设备上也难以提供这么多的存储空间,从而导致通过 hashCode() 计算出的哈希值可 能不在数组大小范围内,进而无法匹配存储位置;
怎么解决?
-
HashMap自己实现了自己的 hash() 方法,通过扰动运算使得它自己的哈希值高低位进行异或运算,降低哈希碰撞概率,也使得数据分布更加均匀
-
在保证数组长度为2的幂次方的时候,使用 hash() 运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有 当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;
8.9 HashMap 与 HashTable 有什么区别?
-
线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本 都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap );
-
效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被 淘汰,不要在代码中使用它;(如果你要保证线程安全的话就使用 ConcurrentHashMap );
-
对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有 一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛 NullPointerException。
-
初始容量大小和每次扩充容量大小的不同 :
-
创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来 的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。
-
创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其 扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为 什么是2的幂次方。
9.hashmap1.7为什么线程不安全
hashmap1.7之所以线程不安全是发生在table扩容时,因为多个线程操作同一个table,所以就会发生多个线程共享同一个内存的线程安全的问题,在哪里会发生线程不安全呢?
我们看一下扩容迁移元素的代码
/**
* 将所有条目从当前表转移到新表。
*/
void transfer(Entry[] newTable, boolean rehash) {
//新table的容量
int newCapacity = newTable.length;
//遍历旧的table,取出每一个元素
for (Entry<K,V> e : table) {
//这里用死循环,因为hashmap是数组+链表,所以这一步就是取出当前拉链上的每一个值
while(null != e) {
//!!!!!这里可能导致线程不安全也就是hashmap1.7循环链表的问题,后续讲解
//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]的位置
e.next = newTable[i];
//把e放在i位置上,这里其实就是向下移动的一个操作,后面画图更好理解
newTable[i] = e;
//把next赋值给e,继续循环,直到 while(null != e),也就是当前桶的链表的最后一个位置被取出为止
e = next;
}
}
}
比如线程1和线程2执行到如下代码时,线程二挂起,线程一进行迁移
Entry<K,V> next = e.next;
形成循环链表的图解如下(也就是hashmap1.7的循环链表问题)
自己看一下这个图,你就会对1.7产生循环链表的原因会非常清晰
好了,后续再补充,今天先到这