Какая из трех моделей трансляции кода программы используется в java
Сегодня мы вернемся к одной из тем, затрагиваемых в нашей замечательной книге "Реактивные шаблоны проектирования". Речь пойдет об Akka Streams и потоковой передаче данных в целом — в книге Роланда Куна этим вопросам посвящены главы 10 и 15-17.
Реактивные потоки – это стандартный способ асинхронной обработки данных в потоковом стиле. Они были включены в Java 9 как интерфейсы java.util.concurrent.Flow , а сейчас становятся настоящей палочкой-выручалочкой для создания потоковых компонентов в различных приложениях — и такая расстановка сохранится на протяжении ближайших лет. Следует отметить, что реактивные потоки – «просто» стандарт, а сами по себе ни на что не годятся. На практике используется та или иная конкретная реализация этого стандарта, и сегодня мы поговорим об Akka Streams – одной из ведущих реализаций реактивных потоков с момента их зарождения.
Типичный конвейер потоковой обработки состоит из нескольких шагов, на каждом из которых информация передается на следующий шаг (то есть, по нисходящей). Итак, если взять два смежных шага и считать вышестоящий поставщиком, а следующий за ним – потребителем данных, то окажется, что поставщик может работать либо медленнее потребителя, либо быстрее его. Когда поставщик работает медленнее – все нормально, но ситуация осложняется, если потребитель не поспевает за поставщиком. В таком случае потребитель может переполниться данными, которые ему приходится (в меру сил) аккуратно обрабатывать.
Идея обратного давления очень важна в контексте Reactive Streams и сводится к тому, что мы ограничиваем объем данных, передаваемых между соседними звеньями конвейера, поэтому ни одно звено не переполняется. Поскольку важнейший аспект реактивного подхода – не допускать блокировок за исключением случаев, когда это совершенно необходимо, реализация обратного давления в реактивном потоке также должна быть неблокирующей.
Как это делается
Стандарт Reactive Streams определяет ряд интерфейсов, но не реализацию как таковую. Это значит, что, просто добавив зависимость к org.reactivestreams:reactive-streams, мы просто топчемся на месте – нам все равно нужна конкретная реализация. Существует множество реализаций Reactive Streams, и в этой статье мы воспользуемся Akka Streams и соответствующим DSL на основе Java. Среди других реализаций можно упомянуть RxJava 2.x или Reactor и др.
Пример использования
Допустим, у нас есть каталог, в котором мы хотим отслеживать новые CSV-файлы, затем каждый файл обрабатывать по потоковому принципу, на лету выполнять кое-какую агрегацию, а собранные таким образом результаты отправлять на веб-сокет (в реальном времени). Кроме того, мы хотим задать некий порог накопления агрегированных данных, по достижении которого будет инициироваться уведомление по электронной почте.
В нашем примере в строках CSV будут пары ( id , value ), причем, id будет меняться раз в две строки, например:
370582,0.17870700247256666
370582,0.5262255382633264
441876,0.30998025265909457
441876,0.3141591265785087
722246,0.7334219632071504
722246,0.5310146239777006
Мы хотим рассчитать среднее значение value для двух строк с общим id и отправлять его на веб-сокет лишь в том случае, если оно превышает 0.9. Более того, мы хотим отправлять по электронной почте уведомление после каждого пятого значения, поступающего на веб-сокет. Наконец, мы хотим считывать и отображать данные, полученные с веб-сокета, и это будет делаться через тривиальный фронтенд, написанный на JavaScript.
Архитектура
Рис. 1. Обзор архитектуры
Если сравнить эту схему с классической архитектурой Java EE, то, вероятно, заметно, что здесь все устроено гораздо проще. Никаких контейнеров и бинов, а лишь простое автономное приложение. Более того, стек Java EE вообще не поддерживает потоковый подход.
Основы Akka Streams
В Akka Streams конвейер обработки (граф) состоит из элементов трех типов Source (источник), Sink (уловитель)и Flow -ы (шаги обработки).
На базе этих компонентов определяем наш граф, который, в сущности – просто рецепт для обработки данных. Никаких вычислений там не производится. Чтобы конвейер заработал, нам нужно материализовать граф, то есть, привести его в запускаемую форму. Для этого вам понадобится так называемый материализатор, оптимизирующий определение графа и, в конечном итоге, запускающий его. Однако, встроенный ActorMaterializer фактически безальтернативен, поэтому вы вряд ли будете пользоваться какой-либо иной реализацией.
Если внимательно присмотреться к параметрам типов компонентов, то заметно, что каждый компонент (кроме соответствующих типов ввода/вывода) имеет таинственный тип Mat. Он относится к так называемому «материализованному значению» — это значение, доступное извне графа (в противоположность типам ввода/вывода, доступным только для внутренней коммуникации между шагами графа – см. рис. 2). Если вы предпочитаете игнорировать материализованное значение (а такое часто случается, если нас интересует всего лишь передача данных между шагами графа), то для обозначения такого варианта есть специальный параметр типа: NotUsed . Его можно сравнить с Void из Java, однако, семантически он чуть нагруженнее: в смысловом отношении «мы не используем этого значения» информативнее Void . Также отмечу, что в некоторых API используется схожий тип Done, сигнализирующий, что та или иная задача завершена. Пожалуй, другие библиотеки Java в обоих этих случаях использовали бы Void , но в Akka Streams все типы стараются по максимуму наполнить полезной семантикой.
Рис. 2. Описание параметров типа Flow
Составные элементы потокового конвейера
На входной точке нашего потокового конвейера мы хотим отслеживать, появились ли в интересующем нас каталоге новые CSV-файлы. Хотелось бы использовать для этого java.nio.file.WatchService , но, поскольку у нас потоковое приложение, нужно получить источник событий ( Source ) и с ним и работать, а не организовывать все через обратные вызовы. К счастью, такой Source уже доступен в Alpakka в форме одного из соединителей DirectoryChangesSource , входит в состав alpakka-file , где «под капотом» используется WatchService :
Так получаем источник, выдающий нам элементы типа Pair . Мы собираемся отфильтровывать их так, чтобы подбирать лишь новые CSV-файлы, а затем передавать их «вниз». Для такого преобразования данных, а также для всех последующих мы будем пользоваться маленькими элементами, именуемыми Flow, из которых затем сложится полноценный конвейер обработки:
Можно создать Flow , к примеру, при помощи обобщенного метода create() — он полезен, когда сам входной тип обобщенный. Здесь результирующий поток будет порождать (в виде Path ) каждый новый CSV-файл, появляющийся в DATA_DIR .
Теперь мы собираемся преобразовать Path-ы в строки, потоком выбираемые из каждого файла. Чтобы превратить источник в другой источник, можно воспользоваться одним из методов flatMap* . В обоих случаях мы создаем Source из каждого входящего элемента и каким-либо образом комбинируем несколько получившихся источников в новый, цельный, сцепляя или сливая исходные источники. В данном случае мы остановимся на flatMapConcat , поскольку хотим сохранить порядок строк, так, чтобы строки с одинаковыми id остались рядом друг с другом. Чтобы преобразовать Path в поток байт, воспользуемся встроенной утилитой FileIO :
На этот раз воспользуемся методом of() для создания нового потока – он удобен, когда входной тип не является обобщенным.
Показанный выше ByteString – это представление последовательности байт, принятое в Akka Streams. В данном случае мы хотим разобрать поток байт как CSV-файл – и для этого вновь воспользуемся одним из модулей Alpakka, на этот раз alpakka-csv :
Обратите внимание на используемый здесь комбинатор via , позволяющий прикрепить произвольный Flow к выводу, полученному на другом шаге графа ( Source или другой Flow ). В результате получается поток элементов, каждый из которых соответствует полю в отдельно взятой строке CSV-файла. Затем их можно преобразовать в модель нашей предметной области:
Для преобразования как такового используем метод map и передаем ссылку на метод Reading.create :
На следующем этапе мы должны сложить показания в пары, вычислить для каждой группы среднее значение value и передавать информацию далее лишь при достижении определенного порога. Поскольку нам требуется, чтобы среднее вычислялось асинхронно, мы воспользуемся методом mapAsyncUnordered , выполняющим асинхронную операцию с заданным уровнем параллелизма:
Определив вышеуказанные компоненты, мы готовы сложить из них цельный конвейер (при помощи уже знакомого вам комбинатора via ). Это совершенно не сложно:
При комбинировании компонентов так, как показано выше, компилятор предохраняет нас, не давая случайно соединить два блока, содержащие несовместимые типы данных.
Поток как веб-сокет
- Предоставлять источник показаний как веб-сокет,
- Выдавать тривиальную веб-страницу, которая подключается к веб-сокету и отображает полученные данные.
Чтобы запустить сервер, нужно всего лишь запустить метод startServer , указав хост-имя и порт:
Чтобы получать данные с веб-сокета и отображать их, воспользуемся совершенно простым кодом на JavaScript, который просто прикрепляет полученные значения к textarea. В этом коде используется синтаксис ES6, который должен нормально выполняться в любом современном браузере.
Чтобы запустить и протестировать приложение, нужно:
- запустить сервер ( sbt run ),
- перейти в браузере на localhost:8080 (или к выбранным вами хосту/порту, если вы изменили умолчания),
- скопировать один или несколько файлов из src/main/resources/sample-data в каталог data в корне проекта (если вы не меняли csv-processor.data-dir в конфигурации),
- смотреть, как данные выводятся в логах сервера и в браузере.
Последний штрих в нашем приложении – побочный канал, в котором мы будем имитировать почтовые оповещения, отсылаемые после поступления на веб-сокет каждого пятого элемента. Он должен работать «сбоку», чтобы не нарушать передачу основных элементов.
Чтобы реализовать такое поведение, воспользуемся более продвинутой возможностью Akka Streams — языком Graph DSL – на котором напишем наш собственный шаг графа, на котором поток разветвляется на две части. Первая просто подает значения на веб-сокет, а вторая контролирует, когда истекут очередные 5 секунд, и отправляет уведомление по электронной почте – см. рис. 3.
Мы будем использовать встроенный шаг Broadcast , на котором наш ввод высылается на набор объявленных выводов. Также напишем наш собственный уловитель — Mailer :
Начинаем создавать наш собственный шаг графа с метода GraphDSL.create() , где предоставляется экземпляр построителя графа, Builder – он используется для манипуляций со структурой графа.
Далее определим наш собственный уловитель, где применяется grouped для объединения входящих элементов в группы произвольного размера (по умолчанию 5), после чего эти группы отправляются вниз. Для каждой такой группы сымитируем побочный эффект: уведомление, поступающее по электронной почте.
Определив наш собственный уловитель, можем использовать экземпляр builder , чтобы добавить его к графу. Также добавляем шаг Broadcast с двумя выходами.
Далее нужно указать соединение между элементами графа – один из выходов шага Broadcast мы хотим соединить с уловителем электронной почты, а другой – сделать выходом для написанного нами шага графа. Ввод написанного нами шага будет напрямую соединен с выходом шага Broadcast .
Примечание 1
Компилятор не может определить, правильно ли соединены все части графа. Однако, этот момент проверяется материализатором во время выполнения, поэтому висящих элементов на входе или на выходе не будет.
Примечание 2
В данном случае можно заметить, что все написанные нами шаги имеют вид Graph, где S – форма, определяющая число и типы входов и выходов, а M – материализованное значение (если таковое имеется). Здесь имеем дело с формой Flow, то есть, у нас один вход и один выход.
На последнем этапе подключаем notifier как дополнительный шаг конвейера liveReadings , который теперь приобретет следующий вид:
Запустив обновленный код, вы увидите, как в логе появляются записи о почтовых уведомлениях. Уведомление отправляется всякий раз, когда через веб-сокет успевает пройти еще пять значений.
В этой статье мы изучили общие концепции потоковой обработки данных, узнали, как при помощи Akka Streams построить легковесный конвейер обработки данных. Это альтернатива традиционному подходу, применяемому на Java EE.
Полноценный рабочий пример с кодом из этой статьи находится на GitHub. В нем есть несколько дополнительных log -шагов, расставленных в разных точках. Они помогают точнее представить, что происходит внутри конвейера. В статье я специально их опустил, чтобы она получилась покороче.
Идеи MVC сформулировал Трюгве Реенскауг (Trygve Reenskaug) во время работы в Xerox PARC в конце 70-х годов. В те времена для работы с ЭВМ было не обойтись без ученой степени и постоянного изучения объемной документации. Задача, которую Реенскауг решал совместно с группой очень сильных разработчиков, заключалась в том, чтобы упростить взаимодействие рядового пользователя с компьютером. Необходимо было создать средства, которые с одной стороны были бы предельно простыми и понятными, а с другой — давали бы возможность управлять компьютером и сложными приложениями. Реенскауг работал в команде, которая занималась разработкой портативного компьютера "для детей всех возрастов" — Dynabook, а также языка SmallTalk под руководством Алана Кея (Alan Kay). Именно тогда и там закладывались понятия дружелюбного интерфейса. Работа Реенскауга совместно с командой во многом повлияла на развитие сферы IT. Приведем интересный факт, который не относится к MVC напрямую, но иллюстрирует значимость тех разработок. В 2007 году после презентации Apple iPhone, Алан Кей сказал: “Когда вышел Macintosh, в Newsweek спросили, что я о нем думаю. Я сказал: это первый персональный компьютер, достойный критики. После презентации Стив Джобс подошел и спросил: достоин ли iPhone критики? И я ответил: сделайте его размером пять на восемь дюймов, и вы завоюете мир”. Спустя 3 года, 27 января 2010 года, Apple представила iPad диагональю 9,7 дюйма. То есть Стив Джобс почти буквально следовал совету Алана Кея. Проект, над которым работал Реннскауг велся на протяжении 10 лет. А первая публикация об MVC от его создателей вышла в свет еще через 10 лет. Мартин Фаулер, автор ряда книг и статей по архитектуре ПО, упоминает, что он изучал MVC по работающей версии SmallTalk. Поскольку информации об MVC из первоисточника долго не было, а также по ряду других причин, появилось большое количество различных трактовок этого понятия. В результате многие считают MVC схемой или паттерном проектирования. Реже MVC называют составным паттерном или комбинацией нескольких паттернов, работающих совместно для реализации сложных приложений. Но на самом деле, как было сказано ранее, MVC — это прежде всего набор архитектурных идей/принципов/подходов, которые можно реализовать различными способами с использованием различных шаблонов. Далее мы попробуем рассмотреть основные идеи, заложенные в концепции MVC.
Что такое MVC: основные идеи и принципы
- VC — это набор архитектурных идей и принципов для построения сложных информационных систем с пользовательским интерфейсом;
- MVC — это аббревиатура, которая расшифровывается так: Model-View-Controller.
Шаг 1. Отделить бизнес-логику приложения от пользовательского интерфейса
Ключевая идея MVC состоит в том, что любое приложение с пользовательским интерфейсом в первом приближении можно разбить на 2 модуля: модуль, отвечающий за реализацию бизнес-логики приложения, и пользовательский интерфейс. В первом модуле будет реализован основной функционал приложения. Данный модуль будет ядром системы, в котором реализуется модель предметной области приложения. В концепции MVC данный модуль будет нашей буковкой M, т.е. моделью. Во втором модуле будет реализован весь пользовательский интерфейс, включая отображение данных пользователю и логику взаимодействия пользователя с приложением. Основная цель такого разделения — сделать так, чтобы ядро системы (Модель в терминологии MVC) могла независимо разрабатываться и тестироваться. Архитектура приложения после подобного разделения будет выглядеть следующим образом:
Шаг 2. Используя шаблон Наблюдатель, добиться еще большей независимости модели, а также синхронизации пользовательских интерфейсов
- Добиться еще большей независимости модели.
- Синхронизировать пользовательские интерфейсы.
Шаг 3. Разделение интерфейса на Вид и Контроллер
- выводить и удобно отображать пользователю информацию о системе;
- вводить данные и команды пользователя в систему (передавать их системе);
- Следуя принципам MVC, систему нужно разделять на модули.
- Самым важным и независимым модулем должна быть модель.
- Модель — ядро системы. Нужна возможность разрабатывать и тестировать ее независимо от интерфейса.
- Для этого на первом шаге сегрегации системы нужно разделить ее на модель и интерфейс.
- Далее, с помощью шаблона Наблюдатель, укрепляем модель в ее независимости и получаем синхронизацию пользовательских интерфейсов.
- Третьим шагом делим интерфейс на контроллер и вид.
- Все, что на ввод информации от пользователя в систему — это в контроллер.
- Все что на вывод информации от системы к пользователю — это в вид.
Немного о взаимосвязи Вида и Контроллера с Моделью
MVC: в чем профит?
Основная цель следования принципам MVC — отделить реализацию бизнес-логики приложения (модели) от ее визуализации (вида). Такое разделение повысит возможность повторного использования кода. Польза применения MVC наиболее наглядна в случаях, когда пользователю нужно предоставлять одни и те же данные в разных формах. Например, в виде таблицы, графика или диаграммы (используя различные виды). При этом, не затрагивая реализацию видов, можно изменить реакции на действия пользователя (нажатие мышью на кнопке, ввод данных). Если следовать принципам MVC, можно упростить написание программ, повысить читаемость кода, сделать легче расширение и поддержку системы в будущем. В завершающем материале цикла “Введение в Enterprise-разработку” мы с тобой посмотрим на реализацию MVC на примере Spring-MVC. Часть 8. Пишем небольшое приложение на spring-boot
Аннотация: В лекции вводится понятие НБФ грамматики, приводится определение порождающей и распознающей грамматики. Дается описание процесса трансляции.
Модели трансляции
Трансляторы
Программа, написанная на языке высокого уровня, перед исполнением должна быть преобразована в программу на "машинном языке". Такой процесс называется трансляцией, или компиляцией. По типу выходных данных различают два основных вида трансляторов:
- компилирующие окончательный выполнимый код;
- компилирующие интерпретируемый код, для выполнения которого требуется дополнительное программное обеспечение.
Окончательным выполнимым кодом являются приложения, реализованные как EXE-файлы, DLL-библиотеки, COM-компоненты. К интерпретируемому коду можно отнести байт-код JAVA-программ, выполняемый посредством виртуальной машины JVM.
Языки, формирующие окончательный выполнимый код, называются компилируемыми языками. К ним относятся языки С, C++, FORTRAN, Pascal. Языки, реализующие интерпретируемый код, называются интерпретируемыми языками. К таким языкам относятся язык Java, LISP, Perl, Prolog.
В большинстве случаев код, получаемый в результате процесса трансляции, формируется из нескольких программных модулей. Программным модулем называется определенным образом оформленный код на языке высокого уровня. Процесс трансляции в этом случае может выполняться как единое целое – компиляция и редактирование связей, или как два отдельных этапа – сначала компиляция объектных модулей, а затем вызов редактора связей, создающего окончательный код. Последний подход более удобен для разработки программ. Он реализован в трансляторах языков С и С++.
Объектный код, создаваемый компилятором, представляет собой область данных и область машинных команд, имеющих адреса, которые в дальнейшем "согласуются" редактором связи (иногда называемым загрузчиком). Редактор связи размещает в едином адресном пространстве все по отдельности откомпилированные объектные модули и статически подключаемые библиотеки.
Будем называть выполнимой формой программы код, получаемый в результате трансляции исходной программы.
Процесс трансляции
Программу, написанную на языке программирования высокого уровня, называют исходной программой, а каждую самостоятельную программную единицу, образующую данную программу, - программным модулем. Для преобразования исходной программы в ее выполняемую форму (выполнимый файл) транслятор выполняет некоторую последовательность действий. Эта последовательность зависит как от языка программирования, так и от конкретной реализации самого транслятора. В ходе трансляции важно не просто откомпилировать программу, а получить при этом достаточно эффективный код.
К достоинствам однопроходного компилятора можно отнести высокую скорость компиляции, а к недостаткам - получение, как правило, не самого эффективного кода.
Широкое распространение получили двухпроходные компиляторы. Они позволяют при первом проходе выполнить анализ программы и построить информационные таблицы, используемые при втором проходе для формирования объектного кода.
На рисунке 2.1 представлены основные этапы, выполняемые в процессе трансляции исходной программы.
Фаза анализа программы состоит из:
- лексического анализа;
- синтаксического анализа;
- семантического анализа.
При анализе исходной программы транслятор последовательно просматривает текст программы, представимой как набор символов, выполняя разбор структуры программы.
На этапе лексического анализа выполняется выделение основных составляющих программы – лексем. Лексемами являются ключевые слова, идентификаторы, символы операций, комментарии, пробелы и разделители. Лексический анализатор не только выделяет лексемы, но и определяет тип каждой лексемы. При этом на этапе лексического анализа составляется таблица символов, в которой каждому идентификатору сопоставлен свой адрес. Это позволяет при дальнейшем анализе вместо конкретного значения (строки символов) использовать его адрес в таблице символов.
Процесс выделения лексем достаточно трудоемок и требует применения сложных контекстно-зависимых алгоритмов.
На этапе синтаксического анализа выполняется разбор полученных лексем с целью получения семантически понятных синтаксических единиц, которые затем обрабатываются семантическим анализатором. Так, синтаксическими единицами выступают выражения, объявление, оператор языка программирования, вызов функции.
На этапе семантического анализа выполняется обработка синтаксических единиц и создание промежуточного кода . В зависимости от наличия или отсутствия фазы оптимизации результатом семантического анализа может быть оптимизируемый далее промежуточный код или готовый объектный модуль.
К наиболее общим задачам, решаемым семантическим анализатором, относятся:
- обнаружение ошибок времени компиляции;
- заполнение таблицы символов, созданной на этапе лексического анализа, конкретными значениями, определяющими дополнительную информацию о каждом элементе таблицы;
- замена макросов их определениями;
- выполнение директив времени компиляции.
Макросом называется некоторый предварительно определенный код, который на этапе компиляции вставляется в программу во всех местах указания вызова данного макроса.
На фазе синтеза программы производится:
- генерация кода;
- редактирование связей.
Процесс генерации кода состоит из преобразования промежуточного кода (или оптимизированного кода) в объектный код. При этом в зависимости от языка программирования получаемый объектный код может быть представлен в выполнимой форме или как объектный модуль, подлежащий дальнейшей обработке редактором связей.
Так, процесс генерации кода является неотъемлемой частью фазы синтеза программы, а необходимость выполнения редактора связей зависит от конкретного языка программирования. Следует учесть, что на практике термин "генерация кода" часто применяют ко всем действиям фазы синтеза программы, ведущим к получению выполнимой формы программы.
Редактор связей приводит в соответствие адреса фрагментов кода, расположенных в отдельных объектных модулях: определяются адреса вызываемых внешних функций, адреса внешних переменных, адреса функций и методов каждого модуля. Для редактирования адресов редактор связей использует специальные, создаваемые на этапе трансляции, таблицы загрузчика. После обработки объектных модулей редактором связей генерируется выполнимая форма программы.
Здравствуйте! В этой статье я вкратце расскажу вам о процессах, потоках, и об основах многопоточного программирования на языке Java.
Наиболее очевидная область применения многопоточности – это программирование интерфейсов. Многопоточность незаменима тогда, когда необходимо, чтобы графический интерфейс продолжал отзываться на действия пользователя во время выполнения некоторой обработки информации. Например, поток, отвечающий за интерфейс, может ждать завершения другого потока, загружающего файл из интернета, и в это время выводить некоторую анимацию или обновлять прогресс-бар. Кроме того он может остановить поток загружающий файл, если была нажата кнопка «отмена».
Еще одна популярная и, пожалуй, одна из самых хардкорных областей применения многопоточности – игры. В играх различные потоки могут отвечать за работу с сетью, анимацию, расчет физики и т.п.
Давайте начнем. Сначала о процессах.
Процессы
Процесс — это совокупность кода и данных, разделяющих общее виртуальное адресное пространство. Чаще всего одна программа состоит из одного процесса, но бывают и исключения (например, браузер Chrome создает отдельный процесс для каждой вкладки, что дает ему некоторые преимущества, вроде независимости вкладок друг от друга). Процессы изолированы друг от друга, поэтому прямой доступ к памяти чужого процесса невозможен (взаимодействие между процессами осуществляется с помощью специальных средств).
Для каждого процесса ОС создает так называемое «виртуальное адресное пространство», к которому процесс имеет прямой доступ. Это пространство принадлежит процессу, содержит только его данные и находится в полном его распоряжении. Операционная система же отвечает за то, как виртуальное пространство процесса проецируется на физическую память.
При запуске программы операционная система создает процесс, загружая в его адресное пространство код и данные программы, а затем запускает главный поток созданного процесса.
Потоки
Один поток – это одна единица исполнения кода. Каждый поток последовательно выполняет инструкции процесса, которому он принадлежит, параллельно с другими потоками этого процесса.
Следует отдельно обговорить фразу «параллельно с другими потоками». Известно, что на одно ядро процессора, в каждый момент времени, приходится одна единица исполнения. То есть одноядерный процессор может обрабатывать команды только последовательно, по одной за раз (в упрощенном случае). Однако запуск нескольких параллельных потоков возможен и в системах с одноядерными процессорами. В этом случае система будет периодически переключаться между потоками, поочередно давая выполняться то одному, то другому потоку. Такая схема называется псевдо-параллелизмом. Система запоминает состояние (контекст) каждого потока, перед тем как переключиться на другой поток, и восстанавливает его по возвращению к выполнению потока. В контекст потока входят такие параметры, как стек, набор значений регистров процессора, адрес исполняемой команды и прочее…
Проще говоря, при псевдопараллельном выполнении потоков процессор мечется между выполнением нескольких потоков, выполняя по очереди часть каждого из них.
Вот как это выглядит:
Цветные квадраты на рисунке – это инструкции процессора (зеленые – инструкции главного потока, синие – побочного). Выполнение идет слева направо. После запуска побочного потока его инструкции начинают выполняться вперемешку с инструкциями главного потока. Кол-во выполняемых инструкций за каждый подход не определено.
То, что инструкции параллельных потоков выполняются вперемешку, в некоторых случаях может привести к конфликтам доступа к данным. Проблемам взаимодействия потоков будет посвящена следующая статья, а пока о том, как запускаются потоки в Java…
Запуск потоков
Каждый процесс имеет хотя бы один выполняющийся поток. Тот поток, с которого начинается выполнение программы, называется главным. В языке Java, после создания процесса, выполнение главного потока начинается с метода main(). Затем, по мере необходимости, в заданных программистом местах, и при выполнении заданных им же условий, запускаются другие, побочные потоки.
В языке Java поток представляется в виде объекта-потомка класса Thread. Этот класс инкапсулирует стандартные механизмы работы с потоком.
Запустить новый поток можно двумя способами:
Способ 1
Создать объект класса Thread, передав ему в конструкторе нечто, реализующее интерфейс Runnable. Этот интерфейс содержит метод run(), который будет выполняться в новом потоке. Поток закончит выполнение, когда завершится его метод run().
Выглядит это так:
Для пущего укорочения кода можно передать в конструктор класса Thread объект безымянного внутреннего класса, реализующего интерфейс Runnable:
Способ 2
Создать потомка класса Thread и переопределить его метод run():
В приведённом выше примере в методе main() создается и запускается еще один поток. Важно отметить, что после вызова метода mSecondThread.start() главный поток продолжает своё выполнение, не дожидаясь пока порожденный им поток завершится. И те инструкции, которые идут после вызова метода start(), будут выполнены параллельно с инструкциями потока mSecondThread.
Для демонстрации параллельной работы потоков давайте рассмотрим программу, в которой два потока спорят на предмет философского вопроса «что было раньше, яйцо или курица?». Главный поток уверен, что первой была курица, о чем он и будет сообщать каждую секунду. Второй же поток раз в секунду будет опровергать своего оппонента. Всего спор продлится 5 секунд. Победит тот поток, который последним изречет свой ответ на этот, без сомнения, животрепещущий философский вопрос. В примере используются средства, о которых пока не было сказано (isAlive() sleep() и join()). К ним даны комментарии, а более подробно они будут разобраны дальше.
В приведенном примере два потока параллельно в течении 5 секунд выводят информацию на консоль. Точно предсказать, какой поток закончит высказываться последним, невозможно. Можно попытаться, и можно даже угадать, но есть большая вероятность того, что та же программа при следующем запуске будет иметь другого «победителя». Это происходит из-за так называемого «асинхронного выполнения кода». Асинхронность означает то, что нельзя утверждать, что какая-либо инструкция одного потока, выполнится раньше или позже инструкции другого. Или, другими словами, параллельные потоки независимы друг от друга, за исключением тех случаев, когда программист сам описывает зависимости между потоками с помощью предусмотренных для этого средств языка.
Теперь немного о завершении процессов…
Завершение процесса и демоны
В Java процесс завершается тогда, когда завершается последний его поток. Даже если метод main() уже завершился, но еще выполняются порожденные им потоки, система будет ждать их завершения.
Однако это правило не относится к особому виду потоков – демонам. Если завершился последний обычный поток процесса, и остались только потоки-демоны, то они будут принудительно завершены и выполнение процесса закончится. Чаще всего потоки-демоны используются для выполнения фоновых задач, обслуживающих процесс в течение его жизни.
Объявить поток демоном достаточно просто — нужно перед запуском потока вызвать его метод setDaemon(true) ;
Проверить, является ли поток демоном, можно вызвав его метод boolean isDaemon() ;
Завершение потоков
В Java существуют (существовали) средства для принудительного завершения потока. В частности метод Thread.stop() завершает поток незамедлительно после своего выполнения. Однако этот метод, а также Thread.suspend(), приостанавливающий поток, и Thread.resume(), продолжающий выполнение потока, были объявлены устаревшими и их использование отныне крайне нежелательно. Дело в том что поток может быть «убит» во время выполнения операции, обрыв которой на полуслове оставит некоторый объект в неправильном состоянии, что приведет к появлению трудноотлавливаемой и случайным образом возникающей ошибке.
Вместо принудительного завершения потока применяется схема, в которой каждый поток сам ответственен за своё завершение. Поток может остановиться либо тогда, когда он закончит выполнение метода run(), (main() — для главного потока) либо по сигналу из другого потока. Причем как реагировать на такой сигнал — дело, опять же, самого потока. Получив его, поток может выполнить некоторые операции и завершить выполнение, а может и вовсе его проигнорировать и продолжить выполняться. Описание реакции на сигнал завершения потока лежит на плечах программиста.
Java имеет встроенный механизм оповещения потока, который называется Interruption (прерывание, вмешательство), и скоро мы его рассмотрим, но сначала посмотрите на следующую программку:
Incremenator — поток, который каждую секунду прибавляет или вычитает единицу из значения статической переменной Program.mValue. Incremenator содержит два закрытых поля – mIsIncrement и mFinish. То, какое действие выполняется, определяется булевой переменной mIsIncrement — если оно равно true, то выполняется прибавление единицы, иначе — вычитание. А завершение потока происходит, когда значение mFinish становится равно true.
Несмотря на то, что метод sleep() может принимать в качестве времени ожидания наносекунды, не стоит принимать это всерьез. Во многих системах время ожидания все равно округляется до миллисекунд а то и до их десятков.
Метод yield()
Статический метод Thread.yield() заставляет процессор переключиться на обработку других потоков системы. Метод может быть полезным, например, когда поток ожидает наступления какого-либо события и необходимо чтобы проверка его наступления происходила как можно чаще. В этом случае можно поместить проверку события и метод Thread.yield() в цикл:
Метод join()
В Java предусмотрен механизм, позволяющий одному потоку ждать завершения выполнения другого. Для этого используется метод join(). Например, чтобы главный поток подождал завершения побочного потока myThready, необходимо выполнить инструкцию myThready.join() в главном потоке. Как только поток myThready завершится, метод join() вернет управление, и главный поток сможет продолжить выполнение.
Метод join() имеет перегруженную версию, которая получает в качестве параметра время ожидания. В этом случае join() возвращает управление либо когда завершится ожидаемый поток, либо когда закончится время ожидания. Подобно методу Thread.sleep() метод join может ждать в течение миллисекунд и наносекунд – аргументы те же.
С помощью задания времени ожидания потока можно, например, выполнять обновление анимированной картинки пока главный (или любой другой) поток ждёт завершения побочного потока, выполняющего ресурсоёмкие операции:
В этом примере поток brain (мозг) думает над чем-то, и предполагается, что это занимает у него длительное время. Главный поток ждет его четверть секунды и, в случае, если этого времени на раздумье не хватило, обновляет «индикатор раздумий» (некоторая анимированная картинка). В итоге, во время раздумий, пользователь наблюдает на экране индикатор мыслительного процесса, что дает ему знать, что электронные мозги чем то заняты.
Приоритеты потоков
Каждый поток в системе имеет свой приоритет. Приоритет – это некоторое число в объекте потока, более высокое значение которого означает больший приоритет. Система в первую очередь выполняет потоки с большим приоритетом, а потоки с меньшим приоритетом получают процессорное время только тогда, когда их более привилегированные собратья простаивают.
Работать с приоритетами потока можно с помощью двух функций:
void setPriority(int priority) – устанавливает приоритет потока.
Возможные значения priority — MIN_PRIORITY, NORM_PRIORITY и MAX_PRIORITY.
int getPriority() – получает приоритет потока.
Некоторые полезные методы класса Thread
Это практически всё. Напоследок приведу несколько полезных методов работы с потоками.
boolean isAlive() — возвращает true если myThready() выполняется и false если поток еще не был запущен или был завершен.
setName(String threadName) – Задает имя потока.
String getName() – Получает имя потока.
Имя потока – ассоциированная с ним строка, которая в некоторых случаях помогает понять, какой поток выполняет некоторое действие. Иногда это бывает полезным.
static Thread Thread.currentThread() — статический метод, возвращающий объект потока, в котором он был вызван.
long getId() – возвращает идентификатор потока. Идентификатор – уникальное число, присвоенное потоку.
Заключение
Отмечу, что в статье рассказано далеко не про все нюансы многопоточного программирования. И коду, приведенному в примерах, для полной корректности не хватает некоторых нюансов. В частности, в примерах не используется синхронизация. Синхронизация потоков — тема, не изучив которую, программировать правильные многопоточные приложения не получится. Почитать о ней вы можете, например, в книге «Java Concurrency in Practice» или здесь (всё на английском).
В статье были рассмотрены основные средства работы с потоками в Java. Если эта статья окажется полезной, то в следующей я расскажу о проблемах совместного доступа потоков к ресурсам и о методах их решения.
Привет, Хабр! Представляю вашему вниманию перевод статьи "Java 8 Stream Tutorial".
Это руководство, основанное на примерах кода, представляет всесторонний обзор потоков в Java 8. При моем первом знакомстве с Stream API, я был озадачен названием, поскольку оно очень созвучно с InputStream и OutputStream из пакета java.io; Однако потоки в Java 8 — нечто абсолютно другое. Потоки представляют собой монады, которые играют важную роль в развитии функционального программирования в Java.
В функциональном программировании монада является структурой, которая представляет вычисление в виде цепи последовательных шагов. Тип и структура монады определяют цепочку операций, в нашем случае — последовательность методов с встроенными функциями заданного типа.
Это руководство научит работе с потоками и покажет как обращаться с различными методами, доступными в Stream API. Мы разберем порядок выполнения операций и проследим как последовательность методов в цепочке влияет на производительность. Познакомимся с мощными методами Stream API, такими как reduce , collect и flatMap . В конце руководства уделим внимание параллельной работе с потоками.
Если вы не чувствуете себя свободно в работе с лямбда-выражениями, функциональными интерфейсами и ссылочными методами, вам будет полезно ознакомиться с моим руководством по нововведениям в Java 8 (перевод на Хабре), а после этого вернуться к изучению потоков.
Как работают потоки
Поток представляет последовательность элементов и предоставляет различные методы для произведения вычислений над данными элементами:
Методы потоков бывают промежуточными (intermediate) и терминальными (terminal). Промежуточные методы возвращают поток, что позволяет последовательно вызывать множество таких методов. Терминальные методы либо не возвращают значения (void) либо возвращают результат типа отличного от потока. В вышеприведенном примере методы filter , map и sorted являются промежуточными, а forEach — терминальным. Для ознакомления с полным списком доступных методов потока обратитесь к документации. Такая цепочка потоковых операций также известна как конвейер операций (operation pipeline).
Большинство методов из Stream API принимают в качестве параметров лямбда-выражения, функциональный интерфейс, описывающие конкретное поведение метода. Большая их часть должна одновременно быть невмешивающейся (non-interfering) и не запоминающей состояние (stateless). Что же это означает?
Метод является невмешивающимся (non-interfering), если он не изменяет исходные данные, лежащие в основе потока. Например, в вышеприведенном примере никакие лямбда-выражения не вносят изменений в списочный массив myList.
Метод является не запоминающим состояние (stateless), если порядок выполнения операции определен. Например, ни одно лямбда-выражение из примера не зависит от изменяемых переменных или состояний внешнего пространства, которые могли бы меняться во время выполнения.
Различные виды потоков
Потоки могут быть созданы из различных исходных данных, главным образом из коллекций. Списки (Lists) и множества (Sets) поддерживают новые методы stream() и parllelStream() для создания последовательных и параллельных потоков. Параллельные потоки способны работать в многопоточном режиме (on multiple threads) и будут рассмотрены в конце руководства. А пока рассмотрим последовательные потоки:
Здесь вызов метода stream() для списка возвращает обычный объект потока.
Однако для работы с потоком вовсе не обязательно создавать коллекцию:
Просто используйте Stream.of() для создания потока из нескольких объектных ссылок.
Помимо обычных потоков объектов Java 8 располагает специальными типами потоков для работы с примитивными типами: int, long, double. Как можно догадаться это IntStream , LongStream , DoubleStream .
Потоки IntStream могут заменить обычные циклы for(;;) используя IntStream.range() :
Все эти потоки для работы с примитивными типами работают так же как и обычные потоки объектов за исключением следующего:
- Потоки примитивов используют специальные лямбда-выражения. Например, IntFunction вместо Function, или IntPredicate вместо Predicate.
- Потоки примитивов поддерживают дополнительные терминальные методы: sum() и average()
Потоки примитивов могут быть преобразованы в потоки объектов посредством вызова mapToObj() :
В следующем примере поток из чисел с плавающей точкой отображается в поток целочисленных чисел и затем отображается в поток объектов:
Порядок выполнения
Сейчас, когда мы узнали как создавать различные потоки и как с ними работать, погрузимся глубже и рассмотрим, как потоковые операции выглядят под капотом.
Важная характеристика промежуточных методов — их лень. В этом примере отсутствует терминальный метод:
При выполнении этого фрагмента кода ничего не будет выведено в консоль. А все потому, что промежуточные методы выполняются только при наличии терминального метода. Давайте расширим пример добавлением терминального метода forEach :
Выполнение этого фрагмента кода приводит к выводу на консоль следующего результата:
Порядок, в котором расположены результаты, может удивить. Можно наивно ожидать, что методы будут выполняться “горизонтально”: один за другим для всех элементов потока. Однако вместо этого элемент двигается по цепочке “вертикально”. Сначала первая строка “d2” проходит через метод filter затем через forEach и только тогда, после прохода первого элемента через всю цепочку методов, следующий элемент начинает обрабатываться.
Принимая во внимание такое поведение, можно уменьшить фактическое количество операций:
Метод anyMatch вернет true, как только предикат будет применен к входящему элементу. В данном случае это второй элемент последовательности — “A2”. Соответственно, благодаря “вертикальному” выполнению цепочки потока map будет вызван только дважды. Таким образом вместо отображения всех элементов потока, map будет вызван минимально возможное количество раз.
Почему последовательность имеет значение
Следующий пример состоит из двух промежуточных методов map и filter и терминального метода forEach . Рассмотрим как выполняются данные методы:
Нетрудно догадаться, что оба метода map и filter вызываются 5 раз за время выполнения — по разу для каждого элемента исходной коллекции, в то время как forEach вызывается только единожды — для элемента прошедшего фильтр.
Можно существенно сократить число операций, если изменить порядок вызовов методов, поместив filter на первое место:
Сейчас map вызывается только один раз. При большом количестве входящих элементов будем наблюдать ощутимый прирост производительности. Помните об этом составляя сложные цепочки методов.
Расширим вышеприведенный пример, добавив дополнительную операцию сортировки — метод sorted :
Сортировка — это специальный вид промежуточных операций. Это так называемая операция с запоминанием состояния (stateful), поскольку для сортировки коллекции необходимо учитывать ее состояния на протяжении всей операции.
В результате выполнения данного кода получаем следующий вывод в консоль:
Сперва производится сортировка всей коллекции целиком. Другими словами метод sorted выполняется “горизонтально”. В данном случае sorted вызывается 8 раз для нескольких комбинаций из элементов входящей коллекции.
Еще раз оптимизируем выполнение данного кода посредством изменения порядка вызовов методов в цепочке:
В этом примере sorted вообще не вызывается т.к. filter сокращает входную коллекцию до одного элемента. В случае с большими входящими данными производительность выиграет существенно.
Повторное использование потоков
В Java 8 потоки не могут быть использованы повторно. После вызова любого терминального метода поток завершается:
Вызов noneMatch после anyMatch в одном потоке приводит к следующей исключительной ситуации:
Для преодоления этого ограничения следует создавать новый поток для каждого терминального метода.
Например, можно создать поставщика (supplier) для конструктора нового потока, в котором будут установлены все промежуточные методы:
Каждый вызов метода get создает новый поток, в котором можно безопасно вызвать желаемый терминальный метод.
Продвинутые методы
Потоки поддерживают большое количество различных методов. Мы уже ознакомились с наиболее важными методами. Чтобы самостоятельно ознакомиться с остальными, обратитесь к документации. А сейчас погрузимся еще глубже в более сложные методы: collect , flatMap и reduce .
Большая часть примеров кода из этого раздела обращается к следующему фрагменту кода для демонстрации работы:
Collect
Collect очень полезный терминальный метод, который служит для преобразования элементов потока в результат иного типа, например, List, Set или Map.
Collect принимает Collector , который содержит четыре различных метода: поставщик (supplier). аккумулятор (accumulator), объединитель (combiner), финишер (finisher). На первый взгляд это выглядит очень сложно, однако Java 8 поддерживает различные встроенные коллекторы через класс Collectors , где реализованы наиболее используемые методы.
Как видите, создать список из элементов потока очень просто. Нужен не список а множество? Используйте Collectors.toSet() .
В следующем примере люди группируются по возрасту:
Коллекторы невероятно разнообразны. Также можно агрегировать элементы коллекции, например, определить средний возраст:
Для получения более исчерпывающей статистики используем резюмирующий коллектор, который возвращает специальный объект с информацией: минимальным, максимальным и средним значениями, суммой значений и количеством элементов:
Следующий пример объединяет все имена в одну строку:
Соединяющий коллектор принимает разделитель, а также опционально префикс и суффикс.
Для преобразования элементов потока в отображение, следует определить каким образом должны отображаться ключи и значения. Помните, что ключи в отображении должны быть уникальными. В противном случае получим IllegalStateException . Можно опционально добавить функцию слияния для обхода исключения:
Итак, мы ознакомились с некоторыми из наиболее мощных встроенных коллекторов. Попробуем построить собственный. Мы хотим преобразовать все элементы потока в одну строку, которая состоит из имен в верхнем регистре, разделенных вертикальной чертой |. Для этого создадим новый коллектор используя Collector.of() . Нам нужны четыре составные части нашего коллектора: поставщик, аккумулятор, соединитель, финишер.
Поскольку строки в Java неизменяемы, нам нужен класс-помощник типа StringJoiner , позволяющий коллектору построить для нас строку. На первой стадии поставщик конструирует StringJoiner с присвоенным разделителем. Аккумулятор используется для добавления каждого имени в StringJoiner .
Соединитель знает как соединить два StringJoiner а в один. И в конце финишер конструирует желаемую строку из StringJoiner ов.
FlatMap
Итак, мы узнали как превращать объекты потока в другие типы объектов при помощи метода map . Map — своего рода ограниченный метод, поскольку каждый объект может быть отображен в только один другой объект. Но что если нужно отобразить один объект в множество других, или вовсе не отображать его? Вот тут-то выручает метод flatMap . FlatMap превращает каждый объект потока в поток других объектов. Содержимое этих потоков затем упаковывается в возвращаемый поток метода flatMap .
Для того чтобы посмотреть на flatMap в действии, соорудим подходящую иерархию типов для примера:
Создадим несколько объектов:
Теперь у нас есть список из трех foo, каждый из которых содержит по три bar.
FlatMap принимает функцию, которая должна вернуть поток объектов. Таким образом, чтобы получить доступ к объектам bar каждого foo, нам просто нужно подобрать подходящую функцию:
Итак, мы успешно превратили поток из трех объектов foo в поток из 9 объектов bar.
Наконец, весь вышеприведенный код можно сократить до простого конвейера операций:
FlatMap также доступен в классе Optional , введенном в Java 8. FlatMap из класса Optional возвращает опциональный объект другого класса. Это может быть использовано чтобы избежать нагромождения проверок на null .
Представьте себе иерархическую структуру типа этой:
Для получения вложенной строки foo из внешнего объекта необходимо добавить множественные проверки на null для избежания NullPointException :
Того же можно добиться, используя flatMap класса Optional:
Каждый вызов flatMap возвращает обертку Optional для желаемого объекта, если он присутствует, либо для null в случае отсутствия объекта.
Reduce
Операция упрощения объединяет все элементы потока в один результат. Java 8 поддерживает три различных типа метода reduce.
Первый сокращает поток элементов до единственного элемента потока. Используем этот метод для определения элемента с наибольшим возрастом:
Метод reduce принимает аккумулирующую функцию с бинарным оператором (BinaryOperator). Тут reduce является би-функцией (BiFunction), где оба аргумента принадлежат одному типу. В нашем случае, к типу Person. Би-функция — практически тоже самое, что и функция (Function), однако принимает 2 аргумента. В нашем примере функция сравнивает возраст двух людей и возвращает элемент с большим возрастом.
Следующий вид метода reduce принимает и начальное значение, и аккумулятор с бинарным оператором. Этот метод может быть использован для создания нового элемента. У нас — Person с именем и возрастом, состоящими из сложения всех имен и суммы прожитых лет:
Третий метод reduce принимает три параметра: изначальное значение, аккумулятор с би-функцией и объединяющую функцию типа бинарного оператора. Поскольку начальное значение типа не ограничено до типа Person, можно использовать редуцирование для определения суммы прожитых лет каждого человека:
Как видим, мы получили результат 76, но что же на самом деле происходит под капотом?
Расширим вышеприведенный фрагмент кода выводом текста для дебага:
Как видим, всю работу выполняет аккумулирующая функция. Впервые она вызывается с изначальным значением 0 и первым человеком Max. В последующих трех шагах sum постоянно возрастает на возраст человека из последнего шага пока не достигает общего возраста 76.
И что дальше? Объединитель никогда не вызывается? Рассмотрим параллельное выполнение этого потока:
При параллельном выполнении получаем совершенно другой консольный вывод. Сейчас объединитель действительно вызывается. Поскольку аккумулятор вызывался параллельно, объединитель должен был суммировать значения, сохраненные по-отдельности.
В следующей главе более детально изучим параллельное выполнение потоков.
Параллельные потоки
На моем компьютере обычный пул потоков по умолчанию инициализируется с распараллеливанием на 3 потока. Это значение можно увеличить или уменьшить посредством установки следующего параметра JVM:
Коллекции поддерживают метод parallelStream() для создания параллельных потоков данных. Также можно вызвать промежуточный метод parallel() для превращения последовательного потока в параллельный.
Для понимания поведения потока при параллельном выполнении, следующий пример печатает информацию про каждый текущий поток (thread) в System.out :
Рассмотрим выводы с записями для дебага чтобы лучше понять, какой поток (thread) используется для выполнения конкретных методов потока (stream):
Как видим, при параллельном выполнении потока данных используются все доступные потоки (threads) текущего ForkJoinPool . Последовательность вывода может отличаться, так как не определена последовательность выполнения каждого конкретного потока (thread).
Давайте расширим пример добавлением метода sort :
На первый взгляд результат может показаться странным:
Кажется, будто sort выполняется последовательно и только в потоке main. На самом деле при параллельном выполнении потока под капотом метода sort из Stream API спрятан метод сортировки класса Arrays , добавленный в Java 8, — Arrays.parallelSort() . Как указано в документации, этот метод на основании длины входящей коллекции определяет, как именно — параллельно или последовательно будет произведена сортировка:
Если длина определенного массива меньше минимальной “зернистости”, сортировка производится посредством выполнения метода Arrays.sort.
Вернемся к примеру с методом reduce из предыдущей главы. Мы уже выяснили, что объединительная функция вызывается только при параллельной работе с потоком. Рассмотрим, какие потоки задействованы:
Консольный вывод показывает, что обе функции: аккумулирующая и объединяющая, выполняются параллельно, используя все возможные потоки:
Можно утверждать, что параллельное выполнение потока способствует значительному повышению эффективности при работе с большими количествами входящих элементов. Однако следует помнить, что некоторые методы при параллельном выполнении требуют дополнительных расчетов (объединительных операций), которые не требуются при последовательном выполнении.
Кроме того, для параллельного выполнения потока используется все тот же ForkJoinPool , так широко используемый в JVM. Так что применение медленных блокирующих методов потока может негативно отразиться на производительности всей программы, за счет блокирования потоков (threads), используемых для обработки в других задачах.
Вот и все
Мое руководство по использованию потоков в Java 8 окончено. Для более подробного изучения работы с потоками можно обратиться к документации. Если вы хотите углубиться и больше узнать про механизмы, лежащие в основе работы потоков, вам может быть интересно прочитать статью Мартина Фаулера (Martin Fowler) Collection Pipelines.
Если вам так же интересен JavaScript, вы можете захотеть взглянуть на Stream.js — JavaScript реализацию Java 8 Streams API. Возможно, вы также захотите прочитать мои статьи Java 8 Tutorial (русский перевод на Хабре) и Java 8 Nashorn Tutorial.
Надеюсь, это руководство было полезным и интересным для вас, и вы наслаждались в процессе чтения. Полный код хранится в GitHub. Чувствуйте себя свободно, создавая ответвление в репозитории.
Читайте также: