面试难度:★★★★★
考察概率:★★★★★
#莫等闲,白了少年头#
本人从毕业开始一直在一线互联网大厂工作,现任技术TL,出版过《深入理解Java并发》一书,折腾过技术开源项目,并长期作为面试官参与面试,深谙双方的诉求与技术沟通。如今归零心态,再出发。#莫等闲,白了少年头#
技术交流+v:xxxyxsyy1234(和笔者一起努力,每日打卡) 2000+以面试官视角总结的考点,可与我共同打卡学习
面试官视角
在实际工作中,处理业务高频使用的就是list/map等各个集合容器,放在并发场景下,自然而言也要选择线程安全的并发容器去处理对应的业务逻辑。如果连基本的线程安全的并发容器,都没掌握的话。换做你是面试官,你会放心招进团队去做业务吗?!因此,这就是为啥每次都会被问ConcurrentHashMap的八股文的原因,这块所有的候选人都会去背,但如果要回答的好的话和脱颖而出的话,也是很有挑战的。
面试题
- 简述ConcurrentHashMap的实现原理?
- 简述COW机制?
- ConcurrentLinkedQueue 是如何实现线程安全的?
- 简述ThreadLocal 的实现原理以及产生内存泄漏的原因?
- 简述阻塞队列 BlockingQueue?
- ArrayBlockingQueue 与 LinkedBlockingQueue 有何区别?
- ConcurrentLinkedQueue 为什么是无锁设计,而ConcurrentHashMap要用分段锁设计?
- 【大招加分项】各种并发容器的横向对比和思考?
回答要点
1. 简述ConcurrentHashMap的实现原理?
ConcurrentHashMap是Java并发包中的线程安全哈希表实现,它提供了高效的并发访问和修改操作。下面是ConcurrentHashMap的主要知识要点:
- 关键属性
table:元素为Node类的哈希桶数组
nextTable:扩容时的新数组
sizeCtl:控制数组的大小
Unsafe u:提供对哈希桶数组元素的CAS操作 - 重要内部类
Node:实现Map.entry接口,存放key,value
TreeNode:继承Node,会被封装成TreeBin
TreeBin:进一步封装TreeNode,链表过长时转换成红黑树时使用
ForwardingNode:扩容时出现的特殊结点 - 涉及到的CAS操作
tabAt:查询哈希桶数组的元素
casTabAt:设置哈希桶数组中索引为i的元素
setTabAt:设置哈希桶数组中索引为i的元素 - 构造方法:
-
- 数组长度总是会保证为2的幂次方;
- 如果选择是无参的构造器的话,这里在new Node数组的时候会使用默认大小为DEFAULT_CAPACITY(16),然后乘以加载因子0.75为12,也就是说数组的可用大小为12。
- put执行流程
ConcurrentHashMap是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,是通过链地址的方式来解决哈希冲突的问题,将hash值相同的节点构成链表的形式,称为“拉链法”。 另外,在1.8版本中为了防止拉链过长,当链表的长度大于8的时候会将链表转换成红黑树。table数组中的每个元素实际上是单链表的头结点或者红黑树的根节点
-
- 如果当前数组还未初始化,先进行初始化initTable操作
- spread方法重哈希(高16位和低16位异或操作),将哈希值与数组长度与运算,确定待插入结点的索引为i
- 当前哈希桶中i处为null,直接插入
- i处结点不为null的话并且结点hash>0,说明i处为链表头结点。遍历链表,遇到与key相同的结点则覆盖其value,如果遍历完没有找到,则尾插入新结点
- i处结点不为null的话并且结点状态为MOVED,则说明在扩容,帮助扩容
- i处结点不为null的话并且结点位TreeBin,则使用红黑树的方式插入结点
- 插入新结点后,检查链表长度是否大于8(默认超过8),若大于,则转换成红黑树
- 检测数组长度,若超过临界值,则扩容
- get执行流程:
-
- 首先先看当前的hash桶数组节点即table[i]是否为查找的节点,若是则直接返回;
- 若不是,则继续再看当前是不是树节点?通过看节点的hash值是否为小于0,如果小于0则为树节点。如果是树节点在红黑树中查找节点;如果不是树节点,那就只剩下为链表的形式的一种可能性了,就向后遍历查找节点,若查找到则返回节点的value即可,若没有找到就返回null。
- 扩容机制
整个扩容操作分为两个部分:
-
- 第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。新建table数组的代码为:Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1],在原容量大小的基础上右移一位;
- 第二部分就是将原来table中的元素复制到nextTable中,主要是遍历复制的过程。 根据运算得到当前遍历的数组的位置i,然后利用tabAt方法获得i位置的元素再进行判断:
-
-
- 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点;
- 如果这个位置是Node节点(fh>=0),通过fh & n对原链表节点进行标记然后构造反序链表,把它们分别放在nextTable的i和i+n的位置上(可以参考这篇博客:www.cnblogs.com/yangchunchu…);
- 如果这个位置是TreeBin节点(fh<0),也需要做节点转移操作,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上;
-
遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为扩容后总容量乘以0.75系数后的值,整个过程就是扩容全过程。设置为新容量的0.75倍代码为 sizeCtl = (n << 1) - (n >>> 1),仔细体会下是不是很巧妙,n<<1相当于n右移一位表示n的两倍即2n(扩容后的预估总容量),n>>>1右移一位相当于n除以2即0.5n,然后两者相减为2n-0.5n=1.5n,最后结果就刚好等于新容量的0.75倍即2n*0.75=1.5n。操作示意图如下图所示:
- 版本的ConcurrentHashMap与之前版本的比较
1.8版本舍弃了segment,并且大量使用了synchronized以及CAS无锁操作以保证ConcurrentHashMap操作的线程安全性。至于为什么不用ReentrantLock而是Synchronzied呢?实际上,synchronzied做了很多的优化,包括偏向锁、轻量级锁以及重量级锁的锁升级机制来提升锁的高并发性和加锁和释放锁的效率。因此,使用synchronized相较于ReentrantLock的性能会持平甚至在某些情况更优。另外,底层数据结构改变为采用数组+链表+红黑树的数据形式。总结下来有如下几点:
-
-
不采用segment而采用更加细粒度的Node节点,降低锁粒度提升容器整体的并发特性和性能;
-
设计了MOVED状态,当resize的中过程中,可以由多线程来并发协助完成容器的扩容;
-
进一步通过无锁算法CAS操作来完成对节点的数据操作来提升性能和并发度;
-
采用synchronized来解决线程安全的问题而不是ReentrantLock。
-
2. 简述COW机制。
COW(Copy-On-Write)是一种并发编程中的策略,它主要用于实现对共享数据的高效读操作和线程安全的写操作。COW机制的基本思想是,在需要修改共享数据时,先对数据进行拷贝(复制),然后在副本上进行修改,从而避免了对原数据的直接修改。COW机制的使用场景通常包括以下两个方面:
- 并发读写场景:
-
- 在并发读写的场景中,如果多个线程同时读取共享数据,而不涉及修改操作,可以共享同一个数据副本,提高读取的效率。
- 当有一个线程需要对数据进行修改时,COW机制会复制出一个新的副本,并在新的副本上进行修改,而其他线程仍然可以继续读取原始的数据副本。
- 这样可以避免读操作和写操作之间的冲突,从而提高并发性能。
- 不可变数据结构:
-
- COW机制也常用于实现不可变数据结构,即一旦创建了数据对象,就不能再对其进行修改。
- 当需要对不可变数据结构进行修改时,COW机制会创建一个新的副本,对新副本进行修改,而原始数据对象保持不变。
- 这样可以确保原始数据对象的线程安全性,而不需要显式的同步机制。
COW机制的主要优点是简化了并发编程中的线程同步和锁的使用,提高了并发读取的性能,并且保证了数据的线程安全性。但是,COW机制也带来了一些开销,包括内存占用和副本创建的开销。因此,在选择使用COW机制时,需要权衡读写操作的比例和数据大小等因素。
Java中的一些类库中使用了COW机制,如CopyOnWriteArrayList和CopyOnWriteArraySet,它们是线程安全的集合类,支持并发读和高效的写操作。此外,还有一些不可变类,如String类和Immutable集合类,也利用了COW机制来实现线程安全和不可变性。
3. ConcurrentLinkedQueue 是如何实现线程安全的?
ConcurrentLinkedQueue是Java并发包中的线程安全队列实现,它采用了无锁(lock-free)的算法,以实现高效的并发访问。以下是ConcurrentLinkedQueue实现线程安全的主要原理:
- 基于链表的数据结构:
-
- ConcurrentLinkedQueue内部使用链表来存储元素,每个节点包含一个元素和指向下一个节点的引用。
- 链表结构可以动态添加和删除节点,实现队列的先进先出(FIFO)特性。
- 使用CAS操作:
-
- ConcurrentLinkedQueue的线程安全性主要通过CAS(Compare and Swap)操作来实现。
- 在插入元素时,通过CAS操作将新节点添加到链表的末尾。CAS操作可以保证只有一个线程能够成功添加节点,其他线程会重试直到成功。
- 在删除元素时,通过CAS操作修改链表的头节点引用,将头节点移除。同样,只有一个线程能够成功修改头节点,其他线程会重试直到成功。
- 无锁算法:
-
- ConcurrentLinkedQueue的实现是无锁的,即没有使用传统的锁机制(如synchronized或ReentrantLock)来实现线程安全性。
- 无锁算法允许多个线程并发地对队列进行插入和删除操作,提高了并发度和性能。
- 原子性操作:
-
- ConcurrentLinkedQueue使用原子性的操作来保证插入和删除操作的线程安全性。
- CAS操作是原子性的,它保证了在并发情况下只有一个线程能够成功修改数据结构,其他线程会进行重试。
总体而言,ConcurrentLinkedQueue通过使用基于链表的数据结构和CAS操作,实现了高效的并发访问和线程安全性。它不需要使用显式的锁机制,并发线程可以自由地进行插入和删除操作,从而提高了并发度和性能。这使得ConcurrentLinkedQueue成为在高并发环境下使用的常见线程安全队列实现。
【可加分亮点】
面试官心理: ConcurrentLinkedQueue是在并发竞争不太频繁的情况下使用,在数据并发读写的处理上,实际上是一种延迟更新的手段,这也是一个冷门的知识点。但同时,这种设计思想在业务建模中也会被使用到,如果候选人能够回答上来的话,是一个惊艳的回答。
HOPS的设计?
插入数据tail延迟更新示意图如下:
删除数据head延迟更新如下:
1.tail更新触发时机
当tail指向的节点的下一个节点为null的时候,也就是说当前队列状态就可以直接插入新的数据,因此直接插入节点即可不更新tail。当tail指向的节点的下一个节点不为null的时候,就需要找到队列“真正”的队尾节点,就需要执行队列寻址队尾节点的操作,直至找到队尾节点后插入新的数据节点并通过casTail进行tail更新;
2.head更新触发时机
当head指向的节点的item域为null的时候,就需要找到队列首个数据节点后后完成删除数据节点,并通过updateHead进行head更新。当head指向的节点的item域不为null的时候,只删除节点完成取数并不更新head。
通过上面的分析可以看出tail以及head的更新是被延迟更新的,并且就像是“跳跃”着隔着一个节点进行更新的。那么这么做的意图是什么呢?在源码中对于更新引用中会有注释:hop two nodes at a time,因此将这种延迟更新的策略简称之HOPS设计。
假设如果让tail永远作为队列的队尾节点,那么在插入数据时就只需要简单的通过tail完成数据插入即可,代码的实现逻辑很简单。但是这样做存在的缺点是如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是很大的性能开销。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师利用HOPS策略来延缓更新tail的时机。但是延迟更新引用后,在新增数据节点也会带来寻址队尾节点的查找开销。
因此对技术设计而言没有绝对的好与坏,只有针对场景才会有合适的技术方案,并且每一种技术方案有优势但是也避免不了会引入缺点,所以就需要进行权衡。对数据容器而言,一般都是读多写少的场景。因此,在写入数据的时候延迟更新引用带来的性能提升就是可观的,确保读的效率要高于写入数据的性能。同样的对删除数据(获取数据)的操作采用HOPS策略也是同样的道理。
4. 简述ThreadLocal 的实现原理以及产生内存泄漏的原因。
ThreadLocal是Java中的一个线程封闭(Thread confinement)机制,它允许在多线程环境中为每个线程存储和获取独立的数据副本。ThreadLocal的实现原理和可能导致内存泄漏的原因如下所述:
ThreadLocal的实现原理:
- 每个Thread对象内部都有一个ThreadLocalMap对象,用于存储线程本地的变量副本。
- ThreadLocalMap是一个自定义的哈希表结构,它的键为ThreadLocal实例,值为对应线程的变量副本。
- 在使用ThreadLocal时,通过ThreadLocal实例调用set方法设置线程本地变量的值,通过get方法获取线程本地变量的值。
- 每个线程只能访问自己的ThreadLocalMap对象,从而实现了线程之间的数据隔离。
产生内存泄漏的原因:
- 首先我们看下引用链A:ThreadRef->Thread->ThreadLocalMap->Entry->valueRef->valueMemory。引用链B:ThreadLocalRef->ThreadLocal<--key。
- 当在进行垃圾回收的时候弱引用会被回收,所以key为NULL,如果对应的线程仍然存活,那引用链A中value还被引用着,但是已经访问不了了,所以会造成内存泄漏。
那如果把弱引用改为强引用会怎么样?那算显示的把ThreadLocal实例设置为null,只要对应线程还存活着,还是会存在内存泄漏。
内存泄漏的解决方案:显式地调用ThreadLocal的remove方法:在不再需要使用ThreadLocal时,显式地调用remove方法清理ThreadLocalMap中的Entry,以避免内存泄漏。
总结:ThreadLocal是一种实现线程封闭的机制,通过为每个线程存储独立的数据副本来实现线程间的数据隔离。然而,使用不当可能导致内存泄漏,特别是对于长时间持有ThreadLocal实例或者不及时调用remove方法的情况。因此,在使用ThreadLocal时,应注意正确使用和管理,避免产生内存泄漏问题。
5. 简述阻塞队列 BlockingQueue。
阻塞队列(BlockingQueue)是Java并发包提供的一个线程安全的队列实现,它提供了一种在多线程环境下进行数据交换的机制。阻塞队列具有以下特点:
- 线程安全性:阻塞队列是线程安全的,多个线程可以同时对队列进行操作,无需额外的同步措施。它内部使用锁或其他同步机制来保证线程安全。
- 阻塞操作:阻塞队列提供了一系列的阻塞操作,当队列为空时,获取元素的操作将会被阻塞,直到有元素可用;当队列已满时,插入元素的操作将会被阻塞,直到有空间可用。
- 内部容量:阻塞队列可以设置一个固定的容量,也可以选择不限制容量。当容量已满时,插入操作将会被阻塞,直到有空间可用;当队列为空时,获取操作将会被阻塞,直到有元素可用。
- 支持多种操作:阻塞队列提供了常用的队列操作,如插入元素、获取元素、查找元素等。具体的操作包括put、take、offer、poll、peek等。
- 针对不同需求的实现:Java并发包提供了多种阻塞队列的实现,如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue等,每种实现都有不同的特点和适用场景。
阻塞队列的使用场景:
- 生产者-消费者模型:阻塞队列非常适用于生产者-消费者模型,生产者线程可以将数据放入队列,消费者线程可以从队列中获取数据,通过阻塞操作可以实现生产者和消费者的协调。
- 线程池:线程池通常使用阻塞队列来存储待执行的任务,当线程池中的线程已满时,新的任务可以被阻塞,直到有空闲的线程可用。
- 任务调度:阻塞队列可以用于实现任务调度,任务在队列中等待执行,可以根据任务的优先级、延迟时间等进行调度。
总结:
阻塞队列是一种线程安全的队列实现,它提供了阻塞操作和内部容量的特性。它可以在多线程环境中实现线程安全的数据交换,并提供了一种简单的方式来协调生产者和消费者线程。阻塞队列在生产者-消费者模型、线程池和任务调度等场景中都有广泛的应用。
6. ArrayBlockingQueue 与 LinkedBlockingQueue 有何区别。
ArrayBlockingQueue和LinkedBlockingQueue是Java并发包中两种常见的阻塞队列实现,它们在实现方式和特性上有以下区别:
- 内部数据结构:
-
- ArrayBlockingQueue使用数组作为内部数据结构,它的容量是固定的。当队列满时,插入操作将会被阻塞,直到有空间可用。
- LinkedBlockingQueue使用链表作为内部数据结构,它的容量可以选择是否限制。如果容量限制为无界(默认),则插入操作不会被阻塞;如果容量限制为有界,当队列满时,插入操作将会被阻塞,直到有空间可用。
- 阻塞特性:
-
- ArrayBlockingQueue和LinkedBlockingQueue都支持阻塞操作,当队列为空时,获取操作将会被阻塞,直到有元素可用;当队列已满时,插入操作将会被阻塞,直到有空间可用。
- 不同之处在于ArrayBlockingQueue的阻塞操作是通过内部锁来实现的,而LinkedBlockingQueue使用了不同的锁对象来分别控制插入和获取操作的并发访问。
- 性能:
-
- 由于ArrayBlockingQueue使用固定的数组作为内部数据结构,它的性能相对较高,因为不涉及动态的节点分配和链接操作。
- LinkedBlockingQueue的性能相对较低,因为它使用链表作为内部数据结构,每次插入和删除都需要动态地分配和释放节点,并且涉及节点的链接操作。
- 迭代顺序:
-
- ArrayBlockingQueue和LinkedBlockingQueue在迭代元素时的顺序上有所不同。
- ArrayBlockingQueue保证元素的迭代顺序与插入顺序一致,即先进先出(FIFO)。
- LinkedBlockingQueue不保证元素的迭代顺序与插入顺序一致,可能会受到并发操作的影响。
选择使用ArrayBlockingQueue还是LinkedBlockingQueue取决于具体的需求:
- 如果需要一个容量固定且性能较高的阻塞队列,可以选择ArrayBlockingQueue。
- 如果需要一个容量可选择限制或者无界的阻塞队列,并且对性能要求相对较低,可以选择LinkedBlockingQueue。
需要注意的是,在使用有界容量的阻塞队列时,要避免出现队列已满而导致插入操作被阻塞而无法进行的情况,否则可能会导致应用程序的堵塞或者资源耗尽。
7. ConcurrentLinkedQueue 为什么是无锁设计,而ConcurrentHashMap要用分段锁设计?
ConcurrentLinkedQueue和ConcurrentHashMap在设计上选择不同的线程安全策略,原因如下:
- 并发度和操作复杂度:
-
- ConcurrentLinkedQueue是一个基于链表的线程安全队列,它的主要操作是插入和删除元素,操作比较简单。
- ConcurrentLinkedQueue使用无锁设计,通过CAS操作来实现线程安全。由于插入和删除操作只涉及到链表的头尾节点,不需要对整个数据结构进行复杂的修改,因此无锁设计足以满足并发需求。
- 数据结构和共享状态:
-
- ConcurrentHashMap是一个基于哈希表的线程安全映射表,它需要支持更复杂的操作,如插入、删除、查找等,并且涉及到多个键值对的共享状态。
- 哈希表的结构决定了在并发环境下,需要考虑更多的并发冲突问题,如哈希冲突和扩容等。为了保证线程安全和高并发度,ConcurrentHashMap采用了分段锁的设计,将整个数据集分成多个Segment,每个Segment拥有自己的锁,允许并发访问不同的Segment。
- 性能和内存开销:
-
- 无锁设计的ConcurrentLinkedQueue可以提供更高的并发度和性能,适用于高并发读写操作频繁的场景,而无需承担锁带来的性能开销。
- 分段锁设计的ConcurrentHashMap可以在保证线程安全的同时,提供更细粒度的并发控制,适用于读多写少或者写操作涉及不同部分数据的场景。但是分段锁会带来额外的内存开销和锁竞争。
综上所述,ConcurrentLinkedQueue选择了无锁设计,适用于简单的队列操作和高并发度的场景,以提供较高的性能。而ConcurrentHashMap选择了分段锁设计,以支持复杂的哈希表操作和更细粒度的并发控制,以满足不同的线程安全需求。选择合适的线程安全策略取决于具体应用场景和性能需求。
代码考核
对于这部分而言,手撕代码的概率不高,但这块会持续的追问原理部分,基本上会存在和面试官刨根问底进行battle的时候,所以这块需要认真的理解上述的这些考点,才有机会突破一轮面试。
知识点详情
这部分可以参考本人的书籍《深入理解Java并发》,或者本人博客