При написании кода для максимальной производительности особое внимание необходимо уделять используемым библиотекам,
даже если эти библиотеки являются частью вашего языка программирования. Необходимо подробно изучать не только контракты,
описанные в их API, но и детали их реализации. Для высокопроизводительного кода детали реализации играют огромное значение.
В
предыдущей записи про оптимизацию кода,
я сравнивал
класс ByteBuffer с int[].
Для этого сравнения я использовал прямой буфер (direct buffer), получаемый вызовом метода
allocateDirect.
Это тот самый буфер, который позволяет вынести данные за пределы кучи в языке Java. А что будет если заменить его
на буфер в куче (heap buffer), получаемый вызовом метода
allocate?
Проверить это не сложно. Достаточно заменить в одной строчке кода allocateDirect на allocate и произвести измерения.
Результаты одного из прогонов
моей простенькой тестовой программы,
в сравнении с предыдущими результатами для прямого буфера, выглядят так (в таблице показано время в наносекундах,
затрачиваемое в расчете на одну итерацию цикла сложения целых чисел из буфера):
РазмерViaByteBuffer2 (direct)ViaByteBuffer3 (heap)
1 0000.779.61
10 0000.779.60
100 0000.869.55
1 000 0000.919.68
10 000 0000.949.76
Скорость работы упала более чем в 10 раз. Чем это можно объяснить? Объяснение легко и просто увидеть, если посмотреть на детали реализации
класса ByteBuffer. Внутри методов allocateDirect и allocate создаются конкретные реализации -
DirectByteBuffer и HeapByteBuffer соответственно.
Посмотрим на реализацию метода getInt в классе DirectByteBuffer:
public int getInt(int i) {
return getInt(ix(checkIndex(i, (1 << 2))));
}
private int getInt(long a) {
if (unaligned) {
int x = unsafe.getInt(a);
return (nativeByteOrder ? x : Bits.swap(x));
}
return Bits.getInt(a, bigEndian);
}
private long ix(int i) {
return address + (i << 0);
}
// В классе Buffer
final int checkIndex(int i, int nb) {
if ((i < 0) || (nb > limit - i))
throw new IndexOutOfBoundsException();
return i;
}
Видим достаточно прямолинейный код, который проверяет границы, вычисляет адрес, и, при естественном порядке байт и возможности платформы
работать с целыми числами независимо от выравнивания, достает целое число напрямую через unsafe.getInt - этот вызов является
встроенным (intrinsic) в HotSpot и транслируется в соответствующую инструкцию процессора.
А что происходит в классе HeapByteBuffer? Давайте посмотрим:
public int getInt(int i) {
return Bits.getInt(this, ix(checkIndex(i, 4)), bigEndian);
}
// В классе Bits
static int getInt(ByteBuffer bb, int bi, boolean bigEndian) {
return bigEndian ? getIntB(bb, bi) : getIntL(bb, bi) ;
}
static int getIntL(ByteBuffer bb, int bi) {
return makeInt(bb._get(bi + 3),
bb._get(bi + 2),
bb._get(bi + 1),
bb._get(bi ));
}
static private int makeInt(byte b3, byte b2, byte b1, byte b0) {
return (((b3 ) << 24) |
((b2 & 0xff) << 16) |
((b1 & 0xff) << 8) |
((b0 & 0xff) ));
}
// Снова в классе HeapByteBuffer
byte _get(int i) {
return hb[i];
}
Даже не вооруженным глазом видно что здесь кода и производимых им вычислений намного больше. После такой же проверки границ как и раньше,
каждый их 4-х байтов достается по одному и, путем сдвигов и логических операций, превращается в целое число.
Можно ли из этого сделать вывод, что прямой буфер работает быстрей, чем буфер в куче? Конечно нет! Мы всего лишь увидели,
что метод getInt в прямом буфере работает быстрей, однако это ничего не говорит о скорости работы других операций.
При написания высокопроизводительного кода, важны детали реализации именно тех методов, которые вы используете, и конкретный
способ их применения для решения вашей задачи.
UPDATE: А еще имеет значение что и как замеряется. Подробности в заметке
"
О замерах времени работы кода или знай что замеряешь часть первая"