Со своей стороны я неосторожно позволил высказать некоторый скептицизм, когда же с этим Project Loom можно будет наконец-то реально поработать. Спустя буквально час прилетела ответочка от самого Ron'а — «а ты попробуй!». Что ж, пришлось пробовать.
Что потребовалось для эксперимента:
- JDK15 включающий в себя Project Loom
- Terminal, поскольку IntelliJ и Gradle наотрез отказались работать с такой дичью
Для начала я решил посмотреть, как будет выглядеть классический пример с потоками из документации к Kotlin Coroutines. Пример написан на Kotlin, но переписать его на Java не составит труда:
public class Main {
public static void main(String[] args) {
var c = new AtomicLong();
for (var i = 0; i < 1_000_000; i++) {
new Thread(() -> {
c.incrementAndGet();
}).start();
}
System.out.println(c.get());
}
}
Запускаем и убеждаемся, что пример все еще виснет, как и раньше:
javac Main.java && java Main
Теперь перепишем пример используя виртуальные потоки, которые нам предоставляет Project Loom:
for (var i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
c.incrementAndGet();
});
}
Результат не заставляет себя долго ждать:
1000000
Во-первых, как не устает повторять Gil Tene, хорошие микробенчмарки — это сложно и долго.
Во-вторых, это было бы пока несправедливо к Project Loom, все еще находящемуся в бете. Порешим, что программа не зависла — уже хорошо.
Но само по себе это меня не слишком впечатлило. В конце-то концов, с coroutine'ами в Kotlin легко достигается тот же результат.
Рон в своей статье верно подмечает, что в Kotlin пришлось ввести функцию delay(), которая позволяет корутине «уснуть», поскольку Thread.sleep() отправляет «спать» не текущую корутину, а текущий scheduler thread, которых не много, обычно по количеству CPU.
А как с этим обстоят дела в Project Loom?
for (var i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
c.incrementAndGet();
try {
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
Результат:
где-то 400K
А вот это уже интересно! С Project Loom вызов Thread.sleep() умеет отличать, находится ли он в обычном потоке, или же в виртуальном, и срабатывает по разному.
Это уже само по себе весьма круто. Но давайте копнем еще чуть глубже:
var threads = new ArrayList<Thread>();
var cores = 10;
for (var i = 0; i < cores; i++) {
var t = Thread.startVirtualThread(() -> {
var bestUUID = "";
for (var j = 0; j < 1_000_000; j++) {
var currentUUID = UUID.randomUUID().toString();
if (currentUUID.compareTo(bestUUID) > 0) {
bestUUID = currentUUID;
}
}
System.out.println("Best slow UUID is " + bestUUID);
});
threads.add(t);
}
for (var i = 0; i < cores; i++) {
var t = Thread.startVirtualThread(() -> {
var bestUUID = UUID.randomUUID().toString();
System.out.println("Best fast UUID is " + bestUUID);
});
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
Тут мы запускаем 10 медленных задач и 10 быстрых задач. Быстрые задачи быстрей медленных в миллион раз, так что логично было бы предположить, что завершаться они раньше.
Но не тут то было:
Best slow UUID is fffffde4-8c70-4ce6-97af-6a1779c206e1
Best slow UUID is ffffe33b-f884-4206-8e00-75bd78f6d3bd
Best slow UUID is fffffeb8-e972-4d2e-a1f8-6ff8aa640b70
Best fast UUID is e13a226a-d335-4d4d-81f5-55ddde69e554
Best fast UUID is ec99ed73-23b8-4ab7-b2ff-7942442a13a9
Best fast UUID is c0cbc46d-4a50-433c-95e7-84876a338212
Best fast UUID is c7672507-351f-4968-8cd2-2f74c754485c
Best fast UUID is d5ae642c-51ce-4b47-95db-abb6965d21c2
Best fast UUID is f2f942e3-f475-42b9-8f38-93d89f978578
Best fast UUID is 469691ee-da9c-4886-b26e-dd009c8753b8
Best fast UUID is 0ceb9554-a7e1-4e37-b477-064c1362c76e
Best fast UUID is 1924119e-1b30-4be9-8093-d5302b0eec5f
Best fast UUID is 94fe1afc-60aa-43ce-a294-f70f3011a424
Best slow UUID is fffffc24-28c5-49ac-8e30-091f1f9b2caf
Best slow UUID is fffff303-8ec1-4767-8643-44051b8276ca
Best slow UUID is ffffefcb-614f-48e0-827d-5e7d4dea1467
Best slow UUID is fffffed1-4348-456c-bc1d-b83e37d953df
Best slow UUID is fffff6d6-6250-4dfd-8d8d-10425640cc5a
Best slow UUID is ffffef57-c3c3-46f5-8ac0-6fad83f9d4d6
Best slow UUID is fffff79f-63a6-4cfa-9381-ee8959a8323d
Интуиция работает лишь до тех пор, пока количество медленных задач меньше, чем количество ядер вашего CPU.
Причина проста — в данный момент Project Loom использует обычный ForkJoinPool. В итоге, не смотря на то, что в документации и по задумке указано, что виртуальные потоки «вытесняющие» (preemtive), в данный момент они ведут себя на «кооперативный» манер. Как и корутины в Kotlin'е, собственно.
Следует отметить, что в вышеупомянутой статье Рон упоминает, что размышляет и о forced preemtion поведении, как у обычных потоков. Но пока это не реализовано, потому что не до конца понятно, насколько такое поведение полезно, когда потоков могут быть десятки тысяч. Тем не менее, в Go 1.14 forced preemtion незаметно ввели.
Вызов функции, в отличие от Go, не приводит к context switch'у. Suspend, как в Kotlin, тоже не завезли. Но можно обойтись Thread.yield(), или вызовом любой Java IO функции: System.out.println(" "), к примеру.
Рассчет прост — большинство реальных программ используют blocking IO достаточно часто. И именно его использование Project Loom и стремится решить в первую очередь.
Немного выводов:
Вынужден признать, что не смотря на мой первоначальный скепсис, Project Loom меня положительно впечатлил. Для обычных Java пользователей, он обещает легковесную многопоточность без нужды в переходе на другой язык, использовании библиотеки или фреймворка. Что уже звучит неплохо.
Но основную революцию, как я ожидаю, это проект совершит среди разработчиков библиотек, которым до сих пор приходилось решать проблему concurrency снова, и снова, и снова. Теперь же, с распространением JDK15, это проблему можно будет переложить на JVM, как раньше перекладывали на JVM оптимизацию памяти (GC) и кода (JIT).
Ссылка на мою оригинальную статью, если вы предпочитаете читать на английском.
sshikov
>У меня Котлин
А у меня Java 8. И так будет еще долго.
shishmakov
Всё это очень похоже про разговоры о Java SE 6 в 2014, когда появилась сама Java SE 8. Вы всегда можете уйти туда где Java SE 11 в проде сейчас.
sshikov
Зачем мне туда? У меня вполне интересные задачи тут.
Все дело в том, что оракл (выпустив Java 9) поменял свою политику выпуска релизов, и эта новая политика частых релизов многих не устраивает на самом деле. Новые релизы нужны тем, кто делает новые проекты, а тем кто эксплуатирует старые много лет — нужна стабильность и совместимость. А они ее сломали. Это был пожалуй единственный релиз, который сломал например maven и gradle. Хотя уж казалось бы, там-то что можно сломать? Даже Java 5 не вызывала таких проблем при миграции (я в своей практике мигрировал раз пять, а считая JavaEE — еще больше).
shishmakov
— Хотя уж казалось бы, там-то что можно сломать?
Это Jigsaw, класс Unsafe и Reflection, чтобы не получали доступ к тому, что не было запроектировано.
Проект Jigsaw ломает доступ к использованию непубличного API. Поэтому сравнение с Java SE 5 некоректно. Когда получилось проект влить в релиз — тогда и сделали. Так совпало, что всё это попало в Java SE 9, а могли и в 8 уже всё это увидеть при определённой развитии истории.
Стабильность это LTS: Java SE 8, 11, 17. Все другие выпуски проходные и мне непонятно зачем крутить счётчик 2 раза в год. Зря отказались от версионирования как в Ubuntu.
sshikov
>— Хотя уж казалось бы, там-то что можно сломать?
>Это Jigsaw, класс Unsafe и Reflection, чтобы не получали доступ к тому, что не было >запроектировано.
Не уверен, что gradle и мавену это все нужно. Скорее уж сломали что-то в класслоадерах (и даже примерно понятно, что именно).
>Проект Jigsaw ломает доступ к использованию непубличного API. Поэтому сравнение с Java SE 5 некоректно.
Сравнение тут затем, что даже сложный переход на generics не стоил когда-то таких усилий (ну, это мои впечатления, если что). И судя по ним же, никогда столько народу не оставалось сидеть на старых версиях, как после выхода Java 9. Когда это получилось — уже не так важно. Важно что это решение сломало обратную совместимость, с которой до этого все было сильно получше.
Ну то есть, я почему maven и gradle упомянул — потому что до этого никогда релизы не ломали такие базовые вещи, как системы сборки — идеологически достаточно простые, в общем-то. А тут сумели. А уж если говорить о проектах посложнее…
Вот оно в 2017 вышло — а до сих пор скажем Hadoop не может завершить миграцию как следует. И какой вывод напрашивается? Что на пользователей хадупа при выпуске 9 наплевали. Что ораклу нужно было решить свои проблемы. Ну вот они наплевали и решили — теперь и имеем то что имеем. Какая там доля Java 8 на сегодня, не напомните?