Switch竟然會報空指針異常,學到了!
本文轉載自微信公眾號「月伴飛魚」,作者日常加油站 。轉載本文請聯系月伴飛魚公眾號。
前言
前幾天重新看 《阿里巴巴Java開發手冊》有一條這樣的規約:
出于好奇,打算研究一下!,強迫癥,沒辦法!
我們先用一個案例測試一下:
- 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("進入default");
- }
- }
- }
顯而易見,如果switch傳入空值,會拋空指針!
看到這,我們先可以思考下面幾個問題:
- switch 除了 String 還支持哪種類型?
- 為什么《阿里巴巴Java開發手冊》規定String類型參數要先進行 null 判斷?
- 為什么可能會拋出空指針異常?
下面開始對上面的問題進行分析
問題分析
首先參考官方文檔對swtich 語句相關描述。
翻譯如下:
switch 的表達式必須是 char, byte, short, int, Character, Byte, Short, Integer, String, 或者 enum 類型,否則會發生編譯錯誤
同時switch 語句必須滿足以下條件,否則會出現編譯錯誤:
- 與 switch 語句關聯的每個 case 都必須和 switch 的表達式的類型一致;
- 如果 switch 表達式是枚舉類型,case 常量也必須是枚舉類型;
- 不允許同一個 switch 的兩個 case 常量的值相同;
- 和 switch 語句關聯的常量不能為 null ;
- 一個 switch 語句最多有一個 default 標簽。
翻譯如下:
switch 語句執行的時候,首先將執行 switch 的表達式。如果表達式為 null, 則會拋出 NullPointerException,整個 switch 語句的執行將被中斷。
另外從《Java虛擬機規范》這本書,我們可以學習到:
總結一下就是:
1.編譯器使用 tableswitch 和 lookupswitch 指令生成 switch 語句的編譯代碼。
2.Java 虛擬機的 tableswitch 和 lookupswitch 指令只能支持 int 類型的條件值。如果 swich 中使用其他類型的值,那么就必須轉化為 int 類型。
所以可以了解到空指針出現的根源在于:虛擬機為了實現 switch 的語法,將參數表達式轉換成 int。而這里的參數為 null, 從而造成了空指針異常。
下面對官方文檔的內容采用反匯編方式進一步分析下
不熟悉字節碼的,推薦看看美團的這篇文章:https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
下面開始硬貨!
反匯編看看
一個例子:
- 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
- }
先介紹一下下面會用到的字節碼指令
- invokevirtual:調用實例方法
- 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中裝載引用類型值
我們繼續看匯編代碼:
先看偏移為 8 的指令,調用了參數的 hashCode() 函數來獲取字符串 "月伴飛魚" 的哈希值。
- 8: invokevirtual #3 // Method java/lang/String.hashCode:()I
接下來我們看偏移為 11 的指令處:
tableswitch 是跳轉引用列表, 如果值小于其中的最小值-768121881 或者大于其中的最大值-768121879,跳轉到 default 語句。
- 11: tableswitch { // -768121881 to -768121879
- -768121881: 36
- -768121880: 50
- -768121879: 64
- default: 75
- }
其中 -768121881 為鍵,36 為對應的目標語句偏移量。
hashCode 和 tableswitch 的鍵相等,則跳轉到對應的目標偏移量,"月伴飛魚"的哈希值806505866不在最小值-768121881和最大值-768121879之間,因此跳轉到 default 對應的語句行(即偏移量為 75 的指令處執行)。
月伴飛魚的hash值計算:("月伴飛魚").hashCode();
從 36 到 75 行,根據哈希值相等跳轉到判斷是否相等的指令。
然后調用java.lang.String#equals判斷 switch 的字符串是否和對應的 case 的字符串相等。
如果相等則分別根據第幾個條件得到條件的索引,然后每個索引對應下一個指定的代碼行數。
繼續從偏移量75行往下看:
- 76: tableswitch { // 0 to 2
- 0: 104
- 1: 115
- 2: 126
- default: 137
- }
default 語句對應 137 行,打印 “default” 字符串,然后執行 145 行 return 命令返回。
通過 tableswitch 判斷執行哪一行打印語句。
總結就是整個流程是先計算字符串參數的哈希值,判斷哈希值的范圍,然后哈希值相等再判斷對象是否相等,然后執行對應的代碼塊。
這種先判斷 hash 值是否相等(有可能是同一個對象/兩個對象有可能相等),再通過 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("進入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 進入default
- 76: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 79: return
- }
可以猜測3392903 應該是 "null" 字符串的哈希值。
- 10: lookupswitch { // 1
- 3392903: 28
- default: 39
- }
我們可以打印其哈希值去印證:System.out.println(("null").hashCode());
總結整體流程:
- String param = null;
- int hashCode = param.hashCode();
- if(hashCode == ("null").hashCode() && param.equals("null")){
- System.out.println("null");
- }else{
- System.out.println("default");
- }
因此空指針的原因就一目了然了:調用了 null 對象的實例方法。