1. 程式人生 > >Java效能優化-字串和數字構造

Java效能優化-字串和數字構造

本部落格來自我的新書Java效能優化(暫定名),第二章的節選2.1和2.2,2.10. 也歡迎購買我的書 《Spring Boot 2 精髓 》

2.1 構造字串

字串在Java裡是不可變的,無論是構造,還是擷取,得到的總是一個新字串。看一下構造一個字串原始碼

private final char value[];
public String(String original) {
  this.value = original.value;
  this.hash = original.hash;
}

原有的字串的value陣列直接通過引用賦值給新的字串value,也就是倆個字串共享一個char陣列,因此這種構造方法有著最快的構造。Java裡的String物件被設計為不可變。意思是指一旦程式獲得了字串物件引用,不必擔心這個字串在別的地方被修改,不可變意味著執行緒安全,在第三章對不可變物件執行緒安全性又說明。

構造字串更多的情況構造字串是通過一個字串陣列,或者在某些框架的反序列化,使用byte[] 來構造字串,這種情況下效能會非常低。 如下是通過char[]陣列構造一個新的字串原始碼

public String(char value[]) {
  this.value = Arrays.copyOf(value, value.length);
}

Arrays.copyOf 會重新拷貝一份新的陣列,方法如下

public static char[] copyOf(char[] original, int newLength) {
  char[] copy = new char[newLength];
  System.arraycopy(original, 0, copy, 0,
                   Math.min(original.length, newLength));
  return copy;
}

可以看到通過陣列構造字串實際上是會建立一個新的字串陣列。如果不這樣,還是直接引用char陣列,那麼外部如果更改char陣列,則這個新的字串就被改變了。

char[] cs = new char[]{'a','b'};
String str = new String(cs);
cs[0] ='!'

上面的程式碼最後一行,修改了cs陣列,但不會影響str。因為str實際上是新的字串陣列構成

通過char陣列構造新的字串是最長用的方法,我們後面看到幾乎每個字串API,都會呼叫這個方法構造新的字串,比如subString,concat等方法。如下程式碼驗證了通過字串構造新的字串,以及使用char陣列構造字串效能比較

String str= "你好,String";
char[] chars = str.toCharArray();

