Перейти к основному содержимому

Поиск по CooLWJ

选择搜索范围,输入关键词后按回车。

Язык

Технологии с глобальной точки зрения — история развития многопоточности в Java

Предисловие

22.09.2022 вышел JDK19. Главной особенностью этой версии стала поддержка виртуальных потоков: семейство легковесных потоков пополнилось ещё одним мощным участником. Виртуальные потоки освобождают JVM от зависимости от планирования потоков операционной системой: их планирует сама JVM. На самом деле ещё на раннем этапе Sun реализовывала планирование потоков средствами JVM в виртуальной машине для операционной системы Solaris, однако из-за сложности и соображений сопровождаемости в итоге вернулась к модели, в которой потоки планирует операционная система.

«长安归来锦衣客,昨日城南起新宅»(«Из Чанъаня вернулся гость в парчовых одеждах;вчера на юге города возвёл новый дом»). Оглядываясь на пройденный путь, понимаешь, что от обилия понятий многопоточности рябит в глазах, а объяснений в интернете не счесть, но всё равно ощущается нехватка общего взгляда. Поэтому я систематически изложил историю развития многопоточности в Java и надеюсь, что это поможет вам освоить эту тему.

О чём эта статья не рассказывает:

1 Здесь не разбираются подробные принципы реализации отдельных технологий, исходный код и схемы; если в статье вы нашли интересные вам понятия или технологии, можете поискать информацию самостоятельно 2 Здесь не рассматриваются библиотеки и фреймворки для поддержки параллельного выполнения, такие как Quasar、Akka、Guava и другие

О чём эта статья

1 Рассказ об истории развития многопоточности в JDK 2 Рассказ о назначении, принципах и предпосылках появления некоторых технологий в ходе этого развития, а также о решённых ими проблемах 3 Мнение автора о некоторых технических аспектах; приглашаю всех, у кого есть иная точка зрения, к обсуждению в комментариях

Веха

По традиции начнём со сводной таблицы. В ней систематизированы основные понятия, связанные с потоками, в разных поколениях JDK. Позволю себе, возможно, не вполне точное сравнение с автомобилями: развитие многопоточности прошло эпоху механической коробки передач(JDK1.4 и ниже), эпоху автоматической коробки передач(JDK5-JDK18)и эпоху беспилотного вождения(JDK19 и позднее). Это сравнение нужно лишь для того, чтобы показать читателю: начиная с JDK5 управлять многопоточностью стало удобнее, а с JDK19 дело вышло за рамки простого удобства — производительность сервисов с интенсивным вводом-выводом совершила качественный скачок.

ЭпохаВерсияДата публикацииОсновные понятия
Механическая коробка передачJDK1.01996-01-23Thread и Runnable
Механическая коробка передачJDK1.21998-12-04ThreadLocal、Collections
Автоматическая коробка передачJDK1.5/5.02004-09-30Уточнение модели памяти Java и введение пакета средств конкурентного программирования
Автоматическая коробка передачJDK1.6/6.02006-12-11Оптимизация synchronized
Автоматическая коробка передачJDK1.7/7.02011-07-28Фреймворк Fork/Join
Автоматическая коробка передачJDK1.8/8.02014-03-18CompletableFuture、Stream
Автоматическая коробка передачJDK1.9/9.02014-09-08Улучшение механизма разрешения конкуренции за блокировки
Автоматическая коробка передачJDK102018-03-21Управление локальными данными потока
Автоматическая коробка передачJDK152020-09-15Отключение смещённых блокировок и объявление их устаревшими
Автономное вождениеJDK192022-09-22Виртуальные потоки

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

Эпоха ручного переключения передач

JDK1.4 и более ранние версии я называю эпохой многопоточности с «ручным переключением передач», или эпохой нативной многопоточности. Работа с потоками тогда оставалась сравнительно низкоуровневой, пулов потоков ещё не было. Разработчикам приходилось самим писать инструменты, чтобы избежать расходования ресурсов из-за частого создания потоков, и вручную блокировать общие ресурсы. Именно в эту эпоху зародилось множество отличных фреймворков для многопоточности, самый известный из которых был включён в JDK5.0.

Thread и Runnable в JDK 1.0

В версии JDK1.0, выпущенной в месяце 1 года 1996, с самого начала была заложена базовая модель потоков Java. В ходе последующих доработок эта модель не претерпела существенных изменений; её можно назвать удачным решением, сохранившим преемственность. Вытесняющее и кооперативное планирование — два распространённых способа планирования процессов/потоков. Операционной системе хорошо подходит вытесняющее планирование процессов: она выделяет разным процессам кванты времени, а у процесса, который долго не отвечает, может отнять ресурсы и даже принудительно остановить его. При кооперативном планировании процессы должны самостоятельно и добровольно освобождать ресурсы; при таком способе планирования один долго выполняющийся поток может привести к «голоданию» всех остальных потоков, которым нужен CPU. Виртуальная машина Java HotSpot использует вытесняющее планирование, поэтому язык Java с самого начала применял вытесняющее планирование потоков. В JDK 1.0 потоки создавались главным образом путём наследования класса Thread или реализации интерфейса Runnable; поток запускался методом start экземпляра объекта, код для параллельной обработки помещался в метод run, а взаимодействие потоков осуществлялось простыми и грубыми методами вроде stop/resume/suspend.

Схема состояний потоков Java и переходов между ними

Как объяснить понятия stop/resume/suspend? Главный поток может напрямую вызывать методы завершения, приостановки и возобновления дочерних потоков. Если в детстве вы пользовались портативным плеером, на нём были три кнопки: стоп, пауза и воспроизведение. Представьте, что вы одновременно слушаете 3 плеера: три плеера — это три дочерних потока, а вы — главный поток; вы можете свободно запускать и останавливать эти три устройства.

У этого механизма есть критическая проблема: он легко приводит к взаимной блокировке. Причина в том, что главный поток может приостановить поток A, когда тот уже заблокировал некоторый ресурс, но ещё не освободил его(метод suspend не освобождает блокировку). Если в этот момент поток B захочет получить этот ресурс, ему придётся ждать, пока поток A возобновит выполнение(resume)и освободит ресурс; иначе он никогда его не получит, и возникнет взаимная блокировка.

JDK 1.2

В этой версии грубый механизм stop/resume/suspend запретили к использованию, перейдя к совместной работе нескольких потоков с помощью wait/notify/sleep. Стоит упомянуть, что в этой версии уже были спроектированы атомарные объекты AtomicityXXX, главным образом для решения проблемы неатомарности i++. Появление ThreadLocal и Collections расширило способы работы с многопоточностью, и благодаря этим двум технологиям автор называет этот период эпохой турбонаддува Java。

ThreadLocal

ThreadLocal — это способ, позволяющий нескольким потокам пользоваться непотокобезопасными объектами без блокировок. Он не решает задачи вроде «пополнения и списания средств с банковского счёта или увеличения и уменьшения складских остатков»; его сильная сторона — безопасное выполнение «инструментальных» методов классов, выполняющих роль «инструментов», за счёт создания отдельных копий. Типичные примеры — SimpleDateFormat, подключения к базе данных и тому подобное. Стоит упомянуть, что его устройство весьма изобретательно. Представьте, что вам поручили его спроектировать. Обычно первое простое решение такое:хранить в ThreadLocal глобальный потокобезопасный Map, где key — поток, а value — общий объект. У такого решения есть недостаток — утечка памяти, поскольку с появлением всё новых потоков этот Map будет неограниченно разрастаться. Чтобы устранить утечку памяти, придётся очищать Map при завершении потока, а это потребует дополнительных возможностей GC; очевидно, затраты себя не оправдывают. Поэтому ThreadLocal устроен так, что Map хранится не в ThreadLocal, а в самом Thread. key — переменная ThreadLocal, value — её значение. Каждый Thread помещает туда все используемые им ThreadLoacl(разумеется, такая конструкция порождает и другие проблемы, которые здесь не рассматриваются; желающие могут поискать информацию самостоятельно).

Collections

В этой версии был разработан служебный класс Collections, предоставляющий потокобезопасные обёртки для коллекций, например SynchronizedList. В эпоху, когда потокобезопасные коллекции были представлены лишь Hashtable, Vector, Stack и им подобными, его появление тоже стало важной вехой. Основная идея Collections такова: я помогу вам обернуть непотокобезопасную коллекцию в потокобезопасную, чтобы обновление существующего кода не отнимало много времени — достаточно при создании коллекции инициализировать её с помощью предоставленного мной метода. Это похоже на автомобильный турбонаддув, который повышает мощность и крутящий момент двигателя без изменения его рабочего объёма. В Java наступила эпоха турбонаддува ^\_^

Эпоха автоматической коробки передач

JDK 5.0

Подключение пакета для параллельного выполнения

Doug Lea,по-китайски 道格·利(Даг Ли),— преподаватель американского университета,выдающийся мастер своего дела,создатель J.U.C。До JDK1.5 для синхронизации конкурентного доступа в программах мы могли использовать только synchronized,производительность которого тогда ещё не была должным образом оптимизирована и оставалась невысокой,а управлять потоками можно было только методами wait и notify класса Object。Тогда Doug Lea подал в JCP предложение JSR-166。До подачи JSR-166 он уже более трёх лет использовал код с функциональностью,похожей на пакет J.U.C,и именно этот код стал прототипом J.U.C。

J.U.C предоставляет несколько крупных категорий инструментов: атомарные объекты, блокировки и вспомогательные средства, пулы потоков, потокобезопасные контейнеры и другие. Разработчики могут гибко использовать любые из этих возможностей для создания своих продуктов: как строить низкоуровневые фреймворки с помощью ReentrantLock, так и писать бизнес-код, непосредственно используя готовые инструменты или контейнеры. С исторической точки зрения J.U.C в 2004 году, без сомнения, можно было назвать «передовым технологическим продуктом». Он внёс огромный вклад в распространение Java. Наступила эпоха Java с автоматической коробкой передач: как автомобиль с автоматической коробкой облегчает жизнь водителю, так J.U.C значительно снизил порог освоения многопоточности для программистов. Этот продукт открыл целую эпоху.

Разумеется, у J.U.C тоже есть некоторые недостатки:

Высокая нагрузка на CPU:Если CAS в цикле ожидания долго не завершается успешно, это создаёт очень большую нагрузку на процессор.

Решение:в некоторых местах JUC число повторных попыток CAS ограничено, например в SynchronousQueue из BlockingQueue。

Проблема ABA:если значение сначала было A, затем стало B, а потом снова A, проверка CAS не обнаружит изменений, хотя на самом деле значение менялось. Это и есть проблема ABA. В большинстве случаев проблема ABA не нарушает корректность параллельного выполнения программы.

Решение: снабдить каждую переменную номером версии и при каждом изменении увеличивать его на 1, то есть A —> B —> A превращается в 1A —> 2B —> 3A. В Java для решения этой проблемы предусмотрен AtomicStampedReference. AtomicStampedReference оборачивает объект в кортеж [E,Integer], присваивая ему метку версии (stamp), что позволяет избежать проблемы ABA.

Можно гарантировать атомарность операций лишь над одной общей переменной:Механизм CAS гарантирует атомарность операции только над одной переменной, но не атомарность всего блока кода.

Решение:например, если необходимо обеспечить совместное атомарное обновление 3 переменных, придётся использовать Synchronized. Можно также рассмотреть AtomicReference для объединения нескольких переменных в один объект и таким способом работать с несколькими общими переменными.

Разобраться в модели памяти Java

В этой версии JDK была заново чётко определена модель памяти Java. До этого к распространённым моделям относились модель последовательной согласованности и модель happens-before. В модели последовательной согласованности порядок выполнения программы полностью совпадает с порядком, указанным в коде. Для современных многоядерных процессоров с оптимизацией выполнения инструкций обеспечить это очень трудно. Кроме того, гарантия последовательной согласованности серьёзно ограничивает оптимизацию кода JVM во время выполнения.

Однако отношение предшествования (Happens-before), определённое спецификацией JSR 133 этой версии, допускает гибкость в порядке выполнения инструкций:

