ThreadLocal、Sychronized和ReentrantLock

ThreadLocal

ThreadLoacl在Java中的核心作用是提供线程内的局部变量。它能让每个线程拥有该变量的一个独立副本,从而避免了多线程之间的竞争和干扰。

底层原理

核心结构:数据存在哪里?

ThreadLocal 本身并不存储数据 ,它只是一个访问入口,真正存储数据的是 Thread 线程对象本身

  • 每个 Thread 对象内部都有一个名为 threadLocals 的成员变量。

  • 这个变量的类型是 ThreadLocalMap(它是 ThreadLocal 的静态内部类)。

  • 当我们调用 set() 时,其实是把数据存到了当前线程threadLocals 那个 Map 里

映射关系与冲突解决

ThreadLocalMap 中:

  • Key :是当前的 ThreadLocal 对象实例。

  • Value :是我们想要保存的业务数据。

    值得注意的是,ThreadLocalMap 和我们常用的 HashMap 不同。它在解决 Hash 冲突时,没有使用链表法,而是采用了线性探测法 。也就是说,如果计算出的槽位被占了,它会顺着数组往后找,直到找到一个空位为止。这也意味着 ThreadLocalMap 不适合存储大量数据。

弱引用与内存泄漏

ThreadLocalMap 的 Entry 对 Key(ThreadLocal 对象)使用的是弱引用 ,但对 Value 使用的是强引用

  • 设计初衷 :如果外部对 ThreadLocal 对象的引用没了,GC 发生时,作为 Key 的 ThreadLocal 会被自动回收,变成 null,这样尽量避免 Key 的内存泄漏。

  • 潜在风险 :虽然 Key 回收了,但只要线程还在运行(比如在线程池中),Value 依然有一条强引用链:Thread -> ThreadLocalMap -> Entry -> Value

  • 后果 :这会导致 Entry 中出现 Keynull,但 Value 还在的情况。这个 Value 既无法被访问(因为 Key 没了),也无法被 GC 回收,从而造成内存泄漏

所以,我们在使用 ThreadLocal 时,标准范式是在 try-finally 代码块的 finally 中,显式调用 remove() 方法。这不仅是为了清理数据,更是为了切断 Value 的引用链,防止内存泄漏和线程复用导致的数据脏读。

应用场景

保存线程上下文信息(代替参数传递)

✨下面的代码模拟了一个 Controller -> Service -> DAO 的三层架构调用。请注意观察中间的 Service 层 ,它的方法参数里完全没有 用户信息,实现了参数解耦。
代码结构

  1. UserContext:基于 ThreadLocal 的上下文容器。

  2. AccountDao (底层):真正需要使用用户信息的地方。

  3. AccountService (中间层) :业务逻辑层,它不需要感知用户信息的传递

  4. AccountController (顶层):模拟接收请求,设置上下文。

java 复制代码
// ==========================================
// 1. 上下文容器 (工具类)
// ==========================================
class UserContext {
    private static final ThreadLocal<String> userHolder = new ThreadLocal<>();

    public static void setUser(String user) {
        userHolder.set(user);
    }

    public static String getUser() {
        return userHolder.get();
    }

    public static void remove() {
        userHolder.remove();
    }
}

// ==========================================
// 2. DAO 层 (最底层,需要用到用户信息)
// ==========================================
class AccountDao {
    public void insertLog(String action) {
        // 【关键点】:这里不需要上层传参,直接从当前线程抓取是谁在操作
        String currentUser = UserContext.getUser();
        
        System.out.println("   └── [DAO层] 正在记录数据库日志...");
        System.out.println("       User: [" + currentUser + "] 执行了操作: " + action);
    }
}

// ==========================================
// 3. Service 层 (中间层,无需透传参数)
// ==========================================
class AccountService {
    private AccountDao accountDao = new AccountDao();

    public void transferMoney() {
        System.out.println("   ├── [Service层] 正在处理转账业务逻辑...");
        
        // 注意:这里调用 DAO 时,不需要手动把 user 传进去
        // 如果没有 ThreadLocal,这里的方法签名通常是:insertLog(user, "转账")
        accountDao.insertLog("转账 1000 元");
    }
}

