Лекция 15

Тема: Компиляция и исполнение Java-приложений. Виртуальная машина Java. JRE и JVM. Сборка мусора. Cильные, слабые, мягкие и фантомные ссылки. WeakReference, SoftReference, PhantomReference, WeakHashMap и ReferenceQueue.

Языки программирования

Мы уже знаем, что программа – это просто набор инструкций, который говорит компьютеру что делать. Очевидно, что мы должны предоставить эти инструкции на языке, который компьютер может понять.

Было бы здорово, если бы компьютер понимал человеческую речь, и мы могли бы просто сказать компьютеру, что нужно сделать (как в научной фантастике). Но это пока остается фантастикой и, к тому же, «натуральные» языки тяжело подходят для описания сложных алгоритмов, так как любой «натуральный» язык наполнен неточностями и двусмысленностью.

Для того, чтобы общаться с компьютерами, учеными были придуманы специальные нотации, чтобы можно было точно и недвусмысленно сформулировать набор инструкций для компьютера. Эти нотации называются языками программирования.

Каждая запись в языке программирования имеет точную форму (синтаксис) и точное значение (семантику).

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

Языки низкого уровня

При использовании языков программирования возникает противоречие. Центральный процессор может понимать только машинный код – набор команд с аргументами. Машинный код тяжело воспринимается человеком и на нем тяжело написать хоть сколько-нибудь серьезную программу. Такого рода языки называются языками низкого уровня (low-level language).

Например, вывод надписи «Hello, world!» в поток вывода в машинном коде выглядит следующим образом

b8 21 0a 00 00	#moving "!\n" into eax
a3 0c 10 00 06	#moving eax into first memory location
b8 6f 72 6c 64	#moving "orld" into eax
a3 08 10 00 06	#moving eax into next memory location
b8 6f 2c 20 57	#moving "o, W" into eax
a3 04 10 00 06	#moving eax into next memory location
b8 48 65 6c 6c	#moving "Hell" into eax
a3 00 10 00 06	#moving eax into next memory location

b9 00 10 00 06	#moving pointer to start of memory location into ecx
ba 10 00 00 00	#moving string size into edx
bb 01 00 00 00	#moving "stdout" number to ebx
b8 04 00 00 00	#moving "print out" syscall number to eax
cd 80		    #calling the linux kernel to execute print to stdout
            
b8 01 00 00 00	#moving "sys_exit" call number to eax
cd 80	        #executing it via linux sys_call

Языки высокого уровня

Языки высокого уровня (high-level language), например: C, Python, Java, C++ и другие, призваны облегчить написание программы за счет того, что язык хорошо понятен для человека и оперирует привычными ему понятиями, а не регистрами, адресами памяти и так далее. К примеру, на языке Python та же программа (вывод сообщения «Hello, world!») выглядит следующим образом:

main.py
print("Hello, world!")

Но как преобразовать код на языке высокого уровня в набор инструкций на машинном языке? Ведь центральный процессор не понимает Python, C или Java.

Существует множество подходов для реализации этого преобразования, рассмотрим два из них.

Первый подход – компилируемые языки программирования. Компилятор – это сложная компьютерная программа, которая принимает на вход код высокого уровня и преобразует его в эквивалентную программу на машинном языке. Компиляторы пишутся для конкретного языка, для конкретного типа процессора и для конкретной операционной системы.

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

Второй подход – интерпретируемые языки программирования. Интерпретатор – специальная программа, которая симулирует компьютер, который понимает язык высокого уровня. Вместо того чтобы преобразовывать (транслировать) исходный код в эквивалентный машинный код, интерпретатор анализирует и выполняет исходный код инструкция за инструкцией (то есть, напрямую с процессором работает не ваш код, а интерпретатор). Интерпретатор еще называют виртуальной машиной.

Существует множество плюсов и минусов того или иного подхода, но сразу стоит отметить одно важное отличие – портируемость (portability). Компилируемые языки программирования имеют низкую портируемость кода за счет того, что необходимо адаптировать код для конкретного процессора и конкретной операционной системы. К тому же, для некоторых конфигурацией может не быть готового компилятора или он может быть неэффективным.

В случае использования интерпретируемых языков, портируемость выше. Разработчики языка, а также сообщество предоставляют интерпретаторы для всевозможных устройств – от микроконтроллеров до полноценных ПК. Разработчикам не надо задумываться о том, где будет запускаться ваш код и нет нужды предоставлять разные версии приложения для разных конфигураций.

