本篇文章是Java多线程经典面试题。
1.创建线程的有哪些方式?
(1)通过继承Thread类
- 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
- 创建Thread子类的实例,即创建了线程对象。
- 调用线程对象的start()方法来启动该线程。
(2)通过实现Runnable接口
- 定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
- 创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
- 调用线程对象的start()方法来启动该线程。
(3)通过Callable和Future创建线程
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
2.创建线程的三种方式如何选择?
(1)继承Thread类
创建简单,但Java不支持多重继承,如果已经继承了其他类,就无法再继承Thread类。
(2)实现Runnable接口
可以避免单一继承的局限性,因为一个类可以实现多个接口。
(3)通过Callable和Future创建线程
Callable接口有返回值,并且能够抛出异常。
提供了更强大的异步执行机制,能够获得任务执行的结果或取消任务。
因此,如果只是创建简单的线程,可以选择继承Thread类;如果需要实现多接口或者避免单一继承的限制,可以选择实现Runnable接口;如果需要更复杂的异步执行和结果获取,可以选择实现Callable接口配合FutureTask。
3.线程的run()和start()有什么区别?
start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。
调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码;此时线程是处于就绪状态,并没有运行。然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。如果直接调用run(),其实就相当于是调用了一个普通函数而已,必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
4.为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
5.servlet是线程安全的吗?
Servlet是Java Web开发中的一个重要组件,它运行在Servlet容器Tomcat中,用于响应客户端的HTTP请求。由于Servlet容器通常采用单例模式来创建Servlet对象,并且使用线程池中的线程来处理并发请求,因此同一个Servlet对象的service()方法可能会被多个线程同时调用。
Servlet的线程安全问题主要是由于实例变量引起的,当多个线程同时访问和修改同一个实例变量时,可能会导致数据不一致或其他未定义的行为。
可以采用哪些措施保证servlet的线程安全呢?
- 不在Servlet中使用实例变量来存储状态信息,可以使用局部变量来代替,因为局部变量存储在每个线程自己的栈中,自然是线程安全的。
- 如果必须使用实例变量,可以通过同步机制(如synchronized关键字)来确保同一时间只有一个线程能够访问和修改这些变量。
- 对于每个线程需要独立存储的数据,可以使用ThreadLocal类来实现线程局部存储,这样每个线程都有自己的数据副本,互不干扰。
6.进程与线程有什么区别?
程序本身是静态的,是众多代码的组合产物,代码保存在文件中。如果程序要运行,则需要将程序加载到内存中,通过编译器将其编译成计算机能够理解的方式运行。
如果想启动一个Java程序,先要创建一个JVM进程。
进程是操作系统进行资源分配的最小单位,在一个进程中可以创建多个线程。多个线程各自拥有独立的局部变量、线程堆栈和程序计数器,能够访问共享的资源。
- 进程是操作系统分配资源的最小单位,线程是CPU调度的最小单位;
- 一个进程中可以包含多个线程;
- 进程与进程之间是相对独立的,进程中的线程之间并不完全独立,可以共享进程中的堆内存、方法区内存、系统资源等;
- 进程上下文的切换要比线程的上下文切换慢很多;
- 某个进程发生异常,不会对其它进程造成影响,但,某个线程发生异常,可能会对此进程中的其它线程造成影响;
7.什么是线程池?
线程池是一种优化手段,通过预先创建一定数量的线程,可以重复利用这些线程来执行多个任务,避免了频繁创建和销毁线程所带来的性能开销。
线程池可以有效的管理线程的数量,避免线程的无限制创建,线程是很耗费系统资源的,动不动就会产生OOM,并且会造成cpu过度切换,也有强大的拓展功能,比如延时定时线程池。
线程池通常具备任务队列,可以对任务进行排队和调度,确保合理分配线程资源。
8.创建线程池的几个核心构造参数
(1)创建方式
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
(2)核心参数
- corePoolSize : 核心线程数,一旦创建将不会再释放。如果创建的线程数还没有达到指定的核心线程数量,将会继续创建新的核心线程,直到达到最大核心线程数后,核心线程数将不在增加;如果没有空闲的核心线程,同时又未达到最大线程数,则将继续创建非核心线程;如果核心线程数等于最大线程数,则当核心线程都处于激活状态时,任务将被挂起,等待空闲线程来执行。
- maximumPoolSize : 最大线程数,允许创建的最大线程数量。如果最大线程数等于核心线程数,则无法创建非核心线程;如果非核心线程处于空闲时,超过设置的空闲时间,则将被回收,释放占用的资源。
- keepAliveTime : 也就是当线程空闲时,所允许保存的最大时间,超过这个时间,线程将被释放销毁,但只针对于非核心线程。
- unit : 时间单位,TimeUnit.SECONDS等。
- workQueue : 任务队列,存储暂时无法执行的任务,等待空闲线程来执行任务。
- threadFactory : 线程工程,用于创建线程。
- handler : 当线程边界和队列容量已经达到最大时,用于处理阻塞时的程序。
9.线程池的类型都有什么?
① 单线程池newSingleThreadExecutor,只有一个核心线程的线程池,保证任务按FIFO顺序一个个执行。
② 固定线程数线程池newFixedThreadPool,固定数量的可复用的线程数,来执行任务。当线程数达到最大核心线程数,则加入队列等待有空闲线程时再执行。
③ 可缓存线程池newCachedThreadPool,创建的都是非核心线程,而且最大线程数为Interge的最大值,空闲线程存活时间是1分钟。如果有大量耗时的任务,则不适该创建方式,它只适用于生命周期短的任务。
④ 固定线程数newScheduledThreadPool,支持定时和周期性任务
10.为何阿里巴巴开发手册中不允许使用Executors创建线程池?
《阿里巴巴 Java 开发手册》强制禁止,线程池不允许使用Executors创建,而是通过ThreadPoolExecutor的方式创建,这样的处理方式能让编写代码的工程师更加明确线程池的运行规则,避免资源耗尽的风险。
Executors返回的线程池对象的弊端如下:
- FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
- CachedThreadPool:允许创建的线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。
具体分析以下【说明】中的内容,以FixedThreadPool为例,允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
也就是说,我建了一个固定长度为200的线程池,当请求超过200时,线程池会新建一个队列queue,来储存阻塞的线程,而且new LinkedBlockingQueue()是一个没有大小限制的。规约的意思是当请求过多,每个任务执行较慢时,远超线程池的大小,会全部压到这个无界Queue中,这个队列queue会扛不住,有点道理。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
11.线程池中的线程是怎么创建的?是一开始就随着线程池的启动创建好的吗?
线程池在启动时会创建一定数量的空闲线程。
线程池的主要目的是通过重用已经创建的线程来减少频繁创建和销毁线程的开销,提高应用程序的性能。当线程池启动时,它会预先创建一些线程,这些线程会等待任务的到来。当提交一个任务到线程池时,如果当前运行的线程数量小于核心线程数(corePoolSize),线程池会立即创建一个新线程来执行这个任务。如果运行的线程数量等于或大于核心线程数,任务会被放入队列等待执行。如果队列已满,线程池会根据其策略(如最大线程数、拒绝策略等)来决定如何处理新任务。
12.线程池的执行过程是什么?
任务提交:线程池接收到新的任务时,会将任务加入到内部维护的任务队列中。这个过程通常是线程安全的,可能会使用互斥锁等同步机制来保护任务队列。
任务分配:线程池中的工作线程会按照一定的策略从任务队列中取出任务。这个策略可能是先进先出(FIFO),也可能是其他如优先级调度等策略。
任务执行:工作线程从任务队列中取出任务后,会执行该任务。执行过程中可能会涉及到资源的分配和释放,以及与其他线程的同步操作。
结果处理:任务执行完成后,可能会有结果需要进一步处理,例如返回给调用者或者传递给其他线程。
13.Java 线程池中 submit() 和 execute()方法有什么区别?
在Java线程池中,submit()和execute()方法都是用来提交任务到线程池中执行的,但它们之间存在一些区别:
- 参数类型,execute()方法只能接收实现了Runnable接口类型的任务,而submit()方法可以接收Runnable类型的任务和Callable接口的实现类。
- 返回类型,execute()方法没有返回值,submit()方法则返回一个Future对象,通过这个对象可以获取任务的执行结果或者取消任务执行。
- 异常处理,execute()方法在执行任务出现异常时会直接抛出异常,而submit()方法则会捕获异常并封装到Future对象中,可以通过调用Future对象的get()方法来获取执行过程中的异常。
- 对线程池的影响,当线程池已满时,execute()方法会直接抛出RejectedExecutionException异常,而submit()方法会将任务放入阻塞队列中,等待有空闲的线程时再执行。
14.如果你提交任务时,线程池队列已满,这时会发生什么
当线程池队列已满时,如果提交任务,线程池的行为取决于其拒绝策略。
Java中的ThreadPoolExecutor类提供了几种不同的拒绝策略,包括:
- AbortPolicy:默认策略。直接抛出RejectedExecutionException异常,表示无法处理新任务。
- CallerRunsPolicy:将任务交给调用线程执行。即在调用者线程中运行被拒绝的任务。
- DiscardOldestPolicy:丢弃队列中最旧的任务,然后尝试重新提交当前任务。
- DiscardPolicy:直接丢弃被拒绝的任务,不进行任何处理。
根据具体的实现和配置,线程池可能会选择其中一种策略来处理任务拒绝的情况。例如,如果使用默认的AbortPolicy策略,那么当线程池队列已满时,提交任务会抛出RejectedExecutionException异常。
当线程池队列已满时,线程池仍然会继续接受新的任务并尝试执行它们,直到达到最大线程数或队列容量的限制。因此,为了避免任务被拒绝,可以合理配置线程池的大小和队列容量,以确保能够处理预期的并发量。
15.说说你对核心线程数的理解?
配置文件中的线程池核心线程数为何配置为
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
Runtime.getRuntime().availableProcessors()获取的是CPU核心线程数,也就是计算资源。
- CPU密集型,线程池大小设置为N,也就是和cpu的线程数相同,可以尽可能地避免线程间上下文切换,但在实际开发中,一般会设置为N+1,为了防止意外情况出现线程阻塞,如果出现阻塞,多出来的线程会继续执行任务,保证CPU的利用效率。
- IO密集型,线程池大小设置为2N,这个数是根据业务压测出来的,如果不涉及业务就使用推荐。
在实际中,需要对具体的线程池大小进行调整,可以通过压测及机器设备现状,进行调整大小。如果线程池太大,则会造成CPU不断的切换,对整个系统性能也不会有太大的提升,反而会导致系统缓慢。
16.什么是线程组?
线程组在Java中主要用于方便地管理线程,它可以包含多个线程,将它们组织成一个单元,从而更容易进行管理和控制。
在Java中创建线程组时,可以使用ThreadGroup类的构造函数,可以指定线程组的名称和父线程组。默认情况下,所有的线程都属于主线程组。我们可以通过线程对象获取它所属的线程组,也可以通过线程组对象获取它所在组的名字。
线程组可以为其中的所有线程设置共同的属性,如线程优先级、是否守护线程等。
一旦线程加入某个线程组,它将一直属于该线程组,直到线程终止,且不能中途改变所属的线程组。
线程组更多地用于对线程进行分组管理和属性设置,而线程池则专注于提高系统性能,通过重用线程来减少资源消耗,并控制并发。
17.为什么在 Java 中不推荐使用线程组?
线程组中的stop、resume和suspend方法会导致安全问题,如死锁,这些方法已经被官方废弃。
线程组的功能相对有限,它无法在运行时对线程进行高级操作,如方法注入或暂停线程等。
在实际开发中,线程组的机制相对笨重,不便于进行动态调度,这导致代码难以扩展。
对于线程管理,推荐使用如Executor框架这样的现代工具,它们提供了更好的线程管理和资源控制,能够更有效地满足并发编程的需求。
Executor框架中的ThreadPoolExecutor类提供了线程池的实现,可以有效地管理和复用线程,减少系统开销。
18.为什么推荐使用Executor框架?
Executor框架将任务的提交与执行分离,提供了一种更加灵活和可扩展的方式来管理线程。它允许开发者专注于任务的实现逻辑,而不必关心任务的执行细节。
通过Executor框架,可以轻松地将任务异步执行,从而提高程序的效率和响应性。
Future接口允许获取异步任务的执行结果,提供了检查任务是否完成、等待任务完成以及获取任务结果的方法。这使得可以灵活地处理异步计算的结果,包括可能的异常处理。
除了基本的线程池实现,Executor框架还提供了ScheduledThreadPoolExecutor类,支持定时或周期性执行任务。
19.Executor 和 Executors 有什么区别?
Executor是一个接口,定义了一个线程池的核心方法execute(),用于提交任务到线程池中执行。它只包含一个execute(Runnable command)方法,用于执行给定的任务。
Executors 是一个工具类,提供了一些静态工厂方法来创建不同类型的ExecutorService实例。这些方法包括newFixedThreadPool、newCachedThreadPool等,它们提供了对ThreadPoolExecutor的封装,生成ExecutorService的具体实现类。
在实际使用中,通常不需要直接与Executor接口打交道,而是通过Executors类提供的方法来获取一个具体的ExecutorService实例。
20.用户线程与守护线程有什么区别?
在Java中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程) 。
用户线程是最常见的线程,比如通过main方法启动,就会创建一个用户线程。
Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。
JVM中的垃圾回收、JIT编译器线程就是最常见的守护线程。
只要有一个用户线程在运行,守护线程就会一直运行。只有所有的用户线程都结束的时候,守护线程才会退出。
编写代码时,也可以通过thread.setDaemon(true)指定线程为守护线程。
21.并行与并发有什么区别?
并行指当多核CPU中的一个CPU执行一个线程时,其它CPU能够同时执行另一个线程,两个线程之间不会抢占CPU资源,可以同时运行。
并发指在一段时间内CPU处理多个线程,这些线程会抢占CPU资源,CPU资源根据时间片周期在多个线程之间来回切换,多个线程在一段时间内同时运行,而在同一时刻不是同时运行的。
并行和并发的区别?
- 并行指多个线程在一段时间的每个时刻都同时运行,并发指多个线程在一段时间内同时运行(不是同一时刻,一段时间内交叉执行)
- 并行的多个线程不会抢占系统资源,并发的多个线程会抢占系统资源。
- 并行是多CPU的产物,单核CPU中只有并发,没有并行;
22.多线程中的上下文切换指的是什么?
在多线程环境下,当一个线程的时间片用完或者由于其他原因被暂停运行时,CPU会保存该线程的状态,并加载另一个线程的状态继续执行的过程。
(1)上下文切换通常发生在以下两种情况:
时间片用尽,在分时操作系统中,CPU为每个线程分配一定的时间片,当线程的时间片耗尽时,操作系统会将CPU的控制权交给下一个线程。
线程阻塞或等待,当线程需要等待某个事件(如I/O操作完成)或因互斥锁等原因进入阻塞状态时,操作系统会选择另一个可运行的线程来继续执行。
(2)上下文切换包含两个过程:
保存状态,操作系统会将当前线程的执行状态(如寄存器值、程序计数器等)保存到内存中,这个过程称为“切出”。
加载状态,随后,操作系统会从内存中加载另一个线程的执行状态,使其继续在CPU上运行,这个过程称为“切入”。
上下文切换是操作系统多任务管理的一个重要组成部分,它允许多个线程在单个CPU上交替运行,从而实现并发执行。然而,频繁的上下文切换会增加系统的开销,因为每次切换都需要保存和加载线程状态,这会消耗CPU时间和内存资源。因此,在设计多线程程序时,应当尽量减少不必要的上下文切换,以提高系统的整体效率。
23.Java 中用到的线程调度算法是什么?
Java中线程调度算法是优先级调度和时间片轮转调度。
优先级调度是根据线程的优先级来决定执行顺序的。
每个线程都有一个优先级,范围从Thread.MIN_PRIORITY(1)到Thread.MAX_PRIORITY(10),默认是Thread.NORM_PRIORITY(5)。高优先级的线程会比低优先级的线程更容易获得CPU的执行权。这就像老师可能会优先让努力学习的学生回答问题一样。但是,过度依赖优先级可能导致低优先级线程饥饿,即它们可能永远得不到执行机会,因此在使用时需要谨慎。
时间片轮转调度是将CPU的时间分割成固定的时间片,然后依次分配给各个可运行的线程。当一个线程的时间片用完后,即使它没有完成,也必须等待下一个时间片才能继续执行。这样可以保证所有线程都有机会被执行,从而避免某些线程长时间占用CPU而其他线程得不到执行的情况。
24.Java中线程调度器和时间分片指的是什么?
线程调度器是操作系统内核的一部分,它的主要职责是管理和调度多个线程对CPU资源的使用。在多线程环境下,由于CPU资源有限,需要有一个机制来决定哪个线程将获得CPU的使用权。线程调度器根据不同的算法和策略,如先来先服务(FIFO)、最短作业优先(SJF)、最高优先级优先以及轮转(Round Robin)等,决定线程的执行顺序。
时间分片是一种确保多个线程能够公平共享CPU时间的技术。在这种机制下,CPU的时间被划分成许多小片段,每个片段称为一个时间片。然后,这些时间片按照某种策略分配给处于可运行状态(Runnable)的线程。时间分片的大小和分配策略可以基于线程的优先级或者等待时间等因素来确定。
线程调度器通过时间分片技术,允许多个线程看似同时地使用单个CPU,从而实现了任务的并发执行。
25.什么是原子操作?Java中有哪些原子类?
原子操作是一系列不可中断的操作,它们的执行要么全部完成,要么全部不执行,不会出现只有部分操作被执行的情况。
Java中,原子类位于java.util.concurrent.atomic包中,主要包括以下几种:
(1)基本数据类型的原子类
AtomicInteger:提供原子更新的整数值。AtomicBoolean:提供原子更新的布尔值。AtomicLong:提供原子更新的长整数值。
(2)引用类型的原子类
AtomicReference:提供对引用类型的原子更新。AtomicStampedReference:提供带有版本号的引用类型的原子更新。AtomicMarkableReference:提供可标记的引用类型的原子更新。
(3)数组类型的原子类
AtomicIntegerArray:提供对整数数组的原子更新。AtomicLongArray:提供对长整数数组的原子更新。AtomicReferenceArray:提供对对象数组的原子更新。
26.wait与notify的区别?
多个线程在争夺同一个资源时,为了让这些线程协同工作、提高CPU利用率,可以让线程之间进行沟通,具体可以通过wait()和notify()实现。
- wait():使当前线程处于等待状态,即阻塞,直到其它线程调用此对象的notify()方法。
- notify():唤醒在此对象监视器上等待的单个线程,如果有多个线程同时在监视器上等待,则随机唤醒一个。
- notifyAll():唤醒在此对象监视器上等待的所有线程。
使用时需要注意几点:
- 三个方法都是Object()类中定义的native方法,而不是thread类提供的,这是因为Java提供的类是对象级的,而不是线程级的。
- 这三个方法都必须在synchronized修饰的方法或代码块中使用,否则会抛出异常。
- 使用wait()时,为了避免并发带来的问题,通常建议将wait()方法写在循环的内部。
27.为什么 wait()、notify()、notifyAll()必须在同步方法或者同步块中被调用?
同步方法或同步块确保了在任一时刻只有一个线程可以执行这些代码区域,这样可以避免多个线程同时访问共享资源时产生的竞态条件。
wait()方法会使当前线程进入等待状态,并释放对象的锁,而notify()和notifyAll()方法用于唤醒在该对象上等待的线程。
这些方法的设计是为了在多线程环境中协调线程的执行顺序和资源共享,如果没有同步机制,无法保证正确的等待和唤醒行为。
如果在非同步的上下文中调用这些方法,可能会导致所谓的“丢失唤醒”问题,即一个线程可能在没有获得锁的情况下被唤醒,这是非常危险的,因为它可能导致线程在不安全的情况下操作共享数据。
由于wait(), notify()和notifyAll()都是对象的方法,它们需要在同步方法或同步块中被调用,以确保线程之间的互斥和同步。这是因为在调用这些方法之前,调用线程必须已经获得了该对象的锁。
28.Thread 类中的 yield 方法有什么作用?
yield的主要作用是让当前正在执行的线程从运行状态变为就绪状态。
当一个线程调用 yield 方法时,它会暂时停止执行,给其他等待线程一个机会来获取 CPU 时间片。
调用 yield 方法并不会释放线程持有的任何监视器锁,这与 wait 方法不同,后者会释放锁并导致线程进入等待状态。
线程调度是由操作系统控制的,因此调用 yield 方法并不保证其他线程会立即得到执行。
29.yield 和 wait 有什么区别?
yield() 是 Thread 类的方法,而 wait() 是 Object 类的方法。
yield() 方法使当前线程暂停执行,为其他线程提供运行机会,但不会释放锁资源;而 wait() 方法会使当前线程进入等待状态,并释放该对象的锁,允许其他线程获取该锁并继续执行。
wait() 必须在同步方法或同步代码块中使用,因为它需要释放锁;yield() 则可以在任何地方使用。
wait() 方法可以被 notify() 或 notifyAll() 方法唤醒,或者在指定时间后自动唤醒;yield() 方法则不受这些方法影响,它依赖于操作系统的调度策略。
30.Java 如何实现多线程之间的通讯和协作?
Java 提供了多种方式来实现多线程之间的通讯和协作,包括使用 wait()、notify() 和 notifyAll() 方法进行线程间的通信,以及使用 java.util.concurrent 包中的高级同步类(如 CountDownLatch、CyclicBarrier、Semaphore、Exchanger 等)进行线程间协作。
31.JVM 对 Java 的原生锁做了哪些优化?
(1)自旋锁
在线程进行阻塞的时候,先让线程自旋等待一段时间,可能这段时间其它线程已经解锁,这时就无需让线程再进行阻塞操作了。
自旋默认次数是10次。
(2)自适应自旋锁
自旋锁的升级,自旋的次数不再固定,由前一次自旋次数和锁的拥有者的状态决定。
(3)锁消除
在动态编译同步代码块的时候,JIT编译器借助逃逸分析技术来判断锁对象是否只被一个线程访问,而没有其他线程,这时就可以取消锁了。
(4)锁粗化
当JIT编译器发现一系列的操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环中,此时会将加锁同步的范围粗化到整个操作系列的外部。
- 锁粒度:不要锁住一些无关的代码。
- 锁粗化:可以一次性执行完的不要多次加锁执行。
32.守护线程是什么?它是如何实现的?
当程序中所有的非守护线程都已经结束时,守护线程会自动退出,程序也会随之终止。换句话说,守护线程的主要作用是陪伴非守护线程,为它们提供某种服务,直到所有非守护线程都完成它们的工作。
守护线程和用户线程的创建方式相同,只是在用户线程的基础上将setDaemon设置为true即可。
垃圾回收线程是守护线程的一个典型应用。
垃圾回收线程的主要任务是实时监控和管理系统中的可回收资源,即那些不再被引用的对象。在Java等具有自动内存管理的语言中,垃圾回收线程会定期检查堆内存中的对象,找出那些不再被引用的对象,并释放它们的内存空间。这样,程序员就不需要手动管理内存,降低了内存泄漏等问题的发生概率。
垃圾回收线程作为守护线程,意味着它不会阻止JVM的退出。当程序中所有的非守护线程都结束时,即使垃圾回收线程还在运行,JVM也会退出。这是因为,如果没有非守护线程在运行,那么程序的主要任务就已经完成,此时垃圾回收线程也没有更多的工作可做,因此JVM会选择退出。
33.为什么代码会重排序?
编译器在编译代码时,可能会根据代码的逻辑和目标平台的特性进行一定的重排序,以提高生成的机器指令的执行效率。
在多线程环境下,由于每个线程的执行顺序可能会因为这种优化而发生改变,从而导致一些意想不到的结果。
34.如何实现线程的同步
在Java中,实现线程同步有多种方式,包括使用synchronized关键字、wait()和notify()/notifyAll()方法,以及使用Lock接口及其实现类(如ReentrantLock)。
(1)使用synchronized关键字
synchronized关键字可以用于方法或代码块,以实现对共享资源的同步访问。当一个线程进入一个对象的synchronized(this)方法或代码块时,其他线程将不能进入该对象的任何其他synchronized(this)方法或代码块,直到第一个线程退出。
(2)使用wait()和notify()/notifyAll()方法
wait()方法使当前线程等待,直到其他线程调用该对象的notify()或notifyAll()方法。notify()方法唤醒等待该对象的单个线程,而notifyAll()方法唤醒等待该对象的所有线程。这些方法通常与synchronized一起使用。
(3)使用Lock接口及其实现类
Java的java.util.concurrent.locks包提供了更灵活的锁机制,如ReentrantLock。与synchronized相比,Lock提供了更多的功能,如可中断的获取锁、尝试获取锁以及定时获取锁等。
35.说说多线程的三大特性
(1)原子性
指一个或多个操作要么全部执行完成,要么完全不执行,执行过程中不会被其他线程打断。原子性保证了复合操作在多线程环境中能够按照预期逻辑正确执行,避免了数据异常和逻辑异常的发生。在Java中,可以使用synchronized关键字或者Lock接口来实现原子性。
(2)可见性
当一个线程对共享变量进行修改后,其他线程可以立即看到修改后的值。Java中的volatile关键字可以保证变量的可见性,它确保修改的值立即更新到主存中,从而使得其他线程读取到的数据始终是最新的。此外,synchronized和Lock也能通过同步机制保证变量的可见性。
Java中,多个线程在读写内存中的共享变量时,会先把主内存中的共享变量数据复制到线程的工作内存中。每个线程在对数据进行读写操作时,都是直接操作自身的工作内存中的数据。由于每个线程都有自己的工作内存,所以线程1的数据对线程2是不可见的。线程1修改了数据,线程2不一定能够立刻读到修改后的值,这就造成了可见性问题。
(3)有序性
指程序的执行顺序应该按照代码的先后顺序来执行。在单线程环境中,有序性通常是自然而然存在的,但在多线程环境中,由于编译器优化、指令重排序等因素,可能导致线程观察到的指令执行顺序与预期不一致。因此,需要通过适当的同步措施来保证有序性。
36.说说你在开发中遇到过的有序性问题?
在Java中一个典型的案例就是使用双重检测机制来创建单例对象。
package com.nezha.thread;
/**
* 线程不安全的单例模式
*/
public class SingleInstance {
private static SingleInstance instance;
public static SingleInstance getInstance(){
if(instance == null){
synchronized (SingleInstance.class){
if(instance == null){
instance = new SingleInstance();
}
}
}
return instance;
}
}
如果编译器和解释器不对上面的代码进行优化,也不改变程序的执行顺序,则代码的执行流程如下图所示:
如上图所示,假如线程1和线程2同时调用getInstance()方法获取对象实例,两个线程会同时发现instance为空,同时对SingleInstance.class加锁,而JVM会保证只有一个线程获取到锁,这里我们假设线程1获取到锁,线程2因为未获取到锁而进行等待。接下来,线程1再次判断instance对象为空,从而创建instance对象的实例,然后释放锁。此时,线程2被唤醒,再次尝试获取锁,获取锁成功后,线程2检查此时的instance对象已经不再是空,线程2不再创建instance对象。
上述流程看起来没有什么问题,但是,在高并发、大流量的场景下获取instance对象时,使用new关键字创建SingleInstance类的实例对象时,会因为编译器或解释器对程序的优化而出现问题。也就是说,问题的根源在于如下代码:
instance = new SingleInstance();
对于上面的代码包含三个步骤:
- ① 分配内存空间
- ② 初始化对象
- ③ 将instance引用指向内存空间
正常执行的CPU指令顺序为①②③,CPU对程序进行重排序后的执行顺序是①③②,此时就会出现问题。
如上图所示,当线程1判断instance为空时,为对象分配内存空间,并将instance指向内存空间。此时还没有进行对象的初始化,发生了线程切换,线程2获取到CPU资源执行任务。线程2判断此时的instance不为空,则不再执行创建对象的操作,直接返回未初始化的instance对象。
所以,造成有序性问题的根本原因是编译器对程序进行优化,从而可能造成有序性问题。
37.Java中提供了哪些类解决多线程特性问题?
在Java中解决原子性问题的方案包括synchronized、Lock、ReentranLock、ReadWriteLock、CAS操作、Java中提供的原子类等。
解决可见性和有序性问题,可以禁用CPU缓存和编译器优化。
JVM提供了禁用缓存和编译优化的方法,包括volatile关键字、synchronized、final关键字以及Java内存模型中的Happens-Before原则。
38.说说你对内存区域的理解?
JVM在运行时,会将其管理的内存区域划分为方法区、堆、虚拟机栈、本地方法栈和程序计数器5个区域;
- 方法区和堆是所有下城共享的区域;
- 虚拟机栈、本地方法栈、程序计数器是各个线程私有的;
39.说说你对Java内存模型的理解?
Java内存模型简称JMM,是Java中为了解决可见性和有序性问题制定的一种编程规范。
Java内存模型规定所有变量都存储在主内存中,也就是存储在计算机的物理内存中,每个线程都有自己的工作内存,用于存储线程私有的数据,线程对变量的所有操作都需要在工作内存中完成。一个线程不能直接访问其它线程工作内存中的数据,只能通过主内存进行数据交互。
- 变量都存储在主内存中;
- 当线程需要操作变量时,需要先将主内存中的变量复制到对应的工作内存中;
- 线程直接读写工作内存中的变量;
- 一个线程不能访问其它线程工作内存中的数据,只能通过主内存间接访问;
40.说说你对Happens-Before原则的理解?
在JMM中,定义了Happens-Before原则,用于保证程序在执行过程中的可见性和有序性。Happens-Before原则主要包括:
程序次序原则表示在单个线程中,程序按照代码的顺序执行,前面的代码操作必然发生于后面的代码操作之前。
volatile变量原则表示对一个volatile变量的写操作,必然发生于后续对这个变量的读操作之前。
传递原则表示如果操作A先于操作B,操作B先于操作C,那么操作A一定先于操作C。
锁定原则表示对一个锁的解锁操作必然发生于后续对这个锁的加锁操作之前。
线程启动原则表示如果线程1调用线程2的start()方法启动线程2,则start()操作必然发生于线程2的任意操作之前。
线程终结原则表示如果线程1等待线程2完成操作,那么当线程2完成后,线程1能够访问到线程2修改后的共享变量的值。写一段代码,理解一下线程终结原则。
package com.nezha.thread;
public class Test0910 {
private String name = "";
private void threadEnd() throws InterruptedException {
Thread thread = new Thread(()->{
name = "哪吒编程";
});
thread.start();//线程开始
thread.join();//等待线程执行完毕
System.out.println(name);//控制台输出哪吒编程
}
public static void main(String[] args) throws InterruptedException {
Test0910 test0910 = new Test0910();
test0910.threadEnd();
}
}
线程中断原则表示对线程interrupt()方法的调用必然发生于被中断线程的代码检测到中断事件发生前。
对象终结原则表示一个对象的初始化必然发生于它的finalize()方法开始前。
41.Java对象结构由哪几部分组成?
Java中对象结构主要包括对象头、实例数据、对其填充三部分。
(1)对象头
对象头中存储了对象的hash码、对象所属的分代年龄、对象锁、锁状态、偏向锁的ID、获得偏向锁的时间戳等,如果当前对象是数组对象,则对象头中还会存储数组的长度信息。
Java中的对象头进一步分为Mark Word、类型指针和数组长度三部分。
Mark Word主要用来存储对象自身的运行时数据,例如,对象的Hash码、GC的分代年龄、锁的状态标志、对象的线程锁状态信息、偏向线程ID、获得的偏向锁的时间戳等。
64位的JVM中Mark Word的结构
- 锁标志位:占用2位存储,锁标志位的值不同,所代表的整个Mark Word的含义不同;
- 是否偏向锁标记:占用1位存储空间,标记对象是否开启了偏向锁。
- 分代年龄:占用4位存储空间,表示Java对象的分代年龄;
- 对象HashCode:占用31位存储空间,主要存储对象的HashCode值;
- 线程ID:占用54位存储空间,表示持有偏向锁的线程ID;
- 时间戳:占用2位存储空间,表示偏向锁的时间戳;
- 指向栈中锁记录的指针:占用62位存储空间,表示在轻量级锁的状态下,指向栈中锁记录的指针;
- 指向重量级锁的指针:占用62位存储空间,表示在重量级锁的状态下,指向对象监视器的指针;
(2)实例数据
实例数据主要存储的是对象的成员变量信息。
(3)对其填充
在HotSpot JVM中,对象的起始地址必须是8的整数倍。由于对象头占用的存储空间已经是8的整数倍,所以如果当前对象的实例变量占用的存储空间不是8的整数倍,则需要使用填充数据来保证8字节的对齐。
42.谈谈你对@Async注解的理解?
@Async的作用就是异步处理任务。
- 在方法上添加@Async,表示此方法是异步方法;
- 在类上添加@Async,表示类中的所有方法都是异步方法;
- 使用此注解的类,必须是Spring管理的类;
- 需要在启动类或配置类中加入@EnableAsync注解,@Async才会生效;
在使用@Async时,如果不指定线程池的名称,也就是不自定义线程池,@Async是有默认线程池的,使用的是Spring默认的线程池SimpleAsyncTaskExecutor。
43.volatile 关键字的作用
Java 提供了 volatile 关键字来保证可见性和禁止指令重排。
volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
44.既然 volatile 能够保证线程间的变量可见性,是不是就意味着基于 volatile 变量的运算就是并发安全的?
虽然 volatile 能够保证线程间的变量可见性,但这并不意味着基于 volatile 变量的运算就是并发安全的。
volatile 关键字在 Java 中确保了变量的更新操作会立即被其他线程看到,但它并不能保证原子性。
对于复合操作(如 i++)并不能保证其原子性。在这种情况下,应该使用其他同步机制(如 synchronized 或原子类)来确保操作的原子性。
在多核处理器的系统中,由于缓存一致性问题,即使在一个线程中对 volatile 变量的写入操作可能会被其他线程延迟看到,这是因为不同核心的缓存需要时间来同步。
45.为什么volatile能保证变量对所有线程的可见性?
对volatile变量的所有读写操作都是直接在主内存中进行的,这意味着任何对该变量的修改都会立即反映到主内存中。
每次线程访问volatile变量时,它都必须从主内存中读取该变量的最新值,而不是使用线程本地缓存中的值。
volatile还能防止编译器对代码进行重排序优化,这样就能确保程序按照代码的顺序执行,进一步维护了变量的可见性和线程安全。
46.volatile是如何实现指令重排的
JVM编译器可以通过在程序编译生成的指令序列中插入内存屏障来禁止在内存屏障前后的指令发生重排。
volatile虽然可以保证数据的可见性和有序性,但不能保证数据的原子性。
- 读屏障插入在读指令前面,能够让CPU缓存中的数据失效,直接从主内存中读取数据;
- 写屏障插入在写指令后面,能够让写入CPU缓存的最新数据立刻刷新到主内存;
47.说说什么是重排序?
为了提高程序的执行性能,编译器和CPU会对程序的指令进行重排序,可以分为编译器重排序和CPU重排序,CPU重排序又可以分为指令级重排序和内存系统重排序。
程序源码通过编译器重排序、CPU重排序中的指令级重排序和内存系统重排序之后,才能生成最终的指令执行序列。可以在这个过程中插入内存屏障来禁止指令重排。
编译器重排序是在代码编译阶段为了提高程序的执行效率,但不改变程序的执行结果而进行的重排序。
比如,在编译过程中,如果编译器需要长时间等待某个操作,而这个操作和它后面的代码没有任何数据上的依赖关系,则编译器可以选择先编译这个操作后面的代码,再回来处理这个操作,这样可以提升编译的速度。
现代CPU基本上都支持流水线操作,在多核CPU中,为了提高CPU的执行效率,流水线都是并行的。同时,在不影响程序语义的前提下,CPU中的处理顺序可以和代码的顺序不一致,只要满足as-if-serial原则即可。
- 指令级重排序指在不影响程序执行的最终结果的前提下,CPU核心对不存在数据依赖性的指令进行的重排序操作;
- 内存系统重排序指在不影响程序执行的最终结果的前提下,CPU对存放在高速缓存中的数据进行的重排序,内存系统重排序虽然可能提升程序的执行效率,但是可能导致数据不一致。
48.as-if-serial原则是什么?
编译器和CPU对程序代码的重排序必须遵循as-if-serial原则,as-if-serial原则规定编译器和CPU无论对程序代码如何重排序,都必须保证程序在单线程环境下运行的正确性。
在符合as-if-serial原则的基础上,编译器和CPU只可能对不存在数据依赖关系的操作进行重排序。如果指令之间存在数据依赖关系,则编译器和CPU不会对这些指令进行重排序。
as-if-serial原则能够保证在单线程环境下程序执行结果的正确性,不能保证在多线程环境下好吃呢个选结果的正确性。
49.什么是缓存一致性?
CPU的缓存一致性要求CPU内部各级缓存之间的数据是一致的。当多个CPU核心涉及对同一块主内存的数据进行读写和计算操作时,可能导致各个CPU核心之间缓存的数据不一致。
通过缓存一致性协议解决缓存一致性问题,比如MSI协议、MESI协议等。
50.说说什么是伪共享?
CPU在读取数据时,是以一个缓存行来读取的。目前,主流的CPU的缓存行大小为64Bytes。所以,一个缓存行中可能存储多个数据(实际存储的是数据的内存块),当多个线程同时修改一个缓存行里的多个变量时,由于MESI协议是针对缓存行修改状态的,就会导致多个线程的性能相互影响,这就是伪共享。
假设缓存行中存储的是64位,也就是8byte的double类型的数据,则一个缓存行可以存储8个double类型的数据。
如果多个线程共享存储在同一个缓存行的不同double数据,并且线程1对变量X的值进行了修改,那么此时,即使线程2并没有修改变量Y的值,即使线程1和线程2不共享同一个变量,线程1和线程2会影响彼此的性能,导致伪共享的问题。
如何解决伪共享问题?
- JDK8之前,通过字节填充的方式解决伪共享的问题;
- JDK8之后,引入@Contended注解来自动填充缓存行,避免伪共享问题;
51.ThreadLocal 是什么?有哪些使用场景?
ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个 ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。
原理:线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。
经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection,还有 Session 管理等问题。
52.请谈谈 ThreadLocal 是怎么解决并发安全的?
ThreadLocal通过为每个线程提供独立的变量副本来解决并发问题,从而避免了不同线程之间的数据冲突和同步问题。
在多线程编程中,共享变量的访问往往需要通过锁或其他同步机制来保证线程安全,这可能会导致性能下降,尤其是在高并发场景下。ThreadLocal提供了一种不同的解决方案,它允许每个线程拥有自己的独立变量副本,这样线程在操作数据时就不会相互干扰,不需要进行额外的同步措施。
具体来说,ThreadLocal的实现原理是利用ThreadLocalMap,这是一个存储在Thread类中的内部数据结构,用于存放每个线程的局部变量副本。当一个线程首次访问ThreadLocal变量时,会在自己的ThreadLocalMap中创建一个新的变量副本,之后的访问都是针对这个副本进行。这样,每个线程都有自己的独立副本,就不会产生线程安全问题。
在spring的源码中,就使用了ThreadLocal来管理连接,在很多开源项目中,都经常使用ThreadLocal来控制多线程并发问题,因为它足够的简单,我们不需要关心是否有线程安全问题,因为变量是每个线程所特有的。
53.使用 ThreadLocal 需要注意些什么?
(1)避免内存泄漏
使用完ThreadLocal后,应及时调用remove()方法清除线程的变量副本,特别是在使用线程池时,因为线程可能会被重用,不清理可能导致内存泄漏。
(2)设置初始值
为ThreadLocal设置初始值,可以通过重写ThreadLocal的initialValue()方法来实现。
(3)线程间隔离
确保ThreadLocal实例不是static的,否则所有线程将共享同一个变量,而不是每个线程有自己的变量副本。
(4)避免封装可变对象
尽量不要将可变对象封装在ThreadLocal中,这可能会导致线程之间的数据不一致。
(5)线程池使用注意
在线程池环境下使用ThreadLocal时,应注意每次任务执行前显式地调用set方法为ThreadLocal设置正确的变量值,以保证数据的正确性。
54.AQS有哪些特性?
- 可重入性:AQS支持一个线程多次获得同一把锁,即锁的可重入性;
- 独占与共享:AQS可以实现独占锁(只有一个线程能持有锁)和共享锁(多个线程可以同时持有锁);
- 公平与非公平:AQS可以根据需求实现公平锁和非公平锁。公平锁是指等待时间最长的线程会优先获得锁,非公平锁则不保证这一点;
- 条件变量:AQS还可以实现条件变量,允许线程在特定条件下等待或被唤醒。
55.AQS中的state是什么?
在AQS中维护着一个表示共享资源加锁情况的变量 volatile int state,以及一个FIFO的线程阻塞队列(称为CLH队列)。当多个线程并发访问共享资源时,如果共享资源已经被某个线程加了锁,那么其它线程在访问此共享资源时就会加入CLH队列。
state表示共享资源被线程加锁的次数。
当state的值为1时,就表示共享资源被某个线程加了一次锁;当state的值为0时,表示共享资源没有被加锁,随时可以访问;
56.访问state的方法
- int getState(),获取state值;
- void setState(int newState),设置state值;
- compareAndSetState(int expect,int update),使用哪个CAS算法,设置state值;
57.什么是阻塞队列?阻塞队列的实现原理是什么?
阻塞队列是一种特殊类型的队列,它支持在尝试获取元素时如果队列为空则等待,以及在尝试添加元素时如果队列已满则等待的附加操作。
阻塞队列通过内部锁机制和条件变量实现了线程安全的队列操作,它们能够在必要的时候让线程等待,直到可以进行相应的操作,从而在多线程环境中提供了一种有效的同步机制。
阻塞队列的实现原理主要基于以下2个方面:
阻塞队列使用锁来确保在任一时刻只有一个线程能够访问队列。这防止了多个线程同时对队列进行修改,从而维持了队列的完整性。
除了标准的锁机制,阻塞队列还利用了条件变量(Condition)。这使得线程可以在特定的条件下挂起和唤醒。例如,当队列为空时,试图取出元素的线程会等待直到队列非空;同样,当队列已满时,试图添加元素的线程会等待直到队列有空间可用。
阻塞队列通常用于生产者-消费者模型中,其中生产者线程负责添加元素到队列,而消费者线程负责从队列中取出元素。这种模式要求线程间要有协调机制,以确保数据的安全传输和处理。
58.什么是CAS?
CAS(V, A, B) ,内存值V,期待值A, 修改值B(V 是否等于 A, 等于执行, 不等于将B赋给V)
59.CAS带来哪些问题?
(1)ABA问题
CAS操作的流程为:
- 读取原值。
- 通过原子操作比较和替换。
- 虽然比较和替换是原子性的,但是读取原值和比较替换这两步不是原子性的,期间原值可能被其它线程修改。
ABA问题有些时候对系统不会产生问题,但是有些时候却也是致命的。
ABA问题的解决方法是对该变量增加一个版本号,每次修改都会更新其版本号。JUC包中提供了一个类AtomicStampedReference,这个类中维护了一个版本号,每次对值的修改都会改动版本号。
(2)自旋次数过多
CAS操作在不成功时会重新读取内存值并自旋尝试,当系统的并发量非常高时即每次读取新值之后该值又被改动,导致CAS操作失败并不断的自旋重试,此时使用CAS并不能提高效率,反而会因为自旋次数过多还不如直接加锁进行操作的效率高。
(3)只能保证一个变量的原子性
当对一个变量操作时,CAS可以保证原子性,但同时操作多个变量时CAS就无能为力了。
可以封装成对象,再对对象进行CAS操作,或者直接加锁。
60.你了解过哪些同步器?请分别介绍下。
(1)Semaphore(信号量)
Semaphore维护一组许可证,可以用来控制同时访问某个特定资源的线程数量。当一个线程申请许可证时,如果可用则分配,否则线程将阻塞直到有可用的许可证。Semaphore常用于限制对资源的并发访问数量,例如在数据库连接池中限制同时打开的连接数。
(2)ReentrantLock(重入锁)
ReentrantLock是一个互斥锁,它提供了与synchronized关键字类似的功能,但是具有更高的灵活性。它允许公平和非公平的锁定策略,并且可以响应中断、尝试非阻塞地获取锁以及定时锁等候等。
(3)CountDownLatch(倒计时锁存器)
CountDownLatch是一个同步辅助工具,允许一个或多个线程等待其他线程完成操作。它包含一个计数器,当计数器的值变为零时,等待的线程可以被唤醒继续执行。
(4)CyclicBarrier(循环屏障)
CyclicBarrier是一种同步辅助工具,它允许一组线程相互等待,直到所有线程都准备好继续执行。它可以用来协调多个线程之间的复杂同步过程。
61.Java 中 Semaphore 是什么?
Semaphore(信号量)在Java中是一个非常重要的并发工具,它主要用于限制可以同时访问某些资源的线程数量。
Semaphore通过内部维护一个计数器来实现这一功能,该计数器的值表示可以访问共享资源的数量。当一个线程想要访问某个共享资源时,它必须首先获得信号量。如果信号量的计数器值大于0,表示有可用的资源,那么计数器会减1,允许线程继续访问资源。如果计数器值为0,则线程会被阻塞,直到其他线程释放信号量。
Semaphore的主要操作包括:
- acquire():获取一个许可,如果没有可用的许可,当前线程将被阻塞直到有许可被释放。
- release():释放一个许可,增加可用的许可数量。
Semaphore可以是公平的或非公平的。公平的Semaphore会在许可可用时优先给等待时间最长的线程分配许可,而非公平的Semaphore则不考虑等待时间,可能允许新来的线程先于等待较久的线程获得许可。公平性取决于创建Semaphore对象时的设置。
62.Lock是什么?有哪优势?使用场景都有哪些?
Lock接口在java.util.concurrent.locks包中,它的主要实现类是ReentrantLock。
与synchronized相比,Lock具有以下优势:
- Lock提供了多种方法,如tryLock、lockInterruptibly等,这些方法允许更灵活的锁定控制,比如可以尝试获取锁,而不是一直等待。
- 通过lockInterruptibly方法,线程在等待锁的过程中可以响应中断。
- Lock可以实现公平锁,即等待时间最长的线程会优先获得锁,这可以减少线程饥饿的问题。
- Lock支持可重入,即一个线程可以多次获取同一把锁。
- Lock可以跨多个代码段进行锁定,而synchronized只能锁定单个方法或代码块。
63.Lock中有哪些常用方法?
(1)void lock()
阻塞模式抢占锁的方法。如果当前线程抢占锁成功,则继续向下执行程序的业务逻辑,否则,当前线程会阻塞,直到其它抢占到锁的线程释放锁后再继续抢占锁。
(2)void lockInterruptibly() throws InterruptedException
可中断模式抢占锁的方法。当前线程在调用lockInterruptibly()方法抢占锁的过程中,能够响应中断信号,从而能够中断当前线程。
(3)boolean tryLock()
非阻塞模式下抢占锁的方法。当前线程调用tryLock()方法抢占锁时,线程不会阻塞,而会立即返回抢占锁的结果。
(4)boolean tryLock(long time, TimeUnit unit) throws InterruptedException
在tryLock()的基础上,加上限制抢占锁的时间限制。
(5)void unlock()
释放锁。
(6)Condition newCondition()
创建与当前线程绑定的Condition条件,主要用于线程间以“等待 - 通知”的方式进行通信。
所以,Lock锁支持响应中断、超时和以非阻塞的方式获取锁,全面弥补了JVM中synchronized内置锁的不足。
64.lock()和tryLock()有何不同?
- lock():尝试获取锁,如果锁被占用则等待。
- tryLock():尝试获取锁,如果锁被占用则返回false,则不再等待,如果锁未被占用,则立刻获取锁,并返回true。
- tryLock(long time, TimeUnit unit):在tryLock()的基础上,如果锁被占用,则会在一段时间内不断地尝试获取锁,直至成功获取锁。
- lockInterruptibly():一个线程加锁之后,其它线程因为无法获取锁而导致的等待状态可以被中断。
65.产生死锁的4个必要条件?
- 互斥条件:指进程对所分配到的资源进行排它性使用,在一段时间内某资源只由一个进程占用,如果此时还有其他进程请求资源,则请求者只能等待,直至占有资源的进程被释放;
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不可剥夺:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 循环等待:一个等待一个,产生了一个闭环。
66.如何避免产生死锁?
- 避免嵌套锁
- 按顺序加锁
为所有锁定义一个全局的顺序,所有线程都按照这个顺序来获取锁。这样可以避免循环等待条件,从而避免死锁。
- 设置超时时间
- 避免持有锁的同时调用wait()方法
在持有锁的情况下调用wait()方法会导致其他线程无法获取该锁,从而可能引发死锁。
- 使用并发工具类
Java提供了许多并发工具类,如java.util.concurrent.locks包中的ReentrantLock、Condition等,这些工具类提供了更高级的锁机制,可以有效减少死锁的风险。
67.产生饥饿的主要原因?
高优先级的线程不断抢占资源,低优先级的线程抢不到,某个线程一直不释放资源,导致其他线程无法获取资源。
68.如何避免饥饿?
- 使用公平锁分配资源;
- 为程序分配足够的系统资源;
- 避免持有锁的线程长时间占用锁;
69.Callable 和Runnable有什么区别?
Callable接口的call()方法允许有返回值,而Runnable接口的run()方法则不返回任何结果。
Callable的call()方法允许抛出异常,而Runnable的run()方法则不能抛出任何被检查的异常。
虽然它们都可以应用于Executors,但Thread类只支持Runnable接口。这意味着如果需要创建一个线程并且不需要返回值或抛出异常,可以使用Runnable接口。而如果需要返回值或者需要处理异常,那么应该选择Callable接口。
如果你的任务需要返回结果或者可能会抛出异常,那么应该选择实现Callable接口。如果任务不需要返回结果且不会抛出异常,那么可以选择实现Runnable接口。
70.一个线程运行时发生异常会怎样?
如果异常被trycatch捕获,在catch中也没有抛出异常或返回,则会继续执行当前线程。
如果异常被抛出且未被捕获:
- 线程会立即停止执行。
- 该异常通常只影响抛出异常的线程本身,不会影响其他线程的执行。
- 如果异常发生在线程池中的一个线程里,那么这个线程通常会被标记为死亡并从线程池中移除,同时线程池可能会创建一个新的线程来替代它。
71.如何在两个线程间共享数据?
全局变量,创建全局变量或类成员变量,这样多个线程可以访问和修改同一个变量,实现数据的共享。
- 同步机制,通过在方法上添加synchronized关键字,确保同一时间只有一个线程能够访问共享数据的方法,从而保证数据的一致性。
- 原子变量,使用Java提供的原子变量类,如AtomicInteger、AtomicLong等,这些类提供了原子操作,确保了数据的一致性和线程安全。
- 锁机制,使用Lock接口及其实现类,如ReentrantLock,来控制对共享资源的访问,确保在任何时刻只有一个线程能够访问共享资源。
72.说说你对Future的理解?
Future是一个代表异步计算结果的接口。它提供了检查计算是否完成的方法以及获取计算结果的方法。通过Future,我们可以了解Callable任务是否已经完成,如果完成了,还可以获取它的返回值。
FutureTask是Future的一个实现类,它同时实现了Runnable接口。这意味着FutureTask既可以作为任务提交给Executor执行,也可以作为Future得到Callable的返回值。这样的设计使得我们能够在不阻塞主线程的情况下执行耗时的操作,并在需要结果时再获取它。
Callable通常用于需要返回结果的异步任务,而Future提供了一种检查任务状态和获取任务结果的机制。在实际使用中,我们通常会将Callable任务提交给Executor,然后获得一个Future对象,通过这个对象可以在适当的时候获取任务的结果。
73.FutureTask是什么?
FutureTask是Java中实现Runnable和Future接口的一个类,它提供了一种方便的方式来执行异步计算并获取计算结果。
FutureTask可以接收一个Callable或Runnable对象作为参数,在执行时,如果传入的是Callable,则可以返回执行结果,如果是Runnable,则不返回结果。这使得FutureTask能够灵活地用于不同类型的任务。
FutureTask的主要功能之一是提供取消和启动计算的方法,以及查询计算是否完成和检索计算结果的方法。当调用get()方法时,如果任务还没有完成,将会阻塞调用线程直到任务完成。一旦计算完成,就不能重新开始或取消计算,但可以通过调用runAndReset()方法重置状态后重新计算。
FutureTask的设计使得它可以很容易地与ExecutorService配合使用。当把FutureTask提交给ExecutorService执行后,它会返回一个表示异步计算结果的Future对象。这个Future对象可以用来检查计算是否完成,并获取计算的结果。
74.什么是竞争条件?如何解决竞争条件?
竞争条件是指在多线程环境中,由于线程的相对执行顺序不确定,导致程序结果不一致的问题。
解决竞争条件的方式有4种:
- 锁机制,通过使用synchronized关键字或显式锁(如ReentrantLock)来保护共享资源的访问,确保同一时刻只有一个线程能够修改数据。
- 原子操作,对于简单的操作,可以使用原子类(如AtomicInteger、AtomicLong等)来代替共享变量,这些类提供了原子性的读写操作。
- 避免共享,尽量减少共享资源的使用,例如使用线程局部存储(ThreadLocal)来为每个线程提供独立的实例。
- 数据结构选择,选择适合并发环境的数据结构,如ConcurrentHashMap、CopyOnWriteArrayList等,它们内部已经实现了必要的同步机制。
75.thread dump是什么?如何生成thread dump?
Thread dump是指Java虚拟机(JVM)在特定时间点上生成的线程快照,它记录了当前所有活动线程的状态信息。
Thread dump可以帮助开发人员诊断和解决多线程应用程序中的问题,例如死锁、资源竞争等。通过分析Thread dump,可以了解每个线程的状态、调用栈、锁信息等,从而找出问题的根源。
要生成Thread dump,可以使用以下方法:
- 使用jstack命令行工具,在命令行中输入jstack,其中是Java进程的ID。这将生成一个包含所有线程状态信息的文本文件。
- 使用kill -3命令,在命令行中输入kill -3,其中是Java进程的ID。这将向Java进程发送一个信号,使其生成Thread dump并输出到标准错误流。
- 使用IDE或调试器,许多集成开发环境(IDE)和调试器提供了生成Thread dump的功能。例如,Eclipse和IntelliJ IDEA都可以通过菜单或快捷键来生成Thread dump。
- 使用Java API,可以使用java.lang.management.ThreadMXBean接口中的dumpAllThreads(true, true)方法来生成Thread dump。这将返回一个包含所有线程状态信息的字符串。
76.Java多线程有几种状态?
线程在Java中有几种状态,主要包括:新建(New)、运行(Runnable)、等待(Waiting)、定时等待(Timed Waiting)和阻塞(Blocked)。
- 新建(New):线程刚创建时的状态,尚未开始执行。
- 运行(Runnable):线程正在Java虚拟机中执行,也可能在等待操作系统资源,如处理器时间片。
- 等待(Waiting):线程在等待其他资源,如等待I/O操作完成或等待获取监视器锁。
- 定时等待(Timed Waiting):与等待状态类似,但有一个预定的等待时间,如使用Thread.sleep(long millis)方法的线程。
- 阻塞(Blocked):线程在等待一个监视器的锁定,即线程试图进入同步代码块/方法,但被锁阻止。
77.如何停止一个正在运行的线程?
设置一个布尔类型的标志位,当需要停止线程时,将标志位设置为true。在线程的运行循环中,检查标志位的值,如果为true,则退出循环,从而停止线程。
调用线程的interrupt()方法可以设置线程的中断状态。在线程的运行过程中,可以使用Thread.currentThread().isInterrupted()方法检查中断状态,如果为true,则退出循环,从而停止线程。
78.什么是乐观锁?
乐观锁认为对一个对象的操作不会引发冲突,所以每次操作都不进行加锁,只是在最后提交更改时验证是否发生冲突,如果冲突则再试一遍,直至成功为止,这个尝试的过程称为自旋。
- 乐观锁没有加锁,但乐观锁引入了ABA问题,此时一般采用版本号进行控制;
- 也可能产生自旋次数过多问题,此时并不能提高效率,反而不如直接加锁的效率高;
- 只能保证一个对象的原子性,可以封装成对象,再进行CAS操作;
79.乐观锁一定比悲观锁性能好吗?
乐观锁通常在高并发场景下表现更好,因为它减少了锁的竞争和线程阻塞的情况。
如果写操作非常频繁,乐观锁可能会因为频繁的冲突和重试而导致性能下降;反之,在读操作远多于写操作的情况下,乐观锁能够提供更好的性能,因为它避免了不必要的加锁操作。
80.悲观锁与乐观锁有什么区别?
悲观锁在一个线程进行加锁操作后使得该对象变为该线程的独有对象,其它的线程都会被悲观锁阻拦在外,无法操作。
悲观锁的缺陷:
- 一个线程获得悲观锁后其它线程必须阻塞。
- 线程切换时要不停的释放锁和获取锁,开销巨大。
- 当一个低优先级的线程获得悲观锁后,高优先级的线程必须等待,导致线程优先级倒置,synchronized锁是一种典型的悲观锁。
乐观锁认为对一个对象的操作不会引发冲突,所以每次操作都不进行加锁,只是在最后提交更改时验证是否发生冲突,如果冲突则再试一遍直到成功为止,这个尝试的过程被称为自旋。乐观锁其实并没有加锁,但乐观锁也引入了诸如ABA、自旋次数过多等问题。
乐观锁一般会采用版本号机制,先读取数据的版本号,在写数据时比较版本号是否一致,如果一致,则更新数据,否则再次读取版本号,直到版本号一致。
Java中的乐观锁都是基于CAS自旋实现的。
81.自旋的优势和应用场景
自旋锁的优势在于节省线程状态切换的开销,适用于锁持有时间较短的场景。
自旋锁是一种在多线程编程中使用的同步机制,它的核心思想是当一个线程尝试获取锁时,如果锁不可用,该线程会不断循环尝试获取锁,而不是进入阻塞状态。
自旋的优势在于:
- 节省状态切换开销,线程在自旋时始终保持在Runnable状态,避免了从Runnable到Blocked再到Runnable的状态转换,这减少了操作系统在线程调度上的开销。
- 减少上下文切换,自旋锁避免了线程因等待锁而进行的上下文切换,这在锁竞争激烈的情况下尤其有利。
- 如果被保护的临界区代码执行时间很短,使用自旋锁可以避免线程长时间阻塞,提高系统的整体效率。
82.什么是锁消除和锁粗化?
(1)锁消除
所消除就是虚拟机根据一个对象是否真正存在同步情况,若不存在同步情况,则对该对象的访问无需经过加锁解锁的操作。
比如StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面的代码进行优化,也就是锁消除。
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
从源码可以看出,append方法用了 synchronized关键字,它是线程安全的。但我们可能仅在线程内部把StringBuffer当做局部变量使用;StringBuffer仅在方法内作用域有效,不存在线程安全的问题,这时我们可以通过编译器将其优化,将锁消除,前提是Java必须运行在server模式,同时必须开启逃逸分析;
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
其中+DoEscapeAnalysis表示开启逃逸分析,+EliminateLocks表示锁消除。
public static String createStringBuffer(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
sBuf.append(str1);// append方法是同步操作
sBuf.append(str2);
return sBuf.toString();
}
逃逸分析:比如上面的代码,它要看sBuf是否可能逃出它的作用域?如果将sBuf作为方法的返回值进行返回,那么它在方法外部可能被当作一个全局对象使用,就有可能发生线程安全问题,这时就可以说sBuf这个对象发生逃逸了,因而不应将append操作的锁消除,但我们上面的代码没有发生锁逃逸,锁消除就可以带来一定的性能提升。
(2)锁粗化
锁的请求、同步、释放都会消耗一定的系统资源,如果高频的锁请求反而不利于系统性能的优化,锁粗化就是把多次的锁请求合并成一个请求,扩大锁的范围,降低锁请求、同步、释放带来的性能损耗。
83.什么是公平锁和非公平锁?
有一天早上,云韵、美杜莎、小医仙结伴去买酱香拿铁,到了咖啡店,先排队,一个一个来。不一会,哪吒来了,也买酱香拿铁,只能在末尾排队。这个就是公平锁。
但是呢?第二天早上,哪吒又去买酱香拿铁,上一次去晚了没买到(线程被饿死了),这次急了,要插队买,不讲武德。终于喝上了心心念念的酱香拿铁,这个就是非公平锁。
84.公平锁是如何实现的?
公平锁,顾名思义,就是争抢锁的时候,大家都是公平的。
每个线程抢占锁的时候,都会检索锁维护的等待队列,如果等待队列为空,或者当前线程是等待队列的第一个线程,则当前线程获取到锁,否则,当前线程加入到等待队列的尾部,然后等待队列中的线程会按先进先出的规则按顺序尝试获取资源。
85.非公平锁是如何实现的?
非公平锁的核心就是抢占锁的所有线程是不公平的,在多线程并发环境中,每个线程在抢占锁的过程中都会先直接尝试抢占锁,如果抢占成功,就继续执行程序的业务逻辑,如果抢占失败,就会进入等待队列中排队。
公平锁和非公平锁的区别是,非公平锁在队列的处理上比公平锁多了一个插队的过程,,如果插队时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
86.什么是内置锁?
Java提供了一种内置的锁机制来支持原子性:同步代码块。
同步代码块包含两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
synchronized(lock){
//访问或修改由锁保护的共享状态
}
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一个互斥体,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞。直到线程B释放这个锁。如果线程B永远不释放锁,那么A也将永远等待下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义,一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其它线程正在执行由同一个锁保护的同步代码块。
87.请谈谈 ReadWriteLock 和 StampedLock
ReadWriteLock包括两种子锁
(1)ReadWriteLock
ReadWriteLock 可以实现多个读锁同时进行,但是读与写和写于写互斥,只能有一个写锁线程在进行。
(2)StampedLock
StampedLock是Jdk在1.8提供的一种读写锁,相比较ReentrantReadWriteLock性能更好,因为ReentrantReadWriteLock在读写之间是互斥的,使用的是一种悲观策略,在读线程特别多的情况下,会造成写线程处于饥饿状态,虽然可以在初始化的时候设置为true指定为公平,但是吞吐量又下去了,而StampedLock是提供了一种乐观策略,更好的实现读写分离,并且吞吐量不会下降。
StampedLock包括三种锁:
(1)写锁writeLock:
writeLock是一个独占锁写锁,当一个线程获得该锁后,其他请求读锁或者写锁的线程阻塞, 获取成功后,会返回一个stamp(凭据)变量来表示该锁的版本,在释放锁时调用unlockWrite方法传递stamp参数。提供了非阻塞式获取锁tryWriteLock。
(2)悲观读锁readLock:
readLock是一个共享读锁,在没有线程获取写锁情况下,多个线程可以获取该锁。如果有写锁获取,那么其他线程请求读锁会被阻塞。悲观读锁会认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据进行加锁,这是在读少写多的情况下考虑的。请求该锁成功后会返回一个stamp值,在释放锁时调用unlockRead方法传递stamp参数。提供了非阻塞式获取锁方法tryWriteLock。
(3)乐观读锁tryOptimisticRead:
tryOptimisticRead相对比悲观读锁,在操作数据前并没有通过CAS设置锁的状态,如果没有线程获取写锁,则返回一个非0的stamp变量,获取该stamp后在操作数据前还需要调用validate方法来判断期间是否有线程获取了写锁,如果是返回值为0则有线程获取写锁,如果不是0则可以使用stamp变量的锁来操作数据。由于tryOptimisticRead并没有修改锁状态,所以不需要释放锁。这是读多写少的情况下考虑的,不涉及CAS操作,所以效率较高,在保证数据一致性上需要复制一份要操作的变量到方法栈中,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性得到了保证。
88.说一下你对偏向锁的理解?
如果在同一时刻有且仅有一个线程执行了synchronized修饰的方法,则执行方法的线程不存在与其它线程竞争锁的情况,此时,锁就会变为偏向锁。当锁进入偏向状态时,对象头中的Mark Word的结构就会进入偏向结构。此时偏向锁标记为1,锁标志位为01,并将当前线程的ID记录在Mark Word中。当前线程如果再次进入此方法,要先检查对象头中的Mark Word中是否存储了自己的线程ID。
- 如果有,表示当前线程已经获取到锁,当前线程可以进入或退出此方法。
- 如果没有,则说明有其它线程参与锁竞争并获得了偏向锁,此时当前线程会尝试CAS方式将Mark Word中的线程ID替换为自己的线程ID,
替换的结果有两种:
- CAS操作成功,表示之前获取到偏向锁的线程已经不存在,Mark Word中的线程ID替换为自己的线程ID;
- CAS操作失败,表示之前获取到偏向锁的线程仍然存在,此时会暂停之前获取到偏向锁的线程,将Mark Word中的偏向锁标记为0,锁标 志位设置为00,偏向锁升级为轻量级锁。
89.说一下撤销偏向锁的过程
选择某个没有执行字节码的时间点,暂停拥有锁的线程;
遍历整个线程栈,检查是否存在对应的锁记录,如果存在锁记录,则清空锁记录,变为无锁状态。同时将锁记录指向Mark Word中的偏向锁标设置为0,锁标志位设置为01,将其设置为无锁状态,并清除Mark Word中的线程ID;
将当前锁升级为轻量级锁,并唤醒被暂停的线程;
90.锁优化有哪些手段?
加锁使得原本能够并行执行的操作变得串行化,串行操作会降低程序的性能,CPU对于线程的上下文切换也会降低系统的性能。下面总结一下锁优化的相关方法。
(1)缩小锁的范围
将一些不会引起线程安全问题的代码,移出同步代码块,尤其是耗时的IO操作,或者可能引起阻塞的方法,这样能提高程序执行的速度。
(2)减小锁的粒度
减小锁的粒度就是缩小锁定的对象,比如将一个大对象拆分成多个小对象,对这些小对象进行加锁,能够提高程序的并行度,提高程序执行的速度。
(3)锁分离
锁分离最典型的技术就是读写锁,ReadWriteLock分为写锁和读锁,其中读读不互斥,读写互斥,写写互斥,这样既保证了线程安全,又提高了性能。
(4)锁分段
进一步缩小锁的粒度,对一个独立对象的锁进行分解的现象叫做锁分段。锁分段最典型的例子就是ConcurrentHashMap。ConcurrentHashMap将数据按照不同的数据段进行存储,每个数据段分配一把锁,当某个数据段占有某个数据段的锁访问数据时,其它数据段的锁也能被其它线程抢占到,提高程序的并行度,提高程序性能。
(5)锁粗化
如果同一个线程不停的请求、同步、释放同一把锁,则会降低程序的执行性能,此时可以扩大锁的范围,即进行锁粗化处理。
91.为何要对文件加锁?
很多时候,会出现多个线程同时修改同一个文件的情形,这时就会出现很多问题,文件锁可以解决这个问题,它可以控制对文件或文件中的内容的访问权限。
加入用户同时想修改某个文件,线程1应该就会锁定此文件,线程2发现文件被锁定时,它必须等待该文件解锁。
可以通过FileChannel类的lock或者tryLock方法进行加锁解锁。
下面代码会使文件会使文件进入锁定状态,直至通道关闭,或者调用release方法。
// 以可写的方式打开一个文件 nezha.txt 的通道
FileChannel channel = FileChannel.open(Paths.get("nezha.txt"), StandardOpenOption.WRITE);
// 阻塞直至获取锁
FileLock lock = channel.lock();
// 会立即返回,要么返回锁,要么返回null
FileLock tryLock = channel.tryLock();
92.FileLock常用方法有哪些?
- FileChannel lock():获取文件的独占锁定。如果文件已被锁定,则阻塞直到锁定可用。
- FileChannel lock(long position, long size, boolean shared):获取文件的锁定,该锁定从文件的指定位置开始,并持续指定大小。如shared为true,则获取共享锁定;如果为false,则获取独占锁定。如果锁定不可用,则阻塞直到锁定可用。
- void release():释放锁定。如果锁定未释放,则阻塞直到锁定释放。
- boolean isShared():返回true如果此锁定是共享锁定,否则返回false。
- boolean isValid():返回true如果此锁定有效且尚未释放,否则返回false。
- long getPosition():返回此锁定的位置。
- long getSize():返回此锁定的大小。
- String toString():返回此锁定的字符串表示形式。
93.为什么说 Synchronized 是一个悲观锁?
当一个线程获得对象的Synchronized锁时,其他线程必须等待直到该锁被释放才能继续执行。这种独占机制确保了在同一时刻只有一个线程能够访问共享资源,防止了并发冲突,但也可能导致线程阻塞和降低系统的并发性能。
Synchronized总是假设最坏的情况,即每次访问都会有线程冲突,从而采取预防措施来避免这种冲突。
由于Synchronized在任何时候都会进行加锁操作,这涉及到用户态到核心态的转换、维护锁计数器以及检查是否有被阻塞的线程需要唤醒等操作,这些都会带来一定的性能开销。
94.synchronized的底层原理是什么?
synchronized是基于JVM中的Monitor锁实现的,Java1.5之前的synchronized锁性能较低,但是从Java1.6开始,对synchronized锁进行了大量的优化,引入可锁粗话、锁消除、偏向锁、轻量级锁、适应性自旋等技术来提升synchronized的性能。
当synchronized修饰方法时,当前方法会比普通方法在常量池中多一个ACC_SYNCHRONIZED标识符,synchronized修饰方法的核心原理如下图:
JVM在执行程序时,会根据这个ACC_SYNCHRONIZED标识符完成方法的同步。如果调用了被synchronized修饰的方法,则调用的指令会检查方法是否设置了ACC_SYNCHRONIZED标识符。如果方法设置了ACC_SYNCHRONIZED标识符,则当前线程先获取monitor对象。同一时刻,只会有一个线程获取monitor对象成功,进入方法体执行方法逻辑。在当前线程释放monitor对象前,其它线程无法获取同一个monitor对象,从而保证了同一时刻只有一个线程进入被synchronized修饰的方法中执行方法体的逻辑。
- synchronized修饰方法时,不需要JVM编译出的字节码完成加锁操作,是一种隐式的实现方式;
- synchronized修饰代码块时,是通过编译出的字节码生成的monitorenter和monitorexit指令完成的,在字节码层面是一种显示的实现方式;
当被加了synchronized的资源在执行过程中出现异常时,锁也会被释放。因此,在并发程序中一定要将异常及时处理,否则会影响并发的逻辑。
95.synchronized 锁升级的原理是什么?
为了重量级锁synchronized提高性能,Java虚拟机(JVM)对synchronized的实现进行了优化,引入了锁升级的概念。
synchronized 锁升级的原理是基于锁状态的变化,从无锁状态开始,逐步升级到偏向锁、轻量级锁,最终到重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级是单向的,只能升级不能降级。
(1)无锁
没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其它修改失败的线程会不断重试直到修改成功。
无锁总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,CAS是无锁技术的关键。
(2)偏向锁
对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。
偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停偏向锁的线程,然后判断锁对象是否处于被锁定状态,如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁。
如果线程处于活动状态,升级为轻量级锁的状态
(3)轻量级锁
轻量级锁是指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从er提升性能。
当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定次数时,轻量级锁便会升级为重量级锁,当一个线程已持有锁,另一个线程在自旋,而此时第三个线程来访时,轻量级锁也会升级为重量级锁。
注:自旋是什么?
自旋(spinlock)是指当一个线程获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
(4)重量级锁
指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁通过对象内部的监听器(monitor)实现,而其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
(5)锁状态对比
偏向锁 | 轻量级锁 | 重量级锁 | |
使用场景 | 只有一个线程进入同步块 | 虽然很多线程,但没有冲突,线程进入时间错开因而并未争抢锁 | 发生了锁争抢的情况,多条线程进入同步块争用锁 |
本质 | 取消同步操作 | CAS操作代替互斥同步 | 互斥同步 |
优点 | 不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是比对ThreadId) | 不会阻塞 | 不会空耗CPU |
缺点 | 适用场景太局限。若竞争产生,会有额外的偏向锁撤销的消耗 | 长时间获取不到锁空耗CPU | 阻塞,上下文切换,重量级操作,消耗操作系统资源 |
96.请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同。
(1)相似点
它们都是阻塞式的同步,也就是说一个线程获得了对象锁,进入代码块,其它访问该同步块的线程都必须阻塞在同步代码块外面等待,而进行线程阻塞和唤醒的代码是比较高的。
(2)功能区别
Synchronized是java语言的关键字,是原生语法层面的互斥,需要JVM实现;ReentrantLock 是JDK1.5之后提供的API层面的互斥锁,需要lock和unlock()方法配合try/finally代码块来完成。
Synchronized使用较ReentrantLock 便利一些;锁的细粒度和灵活性:ReentrantLock强于Synchronized;
(3)性能区别
Synchronized引入偏向锁,自旋锁之后,两者的性能差不多,在这种情况下,官方建议使用Synchronized。
① Synchronized
Synchronized会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令。
在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计数器+1,相应的执行monitorexit时,计数器-1,当计数器为0时,锁就会被释放。如果获取锁失败,当前线程就要阻塞,知道对象锁被另一个线程释放为止。
② ReentrantLock
ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有如下三项:
等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized避免出现死锁的情况。通过lock.lockInterruptibly()来实现这一机制;
公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁是非公平锁;ReentrantLock默认也是非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好;
锁绑定多个条件,一个ReentrantLock对象可以同时绑定多个对象。ReentrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像Synchronized要么随机唤醒一个线程,要么唤醒全部线程。
97.ReentrantLock 是如何实现可重入性的?
(1)什么是可重入性
一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。
(2)synchronized是如何实现可重入性
synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令。每个锁对象内部维护一个计数器,该计数器初始值为0,表示任何线程都可以获取该锁并执行相应的方法。根据虚拟机规范要求,在执行monitorenter指令时,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了对象的锁,把锁的计数器+1,相应的在执行monitorexit指令后锁计数器-1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
(3)ReentrantLock如何实现可重入性
ReentrantLock使用内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。Sync有两个实现,分别为NonfairSync(非公公平锁)和FairSync(公平锁)。Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计算重入次数,避免频繁的持有释放操作带来的线程问题。
(4)ReentrantLock代码实例
// Sync继承于AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
...
}
// ReentrantLock默认是非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
// 可以通过向构造方法中传true来实现公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
protected final boolean tryAcquire(int acquires) {
// 当前想要获取锁的线程
final Thread current = Thread.currentThread();
// 当前锁的状态
int c = getState();
// state == 0 此时此刻没有线程持有锁
if (c == 0) {
// 虽然此时此刻锁是可以用的,但是这是公平锁,既然是公平,就得讲究先来后到,
// 看看有没有别人在队列中等了半天了
if (!hasQueuedPredecessors() &&
// 如果没有线程在等待,那就用CAS尝试一下,成功了就获取到锁了,
// 不成功的话,只能说明一个问题,就在刚刚几乎同一时刻有个线程抢先了 =_=
// 因为刚刚还没人的,我判断过了
compareAndSetState(0, acquires)) {
// 到这里就是获取到锁了,标记一下,告诉大家,现在是我占用了锁
setExclusiveOwnerThread(current);
return true;
}
}
// 会进入这个else if分支,说明是重入了,需要操作:state=state+1
// 这里不存在并发问题
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 如果到这里,说明前面的if和else if都没有返回true,说明没有获取到锁
return false;
}
(5)代码分析
当一个线程在获取锁过程中,先判断state的值是否为0,如果是表示没有线程持有锁,就可以尝试获取锁。当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值+1,表示重入返回即可。
98.跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不同?
- 都是可重入锁。
- ReentrantLock内部是实现了Sync,Sync继承于AQS抽象类。Sync有两个实现,一个是公平锁,一个是非公平锁,通过构造函数定义。AQS中维护了一个state来计算重入次数,避免频繁的持有释放操作带来的线程问题。
- ReentrantLock只能定义代码块,而Synchronized可以定义方法和代码块。
- Synchronized是JVM的一个内部关键字,ReentrantLock是JDK1.5之后引入的一个API层面的互斥锁。
- Synchronized实现自动的加锁、释放锁,ReentrantLock需要手动加锁和释放锁,中间可以暂停。
- Synchronized由于引进了偏向锁和自旋锁,所以性能上和ReentrantLock差不多,但操作上方便很多,所以优先使用Synchronized。
99.那么请谈谈 AQS 框架是怎么回事儿?
AQS的核心思想是如果被请求的共享资源空闲,则将请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果资源被占用,则需要一套线程阻塞等待以及被唤醒时锁分配的机制。
AQS是一个抽象类,主要通过继承的方式来使用,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取和释放的方法来提供自定义的同步组件。
AQS队列内部维护的是一个FIFO的双向链表和一个volatile变量,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的范文前驱和后继节点。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中。
如果当前线程竞争失败,那么AQS会把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
100.Synchronized 用过吗,其原理是什么?
synchronized 是 Java 中用于控制多个线程对共享资源的访问的关键字,它提供了一种内置的锁机制,以确保在并发环境下数据的完整性和一致性。synchronized 的实现原理主要依赖于 Java 虚拟机(JVM)的锁机制以及 Java 内存模型(JMM)。
(1)JVM 的锁机制
在 JVM 中,每个对象都有一个与之关联的监视器锁(monitor lock),也被称为内置锁或互斥锁。当线程进入 synchronized 代码块或方法时,它会自动获取该对象的监视器锁。其他试图进入相同 synchronized 区域的线程将被阻塞,直到持有锁的线程释放锁。
(2)Java 内存模型(JMM)
synchronized 的实现也依赖于 Java 内存模型(JMM),它定义了线程和主内存之间的抽象关系,包括变量的可见性、有序性和原子性。synchronized 确保了以下特性:
可见性:当一个线程释放锁时,它会将修改后的共享变量的值刷新到主内存中,从而确保其他线程能看到最新的值。有序性:synchronized 通过禁止指令重排和内存屏障(memory barrier)来确保指令的执行顺序。
(3)实现方式
synchronized 可以应用于方法或代码块。
应用于方法:当 synchronized 应用于方法时,它锁定的是调用该方法的对象实例。应用于代码块:当 synchronized 应用于代码块时,它锁定的是括号中给出的对象。
(4)性能考虑
虽然 synchronized 提供了简单的线程同步机制,但在高并发场景下,它可能不是最高效的选择。因为 synchronized 是重量级的锁,获取和释放锁的操作相对较慢。在这种情况下,可以考虑使用更轻量级的锁机制,如 ReentrantLock 或其他并发工具类。
(5)指令
- synchronized 修饰代码块时,JVM 采用「monitorenter、monitorexit」两个指令来实现同步;
- synchronized 修饰同步方法时,JVM 采用「ACC_SYNCHRONIZED」标记符来实现同步;
- monitorenter、monitorexit 或者ACC_SYNCHRONIZED 都✁「基于Monitor实现」。
101.CountDownLatch 与CyclicBarrier 的区别?
CountDownLatch和CyclicBarrier在Java并发编程中都是常用的同步工具,但它们在使用场景和特性上存在明显的区别。
CountDownLatch主要用于控制一个或多个线程等待其他多个线程完成操作后再执行。它内部维护一个计数器,计数器的初始值表示需要等待的线程数量。每当一个线程完成其任务后,计数器的值就会减一,当计数器的值减至零时,等待的线程就会开始执行。这适用于一些需要等待多个线程完成初始化或准备工作的场景,例如多个线程加载资源或数据,主线程需要等待这些线程完成后才能继续执行。
CyclicBarrier则是一个可以让一组线程互相等待,直到到达某个公共屏障点的同步工具。与CountDownLatch不同的是,CyclicBarrier的计数器在达到指定数量后会重置,允许下一轮线程的等待,因此它是循环的。当线程到达屏障点时,它们会调用await方法并阻塞,直到所有线程都到达屏障点。CyclicBarrier还允许在最后一组线程到达屏障点后执行一个特定的动作,例如汇总各线程的结果或进行一些清理工作。
CountDownLatch和CyclicBarrier的主要区别在于:
CountDownLatch的计数器只能减少,不能重置,适用于一次性等待多个线程完成任务的场景;而CyclicBarrier的计数器可以重置,支持循环使用,适用于需要多轮等待的场景。
CountDownLatch主要用于控制线程等待,而CyclicBarrier则更侧重于线程间的相互等待和协同工作。
在选择使用哪个工具时,应根据具体的场景和需求来决定。如果只需要等待一组线程完成一次性的任务,那么CountDownLatch可能更合适;如果需要多轮等待或者需要线程间进行更复杂的协同工作,那么CyclicBarrier可能更合适。
102.Fork/Join 框架是什么?
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架。其核心思想是将一个大任务分解成多个小任务,并行执行这些小任务,最后合并每个小任务的执行结果以获取大任务的结果。
该框架主要分为三个步骤:首先,将一个大的主任务拆分为多个小的子任务,并将这些子任务放在双端队列中;接着,从队列中取出子任务执行;最后,合并子任务的结果。
Fork/Join框架特别适用于递归分解型的任务,例如排序、归并、遍历等,以及数组处理,如排序、查找、统计等。在处理大型数组或大数据时,可以将数据分成若干个分片或子数组,并行地处理每个分片或子数组,最后将处理后的分片或子数组合并成一个完整的结果。
Fork/Join框架还采用了工作窃取算法(work-stealing),允许一个线程从其他线程的任务队列中窃取任务来执行,这有助于平衡负载并提高整体的执行效率。