我們都知道,String 在 Java 中是不可變的,如果你改動了其中的內容,它就會產生一個新的字串。如果我們想要用到字串中的一部分數據,就可以使用 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); }
如上述程式碼所示,當我們需要一個子字串的時候,substring 產生了一個新的字串,這個字串透過建構函式的 Arrays.copyOfRange 函式進行建構。
這個函數在 Java7 之後是沒有問題的,但在Java6 中,卻有著記憶體洩漏的風險,我們可以學習這個案例,來看看大物件復用可能會產生的問題。下面是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; }
可以看到,它在創建子字串的時候,並不僅僅拷貝所需要的對象,而是把整個 value 引用了起來。如果原字串比較大,即使不再使用,記憶體也不會釋放。
例如,一篇文章內容可能有幾兆,我們只是需要其中的摘要訊息,也不得不維持整個的大物件。
有一些工作年資比較長的面試官,對 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,如果你不是很清楚底層的結構,那就不妨保持預設。
接下來,我將從資料的結構緯度和時間維度出發,並說明應用層面的最佳化。
給你分享一個實際案例:我們有一個並發量非常高的業務系統,需要頻繁使用到使用者的基本資料。
如下圖所示,由於使用者的基本訊息,都是存放在另外一個服務中,所以每次用到使用者的基本訊息,都需要有一次網路互動。更讓人無法接受的是,即使是只需要使用者的性別屬性,也需要把所有的使用者資訊查詢,拉取一遍。
為了加快資料的查詢速度,對資料進行了初步的緩存,放入到了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 這個結構來壓縮。
这里还有个高频面试问题,那就是 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。还记得我们在之前 《分布式缓存系统必须要解决的四大问题》中提到的缓存穿透吗?就可以使用 Bitmap 避免,Java 中的相关结构类,就是 java.util.BitSet,BitSet 底层是使用 long 数组实现的,所以它的最小容量是 64。
10 亿的 Boolean 值,只需要 128MB 的内存,下面既是一个占用了 256MB 的用户性别的判断逻辑,可以涵盖长度为 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"; }
这些数据,放在堆内内存中,还是过大了。幸运的是,Redis 也支持 Bitmap 结构,如果内存有压力,我们可以把这个结构放到 Redis 中,判断逻辑也是类似的。
再插一道面试算法题:给出一个 1GB 内存的机器,提供 60亿 int 数据,如何快速判断有哪些数据是重复的?
大家可以类比思考一下。Bitmap 是一个比较底层的结构,在它之上还有一个叫作布隆过滤器的结构(Bloom Filter),布隆过滤器可以判断一个值不存在,或者可能存在。
如图,它相比较 Bitmap,它多了一层 hash 算法。既然是 hash 算法,就会有冲突,所以有可能有多个值落在同一个 bit 上。它不像 HashMap一样,使用链表或者红黑树来处理冲突,而是直接将这个hash槽重复使用。从这个特性我们能够看出,布隆过滤器能够明确表示一个值不在集合中,但无法判断一个值确切的在集合中。
Guava 中有一个 BloomFilter 的类,可以方便地实现相关功能。
上面这种优化方式,本质上也是把大对象变成小对象的方式,在软件设计中有很多类似的思路。比如像一篇新发布的文章,频繁用到的是摘要数据,就不需要把整个文章内容都查询出来;用户的 feed 信息,也只需要保证可见信息的速度,而把完整信息存放在速度较慢的大型存储里。
数据除了横向的结构纬度,还有一个纵向的时间维度,对时间维度的优化,最有效的方式就是冷热分离。
所谓热数据,就是靠近用户的,被频繁使用的数据;而冷数据是那些访问频率非常低,年代非常久远的数据。
同一句复杂的 SQL,运行在几千万的数据表上,和运行在几百万的数据表上,前者的效果肯定是很差的。所以,虽然你的系统刚开始上线时速度很快,但随着时间的推移,数据量的增加,就会渐渐变得很慢。
冷热分离是把数据分成两份,如下图,一般都会保持一份全量数据,用来做一些耗时的统计操作。
由于冷热分离在工作中经常遇到,所以面试官会频繁问到数据冷热分离的方案。下面简单介绍三种:
把对冷热库的插入、更新、删除操作,全部放在一个统一的事务里面。由于热库(比如 MySQL)和冷库(比如 Hbase)的类型不同,这个事务大概率会是分布式事务。在项目初期,这种方式是可行的,但如果是改造一些遗留系统,分布式事务基本上是改不动的,我通常会把这种方案直接废弃掉。
通过 MQ 的发布订阅功能,在进行数据操作的时候,先不落库,而是发送到 MQ 中。单独启动消费进程,将 MQ 中的数据分别落到冷库、热库中。使用这种方式改造的业务,逻辑非常清晰,结构也比较优雅。像订单这种结构比较清晰、对顺序性要求较低的系统,就可以采用 MQ 分发的方式。但如果你的数据库实体量非常大,用这种方式就要考虑程序的复杂性了。
针对 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 三段對資料進行了非常緊湊的處理,解析和傳輸速度都特別快。
以上是如何處理Java中的大對象的詳細內容。更多資訊請關注PHP中文網其他相關文章!