Для разработчика программы на интерпретируемом языке программирования, интерпретатор для определенной конфигурации ПК ничем не отличается от интерпретатора, например, для микрокомпьютера Raspberry Pi. Если для того или иного устройства реализован интерпретатор, вы можете запустить на нем ваш код без проблем.

Схема работы Java

Простой и понятный ролик о том, как работает Java - ссылка

Основная особенность Java, которая позволяет решать проблемы безопасности и переносимости программ, состоит в том, что компилятор Java выдает не исполняемый код, а так называемый байт-код (byte-code) - оптимизированный набор инструкций, предназначенный для выполнения в исполняющей среде Java, называемой виртуальной машиной Java (Java Virtual Machine).

Трансляция программы Java в байт-код значительно упрощает ее выполнение в разнотипных средах, поскольку на каждой платформе необходимо реализовать только виртуальную машину (JVM). Если в отдельной системе можно запустить JRE, то в ней можно выполнять любую программу на Java. Виртуальные машины Java, вне зависимости от платформы, интерпретируют один и тот же байт-код одинаково. Таким образом, разработчикам нет нужды предоставлять различные версии программы для различных платформ.

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

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

Язык Java был задуман как интерпретируемый, но ничто не препятствует ему оперативно выполнять компиляцию байт-кода в машиннозависимый код для повышения производительности. Поэтому вскоре после выпуска Java появилась технология HotSpot, которая предоставляет динамический компилятор (или так называемый JIT-компилятор, JIT - Just In Time) байт-кода. В этом случае избранные фрагменты байт-кода компилируются в исполняемый код по частями, в реальном времени и по требованию.

Одновременная компиляция всей программы Java в исполняемый код нецелесообразна, поскольку Java производит различные проверки, которые могут быть сделаны только во время выполнения. Вместо этого динамический компилятор компилирует код во время выполнения по мере надобности. Более того, компилируются не все фрагменты байт-кода, а только те, которым компиляция принесет выгоду, а остальной код просто интерпретируется. Тем не менее принцип динамической компиляции обеспечивает значительное повышение производительности.

Сборка мусора и методы завершения

Как было сказано ранее, при использовании оператора new свободная память для создаваемых объектов динамически выделяется из пула свободной памяти. Разумеется, оперативная память не бесконечна, и поэтому свободная память рано или поздно исчерпывается. Это может привести к невозможности выполнения оператора new из-за нехватки памяти для создания требуемого объекта.

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

Во многих языках программирования освобождение памяти происходит вручную (например, в C++ для этого используется оператор delete). В Java применяется другой, более надежный механизм, который называется сборка мусора.

Система сборки мусора Java освобождает память от лишних объектов автоматически, без вмешательства со стороны программиста. Эта система работает следующим образом: если ссылки на объект отсутствуют (на объект не ссылается ни одна переменная), то такой объект считается больше ненужным, и занимаемая им память в итоге возвращается в пул свободной памяти и, в дальнейшем, может быть распределена для других объектов. Сборка мусора осуществляется время от времени по ходу выполнения программы (обычно, когда количество свободной памяти подходит к концу).

Garbage collection

Сборщик мусора выполняет всего две задачи:

  • поиск "мусора";

  • очистка памяти от "мусора".

Для обнаружения мусора существует два подхода:

  • Reference counting - подсчет ссылок;

  • Tracing - трассировка.

Ясно, теперь нужно ответить на два вопроса: "Как Garbage Collector обнаруживает мусор?" и "Как очищает память?"

Reference counting

Суть подхода состоит в том, что каждый объект имеет счетчик. Счетчик хранит информацию о том, сколько ссылок указывает на объект. Когда ссылка уничтожается, счетчик уменьшается. Если значение счетчика равно нулю, то объект можно считать "мусором" и память можно очищать.

Главным минусом такого подхода является сложность обеспечения точности счетчика. Также при таком подходе сложно выявлять циклические зависимости (когда два объекта указывают друг на друга, но ни один живой объект на них не ссылается). Это приводит к утечкам памяти.

В общем, Reference counting редко используется из за недостатков. Во всяком случае HotSpot VM его не использует.

Tracing

В методе трассировка главная идея состоит в мысли: "Живые объект - те до которых мы можем добраться с корневых точек (GC Root), все остальные - мусор. Все что доступно с живого объекта - также живое".

Если мы представим все объекты и ссылки между ними как дерево, то нам нужно пройти с корневых узлов по всем узлам. При этом узлы, до которых мы сможем добраться - не мусор, все остальные - мусор.

Данный подход обеспечивает выявление циклических ссылок и используется в виртуальной машине HotSpot VM. Теперь необходимо понять, что из себя представляет корневая точка (GC Root)? Существуют следующие типы корневых точек:

  • основной Java-поток;

  • локальные переменные в основном методе;

  • статические переменные основного класса.

