天天看點

java性能優化實戰:大對象的優化與處理

#頭條創作挑戰賽#

今天我們将講解一下對于“大對象”的優化。這裡的“大對象”,是一個泛化概念,它可能存放在 JVM 中,也可能正在網絡上傳輸,也可能存在于資料庫中。那麼為什麼大對象會影響我們的應用性能呢?

  • 第一,大對象占用的資源多,垃圾回收器要花一部分精力去對它進行回收;
  • 第二,大對象在不同的裝置之間交換,會耗費網絡流量,以及昂貴的 I/O;
  • 第三,對大對象的解析和處理操作是耗時的,對象職責不聚焦,就會承擔額外的性能開銷。

結合我們前面提到的緩存,以及對象的池化操作,加上對一些中間結果的儲存,我們能夠對大對象進行初步的提速。

但這還遠遠不夠,我們僅僅減少了對象的建立頻率,但并沒有改變對象“大”這個事實。本課時,将從 JDK 的一些知識點講起,先來看幾個面試頻率比較高的對象複用問題;接下來,從資料的結構緯度和時間次元出發,分别逐漸看一下一些把對象變小,把操作聚焦的政策。

String 的 substring 方法

我們都知道,String 在 Java 中是不可變的,如果你改動了其中的内容,它就會生成一個新的字元串。

如果我們想要用到字元串中的一部分資料,就可以使用 substring 方法。

java性能優化實戰:大對象的優化與處理

如上圖所示,當我們需要一個子字元串的時候,substring 生成了一個新的字元串,這個字元串通過構造函數的 Arrays.copyOfRange 函數進行構造。

這個函數在 JDK7 之後是沒有問題的,但在 JDK6 中,卻有着記憶體洩漏的風險,我們可以學習一下這個案例,來看一下大對象複用可能會産生的問題。

java性能優化實戰:大對象的優化與處理

上圖是我從 JDK 官方的一張截圖。可以看到,它在建立子字元串的時候,并不隻拷貝所需要的對象,而是把整個 value 引用了起來。如果原字元串比較大,即使不再使用,記憶體也不會釋放。

比如,一篇文章内容可能有幾兆,我們僅僅是需要其中的摘要資訊,也不得不維持整個的大對象。

String content = dao.getArticle(id); 
String summary=content.substring(0,100); 
articles.put(id,summary);
           

有一些工作年限比較長的面試官,對 substring 還停留在 JDK6 的印象,但其實,Java 已經将這個 bug 給修改了。

這對我們的借鑒意義是:如果你建立了比較大的對象,并基于這個對象生成了一些其他的資訊,這個時候,一定要記得去掉和這個大對象的引用關系。

集合大對象擴容

對象擴容,在 Java 中是司空見慣的現象,比如 StringBuilder、StringBuffer、HashMap,ArrayList 等。概括來講,Java 的集合,包括 List、Set、Queue、Map 等,其中的資料都不可控。在容量不足的時候,都會有擴容操作,擴容操作需要重新組織資料,是以都不是線程安全的。

我們先來看下 StringBuilder 的擴容代碼:

void expandCapacity(int minimumCapacity) { 
        int newCapacity = value.length * 2 + 2; 
        if (newCapacity - minimumCapacity < 0) 
            newCapacity = minimumCapacity; 
        if (newCapacity < 0) { 
            if (minimumCapacity < 0) // overflow 
                throw new OutOfMemoryError(); 
            newCapacity = Integer.MAX_VALUE; 
        } 
        value = Arrays.copyOf(value, newCapacity); 
}
           

容量不夠的時候,會将記憶體翻倍,并使用 Arrays.copyOf 複制源資料。

下面是 HashMap 的擴容代碼,擴容後大小也是翻倍。它的擴容動作就複雜得多,除了有負載因子的影響,它還需要把原來的資料重新進行散列,由于無法使用 native 的 Arrays.copy 方法,速度就會很慢。

void addEntry(int hash, K key, V value, int bucketIndex) { 
        if ((size >= threshold) && (null != table[bucketIndex])) { 
            resize(2 * table.length); 
            hash = (null != key) ? hash(key) : 0; 
            bucketIndex = indexFor(hash, table.length); 
        } 
        createEntry(hash, key, value, bucketIndex); 
} void resize(int newCapacity) { 
        Entry[] oldTable = table; 
        int oldCapacity = oldTable.length; 
        if (oldCapacity == MAXIMUM_CAPACITY) { 
            threshold = Integer.MAX_VALUE; 
            return; 
        } 
        Entry[] newTable = new Entry[newCapacity]; 
        transfer(newTable, initHashSeedAsNeeded(newCapacity)); 
        table = newTable; 
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); 
}
           

List 的代碼大家可自行檢視,也是阻塞性的,擴容政策是原長度的 1.5 倍。

