JDK序列化Bug難題解決示例詳解
1、背景
最近查看應(yīng)用的崩潰記錄的時候遇到了一個跟 Java 序列化相關(guān)的崩潰,
從崩潰的堆棧來看,整個調(diào)用堆棧里沒有我們自己的代碼信息。崩潰的起點是 Android 系統(tǒng)自動存儲 Fragment 的狀態(tài),也就是將數(shù)據(jù)序列化并寫入 Bundle 時。最終出現(xiàn)問題的代碼則位于 ArrayList 的 writeObject()
方法。
這里順帶說明一下,一般我們在使用序列化的時候只需要讓自己的類實現(xiàn) Serializable 接口即可,最多就是為自己的類增加一個名為 SerialVersionUID
的靜態(tài)字段以標志序列化的版本號。但是,實際上序列化的過程是可以自定義的,也就是通過 writeObject()
和 readObject()
實現(xiàn)。這兩個方法看上去可能比較古怪,因為他們既不存在于 Object 類,也不存在于 Serializable 接口。所以,對它們沒有覆寫一說,并且還是 private 的。從上述堆棧也可以看出,調(diào)用這兩個方法是通過反射的形式調(diào)用的。
2、分析
從堆??闯鰜硎切蛄谢^程中報錯,并且是因為 Fragment 狀態(tài)自動保存過程中報錯,報錯的位置不在我們的代碼中,無法也不應(yīng)該使用 hook 的方式解決。
再從報錯信息看,是多線程修改導(dǎo)致的,也就是因為 ArrayList 并不是線程安全的,所以,如果在調(diào)用序列化的過程中其他線程對 ArrayList 做了修改,那么此時就會拋出 ConcurrentModificationException
異常。
但是! 再進一步看,為了解決 ArrayList 在多線程環(huán)境中不安全的問題,我這里是用了同步容器進行包裝。從堆棧也可以看出,堆棧中包含如下一行代碼,
Collections$SynchronizedCollection.writeObject(Collections.java:2125)
這說明,整個序列化的操作是在同步代碼塊中執(zhí)行的。而就在執(zhí)行過程中,其他線程完成了對 ArrayList 的修改。
再看一下報錯的 ArrayList 的代碼,
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { // Write out element count, and any hidden stuff int expectedModCount = modCount; // 1 s.defaultWriteObject(); // Write out size as capacity for behavioural compatibility with clone() s.writeInt(size); // Write out all elements in the proper order. for (int i=0; i<size; i++) { s.writeObject(elementData[i]); } if (modCount != expectedModCount) { // 2 throw new ConcurrentModificationException(); } }
也就是說,在 writeObject 這個方法執(zhí)行 1 和 2 之間的代碼的時候,容器被修改了。
但是,該方法的調(diào)用是位于同步容器的同步代碼塊中的,這里出現(xiàn)同步錯誤,我首先想到的是如下幾個原因:
- 同步容器的同步鎖沒有覆蓋所有的方法:基本不可能,標準 JDK 應(yīng)該還是嚴謹?shù)?...
- 外部通過反射直接調(diào)用了同步容器內(nèi)的真實數(shù)據(jù):一般不會有這種騷操作
- 執(zhí)行序列化過程的過程跳過了鎖:雖然是反射調(diào)用,但是代碼邏輯的執(zhí)行是在代碼塊內(nèi)部的
- 執(zhí)行序列化方法的過程中釋放了鎖
3、復(fù)現(xiàn)
帶著上述問題,首先還是先復(fù)現(xiàn)該問題。
該異常還是比較容易復(fù)現(xiàn),
private static final int TOTAL_TEST_LOOP = 100; private static final int TOTAL_THREAD_COUNT = 20; private static volatile int writeTaskNo = 0; private static final List<String> list = Collections.synchronizedList(new ArrayList<>()); private static final Executor executor = Executors.newFixedThreadPool(TOTAL_THREAD_COUNT); public static void main(String...args) throws IOException { for (int i = 0; i < TOTAL_TEST_LOOP; i++) { executor.execute(new WriteListTask()); for (int j=0; j<TOTAL_THREAD_COUNT-1; j++) { executor.execute(new ChangeListTask()); } } } private static final class ChangeListTask implements Runnable { @Override public void run() { list.add("hello"); System.out.println("change list job done"); } } private static final class WriteListTask implements Runnable { @Override public void run() { File file = new File("temp"); OutputStream os = null; ObjectOutputStream oos = null; try { os = new FileOutputStream(file); oos = new ObjectOutputStream(os); oos.writeObject(list); oos.flush(); os.flush(); } catch (IOException e) { e.printStackTrace(); } finally { try { oos.close(); os.close(); } catch (IOException e) { e.printStackTrace(); } } System.out.println(String.format("write [%d] list job done", ++writeTaskNo)); } }
這里創(chuàng)建了一個容量為 20 的線程池,遍歷 100 次循環(huán),每次往線程池添加一個序列化的任務(wù)以及 19 個修改列表的操作。
按照上述操作,基本 100% 復(fù)現(xiàn)這個問題。
4、解決
如果只是從堆棧看,這個問題非常“詭異”,它看上去是在執(zhí)行序列化的過程中把線程的鎖釋放了。所以,為了找到問題的原因我做了幾個測試。
當然,我首先想到的是解決并發(fā)修改的問題,除了使用同步容器,另外一種方式是使用并發(fā)容器。ArrayList 對應(yīng)的并發(fā)容器是 CopyOnWriteArrayList
。換了該容器之后可以修復(fù)這個問題。
此外,我用自定義同步鎖的形式在序列化操作的外部對整個序列化過程進行同步,這種方式也可以解決上述問題。
不過,雖然解決了這個問題,此時還存在一個疑問就是序列化過程中鎖是如何“丟”了的。為了更好地分析問題,我 Copy 了一份 JDK 的 SynchronizedList
的源碼,并使用 Copy 的代碼復(fù)現(xiàn)上述問題,試了很多次也沒有出現(xiàn)。所以,這成了“看上去一樣的代碼,但是執(zhí)行起來結(jié)果不同”。感覺非常“詭異”。 ??
最后,我把這個問題放到了 StackOverflow 上面。國外的一個開發(fā)者解答了這個問題,
就是說,
這是 JDK 的一個 bug,并且到 OpenJDK 19.0.2 還沒有解決的一個問題。bug 單位于,
這是因為當我們使用 Collections 的方法 synchronizedList
獲取同步容器的時候(代碼如下),
public static <T> List<T> synchronizedList(List<T> list) { return (list instanceof RandomAccess ? new SynchronizedRandomAccessList<>(list) : new SynchronizedList<>(list)); }
它會根據(jù)被包裝的容器是否實現(xiàn)了 RandomAccess
接口來判斷使用 SynchronizedRandomAccessList
還是 SynchronizedList
進行包裝。RandomAccess 的意思是是否可以在任意位置訪問列表的元素,顯然 ArrayList 實現(xiàn)了這個接口。所以,當我們使用同步容器進行包裝的時候,返回的是 SynchronizedRandomAccessList
這個類而不是 SynchronizedList
的實例.
對 SynchronizedRandomAccessList
,它有一個 writeReplace()
方法
private Object writeReplace() { return new SynchronizedList<>(list); }
這個方法是用來兼容 1.4 之前版本的序列化的,所以,當對 SynchronizedRandomAccessList 執(zhí)行序列化的時候會先調(diào)用 writeReplace()
方法,并將被包裝的 list 對象傳入,然后使用該方法返回的對象進行序列化而不是原始對象。
對于 SynchronizedRandomAccessList,它是 SynchronizedList 的子類,它們對私有鎖的實現(xiàn)機制是相同的,即,兩者都是對自身的實例 (也就是 this
)進行加鎖。所以,兩者持有的 ArrayList 是同一實例,但是加鎖的卻是不同的對象。也就是說,序列化過程中加鎖的對象是 writeReplace()
方法創(chuàng)建的 SynchronizedList 的實例,其他線程修改數(shù)據(jù)時加鎖的是 SynchronizedRandomAccessList 的實例。
驗證的方式比較簡單,在 writeObject()
出打斷點獲取 this 對象和最初的同步容器返回結(jié)果做一個對比即可。
總結(jié)
一個略坑的問題,問題解決比較簡單,但是分析過程有些曲折,主要是被“鎖在序列化過程被釋放了”這個想法誤導(dǎo)。而實際上之所以出現(xiàn)這個問題是因為加鎖的是不同的對象。此外,還有一個原因是,序列化過程許多操作是反射執(zhí)行的,比如 writeReplace()
和 writeObject()
這些方法。如果對 JDK 的序列化過程不了解,很難想到這兩個 private 的方法。
從這個例子中可以得出的另一個結(jié)論就是,同步容器和并發(fā)容器實現(xiàn)邏輯不同,看來在有些情形下兩者起到的效果還是有區(qū)別的。序列化可能是一個極端的例子,但是下次序列化一個列表的時候是否應(yīng)該考慮到 JDK 的這個 bug 呢?
以上就是JDK序列化Bug難題解決示例詳解的詳細內(nèi)容,更多關(guān)于JDK序列化Bug難題解決的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解XML,Object,Json轉(zhuǎn)換與Xstream的使用
這篇文章主要介紹了詳解XML,Object,Json轉(zhuǎn)換與Xstream的使用的相關(guān)資料,需要的朋友可以參考下2017-02-02Java使用easyExcel導(dǎo)出數(shù)據(jù)及單元格多張圖片
除了平時簡單的數(shù)據(jù)導(dǎo)出需求外,我們也經(jīng)常會遇到一些有固定格式或者模板要求的數(shù)據(jù)導(dǎo)出,下面這篇文章主要給大家介紹了關(guān)于Java使用easyExcel導(dǎo)出數(shù)據(jù)及單元格多張圖片的相關(guān)資料,需要的朋友可以參考下2023-05-05springboot應(yīng)用中靜態(tài)資源訪問與接口請求沖突問題解決
這篇文章主要介紹了springboot應(yīng)用中靜態(tài)資源訪問與接口請求沖突,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-06-06