并发编程面试题总结

603 阅读10分钟

并发编程高频面试题,面试必备!

推荐阅读:

文章目录:

目录.PNG

ConcurrentHashMap

什么是ConcurrentHashMap?相比于HashMap和HashTable有什么优势?

CocurrentHashMap可以看作线程安全且高效HashMap,相比于HashMap具有线程安全的优势,相比于HashTable具有效率高的优势。

java中ConcurrentHashMap是如何实现的?

这里经常会将jdk1.7中的ConcurrentHashMap和jdk1.8中的ConcurrentHashMap的实现方式进行对比。

  • JDK1.7

在JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry数组组成,Segment存储的是链表数组的形式,如图所示。

在这里插入图片描述

从上图可以看出,ConcurrentHashMap定位一个元素的过程需要两次Hash的过程,第一次Hash的目的是定位到Segment,第二次Hash的目的是定位到链表的头部。两次Hash所使用的时间比一次Hash的时间要长,但这样做可以在写操作时,只对元素所在的segment枷锁,不会影响到其他segment,这样可以大大提高并发能力。

  • JDK1.8

JDK1.8不在采用segment的结构,而是使用Node数组+链表/红黑树的数据结构来实现的(和HashMap一样,链表节点个数大于8,链表会转换为红黑树)

如下图所示

在这里插入图片描述

从上图可以看出,对于ConcurrentHashMap的实现,JDK1.8的实现方式可以降低锁的粒度,因为JDLK1.7所实现的ConcurrentHashMap的锁的粒度是基于Segment,而一个Segment包含多个HashEntry。

ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?

final修饰变量可以保证变量不需要同步就可以被访问和共享,volatile可以保证内存的可见性,配合CAS操作可以在不加锁的前提支持并发。

ConcurrentHashMap有什么缺点?

因为ConcurrentHashMap在更新数据时只会锁住部分数据,并不会将整个表锁住,读取的时候也并不能保证读取到最近的更新,只能保证读取到已经顺利插入的数据。

ConcurrentHashMap默认初始容量是多少?每次扩容为原来的几倍?

默认的初始容量为16,每次扩容为之前的两倍。

ConCurrentHashMap 的key,value是否可以为null?为什么?HashMap中的key、value是否可以为null?

ConCurrentHashMap中的keyvaluenull会出现空指针异常,而HashMap中的keyvalue值是可以为null的。

原因如下:ConCurrentHashMap是在多线程场景下使用的,如果ConcurrentHashMap.get(key)的值为null,那么无法判断到底是key对应的value的值为null还是不存在对应的key值。而在单线程场景下的HashMap中,可以使用containsKey(key)来判断到底是不存在这个key还是key对应的value的值为null。在多线程的情况下使用containsKey(key)来做这个判断是存在问题的,因为在containsKey(key)ConcurrentHashMap.get(key)两次调用的过程中,key的值已经发生了改变。

ConCurrentHashmap在JDK1.8中,什么情况下链表会转化为红黑树?

当链表长度大于8,Node数组数大于64时。

ConcurrentHashMap在JDK1.7和JDK1.8版本中的区别?

  • 实现结构上的不同,JDK1.7是基于Segment实现的,JDK1.8是基于Node数组+链表/红黑树实现的。

  • 保证线程安全方面:JDK1.7采用了分段锁的机制,当一个线程占用锁时,会锁住一个Segment对象,不会影响其他Segment对象。JDK1.8则是采用了CAS和synchronize的方式来保证线程安全。

  • 在存取数据方面:

    JDK1.7中的put()方法:

    1. 先计算出keyhash值,利用hash值对segment数组取余找到对应的segment对象。
    2. 尝试获取锁,失败则自旋直至成功,获取到锁,通过计算的hash值对hashentry数组进行取余,找到对应的entry对象。
    3. 遍历链表,查找对应的key值,如果找到则将旧的value直接覆盖,如果没有找到,则添加到链表中。(JDK1.7是插入到链表头部,JDK1.8是插入到链表尾部,这里可以思考一下为什么这样)

    JDK1.8中的put()方法:

    1. 计算key值的hash值,找到对应的Node,如果当前位置为空则可以直接写入数据。
    2. 利用CAS尝试写入,如果失败则自旋直至成功,如果都不满足,则利用synchronized锁写入数据。

ConcurrentHashMap迭代器是强一致性还是弱一致性?

与HashMap不同的是,ConcurrentHashMap迭代器是弱一致性。

这里解释一下弱一致性是什么意思,当ConcurrentHashMap的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put()方法将一个元素加入到底层数据结构后,get()可能在某段时间内还看不到这个元素。

这样的设计主要是为ConcurrenthashMap的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以ConcurrentHashMap是支持在迭代过程中,向map中添加元素的,而HashMap这样操作则会抛出异常。

ThreadLocal

什么是ThreadLocal?有哪些应用场景?

ThreadLocal 是 JDK java.lang 包下的一个类,ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,并且不会和其他线程的局部变量冲突,实现了线程间的数据隔离。

ThreadLocal的应用场景主要有以下几个方面:

  • 保存线程上下文信息,在需要的地方可以获取
  • 线程间数据隔离
  • 数据库连接

