应聘 Java 岗,总是免不了几个 Java 并发编程的面试题,不过大多数都局限在 java.util.concurrent
包下的知识和实现问题。本文针对 Java 并发相关的常见的面试题做一些解释。
这是一个非常基础的面试题,如果这道题没有回答的比较满意,一般情况下,面试官会认为应聘者在并发方面的基础只是不牢固,就不会继续深入询问其它并发问题了。
关于进程间通讯那一块可以不用回答,如果你不懂的话,不必然会导致接下来的某个问题是 进程间通讯的的原理.
这道题考察的是对 Runnable 的理解。
创建一个线程的实例,有两种方法可供选择:
start()
方法 Thread thread1 = new Thread(() ->
System.out.println("Hello World from Runnable!"));
thread1.start();
Runnable是一个函数接口,因此可以作为 lambda 表达式传递
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.start()
方法启动的新 Thread 实例。Thread.start()
方法时,会将一个 NEW
线程进入 RUNNABLE
状态。Object.wait()
法时进入此状态,或者在另一个线程上调用 Thread.join()
方法也会进入此状态。Thread.sleep()
、Object.wait()
、或 Thread.join()
和其他一些方法的定时版本后进入此状态Runnable.run()
方法的执行并终止时进入此状态。run()
方法。Runnable 接口不允许此方法返回值或抛出未经检查的异常。call()
方法。call()
方法可以返回一个值 ( 可以是 Void ),也可以抛出一个异常。Callable 通常在 ExecutorService
实例中用于启动异步任务,然后调用返回的 Future 实例以获取其值。start()
之前使用 setDaemon()
方法设置为守护线程。如下所示 Thread daemon = new Thread(()
-> System.out.println("Hello from daemon!"));
daemon.setDaemon(true);
daemon.start();
额外的
奇怪的是,如果将上面的代码放在 main()
内运行,则可能无法打印该消息。而发生这种情况的原因,是因为 main()
线程在守护线程运行到打印消息之前就已经终止。
我们不应该在守护线程中执行任何 I/O 操作,因为它们甚至无法执行其 finally 块并在被放弃时关闭资源。
flag
属性 )。thread.interrupt()
方法。InterruptedException
( wait
、join
、sleep
等 ),那么此方法会立即抛出InterruptedException。线程可以根据自己的逻辑自由处理此异常。如果一个线程不在这样的方法中并且调用了 thread.interrupt()
,则不会发生任何特殊情况。Thread.interrupted()
方法或实例的 isInterrupted()
方法定期检查。这两个方法的区别是静态Thread.interrupt()
会清除了中断标志,而 isInterrupted()
则不会。Executor
和 ExecutorService
是 java.util.concurrent
框架提供的两个相关接口。Executor
是一个非常简单的接口,只有一个 execute()
方法接受 Runnable 实例来执行。在大多数情况下,这是我们的任务执行代码应该依赖的接口。ExecutorService
扩展了 Executor
接口,并且添加了许多其它方法以处理和检查并发任务执行服务的生命周期(在关闭时终止任务)和更复杂的异步任务处理,包括 Futures。更多 Executor 和 ExecutorService 的知识,可以访问 一文秒懂 Java ExecutorService。
这是一个非常变 tai 的问题。问这个问题的面试官,你想咋样啊 ?
ExecutorService 接口有三个标准实现
ThreadPoolExecutor
: 使用线程池执行任务。一旦某个线程完成执行任务,它就会回到线程池中。如果池中的所有线程都忙,则任务必须等待轮到它。ScheduledThreadPoolExecutor
: 允许安排任务执行,而不是简单的在线程可用时立即运行任务。它还可以按固定频率或固定延迟安排任务。ForkJoinPool
: 是一个特殊的 ExecutorService,用于处理递归算法任务。如果你使用常规 ThreadPoolExecutor 进行递归算法,那么你很快发现所有线程都在忙着等待较低级别的递归完成。ForkJoinPool 实现了所谓的工作窃取算法,允许它更有效地使用可用线程。Java 内存模式是 Java 语言规范的一部分,在 第 17.4 章 中描述。
JMM 规定了多个线程如何访问并发 Java 应用程序中的公共内存,以及一个线程的数据更改如何对其他线程可见。
是不是很简单,虽然简短又简洁,但如果没有强大的数学背景,JMM 可能很难掌握。
对内存模型的需求源于这样一个事实: Java 代码访问数据的方式并不像它在底层实际发生的那样 。
在保证内存读写的可观察结果是相同的情况下,Java 编译器,JIT 编译器甚至 CPU 都可以对内存读写进行重新排序或优化。
当我们的应用程序扩展到多个线程时,这会导致反直觉的结果,因为大多数这些优化只会考虑单个执行线程( 跨线程优化器仍然非常难以实现 )。
另一个可怕的问题是现代系统中的内存是多层的: 处理器的多个内核可能会在其缓存或读/写缓冲区中保留一些非刷新数据,这也会影响从其它内核观察到的内存状态 。
更糟糕的是,不同内存访问架构的存在将打破Java 「 一次编写,随处运行 」 的承诺。
但另所有 Java 程序员高兴的是,JMM 指定了在设计多线程应用程序时可能依赖的一些保证。坚持这些保证有助于程序员编写在各种体系结构之间稳定且可移植的多线程代码。
JMM 的主要概念是:
PO
,单个线程内可观察的动作总顺序。SO
,所有同步操作之间的总顺序 – 它必须与程序顺序一致,也就是说,如果两个同步操作在PO 中一个接一个地出现,它们在 SO 中以相同的顺序出现 。SW
,某些同步操作之间的关系,例如解锁监视器和锁定同一监视器( 在另一个或同一个线程中 )。对于给定的程序,我们可以观察到具有各种结果的多个不同的执行.但是如果一个程序正确同步,那么它的所有执行似乎都是顺序一致的,这意味着我们可以将多线程程序推断为一系列按顺序发生的动作。这样可以省去考虑引擎盖下重新排序,优化或数据缓存的麻烦。
如果你了解协程,相关的概念和协程很相像的。
根据 Java 内存模型 ( 参见 Q9 ) ,volatile
字段具有特殊属性。volatile
变量的读取和写入是同步操作,这意味着它们具有总排序( 所有线程将遵循这些操作的一致顺序 )。根据此顺序,保证读取 volatile 变量可以观察到对此变量的最后一次写入。
如果你有一个从多个线程访问的字段,且至少有一个线程写入它,那么你应该考虑使它变得 volatile
,否则某个线程从这个字段读取的内容并不会得到一丝的保证。
volatile
的另一个保证是写入和读取 64 位值( long
类型和 double
类型 )的原子性。如果没有 volatile
修饰符,读取此类字段可能会观察到另一个线程部分写入的值。
是不是瞬间蒙了?我们来解释一下
volatile
修饰符,则保证以原子方式访问 long 变量。AtomicInteger
, AtomicLong
等。final
修饰符的类的字段有什么特殊保证 ?JVM 基本上会保证在任何线程获取对象之前初始化类的 final
字段。
如果没有这种保证,由于重新排序或其他优化,在初始化该对象的所有字段之前,可以向另一个线程发布对象的引用,即变得可见。这可能会导致对这些字段的访问。
这就是为什么在创建不可变对象时,应始终将其所有字段设为 final
,即使它们不能通过 getter 方法访问。
块 ( block ) 之前的 synchronized 关键字表示进入该块的任何线程都必须获取监视器( 括号中的对象 )。
synchronized(object) {
// ...
}
``
如果监视器已被另一个线程获取,则前一个线程将进入 BLOCKED 状态并等待监视器被释放。
同步实例方法具有相同的语义,但会使用实例本身充当监视器。
```java
synchronized void instanceMethod() {
// ...
}
对于静态同步方法,监视器是表示声明类的 Class 对象。
static synchronized void staticMethod() {
// ...
}
如果方法是实例方法,则实例充当方法的监视器。在不同实例上调用该方法的两个线程获取不同的监视器,因此它们都不会被阻塞。
如果方法是静态的,则监视器是 Class
对象。对于两个线程,监视器是相同的,因此其中一个可能会阻塞并等待另一个退出 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);
}
}
DeadLock
) 是一组无法进行的线程中的条件,因为组中的每个线程都必须获取已由组中的另一个线程获取的某些资源。最简单的情况是两个线程需要锁定两个资源才能进行,第一个资源已被一个线程锁定,第二个资源已被另一个线程锁定。因为这些线程永远不会获得对两个资源的锁定,因此永远不会进展。LiveLock
) 是多线程对自己生成的条件或事件做出反应的一种情况。事件发生在一个线程中,必须由另一个线程处理。在此处理期间,发生的新事件必须在第一个线程中处理,依此类推。这样的线程是活着的并且没有被阻挡,但是仍然没有取得任何进展,因为它们用无用的工作压倒了对方Starvation
) 是线程无法获取资源的情况,因为其他线程(或多个线程)占用它太长时间或具有更高的优先级。线程无法取得进展,因此无法完成有用的工作。fork/join 框架允许并行化递归算法。使用 ThreadPoolExecutor 之类的并行递归的主要问题是,可能会快速耗尽线程,因为每个递归步骤都需要自己的线程,而堆栈中的线程将处于空闲状态并等待。
fork/join 框架入口点是 ForkJoinPool 类,它是 ExecutorService 的一个实现。它实现了工作窃取算法,空闲线程会试图从忙线程中 「 窃取 」 工作。这允许在不同线程之间传播计算并在使用比通常的线程池所需的更少的线程时取得进展