[@Benchmark](https://my.oschina.net/u/3268003)
public String string(){
  return new String(str);
}

[@Benchmark](https://my.oschina.net/u/3268003)
public String stringByCharArray(){
  return new String(chars);
}

輸出按照ns/op來輸出,既每次呼叫所用的納秒數,可以看到通過char構造字串還是先當耗時的,特別如果是陣列特別長,那更加耗時

Benchmark                                  Mode     Score    Units     
c.i.c.c.NewStringTest.string               avgt     4.235    ns/op     
c.i.c.c.NewStringTest.stringByCharArray    avgt    11.704    ns/op     

通過位元組構造字串,是一種非常常見的情況,尤其現在分散式和微服務流行,字串在客戶端序列化成位元組陣列,併發送給你給伺服器端,伺服器端會有一個反序列化,通過byte構造字串

如下測試使用byte構造字串效能測試

byte[] bs = "你好,String".getBytes("UTF-8");

[@Benchmark](https://my.oschina.net/u/3268003)
public String stringByByteArray() throws Exception{
  return new String(bs,"UTF-8");
}

測試結果可以看到byte構造字串太耗時了,尤其是當要構造的字串非常長的時候

Benchmark                                  Mode    Score    Units       
c.i.c.c.NewStringTest.string               avgt    4.649    ns/op       
c.i.c.c.NewStringTest.stringByByteArray    avgt   82.166    ns/op       
c.i.c.c.NewStringTest.stringByCharArray    avgt   12.138    ns/op       

通過位元組陣列構造字串,主要涉及到轉碼過程,內部會呼叫 StringCoding.decode轉碼

this.value = StringCoding.decode(charsetName, bytes, offset, length);

charsetName表示字符集,bytes是位元組陣列,offset和length表示位元組陣列

實際負責轉碼的是Charset子類,比如sun.nio.cs.UTF_8的decode方法負責實現位元組轉碼,如果在深入到這個類,你會發現,你看到的是冰上一角,冰上下面這是一個相當耗CPU計算轉碼的工作,屬於無法優化的部分.

在我多次的系統性能優化過程中,都會發現通過位元組資料組構造字串總是排在消耗CPU比較靠前的位置,轉碼消耗的系統性能抵得上百行的業務程式碼。 因此我們系統在設計到分散式的,需要仔細設計需要傳輸的欄位,儘量避免用String。比如時間可以用long型別來表示,業務狀態也可以用int來表示。如下需要序列化的物件

public class OrderResponse{
  //訂單日期,格式'yyyy-MM-dd'
  private String createDate;
  //訂單狀態,"0"表示正常
  private String status;
}

可以改進成更好的定義,以減小序列化和反序列化負擔。

public class OrderResponse{
  //訂單日期
  private long  createDate;
  //訂單狀態,0表示正常
  private int status;
}

關於在微服務中,序列化和反序列化傳輸物件,會在第四章和五章再次介紹物件的序列化

2.2 字串拼接

JDK會自動將使用+號做的字串拼接自動轉化為StringBuilder,如下程式碼:

String a="hello";
String b ="world "
String str=a+b;

虛擬機器會編譯成如下程式碼

String str = new StringBuilder().append(a).append(b).toString();

如果你執行JMH測試這倆段程式碼,效能其實一樣的,因為使用+連線字串是一個常見操作,虛擬機器對如上倆個程式碼片段都會做一些優化,虛擬使用-XX:+OptimizeStringConcat 開啟字串拼接優化,(預設情況下是開啟的)。 如果採用以下程式碼,雖然看是跟上面的程式碼片段差不多,但虛擬機器無法識別這種字串拼接模式,效能會下降很多

StringBuilder sb = new StringBuilder();
sb.append(a);
sb.append(b);

執行StringConcatTest類,程式碼如下

String a = "select u.id,u.name from user  u";
String b="  where u.id=? "   ;
[@Benchmark](https://my.oschina.net/u/3268003)
public String concat(){
  String c = a+b;
  return c ;

}

[@Benchmark](https://my.oschina.net/u/3268003)
public String concatbyOptimizeBuilder(){
  String c = new StringBuilder().append(a).append(b).toString();
  return c;
}


@Benchmark
public String concatbyBuilder(){
  //不會優化
  StringBuilder sb = new StringBuilder();
  sb.append(a);
  sb.append(b);
  return sb.toString();
}

有如下結果說明了虛擬機器優化起了作用

Benchmark                                           Mode    Score    Units         
c.i.c.c.StringConcatTest.concat                     avgt   25.747    ns/op         
c.i.c.c.StringConcatTest.concatbyBuilder            avgt   90.548    ns/op         
c.i.c.c.StringConcatTest.concatbyOptimizeBuilder    avgt   21.904    ns/op         

可以看到concatbyBuilder是最慢的,因為沒有被JVM優化

這裡說的JVM優化,指的是虛擬機器JIT優化,我們會在第8章JIT優化說明

讀者可以自己驗證一下a+b+c這種字串拼接效能,看一下是否被優化了

同StringBuilder類似的還有StringBuffer,主要功能都繼承AbstractStringBuilder, 提供了執行緒安全方法,比如append方法,使用了synchronized關鍵字

@Override
public synchronized StringBuffer append(String str) {
  //忽略其他程式碼
  super.append(str);
  return this;
}

幾乎所有場景字串拼接都不涉及到執行緒同步,因此StringBuffer已經很少使用了,如上的字串拼接例子使用StringBuffer,

  @Benchmark
  public String concatbyBuffer(){
    StringBuffer sb = new StringBuffer();
    sb.append(a);
    sb.append(b);
    return sb.toString();
  }

輸出如下

Benchmark                                           Mode      Score   Units
c.i.c.c.StringConcatTest.concatbyBuffer             avgt    111.417   ns/op
c.i.c.c.StringConcatTest.concatbyBuilder            avgt     94.758   ns/op

可以看到,StringBuffer拼接效能跟StringBuilder相比效能並不差,這得益於虛擬機器的"逃逸分析",也就是JIT在開啟逃逸分析情況以及鎖消除的情況下,有可能消除該物件上的使用synchronzied限定的鎖。

逃逸分析 -XX:+DoEscapeAnalysis和 鎖消除-XX:+EliminateLocks,詳情參考本書第8章JIT優化

如下是一個鎖消除的例子,物件obj只在方法內部使用,因此可以消除synchronized

void foo() {
  //建立一個物件
  Object obj = new Object(); 
  synchronized (obj) {
    doSomething();
  }
}

程式不應該依賴JIT的優化,儘管打開了逃逸分析和鎖消除,但不能保證所有程式碼都會被優化,因為鎖消除是在JIT的C2階段優化的,作為程式設計師,應該在無關執行緒安全情況下,使用StringBuilder。

使用StringBuilder 拼接其他型別,尤其是數字型別,則效能會明顯下降,這是因為數字型別轉字元在JDK內部,需要做很多工作,一個簡單的Int型別轉為字串,需要至少50行程式碼完成。我們在第一章已經看到過了,這裡不再詳細說明。當你用StringBuilder來拼接字串,拼接數字的時候,你需要思考,是否需要一個這樣的字串。

2.10 BigDecimal

我們都知道浮點型變數在進行計算的時候會出現丟失精度的問題。如下一段程式碼

System.out.println(0.05 + 0.01);
System.out.println(1.0 - 0.42); 

輸出: 0.060000000000000005 0.5800000000000001

可以看到在Java中進行浮點數運算的時候,會出現丟失精度的問題。那麼我們如果在進行商品價格計算的時候,就會出現問題。很有可能造成我們手中有0.06元,卻無法購買一個0.05元和一個0.01元的商品。因為如上所示,他們兩個的總和為0.060000000000000005。這無疑是一個很嚴重的問題,尤其是當電商網站的併發量上去的時候,出現的問題將是巨大的。可能會導致無法下單,或者對賬出現問題。

通常有倆個方法來解決這種問題,如果能用long來表示賬戶餘額以分為單位,這是效率最高的。如果不能,則只能使用BigDecimal類來解決這類問題。

BigDecimal a = new BigDecimal("0.05");
BigDecimal b = new BigDecimal("0.01");
BigDecimal ret = a.add(b);
System.out.println(ret.toString());

通過字串來構造BigDecimal,才能保證精度不丟失,如果使用new BigDecimal(0.05),則因為0.05本身精度丟失,使得構造出來的BigDecimal也丟失精度。

BigDecimal能保證精度,但計算會有一定效能影響,如下是測試餘額計算,用long表示分,用BigDecimal表示元的效能對比

BigDecimal a = new BigDecimal("0.05");
BigDecimal b = new BigDecimal("0.01");
long c = 5;
long d = 1;

@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public long addByLong() {
  return (c + d);
}
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public BigDecimal addByBigDecimal() {
  return a.add(b);
}

在我的機器行,上面程式碼都能進行精確計算,通過JMH,測試結果如下

Benchmark                                 Mode   Score    Units    
c.i.c.c.BigDecimalTest.addByBigDecimal    avgt   8.373    ns/op    
c.i.c.c.BigDecimalTest.addByLong          avgt   2.984    ns/op    

所以在專案裡,如果涉及精度結算,不要使用double,可以考慮用BigDecmal,也可以使用long來完成精度計算,具有良好的效能,分散式或者微服務場景,考慮到序列化和反序列化,long也是能被所有序列化框架識別的

內容參考 https://www.jianshu.c