❝本文基于Java 17
一、为什么你必须搞懂JMM?
并发编程中90%的诡异BUG,都源于你对JMM的认知缺失:
- 明明修改了共享变量,另一个线程却始终看不到最新值
- 单线程测试完全正常,多线程运行就出现偶发的逻辑错误
- 加了volatile还是有并发问题,却找不到根源
- 双重检查锁(DCL)单例,为什么必须加volatile?
这些问题的本质,都和CPU缓存、指令重排、多线程内存交互规则直接相关,而JMM(Java内存模型)正是Java官方定义的、用来规范多线程环境下内存交互行为的唯一标准,它屏蔽了不同CPU架构、操作系统的底层差异,让Java并发程序在所有平台上都能保持一致的行为。
❝重要区分:JMM ≠ JVM内存结构
- JVM内存结构(堆、栈、方法区、程序计数器等):解决的是「数据在内存中存在哪里、怎么存」的问题,是JVM运行时的内存分区规则
- JMM:解决的是「多线程下数据什么时候可见、执行顺序如何保证」的问题,是并发内存交互的规范,二者是完全不同的两个概念,切勿混淆
二、JMM要解决的三大核心问题
并发编程的所有问题,都可以归结为三个核心特性的保障:原子性、可见性、有序性,JMM的核心目标就是为这三个特性提供统一的规范保障。
| 特性 | 问题根源 | 核心定义 |
| 原子性 | 线程切换导致的操作中断 | 一个操作要么全部执行完成,要么完全不执行,执行过程中不会被线程调度打断 |
| 可见性 | CPU多级缓存、缓存一致性协议的延迟 | 一个线程修改了共享变量的值,其他线程能立即看到这个修改的最新值 |
| 有序性 | 编译器、CPU的指令重排优化 | 程序的执行顺序,和代码的编写顺序保持一致 |
其中,原子性由synchronized、JUC原子类保障;而可见性与有序性的底层实现,完全依赖JMM的三大核心:指令重排、内存屏障、先行发生原则,下面我们逐层拆解。
三、核心一:指令重排——性能优化的双刃剑
3.1 什么是指令重排?
为了最大化利用CPU的运算性能,编译器和CPU会在不改变单线程执行结果的前提下,对代码指令进行重新排序,这个过程就是指令重排。
举个通俗的例子:你做饭的代码顺序是「洗锅→接水→等水烧开→洗米→切菜」,重排后你会在等水烧开的间隙洗米、切菜,最终的结果和原顺序完全一致,但效率大幅提升,这就是指令重排的核心逻辑。
3.2 指令重排的分类与约束
❝注意:Java前端编译器javac几乎不会做指令重排,绝大多数重排发生在JIT即时编译期和CPU运行期,这是很多博客的常见错误
3.2.1 不可突破的底线:as-if-serial语义
as-if-serial语义是指令重排的核心约束:不管怎么重排,单线程环境下的程序执行结果不能被改变。
为了遵守这个语义,编译器和CPU不会对存在数据依赖的指令进行重排。
- 数据依赖:如果两个指令操作同一个变量,且其中一个是写操作,那么这两个指令就存在数据依赖,重排会改变单线程结果,因此绝对禁止。
- 示例:
int a = 1;
int b = a + 1;
- 这两行代码存在数据依赖,b的值依赖a的赋值结果,因此绝对不会被重排。
- 反例:
int a = 1;
int b = 2;
- 这两行代码没有数据依赖,完全可能被重排,先执行
b=2,再执行a=1,单线程结果不变。
3.2.2 多线程下的致命漏洞
as-if-serial语义只保证单线程的执行结果,完全不考虑多线程环境的影响,这就是并发诡异问题的核心根源。
我们通过一个可直接运行的完整示例,直观看到指令重排带来的问题:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 指令重排问题复现示例
* @author ken
* @date 2024
*/
@Slf4j
public class ReorderDemo {
/** 共享变量a */
private static int a = 0;
/** 标记变量flag */
private static boolean flag = false;
/** 统计重排发生的次数 */
private static final AtomicInteger REORDER_COUNT = new AtomicInteger(0);
/** 循环执行次数,提高重排发生概率 */
private static final int LOOP_COUNT = 100000;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < LOOP_COUNT; i++) {
// 每次循环重置状态
a = 0;
flag = false;
CountDownLatch latch = new CountDownLatch(2);
// 写线程
Thread writerThread = new Thread(() -> {
a = 1;
flag = true;
latch.countDown();
});
// 读线程
Thread readerThread = new Thread(() -> {
if (flag) {
if (a == 0) {
// 进入此分支,说明发生了指令重排:flag=true先执行,a=1后执行
REORDER_COUNT.incrementAndGet();
log.error("指令重排发生!a={}, flag={}", a, flag);
}
}
latch.countDown();
});
writerThread.start();
readerThread.start();
latch.await();
}
log.info("循环执行结束,总次数:{},重排发生次数:{}", LOOP_COUNT, REORDER_COUNT.get());
}
}
代码说明:
- 代码采用JDK17
- 写线程先执行
a=1,再执行flag=true;读线程如果看到flag=true,正常逻辑下a应该等于1 - 但由于指令重排,
a=1和flag=true没有数据依赖,可能被重排为先执行flag=true,再执行a=1 - 此时读线程看到
flag=true,但a还没被赋值,就会出现a=0的情况,也就是重排发生了
运行结果:在JDK17的Server模式下,多次循环后一定会出现重排,日志会打印出重排发生的记录,完美复现了指令重排带来的并发问题。
3.3 控制依赖的重排陷阱
as-if-serial语义只保证数据依赖,不保证控制依赖,这是另一个常见的重排陷阱。 示例代码:
int a = 0;
boolean flag = false;
// 写线程
a = 1;
flag = true;
// 读线程
if (flag) { // 控制依赖
int b = a + 1;
}
写线程的a=1和flag=true没有数据依赖,读线程的if(flag)是控制依赖,编译器和CPU会采用猜测执行的优化:提前计算a+1的值,等flag=true的时候直接赋值给b,这就会导致重排问题,和上面的示例逻辑完全一致。
四、核心二:内存屏障——重排与可见性的底层解药
既然指令重排会带来这么多问题,那怎么禁止重排?答案就是内存屏障。
4.1 什么是内存屏障?
内存屏障是CPU层面的一组特殊指令,它有两个核心作用:
- 禁止指令重排:内存屏障两边的指令,不能越过屏障进行重排,相当于给指令执行加了一道「不可跨越的墙」
- 保证内存可见性:强制刷新CPU缓存到主内存,同时让其他CPU中对应的缓存行失效,确保所有线程看到的变量值是最新的,和MESI缓存一致性协议配合实现
JMM屏蔽了不同CPU架构的底层差异,在不同平台(x86、ARM等)会自动生成对应的内存屏障指令,开发者不需要关心底层实现,只需要遵循JMM规范即可。
4.2 JMM定义的四大标准内存屏障
JSR-133规范中,定义了四种标准的内存屏障,覆盖了所有重排禁止场景:
| 屏障类型 | 核心作用 | 禁止的重排类型 |
| LoadLoad | 保证读操作的顺序性 | 禁止前面的Load读指令,和后面的Load读指令重排 |
| StoreStore | 保证写操作的顺序性 | 禁止前面的Store写指令,和后面的Store写指令重排 |
| LoadStore | 保证读先于写执行 | 禁止前面的Load读指令,和后面的Store写指令重排 |
| StoreLoad | 全能屏障,性能开销最大 | 禁止前面的Store写指令,和后面的Load读指令重排,同时具备其他三种屏障的所有效果 |
4.3 内存屏障的实际应用:volatile的底层实现
我们日常开发中用的volatile关键字,底层就是通过内存屏障实现的,这也是volatile能保证有序性和可见性的核心原因。
JMM为volatile定义了严格的内存屏障插入策略,100%符合JSR-133规范:
- 在每个volatile写操作的前面,插入StoreStore屏障
- 在每个volatile写操作的后面,插入StoreLoad屏障
- 在每个volatile读操作的后面,插入LoadLoad屏障
- 在每个volatile读操作的后面,插入LoadStore屏障
我们用这个策略,来解决上面的指令重排问题:只需要给flag变量加上volatile修饰,就能彻底禁止重排。 修改后的核心代码:
/** 标记变量flag,添加volatile修饰,禁止指令重排 */
private static volatile boolean flag = false;
原理说明:
flag是volatile变量,写操作flag=true的前面有StoreStore屏障,禁止前面的a=1(Store写)和flag=true(volatile写)重排,保证a=1一定先于flag=true执行flag=true的后面有StoreLoad屏障,保证写操作完成后,所有读操作都能看到最新值- 读线程对
flag的读操作后面有LoadLoad和LoadStore屏障,保证先读flag,再读a,不会重排
修改后的代码,无论循环多少次,都不会再出现重排问题,完美解决了有序性和可见性问题。
4.4 其他场景的内存屏障应用
除了volatile,JMM中其他关键字的底层也依赖内存屏障:
- synchronized:
monitorenter(加锁)相当于volatile读,会插入LoadLoad、LoadStore屏障;monitorexit(解锁)相当于volatile写,会插入StoreStore、StoreLoad屏障,因此synchronized能保证原子性、可见性、有序性 - final关键字:JSR-133增强了final的语义,在对象构造函数执行结束后,会插入StoreStore屏障,保证final字段的初始化一定对其他线程可见,不会出现半初始化的对象
- JUC锁:ReentrantLock等锁的底层,通过AQS的volatile state变量实现,同样依赖内存屏障保证可见性和有序性
4.5 JDK9+新特性:VarHandle灵活控制内存屏障
JDK9引入了VarHandle(变量句柄),用来替代不安全的Unsafe类,提供了更安全、更灵活的内存屏障控制能力,性能比volatile更优,是目前JDK官方推荐的并发编程工具。
下面是JDK17下VarHandle的完整使用示例,实现和volatile相同的效果,同时支持更细粒度的内存屏障控制:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* VarHandle内存屏障使用示例
* @author ken
* @date 2024
*/
@Slf4j
public class VarHandleDemo {
private int a = 0;
private boolean flag = false;
private static final VarHandle A_HANDLE;
private static final VarHandle FLAG_HANDLE;
private static final int LOOP_COUNT = 100000;
private static final AtomicInteger ERROR_COUNT = new AtomicInteger(0);
// 静态初始化VarHandle,绑定对应的变量
static {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
A_HANDLE = lookup.findVarHandle(VarHandleDemo.class, "a", int.class);
FLAG_HANDLE = lookup.findVarHandle(VarHandleDemo.class, "flag", boolean.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < LOOP_COUNT; i++) {
VarHandleDemo demo = new VarHandleDemo();
CountDownLatch latch = new CountDownLatch(2);
// 写线程,使用release模式写,对应StoreStore屏障
Thread writerThread = new Thread(() -> {
A_HANDLE.setRelease(demo, 1);
FLAG_HANDLE.setRelease(demo, true);
latch.countDown();
});
// 读线程,使用acquire模式读,对应LoadLoad屏障
Thread readerThread = new Thread(() -> {
if ((boolean) FLAG_HANDLE.getAcquire(demo)) {
if ((int) A_HANDLE.getAcquire(demo) == 0) {
ERROR_COUNT.incrementAndGet();
log.error("并发问题发生!a={}, flag={}", demo.a, demo.flag);
}
}
latch.countDown();
});
writerThread.start();
readerThread.start();
latch.await();
}
log.info("循环执行结束,总次数:{},错误次数:{}", LOOP_COUNT, ERROR_COUNT.get());
}
}
代码说明:
- 基于JDK17
setRelease方法对应StoreStore屏障,保证前面的写操作不会和后面的release写重排getAcquire方法对应LoadLoad屏障,保证前面的acquire读不会和后面的读操作重排- 性能比volatile更优,支持更细粒度的内存屏障控制,是JUC包底层的核心实现工具
五、核心三:先行发生原则——判断并发安全的唯一依据
到这里你可能会问:难道我每次写并发代码,都要去关心底层的内存屏障和指令重排吗?
当然不需要。JMM给开发者提供了一套上层的、无需关心底层实现的判断规则,这就是先行发生原则(happens-before),它是判断多线程环境下,共享变量的访问是否存在竞争、操作是否线程安全的唯一依据。
5.1 先行发生原则的权威定义
JLS Java SE 17 Edition 17.4.5 章节明确规定:
❝如果一个操作A happens-before 操作B,那么A操作的执行结果,对B操作完全可见,且A操作的执行顺序排在B操作之前。 同时,happens-before关系具有传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
必须纠正的致命误区
很多人误以为「先行发生」就是「时间上先执行」,这是完全错误的!
- 时间上先执行的操作,不一定具有先行发生关系
- 具有先行发生关系的操作,时间上不一定先执行(重排不影响先行发生的结果保证)
举个例子:线程A在时间上先修改了一个普通变量,线程B在时间上后读取这个变量,因为没有任何先行发生规则的约束,所以A的修改对B不一定可见,哪怕时间上A先执行。
5.2 JMM内置的8大天然先行发生规则
JMM内置了8条无需任何额外同步、天然存在的先行发生规则,所有的线程安全保障,都基于这8条规则实现,必须逐条吃透,100%准确记忆:
1. 程序顺序规则
在同一个线程内,按照代码的控制流顺序,前面的操作happens-before后面的所有操作。
❝注意:是控制流顺序,不是代码顺序,包含分支、循环等场景;单线程内的重排不影响此规则,因为as-if-serial语义保证了执行结果和代码顺序一致。
2. 管程锁定规则
对同一个锁的unlock解锁操作,happens-before后续对这个锁的lock加锁操作。
❝这是synchronized和ReentrantLock的核心规则,保证了加锁前的所有修改,对加锁后的线程完全可见。
3. volatile变量规则
对一个volatile变量的写操作,happens-before后续对这个变量的读操作。
❝JSR-133增强后的核心规则,保证了volatile写的所有结果,对后续的volatile读完全可见,也是volatile能解决可见性和有序性问题的规范依据。
4. 线程启动规则
对一个Thread对象的start()方法调用,happens-before这个线程内的所有操作。
❝也就是说,主线程启动子线程前的所有修改,子线程启动后都能完全看到。
5. 线程终止规则
一个线程内的所有操作,happens-before其他线程对这个线程的终止检测操作。
❝比如通过Thread.join()等待线程结束、Thread.isAlive()判断线程是否终止,只要检测到线程终止,线程内的所有修改都对当前线程可见。
6. 线程中断规则
对一个线程的interrupt()中断方法调用,happens-before被中断线程检测到中断事件的操作。
❝比如通过isInterrupted()、interrupted()方法检测到中断,一定能看到调用interrupt()之前的所有修改。
7. 对象终结规则
一个对象的初始化完成(构造函数执行结束),happens-before这个对象的finalize()方法的开始执行。
❝保证了finalize()方法执行时,对象的所有字段都已经完成初始化,不会看到半初始化的对象。
8. 传递性规则
如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。
❝这是先行发生原则的核心扩展能力,通过传递性,可以组合多条规则,形成完整的可见性保障。
5.3 先行发生原则实战应用
我们通过两个经典案例,彻底搞懂先行发生原则的实际使用。
案例1:用先行发生原则解释volatile解决重排问题
回到我们最开始的重排示例,给flag加上volatile修饰后,为什么能解决问题?我们用先行发生规则拆解:
- 程序顺序规则:写线程内的
a=1happens-beforeflag=true(volatile写) - volatile变量规则:写线程的
flag=true(volatile写) happens-before 读线程的if(flag)(volatile读) - 程序顺序规则:读线程内的
if(flag)happens-beforeSystem.out.println(a) - 传递性规则:
a=1happens-beforeSystem.out.println(a)
因此,a=1的修改对读线程的打印操作完全可见,绝对不会出现a=0的情况,完美解决了重排问题。
案例2:经典的DCL双重检查锁单例,为什么必须加volatile?
DCL单例是面试高频题,很多人只知道要加volatile,却不知道为什么,我们用先行发生原则彻底讲透。
首先看错误的DCL单例写法(没有volatile):
package com.jam.demo;
/**
* 错误的DCL单例示例(无volatile)
* @author ken
* @date 2024
*/
public class WrongDclSingleton {
private static WrongDclSingleton INSTANCE;
private WrongDclSingleton() {}
public static WrongDclSingleton getInstance() {
if (INSTANCE == null) { // 第一次检查
synchronized (WrongDclSingleton.class) { // 加锁
if (INSTANCE == null) { // 第二次检查
INSTANCE = new WrongDclSingleton(); // 问题根源
}
}
}
return INSTANCE;
}
}
问题根源:INSTANCE = new WrongDclSingleton()这个操作,在底层会分为三个步骤:
- 分配对象的内存空间
- 初始化对象(执行构造函数)
- 将INSTANCE引用指向分配的内存地址
这三个步骤中,2和3没有数据依赖,完全可能被重排,先执行3,再执行2。此时如果有另一个线程来调用getInstance(),会看到INSTANCE不为null,但对象还没有完成初始化,拿到了一个半初始化的对象,使用时就会出现空指针等异常。
正确的DCL单例写法(加volatile):
package com.jam.demo;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* 正确的DCL单例示例(带volatile)
* @author ken
* @date 2024
*/
@Tag(name = "DCL单例", description = "正确的双重检查锁单例实现")
public class CorrectDclSingleton {
/** 单例实例,添加volatile修饰,禁止指令重排 */
private static volatile CorrectDclSingleton INSTANCE;
/** 私有构造函数,防止外部实例化 */
private CorrectDclSingleton() {}
/**
* 获取单例实例
* @return 单例对象
*/
public static CorrectDclSingleton getInstance() {
// 第一次检查,无锁,提高性能
if (INSTANCE == null) {
// 加锁,保证原子性
synchronized (CorrectDclSingleton.class) {
// 第二次检查,防止多线程同时进入第一次检查后重复实例化
if (INSTANCE == null) {
INSTANCE = new CorrectDclSingleton();
}
}
}
return INSTANCE;
}
}
用先行发生原则解释正确性:
- volatile变量规则:对INSTANCE的volatile写操作(初始化赋值),happens-before后续对INSTANCE的volatile读操作(第一次null检查)
- 程序顺序规则:对象初始化的所有操作,happens-before对INSTANCE的volatile写操作
- 传递性规则:对象的初始化完成,happens-before后续对INSTANCE的读操作
因此,其他线程读取INSTANCE时,要么看到null,要么看到完全初始化完成的对象,绝对不会看到半初始化的对象,完美解决了重排问题。
案例3:可见性问题的经典复现与解决
我们再看一个经典的可见性问题示例,用先行发生原则解释为什么加volatile就能解决:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
/**
* 可见性问题复现与解决示例
* @author ken
* @date 2024
*/
@Slf4j
public class VisibilityDemo {
/** 停止标记,添加volatile修饰保证可见性 */
private static volatile boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread workerThread = new Thread(() -> {
int i = 0;
// 读取stop变量,volatile读保证能看到主线程的修改
while (!stop) {
i++;
}
log.info("工作线程停止,循环次数:{}", i);
});
workerThread.start();
// 主线程休眠1秒,让工作线程充分运行
Thread.sleep(1000);
// 修改stop变量,volatile写保证对工作线程可见
stop = true;
log.info("主线程已设置stop为true,等待工作线程停止");
workerThread.join();
}
}
原理说明:
- 如果stop没有volatile修饰,主线程修改了stop的值,工作线程没有任何先行发生规则的约束,看不到这个修改,JIT会把
while(!stop)优化成while(true),工作线程永远不会停止 - 加了volatile修饰后,volatile变量规则保证:主线程对stop的写操作,happens-before工作线程对stop的读操作,因此工作线程能立即看到stop的修改,正常停止
六、JMM开发最佳实践
- 优先使用JUC成熟工具类:优先使用Atomic原子类、ConcurrentHashMap、ReentrantLock等JUC包下的成熟工具,这些类已经完美遵循JMM规范,封装了底层的内存屏障和同步逻辑,不要自己手写volatile和同步代码
- 不要滥用volatile:volatile只能保证可见性和有序性,不能保证原子性,只有在符合以下场景时才使用:
- 状态标记变量(如上面的stop、flag)
- 双重检查锁单例
- 一次性的安全发布对象
- 共享变量必须有先行发生规则约束:多线程操作的共享变量,必须有对应的先行发生规则保障,否则一定会出现可见性、有序性问题
- 不要依赖平台特性:x86是强内存模型,重排发生的概率低,而ARM是弱内存模型,重排非常频繁,不要依赖特定平台的特性,必须严格遵循JMM规范,才能保证跨平台的并发正确性
- 不要试图通过关闭优化解决问题:不要通过关闭JIT编译、禁用指令重排等方式解决并发问题,只有遵循JMM规范的代码,才是通用、稳定、可维护的
七、总结
JMM的三大核心,本质上是一套从底层到上层的完整规范体系:
- 指令重排是CPU和编译器为了提升性能的优化手段,单线程下完全安全,但多线程下会带来有序性和可见性问题
- 内存屏障是解决重排和可见性问题的底层实现,通过禁止重排和强制刷新缓存,保证多线程下的内存一致性
- 先行发生原则是JMM给开发者提供的上层规范,是判断并发操作是否安全的唯一依据,开发者无需关心底层实现,只要遵循这套规则,就能写出线程安全的并发代码
并发编程的本质,就是在性能和线程安全之间找到平衡,而JMM就是这个平衡的核心标尺。只有彻底搞懂JMM的三大核心,你才能从根上理解并发编程的本质,彻底解决那些偶发的、难以复现的并发诡异问题。
附录:项目依赖pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jam.demo</groupId>
<artifactId>jmm-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>6.1.5</spring.version>
<lombok.version>1.18.32</lombok.version>
<guava.version>33.1.0-jre</guava.version>
<fastjson2.version>2.0.49</fastjson2.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<swagger.version>2.5.0</swagger.version>
</properties>
<dependencies>
<!-- Spring核心工具类 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- Guava集合工具类 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- Fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<!-- 日志依赖 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.3</version>
</dependency>
</dependencies>
</project>