在 Java 开发领域,多线程一直是面试中的重点考查内容。面试官期望应聘者不仅能熟练写出多线程相关的代码,更要深入理解背后的原理。本文将围绕一些常见且有深度的 Java 多线程面试题展开详细解析,希望能帮助大家更好地应对相关面试以及加深对多线程知识的掌握。
一、线程的生命周期及状态转换
1. 线程有哪些状态?
在 Java 中,线程一共有 6 种状态,它们被定义在 Thread.State 枚举类中,分别是:
NEW: 线程刚被创建,但还未启动时的状态。此时线程对象已经在 Java 虚拟机中被分配了内存空间,但 start() 方法尚未被调用。
RUNNABLE: 这个状态表示线程处于可运行状态,可能正在 Java 虚拟机中执行,也可能正在等待 CPU 资源分配以便执行。这是一个比较容易混淆的状态,很多人会误以为它等同于正在执行,但其实它包含了就绪(等待 CPU 分配时间片)和正在执行这两种细分情况。
BLOCKED: 当线程试图获取一个被其他线程持有且互斥的锁(例如 synchronized 关键字修饰的代码块或方法对应的锁)时,如果获取不到,线程就会进入阻塞状态,暂停执行,直到获取到该锁为止。
WAITING: 线程处于等待状态,通常是调用了 Object.wait()、Thread.join() 或者 LockSupport.park() 等方法,它会一直等待其他线程执行特定的唤醒操作(如 Object.notify() 或 Object.notifyAll())才会继续执行,期间不会占用 CPU 资源。
TIMED_WAITING: 和 WAITING 类似,但这种等待是有时间限制的,是通过调用 Object.wait(long timeout)、Thread.sleep(long millis)、LockSupport.parkNanos(long nanos) 等带时间参数的方法进入的状态。当等待时间超时后,线程会自动唤醒,继续执行后续操作。
**TERMINATED:**线程执行完毕或者因异常退出后的状态,此时线程的生命周期结束,其所占用的资源也会被释放。
2. 状态之间是如何转换的?(原理解析)
从线程启动到结束,状态转换过程如下:
线程首先被创建,处于 NEW 状态。当调用 start() 方法后,线程进入 RUNNABLE 状态,此时 Java 虚拟机会将其纳入线程调度器的调度范围,等待获取 CPU 时间片来执行线程体中的代码。
当线程执行过程中遇到 synchronized 修饰的代码块或方法,且需要获取的锁已经被其他线程占用时,就会从 RUNNABLE 状态转换为 BLOCKED 状态;一旦获取到锁,又会回到 RUNNABLE 状态继续执行。
如果线程执行到 Object.wait() 等能让其进入等待状态的方法时,会从 RUNNABLE 状态切换到 WAITING 或 TIMED_WAITING 状态(取决于调用的具体方法是否带时间参数)。当其他线程调用了对应的唤醒方法(如 Object.notify() 或 Object.notifyAll()),或者等待时间超时后,线程会再回到 RUNNABLE 状态。
线程正常执行完 run() 方法体中的所有代码,或者因未捕获的异常导致执行中断,就会从 RUNNABLE 状态变为 TERMINATED 状态,整个线程生命周期结束。
原理上,Java 虚拟机内部的线程调度器会根据系统资源、线程优先级等因素来协调这些状态之间的转换,以实现多线程的并发执行与合理的资源分配。例如,在 BLOCKED 状态下,线程会被放入一个锁对应的阻塞队列中,等待锁释放后,线程调度器会按照一定规则(比如公平锁或非公平锁的获取策略)从队列中选取线程重新进入 RUNNABLE 状态进行执行。
二、synchronized 关键字相关
1. synchronized 的作用及原理是什么?
synchronized 关键字主要用于实现多线程环境下的同步,保证在同一时刻,只有一个线程能够访问被它修饰的代码块或者方法,从而避免多个线程并发访问共享资源时出现数据不一致等问题。
其原理基于 Java 对象头和 Monitor(监视器)机制。在 Java 中,每个对象在内存中都有一部分空间用于存储对象头信息,对象头里包含了一些与对象的锁状态相关的标记位。当一个线程访问被 synchronized 修饰的代码时:
如果是修饰实例方法,那么锁对象就是当前实例对象。线程首先会尝试获取对象对应的 Monitor 锁,这个获取过程实际上就是通过 CAS(Compare and Swap,比较并交换)操作等方式去修改对象头中的锁状态标记位,将其设置为锁定状态。如果获取成功,该线程就成为了这个 Monitor 的所有者,其他线程再想获取这个对象的锁时,就会被阻塞进入 BLOCKED 状态,等待锁释放。
如果是修饰静态方法,锁对象则是该类的 Class 对象,因为静态方法属于类级别,不属于某个具体实例。线程同样会去获取对应的 Class 对象的 Monitor 锁来实现同步。
对于修饰代码块的情况,会显示指定一个锁对象,然后线程按照上述获取锁的机制去操作这个指定的对象锁。
当线程执行完 synchronized 修饰的代码后,会释放 Monitor 锁,也就是通过修改对象头的锁状态标记位,通知其他等待的线程可以尝试获取锁了,整个过程确保了同一时刻只有一个线程能操作共享资源。
2. synchronized 是可重入锁吗?请举例说明。
synchronized 是可重入锁,这意味着一个已经获取了某个对象锁的线程,可以再次获取该对象的锁而不会被阻塞,这在实际的代码逻辑中是非常必要的,避免了自己锁死自己的情况。
例如,下面是一个简单的代码示例:
python
class ReentrantExample {
public synchronized void methodA() {
System.out.println("执行 methodA");
methodB();
}
public synchronized void methodB() {
System.out.println("执行 methodB");
}
}
public class Main {
public static void main(String[] args) {
ReentrantExample example = new ReentrantExample();
example.methodA();
}
}
在上述代码中,methodA 和 methodB 都被 synchronized 修饰,并且 methodA 中调用了 methodB。当线程开始执行 methodA 时,它首先获取了 ReentrantExample 实例对应的对象锁,然后在 methodA 内部又调用 methodB,此时由于 synchronized 是可重入锁,该线程可以再次顺利获取到同一个对象锁,继续执行 methodB 的代码,而不会出现自己等待自己释放锁导致死锁的情况。
从原理上来说,在 Java 中,每个对象锁关联着一个计数器,线程每成功获取一次锁,计数器就加 1,当线程释放锁时,计数器减 1,只有当计数器的值为 0 时,锁才真正被释放,可供其他线程获取,这种机制实现了 synchronized 的可重入性。
三、volatile 关键字
1. volatile 的作用及原理是什么?
volatile 关键字主要有两个重要作用:一是保证变量的可见性,二是禁止指令重排序。
**保证可见性原理:**在多线程环境下,每个线程都有自己的工作内存(是对主内存的一份拷贝),线程对变量的操作通常是先在自己的工作内存中进行,然后在合适的时候再同步回主内存。当一个变量被声明为 volatile 时,它具备了一种特殊的机制,只要有线程修改了这个变量的值,会立即将修改后的值刷新到主内存中,并且会通知其他线程,让其他线程对应的工作内存中该变量的值失效,其他线程再次使用这个变量时,就需要重新从主内存中读取最新的值。例如:
python
class VolatileVisibilityExample {
private volatile boolean flag = false;
public void setFlag() {
flag = true;
}
public void doSomething() {
while (!flag) {
// 线程可能会在这里空循环等待,直到 flag 变为 true
}
System.out.println("根据 flag 的变化执行后续操作");
}
}
public class Main {
public static void main(String[] args) {
VolatileVisibilityExample example = new VolatileVisibilityExample();
Thread thread1 = new Thread(() -> {
example.doSomething();
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
example.setFlag();
});
thread1.start();
thread2.start();
}
}
在上述代码中,flag 变量被声明为 volatile,线程 thread1 在 doSomething 方法中会不断检查 flag 的值,线程 thread2 在一秒后会修改 flag 的值。由于 volatile 的可见性保证,thread2 修改 flag 后,thread1 能立即感知到这个变化,从而跳出循环执行后续操作。
**禁止指令重排序原理:**在现代计算机体系结构中,为了提高执行效率,编译器和处理器会对指令进行重排序操作,但这种重排序在多线程环境下可能会导致一些逻辑错误。volatile 通过在变量的读写操作前后添加内存屏障(Memory Barrier)来禁止指令重排序。内存屏障就像是一堵墙,它确保了在屏障一侧的指令执行完成后,另一侧的指令才能开始执行,从而保证了特定的执行顺序,维护了多线程环境下代码逻辑的正确性。
2. volatile 能替代 synchronized 吗?
volatile 不能完全替代 synchronized。虽然 volatile 可以保证变量的可见性和一定程度上避免指令重排序,但它无法像 synchronized 那样实现对代码块或者方法的同步,也就是不能保证原子性操作。
例如,对于一个简单的自增操作 count++(count 是一个普通的整型变量),这个操作在字节码层面实际上包含了读取 count 的值、进行加 1 运算、再将结果写回 count 这三个步骤,在多线程环境下,如果多个线程同时对这个变量进行 count++ 操作,由于 volatile 只保证了可见性和禁止指令重排序,并不能保证这三个步骤作为一个整体的原子性执行,可能会出现数据不一致的情况,而使用 synchronized 修饰包含这个操作的代码块就能确保同一时刻只有一个线程能执行这个自增操作,保证数据的准确性。
所以,volatile 和 synchronized 有着不同的适用场景,需要根据具体的业务逻辑和并发需求来合理选择使用。
四、线程间通信
1. 线程间有哪些常见的通信方式?并简述其原理。
线程间常见的通信方式有以下几种:
Object 类的 wait()、notify() 和 notifyAll() 方法:
原理:基于对象的 Monitor 机制实现。当一个线程调用了某个对象的 wait() 方法时,它会释放当前持有的该对象的 Monitor 锁,并进入等待状态(WAITING 或 TIMED_WAITING,取决于是否带时间参数),直到其他线程调用了这个对象的 notify() 或者 notifyAll() 方法来唤醒它。notify() 方法会随机唤醒一个正在等待该对象锁的线程,而 notifyAll() 则会唤醒所有等待该对象锁的线程,被唤醒的线程需要重新竞争获取对象的锁,获取成功后才能继续执行后续代码。例如,以下是一个简单的生产者 - 消费者模型示例:
java
class MessageQueue {
private List<String> queue = new ArrayList<>();
private final int MAX_SIZE = 10;
public synchronized void put(String message) {
while (queue.size() >= MAX_SIZE) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(message);
notify();
}
public synchronized String take() {
while (queue.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String message = queue.remove(0);
notify();
return message;
}
}
class Producer implements Runnable {
private MessageQueue queue;
public Producer(MessageQueue queue) {
this.queue = queue;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
String message = "消息 " + i;
queue.put(message);
System.out.println("生产者生产: " + message);
}
}
}
class Consumer implements Runnable {
private MessageQueue queue;
public Consumer(MessageQueue queue) {
this.queue = queue;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
String message = queue.take();
System.out.println("消费者消费: " + message);
}
}
}
public class Main {
public static void main(String[] args) {
MessageQueue queue = new MessageQueue();
Thread producerThread = new Thread(new Producer(queue));
Thread consumerThread = new Thread(new Consumer(queue));
producerThread.start();
consumerThread.start();
}
}
在上述代码中,MessageQueue 作为共享资源,生产者线程通过 put 方法向队列中添加消息,当队列已满时,生产者调用 wait() 等待消费者消费消息腾出空间;消费者线程通过 take 方法从队列中获取消息,当队列为空时,消费者调用 wait() 等待生产者生产消息。每次添加或取出消息后,都会调用 notify() (这里其实也可以用 notifyAll())来唤醒可能正在等待的对方线程,实现了生产者和消费者之间的通信。
Lock 和 Condition 接口:
原理:Lock 接口提供了比 synchronized 更灵活的锁机制,而 Condition 接口则是配合 Lock 实现线程间的等待 - 唤醒机制,相当于对 Object 类的 wait()、notify() 等功能的一种替代和扩展。通过 Lock 实例获取对应的 Condition 对象,不同的 Condition 对象可以用来实现更精细的线程分组等待和唤醒逻辑。例如:
java
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BoundedBuffer {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final int[] buffer = new int[10];
private int count = 0;
private int putIndex = 0;
private int takeIndex = 0;
public void put(int value) {
lock.lock();
try {
while (count == buffer.length) {
notFull.await();
}
buffer[putIndex] = value;
putIndex = (putIndex + 1) % buffer.length;
count++;
notEmpty.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public int take() {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
int value = buffer[takeIndex];
takeIndex = (takeIndex + 1) % buffer.length;
count--;
notFull.signal();
return value;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return -1;
}
}
class Producer2 implements Runnable {
private BoundedBuffer buffer;
public Producer2(BoundedBuffer buffer) {
this.buffer = buffer;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
buffer.put(i);
System.out.println("生产者生产: " + i);
}
}
}
class Consumer2 implements Runnable {
private BoundedBuffer buffer;
public Consumer2(BoundedBuffer buffer) {
this.buffer = buffer;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
int value = buffer.take();
System.out.println("消费者消费: " + value);
}
}
}
public class Main {
public static void main(String[] args) {
BoundedBuffer buffer = new BoundedBuffer