写点什么

阿里面试官首次分享完整版多线程核心题,你准备好跳槽了吗?

发布于: 2021 年 04 月 29 日
阿里面试官首次分享完整版多线程核心题,你准备好跳槽了吗?

今日分享开始啦,请大家多多指教~


今天给大家分享的是比较全面的多线程面试题,大家在面试的过程中不免会被问到很多专业性的问题,有的时候回答的并不是那么全面和精细,这仅仅代表个人观点。

1. 如何预防死锁?

1.首先需要将死锁发生的是个必要条件讲出来:

  • 互斥条件 同一时间只能有一个线程获取资源。

  • 不可剥夺条件 一个线程已经占有的资源,在释放之前不会被其它线程抢占

  • 请求和保持条件 线程等待过程中不会释放已占有的资源

  • 循环等待条件 多个线程互相等待对方释放资源

2.死锁预防,那么就是需要破坏这四个必要条件:

  • 由于资源互斥是资源使用的固有特性,无法改变,我们不讨论

  • 破坏不可剥夺条件

一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式地释放重新加入到系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行

  • 破坏请求与保持条件

第一种方法静态分配即每个进程在开始执行时就申请他所需要的全部资源,

第二种是动态分配即每个进程在申请所需要的资源时他本身不占用系统资源

  • 破坏循环等待条件

采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少地采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。

2. 多线程有哪几种创建方式?

  1. 实现 Runnable,Runnable 规定的方法是 run(),无返回值,无法抛出异常

  2. 实现 Callable,Callable 规定的方法是 call(),任务执行后有返回值,可以抛出异常

  3. 继承 Thread 类创建多线程:继承 java.lang.Thread 类,重写 Thread 类的 run()方法,在 run()方法中实现运行在线程上的代码,调用 start()方法开启线程。

  4. Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过 Thread 类的 start()实例方法。start()方法是一个 native 方法,它将启动一个新线程,并执行 run()方法

  5. 通过线程池创建线程. 线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池。

3. 描述一下线程安全活跃态问题,竞态条件?

线程安全的活跃性问题可以分为 死锁、活锁、饥饿

1.活锁 就是有时线程虽然没有发生阻塞,但是仍然会存在执行不下去的情况,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且一直失败重试

  • 我们开发中使用的异步消息队列就有可能造成活锁的问题,在消息队列的消费端如果没有正确的 ack 消息,并且执行过程中报错了,就会再次放回消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的 ack 之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。

  • 解决活锁的方案很简单,尝试等待一个随机的时间就可以,会按时间轮去重试

2.饥饿 就是 线程因无法访问所需资源而无法执行下去的情况,

饥饿分为两种情况:

  • 一种是其他的线程在临界区做了无限循环或无限制等待资源的操作,让其他的线程一直不能拿到锁进入临界区,对其他线程来说,就进入了饥饿状态

  • 另一种是因为线程优先级不合理的分配,导致部分线程始终无法获取到 CPU 资源而一直无法执行

3.解决饥饿的问题有几种方案:

  • 保证资源充足,很多场景下,资源的稀缺性无法解决

  • 公平分配资源,在并发编程里使用公平锁,例如 FIFO 策略,线程等待是有顺序的,排在等待队列前面的线程会优先获得资源

  • 避免持有锁的线程长时间执行,很多场景下,持有锁的线程的执行时间也很难缩短

4.死锁 线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁

  • 线程安全的竞态条件问题

同一个程序多线程访问同一个资源,如果对资源的访问顺序敏感,就称存在竞态条件,代码区成为临界区。 大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序

  • 最常见的竞态条件为

  1. 先检测后执行执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题,见一种可能 的解决办法就是:在一个线程修改访问一个状态时,要防止其他线程访问修改,也就是加锁机制,保证原子性

  2. 延迟初始化(典型为单例)

4. Java 中的 wait 和 sleep 的区别与联系?

1.所属类: 首先,这两个方法来自不同的类分别是 Thread 和 Object ,wait 是 Object 的方法,sleep 是 Thread 的方法

sleep 方法属于 Thread 类中方法,表示让一个线程进入睡眠状态,等待一定的时间之后,自动醒来进入到可运行状态,不会马上进入运行状态,因为线程调度机制恢复线程的运行也需要时间,一个线程对象调用了 sleep 方法之后,并不会释放他所持有的所有对象锁,所以也就不会影响其他进程对象的运行。但在 sleep 的过程中过程中有可能被其他对象调用它的 interrupt(),产生 InterruptedException 异常,如果你的程序不捕获这个异常,线程就会异常终止,进入 TERMINATED 状态,如果你的程序捕获了这个异常,那么程序就会继续执行 catch 语句块(可能还有 finally 语句块)以及以后的代码

2.作用范围: sleep 方法没有释放锁,只是休眠,而 wait 释放了锁,使得其他线程可以使用同步控制块或方法

3.使用范围: wait,notify 和 notifyAll 只能在同步控制方法或者同步控制块里面使用,而 sleep 可以在任何地方使用

4.异常范围: sleep 必须捕获异常,而 wait,notify 和 notifyAll 不需要捕获异常

5. 描述一下进程与线程区别?

1.进程(Process)

是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。总结: j 进程是指在系统中正在运行的一个应用程序;程序一旦运行就是进程;进程——资源分配的最小单位

2.线程

操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。总结: 系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。线程——程序执行的最小单位

6. 描述一下 Java 线程的生命周期?

大致包括 5 个阶段

  • 新建 就是刚使用 new 方法,new 出来的线程;

  • 就绪 就是调用的线程的 start()方法后,这时候线程处于等待 CPU 分配资源阶段,谁先抢的 CPU 资源,谁开始执行;

  • 运行 当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run 方法定义了线程的操作和功能;

  • 阻塞 在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如 sleep()、wait()之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用 notify 或者 notifyAll()方法。唤醒的线程不会立刻执行 run 方法,它们要再次等待 CPU 分配资源进入运行状态;

  • 销毁 如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源

1.按 JDK 的源码分析来看,Thread 的状态分为:

  • NEW: 尚未启动的线程的线程状态

  • RUNNABLE: 处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自操作系统(例如处理器)的其他资源

  • BLOCKED: 线程的线程状态被阻塞,等待监视器锁定。处于阻塞状态的线程正在等待监视器锁定以输入同步的块方法或在调用后重新输入同步的块方法,通过 Object#wait()进入阻塞

  • WAITING:处于等待状态的线程正在等待另一个线程执行特定操作:例如: 在对象上调用了 Object.wait()的线程正在等待另一个线程调用 Object.notify() 或者 Object.notifyAll(), 调用了 Thread.join()的线程正在等待指定的线程终止

  • TIMED_WAITING : 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,因此线程处于定时等待状态:

  • Thread.sleep(long)

  • Object#wait(long)

  • Thread.join(long)

  • LockSupport.parkNanos(long…)

  • LockSupport.parkUntil(long…)

2.TERMINATED: 终止线程的线程状态。线程已完成执行

7. 程序开多少线程合适?

1.这里需要区别下应用是什么样的程序:

CPU 密集型程序,一个完整请求,I/O 操作可以在很短时间内完成, CPU 还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分,线程等待时间接近 0

  • 单核 CPU: 一个完整请求,I/O 操作可以在很短时间内完成, CPU 还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分,线程等待时间接近 0。单核 CPU 处 理 CPU 密集型程序,这种情况并不太适合使用多线程

  • 多核 : 如果是多核 CPU 处理 CPU 密集型程序,我们完全可以最大化地利用 CPU 核心数,应用并发编程来提高效率。CPU 密集型程序的最佳线程数就是:因此对于 CPU 密集型来说,理论上 线程数量 = CPU 核数(逻辑),但是实际上,数量一般会设置为 CPU 核数(逻辑)+ 1(经验值)计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下 CPU 周期不会中断工作

