Tìm hiểu về ThreadLocal trong Java

1. Giới thiệu

Trong bài viết này, mình sẽ giới thiệu về ThreadLocal trong Java. Nó có khả năng lưu trữ dữ liệu riêng lẻ cho luồng hiện tại - và chỉ gói nó lại trong một loại đối tượng đặc biệt.

2. ThreadLocal API

Cấu trúc TheadLocal cho phép chúng ta lưu trữ dữ liệu mà chỉ một luồng cụ thể mới có thể truy cập được.
Giả sử rằng chúng ta muốn có một giá trị Integer sẽ được gói trong 1 Thread cụ thể như sau:

ThreadLocal<Integer> threadLocalValue = new ThreadLocal<>();

Tiếp theo, khi chúng ta muốn sử dụng giá trị này từ một Thread, chúng ta chỉ cần gọi một phương thức get() hoặc set(). Nói một cách đơn giản, chúng ta có thể nghĩ rằng ThreadLocal lưu trữ dữ liệu bên trong map - với thread là key.
Do đó, khi chúng ta gọi một phương thức get() trên threadLocalValue, chúng ta sẽ nhận được một giá trị Integer cho thread yêu cầu:

threadLocalValue.set(1);
Integer result = threadLocalValue.get();

Chúng ta có thể tạo một instance của ThreadLocal bằng cách sử dụng static method withInitial() và truyền vào 1 supplier tương ứng:

ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 1);

Để xóa giá trị khỏi ThreadLocal, chúng ta có thể gọi phương thức remove():

threadLocal.remove();

Để xem cách sử dụng ThreadLocal đúng cách, trước tiên, chúng ta sẽ xem xét một ví dụ không sử dụng ThreadLocal, sau đó chúng ta sẽ viết lại ví dụ của mình để tận dụng cấu trúc đó.

3. Lưu trữ User Data trong 1 Map

Hãy xem xét một chương trình cần lưu trữ dữ liệu - Context dành riêng cho user trên mỗi user id:

public class Context {
    private String userName;

    public Context(String userName) {
        this.userName = userName;
    }
}

Chúng tôi muốn có một thread cho mỗi userId. Chúng ta sẽ tạo một class SharedMapWithUserContext implement interface Runnable. Việc implement trong phương thức run() gọi một số cơ sở dữ liệu thông qua class UserRepository trả về một đối tượng Context cho một userId nhất định.
Tiếp theo, chúng ta lưu trữ Context đó trong map ConcurentHashMap với key là userId:

public class SharedMapWithUserContext implements Runnable {
 
    public static Map<Integer, Context> userContextPerUserId = new ConcurrentHashMap<>();
    private Integer userId;
    private UserRepository userRepository = new UserRepository();

    @Override
    public void run() {
        String userName = userRepository.getUserNameForUserId(userId);
        userContextPerUserId.put(userId, new Context(userName));
    }

    // standard constructor
}

Chúng ta có thể dễ dàng kiểm tra code của mình bằng cách tạo và start 2 thread cho 2 userId khác nhau và xác nhận rằng chúng ta có hai mục trong userContextPerUserId map:

SharedMapWithUserContext firstUser = new SharedMapWithUserContext(1);
SharedMapWithUserContext secondUser = new SharedMapWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();

assertEquals(SharedMapWithUserContext.userContextPerUserId.size(), 2);

4. Lưu trữ User Data trong ThreadLocal

Chúng ta có thể viết lại ví dụ trên để lưu trữ Context của user bằng ThreadLocal. Mỗi thread sẽ có instance ThreadLocal của riêng nó.
Khi sử dụng ThreadLocal, chúng ta cần phải rất cẩn thận vì mọi instance của ThreadLocal đều được liên kết với một thread cụ thể. Trong ví dụ, chúng ta có một luồng dành riêng cho từng userId cụ thể và thread này do chúng ta tạo nên, vì thế chúng ta có toàn quyền kiểm soát nó.
Phương thức run() sẽ tìm nạp user context và lưu trữ nó vào biến ThreadLocal bằng phương thức set():

public class ThreadLocalWithUserContext implements Runnable {
 
    private static ThreadLocal<Context> userContext = new ThreadLocal<>();
    private Integer userId;
    private UserRepository userRepository = new UserRepository();

