Виртуализация -- это парадигма, подход к решению некоторого широкого набора задач при помощи определенной практики.
Слово "виртуальный" -- средневековая латынь, латинское слово virtualis было впервые использовано товарищем, которого граф оставил вместо себя управлять поместьем (потом появилась специальная должность виконт, viscount). Этот граф отправился в крестовый поход, а на его месте остался человек, который обладал теми же достоинствами и добродетелями, но графом не являлся.
По некоторому набору требований мы создаем что-то, что не является тем, что мы воссоздаем (либо потому, что это просто не оно, или потому, что его никогда и не было). Мы создаем модель некоторого окружения, в котором происходит функционирование некоторых сущностей.
Виртуализация -- это всегда создание некоторой "кажимости", некоторого набора условностей. Программа, которая создает набор таких "кажимостей", это и называется виртуальной машиной (монитор, который контролирует исполнение чего-то, что происходит внутри него).
При использовании правильного набора абстракций получается очень мощный механизм, из-за того, что, если мы выделим только определенные важные поведенческие аспекты из какой-то сущности, то мы получим возможность воссоздавать тот же самый набор контрактов и ожиданий на широком наборе реального аппаратного обеспечения.
Типичный пример: высокоуровневый язык. Компьютеры не умеют в си или другие языки программировния, они умеют исполнять набор инструкций -- набор бинарного кода с достаточно простыми, примитивными операциями (прочитать значение, сложить значение, записать назад в память, ...). Этот набор инструкций разный для телефона, компьютера, ..., потому что отличаются архитектуры.
Конкретная реализация компьютерных систем достаточно большие изменения претерпела за последние лет 49, при этом значительная часть мыслей, которые выражены в программах на высокоуровневых языках, не изменились. например, алгоритм умножения матриц запускай хоть на телефоне, хоть на суперкомпьютере -- логика того, что нужно делать, никак не изменится. Поэтому в определенном смысле языки программирования являются технологией виртуализации мыслей: мы выражаем именно то, что хотим выразить, а дальше уже определенный инструмент (в данном случае транслятор) транслирует этот код в какой-то набор команд, понятных конкретному процессору.
Другая похожая система виртуализации -- это система разделения времени. Из истории компьютеров: первая система, которая была предшественницей юникса, называлась MULTICS и являлась желанием людей в университете одновременно использовать один компьютер для более чем одного человека одновременно. Были созданы time-scheduling системы -- системы, которые умели по какому-то внешнему событию (таймеру) переключать текущий контекст исполнения и начинать исполнять что-то другое. В результате (тогда компьютеры уже работали быстрее, чем люди) исполнение, пока один человек как-то взаимодействовал со своей программой, его программа ждала, а другая программа могла исполняться. Если посмотреть на то, что происходит на компьютере при таком раскладе, то программе кажется, что она исполняется непрерывно, при этом реально на процессоре происходит исполнение кусочков программы, прерываемое аппаратным прерыванием, переключением контекста и продолжением исполнения какой-то другой программы. В результате программа может не думать, что она сосуществует с другими программами на этом компьютере, они просто исполняются независимо и не думаю о существовании других программ. И дело некоторого монитора виртуальных машин (schedulerа ядра ОС) -- осуществить создание кажимости, что у каждой из этих программ есть свой процесс.
Третий показательный пример -- виртуальная память. Если мы программировали на си или иже подобных языках, то мы знаем, что, с точки зрения программы на языке си, вся память -- это один большой линейный массив, а указатель -- это индекс в этом большом линейном массиве. Весь язык си построен вокруг указателей, арифметикой над ними. Если это верно про аппаратный компьютер (у компьютера есть какое-то количество физ памяти), то из-за того, что в нем существует множество различных программ, и в разных компьютерах много чего разного (той же памяти), то иллюзия непрерывного линейного пространства (например, от 0 до 4 ГБ для 32-битного пространства, от 0 до дохрена для 64-битного) некорректна. Существует какая-то сущность (в данном случае у ОС) -- менеджер виртуальной памяти, который создает иллюзию непрерывной линейной памяти поверх сузествующей физической памяти и, соответственно, обеспечивает композицию размещения данных разных программ в этой самой наличной физической памяти.
Похожая концепция -- это TCP-соединение -- пример виртуального канала. Нам кажется, что у нас есть некоторая труба, в которую можно с одной стороны посылать байтики, с другой стороны получать байтики, хотя на самом деле это достигается зачем благодаря достаточно сложной коммутируемой сети, которая обменивается пакетами, некоторые пакеты теряются и так далее, так что там внутри все сложно, до для того, что пишет программы в терминах сокетов, это вообще не важно: он знает, куда посылать или откуда получать, вот и абстракция. Эту идею легко переносить между разными компьютерными системами.
Примеры из зала: нету :(
Концепция VM появилась, когда появились достаточно мощные компьютеры (когда вычислительная мощность не только на арифметике, а вообще на абстрактных логических операциях у компьютеров стала сильно больше, чем у людей), стало понятно, что нужны какие-то более простые концепции для создания прикладными программами (программами, которые не очень знают, где они исполняются). Появилось разделение на системных программистов и прикладных (первые хорошо знали, как работают компьютеры, вторые хорошо понимали, как решать конкретные прикладные задачи), и зачастую это оказывались разные люди, и между ними был некоторый договор, который частично достигался различными виртуализационными техниками.
Определение виртуализации получилось скучным, преподаватель постарается пересказать. Это такая техника, которая создает параметризированный контекст исполнения, это технология создания некоторого такого замкнутого самосогласованного мира, в рамках которого происходит некоторое взаимодействие, приэтом у этого мира есть управляющие ручки -- возможность решить что-либо.
Например, процесс в юниксе -- хороший пример многих аспектов виртуализации. Это представление некоторой достаточно сложной сущности работы компьютеров в достаточно простых терминах: у процесса есть линейное адресное пространство, непрерывный поток исполнения (и то и другое -- кажимость), и при этом у него есть внешние ручки (его можно убить, к нему можно присоединиться отладчиком), то есть, у него есть контекст, и этот контекст управляем. Наличие менеждера ресурсов (менеджера контекста) позволяет создавать контекст, при этом сам контекст является контролируемым: он существует не сам по себе, но на него можно как-то извне воздействовать.
И внутренний, и внешний аспекты виртуализации (внутренний -- мир достаточно прост внутри виртуализированной системы, внешний -- этим миром, в котором живут виртуализируемые сущности, можно как-то управлять) одинаково важны и полезны.
Для чего виртуализация вообще людям нужна? Идея -- уйти от каких-то конкретных деталей машины.
-
Упрощение разработки -- польза, например, высокоуровневых языков.
-
Безопасность -- виртуальная память позволяет пользователям не знать конкретные физ. адреса, с которыми они работают.
-
Стабильность
-
Прозрачное управление ресурсами (если мыслить о памяти как о ресурсе, GC -- относительно прозрачный сложный менеджер ресурсов). Если посмотреть на большинство современных сборщиков мусора, это достаточно сложные программные компоненты с десятками тысяч строк кода, с достаточно сложными алгоритмами, но для обычного пользователя эта компонента является практически несуществующей -- практически нет даже операций для ызаимодействия с GC, а те, что есть, достаточно дурацкие. Явная инициализация сборщиков мусора, финализация в той джаве -- достаточно неудобные.
-
Организация обратной совместимости -- во многих компьютерных системах накапливается гигантское количество legacy-кода, который часто и по финансовым, и по практическим причинам невозможно переписать, и поэтому, если legacy-код можно продолжать исполнять, то это создаст людям достаточно большую гибкость в управлении ресурсами.
Например, VirtualBox -- это изначально был небольшой немецкий стартап под названием AnnoTek, который работал ровно над следующим: была такая ОС -- OS/2 от IBM -- достаточно хорошая ОС, которая по маркетинговым причинам не полетела, была завалена Windows. Но за время ее существования было написано большое количество софта для нее, в т.ч. для немецкий банков. И вот эти вот немецкие банки очутились в очень странной ситуации -- у них было написано много ПО, важного для их функционирования, но у них не было компьютеров, на которых они могли бы исполнять эти программы, ибо OS/2 в какое-то время перестала поддерживаться и запускаться на более современных компьютерах.
AnnoTek, осознав такую картину мира, написали гипервизор, который был способен исполнять ту самую OS/2, и продавали его банкам. Стартап затем купил Sun, но это совсем другая история. Вот такой типичный пример, когда виртуализация сильно помогла с обратной совместимостью.
-
Облачные вычисления -- классический пример виртуализующих технологий, в частности, один из важных аспектов виртуализации -- балансировка нагрузки; именно благодаря виртуализации можно покупать на Амазоне очень странные компьютеры, которые вообще в природе не существуют, либо потому что они слишком мощные, либо наоборот, но тем не менее, Амазон создает достаточно непрерывную верификацию именно за счет того, что при помощи виртуализации можно очень тонко контролировать реальное количество ресурсов, доступные компьютеру, и при этом ситуация с программами абсолютно не меняются -- программы, написанные под тот же Linux или Windows, точно так же продолжают исполняться, просто их менеджер ресурсов (в данном случае -- гипервизор, который контролирует контекст исполнения VM, отдает ему ровно столько ресурсов, за сколько уплачено).
-
Создание новых сущностей. Понятно, что такая технология виртуализации может моделировать что-то существующее (например, старые компьютеры, на которых исполнялся OS/2). Так как это программная модель, то на ней можно написать модель чего угодно, например, модель несуществующего компьютера (придумать свой идельный компьютер, придумать модель для него, а затем начать писать программы для него, и потом когда-нибудь воплотить его в кремнии), так что виртуализация позволяет делать прототипы и осуществлять раннюю разработку сложных компьютерных систем.
Если вдуматься, JVM -- тоже компьютер, просто компьютер, никогда не существовавший, компьютер с объектно-ориентированным набором инструкций.
итак, VM в целом -- это конктерная реализация некоторых контекстов окружения, контекстов, в которых происходит некоторое исполнение, причем чаще всего эта реализация является еще и менеджером ресурсов, а этот менеджер, в свою очередь, тоже программа (раньше -- программно-аппаратный комплекс), некоторая такая сущность, которая сама исполняется на чем-то и как-то, и у нее есть некоторый собственный контекст исполнения (некоторый набор доступных для нее инструкций). Типичный пример -- ОС. Она исполняется, начиная с загрузки, выясняет, сколько в компьютере находится памяти, и в дальнейшем вся жизнь ОС строится на этом знании (выделяется ровно столько памяти, сколько можно выделить; если памяти не хватает, то что-то выкидывается в файл подкачки, и так далее), то есть, VM является в большинстве ситуаций менеджером ресурсов.
Кроме того, у нее есть такой любопытный аспект, как менеджер ресурсов, который адаптируется к непредсказуемым запросам нескольких пользователей в типичных ситуациях, т.е. достаточно часто бывает так, что при разработке ОС люди понятия не имеют, какие программы будут под ней запускаться, и сколько их будет, и в каком ассортименту они будут запущены в конкретный момент.
То же верно и про виртуальную малишу джавы, разработчики представления не имеют, какого рода программы будут запущены. Более того, они, наверное, даже не умеют программировать на джаве.
Вот эти вот аспекты -- это аспекты, которые существуют примерно в любой системе управления, в любой VM.
Дальше обсудим уже некоторые такие специфичные вещи (что такое ресурсы, как ими можно управлять, ...) Что такое контекст исполнения? Каким образом можно создать для программы полностью контролируемый контекст? Как этого добиваются разного рода системы исполнения? Дальше рассмотрим реальные машины (виртуальную машину джавы -- HotSpot), затем поговорим о других механизмах виртуализации -- виртуализации существующего аппаратного обеспечения на примере VirtualBox, немножко qemu. Потом немножко поговорим про верификацию программ для исполнения на рантаймах (по сути, про различные системы проверки, что программа делает что-то и не делает чего-то другого, что важно для многих рантаймов, предоставляющих гарантии), на примере верификатора байткода, верификатора в nacl-е. Про разные системы менеджмента памяти (сборщики мусора, конкретные алгоритмы), про системы виртуальной памяти в ОС, немножко про менеджер памяти в гипервизорах (он там простой, к счастью), про разные быстрые системы исполнения инструкций (интерпретатор, JIT, системы гибридного исполнения, как, например, некоторые гипервизоры -- у них часто сочетается непосредственное испонение инструкций с JIT-трансляцией тех кусков, которые нельзя непосредственно исполнить по тем или иным причинам). Про то, как можно эмулировать различное аппаратное обеспечение (на примере x86: сетевые карты, таймеры, контроллеры прерываний итд), про то, как быть менеджером реальных физических устройств, как гипервизоры разделяют доступ к физическим устр-вам. Немного обсудим безопасность VM (как работают верификаторы, как контролируется то, что может делать программа при помощи всяких аппаратных устр-в вроде MMU, привилегированных уровней процессора), ну и немного о самой сложной проблеме любого рантайма -- о производительности (как писать высокопроизводительные рантаймы, как добиваться относительно высокой производительности от существующих рантаймов).
Представим себе большую семью. Ужин. Есть некоторый ресурс -- кастрюля с супом. Есть десяток детишек, четверо взрослых, и необходимость разделить суп по вот этой самой разношерстной публике (детям кому-то год, кому-то 5, кому-то 15), у всех разные состояния. Как делить суп? Какие есть стратегии?
-
Дать каждому поварешку, пусть набирают
-
Есть гипервизор-мама, которая знает, кто сколько хочет, и раздает в той же пропорции (менеджер ресурсов, который учитывает потребность)
-
Всем поровну
-
Никому ничего не давать
-
Упорядочить по степени нужды (давно не ел -- поешь). Планировщик задач, учитывающий предыдущую историю
-
Пускать каструлю по кругу -- монопольный доступ
-
Адаптивная стратегия -- пришел за добавкой -- добавили
Все это валидные стратегии, которые могут присутствовать в менеджере ресурсов.
Какие бывают интерфейсы получения памяти в компьютерах (например, в языке си)?
-
malloc
-
выделение памяти на стеке (выделяется через интеррапты -- когда мы пытаемся на стек что-то записать, чтобы ОС сама докинула туда память). Это пример неявного менеджмента ресурсов с общением с менеджером ресурсов. Не было бы ядра ОС, которая могла бы обработать попытку обратиться к невыделенной памяти
Стековая аллокация в большинстве ядер работает следующим образом: доступ к стеку должен происходить очень быстро, на нем должны часто выделяться/освобождаться объекты, при этом память локальна для текущего потока исполнения. Поэтому типичное выделение памяти на стеке выглядит так: компилятор при кодогенерации выясняет, сколько примерно нужно памяти данной функции, и в пролог (в самое начало функции) он вставляет инструкцию, которая просто вычитает (или прибавляет) из указателя стека определенное число. А дальше просто стеком он пользуется, и соответственно, адресует все структуры, которые должны быть выделены на стеке, отностельно нового значения SP. По сути, никакого выделения не происходит.
Как это работает? Сегмент данных BSS, сегмент стека SS. Эти данные при попытке выполнить программу ничем не инициализируются, просто создается такой layout виртуальной памяти, и stack pointer ставится между сегментами. Этот процесс потребляет (как бы) нек-рое количество вирт. памяти, но не потребляет реальную, так как доступ к любому адресу из ss ведет к ошибке -- page fault 2. Так же работает и загрузка кода -- если есть сегмент кода CS, то чаще всего, когда запускаем программу, сегмент не вычитывается с диска, а происходит просто отображение с файла на иске в память. Начало исполнения ставится на точку входа. При попытке исполнения, когда, собственно, процесср пытается вытащить первую инструкцию с памяти, происходит тот же самый page fault. Дальше исполнение переходит в обработчик в обработчик ОС, который смотрит, ага, у меня здесь вот сегмент кода отображен в файл на диске, и случился page fault. Но это не настояний PF, а это PF, который говорит мне, что файл отображен в память. Поэтому я зачитаю кусочек файла и перенесу его в оперативную память, и дальше продолжу исполнение.
Это позволяет большинству ОС достаточно быстро запускать процесс, даже с большим объемом кода и данных, потому что отображение происходит лениво, реально в памяти оказывается только то, что нужно программе для ее исполнения.
Походая ситуация с SS: когда процессор видит, что происходит fault, но fault в районе сегмента стека, он понимает, ага, это на самом деле не программа что-то плохое сделала, а просто я должен сейчас довыделить кусочек физической памяти и подложить его под сегмент; после этого fault завершается и программа продолжается как ни в чем не бывало. То есть, с точки зрения программы произошло что-то типа
sub rsp, 16
(вычли из регистра стека число 16) иmov [rsp+4], 42
(хотим записать в какое-то место на стеке в стекфрейме число 42). Вторая инструкция сначала уйдет к fault-у в ядро, потом память подмапливается и дальше, если есть следующая записьmov [rsp+8], 99
, то тут уже никакого фолта нет. Благодаря такому механизму у нас есть достаточно гибкий механизм управления памятью.
-
free -- операция, парная к malloc. Дай мне кусочек памяти. я скажу, сколько байтов, ты мне дай по крайней мере столько же, можно больше, и верни адрес этой полученной памяти. я могу делать с ней что хочу, и пока я не скажу free, ты мне как менеджер ресурсов гарантируешь, что никто другой ничего ни читать, ни писать, ни использовать эту память не сможет.
Минусы: можно забыть сделать free, а можно сделать free дважды (использовали библиотечную функцию, освободили память, а потом эта функция освобождает эту же память). Неприятная, сложно отлаживаемая проблема.
Как организованы операции malloc и free? У нас есть массив под названием heap. Мы говорим: malloc 42 байта. Memory allocator бежит по той или иной структуре, описывающей свободное пространство (например, по free list-у), находит подходящее место дял нового объекта в 42 байта и возвращает указатель на начало этого места. Теперь мы говорим: free. Менеджер ресурсов убирает информацию о том, что это место занято, и это место становится свободным. И туда кто-то может что-то записать перед тем, как наш free сработает во второй раз...
В общем, это относительно эффективная схема, как и любой микроменеджмент памяти, то есть, точное говорение менеджеру памяти, что делать, оно достаточно неэффеткивное по многим аспекта , в частности, например, потому, что heap, из которого происходит выделение -- это чаще всего разделяемый ресурс, и при работе с хипом его нужно заблокировать от действий других потоков. Так что если говорить о голой производительности аллокатора памяти, то malloc -- не самая выдающаяся операция.
Следующая техника более популярна для управляемых (managed) рантаймов -- это явное выделение и неявное освобождение (в джаве мы не встречаем команду free по каким-то непонятным причинам, но все понимают, как это работает). Как работает освобождение памяти в управляемых системах? За некоторым ресурсом (за ресурсом памяти) стоит некоторый менеджер ресурсов, который пытается обеспечить видимость того, что, если что-то не нужно, то оно магическим образом перестает занимать память, и поверх этой памяти можно выделить какие-то новые объекты. То есть, в принципе, всем замечательная схема.
Мы просмотрели три варианта (явное выделение и освобождение -- malloc и free; явное выделение, неявное освобождение -- java и GC; неявное выделение и освобождение -- управление стеком, где мы просто инкрементируем регистр в процессоре, а факт, что после этого доступ к памяти привел к активированию какой-то сложной логики в менеджере виртуальной памяти, абсолютно скрыт от пользователя).
Мы знаем, что стек выделяется неявно. А когда он освобождается? При смерти процесса. А может ли физическая память освободиться раньше? См. свопинг.
Что мы увидели на этих примерах? У нас есть какой-то ресурс или их набор. У нас есть нек-рое количество потребителей этого ресурса, причем потребители разные (одним нужно много памяти, другим нужно достаточно мало, но сию секунду, и они не готовы ждать), при этом то, кому что нужно, не зависит от того, что у нас сейчас есть. Людям и программам нужна память в тот момент, когда она им нужна, а не когда она освободилась в ОС. В итоге нам нужен менеджер ресурсов, который может удовлетворять (в каком-то спектре потребностей) запросам широкого набора потребителей и при этом не исчерпывать ресурс до конца. Глядя на все эти рассуждения, мы приходим к концепциям VM, если подумаем о двух вещах: обо всех ресурсах, которые вообще программе нужны для ее существования, и о том, как эти ресурсы распределяются. Получим идею контекста исполнения, в котором происходит исполнение программы.
Что нужно программе или процессу, чтобы исполниться?
- Процессорное время
- Память
- Идентификатор процесса (самому процессу он не нужен)
Что такое ресурсы, легко понять по тому, что потребитель ресурсов может сделать. Ресурсы -- это различные функциональности для исполнения. Что может делать программа на юниксе?
- ввод/вывод (файловая система, сеть, ...) Понятно, что ресурс виртуальный (никаких файлов, сокетов не существует, это абстракции поверх места на диске: файл -- способ организации пространства на диске, сетевое соединение -- абстракция для того, чтобы осуществлять взаимодействие в коммутируемой сети, и т.д.)
Если мы осознаем весь контекст, который хотим предоставить программе (весь набор ресурсов, которым программа может пользоваться), то мы получаем мысль, что если у нас есть менеджер, который всеми этими ресурсами способен управлять, то он сможет полностью предоставить контекст исполнения для нек-рой программы. Такой менеджер ресурсов и называется виртуальной машиной.
В определенном смысле понятно, что VM -- это сама, в свою очередь, программа, поэтому может быть и стек виртуальных машин (например, ОС -- хостовая ОС под ней исполняется -- гипервизор в качестве процесса -- под гипервизором исполняется гостевая ОС -- процесс в гостевой ОС). У каждого элемента есть набор ресурсов, которыми он распоряжается, и нек-рый контекст, который он предоставляет своим подлежащим сущностям.
Во многом у VM, если говорить о ней изнутри (с точки зрения программы, которая исполняется под VM), то это не что иное, как модель контекста -- то, что описывает мир, в котором программа может жить. Можно подумать о java-машине. Как выглядит программа для языка Java, которая исполняется виртуальной машиной? Это .class-файл -- совокупность constant pool, метаданных, описания классов с сигнатурами и байткодом. Индивидуальный .class-файл описывает один отдельный класс, у которого есть какое-то количество метаинформации, связывающей его с другими классами и интерфейсами, и описание некоторой функциональности, которую данный класс реализует. Поэтому все, что нужно для исполнения программ на языке java -- это россыпь .class-файлов. И виртуальная машина должна предоставить все, что можно в этом .class-файле написать.
А что можно в нем написать? В целом, в байткоде написано: я могу делать вот такой набор операций, я могу вот так взаимодействовать с набором других классов
Аналогичным образом мы можем подумать о простой программе на языке си. Если мы напишем hello world, то для ее существования тоже нужен некоторый контекст: компилятор (или интерпретатор, никто не мешает написать интерпретатор для языка си). Например, #include <stdio.h>
-- это инструкция для нек-рой программы под названием препроцессор, которая выполняет сложную операцию: по имени файла в специально обсученных местах находит файл с таким именем и осуществляет его текстовую макроподстановку во вход для компилятора. printf("Hello world")
-- здесь контекст такой, что мы можем что-то выводить пользователю через какой-то канал (монитор, 3d-принтер, азбука Морзе -- здесь не очень понятно, но мы ожидаем некоторый набор side-эффектов). Поэтому даже относительно простые программы на самом деле влекут за собой некоторый весьма нетривиальный контекст.
Но тем не менее оказывается, что этот контекст почти всегда можно замкнуть. Можно понять, какой набор программ, понятий ожидает программа, и на основании этого набора понятий создать менеджер этого самого набора понятий.
Если говорить о компонентизации в JVM, то можно выделить следующие фрагменты:
-
Загрузчик классов (вход в java-машину, получение мета-информации о классе, верификация, понимание, что внутри класса написано)
-
Система исполнения байткода, состоящая обычно из интерпретатора, скомбинированного с динамическим профилировщиком, и система трансляции во время исполнения, то бишь JIT
-
Система управления памятью (что в народе называется garbage collector, что странно, ведь управление памятью -- это не только ее освобождение, но еще и выделение)
-
Система дефрагментации памяти и так далее
Это достаточно разлапистая система, в которой сама система сборки мусора играет хотя и важную роль, но далеко не единственную.
Как мы достигаем полностью предсказуемого контектса в JVM? Почему JVM-языки (тот же Kotlin, который до появления JS и Native был полностью JVM-языком), вход которого -- это некоторый текстовый файл на каком-то языке высокого уровня, а выходом является тот самый байткод, хорошо специфированный формат описания программы в некотором таком объектно-ориентированном мире? Из чего состоит этот объектно-ориентированный мир в JVM?
Понятно, что JVM не равно Java. Java -- это высокоуровневый язык, который можно компилировать при желании в тот же машинный код, а в JVM можно компилировать другие входные языки, главное, чтобы их семантика ложилась на семантику JVM.
Чтобы понять, что такое JVM, надо понять, какие в ней есть абстракции, каким словарем оперирует JVM.
- Объектная модель -- классы, интерфейсы, объекты и методы. Одна из таких популярных парадигм программирования -- это ООП -- попытка рассуждать обо всем как об объекте.
Вообще, большинство парадигм в программировании -- это Everything is a ... . Например, парадигма юникса -- Everything is a file. ФП -- everything is a function. ООП -- everything is an object.
Каждую сущность описывает некоторый самосодержащийся объект, созданный по некоторому шаблону (в данном случае по классу), имеющий ссылку на этот шаблон, параметризованный некоторым внутренним состоянием (полями) и предоставляющий некоторый набор операций (методов).
В рамках этой объектной модели (а Java придумывали как раз тогда, когда ООП было основным способом думать, ФП было сугубо академическим развлечением, поэтому в оригинальной Java функциональных конструкций особо не присутствовало, и все было объектом). Поэтому в формате байткода описывается достаточно точно некоторый набор объектно-ориентированных взаимоотношений (данного .class-файла с другими .class-файлами, вызова тех или иных методов и т.д.)
Стековая машина отличается от регистровой. Характеристика практически любого набора инструкций -- это то, что кодирует инструкция, в каком наборе понятий выражена семантика данной инструкции. Например, если посмотреть на intel x86 manual, то там можно увидеть, что инструкция add -- это сложение значения source регистра с аргументом и помещение назад в тот же регистр. Здесь параметром описания являются имена регистров. У регистровой машины есть набор регистров (предсказуемое количество, не очень большое, регистров), и над ним регистровая машина производит различные манипуляции.
Если посмотреть на чистые регистровые машины (например, про RISC-процессоры типа ARM, SPARC), то у них все операции можно производить исключительно над значениями регистра. Если что-то есть в памяти, то сначала значение надо загрузить в регистр, затем выполнить операцию над регистром, затем, если надо сохранить, то значение регистра сохраняется в памяти.
Операнды инструкций стековой машины динамически переименовываются. Грубо говоря, операндами всегда являются 2-3-4 верхних элемента стека, и причем какие конкретно элементы стека сейчас наверху, зависит исключительно от глубины стека.
Ключевой частью байткода является т.н. байткод, или набор инструкций JVM. Это набор для некоторой стековой машины: все инструкции манипулируют не непосредственными именами регистров, а, грубо говоря, двумя верхними значениями, которые сейчас лежат на вершине стека. Например, IADD берет два значения с верхушки, складывает их, и кладет на вершину стека.
Кроме этого, в JVM существуют стандартизированные типы данных (то есть, JVM не является чисто объектно-ориентированной системой, потому что изначально ориентировалась для embedded-приложений, еще в ранние годы Sun, поэтому там пришлось много вкладывать в эффективность). На самом деле, в Java не все -- объект. Есть несколько выделенных типов (мы их знаем как примитивные), которые представляют примитивными значениями, и ими можно манипулировать непосредственно. Тот же IADD не складывает объекты типа Integer, а складывает intы.
В .class-файле записано вот что. Я класс Foo. Мой суперкласс -- это класс Bar (причем ссылка на суперкласс символическая -- сугубо по имени, просто в constant pool написана строчка Foo и строчка Bar и ссылка в соответствующем элементе метаинформации на соответствующее место в constant pool). Я реализую интерфейсы I1, I2, I3, у меня есть методы m1, m2, m3, и про все поля и методы указаны их полные сигнатуры (и можно по этой метаинформации понять, как слинковаться с данным объектным файлом).
Линковка бывает ранней и поздней. В JVM система из достаточно мелких компонентов, и линковка очень поздняя -- установление отношений между различными классами происходит очень поздно с помощью виртуальной машины. Если мы запустили какую-то программу и в ней сослались на какой-то класс, то происходит процесс динамического разрешения этого самого класса -- по строковому имени находится этот класс и все его транзитивные зависимости (динамическая загрузка классов). После того, как все зависимости найдены и загружены в память VM, только тогда можно начинать исполнение, что частично обеспечивает долгий старт java: при каждом запуске VM происходит подъем достаточно сложного, блогатого контекста.
Поверх этих всех квантов у нас есть абстрактная исполняющая машина, которая умеет, взяв в кач-ве входа .class-файл и нек-рое описание того, что нужно запустить (entry pointer какой-нибудь), загружать требуемый файл и все его зависимости, после чего передавать исполнение на точку входа. И дальше программа исполняется на этой абстрактной стековой машине тем или иным образом до какого-то завершения. При этом в абстрактной машине описан полный lifecycle объектов, полный lifecycle классов, потоки нормального исполнения (controlflow), поток исключительного исполнения (обработка исключений тоже является частью стандарта JVM).
Что является одним из самых величайших достижений Java -- это полностью специфицированная система. Если почитать стандарт языка Си (особенно времен, когда создавалась Java), там до сих пор большая часть естественных операций описана с пометкой UB (например, знаковое переполнение в Си -- до сих пор UB, то есть, взрыв компьютера при умножении больших чисел не запрещен стандартом Си). В JVM все поведение специфицировано (причем все аспекты, в том числе исключительный -- кончилась память, переполнился стек, поделили на 0, разыменовали нулевой указатель, в общем, все классические непонятные ситуации мира Си четко специфицированы в Java и четко описано, что будет при том или ином раскладе).
Также в Java специфицированы стандартные механизмы конкурентного исполнения (может быть, не самые лучшие, но на момент придумывания JVM примитивов синхронизации лучше не было), поэтому в язык жестко гвозд]ями прибита концепция поков, мьютексов, возможности синхронизации на любом объекте и так далее.
Кроме этого, в Java существует экосистема, состоящая не только из базовых операций, но включающая и стандартную библиотеку -- набор предоставленных с самого начала операций, которые есть в любой JVM: ввод-вывод, графический интерфейс, JNI, описывающий, как из JVM передавать управление на код, написанный не на Java (обычно на C/C++).
Также важной частью экосистемы Java являются компиляторы из высокоуровневых языков (в частности, javac компилирует с Java, еще есть Jython, Groovy, ...)
Кроме того, существуют инструменты отладки (инструменты, позволяющие смотреть на VM снаружи -- останавливать исполнение в тот момент, когда необходимо, смотреть значения тех или иных переменных и т.д.)
Также есть инструменты мониторинга и профирования (JVM TI -- tooling interface, позволяющий понимать, что на текущий момент JVM исполняет, и на основании этого интерфейса с помощью тех же профиляторов выяснять, какие в программе bottleneck-и, и т.д.)
Все вместе это создает некоторую экосистему (экосистема -- это нечто большее, чем виртуальная машина, так как она описывает не только взаимоотношения компьютеров и компььютерных программ, но и взаимоотношение людей, то есть, это тот мир, в котором происходит разработка софта). Часто VM и гипервизоры связаны с экосистемами, они являются инструментами, которые позволяют эти экосистемы образовывать. Если у нас есть виртуальная машина, то она позволяет обеспечить воспроизводимость контекста, а в результате на нем можно строить экосистему. Для индустриального программирования это очень важный аспект. Грубо говоря, если мы просто пишем программы, то они скорее всего окажутся на свалке истории, а если мы создаем большие экосистемы, то как-то так выясняется, что они очень долго живут сами по себе, причем часто многие программы сейчас пережили своих автором, а есть и такие программы, которые сейчас уже никто не поймет. Экосистема -- это для людей возможность такого вот sustainable функционирования, потому что мы сейчас очень сильно зависим от программы.
Теперь попытаемся понять, как реализуются разные кусочки этих управляемых контекстов на примере работающей системы (на примере Java).
Разработка (и понимание) любой системы должно начинаться с понимания требований к этой системе (то есть, что система вообще делает). Почитав часть JVMSpec, то мы не увидим там особого описания желаемых алгоритмов GC или чего-то подобного. Но там есть определенный контракт, накладывающий ограничения на то, что любой memory manager для Java обязан делать.
Память всегда выделяется под какой-то объект. У любой памяти есть некоторая типизационная информация: у malloc такой интерфейс, что он не подразумевает, что ту память, которую мы выделили, у нее есть какая-то объектно-ориентированная метаинформация (какой-то тип или что-то еще), мы можем выделить кусок памяти, и это будет просто кусок памяти.
В Java память всегда выделена под некоторый типизированный объект. Это значит, что где-то должна храниться метаинформация (объект класса, который описыыает данный объект). Не может быть ad-hoc объекта. В JS любой объект -- словарь с динамическим содержимым, и привязка к какой-то метаинформации, информации о типах, достаточно условно. Даже объектно-ориентированный, managed рантайм не обязан быть типизированным.
В Java у памяти всегда есть некоторое значение по умолчанию. В том же машинном коде, если мы посмотрим на содержимое памяти, то, что там записано, мы понятия не имеем. В случае JVM память всегда инициализируется в некоторое значение по умолчанию (null для объектных ссылок, false для boolean, нулевой символ для char и т.д.) Для эффективности реализации сделано так, чтобы реальное физ представление в памяти дефолтного значения всегда было одно и то же (нулевые биты), хотя понятно, что в реальной интерпретации для float это будет 0.0, и так далее. Представление информации может быть разным для разных типов, но побитовое представление всегда нулевое.
После начальной инициализации на памяти всегда должен вызываться конструктор объекта (память нельзя просто выделить, не проинициализировав способом, который описал программист). Память может быть освобождена при недоступности объекта.
Абсолютно корректная реализация JVM -- такая, которая вообще не освобождает память. Если у компьютера бесконечная память, то можно написать JVm вообще без GC, то есть, будет простой memory manager, который при необходимости выделить выделяет, но ничего не удаляет.
Ошибкой является отождествление времени жизни объекта с жизненным циклом программы. Объект может удалиться непонятно когда. В языке Java есть метод Object.finalize(), который можно перекрыть и который должен вызываться при достижении объектом конца своей жизни. Кое-кто помещает туда освобождение независимых ресурсов (например, закрытие файловых дескрипторов). Это логическая ошибка, ведущая к тонким и неприятным моментам.
Частью контракта является наличие возможности узнать о том, что объект больше недоступен, при помощи механизма слабой ссылки. По объекту можно создать такую сущность, как слабая ссылка (еще бывают мягкие и фантомные, все это механизмы узнавания о том, что тот или иной объект на текущий момент системой управления памятью признан несуществующим).
Запрещен алиасинг -- нельзя одну и ту же память интерпретировать несколькими способами (вот в C++ существует union).
Кроме этого, у объектов есть такое понятие, как Object identity -- операция сравнения. Операция == -- сравнение объектных ссылок. Это сложная операция, подразумевающая, что у объекта есть собственное существование, идентичность, то место, где объект хранится. Это нетривиальное свойство. На целочисленных значениях == означает совсем другое: что число, хранящееся внутри объекта, равно другому числу. То есть, в Java есть равенство по значению, а есть равенство по identity -- по тому месту, где хранится объект, и это тоже контракт memory manager'a.
Еще один контракт -- это наличие операции hashCode. Возможность реализации конкретного hashCode -- тоже нетривиальная операция. Зачем он нужен? В системах типа C++ есть неявное стабильное свойство типа address -- место в памяти. В Java такого места, которое всегда одинаково, на самом деле нет, стандарт не подразумевает наличие вот такого свойства, так как объекты могут быть перемещены GC. Поэтому пришлось придумать то, как создать и сохранить в объекте hashCode. У каждого объекта есть identityHashCode, это некоторой стабильный идентификатор, который при существовании объекта не меняется даже при перемещении между поколениями -- уникальный номер объекта.
Следующей свойство языка Java, выгодно отличающее его от плюсов -- при недостатке памяти бросается определенная ошибка OOME, и ее можно перехватить и что-то сделать. (В плюсах был бы краш и грустное сообщение). OOME может включить GC (?)
Следующая важная для коллектора часть -- наличие метода finalize(). Если обхект освободился, то, по контракту, в каком-то непонятном контексте должен вызваться finalize(). На самом деле, понятно в каком контексте -- в специально обученном потоке под названием ... поток должен вызваться finalize()
Жизнь объекта:
-
Начинается с того, что это просто участок в некотором heap-e -- месте памяти, которое тем или иным образом выделено менеджером виртуальной машины для того, чтобы выделять в нем память. Для самого heap-а память обычно выделяется прямым запросом у ОС достаточно большого региона. Ключики типа -Xmx регулируют, сколько именно памяти нужно попросить у ОС.
-
После того, как нам поступил запрос на выделение объекта, аллокатор (одна из частей memory mamager-a) выделяет требуемый кусок (обычно немножко выровненный) и заполняет его нулями -- это уже вход для следующей фазы.
-
Записать в этот объект некоторый заголовок (типизационная информация -- ссылка на объект класса, именно она позволяет делать проверки типа instanceof -- выяснять у объекта, какого он собственно типа).
-
Инициализация объекта вызовом конструктора
-
Полноценная жизнь объекта, он участвует во всяких взаимодействиях
-
Объект когда-либо может стать недостижимым. (обсудим, что это, чуть позже)
-
Объект переходит в финализированное состояние
-
Объект во время финализации может перейти в состояние "живое"
-
Но вообще после этого объект находится в фантомном состоянии (точка реального невозврата) и существует исключительно как ключ для информирования фантомных референсов о том, что подлежащий объект уже полностью умер.
-
Когда все эти танцы завершились, он снова становится просто участком в heap-е, могущий стать новым объектом следующих аллокаций.
Анализ достижимости подразумевает достижимость откуда-то. В терминологии сборщиков мусора и систем управления памятью, элементы, объекты, достижимые из которых считаются живыми, называются root set-ом.
Что является элементами root set-а в JVM?
-- Х: стек и статическая память. И: что такое статическая память, прости Господи? Х: Там, где хранятся, например, все статические строки. И: как Вы думаете, если они статические, то какой смысл их анализировать в анализе достижимости? Х: по крайней мере, это то, что нам говорил АМ, но почему им не вести себя просто как остальным объектам? И: в любом анализе достижимости его имеет смысл проводить для чего-то (например, чтобы собрать мусор). Если у нас объекты иммутабельны и внутри нет объектных ссылок либо есть на такие же иммутабельные (например, String), то на них анализ достижимости не очень обязательно производить (ведь тут он ничего нам не даст). Х: хорошо, память статических полей, к примеру, это же не стек. И: статические поля не являются элементами root set-a, несмотря на популярную мифологию.
Далее зал молчит.
И: какие объекты нежелательны для неожиданного исчезания? Объекты стека -- те объекты, с которыми на текущий момент работает программа. Какие еще есть объекты, которым было бы не очень хорошо исчезать? Весь анализ достижимости базируется на том простом факте, что, если ни у кого нет ссылки на что-то или те, у кого она есть, точно такие же мертвые, как и он сам, то этот объект считается недостижимым. Вот и весь анализ достижимости: мы транзитивно проходим по всем объектам, про которые мы прям мамой клянемся, что они нужны, а остальные объекты нам не нужны (она не смогут нам понадобиться в будущем, ибо чтобы они могли нам понадобиться, нам нужно иметь на них ссылку: ссылка на объект является ключом, который, в частности, обеспечивает корректный корректный мененжмент рантайма).
Там все не очень сложно, просто еще служебные структуры VM являются дополнительным источником элементов root set-a, потому что бывает, что чего-то не находится на стеке, но тем не менее это то, с чем сейчас работает VM, и нам не хотелось бы, чтобы это исчезло. Например, саллоцировали мы объект, записали туда типовую информацию, а потом кто-то в другом потоке запустил сборку мусора. Понятно, что этого объекта еще фактически нету, он только-только саллоцирован, но VM бы не очень хотела, чтобы он вдруг неожиданно исчез из-под рук.
Есть еще JNI-ные ссылки (внешние интерфейсы из Java в мир Си, эти интерфейсы, в частности, позволяют создавать держащую ссылку на Java-объект), это тоже гарантированные элементы root set-a.
Root set -- это такие точки входа в объектный граф, а дальше мы считаем транзитивное замыкание отношения "ссылается": обходим объекты, на которые ссылается объект, обходим то, на что ссылаются ссылаемые объекты, и так в результате делим весь класс мыслимых объектов на две группы: на группу достижимых объектов и на группу недостижимых. Дополнение до группы достижимых объектов можно называть мусором, в том смысле, что по построению множества, оно обладает свойством, что до него никогда нельзя добраться.
В целом, есть два класса алгоритмов разделения графа на две группы, они в целом по мере оптимизации постепенно сходятся, но изначально один -- это классический обход графа, или трассировка, а второй -- подсчет ссылок. Но и тот, и другой алгоритм можно оптимизировать, и в какой-то момент их можно дооптимизировать до такого состояния, что они становятся почти неотличимыми (статья Роберта Бекона из IBM).
Слабые ссылки. В Java по мере эволюции появилось достаточно много классов ссылок (изначально существовала только одна -- обычная ссылка на объект). Существует еще три класса ссылок: java.lang.ref.SoftReference, java.lang.ref.WeakReference, java.lang.ref.PhantomReference. Отличие только в различной политике обработки коллектором.
SoftReference просит не коллектить объект, она пытается просить хоть и не держать объект, но все-таки немножко приоретизировать факт его сборки мусором. WeakReference никаким образом не влияет на то, будет объект заколлекчен или нет.
В каких структурах лучше SoftReference? В кешах. Когда мы держим какую-то структуру, которую долго перестраивать, но мы в приципе готовы ее перестроить, но лучше бы не перестраивать без лишней необходимости. WeakReference полезен, например, для ключей в HashMap. Как только ключ перестает быть нужен в системе, нам он тоже уже не нужен, так как по нему ничего не спросишь.
Мы поговорили про семантические требования (требования, накладываемые самим языком). Теперь поговорим о требованиях, связанных с внешним миром: как все-таки функционирует memory manager; что важно учитывать, когда создаем систему управления памятью, кроме требований самой системы понятий, которые мы реализуем.
Это требования по ожидаемому объему данных (достаточно часто JVM пишутся для того, чтобы рабтать в сценарии с достаточно большими графами данных), при этом часто есть требование к производительности (к тому, с какой скоростью мусор может собираться).
Чему должна быть равна скорость сборки мусора? В стабильной системе она просто равна скорости аллокации. В системе, в которой нет memory leak'ов и которая достаточно долго работает, скорость сборки мусора -- это ровно скорость аллокации мусора: сколько мы новых объектов создаем, ровно столько их должно собираться. Поэтому на самом деле производительность сборщиков мусора -- это скорость тог, с какой скоростью можно выделять новый объект. Если мы не выделяем очень много объектов, то нам все рано на производительность GC.
Следующее требование -- ко времени жизни приложения. Есть программы на Java, которые выполняются месяцами или даже годами.
Гарантия на время отклика. Хотелось бы, чтобы при нажатии на кнопочку все не подвисало навека, а откликалось быстро. В системах, которые нуждаются в глобальном анализе heap-a (в глобальном обходе сложных структур), и в процессе этого обхода, понятно, чтобы эта структура не менялась, иначе часть ее будет просто невалидной. Так что если есть большие объемы данных и нужен быстрый отклик, то нужно думать про методы для конкурентного обхода, не останавливающего исполнение программы.
В целом, неплохо бы понимать сценарии нагрузки: в одном случае это какая-то периодическая нагрузка, в другом -- пиковая нагрузка. Типичный сценарий использования JVM -- для всяких сотовых провайдеров, биллингов -- понятно, что в разное время суток нагрузка разная, поэтому VM должна уметь адаптировать к пиковой нагрузке, а во времена не пиковой нагрузки делать что-то полезное, чтобы подготовиться к тому, чтобы просто пройти пик.
Ожидаемый тип аппаратного обеспечения: будет это сильно многопроцессорный сервер из 40 ядер и неоднородной архитектуры памяти, или это небольшое встроенное устройство, у которого 1-2 ядра и не очень много памяти. Тоже приходится по-разному создавать коллекторы. Именно необходимость интегрировать в себя достаточно противоречивые требования и привело к тому, что у многих виртуальных машин (не только у HotSpot) есть несколько разных memory manager'ов, которые работают по одному и тому же интерфейсу, но используют разные алгоритмы.