問題現象
今天偶然看到了一個 JDK 的 Bug,給大家分享一下。
假設現在有如下的代碼:
List<String> list = new ArrayList<>();
list.add("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
上面的代碼是可以正常支執行的,如下圖所示:
修改代碼為如下代碼:
List<String> list = Arrays.asList("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
再次執行代碼,結果就會拋出 ArrayStoreException 異常,這個異常表明這裏並不能把一個 Integer 類型的對象存放到這個數組裏面。如下圖所示:
查看 Arrays 的靜態內部類 ArrayList 的 toArray() 方法的返回值就是 Object[] 類型的,如下圖所示:
這裏就會引發一個疑問: 為啥使用 java.lang.util.ArrayList 代碼就可以正常運行?但是使用 Arrays 的靜態內部類 ArrayList 就會報錯了?
原因分析
首先看下 java.lang.util.ArrayList 類的 toArray() 方法的實現邏輯:
從上面可以看出 toArray() 方法是拷貝了一個 ArrayList 內部的數組對象,然後返回的。而 elementData 這個數組在實際初始化的時候,就是 new 了 Object 類型的數組。如下圖所示:
那麼經過拷貝之後返回的還是一個實際類型為Object 類型的數組。既然這裏是一個 Object 類型的數組,那麼往裏面放一個 Integer 類型的數據是合法的,因為 Object 是 Integer 類型的父類。
然後再看下 Arrays 的靜態內部類 ArrayList 的 toArray() 方法的實現邏輯。這裏返回的是 a 這個數組的一個克隆。如下圖所示:
而這個 a 數組聲明的類型是 E[],根據泛型擦除後的原則,這裏實際上聲明的類型也變成了 Object[]。 如下圖所示:
那接下來再看看 a 實際的類型是什麼? 由於 Arrays 的靜態內部類 ArrayList 的構造函數是包級訪問的,因此只能通過 Arrays.asList() 靜態方法來構造一個這個對象。如下圖所示:
而 Arrays.asList() 方法的簽名是變長參數類型,這個是 Java 的一個語法糖,實際對應的是一個數組,泛型擦除後就變成了 Object[] 類型。如下圖所示:
而在代碼實際調用處,實際上會 new 一個 String 類型的數組,也就是説 「a 的實際類型是一個 String 類型的數組」。 那麼 a 調用了 clone() 方法之後返回的類型也是一個 String 類型的數組,克隆嘛,類型一樣才叫克隆。如下圖所示:
經過上面的分析,答案就呼之欲出了。a 的實際類型是一個 String 類型的數組,那麼往這個數組裏面放一個 Integer 類型的對象那肯定是要報錯的。等效代碼如下圖所示:
為什麼是個Bug ?
查看 Collection 接口的方法簽名,方法聲明明確是要返回的是一個 Object[] 類型的數組,因為方法明確聲明瞭返回的是一個 Object[] 類型的數組,但是實際上在獲取到了這個返回值後把它當作一個 Object[] 類型的數組使用某些情況下是不滿足語義的。
同時這裏要注意一下,返回的這個數組要是一個 「安全」的數組,安全的意思就是「集合本身不能持有對返回的數組的引用」,即使集合的內部是用數組實現的,也不能直接把這個內部的數組直接返回。這就是為什麼上面兩個 toArray() 方法的實現要麼是把原有的數組複製了一份,要麼是克隆了一份,本質上都是新建了一個數組。如下圖所示:
在 OpenJDK 的 BugList 官網上很早就有人提出這個問題了,從時間上看至少在 2005 年就已經發現這個 Bug 了,這個 Bug 真正被解決是在 2015 年的時候,整整隔了 10 年時間。花了 10 年時間修這個 Bug,真是十年磨一劍啊!
如何修正的這個 Bug ?
JDK 9 中的實現修改為了新建一個 Object 類型的數組,然後把原有數組中的元素拷貝到這個數組裏面,然後返回這個 Object 類型的數組,這樣的話就和 java.util.ArrayList 類中的實現方法一樣了。
在 java.util.ArrayList 類的入參為 Collection\<? exends E> 類型的構造函數中就涉及到可能調用 Arrays 的靜態內部類 ArrayList 的 toArray() 方法,JDK 在實現的時候針對這個 Bug 還做了特殊的處理,不同廠商發行的 JDK 處理方式還有細微的不同。
Oracel JDK 8 版本的實現方式:
Eclipse Temurin Open JDK 8 版本的實現方式:
之所以在 java.util.ArrayList 對這個 Bug 做特殊的處理是因為 Sun 公司在當時選擇不修復改這個Bug,因為怕修復了之後已有的代碼就不能運行了。如下圖所示:
比如在修復前有如下的代碼,這個代碼在 JDK 8 版本是可以正常運行的,如下圖所示:
String[] strings = (String[]) Arrays.asList("foo", "bar").toArray();
for (String string : strings) {
System.out.println(string);
}
但是如果升級到 JDK 9 版本,就會報 ClassCastException 異常了,如下圖所示:
因為修復了這個 Bug 之後,編譯器並不能告訴你原來的代碼存在問題,甚至連新的警告都沒有。假設你從 JDK 8 升級到 JDK 9 了,代碼也沒有改,但是突然功能就用不了,這個時候你想不想罵人,哈哈哈哈。這也許就是 Sun 公司當年不願意修復這個 Bug 的原因之一了。當然,如果你要問我為什麼要升級的話,我會説:你發任你發,我用 Java 8 !
題外話
阿里巴巴的 Java開發手冊對 toArray(T[] array) 方法的調用有如下的建議:
這裏以 java.util.ArrayList 類的源碼作為參考,源碼實現如下:
// ArrayList 的 toArray() 方法實現:
public <T> T[] toArray(T[] a) {
if (a.length < size) // 如果傳入的數組的長度小於 size
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
// Arrays 的 coypyOf 方法實現:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
當調用 toArray() 方法時傳入的數組長度為 0 時,方法內部會根據傳入的數組類型動態創建一個和當前集合 size 相同的數組,然後把集合的元素複製到這個數組裏面,然後返回。
當調用 toArray() 方法時傳入的數組長度大於 0,小於 ArrayList 的 size 時,走的邏輯和上面是一樣的,也會進入到 Arays 的 copyOf 方法的調用中,但是調用方法傳入的新建的數組相當於新建之後沒有被使用,白白浪費了,需要等待 GC 回收。
當調用 toArray() 方法時傳入的數組長度大於等於 ArrayList 的 size 時,則會直接把集合的元素拷貝到這個數組中。如果是大於的情況,還會把數組中下標為 size 的元素設置為 null,但是 size 下標後面的元素保持不變。如下所示:
List<String> list = new ArrayList<>();
list.add("1");
String[] array = new String[3];
array[1] = "2";
array[2] = "3";
String[] toArray = list.toArray(array);
System.out.println(array == toArray);
System.out.println(Arrays.toString(toArray));
手冊中提到的在高併發的情況下,傳入的數組長度等於 ArrayList 的 size 時,如果 ArrayList 的 size 在數組創建完成後變大了,還是會走到重新新建數組的邏輯裏面,仍然會導致調用方法傳入的新建的數組沒有被使用,而且這裏因為調用方法時新建的數組和 ArrayList 之前的 size 相同,會造成比傳入長度為 0 的數組浪費多得多的空間。但是我個人覺得,因為 ArrayList 不是線程安全的,如果存在數據競爭的情況就不應該使用。
參考
Arrays.asList(x).toArray().getClass() should be Object[].class
array cast Java 8 vs Java 9
toArray方法的小陷阱,寫開發手冊的大佬也未能倖免
.toArray(new MyClass[0]) or .toArray(new MyClass[myList.size()])?
Arrays of Wisdom of the Ancients
Java開發手冊(黃山版).pdf