type
status
date
slug
summary
tags
category
icon
password

Java并发编程面试题

线程和进程

进程是程序的一次执行过程,是系统运行程序的基本单位,拥有独立的内存空间和资源,创建和切换开销大
是进程中的一个执行单元,是CPU调度和执行的基本单位,共享进程的内存空间和资源,创建和切换开销小

线程状态

NEW: 初始状态,线程被创建出来但没有被调用start()。
RUNNABLE(Ready/Running): 运行状态,线程被调用了 start()等待运行的状态。
BLOCKED:阻塞状态,需要等待锁释放。
WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
TERMINATED:终止状态,表示该线程已经运行完毕
notion image

线程上下文切换

操作系统将CPU从一个线程切换到另一个线程的过程,保存当前线程的上下文,留待线程下次占用CPU的时候恢复现场,并加载下一个将要占用CPU的线程上下文

触发条件

  1. 主动让出CPU,比如调用了sleep(), wait()、yield()等
  1. 时间片用完,操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死
  1. 调用了阻塞类型的系统中断,比如请求IO,线程被阻塞
  1. 有更高优先级的线程变为可运行状态
  1. 被终止或结束运行

sleep()和wait()的区别

  1. sleep()方法没有释放锁,而 wait() 方法释放了锁
  1. wait()通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  1. wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者 notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)超时后线程会自动苏醒
  1. sleep()是Thread类的静态本地方法,wait()则是Object类的本地方法。

并发与并行的区别

并发:两个及两个以上的作业在同一时间段内执行。
并行:两个及两个以上的作业在同一时刻执行。

同步和异步的区别

同步:调用发出之后,在没有得到结果之前,该调用就不可以返回,一直等待。
异步:调用发出之后,不用等待返回结果,该调用直接返回。

线程安全

在多线程环境下,对同一数据进行访问是否能够保证数据的一致性和正确性

产生死锁的必要条件

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

预防和避免死锁

  1. 按序申请资源:所有线程按照相同的顺序获取锁
  1. 尽量减小锁的范围:将锁的粒度范围尽可能缩小,减少占用锁的时间。可以通过拆分锁或细粒度锁实现
  1. 破坏请求与保持条件:一次请求所有资源
  1. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
  1. 设置超时等待时间:为锁设置超时时间,防止线程无期限等待锁
  1. 破坏循环等待条件:按某一顺序申请资源,释放资源则反序释放

并发编程三个必要因素

1、 原子性(Atomicity)

原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。

2、可见性(Visibility)

一个线程对共享变量的修改,其他线程能够立刻看到。

3、有序性(Ordering)

程序执行的顺序按照代码的先后顺序执行。

出现线程安全问题的原因

线程切换带来的原子性问题
解决办法:使用多线程之间同步synchronized或使用锁(Lock)
缓存导致或编译器优化导致可见性问题
解决办法:使用synchronized、volatile、Lock
编译器和处理器会对指令进行优化和重排序,以提高执行效率,重排序可能会导致代码执行顺序和预期不同
解决办法:Happens-Before规则可以解决

Java中volatile关键字作用

  1. 保证变量的可见性但不能保证原子性,指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取最新值。
  1. 禁止指令重排序,将变量声明为volatile,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序(写操作会在其前面插入一个StoreStore屏障,读操作会在其前面插入一个LoadLoad屏障)

悲观锁和乐观锁

悲观锁

共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。如synchronized关键词和ReentrantLock类等独占锁 优点:数据一致性高,避免了脏读和数据冲突
缺点
  • 高并发的场景下,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销
  • 容易造成死锁
适用场景:适用于写操作频繁、数据一致性要求高的场景

乐观锁

假设数据冲突概率低,在操作数据时不加锁,只是在提交修改的时候去校验数据是否被其他线程修改,常见实现方法如版本号机制或CAS算法
优点
  • 高并发性能好,不会阻塞其他线程的读取操作
  • 无死锁风险,因为不需要显式地获取和释放锁
缺点
  • 冲突频繁发生(写占比非常多的情况),会频繁失败和重试(可以LongAdder以空间换时间的方式解决),这样同样会非常影响性能,导致CPU飙升
  • 数据一致性风险较高,如果多个线程同时修改数据,可能导致数据不一致
适用场景:适用于读操作多、写操作少、数据冲突概率低的场景

版本号机制

一般会在数据表中加一个version字段用来表示数据的修改次数,当数据被修改时,version值会加一,当一个线程要更新数据的时候,他会在读取数据的同时读取version的值,当提交更新的时候他会校验刚才读取到的version值与数据库中的version值是否相等,相等才更新,否则重试更新操作直到更新成功

CAS算法(Compare And Swap(比较与交换)

通过用一个预期值和要更新的变量当前值进行比较,两值相等才会进行更新,涉及三个操作数:
V:要更新的变量当前值、E:预期值、N:拟写入的新值
  1. 比较变量V的当前值是否等于预期值E
  1. 如果相等,则将变量V的值更新为新值N
  1. 如果不相等,则说明变量V已经被其他线程修改过,不进行更新操作

自旋锁机制

由于CAS操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功,长时间重试会导致CPU开销增大,解决方案如下
  1. 使用pause指令,延迟流水线执行指令
  1. 限制自旋次数,设置最大自旋次数,超过这个次数后,线程可以选择放弃自旋,进入阻塞状态等待重新调度

ABA问题

如果一个变量在两次检查之间被修改为其他值然后又改回原值,CAS操作无法检测到这种变化,可通过在变量前引入版本号或时间戳来解决

只能保证一个共享变量的原子操作

CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS就显得无能为力。不过,从JDK1.5 开始,Java提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行操作

Synchronized关键词

可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行
  1. 修饰实例方法,给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  1. 修饰静态方法(锁当前类),给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得当前class的锁
  1. 修饰代码块(锁指定对象/类)

jdk1.6之后的synchronized底层做了哪些优化

在Java6之后,synchronized引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让synchronized锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃)
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
锁粗化:当有一系列连续的操作都是对同一个对象进行加锁和解锁,JVM会将多个锁操作合并为一个更大的锁操作,以减少获取和释放锁的次数
锁消除:JVM在JIT编译时会通过逃逸分析来判断代码中的锁是否必要,如果锁对象没有逃出当前线程范围,那么就会将锁去掉,以减少同步开销
无锁状态:对象刚创建时没有任何线程持有锁
偏向锁:一个锁只被同一个线程获取,且占用锁的时间较长,JVM会将其设置为偏向锁。偏向锁的对象头记录线程ID,后续该线程再次请求锁时,无需进行同步操作,直接获取锁。减少同步操作的开销
轻量级锁:当有少量线程竞争时,JVM会将其设置为轻量级锁。轻量级锁通过CAS操作尝试获取锁,如果获取失败线程会进行自旋操作,直至获得锁或超时
重量级锁:通常为传统的互斥锁,当锁竞争较为激烈,多个线程频繁竞争同一个锁时,JVM会将其设置为重量级锁,此时未获取锁的线程会进入阻塞状态,等待获取锁

synchronized和volatile有什么区别

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

优化锁的使用

减小锁的粒度(使用时间)

  • 尽量缩小锁的适用范围,在必要的代码块中使用锁,避免在整个方法或过多的代码上加锁
  • 使用更细粒度的锁,将大对象锁拆成多个小对象锁,提高并行度
  • 在读多写少的场景下,通过读写锁替换独占锁

减少锁的使用

  • 使用无锁编程、CAS操作、原子类来避免使用锁,从而减少锁带来的性能损耗
  • 减少共享资源的使用,避免线程对同一资源的竞争,如使用局部变量或ThreadLocal(线程本地变量)

AQS

AQS是一个框架用来构建锁和同步器,它通过管理同状态、线程阻塞和唤醒以及队列管理来实现线程间的协调

公平锁和非公平锁的区别

公平锁:先申请的线程会先获得锁,由于要保证时间上的绝对顺序,需要频繁的上下文切换,所以性能差
非公平锁:后申请的线程可能先获得锁,是随机或者按照其他的优先级排序的,性能更好,但可能导致有的线程永远获取不到锁

可中断锁和不可中断锁

  • 可中断锁:线程在获取锁的过程中可被中断,不需要一直等待获取锁之后才能进行其他逻辑处理ReentrantLock就属于是可中断锁
  • 不可中断锁:一旦线程申请了锁,就只能等获取到所之后才能进行其他逻辑处理。Synchronized就是不可中断锁

共享锁和独占锁

共享锁:一个锁可以被多个线程同时获取
独占锁:一个锁只能被一个线程获取

线程持有读锁还能获取写锁吗(写锁属于独占锁,读锁属于共享锁)

