JUC

注意:括号中为八股在每次面试中出现的概率

如何创建线程?(582/1759=33.1%)

Java 提供了多种方式来创建和管理线程,最常见的方式一共有四种,接下来我会分别进行讲述。

第一种是通过继承 Thread 类并重写其 run() 方法来创建线程。在run() 方法中定义线程需要执行的任务逻辑,然后

创建该类的实例,调用 start() 方法启动线程,start() 方法会自动调用 run() 方法中的代码逻辑。这种方式简单直观,但由于 Java 不支持多重继承,因此限制了类的扩展性。

第二种是实现 Runnable 接口并将其传递给 Thread 构造器来创建线程。Runnable 是一个函数式接口,其中的 run() 方法定义了任务逻辑。这种方式更加灵活,因为它不占用类的继承关系,同时可以更好地支持资源共享,可以让多个线程共享同一个 Runnable 实例。这种方式适用于需要解耦任务逻辑与线程管理的场景。

第三种是通过实现 Callable 接口来创建有返回值的线程。Callable 接口类似于 Runnable,但它可以返回结果并抛出异常。Callable 的 call() 方法需要通过 FutureTask 包装后传递给 Thread 构造器。通过 Future 对象可以获取线程执行的结果或捕获异常。这种方式适用于需要获取线程执行结果或处理复杂任务的场景。

第四种是通过 Executor 框架创建线程池来管理线程。Executor 框架提供了更高级的线程管理功能,例如线程复用、任务调度等。通过 submit() 或 execute() 方法提交任务,避免频繁创建和销毁线程的开销。它作为最常被使用的方式,广泛用于需要高效管理大量线程的场景。

如何记忆:

1.口诀记忆

线程创建有四招,继承 Thread 跑得早,

Runnable 接口好,资源共享不烦恼,

Callable 返回值高,FutureTask 来打包,

Executor 框架妙,线程池里效率高!

解释 :

第一句点明主题“线程创建有四招”,提到第一种方式“继承 Thread 类”,并用“跑得早”暗示它是最基础的方式。

第二句强调第二种方式“Runnable 接口”的灵活性和资源共享的特点。

第三句突出第三种方式“Callable 接口”的返回值特性,并提到 FutureTask 的作用。

最后一句总结第四种方式“Executor 框架”的高效性。

2. 联想记忆

想象一个工厂生产玩具车的过程,

Thread 类 :就像工厂自己造了一辆专属的车,但这辆车只能按照固定的设计运行,不能改装(Java 不支持多重继承)。

Runnable 接口 :工厂把车的任务外包给工人(Runnable 实例),多个工人可以共享同一套工具(资源共享)。

Callable 接口 :工厂派工人去完成任务,并要求他们带回结果(返回值)或者报告问题(异常)。

Executor 框架 :工厂建立了一个车队管理系统(线程池),统一调度车辆,避免频繁制造新车(减少开销)。

拓展:

1. 创建 Java 线程的本质

在 Java 中,线程的启动本质上是通过调用 Thread 类的 start() 方法完成的。start() 方法会通知 JVM 启动一个新的线程,并在这个新线程中执行 run() 方法中的代码逻辑。因此,无论你使用哪种方式创建线程,最终都会归结到调用 new Thread().start() 来真正启动线程。

换句话说,new Thread().start() 是启动线程的唯一入口,而其他方式(如继承 Thread、实现 Runnable 或 Callable、使用 Executor 框架)都是对 Thread 类的封装或扩展,目的是为了提供更灵活的编程模型。

2. 四种线程创建方式与new Thread().start()的关系

第一种:继承 Thread 类

实现方式:

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread running");
    }
}
MyThread t = new MyThread();
t.start(); // 启动线程

与 new Thread().start() 的关系:这里直接继承了 Thread 类,并重写了 run() 方法。调用 t.start() 时,实际上就是调用了 Thread 类的 start() 方法,启动了一个新线程。

第二种:实现 Runnable 接口

实现方式:

Runnable task = () -> System.out.println("Runnable running");
Thread t = new Thread(task);
t.start(); // 启动线程

与 new Thread().start()的关系:Runnable 是一个函数式接口,定义了线程的任务逻辑。Thread 类的构造器接受一个 Runnable 对象,并将其包装到内部的 run() 方法中。调用 t.start() 时,仍然是通过 Thread 类的 start() 方法启动线程。

第三种:实现 Callable 接口

实现方式:

Callable<Integer> task = () -> {
    System.out.println("Callable running");
    return 42;
};
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start(); // 启动线程

与 new Thread().start()的关系:Callable 接口允许线程返回结果或抛出异常。FutureTask 是对 Callable 的封装,实现了 Runnable 接口。最终还是通过 Thread 类的 start() 方法启动线程。

第四种:使用 Executor 框架

实现方式:

ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> System.out.println("Executor running"));
executor.shutdown();

与 new Thread().start()的关系:Executor 框架是对线程池的封装,提供了更高层次的线程管理功能。在底层,Executor 框架仍然使用 Thread 类来创建和启动线程。当你提交任务时,Executor 内部会调用 new Thread().start() 来启动线程。

通过以上分析,我们可以清楚地看到,虽然 Java 提供了多种线程创建方式,但它们都离不开 new Thread().start() 这一核心机制。每种方式只是对线程创建和管理的不同抽象,适用于不同的场景需求。

说说线程的生命周期和状态?(606/1759=34.5%)

在 Java 中,线程的生命周期可以分为六个状态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、等待(Waiting)和终止(Terminated),线程在运行的整个生命周期的任意一个时刻,只可能处于其中一个状态,接下来我会详细讲述线程从创建到终止的整个过程以及每个状态的特点。

第一个是新建状态,当一个线程对象被创建时,它处于新建状态。

此时,线程还没有开始执行,仅仅是一个普通的 Java 对象,尚未与系统的线程调度器关联。在这个阶段,线程对象已经存在,但调用 start() 方法之前,它不会进入可运行状态。

第二个是就绪状态,当调用线程对象的 start() 方法后,线程进入就绪状态。

此时,线程已经被 JVM 注册到线程调度器中,但它还没有真正开始运行。线程调度器会根据操作系统的调度策略决定何时将 CPU 时间分配给该线程。需要注意的是,就绪状态的线程可能正在等待 CPU 资源,也可能已经获得了资源但尚未执行代码。

第三个是运行状态,当线程获得 CPU 时间片并开始执行其 run() 方法中的代码时,它进入运行状态。

这是线程实际执行任务的状态,也是我们编写业务逻辑的地方。运行状态的线程可能会因为某些原因(如调用 sleep() 或 wait() 方法)主动放弃 CPU,或者因为时间片耗尽而被系统强制切换回就绪状态。

第四个是阻塞状态,当线程试图获取一个锁(例如进入同步代码块或方法),但该锁正被其他线程占用时,它会进入阻塞状态。

此时,线程会暂停执行,直到获取到所需的锁为止。 阻塞状态通常发生在多线程竞争共享资源的场景中。

第五个是等待状态,当线程调用了某些特定的方法(如 Object.wait()、Thread.join() 或 LockSupport.park()),它会进入等待状态。

在这种状态下,线程会无限期地等待,直到其他线程显式地唤醒它(例如通过 notify() 或 notifyAll() 方法)。等待状态通常用于线程间的协作。

第六个是终止状态,当线程的 run() 方法执行完毕,或者由于未捕获的异常导致线程提前退出时,它进入终止状态。

此时,线程的生命周期结束,无法再被启动或复用。

如何记忆:

1.缩写记忆

将六个状态的核心词提取为:N-R-R-B-W-T

N: New(新建)

R: Runnable(就绪)

R: Running(运行)

B: Blocked(阻塞)

W: Waiting(等待)

T: Terminated(终止)

记忆技巧

可以联想成“NR-RBWT”,发音类似“嗯~绕吧我太”(轻松幽默的语气),意思是“线程的状态变化有点绕,但我能记住”。

2.联想记忆

想象一个人从出生到去世的过程,

新建状态(New) :就像一个婴儿刚刚出生,虽然存在,但还不能独立行动。

就绪状态(Runnable) :婴儿长大了一些,可以排队等待玩耍(CPU 时间片)。

运行状态(Running) :终于轮到他玩了,开始执行自己的任务(run() 方法)。

阻塞状态(Blocked) :他想玩滑梯,但滑梯被别人占着,只能等着(锁被占用)。

等待状态(Waiting) :他玩累了,躺在椅子上休息,直到有人叫醒他(显式唤醒)。

终止状态(Terminated) :人老了,生命结束了,无法再复活。

拓展:

1.描述一下线程的生命周期图

线程在创建后首先进入 NEW(新建)状态,调用 start() 方法后进入 READY(可运行)状态,等待 CPU 时间片分配;当线程获得时间片后进入 RUNNING(运行)状态并执行任务。若线程调用 wait() 方法,则进入 WAITING(等待)状态,需依赖其他线程的通知才能恢复运行;而通过 sleep(long millis) 或 wait(long millis) 方法,线程会进入 TIMED_WAITING(超时等待)状态,并在超时结束后返回可运行状态。如果线程试图获取 synchronized 锁但被占用,则进入 BLOCKED(阻塞)状态,直到锁可用。最后,当线程执行完 run() 方法或因异常退出时,进入 TERMINATED(终止)状态,生命周期结束。

2.线程的等待状态

线程的等待状态(Waiting State)是线程生命周期中的一个重要且复杂的状态,表示线程暂时放弃 CPU 的使用权,进入一种“暂停”的状态。等待状态的核心原因是线程需要等待某些条件满足后才能继续运行,比如等待资源、锁、通知或超时等。为了更好地理解等待状态,我们可以将其细分为几种具体的阻塞情况,包括等待阻塞、同步阻塞和其他阻塞。

(1)等待阻塞

定义:当一个线程调用 wait() 方法时,JVM 会将该线程放入等待队列,并使其进入等待阻塞状态。

触发条件:调用 Object.wait() 方法(必须在同步代码块或同步方法中调用)。调用后,当前线程会释放持有的锁,并进入等待队列,直到其他线程调用 notify() 或 notifyAll() 方法唤醒它。

特点:线程不会自动恢复运行,必须依赖外部的通知机制。常用于线程间的协作,例如生产者-消费者模型。

示例:

synchronized (lock) {
    while (!condition) {
        lock.wait(); // 当前线程进入等待阻塞状态
    }
    // 条件满足后继续执行
}

(2)同步阻塞

定义:当一个线程试图获取某个对象的同步锁,但该锁已经被其他线程占用时,JVM 会将该线程放入锁池,并使其进入同步阻塞状态。

触发条件:线程尝试进入 synchronized 方法或代码块,但所需的锁被其他线程持有。

特点:线程会持续尝试获取锁,直到锁可用为止。锁池中的线程不会主动放弃竞争,而是等待锁释放后重新参与竞争。

示例:

synchronized (lock) {
    // 只有获得锁的线程才能进入此代码块
    System.out.println("Thread is running in synchronized block");
}

如果多个线程同时尝试访问这段代码,未获得锁的线程会被放入锁池,进入同步阻塞状态。

(3)其他阻塞

定义:除了上述两种阻塞外,还有一些其他场景会导致线程进入阻塞状态,主要包括以下几种:

Thread.sleep() 方法:调用 Thread.sleep(long millis) 方法会使线程暂停指定的时间。在这段时间内,线程不占用 CPU 时间片,也不会参与调度。当睡眠时间结束后,线程会自动恢复到可运行状态。

Thread.join() 方法:调用 join() 方法会使当前线程等待目标线程执行完毕后再继续运行。这种阻塞状态会在目标线程终止后自动解除。

I/O 阻塞:当线程发起 I/O 操作(如文件读写、网络通信)时,如果操作尚未完成,线程会进入阻塞状态。当 I/O 操作完成后,线程会恢复运行。

特点:这些阻塞状态通常是临时性的,线程会在特定条件满足后自动恢复运行。不需要显式的唤醒机制(如 notify() 或 notifyAll())。

示例:

// Thread.sleep 示例
try {
    Thread.sleep(1000); // 当前线程休眠 1 秒
} catch (InterruptedException e) {
    e.printStackTrace();
}

// Thread.join 示例
Thread t = new Thread(() -> System.out.println("Child thread running"));
t.start();
try {
    t.join(); // 主线程等待子线程执行完毕
} catch (InterruptedException e) {
    e.printStackTrace();
}

什么是线程上下文切换?(388/1759=22.1%)

线程上下文切换是多线程编程中的一个概念,它直接影响程序的性能和效率。接下来我会详细讲述线程上下文切换的定义、发生时机、过程和影响。

首先讲一下什么是线程上下文切换,它是指当 CPU 从一个线程切换到另一个线程时,操作系统需要保存当前线程的执行状态,并加载下一个线程的执行状态,以便它们能够正确地继续运行。执行状态主要包括:寄存器状态、程序计数器(PC)、栈信息、线程的优先级等。

接下来讲一下发生时机,通常有四种情况会发生线程上下文切换。

第一种是时间片耗尽,操作系统为每个线程分配了一个时间片,当线程的时间片用完后,操作系统会强制切换到其他线程,这是为了保证多个线程能够公平地共享 CPU 资源。

第二种是线程主动让出 CPU,当线程调用了某些方法,如 Thread.sleep()、Object.wait() 或 LockSupport.park()等,会使线程主动让出 CPU,导致上下文切换。

第三种是调用了阻塞类型的系统中断,比如:线程执行 I/O 操作时,由于 I/O 操作通常需要等待外部资源,线程会被挂起,会触发上下文切换。

第四种是被终止或结束运行

然后再讲一下线程上下文切换的过程,分为四步。

第一步是保存当前线程的上下文,将当前线程的寄存器状态、程序计数器、栈信息等保存到内存中。

第二步是根据线程调度算法,如:时间片轮转、优先级调度等,选择下一个要运行的线程。

第三步是加载下一个线程的上下文,从内存中恢复所选线程的寄存器状态、程序计数器和栈信息。

第四步是 CPU 开始执行被加载的线程的代码

最后讲一下线程上下文切换所带来的影响。线程上下文切换虽然能够实现多任务并发执行,但它也会带来 CPU 时间消耗、缓存失效以及资源竞争等问题。为了减少线程上下文切换带来的性能损失,可以采取减少线程数量、使用无锁数据结构等方式进行优化。

如何记忆:

1.联想记忆

想象一场接力赛跑:

定义 :接力棒代表线程的上下文(寄存器状态、程序计数器等)。当一个选手(线程)跑完自己的路程时,需要把接力棒交给下一个选手(线程)。

发生时机

  1. 时间片耗尽 :裁判规定每个人只能跑一段路(时间片),跑完就必须换人。
  2. 主动让出 CPU :某个选手累了,主动停下来休息(Thread.sleep())。
  3. 阻塞 I/O :某个选手跑到半路发现鞋子坏了(I/O 操作),需要等待修鞋师傅处理。
  4. 终止或结束 :某个选手跑完全程(线程结束)。

切换过程

  1. 第一步是保存当前选手的状态(保存上下文)。
  2. 第二步是选择下一个选手(线程调度)。
  3. 第三步是把接力棒交给下一位选手(加载上下文)。
  4. 第四步是下一位选手开始跑(CPU 执行代码)。

影响 :频繁交接接力棒会浪费时间(性能损失),所以要尽量减少交接次数(优化线程数量)。

拓展:

1.线程上下文切换的影响

然后,线程上下文切换虽然能够实现多任务并发执行,但它也会带来一定的开销:

CPU 时间消耗 :保存和恢复上下文需要额外的 CPU 周期,这会降低系统的整体性能。

缓存失效 :每次切换线程时,CPU 缓存中的数据可能会失效,导致下一线程需要重新加载数据,进一步增加延迟。

资源竞争 :如果线程数量过多,频繁的上下文切换会导致系统资源的竞争加剧,从而降低吞吐量。

2.如何减少线程上下文切换的影响?

最后,为了减少线程上下文切换带来的性能损失,可以采取以下优化措施:

减少线程数量 :合理控制线程池的大小,避免创建过多的线程。

使用无锁数据结构 :通过 CAS(Compare-And-Swap)等无锁操作减少线程间的锁竞争。

协程或异步编程 :使用轻量级的协程或异步模型(如 Java 的 CompletableFuture 或 Kotlin 的协程)替代传统的线程模型,减少上下文切换的频率。

优化线程调度策略 :根据任务的特点选择合适的线程调度算法,例如优先处理高优先级任务。

并发和并行的区别?(649/1759=36.9%)