由于集合在代碼中使用的頻率非常高,如果你知道具體的資料項上限,那麼不妨設定一個合理的初始化大小。比如,HashMap 需要 1024 個元素,需要 7 次擴容,會影響應用的性能。面試中會頻繁出現這個問題,你需要了解這些擴容操作對性能的影響。

但是要注意,像 HashMap 這種有負載因子的集合(0.75),初始化大小 = 需要的個數/負載因子+1,如果你不是很清楚底層的結構,那就不妨保持預設。

接下來,我将從資料的結構緯度和時間次元出發,講解一下應用層面的優化。

保持合适的對象粒度

給你分享一個實際案例:我們有一個并發量非常高的業務系統,需要頻繁使用到使用者的基本資料。

如下圖所示,由于使用者的基本資訊,都是存放在另外一個服務中,是以每次用到使用者的基本資訊,都需要有一次網絡互動。更加讓人無法接受的是,即使是隻需要使用者的性别屬性,也需要把所有的使用者資訊查詢,拉取一遍。

java性能優化實戰:大對象的優化與處理

為了加快資料的查詢速度,根據我們之前 [《08 | 案例分析:Redis 如何助力秒殺業務》]的描述,對資料進行了初步的緩存,放入到了 Redis 中,查詢性能有了大的改善,但每次還是要查詢很多備援資料。

原始的 redis key 是這樣設計的:

type: string 
key: user_${userid} 
value: json
           

這樣的設計有兩個問題:

  • 查詢其中某個字段的值,需要把所有 json 資料查詢出來,并自行解析;
  • 更新其中某個字段的值,需要更新整個 json 串,代價較高。

針對這種大粒度 json 資訊,就可以采用打散的方式進行優化,使得每次更新和查詢,都有聚焦的目标。

接下來對 Redis 中的資料進行了以下設計,采用 hash 結構而不是 json 結構:

type: hash 
key: user_${userid} 
value: {sex:f, id:1223, age:23}
           

這樣,我們使用 hget 指令,或者 hmget 指令,就可以擷取到想要的資料,加快資訊流轉的速度。

Bitmap 把對象變小

除了以上操作,還能再進一步優化嗎?比如,我們系統中就頻繁用到了使用者的性别資料,用來發放一些禮品,推薦一些異性的好友,定時循環使用者做一些清理動作等;或者,存放一些使用者的狀态資訊,比如是否線上,是否簽到,最近是否發送資訊等,進而統計一下活躍使用者等。那麼對是、否這兩個值得操作,就可以使用 Bitmap 這個結構進行壓縮。

這裡還有個高頻面試問題,那就是 Java 的 Boolean 占用的是多少位?

在 Java 虛拟機規範裡,描述是:将 Boolean 類型映射成的是 1 和 0 兩個數字,它占用的空間是和 int 相同的 32 位。即使有的虛拟機實作把 Boolean 映射到了 byte 類型上,它所占用的空間,對于大量的、有規律的 Boolean 值來說,也是太大了。

如代碼所示,通過判斷 int 中的每一位,它可以儲存 32 個 Boolean 值!

int a= 0b0001_0001_1111_1101_1001_0001_1111_1101;
           

Bitmap 就是使用 Bit 進行記錄的資料結構,裡面存放的資料不是 0 就是 1。還記得我們在之前 [《08 | 案例分析:Redis 如何助力秒殺業務》]中提到的緩存穿透嗎?就可以使用Bitmap 避免,Java 中的相關結構類,就是 java.util.BitSet,BitSet 底層是使用 long 數組實作的,是以它的最小容量是 64。

100 億的 Boolean 值,隻需要 128MB 的記憶體,下面既是一個占用了 256MB 的使用者性别的判斷邏輯,可以涵蓋長度為 100 億的 ID。

static BitSet missSet = new BitSet(010_000_000_000); 
static BitSet sexSet = new BitSet(010_000_000_000); 
String getSex(int userId) { 
    boolean notMiss = missSet.get(userId); 
    if (!notMiss) { 
        //lazy fetch 
        String lazySex = dao.getSex(userId); 
        missSet.set(userId, true); 
        sexSet.set(userId, "female".equals(lazySex)); 
    } 
    return sexSet.get(userId) ? "female" : "male"; 
}
           

這些資料,放在堆内記憶體中,還是過大了。幸運的是,Redis 也支援 Bitmap 結構,如果記憶體有壓力,我們可以把這個結構放到 Redis 中,判斷邏輯也是類似的。

再插一道面試算法題:給出一個 1GB 記憶體的機器,提供 60億 int 資料,如何快速判斷有哪些資料是重複的?

大家可以類比思考一下。Bitmap 是一個比較底層的結構,在它之上還有一個叫作布隆過濾器的結構(Bloom Filter),布隆過濾器可以判斷一個值不存在,或者可能存在。

java性能優化實戰:大對象的優化與處理

