前言
本文前半部分的內(nèi)容在很久之前講過鼠锈,但是最近又有交接到團(tuán)隊(duì)內(nèi)的歷史任務(wù)出現(xiàn)這方面導(dǎo)致的性能問題,故有必要再講一次星著,并擴(kuò)展一部分新內(nèi)容购笆。先通過兩個例子來引入Java類型擦除。
Java類型擦除的表現(xiàn)
- 例一
這段代碼無法通過編譯虚循,提示兩個方法簽名沖突由桌,因?yàn)椴脸愋拖嗤H绻サ羝渲幸粋€方法邮丰,反編譯之后的代碼如下行您。
public void foo(List list) { }
- 例二
這段代碼會返回true
。并且Java只允許List.class
的寫法剪廉,不允許List<T>.class
的寫法娃循。
認(rèn)識類型擦除
我們知道,泛型是高級語言中比較令人頭疼的問題斗蒋,一般來講要實(shí)現(xiàn)泛型有兩種方式:
- Code Sharing:對同一個原始類型下的泛型類型只生成同一份目標(biāo)代碼(在Java中就是字節(jié)碼)捌斧。
- Code Specialization:對每一個泛型類型都生成不同的目標(biāo)代碼笛质。
Java使用的泛型實(shí)現(xiàn)是前者,而C++和C#使用的是后者捞蚂,它們也分別稱為“假”泛型和“真”泛型妇押。Code Sharing通過類型擦除來保證只生成一份目標(biāo)代碼,但也導(dǎo)致程序在運(yùn)行時(shí)對泛型類型沒有感知姓迅,所以上述例子一的代碼反編譯后只剩下了List
敲霍,例子二中的類型比較實(shí)際上都是Class<? extends ArrayList>
的比較。如果Java也采用Code Specialization機(jī)制(想一想C++ Template)的話丁存,所有List<T>
就都是顯式不同的類型了肩杈。
為什么Java要采用Code Sharing和類型擦除呢?主要有兩點(diǎn)原因:一是Java泛型是到1.5版本才出現(xiàn)的特性解寝,在此之前Java已經(jīng)在無泛型的條件下經(jīng)歷了較長時(shí)間的發(fā)展扩然,如果采用Code Specialization,就得對Java類型系統(tǒng)做傷筋動骨的改動聋伦,并且無法保證向前兼容性夫偶;二是Code Specialization對每個泛型類型都生成不同的目標(biāo)代碼,如果有10個不同泛型的List
觉增,就要生成10份字節(jié)碼兵拢,加重解釋和執(zhí)行負(fù)擔(dān)。
由此可見抑片,類型擦除讓JVM省了不少事卵佛,但是加重了編譯器的工作量杨赤。編譯器必須在運(yùn)行期之前就進(jìn)行檢查敞斋,禁止模糊的或者不合法的泛型使用方式。再舉一個例子疾牲。
這種用法也是不允許的植捎,換句話說,里氏替換原則不適用于Java的泛型類型參數(shù)阳柔。這并不難理解:對于一個List<Object>
而言焰枢,向其中添加字符串是完全合法的,但是如果方法傳入的參數(shù)為List<Integer>
的話就會直接造成ClassCastException
舌剂,因此編譯器會提前block掉這種可能性济锄。
還沒完,如果把traverse()
方法參數(shù)中的List<Object>
換成用通配符表示的List<?>
霍转,那么traverse()
方法調(diào)用就沒問題荐绝,但list.add()
語句就會編譯不通過。這是因?yàn)?code>list.add()方法無法判斷具體要加入列表的是Object
的哪個子類實(shí)例避消,因此會用最簡單粗暴的方法來處理低滩,即全部拒絕召夹。相對地,list.get()
則是可以編譯通過的恕沫,因?yàn)榫幾g器能夠通過<? extends T>
與<? super T>
得知泛型類型的上下界限监憎。
如果泛型類型有界限,在類型擦除時(shí)會根據(jù)最左側(cè)的泛型參數(shù)來替換婶溯,例如下面的泛型類鲸阔。
class Test<T extends Comparable & Serializable> {
private T value;
public T getValue() { return value; }
public void setValue(T value) { this.value = value; }
}
類型擦除后就會變成:
class Test {
private Comparable value;
public Comparable getValue() { return value; }
public void setValue(Comparable value) { this.value = value; }
}
同理,如果沒有規(guī)定T是哪個類的子類或者超類爬虱,就會替換為Object隶债。
下面來看類型擦除為Flink類型體系帶來的問題,并介紹Flink規(guī)避此問題的類型提示(Type Hint)機(jī)制跑筝。
Flink的類型提示機(jī)制
以Flink自帶示例中的SocketWindowWordCount
為例死讹,如果我們將它的主邏輯改寫成Lambda表達(dá)式,如下:
DataStream<WordWithCount> windowCounts = text
.flatMap((String value, Collector<WordWithCount> out) -> {
for (String word : value.split("\\s")) {
out.collect(new WordWithCount(word, 1L));
}
})
.keyBy("word")
.timeWindow(Time.seconds(5))
.reduce((a, b) ->
new WordWithCount(a.word, a.count + b.count)
);
執(zhí)行時(shí)會拋出如下異常曲梗。
這說明程序無法在運(yùn)行時(shí)推斷出flatMap()
算子的返回類型赞警。為什么之前采用匿名內(nèi)部類就沒有問題?因?yàn)槟涿麅?nèi)部類會被真正地編譯為.class
文件虏两,而Lambda表達(dá)式是在運(yùn)行時(shí)調(diào)用invokedynamic
指令愧旦,亦即在第一次執(zhí)行其邏輯時(shí)才會確定。因此Lambda表達(dá)式比起匿名內(nèi)部類定罢,會丟失更多的類型信息笤虫。看一下flatMap()
算子的簽名:
void flatMap(T value, Collector<O> out);
經(jīng)過類型擦除祖凫,Collector
的泛型參數(shù)被抹掉了(參看報(bào)錯The generic type parameters of Collector are missing
)琼蚯,自然就會拋出無法確定返回類型的異常。如果我們采用的不是flatMap()
算子而是map()
惠况,就不會出現(xiàn)這種問題遭庶,因?yàn)?code>map()的返回類型可以自動推斷。
為了克服類型擦除帶來的問題稠屠,F(xiàn)link類型系統(tǒng)中內(nèi)置了類型提示機(jī)制峦睡,即用戶在調(diào)用此類算子之后,手動指明返回的類型信息权埠。在flatMap()
之后調(diào)用returns()
方法榨了,就可以指定返回類型了。
text.flatMap((String value, Collector<WordWithCount> out) -> {
for (String word : value.split("\\s")) {
out.collect(new WordWithCount(word, 1L));
}
})
.returns(TypeInformation.of(WordWithCount.class));
但是攘蔽,如果返回類型本身就有泛型龙屉,比如在Flink中常用的元組(TupleX
),就得另外換一種寫法秩彤,即通過繼承TypeHint
的匿名內(nèi)部類保留泛型信息叔扼。
.returns(TypeInformation.of(new TypeHint<Tuple2<String, String>>() { }))
下面再來看一個比較隱匿的可能會引發(fā)性能問題的場景事哭。
POJO序列化fallback問題與類型注入
我們知道,F(xiàn)link對標(biāo)準(zhǔn)的Java POJO類型有專門的PojoSerializer
序列化器支持瓜富,性能相當(dāng)好鳍咱。但是也有例外,考慮以下包含容器成員的POJO:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class MyPojo {
private String myId;
private List<Integer> myTags;
private Map<String, Integer> myFlags;
}
在Flink應(yīng)用執(zhí)行時(shí)与柑,會提示myTags
和myFlags
兩個字段是Generic Types谤辜,也就是要fallback到Kryo Serializer做序列化,而非Flink原生的ListSerializer
和MapSerializer
价捧。如果我們調(diào)用ExecutionConfig#disableGenericTypes()
方法來禁用fallback丑念,則應(yīng)用無法執(zhí)行,并有異常提示Generic types have been disabled and type java.util.Map is treated as a generic type
结蟋。
可見脯倚,由于類型擦除的存在,POJO中的所有泛型參數(shù)都無法識別嵌屎,無法通過原生序列化器操作推正,故序列化性能會有數(shù)倍的下降,在涉及狀態(tài)操作和網(wǎng)絡(luò)傳輸時(shí)尤其明顯宝惰。本文開頭提到的出現(xiàn)問題的歷史Flink任務(wù)植榕,就是因?yàn)閱蝹€POJO中包含了十幾個Map
,且均有狀態(tài)讀寫尼夺,近期因新業(yè)務(wù)上線尊残,流量增大,導(dǎo)致大量時(shí)間耗費(fèi)在序列化層面淤堵,任務(wù)嚴(yán)重反壓寝衫。
由此可見,盡量讓POJO內(nèi)不包含泛型類型(多數(shù)情況下就是避免使用Java容器)是最好的粘勒,但如果必須使用的話竞端,如何解決這個問題呢屎即?答案是自行實(shí)現(xiàn)對應(yīng)類型的TypeInfoFactory
庙睡,并通過@TypeInfo
注解對泛型字段做類型注入。例如技俐,我們實(shí)現(xiàn)一個無嵌套的Map
對應(yīng)的TypeInfoFactory
乘陪。
@SuppressWarnings("unchecked")
public class SimpleMapTypeInfoFactory<K, V> extends TypeInfoFactory<Map<K, V>> {
@Override
public TypeInformation<Map<K, V>> createTypeInfo(Type t, Map<String, TypeInformation<?>> genericParameters) {
return Types.MAP(
(TypeInformation<K>) genericParameters.get("K"),
(TypeInformation<V>) genericParameters.get("V")
);
}
}
然后為myFlags
字段注入此類型,在序列化時(shí)雕擂,MapSerializer
就會對此字段生效啡邑,性能恢復(fù)正常。
@TypeInfo(SimpleMapTypeInfoFactory.class)
private Map<String, Integer> myFlags;
當(dāng)然井赌,對所有其他無嵌套的Map<K, V>
類型字段谤逼,都可以復(fù)用上面的SimpleMapTypeInfoFactory
類贵扰,無需重復(fù)編寫。至于其他類型與組合流部,讀者舉一反三即可戚绕。
最后再提醒一句,Set
不是Flink序列化器原生支持的類型枝冀,即不存在SetSerializer
舞丛,故所有需要用到Set<T>
的場合,都需要用Map<T, Boolean>
代替果漾,并注入上述SimpleMapTypeInfoFactory
球切,以獲得最佳性能。關(guān)于誤用Set
這檔事绒障,筆者之前另有文章說明吨凑,不再贅述。