2.I/O 密集型程序,与 CPU 密集型程序相对,一个完整请求,CPU 运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分,等待时间较长,线程等待时间所占比例越高,需要越多线程;线程 CPU 时间所占比例越高,需要越少线程

  • I/O 密集型程序的最佳线程数就是: 最佳线程数 = CPU 核心数 (1/CPU 利用率) =CPU 核心数 (1 + (I/O 耗时/CPU 耗时))

  • 如果几乎全是 I/O 耗时,那么 CPU 耗时就无限趋近于 0,所以纯理论你就可以说是 2N(N=CPU 核数),当然也有说 2N + 1 的,1 应该是 backup

  • 一般我们说 2N + 1 就即可

8. 描述一下 notify 和 notifyAll 区别?

1.首先最好说一下 锁池 和 等待池 的概念

  • 锁池:假设线程 A 已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个 synchronized 方法(或者 synchronized 块),由于这些线程在进入对象的 synchronized 方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程 A 拥有,所以这些线程就进入了该对象的锁池中。

  • 等待池:假设一个线程 A 调用了某个对象的 wait()方法,线程 A 就会释放该对象的锁(因为 wait()方法必须出现在 synchronized 中,这样自然在执行 wait()方法之前线程 A 就已经拥有了该对象的锁),同时线程 A 就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的 notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的 notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.

2.如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁

3.当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了 notify 后只要一个线程会由等待池进入锁池,而 otifyAll 会将该对象等待池内的所有线程移动到锁池中,等待锁竞争

4.所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify 只会唤醒一个线程。

9. 描述一下 synchronized 和 lock 区别 ?

如下表

可以再多说下 synchronized 的 加锁流程

由于 HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低从而引入偏向锁。偏向锁在获取资源的时候会在锁对象头上记录当前线程 ID,偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断锁对象头中线程 ID 是否为自己,如果是当前线程重入,直接进入同步操作,不需要额外的操作。默认在开启偏向锁和轻量锁的情况下,当线程进来时,首先会加上偏向锁,其实这里只是用一个状态来控制,会记录加锁的线程,如果是线程重入,则不会进行锁升级。获取偏向锁

流程:

1. 判断是否为可偏向状态--MarkWord 中锁标志是否为‘01’,是否偏向锁是否为‘1’

2. 如果是可偏向状态,则查看线程 ID 是否为当前线程,如果是,则进入步骤 'V',否则进入步骤‘III’

3. 通过 CAS 操作竞争锁,如果竞争成功,则将 MarkWord 中线程 ID 设置为当前线程 ID,然后执行‘V’;竞争失败,则执行‘IV’

4. CAS 获取偏向锁失败表示有竞争。当达到 safepoint 时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块

5. 执行同步代码

轻量级锁是相对于重量级锁需要阻塞/唤醒涉及上下文切换而言,主要针对多个线程在不同时间请求同一把锁的场景。轻量级锁获取过程:

  • 进行加锁操作时,jvm 会判断是否已经是重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象 MarkWord 复制到该锁记录中

  • 复制成功之后,jvm 使用 CAS 操作将对象头 MarkWord 更新为指向锁记录的指针,并将锁记录里的 owner 指针指向对象头的 MarkWord。如果成功,则执行‘III’,否则执行‘IV’

  • 更新成功,则当前线程持有该对象锁,并且对象 MarkWord 锁标志设置为‘00’,即表示此对象处于轻量级锁状态

  • 更新失败,jvm 先检查对象 MarkWord 是否指向当前线程栈帧中的锁记录,如果是则执行‘V’,否则执行‘VI’

  • 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word) 为 null,并指向 Mark Word 的锁对象,起到一个重入计数器的作用

  • 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认 10 次),等待次数达到阈值仍未获取到锁,则升级为重量级锁