当线程持有读锁的情况下不能获取写锁,因为获取写锁的时候,如果发现当前的读锁被占用了,就马上获取失败,无论读锁是否被当前线程持有
当线程持有写锁的情况可以继续读锁,只有获取读锁的时候如果发现写锁被占用并且写锁不是被当前线程占用的情况才会获取失败

ThreadLocal

用于实现线程局部变量的类,它为每个线程提供了独立的变量副本,可以将ThreadLocal比作一个存放数据的盒子,盒子内可以存放每个线程的私有数据
每个线程里都有一个ThreadLocalMap用来存放当前线程共享变量的副本,其中key就是ThreadLocal对象,value是一个泛型值

内存泄露问题

ThreadLocalMap中key是弱引用(当一个对象只被弱引用关联时,在下一次垃圾回收时,不管当前内存空间是否充足,都可能被回收),而Value是强引用,所以当ThreadLocal在没有被外部强引用的情况下,在垃圾回收的时候,key会被清除掉变成null值,但value仍存在,最好在使用完ThreadLocal后通过调用remove()方法,remove方法会将key为null的entry清理掉

线程池

管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务

优点:

  1. 降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  1. 提高响应速度,当任务到达时,无需等待线程创建就能立即执行
  1. 提高线程的可管理性,进行线程的统一的管理、调优以及监控

创建线程池

  1. 通过ThreadPoolExecutor构造函数
2.通过Executor框架的工具类Executors(不推荐内置线程池,容易导致OOM)
FixedThreadPool:固定线程数量的线程池,核心线程数和最大线程数相等,当一个新任务提交时,若线程池中有空闲的线程则立即执行,否则会将任务存放在一个队列中,待有空闲的线程时,再执行
SingleThreadExecutor:只有一个线程的线程池,核心线程数和最大线程数都是1,如有多余的线程被提交到这个线程池中,会将其放在一个队列中,待线程空闲时,按先进先出顺序执行队列中的任务
CachedThreadPool:可根据实际情况调整线程数量的线程池,所有线程完成当前任务后会回到线程池进行复用,当有新任务提交,没有空闲线程复用时,则会创建新线程来处理任务
ScheduledThreadPool:延迟执行或定期执行任务的线程池

ThreadPoolExecutor参数

corePoolSize:任务队列未达到队列容量,最大可同时运行的线程数
maxiumPoolSize:任务队列中的任务达到队列容量的时候,当前可以同时运行的线程数变为最大线程数
workQueue:新任务来的时候会先判断当前运行的线程数是否到达核心线程数,到达了的话就会将任务存放在队列中
keepAliveTime:当线程池中的线程大于核心线程数,即存在非核心线程,这些核心线程在空闲的时候不会被立即销毁,而是等超存活时间(keepAliveTime)才会被回收销毁
threadFactory:用于创建新线程的工厂,通过ThreadFactory自定义线程名称、优先级等属性
handler:拒绝策略,当任务无法提交到线程池时的处理策略

线程的核心线程数是否会被回收

即使核心线程空闲了,默认情况下也不会被回收,因为核心线程数需要长期保持活跃,这样是为了减少创建线程的开销。但如果线程池被用于周期性的使用场景,且频率不高(周期之间有明显的空闲时间)可通过allowCoreThreadTimeOut(true)方法,当线程空闲时间达到keepAliveTime就会被回收

线程池的拒绝策略

AbortPolicy:抛出RejectExecutionException异常
CallerRunsPolicy:调用执行线程来运行任务,如果执行程序已关闭,则会丢弃该任务,该策略会降低对于新任务的提交速度,影响程序整体性能。如果处理提交任务的线程是主线程,可能导致主线程堵塞。
DiscardPolicy:不处理新任务直接丢弃
DiscardOldestPolicy:丢弃最早的未处理任务

CallerRunsPolicy拒绝策略风险的解决方案

  1. 在内存允许的情况下,增加阻塞队列BlockingQueue的容量,以容纳更多任务
  1. 为了充分利用CPU可以调整线程池最大线程数量maxiumPoolSize,提高任务处理速度,避免累计在阻塞队列中的任务过多导致内存不足
  1. 任务持久化,将任务存储到数据库、Redis缓存、消息队列中
  1. 创建一个线程池以外的临时线程

