Перформансные задачи от Контура уже были, настала и наша очередь: представляем хардкорные задачи с Java-конференции JBreak 2018, aka «ад от Excelsior». Задачи даны в оригинальных формулировках, в каждой задаче может быть несколько правильных ответов, и к каждой задаче дано решение под спойлером. Ваш коллега начитался Java Language Specification и написал следующее: А разгребать вам: какие результаты исполнения возможны? Правильный ответ: A, C, D. Область видимости переменной obj — весь метод, а область жизни заканчивается после выхода из конструктора WeakReference (на самом деле, даже чуть раньше — во внутренностях конструктора). И именно область жизни влияет на то, может ли GC уничтожить этот объект. Однако иногда VM может продлевать область жизни переменных, если ей это удобно. Например, интерпретатор HotSpot сообщает GC, что переменные живы, пока они видимы (именно это можно наблюдать в отладчике). То есть вариант D легко достигается при запуске примера без каких-либо дополнительных опций на HotSpot VM (или с явным -Xint). Результат C достигается на многих компиляторах (например, HotSpot C1/C2, Excelsior JET JIT & AOT, ...). Компиляторы достаточно умные, чтобы вычислить, что переменная obj не используется и уже к первому вызову get() ничто не мешает GC уничтожить объект. Однако чаще всего GC придет только при явном вызове System.gc(), это поведение проявляется на HotSpot VM с -Xcomp или на Excelsior JET в любом режиме. Вариант A теоретически достижим, если GC придет, например, в конце исполнения конструктора WeakReference. Задачка основана на баге в коде JDK 8, где аргумент метода неаккуратно сохранялся в WeakReference и умирал во время исполнения метода. Про это есть отдельный развернутый пост в нашем техническом блоге. Злой хакер удалил исходный Java-файл и перемешал куски вашего класс-файла: Переставьте их таким образом, чтобы получился верифицируемый класс-файл. Правильный ответ: D, C, A, B. Эта задача скорее на смекалку, но все же учит чему-то новому. Широко известно, что класс-файл начинается с четырехбайтового заголовка 0xCAFEBABE, значит D точно идет первым. Здравый смысл подсказывает, что короткий кусок B идет последним — это хвост. Далее можно было вспомнить, что в класс-файле имеется ConstantPool, в котором имеются строковые константы состоящие из двухбайтовой длины и собственно строки, закодированной в UTF-8. Единственным куском, похожим на UTF-8 является кусок C — это UTF-8 представление строки java/lang/Object (ссылка на супер-класс нашего класса). Значит перед ней должны быть байты 0x0010 (строка имеет длину 16), и единственный подходящий вариант — D, то есть C — вторая. Альтернативно можно было заметить, что вся последняя строка B состоит из нулей, значит и предпоследняя должна оканчиваться на нули, то есть это A! Прослушав очередной доклад про Graal, вдохновенно посмотрев на JVM Compiler Interface, вы решили написать свой компилятор для Java! И решили начать с генерации x86_64 кода для метода: Какой сгенерированный код будет корректным для такого метода? Легенда: используется Intel-синтаксис, соглашение о вызовах таково, что на регистре rcx лежит аргумент, на rax — результат. Правильный ответ: A, B. Все чаще на Java-конференциях можно увидеть ассемблерные листинги, но на случай, если вы еще не знакомы с системой команд Intel x86, ниже представлен эквивалентный C код: На самом деле все эти алгоритмы инвертирования корректно работают, пока входной аргумент принимает привычные логические значения 0 и 1. Далее начинается интересное. С точки зрения верификатора все короткие целочисленные типы (boolean, byte, char, short) эквиваленты типу int. Более того, boolean-специфичных байт-кодных инструкций и вовсе не существует. Например, байт-кодные инструкции исследуемого метода таковы: Таким образом, метод, принимающий boolean, должен быть готов работать с любым int-ом, причем любое ненулевое значение трактуется как true. В этом случае «оптимизированные» варианты C и D начинают вести себя некорректно C(2) = -1 и D(2) = 3, а более прямолинейные A и B продолжают работать A(2) = B(2) = 0. Чтобы проиллюстрировать эти тонкости придется манипулировать байт-кодом. Пример доступен на GitHub: в метод invert передаются числа 0, 1, 2, 3, -1 и выводится результат, сопровождаемый вызовами println(boolean) и println(int). Любопытный факт: в JDK 8 компилятор HotSpot C2 генерировал вариант D, а в JDK 9 шаблон генерации поменялся на более корректный. В JDK 8 хорошо виден шаблон D и выдаваемый некорректный результат: В JDK 9 улучшили нормализацию boolean значений: добавилось приведение входного аргумента к диапазону {0, 1} (инструкции test и setne) и результат стал корректным: Неожиданно вы поняли, что вам очень интересно, что может вывести вызов такого метода: Правильный ответ: A, D. Варианты B и C невозможны, так как Object.getClass() всегда возвращает ненулевой класс, а экземпляров интерфейсного типа не бывает. Вариант A легко реализуется: guessWhat(new ArrayList<Object>()). Однако и вариант D достижим: Integer не реализует интерфейс Iterable, но тем не менее его экземпляр может прийти в этот метод. Разгадка в том, что строгость типовой системы языка Java снова пала под слабостью типовой системы верификатора JVM: любой ссылочный тип совместим по присваиванию с любым интерфейсом. То есть почти всюду, где ожидают интерфейсный тип (включая параметры, возвращаемое значение, поля), можно передать любое ссылочное значение (то есть произвольные классы и массивы). Этот эффект можно продемонстрировать либо с помощью манипуляций с байт-кодом, либо с помощью частичной перекомпиляции класс-файлов. В очередной раз поверив в непогрешимость javac, вы решили поэкспериментировать: Что случится при попытке скомпилировать и запустить класс Test? Правильный ответ: B. Многие верят, что IllegalAccessError — это удел тех, кто перемудрил с частичной перекомпиляцией или обфускацией. Так было и у нас, когда ProGuard во время обфускации дал двум разным методам (один private, другой default) одинаковые имена, и результирующее приложение стало выбрасывать IllegalAccessError. Однако оказалось, что если два таких метода будут иметь одинаковые имена сразу в исходном коде, то javac скомпилирует их без каких-либо предупреждений, а во время исполнения также выбросится IllegalAccessError. Такое поведение JVM объясняется тем, как происходит поиск целевого метода для инструкции invokeinterface. Согласно спецификации, в первую очередь просматриваются instance-методы класса и всех супер-классов, и только затем ищется подходящий default метод среди супер-интерфейсов, при этом приватность найденного метода проверяется только после завершения всего процесса. Таким образом, поиск заканчивается на private методе getBoolean из супер-класса C, который встал на пути поиска default метода getBoolean из супер-интерфейса I. После этого уже логично выбрасывается IllegalAccessError. Интересно, что в Java 11 это планируется изменить, и в процессе поиска пропускать приватные методы. Внезапно вы обнаруживаете себя отлаживающим нативный код скомпилированного Java приложения. У вас нет исходников, но вы уже нашли проблемный метод, вот он: Вы заподозрили, что исполнение этого метода может спровоцировать выброс Java исключений различных типов. Осталось понять, какие инструкции могут быть виноваты (укажите их номера)? Правильный ответ: Компилятор может генерировать проверки исключительных ситуаций разными способами. Например, перед обращением к полю объекта можно сгенерировать явную проверку объекта на отличие от null с выбросом исключения в случае провала. Однако, такие явные проверки негативно влияют на производительность и размер кода. Поэтому компилятор старается обходиться неявными проверками: генерируется только код разыменования указателя, который в случае нулевого указателя приведет к возникновению аппаратного исключения, которое JVM перехватит, распознает и перевыбросит как соответствующее Java-исключение. В данной задаче как раз и нужно было отыскать инструкции, которые могут спровоцировать такие неявные исключения. StackOverflowError возникает при попытке записать/прочитать очередной стековый слот за пределами разрешенного диапазона. Это может происходить в инструкции push rax. На выброс неявного ArithmeticException есть также единственный кандидат: инструкция целочисленного деления idiv dword [rdx+0FH]. Если разыменованное значение равно нулю, случится аппаратное деление на ноль с последующим выбросом ArithmeticException. Неявные проверки, где может быть выброшено NullPointerException, очень популярны в Java-коде. Чтобы найти их, достаточно рассмотреть все места, где что-либо разыменовывается. Инструкция mov rbx, qword [rel _Test_array] разыменовывает статические данные по относительному адресу, поэтому никогда не может приводить к ошибкам. А вот инструкции mov eax, dword [rcx+0FH], idiv dword [rdx+0FH], mov ebx, dword [rbx+3BH] разыменовывают параметры метода и прочитанные статические данные, то есть могут выбросить NullPointerException. Интересно, что инструкция idiv dword [rdx+0FH] содержит сразу две неявные проверки, что порой может доставлять массу проблем JVM. Неявная проверка на IndexOutOfBoundsException должна быть в инструкции, обращающейся к элементу массива. Подсказкой служит чтение некоего _Test_array на регистр и его разыменование в инструкциях 5 и 6. Однако нужно заметить, что при таком шаблоне генерации доступа к элементу массива, индексы, выходящие за допустимый диапазон, будут просто обращаться к памяти в куче, соседней с массивом, что не провоцирует никаких аппаратных исключений. Поэтому на большинстве процессорных архитектур проверки на IndexOutOfBoundsException генерируются явно. Однако в редких случаях компилятор может доказать, что такая проверка и вовсе не нужна, что и происходит в данной задаче. То есть тут вообще не может быть выброшено IndexOutOfBoundsException. Злой хакер снова взломал ваш компьютер и отредактировал в hex-редакторе Helper.class таким образом, что концовка метода sayC стала неверифицируема: Что произойдет при запуске класса Main? Правильный ответ: B. Верификация байт-кода некоторого класса отрабатывает до того, как какой-либо метод этого класса исполнится. В классе Helper есть неверифицируемый метод sayC, значит и весь класс целиком неверифицируемый. Таким образом варианты C и D точно неправильные: исполнение никогда не дойдет до метода sayB. Далее нужно понять, в какой момент выбрасывается VerifyError. По спецификации, ошибки разрешения ссылок должны выбрасываться тогда, когда ссылка требуется при исполнении, даже если у JVM разрешение ссылок энергичное (все ссылки разрешаются сразу при загрузке класса). В данной задаче ссылка на Helper нужна только после вывода «A», поэтому правильный ответ — B. Наглядный пример демонстрирует описанное поведение. Неверифицируемый байт-код получен с помощью ручных манипуляций. Подробнее про верификацию Java байт-кода рассказывал Никита Липский aka pjBooms на JBreak 2018 (пока только слайды) и на JPoint 2017 (есть видео). Хотя на конференции некоторые пугались от вида ассемблера, нашлось довольно много людей, решивших погрузиться в тонкости работы JVM: всем, сдавшим задачи на наш стенд, мы проводили экспресс-курс про тонкости байт-кода, верификатора и неявных исключений. Надеюсь, и вы, прочитав решения, узнали что-то новое. Если так, то наша цель достигнута! Системный программист