Java多线程安全
多线程安全的选择原则
Java的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
以下的七种方法中只有volatile缺少了原子性,Threadlocal则是另辟蹊径也不具备这三个特性,剩下的五种方法各自单独适用都具备了这三种特性
保证数据的一致性有哪些方案呢?
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
- 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
- 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
1. 乐观锁(无锁)
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了
乐观锁一般会使用版本号机制(version字段)或 原子类(底层原理CAS算法)实现。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
适用场景
读多写少(读写比>10:1)
数据版本化管理的场景(如库存扣减)
2. 细粒度锁
当多个线程频繁地同时修改某个资源时,CAS会频繁失败,因为其他线程可能已经修改了该资源的值,导致当前线程需要进行重试。频繁的重试会消耗大量的CPU资源,影响程序的效率。
在这种情况下,可以考虑使用细粒度锁。通过将数据分为多个细粒度的部分,确保每个线程只对一个细粒度的锁进行操作,减少锁的粒度,从而降低线程之间的冲突。
-
分段锁(并发集合):ConcurrentHashMap的桶锁机制
-
读写分离(并发集合):CopyOnWriteArrayList的写时复制
-
锁拆分:按业务维度分离锁(如用户ID哈希)
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 底层实现
1.4.1 实现原理
- synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,
- 使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
- 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
- synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是—一对应的,线程被阳塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。
1.4.2 锁的升级条件和过程
(1)无锁
- 这是锁的初始状态。此时,Java 对象没有任何同步控制,也没有加锁。线程可以自由地执行访问共享资源的操作。
(2)偏向锁(JDK15 默认关闭,JDK18 已废弃,因为性能收益不明显和JVM 内部代码维护成本太高)
- 偏向锁 会在没有线程竞争的情况下启用。Java 会假设当前的锁不会被其他线程竞争,从而减少加锁和解锁的开销。
- 偏向锁启用条件:当线程第一次访问同步代码块时,JVM 会为该线程创建偏向锁,认为这个线程接下来会频繁地访问该同步代码块。
- 偏向锁的工作原理:当第一个线程获取锁时,JVM 会在对象头中记录线程的 ID,使得后续线程访问该对象时,能够快速跳过锁竞争。其他线程在访问时不会被阻塞,而是通过 CAS(Compare-And-Swap)操作来检查是否有锁竞争。
- 偏向锁撤销:如果第二个线程尝试访问该锁且竞争失败,偏向锁会被撤销,锁会升为轻量级锁。
(3)轻量级锁
当 synchronized
块被执行时,JVM 首先会尝试为该对象使用 轻量级锁。
- 无竞争时,线程会通过 CAS 操作尝试获取锁。如果没有其他线程持有锁,当前线程会成功获取锁。
- 有竞争时,如果线程无法通过 CAS 获取锁,线程会自旋进行多次尝试,避免线程阻塞。这个过程是为了减少锁争用时的性能损失。
(4)重量级锁
如果多个线程争用锁且自旋多次仍无法获取锁,轻量级锁就会被升级为 重量级锁。
- 重量级锁会使用操作系统提供的 互斥量(mutex)来进行管理。当线程进入重量级锁时,线程会被阻塞,操作系统会调度线程执行,直到锁被释放。这会带来较高的性能开销,因为涉及到线程的阻塞和唤醒。
1.4.3 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 维护一个双向链表(CLH 队列),用于存储等待获取锁的线程。
当线程尝试获取锁失败时,会被加入到等待队列中,并进入阻塞状态。
独占模式和共享模式:
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
对比
(1)可重入性
synchronized
:
-
是可重入锁。同一个线程可以多次获取同一个锁(例如在递归方法中)。
-
每次进入一个
synchronized
块,锁的计数器加1;退出时计数器减1,直到计数器为0时锁被释放。
ReentrantLock
:
-
也是可重入锁。同一个线程可以多次获取同一个锁。
-
通过
lock()
和unlock()
方法显式控制锁的获取和释放,内部维护一个计数器来记录重入次数。
(2)是否可响应中断
synchronized
:
-
不支持响应中断。如果一个线程在等待获取
synchronized
锁时被中断,它会继续等待,直到获取锁为止。 -
如果线程在等待锁的过程中被中断,中断状态会被设置,但线程不会抛出
InterruptedException
。
ReentrantLock
:
-
支持响应中断。
ReentrantLock
提供了lockInterruptibly()
方法,允许线程在等待锁的过程中响应中断。 -
如果线程在等待锁的过程中被中断,
lockInterruptibly()
会抛出InterruptedException
,线程可以捕获异常并处理中断逻辑。 -
示例:
ReentrantLock lock = new ReentrantLock(); try { lock.lockInterruptibly(); // 可响应中断的加锁 // 临界区代码 } catch (InterruptedException e) { // 处理中断 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }
(3)公平性
synchronized
:
-
非公平锁。当锁被释放时,等待的线程会竞争锁,不保证等待时间最长的线程优先获取锁。
-
优点是性能较高,因为减少了线程切换的开销。
ReentrantLock
:
-
支持公平锁和非公平锁。在创建
ReentrantLock
时,可以通过构造函数指定是否为公平锁:ReentrantLock fairLock = new ReentrantLock(true); // 公平锁 ReentrantLock unfairLock = new ReentrantLock(); // 非公平锁(默认)
-
公平锁会按照线程等待的顺序分配锁,保证等待时间最长的线程优先获取锁。
-
非公平锁的性能通常优于公平锁,因为减少了线程切换的开销。
(4)底层实现
synchronized
-
是JVM内置的关键字,底层通过监视器锁(Monitor)实现。
-
每个Java对象都有一个关联的监视器锁,
synchronized
通过操作对象的监视器来实现锁的获取和释放。 -
在JVM层面,
synchronized
的实现依赖于对象头中的Mark Word和Monitor对象:-
当线程进入
synchronized
块时,JVM会尝试获取对象的监视器锁。 -
如果锁被其他线程持有,当前线程会被放入等待队列,进入阻塞状态。
-
当锁被释放时,JVM会从等待队列中唤醒一个线程。
-
-
synchronized
的优化:-
JDK 1.6之后,JVM对
synchronized
进行了大量优化,如偏向锁、轻量级锁、自旋锁和锁消除等,以减少锁的开销。
-
ReentrantLock
-
是Java标准库中的一个类,基于AQS(AbstractQueuedSynchronizer)实现。
-
AQS是一个用于构建锁和同步器的框架,内部维护了一个FIFO队列来管理等待线程。
-
ReentrantLock
通过AQS的state
字段记录锁的状态(0表示未锁定,大于0表示锁定次数)。 -
ReentrantLock
的实现:-
加锁时,
ReentrantLock
会尝试通过CAS(Compare-And-Swap)操作修改state
字段。 -
如果锁已被占用,当前线程会被放入AQS的等待队列中。
-
解锁时,
ReentrantLock
会释放锁并唤醒等待队列中的线程。
-
三、volatile
3.1 保证变量对所有线程的可见性
线程对共享变量(不加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中主要通过内存屏障来禁止特定类型的指令重排序。
(1)写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
(2)读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
(3)写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。
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 为弱引用” 及“需要手动 remove()”
ThreadLocalMap
有一个特殊处理:它对 key(即 ThreadLocal
对象)使用“弱引用(WeakReference)”来避免内存泄露。但如果 ThreadLocal
对象被垃圾回收了,而我们忘记调用 remove()
去清理 Map 里的 value,那么这个 value 可能会变成”Key = null, Value = XXX“ 的悬挂条目(zombie entry),从而导致内存无法被回收,产生内存泄漏。
因此,官方建议:在使用完 ThreadLocal 后,显式调用 remove()
方法,以保证我们在后续不会出现残留数据,也更安全。
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 使用场景
一次 HTTP 请求对应一个工作线程
在很多后端 Web 框架(包括 Spring Boot, Tomcat 容器)中,请求进来后通常会被分配到线程池中的某个工作线程去处理。请求处理完再归还线程到池里。
在整个处理过程中(Controller -> Service -> DAO -> ...),都处于同一个线程上下文。这时,ThreadLocal
就特别有用。比如:
(1)存储“当前登录用户ID”
- 在请求开始时,通过过滤器/拦截器解析出 token,得到 userId;
UserContextHolder.setUserId(userId)
(内部就是用 ThreadLocal 存储)- 后面的 Service、DAO 想获取当前用户ID时,随时
UserContextHolder.getUserId()
即可。 - 最后在请求结束时
UserContextHolder.remove()
及时清理。
(2)存储“TraceId / RequestId” 以便日志关联
- 日志系统常用 ThreadLocal 来注入一个“traceId”,这样在一条完整请求的日志里就能输出统一的追踪 ID。
- 此外,像 Log4j / Logback 的 MDC 也是借助
ThreadLocal
原理来存储信息。
五、JUC提供的原子类
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 并返回新值。 |
示例:
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) | 获取当前值并设置新值。 |
示例:
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
等)提供了底层的支持和实现。
AQS 关键构件
AQS 核心通过以下几个组件来实现线程的管理和同步:
(1)同步队列(Sync Queue)
- AQS 使用一个 FIFO 队列 来管理所有正在等待的线程。线程会在队列中等待,直到能够获得需要的资源(如锁或许可)。当资源释放时,AQS 会根据队列的顺序唤醒线程。
- AQS 的底层实现是通过 双向链表 来管理等待的线程。每个线程在等待时,都会被包装成一个 Node 节点,并添加到队列中。线程会在队列中阻塞,直到同步器的状态发生变化(如资源被释放),此时队列中的线程会被唤醒。每个节点保存了线程本身和与同步器相关的状态信息。节点有两种模式:一个是 共享模式,另一个是 独占模式。队列中的线程会按顺序排列,AQS 会确保线程按照 FIFO 顺序被唤醒。
(2)状态标记(State)
- AQS 中有一个整型变量
state
,用来表示同步器的当前状态。例如,state
可以代表锁是否被占用,或者信号量的剩余许可数量等。 - 许多同步器(例如
ReentrantLock
)会根据state
的值来判断是否能够成功获得锁,或者是否满足其他同步条件。 - AQS 使用 CAS(Compare and Swap,比较并交换)操作来保证对
state
的修改是线程安全的。通过 CAS,AQS 可以保证线程在并发访问时的一致性,避免竞争条件。
(3)AQS 内部实现的锁与同步方法
acquire(int arg)
和release(int arg)
:这两个方法分别用于获取和释放锁。tryAcquire(int arg)
和tryRelease(int arg)
:这两个方法在具体的同步器中可以被覆盖,用来尝试获取和释放锁,通常是基于当前状态来判断是否能够获得锁。isHeldExclusively()
:用于判断当前同步器是否处于独占状态。-
线程阻塞与唤醒 :AQS 提供了
park()
和unpark()
方法来阻塞和唤醒线程。这些方法通过与 LockSupport 结合使用,确保线程的阻塞和唤醒操作能够高效且可靠地进行。
AQS 的工作原理
-
线程获取资源 当线程请求某个资源时,AQS 会首先检查当前的状态,如果资源可用,则允许线程继续执行。如果资源不可用,则线程被加入到 AQS 的等待队列中,进入 阻塞状态,等待其他线程释放资源。
-
线程释放资源 当持有资源的线程释放资源时,AQS 会检查等待队列,按照 FIFO 顺序唤醒队列中的线程,确保所有线程都能够公平地获得资源。
-
队列管理 在 AQS 中,所有等待的线程都被放入一个 FIFO 队列 中,队列中的线程会依次尝试获取资源。AQS 确保了线程在队列中的顺序,避免了线程饥饿(即某个线程一直无法获取资源)。
-
CAS 操作 AQS 在底层实现时使用了 CAS 操作来进行原子性检查和修改状态,确保多线程环境下状态的同步操作不会出现竞争条件。
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):一个线程已经持有了某个资源,同时又在等待其他线程释放它需要的资源。
- 不可抢占(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();
}
}
九、异步事件处理
9.1. 什么是异步事件处理?
在很多应用中,任务的执行顺序不一定需要严格同步,尤其是那些不影响主流程的任务,可以通过事件驱动的方式解耦主流程。例如,在用户注册完成后,发送欢迎邮件或日志记录等不需要立即返回结果的任务,可以通过异步事件处理完成。
Spring 的事件驱动模型允许在发布事件时,通知多个监听器。通过这种方式,事件发布者与事件处理者(监听器)之间不直接耦合,且可以通过异步方式处理耗时的任务,避免阻塞主线程。
9.2. 实现异步事件处理的步骤
步骤概述:
- 创建一个自定义事件类,继承
ApplicationEvent
。 - 创建一个事件发布者,用于发布事件。
- 创建一个事件监听器,处理该事件。
- 使用
@Async
实现异步事件处理。
9.3. 异步事件处理的示例
9.3.1. 自定义事件类
首先,创建一个自定义事件类,继承 ApplicationEvent
,并定义事件的内容。
import org.springframework.context.ApplicationEvent;
// 自定义事件类,继承 ApplicationEvent
public class UserRegistrationEvent extends ApplicationEvent {
private String username;
public UserRegistrationEvent(Object source, String username) {
super(source);
this.username = username;
}
public String getUsername() {
return username;
}
}
-
source
参数代表事件的源(通常是触发事件的对象)。在publishEvent()
方法中,this
被作为source
传递给了UserRegistrationEvent
构造函数,表明事件是由当前对象触发的。 UserRegistrationEvent
继承自ApplicationEvent
,super(source)
会调用父类ApplicationEvent
的构造方法,将source
赋值给ApplicationEvent
中的source
属性。ApplicationEvent
会将这个事件源(this
)存储起来,以便在后续处理事件时,监听器能够知道事件是由哪个对象发布的。
9.3.2. 事件发布者
创建一个发布者类,负责在用户注册成功后发布 UserRegistrationEvent
事件。
方式一:通过依赖注入的方式直接注入 ApplicationEventPublisher
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.stereotype.Component;
@Component
public class UserRegistrationPublisher {
@Autowired
private ApplicationEventPublisher eventPublisher; // 通过 @Autowired 注入
public void publishEvent(String username) {
UserRegistrationEvent event = new UserRegistrationEvent(this, username);
eventPublisher.publishEvent(event); // 发布事件
}
}
eventPublisher.publishEvent(event)
:这里的eventPublisher
是ApplicationEventPublisher
,它是 Spring 用来发布事件的工具。调用publishEvent()
方法会将事件发布到 Spring 容器中,所有监听该事件的监听器都会收到通知并执行相应的处理方法。
方式二:实现 ApplicationEventPublisherAware接口类来注入ApplicationEventPublisher。
这种方式就必须要重写setApplicationEventPublisher方法
如果你不重写这个方法,eventPublisher
就无法被注入,你将无法发布事件。
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.stereotype.Component;
@Component
public class UserRegistrationPublisher implements ApplicationEventPublisherAware {
private ApplicationEventPublisher eventPublisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.eventPublisher = publisher;
}
public void publishEvent(String username) {
// 发布自定义事件
UserRegistrationEvent event = new UserRegistrationEvent(this, username);
eventPublisher.publishEvent(event);
}
}
this
指的是调用publishEvent()
方法的对象本身,即 事件发布者。- 实现
ApplicationEventPublisherAware
接口的为什么能将ApplicationEventPublisher
注入到事件发布类中,用于发布事件:这源于 Spring 的 IoC(控制反转)机制和Aware
接口的设计。当你实现某个Aware
接口时,Spring 容器知道你需要一些特定的 Spring 组件(如ApplicationEventPublisher
),并在初始化时通过调用setApplicationEventPublisher()
等方法为你注入这些组件。 - 要重写setApplicationEventPublisher方法:当实现
ApplicationEventPublisherAware
接口时,Spring 会在启动时自动调用setApplicationEventPublisher()
方法,并将ApplicationEventPublisher
实例传递给你。这使得你的类可以在运行时发布事件。
9.3.3 事件监听器
创建一个监听器类,负责处理 UserRegistrationEvent
事件,并使用 @Async
注解实现异步处理。
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class UserRegistrationListener {
// 使用 @Async 处理事件的监听
@Async
@EventListener
public void handleUserRegistrationEvent(UserRegistrationEvent event) {
System.out.println("异步处理用户注册事件,发送欢迎邮件给:" + event.getUsername());
// 模拟耗时任务
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("欢迎邮件发送完成");
}
}
- 监听器方法只要被正确标注了
@EventListener
,并且事件被发布, 监听器方法handleUserRegistrationEvent(UserRegistrationEvent event)
的参数类型就是要监听的事件类型(这里的事件类型是自己定义的事件类)。Spring 会自动将发布的事件传递到监听器方法中作为参数。
9.3.4. 启用异步支持
为了让 @Async
生效,你需要在应用的配置类中启用异步处理支持。
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
9.3.5. 测试异步事件
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@Autowired
private UserRegistrationPublisher publisher;
@PostMapping("/register")
public String registerUser(@RequestParam String username) {
// 模拟用户注册
System.out.println("用户注册成功:" + username);
// 发布用户注册事件
publisher.publishEvent(username);
return "用户注册成功";
}
}
运行结果
当用户通过 /register
接口注册成功后,UserRegistrationPublisher
会发布用户注册事件,UserRegistrationListener
接收到事件后,异步执行耗时任务(如发送欢迎邮件)。主线程不会被耗时任务阻塞。
用户注册成功:test_user
异步处理用户注册事件,发送欢迎邮件给:test_user
欢迎邮件发送完成
十、使用 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