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

Android Cool Posts發表於2014-12-17

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

feed_mixer_1

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

1. 謹慎對待Java的迴圈遍歷

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

A:

private final List<Bar> _bars;
for(Bar bar : _bars) {
    //Do important stuff
}

B:

private final List<Bar> _bars;
for(int i = 0; i < _bars.size(); i++) {
Bar bar = _bars.get(i);
//Do important stuff
}

程式碼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):

優化前的程式碼:

HashMap<String,Foo> _map;
void addObjects(List<Foo> input)
{
  _map = new HashMap<String, Foo>();
  for(Foo f: input)
  {
    _map.put(f.getId(), f);
  }
}

優化後的程式碼

HashMap<String,Foo> _map;
void addObjects(List<Foo> input)
{
_map = new HashMap<String, Foo>((int)Math.ceil(input.size() / 0.7));
for(Foo f: input)
{
_map.put(f.getId(), f);
}
}

3. 延遲表示式的計算

在Java中,所有的方法引數會在方法呼叫之前,只要有方法引數是一個表示式的都會先這個表示式進行計算(從左到右)。這個規則會導致一些不必要的操作。考慮到下面一個場景:使用ComparisonChain比較兩個 Foo 物件。使用這樣的比較鏈條的一個好處就是在比較的過程中只要一個 compareTo 方法返回了一個非零值整個比較就結束了,避免了許多無謂的比較。例如現在這個場景中的要比較的物件最先考慮他們的score, 然後是 position, 最後就是 _bar 這個屬性了:

public class Foo {
private float _score;
private int _position;
private Bar _bar;
public int compareTo (Foo other) {
  return ComparisonChain.start().
  compare(_score, other.getScore()).
  compare(_position, other.getPosition()).
  compare(_bar.toString(), other.getBar().toString()).
  result;
}
}

但是上面這種實現方式總是會先生成兩個 String 物件來儲存 bar.toString() 和other.getBar().toString() 的值,即使這兩個字串的比較可能不需要。避免這樣的開銷,可以為Bar 物件實現一個 comparator:

public class Foo {
private float _score;
private int _position;
private Bar _bar;
private final BarComparator BAR_COMPARATOR = new BarComparator();
public int compareTo (Foo other) {
    return ComparisonChain.start().
    compare(_score, other.getScore()).
    compare(_position, other.getPosition()).
    compare(_bar, other.getBar(), BAR_COMPARATOR).
    result();
}
private static class BarComparator implements Comparator<Bar> {
@Override
    public int compare(Bar a, Bar b) {
    return a.toString().compareTo(b.toString());
}
}
}

4. 提前編譯正規表示式

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

優化前:

private String transform(String term) {
    return outputTerm = term.replaceAll(_regex, _replacement);
}

優化後:

private final Pattern _pattern = Pattern.compile(_regex);
private String transform(String term) {
    String outputTerm = _pattern.matcher(term).replaceAll(_replacement);
}

5. 儘可能地快取Cache it if you can

將結果儲存在快取裡也是一個避免過多開銷的方法。但快取只適用於在相同資料集撒花姑娘嗎的相同資料操作(比如對一些配置的預處理或者一些字串處理)。現在已經有多種LRU(Least Recently Used )快取演算法實現,但是Linkedin使用的是 Guava cache (具體原因見這裡) 大致程式碼如下:

private final int MAX_ENTRIES = 1000;
private final LoadingCache<String, String> _cache;
// Initializing the cache
_cache = CacheBuilder.newBuilder().maximumSize(MAX_ENTRIES).build(new CacheLoader<String,String>() {
@Override
public String load(String key) throws Exception {
return expensiveOperationOn(key);
}
}
);
//Using the cache
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%。

MemUtil_incapacity

相關文章