В одном потоке, согласно порядку выполнения кода(то есть порядку, заданному его семантикой), предыдущая операция происходит раньше последующей. Разблокировка объекта monitor происходит раньше последующей блокировки того же объекта monitor. Запись в поле volatile происходит раньше последующего чтения этого поля. Запуск потока(вызов метода start() объекта потока)происходит раньше любых других операций этого потока. Все операции в потоке происходят раньше вызова метода join() для этого потока из любого другого потока. Если операция A происходит раньше B, а операция B — раньше C, то операция A происходит раньше C

Что касается распределения памяти, отделение собственной рабочей памяти каждого потока от основной даёт JVM ещё больше возможностей оптимизировать выполнение инструкций внутри потока. Переменные из основной памяти можно скопировать в рабочую память потока и обрабатывать отдельно, а после завершения выполнения результаты в некоторый момент могут быть записаны обратно в основную память: Но как обеспечить согласованность данных между потоками? JLS(Java Language Specification)предлагает следующий подход: по умолчанию согласованность данных в любой произвольный момент не гарантируется, однако её можно обеспечить с помощью ключевых слов synchronized, volatile и final, семантика которых была усилена.

Оптимизация synchronized в JDK 6.0

Ключевое слово synchronized, этот «старший сын республики», в версии 5.0 оказалось в тени ReentrantLock. В этой версии нужно было отыграться, поэтому в JDK 6.0 внесли ряд оптимизаций блокировок: спин-ожидание, устранение блокировок, объединение блокировок, легковесные и предвзятые блокировки и другие. Эта оптимизация стала воплощением идеи «тонкого управления». До неё у объекта, заблокированного через synchronized, было только два состояния: без блокировки и с блокировкой(тяжеловесной). После оптимизации у блокировки стало 4 состояния, от низшего к высшему: без блокировки, предвзятая блокировка, легковесная блокировка, тяжеловесная блокировка. По мере роста конкуренции эти состояния последовательно повышаются, но не понижаются; цель — повысить эффективность захвата и освобождения блокировок(автор полагает, что на деле всё оказалось слишком сложно и разработчики JVM отступили перед трудностями).

Эта оптимизация позволила synchronized наконец поднять голову: с тех пор он уже не позволял говорить, что по производительности уступает ReentrantLock. Но самое интересное было впереди: в JDK 15 смещённые блокировки объявили устаревшими(─.─||). По мнению автора, synchronized проигрывает из-за того, что это всего лишь ключевое слово: низкоуровневые действия выполняет JVM, и ей приходится «угадывать», какой способ блокировки удобнее приложению. С ReentrantLock всё иначе: он напрямую отдаёт это решение программисту. Нужна справедливость — используйте справедливую блокировку; нужна несправедливая — используйте несправедливую. На уровне проектирования это своего рода упрощение себе работы, но вместе с тем и гибкость.

Фреймворк Fork/Join в JDK 7.0

Появление Fork/Join также стало довольно передовой разработкой. Его главное преимущество — поддержка рекурсивного разбиения задач с последующим объединением их результатов. При этом технология одновременно знакомая и незнакомая: знакомая потому, что применяется во множестве мест, например в CompletableFuture и Stream, о которых пойдёт речь далее в разделе о JDK8.0;незнакомая потому, что, похоже, мы редко используем её непосредственно при разработке бизнес-функциональности.

Некоторые даже считают эту технологию малополезной. На мой взгляд, если вы разрабатываете прикладные бизнес-функции, пользы от неё мало, поскольку она практически не нужна: для больших объёмов данных есть инструменты хранилищ данных, а в остальных случаях её можно заменить пулом потоков; если же вы разрабатываете промежуточное ПО и фреймворки, она может пригодиться. В китайском интернете мало кто ставит эту технологию под сомнение, но за рубежом её уже обсуждают; заинтересованные читатели могут перейти к материалу Сломан ли фреймворк Fork-Join в Java?

JDK 8.0

Выход этой версии стал эпохальным событием для Java: её доля среди работающих сегодня во всём мире программ Java превышает половину. Однако обновления, связанные с многопоточностью, не были столь революционными, как в JDK5.0. Помимо добавления нескольких атомарных объектов, наиболее заметными стали следующие два обновления.

CompletableFuture

В интернете много материалов о CompletableFuture, и большинство из них объясняют принципы его работы и способы использования. Однако мне всё время оставался непонятен один вопрос: зачем появился CompletableFuture, если уже существует столько инструментов для работы с пулами потоков? Какую проблему он решает? В чём именно его главное преимущество? Думаю, если вы об этом задумывались, у вас возникал тот же вопрос. Всё в порядке: я уже нашёл для вас ответ.

Вывод:главное преимущество CompletableFuture —Оркестрация задач。CompletableFuture наследует возможности интерфейса Future, включая параллельное выполнение задач; всем этим возможностям можно найти замену. Но его возможности оркестрации задач незаменимы: основные API, в том числе построение цепочек задач и объединение их результатов, разработаны именно для оркестрации. Поэтому главной причиной появления этого фреймворка в данной версии JDK стала всё более острая потребность в оркестрации задач при разработке бизнес-приложений.

И напоследок ещё одно замечание: в основе реализации CompletableFuture лежит фреймворк Fork/Join.

Stream

В книге «Чистая архитектура» упоминаются три парадигмы программирования:структурное(процедурное), объектно-ориентированное и функциональное программирование. Stream — одно из воплощений функционального программирования в Java. На мой взгляд, освоение Stream — обязательный этап перехода от начинающего программиста к специалисту среднего уровня. При первом знакомстве Stream наверняка покажется непривычным, но, освоившись, вы откроете для себя новый взгляд на способы программирования. Разработчики часто путают три понятия:функциональное программирование, Stream и лямбда-выражения, считая, что речь об одном и том же. Ниже я изложу своё понимание:

•Функциональное программирование — это парадигма программирования, которая находит применение в самых разных языках программирования

•Stream — новая возможность JDK8.0;можно также считать её введением нового понятия, призванного поддержать идеи функционального программирования:с помощью Stream функциональное программирование можно применять к коллекциям

•Лямбда-выражение (lambda expression) — это анонимная функция, позволяющая более кратко и эффективно выражать идеи функционального программирования

Итак, после всего сказанного: как связаны Stream и многопоточность? Соответствующие методы параллельной обработки в Stream реализованы на основе фреймворка Fork/Join. В «Effective Java» есть рекомендация «谨慎使用Stream并行» — «Осторожно используйте параллельную обработку Stream», поскольку вся параллельная обработка выполняется в общем пуле Fork/Join: сбой одного конвейера может ухудшить производительность других, не связанных с ним частей.

JDK 9.0

Улучшение механизма разрешения конкуренции за блокировки

Конкуренция за блокировки ограничивает производительность многих многопоточных Java-приложений. Новый механизм обработки конкуренции за блокировки повышает производительность мониторов Java-объектов, что подтверждено различными тестами производительности(например, Volano). Такие тесты позволяют оценить предельную пропускную способность JVM. На практике новый механизм показал отличные результаты в 22 различных тестах. Если этот механизм будет применён в Java 9, производительность приложений значительно возрастёт. Проще говоря, когда несколько потоков конкурируют за блокировку, до оптимизации все потоки, пришедшие позже, ожидают блокировку по одной и той же стандартной процедуре. После оптимизации JVM распознаёт некоторые ситуации, допускающие оптимизацию, и позволяет позднее пришедшим потокам сразу попытаться захватить блокировку через своего рода «VIP-проход».

Подробное объяснение см. здесь: Объяснение конкуренции за блокировки — подход с точки зрения производительности

Реактивные потоки

Реактивные потоки (Reactive Streams) — стандарт обработки асинхронных потоков данных с неблокирующим обратным давлением, предоставляющий минимальный набор интерфейсов, методов и протоколов для описания необходимых операций и сущностей.

Что такое неблокирующее обратное давление? Термин 背压 — сокращённый перевод back pressure. Проще говоря, производитель отправляет данные потребителю, а когда тот перестаёт справляться с обработкой, сообщает об этом производителю, который снижает скорость производства. Проще всего реализовать этот механизм блокирующим способом: непосредственно при отправке возвращать сведения о нагрузке. Неблокирующая реализация усложняет проектирование, но повышает производительность. PS: По-моему, перевод 背压 неудачен: смысл нельзя понять по самим иероглифам. Может, 反压 («противодавление») лучше? ^\_^

