Linkedin工程師是如何優化他們的Java代碼的

最近在刷各大公司的技術博客的時候,我在Linkedin的技術博客上面發現了一篇很不錯博文。這篇博文介紹了Linkedin信息流中間層Feed Mixer,它爲Linkedin的Web主頁,大學主頁,公司主頁以及客戶端等多個分發渠道提供支撐(如下圖所示)。 

 

在Feed Mixer裏面用到了一個叫做SPR(念“super”)的庫。博文講的就是如何優化SPR的java代碼。下面就是他們總結的優化經驗。 

1. 謹慎對待Java的循環遍歷 

Java中的列表遍歷可比它看起來要麻煩多了。就以下面兩段代碼爲例: 

A: 
Java代碼 
  1. private final List<Bar> _bars;  
  2. for(Bar bar : _bars) {  
  3.     //Do important stuff  
  4. }  

B: 
Java代碼 
  1. private final List<Bar> _bars;  
  2. for(int i = 0; i < _bars.size(); i++) {  
  3. Bar bar = _bars.get(i);  
  4. //Do important stuff  
  5. }  

代碼A執行的時候 會爲這個抽象列表創建一個迭代器,而代碼B就直接使用 get(i) 來獲取元素,相對於代碼A省去了迭代器的開銷。 

實際上這裏還是需要一些權衡的。代碼A使用了迭代器,保證了在獲取元素的時候的時間複雜度是 O(1) (使用了 getNext() 和 hasNext() 方法),最終的時間複雜度爲 O(n) 。但是對於代碼B,循環裏每次在調用 _bars.get(i) 的時候花費的時間複雜度爲 O(n)  (假設這個list爲一個 LinkedList),那麼最終代碼B整個循環的時間複雜度就是 O(n^2)  (但如果代碼B裏面的list是 ArrayList, 那 get(i) 方法的時間複雜度就是 O(1)了)。 

所以在決定使用哪一種遍歷的方式的時候,我們需要考慮列表的底層實現,列表的平均長度以及所使用的內存。最後因爲我們需要優化內存,再加上 ArrayList 在大多數情況下查找的時間複雜度爲 O(1) ,最後決定選擇代碼B所使用的方法。 

2.在初始化的時候預估集合的大小 

從Java的這篇 文檔我們可以瞭解到: “一個HashMap 實例有兩個影響它性能的因素:初始大小和加載因子(load factor)。 […] 當哈希表的大小達到初始大小和加載因子的乘積的時候,哈希表會進行 rehash操作 […] 如果在一個HashMap 實例裏面要存儲多個映射關係時,我們需要設置足夠大的初始化大小以便更有效地存儲映射關係而不是讓哈希表自動增長讓後rehash,造成性能瓶頸。” 

在Linkedin實踐的時候,常常碰到需要遍歷一個 ArrayList 並將這些元素保存到 HashMap 裏面去。將這個 HashMap 初始化預期的大小可以避免再次哈希所帶來的開銷。初始化大小可以設置爲輸入的數組大小除以默認加載因子的結果值(這裏取0.7): 

優化前的代碼: 
Java代碼 
  1. HashMap<String,Foo> _map;  
  2. void addObjects(List<Foo> input)  
  3. {  
  4.   _map = new HashMap<String, Foo>();  
  5.   for(Foo f: input)  
  6.   {  
  7.     _map.put(f.getId(), f);  
  8.   }  
  9. }  

優化後的代碼 
Java代碼 
  1. HashMap<String,Foo> _map;  
  2. void addObjects(List<Foo> input)  
  3. {  
  4. _map = new HashMap<String, Foo>((int)Math.ceil(input.size() / 0.7));  
  5. for(Foo f: input)  
  6. {  
  7. _map.put(f.getId(), f);  
  8. }  
  9. }  


3. 延遲表達式的計算 

在Java中,所有的方法參數會在方法調用之前,只要有方法參數是一個表達式的都會先這個表達式進行計算(從左到右)。這個規則會導致一些不必要的操作。考慮到下面一個場景:使用ComparisonChain比較兩個 Foo 對象。使用這樣的比較鏈條的一個好處就是在比較的過程中只要一個 compareTo 方法返回了一個非零值整個比較就結束了,避免了許多無謂的比較。例如現在這個場景中的要比較的對象最先考慮他們的score, 然後是 position, 最後就是 _bar 這個屬性了: 
Java代碼 
  1. public class Foo {  
  2. private float _score;  
  3. private int _position;  
  4. private Bar _bar;  
  5. public int compareTo (Foo other) {  
  6.   return ComparisonChain.start().  
  7.   compare(_score, other.getScore()).  
  8.   compare(_position, other.getPosition()).  
  9.   compare(_bar.toString(), other.getBar().toString()).  
  10.   result;  
  11. }  
  12. }  

