1. ホーム
  2. Java

StringBuilderが投げるArrayIndexOutOfBoundsExceptionの探索

2022-02-14 22:54:22
<パス

それは、書かれたコードが時々ArrayIndexOutOfBoundsExceptionを報告し、私を大いに悩ませたことです。この問題を説明するために、そのコードを簡略化したものを以下に示します。

1. コードとエラーメッセージ
コードは以下の通りです。


import java.util.ArrayList;
import java.util.List;

/**
 * Test the different performance of StringBuilder and StringBuffer in multi-threaded situations
 * StringBuilder is thread-insecure, but more efficient.
 * StringBuffer is thread-safe, but less efficient
 * @author Herry
 */
public class StringContactTest {

    public static void main(String[] args) {        
        // Test the StringBuilder splicing method
        for(int i=0; i < 20; i++) {         
            stringContactWithBuilder();         
        }   
    }

    // String stitching by StringBuilder
    public static void stringContactWithBuilder(){  
        // to be spliced data
        List<String> dataList = new ArrayList<>();
        // simulate assignment
        for (int i = 0; i < 20; i++) {
            dataList.add("data" + i);
        }

        StringBuilder stringBuilder = new StringBuilder();
        dataList.parallelStream().forEach(data -> {     
            stringBuilder.append(data);     
            StringBuilder stringBuilder2 = new StringBuilder();
            dataList.parallelStream().forEach(data2 -> {                
                stringBuilder2.append(data2);               
            });
            System.out.println(stringBuilder2.toString());          
        });     
        System.out.println(stringBuilder.toString());       
    }

}

エラーメッセージは以下の通りです。

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
    at sun.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
    at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
    at java.lang.reflect.Constructor.newInstance(Unknown Source)
    at java.util.concurrent.ForkJoinTask.getThrowableException(Unknown Source)
    at java.util.concurrent.ForkJoinTask.reportException(Unknown Source)
    forkJoinTask.invoke(Unknown Source) at java.util.concurrent.
    ForEachOps$ForEachOp.evaluateParallel(Unknown Source) at java.util.stream.
    at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(Unknown Source)
    at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
    forEach(Unknown Source) at java.util.stream.ReferencePipeline.
    at java.util.stream.ReferencePipeline$Head.forEach(Unknown Source)
    at com.liu.date20170625.StringContactTest.stringContactWithBuilder(StringContactTest.java:32)
    at com.liu.date20170625.StringContactTest.main(StringContactTest.java:17)
Caused by: java.lang.ArrayIndexOutOfBoundsException
    at java.lang.System.arraycopy(Native Method)
    String.getChars(Unknown Source) at java.lang.
    at java.lang.AbstractStringBuilder.append(Unknown Source)
    at java.lang.StringBuilder.append(Unknown Source)
    at com.liu.date20170625.StringContactTest.lambda$2(StringContactTest.java:36)
    at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(Unknown Source)
    forEachRemaining(Unknown Source) at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(Unknown Source)
    at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
    ForEachOps$ForEachTask.compute(Unknown Source) at java.util.stream.
    CountedCompleter.exec(Unknown Source) at java.util.concurrent.
    forkJoinTask.doExec(Unknown Source) at java.util.concurrent.
    at java.util.concurrent.ForkJoinPool.helpComplete(Unknown Source)
    at java.util.concurrent.ForkJoinPool.awaitJoin(Unknown Source)
    forkJoinTask.doInvoke(Unknown Source) at java.util.concurrent.
    forkJoinTask.invoke(Unknown Source) at java.util.concurrent.
    ForEachOps$ForEachOp.evaluateParallel(Unknown Source) at java.util.stream.
    at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(Unknown Source)
    at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
    forEach(Unknown Source) at java.util.stream.ReferencePipeline.
    at java.util.stream.ReferencePipeline$Head.forEach(Unknown Source)
    at com.liu.date20170625.StringContactTest.lambda$0(StringContactTest.java:35)
    at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(Unknown Source)
    forEachRemaining(Unknown Source) at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(Unknown Source)
    at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
    ForEachOps$ForEachTask.compute(Unknown Source) at java.util.stream.
    CountedCompleter.exec(Unknown Source) at java.util.concurrent.
    forkJoinTask.doExec(Unknown Source) at java.util.concurrent.
    at java.util.concurrent.ForkJoinPool$WorkQueue.execLocalTasks(Unknown Source)
    at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(Unknown Source)
    at java.util.concurrent.ForkJoinPool.runWorker(Unknown Source)
    at java.util.concurrent.ForkJoinWorkerThread.run(Unknown Source)


