Является ли использование собственной емкости int быстрее, чем использование поля .length массива?

В 95% производительности - это чистые представительские модели беседует Мартин Томпсон, между 17 и 21 минутами, представлен такой код:

public class Queue
{
    private final Object[] buffer;
    private final int capacity;

    // Rest of the code

}

В 20:16 он говорит:

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

Я попытался придумать пример кода, в котором capacity будет намного быстрее, чем buffer.length, но я потерпел неудачу.

Мартин говорит, что проблемы возникают в двух сценариях:
  • В параллельном мире. Но поле length также final, JLS 10.7. Итак, я не вижу, как это может быть проблемой.
  • Когда кеш промахивается. я попробовал, вызвав capacity vs buffer.length миллион раз (с очередь, имеющая миллион элементов), но существенной разницы не было. Я использовал JMH для бенчмаркинга.

Не могли бы вы привести пример кода, который демонстрирует случай, когда capacity превосходит buffer.length с точки зрения производительности?

Чем более распространенный случай (часто замеченный в реальном коде), тем лучше.

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

Ответ 1

Когда вы обычно обращаетесь к массиву, JVM использует его length в любом случае для выполнения проверки границ. Но когда вы обращаетесь к массиву через sun.misc.Unsafe (как это делает Мартин), вам не нужно платить это неявное наказание.

Поле Array length обычно находится в той же строке кэша, что и его первые элементы, поэтому у вас будет ложный обмен, когда несколько потоков записываются в первых индексов одновременно. Использование отдельного поля для емкости буфера приведет к нарушению этого ложного обмена.

Вот эталон, показывающий, как поле capacity делает доступ к массиву значительно быстрее:

package bench;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicReferenceArray;

@State(Scope.Benchmark)
@Threads(4)
public class Queue {
    private static final Unsafe unsafe = getUnsafe();
    private static final long base = unsafe.arrayBaseOffset(Object[].class);
    private static final int scale = unsafe.arrayIndexScale(Object[].class);

    private AtomicReferenceArray<Object> atomic;
    private Object[] buffer;
    private int capacity;

    @Param({"0", "25"})
    private volatile int index;

    @Setup
    public void setup() {
        capacity = 32;
        buffer = new Object[capacity];
        atomic = new AtomicReferenceArray<>(capacity);
    }

    @Benchmark
    public void atomicArray() {
        atomic.set(index, "payload");
    }

    @Benchmark
    public void unsafeArrayLength() {
        int index = this.index;
        if (index < 0 || index >= buffer.length) {
            throw new ArrayIndexOutOfBoundsException();
        }
        unsafe.putObjectVolatile(buffer, base + index * scale, "payload");
    }

    @Benchmark
    public void unsafeCapacityField() {
        int index = this.index;
        if (index < 0 || index >= capacity) {
            throw new ArrayIndexOutOfBoundsException();
        }
        unsafe.putObjectVolatile(buffer, base + index * scale, "payload");
    }

    private static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            return (Unsafe) f.get(null);
        } catch (IllegalAccessException | NoSuchFieldException e) {
            throw new AssertionError("Should not happen");
        }
    }
}

Результаты:

Benchmark                  (index)   Mode  Cnt      Score      Error   Units
Queue.atomicArray                0  thrpt    5  41804,825 ±  928,882  ops/ms
Queue.atomicArray               25  thrpt    5  84713,201 ± 1067,911  ops/ms
Queue.unsafeArrayLength          0  thrpt    5  48656,296 ±  676,166  ops/ms
Queue.unsafeArrayLength         25  thrpt    5  88812,863 ± 1089,380  ops/ms
Queue.unsafeCapacityField        0  thrpt    5  88904,433 ±  360,936  ops/ms
Queue.unsafeCapacityField       25  thrpt    5  88633,490 ± 1426,329  ops/ms

Ответ 2

Вы не должны воспринимать слова Мартина напрямую. Когда он сказал: "Использование array.length - это анти-шаблон, который копируется по проектам", я думаю, это лукаво.

Использование поля capacity действительно позволяет улучшить локальность, меньше загрязняет кэши и помогает избежать ложного обмена, но для этого требуется написать действительно ужасный исходный код, который очень далек от того, чтобы быть "чистым и простым", Мартин рекламировал в этот разговор.

