JUC 面试题

1.什么是 Java 中的线程同步

线程同步是指,在多线程环境下为了避免多个线程同时访问共享资源,从而引发数据不一致或其他问题的一种机制。

当多个线程共享同一资源(如变量、对象或文件),若没有同步机制,可能会导致竞态条件,即线程对共享资源的操作是非原子性的,多个线程之间会同时修改数据,导致结果不符合预期。

拓展知识

Java 中常见的同步方式

1)synchronized

Java 提供的关键字,用于在方法或代码块上加锁,确保同一时刻仅有一个线程能执行被同步的方法或代码块

在 synchronized 可以使用 wait()、notify() 和 notifyAll() 实现条件等待通知。

  • wait():当前线程进入等待状态,直到被其他线程唤醒。必须在同步块或同步方法中调用。
  • notify():唤醒一个等待的线程。如果有多个线程在等待,同一时刻只能唤醒一个。
  • notifyAll():唤醒所有等待的线程。

例如在 synchronized 块或方法中,可以使用 wait()方法使线程等待某个条件满足,可使用 notify() 或 notifyAll() 方法唤醒等待的线程。

2)ReentrantLock

是 JUC((java.util.concurrent)提供的可重入锁,相比 synchronized 它更加灵活

ReentrantLock 使用 Condition 对象来提供了更灵活的等待/通知机制。每个 ReentrantLock 可以创建一个或多个Condition 对象,通过 newcondition() 方法创建。

  • await():使当前线程等待,直到收到信号或被中断。
  • signal():唤醒一个等待线程。
  • signalAll():唤醒所有等待线程。

相比于 synchronized,ReentrantLock 还提供了公平锁和非公平锁机制。

Java 其他的同步工具类

Java 提供了一些高级的并发工具类,如 CountDownLatch、CyclicBarrier、Semaphore 等,它用于实现一些复杂的不同需求。

2.Java 中线程安全是什么意思?

线程安全是指多个线程访问某一资源时,能够保证一致性和正确性,无论线程如何交替执行,程序都能产生预期的结果。

常用的线程安全措施

  • 同步锁:synchronized、ReentrantLock
  • 原子操作类:如 AtomicInteger、AtomicReference 等类确保多线程环境下的原子性操作
  • 线程安全容器:如 ConcurrentHashMap、CopyOnWriteArrayList 等,避免手动加锁
  • 局部变量:线程内独立的局部变量天然是线程安全的,因为每个线程都有自己的栈空间(线程隔离)。
  • ThreadLocal:类似于局部变量,属于线程本地资源,通过线程隔离保证线程安全

3.什么是协程,Java 支持吗?

协程是一种轻量级的线程,它允许在执行中暂停,并在之后恢复执行,而无需阻塞线程。与线程相比,协程是用户态调度,效率更高,因为它不涉及操作系统的内核调度。

协程的特点:

  • 轻量级:与传统线程不同,协程在用户态切换,不依赖内核态的上下文切换,避免了线程创建、销毁和切换的高昂成本
  • 非抢占式调度:协程的切换由程序员控制,可以通过显式的 yield或 await 来暂停和恢复执行,避免了线程

4.线程的生命周期在 Java 是如何定义的?

在 Java 中,线程的生命周期可以细化为以下几个状态:

  • New(初始状态):线程对象创建后,但未调用 start()方法。
  • Runnable(可运行状态):调用 start() 方法后,线程进入就绪状态,等待 CPU 调度。
  • Blocked(阻塞状态):线程试图获取一个对象锁而被阻塞。
  • Waiting(等待状态):线程进入等待状态,需要被显式唤醒才能继续执行。
  • Timed Waiting(含等待时间的等待状态)):线程进入等待状态,但指定了等待时间,超时后会被唤醒。
  • Terminated((终止状态):线程执行完成或因异常退出。

操作系统中线程的生命周期

操作系统中线程的生命周期通常包括以下五个阶段:

  • 新建(New):线程对象被创建,但尚未启动。
  • 就绪(Runnable):线程被启动,处于可运行状态,等待CPU调度执行。
  • 运行(Running):线程获得CPU资源,开始执行run()方法中的代码。
  • 阻塞(Blocked):线程因为某些操作(如等待锁、I/0操作)被阻塞,暂时停止执行。
  • 终止(Terminated):线程执行完成或因异常退出,生命周期结束。

5.Java 中线程是如何通信的?

在 Java 中,线程之间的通信是指多个线程协同工作,主要线程方式为:

1)共享变量:

  • 线程可以通过访问共享内存变量来交换信息,前提要保证线程同步
  • 共享的也可以是文件,例如写入同一个文件来进行通信。

2)同步机制:

  • synchronized:Java 中的同步关键字,用于确保同一时刻只有一个线程可以访问共享资源,利用 Object 类提供的 wait()、notify()、notifyAll() 实现线程之间的等待/通知机制
  • ReentrantLock:配合 Condition 提供了类似于 wait()、notify() 的等待/通知机制
  • BlockingQueue:通过阻塞队列实现生产者-消费者模式
  • CountDownLatch:可以允许一个或多个线程等待,直到其他线程中执行的一组操作完成
  • Volatile:Java 中的关键字,确保变量的可见性,防止指令重排
  • Semaphore:信号量,可以控制对特定资源的访问线程数
  • CyclicBarrier:可以让一组线程互相等待,直到到达某个公共屏障点

BlockingQueu 的底层原理:

BlockingQueue 是通过ReentrantLock 来保护其共享资源(即队列),每个锁对象会有与之相关的条件变量(Condition),这些条件变量用来管理不同的条件等待。在入队和出队操作时,生产者和消费者会分别检查队列的状态,如果队列满或空,线程会进入相应的条件等待状态。

6.Java 中如何创建多线程

6.Java 中如何创建多线程

  1. 继承 Thread 接口,重写 run 方法并调用 start 方法
  2. 实现 Runnable 接口,重写 run 方法,
  3. 使用 Callable 和 FutureTask,重写 Callable 的 call 方法,用 FutureTask 包装 Callable 对象,再通过 Thread 启动
  4. 使用线程池
  5. CompletableFuture

7.线程池的原理是什么?

线程池是一种池化技术,用于预先创建线程和管理线程,避免线程的频繁创建和销毁,提高性能和响应速度。

关键参数:

  • 核心线程数
  • 最大线程数
  • 空闲存活时间
  • 工作队列
  • 拒绝策略

主要原理如下:

  1. 默认情况下线程不会预先创建,而是提交了任务才会创建
  2. 当核心线程满了不会立即创建线程,而是堆积到工作队列中
  3. 当核心线程满了和工作队列满了才会创建线程
  4. 当工作队列也放不下了就会执行拒绝策略
  5. 当线程空闲时间大于空闲存活时间,并且线程数大于核心线程数会销毁线程,直到线程数等于核心线程数(设置 allowCoreThreadTimeOut 为 true 可以回收核心线程,默认为 false)

线程池有哪些拒绝策略

  1. AbortPolicy(默认策略):当任务队列满了并且没有空闲线程时会抛出拒绝执行异常,适用于需要通知调用者任务未能执行的场景
  2. CallerRunsPolicy:当任务队列满了并没有空闲线程时,将由调用者线程执行,适用于希望通过减缓任务提交速度来稳定系统场景
  3. DisCardOldestPolicy:当任务队列满了并没有空闲线程时,会删除最早的任务然后重新提交当前任务。适用于希望丢弃旧任务来保证新任务先执行的场景。
  4. DisCardPolicy:直接丢弃当前提交的任务,不会执行任何操作也不会报错。适用于对部分任务丢弃不敏感的场景或系统负载较高不需要处理所有任务的场景。

自定义拒绝策略

1
2
3
4
5
6
7
public class CustomRejectedExecutionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("mianshiya.com Task " + r.toString() + " rejected");
// 可以在这里实现日志记录或其他逻辑
}
}

8.如何合理设置 Java 线程池参数?

CPU 密集型(数学计算):CPU 核心数 + 1

IO 密集型(文件读取,数据库读取):CPU 核心数 * 2 或更多些

9.Java 并发库中提供了哪些线程池实现?他们有啥区别?

1)FixedThreadPool:创建一个固定数量的线程池

线程池中的线程数是固定的,空闲的线程会被复用。如果所有线程都在忙,则新任务会放入队列中等待。
适合负载稳定的场景,任务数量确定且不需要动态调整线程数。

2)CachedThreadPool:一个可以根据需要创建新线程的线程池

线程池的线程数量没有上限,空闲线程会在 60 秒后被回收,如果有新任务且没有可用线程,会创建新线程。

适合短期大量并发任务的场景,任务执行时间短且线程数需求变化较大。

3)SignleThreadExecutor:创建一个只有单线程的线程池

只有一个线程处理任务,任务会按照提交顺序执行。

适用于按顺序执行的场景,或不需要并发处理任务的场景

4)ScheduledThreadPool:支持定时任务和周期性任务的线程池

以定时或固定频次执行任务,如定时任务调度器。

使用周期性执行任务的场景,如定时任务调度器。

5)WorkStealingPool:基于任务窃取算法的线程池

线程池中的每个线程维护一个双端队列(deque),线程可以从自己的队列中取任务执行。如果线程的任务队列为空,它可以从其他线程的队列中“窃取“任务来执行,达到负载均衡的效果。

适合大量小任务并行执行,特别是递归算法或大任务分解成小任务的场景。

不同线程池选用总结:

  • FixedThreadPool 适合任务数量相对固定,且需要限制线程数的场景,避免线程过多占用系统资源。
  • CachedThreadPool 更适合大量短期任务或任务数量不确定的场景,能够根据任务量动态调整线程数。
  • SingleThreadExecutor 保证任务按顺序执行,适合要求严格顺序执行的场景。
  • ScheduledThreadPool 是定时任务的最佳选择,能够轻松实现周期性任务调度。
  • WorkStealingPool 适合处理大量的小任务,能更好地利用 CPU资源。

10.Java 线程池核心线程数能在运行中修改吗?如何修改?

使用 ThreadPoolExecutor.setCorePoolSize(int corePoolsize) 可以动态地修改核心线程数,修改后会立即生效。

注意事项:

  • 核心线程数修改不会中断现有的任务,新的核心线程数会在新任务提交时生效
  • setCorePoolSize() 方法可以减少核心线程数,当线程数大于核心线程数,多的线程不会立即销毁,直到这些线程空闲被回收

11.Java 线程池中 shutdown 和 shutdownNow 有啥区别?

1)shutdown:启动线程池的平滑关闭,它不再接受新的任务,正在执行和已执行的任务会继续完成,只有所有任务完成后线程池才会完全终止。

2)shutdownNow:启动线程池的强制关闭,它会尝试停止/中断所有正在执行的任务,但不保证所有任务立即停止,接着返回等待执行的任务列表。

shutdownNow 的尝试中断

shutdownNow() 会通过调用 Thread.interrupt() 来中断线程,但这取决于任务实现的具体中断响应方式。如果任务在执行过程中没有正确处理中断(如未检查 Thread.interrupted()状态),则无法强制中断。

还有,使用 shutdownow() 时,返回的任务列表包含所有未执行的任务。我们可以选择将这些任务重新提交到另个线程池或进行其他处理(日志记录、落库等等)。

线程池生命周期:

  1. 运行状态:线程池接受新任务并能处理已提交的任务
  2. 关闭状态:线程池执行 shutdown,不接受新任务但能处理正在执行的任务
  3. 停止状态:线程池执行 shutdownNow,该状态下线程池会尝试中断所有正在执行的任务并清空任务队列
  4. 终止状态:所有任务执行完毕并且线程池完全关闭后,线程池进入 TERMINATED 状态

12.Java 线程池内部任务出异常后,如何知道哪个线程出异常?

1)自定义 ThreadFactory

  • 通过自定义线程工厂,为每个线程设置一个异常处理器(UncaughtExceptionHandler),在其中记录发生异常的线程信息

2)使用 Future

  • 提交任务使用 submit(),而不是 execute(),这样可以通过 Future 对象捕获并检查任务的执行结果和异常

3)任务内部手动捕获异常并记录

  • 在任务的 run 方法内部,手动 try catch 掉,并记录线程信息

13.Java 中的 DelayQueue 和 ScheduledThreadPool有什么区别?

DelayQueue 是一个阻塞队列,而 ScheduledThreadPool 是线程池,不过内部核心原理都是差不多的。

DelayQueue 是利用优先队列存储元素,当从队列中获取任务的时候,如果最老的任务已经到了执行时间,可以从队列中出队一个任务,反之可以获得 null 或者阻塞等待任务到时。

ScheduledThreadPool内部也使用的一个优先队列 DelayedWorkQueue 且可以内部多线程执行任务,支持定时执行的任务,即每隔一段时间执行一次的任务。

14.什么是 Java 的 Timer?

Java 的 Timer 是一个用于调度任务的工具类,用于在未来某个时刻执行任务或周期性地执行任务。 Timer 类一般与TimerTask 搭配使用,其中 TimerTask 是一个需要执行的任务。

适用于简单的定时任务,如定时更新、定期发送报告等。

15.时间轮(Time Wheel)是什么?在 Java 中有何应用场景?

时间轮是一个用于管理和调度大量定时任务的数据结构。它是一种高效的定时任务调度算法,主要用于优化任务调度的效率,特别是处理大量定时任务时

时间轮是一种环形的数据结构,通过将时间划分为若干个时间片(槽),每个时间片负责管理一定时间段(如秒、分钟)等内的任务。

工作原理:

  • 时间轮的中心是一个环形结构,每个槽表示一个时间段。当时间轮的指针移动到某个槽时,该槽中的任务会被执行。
  • 任务被插入到特定的槽中,根据任务的延迟时间确定插入的位置。
  • 时间轮以固定的时间步长(如秒)推进,每次推进一个时间单位,执行相应中的任务。

应用场景:

  • 高效的定时任务调度:在需要处理大量定时任务的场景,如高并发的定时任务系统,时间轮可以有效地减少任务调度的开销。
  • 网络服务器:在网络服务器中,时间轮常用于实现定时操作,如连接超时、请求超时等。
  • 分布式系统:在分布式系统中,时间轮可以用于协调不同节点的定时任务,优化任务调度和超时处理。

16.你是用过 Java 哪些并发工具类?

比如:ConcurrentHashMap、Atomiclnteger、Semaphore、CyclicBarrier、CountDownLatch、 BlockingQueue 等。

17.什么是 CompletableFuture?

CompletableFuture 是 Java 8 引入的一个强大的异步编程工具,允许非阻塞地处理异步任务,并且可以通过链式调用组合多个异步操作

核心特性:

  1. 异步执行:使用 runAsync() 或 supplyAsync() 方法,可以非阻塞地执行任务。
  2. 任务的组合:可以使用 thenApply()、thenAccept() 等方法在任务完成后进行后续操作,支持链式调用
  3. 异常处理:提供 exceptionally()、handle() 等方法来处理异步任务中的异常。
  4. 并行任务:支持多个异步任务的组合,如 thencombine()、allof() 等方法,可以在多个任务完成后作。
  5. 非阻塞获取结果:相比 Future,completableFuture 支持通过回调函数获取结果,而不需要显式的阻塞等待。