2. 分析
最初はこの例外がどこから投げられたのかわかりませんでしたが、ソースコードを確認したところ、append()メソッドから投げられたことがわかり、以下はその解析過程です。
エラーメッセージのヒントによると、例外メッセージは36行目に表示され、以下はそのソースコードトレースです。StringBuilderのappend()メソッドに移動して、コードは以下のようになります。

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

StringBuilderのappend()メソッドは、親クラスAbstractStringBuilderのappend()メソッドを呼び出すことで実装されています。ここでは、AbstractStringBuilderのappend()メソッドの具体的な実装を見てみましょう。

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

AbstractStringBuilder の append() メソッドが ensureCapacityInternal() を使って、文字列をスティッチングする前に十分なスペースがあるかどうかをチェックし、ない場合は拡張してからスティッチングしていることがわかります。 しかし、ensureCapacityInternal()メソッドのアノテーション(以下に明記)は、このメソッドが非同期であること、すなわちマルチスレッドの場合には安全でないことを明確にしているのです。

/**
  * For positive values of {@code minimumCapacity}, this method
  * behaves like {@code ensureCapacity}, however it is never
  * synchronized.
  * If {@code minimumCapacity} is non positive due to numeric
  * overflow, this method throws {@code OutOfMemoryError}.
  */

スペースチェックが通ったら、getChars()メソッドを呼んで文字列のつなぎ合わせを行います。 getChar()は以下のように実装されています。

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        if (srcBegin < 0) {
            throw new StringIndexOutOfBoundsException(srcBegin);
        }
        if (srcEnd > value.length) {
            throw new StringIndexOutOfBoundsException(srcEnd);
        }
        if (srcBegin > srcEnd) {
            throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
        }
        System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    }

arraycopy()のソースコードまでたどると、以下のようになります。

public static native void arraycopy(Object src, int srcPos,
                                        Object dest, int destPos,
                                        int length);

これはネイティブメソッドです。ここまでで、ArrayIndexOutOfBoundsExceptionがどこで投げられるのかまだわかりませんでしたが、投げられるのはarraycopy()だけでした。これはローカルメソッドであるため、jdkで直接ソースコードを見つけることができないので、以下のようにネット上でこのメソッドのソースコードを見つけました。

/* 
The arraycopy method in java.lang.System 
*/  
JVM_ENTRY(void, JVM_ArrayCopy(JNIEnv *env, jclass ignored, jobject src, jint src_pos,  
                               jobject dst, jint dst_pos, jint length))  
  JVMWrapper("JVM_ArrayCopy");  
  // Check if we have null pointers  
  // Check that the source and destination arrays are not null  
  if (src == NULL || dst == NULL) {  
    THROW(vmSymbols::java_lang_NullPointerException());  
  }  

  arrayOop s = arrayOop(JNIHandles::resolve_non_null(src));  
  arrayOop d = arrayOop(JNIHandles::resolve_non_null(dst));  
  assert(s->is_oop(), "JVM_ArrayCopy: src not an oop");  
  assert(d->is_oop(), "JVM_ArrayCopy: dst not an oop");  
  // Do copy  
  // actually call the method that makes the copy  
  s->klass()->copy_array(s, src_pos, d, dst_pos, length, thread);  
JVM_END  

上記のメソッドは、実際にはコピーを実装しているわけではなく、単にコピー元とコピー先の配列が空でないことを検出し、いくつかの例外を除外しているだけです。以下は、このメソッドの具体的な実装です。

