透过面试题掌握HashMap【持续更新中】

  • 2020 年 5 月 15 日
  • 筆記

PS:最近做了一个面试题解答的开源项目,大家可以看一看,如果对大家有帮助,希望大家帮忙给一个star,谢谢大家了!

《面试指北》项目地址://github.com/NotFound9/interviewGuide


之前建了一个技术交流群,大家感兴趣也可以进一下,希望可以和大家一起学习进步!

本文主要是自己阅读了HashMap和ConcurrentHashMap源码及一些Java容器类相关的博客后,找了一些很多面经中涉及到的Java容器相关的面试题,自己全部手写的解答,也花了一些流程图,之后会继续更新这一部分。

HashMap相关的面试题

1.HashMap添加一个键值对的过程是怎么样的?

2.ConcurrentHashMap添加一个键值对的过程是怎么样的?

3.HashMap与HashTable,ConcurrentHashMap的区别是什么?

4.HashMap扩容后是否需要rehash?

5.HashMap扩容是怎样扩容的,为什么都是2的N次幂的大小?

6.ConcurrentHashMap是怎么记录元素个数size的?

7.为什么ConcurrentHashMap,HashTable不支持key,value为null?

8.HashSet和HashMap的区别?

9.HashMap遍历时删除元素的有哪些实现方法?

HashMap添加一个键值对的过程是怎么样的?

流程图如下:

1.初始化table

判断table是否为空或为null,否则执行resize()方法(resize方法一般是扩容时调用,也可以调用来初始化table)。

2.计算hash值

根据键值key计算hash值。(因为hashCode是一个int类型的变量,是4字节,32位,所以这里会将hashCode的低16位与高16位进行一个异或运算,来保留高位的特征,以便于得到的hash值更加均匀分布)

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

3.插入或更新节点

根据(n – 1) & hash计算得到插入的数组下标i,然后进行判断

table[i]==null

那么说明当前数组下标下,没有hash冲突的元素,直接新建节点添加。

table[i].hash == hash &&(table[i]== key || (key != null && key.equals(table[i].key)))

判断table[i]的首个元素是否和key一样,如果相同直接更新value。

table[i] instanceof TreeNode

判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对。

其他情况

上面的判断条件都不满足,说明table[i]存储的是一个链表,那么遍历链表,判断是否存在已有元素的key与插入键值对的key相等,如果是,那么更新value,如果没有,那么在链表末尾插入一个新节点。插入之后判断链表长度是否大于8,大于8的话把链表转换为红黑树。

4.扩容

插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(一般是数组长度*负载因子0.75),如果超过,进行扩容。

源代码如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // tab为空则创建 
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算index,并对null做处理  
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        Node<K,V> e; K k;
        // 节点key存在,直接覆盖value 
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录
                e = p;
        // 判断该链为红黑树 
        // hash值不相等,即key不相等;为红黑树结点
        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);
                    // 结点数量达到阈值,转化为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                // 判断链表中结点的key值与插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        // 表示在桶中找到key值、hash值与插入元素相等的结点
        if (e != null) { 
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    ++modCount;
    // 超过最大容量 就扩容 
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

ConcurrentHashMap添加一个键值对的过程是怎么样的?

这是我自己流程图如下所示:

1.判断null值

判断key==null 或者 value == null,如果是,抛出空指针异常。

2.计算hash

根据key计算hash值(计算结果跟HashMap是一致的,写法不同)。

3.进入for循环,插入或更新元素

  • 3.1 tabnull || tab.length0,

    说明当前tab还没有初始化。

    那么调用initTable()方法初始化tab。(在initTable方法中,为了控制只有一个线程对table进行初始化,当前线程会通过CAS操作对SIZECTL变量赋值为-1,如果赋值成功,线程才能初始化table,否则会调用Thread.yield()方法让出时间片)。

  • 3.2 f ==null

    (Node<K,V> f根据hash值计算得到数组下标,下标存储的元素,f可能是null,链表头节点,红黑树根节点或迁移标志节点ForwardNode)

    说明当前位置还没有哈希冲突的键值对。

    那么根据key和value创建一个Node,使用CAS操作设置在当前数组下标下,并且break出for循环。

  • 3.3 f != null && f.hash = -1

    说明ConcurrentHashMap正在在扩容,当前的节点f是一个标志节点,当前下标存储的hash冲突的元素已经迁移了。

    那么当前线程会调用helpTransfer()方法来辅助扩容,扩容完成后会将tab指向新的table,然后继续执行for循环。

  • 3.4 除上面三种以外情况

    说明f节点是一个链表的头结点或者是红黑树的根节点,那么对f加sychronize同步锁,然后进行以下判断:

    • f.hash > 0

      如果是f的hash值大于0,当前数组下标存储的是一个链表,f是链表的头结点。

      对链表进行遍历,如果有节点跟当前需要插入节点的hash值相同,那么对节点的value进行更新,否则根据key,value创建一个Node<K,V>,添加到链表末尾。

    • f instanceof TreeBin

      如果f是TreeBin类型,那么说明当前数组下标存储的是一个红黑树,f是红黑树的根节点,调用putTreeVal方法,插入或更新节点。

    插入完成后,判断binCount(数组下标存储是一个链表时,binCount是链表长度),当binCount超过8时,并且数组的长度大于64时,那么调用treeifyBin方法将链表转换为红黑树。最后break出for循环。

PS:

很多技术文章都是说链表长度大于8就转换为红黑树,我当时也没有注意这个细节,直到有个群里的朋友指正,当原来的链表长度超过8时,确实会调用treeifyBin方法,但是在treeifyBin方法中会判断当前tab是否为空,或者数组长度是否小于64,如果满足条件,那么调用resize方法对tab初始化或者扩容,就不会将链表转换为红黑树了。

添加键值对的putVal方法的源码:

treeifyBin方法的源码:
MIN_TREEIFY_CAPACITY是64

4.判断是否需要扩容

调用addCount()对当前数组长度加1,在addCount()方法中,会判断当前元素个数是否超过sizeCtl(扩容阈值,总长度*0.75),如果是,那么会进行扩容,如果正处于扩容过程中,当前线程会辅助扩容。

HashMap与HashTable,ConcurrentHashMap的区别是什么?

主要从底层数据结构,线程安全,执行效率,是否允许Null值,初始容量及扩容,hash值计算来进行分析。

1.底层数据结构

    transient Node<K,V>[] table; //HashMap
    
    transient volatile Node<K,V>[] table;//ConcurrentHashMap
    
    private transient Entry<?,?>[] table;//HashTable

HashMap=数组+链表+红黑树

HashMap的底层数据结构是一个数组+链表+红黑树,数组的每个元素存储是一个链表的头结点,链表中存储了一组哈希值冲突的键值对,通过链地址法来解决哈希冲突的。为了避免链表长度过长,影响查找元素的效率,当链表的长度>8时,会将链表转换为红黑树,链表的长度<6时,将红黑树转换为链表。之所以临界点为8是因为红黑树的查找时间复杂度为logN,链表的平均时间查找复杂度为N/2,当N为8时,logN为3,是小于N/2的,正好可以通过转换为红黑树减少查找的时间复杂度。

Hashtable=数组+链表

Hashtable底层数据结构跟HashMap一致,底层数据结构是一个数组+链表,也是通过链地址法来解决冲突,只是链表过长时,不会转换为红黑树来减少查找时的时间复杂度。Hashtable属于历史遗留类,实际开发中很少使用。

ConcurrentHashMap=数组+链表+红黑树

ConcurrentHashMap底层数据结构跟HashMap一致,底层数据结构是一个数组+链表+红黑树。只不过使用了volatile来进行修饰它的属性,来保证内存可见性(一个线程修改了这些属性后,会使得其他线程中对于该属性的缓存失效,以便下次读取时取最新的值)。

2.线程安全

HashMap 非线程安全

HashMap是非线程安全的。(例如多个线程插入多个键值对,如果两个键值对的key哈希冲突,可能会使得两个线程在操作同一个链表中的节点,导致一个键值对的value被覆盖)

HashTable 线程安全

HashTable是线程安全的,主要通过使用synchronized关键字修饰大部分方法,使得每次只能一个线程对HashTable进行同步修改,性能开销较大。

ConcurrentHashMap 线程安全

ConcurrentHashMap是线程安全的,主要是通过CAS操作+synchronized来保证线程安全的。

CAS操作

往ConcurrentHashMap中插入新的键值对时,如果对应的数组下标元素为null,那么通过CAS操作原子性地将节点设置到数组中。

//这是添加新的键值对的方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
...其他代码
  if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) 				break; // 因为对应的数组下标元素为null,所以null作为预期值,new Node<K,V>(hash, key, value, null)作为即将更新的值,只有当内存中的值与即将预期值一致时,才会进行更新,保证原子性。
  }
