2021-Java后端工程师面试指南-(并发-多线程)(下)

简介: 前言文本已收录至我的GitHub仓库,欢迎Star:github.com/bin39232820…种一棵树最好的时间是十年前,其次是现在

我们的ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗? 说说其类内部结构关系,聊聊它的上锁过程。

这个可能很多人不是很明白,但是我是站在被面试官问的角色,所以很多前置知识我默认你懂,嘿嘿,建议大家去看我这篇AQS

  • 首先要阐述几个概念,AQS全称是AbstractQueuedSynchronizer,这个就是我们所说所有无锁并发类的基石,其实就是一个队列,那么我们需要重点关注它其中几个字段,Node(用来封装线程的)还有State 锁的状态
  • Node类的设计,里面有几个关键的字段他其实是一个双向链表底层,有它的前驱 有它的后继,还有线程的封装
  • 我们来说说Lock的上锁过程吧
  • ReentrantLock对象首先调用lock方法,尝试去上锁,首先当然是判断是公平还是非公平的方式去加锁(默认是说的是非公平),然后CAS判断AQS里面的state的状态,如果小于等于0,说明没有人用,然后判断队列是否为空,然后CAS去创建队列等,最后成功拿到锁
  • 如果拿到锁失败,就要把当前线程封装成一个Node去入队了,但是再封装好Node之后,(如果是非公平的情况下)当前线程其实还是有一个机会尝试去拿锁的,如果这次它还失败了,那他就只能怪乖的入队了,这么做的目的还是为了减少线程切换的开销。
  • 当然aqs的东西还很多,我也是仅仅把自己懂得表述出来。


聊聊volatile吧

  • 可见性:总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
  • 内存语义,当读一个volatile变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。当写一个volatile变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。


说说线程吧,它有哪些状态

  • NEW 初始状态
  • RUNNABLE 运行状态
  • BLOCKED 阻塞状态
  • WAITING 等待状态
  • TIME_WAITING 超时等待状态
  • TERMINATED 终止状态


聊聊阻塞与等待的区别

  • 阻塞:当一个线程试图获取对象锁(非java.util.concurrent库中的锁,即synchronized),而该锁被其他线程持有,则该线程进入阻塞状态。它的特点是使用简单,由JVM调度器来决定唤醒自己,而不需要由另一个线程来显式唤醒自己,不响应中断。
  • 等待:当一个线程等待另一个线程通知调度器一个条件时,该线程进入等待状态。它的特点是需要等待另一个线程显式地唤醒自己,实现灵活,语义更丰富,可响应中断。例如调用:Object.wait()、Thread.join()以及等待Lock或Condition。等待 一个线程进入了锁,但是需要等待其他线程执行某些操作
  • 需要强调的是虽然synchronized和JUC里的Lock都实现锁的功能,但线程进入的状态是不一样的。synchronized会让线程进入阻塞态,而JUC里的Lock是用LockSupport.park()/unpark()来实现阻塞/唤醒的,会让线程进入等待态。但话又说回来,虽然等锁时进入的状态不一样,但被唤醒后又都进入runnable态,从行为效果来看又是一样的。一个线程进入了锁,但是需要等待其他线程执行某些操作


说说 sleep() 方法和 wait() 方法区别和共同点?

  • 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • wait需要配合synchronized使用


为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。


聊聊多个线程的同时访问,比如说我们的 Semaphore

  • synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • final Semaphore semaphore = new Semaphore(20) 构造方法,确定最多有多少线程资源使用的凭证,semaphore.acquire(1) 从总的那边借凭一个证过来,semaphore.release(1)释放1个凭证。


说说CountDownLatch (倒计时器)吧

  • CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕,并且他是基于AQS实现的。
  • 然后CountDownLatch里面的构造方法传的参数,其实就是设置AQS里面的state,然后它的wait方法,其实就是很简单,就是判断它的state是否为0,而且是一直自旋的判断,然后countDown方法,就是state-1,当然源码没那么简单,只是小六六大致通俗的理解