// ==========================================
// 4. Controller 层 (入口,负责设置和清理)
// ==========================================
class AccountController {
    private AccountService accountService = new AccountService();

    public void processRequest(String userName) {
        System.out.println("Step 1: [Controller] 收到请求,用户是: " + userName);

        try {
            // 1. 在入口处,把用户信息"挂"到当前线程上
            UserContext.setUser(userName);

            // 2. 调用下游业务
            // 【对比】:如果没有 ThreadLocal,这里必须写成 accountService.transferMoney(userName)
            accountService.transferMoney(); 

        } finally {
            // 3. 【务必清理】 防止内存泄漏和线程复用污染
            UserContext.remove();
            System.out.println("Step 4: [Controller] 请求结束,清理上下文\n");
        }
    }
}

// ==========================================
// 5. 测试主程序
// ==========================================
public class ContextDemo {
    public static void main(String[] args) {
        AccountController controller = new AccountController();

        // 模拟请求 1:张三
        new Thread(() -> {
            controller.processRequest("张三");
        }).start();

        // 模拟请求 2:李四
        new Thread(() -> {
            controller.processRequest("李四");
        }).start();
    }
}

通过 ThreadLocal,将用户信息存储在线程中,实现了层与层之间的零侵入 数据共享。

保证对象/资源的线程安全(资源隔离)

场景:数据库连接 (Connection)
问题背景: 假设你在一个 Service 方法里调用了 3 个 DAO 方法(插入A,更新B,删除C)。 为了让这 3 个操作在
同一个事务
里(要么全成功,要么全回滚),它们必须使用同一个数据库连接 (Connection)
ThreadLocal 如何解决:

  1. 开启事务时 :Spring 获取一个数据库连接 conn1,然后调用 ThreadLocal.set(conn1),把它绑在当前线程上。
  2. 执行 DAO A :DAO 问 ThreadLocal 要连接,拿到了 conn1
  3. 执行 DAO B :DAO 问 ThreadLocal 要连接,还是拿到了 conn1
  4. 执行 DAO C :DAO 问 ThreadLocal 要连接,还是拿到了 conn1
  5. 提交事务时 :Spring 从 ThreadLocal 取出 conn1,执行 conn1.commit()
  6. 结束ThreadLocal.remove()

如果不用 ThreadLocal ,你就得把 Connection 对象作为参数,在 Service 和 DAO 的所有方法之间传来传去,代码会极其难看。


Sychronized

synchronized 是 Java 中最基础的并发控制关键字

它是一把"锁",用来控制多个线程对共享资源的访问,保证同一时刻只能有一个线程来执行特定的代码段。

使用方法

