你是否遇到过这些问题:多线程环境下用户上下文莫名串号、线程池集成ThreadLocal后服务运行久了出现OOM、父子线程传递traceId偶发失效、面试被问底层原理只能说出“线程私有变量”便卡壳?本文将从底层源码到生产实践,全链路拆解ThreadLocal的核心逻辑,根治所有常见坑点。
一、ThreadLocal核心认知
ThreadLocal是JDK提供的线程隔离级别的变量存储工具,它的核心设计思想是“空间换时间”,为每个线程创建独立的变量副本,每个线程只能访问和修改自己副本中的值,从根本上避免了多线程共享变量的竞争问题,同时解决了业务上下文跨方法层层传递的冗余问题。
它的核心价值体现在两个场景:
- 线程安全:变量在线程间隔离,无锁竞争,天然避免并发安全问题
- 上下文传递:用户信息、traceId、事务状态等上下文,无需在方法参数中层层传递,可在全链路任意位置获取
二、JDK17底层原理全拆解
2.1 核心数据结构关系
ThreadLocal的核心实现并非在自身类中存储变量,而是依托于Thread线程类的内部存储结构,三者的关系架构如下:
从架构图可以明确三个核心结论:
- 每个Thread线程对象中,都持有两个
ThreadLocal.ThreadLocalMap类型的成员变量:threadLocals和inheritableThreadLocals,默认值为null - ThreadLocal本身不存储变量值,它只是作为key,通过自身的hash值定位到当前线程ThreadLocalMap中的对应Entry,从而获取value
- 变量副本真正存储在每个线程自己的ThreadLocalMap中,线程之间完全隔离,互不可见
2.2 ThreadLocalMap核心实现
ThreadLocalMap是ThreadLocal的静态内部类,是一个定制化的哈希表,专为ThreadLocal场景设计,核心结构如下:
- Entry实体:继承自
WeakReference<ThreadLocal<?>>,key为当前ThreadLocal实例的弱引用,value为线程私有变量的强引用 - 哈希冲突解决:采用线性探测法,而非HashMap的拉链法,哈希值计算采用
nextHashCode增量算法,减少哈希冲突 - 过期清理机制:内置了针对key为null的过期Entry的清理逻辑,在get/set/remove操作时会触发,降低内存泄漏风险
2.3 核心方法执行流程
2.3.1 set()方法执行流程
set()方法用于为当前线程设置ThreadLocal变量副本,执行流程如下:
核心源码逻辑(JDK17)精简如下:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
2.3.2 get()方法执行流程
get()方法用于获取当前线程中ThreadLocal对应的变量副本,核心逻辑为:
- 获取当前线程的ThreadLocalMap
- 以当前ThreadLocal为key,从Map中获取对应的Entry
- 若Entry存在,返回对应的value
- 若Map不存在或Entry不存在,调用
setInitialValue()方法初始化初始值并返回
2.3.3 remove()方法执行流程
remove()方法是ThreadLocal正确使用的核心,它会从当前线程的ThreadLocalMap中,移除当前ThreadLocal对应的Entry,同时触发过期Entry的清理,从根本上避免脏数据和内存泄漏。
三、内存泄漏的本质与根治方案
3.1 内存泄漏的核心误区纠正
90%的开发者都存在一个错误认知:“弱引用是ThreadLocal内存泄漏的元凶”。这个结论完全颠倒了因果,弱引用不仅不是泄漏的原因,反而是JDK为了降低泄漏风险做的兜底优化。
我们先明确Java引用的核心特性:
- 强引用:普通的对象引用,只要强引用存在,GC永远不会回收被引用的对象
- 弱引用:生命周期仅存活到下一次GC之前,无论内存是否充足,GC触发时都会回收被弱引用关联的对象
3.2 内存泄漏的触发原理
ThreadLocal内存泄漏的完整触发流程如下:
从流程中可以明确,内存泄漏的两个必要条件:
- 线程生命周期过长:核心线程池的线程生命周期与JVM一致,线程不会终止,ThreadLocalMap不会被整体回收
- 过期Entry未被清理:ThreadLocal外部强引用被回收后,key变为null,后续没有任何ThreadLocal的操作触发清理逻辑,导致value一直被Entry强引用,无法被GC回收
3.3 为什么说弱引用是兜底优化?
假设Entry的key使用强引用,会发生什么? 即使ThreadLocal的外部强引用被释放,Entry的key依然持有ThreadLocal的强引用,ThreadLocal实例永远不会被GC回收,连key都无法被标记为过期,整个Entry永远不会被清理,内存泄漏会比现在严重得多。
而弱引用的设计,让ThreadLocal实例在外部强引用消失后,能被GC正常回收,key变为null,为后续的清理逻辑提供了触发条件,是JDK提供的一层兜底保障。
3.4 内存泄漏的根治方案
根治内存泄漏只有一个强制标准:每次使用完ThreadLocal,必须在finally块中手动调用remove()方法。
这个操作会直接从当前线程的ThreadLocalMap中移除对应的Entry,彻底释放key和value的引用,无论线程生命周期多长,都不会出现内存泄漏。同时,这个操作也能彻底避免线程池线程复用导致的脏数据问题。
四、架构级正确用法实战
ThreadLocal的架构级用法,核心是封装上下文持有者,实现业务上下文的全链路透明传递,同时严格遵守使用规范,避免生产问题。以下是4个生产环境高频使用的落地示例。
4.1 用户上下文持有者
用户上下文是Web项目中最高频的使用场景,在网关/拦截器中解析用户信息存入ThreadLocal,业务代码中任意位置可直接获取,请求结束后自动清理。
package com.jam.demo.context;
import com.jam.demo.model.UserInfo;
import org.springframework.util.ObjectUtils;
/**
* 用户上下文持有者
*
* @author ken
*/
public final class UserContextHolder {
private UserContextHolder() {
}
private static final ThreadLocal<UserInfo> USER_THREAD_LOCAL = new ThreadLocal<>();
/**
* 设置当前线程的用户信息
*
* @param userInfo 用户信息
*/
public static void set(UserInfo userInfo) {
if (!ObjectUtils.isEmpty(userInfo)) {
USER_THREAD_LOCAL.set(userInfo);
}
}
/**
* 获取当前线程的用户信息
*
* @return 用户信息
*/
public static UserInfo get() {
return USER_THREAD_LOCAL.get();
}
/**
* 获取当前登录用户ID
*
* @return 用户ID
*/
public static Long getUserId() {
UserInfo userInfo = get();
return ObjectUtils.isEmpty(userInfo) ? null : userInfo.getUserId();
}
/**
* 清除当前线程的用户信息
*/
public static void remove() {
USER_THREAD_LOCAL.remove();
}
}
对应的拦截器实现,确保请求结束后自动清理:
package com.jam.demo.interceptor;
import com.jam.demo.context.UserContextHolder;
import com.jam.demo.model.UserInfo;
import com.jam.demo.utils.JwtUtils;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 用户上下文拦截器
*
* @author ken
*/
@Slf4j
@Component
public class UserContextInterceptor implements HandlerInterceptor {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
@Operation(hidden = true)
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
String realToken = token.substring(BEARER_PREFIX.length());
UserInfo userInfo = JwtUtils.parseToken(realToken);
UserContextHolder.set(userInfo);
}
return true;
}
@Override
@Operation(hidden = true)
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContextHolder.remove();
}
}
4.2 全链路追踪traceId上下文
全链路追踪是微服务架构的核心能力,通过ThreadLocal存储traceId,实现全链路日志的串联,定位问题时可通过traceId快速检索全链路日志。
package com.jam.demo.context;
import org.springframework.util.StringUtils;
import java.util.UUID;
/**
* 链路追踪上下文持有者
*
* @author ken
*/
public final class TraceContextHolder {
private TraceContextHolder() {
}
private static final ThreadLocal<String> TRACE_ID_TL = ThreadLocal.withInitial(() -> UUID.randomUUID().toString().replace("-", ""));
/**
* 设置当前链路的traceId
*
* @param traceId 链路ID
*/
public static void set(String traceId) {
if (StringUtils.hasText(traceId)) {
TRACE_ID_TL.set(traceId);
}
}
/**
* 获取当前链路的traceId
*
* @return 链路ID
*/
public static String get() {
return TRACE_ID_TL.get();
}
/**
* 清除当前链路的traceId
*/
public static void remove() {
TRACE_ID_TL.remove();
}
}
4.3 编程式事务上下文管理
编程式事务相比声明式事务,具备更灵活的事务控制能力,通过ThreadLocal存储事务状态,可实现嵌套方法的事务状态共享与统一控制。
package com.jam.demo.context;
import org.springframework.transaction.TransactionStatus;
import org.springframework.util.ObjectUtils;
/**
* 事务上下文持有者
*
* @author ken
*/
public final class TransactionContextHolder {
private TransactionContextHolder() {
}
private static final ThreadLocal<TransactionStatus> TRANSACTION_TL = new ThreadLocal<>();
/**
* 设置当前线程的事务状态
*
* @param transactionStatus 事务状态
*/
public static void set(TransactionStatus transactionStatus) {
if (!ObjectUtils.isEmpty(transactionStatus)) {
TRANSACTION_TL.set(transactionStatus);
}
}
/**
* 获取当前线程的事务状态
*
* @return 事务状态
*/
public static TransactionStatus get() {
return TRANSACTION_TL.get();
}
/**
* 清除当前线程的事务状态
*/
public static void remove() {
TRANSACTION_TL.remove();
}
}
对应的业务使用示例:
package com.jam.demo.service;
import com.jam.demo.context.TransactionContextHolder;
import com.jam.demo.entity.Order;
import com.jam.demo.mapper.OrderMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
/**
* 订单服务
*
* @author ken
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final PlatformTransactionManager transactionManager;
private final TransactionDefinition transactionDefinition;
private final OrderMapper orderMapper;
private final StockService stockService;
/**
* 创建订单
*
* @param order 订单信息
* @return 订单ID
*/
public Long createOrder(Order order) {
TransactionStatus status = transactionManager.getTransaction(transactionDefinition);
try {
TransactionContextHolder.set(status);
stockService.deductStock(order.getProductId(), order.getQuantity());
orderMapper.insert(order);
transactionManager.commit(status);
return order.getId();
} catch (Exception e) {
transactionManager.rollback(status);
log.error("创建订单失败,事务已回滚", e);
throw new RuntimeException("创建订单失败", e);
} finally {
TransactionContextHolder.remove();
}
}
}
4.4 动态数据源切换上下文
多数据源场景下,通过ThreadLocal存储当前线程使用的数据源key,实现动态数据源的切换,满足读写分离、分库分表等业务需求。
package com.jam.demo.context;
import org.springframework.util.StringUtils;
/**
* 动态数据源上下文持有者
*
* @author ken
*/
public final class DynamicDataSourceContextHolder {
private DynamicDataSourceContextHolder() {
}
private static final ThreadLocal<String> DATA_SOURCE_KEY_TL = new ThreadLocal<>();
/**
* 设置当前线程使用的数据源key
*
* @param dataSourceKey 数据源key
*/
public static void set(String dataSourceKey) {
if (StringUtils.hasText(dataSourceKey)) {
DATA_SOURCE_KEY_TL.set(dataSourceKey);
}
}
/**
* 获取当前线程使用的数据源key
*
* @return 数据源key
*/
public static String get() {
return DATA_SOURCE_KEY_TL.get();
}
/**
* 清除当前线程的数据源key,恢复默认数据源
*/
public static void remove() {
DATA_SOURCE_KEY_TL.remove();
}
}
五、全场景避坑指南
5.1 线程池复用导致的脏数据问题
问题本质
线程池的核心是线程复用,线程不会随着任务执行结束而销毁,而是会继续处理下一个任务。如果上一个任务set了ThreadLocal的值,没有调用remove(),下一个任务复用同一个线程时,会拿到上一个任务遗留的值,造成脏数据,甚至引发业务逻辑错误。
错误示例
package com.jam.demo.badcase;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* ThreadLocal线程池脏数据错误示例
*
* @author ken
*/
@Slf4j
public class ThreadLocalDirtyDataBadCase {
private static final ThreadLocal<Integer> COUNT_TL = ThreadLocal.withInitial(() -> 0);
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(1);
public static void main(String[] args) {
EXECUTOR.submit(() -> {
COUNT_TL.set(100);
log.info("第一个任务获取值:{}", COUNT_TL.get());
});
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("线程中断", e);
}
EXECUTOR.submit(() -> {
log.info("第二个任务获取值:{}", COUNT_TL.get());
});
EXECUTOR.shutdown();
}
}
执行结果:第二个任务预期输出0,实际输出100,脏数据产生。
避坑方案
无论任务是否执行成功,必须在finally块中调用remove()方法,确保任务执行结束后,清理当前线程的ThreadLocal值。
package com.jam.demo.goodcase;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* ThreadLocal线程池正确使用示例
*
* @author ken
*/
@Slf4j
public class ThreadLocalCorrectUsageCase {
private static final ThreadLocal<Integer> COUNT_TL = ThreadLocal.withInitial(() -> 0);
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(1);
public static void main(String[] args) {
EXECUTOR.submit(() -> {
try {
COUNT_TL.set(100);
log.info("第一个任务获取值:{}", COUNT_TL.get());
} finally {
COUNT_TL.remove();
}
});
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("线程中断", e);
}
EXECUTOR.submit(() -> {
try {
log.info("第二个任务获取值:{}", COUNT_TL.get());
} finally {
COUNT_TL.remove();
}
});
EXECUTOR.shutdown();
}
}
5.2 父子线程上下文传递失效问题
问题本质
JDK提供的InheritableThreadLocal可以实现父子线程的上下文传递,原理是子线程初始化时,会复制父线程的inheritableThreadLocals到自己的存储空间中。但在线程池场景下,核心线程是提前创建并复用的,不会每次提交任务都重新初始化,导致父线程的上下文更新后,子线程无法拿到最新的值,上下文传递失效。
错误示例
package com.jam.demo.badcase;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* InheritableThreadLocal线程池传值失效错误示例
*
* @author ken
*/
@Slf4j
public class InheritableThreadLocalBadCase {
private static final InheritableThreadLocal<String> TRACE_ID_TL = new InheritableThreadLocal<>();
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(1);
public static void main(String[] args) throws InterruptedException {
EXECUTOR.submit(() -> log.info("核心线程初始化完成")).get();
TRACE_ID_TL.set("trace-001");
log.info("父线程traceId:{}", TRACE_ID_TL.get());
EXECUTOR.submit(() -> log.info("子线程第一次获取traceId:{}", TRACE_ID_TL.get()));
Thread.sleep(1000);
TRACE_ID_TL.set("trace-002");
log.info("父线程新traceId:{}", TRACE_ID_TL.get());
EXECUTOR.submit(() -> log.info("子线程第二次获取traceId:{}", TRACE_ID_TL.get()));
EXECUTOR.shutdown();
}
}
执行结果:子线程第二次预期输出trace-002,实际输出trace-001,传值失效。
避坑方案
使用阿里开源的TransmittableThreadLocal(TTL),它在InheritableThreadLocal的基础上,实现了线程池场景下的上下文传递,每次提交任务时都会复制父线程的最新上下文,任务执行结束后自动清理。
package com.jam.demo.goodcase;
import com.alibaba.ttl.TransmittableThreadLocal;
import com.alibaba.ttl.threadpool.TtlExecutors;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* TransmittableThreadLocal线程池传值正确示例
*
* @author ken
*/
@Slf4j
public class TtlThreadLocalCorrectCase {
private static final TransmittableThreadLocal<String> TRACE_ID_TL = new TransmittableThreadLocal<>();
private static final ExecutorService ORIGIN_EXECUTOR = Executors.newFixedThreadPool(1);
private static final ExecutorService TTL_EXECUTOR = TtlExecutors.getTtlExecutorService(ORIGIN_EXECUTOR);
public static void main(String[] args) throws InterruptedException {
TTL_EXECUTOR.submit(() -> log.info("核心线程初始化完成")).get();
TRACE_ID_TL.set("trace-001");
log.info("父线程traceId:{}", TRACE_ID_TL.get());
TTL_EXECUTOR.submit(() -> log.info("子线程第一次获取traceId:{}", TRACE_ID_TL.get()));
Thread.sleep(1000);
TRACE_ID_TL.set("trace-002");
log.info("父线程新traceId:{}", TRACE_ID_TL.get());
TTL_EXECUTOR.submit(() -> log.info("子线程第二次获取traceId:{}", TRACE_ID_TL.get()));
TRACE_ID_TL.remove();
TTL_EXECUTOR.shutdown();
}
}
5.3 ThreadLocal实例创建不当的性能坑
问题本质
很多开发者会将ThreadLocal声明为非静态变量,每次创建业务对象时,都会生成一个新的ThreadLocal实例,导致每个线程的ThreadLocalMap中存在大量的Entry,不仅浪费内存,还会加剧哈希冲突,线性探测法的寻址时间大幅增加,严重影响性能。
避坑方案
ThreadLocal必须声明为private static final,全局唯一实例,避免重复创建。static修饰确保类加载时初始化一次,final修饰避免引用被修改,从根本上避免实例重复创建的问题。
5.4 共享对象存储的并发安全坑
问题本质
很多开发者误以为,只要把对象存入ThreadLocal,就一定是线程安全的。这个认知存在严重漏洞:如果ThreadLocal中存储的是同一个共享对象的引用,即使每个线程都有这个引用的副本,指向的还是堆中的同一个对象,多线程修改这个对象时,依然会存在并发安全问题。
避坑方案
ThreadLocal中尽量存储不可变对象,若必须存储可变对象,确保每个线程存储的是独立的对象副本,而非共享对象的引用。
六、易混淆技术点明确区分
| 特性 | ThreadLocal | Synchronized | Volatile |
| 核心思想 | 线程隔离,每个线程拥有独立副本,变量不共享 | 线程同步,多线程共享同一变量,锁保证串行访问 | 多线程共享变量,保证可见性与有序性 |
| 解决问题 | 变量隔离存储,避免参数层层传递 | 共享变量的并发安全,保证三大特性 | 共享变量的线程可见性,禁止指令重排 |
| 原子性保证 | 不保证原子性,仅保证副本隔离 | 保证原子性 | 不保证原子性 |
| 性能表现 | 无锁竞争,高并发下性能优异 | 存在锁竞争,高并发下有性能损耗 | 无锁,性能优于锁机制 |
| 适用场景 | 用户上下文、链路追踪、事务上下文 | 共享变量计数、状态更新、资源竞争 | 单次读写的状态标记、双重检查锁 |
七、生产级最佳实践总结
- 【强制】ThreadLocal必须声明为
private static final,全局唯一实例,避免重复创建导致的内存浪费和性能下降 - 【强制】每次使用完ThreadLocal,必须在finally块中手动调用remove()方法,彻底避免脏数据和内存泄漏
- 【推荐】初始化ThreadLocal时,使用
withInitial()方法设置初始值,避免get()返回null导致空指针异常 - 【推荐】父子线程传递上下文时,若使用线程池,必须使用TransmittableThreadLocal,禁止使用InheritableThreadLocal
- 【禁止】使用ThreadLocal存储大对象,若必须存储,需确保使用后立即清理
- 【禁止】在ThreadLocal中存储共享可变对象,避免出现并发安全问题
- 【推荐】封装统一的上下文持有者工具类,禁止业务代码直接操作ThreadLocal的get/set/remove方法,统一管控
ThreadLocal是Java并发编程中的一把利器,只有真正理解了它的底层原理,才能避开所有的坑,在架构设计中发挥它的最大价值,而不是成为生产事故的导火索。
附录:项目依赖配置
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>threadlocal-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>threadlocal-demo</name>
<description>ThreadLocal Demo Project</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<fastjson2.version>2.0.52</fastjson2.version>
<guava.version>33.1.0-jre</guava.version>
<transmittable-thread-local.version>2.14.2</transmittable-thread-local.version>
<springdoc.version>2.5.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>${transmittable-thread-local.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>