詳解處理Java中的大對(duì)象的方法
本文我們將講解一下對(duì)于“大對(duì)象”的優(yōu)化。這里的“大對(duì)象”,是一個(gè)泛化概念,它可能存放在 JVM 中,也可能正在網(wǎng)絡(luò)上傳輸,也可能存在于數(shù)據(jù)庫(kù)中。
那么為什么大對(duì)象會(huì)影響我們的應(yīng)用性能呢?
第一,大對(duì)象占用的資源多,垃圾回收器要花一部分精力去對(duì)它進(jìn)行回收;
第二,大對(duì)象在不同的設(shè)備之間交換,會(huì)耗費(fèi)網(wǎng)絡(luò)流量,以及昂貴的 I/O;
第三,對(duì)大對(duì)象的解析和處理操作是耗時(shí)的,對(duì)象職責(zé)不聚焦,就會(huì)承擔(dān)額外的性能開(kāi)銷。
結(jié)合我們前面提到的緩存,以及對(duì)象的池化操作,加上對(duì)一些中間結(jié)果的保存,我們能夠?qū)Υ髮?duì)象進(jìn)行初步的提速。
但這還遠(yuǎn)遠(yuǎn)不夠,我們僅僅減少了對(duì)象的創(chuàng)建頻率,但并沒(méi)有改變對(duì)象“大”這個(gè)事實(shí)。本文,將從 JDK 的一些知識(shí)點(diǎn)講起,先來(lái)看幾個(gè)面試頻率比較高的對(duì)象復(fù)用問(wèn)題;接下來(lái),從數(shù)據(jù)的結(jié)構(gòu)緯度和時(shí)間維度出發(fā),分別逐步看一下一些把對(duì)象變小,把操作聚焦的策略。
String中的substring
我們都知道,String 在 Java 中是不可變的,如果你改動(dòng)了其中的內(nèi)容,它就會(huì)生成一個(gè)新的字符串。如果我們想要用到字符串中的一部分?jǐn)?shù)據(jù),就可以使用 substring 方法。
下面是Java11中String的源碼。
public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = length() - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } if (beginIndex == 0) { return this; } return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen) : StringUTF16.newString(value, beginIndex, subLen); } public static String newString(byte[] val, int index, int len) { if (String.COMPACT_STRINGS) { byte[] buf = compress(val, index, len); if (buf != null) { return new String(buf, LATIN1); } } int last = index + len; return new String(Arrays.copyOfRange(val, index << 1, last << 1), UTF16); }
如上述代碼所示,當(dāng)我們需要一個(gè)子字符串的時(shí)候,substring 生成了一個(gè)新的字符串,這個(gè)字符串通過(guò)構(gòu)造函數(shù)的 Arrays.copyOfRange 函數(shù)進(jìn)行構(gòu)造。
這個(gè)函數(shù)在 Java7 之后是沒(méi)有問(wèn)題的,但在Java6 中,卻有著內(nèi)存泄漏的風(fēng)險(xiǎn),我們可以學(xué)習(xí)一下這個(gè)案例,來(lái)看一下大對(duì)象復(fù)用可能會(huì)產(chǎn)生的問(wèn)題。下面是Java6中的代碼:
public String substring(int beginIndex, int endIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } if (endIndex > count) { throw new StringIndexOutOfBoundsException(endIndex); } if (beginIndex > endIndex) { throw new StringIndexOutOfBoundsException(endIndex - beginIndex); } return ((beginIndex == 0) && (endIndex == count)) ? this : new String(offset + beginIndex, endIndex - beginIndex, value); } String(int offset, int count, char value[]) { this.value = value; this.offset = offset; this.count = count; }
可以看到,它在創(chuàng)建子字符串的時(shí)候,并不只拷貝所需要的對(duì)象,而是把整個(gè) value 引用了起來(lái)。如果原字符串比較大,即使不再使用,內(nèi)存也不會(huì)釋放。
比如,一篇文章內(nèi)容可能有幾兆,我們僅僅是需要其中的摘要信息,也不得不維持整個(gè)的大對(duì)象。
有一些工作年限比較長(zhǎng)的面試官,對(duì) substring 還停留在 JDK6 的印象,但其實(shí),Java 已經(jīng)將這個(gè) bug 給修改了。如果面試時(shí)遇到這個(gè)問(wèn)題,保險(xiǎn)起見(jiàn),可以把這個(gè)改善過(guò)程答出來(lái)。
這對(duì)我們的借鑒意義是:如果你創(chuàng)建了比較大的對(duì)象,并基于這個(gè)對(duì)象生成了一些其他的信息,這個(gè)時(shí)候,一定要記得去掉和這個(gè)大對(duì)象的引用關(guān)系。
集合大對(duì)象擴(kuò)容
對(duì)象擴(kuò)容,在 Java 中是司空見(jiàn)慣的現(xiàn)象,比如 StringBuilder、StringBuffer、HashMap,ArrayList 等。概括來(lái)講,Java 的集合,包括 List、Set、Queue、Map 等,其中的數(shù)據(jù)都不可控。在容量不足的時(shí)候,都會(huì)有擴(kuò)容操作,擴(kuò)容操作需要重新組織數(shù)據(jù),所以都不是線程安全的。
我們先來(lái)看下 StringBuilder 的擴(kuò)容代碼:
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); }
容量不夠的時(shí)候,會(huì)將內(nèi)存翻倍,并使用 Arrays.copyOf 復(fù)制源數(shù)據(jù)。
下面是 HashMap 的擴(kuò)容代碼,擴(kuò)容后大小也是翻倍。它的擴(kuò)容動(dòng)作就復(fù)雜得多,除了有負(fù)載因子的影響,它還需要把原來(lái)的數(shù)據(jù)重新進(jìn)行散列,由于無(wú)法使用 native 的 Arrays.copy 方法,速度就會(huì)很慢。
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 的代碼大家可自行查看,也是阻塞性的,擴(kuò)容策略是原長(zhǎng)度的 1.5 倍。
由于集合在代碼中使用的頻率非常高,如果你知道具體的數(shù)據(jù)項(xiàng)上限,那么不妨設(shè)置一個(gè)合理的初始化大小。比如,HashMap 需要 1024 個(gè)元素,需要 7 次擴(kuò)容,會(huì)影響應(yīng)用的性能。面試中會(huì)頻繁出現(xiàn)這個(gè)問(wèn)題,你需要了解這些擴(kuò)容操作對(duì)性能的影響。
但是要注意,像 HashMap 這種有負(fù)載因子的集合(0.75),初始化大小 = 需要的個(gè)數(shù)/負(fù)載因子+1,如果你不是很清楚底層的結(jié)構(gòu),那就不妨保持默認(rèn)。
接下來(lái),我將從數(shù)據(jù)的結(jié)構(gòu)緯度和時(shí)間維度出發(fā),講解一下應(yīng)用層面的優(yōu)化。
保持合適的對(duì)象粒度
給你分享一個(gè)實(shí)際案例:我們有一個(gè)并發(fā)量非常高的業(yè)務(wù)系統(tǒng),需要頻繁使用到用戶的基本數(shù)據(jù)。
如下圖所示,由于用戶的基本信息,都是存放在另外一個(gè)服務(wù)中,所以每次用到用戶的基本信息,都需要有一次網(wǎng)絡(luò)交互。更加讓人無(wú)法接受的是,即使是只需要用戶的性別屬性,也需要把所有的用戶信息查詢,拉取一遍。
為了加快數(shù)據(jù)的查詢速度,對(duì)數(shù)據(jù)進(jìn)行了初步的緩存,放入到了 Redis 中,查詢性能有了大的改善,但每次還是要查詢很多冗余數(shù)據(jù)。
原始的 redis key 是這樣設(shè)計(jì)的:
type: string key: user_${userid} value: json
這樣的設(shè)計(jì)有兩個(gè)問(wèn)題:
查詢其中某個(gè)字段的值,需要把所有 json 數(shù)據(jù)查詢出來(lái),并自行解析;
更新其中某個(gè)字段的值,需要更新整個(gè) json 串,代價(jià)較高。
針對(duì)這種大粒度 json 信息,就可以采用打散的方式進(jìn)行優(yōu)化,使得每次更新和查詢,都有聚焦的目標(biāo)。
接下來(lái)對(duì) Redis 中的數(shù)據(jù)進(jìn)行了以下設(shè)計(jì),采用 hash 結(jié)構(gòu)而不是 json 結(jié)構(gòu):
type: hash key: user_${userid} value: {sex:f, id:1223, age:23}
這樣,我們使用 hget 命令,或者 hmget 命令,就可以獲取到想要的數(shù)據(jù),加快信息流轉(zhuǎn)的速度。
Bitmap 把對(duì)象變小
除了以上操作,還能再進(jìn)一步優(yōu)化嗎?比如,我們系統(tǒng)中就頻繁用到了用戶的性別數(shù)據(jù),用來(lái)發(fā)放一些禮品,推薦一些異性的好友,定時(shí)循環(huán)用戶做一些清理動(dòng)作等;或者,存放一些用戶的狀態(tài)信息,比如是否在線,是否簽到,最近是否發(fā)送信息等,從而統(tǒng)計(jì)一下活躍用戶等。那么對(duì)是、否這兩個(gè)值的操作,就可以使用 Bitmap 這個(gè)結(jié)構(gòu)進(jìn)行壓縮。
這里還有個(gè)高頻面試問(wèn)題,那就是 Java 的 Boolean 占用的是多少位?
在 Java 虛擬機(jī)規(guī)范里,描述是:將 Boolean 類型映射成的是 1 和 0 兩個(gè)數(shù)字,它占用的空間是和 int 相同的 32 位。即使有的虛擬機(jī)實(shí)現(xiàn)把 Boolean 映射到了 byte 類型上,它所占用的空間,對(duì)于大量的、有規(guī)律的 Boolean 值來(lái)說(shuō),也是太大了。
如代碼所示,通過(guò)判斷 int 中的每一位,它可以保存 32 個(gè) Boolean 值!
int a= 0b0001_0001_1111_1101_1001_0001_1111_1101;
Bitmap 就是使用 Bit 進(jìn)行記錄的數(shù)據(jù)結(jié)構(gòu),里面存放的數(shù)據(jù)不是 0 就是 1。還記得我們?cè)谥?《分布式緩存系統(tǒng)必須要解決的四大問(wèn)題》中提到的緩存穿透嗎?就可以使用 Bitmap 避免,Java 中的相關(guān)結(jié)構(gòu)類,就是 java.util.BitSet,BitSet 底層是使用 long 數(shù)組實(shí)現(xiàn)的,所以它的最小容量是 64。
10 億的 Boolean 值,只需要 128MB 的內(nèi)存,下面既是一個(gè)占用了 256MB 的用戶性別的判斷邏輯,可以涵蓋長(zhǎng)度為 10 億的 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"; }
這些數(shù)據(jù),放在堆內(nèi)內(nèi)存中,還是過(guò)大了。幸運(yùn)的是,Redis 也支持 Bitmap 結(jié)構(gòu),如果內(nèi)存有壓力,我們可以把這個(gè)結(jié)構(gòu)放到 Redis 中,判斷邏輯也是類似的。
再插一道面試算法題:給出一個(gè) 1GB 內(nèi)存的機(jī)器,提供 60億 int 數(shù)據(jù),如何快速判斷有哪些數(shù)據(jù)是重復(fù)的?
大家可以類比思考一下。Bitmap 是一個(gè)比較底層的結(jié)構(gòu),在它之上還有一個(gè)叫作布隆過(guò)濾器的結(jié)構(gòu)(Bloom Filter),布隆過(guò)濾器可以判斷一個(gè)值不存在,或者可能存在。
如圖,它相比較 Bitmap,它多了一層 hash 算法。既然是 hash 算法,就會(huì)有沖突,所以有可能有多個(gè)值落在同一個(gè) bit 上。它不像 HashMap一樣,使用鏈表或者紅黑樹(shù)來(lái)處理沖突,而是直接將這個(gè)hash槽重復(fù)使用。從這個(gè)特性我們能夠看出,布隆過(guò)濾器能夠明確表示一個(gè)值不在集合中,但無(wú)法判斷一個(gè)值確切的在集合中。
Guava 中有一個(gè) BloomFilter 的類,可以方便地實(shí)現(xiàn)相關(guān)功能。
上面這種優(yōu)化方式,本質(zhì)上也是把大對(duì)象變成小對(duì)象的方式,在軟件設(shè)計(jì)中有很多類似的思路。比如像一篇新發(fā)布的文章,頻繁用到的是摘要數(shù)據(jù),就不需要把整個(gè)文章內(nèi)容都查詢出來(lái);用戶的 feed 信息,也只需要保證可見(jiàn)信息的速度,而把完整信息存放在速度較慢的大型存儲(chǔ)里。
數(shù)據(jù)的冷熱分離
數(shù)據(jù)除了橫向的結(jié)構(gòu)緯度,還有一個(gè)縱向的時(shí)間維度,對(duì)時(shí)間維度的優(yōu)化,最有效的方式就是冷熱分離。
所謂熱數(shù)據(jù),就是靠近用戶的,被頻繁使用的數(shù)據(jù);而冷數(shù)據(jù)是那些訪問(wèn)頻率非常低,年代非常久遠(yuǎn)的數(shù)據(jù)。
同一句復(fù)雜的 SQL,運(yùn)行在幾千萬(wàn)的數(shù)據(jù)表上,和運(yùn)行在幾百萬(wàn)的數(shù)據(jù)表上,前者的效果肯定是很差的。所以,雖然你的系統(tǒng)剛開(kāi)始上線時(shí)速度很快,但隨著時(shí)間的推移,數(shù)據(jù)量的增加,就會(huì)漸漸變得很慢。
冷熱分離是把數(shù)據(jù)分成兩份,如下圖,一般都會(huì)保持一份全量數(shù)據(jù),用來(lái)做一些耗時(shí)的統(tǒng)計(jì)操作。
由于冷熱分離在工作中經(jīng)常遇到,所以面試官會(huì)頻繁問(wèn)到數(shù)據(jù)冷熱分離的方案。下面簡(jiǎn)單介紹三種:
數(shù)據(jù)雙寫(xiě)
把對(duì)冷熱庫(kù)的插入、更新、刪除操作,全部放在一個(gè)統(tǒng)一的事務(wù)里面。由于熱庫(kù)(比如 MySQL)和冷庫(kù)(比如 Hbase)的類型不同,這個(gè)事務(wù)大概率會(huì)是分布式事務(wù)。在項(xiàng)目初期,這種方式是可行的,但如果是改造一些遺留系統(tǒng),分布式事務(wù)基本上是改不動(dòng)的,我通常會(huì)把這種方案直接廢棄掉。
寫(xiě)入 MQ 分發(fā)
通過(guò) MQ 的發(fā)布訂閱功能,在進(jìn)行數(shù)據(jù)操作的時(shí)候,先不落庫(kù),而是發(fā)送到 MQ 中。單獨(dú)啟動(dòng)消費(fèi)進(jìn)程,將 MQ 中的數(shù)據(jù)分別落到冷庫(kù)、熱庫(kù)中。使用這種方式改造的業(yè)務(wù),邏輯非常清晰,結(jié)構(gòu)也比較優(yōu)雅。像訂單這種結(jié)構(gòu)比較清晰、對(duì)順序性要求較低的系統(tǒng),就可以采用 MQ 分發(fā)的方式。但如果你的數(shù)據(jù)庫(kù)實(shí)體量非常大,用這種方式就要考慮程序的復(fù)雜性了。
使用 Binlog 同步
針對(duì) MySQL,就可以采用 Binlog 的方式進(jìn)行同步,使用 Canal 組件,可持續(xù)獲取最新的 Binlog 數(shù)據(jù),結(jié)合 MQ,可以將數(shù)據(jù)同步到其他的數(shù)據(jù)源中。
思維發(fā)散
對(duì)于結(jié)果集的操作,我們可以再發(fā)散一下思維。可以將一個(gè)簡(jiǎn)單冗余的結(jié)果集,改造成復(fù)雜高效的數(shù)據(jù)結(jié)構(gòu)。這個(gè)復(fù)雜的數(shù)據(jù)結(jié)構(gòu)可以代理我們的請(qǐng)求,有效地轉(zhuǎn)移耗時(shí)操作。
比如,我們常用的數(shù)據(jù)庫(kù)索引,就是一種對(duì)數(shù)據(jù)的重新組織、加速。B+ tree 可以有效地減少數(shù)據(jù)庫(kù)與磁盤(pán)交互的次數(shù),它通過(guò)類似 B+ tree 的數(shù)據(jù)結(jié)構(gòu),將最常用的數(shù)據(jù)進(jìn)行索引,存儲(chǔ)在有限的存儲(chǔ)空間中。
還有就是,在 RPC 中常用的序列化。有的服務(wù)是采用的 SOAP 協(xié)議的 WebService,它是基于 XML 的一種協(xié)議,內(nèi)容大傳輸慢,效率低下?,F(xiàn)在的 Web 服務(wù)中,大多數(shù)是使用 json 數(shù)據(jù)進(jìn)行交互的,json 的效率相比 SOAP 就更高一些。
另外,大家應(yīng)該都聽(tīng)過(guò) google 的 protobuf,由于它是二進(jìn)制協(xié)議,而且對(duì)數(shù)據(jù)進(jìn)行了壓縮,性能是非常優(yōu)越的。protobuf 對(duì)數(shù)據(jù)壓縮后,大小只有 json 的 1/10,xml 的 1/20,但是性能卻提高了 5-100 倍。
protobuf 的設(shè)計(jì)是值得借鑒的,它通過(guò) tag|leng|value 三段對(duì)數(shù)據(jù)進(jìn)行了非常緊湊的處理,解析和傳輸速度都特別快。
小結(jié)
最后總結(jié)一下本文的內(nèi)容重點(diǎn):
首先,我們看了比較老的 JDK 版本中,String 為了復(fù)用引起的內(nèi)容泄漏問(wèn)題,所以我們平常的編碼中,一定要注意大對(duì)象的回收,及時(shí)切斷與它的聯(lián)系。
接下來(lái),我們看了 Java 中集合的一些擴(kuò)容操作,如果你知道確切的集合大小,就可以指定一個(gè)初始值,避免耗時(shí)的擴(kuò)容操作。
針對(duì)大對(duì)象,我們有結(jié)構(gòu)緯度的優(yōu)化和時(shí)間維度的優(yōu)化兩種方法:
從結(jié)構(gòu)緯度來(lái)說(shuō),通過(guò)把對(duì)象切分成合適的粒度,可以把操作集中在小數(shù)據(jù)結(jié)構(gòu)上,減少時(shí)間處理成本;通過(guò)把對(duì)象進(jìn)行壓縮、轉(zhuǎn)換,或者提取熱點(diǎn)數(shù)據(jù),就可以避免大對(duì)象的存儲(chǔ)和傳輸成本。
從時(shí)間緯度來(lái)說(shuō),就可以通過(guò)冷熱分離的手段,將常用的數(shù)據(jù)存放在高速設(shè)備中,減少數(shù)據(jù)處理的集合,加快處理速度。
到現(xiàn)在為止,我們學(xué)習(xí)了緩沖、緩存、對(duì)象池化、結(jié)果緩存池、大對(duì)象處理等優(yōu)化性能的手段,由于它們都加入了額外的中間層,會(huì)使得編程模型變得復(fù)雜。
到此這篇關(guān)于詳解處理Java中的大對(duì)象的方法的文章就介紹到這了,更多相關(guān)Java大對(duì)象內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java利用redis zset實(shí)現(xiàn)延時(shí)任務(wù)詳解
zset作為redis的有序集合數(shù)據(jù)結(jié)構(gòu)存在,排序的依據(jù)就是score。本文就將利用zset score這個(gè)排序的這個(gè)特性,來(lái)實(shí)現(xiàn)延時(shí)任務(wù),感興趣的可以了解一下2022-08-08Java實(shí)現(xiàn)合并word文檔的示例代碼
在做項(xiàng)目中,經(jīng)常會(huì)遇到一種情況,需要將一個(gè)小word文檔的內(nèi)容插入到一個(gè)大word(主文檔)中。本文就為大家準(zhǔn)備了Java實(shí)現(xiàn)合并word文檔的方法,需要的可以參考一下2022-08-08詳解SpringBoot與SpringCloud的版本對(duì)應(yīng)詳細(xì)版
這篇文章主要介紹了詳解SpringBoot與SpringCloud的版本對(duì)應(yīng)詳細(xì)版,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09springboot默認(rèn)文件緩存(easy-captcha?驗(yàn)證碼)
這篇文章主要介紹了springboot的文件緩存(easy-captcha?驗(yàn)證碼),本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-06-06MyBatis攔截器動(dòng)態(tài)替換表名的方法詳解
因?yàn)槲覀兂志脤涌蚣芨嗟厥褂肕yBatis,那我們就借助于MyBatis的攔截器來(lái)完成我們的功能,這篇文章主要給大家介紹了關(guān)于MyBatis攔截器動(dòng)態(tài)替換表名的相關(guān)資料,需要的朋友可以參考下2022-04-04教新手使用java如何對(duì)一個(gè)大的文本文件內(nèi)容進(jìn)行去重
用HashSet對(duì)內(nèi)容去重這個(gè)過(guò)程jvm會(huì)內(nèi)存溢出,只能首先將這個(gè)大文件中的內(nèi)容讀取出來(lái),對(duì)每行String的hashCode取模取正整數(shù),可用取模結(jié)果作為文件名,將相同模數(shù)的行寫(xiě)入同一個(gè)文件,再單獨(dú)對(duì)每個(gè)小文件進(jìn)行去重,最后再合并2021-06-06SpringCloud創(chuàng)建多模塊項(xiàng)目的實(shí)現(xiàn)示例
,Spring Cloud作為一個(gè)強(qiáng)大的微服務(wù)框架,提供了豐富的功能和組件,本文主要介紹了SpringCloud創(chuàng)建多模塊項(xiàng)目的實(shí)現(xiàn)示例,具有一定的參考價(jià)值,感興趣的可以了解一下2024-02-02關(guān)于Spring事務(wù)隔離、傳播屬性與@Transactional注解
這篇文章主要介紹了關(guān)于事務(wù)隔離、Spring傳播屬性與@Transactional注解,如果一組處理步驟或者全部發(fā)生或者一步也不執(zhí)行,我們稱該組處理步驟為一個(gè)事務(wù),需要的朋友可以參考下2023-05-05