Обновить

Комментарии 42

Мне кажется, вы всю дорогу пытались провести четкую линию между обоими подходами, но линия в итоге получилась ломаной и размытой. Шо там шо там высокий 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 операции и все пройдет еще быстрее, да? (Жаль здесь нет саркастичных смайлов)

Я никого никуда не натягивал. А Вы явно не поняли посыла статьи.

Зачем? Тут всё упирается в footprint памяти, многопоточность этого не изменит

Не удивлён ни разу. Пришлось много поработать с энтерпрайзным софтом на Java - каким он бывает, в каких условиях и почему я видел. Эта статья, правда, освещает поглубже природу того, что я видел и вот за это автору респект!

Интересно, в каких ситуация иммутабельные типы начинают помогать с оптимизацией в Java. Насколько изменятся тайминги, если сначала обработать нормализацию, а потом таксу. Ну и мнопоточный кейс было бы неплохо добавить для сравнения дял обоих вариантов.

Хорошие вопросы. Я про них думал когда писал, но что-то уже не хотелось перегружать статью.

На мой взгляд иммутабельные объекты могут улучшить оптимизацию за счет встроенного хешкода или при многопоточном взаимодействии за счет того что JVM уверен что объект неизменен. Но в рамках аллокаций - наверно нет, потому что как не крути новый объект.

Насчет таймингов - есть уверенность что не зименятся при замене. Что там, что там я насовал аллокаций. Но мысль интересная надо покрутить.

Ну и многопоточка это наверно то куда я загляну дальше)) Там именно иммутабельность сыграет значительную роль. Тут мы получается платим за бессмысленную потокобезопасность, ну а там другой расклад.

Ниже комментом оставил ссылку на godbolt. Правда чтобы в лимиты влезть оставил 150к, то есть на порядок меньше. Первый код - складируют список в тот же контейнер, второй, соотвественно in place из low churn и последний - оригинальный high churn.

Похоже весь сырбор всплывает в момент когда появляется ограничение по памяти. Если ограничения нет, то исходный high churn работает быстрее, как только оно появляется - Java теряет уверенность и мутабельная версия начинает вырываться вперёд. А ещё огромная разница между разными версиями JDK - в отдельных ситуациях срабатывают какие-то оптимизации, в других - не особо. Учитывая что Java там периодически меняет дефолтные менеджеры памяти это в общем-то не удивительно, хотя вопросы к некоторым оптимизациям всё же имеются.

Да, я тоже заметил про версии Java, но взял самые актуальные. *Чешу голову*, надо было ширше варианты приложить))
Ну да ладно. Все равно за доп анализ спасибо)

Нда. Чтоб враг мой так жил.

Не понял, что не так?)

Степень разброса производительности буквально пророчит жизнь в обнимку с профайлером. То есть каких-то гарантий производительности как-будто вообще нет и всё зависит от того сколько трюков можно впихнуть в единицу кода и не заблудиться.

В функциональном после каждой сборки остаётся несмываемый остаток: новый список processed и новые immutable-объекты, создаваемые в процессе pipeline. Это и есть та волна, которая в итоге переполняет кучу и вызывает OutOfMemoryError.

Я бы сказал, что несмываем остатком является старый список orders, из которого сформирован stream. Этот список не нужен, но по мере обработки нет возможности заменять его элементы null-ами, отпуская память. Так что для варианта, основанного на stream-ах, нужно заранее заложить удерживаемый объём памяти, равный удвоенному объёму начального списка. А если ещё учесть, что toList() будет постепенно собирать внутри список, регулярно увеличивая размер и копируя данные, то лучше заложить утроенный размер.

Абсолютно верно! О чем и речь))
Что используя тот же 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 записей и показал разницу, то тоже было бы нормой

Ну а что заголовок кликбейтный - кто из нас без греха?))))))))))))

По чистоте кода. Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop.

Думаю, как минимум, будет интересное сравнение, если оба кейса будут делать обновление элементов в списке in place, а обновление самого элемента либо immutable style, либо нет.

Поскольку в упражнении из статьи нет фильтрации, а только два map(), то можно воспользоваться replaceAll(), который является мутирующим аналогом map(). Это позволит иметь немутабельные объекты, но не жрать память под второй список.

Понимаю, что на малых и средних нагрузках то и разницы особо нет. На больших где критично я думаю на go напишут сервис. Я бы второй раз не начал бы нагруженный сервис ковырять на буте. Так как столкнулся с такой темой на НТ сервиса. При анализе выплыли такие вещи как стримы и фильтрация в них. Точней плодилось почему-то много обьектов и гц не справлялся. Переписывал на обычные ифы и форы. Тогда ок все полетело. Так что полностью с афтором согласен.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации