Был прекрасный майский день. Мой взгляд случайно упал на чат ребят с крайнего сервера. У них майский день был не таким прекрасным: во время перераскладки второстепенного сервиса упал сервис авторизации, связанный с ним постольку-поскольку. Цимес ситуации в том, что падающую часть сервиса авторизации никто не поддерживает, он перешел к нам по наследству и никогда особо не сбоил. Меня увлекло чтение детектива поиска причин, и до определенного момента я был пассивным читателем — пока не увидел фразу нашего админа, наполненную приобретенной сединой его волос: «За час натекает 800+ потоков».

Вот это уже интересно! На Java течь потоками в таком темпе, да чтобы этого годами не замечать — не так уж это и просто, что я и озвучил. А поскольку в данном чате я был единственным Java-разработчиком, то было лишь вопросом времени, пока кто-нибудь не скажет: «Раз такой умный, возьми да поправь». И не важно, что ты клиентщик, и вообще последние три года пишешь под Андроид.

А почему бы и нет?


Шаг 1: берем сорцы для обзорного ознакомления. Грепаем «Thread», «Executor» и… ничего не находим. Зато находим некую библиотеку, в которую уходят все вызовы.

Шаг 2: берем сорцы библиотеки и… их нет. Вот это поворот! Как так случилось? Да очень просто. Проект состоит из 300+ сервисов. У него богатая и сложная история с неожиданными поворотами. И при переносе всех этих чудес, местами без документации, с разными репозиториями, языками и технологиями, чисто технически не за всем можно уследить, тем более что все отлично компилируется, либо лежит в проекте в виде jar-ки.

В целом, для ознакомления сорцы не особо и нужны. Intellij Idea вполне сносно декомпилирует код. Даже при беглом прочтении волосы встали дыбом. Слово «Executor» все еще не встречалось, зато «new Thread» было буквально повсюду. На этом отложим в сторону код. Прямо сейчас искать в нем утечку ничуть не проще, чем иголку в стоге иголок. Возьмем лучше thread dump и посмотрим:

"Thread-782" daemon prio=10 tid=0x00007f7db4654800 nid=0x2286d9 in Object.wait() [0x00007f7b929d6000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

"Thread-781" daemon prio=10 tid=0x00007f7db4651000 nid=0x2286d7 in Object.wait() [0x00007f7de37ee000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

"Thread-780" daemon prio=10 tid=0x00007f7db464f000 nid=0x2286d5 in Object.wait() [0x00007f7de118a000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000b843f3c8> (a java.util.LinkedList)
	at java.lang.Object.wait(Object.java:503)
	at com.aol.saabclient.SAABConnection.AsynchMsgProcessor.run(AsynchMsgProcessor.java:83)
	- locked <0x00000000b843f3c8> (a java.util.LinkedList)

Ну, раз такая пьянка, пойдем прямиком в AsyncMsgProcessor (да-да, SAABConnection — это package). Там мы видим что-то вроде ручной реализации blocking queue вокруг LinkedList. Ясно-понятно, что цикл разбора вечен, и это даже может быть desirable behaviour. Также становится понятно, что AsyncMsgProcessor создается для каждого соединения, но вот очередь общая (static LinkedList messages). Таким образом, раз все AsyncMsgProcessor’ы разгребают одну и ту же очередь, можно просто ограничить их число. Ищем инстанцирование, и находим только одно. Отлично! Осталось поменять прямое инстанцирование на пул и будет нам счастье.

Для этого есть два пути:

  1. Воткнуть декомпилированный код обратно в компилятор и молиться, чтобы декомпилятор не налажал. Это путь темной стороны, так как ведет к непредсказуемым багам;
  2. Поправить byte-код одного маленького метода руками. Шансов ошибиться в разы меньше, а значит это путь настоящего джедая.

Правим byte-код


Для разбора и сбора обратно class-файлов нужна более-менее специфичная тулза. Я нашел только вот эту: JBE — Java Bytecode Editor. Она имеет большую проблему с редактированием кода: нужно руками считать все смещения в условных и безусловных переходах, что, в общем-то, так себе перспектива, даже для сравнительно небольшого метода. Опять-таки из-за большого шанса ошибиться любое изменение будет даваться кровью и потом. Среди менее готовых для прямого использования тулзов есть отличная, очень мощная штука — ASM. Но из коробки не имеет возможности сначала вывести в виде текста, затем подредактировать и собрать обратно. Но можно научить.

Хачим вывод


Для вывода текста используется классы Textifier + TraceMethodVisitor. Но из такого вывода довольно проблематично будет собрать все обратно, чтобы байткод не изменился (хотя бы функционально). Поэтому немного хаков:

Textifier textifier = new Textifier(Opcodes.ASM5) {
   @Override
   public void visitLabel(Label label) {
       buf.setLength(0);
       buf.append('#');
       appendLabel(label);
       buf.append(":\n");
       text.add(buf.toString());
   }

   @Override
   public void visitLineNumber(int line, Label start) {
       buf.setLength(0);
       buf.append("// line ").append(line).append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitMaxs(int maxStack, int maxLocals) {
       buf.setLength(0);
       buf.append("// MAXSTACK = ").append(maxStack).append('\n');
       text.add(buf.toString());

       buf.setLength(0);
       buf.append("// MAXLOCALS = ").append(maxLocals).append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitLdcInsn(Object cst) {
       buf.setLength(0);
       buf.append(tab2).append("LDC ");
       if (cst instanceof String) {
           Printer.appendString(buf, (String) cst);
       } else if (cst instanceof org.objectweb.asm.Type) {
           buf.append(((org.objectweb.asm.Type) cst).getDescriptor()).append(".class");
       } else if (cst instanceof Long) {
           buf.append(cst).append('L');
       } else if (cst instanceof Float) {
           buf.append(cst).append('F');
       } else if (cst instanceof Double) {
           buf.append(cst).append('D');
       } else if (cst instanceof Integer) {
           buf.append(cst);
       } else {
           throw new IllegalArgumentException("cst " + cst);
       }
       buf.append('\n');
       text.add(buf.toString());
   }

   @Override
   public void visitFrame(int type, int nLocal, Object[] local, int nStack, Object[] stack) {
   }
};

Хачим ввод


С вводом сложнее. В ASM есть класс MethodNode, являющийся визитором. Обычно подразумевается, что MethodNode подсовывается в accept ClassReader, заполняясь из него, возможно, видоизменяясь. Мы же хотим подсунуть в него текст, сгенеренный на прошлом шаге (именно там должны были произойти «видоизменения»). Симитируем поведение Reader:

   for (String line : methodCode.getText().split("\n")) {
       int lastCommentPos = line.lastIndexOf("//");
       if (lastCommentPos != -1) {
           line = line.substring(0, lastCommentPos);
       }
       line = line.trim();
       if (line.isEmpty()) {
           continue;
       }
       String[] withParams = line.split("\\s+");

       String command = withParams[0];
       if (command.startsWith("#")) {
           verify(command.endsWith(":"));
           String substring = command.substring(1, command.length() - 1);
           method.visitLabel(getLabel(substring, labels));
       } else if (command.equals("TRYCATCHBLOCK")) {
           verify(withParams.length == 5);
           Label start = getLabel(withParams[1], labels);
           Label end = getLabel(withParams[2], labels);
           Label handler = getLabel(withParams[3], labels);
           String type = withParams[4];
           if (type.equals("null")) {
               type = null;
           }
           method.visitTryCatchBlock(start, end, handler, type);
       } else {
           Opcode opcode = OPCODES.get(command); //копипаста из сорцов ASM
           if (opcode == null) {
               throw new RuntimeException("Unknown " + command);
           } else {
               switch (opcode.type) {
                   case OpcodeGroup.INSN:
                       verify(withParams.length == 1);
                       method.visitInsn(opcode.opcode);
                       break;
                   case OpcodeGroup.INSN_INT:
                       verify(withParams.length == 2);
                       method.visitIntInsn(opcode.opcode, Integer.valueOf(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_VAR:
                       verify(withParams.length == 2);
                       method.visitVarInsn(opcode.opcode, Integer.valueOf(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_TYPE:
                       verify(withParams.length == 2);
                       method.visitTypeInsn(opcode.opcode, withParams[1]);
                       break;
                   case OpcodeGroup.INSN_FIELD:
                       verify(withParams.length == 4);
                       verify(withParams[2].equals(":"));
                       int dotIndex = withParams[1].indexOf('.');
                       String owner = withParams[1].substring(0, dotIndex);
                       String name = withParams[1].substring(dotIndex + 1);
                       method.visitFieldInsn(opcode.opcode, owner, name, withParams[3]);
                       break;
                   case OpcodeGroup.INSN_METHOD:
                       verify(withParams.length == 3);
                       dotIndex = withParams[1].indexOf('.');
                       owner = withParams[1].substring(0, dotIndex);
                       name = withParams[1].substring(dotIndex + 1);
                       method.visitMethodInsn(opcode.opcode, owner, name, withParams[2], opcode.opcode == INVOKEINTERFACE);
                       break;
                   case OpcodeGroup.INSN_JUMP:
                       verify(withParams.length == 2);
                       method.visitJumpInsn(opcode.opcode, getLabel(withParams[1], labels));
                       break;
                   case OpcodeGroup.INSN_LDC:
                       withParams = line.split("\\s+", 2);
                       verify(withParams.length == 2);
                       method.visitLdcInsn(parseLdc(withParams[1]));
                       break;
                   case OpcodeGroup.INSN_IINC:
                       verify(withParams.length == 3);
                       method.visitIincInsn(Integer.valueOf(withParams[1]), Integer.valueOf(withParams[2]));
                       break;
                   case OpcodeGroup.INSN_MULTIANEWARRAY:
                       verify(withParams.length == 3);
                       method.visitMultiANewArrayInsn(withParams[1], Integer.valueOf(withParams[2]));
                       break;
                   default:
                       throw new IllegalArgumentException();
               }
           }
       }
   }

Врезку в байткод в итоге мы сделали. Осталось напилить тот самый пул. Код приводить не буду, там еще больше всякого… Но подход очень простой: берем модифицированную библиотеку, кладем ее в зависимости, пишем нужные классы и перекомпилируем. После этого потоки перестали течь, все работает, хэппи энд. На самом деле, все, конечно, было не так — потребовалось 5—7 раскладок в тестовом окружении. То байт-код — не байт-код, то пул — не пул…

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

P.S.: Пиво мне так никто и не поставил.

Комментарии (32)


  1. relgames
    27.11.2015 12:17
    +1

    А переписать AsyncMsgProcessor на нормальную очередь и на Executor не получилось?


    1. Artyomcool
      27.11.2015 12:25
      +8

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


  1. HDDimon
    27.11.2015 12:54
    +1

    не нашли причину почему исходники потерялись?


    1. Artyomcool
      27.11.2015 12:59
      +5

      Причина в том, что продукт разрабатывался больше 15 лет в разных компаниях. За такой срок те вещи, которые никто особо не менял, покрылись большим слоем пыли.


      1. MasMaX
        27.11.2015 14:16
        -1

        Поэтому я подсел на репозитории… Их потерять сложнее


        1. Artyomcool
          27.11.2015 14:21
          +2

          Не панацея. Представим, что у нас 10-20 разных команд (в разное время). Обязательно будут разные репозитории. Например, кто-то будет писать компонент в open-source на github'е, другие компоненты будут разрабатываться под SVN в силу каких-нибудь исторических причин, третьи — на внутренних git-репозиториях. Даже если проверить, что все собирается, нет гарантий, что в одном из компонентов не используется библиотека без доступных исходников, как собственно и произошло.


          1. MasMaX
            27.11.2015 14:29

            Ну тогда на будущее возьмите правило, чтобы пушить все в репозитории. Чтобы о вас потом не вспоминали плохо))

            Я сам столкнулся с софтом который писали 10 лет назад незнакомые мне люди. И часто код переписывали под заказчика, компилировали и удаляли изменения. В итоге единственная версия оставалась у заказчика и как ее повторить никто не знает. Были бы репозитории с ветками было бы проще.


            1. Artyomcool
              27.11.2015 14:44
              +5

              пушить все в репозитории


              Уже давным давно все пушат в репозитории. Но, как я уже написал, это не гарантия, для продуктов, состоящих из сотен отдельных тесновзаимосвязанных сервисов, больше половины которых — legacy.


          1. Longer
            27.11.2015 17:38
            +3

            Дополню, даже в emacs 'е не так давно обнаруживалось, что нет части исходников, хоть всё и собиралось. А emacs это более 25 лет разработки.


        1. relgames
          27.11.2015 18:12

          Можно. Репозитории тоже умирают. Например, я недавно не смог собрать проект на gradle, т.к. там использовались некоторые сторонние репозитории, которые умерли за год.


        1. 28.11.2015 02:33

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

          Я ему показал что где лежит, куда что писать и вроде бы все хорошо закончилось — заказчику программу поставили, заказчик доволен, коллега все закоммитил в TFS. Через полгода, когда этот коллега уже уволился, заказчик заявил о какой-то неисправности и я занялся этой проблемой. Естественно, взял последний чекин от того коллеги, нашел источник бага, исправил и поставил заказчику. Вот тут то и началось самое интересное — заказчик чуть ли не через дирекцию написал гневное письмо, что лично я сломал ему работу программы, так как пропало 40% функционала.

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


          1. Zapped
            30.11.2015 09:25

            Сервера сборок, значится, нет?


            1. Oxoron
              30.11.2015 10:28

              Сервер сборок от данной проблемы не спасет. «Новобранец» спокойно отошлет заказчику локально собранный проект.


              1. Zapped
                30.11.2015 10:36

                ну, если с заказчиком общается «новобранец» напрямую, а не через менеджера проекта, и «новобранцу» (да и тому менеджеру) не сказали, что все релизы менеджер забирает только с сервера сборок — то да…


            1. 30.11.2015 10:43

              У нас проекты не такие гигантские. Со сборкой даже самого большого нашего проекта справляется любая локальная машина сотрудника меньше, чем за минуту (у всех сотрудников минимум Core i3), Делать сервер сборок нецелесообразно со стороны дирекции (финансово), да и обычно проблем у нас никогда таких не было.


              1. Zapped
                30.11.2015 14:55

                *тестов, очевидно, тоже нет :)
                дело хозяйское…
                *но первый звоночек уже был ;)))


  1. leremin
    27.11.2015 13:13

    Я только один раз потерял исходники. Очень помогло то, что в то время уже писал на C#, а не на плюсах. К слову, местами приходил в ужас от декомпилированного кода.


  1. Oxoron
    27.11.2015 13:58
    +1

    даже потеря исходных кодов — не катастрофа

    А вот это зависит от декомпиляторов\рефлекторов\дизасмов. В Java и .NET все ок, у АСМщиков таких проблем вообще нет, а вот сишникам придется хуже.
    Кстати, краем уха слышал про подписанные библиотеки, вроде как в них врезать свой код совсем тяжко.


    1. Artyomcool
      27.11.2015 14:17

      сишникам придется хуже

      Не на много. Практически всегда можно сделать тонкий слой врезки и сделегировать основную часть работы в православно написанный код в своей любимой IDE. Если я конечно правильно понял суть высказывания.


      1. Oxoron
        27.11.2015 14:41
        +1

        Врезать код — да. Полагаю, операция почти одинаковая для любого популярного языка.
        Восстановить исходный код из dll для анализа — сложнее. .NET код восстанавливается без проблем. При восстановлении С++ кода уже придется потрудиться.


        1. Artyomcool
          27.11.2015 15:50

          Ну как… Все зависит от навыков и желания:
          habrahabr.ru/post/266385
          habrahabr.ru/post/110395
          Мне кажется, что больше всего отличается порог вхождения: чтобы научиться продуктивно реверсить компилируемый код, нужно больше времени.


  1. Maccimo
    27.11.2015 14:25
    +1

    >> Среди менее готовых для прямого использования тулзов есть отличная, очень мощная штука — ASM. Но из коробки не имеет возможности сначала вывести в виде текста, затем подредактировать и собрать обратно.
    >> Но можно научить.

    >> Симитируем поведение Reader

    Пионеры не ищут лёгких путей?
    Среди примеров к ASM есть «A ClassVisitor that prints a disassembled view of the classes it visits in Jasmin assembler format».
    Вот он: http://websvn.ow2.org/filedetails.php?repname=asm&path=%2Ftrunk%2Fasm%2Fexamples%2Fjasmin%2Fsrc%2FJasminifierClassAdapter.java

    А вот и тот самый ассемблер Jasmin, которым можно ассемблировать полученный при помощи вышеупомянутого посетителя листинг: http://jasmin.sourceforge.net/

    Учитывая древность проблемного кода, вряд ли там было что-то, что не смог бы переварить Jasmin.


    1. Artyomcool
      27.11.2015 14:50

      Да, действительно, как-то не нашел, хотя искал долго и упорно. Вероятно, этого бы хватило. В защиту могу сказать, что у меня весь цикл редактирования-сборки .class-файла происходил в одном гуевом окошечке, что конечно не оправдывает велосипедостроения :)


  1. seregamorph
    27.11.2015 14:51

    Я однажды столкнулся с подобной проблемой, только сорцов не было, т.к. либа была проприетарная. Декомпилировал один нужный мне класс, сделал проект с одним этим классом (в его оригинальном пакете), а исходную либу прицепил как зависимость. Скомпилировал и засунул в архив назад. Вроде так все было.


    1. Artyomcool
      27.11.2015 14:53

      Да, делал так же. И в этот раз, и много лет назад, когда немного видоизменял какое-то приложение для J2ME.


  1. Ghedeon
    27.11.2015 16:01

    Этот дизассемблер отработал на ура в моем случае: github.com/Storyyeller/Krakatau. Отказался от JBE в его пользу, идеально для «поправить class файл».


  1. Raegdan
    27.11.2015 17:43
    +1

    У меня была такая же история, но не с явой, а с флешем.

    кому интересно, заходите
    Была одна нужная флешовая утилита. Захотел скачать её и юзать локально. Не работает. Путём экспериментов в Denwer'е выяснилось, что она проверяет домен, с которого запущена. ОК, будем крячить. Разобрал декомпилятором, пробил поиском нужную строку (имя домена), нашёл, запатчил… обратно не собирается — декомпилировалось криво. Хорошо — исправим в ассемблере. Разобрал Флеш Скальпелем (ассемблер/дизассемблер байткода ActionScript), снова нашёл строку, применил старую добрую инверсию условия (jne -> je и наоборот) и собрал назад. Заработало как часы.


  1. afiskon
    27.11.2015 21:10
    +9

    Я чувствую, что пожалею о том, что спросил. Но серьезно — не стыдно такое в корпоративном блоге публиковать?


    1. Andriyan
      28.11.2015 14:16
      +2

      А почему может быть стыдно? Появилась проблема — решили. На будущее приняли решение (правда, только в комментариях), уменьшающее вероятность повторного наступания на те же грабли.


      1. afiskon
        28.11.2015 16:27
        -2

        Я с вашего позволения оставлю ссылку на запись, в которой мы обсудили этот пост. Таймстэмп — в районе 01:53:15.


        1. Artyomcool
          30.11.2015 13:39
          +8

          Есть одна проблема, которая порождает не до конца верные выводы. Если внутри компании теряются сорцы — это действительно бардак.
          Но тут к в анекдоте — есть один ньюанс. Исходники библиотеки, используемой одним вспомогательным сервисом, потерялись не внутри компании, а при переходе из другой (причем, несколько лет назад).
          Лично мне известен лишь один способ убедиться в том, что у вас есть все сорцы — все пересобрать. Это нетривиально даже для однородного проекта с одной кодовой базой. Но что если проект по-настоящему огромен? Если из 300+ сервисов все написаны в разное время, разными людьми и компаниями, на разных языках, с использованием разных систем сборки и систем контроля версий?
          Всегда найдется краевой случай, когда кто-нибудь в одной из компаний закоммитит jar в репозиторий, вместо того, чтобы воспользоваться системой зависимостей (хотя, как известно, и это не гарантия).
          Было бы здорово сделать выводы, как всего этого не повторить. Но бывают случаи, когда даже бэкап бэкапа, трижды проверенный и верифицированный, не спасет. И тогда есть три варианта:
          1. Заплакать и убежать.
          2. Переписать все 15 лет работы.
          3. Подхачить.
          Мы выбрали вариант №3, о чем собственно и статья.


  1. Andriyan
    28.11.2015 14:16

    (Удалено)