對StringBuilder拋出ArrayIndexOutOfBoundsException的探究

最近在項目開發時遇到一個問題,就是寫好的代碼時不時的報出ArrayIndexOutOfBoundsException的異常,這讓我很困擾。下面是那段代碼的簡化版,只是爲了說明這個問題。

1、 代碼及報錯信息
代碼如下:


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

/**
 * 測試StringBuilder和StringBuffer在多線程情形下的不同表現
 * 說明: StringBuilder是線程不安全的,但是效率較高
 *      StringBuffer是線程安全的,但是效率較低
 * @author Herry
 */
public class StringContactTest {

    public static void main(String[] args) {        
        // 測試StringBuilder拼接方式
        for(int i=0; i < 20; i++) {         
            stringContactWithBuilder();         
        }   
    }

    // 通過StringBuilder來進行字符串拼接
    public static void stringContactWithBuilder(){  
        // 待拼接數據
        List<String> dataList = new ArrayList<>();
        // 模擬賦值
        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)
    at java.util.concurrent.ForkJoinTask.invoke(Unknown Source)
    at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(Unknown Source)
    at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(Unknown Source)
    at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
    at java.util.stream.ReferencePipeline.forEach(Unknown Source)
    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)
    at java.lang.String.getChars(Unknown Source)
    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)
    at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(Unknown Source)
    at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
    at java.util.stream.ForEachOps$ForEachTask.compute(Unknown Source)
    at java.util.concurrent.CountedCompleter.exec(Unknown Source)
    at java.util.concurrent.ForkJoinTask.doExec(Unknown Source)
    at java.util.concurrent.ForkJoinPool.helpComplete(Unknown Source)
    at java.util.concurrent.ForkJoinPool.awaitJoin(Unknown Source)
    at java.util.concurrent.ForkJoinTask.doInvoke(Unknown Source)
    at java.util.concurrent.ForkJoinTask.invoke(Unknown Source)
    at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(Unknown Source)
    at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(Unknown Source)
    at java.util.stream.AbstractPipeline.evaluate(Unknown Source)
    at java.util.stream.ReferencePipeline.forEach(Unknown Source)
    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)
    at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(Unknown Source)
    at java.util.stream.AbstractPipeline.copyInto(Unknown Source)
    at java.util.stream.ForEachOps$ForEachTask.compute(Unknown Source)
    at java.util.concurrent.CountedCompleter.exec(Unknown Source)
    at java.util.concurrent.ForkJoinTask.doExec(Unknown Source)
    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);

這是一個本地(native)方法。直到這裏,我們還是沒有找到拋出ArrayIndexOutOfBoundsException異常的地方,但是唯一可能拋出這個異常的也只有arraycopy()了。由於這是本地方法,無法直接在jdk裏找到源碼,於是在網上找了這個方法的源碼,具體如下:

/* 
java.lang.System中的arraycopy方法 
*/  
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  
  //檢查源數組和目的數組不爲空  
  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  
  //真正調用複製的方法  
  s->klass()->copy_array(s, src_pos, d, dst_pos, length, thread);  
JVM_END  

以上方法沒有真正實現複製,而只是簡單的檢測源數組和目的數組不爲空,排除一些異常情況。下面是具體實現的方法:

/* 
java.lang.System中的arraycopy方法具體實現 
*/  
void ObjArrayKlass::copy_array(arrayOop s, int src_pos, arrayOop d,  
                               int dst_pos, int length, TRAPS) {  
  //檢測s是數組  
  assert(s->is_objArray(), "must be obj array");  

  //目的數組不是數組對象的話,則拋出ArrayStoreException異常  
  if (!d->is_objArray()) {  
    THROW(vmSymbols::java_lang_ArrayStoreException());  
  }  

  // 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  
  //檢測下標參數是否越界  
  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則不需要複製  
  if (length==0) {  
    return;  
  }  
  //UseCompressedOops只是用來區分narrowOop和oop,具體2者有啥區別需要再研究  
  //調用do_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,並行流)中調用的,所以可能有兩個或者多個線程通過了ensureCapacityInternal()方法的空間校驗,而實際空間不足而導致了數組下標越界。下面舉個簡單的例子進行理解。
假如有A、B兩個線程,都需要拼接一個長度爲40的字符串,而當前剩餘空間爲50。當A通過ensureCapacityInternal()檢驗且爲執行getChars()方法時被掛起,這時B線程通過ensureCapacityInternal()對空間進行校驗是可以通過的,因爲40<50。接下來當A、B線程進行數組複製時,後複製的那個線程將出現數組下標越界異常,因爲第一個線程複製完成後,剩下空間只有10。10<40而導致空間不足,下標越界。

3、 結語
所以針對開頭的那個示例代碼,有兩種改法,一種是將並行流(parallelStream)改成串行流(stream),二是將非線程安全的StingBuilder更換成線程安全的StringBuffer。

以前對於多線程程序寫得比較少,遇到這個問題時,有些無從下手,所以決心深究一下其原因。雖然平時也知道StringBuilder是線程不安全、StringBuffer是線程安全的,但是用起來有時候確實不太注意。因此將問題及解決方法記錄下來,希望以後可以引以爲戒,也希望對你有些許幫助,那就足矣。如有哪裏寫得不對,還望大神不吝賜教,不勝感激。

【參考博客】 http://blog.csdn.net/u011642663/article/details/49512643

發佈了95 篇原創文章 · 獲贊 99 · 訪問量 41萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章