说说CyclicBarrier(循环栅栏)

  • CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。
  • CyclicBarrier 内部通过一个 count 变量作为计数器,cout 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。就像早上等地铁的限流,有木有
  • CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。


说说线程安全的容器

  • ConcurrentHashMap: 线程安全的 HashMap
  • CopyOnWriteArrayList: 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector.
  • CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。


说说Atomic原子类

  • 其实这个也没啥好说,他们其实都是基于CAS实现的一些原子类,用法就是很简单,拿来就可以用
  • AtomicInteger AtomicLongArray AtomicReferenceArray AtomicReference等等。


聊聊ThreadLocal吧

  • 它的设计作用是为每一个使用该变量的线程都提供一个变量值的副本,每个线程都是改变自己的副本并且不会和其他线程的副本冲突,这样一来,从线程的角度来看,就好像每个线程都拥有了该变量。
  • 首先哈 我们new 一个ThreadLocal变量,然后呢调用它的set方法,此时就会获取当前线程,然后通过当前线程获取到ThreadLocalMap,然后既然是一个Map,直接调用set方法,key就是当前Threadlocal实例,value就是要存入的值。这样就可以实现每个线程的数据隔离


那你说说为啥它要搞得这么复杂,它为啥不之前用当前线程当key,然后value当值来设计呢?

如果是这种设计的话,那么这样的话如果线程很多的话,那么这个map就会很大,会随线程数增加,而我这样设计的Thread里面ThreadLocalMap的大小就跟线程多小没有关系了,还是和需要存的东西有关了


既然你说ThreadLocalMap是一个Map那你聊聊他底层是怎么样的,他的hash碰撞是怎么处理的

  • HashMap中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。
  • 而ThreadLocalMap中并没有链表结构,他只有数组,他的实现就是也是hash嘛,然后碰到冲突之后,那就接着往下遍历嘛,数组的遍历,找到一个不为null的地方,或者相同的去插入就好了,因为还是要判断equals方法的嘛,哈哈说的很简单,但是源码还是复杂的一批哦
  • 还有就是使用完成之后,记得remove一下。


说说Callable与Runnable Future

  • java.lang.Runnable是一个接口,在它里面只声明了一个run()方法,run返回值是void,任务执行完毕后无法返回任何结果
  • Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法叫做call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型
  • 首先呢?future 是多线程有返回结果的一种,它的使用方式,第一种就是callback,第二种就是futureTask


了解CompletableFuture,说说它的用法

  • 这个是Java8的特性,为了弥补Future的缺点,即异步的任务完成后,需要用其结果继续操作时,无需等待。可以直接通过thenAccept、thenApply、thenCompose等方式将前面异步处理的结果交给另外一个异步事件处理线程来处理。这种方式才是我们需要的异步处理。一个控制流的多个异步事件处理能无缝的连接在一起。
  • 在Java8中,CompletableFuture提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力,可以通过回调的方式处理计算结果,也提供了转换和组合 CompletableFuture 的方法。
  • 具体用法,比如消费一个线程的结果,转换,聚合等等。


了解线程池嘛,说说线程池的好处

  • 池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。


聊聊线程池ThreadPoolExecutor,它的参数的意义

  • corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
  • keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。有拒绝(抛出异常),或者不处理,或者放弃队列中第一个任务,执行当前任务


ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?

  • 线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。
  • 线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转。
  • 线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
  • 线程池内部使用一个变量ctl维护两个值:运行状态(runState)和线程数量 (workerCount)。在具体实现中,线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起,高3位保存runState,低29位保存workerCount.


线程池的任务执行机制(当一个任务加入到线程池中的过程)

  • 首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
  • 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
  • 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
  • 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
  • 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。


说说线程池在各个业务场景的使用。

  • 快速响应用户请求

