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开发来说也是比较重要的一个知识点了。

相关文章
|
3天前
|
Java
Java Socket编程与多线程:提升客户端-服务器通信的并发性能
【6月更文挑战第21天】Java网络编程中,Socket结合多线程提升并发性能,服务器对每个客户端连接启动新线程处理,如示例所示,实现每个客户端的独立操作。多线程利用多核处理器能力,避免串行等待,提升响应速度。防止死锁需减少共享资源,统一锁定顺序,使用超时和重试策略。使用synchronized、ReentrantLock等维持数据一致性。多线程带来性能提升的同时,也伴随复杂性和挑战。
|
1天前
|
Java
Java中,有两种主要的方式来创建和管理线程:`Thread`类和`Runnable`接口。
【6月更文挑战第24天】Java创建线程有两种方式:`Thread`类和`Runnable`接口。`Thread`直接继承受限于单继承,适合简单情况;`Runnable`实现接口可多继承,利于资源共享和任务复用。推荐使用`Runnable`以提高灵活性。启动线程需调用`start()`,`Thread`直接启动,`Runnable`需通过`Thread`实例启动。根据项目需求选择适当方式。
9 2
|
1天前
|
Java
在Java中,死锁是指两个或多个线程互相等待对方释放资源,从而导致所有线程都无法继续执行的情况。
【6月更文挑战第24天】在Java并发中,死锁是多线程互相等待资源导致的僵局。避免死锁的关键策略包括:防止锁嵌套,设定固定的加锁顺序,使用`tryLock`带超时,避免无限等待,减少锁的持有时间,利用高级同步工具如`java.util.concurrent`,以及实施死锁检测和恢复机制。通过这些方法,可以提升程序的并发安全性。
6 1
|
3天前
|
安全 Java 程序员
Java多线程详解
Java多线程详解
|
4天前
|
缓存 安全 Java
Java线程面试题含答案
Java线程面试题含答案
|
2天前
|
Java Android开发 Kotlin
Android面试题:App性能优化之Java和Kotlin常见的数据结构
Java数据结构摘要:ArrayList基于数组,适合查找和修改;LinkedList适合插入删除;HashMap1.8后用数组+链表/红黑树,初始化时预估容量可避免扩容。SparseArray优化查找,ArrayMap减少冲突。 Kotlin优化摘要:Kotlin的List用`listOf/mutableListOf`,Map用`mapOf/mutableMapOf`,支持操作符重载和扩展函数。序列提供懒加载,解构用于遍历Map,扩展函数默认参数增强灵活性。
10 0
|
3天前
|
Java API
|
2天前
|
前端开发 JavaScript API
探索现代Web开发中的动态数据交互——前端与后端整合实战
本文探讨了现代Web开发中前端与后端整合的关键技术点,通过实际案例演示了如何利用JavaScript和Node.js实现动态数据交互,全面解析从数据请求到响应的全过程。
|
5天前
|
中间件 Go
go语言后端开发学习(三)——基于validator包实现接口校验
go语言后端开发学习(三)——基于validator包实现接口校验
|
22小时前
|
消息中间件 Java Spring
JavaWeb后端开发Spring框架之消息 消息队列案例--订单短信通知
JavaWeb后端开发Spring框架之消息 消息队列案例--订单短信通知
9 0