18.如何在 Java 中控制多个线程的执行顺序?

  1. CompletableFuture 的 thenRun 方法

    1
    2
    3
    CompletableFuture.runAsync(() -> {do t1 sth})
    .thenRun(()-> {do t2 sth})
    .thenRun(()-> {do t3 sth});
  2. synchronized + wait/notify 对象锁加线程通信机制控制线程执行顺序

  3. ReentrantLock + condition

  4. Thread 的 join 方法,使一个线程等待另一个线程执行完毕再执行

  5. CountDownLatch,使一个线程或多个线程等待其他线程完成各自工作后再继续执行

  6. 线程池(SingleThreadPool),内部仅设置一个线程来执行任务,按序的将任务提交到线程池中就可以了。

  7. CyclicBarrier,使多个线程互相等待,直到所有线程都到达某个共同点后再继续执行。

  8. Semaphore,控制线程的执行顺序,适用于需要限制同时访问资源的线程数量的场景。

19.使用过哪些 Java 中的阻塞队列?

阻塞队列主要用来阻塞队列的插入和获取操作;当队列满了插入操作就会阻塞,直到有空位,当队列为空时获取操作就会阻塞,知道有值。

常见的阻塞队列包括:

  • ArrayBlockingQueue:一个有界队列,底层基于数组实现。需要在初始化时指定队列的大小,队列满时,生产
    者会被阻塞,队列空时,消费者会被阻塞。
  • LinkedBlockingQueue:基于链表的阻塞队列,允许可选的界限(有界或无界)。无界模式下可以不断添加元
    素,直到耗尽系统资源。有界模式则类似于ArrayBlockingQueue,但吞吐量通常较高。
  • PriorityBlockingQueue:一个无界的优先级队列,元素按照自然顺序或者指定的比较器顺序进行排序。与其他
    阻塞队列不同的是,PriorityBlockingQueue不保证元素的FIFO顺序。
  • DelayQueue:一个无界队列,队列中的元素必须实现Delayed接口,只有当元素的延迟时间到期时,才能被
    取出。常用于延迟任务调度。
  • SynchronousQueue:一个没有内部容量的队列,每个插入操作必须等待对应的移除操作,反之亦然。常用于
    在线程之间的直接传递任务,而不是存储任务。

20.你是用过 Java 中哪些原子类?

Java 中的原子类是通过硬件提供的原子操作指令(如 CAS)来确保操作的原子性,从而避免线程竞争问题。

常用的原子类有以下几种:

  1. Atomiclnteger:用于操作整数的原子类,提供了原子性的自增、自减、加法等操作。
  2. AtomicLong:与 AtomicInteger 类似,但用于操作 long 型数据。
  3. AtomicBoolean:用于操作布尔值的原子类,提供了原子性的布尔值比较和设置操作。
  4. AtomicReference:用于操作对象引用的原子类,支持对引用对象的原子更新。
  5. AtomicStampedReference:在 AtomicReference 的基础上,增加了时间戳或版本号的比较,避免了 ABA 问题。
  6. AtomicIntegerArray 和 AtomicLongArray:分别是 AtomicInteger和 AtomicLong的数组版本,提供了对数组中各个元素的原子操作。

21.用过 Java 中的累加器吗?

在 Java 中累加器(Accumulator)一般指的是 LongAdder 和 DoubleAdder 类,它们在高并发场景下比传统的AtomicLong 更具优势。

LongAdder 和 DoubleAdder

  1. LongAdder:适用于 long 类型的累加操作,提供了高效的累加功能,尤其是在多线程环境中。
  2. DoubleAdder:适用于 double 类型的累加操作,同样优化了在高并发环境下的性能。

核心特点:

高效性:在多线程环境中,通过减少竞争和锁的使用来提高性能。它们通过内部维护多个计数器(桶)来分摊.并发操作的压力,从而减少争用。

线程安全:提供了原子性保证,避免了并发访问中的数据不一致问题。

LongAdder和 DoubleAdder 的工作原理

LongAdder 和 DoubleAdder 通过维护多个ce11实例(每个 cell 实际上是一个 AtomicLong 或AtomicDouble )来实现并发累加操作。每个线程会选择不同的 cell 来更新,这样可以减少锁的竞争。在读取总数时,LongAdder 和DoubleAdder 会将所有 cell的值相加得到最终结果。

与 AtomicLong 的对比

AtomicLong 使用单一的原子变量来实现累加,在高并发情况下,可能会出现大量的竞争和锁争用,导致性能瓶颈。

LongAdder 通过分散计数和减少争用来提高性能,特别是在计数器的更新频繁且读操作远多于写操作的情况

22.什么是 Java 的 CAS?

CAS 是硬件级别的原子操作,它比较内存中的某个值是否为预期值,如果是就更新为新值,否则不修改。

工作原理

比较(Compare):CAS 会检查内存中的某个值是否与预期值相等。

交换(Swap):如果相等,则将内存中的值更新为新值。

失败重试:如果不相等,说明有其他线程已经修改了该值,CAS 操作失败,一般会利用重试,直到成功。

23.说说什么是 AQS?

AQS(AbstractQueuedSynchronizer)是 Java 中构建锁和同步器的一个基础框架,他用一种先进先出的方式管理线程的竞争和阻塞。比如 ReentrantLock、CountDownLatch、Semaphore 等等。

AQS 核心机制

1)状态(state)

AQS 通过一个 volatile 类型的整数 state 来表示同步状态。

子类通过 getState()、setState(int) 和 compareAndSetState(int int) 方法来检查和修改该状态。状态可以表示多种含义,例如在 ReentrantLock 中,状态表示锁的重入次数;在 Semaphore 中,状态表示可用的许可数。

2)队列(Queue)

AQS 维护了一个 FIFO 的等待队列,用于管理等待获取同步状态的线程。每个节点(Node)代表一个等待的线程,节点之间通过 next 和 prev 指针链接。

1
2
3
4
5
6
7
8
9
10
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread; // 保存等待的线程
Node nextWaiter;
.....
}

当一个线程获取同步状态失败时,它会被添加到等待队列中,并自旋等待或被阻塞,直到前面的线程释放同步状态。

3)独占模式和共享模式:

  • 独占模式:只有一个线程能获取同步状态,例如 ReentrantLock。
  • 共享模式:多个线程可以同时获取同步状态,例如 Semaphore 和 ReadWriteLock。

24.ReentrantLock 实现原理是什么?

ReentrantLock 其实就是基于 AQS 实现的一个可重入锁,支持公平和非公平两种方式。

内部实现依靠一个 state 变量和两个等待队列:同步队列和等待队列。同步队列是双向链表,等待队列是单向队列,利用 CAS 修改 state 争抢锁,争抢失败进入同步队列等待,条件 condition 不满足进入等待队列等待,。

是否是公平锁区别在于:线程获取锁时是进入同步队列尾部还是直接利用 CAS 争抢锁。

自旋锁

自旋锁(Spinlock)是一种轻量级锁机制。线程在获取锁失败时不会立即进入阻塞状态,而是会在循环中反复尝试获取锁,直到成功。

这种方式避免了线程的上下文切换开销,所以称之为轻量级锁,适用于锁等待时间较短的场景。以下就是一个简单自旋锁的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);

public void lock() {
while (!lock.compareAndSet(false, true)) {
// 自旋等待
}
}

public void unlock() {
lock.set(false);
}

}

它的优点能避免线程上下文切换的开销,缺点主要有两点:

  • 锁饥饿问题:高并发场景,可能存在某个线程一直 CAS 失败,争抢不到锁。
  • 性能问题:多核处理器如果对同一变量高并发进行 CAS 操作,会导致总线风暴问题(参见 CAS 扩展知识)。

CLH

针对自旋锁的问题,演进出一种基于队列的自旋锁即 CLH(Craig,Landin,and Haqersten),它适用于多处理器环境下的高并发场景。

原理是通过维护一个隐式队列,使线程在等待锁时自旋在本地变量上,从而减少了对共享变量的争用和缓存一致性流量。

它将争抢的线程组织成一个队列,通过排队的方式按序争抢锁。且每个线程不再CAS 争抢一个变量,而是自旋判断排在它前面线程的状态,如果前面的线程状态为释放锁,那么后续的线程则抢锁。

因此,CLH 通过排队按序争抢解决了锁饥饿的问题。通过 CAS 自旋监听前面线程的状态避免的总线风暴问题的产生。

不过 CLH 还是有缺点的:

  • 占用 CPU 资源:自旋期间线程会一直占用 CPU 资源,适用于锁等待时间较短的场景。

注意!上面说了 CLH 是通过隐式队列实现的,这里的隐式指的是不同线程之前是没有真正通过指针连接的, 仅仅是利用 AtomicReference + ThreadLocal 实现了隐式关联。

实例代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class CLHLock {
private static class CLHNode {
volatile boolean isLocked = true; // 默认加锁状态
}

private final ThreadLocal<CLHNode> currentNode;
private final ThreadLocal<CLHNode> predecessorNode;
private final AtomicReference<CLHNode> tail;

public CLHLock() {
this.currentNode = ThreadLocal.withInitial(CLHNode::new);
this.predecessorNode = new ThreadLocal<>();
this.tail = new AtomicReference<>(new CLHNode());
}

public void lock() {
CLHNode node = currentNode.get();
CLHNode pred = tail.getAndSet(node);
predecessorNode.set(pred);

// 自旋等待前驱节点释放锁
while (pred.isLocked) {
}
}

public void unlock() {
CLHNode node = currentNode.get();
node.isLocked = false; // 释放锁
currentNode.set(predecessorNode.get()); // 回收当前节点
}
}

AQS 对 CLH 的改造

因为 CLH 有占用 CPU 资源问题,因此 AQS 将自旋等待前置节点改成了阻塞线程。

而后续的线程阻塞就无法主动发现前面的线程释放锁,因此前面线程需要需要通知后续线程锁被释放了。

所以 AQS 的变型版 CLH 需要显式地维护一个队列,且是一个双向列表实现,因为前面线程需要通知后续线程。

且前面线程如果等待超时或者主动取消后,需要从队列中移除,且后面的线程需要“顶“上来。