并发和并行是多线程编程中的两个核心概念,它们描述了任务执行的不同方式。虽然这两个术语经常被混用,但它们有着本质的区别。接下来我会详细讲述并发和并行的定义以及它们之间的区别。

首先说一下什么是并发并发指的是多个任务在同一时间段内交替执行的能力。换句话说,并发并不一定要求任务同时进行,而是通过快速切换任务来实现“看起来同时运行”的效果。

然后再说一下什么是并行,并行指的是多个任务在同一时刻真正同时执行的能力。并行通常需要多核 CPU 的支持,每个核心独立处理一个任务,从而实现真正的并行计算。

最后说一下它们之间的四点区别

第一是执行方式的不同并发是任务交替执行,强调的是任务调度的时间分片;而并行是任务同时执行,强调的是多核资源的利用。

第二是对硬件的要求不同并发可以在单核 CPU 上实现,通过时间片轮转完成任务切换;而并行需要多核 CPU 的支持,每个核心独立处理一个任务。

第三是适用场景的不同,并发适合 I/O 密集型任务,因为这类任务通常需要等待外部资源,如磁盘、网络等,CPU 可以在这段时间切换到其他任务;而并行适合计算密集型任务,因为这类任务需要大量 CPU 计算,多核并行可以显著加速处理速度。

第四是核心目标的不同,并发的核心目标是提高系统的响应能力和资源利用率;而并行的核心目标是提高系统的吞吐量和计算效率。

如何记忆:

1.联想记忆

想象一个餐厅厨房的运作:

并发

厨房只有一个厨师(单核 CPU),他需要在多个任务之间快速切换,比如切菜、煮汤、炒菜。虽然他不能同时做所有事情,但通过快速切换任务,看起来像是同时进行(交替执行)。

这种方式适合需要等待的任务,比如等水烧开时去切菜(I/O 密集型任务)。

并行

厨房有多个厨师(多核 CPU),每个人独立负责一个任务,比如一个切菜、一个煮汤、一个炒菜。他们真正同时工作,效率更高(同时执行)。

这种方式适合需要大量计算的任务,比如同时处理多个复杂的菜品(计算密集型任务)。

区别

执行方式 :单个厨师切换任务 vs 多个厨师同时工作。

硬件要求 :单个厨师 vs 多个厨师。

适用场景 :等待外部资源(如等水烧开) vs 需要大量计算(如复杂烹饪)。

核心目标 :单个厨师提高效率(响应能力) vs 多个厨师加速完成(吞吐量)。

拓展:

1.并发和并行的实际应用

最后,在实际开发中,并发和并行往往是结合使用的。例如:

(1)在 Java 中,ExecutorService 提供了线程池机制,可以通过合理配置线程池大小来实现并发任务调度。

(2)对于计算密集型任务,可以使用 ForkJoinPool 或并行流(parallelStream)来充分利用多核 CPU 的并行能力。

(3)在分布式系统中,通过将任务分解为多个子任务并发执行,并在多个节点上并行处理,可以进一步提升系统的整体性能。

同步和异步的区别?(534/1759=30.4%)

同步和异步是编程中两种常见的任务执行模式,接下来我会详细讲述同步和异步的定义以及它们之间的区别。

首先说一下什么是同步同步指的是任务按照顺序依次执行的方式。在这种模式下,调用者会阻塞等待任务完成并返回结果后,才会继续执行后续的操作。

然后再说一下什么是异步,异步指的是任务无需等待立即返回,调用方可以继续执行其他操作,而任务的结果会在稍后通过如回调函数、事件通知或 Future 对象等机制传递给调用方。

最后说一下同步和异步的区别

第一是执行方式不同同步是阻塞式的,调用方需要等待任务完成才能继续;而异步是非阻塞式的,调用方无需等待任务完成即可继续执行。

第二是响应机制不同同步直接返回任务的结果,调用方可以直接使用;而异步通常通过回调函数、事件通知或 Future 对象等方式传递结果。

第三是适用场景不同同步适合简单、短时间的任务,或者需要立即获取结果的场景;而异步适合需要提高系统吞吐量的场景,或者用于耗时较长的任务,如:网络请求、文件读写等。

如何记忆:

1.口诀记忆

示例 :

同步阻塞等结果,异步非阻跑得快。同步直接拿数据,异步回调再安排。简单任务用同步,耗时操作靠异步!

解释 :

第一句“同步阻塞等结果”:描述同步模式的特点,调用方需要等待任务完成。

第二句“异步非阻跑得快”:强调异步模式的非阻塞性,调用方可以继续执行其他任务。

第三句“同步直接拿数据”:说明同步模式的结果可以直接使用。

第四句“异步回调再安排”:指出异步模式通常通过回调等方式传递结果。

最后两句总结了两者的适用场景。

2.联想记忆

想象你在餐厅点餐的过程:

同步模式

你点了一份菜,服务员告诉你要等厨师做好才能上桌。

在等待的过程中,你只能坐在那里干等(阻塞),直到菜端上来才能继续吃饭。

异步模式

你点了一份菜,服务员告诉你不用等,你可以先去逛商场或者做别的事情(非阻塞)。

当菜做好后,服务员会通过广播通知你(回调函数或事件通知),你再回来享用。

拓展:

1.同步和异步的实际应用

最后,在实际开发中,同步和异步的选择取决于具体的业务需求。例如:

(1)在 Java 的传统 I/O 操作中,文件读取或网络请求通常是同步的,调用线程会阻塞直到操作完成。

(2)在 Java NIO 或第三方框架(如 Netty)中,I/O 操作是异步的,调用线程不会被阻塞,数据到达后通过回调或事件通知的方式处理。

(3)在现代 Web 开发中,异步编程模型(如 Spring 的 @Async 注解或 Reactor 框架)被广泛应用于提升系统的并发能力和响应速度。

线程池的七大参数?(679/1759=38.6%)

线程池是 Java 并发编程中的重要工具,它通过复用线程来减少线程创建和销毁的开销,从而提高系统的性能和稳定性。在 Java 中,线程池的核心实现类是 ThreadPoolExecutor,它提供了七个重要的参数来配置线程池的行为。接下来我会详细讲述这七大参数的定义、作用以及它们如何影响线程池的工作机制。

第一个是核心线程数(corePoolSize),它是指线程池中始终保持存活的线程数量,即使这些线程处于空闲状态。

当提交一个新任务时,如果当前线程数小于核心线程数,线程池会优先创建新线程来处理任务,而不是将任务放入队列。例如,设置 corePoolSize=5 表示线程池会始终维护至少 5 个线程。

第二个是最大线程数(maximumPoolSize),它是指线程池中允许的最大线程数量。当任务队列已满且当前线程数小于最大线程数时,线程池会继续创建新线程来处理任务。如果线程数已经达到最大值,则任务会被拒绝。例如,设置 maximumPoolSize=10 表示线程池最多可以创建 10 个线程。

第三个是线程空闲时间(keepAliveTime),它是指非核心线程在空闲状态下保持存活的时间。当线程池中的线程数超过核心线程数时,多余的空闲线程会在指定的空闲时间后被回收。例如,设置 keepAliveTime=60 表示非核心线程在空闲 60 秒后会被销毁。

第四个是时间单位(unit),它用于指定线程空闲时间的计量单位。常见的单位包括 TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)等。例如,unit=TimeUnit.SECONDS 表示空闲时间以秒为单位。

第五个是任务队列(workQueue),它是一个阻塞队列,用于存放等待执行的任务。当线程池中的线程数达到核心线程数时,新提交的任务会被放入任务队列中等待执行。常见的队列类型包括:

ArrayBlockingQueue:有界队列,适用于控制资源使用。

LinkedBlockingQueue:无界队列,适用于任务量较大的场景。

SynchronousQueue:不存储任务的队列,适用于直接传递任务给线程的场景。

第六个是线程工厂(threadFactory),它用于创建线程池中的线程。通过自定义线程工厂,可以为线程设置名称、优先级或其他属性,便于调试和管理。例如,使用 Executors.defaultThreadFactory() 创建默认线程工厂。

第七个是拒绝策略(handler),它用于处理当线程池无法接受新任务时的情况(例如线程数达到最大值且任务队列已满)。常见的拒绝策略包括:

AbortPolicy:抛出异常,拒绝任务。

CallerRunsPolicy:由调用线程执行任务。

DiscardPolicy:直接丢弃任务。

DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试重新提交新任务。

如何记忆:

1.联想记忆

想象一个餐厅厨房的运作:

核心线程数(CorePoolSize) :厨房里有固定的厨师(核心线程),他们始终在岗,即使暂时没有客人点餐(空闲状态)。

最大线程数(MaximumPoolSize) :如果客人太多,厨房可以临时增加一些帮厨(非核心线程),但最多只能增加到一定数量(最大线程数)。

空闲时间(KeepAliveTime) :当客人减少时,临时帮厨会在空闲一段时间后离开(非核心线程被回收)。

时间单位(Unit) :规定帮厨的空闲时间是以分钟还是小时计算(时间单位)。

任务队列(workQueue) :如果厨师忙不过来,新的订单会被放入待处理队列中(任务队列)。

线程工厂(ThreadFactory) :餐厅经理负责招聘和培训厨师(线程工厂创建线程)。

拒绝策略(Handler) :如果厨房爆满且订单队列已满,餐厅可以选择拒绝接单(拒绝策略)。

拓展:

1.线程池创建的两种方式

(1)通过 ThreadPoolExecutor 手动创建线程池

ThreadPoolExecutor 是线程池的核心实现类,它允许开发者通过构造函数灵活地配置线程池的参数。以下是 ThreadPoolExecutor 的构造函数:

public ThreadPoolExecutor(
    int corePoolSize,         // 核心线程数
    int maximumPoolSize,      // 最大线程数
    long keepAliveTime,       // 空闲线程存活时间
    TimeUnit unit,            // 存活时间单位
    BlockingQueue<Runnable> workQueue, // 任务队列
    ThreadFactory threadFactory,       // 线程工厂
    RejectedExecutionHandler handler   // 拒绝策略
);

优点: 配置灵活,可以根据业务需求精确控制线程池的行为。 易于调试和排查问题,因为所有参数都是显式设置的。

示例代码:

// 创建一个自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    2,                      // 核心线程数
    4,                      // 最大线程数
    60,                     // 空闲线程存活时间
    TimeUnit.SECONDS,        // 时间单位
    new LinkedBlockingQueue<>(10), // 任务队列
    Executors.defaultThreadFactory(), // 线程工厂
    new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
executor.submit(() -> System.out.println("任务执行"));

(2)通过 Executors 工具类创建线程池(不建议使用)

Executors 是一个工具类,提供了几种常用的线程池创建方法,例如:

newFixedThreadPool(int nThreads):创建固定大小的线程池。

newCachedThreadPool():创建一个根据需要创建新线程的线程池。

newSingleThreadExecutor():创建只有一个线程的线程池。

newScheduledThreadPool(int corePoolSize):创建支持定时及周期性任务执行的线程池。

优点:使用简单,适合快速开发和测试场景。

示例代码:

// 使用 Executors 创建固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> System.out.println("任务执行"));

2. 为什么《阿里巴巴 Java 开发手册》禁止使用 Executors 创建线程池?

尽管 Executors 提供了便捷的线程池创建方式,但《阿里巴巴 Java 开发手册》明确指出:线程池不允许使用 Executors 去创建,而是推荐通过 ThreadPoolExecutor 手动创建线程池。原因如下:

(1)资源耗尽风险

Executors 创建的线程池在某些情况下可能导致资源耗尽,进而引发系统崩溃。例如:

newFixedThreadPool 和 newSingleThreadExecutor:它们使用的是无界队列(LinkedBlockingQueue),如果任务提交速度远高于任务处理速度,队列会不断增长,最终导致内存溢出(OutOfMemoryError)。

ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < Integer.MAX_VALUE; i++) {
    executor.submit(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {}
    });
}

在这个例子中,任务队列会无限增长,最终耗尽内存。

newCachedThreadPool:它允许创建大量线程(理论上没有上限),如果任务提交过多,可能会导致线程数量激增,消耗大量系统资源,甚至导致系统崩溃。

ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 10000; i++) {
    executor.submit(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {}
    });
}

在这个例子中,线程数量可能迅速膨胀到数千个,导致系统资源耗尽。

(2)缺乏灵活性

Executors 创建的线程池隐藏了许多重要参数(如队列容量、拒绝策略等),这使得开发者无法根据实际需求进行精细化调优。例如:

如果任务队列容量过小,可能导致频繁触发拒绝策略。

如果线程池的最大线程数设置不合理,可能导致线程饥饿或资源浪费。

(3)难以排查问题

由于 Executors 创建的线程池内部实现对开发者不透明,当出现性能问题或异常时,很难定位问题根源。而通过 ThreadPoolExecutor 手动创建线程池,所有参数都是显式设置的,便于调试和优化。

线程池中常用的阻塞队列是任务调度的核心组件之一,它用于存储等待执行的任务。不同的阻塞队列适用于不同的业务场景,选择合适的队列可以显著提升线程池的性能和稳定性。以下是几种常见的阻塞队列及其特点:

3.线程池常用的阻塞队列总结

队列类型

是否有界

特点

适用场景

ArrayBlockingQueue

有界

固定容量,基于数组

对任务数量有限制的场景

LinkedBlockingQueue

无界/有界

基于链表,默认无界

任务量较小且处理速度快的场景

SynchronousQueue

无存储

直接移交任务,不存储

高性能、快速响应的系统

PriorityBlockingQueue

无界

按优先级排序

需要优先级调度的场景

DelayQueue

无界

延迟执行任务

定时任务、缓存过期等场景

LinkedTransferQueue

无界

高效的移交操作

高吞吐量、高并发场景

线程池四大拒绝策略?(658/1759=37.4%)

线程池是 Java 并发编程中用于管理线程的重要工具,而拒绝策略则是线程池在资源耗尽时处理新任务的一种机制。当线程池中的线程数达到最大值且任务队列已满时,线程池会根据配置的拒绝策略来决定如何处理无法接受的新任务。接下来我会详细讲述线程池的四大拒绝策略及其特点。

首先是 AbortPolicy(中止策略),它线程池的默认拒绝策略。当线程池无法接受新任务时,它会直接抛出 RejectedExecutionException 异常,终止任务的提交。这种策略适用于对任务执行有严格要求的场景,例如不允许任务丢失的情况。

然后是 CallerRunsPolicy(调用者运行策略),它会将被拒绝的任务回退给提交任务的线程执行。也就是说,任务不会被丢弃,而是由调用线程(通常是主线程)直接运行该任务。这种策略可以减缓任务提交的速度,从而缓解线程池的压力,但可能会导致调用线程阻塞。在这种情况下,主线程会承担部分任务的执行工作。

接下来是 DiscardPolicy(丢弃策略),它会直接丢弃无法处理的任务,并且不会抛出任何异常。这种策略适用于对任务执行要求不高的场景,例如允许部分任务丢失的情况。在这种情况下,被拒绝的任务会被静默丢弃,调用方不会收到任何通知。

最后是 DiscardOldestPolicy(丢弃最旧任务策略),它会丢弃任务队列中最旧的任务(即等待时间最长的任务),然后尝试重新提交当前任务。这种策略可以确保较新的任务有机会被执行,但可能会导致某些任务被重复提交或丢失。在这种情况下,队列中最旧的任务会被移除,为新任务腾出空间。

如何记忆:

1.口诀记忆

示例 :

Abort 抛异常,任务不执行,Caller 调用方,任务自己跑。Discard 丢任务,静默不通知,Oldest 丢旧活,新任务来挤!

解释 :

第一句“Abort 抛异常,任务不执行”:描述 AbortPolicy 的特点(抛出异常,终止任务)。

第二句“Caller 调用方,任务自己跑”:强调 CallerRunsPolicy 的特点(调用线程直接运行任务)。

第三句“Discard 丢任务,静默不通知”:点明 DiscardPolicy 的特点(直接丢弃任务,无通知)。

第四句“Oldest 丢旧活,新任务来挤”:总结 DiscardOldestPolicy 的特点(丢弃最旧任务,腾出空间)。

2.联想记忆

想象一个快递站的运作:

AbortPolicy(中止策略) :快递站满了,新来的包裹直接被拒收,并且快递员会打电话通知客户(抛出异常)。

CallerRunsPolicy(调用者运行策略) :快递站满了,快递员把包裹带回自己家里处理(调用线程自己执行任务)。

DiscardPolicy(丢弃策略) :快递站满了,新来的包裹被直接丢掉,客户也不会收到任何通知(静默丢弃任务)。

