2021-Java后端工程师面试指南-(并发-多线程)#yyds干货盘点#

Posted wx61a9b299d99e6

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2021-Java后端工程师面试指南-(并发-多线程)#yyds干货盘点#相关的知识,希望对你有一定的参考价值。

前言

Tips

面试指南系列,很多情况下不会去深挖细节,是小六六以被面试者的角色去回顾知识的一种方式,所以我默认大部分的东西,作为面试官的你,肯定是懂的。

上面的是脑图地址

叨絮

可能大家觉得有点老生常谈了,确实也是。面试题,面试宝典,随便一搜,根本看不完,也看不过来,那我写这个的意义又何在呢?其实嘛我写这个的有以下的目的

  • 第一就是通过一个体系的复习,让自己前面的写的文章再重新的过一遍,总结升华嘛
  • 第二就是通过写文章帮助大家建立一个复习体系,我会将大部分会问的的知识点以点带面的形式给大家做一个导论

然后下面是前面的文章汇总

  • 2021-Java后端工程师面试指南-(Java基础篇)

    今天来看看多线程的,这块是重点,也是难点,硬核有点多哈哈。

    并发

    记得阿里的第一个题就是面的并发,哈哈 这个小六六得好好总结了。

    聊聊Java的并发模型

    这个为啥是第一个问题,肯定是有原因的,如果连Java的并发模型都不清楚,你跟我扯一堆的锁,一堆的juc有啥用呢?

  • Java并发 采用的是 共享内存模型,Java线程之前的通信总是隐式进行的。

    • Java线程通信由Java内存模型(简称 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象角度看,JMM定义了 线程 和 主内存 之间的抽象关系:线程之间的共享变量储存在主内存中,每个线程都有一个私有的本地内存,本地内存储存了 该线程 以读共享变量的副本。

    对多线程了解吗,说说你平时怎么对临界资源的访问控制的。

    其实这个题就是一个引人,由浅入深的过程,

  • 如果对应的临界资源是在单JVM的进程中,那么我们可以用Synchronized和lock
  • 对于分布式环境下的多线程中,那么就得用上分布式锁(redis 或者zookeeper实现)

    那么聊聊你对Synchronized的认识吧

  • synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
    • synchronized 最主要三种用法
    • 修饰实例方法 要获得当前对象实例的锁
    • 修饰静态方法 获得当前类对象的锁
    • 修饰代码块 synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
    • synchronized 关键字最主要的二种底层实现方式:
    • synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
    • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

聊聊Java对象的布局

  • 首先我们知道Java对象分布由三个部分组成 对象头、实例数据、对对齐填充字节,下面我们来一个个说说
    • 对象头的组成由 Mark Word、类元数据的指针(Klass Pointer)、数组长度(不一定有),在64位Java虚拟机里面的Mark word 包含了我们的 hashcode的值 我们的分代年龄 锁标志位等
    • 实例数据 并不是所有的变量都存放在这里,对象的的所有成员变量以及其父类的成员变量是存放在这里的。
    • JVM要求Java对象的大小必须是8byte的倍数,所以这个的作用就是把对象的大小补齐至8byte的倍数。

那你说说Synchronized锁升级的过程吧

锁级别从低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

小六六在这给大家明确的一点就是,Synchronized锁的是对象而不是其包裹的代码。

  • 对象被new出来后,没有任何线程持有这个对象的锁,这时就是无锁状态;Mark Word锁标识是01
  • 当且仅当只有一个线程A获取到这个对象的锁的时候,对象就会从无锁状态升级成为偏向锁,Mark Word中就会记录这个线程的标识(锁标识是01),此时线程A持有这个对象的锁;
  • 还是这个线程A再次获取这个对象的锁时,发现他是一个偏向锁,并且对象头中记录着自己的线程标识,那么线程A就继续使用这把锁(不需要cas去获取锁了)。这里也是锁的可重入性,所以,synchronized也是可重入锁;
  • 在线程A持有锁的时候,线程B也来争抢这把锁了,线程B发现这是一把偏向锁,并且对象头中的线程标识不是自己。那么首先进行偏向锁的撤销过程,然后偏向锁就会升级为轻量级锁,此时Mark Word 的锁标识是00
  • 又有一些线程来争抢这个轻量级锁了,争抢的过程其实就是利用CAS自旋。为了避免长时间自旋消耗CPU资源,当自旋超过10次的时候,轻量级锁升级为重量级锁(其他线程阻塞,不会耗费cpu)。此时Mark Word的锁标识是10

可以聊聊CAS吗,它有什么问题吗?

CAS,compare and swap的缩写,就是一个保证原子性的一个手段,CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

问题

  • ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
  • 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

    可以说说Synchronized和Lock的区别嘛

  • 首先synchronized是java内置关键字,在jvm层面,Lock是个java类;
  • synchronized会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁;
  • synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可)
  • synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。
  • 而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。

既然提到了Lock,那我们来聊聊他最常用的实现ReentrantLock吧,说说的公平和非公平是怎么实现的,他们的哪个效率高,默认是哪个,又是怎么现实可重入的

  • 首先公平和非公平是指多线程下各线程获取锁的顺序,先到的线程优先获取锁,而非公平锁则无法提供这个保障,但是呢 我们知道ReentrantLock的非公平实现,其实并不是随机的,它是有一定顺序的非公平,举个非公平的例子,假设A来获取锁,如果A获得了锁,此时B来获取锁,然后B失败了,B就去队列等待,此时C来了,然后C也失败了,他也去等待,此时D过来了,然后A释放了锁,那你说如果是绝对公平的话这个时候应该是B获取锁才对,但是源码中是D此时有机会去获取锁,所以它是一定顺序的非公平,非公平锁效率高于公平锁的,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销,所以默认是非公平的锁。而我们的Synchronized也是非公平的
  • 可重入是指,当我一个线程获取了这把锁,下次当前线程再释放锁之前再去获取锁的时候是可以成功,这就是可重入锁,Lock 的实现是判断是当前线程的时候,给锁状态+1,然后我们的Synchronized也是可重入的

我们的ReentrantLock的核心是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当值来设计呢?

    如果是这种设计的话,意味着一个线程只能有一个线程本地变量,存在限制。而以ThreadLocal为key不存在该问题。

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

日常求赞

以上是关于2021-Java后端工程师面试指南-(并发-多线程)#yyds干货盘点#的主要内容,如果未能解决你的问题,请参考以下文章

2021-Java后端工程师面试指南-(MySQL)

2021-Java后端工程师面试指南-(JVM)

2021-Java后端工程师面试指南-(Redis)

2021-Java后端工程师面试指南-(Elasticsearch)

2021-Java后端工程师面试指南-(Java基础篇)

2021-Java后端工程师面试指南-(Redis)