*Java多线程安全基础
*.1 多线程安全的三个原则
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
以下的七种方法中只有volatile缺少了原子性,Threadlocal则是另辟蹊径也不具备这三个特性,剩下的五种方法各自单独适用都具备了这三种特性
*.2 保证数据一致性的三种方案
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
- 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
- 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
*.3 并发冲突时的解决方案(从无锁到加锁)
*.3.1 乐观锁(无锁)
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了
乐观锁一般会使用版本号机制(version字段)或 原子类(底层原理CAS算法)实现。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
适用场景
读多写少(读写比>10:1)
数据版本化管理的场景(如库存扣减)
*.3.2 细粒度锁
当多个线程频繁地同时修改某个资源时,CAS会频繁失败,因为其他线程可能已经修改了该资源的值,导致当前线程需要进行重试。频繁的重试会消耗大量的CPU资源,影响程序的效率。
在这种情况下,可以考虑使用细粒度锁。通过将数据分为多个细粒度的部分,确保每个线程只对一个细粒度的锁进行操作,减少锁的粒度,从而降低线程之间的冲突。
-
分段锁(并发集合):ConcurrentHashMap的桶锁机制
-
读写分离(并发集合):CopyOnWriteArrayList的写时复制
-
锁拆分:按业务维度分离锁(如用户ID哈希)
*.3.3 悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。当竞争变得非常激烈时,使用更强的锁机制(如 synchronized
方法/代码块、ReentrantLock
、分布式锁等)来确保数据的一致性和系统的稳定性。这些锁机制的开销较大,会导致性能下降,因此应该在竞争非常激烈或资源争用严重时使用。
synchronized
方法/代码块:适用于单机环境中,当竞争较大时,可以通过synchronized
来保证线程的互斥访问。但由于它是悲观锁,可能导致线程的阻塞。ReentrantLock
:相比synchronized
,提供了更多的功能(如定时锁、可中断锁等),适用于多线程竞争激烈的场景,能够更好地控制锁的获取和释放。- 分布式锁(如 Redisson、Zookeeper、Etcd):适用于分布式系统中,当多个应用实例需要共享资源时,分布式锁能够保证全局的互斥操作。它的开销较大,但适用于分布式环境下的资源共享和协调。
一、synchronized同步代码块
1.1 修饰实例方法(获取类的实例的对象锁)
当 synchronized
修饰实例方法时,表示该方法在某一时刻只允许一个线程访问。每个对象有一个锁,调用该方法的线程必须持有对象的锁才能执行。
public class MyClass {
public synchronized void method() {
// 这里的代码只有一个线程可以执行
}
}
执行过程:当一个线程调用 method()
时,其他线程无法访问该实例的任何 synchronized
方法,直到该线程执行完成。
1.2 修饰静态方法(获取类的Class对象的对象锁)
当 synchronized
修饰静态方法时,锁定的是类的 Class 对象,而不是实例对象。也就是说,同一个类的所有实例都会共享同一把锁。
public class MyClass {
public static synchronized void staticMethod() {
// 这里的代码只有一个线程可以执行
}
}
执行过程:对于静态方法的 synchronized
,所有对 staticMethod()
的调用都需要争夺同一把锁,因此无论哪个对象调用该方法,都会加锁到该类的 Class
对象。
1.3 修饰代码块(获取锁对象的对象锁)
在 Java 中,synchronized
关键字不仅可以修饰方法,还可以修饰代码块,即“块级锁”。当我们使用 synchronized
修饰代码块时,需要在括号中指定一个“锁对象”。这个锁对象是我们希望用于同步控制的对象。它可以是任何一个对象引用,通常使用 this
、类对象或者是其他显式创建的对象。
synchronized (锁对象) {
// 临界区代码,只有持有锁对象的线程才能执行
}
(锁对象)
中的“锁对象”是我们用来控制对代码块的访问权限的对象。当一个线程进入 synchronized
代码块时,它会尝试获得锁对象的锁。如果线程已经获得了锁,那么它可以继续执行代码块中的代码;如果其他线程已经持有该锁,当前线程会被阻塞,直到该锁被释放。
锁对象的选择
(1)this
:如果你希望同步当前实例的某个方法或代码块,则使用 this
。这表示锁住当前对象,只有当前对象的锁被释放,其他线程才能获得该锁。
synchronized (this) {
// 代码块
}
在这种情况下,this
锁对象表示的是当前实例(对象)所持有的锁。因此,同一个实例的多个线程共享这个锁,其他线程无法同时进入 synchronized
块。
(2)类的 Class
对象:如果你希望同步整个类的所有实例对某个代码块的访问,可以使用类的 Class
对象作为锁对象。可以通过 SomeClass.class
获取这个 Class
对象。
synchronized (SomeClass.class) {
// 代码块
}
在这种情况下,所有的线程,无论是属于同一个实例还是不同实例,都争抢同一把锁,所有线程都必须等待前一个线程释放锁。
(3)自定义锁对象:可以使用自定义的 Object
对象作为锁对象。这样做的好处是,我们可以灵活地控制哪些代码块共享锁,避免过多的线程竞争。
private final Object lock = new Object();
public void method() {
synchronized (lock) {
// 代码块
}
}
使用自定义锁对象的好处是,锁的范围可以控制得非常细致,只针对特定的代码块,不会影响到其他地方的并发。
1.4 底层实现( JDK1.6 开始 对synchronized
进行优化)
- synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,
- 使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁(只有重量级锁依赖)实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
- 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
背景:为什么要优化 synchronized
?
在 JDK1.5 之前,synchronized
是重量级锁(monitor lock),加锁和解锁都需要操作系统的 互斥量(mutex),涉及用户态到内核态的切换,性能开销较大。
从 JDK1.6 开始,为了提高 synchronized
在无竞争或低竞争下的性能,引入了 锁的升级机制(Lock Upgrade),也称为 锁的状态膨胀过程。
锁膨胀:JVM 中的对象头(Mark Word)中记录着锁的状态,锁会根据竞争情况在以下三种状态之间逐级升级。
锁状态 | 适用场景 | 特点 | 升级方向 |
---|---|---|---|
✅ 偏向锁(Biased Locking) | 只有一个线程访问该对象(无竞争) | 极快,无加锁开销 | 有竞争 → 轻量级锁 |
✅ 轻量级锁(Lightweight Lock) | 多线程交替访问,但没有同时访问(有竞争但不激烈) | CAS 操作,用户态加锁 | 多线程同时竞争 → 重量级锁 |
⚠️ 重量级锁(Heavyweight Lock) | 多线程同时访问同一对象 | 使用操作系统的互斥量,涉及线程阻塞/唤醒 | 最后手段,性能最差 |
1.4.1 偏向锁(Biased Locking)
-
特点:认为绝大多数对象只会被一个线程使用。
-
原理:首次加锁时,在对象头中记录下 线程ID,后续如果是同一线程访问,则直接进入同步块,不做任何加锁操作。
-
开销极低:加锁几乎为零。
📌 关闭偏向锁:可以通过 JVM 参数 -XX:-UseBiasedLocking
1.4.2 轻量级锁(Lightweight Lock)
-
触发条件:另一个线程尝试访问已经偏向的对象(出现轻微竞争)。
-
原理:
-
将对象头复制到当前线程的栈帧中(称为锁记录)。
-
尝试通过 CAS 将对象头指向该线程的锁记录。
-
如果成功 → 获得锁;失败 → 说明竞争激烈 → 膨胀为重量级锁。
-
📌 不涉及操作系统调用,属于用户态加锁。
1.4.3 重量级锁(Heavyweight Lock)
-
触发条件:多个线程同时竞争同一个对象,CAS 失败。
-
原理:
-
使用操作系统底层的 互斥量(mutex) 实现。
-
由于Java中的线程和操作系统原生线程是一一对应的,线程被阳塞或者唤醒时会从用户态切换到内核态,这种转换非常消耗性能。
-
加锁和解锁成本高,但线程安全性最高。
-
1.4.4 synchronized 支持重入
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
假如一个类中的两个方法都具有synchronized关键字,一个方法内部要调用另一个方法,如果synchronized 不支持重入,这个时候如果一次性要调用该对象的两个方法,调用第一个方法时获取到了该对象的对象锁,第一个方法内部调用第二个方法时因为该对象的对象锁被占用,无法获取,第一个方法又没执行完成,不能释放对象锁,这个时候就会产生死锁。
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}
public synchronized void method2() {
System.out.println("方法2");
}
}
- 由于
synchronized
锁是可重入的,同一个线程在调用method1()
时可以直接获得当前对象的锁,执行method2()
的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized
是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行method2()
时获取锁失败,会出现死锁问题。
二、JUC提供的Lock接口
Java 提供了 java.util.concurrent.locks.Lock
接口以及其实现类(如 ReentrantLock、
ReentrantReadWriteLock)来提供显式锁机制。这种锁比 synchronized
更灵活,可以人为地手动为某段代码加上锁与释放锁。
(1)ReentrantLock
ReentrantLock
是最常用的Lock
接口实现。它是一个可重入锁,允许线程重复进入该锁。ReentrantLock
提供了独占锁(互斥锁)功能,并具有以下特点:
- 可以显式加锁和释放锁,通过
lock()
和unlock()
方法控制。- 支持公平锁和非公平锁(默认),公平锁按照线程请求的顺序获得锁,而非公平锁则允许“抢占”锁。
- 提供了尝试加锁(
tryLock()
),可以设置超时时间,避免线程无限期等待。- 支持中断操作,线程在等待锁时可以被中断(
lockInterruptibly()
)。(2)ReentrantReadWriteLock
ReentrantReadWriteLock
是读写锁的实现,允许多个线程同时读取,但写线程需要独占访问。它分为两种锁:读写锁适用于读多写少的场景,能有效提高并发性能。
- Read Lock:多个线程可以同时获得读锁,只要没有写锁占用。
- Write Lock:写锁是独占锁,要求没有其他读锁或写锁时才能获得。
ReentrantLock与ReentrantReadWriteLock在创建时的区别是:在创建锁的时候,要先创建ReentrantReadWriteLock的对象,再用这个对象的方法去选择创建读锁还是写锁。
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
2.1 Lock接口方法
方法签名 | 说明 |
---|---|
void lock() | 获取锁。如果锁不可用,当前线程会被阻塞,直到锁被释放。即使线程被中断,也会继续等待锁,不会响应中断。 |
void lockInterruptibly() throws InterruptedException | 获取锁,但允许线程在等待时被中断。如果锁不可用,线程会被阻塞,但如果在等待时线程被中断,将抛出 InterruptedException 。 |
boolean tryLock() | 尝试获取锁,如果锁可用则立即返回 true ,否则返回 false 。该方法不会阻塞线程。 |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 在指定的时间内尝试获取锁。如果锁在指定时间内可用,则返回 true ,否则返回 false 。如果在等待时线程被中断,将抛出 InterruptedException 。 |
void unlock() | 释放锁。通常放在 finally 块中以确保锁被释放,避免死锁。 |
Condition newCondition() | 返回一个与该锁关联的 Condition 实例。Condition 提供了类似 Object.wait() 和 Object.notify() 的功能,但更灵活,可以用于实现复杂的线程间协调机制。 |
2.2 ReentrantLock 实现类
2.2.1 ReentrantLock底层原理
ReentrantLock
的底层是基于 AbstractQueuedSynchronizer
(AQS)实现的。AQS 是 Java 并发包中的一个核心框架,用于构建锁和同步器。
(1)AQS 的核心机制
状态变量(state):
AQS 使用一个
volatile int state
变量来表示锁的状态。对于
ReentrantLock
,state
表示锁的持有次数:
state = 0
:锁未被任何线程持有。
state > 0
:锁被某个线程持有,state
的值表示锁的重入次数。等待队列:
AQS 维护一个双向链表,用于存储等待获取锁的线程。
当线程尝试获取锁失败时,会被加入到等待队列中,并进入阻塞状态。
独占模式和共享模式:
AQS 支持两种模式:独占模式(如
ReentrantLock
)和共享模式(如Semaphore
)。
ReentrantLock
使用的是独占模式,即同一时刻只有一个线程可以持有锁。
(2)ReentrantLock
的工作流程
加锁(lock()):
线程调用
lock()
方法时,会尝试通过 CAS(Compare-And-Swap)操作将state
从 0 改为 1。如果成功,表示获取锁,并将当前线程设置为锁的持有者。
如果失败(锁已被其他线程持有),则当前线程会被加入到等待队列中,并进入阻塞状态。
解锁(unlock()):
线程调用
unlock()
方法时,会将state
减 1。如果
state
变为 0,表示锁被完全释放,AQS 会唤醒等待队列中的下一个线程。可重入性:
ReentrantLock
是可重入锁,即同一个线程可以多次获取锁。每次获取锁时,
state
会加 1;每次释放锁时,state
会减 1。只有当
state
减到 0 时,锁才会被完全释放。
2.2.2 ReentrantLock具体使用
(1)lock()
- 功能:获取锁,若锁已被其他线程占用,则阻塞当前线程,直到锁可用为止。
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 执行需要保护的代码
} finally {
lock.unlock();
}
(2)lockInterruptibly()
- 功能:获取锁,但在等待锁的过程中,如果线程被中断,会抛出
InterruptedException
。这种方式适用于希望响应中断的情况。
ReentrantLock lock = new ReentrantLock();
try {
lock.lockInterruptibly();
// 执行需要保护的代码
} catch (InterruptedException e) {
// 处理中断
} finally {
lock.unlock();
}
假设现在线程1已经开始执行上面这段代码并获取到了锁,当线程2开始执行代码的时候获取不到锁,会发生阻塞,如果在这个时候对线程2调用了interrupt()方法,线程2就会被中断,抛出
InterruptedException,
进入catch模块中。thread1.start(); // 启动线程1 Thread.sleep(1000); // 主线程休眠一秒(不往下执行代码),确保 thread1 已经尝试获取锁 thread2.start(); // 启动线程2 // 中断 thread2 线程 thread2.interrupt();
当调用Thread.interrupted()检查当前线程的中断状态或抛出
InterruptedException
异常时,Java 会自动清除当前线程的中断状态。Thread.isInterrupted()
:仅检查当前线程的中断状态,不会清除该状态。
(3)tryLock()
- 功能:尝试获取锁,如果锁已经被其他线程占用,则立即返回
false
。适用于不希望一直等待锁的场景。
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()) {
try {
// 执行需要保护的代码
} finally {
lock.unlock();
}
} else {
// 锁不可用,执行其他逻辑
}
(4)tryLock(long time, TimeUnit unit)
- 功能:尝试在指定的时间内获取锁。如果在该时间内未能获得锁,则返回
false
。这是一个带有超时机制的锁尝试。
ReentrantLock lock = new ReentrantLock();
try {
if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) {
try {
// 执行需要保护的代码
} finally {
lock.unlock();
}
} else {
// 超时未获取锁,执行其他逻辑
}
} catch (InterruptedException e) {
// 处理中断
}
(5)unlock()
- 功能:释放锁,当前线程必须持有锁,否则会抛出
IllegalMonitorStateException
异常。 - 使用:通常在
finally
块中调用,以确保锁能够被释放:
lock.unlock();
2.3 ReentrantReadWriteLock 实现类
- 这里需要非常强调一点,普通的锁(读读、写写、读写都是互斥的),而读写锁唯一不一样的也只是读读不互斥,写写和读写还是互斥。
- 当一个线程持有读锁,其他线程只能再持有读锁而不能持有写锁,本线程也不能持有写锁(这就是不支持写锁升级为读锁的意思)。
- 当一个线程持有写锁,其他线程读锁和写锁都不能再持有,本线程可以再持有读锁(这就是支持写锁降解为读锁的意思)
读写分离:
读锁(共享锁):允许多个线程同时持有读锁。
写锁(独占锁):同一时刻只能有一个线程持有写锁。
可重入性:读锁和写锁都支持重入,即同一个线程可以多次获取同一把锁。
公平性:支持公平锁和非公平锁(默认是非公平锁)。
锁降级:支持将写锁降级为读锁,但不支持锁升级(即读锁不能升级为写锁)。
条件变量:锁支持条件变量(
Condition
),读锁不支持。
2.3.1 ReentrantReadWriteLock底层原理
(1)状态变量的设计
-
AQS 使用一个
int
类型的state
变量来表示锁的状态。 -
在
ReentrantReadWriteLock
中,state
被分为两部分:-
高 16 位:表示读锁的持有次数(读线程的数量)。
-
低 16 位:表示写锁的持有次数(写线程的重入次数)。
-
通过这种设计,ReentrantReadWriteLock
可以同时管理读锁和写锁的状态。
(2)读锁的实现原理
读锁的获取
-
当线程尝试获取读锁时,会检查写锁是否被持有:
-
如果写锁未被持有(
state
的低 16 位为 0),则线程可以获取读锁。 -
如果写锁被持有,则线程需要等待写锁释放。
-
-
获取读锁后,
state
的高 16 位会加 1,表示读锁的持有次数增加。
读锁的释放
-
当线程释放读锁时,
state
的高 16 位会减 1。 -
如果
state
的高 16 位变为 0,表示没有线程持有读锁。
多个线程同时读
-
由于读锁是共享的,多个线程可以同时获取读锁。
-
每个线程获取读锁时,
state
的高 16 位会递增,表示读锁的持有次数增加。
(3)写锁的实现原理
写锁的获取
-
当线程尝试获取写锁时,会检查读锁和写锁是否被持有:
-
如果读锁或写锁被持有(
state
的高 16 位或低 16 位不为 0),则线程需要等待锁释放。 -
如果读锁和写锁都未被持有,则线程可以获取写锁。
-
-
获取写锁后,
state
的低 16 位会加 1,表示写锁的持有次数增加。
写锁的释放
-
当线程释放写锁时,
state
的低 16 位会减 1。 -
如果
state
的低 16 位变为 0,表示写锁被完全释放。
写锁的独占性
-
写锁是独占的,同一时刻只能有一个线程持有写锁。
-
写锁的获取会阻塞其他线程的读锁和写锁请求。
2.3.2 ReentrantReadWriteLock具体使用
创建一个 ReentrantReadWriteLock
实例,ReentrantReadWriteLock
的构造方法有两种形式:
ReentrantReadWriteLock()
:默认构造方法。ReentrantReadWriteLock(boolean fair)
:指定是否公平锁,true
表示公平锁,false
表示非公平锁。公平锁是指线程按照请求锁的顺序来获得锁,而非公平锁则是线程在等待锁时会随机选择一个线程来获得锁,这样能提高效率,但可能会导致“饥饿”现象。
获取读锁和写锁:
- 获取读锁:
lock.readLock().lock()
。 - 获取写锁:
lock.writeLock().lock()
。
释放锁:
- 释放读锁:
lock.readLock().unlock()
。 - 释放写锁:
lock.writeLock().unlock()
。
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int sharedResource = 0;
// 读操作
public void read() {
lock.readLock().lock(); // 获取读锁
try {
System.out.println(Thread.currentThread().getName() + " 读取资源: " + sharedResource);
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
// 写操作
public void write(int value) {
lock.writeLock().lock(); // 获取写锁
try {
sharedResource = value;
System.out.println(Thread.currentThread().getName() + " 写入资源: " + sharedResource);
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 创建多个线程进行读写操作
Thread writer = new Thread(() -> {
example.write(10);
}, "Writer");
Thread reader1 = new Thread(() -> {
example.read();
}, "Reader1");
Thread reader2 = new Thread(() -> {
example.read();
}, "Reader2");
writer.start();
reader1.start();
reader2.start();
}
}
2.4 Condition接口
在没有 Condition
的情况下,线程间的协调常常依赖于 Object
类的 wait()
、notify()
和 notifyAll()
方法。Condition
作为更先进的替代方案,提供了更多的控制机制,可以使线程在等待条件时更加灵活。
Condition
是Lock
接口的一个配套工具,不能单独使用。- 每个
Lock
对象都可以拥有多个Condition
实例,从而实现多个条件的等待与通知。
2.4.1 Condition
的主要方法
在 Java 中,Condition
是与 Lock
对象绑定的,也就是说它的作用并不是单独存在的,而是依赖于一个已经获取的锁。每次调用 await()
或 signal()
时,都必须先持有相关的 Lock
。这样可以保证线程在执行这些操作时是安全的,并且能够正确地控制对共享资源的访问。
(1)await()
方法
释放锁并等待: 当调用 await()
时,线程会释放当前持有的 Lock
锁,并进入等待状态。线程会进入等待队列,直到被唤醒。
-
释放锁:当一个线程调用
await()
后,它释放当前的锁,这样其他线程就可以获得该锁并继续执行。只有等待队列中的线程获得锁之后,才会继续执行。 -
等待状态:在调用
await()
之后,线程并没有立刻返回执行,而是进入了阻塞状态。直到被唤醒,线程才会继续执行。
相关性: await()
依赖于当前线程持有的 Lock
,而且只有在同一个锁的保护下才能执行 await()
,否则会抛出 IllegalMonitorStateException
。
(2)signal()
方法
唤醒线程: signal()
用于唤醒一个等待条件的线程。这个线程会从 await()
阻塞状态中被唤醒,并且当它重新获得锁之后才会继续执行。
- 唤醒机制:调用
signal()
后,会选择一个等待队列中的线程来唤醒它。被唤醒的线程会进入可运行状态,但仍然需要先获取锁才能继续执行。
相关性: signal()
必须在持有锁的状态下调用。它会影响与该锁关联的 Condition
上的等待线程。当调用 signal()
时,当前线程可以将锁释放,唤醒线程会在获取到锁后继续执行。
(3)signalAll()
方法
唤醒所有线程: signalAll()
会唤醒所有在该 Condition
上等待的线程。与 signal()
不同,它会唤醒等待队列中的所有线程,并让它们竞争锁。每个唤醒的线程都会在获取到锁后继续执行。
2.4.2 Condition
的具体使用
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumerExample {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity = 10;
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void produce() throws InterruptedException {
int value = 0;
while (true) {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // 等待队列不满
}
queue.offer(value);
System.out.println("Produced: " + value);
value++;
notEmpty.signal(); // 唤醒消费者
} finally {
lock.unlock();
}
Thread.sleep(100); // 模拟生产耗时
}
}
public void consume() throws InterruptedException {
while (true) {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 等待队列不空
}
int value = queue.poll();
System.out.println("Consumed: " + value);
notFull.signal(); // 唤醒生产者
} finally {
lock.unlock();
}
Thread.sleep(100); // 模拟消费耗时
}
}
public static void main(String[] args) {
ProducerConsumerExample example = new ProducerConsumerExample();
Thread producerThread = new Thread(() -> {
try {
example.produce();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread consumerThread = new Thread(() -> {
try {
example.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producerThread.start();
consumerThread.start();
}
}
Lock和Condition的创建: 我们使用ReentrantLock
创建了一个锁对象lock
,并通过lock.newCondition()
创建了两个Condition
对象notFull
和notEmpty
,分别用于控制队列不满和队列不空的条件。
生产者线程:
-
生产者线程首先获取锁
lock.lock()
。 -
如果队列已满,生产者线程调用
notFull.await()
进入等待状态,直到队列不满。 -
生产者向队列中添加元素后,调用
notEmpty.signal()
唤醒一个等待在notEmpty
上的消费者线程。 -
最后释放锁
lock.unlock()
。
消费者线程:
-
消费者线程首先获取锁
lock.lock()
。 -
如果队列为空,消费者线程调用
notEmpty.await()
进入等待状态,直到队列不空。 -
消费者从队列中取出元素后,调用
notFull.signal()
唤醒一个等待在notFull
上的生产者线程。 -
最后释放锁
lock.unlock()
。
- 确保锁被正确释放:因为在调用signal()方法后线程仍会先尝试获取锁才会执行,所以await()方法和signal()方法都要在try{}模块中,然后在finally中释放刚获取到的锁。
- 条件的状态检查: 每次调用
await()
后都要重新检查条件,因为条件可能在await()
期间发生变化。所以await()
应该总是放在一个while
循环中,而不是if
语句中。await()
和signal()
需要在同一个锁上:Condition
是与Lock
绑定的,只有在获取到相同的锁之后,才能调用await()
或signal()
。因此,await()
和signal()
必须在同一个锁的保护下调用,否则会抛出IllegalMonitorStateException
synchronized
关键字和ReentrantLock
对比
对比项 | synchronized | ReentrantLock |
---|---|---|
可重入性 | ✅ 支持可重入。线程获得锁后可以再次进入,不会死锁 | ✅ 也支持可重入。通过调用 lock() 多次加锁,需对应调用多次 unlock() 解锁 |
是否可响应中断 | ❌ 不可响应中断,如果一个线程在等待获取synchronized 锁时被中断,它会继续等待,直到获取锁为止。 | ✅ 支持中断。可使用 lockInterruptibly() 的加锁方式来获取锁,这种加锁方式的锁支持响应中断 |
公平性 | ❌ 不支持公平性,锁竞争无顺序保证 | ✅ 支持公平性,在创建ReentrantLock 时,可以通过构造函数指定是否为公平锁(默认是非公平) |
加锁/释放方式 | 自动加锁和自动释放,JVM 保证异常时也会释放锁 | 手动加锁和手动释放,需配合 try...finally 块显式调用 lock() 和 unlock() |
是否支持超时 | ❌ 不支持超时加锁 | ✅ 支持。可以调用 tryLock(long timeout, TimeUnit unit) ,指定超时时间获取锁 |
底层机制 | JVM 内置锁,通过对象头的 Mark Word 实现(支持偏向锁、轻量锁、重量锁) | 基于 AQS(AbstractQueuedSynchronizer) 实现,功能更灵活强大 |
三、volatile
3.1 保证变量对所有线程的可见性
volatile
修饰的是对象引用时,只能保证:
引用的变更(即对象指向了新的内存地址)对其他线程是可见的;
但引用指向的对象内部字段的修改,volatile 无法保证可见性;
同理,数组也是一种对象,
volatile int[] arr
只保证引用arr
的变化是可见的,但 数组元素的变化(如 arr[0] = 1)并不具有可见性保证;若希望多个线程之间安全地共享和操作数组元素,应使用
java.util.concurrent.atomic
包下的原子数组类,如:AtomicIntegerArray arr = new AtomicIntegerArray(size);
它的每个元素操作都具备 可见性 + 原子性,底层使用
volatile
和Unsafe
实现。
线程对共享变量(不加volatile
)的操作
-
加载和存储:当线程需要访问某个共享变量时,线程首先会从自己的 工作内存 中查找该变量。如果变量没有被缓存(例如是第一次访问),它会从 主内存 中加载该变量的最新值到 工作内存。
-
修改和同步:线程对变量的修改,通常是先在 工作内存 中进行操作(因为工作内存访问速度比主内存快),然后再通过某些机制同步回 主内存。在没有
volatile
修饰符的情况下,线程的 工作内存 和 主内存 之间并不是实时同步的。
线程对共享变量(加volatile
)的操作
当变量声明为 volatile
时,Java 内存模型做出了特别的约定,以保证对 volatile
变量的写入操作会及时刷新到主内存,同时,读取操作会直接从主内存获取最新值,从而保证了多线程环境下的可见性。
-
写入立即同步到主内存:当一个线程对
volatile
变量进行写操作时,它会确保该变量的值直接更新到主内存,而不是仅仅更新线程的工作内存。写操作的结果立刻对其他线程可见,从而避免了线程 A 更新变量后,线程 B 读取到过时值的情况。在 JVM 中,这一机制通过 Happens-Before 规则来保证。根据 Java 内存模型的规定,
volatile
变量的写操作发生在对该变量的所有后续读操作之前。因此,线程 A 修改了volatile
变量后,线程 B 能够立即看到该修改。 -
读取直接从主内存获取:当线程 B 读取
volatile
变量时,JVM 会确保线程 B 不会从自己的工作内存中读取该变量的副本,而是直接从主内存中读取最新的值。这样,即使线程 B 有自己的工作内存,它也会忽略自己工作内存中的旧值,确保读取到最新的主存值。
3.2 禁止指令重排序优化
volatile关键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。
3.2.1 写-写屏障(Write-Write Barrier)
作用:
保证 volatile 写操作之前的普通写操作,必须全部刷新到主内存,不能被JIT或CPU重排序跑到
volatile
写后面。
场景:
data = 123; // 普通写
flag = true; // volatile写
解释:
-
JMM要求:在
flag=true
写之前,必须保证data=123
已经写入主内存。 -
其他线程只要能看到
flag=true
,一定能看到data=123
。
CPU层面:
storestore barrier
(存储-存储屏障)阻止data=123
被重排序到flag=true
之后。
3.2.2 读-写屏障(Read-Write Barrier)
作用:
保证 volatile 读之后的普通读写操作,不能重排序到
volatile
读前面,避免提前执行。
场景:
if (flag) { // volatile 读
System.out.println(data); // 普通读
}
解释:
-
flag
一旦读取为true
,JMM保证data
的读取一定是之后的,并且能看到最新的值。 -
不会出现:
data
被提早读了,而flag
还没更新的情况。
CPU层面:
loadload barrier
(加载-加载屏障)阻止data
的读取被提前到flag
之前。
3.2.3 写-读屏障(Write-Read Barrier)【核心重点】
作用:
最关键的屏障!防止
volatile
写 被重排序到volatile
读 的后面,以及防止volatile
读 被重排序到volatile
写 的前面。
核心含义:
volatile 写 happens-before volatile 读
,并且之间的所有内存操作不可穿越。
场景:
// 线程1
data = 123; // 普通写
flag = true; // volatile写
// 线程2
if (flag) { // volatile读
System.out.println(data); // 普通读
}
解释:
-
线程1: 在
flag=true
之前的data=123
保证“先行发生”(通过写-写屏障)。 -
线程2: 只要
flag=true
,保证data=123
已经对它可见(通过写-读屏障)。
JMM通过:
-
写-写屏障(flush):让
data=123
写入主内存; -
写-读屏障(happens-before):让
flag=true
后的读data
肯定看到123
; -
读-写屏障:让
flag
判断之前的所有逻辑不会乱跑。
3.3 单例模式:volatile
+ synchronized
(双重检查锁定)
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
// 初始化逻辑
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
(1)单例对象的创建过程:
- 分配内存:首先,JVM 会为单例对象分配内存空间(这一步是通过
new
操作符完成的)。 - 初始化对象:然后,JVM 会调用构造方法来初始化对象的字段(给字段赋值等)。
- 返回引用:最后,返回这个对象的引用,供其他线程使用。
(2)volatile
关键字的作用
可见性:
具体到单例模式中,使用 volatile
可以确保在多线程环境下,当一个线程创建了单例实例后,其他线程能及时看到这个实例,而不是从自己的工作内存中读取到过时的 null
值。
防止指令重排序:
Java 内存模型(JMM)允许对代码中的指令进行重排序,以提高程序的执行效率。但是,这种重排序可能会导致 严重的线程安全问题。具体到单例模式的创建过程,重排序可能会发生在这三步之间:
- 线程 A 执行
new Singleton()
时,JVM 会先分配内存,然后调用构造函数初始化对象,最后返回对象引用。 - 然而,JVM 有可能会将 "返回对象引用" 这一步提早执行,即在对象还没有完全初始化完成之前就将引用返回给其他线程。
这就意味着,当其他线程通过 getInstance()
获取到实例时,实例虽然是一个非 null
的对象引用,但它的字段(尤其是初始化的字段)可能还没有完成初始化,导致线程安全问题。
为了避免这种问题,volatile
会 禁止指令重排序,具体作用是:JVM 在处理 volatile
变量时,会确保 所有的写操作 发生在 返回引用 之前,且 所有的读操作 都从主内存读取最新的值。这样可以保证,当线程 A 完成对象的创建后,其他线程看到的实例是已完全初始化的。通过 volatile
关键字,JVM 会强制 先完成实例化(包括字段的初始化),再返回对象引用,避免了指令重排序问题。
(3)synchronized
关键字的作用
synchronized
关键字用于 同步代码块,确保 只有一个线程 可以访问被 synchronized
修饰的代码区域。在单例模式中,synchronized
关键字通常用在 getInstance()
方法中,确保每次只有一个线程能够进入该方法,从而避免多次实例化。
但是,使用 synchronized
会带来性能开销,因为每次调用 getInstance()
时,都要获取锁。为了避免这种性能损失,在双重检查锁定的实现中,我们通过 volatile
关键字来优化它:
- 第一次检查:如果单例实例已经创建,直接返回实例。
- 第二次检查:如果没有创建,才进入同步代码块,并在同步代码块内再次检查实例是否已创建。这样,只有第一次创建实例时会加锁,之后的调用会跳过锁定操作,提升性能。
四、Threadlocal(线程局部变量)
4.1. Threadlocal的来历
在多线程环境中,如果多条线程都要访问(读写)同一个全局变量,就会遇到并发、安全、数据一致性等问题。我们可能需要加锁、加 volatile 等,或者想办法把这个变量变成方法参数层层传递,十分繁琐。
但有些场景,数据其实不需要被线程之间共享,而是“线程私有”的。举例:
- 当前线程处理的是“请求A”,里面存了“用户ID=1001”;
- 另一个线程处理“请求B”,里面存了“用户ID=2002”;
- 这两条线程对 “用户ID” 的值并没有交互或共享的必要,每个线程只关心“自己的用户ID”即可。
如果我们希望快速地在同一个线程的上下文里保存并访问这样的数据,同时不必担心和其他线程的冲突,也避免了在方法参数间反复传递,那么 ThreadLocal
就登场了。
ThreadLocal 的核心点
- 同一个 ThreadLocal 实例在不同线程中,会分别存一份“线程私有的数据”。
- 线程之间互不影响,也互不可见。
- 这在多层调用、跨模块时,非常方便,省得层层传递或维护公共状态。
4.2. Threadlocal底层原理
4.2.1. 每个线程有一个 ThreadLocalMap
在 Java 的实现中,每一个 Thread
(准确说是 java.lang.Thread
对象)内部,都会有一个 ThreadLocalMap
的属性。它是一个散列表结构,用来存储 <ThreadLocal<?>, Object>
这样的键值对。
-
当我们对某个
ThreadLocal
实例调用set(value)
时,实际操作的是:
当前线程(Thread.currentThread()
)内部的ThreadLocalMap
,往那张表里塞入一条记录:key = 该ThreadLocal
对象,value =value
。 -
当我们对同一个
ThreadLocal
实例调用get()
时,它会去当前线程的ThreadLocalMap
里找 key=这个ThreadLocal
的记录,然后把 value 取出来返回给我们。
所以,每个线程都维护着一张自己的 ThreadLocalMap
,里面可能会存多条记录。不同线程各自一张表,所以存储在其中的数据自然是互不可见的。
4.2.2. “key 为弱引用” “ value是强引用” “需要手动 remove()”
强引用:只要对象在 GC Root 的可达路径中,就不会被回收;
一旦对象不可达(没有任何强引用指向它),就会被 GC 回收。
弱引用:只要一个对象仅被弱引用持有(即没有任何强引用、软引用指向它),
那么 无论是否“可达”,GC 一来就会回收它。
ThreadLocalMap
有一个特殊处理:它对 key(即 ThreadLocal
对象)使用“弱引用(WeakReference)”来避免内存泄露。但如果 ThreadLocal
对象被垃圾回收了,而我们忘记调用 remove()
去清理 Map 里的 value,那么这个 value 可能会变成”Key = null, Value = XXX“ 的悬挂条目(zombie entry),从而导致内存无法被回收,产生内存泄漏。
因此,官方建议:在使用完 ThreadLocal 后,显式调用 remove()
方法,以保证我们在后续不会出现残留数据,也更安全。
JDK 提供的改进方案
自动清理机制:在访问 ThreadLocalMap 时清理无效 Entry
JDK 在每次
set()
、get()
、remove()
的时候,会扫描整个 Map找到那些
key == null
的 Entry,一并清理掉(Entry.value = null,Entry 移除)📌 但前提是你要访问 ThreadLocal 的方法,否则永远不会清理!
4.3 Threadlocal的具体使用
UserContextHolder
是一个自己封装的类:
public class UserContextHolder {
private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();
public static void setUserId(Long userId) {
userIdHolder.set(userId);
}
public static Long getUserId() {
return userIdHolder.get();
}
public static void removeUserId() {
userIdHolder.remove();
}
}
- UserContextHolder内部通过创建对应类型的ThreadLocal对象来存储对应类型的线程局部变量。
UserContextHolder
类本身对所有线程来说是一份(因为它是静态的)。- 但
ThreadLocal
帮我们完成了数据副本的隔离,保证每个线程获取到的值都是自己那份,不会互相干扰。- 因而,“在不同线程中,
UserContextHolder
看似共享一个ThreadLocal
变量,但实际存的值各不相同”,因为它利用线程内部的ThreadLocalMap
做了隔离。
当有多个线程局部变量
public class UserContextHolder {
// 存储当前线程的用户ID
private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();
// 存储当前线程的租户ID
private static final ThreadLocal<String> tenantIdHolder = new ThreadLocal<>();
// 存储当前线程的请求追踪ID(用于日志跟踪)
private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();
// --------------- 用户ID ---------------
public static void setUserId(Long userId) {
userIdHolder.set(userId);
}
public static Long getUserId() {
return userIdHolder.get();
}
public static void removeUserId() {
userIdHolder.remove();
}
// --------------- 租户ID ---------------
public static void setTenantId(String tenantId) {
tenantIdHolder.set(tenantId);
}
public static String getTenantId() {
return tenantIdHolder.get();
}
public static void removeTenantId() {
tenantIdHolder.remove();
}
// --------------- 追踪ID ---------------
public static void setTraceId(String traceId) {
traceIdHolder.set(traceId);
}
public static String getTraceId() {
return traceIdHolder.get();
}
public static void removeTraceId() {
traceIdHolder.remove();
}
// --------------- 统一清理方法,防止内存泄漏 ---------------
public static void clear() {
userIdHolder.remove();
tenantIdHolder.remove();
traceIdHolder.remove();
}
}
- 每个变量都单独使用一个
ThreadLocal
- 这样保证不同的变量互不干扰,每个线程都能存取自己的
userId
、tenantId
、traceId
。
- 这样保证不同的变量互不干扰,每个线程都能存取自己的
- 提供
set()
/get()
/remove()
三个方法setXXX()
用于存储数据getXXX()
用于读取数据removeXXX()
用于清理数据
- 提供
clear()
方法- 一次性清理所有
ThreadLocal
变量,避免在线程池环境下的内存泄漏问题。 - 在 Web 请求拦截器的
afterCompletion()
里调用clear()
,确保线程不会残留上次请求的数据。
- 一次性清理所有
示例(Spring MVC 拦截器):
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 解析 userId
Long userId = ... // 解析 token
UserContextHolder.setUserId(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 清理
UserContextHolder.removeUserId();
}
}
一定要在在 Web 请求拦截器的 afterCompletion()
里调用ContextHolder中定义的用于清除对应线程局部变量的ThreadLocal的remove方法。
- 线程池环境下,线程是被重复使用的。如果这个线程在上一次请求中存了一个 userId=1001,却没有
remove()
, 那下一次有可能处理另一个用户的时候,再get()
还会拿到残留的 1001,引发严重的安全漏洞或业务错误。 - 从内存泄漏角度,JDK 的实现里,如果
ThreadLocal
对象本身被回收了,而你不清理它在ThreadLocalMap
中的存储条目,就会有“key=null,但 value 还存活”的情况,造成泄漏。
4.4 子线程不能直接获取到主线程的ThreadLocal
的值
ThreadLocal
中设置的值是当前线程私有的,子线程默认无法读取父线程中的 ThreadLocal
值。
-
ThreadLocal
本质上是当前线程维护了一个 Map,即:Map<ThreadLocal<?>, Object> threadLocalMap;
-
所以
ThreadLocal.set(x)
实际是将值写入 当前线程的私有 Map -
子线程创建后,是一个新的线程对象,其
ThreadLocalMap
是空的
有没有办法让子线程也能拿到父线程的值?
有!Java 提供了:InheritableThreadLocal
这是 ThreadLocal
的子类,用来实现 线程间传值(一次性拷贝):
static InheritableThreadLocal<String> local = new InheritableThreadLocal<>();
这样子线程启动时,会自动从父线程中复制一份值。
4.5 具体例子
public class Demo {
public void run() {
ThreadLocal<MyData> local = new ThreadLocal<>();
local.set(new MyData()); // 假设这是 10MB 的大对象
}
}
第一步:你执行完这个方法后
-
变量
local
是局部变量,方法执行完之后没有人再引用它 → 它被 GC 回收了(key 没了)✔️
第二步:value 没有被回收!
但注意:
-
ThreadLocalMap
是挂在线程对象上的(如:线程池中的线程) -
它里面还有个 Entry:
Entry {
WeakReference<ThreadLocal> key = null; // ❗被 GC 回收
Object value = new MyData(); // ❗还活着,没被清除
}
虽然你代码里已经没有人能再访问到这个 ThreadLocal
对象,但:
🚨 它的 value(MyData)仍然被这个 Entry 强引用着,GC 无法清理!
并且你也无法再通过 ThreadLocal 拿到这个 value,因为 key = null 了!
这个 value 就变成了一个“悬空但无法释放的大对象” → 内存泄漏!
🧠 内存泄漏 = 有引用链,但程序再也不会用它!
这是垃圾回收的关键点:
GC 只会清理“不可达”的对象,而不是“没用”的对象!
-
Thread → threadLocals → Entry → value
✅ 这是一条强引用链,所以value
不会被 GC。
✅ JDK 为什么把 key 设计成弱引用?
这是有意为之的设计:
-
如果不这样做,那么你永远得手动 remove,否则 ThreadLocal 实例永远不会被回收。
-
设计成弱引用,让
ThreadLocal
不再被代码引用时能自动回收。
但这样就产生了一个副作用:
key 回收了,value 还在 —— 除非你手动 remove,否则 value 永远泄漏。
五、JUC提供的原子类
CAS:原子类底层原理
CAS = Compare-And-Set(比较并交换):给定“内存地址、期望值、新值”,只有当内存中的当前值等于期望值时,才原子地写入新值;否则失败。它由 CPU 原子指令实现(x86 的
CMPXCHG
、ARM 的LDAXR/STLXR
等),Java 通过 JVM 内建(intrinsic)暴露出来,用来构建无锁算法:失败就重试,不阻塞。Java 如何暴露 CAS
JDK 9+:VarHandle(现代推荐)
VarHandle.compareAndSet(obj, expect, update)
,还能选取内存语义(getAcquire
/setRelease
/getVolatile
…)。JDK 8 及更早:Unsafe(历史做法)
Unsafe.compareAndSwapXxx(...)
,仍在底层框架中常见。高层封装:
java.util.concurrent.atomic.*
AtomicInteger/Long/Reference/StampedReference/LongAdder
等,内部就是 CAS + 自旋(底层调用 VarHandle 或 Unsafe)。总结:原子类 → VarHandle/Unsafe → CPU 原子指令。
以
AtomicInteger.incrementAndGet()
为例,逻辑就是三步:
先读快照:拿到当前值
cur
(因为是volatile
,别的线程刚写的你能“很快”看到)。算新值:
next = cur + 1
。尝试提交:用 CAS 去“对比并替换”:
如果“内存里的当前值”还等于你刚刚读到的
cur
,说明没人在你前面改过 → 一次性写入next
,成功返回;如果不等于,说明别人抢先一步改了 → 提交失败,再从第1步重来(这就叫“自旋重试”)。
伪代码:
for (;;) { int cur = value; // volatile 读:大家能看到彼此最新写入 int next = cur + 1; // 计算 if (CAS(value, cur, next)) { return next; // 原子替换成功:要么全成,要么不成 } // 失败表示有人先改了,重试 }
AtomicStampedReference
如何规避 ABA
AtomicStampedReference<T>
用 (引用 + 版本戳) 一起比较:每次更新把戳+1
,于是 A→B→A 会变成 (A,1)→(B,2)→(A,3),CAS 期望 (A,1) 就会失败,检测到“中途变过”。实现上通常是一个volatile
的 pair,对整个 pair 做对象 CAS——也用到了volatile
5.1. 基本原子类
这些类用于对基本数据类型进行原子操作。
5.1.1 AtomicInteger
用于对 int
类型进行原子操作。
方法 | 描述 |
---|---|
AtomicInteger(int initialValue) | 构造函数,设置初始值。 |
int get() | 获取当前值。 |
void set(int newValue) | 设置新值。 |
int getAndSet(int newValue) | 获取当前值并设置新值。 |
boolean compareAndSet(int expect, int update) | 如果当前值等于 expect,则设置为 update。 |
int getAndIncrement() | 获取当前值并自增 1。 |
int getAndDecrement() | 获取当前值并自减 1。 |
int getAndAdd(int delta) | 获取当前值并加上 delta。 |
int incrementAndGet() | 自增 1 并返回新值。 |
int decrementAndGet() | 自减 1 并返回新值。 |
int addAndGet(int delta) | 加上 delta 并返回新值。 |
示例:
5.1.2 AtomicLong
用于对 long
类型进行原子操作,方法与 AtomicInteger
类似。
方法 | 描述 |
---|---|
AtomicLong(long initialValue) | 构造函数,设置初始值。 |
long get() | 获取当前值。 |
void set(long newValue) | 设置新值。 |
long getAndSet(long newValue) | 获取当前值并设置新值。 |
boolean compareAndSet(long expect, long update) | 如果当前值等于 expect,则设置为 update。 |
long incrementAndGet() | 自增 1 并返回新值。 |
long addAndGet(long delta) | 加上 delta 并返回新值。 |
示例:
// 构造函数:设置初始值
AtomicLong al = new AtomicLong(1000L);
// get():获取当前值
System.out.println("get = " + al.get()); // 1000
// set(newValue):设置新值(无返回)
al.set(2000L);
System.out.println("after set = " + al.get()); // 2000
// getAndSet(newValue):返回旧值,同时设置为新值
long old = al.getAndSet(3000L);
System.out.println("getAndSet old = " + old + ", now = " + al.get()); // old=2000, now=3000
// compareAndSet(expect, update):只有当前值等于 expect 才更新为 update
boolean ok1 = al.compareAndSet(3000L, 4000L); // true
boolean ok2 = al.compareAndSet(3000L, 5000L); // false(当前已是4000)
System.out.println("cas1 = " + ok1 + ", cas2 = " + ok2 + ", now = " + al.get()); // now=4000
// incrementAndGet():自增1并返回新值
long v1 = al.incrementAndGet(); // 4001
System.out.println("incrementAndGet = " + v1);
// addAndGet(delta):加上 delta 并返回新值
long v2 = al.addAndGet(500L); // 4501
System.out.println("addAndGet(500) = " + v2);
5.1.3 AtomicBoolean
用于对 boolean
类型进行原子操作。
方法 | 描述 |
---|---|
AtomicBoolean(boolean initialValue) | 构造函数,设置初始值。 |
boolean get() | 获取当前值。 |
void set(boolean newValue) | 设置新值。 |
boolean compareAndSet(boolean expect, boolean update) | 如果当前值等于 expect,则设置为 update。 |
boolean getAndSet(boolean newValue) | 获取当前值并设置新值。 |
示例:
// 构造函数:设置初始值
AtomicBoolean ab = new AtomicBoolean(false);
// get():获取当前值
System.out.println("get = " + ab.get()); // false
// set(newValue):设置新值(无返回)
ab.set(true);
System.out.println("after set = " + ab.get()); // true
// compareAndSet(expect, update):只有当前值等于 expect 才更新为 update
boolean ok1 = ab.compareAndSet(true, false); // true
boolean ok2 = ab.compareAndSet(true, true); // false(当前已是false)
System.out.println("cas1 = " + ok1 + ", cas2 = " + ok2 + ", now = " + ab.get()); // now=false
// getAndSet(newValue):返回旧值,同时设置为新值
boolean old = ab.getAndSet(true); // 返回旧值 false,并设置为 true
System.out.println("getAndSet old = " + old + ", now = " + ab.get()); // old=false, now=true
}
5.2. 引用类型原子类
这些类用于对对象引用进行原子操作。
5.2.1 AtomicReference<V>
用于对对象引用进行原子操作。
方法 | 描述 |
---|---|
AtomicReference(V initialValue) | 构造函数,设置初始引用值。 |
V get() | 获取当前引用。 |
void set(V newValue) | 设置新引用。 |
boolean compareAndSet(V expect, V update) | 如果当前引用等于 expect,则设置为 update。 |
V getAndSet(V newValue) | 获取当前引用并设置新引用。 |
示例:
AtomicReference<String> atomicRef = new AtomicReference<>("initial");
atomicRef.compareAndSet("initial", "updated"); // true, 当前值变为 "updated"
atomicRef.getAndSet("final"); // "updated", 当前值变为 "final"
5.2.2 AtomicStampedReference<V>
在 AtomicReference
的基础上增加了一个 int
类型的版本号(stamp),用于使用 CAS 进行原子更新时可能出现的ABA 问题。
方法 | 描述 |
---|---|
AtomicStampedReference(V initialValue, int initialStamp) | 构造函数,设置初始引用值和版本号。 |
V getReference() | 获取当前引用。 |
int getStamp() | 获取当前版本号。 |
boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) | 如果引用和版本号都匹配,则更新。 |
示例:
AtomicStampedReference<String> atomicStampedRef = new AtomicStampedReference<>("initial", 0);
int[] stampHolder = new int[1];
String ref = atomicStampedRef.get(stampHolder); // ref = "initial", stampHolder[0] = 0
atomicStampedRef.compareAndSet("initial", "updated", 0, 1); // true, 当前值变为 "updated", 版本号变为 1
5.2.3 AtomicMarkableReference<V>
与 AtomicStampedReference
类似,但使用一个 boolean
标记代替版本号。
方法 | 描述 |
---|---|
AtomicMarkableReference(V initialValue, boolean initialMark) | 构造函数,设置初始引用值和标记。 |
V getReference() | 获取当前引用。 |
boolean isMarked() | 获取当前标记。 |
boolean compareAndSet(V expectedReference, V newReference, boolean expectedMark, boolean newMark) | 如果引用和标记都匹配,则更新。 |
示例:
AtomicMarkableReference<String> atomicMarkableRef = new AtomicMarkableReference<>("initial", false);
atomicMarkableRef.compareAndSet("initial", "updated", false, true); // true, 当前值变为 "updated", 标记变为 true
5.3. 字段更新器
这些类用于对对象的字段进行原子更新。
5.3.1 AtomicIntegerFieldUpdater<T>
用于对对象的 int
类型字段进行原子更新。
方法 | 描述 |
---|---|
AtomicIntegerFieldUpdater<T> newUpdater(Class<T> targetClass, String fieldName) | 构造函数,创建一个字段更新器。 |
int get(T obj) | 获取对象 obj 中字段的值。 |
void set(T obj, int newValue) | 设置对象 obj 中字段的新值。 |
boolean compareAndSet(T obj, int expect, int update) | 如果对象 obj 中的字段值等于 expect,则设置为 update。 |
示例:
class Counter {
volatile int count;
}
AtomicIntegerFieldUpdater<Counter> updater = AtomicIntegerFieldUpdater.newUpdater(Counter.class, "count");
Counter counter = new Counter();
updater.incrementAndGet(counter); // 1
5.3.2 AtomicLongFieldUpdater<T>
用于对对象的 long
类型字段进行原子更新。
方法 | 描述 |
---|---|
AtomicLongFieldUpdater<T> newUpdater(Class<T> targetClass, String fieldName) | 构造函数,创建一个字段更新器。 |
long get(T obj) | 获取对象 obj 中字段的值。 |
void set(T obj, long newValue) | 设置对象 obj 中字段的新值。 |
boolean compareAndSet(T obj, long expect, long update) | 如果对象 obj 中的字段值等于 expect,则设置为 update。 |
5.3.3 AtomicReferenceFieldUpdater<T, V>
用于对对象的引用类型字段进行原子更新。
方法 | 描述 |
---|---|
AtomicReferenceFieldUpdater<T, V> newUpdater(Class<T> targetClass, Class<V> fieldClass, String fieldName) | 构造函数,创建一个字段更新器。 |
V get(T obj) | 获取对象 obj 中字段的引用。 |
void set(T obj, V newValue) | 设置对象 obj 中字段的新引用。 |
boolean compareAndSet(T obj, V expect, V update) | 如果对象 obj 中的字段值等于 expect,则设置为 update。 |
示例:
class Container {
volatile String value;
}
AtomicReferenceFieldUpdater<Container, String> updater = AtomicReferenceFieldUpdater.newUpdater(Container.class, String.class, "value");
Container container = new Container();
updater.compareAndSet(container, null, "newValue"); // true, value 变为 "newValue"
5.4. 数组原子类
这些类用于对数组中的元素进行原子操作。
5.4.1 AtomicIntegerArray
用于对 int[]
数组中的元素进行原子操作。
方法 | 描述 |
---|---|
AtomicIntegerArray(int length) | 构造函数,设置数组长度。 |
int get(int i) | 获取索引 i 处的值。 |
void set(int i, int newValue) | 设置索引 i 处的值。 |
int getAndSet(int i, int newValue) | 获取索引 i 处的值并设置新值。 |
boolean compareAndSet(int i, int expect, int update) | 如果索引 i 处的值等于 expect,则设置为 update。 |
示例:
AtomicIntegerArray atomicIntArray = new AtomicIntegerArray(10);
atomicIntArray.set(0, 10);
atomicIntArray.compareAndSet(0, 10, 20); // true, 索引 0 处的值变为 20
5.4.2 AtomicLongArray
用于对 long[]
数组中的元素进行原子操作,方法与 AtomicIntegerArray
类似。
方法 | 描述 |
---|---|
AtomicLongArray(int length) | 构造函数,设置数组长度。 |
long get(int i) | 获取索引 i 处的值。 |
void set(int i, long newValue) | 设置索引 i 处的值。 |
long getAndSet(int i, long newValue) | 获取索引 i 处的值并设置新值。 |
boolean compareAndSet(int i, long expect, long update) | 如果索引 i 处的值等于 expect,则设置为 update。 |
5.4.3 AtomicReferenceArray<E>
用于对对象引用数组中的元素进行原子操作。
方法 | 描述 |
---|---|
AtomicReferenceArray(int length) | 构造函数,设置数组长度。 |
E get(int i) | 获取索引 i 处的引用。 |
void set(int i, E newValue) | 设置索引 i 处的引用。 |
boolean compareAndSet(int i, E expect, E update) | 如果索引 i 处的引用等于 expect,则设置为 update。 |
示例:
AtomicReferenceArray<String> atomicRefArray = new AtomicReferenceArray<>(10);
atomicRefArray.set(0, "initial");
atomicRefArray.compareAndSet(0, "initial", "updated"); // true, 索引 0 处的值变为 "updated"
5.5 累加器
这些类用于在高并发环境下进行高效的累加操作。
5.5.1 LongAdder
LongAdder
是 AtomicLong
的一种优化,它在高并发情况下提供更好的性能。
合并多个线程累加的总和的时候具有更好的性能原理
LongAdder
通过 分段存储 来减少竞争,它的实现与 AtomicLong
不同。在 AtomicLong
中,只有一个单独的值需要保证原子性,因此每次更新时会引起竞争。而 LongAdder
将计数分成多个段(一般是两个或更多个 Cell
)。这些段的更新是独立的,减少了竞争的范围。
具体来说:
- 分段存储:
LongAdder
会把实际的累加值拆分成多个 "桶"(bucket),每个桶有一个独立的累加器。每个线程会选择一个桶来更新,而不是直接操作单一的累加值。 - 并发更新:多个线程在更新不同的桶时,不会互相干扰,因为每个桶都是独立更新的。只有在最终需要合并结果时,才会把各个桶的值汇总成一个总值。
- 减少锁竞争:每个桶都是独立操作的,线程之间的冲突大大减少,从而避免了大量线程对单一变量的竞争。对于性能要求极高的计数场景,这种方式比直接使用
AtomicLong
更加高效。
方法 | 描述 |
---|---|
LongAdder() | 构造函数,初始化为 0。 |
void add(long x) | 将指定值 x 加到当前值上。 |
long sum() | 返回当前所有线程的累加和。 |
long sumThenReset() | 返回当前值并将累加器重置为 0。 |
void increment() | 将当前值增加 1。 |
void reset() | 将累加器重置为 0。 |
LongAdder adder = new LongAdder();
adder.add(10);
adder.add(20);
long sum = adder.sum(); // 30
5.5.2 DoubleAdder
用于高效地累加 double
值。
方法 | 描述 |
---|---|
DoubleAdder() | 构造函数,初始化累加值为 0。 |
void add(double x) | 将指定的 double 值 x 加到当前值上。 |
double sum() | 返回当前所有线程的累加和。 |
double sumThenReset() | 返回当前值并将累加器重置为 0。 |
void increment() | 将当前值增加 1。 |
void reset() | 将累加器重置为 0。 |
DoubleAdder adder = new DoubleAdder();
adder.add(10.5); // 累加 10.5
adder.add(20.25); // 累加 20.25
double sum = adder.sum(); // 获取当前总和 30.75
六、JUC提供的并发集合
BlockingQueue:适用于生产者-消费者模型,具体实现类根据队列大小、优先级等需求选择。
ConcurrentHashMap:适用于高并发读写的场景。
CopyOnWriteArrayList:适用于读多写少的场景。
CopyOnWriteArraySet:适用于读多写少且需要保证元素唯一性的场景。
6.1. BlockingQueue(接口)
BlockingQueue
是一个非常重要的接口,表示一个线程安全的队列,可以用于生产者-消费者问题。它定义了线程安全的插入、删除、查看操作,并且在队列为空时对取出操作进行阻塞,在队列满时对插入操作进行阻塞,帮助实现线程间的协作。
主要特性:
- 阻塞操作:当队列为空时,调用
take()
会阻塞,直到队列中有元素;当队列已满时,调用put()
会阻塞,直到队列中有空间。 - 线程安全:
BlockingQueue
提供了线程安全的操作,避免了多线程环境下的竞态条件。 - 适用于生产者-消费者模式:它常常被用于生产者和消费者线程之间传递任务或数据。
常用实现类:
- ArrayBlockingQueue:一个基于数组的阻塞队列,容量固定。
- LinkedBlockingQueue:一个基于链表的阻塞队列,容量可选,默认为
Integer.MAX_VALUE
。 - PriorityBlockingQueue:一个基于优先级排序的阻塞队列,元素按优先级顺序取出。
- SynchronousQueue:一个没有容量的阻塞队列,插入操作需要等待取出操作,反之亦然。
使用示例:
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);
// 生产者线程
Runnable producer = () -> {
try {
for (int i = 0; i < 5; i++) {
queue.put(i); // 阻塞直到队列有空位
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
// 消费者线程
Runnable consumer = () -> {
try {
for (int i = 0; i < 5; i++) {
Integer item = queue.take(); // 阻塞直到队列有元素
System.out.println("Consumed: " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
new Thread(producer).start();
new Thread(consumer).start();
6.2. ConcurrentHashMap
ConcurrentHashMap
是一个线程安全的哈希表,它允许多个线程同时对其进行插入、更新、删除等操作,避免了同步的瓶颈。
底层原理:
Java 7 中的分段锁机制(Segment)
在 Java 7 中,ConcurrentHashMap
的线程安全是通过分段锁(Segment Locking)机制实现的:
-
分段结构:整个
ConcurrentHashMap
被分成了多个独立的段(Segment
),每个段相当于一个小型的哈希表,拥有自己的锁。段数量通常由默认的并发级别(concurrencyLevel
)来决定。这样,每个段可以独立地进行读写操作,不会影响其他段。
Java 8 中的 CAS 和细粒度锁
Java 8 对 ConcurrentHashMap
进行了重构,取消了分段锁机制,改为使用 CAS(Compare-And-Swap,比较并交换)操作和细粒度锁,主要特点如下:
-
CAS 操作:CAS 是一种硬件级的原子操作,用于无锁更新。Java 8 中的
ConcurrentHashMap
使用 CAS 来实现原子性写操作,避免了锁的开销。在插入或更新元素时,通过 CAS 操作可以直接在不加锁的情况下完成原子更新。- 读取当前值(预期值):CAS 操作会首先读取一个共享变量的当前值。
- 比较预期值:然后,将这个当前值与某个“预期值”进行比较。如果当前值等于预期值,则表示共享变量没有被其他线程更改,可以继续执行写操作。
- 更新值:如果预期值匹配成功,CAS 就会将共享变量更新为新值。否则,CAS 操作失败,通常会重试操作直到成功。
-
细粒度锁:在 Java 8 中,
ConcurrentHashMap
引入了细粒度锁,这种锁的粒度缩小到了单个桶(bucket)或桶内部的数据结构(链表或红黑树)。当发生哈希冲突时,数据会存储在同一个桶内,而同一个桶内的数据可能形成链表或红黑树。- 如果链表或红黑树发生结构性变化(如插入、删除),Java 8 的
ConcurrentHashMap
仅对该桶加锁,确保线程安全性。其他线程仍然可以访问其他桶,避免了锁住整个哈希表所带来的性能损失。 - 这种细粒度锁控制的粒度缩小到了单个桶甚至桶内部的数据结构,进一步提高了并发性能,因为锁冲突的概率显著降低了。
- 如果链表或红黑树发生结构性变化(如插入、删除),Java 8 的
常用方法:
put(K key, V value)
:将key
和value
插入到ConcurrentHashMap
中。get(Object key)
:获取指定key
对应的值。remove(Object key)
:移除指定的key
和它的value
。replace(K key, V oldValue, V newValue)
:原子地替换指定key
的value
。computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)
:如果key
不存在,使用mappingFunction
生成一个新的值并放入ConcurrentHashMap
。
使用示例:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key1", 1);
map.put("key2", 2);
System.out.println(map.get("key1")); // 返回 1
// 并发环境下安全地替换
map.replace("key1", 1, 10); // 如果值是 1,则替换为 10
// 并发环境下获取或创建元素
map.computeIfAbsent("key3", k -> 3);
6.3. CopyOnWriteArrayList
CopyOnWriteArrayList
是一个线程安全的列表实现,它采用 写时复制(Copy-on-write) 的策略。每当执行修改操作(如 add
、remove
等)时,它会复制整个底层数组,这使得它适用于读多写少的场景。
主要特性:
- 写时复制:每次修改列表时都会复制底层数组,这保证了读操作的线程安全,但会导致写操作的性能开销较大。
- 适合读多写少:适用于读操作远多于写操作的场景,读操作是线程安全的,因为读操作无需加锁。
- 元素不重复:修改操作(如
add
、remove
等)会返回一个新的数组,因此并发读不会影响到正在进行的写操作。
常用方法:
add(E e)
:将元素添加到列表的末尾。get(int index)
:获取指定位置的元素。remove(int index)
:删除指定位置的元素。set(int index, E element)
:设置指定位置的元素。
使用示例:
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
System.out.println(list.get(0)); // 返回 1
list.remove(0); // 删除索引为 0 的元素
6.4. CopyOnWriteArraySet
CopyOnWriteArraySet
基于 CopyOnWriteArrayList
实现的一个线程安全的 Set
,它保证在并发环境下对集合的操作不会产生竞态条件,采用写时复制机制。
主要特性:
- 写时复制:每次修改(如
add
或remove
)时,都会复制整个底层数组,这保证了线程安全的读操作,但会导致写操作的性能开销较大。 - 集合无重复元素:保证每个元素都是唯一的。
常用方法:
add(E e)
:向集合中添加元素。remove(Object o)
:从集合中移除元素。contains(Object o)
:检查集合中是否包含指定元素。
使用示例:
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>();
set.add("a");
set.add("b");
System.out.println(set.contains("a")); // 返回 true
set.remove("a"); // 删除元素 "a"
七、JUC提供的同步工具类
7.1 AQS抽象类(实现同步器的框架)
AQS(AbstractQueuedSynchronizer) 是 Java 并发编程中非常重要的一个类,它是 JUC(Java Util Concurrent)包中的一个基础工具类,用于实现同步器的框架。AQS 为许多常见的同步器(如 CountDownLatch
、CyclicBarrier
、Semaphore
、ReentrantLock
等)提供了底层的支持和实现。
7.1.1 AQS 底层结构
volatile int state
:同步状态(独占/共享都复用这个字段)。
-
独占:
0
空闲;>0
被占。重入就是state++/--
。 -
共享:
state
表示剩余许可(如Semaphore
)。
队列节点 Node
(CLH 变体)(简化):
static final class Node {
// waitStatus 语义:
// 1=CANCELLED(取消)、0=默认、-1=SIGNAL(需唤醒后继)、-2=CONDITION、-3=PROPAGATE(共享传播)
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
// 模式:EXCLUSIVE 或 SHARED(AQS 用 nextWaiter 区分)
}
head/tail 不变式:
-
队列始终存在一个哨兵头结点(
head
,不关联实际线程),tail
指向最后节点; -
入队仅通过CAS 设置新 tail,形成FIFO;
-
head.next
是下一位“候唤醒者”的唯一候选。
可见性保证:所有这些关键字段(
state/prev/next/waitStatus
)是volatile
,配合 CAS 的内存屏障 → 建立happens-before。
7.1.2 获取流程(独占)
-
尝试获取:
tryAcquire(arg)
(由具体同步器重写,如ReentrantLock
)-
底层用 CAS 改
state
;成功 → 返回;失败 → 2
-
-
入队:
enq(addWaiter(EXCLUSIVE))
-
若队列未初始化:CAS 设置
head
为哨兵,再将自己 CAS 到tail
; -
否则直接以 O(1) CAS 将自己接到
tail
后。
-
-
队列等待:
acquireQueued(node, arg)
-
若
node.prev == head
,再尝试tryAcquire(arg)
(队首竞争权); -
未成功:检查/设置
node.prev.waitStatus = SIGNAL
,park 当前线程; -
被前驱唤醒后继续循环,直到获取成功。
-
中断处理:在本方法内记录中断,出队后统一
selfInterrupt()
,避免状态乱序。
-
-
成为新头:获取成功,将自己设置为新
head
,head.thread = null
,便于 GC。
7.1.3 释放流程(独占)
-
tryRelease(arg)
(由具体同步器重写)将state
归零; -
若归零成功:
unparkSuccessor(head)
-
首选唤醒
head.next
;若head.next
已取消/为空,向后跳过 CANCELLED 节点找第一个有效后继; -
仅精准唤醒一个(避免惊群)。
-
精准交接:释放方只唤醒“队首的下一个”,与 CLH 的“前驱—后继”点对点交接一致。
7.1.4 CLH 队列的“阻塞型变体”
CLH(Craig–Landin–Hagersten)是一种队列自旋锁协议:
-
FIFO;
-
每个线程有一个本地节点,只自旋等待前驱节点的状态(“我的前驱释放了没?”);
-
避免所有线程盯一个全局标志(会造成缓存一致性总线风暴)。
AQS 采用的是 CLH 的“阻塞型变体”
AQS 并不是把原始 CLH“照抄”——它做了三件至关重要的改造:
(1)忙等 → 阻塞/唤醒
原始 CLH 是自旋;AQS 用 LockSupport.park/unpark
:
-
等待时挂起线程,几乎不占 CPU;
-
唤醒时只
unpark
head.next(精准)。
(2)单向自旋队列 → 双向链
结构上采用双向链表(prev/next
都有),方便清理取消节点;
(3)显式状态协议 + volatile
内存语义
用 waitStatus
做“需要唤醒”的信号约定(SIGNAL=-1
等),且字段都是 volatile 保证可见性。
7.1.5 为什么要用CLH队列不用普通链表
(1)入队竞争大
-
多线程同时入队,需要加大锁/重 CAS,容易在
head/tail
形成热点; -
没有成熟的“只改 tail 的 O(1) 并发入队套路”,失败率高、重试多。
(2)唤醒不精准,容易惊群
-
不知道“下一个该叫谁”,可能要遍历找可用节点,或干脆广播唤醒很多线程再让它们抢(浪费上下文切换)。
(3)可见性难保证
-
“前驱释放 -> 后继可见”这件事,用普通链表你得自己补全happens-before 关系;
-
少了
volatile
/有序写入/写-读屏障的配合,就会出现后继看不到前驱释放的情况。
7.2 CountDownLatch
7.2.1. CountDownLatch 是什么?
CountDownLatch
是 Java 并发包(java.util.concurrent
)中的一个同步工具类,它允许一个或多个线程等待其他线程的完成(或达到某个条件)后再继续执行。可以把它看作是一个计数器,计数器从一个初始值开始递减,直到它的值变为 0,所有等待的线程才会继续执行。
- 计数器:
CountDownLatch
初始化时设置一个整数值,表示需要等待的事件数量。 - 线程等待:线程可以调用
await()
方法等待,直到计数器减到 0。 - 计数器减少:其他线程通过调用
countDown()
方法来将计数器递减,直到达到 0。
7.2.2. CountDownLatch 的作用
CountDownLatch
的主要作用是允许一个或多个线程等待其他线程完成某些操作后再继续执行。它广泛应用于以下场景:
- 并发控制:等待多个线程完成某项任务后,才开始后续的操作。
- 线程协调:控制多个线程在某些时刻的同步,避免某些线程开始过早或过迟。
- 实现“门闩”机制:常用于多个线程完成某些准备工作后再一起开始执行。
7.2.3. CountDownLatch 的使用方法
常见构造方法
CountDownLatch(int count)
:构造一个CountDownLatch
实例,初始化计数器的值为count
。该值通常表示需要等待的事件数量。
主要方法
-
void await()
:让当前线程等待,直到计数器的值减到 0。调用此方法的线程会被阻塞,直到countDown()
被调用并且计数器减为 0。- 如果计数器已经是 0,调用该方法的线程会立即继续执行。
await()
可以抛出InterruptedException
,如果线程在等待过程中被中断,就会抛出异常。
-
void countDown()
:将计数器的值减 1。当一个线程完成某项任务时,它调用countDown()
方法,表示“已完成”。当计数器的值减到 0,所有调用await()
的线程才会被唤醒。 -
long await(long timeout, TimeUnit unit)
:与await()
方法类似,区别在于它允许指定超时时间。如果计数器在超时时间内没有归 0,当前线程将会抛出TimeoutException
异常。 -
int getCount()
:返回当前的计数器值,可以用于查看当前还有多少个事件等待完成。
代码示例
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
// 初始化计数器,值为3,表示等待3个线程
CountDownLatch latch = new CountDownLatch(3);
// 创建3个子线程并启动
for (int i = 0; i < 3; i++) {
new Thread(new Task(latch)).start();
}
// 主线程等待计数器变为0
latch.await();
System.out.println("All tasks are finished. Main thread is resuming.");
}
static class Task implements Runnable {
private CountDownLatch latch;
public Task(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " is working.");
Thread.sleep((long) (Math.random() * 1000)); // 模拟任务
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " finished.");
latch.countDown(); // 完成任务后计数器减1
}
}
}
}
在这个例子中:
- 主线程会等待直到 3 个子线程完成工作(调用
countDown()
)。 - 子线程完成任务后会调用
latch.countDown()
,计数器会减少。 - 当计数器变为 0 时,主线程会继续执行并打印
"All tasks are finished. Main thread is resuming."
。
7.2.4. CountDownLatch 的使用场景
CountDownLatch
在多个线程需要协同工作时非常有用,常见的使用场景包括:
-
多线程并发任务的等待
- 场景:多个线程需要并行处理一些任务,主线程必须等到所有线程都完成后才能继续执行。例如:在分布式系统中,主线程可能需要等待多个服务启动完毕才能继续执行接下来的操作。
- 示例:等待多个并行计算任务完成,才能进行最终的汇总计算。
-
控制并发启动时机
- 场景:多个线程需要在某个时刻同时启动,可以通过
CountDownLatch
来控制。 - 示例:在分布式系统中,多个服务需要在同一时刻启动,可以使用
CountDownLatch
来确保所有服务在同一时刻开始运行。
- 场景:多个线程需要在某个时刻同时启动,可以通过
-
协调多个线程的结束
- 场景:在某些情况下,需要等待多个线程完成某个阶段的工作后,再一起执行后续操作。
- 示例:当多个线程分别加载一些资源并准备好后,主线程可以通过
CountDownLatch
等待所有线程准备完毕,再继续执行其他操作。
-
并行计算的等待
- 场景:多个线程执行相同的计算任务,直到所有计算完成后,主线程才能进行汇总计算。
- 示例:在大数据处理过程中,将数据分割成多个部分并行计算,计算完成后,主线程等待所有计算完成后进行合并。
7.3 CyclicBarrier
7.3.1. CyclicBarrier 是什么?
CyclicBarrier
是 Java 并发包(java.util.concurrent
)中的一个同步工具类,它允许一组线程互相等待,直到所有线程都到达某个同步点后再继续执行。不同于 CountDownLatch
,CyclicBarrier
的计数器在达到指定数量后可以重置,因此可以循环使用。
- 计数器:
CyclicBarrier
初始化时设置一个计数器值,表示需要等待的线程数量。 - 线程等待:当一个线程到达
CyclicBarrier
时,它会调用await()
方法,进入等待状态,直到所有线程都到达CyclicBarrier
同步点。 - 同步点:一旦所有线程都到达同步点,
CyclicBarrier
会释放所有等待的线程,继续执行后续操作。 - 可重用性:
CyclicBarrier
的最大特点是它是 可重用的,每次所有线程达到同步点后,计数器会自动重置,线程可以继续使用CyclicBarrier
。
7.3.2. CyclicBarrier 的作用
CyclicBarrier
的主要作用是协调多个线程在某个特定时刻同时执行。它常用于以下场景:
- 并行任务的分段同步:多个线程在执行不同的任务时,某些步骤必须等待其他线程完成才能继续。例如,多个线程并行处理数据的不同部分,必须等到所有线程都完成后再进行汇总。
- 多个线程之间的协作:多个线程相互依赖、需要协调执行的场景,保证它们在每次到达某个阶段时进行同步。
- 批量处理的阶段性同步:例如,每个线程都在执行相同的计算任务,每一阶段都需要等待所有线程完成某项任务后,才能开始下一阶段。
7.3.3. CyclicBarrier 的使用方法
常见构造方法
CyclicBarrier(int parties)
:构造一个CyclicBarrier
实例,初始化时设置计数器的值为parties
,表示等待parties
个线程。CyclicBarrier(int parties, Runnable barrierAction)
:构造一个CyclicBarrier
实例,除了设置等待线程数量,还可以指定一个Runnable
,当所有线程都到达同步点时,执行该Runnable
。
主要方法
-
void await()
:让当前线程等待,直到所有线程都到达同步点。当一个线程调用await()
时,它会被阻塞,直到其他线程也调用await()
,然后所有线程会一起继续执行。- 如果计数器已经为零,调用
await()
的线程将会立即继续执行。 await()
可以抛出InterruptedException
和BrokenBarrierException
,如果线程在等待过程中被中断或发生异常,就会抛出相应的异常。
- 如果计数器已经为零,调用
-
long await(long timeout, TimeUnit unit)
:与await()
类似,区别在于它允许指定超时时间。如果在超时时间内,计数器没有归零,则会抛出TimeoutException
异常。 -
int getNumberWaiting()
:返回当前在CyclicBarrier
上等待的线程数量。 -
int getParties()
:返回需要等待的线程数量,即构造时设置的parties
。
代码示例
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
public static void main(String[] args) throws InterruptedException {
// 初始化CyclicBarrier,等待3个线程
CyclicBarrier barrier = new CyclicBarrier(3, new Runnable() {
@Override
public void run() {
System.out.println("All threads reached the barrier, proceeding to next step...");
}
});
// 创建3个子线程
for (int i = 0; i < 3; i++) {
new Thread(new Task(barrier)).start();
}
}
static class Task implements Runnable {
private CyclicBarrier barrier;
public Task(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " is working.");
Thread.sleep((long) (Math.random() * 1000)); // 模拟任务执行
System.out.println(Thread.currentThread().getName() + " is waiting at the barrier.");
barrier.await(); // 到达同步点,等待其他线程
System.out.println(Thread.currentThread().getName() + " has passed the barrier.");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
在这个例子中:
CyclicBarrier
被设置为等待 3 个线程。- 每个线程执行任务后会调用
barrier.await()
,并等待其他线程。 - 当所有线程都到达同步点时,
CyclicBarrier
会调用指定的Runnable
(barrierAction
),打印"All threads reached the barrier, proceeding to next step..."
。 - 然后,所有线程继续执行。
7.3.4. CyclicBarrier 的使用场景
-
并行任务的阶段性同步
- 场景:多个线程并行执行任务,但每个线程的任务在某一时刻必须等待其他线程完成,才能继续后续操作。
- 示例:多个线程处理不同的部分数据,并且在某一阶段所有线程完成任务后,才能汇总结果。
- 应用:在大数据处理中,多个线程同时处理不同的数据块,处理完成后,再进行合并。
-
线程间的相互依赖与协作
- 场景:多个线程在执行过程中需要彼此等待,确保在某个时刻所有线程都达到某一条件后,才可以进行后续操作。
- 示例:多个线程并行计算某个复杂的数学模型,在每一轮计算后,所有线程都必须同步,确保下一轮计算在所有线程都准备好后开始。
- 应用:在计算密集型的任务中,不同线程的任务需要通过
CyclicBarrier
来同步和协作。
-
实现批处理的同步
- 场景:多个线程分批次执行任务,某一批任务执行完毕后,再开始下一批任务。
- 示例:在并行处理任务时,每个线程负责一部分任务,当所有线程都完成当前任务时,可以开始下一轮的批处理。
- 应用:例如,在图像处理任务中,每个线程处理一部分图像,当所有线程处理完当前部分后,统一进行后续操作。
-
“多个线程同时到达”问题
- 场景:多个线程需要在某个时刻达到一个同步点,且所有线程都必须同时到达,才能继续后续操作。
- 示例:多个线程在执行某些准备工作后,需要在某个时刻一起启动。
- 应用:用于模拟多个线程在特定时间点同时启动,避免某些线程过早或过晚开始执行。
7.4 Semaphore(信号量)
7.4.1. Semaphore 是什么?
Semaphore
(信号量)是 Java 并发包(java.util.concurrent
)中的一个同步工具类,用于控制对共享资源的访问。它通过维护一个计数器来实现多线程对资源的控制,允许多个线程并发访问共享资源,但限制同时访问的线程数量。
- 计数器:
Semaphore
的计数器表示可用的资源数量。当计数器大于 0 时,表示有可用资源,线程可以通过调用acquire()
获取资源,执行完毕后通过调用release()
释放资源。 - 同步控制:多个线程可以并发执行,但最多只有计数器所设置数量的线程能够同时访问共享资源。当计数器值为 0 时,后续线程必须等待,直到有线程释放资源并使计数器增加。
- 用途:
Semaphore
是一种非常常见的工具,用于控制资源池的大小、限制并发任务数或协调线程之间的工作。
7.4.2. Semaphore 的作用
Semaphore
的主要作用是 控制并发访问资源的数量,确保在高并发场景中,多个线程不会同时访问共享资源而导致资源争用或系统过载。
具体应用包括:
- 限制并发任务数:控制同一时刻可以执行的线程数,避免过多线程竞争某个有限资源。
- 资源池管理:例如数据库连接池、线程池等,限制最大可用连接数或线程数。
- 资源共享控制:多个线程访问共享资源时,限制最多只能有多少个线程同时访问某个资源。
7.4.3. Semaphore 的使用方法
构造方法
Semaphore(int permits)
:初始化Semaphore
实例,设置可用的资源数量permits
,即允许的最大并发线程数。Semaphore(int permits, boolean fair)
:初始化Semaphore
实例,设置可用资源数量并指定是否公平(fair
参数)。如果为true
,表示按照线程请求的顺序分配资源,否则为非公平模式(默认值)。
主要方法
-
void acquire()
:获取一个许可。如果当前没有许可可用,调用线程会被阻塞,直到有可用的许可。通过调用release()
来释放资源,从而使其他线程能够获取许可。- 该方法会抛出
InterruptedException
异常,如果线程在等待许可的过程中被中断,会抛出此异常。
- 该方法会抛出
-
void acquire(int permits)
:尝试一次获取多个许可。如果当前无法获取指定数量的许可,调用线程将会被阻塞,直到许可可用为止。 -
void release()
:释放一个许可。如果有线程在等待许可,它将会唤醒一个等待的线程,允许其获取许可。 -
void release(int permits)
:释放指定数量的许可。 -
int availablePermits()
:返回当前可用的许可数。也就是可以立即被线程获取的资源数量。 -
boolean tryAcquire()
:尝试获取许可,如果当前有可用的许可,则立即获取并返回true
,否则返回false
。此方法不会阻塞线程。 -
boolean tryAcquire(long timeout, TimeUnit unit)
:在指定的时间内尝试获取许可,如果在超时时间内获取到许可则返回true
,否则返回false
。 -
boolean tryAcquire(int permits, long timeout, TimeUnit unit)
:尝试在指定的时间内获取多个许可。
代码示例
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个信号量,允许最多 3 个线程同时访问
Semaphore semaphore = new Semaphore(3, true); // true 为公平模式
// 创建 5 个线程
for (int i = 0; i < 5; i++) {
new Thread(new Task(semaphore)).start();
}
}
static class Task implements Runnable {
private Semaphore semaphore;
public Task(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " trying to acquire permit...");
semaphore.acquire(); // 获取许可
System.out.println(Thread.currentThread().getName() + " acquired permit.");
// 模拟线程工作
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " releasing permit...");
semaphore.release(); // 释放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
在这个例子中:
Semaphore
被设置为最多允许 3 个线程同时获取资源(许可)。- 创建了 5 个线程,模拟它们并发执行的场景。
- 每个线程在获取许可后会执行任务(通过
Thread.sleep()
模拟),然后释放许可。
7.3.4. Semaphore 的使用场景
Semaphore
在 Java 后端开发中的使用场景较为广泛,尤其适用于以下几种情况:
-
限制并发任务数
- 场景:当某个系统资源有限,无法支持过多的线程并发访问时,可以使用
Semaphore
来控制同时能够访问的线程数。此时,Semaphore
的计数器就表示最大允许并发访问的数量。 - 示例:数据库连接池管理,限制同时能够打开的数据库连接数量;HTTP 请求并发控制,限制并发的请求数量。
- 应用:在一些系统中,比如 Web 服务器或者微服务架构,控制同时处理请求的数量,避免过多线程导致系统资源耗尽。
- 场景:当某个系统资源有限,无法支持过多的线程并发访问时,可以使用
-
实现资源池
- 场景:在资源池(如线程池、数据库连接池)中,资源数量是有限的,
Semaphore
可以用来限制池中资源的并发访问。线程通过acquire()
获取资源,执行完毕后调用release()
释放资源。 - 示例:线程池管理,数据库连接池管理,或者通过信号量控制多个线程对共享资源的访问。
- 应用:多线程程序中,使用
Semaphore
来限制线程池中最大并发执行任务数,防止系统过载。
- 场景:在资源池(如线程池、数据库连接池)中,资源数量是有限的,
-
流量控制
- 场景:在分布式系统或高并发场景中,可以使用
Semaphore
来限制请求流量,避免某个服务被过多请求压垮。 - 示例:API 请求限流,控制访问某个外部服务的并发请求数量。
- 应用:例如,API 网关控制请求流量,限制对某个服务的并发调用数。
- 场景:在分布式系统或高并发场景中,可以使用
-
互斥资源访问
- 场景:
Semaphore
也可用作互斥锁的替代。当Semaphore
的计数器为 1 时,只有一个线程可以访问共享资源,类似于互斥锁(ReentrantLock
)的效果。 - 示例:单个资源的并发访问控制,防止多个线程同时访问导致数据不一致或死锁。
- 应用:例如,多个线程访问某个共享变量,使用信号量来确保同一时刻只有一个线程能够访问。
- 场景:
-
限速与节流
- 场景:在一些高并发场景下,为了避免过多的操作占用系统资源,可以使用
Semaphore
实现限速或节流机制。 - 示例:限制每秒请求的最大数量。
- 应用:例如,控制每秒钟最多允许处理 10 个请求,超过请求数量的线程会被阻塞,直到有足够的信号量释放。
- 场景:在一些高并发场景下,为了避免过多的操作占用系统资源,可以使用
7.5 三种同步工具对比
特性 | CountDownLatch | CyclicBarrier | Semaphore |
---|---|---|---|
定义 | 用于使一个或多个线程等待其他线程完成某些操作后再继续执行。 | 用于使一组线程互相等待,直到所有线程都到达某个同步点后再继续执行。 | 控制同时访问某个特定资源的线程数量。 |
计数器 | 内部有一个计数器,表示需要等待的事件次数。 | 内部有一个计数器,表示需要等待的线程数量。 | 内部有一个计数器,表示可用资源的数量(许可数量)。 |
操作方式 | 每次 countDown() 调用减少计数器的值,计数器为 0 时,所有等待的线程才会继续执行。 | 每次线程调用 await() ,都会等待直到计数器值为 0,所有线程到达同步点后,计数器重置。 | 每次 acquire() 获取一个许可,release() 释放一个许可,控制许可的数量。 |
使用场景 | 适合在所有线程完成某些操作后再继续执行,比如任务完成后合并结果。 | 适合在多个线程执行并行任务,并在某个时刻等待所有线程完成同步工作。 | 适用于限制同时执行的线程数,如连接池、数据库资源池等。 |
是否可重用 | 不可重用,一旦计数器为 0,不能再次使用。 | 可重用,计数器在达到同步点后会重置,继续使用。 | 可重用,许可可以被重复获取和释放。 |
线程阻塞 | 线程会在 await() 处阻塞,直到计数器为 0。 | 线程会在 await() 处阻塞,直到所有线程到达同步点。 | 线程会在 acquire() 处阻塞,直到有可用的许可。 |
初始化参数 | 需要指定计数器的初始值(表示需要等待的事件数)。 | 需要指定参与线程数(即等待线程数)。 | 需要指定许可的数量(即控制的并发访问量)。 |
是否公平 | 没有公平性选项。 | 有公平性选项,CyclicBarrier(boolean fair) 。 | 有公平性选项,Semaphore(int permits, boolean fair) 。 |
示例 | 实现等待所有线程完成任务后继续执行。 | 多线程并行执行任务,等待所有线程同步完成后再继续。 | 限制同时访问某个资源的线程数。 |
详细对比说明:
-
CountDownLatch
:- 适用于一次性等待某些事件的场景,例如所有线程执行完某个操作后再继续执行后续任务。比如在分布式系统中,主线程需要等待所有工作线程完成才能继续合并结果。
- 不可重用:一旦计数器变为 0,
CountDownLatch
就会失效,无法再次使用。 - 典型场景:任务完成合并结果、初始化过程等待等。
-
CyclicBarrier
:- 适用于需要线程协作的场景,多个线程可以并行执行任务,并且在某个点等待所有线程都到达后继续。它允许多个线程在某个时刻进行同步。
- 可重用:每次所有线程到达同步点后,计数器会被重置,可以继续使用。
- 典型场景:模拟并行计算,所有线程完成某个阶段的任务后再继续执行,进行下一轮的计算。
-
Semaphore
:- 适用于限制并发线程数的场景。通过信号量来限制同时访问某个共享资源的线程数量。它可以用于流量控制、资源池管理等。
- 可重用:许可可以不断被获取和释放,可以在多个线程间共享。
- 典型场景:控制并发请求数、资源池管理(如数据库连接池、线程池)、限流。
八、死锁
死锁通常是由于线程对共享资源的访问存在相互依赖关系,且这些资源的访问顺序不一致而导致的。具体来说,死锁的四个必要条件包括:
死锁 = 互斥 + 占有且等待 + 不可抢占 + 循环等待,四者同时成立才会死锁。
- 互斥条件(Mutual Exclusion):至少有一个资源是以非共享的方式分配的,即某个资源一次只能被一个线程占用。
- 占有且等待(Hold and Wait):一个线程已经持有了某个资源,同时又在等待其他线程释放它需要的资源。解决方法:一次性申请所有的锁(通过select....for update)
- 不可抢占(No Preemption):已经分配给某个线程的资源,不能强制被其他线程抢占,只能在该线程释放资源后才能由其他线程获取。
- 循环等待(Circular Wait):存在一个线程集合,线程 A 等待线程 B 占有的资源,线程 B 等待线程 C 占有的资源,线程 C 又在等待线程 A 占有的资源,从而形成一个闭环。解决方法:按照主键排序后统一批量更新
8.1 资源竞争并且锁嵌套(锁的顺序不一致)
当多个线程需要访问多个共享资源,且访问的顺序不一致(锁的顺序不一致)时,且每个线程都持有部分资源并等待其他资源释放,很容易发生死锁。
示例:线程 1 持有资源 A,等待资源 B;线程 2 持有资源 B,等待资源 A,形成死锁。
解决方案: 锁顺序化
确保所有线程以相同的顺序获取锁。
public class DeadlockAvoidance {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void thread1() {
synchronized (lock1) {
// 获取锁1
synchronized (lock2) {
// 获取锁2
}
}
}
public void thread2() {
synchronized (lock1) {
// 获取锁1
synchronized (lock2) {
// 获取锁2
}
}
}
}
8.2 长时间占用锁导致其他等待锁的线程长时间阻塞
如果线程持有锁的时间过长,而在持有锁的期间做了很多阻塞操作(例如 I/O 操作)或陷入死循环,这会阻塞其他线程,导致死锁。
示例:线程 1 长时间持有锁 1,同时执行 I/O 操作,线程 2 等待锁 1。
解决方案:使用超时机制(TryLock)
通过Lock
接口的tryLock()
方法设置超时时间,超时后放弃锁并重试或回滚。
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
// 尝试获取锁,设置超时时间
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 操作共享资源
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
九、JMM(Java内存模型)
JMM(Java Memory Model,Java内存模型)是Java虚拟机规范中定义的一套规则,用于描述Java程序中各种变量的访问规则,以及在并发环境下如何保证内存的可见性、有序性和原子性。
组成部分 | 描述 | 作用 |
---|---|---|
主内存 | 所有线程共享的内存区域 | 存储所有实例字段、静态字段和数组元素 |
工作内存 | 每个线程的私有内存空间 | 存储该线程使用到的变量的主内存副本 |
内存交互协议 | 8种原子操作 | 定义主内存和工作内存之间的数据传输规则 |
happens-before 核心六大原则
(1)程序顺序原则(Program Order Rule)
-
在一个线程内,按照代码顺序,书写在前的操作先行发生于后面的操作。
-
即:同一线程中的操作,编译器和 CPU 在优化时仍需保证单线程语义的正确性。
int a = 1; // happens-before
int b = a+1; // 这行
(2)监视器锁原则(Monitor Lock Rule)
-
对一个锁的 unlock 操作,happens-before 之后面对同一个锁的 lock 操作。
-
即:释放锁的线程对共享变量的修改,对随后获取该锁的线程可见。
synchronized(obj) { // unlock happens-before lock
sharedVar = 1;
}
synchronized(obj) {
// 能看到 sharedVar = 1
}
(3)volatile 变量原则(Volatile Variable Rule)
-
对一个
volatile
变量的写,happens-before 后面对这个变量的读。 -
即:保证了
volatile
变量的可见性和禁止指令重排。
volatile boolean flag = false;
flag = true; // 写 happens-before
if (flag) { // 读
// 一定能看到 true
}
(4)传递性(Transitivity Rule)
-
如果 A happens-before B,且 B happens-before C,那么可以推出 A happens-before C。
-
即:
happens-before
关系具备传递性。
a = 1; // A
flag = true; // B
if(flag) { // C
// 一定能看到 a = 1
}
(5)线程启动原则(Thread Start Rule)
-
主线程调用子线程的
start()
方法,happens-before 于子线程的任何操作。
int data = 0;
Thread t = new Thread(() -> {
// 能看到 data = 1
System.out.println(data);
});
data = 1;
t.start();
(6)线程终止原则(Thread Termination Rule)
-
子线程的所有操作 happens-before 于主线程检测到该子线程结束(
join()
成功返回)。
Thread t = new Thread(() -> {
data = 1;
});
t.start();
t.join(); // join happens-before
System.out.println(data); // 一定能看到 data = 1
十、使用 Spring Boot Actuator 进行监控
10.1. 添加依赖
要使用 Spring Boot Actuator,首先需要在 pom.xml
文件中添加 Actuator 依赖。这个依赖是 Spring Boot 提供的监控和管理工具。
Maven依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
10.2. 配置 Actuator
Spring Boot Actuator 默认会启用一些端点,如 /actuator/health
、/actuator/metrics
、/actuator/info
等。你可以在 application.properties
或 application.yml
中进行配置,控制哪些端点是启用的。
启用所有端点
在 application.properties
中启用所有 Actuator 端点:
management.endpoints.web.exposure.include=*
只启用特定端点
例如,启用健康检查和应用信息端点:
management.endpoints.web.exposure.include=health,info
修改 Actuator 路径
如果你想修改 Actuator 端点的基本路径,可以这样配置:
management.endpoints.web.base-path=/custom-actuator
这样,所有 Actuator 的端点都将以 /custom-actuator
为前缀,例如:/custom-actuator/health
。
10.3. 常见端点和使用方式
Spring Boot Actuator 提供了一些非常有用的端点,下面是常用的一些端点及其作用。
10.3.1 健康检查 (Health Check)
健康检查端点 /actuator/health
用于监控应用的健康状态,通常用于生产环境中的监控工具。你可以访问这个端点查看应用是否处于健康状态。
访问:GET http://localhost:8080/actuator/health
返回值:
{
"status": "UP"
}
如果你的数据库、消息队列等服务没有问题,返回 "status": "UP"
;如果有问题,返回 "status": "DOWN"
。
10.3.2 性能指标 (Metrics)
/actuator/metrics
提供关于应用性能的详细信息,如请求次数、内存使用、JVM 指标等。
访问:GET http://localhost:8080/actuator/metrics
返回值:
{
"names": [
"jvm.memory.used",
"jvm.gc.live.data.size",
"http.server.requests"
]
}
你可以通过访问具体的指标,例如 jvm.memory.used
来查看 JVM 内存使用情况:
- 访问:
GET http://localhost:8080/actuator/metrics/jvm.memory.used
10.3.3 应用信息 (Info)
/actuator/info
提供了有关应用的自定义信息,如版本号、构建时间等。你可以在 application.properties
文件中添加自定义的信息,作为应用的一部分。
访问:GET http://localhost:8080/actuator/info
返回值:
{
"app": {
"name": "MyApp",
"version": "1.0.0"
}
}
你可以在 application.properties
中添加自定义信息:
info.app.name=MyApp info.app.version=1.0.0
10.3.4 线程堆栈 (Thread Dump)
/actuator/threaddump
提供应用的线程信息,可以帮助开发人员分析线程死锁、资源竞争等问题。
访问:GET http://localhost:8080/actuator/threaddump
返回值:
{
"threads": [
{
"threadName": "main",
"threadState": "RUNNABLE",
"stackTrace": [
"java.lang.Thread.sleep(Native Method)",
"java.lang.Thread.sleep(Thread.java:339)"
]
}
]
}
这个端点会返回当前所有线程的堆栈信息,帮助开发人员分析线程状态。
10.3.5 日志管理 (Loggers)
通过 /actuator/loggers
端点,你可以动态地查看和修改应用的日志级别。例如,你可以临时调整某个包或类的日志级别为 DEBUG
以便于调试。
访问:GET http://localhost:8080/actuator/loggers
设置日志级别:POST http://localhost:8080/actuator/loggers/org.springframework=DEBUG
10.4. 自定制健康检查
你可以通过实现 HealthIndicator
接口来定义自定义的健康检查。例如,如果你需要检查某个外部服务(如数据库、API)的健康状态,可以创建一个自定义的健康检查器。
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
boolean serviceHealthy = checkServiceHealth(); // 假设检查外部服务的健康状况
if (serviceHealthy) {
return Health.up().withDetail("Custom Service", "Service is healthy").build();
} else {
return Health.down().withDetail("Custom Service", "Service is down").build();
}
}
private boolean checkServiceHealth() {
// 模拟外部服务健康检查
return true;
}
}
添加自定义健康检查后,当你访问 /actuator/health
时,它会显示自定义健康检查的结果。
10.5. 配置 Actuator 端点的安全性
由于 Actuator 提供了大量敏感信息,你可能不希望它们暴露给外部。你可以通过配置 Spring Security 来保护 Actuator 端点。
添加 Spring Security 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置安全性
# 允许访问健康检查端点
management.endpoints.web.exposure.include=health
# 配置基本身份验证
management.endpoints.web.exposure.include=*
spring.security.user.name=admin
spring.security.user.password=admin