    @Override
    public void run() {
        String userName = userRepository.getUserNameForUserId(userId);
        userContext.set(new Context(userName));
        System.out.println("thread context for given userId: " + userId + " is: " + userContext.get());
    }
    
    // standard constructor
}

Chúng ta có thể kiểm tra nó bằng cách bắt đầu hai thread sẽ thực thi hành động cho một userId nhất định:

ThreadLocalWithUserContext firstUser = new ThreadLocalWithUserContext(1);
ThreadLocalWithUserContext secondUser = new ThreadLocalWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();

Sau khi chạy đoạn code này, chúng ta sẽ thấy trên đầu ra tiêu chuẩn mà ThreadLocal đã được đặt cho mỗi thread nhất định:

thread context for given userId: 1 is: Context{userNameSecret='18a78f8e-24d2-4abf-91d6-79eaa198123f'}
thread context for given userId: 2 is: Context{userNameSecret='e19f6a0a-253e-423e-8b2b-bca1f471ae5c'}

Chúng ta có thể thấy rằng mỗi user có Context riêng.

5. ThreadLocals và Thread Pools

ThreadLocal cung cấp một API dễ sử dụng để giới hạn một số giá trị cho mỗi thread. Đây là một cách hợp lý để đạt được an toàn luồng trong Java. Tuy nhiên, chúng ta nên hết sức cẩn thận khi sử dụng ThreadLocals và thread pool cùng nhau.
Để hiểu rõ hơn về cảnh báo có thể xảy ra, chúng ta hãy xem xét tình huống sau:

    1. Đầu tiên, ứng dụng mượn một thread từ pool.
    1. Sau đó, nó lưu trữ một số giá trị giới hạn thread vào ThreadLocal của thread hiện tại.
    1. Khi quá trình thực thi hiện tại kết thúc, ứng dụng sẽ trả lại thread đã mượn cho nhóm.
    1. Sau một thời gian, ứng dụng mượn cùng một thread để xử lý một yêu cầu khác.
    1. Vì lần trước ứng dụng không thực hiện các thao tác dọn dẹp cần thiết, nên nó có thể sử dụng lại cùng một dữ liệu ThreadLocal cho yêu cầu mới.

Điều này có thể gây ra hậu quả đáng ngạc nhiên trong các ứng dụng có độ đồng thời cao.
Một cách để giải quyết vấn đề này là xóa thủ công từng ThreadLocal sau khi chúng ta sử dụng xong. Vì cách tiếp cận này cần xem xét code nghiêm ngặt, nó có thể dễ xảy ra lỗi.

5.1. Mở rộng ThreadPoolExecutor

Hóa ra, có thể mở rộng lớp ThreadPoolExecutor và cung cấp triển khai hook tùy chỉnh cho các phương thức beforeExecute()afterExecute(). Thread pool sẽ gọi phương thức beforeExecute() trước khi chạy bất cứ thứ gì bằng thread mượn. Mặt khác, nó sẽ gọi phương thức afterExecute() sau khi thực thi logic của chúng ta.
Therefore, we can extend the ThreadPoolExecutor class and remove the ThreadLocal data in the afterExecute() method:

public class ThreadLocalAwareThreadPool extends ThreadPoolExecutor {

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        // Call remove on each ThreadLocal
    }
}

Nếu chúng ta gửi yêu cầu của mình đến việc implement ExecutorService này, thì chúng ta có thể chắc chắn rằng việc sử dụng ThreadLocal và các thread pool sẽ không gây ra các nguy cơ mất an toàn cho ứng dụng của chúng ta.

6. Kết luận

Trong bài viết này, chúng ta đã xem xét cấu trúc ThreadLocal. Chúng ta đã triển khai logic sử dụng ConcurrentHashMap được chia sẻ giữa các luồng để lưu trữ ngữ cảnh được liên kết với một userId cụ thể. Tiếp theo, chúng ta viết lại ví dụ của mình để tận dụng ThreadLocal để lưu trữ dữ liệu được liên kết với một userId cụ thể và với một thread cụ thể.
Bạn có thể tham khảo thêm cách implement bằng 1 project ví dụ trên github

7. Tham khảo

An Introduction to ThreadLocal in Java - baeldung.com


All Rights Reserved