ThreadLocal原理和内存泄露?

要搞懂ThreadLocal的底层原理需要看下他的源码,太长了,有兴趣的同学可以自己看看相关资料,这里只是简单介绍下结构,因为Threadlocal内存泄露是个高频知识点,并且需要简单了解ThreadLocal结构。

ThreadLocal的原理可以概括为下图:

在这里插入图片描述

从上图可以看出每个线程都有一个ThreadLocalMapThreadLocalMap中保存着所有的ThreadLocal,而ThreadLocal本身只是一个引用本身并不保存值,值都是保存在ThreadLocalMap中的,其中ThreadLocalThreadLocalMap中的key。其中图中的虚线表示弱引用。

这里简单说下Java中的引用类型,Java的引用类型主要分为强引用、软引用、弱引用和虚引用。

  • 强引用:发生 gc 的时候不会被回收。
  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
  • 弱引用:有用但不是必须的对象,在下一次GC时会被回收。
  • 虚引用:无法通过虚引用获得对象,虚引用的用途是在 gc 时返回一个通知。

为什么ThreadLocal会发生内存泄漏呢?

因为ThreadLocal中的key是弱引用,而value是强引用。当ThreadLocal没有被强引用时,在进行垃圾回收时,key会被清理掉,而value不会被清理掉,这时如果不做任何处理,value将永远不会被回收,产生内存泄漏。

如何解决ThreadLocal的内存泄漏?

其实在ThreadLocal在设计的时候已经考虑到了这种情况,在调用set()get()remove()等方法时就会清理掉keynull的记录,所以在使用完ThreadLocal后最好手动调用remove()方法。

为什么要将key设计成ThreadLocal的弱引用?

如果ThreadLocalkey是强引用,同样会发生内存泄漏的。如果ThreadLocalkey是强引用,引用的ThreadLocal 的对象被回收了,但是ThreadLocalMap 还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,发生内存泄漏。

如果是弱引用的话,引用的ThreadLocal 的对象被回收了,即使没有手动删除,ThreadLocal也会被回收。value也会在ThreadLocalMap 调用 set()get()remove() 的时候会被清除。

所以两种方案比较下来,还是ThreadLoaclkey为弱引用好一些。

线程池

什么是线程池?为什么使用线程池

线程池是一种多线程处理形式,处理过程中将任务提交到线程池,任务的执行交给线程池来管理。

为什么使用线程池?

  • 降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度,当任务到达时,任务可以不需要等到线程创建就立即执行。
  • 提高线程的可管理性,线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配。

创建线程池的几种方法

线程池的常用创建方式主要有两种,通过Executors工厂方法创建和**通过new ThreadPoolExecutor**方法创建。

  • Executors工厂方法创建,在工具类 Executors 提供了一些静态的工厂方法
    1. newSingleThreadExecutor:创建一个单线程的线程池。
    2. newFixedThreadPool:创建固定大小的线程池。
    3. newCachedThreadPool:创建一个可缓存的线程池。
    4. newScheduledThreadPool:创建一个大小无限的线程池。
  • new ThreadPoolExecutor 方法创建: 通过new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)自定义创建

ThreadPoolExecutor构造函数的重要参数分析

三个比较重要的参数:

  • corePoolSize :核心线程数,定义了最小可以同时运行的线程数量。
  • maximumPoolSize :线程中允许存在的最大工作线程数量
  • workQueue:存放任务的阻塞队列。新来的任务会先判断当前运行的线程数是否到达核心线程数,如果到达的话,任务就会先放到阻塞队列。

其他参数:

  • keepAliveTime:当线程池中的数量大于核心线程数时,如果没有新的任务提交,核心线程外的线程不会立即销毁,而是会等到时间超过keepAliveTime时才会被销毁。
  • unitkeepAliveTime 参数的时间单位。
  • threadFactory:为线程池提供创建新线程的线程工厂。
  • handler :线程池任务队列超过 maxinumPoolSize 之后的拒绝策略

ThreadPoolExecutor的饱和策略(拒绝策略)

当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时,ThreadPoolExecutor会指定一些饱和策略。主要有以下四种类型:

  • AbortPolicy策略:该策略会直接抛出异常拒绝新任务
  • CallerRunsPolicy策略:当线程池无法处理当前任务时,会将该任务交由提交任务的线程来执行。
  • DiscardPolicy策略:直接丢弃新任务。
  • DiscardOleddestPolicy策略:丢弃最早的未处理的任务请求。

线程池的执行流程

创建线程池创建后提交任务的流程如下图所示: 在这里插入图片描述

execute()方法和submit()方法的区别

这个地方首先要知道Runnable接口和Callable接口的区别,之前有写到过

execute()submit()的区别主要有两点:

  • execute()方法只能执行 Runnable 类型的任务。submit()方法可以执行RunnableCallable 类型的任务。
  • submit()方法可以返回持有计算结果的Future 对象,同时还可以抛出异常,而execute()方法不可以。

换句话说就是,execute()方法用于提交不需要返回值的任务,submit()方法用于需要提交返回值的任务。

微信搜索公众号路人zhang,回复面试手册,领取更多高频面试题PDF版及更多面试资料。