Как мы себя заново писали, или как потерять исходники и не подать виду
Был прекрасный майский день. Мой взгляд случайно упал на чат ребят с крайнего сервера. У них майский день был не таким прекрасным: во время перераскладки второстепенного сервиса упал сервис авторизации, связанный с ним постольку-поскольку. Цимес ситуации в том, что падающую часть сервиса авторизации никто не поддерживает, он перешел к нам по наследству и никогда особо не сбоил. Меня увлекло чтение детектива поиска причин, и до определенного момента я был пассивным читателем — пока не увидел фразу нашего админа, наполненную приобретенной сединой его волос: «За час натекает 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«ы разгребают одну и ту же очередь, можно просто ограничить их число. Ищем инстанцирование, и находим только одно. Отлично! Осталось поменять прямое инстанцирование на пул и будет нам счастье.
Для этого есть два пути:
- Воткнуть декомпилированный код обратно в компилятор и молиться, чтобы декомпилятор не налажал. Это путь темной стороны, так как ведет к непредсказуемым багам;
- Поправить 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.: Пиво мне так никто и не поставил.