HashMap和ConcurrentHashMap 面经补充

基础

1 Java 8系列之重新认识HashMap
2 面试 ConcurrentHashMap ,看这一篇就够了!

1 HashMap

1.1 HashMap链表转化为红黑树的时机,为什么是这个时机

两个条件
1 链表长度大于等于8
2 MIN_TREEIFY_CAPACITY参数即底层数组的大小大于等于64的时候(不是总的数据项(键值对)的数量。)

1.1.1 为什么达到8就需要变成树?降到6又变回链表?

事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低, 而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值

1.1.2 为什么还需要满足底层数组的大小大于等于64的条件?

在这个上下文中,MIN_TREEIFY_CAPACITY(默认值64)是指HashMap的容量,也就是说,是底层数组的大小,而不是总的数据项(键值对)的数量。

当HashMap的底层数组的容量达到MIN_TREEIFY_CAPACITY,并且某个索引下链表的长度达到阈值(默认为8),此时才会将这个链表转换为红黑树。

之所以要求底层数组的容量达到MIN_TREEIFY_CAPACITY,是因为在数组容量扩充之后,原本在同一个索引下产生冲突的元素有可能会被分配到不同的索引下,从而减少链表的长度。所以,当数组容量还比较小的时候,首选的优化策略是扩充数组的容量,而不是将链表转换为红黑树。只有当数组容量已经达到一定大小,但链表长度仍然过长的时候,才需要将链表转换为红黑树,以提高查找效率

2 ConcurrentHashMap

2.1 synchronized+CAS的应用及其配合的原因

2.1.1 ConcurrentHashMap进行put操作时,已经用了synchronized,为什么还要用CAS呢?

1 用CAS(Compare And Swap)操作取代了部分synchronized同步块,使得synchronized锁的粒度更小。主要原因是CAS操作通常比synchronized块有更好的性能,这种优势尤其在并发数较少时特别明显。比如在put操作时,线程会首先判断该hash槽的首节点是否为空,如果为空则说明此时参与竞争线程数并不多甚至可能为0,所以使用CAS添加一个首节点,完成put操作

2 synchronized本身在重量级锁的状态下,线程尝试获取重量级状态的synchronized锁,无论是否成功,都会涉及到用户态到内核态的切换,然后再切回用户态的转换,这是十分耗时的。所以对于一些单一的内存并发赋值操作,使用CAS完成比较好。

2.1.2 获取synchronized锁失败时会导致内核态的切换吗

获取synchronized锁失败时,会有两种可能的行为,取决于JVM的具体实现和配置。

  1. 早期的JVM实现,在获取锁失败时,线程会直接进入阻塞状态,这将导致用户态到内核态的切换。这是因为线程的挂起和恢复操作需要由操作系统内核进行,这将导致昂贵的上下文切换,对性能有所影响。

  2. 但是在较新的JVM实现中,引入了一种称为"自旋锁"的机制来改善这个问题。自旋锁(其实也是类似CAS的思想)的基本思想是,当线程尝试获取锁失败时,而不是立即挂起线程,它会让线程在用户态进行一定次数的循环(即自旋),看看锁是否能够被快速释放。如果在自旋过程中锁被释放,那么线程就可以避免进入阻塞状态,从而避免用户态和内核态的切换。但如果自旋结束后,锁仍然没有被释放,那么线程会进入阻塞状态,这时候就会发生用户态到内核态的切换。

总的来说,获取synchronized锁失败时,是否会导致用户态到内核态的切换,取决于JVM的具体实现和配置。在自旋锁机制下,如果锁能在自旋期间被释放,就可以避免状态切换,从而提高性能。

2.1.3 ConcurrentHashMap扩容时如何保证线程安全呢?

ConcurrentHashMap扩容时保证线程安全的主要方式是使用"sizeCtl"字段和CAS操作。当一个线程发现表需要扩容时(例如,当元素数量达到阈值),它会使用CAS操作尝试更新"sizeCtl"字段。只有成功更新"sizeCtl"字段的线程才能执行扩容操作,其他线程则不会进行扩容。在扩容过程中,如果其他线程尝试添加新元素,它们将会在新表中添加元素,而不是旧表。这样可以保证在扩容过程中,新旧表中的元素都是一致的,从而保证线程安全。

2.1.4 如果一个线程A正在使用cas扩容过程中,线程B,C想要进行put操作,B和C的行为是怎么样的,会被阻塞直到A完成扩容吗?

在Java 8及以后的ConcurrentHashMap中,如果线程A正在进行扩容操作,线程B和C尝试进行put操作,它们不会被阻塞,而是可以帮助线程A一起进行扩容操作。这种机制被称为“帮助扩容”。

具体来说,当线程B和C尝试获取锁并发现当前HashMap正在进行扩容时,它们可以选择参与到扩容操作中,即复制旧数组中的元素到新的扩容数组。在此过程中,新添加的元素可以直接放入新的扩容数组中,这就确保了线程安全。

这种设计使得ConcurrentHashMap可以在扩容时仍然保持高并发性能。这是因为扩容操作被多个线程分担,而不是单一线程执行,大大提高了效率。

需要注意的是,这种帮助扩容的设计依赖于CAS操作和volatile变量来保证线程之间的同步(给HashEntry数组加上了一个volatile关键字,保证了可见性,所以线程A新建一个双倍长度的数组后,B和C会立即看到这个新数组。具体的实现细节可能会因JVM的实现和版本而有所不同。

2.1.5 cas更适用于线程数即并发量较少的场景对吗?

CAS操作通常在并发量较小,且竞争不激烈的情况下工作得更好,因为在这种情况下,CAS操作失败的概率较小,性能较高。然而,并不是说CAS操作不适用于并发量大的场景。在一些设计得当的并发数据结构中,例如ConcurrentHashMap,通过精心设计的算法和数据结构,可以让CAS操作在并发量大的情况下仍然保持高性能。例如,ConcurrentHashMap通过分段锁和扩容帮助等机制,有效地减少了CAS操作的竞争,使其在高并发环境下仍然能够工作得很好。

2.1.6 synchronized锁是重量级,那为什么不直接弃用它改为全部使用cas完成并发操作呢?

ConcurrentHashMap的put操作不仅仅是简单的添加元素,还涉及到一些其他复杂的逻辑,例如哈希冲突处理、扩容操作等。CAS操作虽然可以保证单个内存位置的原子性,但不能保证整个put操作的原子性。因此,为了保证线程安全,ConcurrentHashMap在进行put操作时需要在某些情况下使用synchronized进行同步。

2.1.7 如果一个ConcurrentHashMap在被多个线程操作,在进行扩容操作时会有几个线程在处理

在Java 8及以后的ConcurrentHashMap实现中,所有试图写入(例如put操作)或读取(例如get操作并发现需要扩容)ConcurrentHashMap并发现它正在扩容的线程,都会参与到扩容操作中。也就是说,如果有多个线程同时操作ConcurrentHashMap并发现它需要扩容,那么这些线程都可能参与到扩容操作中。

这种设计使得ConcurrentHashMap的扩容操作可以并行执行,从而大大提高了扩容的效率。并且,由于这些线程都是在执行它们自己的操作(例如put或get)时“顺便”进行扩容的,所以这种扩容方式对线程的阻塞影响很小,也就是说,即使在进行扩容操作时,ConcurrentHashMap也能保持高并发性能。

具体参与扩容操作的线程数量会根据当前系统的实际并发情况而变化,如果并发度高,参与扩容的线程数就可能会多;反之,如果并发度低,那么可能只有一个线程参与扩容

2.1.8 ConcurrentHashMap在并发环境下的扩容过程可以分为以下步骤:

当一个线程发现表需要扩容时,例如当元素数量达到阈值,它会使用CAS操作尝试更新"sizeCtl"字段。如果更新成功,该线程就会开始进行扩容操作。
扩容操作首先需要创建一个新的、更大的数组。新数组的长度是旧数组长度的两倍。
接下来,每个线程都会帮助进行哈希槽的迁移操作。具体来说,每个线程都会选择一个还没有被迁移的哈希槽,然后使用CAS操作尝试获取对应哈希槽的控制权。如果获取成功,该线程就会把该哈希槽中的所有元素迁移到新数组中。
如果一个线程在进行其他操作时(例如插入元素),并发现当前正在进行扩容操作,那么它也会帮助进行哈希槽的迁移操作。新插入的元素会直接放入新的数组中。
所有的哈

2.2 下面描述的哪一步使得concurrentHashMap是弱一致性的,为什么

ConcurrentHashMap 迭代器是强一致性还是弱一致性?★★ 与 HashMap
迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。

ConcurrentHashMap
的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。

这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。

ConcurrentHashMap 的弱一致性主要来源于它的分段锁设计和对迭代器的处理策略。

在 Java 中,ConcurrentHashMap 使用分段锁(Segment)的设计来支持高并发。整个 ConcurrentHashMap 被分为多个 Segment,每个 Segment 包含一定数量的桶(Bucket),而每个桶又包含多个 Entry。当对 ConcurrentHashMap 进行写操作时,只需锁住目标数据所在的 Segment,而不用锁整个 Map。这种方式大大提升了并发写的性能。

同时,ConcurrentHashMap 的迭代器是弱一致性的,这主要体现在以下两个方面:

  1. 当迭代器被创建时,它会对当前的 ConcurrentHashMap 数据结构进行"快照"。这意味着,迭代器遍历的是迭代器创建那一刻的数据,而不是实时的数据。如果在遍历过程中有新的数据被添加进来,这些数据不会被迭代器看到。

  2. 另一方面,如果在迭代过程中有元素被移除,迭代器并不会因此抛出 ConcurrentModificationException。这是因为,ConcurrentHashMap 的设计允许在遍历过程中对 Map 进行修改。

因此,ConcurrentHashMap 的迭代器在某些情况下可能无法反映出迭代过程中对 Map 的修改,这就是所谓的"弱一致性"。这种设计使得 ConcurrentHashMap 可以在提供较好的并发性能的同时,避免了一些并发修改导致的问题。

2.3 ConcurrentHashMap 迭代器是强一致性还是弱一致性?读取数据时,会产生不一致的问题吧,如果某一个操作需要在迭代器迭代ConcurrentHashMap保证强一致性,即要求总是读取最新数据怎么办?

在Java中,ConcurrentHashMap提供的迭代器是弱一致性的(weakly consistent)。这意味着它可能不会反映出迭代过程中对HashMap的修改。换句话说,迭代器可能不会看到迭代开始后添加的元素,并且不会抛出ConcurrentModificationException。

这种弱一致性迭代器主要是为了在并发环境中提供更好的性能。由于并发操作的复杂性,保证迭代器的强一致性通常需要大量的同步,这可能会严重影响性能。通过允许弱一致性,ConcurrentHashMap可以在并发环境中提供更高的性能。

如果你需要在迭代ConcurrentHashMap时保证强一致性,即总是读取最新的数据,你需要添加额外的同步。一个可能的做法是使用读写锁(ReadWriteLock)。你可以在开始迭代时获取读锁,然后在添加、删除或修改元素时获取写锁。这样可以保证在迭代过程中,只有读操作,没有写操作,从而保证数据的一致性。然而,请注意,这可能会降低并发性能

这种方式在并发编程中常常被用到,称之为"读多写少"的策略。在这种策略中,读操作不会阻塞其他读操作,但写操作会阻塞所有其他的读和写操作。所以,如果你的应用中有大量的读操作和少量的写操作,这种策略可能会提供良好的性能。

然而,请注意,这种方法不能解决所有的并发问题。例如,如果一个线程在迭代过程中需要修改HashMap,那么可能还需要其他的同步机制来保证数据的一臀性。

2.4 concurrentHashMap不允许key为null?

问题在于null在并发环境中的语义。在ConcurrentHashMap中,null经常用作某些方法的返回值,比如get()方法,表示某些操作没有找到键或某些其他条件。如果允许null作为键或值,那么当get方法返回null时,这可能意味着它是一个实际的值,或者它可能意味着该值不存在,此时具有了二义性,此外,因为。因此,为了避免这种情况并简化并发代码的逻辑,ConcurrentHashMap不允许null作为键或值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值