В данном посте, я расскажу про интересный случай возникший у меня работе.

Есть следующий код:

public String pack(final byte[] data) {
        int bufferIdx = 0;
        char[] buffer;
        if (data.length % 2 != 0) {
            buffer = new char[(data.length >> 1) + 2];
            buffer[0] = flag;
            bufferIdx = 1;
        } else {
            buffer = new char[data.length >> 1];
        }
        int idx = 0;
        for (int i = bufferIdx; i < buffer.length; i++) {
            int bpos = idx << 1;
            char c;
            if (bpos + 1 < data.length) {
                c = (char) (((data[bpos] & 0x00FF) << 8) + (data[bpos + 1] & 0x00FF));
            } else {
                c = (char) (((data[bpos] & 0x00FF) << 8));
            }
            buffer[i] = c;
            idx++;
        }
        return new String(buffer);
}

В методе проиcходит сериализация массива байт. Используется тот факт, что размер char в java — 16 бит, а размер byte (Внезапно 😱) — 8 бит. Но суть не в этом. Интересный момент здесь заключается в том, что метод работает гораздо медленее на массивах нечетной длины.


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

Значит применялись какая то оптимизации в рантайме. Это мог только сделать jit компилятор. Для проверки гипотизы, я воспользовался иструментом JitWatcher. Эта программа позволяет посмотреть сгенерированный asm для каждой стадии оптимизации.

Небольшой offtop:

JitWatcher использует параметры jvm для отображения сгенерированного кода. Собственно никто не запрещает это сделать самостоятельно, запустив вашу программу с ключиками:

-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:+TraceClassLoading -XX:+PrintAssembly

Но поверьте, вы не захотите в этом разбираться. C JitWatcher вам не придется перелопачивать тонны asm кода, хотя вдруг вам это понравится 😏

Чтобы ключик -XX:+PrintAssembly заработал jvm требует наличие библиотеки hsdis. Она должна находиться в директории $JAVA_HOME/jre/lib/server/. Ее можно либо собрать самостоятельно, либо скачать уже готовый артифакт, например здесь

Но вернемся к нашим баранам. Выяснилось, что jit делает «странное» предположение. Оно заключается в следующем — для массивов четной длины, использовались векторые инструкции, а для работы с массивами нечетной длины он решил их не использовать.

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

int countOfIteration = data.length / 2;
for (int index = 0; index < countOfIteration; index++) {
      final int position = index * 2;
      char firstPart = (char) ((data[position] & 0x00FF) << 8);
      char secondPart = (char) (data[position + 1] & 0x00FF);
      buffer[index] = (char) (firstPart + secondPart);
}

Тут то jit’у было не отвертеться и он стал стабильно компилировать ассемблеровский код с векторными интрукциями независимо от размера массива:

Результаты измерений показывающие разницу:

Benchmark Size of array Mode Score Units
with optimization 100000 avgt 46777.0331 ns/op
with optimization 100001 avgt 47718.458 ns/op
without optimization 100000 avgt 47218.527 ns/op
without optimization 100001 avgt 71312.515 ns/op