/* 
The concrete implementation of the arraycopy method in java.lang. 
*/  
void ObjArrayKlass::copy_array(arrayOop s, int src_pos, arrayOop d,  
                               int dst_pos, int length, TRAPS) {  
  // detect that s is an array  
  assert(s->is_objArray(), "must be obj array");  

  //Throw an ArrayStoreException if the destination array is not an array object  
  if (!d->is_objArray()) {  
    THROW(vmSymbols::java_lang_ArrayStoreException());  
  }  

  // Check is all offsets and lengths are non negative  
  // Check is all offsets and lengths are non negative  
  if (src_pos < 0 || dst_pos < 0 || length < 0) {  
    THROW(vmSymbols::java_lang_ArrayIndexOutOfBoundsException());  
  }  
  // Check if the ranges are valid  
  // Check if the subscript parameter is out of bounds  
  if (((unsigned int) length + (unsigned int) src_pos) > (unsigned int) s-> length())  
     || (((unsigned int) length + (unsigned int) dst_pos) > (unsigned int) d-> length()) {  
    THROW(vmSymbols::java_lang_ArrayIndexOutOfBoundsException());  
  }  

  // Special case. Boundary cases must be checked first  
  // This allows the following call: copy_array(s, s.length(), d.length(), 0).  
  // This is correct, since the position is supposed to be an 'in between point', i.e., s.length(),  
  // points to the right of the last element.  
  // length==0 then no copy is needed  
  if (length==0) {  
    return;  
  }  
  // UseCompressedOops is only used to distinguish between narrowOop and oop, what is the difference between the two needs to be studied  
  //call the do_copy function to copy  
  if (UseCompressedOops) {  
    narrowOop* const src = objArrayOop(s)->obj_at_addr<narrowOop>(src_pos);  
    narrowOop* const dst = objArrayOop(d)->obj_at_addr<narrowOop>(dst_pos);  
    do_copy<narrowOop>(s, src, d, dst, length, CHECK);  
  } else {  
    oop* const src = objArrayOop(s)->obj_at_addr<oop>(src_pos);  
    oop* const dst = objArrayOop(d)->obj_at_addr<oop>(dst_pos);  
    do_copy<oop> (s, src, d, dst, length, CHECK);  
  }  
}  


例外が発生した場所とその理由を調べるためで、それ以上、例えば do_copy() メソッドの実装は調べない。ensureCapacityInternal()メソッドの非同期性を組み合わせると、ここで例外が投げられた理由を知ることができるのです。 append() メソッドはマルチスレッド (parallelStream) 環境で呼び出されるため、2つ以上のスレッドが ensureCapacityInternal() メソッドのスペースチェックを通過し、配列添え字が境界外にあるためスペースが足りなくなった可能性があります。 わかりやすい例で説明します。
AとBの2つのスレッドがあり、どちらも長さ40の文字列をステッチする必要があり、現在の残りスペースが50の場合、Aが ensureCapacityInternal() によるチェックと getChars() メソッドの実行中にハングしたとき、スレッドBは ensureCapacityInternal() によってスペースをチェックし 次にスレッドAとBが配列をコピーするとき、最初のスレッドがコピーを終了した後、残りスペースは10だけなので後からコピーするスレッドは配列添字境界外の例外を持つことになります。10<40となり、スペースが足りず、添え字が境界外になってしまいます。

3. 結論
つまり、冒頭のサンプルコードについては、並列ストリーム(parallelStream)をシリアルストリーム(stream)に変更する方法と、スレッドセーフでないStingBuilderをスレッドセーフなStringBufferに置き換える方法の2つがあるわけです。

これまでマルチスレッドのプログラムを書くことが少なかったので、この問題に遭遇したときは少し戸惑いましたが、その原因を深く調べてみようと思いました。普段からStringBuilderがスレッドインセキュア、StringBufferがスレッドセーフであることは知っていても、使うときにはあまり気にしないことがあるのです。そこで、問題と解決策を記録し、今後、警告することができることを期待するだけでなく、いくつかのヘルプを持っていることを期待し、それで十分でしょう。何か問題がある場合、私は神々がアドバイスを与えることを躊躇しないことを願って、私は感謝しています。

[参照元ブログ】をご覧ください。] http://blog.csdn.net/u011642663/article/details/49512643