...其他代码
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
synchronized锁

往ConcurrentHashMap中插入新的键值对时,如果对应的数组下标元素不为null,那么会对数组下标存储的元素(也就是链表的头节点)加synchronized锁, 然后进行插入操作,

Node<K,V> f = tabAt(tab, i = (n - 1) & hash));
synchronized (f) {//f就是数组下标存储的元素
    if (tabAt(tab, i) == f) {
        if (fh >= 0) {
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
                K ek;
                if (e.hash == hash &&
                    ((ek = e.key) == key ||
                     (ek != null && key.equals(ek)))) {
                    oldVal = e.val;
                    if (!onlyIfAbsent)
                        e.val = value;
                    break;
                }
                Node<K,V> pred = e;
                if ((e = e.next) == null) {
                    pred.next = new Node<K,V>(hash, key,
                                              value, null);
                    break;
                }
            }
        }
        else if (f instanceof TreeBin) {
            Node<K,V> p;
            binCount = 2;
            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                           value)) != null) {
                oldVal = p.val;
                if (!onlyIfAbsent)
                    p.val = value;
            }
        }
    }
}

3.执行效率

因为HashMap是非线程安全的,执行效率会高一些,其次是ConcurrentHashMap,因为HashTable在进行修改和访问时是对整个HashTable加synchronized锁,所以效率最低。

4.是否允许null值出现

HashMap的key和null都可以为null,如果key为null,那么计算的hash值会是0,最终计算得到的数组下标也会是0,所以key为null的键值对会存储在数组中的首元素的链表中。value为null的键值对也能正常插入,跟普通键值对插入过程一致。

static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

HashTable的键和值都不能为null,如果将HashTable的一个键值对的key设置为null,因为null值没法调用hashCode()方法获取哈希值,所以会抛出空指针异常。同样value为null时,在put方法中会进行判断,然后抛出空指针异常。

public synchronized V put(K key, V value) {
        // Make sure the value is not null
        if (value == null) {
            throw new NullPointerException();
        }
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        ...其他代码
}

ConcurrentHashMap的键和值都不能为null,在putVal方法中会进行判断,为null会抛出空指针异常。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    ...其他代码
}

5.初始容量及扩容

不指定初始容量

如果不指定初始容量,HashMap和ConcurrentHashMap默认会是16,HashTable的容量默认会是11。

不指定初始容量

如果制定了初始容量,HashMap和ConcurrentHashMap的容量会是比初始容量稍微大一些的2的幂次方大小,HashTable会使用初始容量,

扩容

扩容时,HashMap和ConcurrentHashMap扩容时会是原来长度的两倍,HashTable则是2倍加上1.

6.hash值计算

HashTable会扩容为2n+1,HashTable之所以容量取11,及扩容时是是2n+1,是为了保证 HashTable的长度是一个素数,因为数组的下标是用key的hashCode与数组的长度取模进行计算得到的,而当数组的长度是素数时,可以保证计算得到的数组下标分布得更加均匀,可以看看这篇文章//zhaox.github.io/algorithm/2015/06/29/hash