1.修饰实例方法(锁的是当前对象 this

java 复制代码
public synchronized void add() { 
	// 只有一个线程能进入这个方法 
	count++; 
}

含义 :想进这个屋,必须拿到这个对象实例的钥匙。

2.修饰静态方法(锁的是类对象 Class

java 复制代码
public static synchronized void add() {
    count++;
}

含义 :想进这个屋,必须拿到这个类的钥匙。因为类只有一个,所以这是全局锁,控制所有对象。

3.修饰代码块(锁的是指定对象)

java 复制代码
public void add() {
    // 只有这一小段代码需要排队,其他代码可以并发执行
    synchronized(this) { 
        count++;
    }
}

这是最灵活的方式,也是推荐的方式(锁的范围越小性能越好)。

底层原理

synchronized 主要是通过**对象头(Object Header)**中的标记来实现的。

  • 对象头:Java 中的每个对象在内存中都戴着一顶"帽子"(对象头)。这顶帽子里记录了这个对象**"属于谁"**(锁状态)。

  • Monitor(监视器锁)

    • 当线程执行到 synchronized 时,会尝试去获取该对象的 Monitor。
    • 如果获取成功,对象头的标记就会变成"锁定",并记录当前线程 ID。
    • 其他线程再来,发现对象头显示"已锁定",就会进入**等待队列(Entry Set)**睡觉,直到持有锁的线程出来。

Synchronized的锁升级过程是怎么样的

为了提升性能,在Java6中JVM 引入了**"偏向锁 -> 轻量级锁 -> 重量级锁"**的升级过程。

形象比喻:占座

想象有一个会议室(对象),大家都要进去开会(获取锁)。

  1. 偏向锁 :只有张三一个人经常来,他在门口贴了个名字贴纸(记录线程ID),下次直接进,不用办手续。

  2. 轻量级锁李四也来了,发现贴着张三的名字。李四不甘心,就在门口转圈圈(自旋),等着张三出来抢贴纸。

  3. 重量级锁王五、赵六全来了,门口挤满了人。大家决定别转圈了,找个管理员(操作系统)装一把大铁锁,没钥匙的人全部去睡觉(阻塞),等叫号。

详细升级过程
第一阶段:偏向锁 (Biased Lock)
  • 场景只有一个线程反复进入临界区,没有任何竞争。

  • 动作

    • 当线程 A 第一次访问同步块时,JVM 只是把 线程 A 的 ID 通过 CAS 操作记录在对象的 Mark Word 中。

    • 以后线程 A 再来,只要检查一下 Mark Word 里的 ID 是不是自己。如果是,完全不需要加锁解锁,直接进。

  • 目的:消除在无竞争情况下的同步原语,提高单线程运行效率。

  • :如果 JDK 启动参数关闭了偏向锁(JDK 15 以后默认废弃了偏向锁),则直接进入轻量级锁。

第二阶段:轻量级锁 (Lightweight Lock)
  • 场景出现了轻微竞争。线程 A 还没执行完,线程 B 来了。

  • 触发:线程 B 发现对象头里是线程 A 的 ID(偏向锁),于是尝试撤销偏向锁,升级为轻量级锁。

  • 动作

    • 线程 A 和 线程 B 会在自己的栈帧中创建一块空间叫 Lock Record(锁记录)。

    • 大家尝试用 CAS (Compare And Swap) 将对象头的 Mark Word 替换为指向自己 Lock Record 的指针。

    • 抢到的:持有锁,执行代码。

    • 没抢到的:不会立即睡觉(阻塞),而是进入**"自旋"**状态(Spinning)。也就是说,它会在 CPU 上空跑几个循环,看看持有锁的人会不会很快出来。

  • 目的:避免线程挂起和恢复带来的上下文切换开销(这很慢)。

第三阶段:重量级锁 (Heavyweight Lock)
  • 场景竞争变得剧烈

    • 情况 1:线程 B 自旋了很多次(默认 10 次)还没等到锁。

    • 情况 2:线程 B 还在自旋,又来了个线程 C 也要抢。

  • 触发:轻量级锁撑不住了,膨胀为重量级锁。

  • 动作

    • JVM 会向操作系统申请真正的互斥锁(Mutex Lock)。

    • 锁标志位变为重量级锁状态。

    • 此时,没抢到锁的线程不再自旋 (不耗 CPU 了),而是直接被操作系统挂起(Blocked),进入等待队列。

  • 代价:线程的挂起和唤醒需要从"用户态"切换到"内核态",性能开销很大。

关键点

  • 不可逆性 : 锁通常只能升级,不能降级。一旦升级为重量级锁,就不会再回到轻量级锁或偏向锁。(注:在 GC 期间只有极少数情况会降级,但面试通常忽略)。

  • 为什么要有轻量级锁? 因为在实际开发中,很多锁的代码执行时间很短。如果直接挂起线程再唤醒,花费的时间可能比执行代码的时间还长。自旋(轻量级锁)就是用 CPU 时间换取响应速度。

  • 为什么要有重量级锁? 如果持有锁的线程执行很慢,或者竞争非常激烈,一直自旋会白白消耗 CPU 资源。这时候必须让等待线程"睡觉",把 CPU 让给更有用的任务。


ReentrantLock

ReentrantLock (可重入锁)是 一种显式锁

如果把 synchronized 比作**"自动挡汽车"(简单、省心、由 JVM 自动控制),那么 ReentrantLock 就是"手动挡汽车"**(功能强大、灵活、需要手动挂挡/摘挡)。

核心特点:手动控制

synchronized 不同,ReentrantLock 需要你手动调用方法来加锁和释放锁。

java 复制代码
// 1. 创建锁对象
Lock lock = new ReentrantLock();

// 2. 加锁
lock.lock(); 
try {
    // === 业务逻辑 ===
    System.out.println("我拿到锁了!");
} finally {
    // 3. 【必须】在 finally 中解锁
    // 如果不在 finally 解锁,一旦业务代码抛异常,锁永远不会释放,导致死锁
    lock.unlock(); 
}

ReentrantLock 比 synchronized 的核心区别

① 等待可中断 (lockInterruptibly)
  • synchronized :一旦去抢锁,要么抢到,要么一直等。如果别人一直不释放,你只能一直等,连线程中断(interrupt)都打断不掉你

  • ReentrantLock :可以使用 lock.lockInterruptibly()。如果你等得不耐烦了,别人可以调用 thread.interrupt() 打断你的等待,让你抛出异常去干别的事。

② 可实现"公平锁" (Fair Sync)
  • synchronized :默认是非公平的。锁释放了,大家一拥而上抢,谁运气好谁拿到(插队现象严重)。

  • ReentrantLock :默认也是非公平的(性能好),但你可以强制开启公平模式

java 复制代码
// true 表示开启公平锁:严格按照"先来后到"排队,绝对不允许插队
Lock lock = new ReentrantLock(true);
③ 尝试非阻塞获取锁 (tryLock)

这是最骚的操作。

  • synchronized:不管有没有锁,头铁往里冲,没锁就阻塞。

  • ReentrantLock :可以使用 tryLock()

    • "有人占着坑吗?有的话我就走了,不排队了。"

    • 或者:"我等你 2 秒,2 秒不开门我就走了。"

java 复制代码
if (lock.tryLock(2, TimeUnit.SECONDS)) {
    try {
        // 拿到锁了,干活
    } finally {
        lock.unlock();
    }
} else {
    // 没拿到锁,做降级处理(比如记录日志、以后再试)
    System.out.println("太忙了,我溜了");
}

底层原理:AQS + CAS

ReentrantLock 的底层完全是基于 AQS (AbstractQueuedSynchronizer) 框架实现的。

  • State 变量 :AQS 内部有一个 volatile int state

    • state = 0:表示锁是自由的。

    • state > 0:表示锁被占用了(数值表示重入次数)。

  • 加锁过程

    1. 线程尝试用 CASstate 从 0 改成 1。

    2. 如果成功:记录当前线程是锁的持有者(ExclusiveOwnerThread)。

    3. 如果失败:说明锁被占了。放入 AQS 的等待队列中(park 挂起),等待被唤醒。

synchronized和ReentrantLock的区别

特性 synchronized ReentrantLock
用法 关键字,JVM 自动管理加锁解锁 类,必须手动 lock/unlock
灵活性 低,死等 高,可中断、可超时、可尝试
公平性 只能非公平 可选 公平/非公平
实现机制 Mark Word (对象头) + Monitor AQS + CAS
条件队列 只有一个 (wait/notify) 可以有多个 (Condition)
最佳实践建议:
  • 首选 synchronized:在 JDK 1.6 之后,它的性能已经非常好了,而且代码简洁,不用担心忘记释放锁导致死锁。

  • 只有当你需要以下功能时,才用 ReentrantLock

    1. 需要超时等待(等不到就不等了)。

    2. 需要公平锁(先来后到)。

    3. 需要复杂的线程协作(多个 Condition 条件)。

相关推荐
侠客行03178 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪8 小时前
深入浅出LangChain4J
java·langchain·llm
老毛肚10 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
风流倜傥唐伯虎11 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
Yvonne爱编码11 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python
Re.不晚11 小时前
JAVA进阶之路——无奖问答挑战1
java·开发语言
你这个代码我看不懂11 小时前
@ConditionalOnProperty不直接使用松绑定规则
java·开发语言
fuquxiaoguang11 小时前
深入浅出:使用MDC构建SpringBoot全链路请求追踪系统
java·spring boot·后端·调用链分析
琹箐11 小时前
最大堆和最小堆 实现思路
java·开发语言·算法
__WanG11 小时前
JavaTuples 库分析
java