DiscardOldestPolicy(丢弃最旧任务策略) :快递站满了,工作人员把最早送到的包裹扔掉,给新包裹腾出空间(丢弃最旧任务)。

拓展:

1.详细说明CallerRunsPolicy

(1)什么是 CallerRunsPolicy?

CallerRunsPolicy 是 Java 线程池中的一种拒绝策略(RejectedExecutionHandler),当线程池和任务队列都已满时,它会将被拒绝的任务回退给提交任务的线程执行。也就是说,任务不会被丢弃,而是由调用线程(通常是主线程)直接运行该任务。

定义:

public static class CallerRunsPolicy implements RejectedExecutionHandler {
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        if (!executor.isShutdown()) {
            r.run(); // 由调用线程执行任务
        }
    }
}

特点:不会丢弃任务,也不会抛出异常。调用线程会承担任务的执行工作,从而减缓新任务的提交速度。

(2)CallerRunsPolicy 的工作原理

当线程池中的线程数达到最大值(maximumPoolSize),且任务队列也已满时,线程池会触发拒绝策略。如果使用了 CallerRunsPolicy,以下是其工作流程:

任务提交:调用线程尝试向线程池提交一个新任务。

资源耗尽:线程池的核心线程和最大线程都在忙碌,任务队列也已满。

触发拒绝策略:线程池调用 CallerRunsPolicy.rejectedExecution() 方法。

任务回退:被拒绝的任务不会被丢弃,而是由调用线程直接执行。

(3)使用场景

CallerRunsPolicy 适用于以下场景:

不允许任务丢失:如果业务要求所有任务都必须被执行,不能丢弃任何任务,则可以使用 CallerRunsPolicy。例如:日志记录、订单处理等关键任务。

减缓任务提交速度:当任务提交速度远高于任务处理速度时,CallerRunsPolicy 可以通过让调用线程执行任务来减缓任务提交的速度,从而避免系统过载。

平滑降级:在高负载情况下,CallerRunsPolicy 提供了一种平滑降级的方式,避免系统因资源耗尽而崩溃。

(4)优点

任务不丢失:CallerRunsPolicy 确保所有任务都能被执行,避免了任务丢失的风险。

降低系统压力:通过让调用线程执行任务,可以有效减缓任务提交的速度,从而降低系统的负载。

简单易用:相比其他复杂的拒绝策略(如动态扩展线程池或持久化任务),CallerRunsPolicy 实现简单,易于理解和使用。

(5)缺点

调用线程可能被阻塞:如果任务执行时间较长,调用线程(如主线程)可能会被阻塞,导致系统响应变慢甚至卡顿。

不适合实时性要求高的场景:由于任务可能被延迟执行,因此不适合对实时性要求较高的场景。

可能导致性能瓶颈:如果调用线程频繁执行任务,可能会成为性能瓶颈,影响整体吞吐量。

(6)示例分析

以下是一个完整的示例,演示 CallerRunsPolicy 的行为:

public class CallerRunsPolicyExample {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            1, 1, 60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(1),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

        for (int i = 0; i < 5; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("任务 " + taskId + " 正在运行,线程:" + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000); // 模拟任务执行时间
                } catch (InterruptedException e) {}
            });
        }

        executor.shutdown();
    }
}

输出结果(部分):

任务 0 正在运行,线程:pool-1-thread-1
任务 1 正在运行,线程:pool-1-thread-1
任务 2 正在运行,线程:main
任务 3 正在运行,线程:main
任务 4 正在运行,线程:main

从输出可以看出,当线程池和队列都满时,后续任务被回退到主线程执行。

CurrentHashMap的原理?(488/1759=27.7%)

ConcurrentHashMap 是 Java 中常用的并发容器,它的实现从 JDK 1.7 到 JDK 1.8 发生了较大的变化,JDK 1.7 通过分段锁提高并发性能,但锁的粒度较粗,而 JDK 1.8 通过 CAS 和红黑树优化,实现了更高的并发性和查询效率,简化了实现逻辑。下面分别说明两种版本的实现原理:

首先从六个方面说一下,JDK 1.7版本:

第一,ConcurrentHashMap 的实现方式采用了 数组 + Segment + 分段锁 的方式。Segment 是一种特殊的分段锁,继承了 ReentrantLock,每个 Segment 对应一个 HashMap 子集。

第二,它通过对某个 Segment 加锁实现线程安全。这样多个线程可以同时访问不同的 Segment,提高了并发性能。

第三,它内部结构是 数组 + Segment + 分段锁,每个 Segment 里面包含一个 Entry 数组,Entry 数组中的元素以链表形式存储。

第四,它的锁颗粒度相对较小只对需要操作的 Segment 加锁,其他 Segment 不受影响,从而降低锁竞争。

第五,从查询时间复杂度来说在最坏情况下需要遍历链表,时间复杂度为 O(n)。

第六,从并发性能来说默认有 16 个 Segment,也就是支持 16 线程同时操作,不会发生锁冲突。

然后再说一下 JDK 1.8版本:

第一,JDK 1.8 摒弃了分段锁的实现方式,改用 synchronized + CAS + 红黑树,更加高效。

第二,它采用 CAS 操作(Compare-And-Swap)保证并发安全,必要时使用 Synchronized 来解决并发冲突。

第三,它采用了 数组 + 链表 + 红黑树数据结构。链表长度超过阈值(默认 8)时,会转化为红黑树,从而优化查询性能。

第四,它锁的颗粒度细化到桶(Node),并且 value 和 next 使用 volatile 修饰,保证并发的可见性。

第五,从查询时间复杂度来说,使用链表时为 O(n),使用红黑树后降为 O(logN)。

第六,从并发性能来讲,并发粒度与数组长度相关,每个桶可以独立加锁,支持更高的并发度。

如何记忆:

1.联想记忆

想象一个图书馆的书籍管理方式:

JDK 1.7 :

图书馆分为多个区域(Segment),每个区域有独立的管理员(锁)。

如果某个区域需要整理书籍(修改数据),只需要锁住该区域,其他区域不受影响。

书籍按照编号存放在架子上(链表),但查找时可能需要遍历整个架子(O(n))。

最多允许 16 个管理员同时工作(支持 16 线程并发)。

JDK 1.8 :

图书馆取消了分区管理,改为每个书架(桶)独立管理。

使用智能系统(CAS)自动分配任务,必要时请管理员(Synchronized)介入。

书籍数量过多时,会用分类标签(红黑树)优化查找效率(O(logN))。

每个书架可以独立加锁,支持更多管理员同时工作(更高的并发性)。

拓展:

1. ConcurrentHashMap 解决了传统 HashMap的什么问题

通过上述设计,ConcurrentHashMap 解决了传统 HashMap 和 Collections.synchronizedMap() 的以下问题:

线程安全问题 :避免了多线程环境下的数据不一致和死循环问题。

性能瓶颈 :通过分段锁或细粒度锁机制,允许多个线程同时操作不同的部分,显著提高了并发性能。

扩展性 :支持动态扩容和高效的并发操作,能够适应大规模数据和高并发场景。

适用性 :提供了丰富的 API(如 computeIfAbsent、merge 等),支持复杂的并发操作,满足更多业务需求。

什么是线程死锁?(621/1759=35.3%)

线程死锁是多线程编程中一个常见的问题,它会导致程序陷入一种无法继续执行的状态。接下来我会详细讲述线程死锁的定义和产生原因。

首先说一下什么是线程死锁,它是指两个或多个线程在执行过程中,因为争夺资源而相互等待对方释放资源,从而导致所有相关线程都无法继续执行的情况。例如,线程 A 持有资源 1 并等待资源 2,而线程 B 持有资源 2 并等待资源 1,这样两个线程就会陷入互相等待的状态,形成死锁。

接下来说一下线程死锁的产生条件,线程死锁的产生需要同时满足以下四个必要条件:

第一个是互斥条件,资源只能被一个线程占用,其他线程必须等待资源释放后才能使用。

第二个是占有且等待,线程已经占有了某些资源,并且正在等待获取其他被占用的资源。

第三个是不可剥夺条件,线程持有的资源不能被强制剥夺,只有线程自己可以释放资源。

第四个是循环等待条件,存在一组线程形成循环等待,每个线程都在等待下一个线程所占有的资源。

如何记忆:

1.缩写记忆

