ThreadLocal cho mỗi luồng một bản riêng của cùng một biến. Nghe vô hại, và nó giải quyết được vài bài toán rất gọn.

Nhưng nó được thiết kế cho thời mà luồng sinh ra rồi chết. Trong thế giới có pool luồng — tức là gần như mọi ứng dụng máy chủ — nó mang theo hai cái bẫy, và cái thứ nhất tệ hơn hẳn một lỗi hiệu năng.

Nó dùng để làm gì

static final ThreadLocal<SimpleDateFormat> DINH_DANG =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("dd/MM/yyyy"));
  4 luồng -> 4 đối tượng SimpleDateFormat khác nhau (an toàn luồng miễn phí)

SimpleDateFormat không an toàn luồng — đó là lỗi kinh điển đã nhắc ở bài 61. Ba cách chữa: tạo mới mỗi lần (tốn), bọc khoá (chậm), hoặc cho mỗi luồng một bản.

Công dụng thứ hai, phổ biến hơn nhiều: truyền ngữ cảnh mà không phải thêm tham số. Người dùng hiện tại, mã theo dõi request, kết nối cơ sở dữ liệu của giao dịch đang mở. Spring Security, MDC của log, TransactionSynchronizationManager đều dựa trên nó.

Sức hấp dẫn nằm ở chỗ đó: bạn đặt giá trị ở tầng ngoài cùng và một hàm sâu mười tầng đọc được, không phải sửa mười chữ ký hàm.

Bẫy một: dữ liệu rò sang người dùng khác

Pool 2 luồng, sáu "request". Ba request đầu đặt tên người dùng, ba request sau không đặt gì cả và chỉ đọc:

  request 1: đặt NGUOI_DUNG = user-1
  request 2: đặt NGUOI_DUNG = user-2
  request 3: đặt NGUOI_DUNG = user-3
  request 4: KHÔNG đặt gì, đọc ra -> user-2   <<< RÒ DỮ LIỆU CỦA NGƯỜI KHÁC
  request 6: KHÔNG đặt gì, đọc ra -> user-2   <<< RÒ DỮ LIỆU CỦA NGƯỜI KHÁC
  request 5: KHÔNG đặt gì, đọc ra -> user-3   <<< RÒ DỮ LIỆU CỦA NGƯỜI KHÁC

Đây không phải lỗi hiệu năng. Đây là lỗ hổng bảo mật.

Luồng trong pool được tái sử dụng, và ThreadLocal gắn vào luồng, không gắn vào request. Giá trị mà request 2 đặt vào vẫn nằm nguyên đó khi luồng ấy nhận request 4.

Hình dung trong hệ thống thật: một bộ lọc xác thực đặt "người dùng hiện tại" vào ThreadLocal. Một đường đi nào đó trong mã — trang lỗi, endpoint công khai, một nhánh ngoại lệ — không đi qua bộ lọc đó. Mã phía sau đọc ThreadLocal và nhận được người dùng của request trước. Rồi nó hiển thị dữ liệu của người đó.

Thêm remove() trong finally:

try {
    NGUOI_DUNG.set(nguoiDung);
    xuLy(request);
} finally {
    NGUOI_DUNG.remove();
}
  request 4 đọc ra -> null
  request 5 đọc ra -> null
  request 6 đọc ra -> null
Mọi set() phải có một remove() tương ứng trong finally. Không có ngoại lệ. Và remove() phải nằm ở tầng sở hữu vòng đời request — thường là một servlet filter hoặc interceptor — chứ không rải rác trong mã nghiệp vụ.

Chú ý điều làm lỗi này khó phát hiện: nó không xảy ra khi bạn thử một mình. Phải có pool tái sử dụng luồng, phải có request không đặt giá trị, và phải đúng thứ tự. Trên máy phát triển với một người dùng thì không bao giờ thấy.

Bẫy hai: rò rỉ bộ nhớ

Năm mươi luồng, mỗi luồng đặt một mảng 2MB vào ThreadLocal, rồi ta ép chạy GC:

  KHÔNG remove() : sau GC còn giữ 110 MB
  CÓ remove()    : sau GC còn giữ   4 MB

Bộ thu gom rác không dọn được, vì giá trị vẫn đang được tham chiếu — bởi chính luồng còn sống trong pool.

Cơ chế bên trong đáng biết, vì nó giải thích vì sao lời khuyên "cứ để GC lo" không đúng ở đây. Mỗi Thread có một ThreadLocalMap, trong đó khoá là tham chiếu yếu tới đối tượng ThreadLocal, nhưng giá trị là tham chiếu mạnh.

Nên khi đối tượng ThreadLocal không còn ai dùng, khoá bị thu hồi và mục đó thành "khoá null, giá trị vẫn còn" — một mục mồ côi mà chỉ có thao tác tiếp theo trên cùng luồng mới dọn hộ. Với ThreadLocal khai static final (tức là gần như mọi trường hợp), khoá không bao giờ bị thu hồi, nên giá trị sống đúng bằng đời của luồng.