Проблема в том, что даже вы не пишете array.length в своем источнике напрямую, JVM в любом случае получает доступ к длине (это значит, обращается к заголовку массива) для каждого индексации массива array[i], чтобы проверить границы. Hotspot JVM имеет проблемы с устранением проверок границ даже в "простых" случаях циклов, и я думаю, что он не может интерпретировать некоторые "внешние" проверки, такие как if (i < capacity) return array[i];, как связанная проверка, т.е. е. свяжите поле емкости и размер массива.

Вот почему, чтобы сделать capacity -pattern имеющим смысл, вам нужно получить доступ к массиву только через Unsafe! Это, к сожалению, отключает многие оптимизации массового цикла.

Посмотрите на реализацию "чистой" очереди Мартина:)

Я также мог бы попытаться объяснить, что подразумевалось под параллельными соображениями при доступе к "окончательному" array.length. Мои эксперименты показывают, что даже "чтение-чтение" параллельного доступа к кеше вводит какой-то "ложный обмен" и замедляет работу. (Я думаю, что инженеры JVM рассмотрели это, когда @sun.misc.Contended выполнили смещение 128 байт с обеих сторон разрешенных полей, вероятно, это должно обеспечить как предварительную выборку двух сторон кеш-кеши, так и "чтение-чтение ложного обмена" не повлияет на производительность.)

Вот почему, когда потребители и производители очереди получают доступ к емкости для обертывания вокруг буфера, они лучше получают доступ к различным объектам, содержащим одно и то же поле (по значению) capacity и ссылку на тот же массив. Доступ к этому массиву через небезопасных производителей и компиляторов обычно обеспечивает доступ к различным областям этого массива, не распространяйте ничего ложно.

IMO антипаттерн теперь должен попытаться реализовать еще один Queue, в то время как люди, стоящие за https://github.com/JCTools/JCTools (включая Martin, btw), оптимизируют это до смерти.

Ответ 3

Я не эксперт JVM и не претендую на понимание его оптимизации.

Рассматривали ли вы просмотр байтового кода, чтобы узнать, какие инструкции выполняются?

public class Queue {

    private final Object[] buffer;
    private final int capacity;

    public Queue(int size) {
        buffer = new Object[size];
        this.capacity =  size;
    }

    public static void main(String... args) {
        Queue q = new Queue(10);
        int c = q.capacity;
        int l = q.buffer.length;
    }
}

Это дизассемблированный байт-код для основного метода выше.

public static void main(java.lang.String...);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
    Code:
      stack=3, locals=4, args_size=1
         0: new           #5                  // class Queue
         3: dup
         4: bipush        10
         6: invokespecial #6                  // Method "<init>":(I)V
         9: astore_1

        10: aload_1
        11: getfield      #4                  // Field capacity:I
        14: istore_2

        15: aload_1
        16: getfield      #3                  // Field buffer:[Ljava/lang/Object;
        19: arraylength

        20: istore_3
        21: return

Мы видим, что обе имеют команду getfield, однако array.length имеет дополнительную команду arraylength

Глядя на спецификацию jvm для arraylength

instructionIsTypeSafe(arraylength, Environment, _Offset, StackFrame,
                      NextStackFrame, ExceptionStackFrame) :- 
    nth1OperandStackIs(1, StackFrame, ArrayType),
    arrayComponentType(ArrayType, _),
    validTypeTransition(Environment, [top], int, StackFrame, NextStackFrame),
    exceptionStackFrame(StackFrame, ExceptionStackFrame).

nth1OperandStackIs. Эта команда проверяет, что входящий является ссылочным типом и ссылается на массив. Если ссылка массива равна NULL, генерирует исключение NullPointerException

arrayComponentType. Проверьте тип элементов. Тип компонента массива X - это X

validTypeTransition - правила проверки типов

Таким образом, длина вызова в массиве имеет дополнительную длину arrail. Очень интересно узнать больше об этом вопросе.

Ответ 4

Я сомневаюсь, что это окажет положительное влияние на производительность. Например, это не поможет устранить связанные проверки в Hotspot. Еще хуже: он может быть быстрее в одной JVM, но, возможно, в следующей версии он болит. Java продолжает получать дополнительные оптимизации, а проверки границ границ - одна вещь, которую они стараются оптимизировать...

Я считаю, что это может быть проблемой перезаписи реального кода очереди, чтобы создать более простой пример. Потому что в реальной очереди вам нужно будет позаботиться об использованной емкости, а иногда вы хотите разрешить верхнюю границу емкости (блокировать производителей, когда потребители не могут идти в ногу). Если у вас есть такой код (с setCapacity/getCapacity и не конечная емкость), и упростите его, удалив логику изменения размера и завершая хранение резервной копии, это то, что вы можете в итоге.