Какая фактическая причина сбоя StringBuilder в многопоточной среде

StringBuffer синхронизируется, но StringBuilder нет! Это было подробно обсуждено в Различия между StringBuilder и StringBuffer.

Здесь есть пример кода (Answer by @NicolasZozol), который адресует две проблемы:

  • сравнивает производительность этих StringBuffer и StringBuilder
  • показывает, что StringBuilder может выйти из строя в многопоточной среде.

Мой вопрос о второй части, что именно заставляет его ошибаться?! Когда вы запускаете код несколько раз, трассировка стека отображается ниже:

Exception in thread "pool-2-thread-2" java.lang.ArrayIndexOutOfBoundsException
    at java.lang.String.getChars(String.java:826)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:416)
    at java.lang.StringBuilder.append(StringBuilder.java:132)
    at java.lang.StringBuilder.append(StringBuilder.java:179)
    at java.lang.StringBuilder.append(StringBuilder.java:72)
    at test.SampleTest.AppendableRunnable.run(SampleTest.java:59)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
    at java.lang.Thread.run(Thread.java:722)

Когда я прослеживаю код, я обнаруживаю, что класс, который на самом деле генерирует исключение: String.class at getChars метод, который вызывает System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin); В соответствии с System.arraycopy javadoc:

Копирует массив из указанного исходного массива, начиная с указанной позиции, в указанную позицию места назначения массив. Подпоследовательность элементов массива копируется из источника массив, на который ссылается src, в массив назначения, на который ссылается dest. Количество копируемых компонентов равно аргументу длины.....

IndexOutOfBoundsException - если копирование приведет к доступу к данным внешние границы массива.

Для простоты я точно вставляю код здесь:

public class StringsPerf {

    public static void main(String[] args) {

        ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
        //With Buffer
        StringBuffer buffer = new StringBuffer();
        for (int i = 0 ; i < 10; i++){
            executorService.execute(new AppendableRunnable(buffer));
        }
        shutdownAndAwaitTermination(executorService);
        System.out.println(" Thread Buffer : "+ AppendableRunnable.time);

        //With Builder
        AppendableRunnable.time = 0;
        executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);
        StringBuilder builder = new StringBuilder();
        for (int i = 0 ; i < 10; i++){
            executorService.execute(new AppendableRunnable(builder));
        }
        shutdownAndAwaitTermination(executorService);
        System.out.println(" Thread Builder: "+ AppendableRunnable.time);

    }

   static void shutdownAndAwaitTermination(ExecutorService pool) {
        pool.shutdown(); // code reduced from Official Javadoc for Executors
        try {
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                pool.shutdownNow();
                if (!pool.awaitTermination(60, TimeUnit.SECONDS))
                    System.err.println("Pool did not terminate");
            }
        } catch (Exception e) {}
    }
}

class AppendableRunnable<T extends Appendable> implements Runnable {

    static long time = 0;
    T appendable;
    public AppendableRunnable(T appendable){
        this.appendable = appendable;
    }

    @Override
    public void run(){
        long t0 = System.currentTimeMillis();
        for (int j = 0 ; j < 10000 ; j++){
            try {
                appendable.append("some string");
            } catch (IOException e) {}
        }
        time+=(System.currentTimeMillis() - t0);
    }
}

Можете ли вы описать более подробно (или с образцом), чтобы показать, как многопоточный вызов System.arraycopy не удается,?! Или как потоки передают invalid data на System.arraycopy?!

Ответ 1

Вот как я это понимаю. Вы должны сделать один шаг назад и посмотреть, где getChars вызывается из метода AbstractStringBuilder append:

public AbstractStringBuilder append(String str) {
    if (str == null) str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}

Метод ensureCapacity будет проверять, что атрибут value достаточно длинный, чтобы сохранить добавленное значение, а если нет, то оно будет соответствующим образом изменяться.

Предположим, что 2 потока вызывают этот метод в том же экземпляре. Имейте в виду, что value и count доступны обоими потоками. В этом надуманном сценарии, скажем, value представляет собой массив размером 5 и в массиве 2 символа, поэтому count=2 (если вы посмотрите на метод length, вы увидите, что он возвращает count).

Thread 1 вызывает append("ABC"), который будет вызывать ensureCapacityInternal и value достаточно большой, чтобы он не изменялся (требуется размер 5). Тема 1 приостановлена.

Thread 2 вызывает append("DEF"), который будет вызывать ensureCapacityInternal и value достаточно большой, чтобы он не изменялся (также требуется размер 5). Тема 2 паузы.

Тема 1 продолжается и вызывает str.getChars без проблем. Затем он вызывает count += len. Тема 1 пауза. Обратите внимание, что value теперь содержит 5 символов и длина 5.

Теперь продолжается поток 2 и вызывает str.getChars. Помните, что он использует те же value и те же count как Thread 1. Но теперь count увеличился и потенциально может быть больше размера value, то есть индекс назначения для копирования больше, чем длина массив, который вызывает IndexOutOfBoundsException при вызове System.arraycopy внутри str.getChars. В нашем надуманном сценарии count=5 и размер value равен 5, поэтому при вызове System.arraycopy он не может скопировать 6-ю позицию массива длиной 5.

Ответ 2

Если вы сравниваете метод append в обоих классах, т.е. StringBuilder и StringBuffer. Вы можете найти StringBuilder.append() не синхронизирован, где StringBuffer.append() синхронизирован.

// StringBuffer.append
public synchronized StringBuffer append(String str) {
    super.append(str);
    return this;
}

// StringBuilder.append
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

Итак, когда вы пытаетесь добавить "some string" с помощью нескольких потоков.

В случае StringBuilder ensureCapacityInternal() вызывается из разных потоков одновременно. Это приводит к изменению размера на основе предыдущего значения в обоих вызовах, и после этого оба потока добавляются "some string", вызывая ArrayIndexOutOfBoundsException.

Например: Строковое значение - это "некоторая строковая строка". Теперь 2 потока хотят добавить "некоторую строку". Таким образом, оба метода вызовут метод ensureCapacityInternal(), и это приведет к увеличению длины, если недостаточно места, но если осталось 11 мест, то он не будет увеличивать размер. Теперь два потока вызвали System.arraycopy с "некоторой строкой" одновременно. И тогда оба потока пытаются добавить "некоторую строку". Поэтому фактическое увеличение длины должно быть 22, но char [] имеет 11 пустых мест внутри него, что приводит к ошибке ArrayIndexOutOfBoundsException.

В случае StringBuffer, метод append уже синхронизирован, поэтому этот сценарий не будет возникать.