Trong máy chủ ứng dụng có nạp lại classloader, chuyện còn tệ hơn: giá trị giữ tham chiếu tới lớp của nó, lớp giữ classloader, và cả ứng dụng cũ không được giải phóng. Đó là nguồn của thông báo quen thuộc "web application appears to have started a thread but has failed to stop it".

InheritableThreadLocal

  ThreadLocal thường     trong luồng con: null
  InheritableThreadLocal trong luồng con: giá trị của luồng chính

Bản kế thừa sao giá trị sang luồng con lúc tạo luồng. Nghe tiện cho việc truyền ngữ cảnh xuống các tác vụ con.

Nhưng trong môi trường có pool, nó gần như vô dụng: luồng của pool được tạo một lần duy nhất, thường là lúc pool khởi động. Chúng thừa hưởng ngữ cảnh của cái luồng ngẫu nhiên nào đó đã kích hoạt việc tạo luồng, rồi giữ nguyên mãi mãi. Kết quả là một giá trị sai, ổn định, rất khó nghi ngờ.

Luồng ảo nhân cái giá lên

   10.000 luồng ảo, mỗi cái giữ 10KB -> bộ nhớ tăng   130 MB
  100.000 luồng ảo, mỗi cái giữ 10KB -> bộ nhớ tăng 1.086 MB
  500.000 luồng ảo, mỗi cái giữ 10KB -> bộ nhớ tăng 4.135 MB

Tuyến tính, đúng như thiết kế: mỗi luồng một bản riêng.

Vấn đề là bài 67 đã đo — luồng ảo sinh ra để bạn tạo hàng trăm nghìn, hàng triệu cái. Nhân số đó với kích thước một ThreadLocal là ra một con số không dùng được.

Điểm sáng: luồng ảo không nằm trong pool, mỗi tác vụ một luồng mới rồi chết ngay. Nên bẫy rò dữ liệu ở mục đầu biến mất hoàn toàn — không có luồng nào được tái sử dụng để mà mang theo giá trị cũ.

Nói cách khác, luồng ảo chữa bẫy thứ nhất và làm nặng thêm bẫy thứ hai.

ScopedValue: cách làm của Java 21

Java 21 giới thiệu ScopedValue (bản xem trước) để thay ThreadLocal cho đúng bài toán truyền ngữ cảnh:

static final ScopedValue<String> NGUOI_DUNG = ScopedValue.newInstance();

ScopedValue.where(NGUOI_DUNG, "user-7").run(() -> {
    xuLy();                       // mọi hàm gọi bên trong đọc được
});
  ngoài phạm vi, isBound() = false
  trong phạm vi  : user-7
  hàm gọi sâu bên trong đọc được: user-7
  ra khỏi phạm vi, isBound() = false   <- tự huỷ, không cần remove()

Ba khác biệt quan trọng:

Bất biến. Không có set(). Giá trị được gắn khi vào phạm vi và không đổi được bên trong, nên không có chuyện một hàm sâu bên trong lặng lẽ sửa ngữ cảnh của bạn.

Tự huỷ khi ra khỏi phạm vi. Không thể quên remove() vì không có remove(). Cả hai cái bẫy của bài này đều biến mất theo cấu trúc, không phải nhờ kỷ luật.

Rẻ khi chia sẻ với luồng con. Với StructuredTaskScope, các tác vụ con thấy được giá trị mà không phải sao chép — khác InheritableThreadLocal.

Nó vẫn là bản xem trước ở Java 21 (cần --enable-preview), nên chưa dùng cho mã sản xuất được. Nhưng hướng đi đã rõ, và nếu bạn đang thiết kế một cơ chế truyền ngữ cảnh mới thì đáng biết trước.

Bốn quy tắc

Luôn remove() trong finally, ở tầng sở hữu vòng đời request.

Khai static final. ThreadLocal không phải static thì mỗi thực thể tạo một mục mới trong bản đồ của mọi luồng — vừa rò rỉ vừa vô nghĩa.

Đừng đặt vật nặng vào đó. Bộ nhớ đệm, danh sách lớn, kết nối. Nhân với số luồng là ra con số thật, và với luồng ảo thì nhân với số rất lớn.

Cân nhắc truyền tham số tường minh trước. ThreadLocal là trạng thái ẩn — nó không hiện trong chữ ký hàm, nên người đọc mã không thấy, và test phải nhớ thiết lập nó. Nhiều chỗ dùng ThreadLocal thật ra chỉ đang tránh việc thêm một tham số.

Thử ba mươi giây

Tìm trong dự án của bạn new ThreadLocalThreadLocal.withInitial.

Với mỗi chỗ, tìm lời gọi remove() tương ứng. Không có thì bạn đang mang cả hai cái bẫy trong bài — và cái nguy hiểm hơn không phải rò rỉ bộ nhớ, mà là request của người này đọc được dữ liệu của người kia, đúng như ba dòng đầu bài.

Ngày mai dành trọn cho luồng ảo — thứ đã xuất hiện rải rác suốt chặng này: cơ chế gắn và tháo khỏi luồng mang, chi phí thật đo bằng JFR, và những trường hợp không nên đổi sang.