public synchronized V put(K key, V value) {
         ...其他代码
        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        ...其他代码
}

HashMap和ConcurrentHashMap的hash值都是通过将key的hashCode()高16位与低16位进行异或运算(这样可以保留高位的特征,避免一些key的hashCode高位不同,低位相同,造成hash冲突),得到hash值,然后将hash&(n-1)计算得到数组下标。(n为数组的长度,因为当n为2的整数次幂时,hash mod n的结果在数学上等于hash&(n-1),而且计算机进行&运算更快,所以这也是HashMap的长度总是设置为2的整数次幂的原因)

//HashMap计算hash值的方法
static int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
}
//ConcurrentHashMap计算hash值的方法 
static  int spread(int h) {//h是对象的hashCode
    return (h ^ (h >>> 16)) & HASH_BITS;// HASH_BITS = 0x7fffffff;
}
  

HashMap扩容后是否需要rehash?

在JDK1.8以后,不需要rehash,因为键值对的Hash值主要是根据key的hashCode()的高16位与低16位进行异或计算后得到,根据hash%length,计算得到数组的下标index,因为length是2的整数次幂,当扩容后length变为原来的两倍时,hash%(2*length)的计算结果结果差别在于第length位的值是1还是0,如果是0,那么在新数组中的index与旧数组的一直,如果是1,在新数组中的index会是旧数组中的数组中的index+length。

HashMap扩容是怎样扩容的,为什么都是2的N次幂的大小?

触发扩容

在没有指定初始长度的情况下,HashMap数组的默认长度为16,在添加一个新的键值对时,会调用putVal()方法,在方法中,成功添加一个新的键值对以后,会判断当前的元素个数是否超过阀值(数组长度*负载因子0.75),如果超过那么调用resize方法进行扩容。具体的扩容步骤如下:

计算扩容后的长度

  • 如果当前table为null

    那么直接初始化一个数组长度为16的数组返回。

  • 如果当前table的length已经大于HashMap指定的最大值2的30次方

    那么直接返回旧table,不进行扩容。

  • 其他情况

    将table的length扩容为2倍,然后计算新的扩容阀值(新数组长度*0.75)。

初始化新数组

会根据扩容后的数组长度初始化话一个新的数组,并且直接赋值给当前hashMap的成员变量table。

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

这一步就很有意思,也是HashMap是非线程安全的表现之一,因为此时newTab还是一个空数组,如果有其他线程访问HashMap,根据key去newTab中找键值对,会返回null。实际上可能key是有对应的键值对的,只不过键值对都保存在旧table中,还没有迁移过来。

