编程

Java 并发面试题及答案

212 2024-07-07 18:35:00

1. 介绍

Java 中的并发是在技术面试中提出的最复杂和最高级的主题之一。这篇文章提供了你可能遇到的一些面试问题的答案。

Q1. 进程和线程的区别

进程和线程都是并发单元,但它们有一个根本的区别:进程不共享公共内存,而线程共享。

从操作系统的角度来看,进程是在自己的虚拟内存空间中运行的独立软件。任何多任务操作系统(几乎所有现代操作系统)都必须在内存中分离进程,这样一个失败的进程就不会因为扰乱公共内存而拖累所有其他进程。

因此,进程通常是隔离的,它们通过进程间通信的方式进行协作,该通信由操作系统定义为一种中间 API。

相反,线程是应用的一部分,与同一应用的其他线程共享公共内存。使用公共内存可以减少大量开销,设计线程以更快地在它们之间协作和交换数据。

Q2. 如何创建线程实例并运行?

要创建线程的实例,你有两种方式。第一种方式是,将一个 Runnable 实例传递给其构造函数并调用 start() 方法。Runnable 是一个函数接口,因此它可以作为 lambda 表达式传递:

Thread thread1 = new Thread(() ->
  System.out.println("Hello World from Runnable!"));
thread1.start();

线程(Thread) 也实现了 Runnable,因此,启动线程的另一种方法是创建一个匿名子类,覆盖其 run() 方法,然后调用 start()

Thread thread2 = new Thread() {
    @Override
    public void run() {
        System.out.println("Hello World from subclass!");
    }
};
thread2.start();

Q3. 请描述一下线程的不同状态以及状态转换何时发生。

线程的状态可以使用 Thread.getState() 方法进行确认。Thread.State 枚举描述了线程的不同状态。这些状态包括:

  • NEW — 新线程(Thread)实例,还未通过 Thread.start() 启动
  • RUNNABLE — 正在运行的线程。它被称为可运行,因为在任何给定的时间,它都可能正在运行或等待线程调度器在下一个时间段运行。NEW 状态下的线程,在调用 Thread.start() 时,进入 RUNNABLE 状态。
  • BLOCKED — 如果一个运行中的线程进入一个同步区,但由于另一个线程持有该区的监视器(monitor)而无法进入,那么它就会变成阻塞(BLOCKED)
  • WAITING — 如果线程等待另一个线程执行特定操作,则该线程将进入此状态。例如,线程在其持有的监视器(monitor)上调用 Object.wait() 方法或在另一个线程上调用 Thread.join() 方法时进入这种状态
  • TIMED_WAITING — 如上,不过线程在调用定时版的 Thread.sleep()Object.wait()Thread.join() 等方法时进入这个状态
  • TERMINATED — 线程已完成其 Runnable.run() 方法的执行并终止

Q4. Runnable 和 Callable 接口的区别以及如何使用

Runnable 接口有单一的 run 方法。它表示必须在单独的线程中运行的计算单元。Runnable 接口不允许此方法返回值或抛出未检查的异常。

Callable 接口有单一的 call方法,表示一个有值的任务。这也是为什么 call 方法返回一个值。它也能抛出异常。Callable 通常用于 ExecutorService 实例,用来启动异步任务,然后调用返回的 Future 实例以获取它的值。

Q5. 什么是守护线程,它有什么用例?如何创建守护线程?

守护线程是不阻止 JVM 退出的线程。当所有非守护线程都终止时,JVM 会停止所有剩下的守护线程。守护线程通常用于为其他线程执行一些支持性或服务性任务,但你应该注意到它们随时可能被放弃。

要将线程启动为守护线程,你应该在调用 start() 方法前使用 setDaemon() 方法:

Thread daemon = new Thread(()
  -> System.out.println("Hello from daemon!"));
daemon.setDaemon(true);
daemon.start();

奇怪的是,如果将其作为 main() 方法的一部分运行,则该消息可能不会打印出来。如果 main() 线程在守护线程到达打印消息的点之前终止,则可能会发生这种情况。通常不应该在守护进线程中进行任何 I/O 操作,因为如果放弃,它们甚至无法执行 finally 块并关闭资源。

Q6. 什么是线程中断标志?如何设置或者查询中断标志?它与中断异常有何关系?

中断标志或中断状态是线程中断时设置的内部线程标志。要设置它,只需对线程对象调用 Thread.interrupt()
如果线程当前位于会抛出 InterruptedException 的方法之一(waitjoinsleep)内,则此方法会立即抛出  InterruptidException。线程可以根据自己的逻辑自由处理此异常。

如果线程不在这样的方法中,并且调用了 Thread.interrupt(),则不会发生任何特殊情况。使用静态Thread.interrupted() 或实例的 isInterrupted() 方法定期检查中断状态是线程的责任。这两种方法的区别在于静态 Thread.interrupted() 清除中断标志,而 isInterrupt() 则不清除。

Q7. 什么是执行器(Executor)和执行器服务(Executorservice)?这些接口之间有什么区别?

