這是我之前項目同學遇到的一個問題,現實代碼比較複雜,現在我將盡可能簡單的描述這個問題,並且內容重心會放在預防階段。
一、問題的起源
先看一個非常簡單的model類Boy:
public class Boy {
public String boyName;
public Girl girl;
public class Girl {
public String girlName;
}
}
項目中一般都會有非常多的model類,比如界面上的每個卡片,都是解析Server返回的數據,然後解析出一個個卡片model對吧。
對於解析Server數據,大多數情況下,Server返回的是json字符串,而我們客戶端會使用Gson進行解析。
那我們看下上例這個Boy類,通過Gson解析的代碼:
public class Test01 {
public static void main(String[] args) {
Gson gson = new Gson();
String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}";
Boy boy = gson.fromJson(boyJsonStr, Boy.class);
System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName);
}
}
運行結果是?
我們來看一眼:
boy name is = zhy , girl name is = lmj
非常正常哈,符合我們的預期。
忽然有一天,有個同學給girl類中新增了一個方法getBoyName(),想獲取這個女孩心目男孩的名稱,很簡單:
public class Boy {
public String boyName;
public Girl girl;
public class Girl {
public String girlName;
public String getBoyName() {
return boyName;
}
}
}
看起來,代碼也沒毛病,要是你讓我在這個基礎上新增getBoyName(),可能代碼也是這麼寫的。
但是,這樣的代碼埋下了深深的坑。
什麼樣的坑呢?
再回到我們的剛纔測試代碼,我們現在嘗試解析完成json字符串,調用一下girl.getBoyName():
public class Test01 {
public static void main(String[] args) {
Gson gson = new Gson();
String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}";
Boy boy = gson.fromJson(boyJsonStr, Boy.class);
System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName);
// 新增
System.out.println(boy.girl.getBoyName());
}
}
很簡單,加了一行打印。
這次,大家覺得運行結果是什麼樣呢?
還是沒問題?當然不是,結果:
boy name is = zhy , girl name is = lmj
Exception in thread "main" java.lang.NullPointerException
at com.example.zhanghongyang.blog01.model.Boy$Girl.getBoyName(Boy.java:12)
at com.example.zhanghongyang.blog01.Test01.main(Test01.java:15)
Boy$Girl.getBoyName
報出了npe,是girl爲null?明顯不是,我們上面打印了girl.name,那更不可能是boy爲null了。
那就奇怪了,getBoyName裏面就一行代碼:
public String getBoyName() {
return boyName; // npe
}
到底是誰爲null呢?
二、令人不解的空指針
return boyName;
只能猜測是某對象.boyName,這個某對象是null了。
這個某對象是誰呢?
我們重新看下getBoyName()返回的是boy對象的boyName字段,這個方法更細緻一些寫法應該是:
public String getBoyName() {
return Boy.this.boyName;
}
所以,現在問題清楚了,確實是Boy.this這個對象是null。
** 那麼問題來了,爲什麼經過Gson序列化之後需,這個對象爲null呢?**
想搞清楚這個問題,還有個前置問題:
在Girl類裏面爲什麼我們能夠訪問外部類Boy的屬性以及方法?
三、非靜態內部類的一些祕密
探索Java代碼的祕密,最好的手段就是看字節碼了。
我們下去一看Girl的字節碼,看看getBodyName()這個“罪魁禍首”到底是怎麼寫的?
javap -v Girl.class
看下getBodyName()的字節碼:
public java.lang.String getBoyName();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #1 // Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy;
4: getfield #3 // Field com/example/zhanghongyang/blog01/model/Boy.boyName:Ljava/lang/String;
7: areturn
可以看到aload_0,肯定是this對象了,然後是getfield獲取 t h i s 0 字 段 , 再 通 過 this0字段,再通過 this0字段,再通過this0再去getfield獲取boyName字段,也就是說:
public String getBoyName() {
return boyName;
}
相當於:
public String getBoyName(){
return $this0.boyName;
}
那麼這個$this0哪來的呢?
我們再看下Girl的字節碼的成員變量:
final com.example.zhanghongyang.blog01.model.Boy this$0;
descriptor: Lcom/example/zhanghongyang/blog01/model/Boy;
flags: ACC_FINAL, ACC_SYNTHETIC
其中果然有個this$0字段,這個時候你獲取困惑,我的代碼裏面沒有呀?
我們稍後解釋。
再看下這個this$0在哪兒能夠進行賦值?
翻了下字節碼,發現Girl的構造方法是這麼寫的:
public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);
descriptor: (Lcom/example/zhanghongyang/blog01/model/Boy;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #1 // Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy;
5: aload_0
6: invokespecial #2 // Method java/lang/Object."<init>":()V
9: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/example/zhanghongyang/blog01/model/Boy$Girl;
0 10 1 this$0 Lcom/example/zhanghongyang/blog01/model/Boy;
可以看到這個構造方法包含一個形參,即Boy對象,最終這個會賦值給我們的$this0。
而且我們還發下一件事,我們再整體看下Girl的字節碼:
public class com.example.zhanghongyang.blog01.model.Boy$Girl {
public java.lang.String girlName;
final com.example.zhanghongyang.blog01.model.Boy this$0;
public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);
public java.lang.String getBoyName();
}
其只有一個構造方法,就是我們剛纔說的需要傳入Boy對象的構造方法。
這塊有個小知識,並不是所有沒寫構造方法的對象,都會有個默認的無參構造喲。
也就是說:
如果你想構造一個正常的Girl對象,理論上是必須要傳入一個Boy對象的。
所以正常的你想構建一個Girl對象,Java代碼你得這麼寫:
public static void testGenerateGirl() {
Boy.Girl girl = new Boy().new Girl();
}
先有body纔能有girl。
這裏,我們搞清楚了非靜態內部類調用外部類的祕密了,我們再來想想Java爲什麼要這麼設計呢?
因爲Java支持非靜態內部類,並且該內部類中可以訪問外部類的屬性和變量,但是在編譯後,其實內部類會變成獨立的類對象,例如下圖:
讓另一個類中可以訪問另一個類裏面的成員,那就必須要把被訪問對象傳進入了,想一定能傳入,那麼就是唯一的構造方法最合適了。可以看到Java編譯器爲了支持一些特性,背後默默的提供支持,其實這種支持不僅於此,非常多的地方都能看到,而且一些在編譯期間新增的這些變量和方法,都會有個修飾符去修飾:ACC_SYNTHETIC。
不信,你再仔細看下$this0的聲明。
final com.example.zhanghongyang.blog01.model.Boy this$0;
descriptor: Lcom/example/zhanghongyang/blog01/model/Boy;
flags: ACC_FINAL, ACC_SYNTHETIC
到這裏,我們已經完全瞭解這個過程了,肯定是Gson在反序列化字符串爲對象的時候沒有傳入body對象,然後造成$this0其實一直是null,當我們調用任何外部類的成員方法、成員變量是,熬的一聲給你扔個NullPointerException。
四、Gson怎麼構造的非靜態匿名內部類對象?
現在我就一個好奇點,因爲我們已經看到Girl是沒有無參構造的,只有一個包含Boy參數的構造方法,那麼Girl對象Gson是如何創建出來的呢?
是找到帶Body參數的構造方法,然後反射newInstance,只不過Body對象傳入的是null?
好像也能講的通,下面看代碼看看是不是這樣吧:
這塊其實和我之前寫的另一個Gson的坑的源碼分析類似了:
Android避坑指南,Gson與Kotlin碰撞出一個不安全的操作
我就長話短說了:
Gson裏面去構建對象,一把都是通過找到對象的類型,然後找對應的TypeAdapter去處理,本例我們的Girl對象,最終會走走到ReflectiveTypeAdapterFactory.create然後返回一個TypeAdapter。
我只能再搬運一次了:
# ReflectiveTypeAdapterFactory.create
@Override
public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
Class<? super T> raw = type.getRawType();
if (!Object.class.isAssignableFrom(raw)) {
return null; // it's a primitive!
}
ObjectConstructor<T> constructor = constructorConstructor.get(type);
return new Adapter<T>(constructor, getBoundFields(gson, type, raw));
}
重點看constructor這個對象的賦值,它一眼就知道跟構造對象相關。
# ConstructorConstructor.get
public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
final Type type = typeToken.getType();
final Class<? super T> rawType = typeToken.getRawType();
// ...省略一些緩存容器相關代碼
ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);
if (defaultConstructor != null) {
return defaultConstructor;
}
ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);
if (defaultImplementation != null) {
return defaultImplementation;
}
// finally try unsafe
return newUnsafeAllocator(type, rawType);
}
可以看到該方法的返回值有3個流程:
newDefaultConstructor
newDefaultImplementationConstructor
newUnsafeAllocator
我們先看第一個newDefaultConstructor
private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) {
try {
final Constructor<? super T> constructor = rawType.getDeclaredConstructor();
if (!constructor.isAccessible()) {
constructor.setAccessible(true);
}
return new ObjectConstructor<T>() {
@SuppressWarnings("unchecked") // T is the same raw type as is requested
@Override public T construct() {
Object[] args = null;
return (T) constructor.newInstance(args);
// 省略了一些異常處理
};
} catch (NoSuchMethodException e) {
return null;
}
}
可以看到,很簡單,嘗試獲取了無參的構造函數,如果能夠找到,則通過newInstance反射的方式構建對象。
追隨到我們的Girl的代碼,並沒有無參構造,從而會命中NoSuchMethodException,返回null。
返回null會走newDefaultImplementationConstructor,這個方法裏面都是一些集合類相關對象的邏輯,直接跳過。
那麼,最後只能走:newUnsafeAllocator 方法了。
從命名上面就能看出來,這是個不安全的操作。
newUnsafeAllocator最終是怎麼不安全的構建出一個對象呢?
往下看,最終執行的是:
public static UnsafeAllocator create() {
// try JVM
// public class Unsafe {
// public Object allocateInstance(Class<?> type);
// }
try {
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
Field f = unsafeClass.getDeclaredField("theUnsafe");
f.setAccessible(true);
final Object unsafe = f.get(null);
final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class);
return new UnsafeAllocator() {
@Override
@SuppressWarnings("unchecked")
public <T> T newInstance(Class<T> c) throws Exception {
assertInstantiable(c);
return (T) allocateInstance.invoke(unsafe, c);
}
};
} catch (Exception ignored) {
}
// try dalvikvm, post-gingerbread use ObjectStreamClass
// try dalvikvm, pre-gingerbread , ObjectInputStream
}
嗯…我們上面猜測錯了,Gson實際上內部在沒有找到它認爲合適的構造方法後,通過一種非常不安全的方式構建了一個對象。
關於更多UnSafe的知識,可以參考:
五、如何避免這個問題?
其實最好的方式,會被Gson去做反序列化的這個model對象,儘可能不要去寫非靜態內部類。
在Gson的用戶指南中,其實有寫到:
https://github.com/google/gson/blob/master/UserGuide.md#TOC-Nested-Classes-including-Inner-Classes-
大概意思是如果你有要寫非靜態內部類的case,你有兩個選擇保證其正確:
- 內部類寫成靜態內部類;
- 自定義InstanceCreator
2的示例代碼在這,但是我們不建議你使用。
嗯…所以,我簡化的翻譯一下,就是:
別問,問就是加static
不要使用這種口頭的要求,怎麼能讓團隊的同學都自覺遵守呢,誰不注意就會寫錯,所以一般遇到這類約定性的寫法,最好的方式就是加監控糾錯,不這麼寫,編譯報錯。
六、那就來監控一下?
我在腦子裏面大概想了下,有4種方法可能可行。
嗯…你也可以選擇自己想下,然後再往下看。
- 最簡單、最暴力,編譯的時候,掃描model所在目錄,直接讀java源文件,做正則匹配去發現非靜態內部類,然後然後隨便找個編譯時的task,綁在它前面,就能做到每次編譯時都運行了。
- Gradle Transform,這個不要說了,掃描model所在包下的class類,然後看類名如果包含A B 的 形 式 , 且 構 造 方 法 中 只 有 一 個 需 要 A 的 構 造 且 成 員 變 量 包 含 B的形式,且構造方法中只有一個需要A的構造且成員變量包含 B的形式,且構造方法中只有一個需要A的構造且成員變量包含this0拿下。
- AST 或者lint做語法樹分析;
- 運行時去匹配,也是一樣的,運行時去拿到model對象的包路徑下所有的class對象,然後做規則匹配。
好了,以上四個方案是我臨時想的,理論上應該都可行,實際上不一定可行,歡迎大家嘗試,或者提出新方案。
有新的方案,求留言補充下知識面
鑑於篇幅…
不,其實我一個都沒寫過,不太想都寫一篇了,這樣博客太長了。
- 方案1,大家拍大腿都能寫出來,過,不過我感覺1最實在了,而且觸發速度極快,不怎麼影響研發體驗;
- 方案2,大家查一下Transform基本寫法,利用javassist,或者ASM,估計也問題不大,過;
- 方案3,AST的語法我也要去查,我寫起來也費勁,過;
- 方案4,是我最後一個想出來的,寫一下吧。
其實方案4,如果你看到ARouter的早期版本的初始化,你就明白了。
其實就是遍歷dex中所有的類,根據包+類名規則去匹配,然後就是發射API了。
我們一起寫下。
運行時,我們要遍歷類,就是拿到dex,怎麼拿到dex呢?
可以通過apk獲取,apk怎麼拿呢?其實通過cotext就能拿到apk路徑。
public class PureInnerClassDetector {
private static final String sPackageNeedDetect = "com.example.zhanghongyang.blog01.model";
public static void startDetect(Application context) {
try {
final Set<String> classNames = new HashSet<>();
ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);
DexFile dexfile = new DexFile(sourceApk);
Enumeration<String> dexEntries = dexfile.entries();
while (dexEntries.hasMoreElements()) {
String className = dexEntries.nextElement();
Log.d("zhy-blog", "detect " + className);
if (className.startsWith(sPackageNeedDetect)) {
if (isPureInnerClass(className)) {
classNames.add(className);
}
}
}
if (!classNames.isEmpty()) {
for (String className : classNames) {
// crash ?
Log.e("zhy-blog", "編寫非靜態內部類被發現:" + className);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
private static boolean isPureInnerClass(String className) {
if (!className.contains("$")) {
return false;
}
try {
Class<?> aClass = Class.forName(className);
Field $this0 = aClass.getDeclaredField("this$0");
if (!$this0.isSynthetic()) {
return false;
}
// 其他匹配條件
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
啓動app:
以上僅爲demo代碼,並不嚴謹,需要自行完善。
就幾十行代碼,首先通過cotext拿ApplicationInfo,那麼apk的path,然後構建DexFile對象,遍歷其中的類即可,找到類,就可以做匹配了。
over~~