(与之相反,HashTable在解决扩容时其他线程访问的问题,是通过对大部分方法使用sychronized关键字修饰,也就是某个线程在执行扩容方法时,会对HashTable对象加锁,其他线程无法访问HashTable。ConcurrentHashMap在解决扩容时其他线程访问的问题,是通过设置ForwardingNode标识节点来解决的,扩容时,某个线程对数组中某个下标下所有Hash冲突的元素进行迁移时,那么会将数组下标的数组元素设置为一个标识节点ForwardingNode,之后其他线程在访问时,如果发现key的hash值映射的数组下标对应是一个标识节点ForwardingNode(ForwardingNode继承于普通Node,区别字啊呀这个节点的hash值会设置为-1,并且会多一个指向扩容过程中新tab的指针nextTable),那么会根据ForwardingNode中的nextTable变量,去新的tab中查找元素。(如果是添加新的键值对时发现是ForwardingNode,那么辅助扩容或阻塞等待,扩容完成后去新数组中更新或插入元素)

迁移元素

因为HashMap的数组长度总是2的N次幂,扩容后也是变为原来的2倍,所以有一个数学公式,当length为2的N次幂时,

hash%length=hash&(length-1)

而因为length是2的N次幂,length-1在二进制中其实是N-1个1。例如:

length为16,length用2进制表示是10000,

length-1是15,用2进制表示是1111,

2*length为32,length用2进制表示是100000,

2*length-1为31,length用2进制表示是11111,

所以hash&(length-1)的计算结果与hash&(2*length-1)的计算结果差别在于扩容后需要多看一位,也就是看第N位的1与hash值的&结果。

假设原数组长度为16,length-1二进制表示为1111。key1的hash值为9,二进制表示为01001,key2的hash值为25,11001,

所以hash&(length-1)的结果只要看低4位的结果,9和25的低4位都是1001,所以计算结果一致,计算结果都是9,所以在数组中处于数组下标为9的元素链表中。

扩容后数组长度为32,length-1二进制表示为11111,key1的hash值为9,二进制表示为01001,key2的hash值为25,11001,

所以hash&(2*length-1)的结果需要看低5位的结果,9和25的低4位都是1001,所以计算结果不一致,计算结果都是9和25,因为key2的hash值的第五位为1,key1的hash值的第五位为0,所以会多16,也就是原数组长度的大小。

所以原数组同一下标index下的链表存储的hash冲突的元素,扩容后在新数组中的下标newIndex要么为index,要么为index+length(去决定于hash值的第N位为1,还是0,也就是hash&length的结果,原数组长度length为2的N-1次幂)

所以会遍历链表(或者红黑树),然后对数组下标index下每个节点计算hash&length的结果,然后存放在两个不同的临时链表中,遍历完成后,hash&length结果为0的元素组成的临时链表会存储在新数组index位置,hash&length结果为1的元素组成的临时链表会存储在新数组index+length位置。

ConcurrentHashMap是怎么记录元素个数size的?

HashMap默认是非线程安全的,可以认为每次只有一个线程来执行操作,所以hashMap就使用一个很简单的int类型的size变量来记录HashMap键值对数量就行了。

HashMap记录键值对数量的实现如下:

transient int size;
public int size() {
	return size;
}

ConcurrentHashMap记录键值对数量的实现如下:

//size方法最大只能返回Integer.MAX_VALUE
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ?Integer.MAX_VALUE : (int)n);
}

//mappingCount方法可以返回long类型的最大值,
public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}

private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

//sumCount会返回sumCount加上CounterCells数组中每个元素as存储的value
final long sumCount() {
		CounterCell[] as = counterCells; CounterCell a;
		long sum = baseCount;
		if (as != null) {
				for (int i = 0; i < as.length; ++i) {
					if ((a = as[i]) != null)
							sum += a.value;
				}
		}
		return sum;
}

@sun.misc.Contended //这个注解可以避免伪共享,提升性能。加与不加,性能差距达到了 5 倍。在缓存系统中,由于一个缓存行是出于32-256个字节之间,常见的缓存行为64个字节。而一般的变量可能达不到那么多字节,所以会出现多个相互独立的变量存储在一个缓存行中的情况,此时即便多线程访问缓存行上相互独立变量时,也涉及到并发竞争,会有性能开销,加了@sun.misc.Contended这个注解,在jDK8中,会对对象前后都增加128字节的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

每次添加x个新的键值对后,会调用addCount()方法使用CAS操作对baseCount+x,如果操作失败,那么会新建一个CounterCell类型的对象,保存新增的数量x,并且将对象添加到CounterCells数组中去。

为什么ConcurrentHashMap,HashTable不支持key,value为null?

因为HashMap是非线程安全的,默认单线程环境中使用,如果get(key)为null,可以通过containsKey(key)
方法来判断这个key的value为null,还是不存在这个key,而ConcurrentHashMap,HashTable是线程安全的,
在多线程操作时,因为get(key)和containsKey(key)两个操作和在一起不是一个原子性操作,
可能在执行中间,有其他线程修改了数据,所以无法区分value为null还是不存在key。
至于ConcurrentHashMap,HashTable的key不能为null,主要是设计者的设计意图。

HashSet和HashMap的区别?