Чтобы потребитель не рухнул под огромной нагрузкой на ресурсы (pressure), скорость потока данных необходимо ограничивать, то есть применять управление потоком (flow control), не позволяя быстрому потоку перегрузить получателя. Для этого необходимо обратное давление (back pressure): производитель и потребитель должны взаимодействовать через соответствующий механизм. Он должен быть асинхронным и неблокирующим. При синхронной блокирующей реализации производителю пришлось бы ждать, пока потребитель обрабатывает данные, что создавало бы проблемы с производительностью.

Reactive Streams определяет стандарт реализации неблокирующего обратного давления через набор сущностей, интерфейсов и способов взаимодействия. Сторонние разработчики следуют этому стандарту при создании конкретных решений; среди распространённых — Reactor, RxJava, Akka Streams, Ratpack и другие.

JDK 10 Управление локальными переменными потоков

Safepoint и его недостатки:

Safepoint — это механизм Hotspot JVM, позволяющий останавливать все приложения. Для выполнения некоторых низкоуровневых операций JVM требуется Stop The World, то есть остановка всех потоков приложения. Но потоки нельзя просто грубо остановить: им отправляется управляющий сигнал, сообщающий, что пора остановиться. Когда поток приложения доходит до точки Safepoint, он выполняет указание и реагирует на сигнал. Отсюда и название Safepoint. Слово safe подчёркивает, что JVM предстоит безопасно выполнить некоторые глобальные операции, поэтому в название этой точки добавили safe.

К операциям, требующим общей безопасной точки, относятся: 1、пауза для сборки мусора 2、деоптимизация кода(Code deoptimization)。 3、очистка кеша кода。 4、переопределение классов(Class redefinition, например горячее обновление или инструментирование)。 5、отмена предвзятой блокировки(Biased lock revocation)。 6、различные операции отладки(например: проверка взаимоблокировок или выгрузка трассировки стека и т. п.)。

Однако само по себе ожидание, пока все потоки дойдут до ближайшей безопасной точки и остановятся, занимает немало времени. К тому же не слишком ли это грубо и бесцеремонно — останавливать все потоки? Поэтому в Java10 появился способ обходиться без остановки всех потоков: локальное согласование с потоком (Thread Local Handshake).

Например, вот ситуации, с которыми можно справиться без остановки всех потоков: 1、Отмена смещённой блокировки. Для её отмены достаточно остановить один поток, останавливать все потоки не требуется. 2、Снижение общего влияния различных диагностических запросов на задержки VM: например, получение трассировок стека всех потоков на VM с большим числом Java-потоков может выполняться медленно. 3、Более безопасное снятие выборок трассировок стека за счёт уменьшения зависимости от сигналов (signals). 4、Использование так называемой асимметричной синхронизации Деккера для устранения некоторых барьеров памяти посредством рукопожатия с Java-потоками. Например, «код условной маркировки карт» (conditional card mark code), используемый в G1 и CMS, больше не будет нуждаться в «барьере памяти». Это позволит оптимизировать «барьер записи (write barrier)», генерируемый G1, а также удалить ветвления, предназначенные для обхода «барьера памяти».

JDK 15: отключение и объявление устаревшими блокировок со смещением

Почему решено отказаться от блокировок со смещением?Прирост производительности, который они давали раньше, сегодня уже не столь заметен. Приложения, выигрывающие от таких блокировок, обычно используют ранние API коллекций Java(JDK 1.1), в которых API(Hashtable и Vector) выполняют синхронизацию при каждом обращении. В JDK 1.2 появились несинхронизированные коллекции для однопоточных сценариев(HashMap и ArrayList), а в JDK 1.5 — более производительные конкурентные структуры данных для многопоточных сценариев. Это означает, что приложения, которым блокировки со смещением помогали из-за избыточной синхронизации, могут получить значительный прирост производительности после перехода на более новые классы. Кроме того, приложения, построенные вокруг очередей пулов потоков и рабочих потоков, обычно работают быстрее при отключённых блокировках со смещением。

