Java并发面试问题

进程和线程有什么区别?

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

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

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

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

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

要创建线程的实例,有两个选择。 首先,将 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();

描述线程的不同状态以及状态转换何时发生。

可以使用 Thread.getState() 方法检查 Thread 的状态。 Thread.State 枚举中描述了 Thread 的不同状态。他们是:

  • NEW — 尚未通过 Thread.start() 启动的新 Thread 实例
  • RUNNABLE — 一个正在运行的线程。它被称为可运行的,因为在任何给定的时间它可能正在运行或等待来自线程调度程序的下一个时间量。一个新线程在调用 Thread.start() 时进入 RUNNABLE 状态
  • BLOCKED — 如果一个正在运行的线程需要进入一个同步部分,但由于另一个线程持有该部分的监视器而不能这样做,它就会被阻塞
  • WAITING — 如果线程等待另一个线程执行特定操作,则该线程进入此状态。例如,一个线程在调用它持有的监视器上的 Object.wait() 方法或另一个线程上的 Thread.join() 方法时进入此状态
  • TIMED_WAITING — 同上,但线程在调用定时版本的 Thread.sleep()、Object.wait()、Thread.join() 和其他一些方法后进入此状态
  • TERMINATED — 线程已完成其 Runnable.run() 方法的执行并终止

Runnable 和 Callable 接口之间有什么区别?它们是如何使用的?

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

Callable 接口具有单个调用方法并表示具有值的任务。 这就是调用方法返回一个值的原因。 它也可以抛出异常。 Callable 一般用在 ExecutorService 实例中,用来启动一个异步任务,然后调用返回的 Future 实例来获取它的值。

扫描二维码关注公众号,回复: 13414889 查看本文章

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

守护线程是不阻止 JVM 退出的线程。 当所有非守护线程终止时,JVM 简单地放弃所有剩余的守护线程。 守护线程通常用于为其他线程执行一些支持或服务任务,但应该考虑到它们随时可能被放弃。

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

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

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

线程的中断标志是什么? 如何设置和检查它? 它与 InterruptedException 有什么关系?

中断标志或中断状态是线程被中断时设置的内部线程标志。 要设置它,只需在线程对象上调用 thread.interrupt() 即可。

如果线程当前位于抛出 InterruptedException(等待、加入、睡眠等)的方法之一中,则该方法立即抛出 InterruptedException。 线程可以根据自己的逻辑自由处理这个异常。

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

什么是 Executor 和 Executorservice? 这些接口之间有什么区别?

Executor 和 ExecutorService 是 java.util.concurrent 框架的两个相关接口。 Executor 是一个非常简单的接口,有一个 execute 方法接受 Runnable 实例来执行。 在大多数情况下,这是任务执行代码应该依赖的接口。

ExecutorService 扩展了 Executor 接口,使用多种方法来处理和检查并发任务执行服务的生命周期(在关闭的情况下终止任务)以及用于更复杂的异步任务处理的方法,包括 Futures。

标准库中 Executorservice 的可用实现有哪些?

ExecutorService 接口具有三个标准实现:

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

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

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

之所以需要内存模型,是因为 Java 代码访问数据的方式并不是它在较低级别上实际发生的方式。内存写入和读取可能会被 Java 编译器、JIT 编译器甚至 CPU 重新排序或优化,只要这些读取和写入的可观察结果相同即可。

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

不同内存访问架构的存在会破坏 Java 的“一次编写,到处运行”的承诺。 JMM 指定了一些在设计多线程应用程序时可以依赖的保证。 坚持这些保证有助于编写在各种体系结构之间稳定且可移植的多线程代码。

JMM 的主要概念是:

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

对于给定的程序,可以观察到具有不同结果的多个不同执行。 但是如果一个程序正确同步,那么它的所有执行似乎都是顺序一致的,这意味着可以将多线程程序推理为一组以某种顺序发生的动作。 这省去了考虑底层重新排序、优化或数据缓存的麻烦

什么是Volatile关键字,Jmm 对此类字段有什么保证?

根据 Java 内存模型,Volatile字段具有特殊属性。 volatile 变量的读取和写入是同步操作,这意味着它们具有总排序(所有线程将观察这些操作的一致顺序)。 根据此顺序,保证读取 volatile 变量会观察对该变量的最后一次写入。

如果有一个可以从多个线程访问的字段,并且至少有一个线程写入它,那么应该考虑将其设置为 volatile,否则对于某个线程将从该字段读取的内容有一点保证。

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

以下哪个操作是原子操作

写入 非 volatile修饰的 int类型字段;
写入 volatile修饰的 int类型字段;
写入非volatile修饰的 long类型字段;
写入 volatile修饰的long类型字段;
自增的long类型 volatile修饰的字段;

对 int(32 位)变量的写入保证是原子的,无论它是否是 volatile。 长(64 位)变量可以在两个单独的步骤中写入,例如,在 32 位架构上,因此默认情况下,没有原子性保证。 但是,如果指定 volatile 修饰符,则可以保证以原子方式访问 long 变量。

增量操作通常在多个步骤中完成(检索一个值,更改它并写回),所以它永远不能保证是原子的,无论变量是否是 volatile。 如果需要实现一个值的原子增量,应该使用类 AtomicInteger、AtomicLong 等。

Jmm 对类的 final 字段有什么特殊保证

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

这就是为什么在创建不可变对象时,应该始终将其所有字段设为 final,即使它们无法通过 getter 方法访问。

方法定义中的同步关键字是什么意思? 静态方法? 代码块?

代码块的 synchronized 关键字意味着任何进入该块的线程都必须获取监视器(括号中的对象)。 如果监视器已被另一个线程获取,则前一个线程将进入 BLOCKED 状态并等待监视器被释放。

synchronized(object) {
    
    
    // ...
}

同步实例方法具有相同的语义,但实例本身充当监视器。

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

对于静态同步方法,监视器是表示声明类的 Class 对象。

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

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

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

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

对象类的Wait、Notify和Notifyall方法的目的是什么?

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

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

以下 BlockingQueue 实现展示了多个线程如何通过等待通知模式协同工作。 如果将一个元素放入一个空队列,则所有在 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);
    }
    
}

描述死锁、活锁和饥饿的条件。 描述这些情况的可能原因。

  • 死锁是一组线程中无法取得进展的情况,因为该组中的每个线程都必须获取一些已被该组中的另一个线程获取的资源。 最简单的情况是,当两个线程需要锁定两个资源才能进行时,第一个资源已经被一个线程锁定,第二个资源已经被另一个线程锁定。 这些线程永远不会获得对这两个资源的锁定,因此永远不会进展。
  • Livelock 是多个线程对自身生成的条件或事件做出反应的情况。 事件发生在一个线程中,必须由另一个线程处理。 在此处理过程中,会发生一个必须在第一个线程中处理的新事件,依此类推。 这样的线程是活着的,没有被阻塞,但仍然没有任何进展,因为它们用无用的工作压倒了彼此。
  • 饥饿是一个线程无法获取资源的情况,因为其他线程(或多个线程)占用它的时间太长或具有更高的优先级。 线程无法取得进展,因此无法完成有用的工作。

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

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

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

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/niugang0920/article/details/120481338
今日推荐