将四个必要条件的核心词提取为:M-H-N-C

M: Mutual Exclusion(互斥条件)

H: Hold and Wait(占有且等待)

N: No Preemption(不可剥夺条件)

C: Circular Wait(循环等待条件)

记忆技巧 :

可以联想成“MHNC”,发音类似“迷惑难缠”,意思是“死锁的四个条件让人迷惑难缠”。

2.联想记忆

想象两个人在餐厅争夺餐具的情景:

互斥条件 :餐厅里只有一套餐具(资源),只能一个人使用,另一个人必须等待。

占有且等待 :A 拿着刀(占有资源),但还需要叉子;B 拿着叉子(占有资源),但还需要刀。两人互相等待对方放下手中的餐具。

不可剥夺条件 :餐厅规定,不能强行从别人手中抢走餐具,只能等对方主动放下。

循环等待条件 :A 等待 B 的叉子,B 等待 A 的刀,形成了一个循环等待的状态。

拓展:

1.线程死锁的示例

public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread A: Holding resource 1...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                System.out.println("Thread A: Waiting for resource 2...");
                synchronized (resource2) {
                    System.out.println("Thread A: Holding resource 1 and 2.");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread B: Holding resource 2...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                System.out.println("Thread B: Waiting for resource 1...");
                synchronized (resource1) {
                    System.out.println("Thread B: Holding resource 1 and 2.");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在这个例子中,线程 A 和线程 B 分别持有不同的资源,并试图获取对方持有的资源,最终导致死锁。

如何预防和避免线程死锁?(563/1759=32.0%)

线程死锁是多线程编程中一个常见的问题,它会导致程序陷入一种无法继续执行的状态。接下来我会详细讲述死锁发生的必要条件以及避免线程死锁的方法。

首先说一下线程死锁的产生条件,线程死锁的产生需要同时满足以下四个必要条件:

第一个是互斥条件,资源只能被一个线程占用,其他线程必须等待资源释放后才能使用。

第二个是占有且等待,线程已经占有了某些资源,并且正在等待获取其他被占用的资源。

第三个是不可剥夺条件,线程持有的资源不能被强制剥夺,只有线程自己可以释放资源。

第四个是循环等待条件,存在一组线程形成循环等待,每个线程都在等待下一个线程所占有的资源。

其次,如果我们想要避免死锁的发生,只要破坏其中任何一个条件。

第一种是破坏互斥条件我们可以尽量减少对共享资源的独占性访问。 使用无锁数据结构来替代传统的同步机制,比如:ConcurrentHashMap、AtomicInteger 等。对于只读资源,可以通过复制或缓存的方式避免竞争。

第二种是破坏占有且等待条件,我们可以要求线程在开始执行前一次性获取所有需要的资源。如果无法获取所有资源,则释放已占有的资源并稍后重试。这种方法被称为“一次性申请所有资源”,但需要注意的是,它可能会增加资源的竞争压力。

第三种是破坏不可剥夺条件,我们可以允许系统强制剥夺线程占有的资源。这种方法通常用于操作系统层面,但在 Java 中并不常见,因为强制剥夺资源可能会导致数据不一致或复杂的恢复逻辑。

第四种是破坏循环等待条件,我们可以为资源分配一个全局的顺序编号,并要求线程按照固定的顺序申请资源。

这种方法可以有效避免循环等待,从而防止死锁的发生。

如何记忆:

1.联想记忆

想象两个人在餐厅争夺餐具的情景:

死锁条件 :

互斥条件 :餐厅里只有一套餐具(资源),只能一个人使用,另一个人必须等待。

占有且等待 :A 拿着刀(占有资源),但还需要叉子;B 拿着叉子(占有资源),但还需要刀。两人互相等待对方放下手中的餐具。

不可剥夺条件 :餐厅规定,不能强行从别人手中抢走餐具,只能等对方主动放下。

循环等待条件 :A 等待 B 的叉子,B 等待 A 的刀,形成了一个循环等待的状态。

避免方法 :

减少互斥 :餐厅提供多套餐具(无锁数据结构),避免竞争。

一次性申请 :要求每个人进餐前先拿齐刀和叉(一次性获取所有资源)。

强制剥夺 :服务员可以强行收回餐具(强制剥夺资源)。

资源排序 :规定所有人必须先拿刀再拿叉(资源排序),避免循环等待。

拓展:

1.如何检测死锁

Java 提供了一些内置工具和 API,可以用来检测死锁。

(1) jstack 命令

jstack 是 JDK 自带的一个命令行工具,用于生成 Java 进程的线程快照。通过分析线程快照,可以发现死锁。

使用方法:

jstack <pid>

其中 <pid> 是目标 Java 进程的进程 ID。

输出示例:

如果存在死锁,jstack 会明确指出死锁的线程及其持有的锁信息。例如:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00007f8c00001234 (object 0x000000076b5c1234, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007f8c00005678 (object 0x000000076b5c5678, a java.lang.Object),
  which is held by "Thread-1"

优点:简单易用,适合快速定位死锁问题。可以查看线程的调用栈,帮助分析死锁的根本原因。

(2) ThreadMXBean API

ThreadMXBean 是 Java 提供的一个管理接口,可以通过编程方式检测死锁。

代码示例:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DeadlockDetector {
    public static void main(String[] args) {
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();

        if (deadlockedThreads != null) {
            System.out.println("检测到死锁!");
            for (long threadId : deadlockedThreads) {
                ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
                System.out.println("死锁线程:" + threadInfo.getThreadName());
            }
        } else {
            System.out.println("未检测到死锁。");
        }
    }
}

优点:可以集成到应用程序中,实现自动化的死锁检测。提供了更细粒度的线程信息,便于进一步分析。

synchronized 底层原理了解吗?(543/1759=30.9%)

synchronized 用于保证多线程环境下的数据一致性。接下来我会详细讲述 synchronized 的定义和底层实现。

首先说一下什么是synchronized它是一种内置的锁机制,它可以作用于方法或代码块,用于控制多个线程对共享资源的访问。当一个线程进入 synchronized 保护的代码区域时,它会尝试获取锁;如果锁已被其他线程占用,则当前线程会被阻塞,直到锁被释放。锁的持有者在退出同步代码块或方法时会自动释放锁,从而允许其他线程继续执行。

接下来说一下 synchronized 的底层是如何实现的,它的依赖于 JVM 的监视器锁(Monitor)机制,每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取锁,会判断 monitor 的进入数是否为 0 ,如果为 0 则该线程进入monitor,然后将进入数设置为 1,该线程即为monitor的所有者;如果不为 0,说明已有线程占有该monitor,那么线程就会进入并处于阻塞状态,直到monitor的进入数为 0,才会重新尝试获取monitor的所有权。

退出同步代码块时,线程会执行 monitorexit,该线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。

如何记忆:

1.联想记忆

想象一个银行柜台的场景:

synchronized 的定义

银行柜台有一个窗口(共享资源),多个顾客(线程)需要办理业务。

当一个顾客进入窗口时,他会拿走一把钥匙(获取锁),其他顾客必须在外面排队等待(阻塞)。

办完业务后,顾客会归还钥匙(释放锁),下一个顾客才能进入窗口。

synchronized 的底层实现

Monitor 机制 :银行柜台有一个电子计数器(monitor 的进入数)。如果计数器为 0,说明没人占用窗口,顾客可以进入并把计数器设为 1(monitorenter)。如果计数器不为 0,说明窗口被占用,顾客只能在外面排队(阻塞)。

monitorenter 和 monitorexit :顾客进入窗口时按下“开始”按钮(monitorenter)。顾客离开窗口时按下“结束”按钮(monitorexit),计数器减 1,允许下一个顾客进入。

拓展:

1.如何使用 synchronized?

(1)修饰实例方法

当 synchronized 修饰实例方法时,锁的对象是当前实例(即 this)。也就是说,同一时间只有一个线程可以访问该实例的同步方法。

使用场景:适用于需要对某个对象的实例方法进行同步控制的场景。

示例代码:

public class Counter {
    private int count = 0;

    // synchronized 修饰实例方法
    public synchronized void increment() {
        count++;
        System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);
    }

    public synchronized int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();
    }
}

特点:锁住的是当前实例对象(this)。不同实例之间的同步方法互不影响。

(2)修饰静态方法

当 synchronized 修饰静态方法时,锁的对象是类的 Class 对象(即 Counter.class)。这意味着所有线程在访问该类的静态同步方法时都会被同步控制。

使用场景:适用于需要对整个类的所有实例共享的静态资源进行同步控制的场景。

示例代码:

public class Counter {
    private static int count = 0;

    // synchronized 修饰静态方法
    public static synchronized void increment() {
        count++;
        System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);
    }

    public static synchronized int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                Counter.increment();
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();
    }
}