HashMap主要是用于存储非重复键值对,HashSet存储非重复的对象。虽然HashMap是继承于AbstractMap,实现了Map接口,HashSet继承于AbstractSet,实现了Set接口。但是由于它们都有去重的需求,所以HashSet主要实现都是基于HashMap的(如果需要复用一个类,我们可以使用继承模式,也可以使用组合模式。组合模式就是将一个类作为新类的组成部分,以此来达到复用的目的。)例如,在HashSet类中,有一个HashMap类型的成员变量map,这就是组合模式的应用。

    public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
    private transient HashMap<E,Object> map;
    private static final Object PRESENT = new Object();//占位对象
    public HashSet() {
        map = new HashMap<>();
    }
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;//占位对象
    }
}

在HashSet的构造方法中,创建了一个HashMap,赋值给map属性,之后在添加元素时,就是将元素作为key添加到HashMap中,只不过value是一个占位对象PRESENT。除了 clone()writeObject()readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。那么HashMap又是如何实现key不重复的呢?

在调用HashMap的putVal方法添加新的键值对时,会进行如下操作:

1.根据key计算hash值。

2.根据hash值映射数组下标,然后获取数组下标的对应的元素。

3.数组下标存储的是一个链表,链表包含了哈希冲突的元素,会对链表进行遍历,判断hash1hash2,除此以外,还必须要key1key2,或者key1.equals(key2)。

因为两个不同的对象的hashCode可能相等,但是相同的对象的hashCode肯定相等,

==是判断两个变量或实例是不是指向同一个内存地址,如果是同一个内存地址,对象肯定相等。

int hash = hash(key);//根据key计算hash值
p = tab[i = (n - 1) & hash];//根据hash值映射数组下标,然后获取数组下标的对应的元素。
for (int binCount = 0; ; ++binCount) {//数组下标存储的是一个链表,链表包含了哈希冲突的元素,会对链表进行遍历,判断每个节点的hash值与插入元素的hash值是否相等,并且是存储key对象的地址相等,或者key相等。
if (e.hash == hash &&
	((k = e.key) == key || (key != null && key.equals(k))))
		break;
		p = e;
}

HashMap遍历时删除元素的有哪些实现方法?

首先结论如下:

第1种方法 – for-each遍历HashMap.entrySet,使用HashMap.remove()删除(结果:抛出异常)。

第2种方法-for-each遍历HashMap.keySet,使用HashMap.remove()删除(结果:抛出异常)。

第3种方法-使用HashMap.entrySet().iterator()遍历删除(结果:正确删除)。

下面让我们来详细探究一下原因吧!

HashMap的遍历删除方法与ArrayList的大同小异,只是api的调用方式不同。首先初始化一个HashMap,我们要删除key包含”3″字符串的键值对。

HashMap<String,Integer> hashMap = new HashMap<String,Integer>();
hashMap.put("key1",1);
hashMap.put("key2",2);
hashMap.put("key3",3);
hashMap.put("key4",4);
hashMap.put("key5",5);
hashMap.put("key6",6);

第1种方法 – for-each遍历HashMap.entrySet,使用HashMap.remove()删除(结果:抛出异常)

for (Map.Entry<String,Integer> entry: hashMap.entrySet()) {
        String key = entry.getKey();
        if(key.contains("3")){
            hashMap.remove(entry.getKey());
        }
     System.out.println("当前HashMap是"+hashMap+" 当前entry是"+entry);

}

输出结果:

当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前entry是key1=1
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前entry是key2=2
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前entry是key5=5
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前entry是key6=6
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4} 当前entry是key3=3
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.HashMap$HashIterator.nextNode(HashMap.java:1429)
	at java.util.HashMap$EntryIterator.next(HashMap.java:1463)
	at java.util.HashMap$EntryIterator.next(HashMap.java:1461)
	at com.test.HashMapTest.removeWayOne(HashMapTest.java:29)
	at com.test.HashMapTest.main(HashMapTest.java:22)

第2种方法-for-each遍历HashMap.keySet,使用HashMap.remove()删除(结果:抛出异常)

