Комментарии 51
Мне кажется, вы всю дорогу пытались провести четкую линию между обоими подходами, но линия в итоге получилась ломаной и размытой. Шо там шо там высокий allocation rate тупо из-за строк и BigDecimal, а всякие промежуточные коллекции это мелочи. Если в рамках бизнес-логики вы не можете избежать этого, то стиль не особо важен. Там уже в сторону батчинга нужно смотреть.
Главный вывод этой статьи
Дружите с профайлером и помните про боксинг
Я ничего не пытался, вы заблуждаетесь. Я лишь подсветил нюансы. Оба подхода имеют свою цену о которой я честно попытался предупредить тех, кто о таком не знал.
Ну а я вам подсвечиваю, что ваши подходы отличаются меньше, чем вам кажется. Повторюсь, что слон в комнате - аллокации String и BigDecimal. А ваши подходы отличаются в плане "обходить слона бочком" или "пролазить между слоновьих ног".
Для хардкорного императивного стиля нужно как минимум переводить логику на целочисленную математику и работать с long вместо BigDecimal.
Ну да. Аллокации String и BigDecimal представлены в обоих вариантах, причем я старался их сделать одинаковыми, чтобы они усилили эффект.
Но результат выполнения классов разный. В этом дело.
"всякие промежуточные коллекции это мелочи" - отъели хорошую часть памяти, но про это вы не говорите.
Цель статьи не указать что тот или иной стиль лучше - а показать что при неразумном использовании оба могут быть затратными.
Для хардкорного императивного стиля нужно как минимум переводить логику на целочисленную математику и работать с long вместо BigDecimal.
А со String что делать? Что делать в .NET я знаю - StringBuilder, Span… Но есть ли аналоги в Java?
Да, тут две не связанные друг с другом вещи: немутабельные объекты, создающие новые экземпляры при операциях, и немутирующий проход по коллекции с удерживанием старой и созданием новой коллекции-результата. Первое даёт высокий allocation rate, а второй - высокий footprint, и allocation rate + затраты на копирование из-за ресайза списка-получателя.
Интересное исследование спасибо)
Когда увидел, что читать 127 минут испугался, но пошел читать ахахах
Пока вы смотрите цену чистого кода, люди продолжают писать цикл в цикле в цикле. Конечно, все зависит от задач и проекта. Но зачастую мы не гонимся за килобайтами, я бы выбирал более лаконичный и чистый код, даже жертвуя немного памятью, но я и не был в местах, где за каждый байт будет драка. Например в ВК видео пишут на джаве, проходил я как-то туда собес, вот там как раз по памяти только были вопросы, но там они и переживают за все это очень)
А так делать анализ профайрером или снимать хип дамб полезно всегда, без спорно)
Удачи в поисках, надеюсь, что скоро найдете самое хлебное место)
Спасибо за оценку)) Я сам не ожидал 127 минут) Походу это из-за кода и результатов тестов, они довольно объемные)
Подсчет длительности чтения одинаково считает что основной текст статьи, что скрытый текст (куда вы логи позапихивали). В результате ваша статья, основной текст которой имеет вполне разумный объем, оценивается в эту уйму времени. Хотя читается без проблем: я про 127 минут узнал только из предыдущего комментария, так бы не подумал.
Кмк в функциональном подходе лист не надо, надо Стрим. Это не избавит от аллокаций, но должно помочь от переполнения кучи.
Бизнес логика построенная на передаваемых туда-сюда стримах очень тяжела в поддержке. Хотя и более оптимальная в целом.
ToList в конце любой операции это норма. Удобство резко повышается.
В статье затронута тема удобства использования immutable конструкций. Основное преимущество - можно расшарить ссылку и быть уверенным, что состояние не изменится. Stream это mutable конструкция, в отличие от списка, получаемого через toList()
Вы правы. И это еще один из вариантов оптимизации, которые я сознательно не освещал))
Ну это все цена иммутабельности. Как бы она известна изначально. Единственное ее преимущество, которое кричат на каждом углу - вы обезьяны, все равно где то ошибетесь. Понятно что изменить пару байтов поля в памяти не идет ни в какое сравнение с созданием нового объекта. А еще отказ от геттеров и сеттеров и работа прямо с полями дает вполне ощутимый прирост. Но ява уже на столько утонула в трясине академических и корпоративных методологий что все равно свой код не сильно исправляет. Как ни старайся библиотеки все равно тонут в десятках и даже сотнях прокладок интерфейсов и всяких паттернов ради самих паттернов а не реальной необходимости. Что часто проще от нее совсем отказаться.
Это гигантская волна, которая заливает кучу и не даёт GC нормально работать.
А по сто объектов, например, трансформировать и в список добавлять, ни как? Не судьба? Это видимо вообще неизвестный стиль или очень опасный потому что невозможно сложный, аж в два раза больше думать надо,если не в три...
Видимо правильный стиль теперь только тот где думать надо как можно меньше, в идеале вообще не думать.
Видимо придется немного разжевать для Вас: данная статья предупреждает лишь бездумное использование кода. Есть большое количество вариантов оптимизации и улучшение помогающее избегать описанных негативных моментов.
Я понимаю, что для некоторых нужно крупными буквами писать - НЕ ЛЕЗЬ, БЛ, УБЬЕТ, ну Вы то программист, наверное, умный человек, когда читаете статьи понимаете к чему автор ведет?
Синтетические тесты доказывают только то, что их автор придумал теорию и успешно натянул на нее сову.
Попробуйте написать многопоточный код разбив на 10 параллельных операций по 150 тыс объектов.
А можно попробовать написать код разбив на 1.5 млн виртуальных потоков по 1 операции и все пройдет еще быстрее, да? (Жаль здесь нет саркастичных смайлов)
Я никого никуда не натягивал. А Вы явно не поняли посыла статьи.
Смысл статьи укладывается в паре предложений: «Я сделал 1.5 млн объектов и поменял их и у меня все работает. А потом я сделал 1.5 млн объектов и скопировал их и оно сломалось. Мам, смотри, я погромист!»
Вас задела реальность?
Тогда проведите тесты сами, добавьте случай в многопотоке, который вы описали выше, напишите разгромную опровергающую статью.
Если вы под синтетикой подразумеваете 1.5 млн записей, т.к. в реальной жизни столько не будет, то у вас просто случай cold или warm path, и как написал автор, почти не важно какой подход использовать. Получается вы либо статью не дочитали, либо ваши комментарии выглядят так:
“Мам, смотри, я комм оставил”
или
“Бро, смотри,я унизил чела”
Очень профессиональный комментарий, да...
Зачем? Тут всё упирается в footprint памяти, многопоточность этого не изменит
Не удивлён ни разу. Пришлось много поработать с энтерпрайзным софтом на Java - каким он бывает, в каких условиях и почему я видел. Эта статья, правда, освещает поглубже природу того, что я видел и вот за это автору респект!
Интересно, в каких ситуация иммутабельные типы начинают помогать с оптимизацией в Java. Насколько изменятся тайминги, если сначала обработать нормализацию, а потом таксу. Ну и мнопоточный кейс было бы неплохо добавить для сравнения дял обоих вариантов.
Хорошие вопросы. Я про них думал когда писал, но что-то уже не хотелось перегружать статью.
На мой взгляд иммутабельные объекты могут улучшить оптимизацию за счет встроенного хешкода или при многопоточном взаимодействии за счет того что JVM уверен что объект неизменен. Но в рамках аллокаций - наверно нет, потому что как не крути новый объект.
Насчет таймингов - есть уверенность что не зименятся при замене. Что там, что там я насовал аллокаций. Но мысль интересная надо покрутить.
Ну и многопоточка это наверно то куда я загляну дальше)) Там именно иммутабельность сыграет значительную роль. Тут мы получается платим за бессмысленную потокобезопасность, ну а там другой расклад.
Ниже комментом оставил ссылку на godbolt. Правда чтобы в лимиты влезть оставил 150к, то есть на порядок меньше. Первый код - складируют список в тот же контейнер, второй, соотвественно in place из low churn и последний - оригинальный high churn.
Похоже весь сырбор всплывает в момент когда появляется ограничение по памяти. Если ограничения нет, то исходный high churn работает быстрее, как только оно появляется - Java теряет уверенность и мутабельная версия начинает вырываться вперёд. А ещё огромная разница между разными версиями JDK - в отдельных ситуациях срабатывают какие-то оптимизации, в других - не особо. Учитывая что Java там периодически меняет дефолтные менеджеры памяти это в общем-то не удивительно, хотя вопросы к некоторым оптимизациям всё же имеются.
В функциональном после каждой сборки остаётся несмываемый остаток: новый список
processedи новые immutable-объекты, создаваемые в процессе pipeline. Это и есть та волна, которая в итоге переполняет кучу и вызываетOutOfMemoryError.
Я бы сказал, что несмываем остатком является старый список orders, из которого сформирован stream. Этот список не нужен, но по мере обработки нет возможности заменять его элементы null-ами, отпуская память. Так что для варианта, основанного на stream-ах, нужно заранее заложить удерживаемый объём памяти, равный удвоенному объёму начального списка. А если ещё учесть, что toList() будет постепенно собирать внутри список, регулярно увеличивая размер и копируя данные, то лучше заложить утроенный размер.
Хорошая статься, мне понравилось !
Так-то, дело ни в Java, ни в чистом коде, ни в иммутабельности. Заголовок тумач кликбейтный.
Список из 1.5 млн отдельных объектов занимает ~250 мб уже сходу, не говоря уже о работе с этим списком далее.
В highchurn примере, связка .map() и .toList() выдаёт новый список новых объектов, сохраняя исходный. Это вполне себе ожидаемо и не неявное поведение. Это еще 250 мб. Сумма 500 мб. Не мусора. При 512 мб max heap, очевидно это будет выливаться в пыхтение GC впустую, или выброс OOM.
Всё. И уже не важно, что там внутри бизнес логики происходит (applyTax(), ...), мутабельно\немутабельно, заумный allocation rate, итд.
Да, можно попытаться сказать сам .toList() это немутабельный подход, и будто в этом всё и дело. Но давайте по факту, обычно http request handler, или kafka consumer, обрабатывают лишь десятки элементов, и в конце не то что какой-то список умирает, а вообще весь working set обработки запроса, и память быстро поддаётся очистке, а потому будет не так важно какой подход использовать. Получается в большинстве реальных кейсов нет никакой катастрофы. (ну или как раз давайте посмотрим на сравнение этих подходов именно там...?)
А когда у нас миллионы элементов разом, такие кейсы бездумно типичным кодом не обрабатывают. Да еще когда heap впритирку... Так что и тут проблем обычно не возникнет.
По чистоте кода. Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop.
Другие языки. Всё то же самое в целом. Либо пишешь мутабельно, либо нет. Зато уже набежали писать про кровавый энтерпрайз...
Ну то есть статья к какому выводу нас приводит? Если много элементов, heap аккурат настроен под завязку, то будет плохо? Это вроде и так было понятно без глубокого анализа. А реальных кейсов, которые можно пойти применить в работе, рассмотрено не было, инсайта не случилось.
Думаю, как минимум, будет интересное сравнение, если оба кейса будут делать обновление элементов в списке in place, а обновление самого элемента либо immutable style, либо нет. С разным размером heap, и с замером времени исполнения, потому что обычно это целевая метрика, а память скорее косвенная цена.
Ну давайте по порядку:
"Список из 1.5 млн отдельных объектов занимает ~250 мб уже сходу, не говоря уже о работе с этим списком далее" - вы пытаетесь свести проблему к "два списка по 250 МБ = 500 МБ = OOM".
Но в реальности OOM наступает не из-за того, что два списка не влезают, а из-за того, что GC не успевает подчищать промежуточный мусор между итерациями стрима.
Если посмотрите на файл с результатами выполнения упавшего декларатива, то наверно заметите:
GC(0) 25M->7M(512M) куча почти пуста
GC(1) 31M->16M(512M) занято всего 3%
GC(2) 54M->32M(512M) занято ~6%
GC(6) 219M->159M(512M) занято ~31%
GC(7) 285M->212M(512M)занято ~41%
GC(8) 347M->265M(512M) занято ~52%
Тут GC уже делает паузы по 11–12 мс, а Old Generation растёт. И это действительно предсказуемо, но если понимать о чем речь (как раз для этого статья).
"Но давайте по факту, обычно http request handler, или kafka consumer, обрабатывают лишь десятки элементов" - вообще не буду спорить. Но проектов миллионы а handler-ов и consumer-ов миллиарды, что не отменяет существования сценариев с миллионами записей - и статья как раз для таких случаев.
Более того, я явно, первым же выводом делю код на cold/warm/hot path и говорю: если это cold или warm path не замарачивайтесь, пишите как читается. Вы просто повторяете мой вывод не замечая этого.
"А когда у нас миллионы элементов разом, такие кейсы бездумно типичным кодом не обрабатывают" - может вы как опытный разработчик и не обрабатываете. Но есть те кто просто скопировал с ллм или старого кода, бездумно вставив типичный stream-пайплайн, прогнали его по локальным тестам и все хорошо. А потом падают.
Ну и "heap впритирку" - не знаю как вы, я взял за практику ограничивать память в контейнерах докера. Поэтому сценарий ну весьма реалистичен.
"Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop" - вот тут однозначно согласен. Абсолютно верное замечание. В свою защиту скажу лишь, что я в статье нигде и не писал, что .toList() единственный способ. Я подсветил - вот популярный стиль, и вот его цена. Комментарии выше, да и опыт подтверждают .toList() один из самых популярных терминальных методов.
Я обязательно найду время и поиграю с .removeIf(), и .forEach(), а также обновления элементов в списке in place, спасибо за советы.
ну очевидно же что в качестве задачи вы предлагаете какой-то бред. Ну представьте что у вас клиент сетевой, он что должен 250Мб закачать по сети чтобы их преобразовать, а потом обратно загрузить? И на этом фоне вы с серьезным видом рассуждаете о проблемах мутабельности-имутабельности? У вас фреймворки загибаются-падают при обработке этого бреда, это нам о чем говорит?
А заголовок и должен быть кликбейтным, это профессиональный подход для любого писателя! Это действительно странная претензия, по моему.
Вы, кажется, немного не так поняли сценарий. У меня нет клиента, который качает 250 МБ по сети и обратно. Речь о серверной обработке консьюмеры, батчевые процессы, ценовые движки, там миллионы записей обрабатываются внутри сервиса, и вопрос аллокаций становится критичным.
а какая разница? Вы серьезно рассматриваете сервер который держит в памяти полтора миллиона записей какой-то одной таблицы? Разве нет разницы между
миллионы записей обрабатываются внутри сервиса
и
миллионы записей хранятся внутри сервиса чтобы быть обработанными
Зачем вы симулируете (видимо) загрузку в память (даже если сервера) таблицы на 1.5 миллиона записей, это же очевидно не адекватное начальное условие для любой задачи программирования.
Получается вы считаете что я ошибаюсь в оценке адекватности этого исходного условия задачи?
Я не вижу причин продолжать этот диалог)
Хотелось бы чтобы вы финально зафиксировали: предложенные для примера код и условия является синтетическими отражениями реальных, к сожалению периодически встречаемых, которые в высоконашруженных сервисах применять и писать НЕ РЕКОМЕНДУЕТСЯ.
Рассмотренные результаты - показательные примеры того, что может произойти если такие данные ИСПОЛЬЗОВАТЬ в hot path сервисах.
Остальные "если бы", "да кабы", находятся вне рассмотренного примера.
Адекватные они или нет решайте для себя лично, но для Kafka-консьюмеров, батчевой обработки и ETL-процессов 1.5 млн записей за один проход это норма.
1,5 млн записей 10 млн или просто 5, какая разница, статья же описывает ПОДХОД!
А то, что вы пишите, это уже какое-то высасывание из пальца. Ну такое невозможно, да конечно возможно, разные ситуации и проекты, все пишут по-своему.
Здесь же описывается подход к решению задач и не просто делать тыки на кнопки, а еще думать и о том, что можно просто занимать при этом меньше памяти. Поэтому даже если бы автор приложил 5 записей и показал разницу, то тоже было бы нормой
...
GC(8) 347M->265M(512M) занято ~52%
...
GC логи показывают, что память растёт, а освобождать нечего. Вы похоже думаете, что эта строка лога, и все остальные в целом, соответствуют лишь этапу обработки списка. Но ведь предварительно идет само создание списка.
Коммент в коде, похоже, подтверждает это заблуждение:
...
List<Order> orders = generateOrders(size);
...
//тут основные аллокации
long start = System.nanoTime();
...
List<Order> processed = orders.stream()
..."основные аллокации"... На самом деле, уже на generateOrders() мы получаем примерно половину аллокаций, а в for loop варианте вообще чуть ли не бОльшую их часть.
У вас как раз в for loop варианте (1 список) есть такой GC лог:
[0.688s][info][gc ] GC(21) Pause Full (G1 Compaction Pause) 512M->365M(512M) 55.071ms
то есть, после Full GC осталось 365 MB не мусора. Это список и его элементы (другому там жить нечего). А то ещё и не весь, ведь мы не знаем тайминга окончания генерации списка.
Это подтверждает мысленную оценку, что сам список уже будет занимать около половины 512 mb хипа. А дальше map() toList() нам говорят, что памяти нужно вдвое больше, и все. Без глубоких анализов, и вне зависимости от логики обработки. На этом приложение и падает.
Но в реальности OOM наступает не из-за того, что два списка не влезают, а из-за того, что GC не успевает подчищать промежуточный мусор между итерациями стрима.
Такого в принципе нету, что GC не успевает и приложение падает. У вас просто не было памяти, нечего очищать, вот и все:
"OOM: Java heap space" - возникает только если памяти действительно нету.
"OOM: GC overhead limit exceeded" - да, можно сказать это "gc не успевал", но по факту оно вылетает, когда свободной памяти настолько мало, что пыхтим, но уже слишком долго на грани "java heap space". То есть он и есть.
Но проектов миллионы а handler-ов и consumer-ов миллиарды, что не отменяет существования сценариев с миллионами записей - и статья как раз для таких случаев.
Вот прям сильно нередко грузим миллионы записей в память?
Более того, я явно, первым же выводом делю код на cold/warm/hot path и говорю: если это cold или warm path не замарачивайтесь, пишите как читается. Вы просто повторяете мой вывод не замечая этого.
Дело вообще не в горячести путей, в ваших кейсах. У вас даже один запуск падает. А о причине вы заблуждаетесь.
может вы как опытный разработчик и не обрабатываете. Но есть те кто просто скопировал с ллм...
Дело не в опытности разработчика, или редкости кейса, а в том, что в такой постановке вопроса, проблема находится уже сильно раньше. Рассуждать о for loop vs stream уже поздно, когда разработчик не спросил себя раньше: грузим 1.5 млн в память? BigDecimal в каждом элементе? У нас 512 mb heap? И еще много чего... То есть вы уводите фокус новичков (якобы потенциальная аудитория статьи) не туда, и путаете. Вероятно потому, что сами не поняли причину проблемы.
Высосанные из пальца примеры имеют право на существование, но только если они объясняют механику, которую можно применить на других кейсах. У вас же, повторюсь, один список еле помещается в память, а два тем более, вот и весь кейс. И не нужно ни профилировщиком мерить, ни allocation rate считать, и тем более давать вредный совет использовать все эти техники в таких ситуациях, вместо простого оценочного подсчета данных в памяти... А то можно и 1MB max heap поставить, а размер списка взять 1000, и начать рассуждать нам for loop или stream? Размер списка будет реалистичен, но heap нет.
Вы делали анализ потому, что вероятно считали, что дело в аллокациях внутри бизнес логики, но дело не в этом. Поэтому и говорю, если хочется чтобы это всё имело смысл, и тем более реальное применение, нужно сравнивать реальные кейсы.
К тому же, такой статьей вы только зря дискредитировали Java и затригерили свидетелей кровавого ентерпрайза, и якобы быстрого GO.
Я был бы признателен, если бы вы читали статью и результаты тестов внимательнее, прежде чем обвинять меня в заблуждениях.
Это реально сэкономило бы всем время.
Вы утверждаете, что причина проблемы в том, что даже один список из 1.5 млн записей не помещается в 512 МБ кучи. Это было бы убедительным аргументом, если бы не один маленький нюанс.
На тех же 512 МБ и с теми же 1.5 млн записей императивный стиль успешно отрабатывает и завершается без ошибок.
Данные из сводной таблицы, которую я привел в статье:
Императивный стиль: JDK 21 G1 - успешно. JDK 17 G1 - успешно.
Функциональный стиль: JDK 21 G1 - OOM. JDK 17 G1 - OOM.
Если проблема исключительно в размере исходных данных, как вы предполагаете, и один список еле помещается в память, то императивный стиль тоже должен был упасть.
Однако он выживает, а функциональный нет. Это прямое доказательство того, что корень проблемы не в размере списка, а в разнице архитектуры стилей - жизненном цикле объектов, накоплении долгоживущих объектов и в неспособности GC своевременно очищать память.
Именно эта разница в поведении и является предметом исследования.
И именно для её демонстрации и предназначен синтетический пример. Я не даю вредных советов и нигде не утверждаю, что нагружать единственный хендлер миллионами записей это хорошая инженерная практика.
Цель статьи, которую вы, похоже, упустили это показать на изолированном примере саму механику проблемы.
А при чем тут GO я вообще не понял...
Вы утверждаете, что причина проблемы в том, что даже один список из 1.5 млн записей не помещается в 512 МБ кучи.
Вас с тем же успехом можно обвинить в том что вы не прочитали комментарий внимательно. Потому что там написано:
Рассуждать о for loop vs stream уже поздно, когда разработчик не спросил себя раньше: грузим 1.5 млн в память?
У вас задача начинается с того что сначала по щучьему велению появляются данные в памяти аж 1.5 миллиона записей. Если рассматривать реальную задачу там будет три этапа основных:
загрузка данных в память для обработки
собственно обработка (трансформация, преобразование) данных -получение новых данных
отправка данных туда где они будут использоваться. Хотя бы в никуда для симуляции, но без отправкиЕ в предыдущих действиях смысла нет!
нельзя рассматривать пункт 2. в отрыве от пунктов 1. и 3. , так как если вы сначала загрузили 1.5 миллиона записей, потом их обработали, сгенерировали еще 1.5 миллиона записей, и все это просто протухло в памяти никуда не отправленное, какой в этом смысл?
Если вы считаете что это нормальная ситуация что софт :
тратит время и память чтобы загрузить миллионы записей в память (получается что вы считаете, что это нормально! И в этом главная претензия к вашей статье, и я думаю вы это уже поняли.)
обрабатывает эти миллионы записей - генерирует новые миллионы записей (тоже вопрос возникает в адекватности такого подхода к построению pipeline обработки)
мы наблюдаем как растет footprint от этого шаманства (что будет с данными, зачем нужна обработка нас не волнует)
Если анализировать реальную задачу, схематично обозначенную выше, то вполне можно обойтись вообще без аллокаций на hot path - путях, аллокации можно будет вынести ДО работы алгоритма по этим hot path путям, напишите статью об этом!Будет очень интересно и поучительно!
Уважаю вас, за вашу настойчивость.
Так упорно доказывать свою точку зрения не пытаясь вникать в даваемые ответы - достойно восхищения. Вы нашли "новые ворота".
Наверно уже все кто читал и не читал данную статью поняли вашу точку зрения. Да, и я тоже.
Да, вариант того что разработчик загрузит 1.5 миллиона записей в память ужасен, ненормален и всячески осуждаем.
НО! НЕ НЕВОЗМОЖЕН...
А это значит все последующие обсуждения глубоко бессмысленны...
Спасибо за идею про статью о выносе аллокаций из hot path — возможно, я её реализую
Вы утверждаете, что причина проблемы в том, что даже один список из 1.5 млн записей не помещается в 512 МБ кучи.
Да? Но вроде бы я сказал:
У вас как раз в for loop ...
... GC(21) Pause Full 512M->365M
... то есть, после Full GC осталось 365 MB не мусора. Это список и его элементы (другому там жить нечего)
... подтверждает мысленную оценку, что сам список уже будет занимать около половины 512 mb хипа
... У вас же, повторюсь, один список еле помещается в память, а два тем более, вот и весь кейс.
("один еле помещается" очевидно = помещается с трудом, а два уже нет)
Вы бы хоть с LLM поговорили. "Я говорю А, мне говорят Б. Как проверить кто прав?"
Удивляют конечно люди, которым после таких разжевываний не стремно, и не возникает желания перепроверить себя. Это еще печальнее, чем статья.
А при чем тут GO я вообще не понял...
При том, что вместо просветления масс, вы необоснованно посеяли ложное предубеждение про Java, и подлили в почву недальновидных думать что Java плохо, а Go хорошо. Почитайте что люди пишут в соседних комментах.
Я не вижу причин продолжать это обсуждение.
Вы частично правы, но не до конца.
Вы утверждаете, что проблема сводится к двум спискам которые не влезают. Я показал, что даже когда список один, GC начинает деградировать задолго до OOM.
Разве это противоречит вашему тезису?
Нет, оно его дополняет. Это два уровня анализа одной проблемы - вы говорите о причине (два списка), я - о процессе (как именно это убивает JVM).
Оба наблюдения верны, и оба есть в статье.
Я учту все рассмотренные нюансы и сделаю следующий эксперимент чище
Ну а что заголовок кликбейтный - кто из нас без греха?))))))))))))
По чистоте кода. Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop.
Думаю, как минимум, будет интересное сравнение, если оба кейса будут делать обновление элементов в списке in place, а обновление самого элемента либо immutable style, либо нет.
Поскольку в упражнении из статьи нет фильтрации, а только два map(), то можно воспользоваться replaceAll(), который является мутирующим аналогом map(). Это позволит иметь немутабельные объекты, но не жрать память под второй список.
Понимаю, что на малых и средних нагрузках то и разницы особо нет. На больших где критично я думаю на go напишут сервис. Я бы второй раз не начал бы нагруженный сервис ковырять на буте. Так как столкнулся с такой темой на НТ сервиса. При анализе выплыли такие вещи как стримы и фильтрация в них. Точней плодилось почему-то много обьектов и гц не справлялся. Переписывал на обычные ифы и форы. Тогда ок все полетело. Так что полностью с афтором согласен.

Java нас обманывает: скрытая цена чистого кода