Таким образом, простое java-приложение будет иметь следующие корневые точки:

  • параметры метода main() и локальные переменные внутри метода main();

  • поток, который выполняет метод main();

  • статические переменные основного класса, внутри которого находится метод main().

Очистка памяти

Имеется несколько подходов к очистке памяти, которые в совокупности определяют принцип функционирования GC. JVM HotSpot использует алгоритм сборки мусора типа "Generational Garbage Collection", который позволяет применять разные модули для разных этапов сборки мусора. Всего в HotSpot реализовано четыре сборщика мусора:

  • Serial GC;

  • Parallel GC;

  • CMS GC;

  • G1 GC.

Serial GC относится к одним из первых сборщиков мусора в HotSpot VM. Во время работы этого сборщика приложение приостанавливается и возобновляет работу только после прекращения сборки мусора. В Serial Garbage Collection область памяти делится на две части («young generation» и «old generation»), для которых выполняются два типа сборки мусора:

  • minor GC - частый и быстрый с областью памяти "young generation";

  • mark-sweep-compact - редкий и более длительный с областью памяти "old generation".

Область памяти «young generation» разделена на две части, одна из которых Survior также разделена на 2 части (From, To).

Алгоритм работы minor GC

Алгоритм работы minor GC очень похож на метод «Copying collectors». Отличие связано с дополнительным использованием области памяти «Eden». Очистка мусора выполняется в несколько шагов:

  • приложение приостанавливается на начало сборки мусора;

  • «живые» объекты из Eden перемещаются в область памяти «To»;

  • «живые» объекты из «From» перемещаются в «To» или в «old generation», если они достаточно «старые»;

  • Eden и «From» очищаются от мусора;

  • «To» и «From» меняются местами;

  • приложение возобновляет работу.

В результате сборки мусора картинка области памяти изменится и будет выглядеть следующим образом:

Некоторые объекты, пережившие несколько сборок мусора в области From, переносятся в «old generation». Следует, также отметить, что и «большие живые» объекты могут также сразу же переместиться из области Eden в «old generation» (на картинке не показаны).

Алгоритм работы mark-sweep-compact

Алгоритм «mark-sweep-compact» связан с очисткой и уплотнением области памяти «old generation».

Принцип работы «mark-sweep-compact» похож на описанный выше «Mark-and-sweep», но добавляется процедура «уплотнения», позволяющая более эффективно использовать память. В процедуре живые объекты перемещаются в начало. Таким образом, мусор остается в конце памяти.

При работе с областью памяти используется механизм «bump-the-pointer», определяющий указатель на начало свободной памяти, в которой размещается создаваемый объект, после чего указатель смещается. В многопоточном приложении используется механизм TLAB (Thread-Local Allocation Buffers), который для каждого потока выделяет определенную область памяти.

Серия статей про сборщик мусора в HotSpot:

Метод finalize()

В Java предусмотрена возможность определить метод, который называется финализатор, который будет вызван непосредственно перед окончательным удалением объекта из памяти. Этот метод называется finalize(). Он позволяет убедиться, что объект можно безболезненно удалить (например, его можно использовать для закрытия файла, ранее открытого удаляемым объектом).

Для того, чтобы добавить в класс финализатор, достаточно определить метод finalize(). Исполняющая система Java вызовет этот метод перед фактическим удалением объекта. В теле метода finalize() следует предусмотреть действия, которые должны быть выполнены непосредственно перед удалением объекта.

protected void finalize() {
    // …
}

Важно понимать, что метод finalize() вызывается тогда, когда система сборки мусора принимает решение об освобождение памяти, занимаемой данным объектом. Но если объект в это время оказывается вне области видимости, то метод finalize() не будет вызван. Поэтому невозможно предсказать, когда именно и при каких условиях метод finalize() будет выполнен. Например, если программа успеет завершиться еще до того, как будет запущена процедура сборки мусора, то вызова метода finalize() не произойдет. Поэтому, нормальная работа программы не должна зависеть от того, вызван или не вызван метод finalize().

Компилятор Java генерирует не исполняемый машинный код, а так называемый байт-код – набор инструкций, предназначенный для выполнения в исполняющей среде Java, которая называется виртуальной машиной Java (Java Virtual Machine – JVM).

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

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

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

Сборщик мусора – специальный процесс, который запускается периодически и освобождает память, использованную объектами, которые больше не нужны. Таким образом, программисту не нужно следить за распределением памяти.

Last updated