在 AQS 中,线程的等待状态有以下几种:

  • 0,初始化的时候的默认值

  • CANCELLED,值为1,由于超时、中断或其他原因,该节点被取消

  • SIGNAL,值为-1,表示该节点准备就绪,正常等待资源

  • CONDITION,值为-2,表示该节点位于条件等待队列中

  • PROPAGATE,值为 -3,当处在 SHARED 情况下,该字段才有用,将releaseShared 动作需要传播到其他节点

25.Java 中 synchronized 关键字咋实现的?

synchronized 实现原理依赖于 JVM 的 Monitor(监视器锁)和对象头(Object Header)。

当 synchronized 修饰在方法或代码块上时,会对特定的对象或类加锁,从而确保同一时刻只有一个线程能执行加锁的代码块。

  • synchronized 修饰方法:方法的常量池会增加一个 ACC SYNCHRONIZED 标志,当某个线程访问这个方法检查是否有 ACC SYNCHRONIZED 标志,若有则需要获得监视器锁才可执行方法,此时就保证了方法的同步。
  • synchronized 修饰代码块:会在代码块的前后插入 monitorenter 和 monitorexit 字节码指令。可以把 monitorenter 理解为加锁,monitorexit 理解为解锁

26.Java 中的 synchronized 轻量级锁是否进行自旋?

轻量级锁 CAS 失败后,不会有自选操作,会直接进入重量级锁膨胀过程

27.当 Java 的 synchronized 升级到重量级锁后,所有线程都释放锁了,此时它还是重量级锁吗?

当重量级锁释放了之后,锁对象是无锁的。有新的线程来竞争的话又会从轻量级锁开始。

28.什么是 Java 中锁自适应自旋?

锁自适应自旋是 Java 锁优化中的一种机制,用于减少线程在竞争锁时频繁挂起和恢复的开销

自适应自旋的核心思想是,在锁争用较少的情况下,线程在进入等待状态前,先执行一段自旋操作(即短暂忙等)
而不是立刻挂起线程。

在 Java 中 Syncronized 在争抢重量级锁时候会自旋。具体指的是在重量级锁时,一个线程如果竞争锁失败会进行自旋操作,说白了就是执行一些无意义的执行,空转 CPU 等着锁的释放。

原理:

自旋:当一个线程尝试获取锁失败时,它会先忙等一段时间,即自旋,而不是立刻进入阻塞状态。自旋指的是线程反复检查锁是否释放的操作。

自适应性:自适应自旋锁通过动态调整自旋的次数来提高性能。自适应的策略基于之前的自旋结果,假如上一次自旋很快获得了锁,下次可能会增加自旋次数;如果自旋失败,则减少自旋时间甚至直接放弃自旋。

优点:

自旋可以避免线程的上下文切换开销,因为线程进入阻塞状态会涉及到操作系统层面的挂起和唤醒,代价较高。

自适应自旋通过动态调整自旋次数,使得在轻度锁争用情况下的性能提升显著。

缺点:

如果锁争用激烈,自旋可能会白白浪费 CPU 时间,因此自适应自旋需要合理的机制来判断是否应该继续自旋。

自适应自旋扩展

在 Java 中,锁通常是由操作系统的同步原语(如 mutex)实现的。当线程无法获取锁时,通常会被操作系统挂起这会涉及到线程状态的转换和 CPU 上下文的切换,代价较高。而自旋锁避免了这种高开销操作。
在 HotSpot JM 中,自适应自旋是默认开启的,且锁的自旋次数根据上一次锁的争用情况动态调整,具体实现可以参考 LockingMechanisms.cpp 文件中的 AdaptiveSpinLock 部分。

自适应自旋的调优:

在JM 中,可以通过 -xx:+usespinning 参数启用或关闭自旋锁,并通过 -xx:PreBlockspin 参数调整自旋的.
次数。
但是,IM 默认的自适应自旋已经做了合理优化,手动调整这些参数的场景不多。

自适应自旋通俗理解

形象一点就像怠速停车和熄火的区别,如果等待时候很长(长时候都拿不到锁),那肯定熄火划算(阻塞)。
如果一会儿就要出发(拿到锁),那怠速停车(自旋)比较划算。

不过因为这个自旋次数不好判断,所以引入自适应自旋。

说白了就是结合经验值来看,如果上次自旋一会儿就拿到锁,那这次多自旋几次,如果上次自旋很久都拿不到,这次就少自旋。

这就叫锁的自适应自旋。

29.Synchronized 和 ReentrantLock 的区别?

  • Synchronized 是 Java 内置的关键字,实现基本的同步机制,不支持超时,非公平,不可中断,不支持多条件。
  • ReentrantLock 是 JUC 类库提供的,由 JDK 1.5 引入,支持设置超时时间,可以避免死锁,比较灵活,并且支持公平锁,可中断,支持多条件判断。
  • ReentrantLock 需要手动解锁,而 Synchronized 不需要,它们都是可重入锁。
  • 一般情况下用 Synchronized 足矣,比较简单,而 ReentrantLock 比较灵活,支持的功能比较多,所以复杂的情况用ReentrantLock 。

