Рубрики
Без рубрики

Реализация кольцевого буфера в Java

Узнайте, как реализовать кольцевой буфер в Java.

Автор оригинала: Priyank Srivastava.

1. Обзор

В этом уроке мы узнаем, как реализовать кольцевой буфер в Java.

2. Кольцевой Буфер

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

2.1. Как Это Работает

Кольцевой буфер реализован с использованием массива фиксированного размера, который обтекает границы .

Помимо массива, он отслеживает три вещи:

  • следующий доступный слот в буфере для вставки элемента,
  • следующий непрочитанный элемент в буфере,
  • и конец массива – точка, в которой буфер оборачивается к началу массива

Механика того, как кольцевой буфер обрабатывает эти требования, зависит от реализации. Например, в записи Wikipedia по этому вопросу показан метод, использующий четыре указателя.

Мы позаимствуем этот подход из реализации Disruptor кольцевого буфера с использованием последовательностей.

Первое, что нам нужно знать, – это емкость-фиксированный максимальный размер буфера. Далее, мы будем использовать две монотонно возрастающие | последовательности :

  • Последовательность записи: начиная с -1, увеличивается на 1, когда мы вставляем элемент
  • Последовательность чтения: начиная с 0, увеличивается на 1 по мере потребления элемента

Мы можем сопоставить последовательность с индексом в массиве с помощью операции mod:

arrayIndex = sequence % capacity

Операция mod обертывает последовательность вокруг границ, чтобы получить слот в буфере :

Давайте посмотрим, как мы вставим элемент:

buffer[++writeSequence % capacity] = element

Мы предварительно увеличиваем последовательность перед вставкой элемента.

Чтобы потреблять элемент, мы делаем постинкремент:

element = buffer[readSequence++ % capacity]

В этом случае мы выполняем последующее приращение последовательности. Потребление элемента не удаляет его из буфера – он просто остается в массиве до тех пор, пока не будет перезаписан .

2.2. Пустые и Полные буферы

Когда мы обернем массив, мы начнем перезаписывать данные в буфере. Если буфер заполнен, мы можем либо перезаписать самые старые данные независимо от того, потребил ли их читатель, либо предотвратить перезапись данных, которые не были прочитаны .

Если читатель может позволить себе пропустить промежуточные или старые значения (например, тикер цены акций), мы можем перезаписать данные, не дожидаясь их использования. С другой стороны, если читатель должен потреблять все значения (как в случае с транзакциями электронной коммерции), мы должны ждать (блокировать/занят-ждать), пока в буфере не появится свободный слот.

Буфер заполнен , если размер буфера равен его емкости , где его размер равен количеству непрочитанных элементов:

size = (writeSequence - readSequence) + 1
isFull = (size == capacity)

Если последовательность записи отстает от последовательности чтения, буфер пуст :

isEmpty = writeSequence < readSequence

Буфер возвращает значение null , если он пуст.

2.2. Преимущества и недостатки

Кольцевой буфер-это эффективный буфер FIFO. Он использует массив фиксированного размера, который может быть предварительно выделен заранее и обеспечивает эффективный шаблон доступа к памяти. Все буферные операции являются постоянными по времени O(1) , включая потребление элемента, так как для этого не требуется смещение элементов.

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

3. Реализация на Java

Теперь, когда мы поняли, как работает кольцевой буфер, давайте перейдем к его реализации в Java.

3.1. Инициализация

Во-первых, давайте определим конструктор, который инициализирует буфер с предопределенной емкостью:

public CircularBuffer(int capacity) {
    this.capacity = (capacity < 1) ? DEFAULT_CAPACITY : capacity;
    this.data = (E[]) new Object[this.capacity];
    this.readSequence = 0;
    this.writeSequence = -1;
}

Это создаст пустой буфер и инициализирует поля последовательности, как описано в предыдущем разделе.

3.2. Предложение

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

Давайте реализуем метод offer в Java:

public boolean offer(E element) {
    boolean isFull = (writeSequence - readSequence) + 1 == capacity;
    if (!isFull) {
        int nextWriteSeq = writeSequence + 1;
        data[nextWriteSeq % capacity] = element;
        writeSequence++;
        return true;
    }
    return false;
}

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

Давайте попробуем:

@Test
public void givenCircularBuffer_whenAnElementIsEnqueued_thenSizeIsOne() {
    CircularBuffer buffer = new CircularBuffer<>(defaultCapacity);

    assertTrue(buffer.offer("Square"));
    assertEquals(1, buffer.size());
}

3.3. Опрос

Наконец, мы реализуем операцию poll , которая извлекает и удаляет следующий непрочитанный элемент. Операция poll не удаляет элемент, а увеличивает последовательность чтения .

Давайте его реализуем:

public E poll() {
    boolean isEmpty = writeSequence < readSequence;
    if (!isEmpty) {
        E nextValue = data[readSequence % capacity];
        readSequence++;
        return nextValue;
    }
    return null;
}

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

Давайте проверим это:

@Test
public void givenCircularBuffer_whenAnElementIsDequeued_thenElementMatchesEnqueuedElement() {
    CircularBuffer buffer = new CircularBuffer<>(defaultCapacity);
    buffer.offer("Triangle");
    String shape = buffer.poll();

    assertEquals("Triangle", shape);
}

4. Проблема Производитель-Потребитель

Мы говорили об использовании кольцевого буфера для обмена данными между двумя или более потоками, что является примером проблемы синхронизации, называемой проблемой Производитель-Потребитель . В Java мы можем решить проблему производителя-потребителя различными способами, используя семафоры , ограниченные очереди , кольцевые буферы и т. Д.

Давайте реализуем решение, основанное на кольцевом буфере.

4.1. изменчивые поля Последовательности

Наша реализация кольцевого буфера не является потокобезопасной. Давайте сделаем его потокобезопасным для простого случая с одним производителем и одним потребителем.

Производитель записывает данные в буфер и увеличивает последовательность записи , в то время как потребитель только читает из буфера и увеличивает последовательность чтения . Таким образом, резервный массив свободен от конфликтов, и мы можем уйти без какой-либо синхронизации.

Но нам все равно нужно убедиться, что потребитель может видеть последнее значение поля последовательность записи ( видимость ) и что Последовательность записи не обновляется до того, как данные будут фактически доступны в буфере ( упорядочение ).

В этом случае мы можем сделать кольцевой буфер параллельным и свободным от блокировки, сделав поля последовательности изменчивыми :

private volatile int writeSequence = -1, readSequence = 0;

В методе offer запись в volatile поле последовательность записи гарантирует, что запись в буфер произойдет до обновления последовательности. В то же время гарантия видимости volatile гарантирует, что потребитель всегда будет видеть последнее значение writeSequence .

4.2. Производитель

Давайте реализуем простой производитель Runnable , который записывает данные в кольцевой буфер:

public void run() {
    for (int i = 0; i < items.length;) {
        if (buffer.offer(items[i])) {
           System.out.println("Produced: " + items[i]);
            i++;
        }
    }
}

Поток-производитель будет ждать пустого слота в цикле (занято-ожидание).

4.3. Потребитель

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

public T[] call() {
    T[] items = (T[]) new Object[expectedCount];
    for (int i = 0; i < items.length;) {
        T item = buffer.poll();
        if (item != null) {
            items[i++] = item;
            System.out.println("Consumed: " + item);
        }
    }
    return items;
}

Поток-потребитель продолжает работу без печати, если он получает значение null из буфера.

Давайте напишем наш код драйвера:

executorService.submit(new Thread(new Producer(buffer)));
executorService.submit(new Thread(new Consumer(buffer)));

Выполнение нашей программы “производитель-потребитель” дает результат, как показано ниже:

Produced: Circle
Produced: Triangle
  Consumed: Circle
Produced: Rectangle
  Consumed: Triangle
  Consumed: Rectangle
Produced: Square
Produced: Rhombus
  Consumed: Square
Produced: Trapezoid
  Consumed: Rhombus
  Consumed: Trapezoid
Produced: Pentagon
Produced: Pentagram
Produced: Hexagon
  Consumed: Pentagon
  Consumed: Pentagram
Produced: Hexagram
  Consumed: Hexagon
  Consumed: Hexagram

5. Заключение

В этом уроке мы узнали, как реализовать кольцевой буфер, и изучили, как его можно использовать для решения проблемы “производитель-потребитель”.

Как обычно, исходный код для всех примеров доступен на GitHub .