张三求职日记--面试题篇--并发编程

23 阅读7分钟

张三求职日记--面试题篇--并发编程封面图.jpg

进程和线程

进程可以认为是执行中的程序,线程是处理器执行任务的基本单位。可认为是一次任务执行是一个进程,一个进程里可以启多个线程。
JDK21里引入了协程(虚拟线程)的概念,协程可以认为是轻量级的线程,由JVM而不是操作系统管理,内存开销极小,可轻松创建百万级的规模。

如何创建线程

通常说创建线程有4种方式:1.继承Thread类 2.实现Runnable接口 3.实现Callable接口 4.使用线程池。
往底层追溯一下,Thread类的底层就是实现Runnable接口并重写了run方法,而Runnable和Callable的区别主要在于有无返回值,是否需要进行阻塞,而使用多线程创建的会根据线程池的提交方式不同选择创建Runnable和Callable任务。
往外扩展一下,我们最简化可通过lambda来创建Runnable活Callable线程,这么做可行的原因是lambda表达式可以简化匿名内部类的写法,线程可通过匿名内部类创建。

线程的生命周期

通常认为线程的生命周期有如下几个新建、就绪、运行、阻塞、等待、终止

线程池的参数

线程池(ThreadPoolExecutor)常规说法是7大参数:核心线程数、最大线程数、超时时间、超时时间单位、线程队列、线程工厂、拒绝策略。
1.核心线程数(corePoolSize):线程池管理保留的最小的线程数。通常线程池管理的线程数就是核心线程数。
2.最大线程数(maximumPoolSize):线程池可创建的最大的线程数,当阻塞队列满了时候,才开始创建新的线程。

  • 疑问(无固定答案):那么阻塞队列满了不是有可能被拒绝策略拒绝掉吗?

3.超时时间(keepAliveTime):线程池中没有任务时线程销毁的时间。
4.超时时间单位(unit):TimeUnit枚举,从纳秒到天级都有。
5.线程队列(workQueue):BlockingQueue对象,用于存放没有来得及处理的线程对象。

  • 存的是Runnable那对于Callable线程是怎么实现的? --通过FutureTask适配器来实现

6.线程工厂(threadFactory):自己实现的话通常用于定义线程名字和异常处理。
7.拒绝策略(handler):参数为RejectedExecutionHandler的实现类,存在如下几种拒绝策略,需根据实际业务选择最合适的拒绝策略。

  • AbortPolicy:默认策略,超出队列直接抛出异常。
  • CallerRunsPolicy:直接执行被拒绝的策略。
  • DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试提交新任务。
  • DiscardPolicy:丢弃被拒绝的策略。

什么是死锁,如何定位死锁问题

简单来说就是A持有1锁请求2锁,B持有2锁请求1锁。
满足死锁的4个条件:互斥、占有且等待、不可抢占、循环等待。写代码时不要让你的代码同时满足这些条件。
定位死锁问题:1.使用jstack命令定位死锁问题。2.通过jconsole、VisualVM等工具定位死锁问题。

synchronized和ReentrantLock的区别

synchronized:是一个关键字,可以给普通方法、静态方法和代码块加锁,当锁的是普通方法或者普通对象时锁的是对象,当锁的是静态方法或者类.class时锁的是类。
ReentrantLock:为JUC中的并发类,通过lock()、tryLock()方法加锁,通过unlock释放锁。ReentrantLock底层使用到了Sync内部类,而Sync继承于AQS。 ReentrantLock可以灵活指定加锁和释放锁的位置,也可以灵活选择加锁的时机。通过ReentrantLock可以选择创建公平锁(默认与synchronized一样是非公平的)。
两者的区别:

  1. synchronized可用来修饰普通方法、静态方法和代码块。ReentrantLock只能用于代码块。
  2. synchronized是自动加锁和释放锁的。ReentrantLock是手动加锁和释放锁的。
  3. synchronized是非公平的,ReentrantLock可以选择是公平还是非公平的。
  4. synchronized不能响应中断,ReentrantLock可以响应中断。
  5. synchronized底层是通过JVM的Monitor实现的,ReentrantLock是通过AQS实现的。

