多线程
⭐ Java 中的多线程实现方式有哪些?
- Thread:简单但受限(继承)
- Runnable:最常用,无返回值
- Callable:有返回值
- ExecutorService:实际项目必用
- ForkJoinPool:并行计算专用
继承 Thread
- 重写
run(),调用start()启动线程 - 缺点:不能再继承其他类
class MyThread extends Thread {
public void run() {
System.out.println("Thread running");
}
}
new MyThread().start();实现 Runnable
- 更灵活,可与线程分离
- 推荐使用,因为 避免单继承限制
class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable running");
}
}
new Thread(new MyRunnable()).start();使用 Callable + Future
- 有返回值,可抛异常
- 通过线程池
submit()提交任务
class MyCallable implements Callable<String> {
public String call() {
return "Callable running";
}
}
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<String> future = pool.submit(new MyCallable());
System.out.println(future.get());
pool.shutdown();使用线程池 ExecutorService
- 最佳生产实践
- 避免频繁创建销毁线程
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> System.out.println("Thread pool running"));
pool.shutdown();什么是线程安全?如何实现?
1. 什么是线程安全?
线程安全指:
在多线程同时访问共享数据时,不会出现数据错误、结果不一致、状态混乱等问题。
2. 如何实现线程安全?
① 使用同步(锁机制)
对共享资源加锁,确保同一时间只有一个线程访问。
synchronizedReentrantLockReentrantReadWriteLock
synchronized void add() {
count++;
}② 使用线程安全的类
直接使用 JDK 里封装好的线程安全数据结构。
Vector→ 过时,(改用CopyOnWriteArrayList)Hashtable→ 过时,(改用ConcurrentHashMap)BlockingQueue
③ 使用原子类(无锁 CAS)
通过底层 CAS 实现线程安全,性能更高。
AtomicIntegerAtomicLongAtomicReference
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();④ 不共享数据(最根本的线程安全)
如果每个线程使用自己的数据,就天然线程安全。
- 局部变量(线程独享)
- ThreadLocal(为每个线程提供独立副本)
ThreadLocal<Integer> local = ThreadLocal.withInitial(() -> 0);两个线程同时操作同一条数据,如何保证数据安全?
单实例服务
- 使用本地锁即可(如
synchronized、ReentrantLock),因为所有线程都在同一 JVM 中。
- 使用本地锁即可(如
多实例/分布式部署
必须使用外部锁,因为本地锁无法跨 JVM:
- 数据库锁:行锁、
SELECT … FOR UPDATE - 乐观锁:版本号
- 分布式锁:Redis / Zookeeper
- 数据库锁:行锁、
核心要点:
是否需要数据库/分布式锁,取决于是否是分布式场景。本地锁只能在单实例生效。
什么是线程的虚假唤醒?为什么使用 while 而不是 if?
1. 虚假唤醒(Spurious Wakeup)
虚假唤醒指线程在没有收到 notify()、notifyAll() 或条件真正满足的情况下,从 wait() 中意外苏醒。
Java 规范明确允许虚假唤醒,因此开发者必须自行防御。
虚假唤醒是底层 OS 的条件变量允许无理由唤醒导致的,JVM 继承这一特性。
由于竞争条件和系统优化等因素,线程可能在未收到通知的情况下醒来,因此必须用 while 循环重新检查条件。
2. 为什么不能使用 if?
if 只做 一次条件检查。若线程被虚假唤醒,它会直接往下执行,可能导致严重的逻辑错误。
错误示例:
synchronized (lock) {
if (step != 1) {
lock.wait(); // 仅检查一次
}
printSecond.run(); // step 可能并不为 1
}此时若虚假唤醒或条件被其他线程修改,会导致程序顺序混乱。
3. 使用 while 的原因
while 会在每次苏醒后 重新检查条件,确保条件真正满足才继续执行,从而规避虚假唤醒和竞态问题。
正确示例:
synchronized (lock) {
while (step != 1) {
lock.wait(); // 防御虚假唤醒
}
printSecond.run();
}为什么在多线程中不建议使用 static 共享变量?
1. 所有线程共享同一份数据
static 属于类级别,天生共享,多个线程同时修改可能导致:
- 数据竞争
- 状态错乱
- 线程间干扰
2. 可见性问题
即便使用锁或 volatile,仍可能出现不同线程读取到旧值的情况(由 CPU 缓存导致)。
3. 可维护性下降
共享状态会破坏封装,增加耦合,使测试与调试困难。
4. 替代方案
如确需共享,可使用:
AtomicInteger/AtomicReference- 受控的
synchronized代码块 - 线程安全集合(如
ConcurrentHashMap)
什么是锁?
锁是多线程编程中的机制,用于控制对共享资源的访问,确保每次只有一个线程访问资源,避免并发问题。
为什么需要锁?
- 防止竞态条件:避免多个线程同时读写共享数据导致结果异常。
- 保证线程安全:通过互斥访问确保数据的一致性与可见性。
- 协调线程访问顺序:确保某些操作按预期顺序执行。
- 避免资源冲突:避免业务逻辑因并发修改而混乱。
Java 中的主要锁机制
1. synchronized(内置锁 / Monitor Lock)
- Java 关键字,最基本的锁机制。
- 具备 可重入、互斥、自动释放锁 特性。
- 修饰代码块、方法、类。
2. ReentrantLock(重入锁)
来自 java.util.concurrent.locks,功能比 synchronized 更丰富:
- 可重入
- 可中断
- 可公平锁/非公平锁选择
- 支持尝试加锁
tryLock() - 需手动释放锁(
unlock())
适用于复杂并发控制场景。
3. 读写锁 ReentrantReadWriteLock
- 将锁拆分为 读锁(共享) 和 写锁(独占)。
- 多读不互斥,提高性能。
- 写操作仍保持独占性质。
适用于 读多写少 的业务场景,如缓存、配置加载。
4. 乐观锁(CAS)
以无锁方式实现并发控制,如:
AtomicIntegerAtomicReferenceAtomicLong
特点:
- 高性能
- 不阻塞线程
- 依赖 CPU CAS 指令实现乐观并发控制
适合高并发下减少锁竞争。
5. StampedLock
JDK 8 提供的高性能锁:
- 支持 乐观读(性能最高)
- 支持读锁、写锁
- 更适合读多写少的场景
- 适配高吞吐系统
6. Semaphore(信号量)
用于控制能同时访问资源的 线程数量。
场景:连接池、限流器。
7. CountDownLatch / CyclicBarrier(协调类锁)
用于 线程同步与协调,保证多个线程等待彼此或等待条件满足。
8. LockSupport(底层线程阻塞工具)
构建高级同步工具的底层机制,例如:
- AQS(AbstractQueuedSynchronizer)
- ReentrantLock
说一下synchronized 的使用方式
1. 修饰实例方法
- 锁住的是当前实例对象 (
this)。 - 适合保护实例变量的访问。
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}说明:多个线程调用同一个实例的 increment() 方法时,会被同步锁阻塞,保证操作的原子性。
2. 修饰静态方法
- 锁住的是类对象 (
Class对象),与实例无关。 - 适合保护类级别的共享资源。
class Counter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
}3. 修饰代码块
- 可以指定锁对象,更灵活。
- 适合对部分代码进行加锁,而非整个方法。
class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) { // 锁住 lock 对象
count++;
}
}
}说明:只有获得 lock 对象的线程才能执行 synchronized 块中的代码。
什么是可重入锁(Reentrant Lock)?
可重入锁是指同一个线程在外层方法获得锁后,可以在内层方法再次获得该锁而不会被阻塞。
它解决了多线程调用嵌套同步方法时可能出现的死锁问题。
工作原理:
- 当一个线程获得了可重入锁后,它可以多次获取该锁,每次获取时都会对锁的计数器进行增加,直到调用
unlock()的次数与锁的获取次数相等时,锁才会真正被释放。
特点:
- 避免死锁:同一线程可以重复获取自己已经拥有的锁,而不会造成阻塞或死锁。
- 递归调用:当方法内部调用了自己(递归调用)或者调用了其他需要锁的代码时,可以顺利执行。
常见实现:
synchronized:Java的内置synchronized锁就是可重入的。ReentrantLock:java.util.concurrent.locks.ReentrantLock也是可重入的。
示例:
使用
synchronized(隐式可重入锁):public class ReentrantExample { public synchronized void method1() { method2(); // 同一线程可以重复获取锁 } public synchronized void method2() { System.out.println("Inside method2"); } }使用
ReentrantLock(显式可重入锁):import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockExample { private final ReentrantLock lock = new ReentrantLock(); public void method1() { lock.lock(); try { method2(); // 同一线程可以重复获取锁 } finally { lock.unlock(); } } public void method2() { lock.lock(); try { System.out.println("Inside method2"); } finally { lock.unlock(); } } }
ReentrantLock与synchronized的区别是什么?
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取方式 | 隐式获取和释放 | 显式调用lock()和unlock() |
| 可中断性 | 不可中断 | 可中断(lockInterruptibly()) |
| 尝试锁 | 不支持 | 支持(tryLock()) |
| 公平性 | 非公平 | 可选择公平或非公平 |
| 条件变量 | 单一wait/notify机制 | 可创建多个Condition对象 |
什么情况下选择synchronized,什么情况下选择ReentrantLock?
选择
synchronized:- 代码简单,不需要高级锁特性
- 优先考虑代码简洁性和可读性
选择
ReentrantLock:- 需要可中断锁、尝试锁或公平锁
- 需要使用多个条件变量
- 对锁的性能有更高要求
读写锁(ReadWriteLock)
读写锁允许多个线程同时读取共享资源,但写操作时只能单线程独占,适合读多写少的场景,提高并发性能。
工作原理
- 读锁(Read Lock):多个线程可同时持有,只要没有写锁。
- 写锁(Write Lock):独占锁,持有写锁时,其他线程无法读写。
优点
- 高并发读:读操作不会互相阻塞。
- 写操作安全:写锁独占,保证数据一致性。
常用实现
ReentrantReadWriteLock(Javajava.util.concurrent.locks包)
示例
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private int count = 0;
public void read() {
rwLock.readLock().lock();
try {
System.out.println("Reading: " + count);
} finally {
rwLock.readLock().unlock();
}
}
public void write(int value) {
rwLock.writeLock().lock();
try {
count = value;
System.out.println("Writing: " + count);
} finally {
rwLock.writeLock().unlock();
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
new Thread(example::read).start();
new Thread(example::read).start();
new Thread(() -> example.write(100)).start();
}
}什么是公平锁和非公平锁?
- 公平锁:线程按照请求锁的顺序获取锁,先到先得
- 非公平锁:线程获取锁的顺序不确定,可能后请求的线程先获得锁
ReentrantLock默认是非公平锁,可以通过构造函数指定为公平锁:
Lock fairLock = new ReentrantLock(true); // 公平锁如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。Java中的Atomic类(如AtomicInteger)基于CAS实现。
StampedLock是什么?
StampedLock 是 Java 8 引入的一种锁机制,主要用于提高高并发环境下的性能,特别是在读操作远远多于写操作的场景中。
特点:
- 乐观读:允许线程在不获取锁的情况下读取共享数据(称为乐观锁)。只有在写锁竞争时,才会升级为悲观锁。
- 悲观读:类似传统的读锁,获取锁后才能读取数据。
- 写锁:与传统的写锁类似,写操作需要独占锁。
优点:
- 提供更高的并发性,特别是读多写少的场景。
- 通过乐观锁减少了不必要的锁竞争,提高了效率。
使用方式:
readLock():获取乐观锁。tryOptimisticRead():尝试乐观锁,如果读取的数据未被修改则可直接使用,若被修改则需要获取悲观锁。writeLock():获取写锁。unlock():释放锁。
示例:
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private final StampedLock lock = new StampedLock();
private int count = 0;
// 乐观读操作
public int optimisticRead() {
long stamp = lock.tryOptimisticRead(); // 获取乐观锁
int currentCount = count; // 乐观读取数据
if (!lock.validate(stamp)) { // 如果数据被修改,进行悲观读操作
stamp = lock.readLock(); // 获取悲观读锁,可能会阻塞直到没有写锁
try {
currentCount = count; // 再次读取数据
} finally {
lock.unlockRead(stamp); // 释放悲观读锁
}
}
return currentCount; // 返回读取的数据
}
// 写操作
public void increment() {
long stamp = lock.writeLock();
try {
count++;
} finally {
lock.unlockWrite(stamp);
}
}
}锁与原子类的区别是什么?
- 锁:通过阻塞线程来保证线程安全,适合保护复杂的操作
- 原子类:基于CAS实现,是非阻塞的,适合简单的原子操作
原子类(如AtomicInteger)通常比锁具有更好的性能,特别是在竞争较少的情况下。
什么是锁的饥饿和公平性问题?
- 锁饥饿:某些线程长期无法获得锁,导致程序性能下降
- 公平性:通过公平锁机制确保所有线程都有机会获得锁
公平锁可以减少锁饥饿问题,但会带来一定的性能开销。
乐观锁的典型实现方式有哪些?
回答:
版本号机制:
- 每次修改数据时递增版本号。
- 线程读取数据时记录版本号,更新时检查版本号是否与读取时一致。
- 示例:数据库表中添加
version字段。
CAS(Compare-and-Swap):
- 原子操作,三个参数:内存值(V)、预期原值(A)、新值(B)。
- 如果 V == A,则将 V 更新为 B;否则操作失败。
- Java中的
AtomicInteger类基于CAS实现。
CAS是什么?
CAS是一种无锁算法,用于实现原子操作。它包含三个操作数:
- 内存位置(V)
- 预期原值(A)
- 新值(B)
CAS的底层原理是什么?
回答:
- 硬件支持:CAS依赖CPU的原子指令(如x86的
CMPXCHG),确保操作的原子性。 - Java实现:
Unsafe类的compareAndSwap方法是CAS的核心。AtomicInteger等原子类通过Unsafe实现无锁操作。
- 示例:
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
乐观锁有哪些优缺点?
优点:
- 无锁开销:无锁机制,提高了并发性能。
- 非阻塞:线程不会被阻塞,减少上下文切换。
- 适合读多写少的场景:当冲突较少时,性能极高。
缺点:
- 冲突时重试消耗资源:频繁的冲突会导致线程重试,浪费CPU资源。
冲突通常指:
数据版本冲突: 一个线程在读取数据时,其他线程可能已经修改了数据。乐观锁通过记录版本号或时间戳,来检测在操作期间数据是否被其他线程修改。
重试消耗: 乐观锁在检测到冲突时会让线程重试操作,如果冲突发生频繁,线程就会反复重试,消耗CPU资源,影响性能。
- 不适合长事务:长时间持有数据可能增加冲突的概率。比如线程A占用数据的时间非常长(例如复杂计算),线程B可能在乐观锁的“验证阶段”发现版本号已被更新,导致线程B必须反复尝试。
- ABA问题:若数据值被修改后再恢复原值,乐观锁无法检测到(可以使用
AtomicStampedReference解决)。
什么是ABA问题?如何解决?
回答:
- 问题描述:值从A变为B再变回A,CAS操作误认为值未修改。
- 解决方案:
- 版本号:使用
AtomicStampedReference,每次修改时增加版本号。 - 时间戳:记录值的修改时间,确保唯一性。
- 版本号:使用
乐观锁和悲观锁的适用场景分别是什么?
回答:
| 场景 | 乐观锁 | 悲观锁 |
|---|---|---|
| 读写比例 | 读多写少 | 写多读少 |
| 冲突频率 | 冲突少 | 冲突多 |
| 典型实现 | CAS、版本号 | synchronized、ReentrantLock |
| 示例 | 缓存更新、数据库乐观锁 | 临界区代码、数据库行锁 |
什么是 Condition 接口?
回答:
Condition接口是 Java 并发包(java.util.concurrent.locks)中的一个组件,用于替代传统的Object.wait()、notify()和notifyAll()方法,提供更灵活的线程等待和唤醒机制。
与 Lock 配合使用:Condition实例通过Lock.newCondition()创建,每个Lock可以关联多个Condition对象。
Condition 的核心方法有哪些?
回答:
- 等待方法:
await():当前线程进入等待状态,直到被唤醒或中断。await(long time, TimeUnit unit):带超时的等待。awaitUninterruptibly():不可中断的等待。awaitUntil(Date deadline):等待到指定时间。
- 唤醒方法:
signal():唤醒一个等待在该 Condition 上的线程。signalAll():唤醒所有等待线程。