ScopedValue vs ThreadLocal. Новий крок в еволюції Java
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як особливості розробки багатопотокових застосунків. Це одна з найскладніших тем у розробці Java-застосунків та на технічних співбесідах часто на цю тему перевіряється глибина знань кандидата та його практичний досвід.
Довгий час джавісти використовували ThreadLocal для прив’язування об’єктів до поточного потоку. Це застосовувалося і при обробці вебзапитів, і при використанні транзакцій в реляційних БД, і багатьох інших випадках. Але в JDK 20 з’явився новий клас ScopedValue, який пропонується інженерами Oracle як альтернатива ThreadLocal. У цій статті я хотів би розповісти про зручності його використання і причини його появи в JDK.
ThreadLocal
Навіть якщо ви ніколи не чули і ніколи не використовували явно ThreadLocal, все одно ваші проєкти швидше за все не змогли б без нього обійтися, оскільки він використовується в багатьох фреймворках Java.
ThreadLocal з’явився у Java 2, а Java 5 став generic type. По суті, його головне призначення — зберігання змінних з областю видимості (scope) поточний потік. Чому не можна обійтися якимсь Singleton або іншими підручними засобами? Насправді можна, але це буде не дуже зручно, тому що доведеться деякі загальні параметри передавати між усіма методами в рамках якогось глобального процесу (наприклад, транзакції або обробки вебзапиту).
А дуже зручно. Достатньо оголосити ось таке поле у вашому класі:
static final ThreadLocal<Long> CONTEXT = new ThreadLocal<>();
і можна змінити його значення або отримати поточне значення, і це значення буде доступне лише для поточного потоку:
long id = CONTEXT.get();
В принципі, ви і самі можете придумати щось схоже за допомогою Map.:
private final Map<Thread, Object> values;
Але тут можуть виникнути проблеми, якщо ваші потоки не зупиняються при завершенні роботи програми (наприклад, сервер застосунків), і тоді збирач сміття буде безсилий видалити такий об’єкт. Тому ThreadLocal реалізований хитріше. По-перше, всі значення зберігаються не в ThreadLocal, а в самому потоці:
ThreadLocal.ThreadLocalMap threadLocals;
А об’єкт ThreadLocalMap зберігає посилання на ThreadLocal і розрізняє їх за спеціальним образом згенерованого унікального hashCode. Начебто все добре, і ідея приваблива, але це тільки якщо не вдаватися в деталі. Не всі знають, що ThreadLocal можна відключити для поточного потоку. Для цього є спеціальна бітова маска, яку потрібно вказати при створенні потоку (в його характеристиках):
static final int NO_THREAD_LOCALS = 1 << 1;
У такому разі метод get() завжди повертає початкове значення. Але це не найбільша проблема, тому що ви контролюєте створення потоків та їх характеристики. Є й серйозніші недоліки. Вся обробка транзакцій та вебзапитів спочатку будувалася на тому, що вона йтиме лише в одному потоці. Але що, якщо передамо управління в інший (дочірній) потік? Зрозуміло, в ньому встановлене раніше значення не буде доступно, і можна поламати логіку роботи деяких фреймворків. Ось як у Spring перевіряється статус поточної транзакції:
public abstract class TransactionSynchronizationManager {
private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
public static boolean isActualTransactionActive() {
return (actualTransactionActive.get() != null);
}
Тепер Spring вирішить, що ніякої активної транзакції немає, і це може призвести до непередбачуваних наслідків. Тому для таких випадків є спеціальний патерн, який копіює значення з ThreadLocal в дочірній потік перед виконанням завдання, а потім його відновлює:
class WrappedRunnable implements Runnable {
private final Long contextId;
private final Runnable task;
public WrappedRunnable(Runnable task) {
this.contextId = CONTEXT.get();
this.task = task;
}
@Override
public void run() {
Long oldId = CONTEXT.get();
CONTEXT.set(contextId);
try {
task.run();
} finally {
CONTEXT.set(oldId);
}
}
}
Таким чином, вам потрібно скопіювати ВСІ значення з ThreadLocal головного потоку до дочірнього потоку, що вплине і на витрату ресурсів, і на швидкодію.
Є й інші недоліки ThreadLocal, саме те, що він є mutable за рахунок методу set(). Таким чином, будь-хто, хто має доступ до об’єкта ThreadLocal, може змінити його поточне значення, і це знову ж таки може призвести до непередбачуваних наслідків. Тому рекомендується ніколи не звертатися до вашого ThreadLocal безпосередньо, а тільки через деякий публічний API, наприклад, той же TransactionSynchronizationManager в Spring.
Третій недолік полягає в тому, що можна забути видалити поточне значення (очистити ThreadLocal) після завершення роботи вашого завдання. Оскільки всі ці значення зберігаються в потоці, який може перевикористовуватися, тобто ризик отримати значення від старої задачі. Тому краще використовувати такий шаблон, який спочатку ініціалізує ThreadLocal, а потім його явно очищає:
public static void runInContext(Context context, Runnable runnable) {
setUp(context);
try {
runnable.run();
} finally {
tearDown();
}
}
Такий підхід не завжди підходить у зв’язці з Executors Framework, тому що ви не знаєте, який саме потік виконуватиме ваше завдання. І останній недолік полягає в тому, що ThreadLocal не підтримує з коробки вкладені операції (наприклад, транзакції). І якщо ви в Spring стартували вкладену транзакцію, то доведеться зберігати всі атрибути поточної транзакції, а тільки потім вже почати цю транзакцію.
Але ThreadLocal має несподівані області застосування. Наприклад, як виглядає знайомий багатьом метод format() класу String:
public static String format(String format, Object... args) {
return new Formatter().format(format, args).toString();
}
Тут щоразу створюється об’єкт Formatter, що загалом уповільнює роботу цього. Здавалося б, що простіше — створити один об’єкт Formatter на весь застосунок та використовувати. Але, на жаль, як випливає з JavaDocs, він не thread-safe:
* <p> Formatters are not necessarily safe for multithreaded access. Thread
* safety is optional and is the responsibility of users of methods in this
* class.
Можна все одно створити Singleton об’єкт і зробити його synchronized. Але це знову ж таки уповільнить його роботу. Тому несподіваний вихід — створювати такий об’єкт на кожен потік. Цим можна уникнути і блокувань, і створення об’єкта Formatter при кожному форматуванні рядка:
class FormatterUtils {
private final static ThreadLocal<Formatter> threadLocal =
ThreadLocal.withInitial(() -> new Formatter());
public static String format(String format, Object... args) {
return threadLocal.get().format(format, args).toString();
}
}
ScopedValue
Серед тих фітч, які заявлені та потрапили в JDK 20, переважають вже знайомі нам специфікації, які все ще перебувають у стадії тестування:
- Record patterns;
- Pattern matching for switch;
- Foreign function & Memory API;
- Віртуальні потоки;
- Structured concurrency;
- Vector API.
Єдиною дійсно новою фітчею є так звані Scoped Values (в JDK 19 вони називалися ExtentLocal). Вони є альтернативою або покращеною версією ThreadLocal. Але чому вони з’явилися тільки зараз, якщо ThreadLocal та його слабкості відомі з Java 2 (1998)? Вся справа у двох фітчах, які додали в JDK 19 — віртуальні потоки та Structured concurrency API. ScopedValues по суті є одним цілим з цим набором фітч. Більш того, без ScopedValue виникли б труднощі з реалізацією віртуальних потоків. Адже віртуальні потоки прив’язані до одного platform-потоку (або carrier thread), а це означає, що і ThreadLocal у них міститиме те саме значення.
Раніше ми відзначили три головні недоліки ThreadLocal:
- Mutability.
- Недоступність для дочірніх потоків.
- Необхідність явного очищення встановлених даних.
Так ось, на відміну від ThreadLocal ScopedValue є immutable, і ви не можете змінити його після ініціалізації. Таким чином, те значення, яке зберігалося в ScopedValue, має час життя — тривалість виконання тієї операції, яка спочатку була запущена. Після цього значення недоступне (unbound). Це цікавий приклад так званого динамічного scope (як, наприклад, об’єкти, які зберігаються у вебзапиті або сесії).
Розглянемо типовий приклад використання. Оскільки специфікація ScopedValue знаходиться в режимі інкубації і за замовчуванням недоступна, потрібно явно вказати, що вам потрібний доступ до її пакетів.:
module java20 {
requires jdk.incubator.concurrent;
}
Сам код виглядає досить просто:
public class DataManager {
private static final ScopedValue<Integer> CONTEXT_ID = ScopedValue.newInstance();
public void execute(int userId) {
ScopedValue.where(CONTEXT_ID, userId).run(
this::update);
}
private void update() {
int userId = CONTEXT_ID.get();
Спочатку це нагадує ThreadLocal. Ми оголошуємо та створюємо новий об’єкт ScopedValue, але потім починаються цікавіші речі. Надання поточного значення йде в методі ScopedValue.where. Тут назва where() говорить не про те, що ми шукаємо дані, а саме привласнюємо (bind) їх. А далі запускаємо потрібний нам Runnable чи Callable. Всередині цих методів за допомогою CONTEXT_ID.get() можна отримати поточне значення. У цьому ще одна відмінність від ThreadLocal, який являє собою звичайний API для читання/ запису даних. Він доступний завжди, поки є поточний потік. У ScopedValue значення прив’язане до того процесу, який ми запускаємо після надання значення. Немає процесу — немає значення.
Що, якщо ScopedValue не зберігає значення для потоку? І тут криється ще одна відмінність від ThreadLocal, де поверталося б початкове значення. І неможливо було б зрозуміти, чи це поточне значення, чи початкове. У ScopedValue якщо поточного значення немає, то викидається NoSuchElementException. Тому безпечніше додати перевірку:
if(CONTEXT_ID.isBound()) {
int userId = CONTEXT_ID.get();
}
Або, за аналогією з Optional:
int userId = CONTEXT_ID.orElse(0);
Якщо ScopedValue є immutable, то, якщо ми спробуємо ще раз викликати ScopedValue.where() в поточному потоці?
private void update() {
ScopedValue.where(CONTEXT_ID, -1).run(
() -> System.out.println(CONTEXT_ID.get()));
І тут позначається ще одна перевага ScopedValue — можливість проводити так званий rebinding. Тобто у разі вкладеного виклику попереднє (старе) значення буде збережено і доступне після завершення поточної операції. Більш того, якщо API для ThreadLocal дозволяє їх використовувати лише незалежно один від одного, то тут можна запустити процес, ініціалізувавши відразу кілька ScopedValue у функціональному стилі:
private static final ScopedValue<Integer> CONTEXT_ID = ScopedValue.newInstance();
private static final ScopedValue<Integer> DATA_ID = ScopedValue.newInstance();
public void execute(int userId) {
ScopedValue.where(CONTEXT_ID, userId).where(REQUEST_ID, 0).run(
this::update);
А якщо ми створимо дочірній потік? Чи буде доступне значення з ScopedValue? Зрозуміло, ні, якщо використовувати звичний Executors Framework, а не Structured Concurrency API і клас StructuredTaskScope:
private void update() {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> future = scope.fork(this::findUser);
scope.join().throwIfFailed();
User user = future.resultNow();
У цьому випадку scope — це змінна типу StructuredTaskScope, яка використовує фабрику створення віртуальних потоків (хоча можна використовувати будь-яку іншу). А scope.fork() виконує метод findUser() в новому потоці і потім чекає його завершення. Як ви бачите, більше немає необхідності для розробника копіювати потрібні значення між дочірніми потоками.
Тепер залишилося єдине питання — а як всі ці видимі плюси відбилися на швидкодії? Це питання можна розбити на два:
- Швидкість додавання нових значень.
- Швидкість їх вилучення.
Усі значення, які передаються в ScopedValue, зберігаються у вигляді так званих bindings в об’єктах Snapshot, які схожі на вузол в списку (LinkedList). Таким чином, додавання нового значення — досить швидка операція. У той же час отримання значення призводить до ітерації за цим списком, де на кожному кроці перевіряється hash (або bit-mask) у поточного ScopedValue (він є унікальним для поточного потоку). І це вже операція повільна. Тому в ScopedValue вбудовано кеш. Це масив максимального розміру в 16 елементів (системна властивість jdk.incubator.concurrent.ScopedValue.cacheSize контролює розмір кеша). Якщо значення перебуває у кеші, воно з нього береться. Якщо ж ні, то береться з bindings і поміщається в кеш. Тепер залишилося перевірити на практиці різницю у швидкодії між ThreadLocal та ScopedValue.
Додамо benchmarks на основі JMH, які перевірятимуть два найпростіші сценарії роботи — ініціалізація та отримання значення:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
public class ScopedValueBenchmark {
private ThreadLocal<String> threadLocal;
private ScopedValue<String> scopedValue;
@Setup
public void setup() {
threadLocal = ThreadLocal.withInitial(() -> "1");
scopedValue = ScopedValue.newInstance();
}
@Benchmark
public String accessThreadLocal() {
threadLocal.set("2");
return threadLocal.get();
}
@Benchmark
public String accessScopedValue() throws Exception {
return ScopedValue.where(scopedValue, "2").call(() -> scopedValue.get());
}
І аналогічна операція для багатьох об’єктів:
private List<ThreadLocal<String>> threadLocals;
private List<ScopedValue<String>> scopedValues;
Оскільки наш проєкт став модульним, потрібно додати всі залежності та зв’язки в module-info.java:
module java20 {
requires jdk.incubator.concurrent;
requires jmh.core;
requires jdk.unsupported;
exports demo.jmh_generated to jmh.core;
}
Для тестування використовуємо таку конфігурацію:
- JMH 1.36
- JDK 20.0
- Intel Core i9, 8 cores
- 32 GB
- 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)
Результати benchmarks:
Time (ns/op) | ThreadLocal | ScopedValue |
1 variable | 4.3 | 29.8 |
100 variables | 636.5 | 3430.9 |
Як бачимо, ThreadLocal поки що випереджає за швидкістю ScopedValue приблизно у
Висновки
ScopedValue зараз не можна використовувати в production, вона навіть не перебуває в режимі preview, а все ще в стадії інкубації і там можуть бути значні зміни. Тим не менш, є хороші шанси, що вона стане стабільною в майбутніх релізах Java, так як вона входить в проєкт Loom і йде в тандемі з віртуальними потоками та Structured Concurrency API.
Мені ця фіча чимось нагадала блокування (Locks), які з’явилися в Java 5 і являли собою значне поліпшення в порівнянні з методами/ блокуваннями синхронізації. Потрібен деякий час, щоб ця фіча стала популярною, і головне тут — підтримка з боку фреймворків (Spring, Hibernate), де її використання цілком виправдане.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів