天天看點

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

前言

在公司的測試平台上,對新寫的RPC接口進行測試,但是發現傳回的是無法轉換POJO的異常:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

最初以為隻是業務代碼寫得有問題,結果發現問題并沒有那麼簡單!

排查思路

▐ 業務代碼問題

第一時間認為是自己業務代碼的問題,于是使用公司開源的arthas工具初步确認接口傳回的結果異常。然而事情并不如我所料,arthas顯示我的接口多次調用均正确地傳回了結果。摘取兩次顯示的結果片段:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結
循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

這個結果是出乎我意料的,于是我使用idea遠端調試以進一步确認,結果也正常傳回。是以可以斷定不是業務代碼出現問題。

▐ RPC架構問題

既然不是業務代碼出現問題,那是否可能是我服務端使用的RPC架構出現了問題呢?并且仔細觀察測試平台顯示的異常資訊,可以看到異常是在hsf(公司的RPC架構)中抛出的,異常資訊也是POJO對象轉化異常,是以我大膽猜測是服務端RPC架構的序列化出現了問題。

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

于是根據異常資訊,我在異常抛出的位置打下了斷點進行調試,結果再次出乎我的意料——斷點處并沒有進入并抛異常。是以排除服務端序列化問題。

▐ 測試平台服務端問題

既然不是我服務端的序列化問題,那會不會是測試平台伺服器這邊的序列化問題呢?測試平台的伺服器需要将遠端調用結果以json的形式穿給前端進行展示,而我們使用的RPC架構序列化協定是hessain2,是以在測試平台伺服器在得到接口傳回值後,還需要将其序列化為json給前端展示。

通過詢問RPC架構開發人員,得知測試平台的json序列化目前采用的jackson,故在本地使用jackson對結果進行序列化,結果抛出異常:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

原因是傳回的類内部存在循環引用,導緻jackson序列化時棧溢出。

為了進一步确認采用hessain2序列化協定在服務間調用沒有問題,我在另一個服務中遠端調用本文測試接口,發現結果是正常的。

至此,異常問題基本定位,産生的罪魁禍首在于傳回結果中存在循環引用!

解決方案

由于後續我們也需要對這個接口的傳回值透出至前端展示,并且目前該類修改成本很大,它包含非常多領域的資料,可以說基本不可能修改,是以需要探尋其它的json序列化方案處理該複雜類以透出到前端展示。

▐ Gson

在使用Gson的過程中,暴露了這個複雜類的另一個問題——類中存在某成員與其父類成員同名,導緻Gson抛出IllegalArgumentException異常,而目前該類是無法修改的,是以排除Gson方案。

▐ fastjson

另一個就是常用的就是公司開源的fastjson了,最初直接使用toJSONString接口進行序列化,結果抛出了空指針異常。為了一探究竟,開始扒一扒它的源碼。

閱讀com.alibaba.fastjson.serializer.JavaBeanSerializer代碼可以看到:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

fastjson通過getter擷取成員并進行序列化,而這裡的getter閱讀com.alibaba.fastjson.util.TypeUtils.computeGetters代碼:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

可以看到getter是根據getXxx方法得到,是以,它會調用getXxx方法,而我們的類中存在與成員無關的get方法,同時該方法并沒有做好異常處理,是以在調用該方法時抛出了空指針異常。

為了解決這個問題,fastjson在1.2.7之後支援SerializerFeature.IgnoreNonFieldGetter參數,直接在toJSONString接口中添加即可。

至此,序列化結果順利透出,問題得到解決。對于我們這種複雜傳回類型,也暴露了json序列化容易踩的坑,未來需要序列化的對象盡量遵循幾個原則:

  1. 避免循環依賴;
  2. 子類不與父類定義相同名稱的成員;
  3. 避免定義非成員變量的getter/setter方法。

循環引用解決原理

循環引用造成問題的場景主要可以分為序列化和對象建立兩種,假定類A對象引用類B對象,類B對象引用類A對象:

  1. 序列化A對象的時候,同時又會序列化B對象,序列化B對象的時候,又會反過來序列化A對象,如果采用遞歸實作,最終會導緻堆棧溢出,如果采用循環實作,則會導緻死循環;
  2. 建立A對象的時候,需要裝載B對象,而B對象又反過來需要裝載A,最終導緻對象建立失敗。

為了解決循環引用帶來的上述問題,本質上需要将循環引用中的其中一個對象緩存起來,以避免重複地序列化或建立。具體案例如下:

▐ fastjson/hessian

前文提到的jackson和Gson是不支援存在循環引用對象的序列化的,fastjson/hessain則是解決序列化場景循環引用的典型。閱讀com.alibaba.fastjson.serializer.JavaBeanSerializer代碼可以看到:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

它有專門針對循環引用的方法,這裡實際是對對象進行了緩存,如果引用的對象在緩存中,則不進一步進行序列化,而是以ref符号代替,規則如下:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

hessian實際也是采用的相同的方式解決循環引用問題的,閱讀com.caucho.hessian.io.AbstractSerializer即可看到:

循環引用導緻的問題與解決方案前言排查思路解決方案循環引用解決原理總結

這裡采用的方式是一樣的,每次序列化前先判斷是否在緩存中即可。

▐ Spring

Spring是自動建立對象場景的典型,它采用三級緩存的方式解決循環引用對象的建立。

一級緩存:已經完全建立好的對象的緩存;

二級緩存:正在建立中,某些成員還未裝載的對象的緩存;

三級緩存:存放建立對象方法的緩存(即存放工廠,而非對象的緩存)。

假定類A對象引用類B對象,類B對象引用類A對象,在建立類A對象的過程中,需要裝載B對象,這時首先會在一級緩存中尋找B對象,若沒有,則在二級緩存在找,若依然沒有,則會從三級緩存找到建立B的方法,并建立一個"裸"bean(未裝載成員對象的bean),放進二級緩存,然後将這個對象裝載給A對象,同時還會将三級緩存中建立B的方法移除,防止重複建立,最後将A對象放入一級緩存。建立B對象時,直接在一級緩存中即可找到A對象進行裝載,最後再将自己放入一級緩存中。

實際整個過程中,二級緩存承擔的是解決循環引用問題的角色,個人了解三級緩存主要是為了實作上的優雅而存在的,沒有也不影響循環引用問題的解決。

總結

從開發側和工具側兩個方面做一些總結:

  • 開發側:json序列化是很容易踩坑的,未來需要序列化的對象盡量做到避免循環依賴、子類不與父類定義相同名稱的成員、避免定義非成員變量的getter/setter方法。
  • 工具側:如果涉及到序列化和對象建立工具的開發,那麼需要考慮循環引用問題的解決,主要方法即将循環引用中的其中一個對象緩存起來,以避免重複地序列化或建立。