性能问题:很多年前,Synchronized 性能不如 ReentrantLock,现在基本上性能是一致的。

可重入锁

重入锁指的是同一个线程在持有某个锁的时候,可以再次获取该锁而不会发生死锁。例如以下代码:

outer 还需要调用 inner,它们都用到了同一把锁,如果不可重入那么就会导致死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();

public void outer() {
lock.lock();
try {
inner();
} finally {
lock.unlock();
}
}

public void inner() {
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
}

在递归调用或循环调用上锁时,可重入这个特性就十分重要了。

可重入锁实现方式

一般可重入锁是通过计数的方式实现,例如维护一个计数器,当前线程抢到锁则+1,如果当前线程再次抢到锁则继续+1。

如果当前线程释放锁之后,则计数器-1,当减到0则释放当前锁。

30.如何优化 Java 中锁的使用?

1)减少锁的粒度(使用的时间):

  • 尽量缩小锁的范围,减少锁的持有时间。即在必要的最小代码块内使用锁,避免对整个方法或过多的方法加锁
  • 使用更细粒度的锁,比如将一个大对象拆分为多个小对象锁,以提高并行度(参考 HashTable 和 ConcurrentHashMap 的区别)

2)减少锁的使用:

通过无锁编程、CAS(Compare-And-swap)操作和原子类(如 AtomicInteger、AtomicReference)来避免使用锁,从而减少锁带来的性能损耗。

通过减少共享资源的使用,避免线程对同一个资源的竞争。例如,使用局部变量或线程本地变量(ThreadLocal)来减少多个线程对同一资源的访问。

31.Java 中的读写锁是什么?

读写锁,它允许多个线程同时读取共享资源,而在写操作时确保只有一个线程能够进行写操作(读读操作不互斥,读写互斥、写写互斥)。这种机制适合于读多写少的场景,因为它提高了系统的并发性和性能。

Java 中的 ReadwriteLock 是通过 ReentrantReadwriteLock 实现的,它提供了以下两种锁模式:

  • 读锁(共享锁):允许多个线程同时获取读锁,只要没有任何线程持有写锁。适合读操作频繁而写操作较少的场
    景。
  • 写锁(独占锁):写锁是独占的,当有线程持有写锁时,其他线程既不能获取写锁,也不能获取读锁。写锁用于保证写操作的独占性,防止数据不一致。

读写锁的原理:

  • 共享与独占:读锁是共享锁,多个线程可以同时获取;而写锁是独占锁,在持有写锁期间,其他线程不能获取写锁或读锁。
  • 锁降级:ReentrantReadriteLock 支持锁降级,即持有写锁的线程可以直接获取读锁,从而在写操作完成后不必完全释放锁,但不支持锁升级(即不能从读锁升级为写锁)。
  • 公平锁与非公平锁:ReentrantReadwriteLock 提供了公平和非公平模式。在公平模式下,线程将按照请求的顺序获取锁;而在非公平模式下,线程可能会插队,提高吞吐量。
  • 读写锁也是基于 AQS 实现的,再具体点的实现就是将 state 分为了两部分,高16bit用于标识读状态、低16bit标识写状态。

32.什么是 Java 中的原子性、可见性和有序性?

1)原子性(Atomicity):
原子性指的是一个操作或一系列操作要么全部执行成功,要么全部不执行,期间不会被其他线程干扰。

2)可见性(Visibility):
可见性指的是当一个线程修改了某个共享变量的值,其他线程能够立即看到这个修改。

3)有序性(Ordering):
有序性指的是程序执行的顺序和代码的先后顺序一致。但在多线程环境下,为了优化性能,编译器和处理器可能会对指令进行重排序。

33.Java 中为什么要用 ThreadLocal?

因为在多线程编程中,多个线程可能会同时访问和修改共享变量,导致线程安全问题。Threadocal 提供了一种简单的解决方案,使每个线程都有自己的独立变量副本,避免了多线程间的变量共享和竞争,从而解决了线程安全问题。

与通过加锁、同步块等传统方式来保证线程安全相比。ThreadLoca1 不需要对变量访问进行同步,减少了上下文切换、锁竞争的性能损耗。

常见应用场景

  1. 数据库连接管理:每个线程拥有自己的数据库连接,避免了多个线程共享同一个连接导致的线程安全问题。
  2. 用户上下文管理:在处理用户请求时,每个线程拥有独立的用户上下文(如用户ID、Session信息),在并发环境中确保正确的用户数据。

ThreadLocal 的原理

ThreadLoca1 通过为每个线程创建一个独立的变量副本来实现线程本地化存储。Threadlocal 实际上是为每个线程创建了一个 ThreadLocalMap,而 ThreadLocalMap 是每个线程内部持有的结构。

该 ThreadLocalMap 的键是 ThreadLocal 对象,而值则是线程独立的变量副本。当线程访问 ThreadLoca1.get()时,它会根据当前线程在自己的 ThreadLocalmap 中找到对应的变量副本。

以下是一个简化的访问流程:

  • 线程A访问 ThreadLoca1.get()时,从 ThreadLocalMap 中找到与该 ThreadLocal 对象对应的值。
  • 线程B访问 ThreadLoca1.get()时,它有自己独立的 ThreadLocalMap ,获取的是与其自身相关的值,互不干扰。

