Switch竟然會報(bào)空指針異常,學(xué)到了!
本文轉(zhuǎn)載自微信公眾號「月伴飛魚」,作者日常加油站 。轉(zhuǎn)載本文請聯(lián)系月伴飛魚公眾號。
前言
前幾天重新看 《阿里巴巴Java開發(fā)手冊》有一條這樣的規(guī)約:
出于好奇,打算研究一下!,強(qiáng)迫癥,沒辦法!
我們先用一個(gè)案例測試一下:
- public class Test {
- public static void main(String[] args) {
- String param = null;
- switch (param) {
- case "null":
- System.out.println("匹配null字符串");
- break;
- default:
- System.out.println("進(jìn)入default");
- }
- }
- }
顯而易見,如果switch傳入空值,會拋空指針!
看到這,我們先可以思考下面幾個(gè)問題:
- switch 除了 String 還支持哪種類型?
- 為什么《阿里巴巴Java開發(fā)手冊》規(guī)定String類型參數(shù)要先進(jìn)行 null 判斷?
- 為什么可能會拋出空指針異常?
下面開始對上面的問題進(jìn)行分析
問題分析
首先參考官方文檔對swtich 語句相關(guān)描述。
翻譯如下:
switch 的表達(dá)式必須是 char, byte, short, int, Character, Byte, Short, Integer, String, 或者 enum 類型,否則會發(fā)生編譯錯(cuò)誤
同時(shí)switch 語句必須滿足以下條件,否則會出現(xiàn)編譯錯(cuò)誤:
- 與 switch 語句關(guān)聯(lián)的每個(gè) case 都必須和 switch 的表達(dá)式的類型一致;
- 如果 switch 表達(dá)式是枚舉類型,case 常量也必須是枚舉類型;
- 不允許同一個(gè) switch 的兩個(gè) case 常量的值相同;
- 和 switch 語句關(guān)聯(lián)的常量不能為 null ;
- 一個(gè) switch 語句最多有一個(gè) default 標(biāo)簽。
翻譯如下:
switch 語句執(zhí)行的時(shí)候,首先將執(zhí)行 switch 的表達(dá)式。如果表達(dá)式為 null, 則會拋出 NullPointerException,整個(gè) switch 語句的執(zhí)行將被中斷。
另外從《Java虛擬機(jī)規(guī)范》這本書,我們可以學(xué)習(xí)到:
總結(jié)一下就是:
1.編譯器使用 tableswitch 和 lookupswitch 指令生成 switch 語句的編譯代碼。
2.Java 虛擬機(jī)的 tableswitch 和 lookupswitch 指令只能支持 int 類型的條件值。如果 swich 中使用其他類型的值,那么就必須轉(zhuǎn)化為 int 類型。
所以可以了解到空指針出現(xiàn)的根源在于:虛擬機(jī)為了實(shí)現(xiàn) switch 的語法,將參數(shù)表達(dá)式轉(zhuǎn)換成 int。而這里的參數(shù)為 null, 從而造成了空指針異常。
下面對官方文檔的內(nèi)容采用反匯編方式進(jìn)一步分析下
不熟悉字節(jié)碼的,推薦看看美團(tuán)的這篇文章:https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
下面開始硬貨!
反匯編看看
一個(gè)例子:
- public class Test {
- public static void main(String[] args) {
- String param = "月伴飛魚";
- switch (param) {
- case "月伴飛魚1":
- System.out.println("月伴飛魚1");
- break;
- case "月伴飛魚2":
- System.out.println("月伴飛魚2");
- break;
- case "月伴飛魚3":
- System.out.println("月伴飛魚3");
- break;
- default:
- System.out.println("default");
- }
- }
- }
反匯編代碼得到:
- Compiled from "Test.java"
- public class com.zhou.Test {
- public zhou.Test();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- public static void main(java.lang.String[]);
- Code:
- 0: ldc #2 // String 月伴飛魚
- 2: astore_1
- 3: aload_1
- 4: astore_2
- 5: iconst_m1
- 6: istore_3
- 7: aload_2
- 8: invokevirtual #3 // Method java/lang/String.hashCode:()I
- 11: tableswitch { // -768121881 to -768121879
- -768121881: 36
- -768121880: 50
- -768121879: 64
- default: 75
- }
- 36: aload_2
- 37: ldc #4 // String 月伴飛魚1
- 39: invokevirtual #5 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
- 42: ifeq 75
- 45: iconst_0
- 46: istore_3
- 47: goto 75
- 50: aload_2
- 51: ldc #6 // String 月伴飛魚2
- 53: invokevirtual #5 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
- 56: ifeq 75
- 59: iconst_1
- 60: istore_3
- 61: goto 75
- 64: aload_2
- 65: ldc #7 // String 月伴飛魚3
- 67: invokevirtual #5 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
- 70: ifeq 75
- 73: iconst_2
- 74: istore_3
- 75: iload_3
- 76: tableswitch { // 0 to 2
- 0: 104
- 1: 115
- 2: 126
- default: 137
- }
- 104: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
- 107: ldc #4 // String 月伴飛魚1
- 109: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 112: goto 145
- 115: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
- 118: ldc #6 // String 月伴飛魚2
- 120: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 123: goto 145
- 126: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
- 129: ldc #7 // String 月伴飛魚3
- 131: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 134: goto 145
- 137: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
- 140: ldc #10 // String default
- 142: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 145: return
- }
先介紹一下下面會用到的字節(jié)碼指令
- invokevirtual:調(diào)用實(shí)例方法
- istore_0 將int類型值存入局部變量0
- istore_1 將int類型值存入局部變量1
- istore_2 將int類型值存入局部變量2
- istore_3 將int類型值存入局部變量3
- aload_0 從局部變量0中裝載引用類型值
- aload_1 從局部變量1中裝載引用類型值
- aload_2 從局部變量2中裝載引用類型值
我們繼續(xù)看匯編代碼:
先看偏移為 8 的指令,調(diào)用了參數(shù)的 hashCode() 函數(shù)來獲取字符串 "月伴飛魚" 的哈希值。
- 8: invokevirtual #3 // Method java/lang/String.hashCode:()I
接下來我們看偏移為 11 的指令處:
tableswitch 是跳轉(zhuǎn)引用列表, 如果值小于其中的最小值-768121881 或者大于其中的最大值-768121879,跳轉(zhuǎn)到 default 語句。
- 11: tableswitch { // -768121881 to -768121879
- -768121881: 36
- -768121880: 50
- -768121879: 64
- default: 75
- }
其中 -768121881 為鍵,36 為對應(yīng)的目標(biāo)語句偏移量。
hashCode 和 tableswitch 的鍵相等,則跳轉(zhuǎn)到對應(yīng)的目標(biāo)偏移量,"月伴飛魚"的哈希值806505866不在最小值-768121881和最大值-768121879之間,因此跳轉(zhuǎn)到 default 對應(yīng)的語句行(即偏移量為 75 的指令處執(zhí)行)。
月伴飛魚的hash值計(jì)算:("月伴飛魚").hashCode();
從 36 到 75 行,根據(jù)哈希值相等跳轉(zhuǎn)到判斷是否相等的指令。
然后調(diào)用java.lang.String#equals判斷 switch 的字符串是否和對應(yīng)的 case 的字符串相等。
如果相等則分別根據(jù)第幾個(gè)條件得到條件的索引,然后每個(gè)索引對應(yīng)下一個(gè)指定的代碼行數(shù)。
繼續(xù)從偏移量75行往下看:
- 76: tableswitch { // 0 to 2
- 0: 104
- 1: 115
- 2: 126
- default: 137
- }
default 語句對應(yīng) 137 行,打印 “default” 字符串,然后執(zhí)行 145 行 return 命令返回。
通過 tableswitch 判斷執(zhí)行哪一行打印語句。
總結(jié)就是整個(gè)流程是先計(jì)算字符串參數(shù)的哈希值,判斷哈希值的范圍,然后哈希值相等再判斷對象是否相等,然后執(zhí)行對應(yīng)的代碼塊。
這種先判斷 hash 值是否相等(有可能是同一個(gè)對象/兩個(gè)對象有可能相等),再通過 equals 比較 對象是否相等 的做法,在 Java 的很多 JDK 源碼中和其他框架中也非常常見的。
分析空指針問題
反匯編前言中的代碼:
- public class Test {
- public static void main(String[] args) {
- String param = null;
- switch (param) {
- case "null":
- System.out.println("匹配null字符串");
- break;
- default:
- System.out.println("進(jìn)入default");
- }
- }
- }
- public class com.zhou.Test {
- public com.zhou.Test();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- public static void main(java.lang.String[]);
- Code:
- 0: aconst_null
- 1: astore_1
- 2: aload_1
- 3: astore_2
- 4: iconst_m1
- 5: istore_3
- 6: aload_2
- 7: invokevirtual #2 // Method java/lang/String.hashCode:()I
- 10: lookupswitch { // 1
- 3392903: 28
- default: 39
- }
- 28: aload_2
- 29: ldc #3 // String null
- 31: invokevirtual #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
- 34: ifeq 39
- 37: iconst_0
- 38: istore_3
- 39: iload_3
- 40: lookupswitch { // 1
- 0: 60
- default: 71
- }
- 60: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
- 63: ldc #6 // String 匹配null字符串
- 65: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 68: goto 79
- 71: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
- 74: ldc #8 // String 進(jìn)入default
- 76: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 79: return
- }
可以猜測3392903 應(yīng)該是 "null" 字符串的哈希值。
- 10: lookupswitch { // 1
- 3392903: 28
- default: 39
- }
我們可以打印其哈希值去印證:System.out.println(("null").hashCode());
總結(jié)整體流程:
- String param = null;
- int hashCode = param.hashCode();
- if(hashCode == ("null").hashCode() && param.equals("null")){
- System.out.println("null");
- }else{
- System.out.println("default");
- }
因此空指針的原因就一目了然了:調(diào)用了 null 對象的實(shí)例方法。