Ниже приведена реализация с использованием API Hashtable и Vector: java.lang.Classloader использует Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL использует Hashtable java.net.URConnection использует Hashtable java.util.ZipOutputStream использует Vector javax.management.timer.TimerMBean имеет Vector в интерфейсе

Эпоха беспилотного вождения

Виртуальные потоки ввели Java в эпоху автопилота. Во многих языках, например Go, C#, Erlang и Lua, есть технологии, похожие на «виртуальные потоки»; их называют «сопрограммами». На этот раз в Java не добавили ни одного ключевого слова и даже не ввели новых понятий. Виртуальные потоки гораздо проще понять, чем goroutine и сопрограммы: уже по названию примерно ясно, что они делают.

Виртуальные потоки JDK 19

В традиционной модели Java потоки соответствуют потокам операционной системы в соотношении 1:1. Создание и переключение потоков обходится дорого, а ограничения операционной системы позволяют создать лишь ограниченное их количество. При высокой конкурентной нагрузке невозможно создавать отдельный поток для каждого запроса. Пул потоков позволяет смягчить проблему: он сокращает затраты на создание потоков, но не позволяет увеличить их количество. Если одновременно поступает 2000 запросов, а в пуле лишь 1000 потоков, то в один момент можно обработать только 1000 запросов. Ещё 1000 запросов обработать невозможно: их можно отклонить либо заставить ждать, пока не освободится поток.

До виртуальных потоков использовался асинхронный подход. Многие фреймворки уже реализовали асинхронное конкурентное программирование(например, Reactor в Spring5), обеспечивая более высокую доступность за счёт совместного использования потоков. Принцип заключается в том, что совместное использование потоков сокращает число переключений между ними, снижает затраты и позволяет избежать блокировок: поток используется только во время выполнения программы, а во время ожидания программа его не занимает. Асинхронный подход действительно даёт немало улучшений, но имеет и недостатки. Большинство асинхронных фреймворков используют цепочки вызовов, разделяя программу на множество шагов, каждый из которых может выполняться в другом потоке. Привычные API конкурентного программирования, такие как ThreadLocal, уже нельзя использовать как прежде, иначе возможны ошибки. Стиль программирования тоже существенно меняется и становится гораздо сложнее традиционного; освоение требует больших усилий, а для адаптации к асинхронной модели может понадобиться переработать многие существующие модули проекта.

Принцип реализации виртуальных потоков похож на некоторые асинхронные фреймворки: они также совместно используют потоки, поэтому пул им не нужен. При использовании можно считать, что виртуальные потоки доступны в неограниченном количестве: создавайте столько, сколько хотите, не опасаясь проблем. Более того, виртуальные потоки поддерживают отладку и могут отслеживаться средствами мониторинга Java, что очень важно. Виртуальные потоки существенно снижают потребление памяти программой. Все приложения с интенсивным вводом-выводом, например веб-серверы, могут значительно повысить пропускную способность ввода-вывода на том же оборудовании. Раньше при 1 ГБ памяти можно было одновременно обслуживать 1000 обращений. С виртуальными потоками, согласно официальным заявлениям, можно легко обрабатывать 100 десятков тысяч одновременных обращений. Выдержит ли система такую нагрузку в конкретном бизнес-сценарии, ещё предстоит проверить нагрузочным тестированием, но даже если снизить оценку, 10 десятков тысяч должны достигаться без труда, причём вам не придётся за это ничем платить — возможно, даже код менять не потребуется. Ведь виртуальные потоки позволяют сохранить привычный стиль программирования: один запрос — один поток. Используйте виртуальные потоки так же, как обычные, и программе потребуется совсем немного изменений. Виртуальные потоки также не вводят нового синтаксиса, поэтому затраты на освоение и миграцию можно назвать крайне низкими.

Стоит упомянуть, что в основе виртуальных потоков по-прежнему используется фреймворк Fork/Join.


Комментарии

Отправка

Выберите оформление

Выбранное оформление применяется ко всему сайту.