线程池常用的阻塞队列

  • LinkedBlockingQueue:容量默认为Integer.MAX_VALUE有界阻塞队列
  • SynchronousQueue:同步队列,没有容量
  • DelayWorkQueue:延迟队列,内部采用堆的数据结构,会按照延迟时间排序,保证每次出对的任务都是当前队列中执行时间最靠前的,当队列满了会自动扩容,增加原来容量的50%,永远不会阻塞,最大容量为Interger.MAX_VALUE
  • ArrayBlockingQueue:有界阻塞队列,底层由数组实现,容量一旦创建不能修改
  • PriorityBlockingQueue:优先级阻塞队列,底层是小顶堆型式的二叉堆,即值最小的元素优先出队

线程池处理任务流程

notion image
  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务
  1. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行
  1. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务
  1. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法

线程池的生命周期

运行状态(RUNNING):线程池正常接收新任务处理已提交的任务
关闭状态(SHUNTDOWN):通过调用shuntdown()方法进入该状态,该状态不会再接收新的任务,但会继续执行正在执行以及任务队列中等待的任务
停止状态(STOP):通过调用shuntdownNow()方法进入该状态,该状态会尝试中断正在执行的任务(并不能保证所有任务都立即停止),以及清空任务队列,并返回等待执行的任务列表
终止状态(TERMINATED):所有任务执行完毕,且线程池处于完全关闭状态后,进入终止状态

在提交任务前线程池创建线程的方法

  • prestartCoreThread():启动一个线程,等待任务,如果已达到核心线程数,这个方法返回false,否则返回true。
  • prestartAllCoreThreads():启动所有的核心线程,并返回启动成功的核心线程数。

线程池中的线程异常后,销毁还是复用

  1. 使用execute()提交任务:如果异常在任务内没有被捕获,那么该异常会导致当前线程终止,异常会被打印在控制台或日志文件中,线程池会检测到这种线程的终止,并且创建一个新线程来代替他,从而保持原配置线程数不变。
  1. 使用submit()提交任务:异常不会直接打印出来,会被封装在由sumbit()返回的Future对象中,当调用Future.get()方法可获取到一个ExecutionException异常,这种情况线程不会终止,会继续存在于线程池中,准备执行后续任务
submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景

线程池自定义命名

  1. 利用guava的ThreadFactoryBuilder
  1. 实现ThreadFactory接口

线程池大小设定

如果我们设置的线程池数量太小的话,同一时间有大量的任务需要处理,可能会导致大量的任务在任务队列中等待执行,容易导致OOM,CPU也没有得到充分利用
如果我们设置的线程池数量太大,大量线程可能会同时争夺CPU资源,导致大量的上下文切换,影响整体的执行效率
CPU密集型任务(N+1):这种任务主要消耗CPU资源,可以将线程数设置为N(CPU核心数量)+1,多出来的这一个线程用来预防线程偶发的缺页中断或者其他原因导致的任务暂停,这时候CPU处于空闲状态,多出来的这个线程刚好可以利用CPU的空闲时间
I/O密集型任务(2N):这种任务线程大部分时间都在处理I/O交互,这段时间不会占用CPU资源,这时可以将CPU交给其他线程使用,所以我们可以多配置点线程数

设计一个能够根据任务的优先级来执行的线程池

要实现一个优先级任务线程池的话,那可以考虑使用PriorityBlockingQueue作为任务队列

通过PriorityBlockingQueue实现对任务的排序有两种方案:

  1. 提交到线程池的任务实现comparable接口,然后重写compareTo方法来指定任务之间的优先级比较规则
  1. 在创建PriorityBlockingQueue时传入Comparator对象来指定任务间的排序规则(推荐)

可能存在一些风险和问题对应的解决方案:

  1. PriorityBlockingQueue是一个无界的阻塞队列,可能会堆积大量的任务,从而导致OOM。
可通过重写PriorityBlockingQueue的offer方法(入队),当插入元素数量超过指定值就返回false
  1. 优先级较低的任务可能长时间得不到执行。
长时间等待的任务会被移除,提升任务的优先级,然后重新加入队列
  1. 需要对队列中的元素进行排序操作以及保证线程安全(采用可重入锁ReentrantLock),可能降低性能

Future

Future类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。 Future在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的get()方法为阻塞调用,必须等待任务执行完成并返回结果
Java I/O面试题SpringBoot面试题
Loading...
目录
0%
JackJame
JackJame
一个苦逼的码农😘
最新发布
Redis面试题
2025-3-3
面试题总结
2025-2-22
SpringBoot面试题
2025-2-18
JVM面试题
2025-2-18
数据库面试题
2025-2-16
Java并发编程面试题
2025-2-13
公告
目录
0%