特点:锁住的是类的 Class 对象(Counter.class)。所有线程在访问该类的静态同步方法时都会被阻塞。

(3)修饰代码块

当 synchronized 修饰代码块时,可以显式指定锁的对象。这种方式更加灵活,允许开发者选择具体的锁对象。

使用场景:适用于只需要对部分代码进行同步控制的场景。

示例代码:

public class Counter {
    private int count = 0;
    private final Object lock = new Object(); // 自定义锁对象

    public void increment() {
        // synchronized 修饰代码块
        synchronized (lock) {
            count++;
            System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);
        }
    }

    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();
    }
}

特点:可以显式指定锁对象(如 this 或其他自定义对象)。更加灵活,适合局部同步需求。

(4)总结对比

修饰方式

锁对象

适用场景

修饰实例方法

当前实例对象(this)

同步控制单个实例的方法调用。

修饰静态方法

类的 Class 对象(Counter.class)

同步控制所有实例共享的静态资源。

修饰代码块

显式指定的锁对象(如 this 或自定义对象)

灵活控制特定代码段的同步,减少锁的范围,提高性能。

2. synchronized 的优缺点

优点:简单易用,无需手动管理锁的获取和释放。底层经过多次优化,性能在大多数场景下已经足够高效。

缺点:在高并发场景下,重量级锁可能会导致性能瓶颈。不支持锁的中断或超时等高级功能(相比之下,ReentrantLock 提供了更灵活的锁机制)。

synchronized和ReentrantLock的区别?(642/1759=36.5%)

synchronized 和 ReentrantLock 是 Java 中实现线程同步的两种主要方式,它们都能保证多线程环境下的数据一致性,接下来我会详细讲述两者在基本概念、功能特性、性能表现以及锁的释放与异常处理上的区别。

第一个是基本概念上的区别,synchronized 是 Java 的内置关键字,它是隐式的,通过 JVM 提供的监视器锁机制实现同步,使用简单,无需手动管理锁的获取和释放;而 ReentrantLock 是 java.util.concurrent.locks 包中的一个类,它是是显式的,提供了更灵活的锁机制,需要开发者手动调用 lock() 和 unlock() 方法来控制锁的生命周期。

第二个是功能特性上的区别,ReentrantLock 提供了比 synchronized 更丰富的功能,比如:ReentrantLock 支持在等待锁的过程中响应中断,而 synchronized 不支持中断;还有ReentrantLock 提供了 tryLock() 方法,允许线程尝试获取锁并在指定时间内返回结果,而 synchronized 必须一直等待锁释放。

第三个是性能上的区别,synchronized 和 ReentrantLock 在不同场景下各有优势。

对于低竞争场景,由于synchronized 经过多次优化(如偏向锁、轻量级锁),一般与 ReentrantLock 相当甚至更好。

对于高竞争场景,ReentrantLock 提供了更多的灵活性(如公平锁、可中断锁等),更适合复杂需求。

第四个是锁的释放与异常处理上的区别,synchronized 在退出同步代码块时会自动释放锁,即使发生异常也不会导致死锁;而ReentrantLock 需要开发者手动调用 unlock() 方法释放锁,因此必须在 finally 块中确保锁的释放,否则可能导致死锁。

如何记忆:

1.联想记忆

想象两个人在一家餐厅里争夺一张餐桌的情景:

基本概念 :

synchronized :像一个自动感应门(隐式),顾客只要走进餐厅,就可以直接使用餐桌,离开时门会自动关闭(无需手动管理锁)。比如,顾客 A 进入餐厅后,系统会自动为他分配餐桌,并在他离开时自动释放餐桌资源。

ReentrantLock :像一把需要钥匙的锁(显式),顾客必须用钥匙打开餐桌的锁(调用 lock() 方法),用餐结束后还需要手动上锁(调用 unlock() 方法)。比如,顾客 B 需要自己拿钥匙开锁占用餐桌,并在用餐结束后手动归还钥匙。

功能特性 :

中断支持 :如果有人突然喊停(中断),ReentrantLock 可以立即停下并离开餐桌,而 synchronized 不理会喊停。比如,顾客 B 听到紧急通知后可以立即放下餐具离开,但顾客 A 使用的是自动门机制,无法响应中断。

tryLock :ReentrantLock 可以尝试占用餐桌一段时间,如果失败就放弃,而 synchronized 会一直等待直到成功。比如,顾客 B 可以尝试占用餐桌 5 分钟,如果失败就去别的地方用餐;而顾客 A 会一直站在餐桌旁排队,直到轮到他为止。

性能表现 :

低竞争场景 :当餐厅人少时,自动门(synchronized)更快捷,因为不需要额外的操作。比如,顾客 A 只需走进餐厅就能快速找到空闲餐桌。

高竞争场景 :当餐厅人多时,带钥匙的锁(ReentrantLock)更灵活,可以设置公平规则(公平锁)或允许插队(非公平锁)。比如,顾客 B 可以选择排队等候(公平锁),或者直接插队抢占空闲餐桌(非公平锁)。

异常处理 :

synchronized :即使发生意外(异常),自动门会自己关上,不会导致混乱。比如,顾客 A 突然感到身体不适离开餐厅,系统会自动释放他的餐桌资源。

ReentrantLock :如果忘记手动上锁(unlock()),可能会导致其他人无法使用餐桌(死锁)。比如,顾客 B 忘记归还钥匙,其他顾客就无法使用这张餐桌。

拓展:

1.锁的状态与升级过程

(1)锁的状态

在Java中,synchronized关键字和ReentrantLock等锁机制都涉及锁的状态管理。锁的状态通常可以分为以下几种:

无锁状态(Unlocked):当一个对象或资源没有被任何线程持有锁时,它处于无锁状态。此时,多个线程可以自由访问该资源。

偏向锁(Biased Locking):偏向锁是一种优化机制,用于减少无竞争情况下的同步开销。当一个线程第一次获取锁时,JVM会将锁标记为偏向该线程,并记录线程ID。如果后续该线程再次尝试获取锁,无需进行额外的同步操作,直接判断线程ID是否匹配即可。偏向锁适用于只有一个线程访问同步块的场景。

轻量级锁(Lightweight Locking):当有第二个线程尝试获取已经被偏向的锁时,偏向锁会升级为轻量级锁。轻量级锁通过CAS(Compare-And-Swap)操作来尝试获取锁。如果CAS操作成功,则线程获取锁;如果失败,则进入自旋等待状态,尝试多次获取锁。

重量级锁(Heavyweight Locking):当多个线程竞争锁且自旋等待无法快速获取锁时,轻量级锁会升级为重量级锁。重量级锁会将未获取锁的线程挂起(进入阻塞状态),并由操作系统调度。这种方式会带来较大的性能开销,因为线程的挂起和唤醒需要上下文切换。

(2)锁的升级过程

锁的升级过程是一个从低开销到高开销的逐步演化过程,目的是在不同竞争程度下选择最优的锁实现。以下是锁升级的具体流程:

初始状态:无锁,对象刚创建时,没有任何线程竞争锁,处于无锁状态。

偏向锁,第一个线程尝试获取锁时,JVM会将锁标记为偏向锁,并记录线程ID。后续该线程再次尝试获取锁时,只需检查线程ID是否匹配,无需额外操作。

轻量级锁,当第二个线程尝试获取锁时,偏向锁失效,升级为轻量级锁。轻量级锁通过CAS操作尝试获取锁。如果CAS操作失败,线程会进入自旋状态,反复尝试获取锁。

重量级锁,如果自旋一定次数后仍然无法获取锁,或者系统检测到锁竞争激烈,轻量级锁会升级为重量级锁。重量级锁会将未获取锁的线程挂起,避免CPU资源浪费。

(3)锁升级的意义

锁升级的核心目的是在不同的竞争场景下平衡性能和资源消耗:

偏向锁:适合单线程频繁访问的场景,减少同步开销。

轻量级锁:适合少量线程竞争的场景,利用CAS和自旋提高效率。