volatile的作用(为什么不能保证线程安全)

作用:1.声明变量的可见性 2.禁止指令重排
为什么不能保证线程安全:volatile并不能保证原子性,非原子操作如并发读并修改时可能导致修改的值不对。

什么是CAS

CAS即Compare and Swap,是一种轻量级的保证线程安全的方法,是一种乐观锁的实现。基本思路就是线程开始时先存放处理前的值,处理完之后修改前对比一下当前值与处理前的值是否相等,只有相等时才进行修改。
直接使用这种思想来实现CAS可能会导致ABA的问题,即可能虽然值和之前相等,但其实数据已经被改过了,为了解决这种问题,通常会引入一个版本号的概念,用于保证对比的就是最开始需要比较的数据。

什么是AQS

AQS即AbstractQueuedSynchronizer,是JUC中的一个非常重要的抽象类,是ReentrantLock、CountDownLatch、Semaphore等并发工具的重要底层实现。
AQS源码中有一个重要属性state,它是指定线程获取锁成功的数量,像是ReentrantLock与Semaphore最主要的区别就是state的值不同,ReentrantLock最多只允许一个线程获取锁,而Semaphore允许指定数量。
AQS的底层是一个双向链表,用于存放阻塞的线程,存在head和tail节点,这么设计可以灵活的控制线程的等待与唤醒。
AQS通过hasQueuedPredecessors控制线程的公平策略。

使用过什么JUC并发类

实际肯定不止以下这些,这里例举几个比较重要的

  1. ConcurrentHashMap:并发场景下用于替换HashMap的并发类,数据结构与HashMap一致,JDK8之前采用分段锁进行实现,JDK8之后采用CAS+synchronized实现,细粒度加锁来保证线程安全。
  2. CopyOnWriteArrayList:并发场景用于替代ArrayList的并发类,读操作无锁,写操作需要复制数组。
  3. CountDownLatch:用于等待一组线程完成,一般在主线程中await(),在子线程中countDown()。
  4. Semaphore:用于控制并发执行的线程数,可通过acquire()获取资源许可,通过release()释放许可。
  5. ReentrantLock:前面已介绍。
  6. ThreadPoolExecutor:前面已介绍。
  7. AtomicInteger等原子类:通过CAS机制来保证线程安全。

HashMap为什么线程不安全,ConcurrentHashMap如何保证线程安全

JDK1.7及之前,数据插入采用头插法,在并发插入或者扩容的时候可能出现循环链表。
JDK1.8之后将头插法改为了尾插法,虽然改进了此问题,但在扩容时并发操作仍可能导致树结构破坏,另外,在并发插入到空链表中的场景,可能出现数值覆盖的问题。
而ConcurrentHashMap是并发场景下安全的HashMap。
在JDK1.7及之前,ConcurrentHashMap采用分段锁的思路,对每个Segment用ReentrantLock进行加锁。
而在JDK1.8之后,ConcurrentHashMap对每个列表通过CAS和synchronize进行加锁。

如何并发修改HashMap的value

最容易想到的方式是:对需要并发修改方法加同步锁,但是性能不行。
比较合适的方案是:改为使用ConcurrentHashMap,但是ConcurrentHashMap只能保证并发修改不会发生异常,但put()操作不是原子操作,在并发场景下直接put()仍有可能出现覆盖数据的情况,需要通过compute()方法来保证安全的修改。
还可能使用的方式是:将value使用原子类来进行原子计算。

生产者消费者模型

可通过wait(),notifyAll()模型来实现生产者和消费者模型,大致思路是,生产货物数到一定程度时,对生产线程进行wait(),通过notifyAll()唤醒消费者线程,当消费货物到一定数量,对生产线程进行wait(),通过notifyAll()唤醒生产者线程。

多线程循环打印ABC

可通过wait(),notifyAll()模型来实现,判断不需要打印的时候进行wait(),判断需要打印的时候进行打印并触发notifyAll()。