Как растаращить class-файл

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

    Можно поискать какие-то короткие конструкции языка, которые компилируются в длинные цепочки байткода, но линейный прирост меня не устраивал. Я сразу подумал про компиляцию finally-блоков: про неё уже писали на Хабре. Если вкратце, то для каждого finally-блока при непустом try-блоке создаётся минимум два варианта в байткоде: для случая нормального завершения try-блока и для случая завершения с исключением. В последнем случае исключение сохраняется в новую локальную переменную, выполняется код finally, затем исключение достаётся из локальной переменной и перебрасывается. А что если внутри finally снова разместить try-finally и так далее? Результат превзошёл все ожидания.

    Я компилировал с помощью Oracle javac 1.7.0.60 и 1.8.0.25, результаты практически не отличались. Путь для исключения формируется даже в том случае, если в блоке try совсем ничего предосудительного. Например, присваивание целочисленной константы в локальную переменную — это две инструкции iconst и istore, ни про одну из них в спецификации не сказано, что они могут сгенерировать исключение. Так и будем писать:
    class A {{
      int a;
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      try {a=0;} finally {
      a=0;
      }}}}}}}}}}}}
    }}

    Добавление нового нетривиального кода в самый внутренний finally вызывает ошибку компиляции code too large, поэтому ограничимся таким. Если кто-то подзабыл, это у нас блок инициализации, который подклеивается к каждому конструктору. Для нашей задачи метод объявлять смысла нет.

    Такой исходник занимает 336 байт, а получившийся class-файл растаращило до 6 571 429 байт, то есть в 19 557 раз (назовём это коэффициентом растаращивания). Даже при отключении всей отладочной информации с помощью -g:none class-файл весит 6 522 221 байт, что ненамного меньше. Посмотрим, что внутри с помощью утилиты javap.

    Пул констант

    Пул констант получился небольшой: всего 16 записей. По сути всё самое необходимое: имена атрибутов типа Code, имя класса, Java-файла, ссылка на конструктор родительского класса Object и т. д. При отключении отладочной информации исчезают три записи: имена атрибутов LineNumberTable, SourceFile и значение A.java для атрибута SourceFile.

    Код

    Код конструктора по умолчанию составил 64507 байт, почти упираясь в максимально допустимый предел. Начинается он с нормального выполнения:
    Код
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."<init>":()V
             4: iconst_0
             5: istore_1
             6: iconst_0
             7: istore_1
             8: iconst_0
             9: istore_1
            10: iconst_0
            11: istore_1
            12: iconst_0
            13: istore_1
            14: iconst_0
            15: istore_1
            16: iconst_0
            17: istore_1
            18: iconst_0
            19: istore_1
            20: iconst_0
            21: istore_1
            22: iconst_0
            23: istore_1
            24: iconst_0
            25: istore_1
            26: iconst_0
            27: istore_1
            28: iconst_0
            29: istore_1
            30: goto          38
    

    То есть вызывается конструктор родительского класса, а затем 13 раз записывается единица в первую локальную переменную. После этого начинается длинная цепочка goto, которая обходит мимо всех остальных копий finally: 30->38->58->104->198->388->770->1536->3074->7168->15358->31740->64506, а по адресу 64506 мы находим долгожданную инструкцию return.

    В промежутках между этими goto всевозможные комбинации нормальных и исключительных завершений каждого блока try. Неожиданным оказалось то, что для каждого finally, обрабатывающего исключение, для хранения исключения создаётся новая локальная переменная, даже если блоки заведомо взаимоисключающие. Из-за этого коду требуется 4097 локальных переменных. Небольшая статистика по инструкциям:

    • iconst_1 — 8191 раз
    • istore_1 — 8191 раз
    • goto — 4095 раз
    • athrow — 4095 раз
    • astore_2/aload_2 — 1 раз
    • astore_3/aload_3 — 1 раз
    • astore/aload — 252 раза (локальные переменные с номерами от 4 до 255)
    • astore_w/aload_w — 3841 раз (локальные переменные с номерами больше 255)

    Плюс один aload_0, один invokespecial и один return — итого 32765 инструкций. Желающие могут нарисовать граф потока управления и повесить на стенку.

    Таблица исключений

    Таблица исключений содержит записи вида (start_pc, end_pc, handler_pc, catch_type) и говорит виртуальной машине «если при выполнении инструкций от адреса start_pc до адреса end_pc произошло исключение типа catch_type, то передай управление на адрес handler_pc». В данном случае catch_type везде равен any, то есть исключения любого типа. Записей в таблице 8188 и занимает она примерно столько же, сколько и код — около 64 килобайт. Начало выглядит так:
             from    to  target type
                26    28    33   any
                24    26    41   any
                42    44    49   any
                49    51    49   any
                22    24    61   any
    


    Таблица номеров строк

    Таблица номеров строк — это отладочная информация, сопоставляющая адресам инструкций байткода номера строк в исходнике. В ней 12288 записей и чаще всего попадаются ссылки на строчку с самым внутренним finally. Занимает она около 48 килобайт.

    StackMapTable

    Куда же ушло всё остальное место? Его заняла таблица StackMapTable, которая необходима для верификации class-файла. Если совсем грубо, для каждой точки ветвления в коде эта таблица содержит типы элементов в стеке и типы локальных переменных в данной точке. Так как локальных переменных у нас очень много и точек ветвления тоже, размер этой таблицы растёт квадратично от размера кода. Если бы локальные переменные для исключений в непересекающихся ветках переиспользовались, их бы потребовалось всего 13 и таблица StackMapTable была бы куда скромнее по размерам.

    Таращим дальше

    Можно ли растаращить class-файл ещё сильнее? Конечно, можно раскопировать метод, содержащий вложенные try-finally. Но компилятор вполне может сделать это за нас. Вспомните, что блок инициализации приклеивается к каждому конструктору автоматически. Достаточно добавить в код много пустых конструкторов с разными сигнатурами. Здесь будьте осторожны, а то у компилятора кончится память. Ну вот так можно скромненько написать, упаковав код в одну строчку:

    class A{{int a;try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{try{a=0;}finally{a=0;}}}}}}}}}}}}}A(){}A(int a){}A(char a){}A(double a){}A(float a){}A(long a){}A(short a){}A(boolean a){}A(String a){}A(Integer a){}A(Float a){}A(Short a){}A(Long a){}A(Double a){}A(Boolean a){}A(Character a){}}

    Здесь у меня 16 конструкторов, исходник занимает 430 байт. После компиляции имеем 104 450 071 байт, коэффициент растаращивания составил 242 907. И это не предел!
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 62

      +8
      Для сравнения стоило бы привести размеры этого же кода при компиляции с -source 1.5 -target 1.5: 711 и 7 978 байтов соответственно.
      Class-файл версии 50 и ниже так просто не растаращишь.
        +9
        Дельное замечание. Вот они, передовые технологии!
        +3
        Путь для исключения формируется даже в том случае, если в блоке try совсем ничего предосудительного. Например, присваивание целочисленной константы в локальную переменную — это две инструкции iconst и istore, ни про одну из них в спецификации не сказано, что они могут сгенерировать исключение.
        Тут вы не правы. Параграф 2.10 JVMS говорит:
        An asynchronous exception, by contrast, can potentially occur at any point in the execution of a program.
        An asynchronous exception occurred because:
        * The stop method of class Thread or ThreadGroup was invoked, or
        * An internal error occurred in the Java Virtual Machine implementation.
        Таким образом JVM должна быть готова к тому, что во время исполнения любой инструкции может быть выброшено исключение.
          0
          И в этих случаях действительно будет выполняться finally-блок?

          Почитал, в случае Thread.stop выполнится. Ваша правда.
          +7
          Я слышал, что есть заказчики, которые платят за строчку кода. Было было забавно найти заказчиков, которые платят за килобайт байт-кода и провернуть такое.
            +4
            Кстати, если заказчики, которые требуют 80% покрытие кода тестами. Если считать по байткоду (как, например, EMMA делает), с таким методом возникнут очень большие проблемы.
              +15
              Наоборот, можно только один этот класс и покрыть — а остальной проект в 20% уложится!
                +2
                А как вы его собрались покрывать? Там большая часть кода исполняется только когда звёзды встанут в очень специфическую конфигурацию.
                  0
                  Можно случайным образом вбрасывать исключения из разных мест. Правда, может не скомпилироваться.
              –2
              Я слышал, что есть заказчики, которые платят за строчку кода.

              О, да с удовольствием!

              HelloWriter.java:
              
              public class HelloWriter {
                  public void doIt () {
                      System.out.println ("Hello world!");
                  }
              }
              
              Main.java:
              
              public class Main {
                  public static void main (String[] args) {
                      HelloWriter writer = new HelloWriter ();
                      writer.doIt ();
                  }
              }
              
              javac HelloWriter.java Main.java
              java Main
              
                +6
                  0
                  А ещё можно было открывающую скобочку с новой строки печатать. И параметры метода по своим отдельным строчкам. И инициализировать отдельной строчкой, а не сразу при об'явлении. И вообще всё по блочным операторам разбить. Заменить на printf с параметрами и try/catch ещё добавить. Конструктор по-умолчанию добавить. А package где?
                  Плохо работаете.
                    0
                    То, что вы предложили — для меня зло, ибо так меня научили, поэтому как я мог стать тем, с чем так борюсь?) Я усложнил код, однако и усложнение должно быть в меру. Пусть дальше усложняют любители чего-нибудь погорячее, я предложил саму парадигму)
                    0
                    Разве на этом заработаешь? Надо примерно так.

                    Техническое задание: написать программу печатающую Hello world!
                    Шаг 1:
                    Как известно константы в коде это не наш метод, поэтому добавим программе гибкости и будем хранить их вне кода. Для гибкости в json, xml, ymi и properites формате. На работу с этими форматами запросим мелочь по тысячи строк с каждого.

                    Шаг 2:
                    Естественно, реализуем фабрику, абстрактную фабрику и систему редеров и лодеров всех этих файлов. Ну, пара тысяч строк.

                    Шаг 3:
                    Вспомним про мультиязыковую поддержку и создадим файлы на 50 мировых языках, реализуем поддержку всех возможных кодировок. Мы ведь хотим чтобы у нас было гибкое приложение? Ну, ещё тысяч пять строк.

                    Шаг 4:
                    Нет, ну как в нашем системе без unit tes'ов? Никак, увеличем кол-во строк ещё вдвое.

                    Шаг 5:
                    А ещё интеграционные тесты, ну пару тысяч. Это мелочь.

                    Шаг 6:
                    Таак, а если пользователь захочет поменять сообщение сам? Нет, мы за гибкость. Реализуем Rest Api, web интерфейс, desktop интерфейс для основных ОС и делаем приложение для основных мобильных платформ. Ну, тут без 50 тысяч строк никак.

                    Шаг 7:
                    Нам ведь строку надо где-то хранить, правильно? Релизуем работу с основными СУБД (мало ли что заказчик захочет), вспомним про nosql и поддержим несколько nosql систем.

                    Шаг 8:
                    Ну да, юнит тесты, интеграционные тест, автоматические тесты, тесты производительности. Все по правильному. Умножаем все строчки кода ещё раза в три.

                    Шаг 9:
                    Вспоминаем про многопоточность и реализуем коллизии одновременных изменений строки hello word. Вспомнием про отказоустойчивость, обработку ошибок, логирование…

                    Шаг…

                    Шаг N:
                    Понимаем что технологии на шагах 1..N-1 уже устарели, их выкидываем, делаем рефакторинг и делаем все заново.

                    Шаг…

                    Итого: это проект в несколько миллионов строк кода и много лет работы команды из сотни человек.
                      0
                      Насчёт наскольких миллионов строк вы, пожалуй, загнули, но многое из вам описанного в GNU hello всё-таки есть. И локализация, и включение/выключение псевдографики и многое другое.
                        –1
                        Хо-хооооо! Я должен внести это в Избранное! Это просто гениально! Это ладно реализовать, но это ж надо же такое предложить!)))
                    +17
                    Спасибо за коэффициент растаращивания, прям чувствуется дух пятницы )
                      +3
                      Ради интереса взял аналогичный код на C#:
                      Код
                      using System;
                      
                      namespace TestConsole
                      {
                          public class Program
                          {
                      
                              public static void Main(string[] args)
                              {
                                  int a;
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  try { a = 0; } finally {
                                  }}}}}}}}}}}}
                      
                                  Console.WriteLine(a);
                              }
                      
                              // Точка входа для Java
                              public static void main(string[] args)
                              {
                                  Main(args);
                              }
                          }
                      }
                      

                      Скомпилировал в .NET и натравил на него свой компилятор CIL2Java. Сильно не углублялся что к чему, но статистика получилась такая:
                      Размер class файла: 184 915 байт
                      Пул констант: 4127 записей (из за отладочной информации)
                      Размер кода метода: 40 949 байт
                      А вот локальных переменных потребовалось всего 14 (CIL2Java всё таки освобождает временную переменную когда она становится не нужна)
                      Записей в таблице исключений: 4095 (что равно 32 760 байт)
                      Вот вкратце получилось так. Можно сделать вывод что мой компилятор генерирует даже более компактный код чем java =))
                        0
                        О, хорошая проверка. Интересно, а что конкретно в пуле констант такое большое добавилось?
                          0
                          Имена для временных переменных. Не смотря на то, что переменные в результате переиспользуются, для каждого отдельного использования генерируется новое имя. Вот и получилось столько.
                            0
                            А, вона что. Javac не заморачивается давать им имена. Может, конечно, настройка какая-то есть…
                          0
                          А какую версию байткода поддерживает ваш компилятор на выходе? Или может выложите куда-нибудь получившийся .class?
                            0
                            Версия class файла — 49, но сам по себе байт код соответствует больше 1.6 и выше. По крайней мере try/finally блоки генерируются похожим образом. class файл — здесь
                              0
                              Class-файл версии 50 и ниже так просто не растаращишь.
                              Там выше был пример с полуторкой. Собственно потому и результат такой.
                                +1
                                Вы не поняли. У zebraxxl свой компилятор, самописный, не имеющий ничего общего с javac. Вот, почитайте его статью. Javac в режиме 1.5 работает через инструкцию JSR, но в class-файлах 51+ она запрещена (видимо, как раз для упрощения верификации). Хотя в 49-й можно было использовать JSR, но zebraxxl не пользуется ей при генерации. Другое дело, что он не создаёт StackMapTable и переиспользует переменные, поэтому он экономит на другом (в частности, обходится без aload_w, что уже снижает длину байткода).
                          –6
                          А можно в двух словах пояснить — зачем?
                          because I can?
                            +14
                            По-моему, это хороший пример почему крайне аккуратно стоит использовать вложенные finally блоки.
                              0
                              А! Каюсь, в этом аспекте не рассматривал. Спасибо.
                              –8
                              О как. Минус за просьбу пояснить? Хабр торт.
                                +5
                                Ваш комментарий имеет два смысла — это и просьба объяснить, и одновременно упрек автору поста.

                                Также хочу отметить, что минус комментарию означает всего лишь «мне не понравился этот комментарий», а то что негативный комментарий кому-то не понравился — это совершенно нормально.

                                Вот если бы вам за такой комментарий прилетел минус в карму (что равносильно «я не хочу видеть тут этого человека») — это было бы неправильно и можно было бы возмущаться.
                                  +6
                                  Возмущаться как правило чревато прилетом еще пачки «не хочу видеть тут этого человека».
                                    +2
                                    Если возмущение некорректное — то да. А если вежливое и по делу — иногда и компенсация прилететь может…
                                +28
                                Вообще я разбирался с генерацией кода finally для FindBugs: из-за анализа всех альтернативных веток иногда возникают ложные срабатывания в моём новом детекторе, который отлавливает некоторые заведомо бесполезные условия в коде. В смысле, найденные условия действительно бесполезны, но они добавлены компилятором автоматически, а не автором кода, поэтому сообщать о них бессмысленно. Сейчас стоит задача по заданному байт-коду восстановить, какие фрагменты были в finally-блоках. Данная статья — смешной побочный эффект этой работы. Но даже несмотря на её несерьёзность, я лучше понял процедуру компиляции и устройство StackMapTable (её тоже мы в FindBugs разбираем иногда) и поделился информацией с другими. Кроме того, выше были дельные комментарии, которые способствовали обмену знаниями. Наконец, подобные исследования из игры могут привести к важным последствиям: к примеру, можно сочинить вектор DoS-атаки на какие-нибудь системы, которые автоматически компилируют фрагменты Java-кода из недоверенных источников.

                                Я достаточно занудно ответил на ваш вопрос? :-)
                                • UFO just landed and posted this here
                                  +3
                                  Чтобы спустя год с лишним кто-то, кому нужно в тестах получить metaspace oom, используя как можно более крупные классы (а не, например, много мелких), вспомнил эту прекрасную статью, и сэкономил кучу времени. Спасибо тебе, lany!
                                    0
                                    Обращайся ещё :D
                                  +9
                                  Этот пост стоит плюсовать только из-за потрясающего глагола в заголовке.
                                    +1
                                    Не пишу на Java, почитываю иногда статьи для расширения кругозора.
                                    Это статья была отменной: одновременно и поучительная и пятничная!
                                    А глагол, да — в мемориз, однозначно.

                                    Реквестирую подобную статью под c#, никто не хочет подготовить небольшое исследование?
                                      0
                                      Могу предложить перевести цикл Abusing C# от Джона Скита (Jon Skeet). Пусть библиотеки там не растаращивает, но компиляторы вполне.
                                        +2
                                        На C# растаращить можно ещё проще. Рассмотрим вот такой вот код:

                                        class X<A, B, C, D, E> { class Y : X<Y, Y, Y, Y, Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y;} }
                                        

                                        И — вуаля, сборочка весит уже 29 мегабайт. Можно растаращить ещё больше, но это я оставлю в качестве упражнения для читателя :)
                                          +1
                                          а что такое Y.Y.Y.Y.Y.Y.Y.Y.Y?
                                            –2
                                            Полагаю, что имя класса.
                                              +1
                                              Вложенный класс же.
                                              +2
                                              Не знаток C#, так что поясните пожалуйста за счет чего здесь происходит растаращивание. Т.е. в примере из статьи причина всему — инлайн finally блоков, а здесь что? Предположу, что генерация классов.
                                                +1
                                                Сходу не могу назвать конкретной причины. Постараюсь изучить вопрос и ответить отдельным постом :)
                                                  0
                                                  Было бы интересно почитать! :)
                                                0
                                                по-идее сама сборка должна весить копейки.Странное поведение если нет
                                                  0
                                                  А вы попробуйте сами, соберите. Этот пример кода довольно старый (но я проверил — на компиляторе из VS 2013 воспроизводится); пару лет назад я хотел было разобраться, что ж там такое хранится, но тогдашний рефлектор не сумел разобрать такую большую сборку, на том исследования в тот раз и закончились.
                                                    0
                                                    <предположение>
                                                    Generic-классы вроде бы компилируются в «невидимые» классы с именами, не соответствующими стандартам именования C#, что бы точно не пересечься с именами классов разработчика.
                                                    Соответственно, для каждого вложенного поля нужно было создать отдельный класс с конструктором, таблицей методов и т.д.
                                                    Видимо, рекурсия в этом случае генерит очень сложный код.
                                                    </предположение>
                                                      +2
                                                      Не совсем верно. Именования дженериков классов действительно несколько отличаются от стандартных. Но все отличие заключается лишь в том, что к имени класса добавляется суфикс "`N", где N — количество дженерик параметров. И нужно лишь для того, что бы не было пересечений имен в случаях когда есть три класса: C, C и C<T, F> (после компиляции превратятся в C, C`1 и C`2 соответственно). Это описано в стандарте ECMA-335, раздел II.9, второй абзац:
                                                      Цитата
                                                      A generic type consists of a name followed by a <…>-delimited list of generic parameters, as in C.
                                                      Two or more generic types shall not be defined with the same name, but different numbers of generic
                                                      parameters, in the same scope. However, to allow such overloading on generic arity at the source
                                                      language level, CLS Rule 43 is defined to map generic type names to unique CIL names. That Rule
                                                      states that the CLS-compliant name of a type C having one or more generic parameters, shall have a
                                                      suffix of the form `n, where n is a decimal integer constant (without leading zeros) representing the
                                                      number of generic parameters that C has. For example: the types C, C, and C<K,V> have CLScompliant
                                                      names of C, C`1, and C`2<K,V>, respectively. [Note: The names of all standard library
                                                      types are CLS-compliant; e.g., System.Collections.Generic.IEnumerable`1. end note]
                                              0
                                              MaximAL, EvilBeaver, r1alex, «кровь, кишки, растаращило» из этого поста: habrahabr.ru/company/parallels/blog/231607
                                                0
                                                Я не у Parallels это словечко подхватил, честно :-)
                                              +6
                                              «Коэффициент растаращивания» — записал себе термин ))
                                                –7
                                                рас-3.14-дорасило
                                                  –5
                                                  Растаращить )
                                                  0
                                                  Кстати, на практике не пробовал, но по идее похожего эффекта можно достичь при использовании switch со строкой, компилятор это превращает в два tableswitch'a (или в один lookup- и один tableswitch).
                                                    0
                                                    Эффекта экспоненциального роста не будет, и даже квадратичного не получится. Будет всего одна копия байт-кода на каждый case.
                                                    +4
                                                    автор — демон!!!

                                                    а если серьёзно, то если в проекте используется кодогенератор, на лету оборачивающий генерируемый код в блоки try-finally, это будет довольно неплохой способ изготовить classloader bomb.

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

                                                    полезное знание, спасибо.
                                                      +4
                                                      Ну, значит все-таки не зря переводил :)
                                                        +2
                                                        Я вашу статью постфактум увидал, уже когда сам со всем этим разобрался. Но да, всяко не зря :-)

                                                      Only users with full accounts can post comments. Log in, please.