如圖,它相比較 Bitmap,它多了一層 hash 算法。既然是 hash 算法,就會有沖突,是以有可能有多個值落在同一個 bit 上。它不像 HashMap一樣,使用連結清單或者紅黑樹來處理沖突,而是直接将這個hash槽重複使用。從這個特性我們能夠看出,布隆過濾器能夠明确表示一個值不在集合中,但無法判斷一個值确切的在集合中。

Guava 中有一個 BloomFilter 的類,可以友善地實作相關功能。

上面這種優化方式,本質上也是把大對象變成小對象的方式,在軟體設計中有很多類似的思路。比如像一篇新釋出的文章,頻繁用到的是摘要資料,就不需要把整個文章内容都查詢出來;使用者的 feed 資訊,也隻需要保證可見資訊的速度,而把完整資訊存放在速度較慢的大型存儲裡。

資料的冷熱分離

資料除了橫向的結構緯度,還有一個縱向的時間次元,對時間次元的優化,最有效的方式就是冷熱分離。

所謂熱資料,就是靠近使用者的,被頻繁使用的資料;而冷資料是那些通路頻率非常低,年代非常久遠的資料。

同一句複雜的 SQL,運作在幾千萬的資料表上,和運作在幾百萬的資料表上,前者的效果肯定是很差的。是以,雖然你的系統剛開始上線時速度很快,但随着時間的推移,資料量的增加,就會漸漸變得很慢。

冷熱分離是把資料分成兩份,如下圖,一般都會保持一份全量資料,用來做一些耗時的統計操作。

java性能優化實戰:大對象的優化與處理

由于冷熱分離在工作中經常遇到,是以面試官會頻繁問到資料冷熱分離的方案。下面簡單介紹三種:

1.資料雙寫

把對冷熱庫的插入、更新、删除操作,全部放在一個統一的事務裡面。由于熱庫(比如 MySQL)和冷庫(比如 Hbase)的類型不同,這個事務大機率會是分布式事務。在項目初期,這種方式是可行的,但如果是改造一些遺留系統,分布式事務基本上是改不動的,我通常會把這種方案直接廢棄掉。

2.寫入 MQ 分發

通過 MQ 的釋出訂閱功能,在進行資料操作的時候,先不落庫,而是發送到 MQ 中。單獨啟動消費程序,将 MQ 中的資料分别落到冷庫、熱庫中。使用這種方式改造的業務,邏輯非常清晰,結構也比較優雅。像訂單這種結構比較清晰、對順序性要求較低的系統,就可以采用 MQ 分發的方式。但如果你的資料庫實體量非常大,用這種方式就要考慮程式的複雜性了。

3.使用 Binlog 同步

針對 MySQL,就可以采用 Binlog 的方式進行同步,使用 Canal 元件,可持續擷取最新的 Binlog 資料,結合 MQ,可以将資料同步到其他的資料源中。

思維發散

對于結果集的操作,我們可以再發散一下思維。可以将一個簡單備援的結果集,改造成複雜高效的資料結構。這個複雜的資料結構可以代理我們的請求,有效地轉移耗時操作。

  • 比如,我們常用的資料庫索引,就是一種對資料的重新組織、加速。

B+ tree 可以有效地減少資料庫與磁盤互動的次數,它通過類似 B+ tree 的資料結構,将最常用的資料進行索引,存儲在有限的存儲空間中。

  • 還有就是,在 RPC 中常用的序列化。

有的服務是采用的 SOAP 協定的 WebService,它是基于 XML 的一種協定,内容大傳輸慢,效率低下。現在的 Web 服務中,大多數是使用 json 資料進行互動的,json 的效率相比 SOAP 就更高一些。

另外,大家應該都聽過 google 的 protobuf,由于它是二進制協定,而且對資料進行了壓縮,性能是非常優越的。protobuf 對資料壓縮後,大小隻有 json 的 1/10,xml 的 1/20,但是性能卻提高了 5-100 倍。

protobuf 的設計是值得借鑒的,它通過 tag|leng|value 三段對資料進行了非常緊湊的處理,解析和傳輸速度都特别快。

小結

最後總結一下今天的内容重點:

首先,我們看了比較老的 JDK 版本中,String 為了複用引起的内容洩漏問題,是以我們平常的編碼中,一定要注意大對象的回收,及時切斷與它的聯系。

接下來,我們看了 Java 中集合的一些擴容操作,如果你知道确切的集合大小,就可以指定一個初始值,避免耗時的擴容操作。

針對大對象,我們有結構緯度的優化和時間次元的優化兩種方法:

從結構緯度來說,通過把對象切分成合适的粒度,可以把操作集中在小資料結構上,減少時間處理成本;通過把對象進行壓縮、轉換,或者提取熱點資料,就可以避免大對象的存儲和傳輸成本。

從時間緯度來說,就可以通過冷熱分離的手段,将常用的資料存放在高速裝置中,減少資料處理的集合,加快處理速度。

到現在為止,我們學習了緩沖、緩存、對象池化、結果緩存池、大對象處理等優化性能的手段,由于它們都加入了額外的中間層,會使得程式設計模型變得複雜。