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 + Spring Boot后端框架,搭配Vue2前端技术,支持SaaS云部署
智慧班牌系统是一款基于信息化与物联网技术的校园管理工具,集成电子屏显示、人脸识别及数据交互功能,实现班级信息展示、智能考勤与家校互通。系统采用Java + Spring Boot后端框架,搭配Vue2前端技术,支持SaaS云部署与私有化定制。核心功能涵盖信息发布、考勤管理、教务处理及数据分析,助力校园文化建设与教学优化。其综合性和可扩展性有效打破数据孤岛,提升交互体验并降低管理成本,适用于日常教学、考试管理和应急场景,为智慧校园建设提供全面解决方案。
60 14
|
28天前
|
缓存 安全 Java
java面试-基础语法与面向对象
本文介绍了 Java 编程中的几个核心概念。首先,详细区分了方法重载与重写的定义、发生阶段及规则;其次,分析了 `==` 与 `equals` 的区别,强调了基本类型和引用类型的比较方式;接着,对比了 `String`、`StringBuilder` 和 `StringBuffer` 的特性,包括线程安全性和性能差异;最后,讲解了 Java 异常机制,包括自定义异常的实现以及常见非检查异常的类型。这些内容对理解 Java 面向对象编程和实际开发问题解决具有重要意义。
54 15
|
1月前
|
Java API Docker
在线编程实现!如何在Java后端通过DockerClient操作Docker生成python环境
以上内容是一个简单的实现在Java后端中通过DockerClient操作Docker生成python环境并执行代码,最后销毁的案例全过程,也是实现一个简单的在线编程后端API的完整流程,你可以在此基础上添加额外的辅助功能,比如上传文件、编辑文件、查阅文件、自定义安装等功能。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
在线编程实现!如何在Java后端通过DockerClient操作Docker生成python环境
|
2月前
|
监控 前端开发 Java
构建高效Java后端与前端交互的定时任务调度系统
通过以上步骤,我们构建了一个高效的Java后端与前端交互的定时任务调度系统。该系统使用Spring Boot作为后端框架,Quartz作为任务调度器,并通过前端界面实现用户交互。此系统可以应用于各种需要定时任务调度的业务场景,如数据同步、报告生成和系统监控等。
72 9
|
2月前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
168 14
|
1月前
|
JavaScript Java Docker
干货含源码!如何用Java后端操作Docker(命令行篇)
只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
11月前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【2月更文挑战第22天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个主题,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。
80 0
|
11月前
|
存储 安全 Java
深入理解Java并发编程:线程安全与锁机制
【5月更文挑战第31天】在Java并发编程中,线程安全和锁机制是两个核心概念。本文将深入探讨这两个概念,包括它们的定义、实现方式以及在实际开发中的应用。通过对线程安全和锁机制的深入理解,可以帮助我们更好地解决并发编程中的问题,提高程序的性能和稳定性。
|
8月前
|
存储 安全 Java
解锁Java并发编程奥秘:深入剖析Synchronized关键字的同步机制与实现原理,让多线程安全如磐石般稳固!
【8月更文挑战第4天】Java并发编程中,Synchronized关键字是确保多线程环境下数据一致性与线程安全的基础机制。它可通过修饰实例方法、静态方法或代码块来控制对共享资源的独占访问。Synchronized基于Java对象头中的监视器锁实现,通过MonitorEnter/MonitorExit指令管理锁的获取与释放。示例展示了如何使用Synchronized修饰方法以实现线程间的同步,避免数据竞争。掌握其原理对编写高效安全的多线程程序极为关键。
105 1
|
11月前
|
缓存 安全 Java
Java并发编程中的线程安全问题及解决方法
在Java编程中,线程安全是一个至关重要的问题,特别是在并发编程中。本文将探讨Java并发编程中常见的线程安全问题,包括数据竞争、死锁和内存可见性,并介绍了相应的解决方法,如使用同步锁、并发容器和原子类等技术,以确保多线程环境下程序的正确性和性能。
109 29

热门文章

最新文章