这个得结合业务来说了,小六六公司是在线教育嘛,然后有在线网校,其实就这个东西类似于电商,网校就是卖的在线课程,直播,录播,课程,题库这些东西,然后就是一门课程详情的时候,要涉及订单,商品,课程,多个服务,然后组装数据给前端展现,那么对于用户来说,当然是希望越快出现这个界面越好,如果太久了,可能我就没有心思去看了,那么我们就可以用线程池的方式去请求各个服务,来缩短请求时间嘛,这个对线程池的要求是什么呢?这个场景最重要的就是获取最大的响应速度去满足用户,所以应该少设置队列的size,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。


  • 快速处理批量任务

就好比我们的题库,那学生刷题之后,我们是不是得在后台统计,各种完成率,达标率,涉及到学员端的,还有可能是这个老师所带的班级的等等,这种批量业务,这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。


那我们应该去设计一个系统的线程池呢?它的最佳实践是什么呢?

  • 设计线程池的时候,一定要用ThreadPoolExecutor,避免使用Executors  类的 newFixedThreadPool 和 newCachedThreadPool ,因为可能会有 OOM 的风险。
  • 监测线程池运行状态,我们通过ThreadPoolExecutor 可以实时查到当前线程的状态,我们可以写个接口,把他接入到我们系统监控里面
  • 记得给线程设置名称
  • 美团的骚操作,这个真的可以,小六六照着美团大佬给的思路,自己玩了玩。真香,它就是线程池参数动态化?
  • 这个是啥意思呢?就是说,我们一开始的时候,我们并不知道这个系统的线程池参数的最佳实践,打个比方哈,比如我这有一批业务需要线程池的线程去处理,然后我就设置了很多核心线程,和最大线程,但是我处理这个服务的过程中,我还需要对接其他的下游业务,如果他处理不过来,那不是把人家的服务搞蹦了,对吧,还有各种不同的问题,美团技术团队,为了应付这种极端的业务场景,设计了这个 线程池参数动态的方案来应对极端情况
  • 那么方案呢其实很简单,JDK允许线程池使用方通过ThreadPoolExecutor的实例来动态设置线程池的核心策略,以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idle的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务。这个就很简单了,那么直接可以用nacos分布式配置中心,把需要设置的东西放到配置里面,这样就可以做到动态更新这些参数了,比如 核心线程数和最大线程数
  • 还有一个点,就是我们怎么设置队列的大小呢?因为源码中并不能设置,因为队列里面的size字段竟然是fianl修饰的,哈哈,以为这样就可以拦住我们了嘛,聪明的我们,把源码拷出一个类了,把fianl去掉,然后提供这个字段的get set方法,然后变成从配置读取。。嘿嘿,是不是思路清晰。


结束


并发多线程,就差不多了,下篇不出意外就是JVM,JVM其实对于我们Java开发来说也是比较重要的一个知识点了。

相关文章
|
4天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
23 9
|
7天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
4天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
7天前
|
Java
JAVA多线程通信:为何wait()与notify()如此重要?
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是实现线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件满足时被唤醒,从而确保数据一致性和同步。相比其他通信方式,如忙等待,这些方法更高效灵活。 示例代码展示了如何在生产者-消费者模型中使用这些方法实现线程间的协调和同步。
21 3
|
6天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
7天前
|
Java
java小知识—进程和线程
进程 进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程 线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比
17 1
|
7天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。
|
3月前
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
8天前
|
存储 算法 Java
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
本文详解自旋锁的概念、优缺点、使用场景及Java实现。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:什么是自旋锁?Java 实现自旋锁的原理?
|
9天前
|
存储 缓存 Java
大厂面试必看!Java基本数据类型和包装类的那些坑
本文介绍了Java中的基本数据类型和包装类,包括整数类型、浮点数类型、字符类型和布尔类型。详细讲解了每种类型的特性和应用场景,并探讨了包装类的引入原因、装箱与拆箱机制以及缓存机制。最后总结了面试中常见的相关考点,帮助读者更好地理解和应对面试中的问题。
33 4