前面的文章,我們分别了解緩沖、緩存、池化對象、大對象複用、并行計算、鎖優化、NIO 等優化方法,它們對性能的提升往往是質的飛躍。
但語言本身對性能也是有影響的,比如就有很多公司就因為語言的特性由 Java 切換到 Golang。對于 Java 語言來說,也有它的一套優化法則,這些細微的性能差異,經過多次調用和疊代,會産生越來越大的影響。
今天我們将集中講解一些常用的代碼優化法則,進而在編碼中保持好的習慣,讓代碼保持最優狀态。
代碼優化法則
1.使用局部變量可避免在堆上配置設定
由于堆資源是多線程共享的,是垃圾回收器工作的主要區域,過多的對象會造成 GC 壓力。可以通過局部變量的方式,将變量在棧上配置設定。這種方式變量會随着方法執行的完畢而銷毀,能夠減輕 GC 的壓力。
2.減少變量的作用範圍
注意變量的作用範圍,盡量減少對象的建立。如下面的代碼,變量 a 每次進入方法都會建立,可以将它移動到 if 語句内部。
public void test1(String str) {
final int a = 100;
if (!StringUtils.isEmpty(str)) {
int b = a * a;
}
}
3.通路靜态變量直接使用類名
有的同學習慣使用對象通路靜态變量,這種方式多了一步尋址操作,需要先找到變量對應的類,再找到類對應的變量,如下面的代碼:
public class StaticCall {
public static final int A = 1;
void test() {
System.out.println(this.A);
System.out.println(StaticCall.A);
}
}
對應的位元組碼為:
void test();
descriptor: ()V
flags:
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: pop
5: iconst_1
6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
9: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
12: iconst_1
13: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
16: return
LineNumberTable:
line 5: 0
line 6: 9
line 7: 16
可以看到使用 this 的方式多了一個步驟。
4.字元串拼接使用 StringBuilder
字元串拼接,使用 StringBuilder 或者 StringBuffer,不要使用 + 号。比如下面這段代碼,在循環中拼接了字元串。
public String test() {
String str = "-1";
for (int i = 0; i < 10; i++) {
str += i;
}
return str;
}
從下面對應的位元組碼内容可以看出,它在每個循環裡都建立了一個 StringBuilder 對象。是以,我們在平常的編碼中,顯式地建立一次即可。
5: iload_2
6: bipush 10
8: if_icmpge 36
11: new #3 // class java/lang/StringBuilder
14: dup
15: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
18: aload_1
19: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: iload_2
23: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
26: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
29: astore_1
30: iinc 2, 1
33: goto 5
5.重寫對象的 HashCode,不要簡單地傳回固定值
在代碼 review 的時候,我發現有開發重寫 HashCode 和 Equals 方法時,會把 HashCode 的值傳回固定的 0,而這樣做是不恰當的。
當這些對象存入 HashMap 時,性能就會非常低,因為 HashMap 是通過 HashCode 定位到 Hash 槽,有沖突的時候,才會使用連結清單或者紅黑樹組織節點。固定地傳回 0,相當于把 Hash 尋址功能給廢除了。
6.HashMap 等集合初始化的時候,指定初始值大小
這個原則參見 “10 | 案例分析:大對象複用的目标和注意點”,這樣的對象有很多,比如 ArrayList,StringBuilder 等,通過指定初始值大小可減少擴容造成的性能損耗。
7.周遊 Map 的時候,使用 EntrySet 方法
使用 EntrySet 方法,可以直接傳回 set 對象,直接拿來用即可;而使用 KeySet 方法,獲得的是key 的集合,需要再進行一次 get 操作,多了一個操作步驟。是以更推薦使用 EntrySet 方式周遊 Map。
8.不要在多線程下使用同一個 Random
Random 類的 seed 會在并發通路的情況下發生競争,造成性能降低,建議在多線程環境下使用 ThreadLocalRandom 類。
在 Linux 上,通過加入 JVM 配置 -Djava.security.egd=file:/dev/./urandom,使用 urandom 随機生成器,在進行随機數擷取時,速度會更快。
9.自增推薦使用 LongAddr
自增運算可以通過 synchronized 和 volatile 的組合,或者也可以使用原子類(比如 AtomicLong)。
後者的速度比前者要高一些,AtomicLong 使用 CAS 進行比較替換,線上程多的情況下會造成過多無效自旋,是以可以使用 LongAdder 替換 AtomicLong 進行進一步的性能提升。
10.不要使用異常控制程式流程
異常,是用來了解并解決程式中遇到的各種不正常的情況,它的實作方式比較昂貴,比平常的條件判斷語句效率要低很多。
這是因為異常在位元組碼層面,需要生成一個如下所示的異常表(Exception table),多了很多判斷步驟。
Exception table:
from to target type
7 17 20 any
20 23 20 any
是以,盡量不要使用異常控制程式流程。
11.不要在循環中使用 try catch
道理與上面類似,很多文章介紹,不要把異常處理放在循環裡,而應該把它放在最外層,但實際測試情況表明這兩種方式性能相差并不大。
既然性能沒什麼差别,那麼就推薦根據業務的需求進行編碼。比如,循環遇到異常時,不允許中斷,也就是允許在發生異常的時候能夠繼續運作下去,那麼異常就隻能在 for 循環裡進行處理。
12.不要捕捉 RuntimeException
Java 異常分為兩種,一種是可以通過預檢查機制避免的 RuntimeException;另外一種就是普通異常。
其中,RuntimeException 不應該通過 catch 語句去捕捉,而應該使用編碼手段進行規避。
如下面的代碼,list 可能會出現數組越界異常。是否越界是可以通過代碼提前判斷的,而不是等到發生異常時去捕捉。提前判斷這種方式,代碼會更優雅,效率也更高。
//BAD
public String test1(List<String> list, int index) {
try {
return list.get(index);
} catch (IndexOutOfBoundsException ex) {
return null;
}
}
//GOOD
public String test2(List<String> list, int index) {
if (index >= list.size() || index < 0) {
return null;
}
return list.get(index);
}
13.合理使用 PreparedStatement
PreparedStatement 使用預編譯對 SQL 的執行進行提速,大多數資料庫都會努力對這些能夠複用的查詢語句進行預編譯優化,并能夠将這些編譯結果緩存起來。
這樣等到下次用到的時候,就可以很快進行執行,也就少了一步對 SQL 的解析動作。
PreparedStatement 還能提高程式的安全性,能夠有效防止 SQL 注入。
但如果你的程式每次 SQL 都會變化,不得不手工拼接一些資料,那麼 PreparedStatement 就失去了它的作用,反而使用普通的 Statement 速度會更快一些。
14.日志列印的注意事項
我們在“06 | 案例分析:緩沖區如何讓代碼加速”中了解了 logback 的異步日志,日志列印還有一些其他要注意的事情。
我們平常會使用 debug 輸出一些調試資訊,然後線上上關掉它。如下代碼:
logger.debug("xjjdog:"+ topic + " is awesome" );
程式每次運作到這裡,都會構造一個字元串,不管你是否把日志級别調試到 INFO 還是 WARN,這樣效率就會很低。
可以在每次列印之前都使用 isDebugEnabled 方法判斷一下日志級别,代碼如下:
if(logger.isDebugEnabled()) {
logger.debug("xjjdog:"+ topic + " is awesome" );
}
使用占位符的方式,也可以達到相同的效果,就不用手動添加 isDebugEnabled 方法了,代碼也優雅得多。
logger.debug("xjjdog:{} is awesome" ,topic);
對于業務系統來說,日志對系統的性能影響非常大,不需要的日志,盡量不要列印,避免占用 I/O 資源。
15.減少事務的作用範圍
如果的程式使用了事務,那一定要注意事務的作用範圍,盡量以最快的速度完成事務操作。這是因為,事務的隔離性是使用鎖實作的,可以類比使用 “13 | 案例分析:多線程鎖的優化” 中的多線程鎖進行優化。
@Transactional
public void test(String id){
String value = rpc.getValue(id); //高耗時
testDao.update(sql,value);
}
如上面的代碼,由于 rpc 服務耗時高且不穩定,就應該把它移出到事務之外,改造如下:
public void test(String id){
String value = rpc.getValue(id); //高耗時
testDao(value);
}
@Transactional
public void testDao(String value){
testDao.update(value);
}
這裡有一點需要注意的地方,由于 SpringAOP 的原因,@Transactional 注解隻能用到 public 方法上,如果用到 private 方法上,将會被忽略,這也是面試經常問的考點之一。
16.使用位移操作替代乘除法
計算機是使用二進制表示的,位移操作會極大地提高性能。
- << 左移相當于乘以 2;
- >> 右移相當于除以 2;
- >>> 無符号右移相當于除以 2,但它會忽略符号位,空位都以 0 補齊。
int a = 2;
int b = (a++) << (++a) + (++a);
System.out.println(b);
注意:位移操作的優先級非常低,是以上面的代碼,輸出是 1024。
17.不要列印大集合或者使用大集合的 toString 方法
有的開發喜歡将集合作為字元串輸出到日志檔案中,這個習慣是非常不好的。
拿 ArrayList 來說,它需要周遊所有的元素來疊代生成字元串。在集合中元素非常多的情況下,這不僅會占用大量的記憶體空間,執行效率也非常慢。我曾經就遇到過這種批量列印方式造成系統性能直線下降的實際案例。
下面這段代碼,就是 ArrayList 的 toString 方法。它需要生成一個疊代器,然後把所有的元素内容拼接成一個字元串,非常浪費空間。
public String toString() {
Iterator<E> it = iterator();
if (! it.hasNext())
return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (;;) {
E e = it.next();
sb.append(e == this ? "(this Collection)" : e);
if (! it.hasNext())
return sb.append(']').toString();
sb.append(',').append(' ');
}
}
18.程式中少用反射
反射的功能很強大,但它是通過解析位元組碼實作的,性能就不是很理想。
現實中有很多對反射的優化方法,比如把反射執行的過程(比如 Method)緩存起來,使用複用來加快反射速度。
Java 7.0 之後,加入了新的包 java.lang.invoke,同時加入了新的 JVM 位元組碼指令 invokedynamic,用來支援從 JVM 層面,直接通過字元串對目标方法進行調用。
如果你對性能有非常苛刻的要求,則使用 invoke 包下的 MethodHandle 對代碼進行着重優化,但它的程式設計不如反射友善,在平常的編碼中,反射依然是首選。
下面是一個使用 MethodHandle 編寫的代碼實作類。它可以完成一些動态語言的特性,通過方法名稱和傳入的對象主體,進行不同的調用,而 Bike 和 Man 類,可以是沒有任何關系的。
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class MethodHandleDemo {
static class Bike {
String sound() {
return "ding ding";
}
}
static class Animal {
String sound() {
return "wow wow";
}
}
static class Man extends Animal {
@Override
String sound() {
return "hou hou";
}
}
String sound(Object o) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(String.class);
MethodHandle methodHandle = lookup.findVirtual(o.getClass(), "sound", methodType);
String obj = (String) methodHandle.invoke(o);
return obj;
}
public static void main(String[] args) throws Throwable {
String str = new MethodHandleDemo().sound(new Bike());
System.out.println(str);
str = new MethodHandleDemo().sound(new Animal());
System.out.println(str);
str = new MethodHandleDemo().sound(new Man());
System.out.println(str);
}
}
19.正規表達式可以預先編譯,加快速度
Java 的正規表達式需要先編譯再使用。
典型代碼如下:
Pattern pattern = Pattern.compile({pattern});
Matcher pattern = pattern.matcher({content});
Pattern 編譯非常耗時,它的 Matcher 方法是線程安全的,每次調用方法這個方法都會生成一個新的 Matcher 對象。是以,一般 Pattern 初始化一次即可,可以作為類的靜态成員變量。
案例分析
案例 1:正規表達式和狀态機
正規表達式的執行效率是非常慢的,尤其是貪婪模式。
下面介紹一個我在實際工作中對正則的一個優化,使用狀态機完成字元串比對。
考慮到下面的一個 SQL 語句,它的文法類似于 NamedParameterJdbcTemplate,但我們對它做了增強。SQL 接收兩個參數:smallId 和 firstName,當 firstName 為空的時候,處在 ##{} 之間的語句将被抹去。
select * from USERS
where id>:smallId
##{
and FIRST_NAME like concat('%',:firstName,'%') }
可以看到,使用正規表達式可以很容易地實作這個功能。
#\{(.*?:([a-zA-Z0-9_]+).*?)\}
通過定義上面這樣一個正則比對,使用 Pattern 的 group 功能便能提取到相應的字元串。我們把比對到的字元串儲存下來,最後使用 replace 函數,将它替換成空字元串即可。
結果在實際使用的時候,發現正則的解析速度特别慢,尤其是在 SQL 非常大的時候,這種情況下,可以使用狀态機去優化。我這裡選用的是 ragel,你也可以使用類似 javacc 或者 antlr 之類的工具。它通過文法解析和簡單的正規表達式,最終可以生成 Java 文法的代碼。
生成的代碼一般是不可讀的,我們隻關注定義檔案即可。如下定義檔案代碼所示,通過定義一批描述符和處理程式,使用一些中間資料結構對結果進行緩存,隻需要對 SQL 掃描一遍,即可擷取相應的結果。
pairStart = '#{';
pairEnd = '}';
namedQueryStringFull = ( ':'alnum+)
>buffer
%namedQueryStringFull
;
pairBlock =
(pairStart
any*
namedQueryStringFull
any*
pairEnd)
>pairBlockBegin %pairBlockEnd
;
main := any* pairBlock any*;
把檔案定義好之後,即可通過 ragel 指令生成 Java 文法的最終檔案。
ragel -G2 -J -o P.java P.rl
完整的代碼有點複雜,我已經放到了倉庫中,你可以實際分析一下。
我們來看一下它的性能。從測試結果可以看到,ragel 模式的性能是 regex 模式的 3 倍還多,SQL 越長,效果越明顯。
Benchmark Mode Cnt Score Error Units
RegexVsRagelBenchmark.ragel thrpt 10 691.224 ± 446.217 ops/ms
RegexVsRagelBenchmark.regex thrpt 10 201.322 ± 47.056 ops/ms
案例 2:HikariCP 的位元組碼修改
在 “09 | 案例分析:池化對象的應用場景” 中,我們提到了 HikariCP 對位元組碼的修改,這個職責是由 JavassistProxyFactory 類來管理的。Javassist 是一個位元組碼類庫,HikariCP 就是用它對位元組碼進行修改。
如下圖所示,這是工廠類的主要方法。
它通過 generateProxyClass 生成代理類,主要是針對 Connection、Statement、ResultSet、DatabaseMetaData 等 jdbc 的核心接口。
右鍵運作這個類,可以看到代碼生成了一堆 Class 檔案。
Generating com.zaxxer.hikari.pool.HikariProxyConnection
Generating com.zaxxer.hikari.pool.HikariProxyStatement
Generating com.zaxxer.hikari.pool.HikariProxyResultSet
Generating com.zaxxer.hikari.pool.HikariProxyDatabaseMetaData
Generating com.zaxxer.hikari.pool.HikariProxyPreparedStatement
Generating com.zaxxer.hikari.pool.HikariProxyCallableStatement
Generating method bodies for com.zaxxer.hikari.proxy.ProxyFactory
對于這一部分的代碼組織,使用了設計模式中的委托模式。我們發現 HikariCP 源碼中的代理類,比如 ProxyConnection,都是 abstract 的,它的具體執行個體就是使用 javassist 生成的 class 檔案。反編譯這些生成的 class 檔案,可以看到它實際上是通過調用父類中的委托對象進行處理的。
這麼做有兩個好處:
- 第一,在代碼中隻需要實作需要修改的 JDBC 接口方法,其他的交給代理類自動生成的代碼,極大地減少了編碼數量。
- 第二,出現問題時,可以通過 checkException 函數對錯誤進行統一處理。
另外,我們注意到 ProxyFactory 類中的方法,都是靜态方法,而不是通過單例實作的。為什麼這麼做呢?這就涉及 JVM 底層的兩個位元組碼指令:invokestatic 和 invokevirtual。
下面是兩種不同類型調用的位元組碼。
- invokevirtual
public final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=5, locals=3, args_size=3
0: getstatic #59 // Field PROXY_FACTORY:Lcom/zaxxer/hikari/proxy/ProxyFactory;
3: aload_0
4: aload_0
5: getfield #3 // Field delegate:Ljava/sql/Connection;
8: aload_1
9: aload_2
10: invokeinterface #74, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
15: invokevirtual #69 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
18: return
- invokestatic
private final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=4, locals=3, args_size=3
0: aload_0
1: aload_0
2: getfield #3 // Field delegate:Ljava/sql/Connection;
5: aload_1
6: aload_2
7: invokeinterface #72, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
12: invokestatic #67 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
15: areturn
大多數普通方法調用,使用的是invokevirtual指令,屬于虛方法調用。
很多時候,JVM 需要根據調用者的動态類型,來确定調用的目标方法,這就是動态綁定的過程;相對比,invokestatic指令,就屬于靜态綁定過程,能夠直接識别目标方法,效率會高那麼一點點。
雖然 HikariCP 的這些優化有點吹毛求疵,但我們能夠從中看到 HikariCP 這些追求性能極緻的編碼技巧。
小結
此外,學習 Java 規範,你還可以細讀《阿裡巴巴 Java 開發規範》,裡面也有很多有意義的建議。
其實語言層面的性能優化,都是在各個資源之間的權衡(比如開發時間、代碼複雜度、擴充性等)。這些法則也不是一成不變的教條,這就要求我們在編碼中選擇合适的工具,根據實際的工作場景進行靈活變動。