当有多个锁竞争轻量级锁则会升级为重量级锁,重量级锁正常会进入一个 cxq 的队列,在调用 wait 方法之后,则会进入一个 waitSet 的队列 park 等待,而当调用 notify 方法唤醒之后,则有可能进入 EntryList。重量级锁加锁过程:

  • 分配一个 ObjectMonitor 对象,把 Mark Word 锁标志置为‘10’,然后 Mark Word 存储指向 ObjectMonitor 对象的指针。ObjectMonitor 对象有两个队列和一个指针,每个需要获取锁的线程都包装成 ObjectWaiter 对象

  • 多个线程同时执行同一段同步代码时,ObjectWaiter 先进入 EntryList 队列,当某个线程获取到对象的 monitor 以后进入 Owner 区域,并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count+1;

10.简单描述一下 ABA 问题?

1.有两个线程同时去修改一个变量的值,比如线程 1、线程 2,都更新变量值,将变量值从 A 更新成 B。

2.首先线程 1、获取到 CPU 的时间片,线程 2 由于某些原因发生阻塞进行等待,此时线程 1 进行比较更新(CompareAndSwap),成功将变量的值从 A 更新成 B。

3.更新完毕之后,恰好又有线程 3 进来想要把变量的值从 B 更新成 A,线程 3 进行比较更新,成功将变量的值从 B 更新成 A。 4. 线程 2 获取到 CPU 的时间片,然后进行比较更新,发现值是预期的 A,然后又更新成了 B。但是线程 1 并不知道,该值已经有了 A->B->A 这个过程,这也就是我们常说的 ABA 问题。

4.可以通过加版本号或者加时间戳解决,或者保证单向递增或者递减就不会存在此类问题。

11.实现一下 DCL?

12.实现一个阻塞队列(用 Condition 写生产者与消费者就)?


13.实现多个线程顺序打印 abc?




14.服务器 CPU 数量及线程池线程数量的关系?

首先确认业务是 CPU 密集型还是 IO 密集型的,

如果是 CPU 密集型的,那么就应该尽量少的线程数量,一般为 CPU 的核数+1;

如果是 IO 密集型:所以可多分配一点 cpu 核数*2 也可以使用公式:CPU 核数 / (1 - 阻塞系数);其中阻塞系数 在 0.8 ~ 0.9 之间。

15.多线程之间是如何通信的?

1、通过共享变量,变量需要 volatile 修饰

2、使用 wait()和 notifyAll()方法,但是由于需要使用同一把锁,所以必须通知线程释放锁,被通知线程才能获取到锁,这样导致通知不及时。

3、使用 CountDownLatch 实现,通知线程到指定条件,调用 countDownLatch.countDown(),被通知线程进行 countDownLatch.await()。

4、使用 Condition 的 await()和 signalAll()方法。

16.synchronized 关键字加在静态方法和实例方法的区别?

修饰静态方法,是对类进行加锁,如果该类中有 methodA 和 methodB 都是被 synchronized 修饰的静态方法,此时有两个线程 T1、T2 分别调用 methodA()和 methodB(),则 T2 会阻塞等待直到 T1 执行完成之后才能执行。

修饰实例方法时,是对实例进行加锁,锁的是实例对象的对象头,如果调用同一个对象的两个不同的被 synchronized 修饰的实例方法时,看到的效果和上面的一样,如果调用不同对象的两个不同的被 synchronized 修饰的实例方法时,则不会阻塞。

17.countdownlatch 的用法?

两种用法:

1、让主线程 await,业务线程进行业务处理,处理完成时调用 countdownLatch.countDown(),CountDownLatch 实例化的时候需要根据业务去选择 CountDownLatch 的 count;

2、让业务线程 await,主线程处理完数据之后进行 countdownLatch.countDown(),此时业务线程被唤醒,然后去主线程拿数据,或者执行自己的业务逻辑。