Set<String> keySet = hashMap.keySet();
for(String key : keySet){
    if(key.contains("3")){
        keySet.remove(key);
    }
    System.out.println("当前HashMap是"+hashMap+" 当前key是"+key);
}

输出结果如下:

当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前key是key1
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前key是key2
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前key是key5
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key3=3, key4=4} 当前key是key6
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4} 当前key是key3
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.HashMap$HashIterator.nextNode(HashMap.java:1429)
	at java.util.HashMap$KeyIterator.next(HashMap.java:1453)
	at com.test.HashMapTest.removeWayTwo(HashMapTest.java:40)
	at com.test.HashMapTest.main(HashMapTest.java:23)

第3种方法-使用HashMap.entrySet().iterator()遍历删除(结果:正确删除)

Iterator<Map.Entry<String, Integer>> iterator  = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Integer> entry = iterator.next();
    if(entry.getKey().contains("3")){
        iterator.remove();
    }
    System.out.println("当前HashMap是"+hashMap+" 当前entry是"+entry);
}

输出结果:

当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4, deletekey=3} 当前entry是key1=1
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4, deletekey=3} 当前entry是key2=2
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4, deletekey=3} 当前entry是key5=5
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4, deletekey=3} 当前entry是key6=6
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4, deletekey=3} 当前entry是key4=4
当前HashMap是{key1=1, key2=2, key5=5, key6=6, key4=4} 当前entry是deletekey=3

第1种方法和第2种方法抛出ConcurrentModificationException异常与上面ArrayList错误遍历-删除方法的原因一致,HashIterator也有一个expectedModCount,在遍历时获取下一个元素时,会调用next()方法,然后对
expectedModCount和modCount进行判断,不一致就抛出ConcurrentModificationException异常。

abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }

    public final void remove() {
        Node<K,V> p = current;
        if (p == null)
            throw new IllegalStateException();
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        current = null;
        K key = p.key;
        removeNode(hash(key), key, null, false, false);
        expectedModCount = modCount;
    }
}

PS:ConcurrentModificationException是什么?

根据ConcurrentModificationException的文档介绍,一些对象不允许并发修改,当这些修改行为被检测到时,就会抛出这个异常。(例如一些集合不允许一个线程一边遍历时,另一个线程去修改这个集合)。

一些集合(例如Collection, Vector, ArrayList,LinkedList, HashSet, Hashtable, TreeMap, AbstractList, Serialized Form)的Iterator实现中,如果提供这种并发修改异常检测,那么这些Iterator可以称为是”fail-fast Iterator”,意思是快速失败迭代器,就是检测到并发修改时,直接抛出异常,而不是继续执行,等到获取到一些错误值时在抛出异常。

异常检测主要是通过modCount和expectedModCount两个变量来实现的,

  • modCount
    集合被修改的次数,一般是被集合(ArrayList之类的)持有,每次调用add(),remove()方法会导致modCount+1

  • expectedModCount
    期待的modCount,一般是被Iterator(ArrayList.iterator()方法返回的iterator对象)持有,一般在Iterator初始化时会赋初始值,在调用Iterator的remove()方法时会对expectedModCount进行更新。(可以看看上面的ArrayList.Itr源码)

然后在Iterator调用next()遍历元素时,会调用checkForComodification()方法比较modCount和expectedModCount,不一致就抛出ConcurrentModificationException。

单线程操作Iterator不当时也会抛出ConcurrentModificationException异常。(上面的例子就是)

总结

因为ArrayList和HashMap的Iterator都是上面所说的“fail-fast Iterator”,Iterator在获取下一个元素,删除元素时,都会比较expectedModCount和modCount,不一致就会抛出异常。

所以当使用Iterator遍历元素(for-each遍历底层实现也是Iterator)时,需要删除元素,一定需要使用 Iterator的remove()方法 来删除,而不是直接调用ArrayList或HashMap自身的remove()方法,否则会导致Iterator中的expectedModCount没有及时更新,之后获取下一个元素或者删除元素时,expectedModCount和modCount不一致,然后抛出ConcurrentModificationException异常。