ExecutorExecutorServicejava.util.concurrent 框架中两个相关联的接口。Executor 是一个非常简单的接口,它有一个接受 Runnable 实例的单一 execute 方法。大部分情况下,这是执行代码的任务应该依赖的接口。

ExecutorService 继承了 Executor 接口,有多个方法处理和检查当前任务执行服务(在关闭的情况下终止任务)的生命周期以及更复杂的异步任务处理方法(包括 Futures)。

Q8. 标准库中有什么可用的 Executorservice 实现?

ExecutorService 接口有三个标准实现:

  • ThreadPoolExecutor — 用于使用线程池执行任务。一旦线程完成了任务的执行,它就会返回到池中。如果池中的所有线程都很忙,则任务必须等待轮到它。
  • ScheduledThreadPoolExecutor 允许调度任务执行,而不是在线程可用时立即运行任务。它还可以安排具有固定速率或固定延迟的任务。
  • ForkJoinPool 是一个特殊的 ExecutorService,用于处理递归算法任务。如果你使用常规的 ThreadPoolExecutor 进行递归算法,你会很快发现所有线程都在忙于等待较低级别的递归完成。ForkJoinPool 实现了所谓的工作窃取算法,使其能够更有效地使用可用线程。

Q9. 什么是 Java 内存模型(Jmm)? 描述其目的和基本思想。

Java 内存模型是 Java 语言规范的一部分,如第 17.4 章所述。它指定了多个线程如何访问并发 Java 应用中的公共内存,以及如何使一个线程更改的数据对其他线程可见。虽然 JMM 非常简短,但如果没有强大的数学背景,可能很难掌握。

对内存模型的需求源于这样一个事实,即 Java 代码访问数据的方式并不是在较低级别上实际发生的方式。内存写入和读取可以由 Java 编译器、JIT 编译器甚至 CPU 重新排序或优化,只要这些读取和写入的可观察结果相同。

当应用扩展到多个线程时,这可能会导致反直觉的结果,因为大多数优化都考虑了单个执行线程(跨线程优化器仍然非常难以实现)。另一个巨大的问题是,现代系统中的内存是多层的:一个处理器的多个核心可能会在其缓存或读/写缓冲区中保留一些未刷新的数据,这也会影响从其他核心观察到的内存状态。

更糟糕的是,不同内存访问体系结构的存在将打破 Java “一次写入,处处运行”的承诺。令程序员高兴的是,JMM 指定了一些在设计多线程应用时可能依赖的保证。坚持这些保证有助于程序员编写在各种体系结构之间稳定且可移植的多线程代码。
JMM 的主要概念是:

  • Actions, 这些是线程间操作,可以由一个线程执行,也可以由另一个线程检测,如读取或写入变量、锁定/解锁监视器等等
  • Synchronization actions, 特定的操作子集,如读取/写入易失性变量,或锁定/解锁监视器
  • Program Order (PO), 单个线程内可观察到的操作的总顺序
  • Synchronization Order (SO), 所有同步操作之间的总顺序——它必须与程序顺序一致,也就是说,如果两个同步操作在 PO 中相继出现,那么它们在 SO 中以相同的顺序出现
  • synchronizes-with (SW) 某些同步操作之间的关系,如监视器的解锁和同一监视器的锁定(在另一个或同一线程中)r
  • Happens-before Order — 将 PO 与 SW 相结合(这在集合论中被称为传递闭包),以创建线程之间所有操作的偏序。如果一个动作发生在另一个动作之前(happens-before),那么第一个动作的结果可以被第二个动作观察到(例如,在一个线程中写入变量,在另一线程中读取)
  • Happens-before consistency — 如果每次读取都观察到“先发生后发生”顺序中对该位置的最后一次写入,或者通过数据竞争进行其他写入,则一组操作是 HB 一致的
  • Execution — 一组有序的操作及其一致性规则

对于给定的程序,我们可以观察到具有不同结果的多个不同执行。但是,如果一个程序被正确同步,那么它的所有执行看起来都是顺序一致的,这意味着你可以将多线程程序解释为一组按顺序发生的操作。这为你省去了考虑隐藏的重新排序、优化或数据缓存的麻烦。

Q10. 什么是 Volatile 字段,Jmm 对这样的字段有什么保证?

根据 JAVA 内存模型,volatile 字段有特殊的属性。volatile 变量的读写是同步操作,也就是说它们有一个总顺序(所有线程都将遵守这些操作的一致顺序)。根据此顺序,volatitle 变量的读取保证观察该变量的最后一次写入。

如果你在多个线程中访问一个字段,其中至少有一个线程对其写入,那么你应该考虑将其设为 volatile,或者稍微保证某个特定线程会从这个字段中读取到什么。

volatile 的另一个保证是 64 位值(long 和 double)读写的原子性。没有 volatile 修饰符,这类字段的读取可能会观察到另一个线程部分写入的值。

Q11. 以下拿些操作是非原子化的?

  • 写入到非 volatile int;
  • 写入到 volatile int;
  • 写入到非 volatile long;
  • 写入到 volatile long;
  • 递增 volatile long?

写入到 int (32-bit) 变量保证是原子的,无论其是否为 volatilelong (64-bit) 变量可以用两个分开的步骤吸入,比如,在32 位架构上,默认是没有原子化保证的。不过,如果你指定了 volatile 修饰符,long 变量保证了访问的原子性。

递增操作通常在多个步骤(检索值、修改值并写回)中完成,因此,它从未保证原子化,无论其是否为 volatile。如果你需要实现值的原子增量,则应该使用 AtomicIntegerAtomicLong 等类。

Q12. Jmm 对类的 Final 字段有什么特殊保证?

JVM 基本上保证在任何线程获得对象之前初始化类的 final 字段。如果没有这一保证,由于重新排序或其他优化,在初始化该对象的所有字段之前,对该对象的引用可能会被发布,即对另一个线程可见。这可能会导致对这些字段的 racy 访问。

这就是为什么,在创建一个不可变(immutable)对象时,你应该始终使其所有字段成为 final 字段,即使它们不能通过 getter 方法访问。

Q13. 方法定义中断 Synchronized 关键词是什么意思? 在静态方法中呢? 在代码块前呢?

代码块前的 synchronized 关键词指的是进入该代码块的任何线程都必须获取监视器(花括号内的对象)。如果监视器已经被另一个线程获取,前面的线程将进入阻塞(BLOCKED)状态,并等待监视器的释放。

synchronized(object) {
    // ...
}

synchronized 的实例方法有相同的语义,不过该实例本身也是一个监视器。

synchronized void instanceMethod() {
    // ...
}

对于 static synchronized 方法,监视器是表示声明类的类对象。

static synchronized void staticMethod() {
    // ...
}

Q14. 如果两个线程同时调用不同对象实例上的同步(Synchronized)方法,其中一个线程会阻塞吗?如果方法是静态的怎么办?

如果该方法是实例方法,则该实例充当该方法的监视器。在不同实例上调用该方法的两个线程获取不同的监视器,因此它们都不会被阻塞。

如果方法是静态的,那么监视器就是该类象。对于这两个线程,监视器是相同的,因此其中一个线程可能会阻塞并等待另一个线程退出同步方法。

Q15. Object 类的 Wait, Notify 和 Notifyall 方法的目的是什么?

拥有对象监视器的线程(例如,已进入由对象保护的 synchronized  区的线程)可以调用 object.wait() 来临时释放监视器,并给其他线程获取监视器的机会。例如,可以这样做以等待某个条件。

当获取监视器的另一个线程满足条件时,它可以调用 object.notify()object.notifyAll() 并释放监视器。notify 方法唤醒处于等待状态的单个线程,notifyAll 方法唤醒所有等待该监视器的线程,它们都在竞争重新获取锁。

下例的 BlockingQueue 实现展示了多个线程如何通过等待通知(wait-notify)模式协同工作。如果我们将一个元素放入一个空队列中,那么在 take 方法中等待的所有线程都会唤醒并尝试接收该值。如果我们将一个元素放入一个完整的队列中, put 方法将等待对 get 方法的调用。get 方法删除一个元素,并通知在 put 方法中等待的线程队列中有一个新项目的空位置。 

public class BlockingQueue<T> {

    private List<T> queue = new LinkedList<T>();

    private int limit = 10;

    public synchronized void put(T item) {
        while (queue.size() == limit) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        if (queue.isEmpty()) {
            notifyAll();
        }
        queue.add(item);
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        if (queue.size() == limit) {
            notifyAll();
        }
        return queue.remove(0);
    }
    
}

Q16. 描述死锁(Deadlock)、活锁(Livelock) 和饥饿(Starvation)的条件。描述这些条件的可能原因。

死锁(Deadlock) 是一组线程中无法取得进展的情况,因为该组中的每个线程都必须获取该组中另一个线程已经获取的某些资源。最简单的情况是,当两个线程需要锁定两个资源才能进行时,第一个资源已经被一个线程锁定,第二个资源已经由另一个线程锁住。这些线程永远不会获得对这两个资源的锁定,因此永远不会前进。

活锁(Livelock)是多个线程对自身生成的条件或事件作出反应的情况。一个事件发生在一个线程中,必须由另一个线程处理。在这个处理过程中,一个新的事件发生,必须在第一个线程中处理,以此类推。这些线程是活动的,没有被阻塞,但仍然没有任何进展,因为它们被无用的工作淹没了彼此。

饥饿(Starvation)是一个线程无法获取资源的情况,因为其他线程占用资源的时间太长或优先级更高。线程无法取得进展,因此无法完成有用的工作。

Q17. 描述 Fork/Join 框架的目的和用例

fork/join 框架允许并行递归算法。使用 ThreadPoolExecutor 之类的方法并行递归的主要问题是,你可能会很快用完线程,因为每个递归步骤都需要自己的线程,而堆栈中的线程将处于空闲状态并等待。

fork/join 框架的入口点是 ForkJoinPool 类,它是 ExecutorService 的一个实现。它实现了工作窃取算法,空闲线程试图从繁忙线程“窃取”工作。这允许在不同的线程之间分散计算并取得进展,同时使用比普通线程池所需更少的线程。