잠금이 없는 스레드 안전 아키텍처 구축: Java에서 ThreadLocal의 원칙을 마스터하고 유연하게 적용

ThreadLocal은 Java에서 제공하는 스레드 수준 변수 저장 도구로 각 스레드가 자체 독립 변수 복사본을 가질 수 있으며 각 스레드는 서로 간섭하지 않고 자체 변수 복사본을 독립적으로 운영할 수 있습니다. 이 기사에서는 ThreadLocal의 원리와 사용 시나리오를 자세히 소개하고 코드 예제를 통해 설명합니다.

1. ThreadLocal의 원리

1.1 개요

ThreadLocal은 스레드 제한을 구현하는 간단한 방법을 제공합니다. 이 방법은 데이터를 스레드와 연결하여 각 스레드가 자체적인 독립적인 데이터 복사본을 갖도록 하여 스레드 안전 문제를 방지합니다. 다중 스레드 환경에서 ThreadLocal을 사용하면 스레드 간에 데이터 격리를 쉽게 구현할 수 있으므로 각 스레드가 자체 데이터에 액세스할 수 있습니다.

1.2 데이터 구조

ThreadLocal은 내부적으로 특별한 데이터 구조를 사용하여 각 스레드의 변수 복사본을 저장하는데, 이 데이터 구조를 ThreadLocalMap이라고 합니다. 각 ThreadLocal 개체는 스레드의 변수 복사본을 나타내는 값에 해당하는 키로 사용됩니다. ThreadLocalMap은 스레드 로컬 변수를 저장하는 데 사용되는 ThreadLocal 클래스의 내부 정적 클래스입니다.

ThreadLocal은 스레드 공유 변수입니다. ThreadLoacl에는 Key가 ThreadLocal 개체이고 값이 Entry 개체인 정적 내부 클래스 ThreadLocalMap이 있으며 ThreadLocalMap은 각 스레드에 대해 비공개입니다.

  • set ThreadLocalMap의 값을 설정합니다.
  • get ThreadLocalMap을 가져옵니다.
  • remove ThreadLocalMap 유형의 개체를 삭제합니다.

1.3 실행 원칙

ThreadLocal의 구현 원리는 다음 단계로 간략하게 요약할 수 있습니다.

  1. 각 스레드 내부에 ThreadLocalMap 개체를 만들어 스레드 변수의 복사본을 저장합니다.
  2. 스레드 로컬 변수를 사용해야 하는 경우 get() 메서드를 통해 현재 스레드에 해당하는 ThreadLocalMap 객체를 가져옵니다.
  3. 在 ThreadLocalMap 中以当前 ThreadLocal 对象作为 key,获取或设置变量副本。

具体流程如下图所示:

image.png

简单描述也就是这样:

main Thread:          Thread1:                        Thread2:
┌─────────┐          ┌─────────┐       ┌─────────┐    ┌─────────┐
│ Thread  │          │ Thread  │       │ Thread  │    │ Thread  │
├─────────┤          ├─────────┤       ├─────────┤    ├─────────┤
│         ├──┐       │         ├──┐    │         ├───>│         │
└─────────┘  │       └─────────┘  │    └─────────┘    └─────────┘
             │                    │
     get()   │     set("Data1")   │
   ┌──────────┼──────────────────┼───────────────────┐
   │          │                  │                   │
┌───────────┐  │    ┌───────────┐ │    ┌───────────┐  │
│ ThreadMap │  │    │ ThreadMap │ │    │ ThreadMap │  │
├───────────┤  │    ├───────────┤ │    ├───────────┤  │
│ ThreadMap ├──┼───>│ ThreadMap ├──┼───>│ ThreadMap │  │
└───────────┘  │    └───────────┘ │    └───────────┘  │
               │                  │                   │
           ┌───────────┐      ┌───────────┐       ┌───────────┐
           │ ThreadLocal1 │      │ ThreadLocal1 │       │ ThreadLocal1 │
           ├───────────┤      ├───────────┤       ├───────────┤
           │     Data1     │      │     Data2     │       │     Data3  │
           └───────────┘      └───────────┘       └───────────┘

二、ThreadLocal 的使用场景

2.1 线程安全问题

在多线程环境中,多个线程访问共享数据时可能出现线程安全问题,例如数据被意外修改、并发写入等。此时,可以使用 ThreadLocal 将数据与线程关联起来,确保每个线程都操作自己的数据副本,从而避免线程安全问题。

2.2 传递上下文信息

在一些需要跨层传递上下文信息的场景下,使用 ThreadLocal 可以简化代码实现。例如,在 Web 应用中,用户的登录信息通常需要在多个组件间传递,可以使用 ThreadLocal 来存储用户登录信息,每个组件获取登录信息时直接从 ThreadLocal 中获取,避免了繁琐的参数传递过程。

2.3 数据库连接管理

在使用数据库连接池时,每个线程从连接池中获取连接执行数据库操作,并在处理完毕后将连接释放到连接池中。此时,可以使用 ThreadLocal 来管理数据库连接,确保每个线程都使用自己独立的连接,避免多线程并发访问同一个连接引发的问题。