重量级锁:适合高竞争场景,避免线程长时间占用CPU资源。

(4)锁降级

需要注意的是,锁的升级是单向的,即从无锁 → 偏向锁 → 轻量级锁 → 重量级锁。一旦锁升级为重量级锁,就不会再降级为轻量级锁或偏向锁。

什么是乐观锁?(635/1759=36.1%)

乐观锁是一种并发控制机制,接下来我会详细讲述乐观锁的定义、实现方式、特点以及它的适用场景。

首先说一下什么是乐观锁,它是一种基于“无锁”思想的并发控制机制。它假设多线程操作之间很少发生冲突,因此在读取数据时不会加锁,而是通过某种机制(如版本号或时间戳)来检测数据是否被其他线程修改过。如果检测到数据未被修改,则提交更新;如果检测到数据已被修改,则根据策略进行处理(如重试或抛出异常)。

接下来说一下乐观锁的实现方式,乐观锁的实现通常依赖于以下两种机制:

一种是版本号机制:为数据添加一个版本号字段,每次更新时递增版本号,并在更新时验证版本号是否匹配。

另一种是CAS 操作:使用比较并交换(Compare-And-Swap)指令,直接在硬件层面实现无锁操作。CAS 操作包含内存位置(V)、预期值(A)和新值(B)这三个参数。只有当内存位置的值等于预期值时,才会将内存位置的值更新为新值。

然后再说一下乐观锁的特点,一共有三个。

第一个是无锁设计,乐观锁不依赖传统的锁机制,减少了线程阻塞和上下文切换的开销。

第二个是性能好,在低冲突场景下,乐观锁的性能优于悲观锁,因为它避免了锁的竞争。

第三个是支持冲突检测,乐观锁通过版本号或 CAS 操作检测冲突,但需要开发者显式处理冲突(如重试或回滚),这可能会增加代码的复杂性。

最后说一下乐观锁的适用场景,一般有三种场景比较适合。

第一种是读多写少的场景,例如缓存系统、统计计数器等,读操作远多于写操作,冲突概率较低。

第二种是在分布式环境中,乐观锁可以通过版本号或时间戳实现跨节点的数据一致性。

第三种是高并发环境,在高并发场景下,乐观锁可以减少锁的竞争,从而提高系统的吞吐量。

需要注意的是,乐观锁并不适合写操作频繁或冲突概率较高的场景,因为频繁的冲突会导致大量的重试操作,反而降低性能。

如何记忆:

1.口诀记忆

口诀:

乐观锁无锁思想妙,冲突少时效率高。

版本号 CAS 都好用,检测冲突不烦恼。

无锁设计性能好,读多写少最相宜。

分布式中一致性,高并发下吞吐提。

写多冲突不适合,重试频繁性能低!

解释 :

第一段描述乐观锁的定义和实现方式(版本号和 CAS)。

第二段描述乐观锁的特点和适用场景(无锁设计、读多写少、分布式环境、高并发场景)。

第三段提醒注意事项(不适合写多冲突高的场景)。

2.联想记忆

想象一个图书馆的借书系统:

乐观锁定义 :

图书馆假设读者很少会同时借同一本书(冲突少),因此不需要对书籍加锁。

当读者 A 借书时,系统会检查这本书是否被其他人借走(冲突检测)。如果没有被借走,则允许借阅;如果被借走了,则提示读者 A 稍后再试。

实现方式 :

版本号机制 :每本书都有一个版本号,每次借阅时版本号递增。如果读者 A 想借书时发现版本号变了,说明书已被借走。

CAS 操作 :系统直接检查书的状态(内存位置),只有当状态符合预期(未被借走)时,才会完成借阅操作。

特点 :

无锁设计 :图书馆没有物理锁,减少了等待时间。

性能好 :在借阅人数少的情况下,系统运行非常高效。

冲突检测 :如果书被借走,系统会提示读者重试,但需要开发者处理这种情况。

适用场景 :

读多写少 :图书馆里大多数时候是读者查阅书籍,而不是借阅。

分布式环境 :多个图书馆分馆可以通过版本号保持书籍状态一致。

高并发场景 :在高峰期,系统能快速处理大量查询请求。

拓展:

1.CAS 算法的主要问题

(1)ABA 问题

问题描述:

在 CAS 操作中,如果一个变量的值从 A 变为 B,然后又变回 A,那么 CAS 操作会认为该变量没有被修改过,从而成功执行更新操作。但实际上,变量的值可能已经被其他线程修改过。

示例:

假设有两个线程操作同一个变量 value:

初始值:value = A

线程1读取 value 的值为 A。

线程2将 value 修改为 B,然后再改回 A。

线程1尝试使用 CAS 更新 value,发现当前值仍然是 A,于是成功更新。

尽管最终值看起来没有变化,但中间的状态已经被修改过,这可能导致逻辑错误。

解决方案:

引入版本号:使用带有版本号的原子类(如 AtomicStampedReference),在每次修改时增加版本号,确保可以检测到中间状态的变化。

使用其他数据结构:例如使用不可变对象或链表等数据结构,避免直接修改共享变量。

(2)循环时间长开销大

问题描述:

当多个线程竞争同一个资源时,CAS 操作可能会失败多次。在这种情况下,线程通常会进入自旋(即不断重试 CAS 操作),直到成功为止。这种自旋操作会消耗大量的 CPU 资源,尤其是在高并发场景下。

示例:

假设多个线程同时尝试对一个共享变量进行 CAS 操作:

AtomicInteger atomicInt = new AtomicInteger(0);

// 线程1和线程2都尝试将值从0改为1
while (!atomicInt.compareAndSet(0, 1)) {
    // 自旋等待
}

如果竞争激烈,线程可能需要多次尝试才能成功,导致 CPU 使用率飙升。

解决方案:

限制自旋次数:设置最大重试次数,超过后切换到阻塞模式(如使用 Lock)。

使用更高级的同步机制:例如 ReentrantLock 或 Semaphore,它们可以在竞争激烈时挂起线程,减少 CPU 开销。

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

问题描述:

CAS 操作只能针对单个变量进行原子操作。如果需要对多个变量进行原子操作,CAS 本身无法直接支持。

示例:

假设有两个共享变量 x 和 y,需要同时更新它们的值:

AtomicInteger x = new AtomicInteger(0);
AtomicInteger y = new AtomicInteger(0);

// 需要同时更新 x 和 y
x.incrementAndGet();
y.incrementAndGet();

上述代码中,x 和 y 的更新并不是原子性的,可能存在线程安全问题。

解决方案:

使用锁:通过显式锁(如 ReentrantLock)或内置锁(synchronized)来保证多个变量的原子性。

封装成对象:将多个变量封装到一个对象中,使用 AtomicReference 对整个对象进行 CAS 操作。

事务性内存:某些高级并发框架(如 STM)提供了对多个变量的原子性支持。

(4)其他潜在问题

除了上述三个主要问题外,CAS 还可能存在以下问题:

性能瓶颈:在低竞争场景下,CAS 性能很高;但在高竞争场景下,由于频繁的自旋和重试,性能会显著下降。可以结合锁或其他同步机制,根据竞争程度动态选择合适的同步策略。

缺乏公平性:CAS 是一种非公平的同步机制,先到的线程可能因为竞争失败而被后来的线程抢占资源。可以使用公平锁(如 ReentrantLock 的公平模式)来保证线程调度的公平性。

复杂性增加:使用 CAS 时,开发者需要手动处理重试逻辑、版本号等问题,增加了代码的复杂性。尽量使用更高层的并发工具(如 ConcurrentHashMap、CopyOnWriteArrayList 等),减少手动实现 CAS 的需求。

【神品八股】1759篇面经精华 文章被收录于专栏

神哥引路,稳稳起步!!早鸟特惠,仅剩177名额!晚了就涨到29.9了! 核心亮点: 1.数据驱动,精准高频:基于1759篇面经、24139道八股题,精准提炼真实高频八股。 2.科学记忆,高效掌握:融合科学记忆法和面试表达技巧,记得住,说得出。 3.提升思维,掌握财商:不仅可学习八股,更可教你变现,3个月赚不回购买价,全额退。 适宜人群: 在校生、社招求职者及自学者。

全部评论
1 回复 分享
发布于 02-17 19:27 四川

相关推荐

评论
3
6
分享

创作者周榜

更多
牛客网
牛客企业服务