模式切换
并发编程篇
说说进程和线程的区别
进程是操作系统分配资源的基本单位,每个进程都有独立的内存空间、文件描述等资源。操作系统通常使用进程来表示独立的应用程序实例,进程之间相互独立,互不干扰。例如计算机上的浏览器、音乐播放器等进程。
线程是进程的一个执行单元,一个进程可以包含多个线程,多个线程可以协同工作,执行不同的任务,共享进程的资源。例如文字处理软件可能包含一个主线程,用于处理用户界面响应,以及一个后台线程,用于自动保存文件。
什么情况下线程会进入 WAITING 状态?
线程会在以下几种情况下进入 WAITING 状态:
- 调用
Object.wait()
方法:线程调用某个对象的wait()
方法,并等待其他线程调用该对象的notify()
或notifyAll()
方法来唤醒它。 - 调用
Thread.join()
方法:线程调用另一个线程的join()
方法,并等待该线程终止。 - 调用
LockSupport.park()
方法:线程调用LockSupport.park()
方法,并等待其他线程调用LockSupport.unpark(Thread)
方法来唤醒它。
阻塞与非阻塞
在 Java 中,阻塞(Blocking)与非阻塞(Non-Blocking)是描述 I/O 操作或线程间通信时,操作等待资源或条件成立时行为的两种不同模式。
- 阻塞(Blocking)
当一个线程执行到某个 I/O 操作时,如果该操作因为某些原因(如等待数据到达、磁盘 I/O、网络 I/O 等)暂时无法完成,那么这个线程就会阻塞,即线程会暂停执行,等待 I/O 操作完成后再继续执行。
特点:
- 线程在等待期间不占用 CPU 资源,处于休眠状态。
- 线程恢复执行需要等待外部事件(如数据到达)。
- 可能会导致线程切换,增加系统开销。
例如,传统的文件读写操作、网络套接字读写等。在 Java 中,如果不使用非阻塞模式或 NIO(New I/O),那么这些操作都是阻塞的。
- 非阻塞(Non-Blocking)
与阻塞相反,非阻塞 I/O 操作不会让线程等待,如果某个操作暂时不能完成,它会立即返回一个特定的值(如 null、错误码或特殊的对象)来表示操作尚未完成,线程可以继续执行后续的操作,而不是等待 I/O 操作完成。
特点:
- 线程在等待 I/O 操作完成期间可以继续执行其他任务。
- 需要线程轮询或事件通知机制来检查操作是否完成。
- 提高系统的响应性和吞吐量,但可能会增加编程的复杂性。
例如,Java NIO(New Input/Output)提供了非阻塞 I/O 的实现,包括 Selector、Channel、Buffer 等类,允许一个线程管理多个输入输出通道,通过轮询检查通道是否就绪,从而避免了线程的阻塞。
总结
阻塞和非阻塞 I/O 的主要区别在于线程在等待 I/O 操作完成时的行为。阻塞 I/O 会让线程暂停执行,直到 I/O 操作完成; 而非阻塞 I/O 则允许线程继续执行其他任务,直到 I/O 操作真正完成(这通常需要通过轮询或事件通知机制来实现)。
如何实现线程同步
线程同步是指多个线程在访问共享资源时,为了保证共享资源在同一时刻只能被一个线程访问,以避免出现数据不一致或竞争的情况。
在 Java 中常见的线程同步方式有:
- synchronized 关键字
通过在方法或代码块前加上 synchronized
关键字,可以保证同一时刻只有一个线程可以访问该方法或代码块。避免了多个线程同时访问共享资源的情况。
- ReentrantLock
ReentrantLock
是可重入锁,通过调用 lock()
方法获取锁,调用 unlock()
方法释放锁。与 synchronized
关键字相比,ReentrantLock
提供了更多、更加灵活的功能,如可中断锁、超时锁、公平锁等。
- 使用 wait()、notify()、notifyAll() 实现线程间通信
通过调用 wait()
方法使线程进入等待状态,调用 notify()
或 notifyAll()
方法唤醒等待的线程。这三个方法必须在同步代码块中调用,且调用对象必须是同步锁。
- 使用 CountDownLatch、CyclicBarrier、Semaphore 等工具类
它们都是并发工具类,用于线程之间的同步和等待。CountDownLatch 用于等待其他线程执行完毕后再执行,CyclicBarrier 用于等待所有线程都到达某个状态后再执行,Semaphore 用于控制同时访问共享资源的线程个数。
Java 中创建线程的方式有哪些?
- 继承 Thread 类
创建一个继承自 Thread 的子类,重写 run() 方法,将线程的任务逻辑放到 run() 方法中,然后通过创建子类的实例并调用 start() 方法来启动线程。
java
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread is running...");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
- 重写 Runnable 接口
创建一个实现 Runnable 接口的类,实现 run() 方法,然后通过创建该类的实例并将其作为参数传递给 Thread 类的构造方法来创建线程。
java
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("MyRunnable is running...");
}
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
}
- 使用 Callable 和 FutureTask
Callable 接口是类似于 Runnable 的接口,但它可以返回线程执行的结果,并且可以抛出异常。FutureTask 是 Future 接口的实现类,用于获取 Callable 的返回结果。
java
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "MyCallable is running...";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable callable = new MyCallable();
FutureTask<String> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
- 使用匿名内部类
可以通过创建一个继承自 Thread 或实现 Runnable 接口的匿名内部类来创建线程。
java
public class Main {
public static void main(String[] args) {
// 创建一个继承自 Thread 的匿名内部类
Thread thread1 = new Thread() {
@Override
public void run() {
System.out.println("Thread1 is running...");
}
};
thread1.start();
// 创建一个实现 Runnable 接口的匿名内部类
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Runnable is running...");
}
};
Thread thread2 = new Thread(runnable);
thread2.start();
}
}
- 使用 Lambda 表达式
可以使用 Lambda 表达式来简化创建线程的过程,只需要传入一个 Runnable 对象即可。
java
public class Main {
public static void main(String[] args) {
// 使用 Lambda 表达式创建线程
Thread thread = new Thread(() -> {
System.out.println("Lambda is running...");
});
thread.start();
}
}
- 使用线程池
通过线程池来管理线程的创建和销毁,可以避免频繁创建和销毁线程带来的性能开销,提高程序的性能和可维护性。
java
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
// 提交任务到线程池
pool.submit(() -> {
System.out.println("ThreadPool is running...");
});
// 关闭线程池
pool.shutdown();
}
}
注意事项
推荐使用实现 Runnable 接口或 Lambda 的方式来创建线程,因为它们更加灵活,避免 Java 单继承的限制,符合面向对象设计原则。
启动线程为什么调用的是 start() 方法而不是 run() 方法?
start()
方法:
start()
方法是Thread
类中的一个方法,用于启动一个新线程。调用start()
方法后,JVM 会创建一个新的线程,并调用该线程的run()
方法。start()
方法会使线程进入就绪状态,等待 CPU 调度执行。
run()
方法:
run()
方法是Runnable
接口中的一个方法,包含了线程执行的代码。- 直接调用
run()
方法不会启动一个新线程,而是在当前线程中执行run()
方法中的代码。
示例:
java
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // Starts a new thread and calls the run() method
// thread.run(); // Calls the run() method in the current thread
}
}
Runnable 和 Callable 有什么区别?
Callable 和 Runnable 都是 Java 中用于创建线程的接口,它们之间的主要区别在于返回值和异常处理。
- 返回值
- Runnable 接口中的 run() 方法没有返回值,因此无法返回执行结果。
- Callable 接口中的 call() 方法可以返回执行结果,且可以抛出异常,可以通过 Future 对象获取。
- 异常处理
- Runnable 接口中的 run() 方法无法抛出已检查异常,只能捕获异常并处理。
- Callable 接口中的 call() 方法可以抛出已检查异常,可以通过 Future 对象获取异常信息。
- 范型
- Runnable 接口没有使用范型,无法指定返回值类型。
- Callable 接口是一个泛型接口,可以指定 call() 方法的返回值类型。
- 线程池支持
- Runnable 接口可以与 Executor 框架一起使用,但是 Callable 接口提供了更丰富的功能,如取消任务、获取执行结果等。
- Callable 接口通常与 Executor 框架一起使用,可以提交给 ExecutorService 来执行,并通过 Future 对象获取执行结果。
有三个线程 T1、T2 和 T3,如何确保它们按顺序执行?
- 使用 join() 方法
在 Java 中,可以通过线程的 join() 方法来实现线程的顺序执行。join() 方法会让当前线程等待调用 join() 方法的线程执行完毕后再继续执行。
使用 join() 方法可以确保线程的顺序执行,但需要注意避免死锁的情况,即线程之间相互等待对方执行完毕,导致所有线程都无法继续执行。
例如下面的代码所示,线程 T1、T2 和 T3 依次启动,T2 和 T3 分别调用了 T1 和 T2 的 join() 方法,确保了它们的执行顺序。
java
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("T1 is running...");
});
Thread t2 = new Thread(() -> {
try {
t1.join(); // 等待 t1 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is running...");
});
Thread t3 = new Thread(() -> {
try {
t2.join(); // 等待 t2 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is running...");
});
t1.start();
t2.start();
t3.start();
}
}
- 使用 CountDownLatch
CountDownLatch 是 Java 中的一个多线程协作工具,它可以让多个线程在一个屏障点等待,并在所有线程都到达后一起继续执行。
通过 CountDownLatch 可以实现线程的顺序执行,如下所示,线程 T1、T2 和 T3 依次启动,T2 和 T3 分别调用了 T1 和 T2 的 countDown() 方法,确保了它们的执行顺序。
java
public class Main {
public static void main(String[] args) {
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
System.out.println("T1 is running...");
latch1.countDown();
});
Thread t2 = new Thread(() -> {
try {
latch1.await(); // 等待 t1 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T2 is running...");
latch2.countDown();
});
Thread t3 = new Thread(() -> {
try {
latch2.await(); // 等待 t2 执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("T3 is running...");
});
t1.start();
t2.start();
t3.start();
}
}
- 使用 LockSupport
LockSupport 是 Java 中用于线程同步的工具类,它可以让线程在任意位置阻塞和唤醒。通过 LockSupport 可以实现线程的顺序执行,如下所示,线程 T1、T2 和 T3 依次启动,T2 和 T3 分别调用了 T1 和 T2 的 park() 方法,确保了它们的执行顺序。
java
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("T1 is running...");
});
Thread t2 = new Thread(() -> {
LockSupport.park(); // 阻塞 t2
System.out.println("T2 is running...");
LockSupport.unpark(t1); // 唤醒 t1
});
Thread t3 = new Thread(() -> {
LockSupport.park(); // 阻塞 t3
System.out.println("T3 is running...");
LockSupport.unpark(t2); // 唤醒 t2
});
t1.start();
t2.start();
t3.start();
LockSupport.unpark(t3); // 唤醒 t3
}
}
三种方式都可以实现线程的顺序执行,但使用 join() 方法更加简单直观,CountDownLatch 和 LockSupport 则更加灵活,适用于更复杂的线程协作场景。
如何确保线程安全?
- 使用 synchronized 关键字:synchronized 关键字可以确保同一时刻只有一个线程可以执行某个代码块,从而避免了多线程同时访问和修改共享资源的问题。
- 使用 Atomic 类:Java 提供了多个原子操作类,例如 AtomicInteger、AtomicLong、AtomicReference 等,它们可以保证线程安全地执行读取和写入操作。
- 使用 ReentrantLock 类:ReentrantLock 是可重入锁,通过 lock() 和 unlock() 方法可以控制线程对共享资源的访问,避免了多线程并发访问的问题。
- 使用线程安全的数据结构:Java 提供了多个线程安全的数据结构,如 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 等,它们可以保证多线程并发访问时的线程安全性。
- 使用线程池:通过线程池来管理线程的创建和销毁,可以避免频繁创建和销毁线程带来的性能开销,提高程序的性能和可维护性。
- 避免使用共享变量:尽量避免多个线程访问和修改共享变量,可以通过将共享变量设置为 final 或使用局部变量等方式来避免线程安全问题。
- 使用线程安全的设计模式:如单例模式、工厂模式、享元模式等,可以避免多线程并发访问时的线程安全问题。
如何停止一个正在运行的线程?
在 Java 中,线程的停止涉及到线程安全和资源释放等问题,停止一个正在运行的线程通常有以下几种方式:
- 使用标志位
通过设置一个标志位来控制线程的执行,当标志位为 true 时,线程继续执行;当标志位为 false 时,线程终止执行。这是一种比较安全可控的方式。
java
public class MyThread extends Thread {
private volatile boolean flag = true;
@Override
public void run() {
while (flag) {
// 线程执行的任务逻辑
}
}
public void stopThread() {
flag = false;
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
// 停止线程
thread.stopThread();
}
}
- 使用 interrupt() 方法
通过调用线程的 interrupt() 方法来中断线程的执行,使用 Thread.currentThread().isInterrupted()
检查线程是否被中断。当线程处于阻塞状态时,会抛出 InterruptedException 异常,从而提前结束线程的执行。
java
public class MyThread extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 线程执行的任务逻辑
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
// 中断线程
thread.interrupt();
}
}
- 使用 stop() 方法
stop()
方法是 Thread 类提供的一个用于停止线程的方法,但它已经被废弃,不推荐使用。因为 stop() 方法会立即终止线程,可能导致线程的资源没有得到释放,出现数据不一致或死锁等问题。
java
public class MyThread extends Thread {
@Override
public void run() {
while (true) {
// 线程执行的任务逻辑
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
// 停止线程
thread.stop();
}
}
注意事项
推荐使用设置标志位或 interrupt() 方法来停止线程,避免使用 stop() 方法,因为 stop() 方法可能会导致线程资源没有得到释放,出现数据不一致或死锁等问题。
如何优雅地停止一个线程?
Thread 类中有两个方法:
- start() 方法:开启一个线程。
- stop() 方法:停止一个线程。
但是 stop() 方法太粗暴已经被废弃,一旦调用了 stop() 方法就会直接停掉线程,这样可能会导致一些问题,比如任务执行到了哪一步?线程没有释放锁?导致死锁等问题。
stop() 会释放线程占用的 synchronized 锁,但是不会自动释放 ReentrantLock 锁。
推荐使用中断来停止线程。例如下方的示例代码,我们可以控制变量 i 只有在大于 500000 时才会停止,不然就算中断了也不会停止。
java
public class MyThreadInterrupted {
public static void main(String[] args) throws InterruptedException {
Thread myThread = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
if (Thread.currentThread().isInterrupted() && i > 500000) {
break;
}
System.out.println("i = " + i);
}
});
myThread.start();
Thread.sleep(1000);
myThread.interrupt();
}
}
另外线程池中,也使用过中断 interrupt() 方法来停止线程,例如 shutdownNow() 方法中就是通过 interrupt() 方法来停止线程的。
java
void interruptIfStarted() {
Thread t;
if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
}
}
}
notify() 和 notifyAll() 有什么区别?
notify()
和 notifyAll()
都是实现线程间通信,用于唤醒等待的线程,它们的主要区别在于:
- notify()
notify()
用于唤醒在当前对象上等待的单个线程。如果有多个线程同时在某个对象上等待(通过调用该对象的 wait() 方法),则只会唤醒其中的一个线程,并使其从等待状态变为可运行状态。具体是哪个线程被唤醒是不确定的,取决于线程调度器的实现。
- notifyAll()
notifyAll()
用于唤醒在当前对象上等待的所有线程。如果有多个线程同时在某个对象上等待,调用 notifyAll()
方法后,所有等待的线程都会被唤醒并竞争该对象的锁。其中一个线程获得锁后继续执行,其他线程则继续等待。
注意事项
notify() 和 notifyAll() 只能在同步代码块或同步方法内部调用,并且必须拥有与该对象关联的锁。否则会抛出 IllegalMonitorStateException
异常。
为什么 wait() 和 notify() 方法要在同步块中调用?
将 wait() 和 notify() 方法放在同步块中调用,有助于确保线程间的协同和同步工作正确,避免多线程安全问题,提高程序的可靠性和安全性。
- 互斥性:多线程环境下,我们希望在同一时刻z还有一个线程能够执行 wait()、notify() 或 notifyAll() 方法。使用同步块(synchronized)提供了这种互斥性,避免多线程并发修改问题。
- 上下文切换:当一个线程调用 wait() 时,它会暂时放弃执行权并释放对象锁。如果不在同步块内调用 wait(),线程可能在不合适的时机被唤醒,导致混乱。同步块内的 wait() 确保线程在正确的上下文中被唤醒,可以继续执行并获取锁。
- 安全性:如果不在同步块内使用 wait()、notify() 或 notifyAll(),多个线程可能同时访问和修改同一个共享对象的状态,可能引发竟态条件,导致程序行为不确定。同步块可以确保对这些方法的访问是原子性的,避免了潜在的并发问题。
Java 线程之间是如何通信的?
Java 线程之间的通信可以通过以下几种方式实现:
- 使用
wait()
、notify()
和notifyAll()
方法:
这些方法是 Object
类的一部分,用于线程间的通信和协作。它们必须在同步代码块中调用。
java
public class WaitNotifyExample {
private static final Object lock = new Object();
private static boolean condition = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
while (!condition) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Condition met, thread t1 proceeding");
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
condition = true;
lock.notify();
System.out.println("Condition set, thread t2 notified");
}
});
t1.start();
t2.start();
}
}
- 使用
volatile
关键字:
volatile
关键字可以保证变量的可见性,确保一个线程对变量的修改对其他线程可见。
java
public class VolatileExample {
private static volatile boolean condition = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (!condition) {
// Busy-wait
}
System.out.println("Condition met, thread t1 proceeding");
});
Thread t2 = new Thread(() -> {
condition = true;
System.out.println("Condition set, thread t2 proceeding");
});
t1.start();
t2.start();
}
}
- 使用
BlockingQueue
:
BlockingQueue
是线程安全的队列,适用于生产者-消费者模式。
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueExample {
private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
public static void main(String[] args) {
Thread producer = new Thread(() -> {
try {
queue.put(1);
System.out.println("Produced 1");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
Integer value = queue.take();
System.out.println("Consumed " + value);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
}
}
- 使用
CountDownLatch
:
CountDownLatch
可以使一个或多个线程等待其他线程完成操作。
java
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
private static final CountDownLatch latch = new CountDownLatch(1);
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
try {
latch.await();
System.out.println("Latch released, thread t1 proceeding");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread t2 = new Thread(() -> {
latch.countDown();
System.out.println("Latch counted down, thread t2 proceeding");
});
t1.start();
t2.start();
}
}
Java 中使用的线程调度算法是什么?
Java 中使用的线程调度算法是时间片轮转调度算法(Time-Slicing Scheduling Algorithm)。这是由操作系统的线程调度器决定的,Java 本身并不直接控制线程调度。
在时间片轮转调度算法中,每个线程被分配一个时间片(时间段),在这个时间片内,线程可以执行其任务。当时间片用完时,调度器会将线程切换出去,并将 CPU 分配给下一个线程。这种方式确保了所有线程都有机会获得 CPU 时间,从而实现多线程并发执行。
需要注意的是,具体的线程调度行为可能会因操作系统的不同而有所差异。Java 线程调度依赖于底层操作系统的实现,因此在不同的操作系统上,线程调度的具体行为可能会有所不同。
什么是守护线程?与普通线程有什么区别?
守护线程是程序运行时在后台提供的一种支持性的线程。通过设置线程的 setDaemon(true)
方法将线程设置为守护线程。
与普通线程相比,守护线程有以下区别:
- 终止条件
当所有用户线程结束时,守护线程会自动停止。因此,守护线程通常用于执行支持性任务,如垃圾回收、内存管理等。
- 生命周期
守护线程的生命周期与主线程或其他用户线程无关。当所有非守护线程都结束时,JVM 会退出并停止守护线程的执行。
- 线程优先级
守护线程的优先级默认与普通线程一样。优先级较高的守护线程也不能够保证一定在其他线程之前执行。
- 资源回收
守护线程通常被用于执行一些后台任务,例如垃圾回收、日志记录、定时任务等。当只剩下守护线程时,JVM 会自动退出并不会等待守护线程执行完毕。
谈谈你对 ThreadLocal 的理解
ThreadLocal 是 Java 中的一个类,用于在多线程环境下实现线程局部变量存储。它提供了一种让每个线程都拥有独立变量副本的机制,从而避免多线程之间相互干扰和竞争的问题。
在多线程编程中,共享变量的访问往往需要考虑线程安全和数据隔离问题。ThreadLocal 通过为每个线程创建独立的副本变量来解决这些问题。每个线程可以独立地对自己的变量副本进行操作,而不会影响其他线程的副本。
ThreadLocal 的底层是通过 ThreadLocalMap 来实现的,每个 Thread 对象(注意不是 ThreadLocal 对象)中都存在一个 ThreadLocalMap,Map 的 Key 为 ThreadLocal 对象,Map 的 Value 为需要缓存的值。
如果在线程池中使用 ThreadLocal 会造成内存泄漏,因为当 ThreadLocal 对象使用完成后,应该要将设置的 Key 和 Value(即 Entry 对象)进行回收。但是线程池中的线程不会回收,而线程对象是通过强引用指向 ThreadLocalMap,并且 ThreadLocalMap 也是通过强引用指向 Entry 对象。 如果线程不被回收,则 Entry 对象也不会被回收,这样就会造成内存泄漏。解决办法是,在使用完 ThreadLocal 后,手动调用 remove() 方法清理与当前线程相关的变量副本。
使用 ThreadLocal 时需要注意以下几点:
- 内存泄漏:在使用完 ThreadLocal 后,应及时调用 remove() 方法清理与当前线程相关的变量副本,避免长时间持有引用导致内存泄漏。
- 线程安全性:ThreadLocal 本身并不解决多线程并发访问共享变量的问题,需要额外的同步机制来保证线程安全性。
- 数据隔离:ThreadLocal 适用于多线程环境下需要保持独立变量的场景,如数据库连接、Session 管理等,可以避免使用传统的同步方式对共享变量进行操作,提高并发性能。
ThreadLocal 常见的应用场景包括线程池、Web 开发中的请求上下文信息管理、数据库连接管理和日志记录等。通过合理地使用 ThreadLocal,可以简化多线程编程,并提高程序的性能和可维护性。
ThreadLocal 有哪些使用场景?
ThreadLocal 是 Java 中一个用于实现线程局部变量的工具,它提供了每个线程独立的变量副本,使得不同线程对该变量的操作不会相互干扰。
- 线程间的数据隔离
在多线程环境中,每个线程可能需要使用自己的独立数据副本,以避免数据共享带来的线程安全问题。ThreadLocal 可以为每个线程提供独立的变量副本,确保线程间数据的隔离性。
例如,在 Web 应用中,使用 ThreadLocal 保存每个线程的 Session 信息,使得在处理 HTTP 请求的过程中,在任何地方都能够方便地访问到当前用户的 Session,而不需要通过参数传递。
- 线程安全的对象共享
在多线程环境中,一些对象(如 SimpleDateFormat、数据库连接等)如果被多个线程共享,可能会引发线程安全问题。通过使用 ThreadLocal,每个线程都有一个独立的对象实例,从而避免了线程安全问题。
例如,SimpleDateFormat 类不是线程安全的,如果多个线程共享同一个 SimpleDateFormat 实例进行日期格式,可能会导致结果错乱。使用 ThreadLocal 为每个线程提供独立的 SimpleDateFormat 实例,可以避免这个问题。
- 跨线程的上下文传递
在跨线程调用的场景中,可能需要传递一些上下文信息(如用户信息、请求 ID 等),但又不希望修改方法签名或增加参数。这时,可以使用 ThreadLocal 来存储这些信息,以便在需要时能够方便地获取。
例如,在一些复杂的业务场景中,可能需要将用户信息、事务信息等跨多个方法进行传递。通过 ThreadLocal 可以将这些信息绑定到线程上,从而避免在方法间传递参数。
- 事务管理
在需要手动管理事务的场景中,可以使用 ThreadLocal 来存储事务的上下文信息(如事务状态、连接对象等),确保在整个事务处理的过程中,所有数据库操作都在同一个事务中执行,而不会被其他线程干扰。
例如,Spring 的事务管理器通过 AOP 切入业务代码,在进入业务代码前,会根据相应的事务管理器提取出相应的事务对象,并将其保存在 ThreadLocal 中,以便在后续的操作中能够方便的获取和使用。
- 复杂场景中的参数传递优化
在复杂的应用程序中,如果多个方法间需要传递相同的参数,而这些参数又比较复杂或频繁使用,可以通过 ThreadLocal 来避免在多个方法间传递相同的参数,从而简化代码结构。
例如,在日志处理、国际化设置等场景中,可以讲一些公共的配置信息放到 ThreadLocal 中,以便在整个线程的执行过程中都能够方便地访问到这些信息。
注意事项
- ThreadLocal 使用不当可能会导致内存泄漏,尤其是在使用线程池时,线程可能被重用而未清理 ThreadLocal 中的数据。因此,在使用完 ThreadLocal 后,建议通过 remove() 方法显式清除数据。
- TransmittableThreadLocal 是 ThreadLocal 的增强版,它解决了在使用线程池等场景下 ThreadLocal 变量值不能传递的问题,是处理多线程应用中上下文传递的理想选择。
ThreadLocal 如何防止内存泄漏?
ThreadLocal 可能会导致内存泄漏的原因是,ThreadLocal 的底层数据结构是 ThreadLocalMap。ThreadLocalMap 中的 Entry 对象的 key 是弱引用,而 value 是强引用。 如果 ThreadLocal 没有被外部强引用,且发生了垃圾回收,那么 key 会被回收,但 value 不会被回收,这样就会导致 key 为 null,而 value 不为 null 的情况(即 ThreadLocalMap 中的 Entry 没有被及时清理导致的)。
为了避免 ThreadLocal 内存泄漏,可以通过以下几种方式来解决:
- 使用完 ThreadLocal 后,调用 remove() 方法手动清理数据
在使用完 ThreadLocal 后,应该调用 remove() 方法手动清理数据,确保 ThreadLocalMap 不会持有对对象的引用,避免数据长时间存放在 ThreadLocal 中而无法被回收,从而帮助垃圾回收器正常回收不再需要的对象。
java
public class MyThreadLocal {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void set(String value) {
threadLocal.set(value);
}
public static String get() {
return threadLocal.get();
}
public static void remove() {
threadLocal.remove();
}
public static void main(String[] args) {
MyThreadLocal.set("value");
System.out.println(MyThreadLocal.get());
MyThreadLocal.remove();
}
}
- 使用 try-with-resources 语法或 try-finally 语句
在使用 ThreadLocal 时,可以使用 try-with-resources 语法或 try-finally 语句来确保在使用完 ThreadLocal 后能够及时清理数据,避免内存泄漏。
java
public class MyThreadLocal {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void set(String value) {
threadLocal.set(value);
}
public static String get() {
return threadLocal.get();
}
public static void main(String[] args) {
try {
MyThreadLocal.set("value");
System.out.println(MyThreadLocal.get());
} finally {
MyThreadLocal.remove();
}
}
}
- 使用 InheritableThreadLocal
InheritableThreadLocal 是 ThreadLocal 的一个子类,它可以在子线程中获取父线程的 ThreadLocal 变量。InheritableThreadLocal 适用于需要在子线程中获取父线程的 ThreadLocal 变量的场景。
java
public class MyInheritableThreadLocal {
private static ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();
public static void set(String value) {
threadLocal.set(value);
}
public static String get() {
return threadLocal.get();
}
public static void main(String[] args) {
MyInheritableThreadLocal.set("value");
System.out.println(MyInheritableThreadLocal.get());
}
}
谈谈你对 CountDownLatch 的理解
CountDownLatch 是 Java 中用于多线程协作的辅助类,它可以让一个或多个线程等发你其他线程完成某个任务后再继续执行。
CountDownLatch 通过一个计数器来实现,计数器的初始值可以设置为等待的线程数量。每个线程在完成任务后都会调用 countDown() 方法来减少计数器的值。当计数器的值减至 0 时,等待在 CountDownLatch 上的线程就会被唤醒,可以继续执行后续的操作。
CountDownLatch 的主要作用是协调多个线程的执行顺序,使得某个或多个线程必须等待其他线程完成后才能继续执行。它常用于以下场景:
- 主线程等待多个子线程完成任务:主线程可以使用 wait() 方法等待多个子线程完成任务后再继续执行。
- 多个线程等待外部事件的发生:多个线程可以同时等待某个共同的事件发生,比如等待某个资源准备就绪或等待某个信号的触发。
- 控制并发任务的执行:在某些并发场景中,需要等待所有线程都准备就绪后才能同时开始执行任务,CountDownLatch 提供了一种便携的方式来实现这一需求。
需要注意的是,CountDownLatch 的计数器是不能被重置的,也就是说它是一次性的。一旦计数器减至 0,它将无法再次使用。如果需要多次使用可重置的计数器,可以考虑 CyclicBarrier 或 Semaphore 等其他并发工具类。
谈谈你对 CyclicBarrier 的理解
CyclicBarrier 是 Java 中的一个多线程协作工具,它可以让多个线程在一个屏障点等待,并在所有线程都到达后一起继续执行。与 CountDownLatch 不同,CyclicBarrier 可以重复使用,并且可以指定屏障点后执行的额外动作。
CyclicBarrier 的主要特点有:
- 可重复使用,这意味着当所有线程都到达屏障点后,屏障点会自动重置,可以用来处理多次等待的任务。
- 可以协调多个线程同时开始执行,这在分阶段任务和并发游戏等场景中非常有用。
- 提供可选的动作,在所有线程到达屏障点时执行,可以实现额外的逻辑。
需要注意的是,在创建 CyclicBarrier 时需要指定参与线程的数量,一旦所有参与线程都到达屏障点,CyclicBarrier 就会自动释放所有线程,解除阻塞,并执行指定的动作,所有线程可以继续执行后续的操作。
线程池中的核心线程数量大小怎么设置?
- CPU 密集型任务:例如加解密、压缩、计算等一系列需要大量消耗 CPU 资源的任务,大部分场景下都是纯 CPU 计算。尽量使用较小的线程池,一般为
CPU 核心数 + 1
。因为 CPU 密集型任务使得 CPU 使用率很高,如果开辟过多的线程,会造成 CPU 过度切换。 - I/O 密集型任务:例如 MySQL 数据库的操作、文件的读写、网络通信等任务,这类任务不会特别消耗 CPU 资源,但是 I/O 操作比较耗时,会占用比较多的时间。可以使用稍大的线程池,一般为
2 * CPU 核心数
。I/O 密集型任务 CPU 使用率不高,因此可以让 CPU 在等待 I/O 的时候有其他的线程去处理别的任务,充分利用 CPU 时间。
另外,线程的平均工作时间所占比例越高,就需要越少的线程;线程的平均等待时间所占比例越高,就需要越多的线程。
线程池中提交一个任务的流程是怎样的?
在使用 execute() 方法提交一个 Runnable 对象时,会先判断当前线程池中的线程数是否小于 corePoolSize。
如果小于,则创建新线程并执行 Runnable。
如果大于等于,则尝试将 Runnable 加入到 workQueue 中。
如果 workQueue 没满,则将 Runnable 加入到 workQueue 中,等待执行。
如果 workQueue 满了,则会入队失败,那么将会尝试继续增加线程。
如果当前的线程池中的线程数小于 maximumPoolSize,则创建新线程并执行 Runnable 任务。
如果大于等于,则执行拒绝策略,拒绝此 Runnable。
注意事项
- 提交一个 Runnable 时,不管当前线程池中的线程是否空闲,只要数量小于核心线程数就会创建新的线程。
- ThreadPoolExecutor 相当于是非公平的,比如队列满了之后提交 Runnable 可能会比正在排队的 Runnable 先执行。
线程池有几种状态?分别是如何变化的?
线程池有五种状态,分别为:
- RUNNING:能够接收新任务,并处理等待队列中的任务。
- SHUTDOWN:不再接收新任务,但能处理等待队列中的任务,处理完后会中断所有线程。
- STOP:不再接收新任务,不处理等待队列中的任务,直接中断正在执行的任务。
- TIDYING:所有任务都已终止,工作线程数量为 0,线程转换为 TIDYING 状态并将执行 terminated() 钩子方法。
- TERMINATED:terminated() 方法执行完毕后,线程池进入 TERMINATED 状态。
这五种状态不能任意转换,只有以下几种转换情况:
- RUNNING -> SHUTDOWN:手动调用 shutdown() 方法触发,或者线程池对象 GC 时会调用 finalize() 方法从而调用 shutdown()。
- RUNNING -> STOP:手动调用 shutdownNow() 方法触发。
- SHUTDOWN -> TIDYING:手动先调用 shutdown() 方法,紧接着调用 shutdownNow() 方法。
- STOP -> TIDYING:线程池中所有的线程都停之后自动触发。
- TIDYING -> TERMINATED:线程池自动调用 terminated() 方法后触发。
判断线程池任务执行完毕的方法有哪些?
判断线程池任务执行完毕的方法有以下几种:
- isTerminated() 方法,在执行 shutdown() 关闭线程池后,判断是否所有任务都已经执行完毕。
在调用 shutdown() 方法后,线程池不再接受新的任务,但会继续执行已经提交的任务。当所有任务执行完毕后,线程池才会真正关闭。如果线程池已经处于关闭状态,则调用该方法没有额外的作用。
通过 isTerminated() 方法来判断线程池是否已经关闭。只有当调用了 shutdown() 或 shutdownNow() 方法后,且所有任务都已经执行完毕时,isTerminated() 方法才会返回 true。 即在调用 shutdown() 方法前,isTerminated() 方法始终返回 false。
- 优点:简单直接,通过一个方法即可判断线程池是否已经关闭。
- 缺点:需要关闭线程池,日常使用中是将线程池注入到 Spring 容器中,然后各个组件统一用一个线程池,不方便手动关闭。
java
public class Main {
/**
* 创建一个最大线程数为15的线程池
* 参数:核心线程数10,最大线程数15,线程空闲时间0毫秒,任务队列大小10
* ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO原则
*/
public static ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 15, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10));
/**
* 线程执行方法,随机等待0-10秒
*/
private static void sleepMethod(int index) {
try {
long sleepTime = new Double(Math.random() * 10000).longValue(); // 生成一个0到10000之间的随机数
Thread.sleep(sleepTime);
System.out.println("当前线程执行结束:" + index);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
// 循环提交10个任务到线程池
for (int i = 0; i < 10; i++) {
int index = i;
pool.execute(() -> {
sleepMethod(index);
});
}
pool.shutdown(); // 关闭线程池
// 使用循环来检查线程池是否已经关闭
while (!pool.isTerminated()) {
Thread.sleep(1000);
System.out.println("线程池未关闭");
}
System.out.println("线程池已关闭");
}
}
- ThreadPoolExecutor 的 getCompletedTaskCount() 方法,获取已经完成的任务数量,通过与任务总数进行比较来判断任务是否执行完毕。
getTaskCount() 返回线程池已经接收的任务总数,包括已完成的、正在执行的和等待执行的任务。由于任务和线程的状态可能在计算过程中动态变化,但会的值是一个近似值。
getCompletedTaskCount()` 返回线程池已经完成的任务数,不包括正在执行的任务。由于任务和线程的状态可能在计算过程中动态变化,但会的值是一个近似值,并且在连续的调用中不会减少。
!(pool.getTaskCount() == pool.getCompletedTaskCount())
这个表达式的结果为真,表示线程池中还有任务未完成。如果结果为假,表示线程池中的所有任务都已完成。
- 优点:不需要关闭线程池,避免创建和销毁带来的损耗,可以实时监控任务的执行情况。
- 缺点:需要手动判断任务总数和已完成任务数是否相等,且要求在判断过程中没有新的任务产生,逻辑稍显复杂。
java
public class Main {
/**
* 创建一个最大线程数为15的线程池
* 参数:核心线程数10,最大线程数15,线程空闲时间0毫秒,任务队列大小10
* ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO原则
*/
public static ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 15, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10));
/**
* 线程执行方法,随机等待0-10秒
*/
private static void sleepMethod(int index) {
try {
long sleepTime = new Double(Math.random() * 10000).longValue(); // 生成一个0到10000之间的随机数
Thread.sleep(sleepTime);
System.out.println("当前线程执行结束:" + index);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
// 循环提交10个任务到线程池
for (int i = 0; i < 10; i++) {
int index = i;
pool.execute(() -> {
sleepMethod(index);
});
}
// 判断线程池中的任务总数是否等于已完成的任务数
while (!(pool.getTaskCount() == pool.getCompletedTaskCount())) {
System.out.println("任务总数:" + pool.getTaskCount() + ",已完成任务数:" + pool.getCompletedTaskCount());
Thread.sleep(1000);
System.out.println("线程池未关闭");
}
System.out.println("线程池已关闭");
}
}
- 使用 CountDownLatch,通过创建一个 CountDownLatch,将计数器初始化为任务数量,每个任务执行完毕后调用 countDown() 方法,主线程调用 await() 方法等待计数器归零。
- 优点:简单直接,通过一个类即可实现线程池任务执行完毕的判断,不需要对线程池进行操作。
- 缺点:需要提前知道线程数量,手动维护一个计数器,稍显繁琐,性能较差。并且需要加上异常处理,否则可能会导致主线程一直阻塞。
java
public class Main {
/**
* 创建一个最大线程数为15的线程池
* 参数:核心线程数10,最大线程数15,线程空闲时间0毫秒,任务队列大小10
* ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO原则
*/
public static ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 15, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10));
/**
* 线程执行方法,随机等待0-10秒
*/
private static void sleepMethod(int index) {
try {
long sleepTime = new Double(Math.random() * 10000).longValue(); // 生成一个0到10000之间的随机数
Thread.sleep(sleepTime);
System.out.println("当前线程执行结束:" + index);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
// 创建一个计数器,初始值为10,判断线程是否执行结束
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int index = i;
pool.execute(() -> {
sleepMethod(index);
countDownLatch.countDown();
System.out.println("当前计数器数量:" + countDownLatch.getCount());
});
}
try {
// 当前线程阻塞,等待计数器归零
countDownLatch.await();
System.out.println("线程池已关闭");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 手动维护一个计数器,每个任务执行完毕后递减,主线程等待计数器归零,原理与 CountDownLatch 类似,更加灵活。
本质就是通过加锁计数,然后循环判断。
- 优点:不需要关闭线程池,避免创建和销毁带来的损耗,可以实时监控任务的执行情况。手动维护更加灵活,对于一些特殊场景可以手动处理。
- 缺点:需要手动维护计数器,需要知道线程数量,逻辑稍显复杂,需要加上异常处理,否则可能会导致主线程一直阻塞。
java
public class Main {
/**
* 创建一个最大线程数为15的线程池
* 参数:核心线程数10,最大线程数15,线程空闲时间0毫秒,任务队列大小10
* ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO原则
*/
public static ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 15, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10));
/**
* 线程执行方法,随机等待0-10秒
*/
private static void sleepMethod(int index) {
try {
long sleepTime = new Double(Math.random() * 10000).longValue(); // 生成一个0到10000之间的随机数
Thread.sleep(sleepTime);
System.out.println("当前线程执行结束:" + index);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static int taskNum = 0; // 计数器,任务总数
public static void main(String[] args) {
Lock lock = new ReentrantLock();
for (int i = 0; i < 10; i++) {
int index = i;
pool.execute(() -> {
sleepMethod(index);
lock.lock();
taskNum++;
lock.unlock();
});
}
while (taskNum < 10) {
try {
Thread.sleep(1000);
System.out.println("线程池未关闭,当前任务数:" + taskNum);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程池已关闭");
}
}
- 使用 submit 向线程池提交任务,Future 判断任务执行状态。
- 优点:不需要关闭线程池,使用简单。
- 缺点:每个提交给线程池的任务都会关联一个 Future 对象,这可能会引入额外的内存开销。如果需要处理大量任务,可能会占用较多的内存。
java
public class Main {
/**
* 创建一个最大线程数为15的线程池
* 参数:核心线程数10,最大线程数15,线程空闲时间0毫秒,任务队列大小10
* ArrayBlockingQueue:基于数组的有界阻塞队列,FIFO原则
*/
public static ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 15, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(10));
/**
* 线程执行方法,随机等待0-10秒
*/
private static void sleepMethod(int index) {
try {
long sleepTime = new Double(Math.random() * 10000).longValue(); // 生成一个0到10000之间的随机数
Thread.sleep(sleepTime);
System.out.println("当前线程执行结束:" + index);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Future future = pool.submit(() -> {
sleepMethod(1);
});
while (!future.isDone()) {
try {
Thread.sleep(1000);
System.out.println("线程池未关闭");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程池已关闭");
}
}
线程池中线程复用的原理
线程池的线程复用原理是指将线程放入线程池中重复利用,而不是每次执行一个任务就创建一个新的线程。线程池会对线程进行封装,核心原理在于将线程的创建和管理与任务的执行分离。
线程池通过工作队列(WorkQueue)来存储任务,队列中可能有多个任务等待被执行。线程池中的线程数是有限的,核心线程数通常是固定的,最大线程数可以设置,超过最大线程数后,任务会被拒绝执行。
当提交任务时,线程池首先会检查当前线程数是否小于核心线程数,如果是,则新建一个线程数来执行任务;如果当前线程数已经达到核心线程数,但队列中没有正在执行的任务,则将任务放入队列中等待执行; 如果队列已满,且线程池中的线程数未达到最大线程数,则新建线程来执行任务;如果队列已满,且线程池中的线程数达到最大线程数,则根据拒绝策略来处理无法执行的任务。
线程复用的关键是将任务的提交和线程的创建、管理和执行分离,通过线程池来统一管理和调度,减少创建和销毁线程的开销,提高系统的效率。同时,由于线程复用的特性,可以有效地控制并发,避免大量线程的创建和销毁导致系统负载过大。
线程池底层工作原理
线程池是一种用于管理和重用线程的机制,其底层工作原理涉及线程的创建、调度、执行以及回收等关键的过程。
- 线程池的创建:在使用线程池之前,首先要创建一个线程池。通常线程池会根据配置参数(如核心线程数、最大线程数、队列类型等)来初始化线程池的基本属性。
- 任务提交:当有任务需要执行时,将任务提交给线程池。任务可以是 Runnable 或 Callable 对象,表示需要在一个独立线程中执行的工作单元。
- 线程分配:线程池内部维护了一组工作线程,这些线程会被动态地分配执行任务。线程池首先会尝试将任务分配给核心线程,如果核心线程数没有达到上限,就创建一个新的核心线程来执行任务。如果核心线程已满,任务会被放入任务队列中等待执行。
- 任务执行:分配给线程的任务会被执行。每个工作线程会不断地从任务队列中获取任务并执行。一旦任务完成,线程可以选择等待新的任务或者被回收,具体取决于线程池的配置和实现方式。
- 线程回收:线程池内的线程可能被回收,这可以是根据一些策略,如闲置时间超过一定的阈值或线程数超过最大线程数等。回收的线程会释放资源,如内存和 CPU 资源,一以便在需要时重新使用。
- 任务完成和结果返回:任务执行完成后,可以将执行结果返回给调用者。如果任务是通过 Callable 提交的,线程池回 Future 对象,通过该对象可以获取任务的执行结果。
- 异常处理:线程池通常会处理任务执行过程中抛出的异常,可以将异常信息记录下来或采取适当的措施,以确保线程池的稳定性。
线程池的哪些参数会影响性能?
- 核心线程数(Core Pool Size):这是线程池中一直保持活动状态的最小线程数量。核心线程在空闲时不会被销毁,除非启用了 allowCoreThreadTimeOut 参数。核心线程数的设置会影响线程池的并发度和资源占用情况。
- 最大线程数(Maximum Pool Size):这是线程池中允许的最大线程数量,过高的设置可能导致资源消耗过多。当任务提交到线程池时,首先尝试使用已有的空闲线程来处理,如果没有空闲的线程则根据需要创建新的线程。
- keepAliveTime 和 TimeUnit:这两个参数用于控制空闲线程的存活时间。如果线程在空闲时间超过 keepAliveTime 指定的时间段,它将被终止并从线程池中移除。合理的设置可以降低线程池的维护成本。
- 工作队列(Work Queue):工作队列用于存储等待执行的任务。不同类型的工作队列(如有界队列和无界队列)对线程池的性能有一定影响。有界队列可以避免无限制的任务积压,但可能导致任务丢失,而无界队列可能会占用更多的内存。
如何优化线程池的性能?
要优化线程池的性能,需要根据实际情况进行参数的配置。
- 根据应用场景和任务性质,合理设置核心线程数(corePoolSize)和最大线程数(maximumPoolSize)。如果是 I/O 密集型任务,核心线程数可以设置为 CPU 核心数的两倍左右,最大线程数可以设置为 CPU 核心数的四倍左右。如果是 CPU 密集型任务,核心线程数可以适当减少,最大线程数也可以适当减少。
- 根据任务性质和实际需求,选择合适的任务队列(workQueue)。例如,如果是 I/O 密集型任务,可以选择容量较小的无界队列,以避免队列过小导致任务拒绝的问题;CPU 密集型任务,可以选择容量较大的有界队列,以减少线程的创建和销毁。
- 根据系统资源和任务性质,合理设置线程的存活时间(keepAliveTime)。如果系统资源充足且任务性质不紧张,可以适当增加线程存活时间,以减少线程创建和销毁;如果系统资源有限或任务性质较为紧张,可以适当减少线程存活时间,以减少线程的空闲时间。
- 根据实际需求,自定义线程工厂(threadFactory)和任务拒绝策略(handler)。可以通过实现自己的线程工厂来设置线程的名称、优先级等属性,以提高线程池的可维护性。当任务队列已满且线程数达到最大值时,可以使用任务拒绝策略来处理无法执行的任务,例如抛出异常、记录日志或尝试重新提交等。
如果不允许线程池丢弃任务,应该选择哪个决绝策略?
众所周知,线程池的核心线程满了,任务就会放在阻塞队列中,阻塞队列满了就会创建临时线程,如果超过最大线程数,就会触发任务拒绝策略,任务直接丢弃。
但是,如果任务很重要,不允许丢弃,则可以参考下面的几种方案:
- 用 CallerRunsPolicy 策略,让主线程执行任务,但是如果任务非常耗时,则会阻塞主线程,这在高并发场景中不推荐。
- 将任务持久化,可以采用 MySQL、Redis 或 MQ 等方案,将任务持久化,然后定时执行。
- 参考 netty 的方案,将任务放到队列中,然后定时执行。
线程池中的线程抛异常后,是销毁还是复用?
在 Java 线程池中,线程抛异常后的处理方式取决于任务的提交方式(execute 或 submit)以及异常的处理逻辑。
- 使用 execute 提交任务
当任务通过 execute 方法提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。
- 异常处理:如果异常没有被捕获,它会被打印到控制台或日志文件中。
- 线程状态:抛出异常的线程会被终止,并由线程池创建一个新线程来替换。
- 线程复用:在这种情况下,由于线程被终止并替换,因此不能说是复用了原有线程。
- 使用 submit 提交任务
对于通过 submit 提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由 submit 方法返回的 Future - 对象中。
- 异常处理:异常被封装在 Future 对象中,当调用 Future.get 方法时,可以捕获到一个 ExecutionException。
- 线程状态:线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
- 线程复用:在这种情况下,线程继续存在于线程池中,可以被复用执行后续任务。
- 总结与建议
- 异常捕获与处理:无论使用哪种提交方式,都建议在任务执行过程中使用 try-catch 块来捕获和处理可能的异常,以避免异常导致线程终止或影响整个系统的稳定性。
- 线程池配置:根据业务需求和系统负载合理配置线程池的大小和参数,以确保系统的性能和稳定性。
- UncaughtExceptionHandler:可以设置 UncaughtExceptionHandler 来处理未捕获的异常,进一步增强系统的健壮性。
Tomcat 是如何自定义线程池的?
Tomcat 中自己定义了一个 org.apache.tomcat.util.threads.ThreadPoolExecutor
类,类名和 JDK 中的 ThreadPoolExecutor
类名相同,但是包名时 Tomcat 自己的。
Tomcat 会创建这样一个线程池:
java
public void createExecutor() {
// ...
TaskQueue taskqueue = new TaskQueue();
TaskThreadFactory tf = new TaskThreadFactory(namePrefix + "-exec-", daemon, getThreadPriority());
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS, taskqueue, tf);
taskqueue.setParent((ThreadPoolExecutor)executor);
}
注入传入的队列为 TaskQueue
,这个队列是 Tomcat 自定义的一个队列,继承了 LinkedBlockingQueue
,并且重写了 offer()
方法,将任务提交到队列中。它的入队逻辑为:
java
public boolean offer(Runnable o) {
// we can't do any checks
if (parent==null) return super.offer(o);
// we are maxed out on threads, simply queue the object
// 线程数等于最大线程数,直接入队
if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
// we have idle threads, just add it to the queue
// 在执行的任务数小于线程数,表示有空闲线程,此时可以入队
if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o);
// if we have less threads than maximum force creation of a new thread
// 线程数小于最大线程数不能入队
if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
// if we reached here, we need to add it to the queue
return super.offer(o);
}
Tomcat 中的线程池在构造器开始执行时就会启动所有的核心线程。Tomcat 中的线程池核心思想就是优先启动线程,线程达到上限了才将任务入队。
说说并发和并行的区别
并发和并行是计算机科学中两个重要的概念,虽然它们经常被混淆,但实际上有着不同的含义。
并发(Concurrency):
- 并发是指在同一时间段内,多个任务交替进行。并发并不要求多个任务同时执行,而是通过任务切换来实现多个任务的进展。
- 并发主要用于提高程序的响应性和资源利用率。
- 例如,在单核处理器上,通过时间片轮转的方式实现多个任务的并发执行。
并行(Parallelism):
- 并行是指在同一时刻,多个任务同时执行。并行需要多个处理器或多核处理器来实现。
- 并行主要用于提高程序的执行速度和处理能力。
- 例如,在多核处理器上,不同的核可以同时执行不同的任务。
总结:
- 并发是任务之间的交替进行,而并行是任务之间的同时进行。
- 并发可以在单核处理器上实现,而并行需要多核处理器或多个处理器。
如何理解 Java 中的并发可见性?
并发可见性是指多个线程访问共享变量时,一个线程对共享变量的修改能夿及时被其他线程看到。在多线程编程中,由于线程之间的交互,可能会导致共享变量的不一致性,从而引发一些问题,如数据竞争、死锁等。
如上图,当 线程A 读取 变量i 时,会从内存中读取数据,并缓存一份到 CPU1 的内部高速缓存中,然后 线程1 修改 变量i 为 2,但是还没有写回到内存中,此时 线程B 也来读取 变量i,那么也会从内存中读取读取,读到的 变量i 仍然为 1,此时就出现了并发可见性问题。
在 Java 中可以使用 volatile 关键字来保证变量的可见性,对于加了 volatile 的变量,线程在读取该变量时会直接从内存中读取,而不会从 CPU 缓存中读取,再修改该变量时会同时修改 CPU 高速缓存和内存中的值,从而保证了变量的可见性。
如何理解 Java 中的并发原子性?
并发原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在多线程编程中,由于线程之间的交互,可能会导致共享变量的不一致性,从而引发一些问题,如数据竞争、死锁等。
由于 CPU、内存、I/O(磁盘、网络)之间的差距,为了充分利用 CPU,当线程执行 I/O 操作时,线程会让出 CPU,使得 CPU 去执行其他线程的指令,并且本身来说,为了达到线程并发执行的效果,CPU 也会按照固定时间片来切换执行不同的线程。
例如,当执行 i++ 时,实际上是分为三步来执行的:
- 从内存中读取 i 的值到 CPU 缓存中。
- CPU 执行 i++ 操作。
- 将 CPU 缓存中的值写回内存。
如果多个线程同时执行 i++ 操作,可能会导致数据不一致的问题,例如线程 A 读取 i 的值为 1,线程 B 读取 i 的值为 1,然后线程 A 执行 i++ 操作,将 i 的值变为 2,然后线程 B 也执行 i++ 操作,将 i 的值变为 2,这样就导致了数据不一致的问题。
在 Java 中需要使用锁机制来保证并发原子性,可以使用 synchronized 关键字或者 Lock 接口来保证对共享变量的原子操作。
如何理解 Java 并发有序性?
并发有序性是指程序的执行顺序与代码的编写顺序一致,即程序按照代码的编写顺序来执行。在多线程编程中,由于线程之间的交互,可能会导致程序的执行顺序发生变化,从而引发一些问题,如数据竞争、死锁等。
编译器有时为了编译优化而进行指令重排。例如下方的 Java 代码:
java
new Person();
这行代码实际上是分为三步来执行的:
- 分配内存空间。
- 在内存空间初始化 Person 对象。
- 返回 Person 对象的地址。
但是编译器有时会对这三步进行重排,例如:
- 分配内存空间。
- 返回 Person 对象的地址。
- 在内存空间初始化 Person 对象。
在 Java 中可以使用 volatile 关键字来保证变量的有序性,对于加了 volatile 的变量,线程在读取该变量时会直接从内存中读取,而不会从 CPU 缓存中读取,从而保证了变量的有序性。
DCL(Double-Checked Locking,双重检查锁定)是一种用来延迟初始化单例对象或其他资源,同时保持线程安全的技术。它旨在减少获取锁的开销,只在必要时才进行同步。 但是,在 Java 1.4 及之前的版本中,由于 JVM 对 volatile 关键字的实现问题,DCL 并不总是能正确工作。从 Java 1.5(也称为 Java 5.0)开始,volatile 关键字的行为得到了增强,从而可以安全地实现 DCL 模式。
双重检查锁定的基本思想:
- 非同步检查:首先检查实例是否已经被创建。这避免了在实例已经存在时还要进行同步。
- 同步代码块:如果实例未被创建,则通过同步块来确保只有一个线程能创建实例。
- 再次检查:在同步块内部,再次检查实例是否已经被创建(双重检查)。这是为了应对在同步块外部检查和进入同步块之间,实例被另一个线程创建的情况。
- 创建实例:如果实例确实未被创建,则进行创建。
Java 中的 DCL 实现(Java 1.5 及以上)
java
public class Singleton {
// 使用volatile确保多线程正确处理instance变量
private static volatile Singleton instance;
// 私有构造函数,防止外部通过new创建实例
private Singleton() {}
// 双重检查锁定
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 创建实例
}
}
}
return instance;
}
}
注意事项
- volatile 的使用:在 Java 1.5 及以上版本中,volatile 关键字确保了多线程环境下变量的可见性和禁止指令重排序。这是实现 DCL 的关键。
- 指令重排序:在 Java 中,编译器和运行时可能会优化代码执行顺序,即指令重排序。在没有 volatile 的情况下,可能会导致
instance = new Singleton();
这行代码被分解成多个步骤,且步骤之间可能被重排序,这可能导致一个线程看到 instance 非 null 但实际上对象还未完全初始化。 - 单例模式的其他实现:除了 DCL,还有其他几种单例模式的实现方式,如懒汉式(非线程安全)、懒汉式(线程安全)、饿汉式、静态内部类方式、枚举方式等。选择哪种方式取决于具体需求和场景。
- 总之,双重检查锁定是一种在需要延迟初始化且保持线程安全时可以考虑的技术,但使用时需要注意Java版本和volatile关键字的使用。
如何理解 Java 并发中的条件等待队列?
在 Java 并发编程中,条件等待队列(Condition Queue)是理解线程同步与协作的一个核心概念,特别是在使用 java.util.concurrent.locks.Lock
接口及其 Condition 接口时尤为重要。 理解条件等待队列之前,我们需要先了解 Lock 和 Condition 的基本概念和用途。
Lock 与 Condition
- Lock:是 Java 并发包(java.util.concurrent.locks)中的一个接口,它提供了比 synchronized 方法和语句更广泛的锁定操作。Lock 允许更灵活的结构,可以拥有多个相关的条件对象(Condition Objects),这些条件对象可以替代传统 Object 监视器方法(如 wait(), notify(), 和 notifyAll())上的隐式监视器锁。
- Condition:是 Lock 接口中定义的一个内部接口,它提供了与 Object 监视器方法相似的功能(如等待/通知机制),但与 Lock 实现相关联。每个 Lock 可以有多个 Condition 实例,允许线程在不同的条件上等待和唤醒,这提高了灵活性并减少了竞争。
条件等待队列
条件等待队列是 Condition 实现中的一个核心概念。当线程在某个条件上调用 await() 方法时,它会被阻塞并放入与该条件相关联的等待队列中。这个队列是 FIFO(先进先出)的,保证了等待线程的顺序。
主要操作
- await():导致当前线程在接到信号或被中断之前一直处于等待状态。线程会释放锁并进入等待状态,直到其他线程在同一锁上调用该条件的signal()或signalAll()方法。线程被唤醒后,会重新尝试获取锁(如果锁在调用await()时是可用的,则线程不会进入等待状态)。
- signal():唤醒在此条件上等待的单个线程(如果存在)。在选择要唤醒的线程时,会优先考虑等待时间最长的线程。但是,这不是一个严格的规则,实现可以自由选择。
- signalAll():唤醒在此条件上等待的所有线程。
条件等待队列的一个典型应用是生产者-消费者问题。这个问题描述了两组线程 —— 生产者线程负责生成数据并将其放入缓冲区,而消费者线程则从缓冲区中取出数据并处理。 使用 Java 的 Lock 和 Condition 接口,我们可以有效地实现这个模式,并利用条件等待队列来管理线程间的同步与协作。
以下是一个简化的生产者-消费者问题的示例。
在这个例子中:
- 使用了一个
Queue<Integer>
作为缓冲区,并设置了其容量为 10。 - 使用 ReentrantLock 作为锁,以确保生产者和消费者之间的同步。
- 创建了两个 Condition 对象:notEmpty 和 notFull,分别用于控制队列非空和非满的条件。
- 在生产者方法中,如果队列已满,则调用 notFull.await() 使当前线程等待,并在生产数据后调用 notEmpty.signal() 来唤醒等待的消费者线程。
- 在消费者方法中,如果队列为空,则调用 notEmpty.await() 使当前线程等待,并在消费数据后调用 notFull.signal() 来唤醒等待的生产者线程。
- 这样,生产者和消费者线程就能够通过条件等待队列来高效地协作,避免了不必要的等待和竞争,提高了程序的性能和响应性。
java
public class ProducerConsumerExample {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity = 10; // 缓冲区容量
private final Lock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition(); // 队列非空条件
private final Condition notFull = lock.newCondition(); // 队列非满条件
// 生产者方法
public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) { // 如果队列已满,则等待
notFull.await();
}
queue.offer(value); // 生产数据
System.out.println("Produced: " + value);
notEmpty.signal(); // 通知等待的消费者
} finally {
lock.unlock();
}
}
// 消费者方法
public void consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) { // 如果队列为空,则等待
notEmpty.await();
}
Integer value = queue.poll(); // 消费数据
System.out.println("Consumed: " + value);
notFull.signal(); // 通知等待的生产者
} finally {
lock.unlock();
}
}
// 主函数,用于测试
public static void main(String[] args) {
ProducerConsumerExample example = new ProducerConsumerExample();
// 创建并启动生产者线程
Thread producer = new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
example.produce(i);
Thread.sleep(100); // 模拟生产耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// 创建并启动消费者线程
Thread consumer = new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
example.consume();
Thread.sleep(200); // 模拟消费耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
producer.start();
consumer.start();
}
}
什么是可重入锁?
可重入锁(Reentrant Lock)是指一个线程在持有锁的情况下,可以再次获取该锁,而不会被自己所持有的锁所阻塞。可重入锁的一个典型应用是递归函数,递归函数在调用自身时需要获取同一把锁,如果锁不是可重入的,那么递归函数将会被自己所持有的锁所阻塞。
简单地来说,可重入锁可以理解为一个可以重复获取的锁,就像拿钥匙开锁一样,可以反复使用同一把钥匙开锁。这种锁在同一线程内是安全的,因为它可以被同一线程多次获取,而不会产生不一致的状态。
例如,线程 A 在执行一个方法,同时这个方法内部又调用了另一个方法,那么线程 A 可以重复获取同一个锁,而不会出现死锁的情况。因为同一线程可以多次获取同一个锁,所以这种锁机制避免了死锁的产生。
但是,在使用可重入锁时,必须保证在释放锁之前已经获取了该锁,否则会导致死锁。同时还需要保证在获取锁的时候没有嵌套地获取其他锁,否则也会导致死锁。另外,还必须保证在获取锁的时候没有阻塞其他线程,否则同样会造成死锁。
ReentrantLock 中的公平锁和非公平锁的底层实现?
ReentrantLock 是 Java 并发包(java.util.concurrent.locks)中的一个锁实现,它提供了比 synchronized 方法和语句更广泛的锁定操作。ReentrantLock 提供了两种锁模式:公平锁和非公平锁。
公平锁和非公平锁
- 公平锁:是指多个线程按照申请锁的顺序来获取锁。ReentrantLock 默认使用的是非公平锁,可以通过构造函数 ReentrantLock(boolean fair) 来指定是否使用公平锁。
- 非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。这种方式可能造成某些线程“饥饿”,即一直获取不到锁。
不管是公平锁还是非公平锁,它们的底层原理都是通过 AQS(AbstractQueuedSynchronizer)来实现的。AQS 是 Java 并发包中用于构建锁和同步器的框架,它提供了一种基于 FIFO 等待队列的机制,用于管理线程的阻塞和唤醒。
公平锁与非公平锁的区别在于:线程在使用 lock() 方法加锁时,如果是公平锁,会先检查 AQS 队列中是否存在线程在排队,如果有,则当前线程也会进入队列等待;如果是非公平锁,则不会检查是否有线程在排队,直接尝试获取锁。
两者一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程。所以非公平锁只是体现在了线程加锁的阶段,并没有体现在线程被唤醒的阶段。
另外,ReentrantLock 的锁是可重入锁,即同一个线程可以多次获取同一把锁,而不会造成死锁。
ReentrantLock 中的 tryLock() 方法和 lock() 方法的区别?
tryLock() 方法和 lock() 方法都是 ReentrantLock 类中的方法,用于获取锁。它们的区别主要体现在获取锁的方式和获取锁的结果上。
tryLock() 方法是尝试获取锁,可能获取到锁,也可能获取不到锁,该方法不会阻塞线程,如果获取到锁直接返回 true,否则返回 false。
lock() 方法是获取锁,如果获取不到锁,会一直阻塞线程,直到获取到锁为止,该方法没有返回值。
ReentrantLock 中公平锁和非公平锁的底层实现?
ReentrantLock 是 Java 中提供的一种可重入锁,它实现了 Lock 接口,并提供了比 synchronized 更加灵活的锁机制。ReentrantLock 支持两种锁的模式:公平锁(Fair Lock)和非公平锁(Non-Fair Lock)。
ReentrantLock 中的公平锁和非公平锁在底层实现上的主要区别在于如何处理等待队列中的线程以及是否按照请求的顺序来分配锁。公平锁通过维护一个有序的等待队列来确保线程按照请求锁的顺序来获取锁,而非公平锁则允许线程直接竞争锁,可能会忽略等待队列中的线程而直接获取锁。
- 公平锁(Fair Lock)
特点:
- 按照请求锁的顺序来分配锁,即先到先得。
- 可以避免线程饥饿问题,即长时间等待的线程最终能够获得锁。
底层实现:
- 使用了一个 FIFO 队列(First-In-First-Out),即等待队列。
- 当一个线程请求锁时,如果锁已经被其他线程持有,请求线程会被放入等待队列的末尾。
- 当锁被释放时,等待队列中的第一个线程会被唤醒并获得锁。
- 在 ReentrantLock 中,通过构造函数可以选择创建一个公平锁。
内部类:
- FairSync:服务于公平锁的实现,继承自 AQS(AbstractQueuedSynchronizer)。
- 非公平锁(Non-Fair Lock)
特点:
- 不考虑请求锁的顺序,允许新的请求线程插队并尝试立即获取锁。
- 在高并发情况下可能更加灵活,但可能导致某些线程一直获取不到锁(即线程饥饿)。
底层实现:
- 底层实现中也有一个等待队列,但它不会严格按照请求的顺序来分配锁。
- 当一个线程请求锁时,会首先尝试直接获取锁,如果获取失败,再进入等待队列。
- 在 ReentrantLock 中,默认情况下创建的是非公平锁。
内部类:
- NonfairSync:服务于非公平锁的实现,继承自 AQS(AbstractQueuedSynchronizer)。
- 公平锁与非公平锁的共同点
- 无论是公平锁还是非公平锁,都使用了类似的同步器(Sync)来管理锁的状态和线程的竞争。
- 底层实现都依赖于 AbstractQueuedSynchronizer(AQS)框架。
- 在获取锁时,都会首先检查 AQS 的 state 状态,如果 state 为 0,表示锁是可用的,线程成功获取锁,并将 state 增加。否则,线程进入阻塞队列。
CountDownLatch 和 Semaphore 的区别和底层原理?
CountDownLatch 和 Semaphore 都是 Java 并发包(java.util.concurrent)中的同步工具类,用于控制多个线程之间的同步和协作。它们的主要区别在于用途和实现原理。
CountDownLatch 表示计数器,可以给 CountDownLatch 设置一个数字,一个线程调用 CountDownLatch 的 await() 方法将会阻塞,其他线程可以调用 countDown() 方法来减少计数器的值,当计数器的值减少到 0 时,所有因调用 await() 方法而阻塞的线程将会被唤醒。
对应的底层原理就是,调用 await() 方法的线程会利用 AQS 排队,一旦数字减少到 0,会将 AQS 中排队的线程依次唤醒。
Semaphore 表示信号量,可以设置许可的个数,表示同时允最多多少个线程使用该信号量。通过 acquire() 方法来获取许可,如果没有许可可用则线程阻塞,并通过 AQS 来排队。 可以通过 release() 方法来释放许可,当某个线程释放了某个许可后,会从 AQS 中正在排队的第一个线程开始依次唤醒,直到没有空闲的许可。
Synchronized 的实现原理
synchronized 是 Java 中用于实现线程同步的关键字,它通过互斥锁控制线程对共享变量的访问,保证同一时刻只有一个线程可以访问共享资源。
synchronized 的实现原理主要有以下几点:
- synchronized 的实现基础是对象内部的锁(也称为监视器锁或管程),每个锁关联着一个对象实例。
- 当 synchronized 作用于某个对象时,它就会尝试获取这个对象的锁,如果锁没有被其他线程占用,则当前线程获取到锁,并可以执行同步代码块;如果锁已经被其他线程占用,则当前线程阻塞在同步代码块外,直到获取锁。
- synchronized 还支持作用于类上,此时它锁住的是整个类,而不是类的某个实例。在这种情况下,由于只有一个锁的存在,所以所有使用该类的线程都要等待锁的释放。
- 在 JVM 内部,每个对象都有头信息,其中包含了对象的一些愿信息和状态标志。synchronized 通过修改对象头信息中的标志位来获取和释放锁。
- synchronized 还支持可重入性,即在同一个线程中可以多次获取同一把锁,这样可以避免死锁的发生。
- JVM 会通过锁升级的方式来优化 synchronized 的性能,包括偏向锁、轻量级锁和重量级锁,使得在竞争不激烈的情况下,synchronized 的性能接近于无锁状态。
Synchronized 锁优化
synchronized 还有一种重要的优化方式,即锁的优化技术。在 JDK 6 及其之后,JVM 引入了偏向锁、轻量级锁和重量级锁等锁优化技术,以提高 synchronized 的性能。 这些优化方式的原理如下:
- 偏向锁:偏向锁是指当一个线程获取到锁之后,会在对象头中记录下该线程的标识,下次再进入同步块时,无需进行额外的加锁操作,从而提高性能。
- 轻量级锁:当多个线程对同一个锁进行争夺时,JVM 会使用轻量级锁来避免传统的重量级锁带来的性能消耗。它采用自旋的方式,即不放弃 CPU 的执行时间,尝试快速获取锁,避免线程阻塞和上下文切换的开销。
- 重量级锁:当多个线程对同一把锁进行强烈争夺时,JVM 会将锁升级为重量级锁,此时线程会进入阻塞状态,等待锁的释放。这种方式适用于锁竞争激烈的情况,但会带来较大的性能开销。
锁优化技术是为了提高 synchronized 的并发性能,根据锁的竞争程度和持有时间的长短选择相应的锁状态,使得多个线程能够高效地共享资源。
Synchronized 和 ReentrantLock 的区别?
- Synchronized 是 Java 语言关键字,ReentrantLock 是一个类。
- Synchronized 会自动地加锁与释放锁,ReentrantLock 需要手动地加锁与释放锁。
- Synchronized 是 JVM 层面的锁,ReentrantLock 是 JDK API 层面的锁。
- Synchronized 是非公平锁,ReentrantLock 可以选择公平锁或非公平锁。
- Synchronized 锁的是对象,锁信息保存在对象头中,ReentrantLock 通过代码中 int 类型的 state 标识来识别锁的状态。
- Synchronized 底层有锁升级的机制,ReentrantLock 没有锁升级的机制。
父子线程之间如何共享传递数据?
在 Java 中,父子线程之间共享和传递数据有多种方式,以下是常见的几种方法:
- 使用共享对象
父子线程可以共享同一个对象,通过操作对象的属性来传递数据。为了避免多线程环境下数据不一致的问题,可以使用 volatile
关键字或加锁机制。
java
class SharedData {
public volatile int data; // 共享变量
}
public class ParentThread {
public static void main(String[] args) {
SharedData sharedData = new SharedData(); // 父线程和子线程共享的对象
Thread childThread = new Thread(() -> {
// 子线程操作共享数据
sharedData.data = 42;
System.out.println("Child thread updated data to: " + sharedData.data);
});
childThread.start();
try {
childThread.join(); // 等待子线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
// 父线程获取子线程修改后的数据
System.out.println("Parent thread reads data: " + sharedData.data);
}
}
在这个例子中,SharedData
是父子线程共享的数据对象,使用 volatile
保证可见性。
- 使用
ThreadLocal
ThreadLocal
提供了每个线程独立的局部变量,虽然不能直接用于父子线程间共享,但可以用于父线程向子线程传递数据,特别是一些上下文数据。
java
public class ParentThread {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
threadLocal.set("Data from parent thread");
Thread childThread = new Thread(() -> {
// 子线程可以访问父线程设置的值
System.out.println("Child thread reads data: " + threadLocal.get());
});
childThread.start();
try {
childThread.join(); // 等待子线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 通过构造函数或方法传递
你可以通过线程的构造函数或启动前传递参数的方式,将父线程中的数据传递给子线程。
java
class ChildThread extends Thread {
private String data;
public ChildThread(String data) {
this.data = data;
}
@Override
public void run() {
System.out.println("Child thread received data: " + data);
}
}
public class ParentThread {
public static void main(String[] args) {
String sharedData = "Data from parent thread";
ChildThread childThread = new ChildThread(sharedData); // 通过构造函数传递数据
childThread.start();
try {
childThread.join(); // 等待子线程结束
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 使用阻塞队列(BlockingQueue)
可以使用 BlockingQueue
来实现父子线程之间的数据传递。这种方式特别适用于生产者-消费者模式。
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ParentThread {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1); // 阻塞队列
Thread childThread = new Thread(() -> {
try {
queue.put(42); // 子线程放入数据
System.out.println("Child thread put data: 42");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
childThread.start();
try {
Integer data = queue.take(); // 父线程从队列中取数据
System.out.println("Parent thread got data: " + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 使用
FutureTask
或Callable
使用 FutureTask
或 Callable
可以让子线程返回结果,并由父线程获取这些结果。
java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ParentThread {
public static void main(String[] args) {
Callable<Integer> callableTask = () -> {
// 子线程任务
return 42;
};
FutureTask<Integer> futureTask = new FutureTask<>(callableTask);
Thread childThread = new Thread(futureTask);
childThread.start();
try {
Integer result = futureTask.get(); // 父线程获取子线程的返回结果
System.out.println("Parent thread got result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
死锁、活锁和饥饿的概念?
- 死锁(Dead lock):是指两个或多个线程互相持有对方所需的资源,导致它们都无法继续执行。在死锁状态下,每个线程都在等待对方释放资源,从而导致所有线程都无法继续执行。
- 活锁(Live lock):是指线程不断重复相同的操作,但没有进展,导致线程无法继续执行。在活锁状态下,线程会不断重复尝试某个操作,但由于其他线程的影响,导致操作无法成功,从而陷入循环。就像两个人在狭窄的道路上相遇,一方让对方通过,对方也让自己通过,结果两人都无法通过。
- 饥饿(Starvation):是指某个或某些线程无法获得所需的资源,导致无法继续执行。在饥饿状态下,线程可能会长时间等待资源,但始终无法获取到,从而无法继续执行。例如一个人在繁忙的餐厅排队等待很长时间,但始终无法进入就餐。
Java 中如何避免死锁?
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。在 Java 中,死锁是一种常见的多线程问题,通常由于多个线程争夺资源而造成。
造成死锁的四个必要条件:
- 互斥条件:一个资源每次只能被一个线程使用;
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
- 不剥夺条件:线程已获得的资源在未使用完之前不能强行剥夺;
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如果要避免死锁,只需要不满足上面四个条件中的任意一个即可。而其中前三个条件是作为锁机制的基础,无法避免,所以只能通过破坏循环等待条件来避免死锁。
因此,避免死锁的方法主要有以下几种:
- 要注意加锁顺序,保证每个线程按照同样的顺序进行加锁;
- 要注意加锁的时限,可以针对锁设置一个超时时间;
- 要注意死锁的检查,这是一种预防机制,确保在发生死锁时能够及时检测到,并进行解决。
如何解决线程死锁的问题
在 Java 中解决线程死锁问题可以通过以下几种方式:
- 避免嵌套锁定
死锁通常发生在多个线程同时获取多个锁时。避免嵌套锁的一个简单方法是按固定顺序获取锁。比如,线程A 和 线程B 都需要锁 Lock1 和 Lock2 时,规定每个线程必须先获取 Lock1,再获取 Lock2,这样就能避免相互等待。
示例:
java
public class AvoidNestedLocks {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
synchronized (lock2) {
// critical section
}
}
}
public void method2() {
synchronized (lock1) {
synchronized (lock2) {
// critical section
}
}
}
}
- 使用
tryLock()
Java 的 java.util.concurrent.locks.Lock
提供了 tryLock()
方法,它允许线程尝试获取锁,如果获取失败,线程不会被阻塞。这避免了无限期等待可能导致的死锁。
示例:
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockExample {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void method1() {
try {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
// critical section
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public void method2() {
try {
if (lock2.tryLock()) {
try {
if (lock1.tryLock()) {
try {
// critical section
} finally {
lock1.unlock();
}
}
} finally {
lock2.unlock();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 降低锁的持有时间
尽量减少锁的持有时间,确保锁定的代码块尽量小,只有在真正需要同步的地方使用锁。这样可以降低锁的竞争,进而减少死锁发生的可能性。
- 使用死锁检测工具
使用监控工具如 JConsole 或 VisualVM 可以帮助开发者在运行时检测死锁。此外,可以编写专门的死锁检测代码。
示例:
java
public class DeadlockDetector {
public static void checkForDeadlocks() {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
long[] threadIds = threadBean.findDeadlockedThreads();
if (threadIds != null) {
ThreadInfo[] threadInfo = threadBean.getThreadInfo(threadIds);
for (ThreadInfo info : threadInfo) {
System.out.println("Deadlocked Thread: " + info.getThreadName());
}
}
}
}
- 避免过多的锁
使用尽量少的锁来同步代码。过多的锁会增加锁争用和死锁的可能性。
- 使用并发库(java.util.concurrent)
Java 提供了 java.util.concurrent
包,里面有一些更高级的并发工具,如 Semaphore
、CountDownLatch
、CyclicBarrier
、ExecutorService
等,这些工具可以避免手动管理锁,降低死锁发生的可能性。
Synchronized 如何实现线程同步?
Synchronized
是 Java 中的关键字,用于实现线程同步,确保在同一时刻只有一个线程可以访问某个临界区(关键代码)。Synchronized
通过内置的锁机制实现线程同步,保证共享资源的并发访问不会导致数据不一致的问题。
synchronized 的工作原理:
每个对象和类在 Java 中都有一把内置锁(Monitor)。当线程进入一个被 synchronized
修饰的方法或代码块时,它会自动获取这把锁,并在离开方法或代码块时释放锁。如果其他线程尝试进入已经被加锁的代码块或方法,它将被阻塞,直到当前线程释放锁。
synchronized 的三种使用方式:
- 同步实例方法
当一个实例方法被 synchronized
修饰时,调用该方法时线程会锁定当前实例对象。这意味着同一个对象的其他 synchronized
方法不能被其他线程同时执行。
示例:
java
public class SynchronizedExample {
public synchronized void method() {
// critical section
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finished");
}
}
public class Main {
public static void main(String[] args) {
SynchronizedExample example = new SynchronizedExample();
Thread t1 = new Thread(() -> example.method());
Thread t2 = new Thread(() -> example.method());
t1.start();
t2.start();
}
}
输出:
- 线程
t1
执行时,线程t2
被阻塞,直到t1
完成后,t2
才能获取锁。
- 同步代码块
可以使用 synchronized
来锁定代码块,而不是整个方法。这样可以提高并发性能,因为锁的范围更小,仅限于真正需要同步的部分。
示例:
java
public class SynchronizedBlockExample {
private final Object lock = new Object();
public void method() {
System.out.println(Thread.currentThread().getName() + " trying to enter critical section");
synchronized (lock) {
// critical section
System.out.println(Thread.currentThread().getName() + " is in critical section");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finished");
}
}
}
public class Main {
public static void main(String[] args) {
SynchronizedBlockExample example = new SynchronizedBlockExample();
Thread t1 = new Thread(() -> example.method());
Thread t2 = new Thread(() -> example.method());
t1.start();
t2.start();
}
}
- 只有在同步代码块(
synchronized (lock) {...}
)内时,线程才会受到锁的影响。
- 同步静态方法
synchronized
修饰静态方法时,锁定的是类的 Class
对象,而不是实例对象。因此,即使有多个实例同时访问这个静态方法,它们也会同步执行。
示例:
java
public class SynchronizedStaticExample {
public static synchronized void staticMethod() {
System.out.println(Thread.currentThread().getName() + " is running static method");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finished");
}
}
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread(() -> SynchronizedStaticExample.staticMethod());
Thread t2 = new Thread(() -> SynchronizedStaticExample.staticMethod());
t1.start();
t2.start();
}
}
- 线程
t1
和t2
会按顺序访问staticMethod
,即使它们访问的是同一个类的静态方法,而不是同一个实例。
synchronized 的注意事项:
- 性能开销:虽然
synchronized
可以保证线程安全,但也会带来性能开销,特别是在高并发的情况下,锁的竞争可能导致线程阻塞和性能下降。 - 锁的粒度:为了减少锁的竞争,建议尽量缩小锁的范围(比如使用同步代码块),仅对确实需要同步的代码部分加锁。
- 避免死锁:多个线程嵌套获取多个锁时,可能会导致死锁,因此要小心锁的顺序和使用。
读写锁(ReadWriteLock)的应用场景是什么?
在 Java 中,读写锁(ReadWriteLock)是用于优化并发访问时的锁机制,适用于读多写少的场景。它的核心思想是区分读操作和写操作:多个读线程可以同时访问共享资源,而写操作需要独占锁。Java 中的 ReadWriteLock
接口最常见的实现是 ReentrantReadWriteLock
。
典型的应用场景
- 缓存系统
- 在缓存系统中,读操作通常比写操作频繁得多。使用读写锁可以让多个线程同时读取缓存数据,从而提高并发性能。只有在缓存失效或数据更新时,才会使用写锁来更新缓存。
- 配置文件的读取和修改
- 应用程序可能会频繁读取配置文件(如日志级别、数据库配置等),而配置文件的修改则相对较少。通过读写锁,多个线程可以同时读取配置,而在更新配置文件时,写操作会阻止其他线程的读取。
- 统计数据的收集和展示
- 假设一个系统需要实时展示统计数据,而这些数据可能会被后台定时更新。读写锁可以允许多个客户端同时读取统计结果,而在数据更新时,写锁可以确保数据一致性。
- 商品库存系统
- 在电商系统中,商品的库存信息会被频繁读取,而库存更新(如下单、退款等)相对较少。使用读写锁可以允许并发读取库存信息,提高系统的吞吐量。
ReentrantReadWriteLock 的使用示例
java
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private int data = 0; // 模拟共享资源
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// 读操作
public int readData() {
lock.readLock().lock(); // 获取读锁
try {
System.out.println("Reading data: " + data);
return data;
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
// 写操作
public void writeData(int value) {
lock.writeLock().lock(); // 获取写锁
try {
System.out.println("Writing data: " + value);
this.data = value;
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 模拟读写操作
new Thread(() -> example.readData()).start();
new Thread(() -> example.writeData(42)).start();
new Thread(() -> example.readData()).start();
}
}
优点
- 提高读操作的并发性:多个读线程可以同时访问资源,减少竞争。
- 写操作的独占性:写操作保证了数据的一致性。
适用条件
- 读操作远多于写操作:如果写操作频繁,那么写锁的竞争会导致性能下降,这时可能不如使用普通的
ReentrantLock
或synchronized
。
什么是阻塞队列(Blocking Queue),应用场景有哪些?
阻塞队列(Blocking Queue)是一种支持线程安全的、用于在生产者-消费者模式中协调线程间通信的队列。Java 中的阻塞队列位于 java.util.concurrent
包中,常见的实现类包括 ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等。它们在处理并发时会自动处理锁的细节,因此非常适合在多线程环境下使用。
阻塞队列的特性
- 线程安全:阻塞队列在内部使用了锁机制,保证了多线程环境下的安全性。
- 阻塞操作:
- 阻塞的插入操作:当队列满时,如果试图插入元素,操作会被阻塞直到队列有空闲空间。
- 阻塞的取出操作:当队列为空时,取出操作会被阻塞直到队列中有新的元素。
Java 中的阻塞队列提供了两种方式的插入和取出操作:
- 抛出异常的方式:如
add()
、remove()
等方法。 - 阻塞的方式:如
put()
、take()
等方法。
阻塞队列的常见实现
- ArrayBlockingQueue
- 一个有界(固定大小)的阻塞队列,它基于数组实现。
- 适用于限制队列长度的场景,例如需要控制任务队列大小的线程池。
- LinkedBlockingQueue
- 一个可选边界(可以设定大小或无界)的阻塞队列,基于链表实现。
- 常用于生产者-消费者模型,适合处理较高的并发读写操作。
- PriorityBlockingQueue
- 一个无界的阻塞队列,支持按优先级排序存储元素。取出的元素是队列中最优先级的元素。
- 适用于需要任务优先级调度的场景。
- SynchronousQueue
- 一个没有容量的阻塞队列。每个插入操作必须等到有一个对应的取出操作,反之亦然。
- 适用于两个线程之间直接交互数据的场景,如线程之间的数据传递。
典型应用场景
- 生产者-消费者模型
- 阻塞队列最经典的应用场景是生产者-消费者模型,生产者负责向队列中放入任务,消费者从队列中取出任务进行处理。当队列满时,生产者会阻塞等待消费者消费;当队列为空时,消费者会阻塞等待生产者生产。这样可以很好地控制任务的生产和消费节奏。
java
class Producer implements Runnable {
private BlockingQueue<Integer> queue;
public Producer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
for (int i = 0; i < 10; i++) {
queue.put(i); // 阻塞直到有空间
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
class Consumer implements Runnable {
private BlockingQueue<Integer> queue;
public Consumer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (true) {
Integer item = queue.take(); // 阻塞直到有元素
System.out.println("Consumed: " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public class BlockingQueueExample {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
new Thread(new Producer(queue)).start();
new Thread(new Consumer(queue)).start();
}
}
- 线程池中的任务队列
- 在多线程环境中,常用阻塞队列作为线程池的任务队列。例如,当任务提交给线程池时,若当前没有空闲线程处理任务,任务将被存放在阻塞队列中等待线程处理。
- 限流机制
- 阻塞队列可以限制生产者生成任务的速度。例如,在一些需要限流的场景中,可以使用
ArrayBlockingQueue
来设定队列的上限,防止任务堆积过多。
- 异步日志系统
- 在日志系统中,日志的生产和写入往往是异步操作。生产日志时会将日志消息放入阻塞队列中,日志写入线程则不断从队列中取出日志进行写入。这种方式可以降低日志写入对主线程性能的影响。
优点
- 简化并发编程:阻塞队列自动管理同步机制,避免了手动处理锁和条件变量的复杂性。
- 提供灵活的阻塞操作:当资源不可用时,线程可以安全地进入等待状态而不会出现忙等待问题。
- 避免过载:通过有界阻塞队列,生产者可以被阻塞,防止系统超负荷。
阻塞队列通过对并发操作的支持,在提高程序性能的同时,也简化了线程间的协作,使其在多线程环境中非常适用。
JDK 21 中的虚拟线程?
在 JDK 21 中,虚拟线程(Virtual Threads)是 Project Loom 的一部分,用来简化并发编程。虚拟线程是轻量级的线程,与传统的操作系统线程(也称为"平台线程")相比,它们消耗的系统资源更少。 JDK 21中的虚拟线程让开发者可以以更低的成本和复杂度使用大量线程,提升了并发处理的能力。
主要特点
- 轻量级:虚拟线程的创建和销毁成本极低,可以支持百万级别的并发线程,而不会像平台线程那样受限于内存或内核资源。
- 更简单的并发模型:你可以像写同步代码一样编写并发程序,而不需要手动处理复杂的线程池管理。
- 兼容性好:虚拟线程与现有的 Java API 和框架兼容,开发者可以在当前的代码基础上逐步引入虚拟线程。
使用示例
在 JDK 21 中,你可以使用 Thread.ofVirtual().start()
来创建虚拟线程:
java
Thread.startVirtualThread(() -> {
// 虚拟线程执行的任务
System.out.println("Hello from a virtual thread!");
});
或者通过 Executors.newVirtualThreadPerTaskExecutor()
来创建一个基于虚拟线程的 ExecutorService:
java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// 执行并发任务
System.out.println("Task executed by virtual thread");
});
}
优势
- 高并发:虚拟线程可以支持大量并发任务,而不会像传统线程那样产生大量的上下文切换开销。
- 简化代码:你可以用同步的编程模型(阻塞 I/O、同步等待等)编写高并发程序,不需要为每个任务手动管理线程池。
虚拟线程大大简化了在 Java 中处理并发的方式,非常适合用在高并发的应用场景,例如服务器编程、实时数据处理等。
说一下 CAS 的实现与原理
在 Java 中,CAS(Compare-And-Swap,即比较并交换)是一种用于实现并发控制的技术,广泛应用于原子类(如 AtomicInteger
、AtomicReference
)等无锁编程的场景。
CAS 的原理
CAS 是一种乐观锁机制,其基本思想是在进行更新操作时,假设没有其他线程对该值进行修改,通过比较预期值和当前值,只有当两者相等时才进行更新操作,否则重试。CAS 包含三个操作数:
- 内存位置(V):需要进行操作的变量。
- 预期值(A):期望内存位置的值。
- 新值(B):希望将内存位置的值更新为的值。
工作原理
- 比较:CAS 首先检查内存位置 V 的当前值是否等于预期值 A。
- 交换:如果等于,则将内存位置的值更新为新值 B;如果不等,意味着该值已经被其他线程修改,则不执行更新操作,并返回失败。
这保证了在多线程环境下,只有一个线程能够成功修改共享变量。
CAS 在 Java 中的实现
在 Java 中,CAS 依赖于底层的 CPU 指令来实现(通常使用处理器提供的原子指令如 cmpxchg
),而且通过 Unsafe
类实现原子操作。虽然 Unsafe
类并不对外公开,但 Java 的 java.util.concurrent
包中的原子类(例如 AtomicInteger
、AtomicLong
、AtomicReference
)已经使用了该技术。
以下是一个 AtomicInteger
类的简化版本展示了 CAS 的应用:
java
import java.util.concurrent.atomic.AtomicInteger;
public class CASExample {
public static void main(String[] args) {
AtomicInteger atomicInt = new AtomicInteger(0);
// 使用 CAS 来更新值
boolean success = atomicInt.compareAndSet(0, 10);
System.out.println("CAS 是否成功: " + success); // 输出: CAS 是否成功: true
System.out.println("当前值: " + atomicInt.get()); // 输出: 当前值: 10
}
}
CAS 的优点
- 无锁编程:相比传统的锁机制(如
synchronized
或Lock
),CAS 避免了线程阻塞,提高了并发性能。 - 高效性:在大多数情况下,CAS 的操作比锁机制开销更小,尤其在低争用的情况下。
CAS 的缺点
- ABA 问题:假设某线程将值从 A 改为 B,又改回 A,CAS 只检查值是否等于预期值,而不检查中间的变化。这会导致误判。Java 提供了
AtomicStampedReference
类来解决这个问题,通过引入版本号来标识变化。 - 忙等问题:如果多个线程同时竞争更新同一变量,但都失败了,可能会导致自旋消耗大量 CPU 资源。
- 只能保证单个变量的原子性:CAS 仅能用于单个变量的原子操作,无法保证多个变量的操作是原子的。对于多个变量的原子性操作,需要结合其他并发工具。
总结
CAS 是一种乐观锁的无锁并发控制技术,通过底层硬件的支持实现,能够提高系统的并发性能,但在使用过程中也要注意其缺陷如 ABA 问题和忙等问题。在 Java 中,CAS 通过 Unsafe
类和 Atomic
类族得以实现,是高效的并发控制基础。
什么是伪共享问题以及如何解决?
伪共享(False Sharing)是多线程编程中一种常见的性能问题,通常发生在共享内存的多核处理器环境中。它涉及多个线程访问不同但靠近的内存数据时,由于这些数据存储在同一个缓存行中,导致不必要的缓存同步,进而影响程序的性能。
伪共享的原理
现代多核处理器为了提升内存访问的速度,会将数据按缓存行(通常是 64字节)来进行缓存。当多个线程访问不同的变量时,如果这些变量恰好位于同一个缓存行中,即使线程之间没有共享这些变量,处理器仍然需要对该缓存行进行同步和一致性检查。
这种缓存行共享会导致两个问题:
- 缓存行失效:当一个线程修改了缓存行中的数据,其他线程必须重新从内存中获取更新后的缓存行,即使这些线程关心的是缓存行中未被修改的数据部分。
- 缓存一致性协议开销:由于缓存一致性协议(如MESI协议)的作用,多个线程在访问同一个缓存行时,会引发缓存的无效化和同步操作,增加了额外的开销。
伪共享问题的示例
假设有两个线程,它们各自访问和修改两个不同的变量,但这两个变量共享了同一个缓存行:
java
public class FalseSharing implements Runnable {
public static long[] arr = new long[2]; // 数组元素可能会落在同一个缓存行中
private int index;
public FalseSharing(int index) {
this.index = index;
}
@Override
public void run() {
for (int i = 0; i < 10_000_000; i++) {
arr[index]++;
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new FalseSharing(0));
Thread t2 = new Thread(new FalseSharing(1));
t1.start();
t2.start();
t1.join();
t2.join();
}
}
在这个示例中,arr[0]
和 arr[1]
可能位于同一个缓存行中,即使线程 t1
和 t2
各自只访问一个元素,它们仍然会因共享缓存行而导致伪共享问题,进而影响性能。
解决伪共享的方法
- 内存对齐与填充(Padding):通过在变量之间增加填充,使得每个线程操作的变量都位于不同的缓存行。Java 8 提供了
@Contended
注解用于填充变量,避免伪共享:
java
@sun.misc.Contended
public class CacheLinePadding {
public volatile long value1;
public volatile long value2;
}
也可以手动填充:
java
public class CacheLinePaddingManual {
public volatile long value1;
private long p1, p2, p3, p4, p5, p6, p7; // 手动填充使 value2 落在不同的缓存行
public volatile long value2;
}
将变量分离(Array分离):如果使用数组,可以确保数组的每个元素分布在不同的缓存行上,例如使用较大的数组间隔。
减少共享数据的访问:如果可以,尽量减少多个线程同时操作共享数据,将频繁操作的变量限制在单个线程内,避免并发访问。
Future 和 CompleteFuture 的区别?
在 Java 中,Future
和 CompletableFuture
都用于异步编程,但它们有一些关键区别。
Future
- 定义:
Future
是 Java 5 引入的接口,用于表示一个异步操作的结果。 - 限制:
Future
的操作是阻塞的。通过调用get()
方法来获取结果,这会使当前线程阻塞,直到任务完成。- 它没有提供任何方法来手动完成任务或进行回调处理。
Future
不提供组合多个任务的能力,也没有异步处理链的功能。- 取消任务比较有限,只能通过
cancel()
方法来中断任务执行,但这也可能不总是成功。
- 示例:java
ExecutorService executor = Executors.newFixedThreadPool(1); Future<Integer> future = executor.submit(() -> { // 模拟长时间计算 Thread.sleep(2000); return 5; }); Integer result = future.get(); // 阻塞,直到任务完成 System.out.println(result);
CompletableFuture
- 定义:
CompletableFuture
是 Java 8 引入的类,扩展了Future
接口,支持更丰富的功能。 - 特点:
- 非阻塞:
CompletableFuture
支持回调,当任务完成时可以注册回调函数进行处理。 - 手动完成:可以通过
complete()
方法手动完成任务,这在构建异步逻辑时非常有用。 - 任务组合:
CompletableFuture
支持任务链式组合,如thenApply()
、thenCompose()
、thenAccept()
等方法,可以使多个异步任务串行或并行执行。 - 异常处理:提供异常处理的能力,如
exceptionally()
方法,可以捕获并处理异步任务中的异常。 - 并行执行:可以方便地启动并行任务,并将多个任务的结果组合起来,如
allOf()
或anyOf()
。
- 非阻塞:
- 示例:java
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> { // 模拟长时间计算 try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } return 5; }); future.thenAccept(result -> { System.out.println("Result: " + result); // 非阻塞执行,任务完成时调用 });
总结
- Future:用于简单的异步任务,主要特点是阻塞,缺乏流畅的异步链式调用和任务组合能力。
- CompletableFuture:提供了更强大的异步编程功能,支持非阻塞、回调、任务组合和异常处理。
CompletableFuture
是现代 Java 异步编程中更为推荐的工具,因为它功能强大且更灵活。
线程数设定成多少更合适?
设定合适的线程数是设计搞笑并发程序的关键之一。适当的线程数取决于多个因素,包括:
- CPU 核心数:通常情况下,线程数不应超过 CPU 的核心数,否则会导致线程上下文切换过多,降低性能。
- I/O 操作:对于 I/O 密集型任务,可以使用更多的线程,因为线程在等待 I/O 操作时不会占用 CPU 时间。
- 任务性质:任务的性质和上下文切换的开销也是考虑的因素。
理论指导
- 计算密集型任务:建议线程数 = CPU 核心数。例如有 8 核的 CPU,可以启动 8 个线程来充分利用 CPU 资源。
- I/O 密集型任务:建议线程数稍微高于核心数,通常采用的策略是 线程数 = 核心数 + (1 + 线程等待时间/线程计算时间)。
代码示例
下面是一个简单的示例,展示如何根据 CPU 核心数设置线程池:
java
public class ThreadPoolExample {
public static void main(String[] args) {
// 获取可用的 CPU 核心线程数
int coreCount = Runtime.getRuntime().availableProcessors();
System.out.println("CPU 核心数: " + coreCount);
// 根据任务类型设定线程池大小
// 计算密集型任务推荐配置
ExecutorService computeThreadPool = Executors.newFixedThreadPool(coreCount);
// I/O 密集型任务推荐配置(假设等待时间与计算时间接近)
int maxIOTasks = coreCount * 2;
// 实际值根据测量的 I/O 等待时间调整
ExecutorService ioThreadPool = Executors.newFixedThreadPool(maxIOTasks);
// 提交一些示例任务给线程池
for (int i = 0; i< 10; i++) {
computeThreadPool.submit(() -> {
// 计算密集型任务
System.out.println("Compute task executed by: " + Thread.currentThread().getName());
});
ioThreadPool.submit(() -> {
// I/O 密集型任务
System.out.println("I/O task executed by: " + Thread.currentThread().getName());
try {
Thread.sleep(1000); // 模拟 I/O 操作等待时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// 关闭线程池
computeThreadPool.shutdown();
ioThreadPool.shutdown();
}
}
关键点
- 合理配置线程池:根据计算任务和 I/O 任务的比例合理配置线程池的大小可以提高程序的性能。
- 测量等待时间:在 I/O 密集型任务中,精确测量线程等待时间与计算时间的比例有助于更准确地配置线程数。
- 资源限制:注意系统的资源限制,过多的线程可能导致上下文切换的开销增加,同时消耗过多的系统资源。
- 负载测试:实际应用中,需要通过负载测试来确定最佳的线程数,以达到最佳的性能。
线程调用 2 次 start() 方法会出现什么问题?
线程调用 2 次 start() 方法会抛出 IllegalThreadStateException 异常。每个线程对象只能启动一次,第二次调用 start() 方法会导致运行时错误。
这是因为 Java 线程的生命周期规定,线程对象只能启动一次,一旦线程启动,就会进入运行状态,直到线程执行完毕或被中断。如果尝试对已经启动的线程再次调用 start() 方法,会抛出 IllegalThreadStateException 异常。
锁优化机制
锁的优化机制是 Java 等编程语言中常见的一种提高并发性能的方法。锁的优化目的是减少锁的竞争,从而提高程序的性能。
- 偏向锁(Biased Locking):偏向锁是一种针对无锁竞争情况的锁优化机制。它通过消除无谓的获取锁和释放锁的操作,提高了程序的性能。偏向锁会记录哪个线程正在访问某个对象,并且后续的访问请求如果是同一个线程,就可以不需要加锁直接访问。
- 轻量级锁(Lightweight Locking):轻量级锁是一种针对单线程访问的情况的锁优化机制。它通过使用标记位或者 CAS 操作来对共享资源进行加锁和解锁,避免了使用重量级锁时的上下文切换和内核态切换的开销。
- 自旋锁(Spin Lock):自旋锁是一种非阻塞的锁机制,当线程无法立即获取锁的时候,它会持续检查锁是否被释放,直到获取锁为止。自旋锁可以减少上下文切换的开销,但在持有锁的时间较长的情况下,会浪费 CPU 资源。
- 适应性自旋锁(Adaptive Spin Lock):适应性自旋锁是一种结合了自旋锁和阻塞锁的锁机制。在刚开始时,线程会采用自旋的方式来等待锁的释放,但随着时间的推移,如果锁仍然没有被释放,线程会逐渐切换到阻塞状态,从而减少 CPU 资源的浪费。
- 分段锁(Segmented Locking):分段锁是一种针对共享资源过多的情况下的一种锁机制。它将共享资源分为多段,每个线程只需要对其中的一部分进行加锁和解锁的操作,从而减少了锁竞争和开销。
- 乐观锁(Optimistic Locking):乐观锁是一种基于锁冲突检测的锁机制。它假设多个线程同时访问和修改同一个数据的概率较小,因此在读取数据时不会加锁,而是在提交数据时检测是否存在冲突。如果冲突,则进行回滚或重试操作。适用于读操作较多的场景。
- 锁粗化(Lock Coarsening):锁粗化是一种针对长时间持有锁的场景的优化策略。如果一个线程在短时间内需要连续多次加锁和解锁,那么可以将这些加锁和解锁操作合并成一个较大的加锁和解锁操作,从而减少了加锁和解锁的次数,提高效率。
什么是不可变对象,对并发有什么帮助?
不可变对象(Immutable Object)是指一旦创建后,其状态(属性)就不能被修改的对象。不可变对象的属性是只读的,不提供任何修改属性的方法,因此不可变对象是线程安全的。 在 Java 中,不可变对象包括 String、基本类型的包装类(如 Integer、Double 等)等。
不可变对象对并发的帮助:
- 线程安全:不可变对象是线程安全的,因为他们不会被其他线程修改。因此多个线程可以同时使用不可变对象,无需额外的同步措施。
- 减少锁竞争:由于不可变对象的状态不能被修改,因此不需要使用锁机制来保护其访问。这减少了锁竞争的可能性,从而提高了程序的性能。
- 缓存优化:由于不可变对象一旦创建后其状态就不能被修改,因此可以将它们用作缓存项。这是应为缓存项的值不会再缓存和使用期间发生改变,从而避免因缓存项状态修改而导致的缓存失效的问题。
需要注意的是,不可变对象也有相应的缺点。例如创建新的不可变对象比创建可变对象需要更多的内存空间,因为每次其状态改变都需要创建新的对象。
谈谈你对 JMM 的理解?
JMM(Java Memory Model)即 Java 内存模型,是一种抽象的概念,它并不真实存在,而是描述了一组规则或规范,通过这些规则、规范定义了程序中各个变量的访问方式。
JMM 内存模型是 Java 并发编程中的核心概念之一。它定义了线程和主内存之间的交互规则,保证了多线程程序的正确性和安全性。深入理解 JMM 对于编写高效、安全的并发程序具有重要意义。
- JMM 内存模型的基本概念
- 主内存:所有线程共享的内存区域,用于存储变量和实例对象。在 Java 中,所有变量都存储在主内存中。
- 工作内存:每个线程私有的内存区域,用于存储主内存中的变量副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
- JMM 内存模型的三大特性
原子性:
- 原子性指的是一个操作是不可分割的最小单位,要么全部执行完毕,要么完全不执行,不存在执行了一半的情况。
- 在 Java 中,原子性通常通过
synchronized
关键字、Lock
锁或者原子类(如AtomicInteger
、AtomicLong
等)来实现。 - 这些机制可以确保某些操作以原子方式执行,从而避免了多线程环境下的数据竞争和不一致问题。
可见性:
- 可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。
- 在 Java 中,可见性问题可以通过
synchronized
关键字、volatile
关键字、显式锁(如Lock
)以及特定的并发工具来解决。 - 使用
volatile
关键字可以确保被修饰的变量的写操作对其他线程立即可见,即使在不同的线程间发生了缓存一致性的问题。 - 使用
synchronized
关键字或者显式锁可以保证代码块在同一时刻只能被一个线程执行,当线程释放锁时,会将该线程对共享变量的修改刷新到主内存,从而保证了可见性。
有序性:
- 有序性指的是程序执行的顺序与代码中的顺序相匹配。
- 在单线程环境下,代码的执行顺序通常是按照编写的顺序执行的。但在多线程环境下,由于指令重排序等原因,代码的执行顺序可能会与编写的顺序不一致。
- Java 内存模型通过 Happens-Before 关系来保证程序中操作的执行顺序。Happens-Before 关系定义了一组偏序关系,用于判断两个操作之间的内存可见性和有序性。
- 使用
volatile
关键字可以禁止指令重排序,从而确保了一些简单操作的有序性。 synchronized
和Lock
也保证了有序性,因为同一时刻只允许一个线程访问同步代码块,自然保证了线程之间在同步代码块的有序执行。
- JMM 内存模型的关键组件
内存屏障(Memory Barrier):
- 内存屏障是 JMM 中用于控制内存访问顺序的指令。它确保指令序列中的内存读写操作按照特定的顺序执行,从而保证线程间的内存可见性和有序性。
- 内存屏障可以分为四种类型:LoadLoad、LoadStore、StoreLoad 和 StoreStore,分别对应不同的读写操作组合。
Happens-Before 规则:
- Happens-Before 是 JMM 中最核心的概念之一,它定义了一组偏序关系,用于判断两个操作之间的内存可见性和有序性。
- 如果一个操作 A happens-before 另一个操作 B,那么 A 的执行结果对 B 是可见的,且 A 的执行顺序排在 B 之前。
- Happens-Before 规则包括程序顺序规则、监视器锁规则、volatile 变量规则、传递性规则、线程启动规则、线程终止规则、线程中断规则和最终结束规则等。
谈谈你对 AQS 的理解?
AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 Java 并发编程框架中的一个核心类,提供了一个用于构建锁和其他同步器的基础框架。
- AQS 的定义与功能
- 定义:AQS 是一个抽象类,它定义了一套多线程访问共享资源的同步器框架。
- 功能:AQS 主要用于实现同步器,如独占锁(ReentrantLock)、共享锁(ReentrantReadWriteLock)等。它通过一个 FIFO(先进先出)的等待队列来管理多线程对资源的访问。
- AQS 的核心思想与实现
- 核心思想:AQS 的核心思想是利用一个队列来管理对共享资源的访问。当线程请求访问共享资源时,如果资源空闲,则将线程设置为有效的工作线程,并将资源标记为已占用。如果资源被占用,则将线程加入队列并阻塞,直到资源空闲并被唤醒。
- 实现方式:AQS 内部维护了一个同步状态(state)和一个同步队列。同步状态表示资源的占用情况,而同步队列则用于存储等待获取资源的线程。AQS 还定义了一些抽象方法,允许子类来实现自定义的同步器。
- AQS 的关键方法
AQS 提供了一些核心方法来实现同步操作,这些方法包括:
- acquire(int arg):尝试获取同步状态,如果获取失败则加入同步队列并阻塞等待唤醒,直到获取同步状态成功。
- tryAcquire(int arg):尝试获取同步状态,如果获取成功则返回 true,否则返回 false。
- release(int arg):释放同步状态,通知其他线程可以尝试获取同步状态。
- tryRelease(int arg):尝试释放同步状态,如果释放成功则返回 true,否则返回 false。
- acquireShared(int arg):尝试获取共享同步状态,如果获取失败则加入同步队列并阻塞等待唤醒,直到获取共享同步状态成功。
- tryAcquireShared(int arg):尝试获取共享同步状态,如果获取成功则返回非负数,否则返回负数。
- releaseShared(int arg):释放共享同步状态,通知其他线程可以尝试获取共享同步状态。
- AQS 的两种模式
AQS 提供了两种模式来满足不同的同步需求:
- 独占模式:只有一个线程可以持有同步状态,如 ReentrantLock。
- 共享模式:多个线程可以同时持有同步状态,如 Semaphore。
- AQS 的应用与重要性
- 应用:AQS 在 Java 并发编程中发挥着重要作用,它是实现各种同步器的基础。例如,ReentrantLock、Semaphore、CountDownLatch 等同步工具类都使用了 AQS 的实现方式。
- 重要性:AQS 是 Java 并发编程中的一个重要组成部分,它简化了锁和其他同步器的实现,提高了并发性能。通过继承 AQS 并实现其抽象方法,开发者可以灵活地实现各种同步机制。
Java 中如何唤醒一个阻塞的线程?
在 Java 中,可以使用 Thread 类的 interrupt() 方法来唤醒一个阻塞线程。
首先需要获取该线程的对象,然后调用 interrupt() 方法:
java
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 线程执行代码
}
});
thread.start(); // 启动线程
// 线程阻塞情况下,调用 interrupt() 方法
thread.interrupt();
当线程被中断后,会设置线程中的中断状态为 true。在代码中可以通过 Thread.currentThread().isInterrupted() 方法来检查线程的中断状态,如果线程检测到自己被中断,那么可以根据中断状态做出相应的处理。
在 Java 中被中断的线程需要自己编写代码响应中断,否则线程仍然处于阻塞。
什么是线程调度器和时间分片?
线程调度器(Thread Schedule)是操作系统内核中的一个重要组件,负责分配并管理处理器时间片,控制多线程程序的执行顺序。当有多个线程同时运行时,线程调度器会在这些线程之间进行切换,使得每个线程都有机会使用到 CPU 的资源,并实现任务的并发执行。
时间分片(Time Slicing)是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配 CPU 时间可以基于线程优先级或者线程等待时间。这样,每个线程运行一段时间后会被暂停,然后调度器会选择下一个线程执行。这种方式可以确保每个线程都能获得一定的运行时间,从而实现多任务并发执行。
谈谈你对 volatile 的理解?
volatile
是 Java 中的一个关键字,用于修饰变量,以确保多个线程能够正确处理该变量的可见性。
- 可见性
volatile
保证了变量的可见性。在多线程环境中,每个线程都有自己的工作内存(也称为线程栈),用于存储变量的副本。当一个线程修改了一个变量的值,其他线程可能不会立即看到这个修改,因为它们可能还在使用旧的副本。使用 volatile
修饰变量可以确保,当一个线程修改了变量的值,这个修改会立即被传播到主内存,并且其他线程能够立即看到这个修改后的值。
- 禁止指令重排序
volatile
还可以禁止指令重排序优化。Java 编译器和运行时可能会对指令进行重排序,以提高性能。但在多线程环境下,这种重排序可能会导致数据不一致的问题。volatile
关键字可以确保被修饰的变量的读写操作不会被重排序到其前后其他读写操作的前面或后面,从而避免了一些潜在的并发问题。
- 不保证原子性
虽然 volatile
保证了变量的可见性和禁止指令重排序,但它并不保证变量的操作是原子的。原子性意味着一个操作要么完全执行,要么完全不执行,中间不会被其他线程打断。例如,对于 volatile
修饰的 int
变量,i++
这样的操作仍然不是原子的,因为它包含了读取、加1和写入三个步骤,可能会被其他线程中断。
- 使用场景
- 状态标志:当多个线程共享一个状态标志,并且这个标志只会被一个线程修改时,可以使用
volatile
。 - 实现轻量级锁:在一些高级的并发编程场景中,如
Double-Checked Locking
(双重检查锁定),volatile
可以用于确保变量的可见性和禁止指令重排序,但通常更推荐使用java.util.concurrent
包中的类来实现线程安全的单例模式。
- 注意事项
volatile
并不能替代synchronized
或其他同步机制。在需要保证原子性和复合操作的情况下,应该使用synchronized
或java.util.concurrent
包中的类。- 使用
volatile
会增加访问变量的开销,因为它需要确保每次读写操作都直接作用于主内存。