【Java面试题】之 多线程

本文详细介绍了进程、线程的概念,探讨了并发与并行的区别,并深入分析了多线程的优势及其可能带来的问题。此外,还讲解了线程生命周期、上下文切换、死锁等问题,并对比了synchronized与ReentrantLock等锁机制。
摘要由CSDN通过智能技术生成

文章目录

原文

原文:https://www.yuque.com/unityalvin/baguwen/xbg8ds

1、什么是进程

后台运行的每一个程序都是一个进程

2、什么是线程

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

3、什么是并发,什么是并行

并行是指两个或者多个事件在同一时刻发生,而并发是指两个或多个事件在同一时间间隔发生。

4、为什么要使用多线程

从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

5、使用多线程可能带来什么问题?

使用多线程的目的就是为了提高程序的执行效率、运行速度,但是这不代表多线程是万能的,使用多线程的过程中可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

6、说说线程的声明周期和状态

  1. 线程创建之后它将处于 NEW(新建) 状态,
  2. 调用 start() 方法后开始运行,线程就会进入 READY(准备就绪) 状态。
  3. 准备就绪的线程获得了 CPU 时间片(timeslice)后就会进入 RUNNING(运行) 状态。
  4. 当线程执行 wait()方法之后,线程会进入 WAITING(等待) 状态。进入等待状态的线程,需要依靠其他线程的通知才能够返回到运行状态。
  5. 通过 sleep(long millis) 方法或 wait(long millis) 方法可以将线程设置为 TIME_WAITING(超时等待)状态,相当于给等待状态添加了超时时间,一旦超时,线程将会返回到准备就绪状态
  6. 当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。
  7. 线程在执行 Runnable 的 run() 方法之后将会进入到 TERMINATED(终止) 状态。

Thread.State枚举类中:
在这里插入图片描述

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

start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

而直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

8、什么是上下文切换

当前任务在执行完 CPU 时间片切换到另⼀个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是⼀次上下文切换。

9、什么是线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,会导致多个线程同时被阻塞,程序无法正常终止。

10、为什么会产生死锁(4)

产生死锁一定会具备以下 4 个条件:

  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有⾃⼰使用完毕后才释放资源。
  4. 循环等待条件:若干进程之间形成⼀种头尾相接的循环等待资源关系。

11、如何避免线程死锁(3)

  1. 破坏请求与保持条件 :⼀次性申请所有的资源。
  2. 破坏不剥夺条件 :占用部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。

12、如何解决死锁

  1. 先使用 jps 命令定位线程编号
  2. 再使用 jstack 找到死锁

13、什么是公平锁和非公平锁

● 公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队,先来后到,比如:设置为 true 的 ReentrantLock
● 非公平锁是指在高并发的情况下,有可能后面的线程比前面的线程先得到锁,多个线程获取锁的顺序并不按照申请锁的顺序,这就可能造成优先级反转或者饥饿现象,比如:synchronized、默认为 false 的 ReentrantLock

14、什么是可重入锁

● 可重入锁也叫做递归锁,指的是同一线程,外层函数获得锁之后,内层递归函数仍然能获取该锁的代码。
● ReentrantLock / synchronized 就是典型的可重入锁,可重入锁最大的作用就是避免死锁

15、什么是悲观锁、乐观锁

  • 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
  • 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。

16、你怎么选择悲观锁、乐观锁

在现在这个高并发、高性能、高可用的环境下,一般使用乐观锁比较多,因为乐观锁并未真正加锁,效率高,虽然锁的粒度掌握不好,可能会导致更新失败,但也比悲观锁依赖数据库锁,效率低,来的好。

17、什么是自旋锁

是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU
在这里插入图片描述

18、什么是独占锁(写)、共享锁(读)

独占锁:指该锁一次只能被一个线程所持有,ReentrantLock 和 synchronized 都是独占锁

共享锁:指该锁可被多个线程所持有

19、为什么要用读写锁

为了满足并发的要求,多个线程同时读取一个资源类应该可以同时进行,但是如果有一个线程想去写共享资源,此时就不应该再有其它线程可以对该资源进行读或写。

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

● 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
● 两者都可以暂停线程的执行。
● wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
● wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

21、说一说对 synchronized 关键字的了解

synchronized关键字解决了多个线程之间访问资源的同步性,它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。

22、为什么以前的 synchronized 效率低下呢?

Java 虚拟机中的 synchronized 基于进入和退出 Monitor 对象(也称为管程或监视器锁)实现的,而监视器锁(monitor)是依赖于底层操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过从 JDK1.6 开始,Java 官方从 JVM 层面对 synchronized 做了较大的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

23、JDK1.6 之后 synchronized 优化了哪些呢?

  • 首先对锁进行了重新分类,级别从低到高分别是:无锁状态 -> 偏向锁状态 -> 轻量级锁状态 -> 重量级锁状态,

  • 偏向锁
    ● 偏向锁是针对于一个线程而言的,线程获得锁之后不会再有解锁等操作, 这样可以省略很多开销。
    ● 偏向锁在 JDK1.6 及更高版本中是默认启用的,但是它在程序启动几秒钟后才激活。可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟,也可以使用 -XX:-UseBiasedLocking=false来关闭偏向锁,关闭之后程序会直接进入轻量级锁状态,
    ● 适用于只有一个线程访问的同步场景

  • 轻量级锁
    ● 当出现两个线程来竞争锁的话, 那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁,轻量级锁竞争线程时不会阻塞,提高了程序的响应速度,但是如果一直得不到要竞争的线程,锁就会自旋,消耗 CPU
    ● 适用于低延迟、同步快、执行速度非常快的场景

  • 重量级锁
    ● 在竞争锁期间,如果没获取到锁的话, 它不会自旋,而是直接让线程阻塞,
    ● 适用于高吞吐量、同步快、执行速度较慢的场景

24、你是怎么使用 synchronized 关键字的?(3)

  1. 修饰实例方法
    ○ 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
    ○ synchronized void method() { //业务代码 }
  2. 修饰静态方法
    ○ 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
    ○ synchronized static void method() { //业务代码 }
  3. 修饰代码块指定加锁对象
    ○ 对给定对象/类加锁。 表示进入同步代码库前要获得给定对象的锁。
    ○ synchronized(this) { //业务代码 }

25、构造方法可以使用 synchronized 修饰吗?

不能,因为构造方法本身就是安全的,不存在同步的构造方法这一说

26、说一下 synchronized 的底层原理

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指指向同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是获取监视器 monitor。

27、谈谈 synchronized 和 ReentrantLock 的区别(5)

原始构成

● synchronized 是关键字,属于JVM层面
● ReentrantLock 是具体类(java.util.concurrent.locks.lock)是api层面的锁

使用方法

● synchronized 不需要用户去手动释放锁,当 synchronized 代码执行完后,系统会自动让现场释放对锁的占用
● ReentrantLock 则需要用户去手动释放锁,若没有主动释放锁,就有可能导致出现死锁现象,需要 lock() 和 unlock() 方法配合 try/finally语句

是否可中断

● synchronized 不可中断,除非抛出异常或者正常运行完成
● ReentrantLock 可中断,设置超时方法 tryLock(long timeout, TimeUnit ),LockInterruptibly() 放代码块中,调用interrupt() 方法可中断

加锁是否公平

● synchronized 非公平锁
● ReentrantLock 两者都可以,默认 false 非公平锁,构造方法可以传入 true/false

锁绑定多个条件(Condition)

● synchronized 没有
● ReentrantLock 用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像 synchronized 要么随机唤醒一个要么唤醒全部线程

28、讲一下 JMM(Java 内存模型)

在 JDK1.2 之前,Java 的内存模型实现是从共享内存读取变量的,不需要进行特别注意。而在当前的 Java 内存模型下,线程可以把变量保存到本地内存(比如机器的寄存器)中,而不是直接在共享内存中进行读写。这就可能造成一个线程在共享内存中修改了一个变量的值,而另外一个线程还继续使用它在本地内存中的共享变量副本,这就会造成数据的不一致。

要解决这个问题,就需要把变量声明为 volatile,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到共享内存中进行读取。

所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。

更详细的:https://blog.csdn.net/m0_55155505/article/details/126134031#JMM_10

29、volatile 是什么意思

volatile 是 Java 虚拟机提供的一种轻量级的同步机制,它能够保证可见性、有序性、但是无法保证原子性。

并发编程的三个重要特性

  1. 原子性:指一个操作是不可中断的,要全部执行完成,要么全部不执行。
  2. 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。
  3. 有序性:程序执行的顺序按照代码的先后顺序执行

30、你在什么地方使用过 volatile

在单例模式中使用过,本来只用的双端检锁(在加锁前后都进行判断),但是由于指令重排,可能会使某一个线程在第一次检测 instance 时不为 null,而实际上 instance 根本没有完成初始化,所以使用 volatile 禁止指令重排

31、synchronized 和 volatile 的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

● volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
● volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
● volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

32、如何解决不保证原子性的问题

  • synchronized
  • lock
  • AtomicInteger

33、为什么加了 AtomicInteger 就能解决不保证原子性问题?

因为它里面有一个方法,getAndIncrement,它的意思就是带原子性的使一个值加 1

这样其它线程就必须等待操作它的线程执行完之后,才可以对它进行操作,最终就能解决不保证原子性的问题

AtomicInteger底层是CAS

CAS的全称为 Compare And Swap 「比较并交换」,它是一条CPU并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,否则继续比较直到主内存和工作内存中的值一致为止,整个过程是原子的,不会造成数据不一致的问题。

举个例子:

  1. 主物理内存中有 1 个 5,此时有两个线程要对其操作
  2. A、B 线程都对主物理内存的这个值进行了副本拷贝
  3. A 线程从主物理内存中取走的时候是5,要写入主物理内存时发现主物理内存的值还是 5,这说明没有人动过,此时成功将值修改为2019
  4. 与此同时 B 线程也要写入主物理内存,当它判断拿走的值与主物理内存是否一致时,发现主物理内存的值已经被修改过了,B 线程修改失败,主物理内存的值保持不变。

34、CAS 的底层原理

Unsafe类加自旋

Unsafe 存在于 sun.misc 包中,是 CAS 的核心类

由于Java 方法无法直接访问底层 ,所以需要通过本地(native)方法来访问,而 Unsafe 类所有的方法都是 native 修饰的,它的内部方法可以像 C 的指针一样直接操作内存,调用操作系统底层资源执行相应任务,相当于 Java 直接调用操作系统资源的中间类,所以 Java 中 CAS 的执行依赖于 Unsafe 类的方法

35、CAS 有什么缺点(3)

  1. 循环时间长开销大
    在 getAndAddInt() 方法中有个 do while,如果CAS失败,会一直进行尝试,如果 CAS 长时间一直不成功,则会给 CPU 带来很大的开销。
  2. 只能保证一个共享变量的原子操作
    对一个共享变量执行操作时,我们可以使用 CAS 的方式来保证原子性,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性了,这个时候就需要用锁来保证原子性
  3. 会有 ABA 问题

36、ABA 问题

CAS 算法实现的一个重要前提是需要取出内存中某时刻的数据,并在当下时刻比较并替换,这个时间差会导致数据有变化

举例:

  • 比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且线程 two 进行了一些操作将值变成了 B,

  • 然后线程 two又将数据变成 A,这时候线程 one 进行 CAS 操作时发现内存中仍然是 A,然后线程 one 操作成功。

  • 这期间内存中的值被改变过,但是由于最后又改回原值了,所以 CAS 没有察觉

如何解决 ABA?

  • 使用版本号机制,在修改的时候同时对比版本号,版本号与值一致再进行修改,否则不进行修改

37、ThreadLocal

ThreadLocal 可以让每个线程都有自己专属的本地变量,访问这个变量的每个线程都会有这个变量的本地副本,他们可以使⽤ get() 来获取默认值,也可以使用 set() 方法将值更改为当前线程所存的副本的值,从而避免了线程安全问题。

ThreadLocal 底层
ThreadLocal 内部维护的是⼀个类似 Map 的 ThreadLocalMap 数据结构,
在这里插入图片描述

ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是ThreadLocal 对象调⽤ set 方法设置的值,我们放入 ThreadLocal 的值,只不过是 ThreadLocalMap 的封装,传递了变量值。
在这里插入图片描述

38、ThreadLocal 内存泄漏了解不?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

39、使用线程池的好处

线程复用:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

控制最大并发数:提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即执行。

管理线程:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池则可以进行统一的分配,调优和监控。

40、实现 Runnable 接口 和 Callable 接口的区别

  1. 因为并发,异步导致 Callable 接口的出现
  2. 主要是用 Callable 能够实现当多个任务执行时,若有一个任务耗时较长,可以把这个任务放到后台执行,主线程先完成其他任务,最后再等后台的任务结束,再一起进行总的计算。

41、线程池

总结在另一篇文章:https://blog.csdn.net/m0_55155505/article/details/125191350

42、AQS 了解过吗?

AQS 的全称为(AbstractQueuedSynchronizer),是 JUC 包下的一个类,它是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器

43、AQS 的组件

Semaphore:信号量,它有两个主要作用,一个是用于多个共享资源的互斥使用,另一个是用于并发线程数的控制,有两个主要方法

● acquire() :获取,当一个线程调用 acquire() 操作时,它要么通过成功获取信号量(信号量减 1),要么一直等下去,直到有线程释放信号量,或超时。
● release():释放,实际上会将信号量的值加 1,然后唤醒等待的线程。

CountDownLatch:倒计时器,它可以让某一个线程等待直到倒计时结束,再开始执行,有两个主要方法

  1. await():当一个或多个线程调用 await() 方法时,这些线程会阻塞。
  2. countDown():其它线程调用countDown()方法会将计数器减 1,当计数器的值变为0时,因 await() 方法阻塞的线程会被唤醒,然后继续执行。

CyclicBarrier:它的主要作用是让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。线程是通过CyclicBarrier.await()方法进入的屏障

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值