34.ThreadLocal 是如何实现线程资源隔离的?

ThreadLocal 提供了一种线程内独享的变量机制,使每个线程都能有自己独立的变量副本。每个线程内部维护一个ThreadLocalMap ,这个 ThreadLocalMap 用于存储线程独立的变量副本。ThreadLocalMap以 ThreadLocal 实例作为键,以线程独立的变量副本作为值。不同线程通过 ThreadLocal 获取各自的变量副本,而不会影响其他线程的数据。

工作流程:

  • 当线程访问 ThreadLoca1.get()时,当前线程会根据自身的 ThreadLocalMap 获取到与调用的 Threadlocal 对应的值。如果是第一次访问,ThreadLoca1会初始化一个值,并将其存入该线程的 ThreadLocalMap 中。后续访问时,直接从 ThreadLocalMap 中获取,确保每个线程都有自己的数据副本。

35.Java 中使用 ThreadLocal 的最佳实践是什么?

1)不要滥用 ThreadLocal:

Threadloca1 适用于需要为每个线程维护独立副本的场景,例如:数据库连接、用户会话、事务上下文、临时缓存等。

对于能通过参数传递的上下文信息,不应该使用 ThreadLoca1 来处理,避免设计不合理和代码可读性差的问题。
2)避免内存泄漏:

ThreadLocal 中的 key 是弱引用,但 value 是强引用,因此需要在适当的时机调用 remove()方法来清除ThreadLoca1 的值,避免内存泄漏。尤其是在使用线程池时,线程对象会被重用,若不手动清理,容易导致内存泄漏。

3)使用静态变量存放 ThreadLocal:

将 ThreadLoca1 作为类的静态变量保存,这样可以确保同一个线程的局部变量在线程的生命周期内都可以被访问,避免对象频繁创建。

4)合理的生命周期:
确保在线程使用 hreadlocal 完成后及时释放其关联的对象,避免由于线程未结束导致的资源浪费,尤其在线程池或长时间运行的服务中,建议在任务执行结束时清理 ThreadLocal 变量。

36.ThreadLocal 的缺点?

正常情况下使用 Threadlocal 是没什么问题的,但是如果极端情况下,数据比较多,则可能会出现以下几个问题:

  • 内存泄露问题
  • ThreadLocal 中的 ThreadLocalMap Hash 冲突用的是线性探测法,效率低
  • ThreadLocal 主动清理的开销问题

37.Java 中Thread.sleep 和 Thread.yield 的区别?

Thread.sleep() 和 Thread.yield() 都是用于控制线程行为的两个方法。

Thread.sleep():

  • 使当前线程进入休眠状态(TIMED_WAITING 状态),暂停执行指定的时间(以毫秒为单位)。在休眠期间,线程不会占用 CPU 时间片。休眠结束后,线程会尝试重新获取CPU 时间片,进入可运行状态。
  • 休眠时间取决于操作系统的计时器精度,可能会有轻微的误差。

Thread.yield():

  • 提示当前线程愿意让出 CPU 时间片给其他线程。调用 yield()后,线程会进入 RUNNABLE 状态,但没有阻塞。调度器会尝试将 CPU 时间片分配给相同优先级的其他线程。如果没有其他合适的线程,当前线程可能会继续执行。
  • yield()只是一个提示,操作系统的线程调度器可以选择忽略它
  • 它并不会使线程进入阻塞状态,线程依然处于RUNNABLE状态。

38.Java 中 volatile 关键字的作用是什么?

volatile 它的主要作用是保证变量的可见性和禁止指令重排优化。

1)可见性(Visibility):

volatile 关键字确保变量的可见性。当一个线程修改了 volatile 变量的值,新值会立即被刷新到主内存中
其他线程在读取该变量时可以立即获得最新的值。这样可以避免线程间由于缓存一致性问题导致的“看见”旧值的现象。

2)禁止指令重排序(Ordering):

volatile 还通过内存屏障来禁止特定情况下的指令重排序,从而保证程序的执行顺序符合预期。对 volatile
变量的写操作会在其前面插入一个 StoreStore 屏障,而对 volatile变量的读操作则会在其后插入一个LoadLoad 屏障。这确保了在多线程环境下,某些代码块执行顺序的可预测性。

39.Java 中主线程如何知晓子线程是否执行成功?

1)使用 Thread.join():
主线程通过调用 join()方法等待子线程执行完毕。子线程正常结束,说明执行成功,若抛出异常则需要捕获
处理。
2)使用Callable 和 Future:
通过 Callable创建可返回结果的任务,并通过 Future.get()获取子线程的执行结果或捕获异常。会阻塞直到任务完成,若任务正常完成,返回结果,否则抛出异常。Future.get()

3)使用回调机制:

可以通过自定义回调机制,主线程传入一个回调函数,子线程完成后调用该函数并传递执行结果。这样可以非阻塞地通知主线程任务完成情况。

4)使用 countDownLatch 或其他 JUC 相关类:
主线程通过 countDownLatch 来等待子线程完成。当子线程执行完毕后调用countDown(),主线程通过await()等待子线程完成任务。