但是上面這種實現方式總是會先生成兩個 String 對象來保存 bar.toString() 和other.getBar().toString() 的值,即使這兩個字符串的比較可能不需要。避免這樣的開銷,可以爲Bar 對象實現一個 comparator: 
Java代碼 
  1. public class Foo {  
  2. private float _score;  
  3. private int _position;  
  4. private Bar _bar;  
  5. private final BarComparator BAR_COMPARATOR = new BarComparator();  
  6. public int compareTo (Foo other) {  
  7.     return ComparisonChain.start().  
  8.     compare(_score, other.getScore()).  
  9.     compare(_position, other.getPosition()).  
  10.     compare(_bar, other.getBar(), BAR_COMPARATOR).  
  11.     result();  
  12. }  
  13. private static class BarComparator implements Comparator<Bar> {  
  14. @Override  
  15.     public int compare(Bar a, Bar b) {  
  16.     return a.toString().compareTo(b.toString());  
  17. }  
  18. }  
  19. }  


4. 提前編譯正則表達式 

字符串的操作在Java中算是開銷比較大的操作。還好Java提供了一些工具讓正則表達式儘可能地高效。動態的正則表達式在實踐中比較少見。在接下來要舉的例子中,每次調用 String.replaceAll() 都包含了一個常量模式應用到輸入值中去。因此我們預先編譯這個模式可以節省CPU和內存的開銷。 

優化前: 
Java代碼 
  1. private String transform(String term) {  
  2.     return outputTerm = term.replaceAll(_regex, _replacement);  
  3. }  

優化後: 
Java代碼 
  1. private final Pattern _pattern = Pattern.compile(_regex);  
  2. private String transform(String term) {  
  3.     String outputTerm = _pattern.matcher(term).replaceAll(_replacement);  
  4. }  

5. 儘可能地緩存Cache it if you can 

將結果保存在緩存裏也是一個避免過多開銷的方法。但緩存只適用於在相同數據集撒花姑娘嗎的相同數據操作(比如對一些配置的預處理或者一些字符串處理)。現在已經有多種LRU(Least Recently Used )緩存算法實現,但是Linkedin使用的是 Guava cache (具體原因見這裏) 大致代碼如下: 
Java代碼 
  1. private final int MAX_ENTRIES = 1000;  
  2. private final LoadingCache<String, String> _cache;  
  3. // Initializing the cache  
  4. _cache = CacheBuilder.newBuilder().maximumSize(MAX_ENTRIES).build(new CacheLoader<String,String>() {  
  5. @Override  
  6. public String load(String key) throws Exception {  
  7. return expensiveOperationOn(key);  
  8. }  
  9. }  
  10. );  
  11. //Using the cache  
  12. String output = _cache.getUnchecked(input);  


6. String的intern方法有用,但是也有危險 

String 的 intern 特性有時候可以代替緩存來使用。 

從這篇文檔,我們可以知道: 
引用
“A pool of strings, initially empty, is maintained privately by the class String. When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned”.

這個特性跟緩存很類似,但有一個限制,你不能設置最多可容納的元素數目。因此,如果這些intern的字符串沒有限制(比如字符串代表着一些唯一的id),那麼它會讓內存佔用飛速增長。Linkedin曾經在這上面栽過跟頭——當時是對一些鍵值使用intern方法,線下模擬的時候一切正常,但一旦部署上線,系統的內存佔用一下就升上去了(因爲大量唯一的字符串被intern了)。所以最後Linkedin選擇使用 LRU 緩存,這樣可以限制最大元素數目。 

最終結果 

SPR的內存佔用減少了75%,進而將feed-mixer的內存佔用減少了 50% (如下圖所示)。這些優化減少了對象的生成,進而減少了GC得頻率,整個服務的延遲就減少了25%。 


原文地址:http://www.iteye.com/news/29960

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章