2.4 其他应用场景

除了上述场景,ThreadLocal 还可以用于实现定制化的线程封闭策略,例如在线程池中复用线程时,通过使用 ThreadLocal 可以隔离线程之间的数据。

总的概括就是:

(1)每个线程需要有自己单独的实例

(2)实例需要在多个方法中共享,但不希望被多线程共享

三、ThreadLocal 的使用方式

下面举几个具体的例子来演示 ThreadLocal 的使用方式。

3.1 示例一:传递用户信息

假设有一个 Web 应用,需要在不同层级的组件中传递用户的登录信息。首先,我们定义一个包含用户信息的类 User:

public class User {
    private String username;
    // ...
    
    // 构造方法和getter/setter 省略
}

接下来,在一个拦截用户请求的过滤器中,将用户信息存储到 ThreadLocal 中:

public class UserFilter implements Filter {
    private static final ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // 从请求中获取用户信息
        User user = extractUserFromRequest(request);
        
        // 将用户信息存储到 ThreadLocal 中
        userThreadLocal.set(user);
        
        try {
            chain.doFilter(request, response);
        } finally {
            // 请求处理完毕后清除 ThreadLocal 中的数据
            userThreadLocal.remove();
        }
    }
    
    private User extractUserFromRequest(ServletRequest request) {
        // 从请求中提取用户信息
        // ...
        return new User("Alice");
    }
}

在其他组件中,可以通过 ThreadLocal 获取当前线程对应的用户信息:

public class SomeComponent {
    public void doSomething() {
        User user = UserFilter.userThreadLocal.get();
        // 使用用户信息进行操作
        // ...
    }
}

在上述示例中,通过 ThreadLocal 将用户信息存储在不同的线程中,避免了在不同组件间传递参数的麻烦,实现了上下文信息的传递。

3.2 示例二:数据库连接管理

在一个多线程的数据库访问场景中,使用 ThreadLocal 可以实现每个线程使用自己的数据库连接,封装数据库连接的获取和释放过程。

首先,定义一个数据库连接管理类:

public class ConnectionManager {
    private static final ThreadLocal<Connection> connectionThreadLocal = new ThreadLocal<>();
    
    public static Connection getConnection() {
        Connection connection = connectionThreadLocal.get();
        if (connection == null) {
            // 创建新的数据库连接
            connection = createConnection();
            connectionThreadLocal.set(connection);
        }
        return connection;
    }
    
    public static void releaseConnection() {
        Connection connection = connectionThreadLocal.get();
        if (connection != null) {
            // 关闭数据库连接
            closeConnection(connection);
            connectionThreadLocal.remove();
        }
    }
    
    private static Connection createConnection() {
        // 创建数据库连接
        // ...
        return new Connection();
    }
    
    private static void closeConnection(Connection connection) {
        // 关闭数据库连接
        // ...
    }
}

然后,在数据库访问的代码中,通过 ConnectionManager 来获取和释放数据库连接:

public class UserDao {
    public void save(User user) {
        Connection connection = ConnectionManager.getConnection();
        try {
            // 使用数据库连接进行数据保存操作
            // ...
        } finally {
            ConnectionManager.releaseConnection();
        }
    }
}

在上述示例中,每个线程都会通过 ThreadLocal 存储自己的数据库连接,避免了多线程并发访问同一个连接引发的问题。

四、存在的问题

ThreadLocal 存在的问题以及解决方法:

4.1 内存泄漏问题

ThreadLocal 的内部实现是通过 ThreadLocalMap 来维护每个线程的局部变量,并且 ThreadLocalMap 中的 Entry 对象使用弱引用来引用 ThreadLocal 对象。这就意味着,在没有其他强引用指向 ThreadLocal 对象时,ThreadLocal 对象可能被垃圾回收器回收,而相应的线程局部变量的值仍然保留在 ThreadLocalMap 中,从而导致内存泄漏问题。

解决方法: 为了避免内存泄漏,需要在使用完 ThreadLocal 后手动调用 remove() 方法清理对应的线程局部变量。通常可以通过在 finally 块中进行清理操作,以确保即使发生异常,也能正确清理 ThreadLocal。下面是一个示例代码:

class MyThreadLocalExample {
   private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

   public static void main(String[] args) {
       try {
           // 设置线程局部变量的值
           threadLocal.set(123);

           // 执行业务逻辑...
       } finally {
           // 清理线程局部变量
           threadLocal.remove();
       }
   }
}

4.2 线程复用时的数据共享问题

在线程池等多线程复用的场景中,通过 ThreadLocal 存储的线程局部变量可能会被 “复用” 给其他线程使用,从而导致数据共享问题。也就是说,在某些情况下,多个线程共享同一个 ThreadLocal 的值,这不符合我们使用 ThreadLocal 的初衷。

解决方法: 对于线程池等多线程复用的场景,可以考虑使用 InheritableThreadLocal 来解决数据共享问题。InheritableThreadLocal 是 ThreadLocal 的一个子类,它允许子线程从父线程中继承线程局部变量的值。下面是一个简单的示例代码:

class MyInheritableThreadLocalExample {
   private static ThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();

   public static void main(String[] args) {
       // 设置线程局部变量的值
       threadLocal.set(123);

       // 创建子线程并执行任务
       Thread childThread = new Thread(() -> {
           // 子线程可以继承父线程的线程局部变量的值
           int value = threadLocal.get();
           System.out.println("子线程获取到的值:" + value);
       });
       childThread.start();
   }
}

4.3 使用 ThreadLocal 的弱引用

在一些场景下,我们不希望 ThreadLocal 对象长期持有对线程局部变量的引用,以避免潜在的内存泄漏问题。可以使用 WeakReference 或者自定义的 WeakThreadLocal 来实现 ThreadLocal 的弱引用版本。这样,在没有其他强引用指向 ThreadLocal 对象时,ThreadLocal 对象就可以被垃圾回收。

解决方法: 下面是一个解决方法的demo代码,展示如何使用 WeakReference 来实现 ThreadLocal 的弱引用版本:

class MyWeakThreadLocalExample {
   private static ThreadLocal<WeakReference<Integer>> threadLocal = new ThreadLocal<>();

   public static void main(String[] args) {
       // 设置线程局部变量的值
       threadLocal.set(new WeakReference<>(123));

       // 业务逻辑...

       // 获取线程局部变量的值
       WeakReference<Integer> reference = threadLocal.get();
       Integer value = reference.get();
       System.out.println("线程局部变量的值:" + value);
   }
}

通过及时清理 ThreadLocal、使用 InheritableThreadLocal 或者使用 ThreadLocal 的弱引用,可以解决 ThreadLocal 存在的问题,并保证线程安全和正确性。应根据具体场景选择合适的解决方法。

五、ThreadLocal和Synchronized的区别

很多同学分不清:ThreadLocal和Synchronized这两种Java多线程编程中用于实现线程安全的两种机制。也不太明白如何去用它们。下面我简单归纳一下吧,它们在实现方式、适用场景和效果上确实是有一些区别的。

(1)实现方式:

  • ThreadLocal:ThreadLocal是一种基于线程的局部变量实现机制。每个线程都有自己独立的ThreadLocal实例,并且每个线程可以访问各自的ThreadLocal实例,线程之间的变量互不干扰。ThreadLocal内部使用一个Map结构,在每个线程内部维护一个变量副本。
  • Synchronized:Synchronized是通过互斥锁(也称为监视器锁)来实现线程安全的。当一个线程获取到锁时,其他线程需要等待,直到持有锁的线程释放锁才能执行相应的代码块。

(2)适用场景:

  • ThreadLocal:ThreadLocal适用于需要在线程之间隔离数据的场景,每个线程可以独立地操作自己的变量副本。常见的使用场景包括Web应用程序的请求处理、数据库事务管理等。
  • Synchronized:Synchronized适用于多个线程共享同一个资源的场景,需要确保在同一时间只有一个线程访问共享资源,从而避免数据竞争和不一致性。常见的使用场景包括共享数据的读写、临界区的保护等。

(3)效果:

  • ThreadLocal:通过ThreadLocal可以实现线程间的数据隔离,每个线程都有自己独立的变量副本,并且修改不会影响其他线程。这样可以避免使用锁带来的开销,提高并发性能。但需要注意合理管理ThreadLocal实例,避免内存泄漏。
  • Synchronized:使用Synchronized可以保证线程安全,确保共享资源在同一时间只能被一个线程访问,从而避免数据竞争和不一致性。Synchronized通过获取锁来控制对共享资源的访问,保证了线程安全。但是,使用锁会引入额外的开销,并且当多个线程竞争同一个锁时,可能会导致线程阻塞和性能下降。

以下是ThreadLocal和Synchronized的对比情况:

比较类型 ThreadLocal Synchronized
实现方式 基于线程的局部变量 通过互斥锁(监视器锁)实现
适用场景 需要在线程之间隔离数据的场景 多个线程共享同一个资源的场景
效果 线程间数据隔离,避免锁的开销,提高并发性能 线程安全,保证共享资源在同一时间只能被一个线程访问
锁的粒度 线程级别 对象级别
并发性能 可以提高并发性能 引入额外的开销,可能导致线程阻塞
内存管理 需要注意合理管理ThreadLocal实例,避免泄漏 无需额外的内存管理
使用复杂度 相对较低,简单易用 相对较高,需要手动控制加锁和释放锁
编程范式 面向变量副本 面向共享资源

ThreadLocal适用于需要在线程间隔离数据的场景,可以提高并发性能,但需要注意管理ThreadLocal实例。Synchronized适用于多个线程共享同一个资源的场景,保证线程安全,但可能引入额外的开销和线程阻塞。 ThreadLocal 可以实现线程级别的变量存储,确保每个线程都拥有自己独立的变量副本,避免线程安全问题。ThreadLocal 的使用场景包括线程安全问题、传递上下文信息、数据库连接管理等。通过合理地运用 ThreadLocal,可以简化多线程编程,提高代码的可读性和可维护性。

추천

출처juejin.im/post/7248137735310966845