18.线程池问题:

(1)Executor 提供了几种线程池

1、newCachedThreadPool()(工作队列使用的是 SynchronousQueue)

创建一个线程池,如果线程池中的线程数量过大,它可以有效地回收多余的线程,如果线程数不足,那么它可 以创建新的线程。

不足:这种方式虽然可以根据业务场景自动的扩展线程数来处理我们的业务,但是最多需要多少个线程同时处 理却是我们无法控制的。

优点:如果当第二个任务开始,第一个任务已经执行结束,那么第二个任务会复用第一个任务创建的线程,并 不会重新创建新的线程,提高了线程的复用率。

作用:该方法返回一个可以根据实际情况调整线程池中线程的数量的线程池。即该线程池中的线程数量不确 定,是根据实际情况动态调整的。

2、newFixedThreadPool()(工作队列使用的是 LinkedBlockingQueue)

这种方式可以指定线程池中的线程数。如果满了后又来了新任务,此时只能排队等待。

优点:newFixedThreadPool 的线程数是可以进行控制的,因此我们可以通过控制最大线程来使我们的服务 器达到最大的使用率,同时又可以保证即使流量突然增大也不会占用服务器过多的资源。

作用:该方法返回一个固定线程数量的线程池,该线程池中的线程数量始终不变,即不会再创建新的线程,也 不会销毁已经创建好的线程,自始自终都是那几个固定的线程在工作,所以该线程池可以控制线程的最大并发数

3、newScheduledThreadPool()

该线程池支持定时,以及周期性的任务执行,我们可以延迟任务的执行时间,也可以设置一个周期性的时间让 任务重复执行。该线程池中有以下两种延迟的方法。

scheduleAtFixedRate 不同的地方是任务的执行时间,如果间隔时间大于任务的执行时间,任务不受执行 时间的影响。如果间隔时间小于任务的执行时间,那么任务执行结束之后,会立马执行,至此间隔时间就会被 打乱。

scheduleWithFixedDelay 的间隔时间不会受任务执行时间长短的影响。

作用:该方法返回一个可以控制线程池内线程定时或周期性执行某任务的线程池。

4、newSingleThreadExecutor()

这是一个单线程池,至始至终都由一个线程来执行。

作用:该方法返回一个只有一个线程的线程池,即每次只能执行一个线程任务,多余的任务会保存到一个任务 队列中,等待这一个线程空闲,当这个线程空闲了再按 FIFO 方式顺序执行任务队列中的任务。

5、newSingleThreadScheduledExecutor()只有一个线程,用来调度任务在指定时间执行。作用:该方法返回一个可以控制线程池内线程定时或周期性执行某任务的线程池。只不过和上面的区别是该线 程池大小为 1,而上面的可以指定线程池的大小。

(2)线程池的参数

int corePoolSize,//线程池核心线程大小

int maximumPoolSize,//线程池最大线程数量

long keepAliveTime,//空闲线程存活时间

TimeUnit unit,//空闲线程存活时间单位,一共有七种静态属性(TimeUnit.DAYS 天,TimeUnit.HOURS 小时,TimeUnit.MINUTES 分钟,TimeUnit.SECONDS 秒,TimeUnit.MILLISECONDS 毫 秒,TimeUnit.MICROSECONDS 微妙,TimeUnit.NANOSECONDS 纳秒)

BlockingQueue workQueue,//工作队列

ThreadFactory threadFactory,//线程工厂,主要用来创建线程(默认的工厂方法是: Executors.defaultThreadFactory()对线程进行安全检查并命名)

RejectedExecutionHandler handler//拒绝策略(默认是:ThreadPoolExecutor.AbortPolicy 不 执行并抛出异常)

(3)拒绝策略

当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进 来,就会执行拒绝策略。

jdk 中提供了 4 种拒绝策略:

①ThreadPoolExecutor.CallerRunsPolicy:该策略下,在调用者线程中直接执行被拒绝任务的 run 方法,除非线程池已经 shutdown,则直接抛弃任 务。

②ThreadPoolExecutor.AbortPolicy:该策略下,直接丢弃任务,并抛出 RejectedExecutionException 异常。

③ThreadPoolExecutor.DiscardPolicy:该策略下,直接丢弃任务,什么都不做。

④ThreadPoolExecutor.DiscardOldestPolicy:该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列。

除此之外,还可以根据应用场景需要来实现 RejectedExecutionHandler 接口自定义策略。

(4)任务放置的顺序过程

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。 了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由 execute 方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行 线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝 该任务。其执行过程如下:

首先检测线程池运行状态,如果不是 RUNNING,则直接拒绝,线程池要保证在 RUNNING 的状态下执行任务。

如果 workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。

如果 workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。

如果 workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队 列已满,则创建并启动一个线程来执行新提交的任务。

如果 workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

其执行流程如下图所示:

(5)任务结束后会不会回收线程

final void runWorker(Worker w) {        Thread wt = Thread.currentThread();        Runnable task = w.firstTask;        w.firstTask = null;        w.unlock(); // allow interrupts        boolean completedAbruptly = true;        try {            while (task != null || (task = getTask()) != null) {                w.lock();               if ((runStateAtLeast(ctl.get(), STOP) ||                     (Thread.interrupted() &&                      runStateAtLeast(ctl.get(), STOP))) &&                    !wt.isInterrupted())                    wt.interrupt();                try {                    beforeExecute(wt, task);                    Throwable thrown = null;                    try {                        task.run();                    } catch (RuntimeException x) {                        thrown = x; throw x;                    } catch (Error x) {                        thrown = x; throw x;                    } catch (Throwable x) {                        thrown = x; throw new Error(x);                    } finally {                        afterExecute(task, thrown);                    }                } finally {                    task = null;                    w.completedTasks++;                    w.unlock();                }            }            completedAbruptly = false;        } finally {            processWorkerExit(w, completedAbruptly);        }    }
复制代码

首先线程池内的线程都被包装成了一个个的 java.util.concurrent.ThreadPoolExecutor.Worker,然 后这个 worker 会马不停蹄的执行任务,执行完任务之后就会在 while 循环中去取任务,取到任务就继续执行,取 不到任务就跳出 while 循环(这个时候 worker 就不能再执行任务了)执行 processWorkerExit 方法,这个方 法呢就是做清场处理,将当前 woker 线程从线程池中移除,并且判断是否是异常进入 processWorkerExit 方 法,如果是非异常情况,就对当前线程池状态(RUNNING,shutdown)和当前工作线程数和当前任务数做判断,是否要加入一个新的线程去完成最后的任务.

(6)未使用的线程池中的线程放在哪里

private final HashSet<Worker> workers = new HashSet<Worker>();
复制代码

(7)线程池线程存在哪

private final HashSet<Worker> workers = new HashSet<Worker>();
复制代码

19.Java 多线程的几种状态及线程各个状态之间是如何切换的?


20.如何在方法栈中进行数据传递?

通过方法参数传递;通过共享变量;如果在用一个线程中,还可以使用 ThreadLocal 进行传递.

21.描述一下 ThreadLocal 的底层实现形式及实现的数据结构?

Thread 类中有两个变量 threadLocals 和 inheritableThreadLocals,二者都是 ThreadLocal 内部类 ThreadLocalMap 类型的变量,我们通过查看内部内 ThreadLocalMap 可以发现实际上它类似于一个 HashMap。在默认情况下,每个线程中的这两个变量都为 null:

只有当线程第一次调用 ThreadLocal 的 set 或者 get 方法的时候才会创建它们。

除此之外,和我所想的不同的是,每个线程的本地变量不是存放在 ThreadLocal 实例中,而是放在调用线程的 ThreadLocals 变量里面。也就是说,ThreadLocal 类型的本地变量是存放在具体的线程空间上,其本身相当于一个装载本地变量的工具壳,通过 set 方法将 value 添加到调用线程的 threadLocals 中,当调用线程调用 get 方法时候能够从它的 threadLocals 中取出变量。如果调用线程一直不终止,那么这个本地变量将会一直存放在他的 threadLocals 中,所以不使用本地变量的时候需要调用 remove 方法将 threadLocals 中删除不用的本地变量,防止出现内存泄漏。

22.Sychornized 是否是公平锁?

不是公平锁

23.描述一下锁的四种状态及升级过程?

以下是 32 位的对象头描述

synchronized 锁的膨胀过程:

当线程访问同步代码块。首先查看当前锁状态是否是偏向锁(可偏向状态)

1、如果是偏向锁:

1.1、检查当前 mark word 中记录是否是当前线程 id,如果是当前线程 id,则获得偏向锁执行同步代码 块。

1.2、如果不是当前线程 id,cas 操作替换线程 id,替换成功获得偏向锁(线程复用),替换失败锁撤销升 级轻量锁(同一类对象多次撤销升级达到阈值 20,则批量重偏向,这个点可以稍微提一下,详见下面的注意)

2、升级轻量锁升级轻量锁对于当前线程,分配栈帧锁记录 lock_record(包含 mark word 和 object-指向锁记录首地 址),对象头 mark word 复制到线程栈帧的锁记录 mark word 存储的是无锁的 hashcode(里面有重入次数 问题)。

3、重量级锁(纯理论可结合源码)

CAS 自旋达到一定次数升级为重量级锁(多个线程同时竞争锁时)

存储在 ObjectMonitor 对象,里面有很多属性 ContentionList、EntryList 、WaitSet、 owner。当一个线程尝试获取锁时,如果该锁已经被占用,则该线程封装成 ObjectWaiter 对象插到 ContentionList 队列的对首,然后调用 park 挂起。该线程锁时方式会从 ContentionList 或 EntryList 挑 一个唤醒。线程获得锁后调用 Object 的 wait 方法,则会加入到 WaitSet 集合中(当前锁或膨胀为重量级锁)

注意:

1.偏向锁在 JDK1.6 以上默认开启,开启后程序启动几秒后才会被激活

2.偏向锁撤销是需要在 safe_point,也就是安全点的时候进行,这个时候是 stop the word 的,所以说偏向 锁的撤销是开销很大的,如果明确了项目里的竞争情况比较多,那么关闭偏向锁可以减少一些偏向锁撤销的开销

3.以 class 为单位,为每个 class 维护一个偏向锁撤销计数器。每一次该 class 的对象发生偏向撤销操作时 (这个时候进入轻量级锁),该计数器+1,当这个值达到重偏向阈值(默认 20,也就是说前 19 次进行加锁的时候, 都是假的轻量级锁,当第 20 次加锁的时候,就会走批量冲偏向的逻辑)时,JVM 就认为该 class 的偏向锁有问 题,因此会进行批量重偏向。每个 class 对象也会有一个对应的 epoch 字段,每个处于偏向锁状态对象的 mark word 中也有该字段,其初始值为创建该对象时,class 中的 epoch 值。每次发生批量重偏向时,就将该值+1, 同时遍历 JVM 中所有线程的站,找到该 class 所有正处于加锁状态的偏向锁,将其 epoch 字段改为新值。下次 获取锁时,发现当前对象的 epoch 值和 class 不相等,那就算当前已经偏向了其他线程,也不会执行撤销操 作,而是直接通过 CAS 操作将其 mark word 的 Thread Id 改为当前线程 ID

23. CAS 的 ABA 问题怎么解决的?

通过加版本号控制,只要有变更,就更新版本号

24. 描述一下 AQS?

状态变量 state:

AQS 中定义了一个状态变量 state,它有以下两种使用方法:

(1)互斥锁

当 AQS 只实现为互斥锁的时候,每次只要原子更新 state 的值从 0 变为 1 成功了就获取了锁,可重入是通过不断把 state 原子更新加 1 实现的。

(2)互斥锁 + 共享锁

当 AQS 需要同时实现为互斥锁+共享锁的时候,低 16 位存储互斥锁的状态,高 16 位存储共享锁的状态,主要用于实现读写锁。

互斥锁是一种独占锁,每次只允许一个线程独占,且当一个线程独占时,其它线程将无法再获取互斥锁及共享锁,但是它自己可以获取共享锁。

共享锁同时允许多个线程占有,只要有一个线程占有了共享锁,所有线程(包括自己)都将无法再获取互斥锁,但是可以获取共享锁。

AQS 队列

AQS 中维护了一个队列,获取锁失败(非 tryLock())的线程都将进入这个队列中排队,等待锁释放后唤醒下一个排队的线程(互斥锁模式下)。

condition 队列

AQS 中还有另一个非常重要的内部类 ConditionObject,它实现了 Condition 接口,主要用于实现条件锁。

ConditionObject 中也维护了一个队列,这个队列主要用于等待条件的成立,当条件成立时,其它线程将 signal 这个队列中的元素,将其移动到 AQS 的队列中,等待占有锁的线程释放锁后被唤醒。

Condition 典型的运用场景是在 BlockingQueue 中的实现,当队列为空时,获取元素的线程阻塞在 notEmpty 条件上,一旦队列中添加了一个元素,将通知 notEmpty 条件,将其队列中的元素移动到 AQS 队列中等待被唤醒。

获取锁、释放锁的这些方法基本上都穿插在 ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch 的源码解析中需要子类实现的方法上面一起学习了 AQS 中几个重要的模板方法,下面我们再一起学习下几个需要子类实现的方法:

这几个方法为什么不直接定义成抽象方法呢?

因为子类只要实现这几个方法中的一部分就可以实现一个同步器了,所以不需要定义成抽象方法。

25.介绍一下 volatile 的功能?

保证线程可见性

防止指令重排序

26.volatile 的可见性和禁止指令重排序怎么实现的?

可见性:

volatile 的功能就是被修饰的变量在被修改后可以立即同步到主内存,被修饰的变量在每次是用之前都从主内存刷新。本质也是通过内存屏障来实现可见性

写内存屏障(Store Memory Barrier)可以促使处理器将当前 store buffer(存储缓存)的值写回主存。

读内存屏障(Load Memory Barrier)可以促使处理器处理 invalidate queue(失效队列)。进而避免由于 Store Buffer 和 Invalidate Queue 的非实时性带来的问题。

  • 禁止指令重排序:


    volatile 是通过内存屏障来禁止指令重排序


    JMM 内存屏障的策略

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

27.简要描述一下 ConcurrentHashMap 底层原理?

JDK1.7 中的 ConcurrentHashMap

内部主要是一个 Segment 数组,而数组的每一项又是一个 HashEntry 数组,元素都存在 HashEntry 数组里。因为每次锁定的是 Segment 对象,也就是整个 HashEntry 数组,所以又叫分段锁。

JDK1.8 中的 ConcurrentHashMap

舍弃了分段锁的实现方式,元素都存在 Node 数组中,每次锁住的是一个 Node 对象,而不是某一段数组,所以支持的写的并发度更高。

再者它引入了红黑树,在 hash 冲突严重时,读操作的效率更高。


今日份分享已结束,请大家多多包涵和指点!


用户头像

还未添加个人签名 2021.04.20 加入

Java工具与相关资料获取等WX: pfx950924(备注来源)

评论

发布
暂无评论
阿里面试官首次分享完整版多线程核心题,你准备好跳槽了吗?