大家好,我是木宛哥,今天和大家分享下——程式碼 CR 時針對惱人的空指標異常(NullPointerException
)如何做到體系化去防控;
什麼是空指標異常
從記憶體角度看,物件的例項化需要在堆記憶體中分配空間。如果一個物件沒有被建立,那也就沒有分配記憶體,當應用程式訪問空物件時,實際上是訪問一個“無效”的記憶體區域,從而導致系統丟擲異常。
我們在 Java 程式設計時,空指標異常是一個常見的執行時錯誤,嚴重甚至會導致程序退出。所以這也是為什麼我們要在 CR 時如此重視它的原因。
CR 我們要做什麼
木宛哥認為 CR 應該重點關注三點:
- 業務邏輯正確性
- 程式碼的可讀性和可維護性
- 程式碼的健壯性和穩定性
OK,再回過頭來看,針對空指標異常,在 CR 時,更多要從程式碼的健壯性和穩定性切入,可分為:
- 防禦性去杜絕空指標異常的出現(大多是訪問了不存在的物件)
- 對三方框架或者 JDK 認知不完善導致的潛在空指標異常發生(更多靠評審參與者經驗分享)
防禦性程式設計
防禦性程式設計是非常有必要的,一方面可以提高系統穩定性和健壯性,另一方面可以形成比較好的程式碼規範;同時也是非常重要的思想,每個人都會如此去實踐,在 CR 時針對空指標異常的防禦是 common sense
;例如:
1.防禦使用了未初始化的物件:
MyObject obj = null;
if (obj!= null){
obj.someMethod();
}
2.防禦使用了物件沒有初始化的欄位;
class MyClass {
String name;
}
MyClass obj = new MyClass();
if (StringUtils.isNotBlank(obj.getName())){
// do something
}
3.防禦當呼叫方法返回 null 後,試圖對返回的物件呼叫其方法或屬性:
MyObject obj = getMyObject(); // 假設返回 null
if (obj != null) {
obj.someMethod();
}
4.防禦訪問了集合中不存在的元素:
- Map.get() 方法返回 null
- Queue 的方法如 poll() 或 peek() 返回 null
Map<String ,String> dummyMap = new HashMap<>();
String value = dummyMap.get("key");
if (org.apache.commons.lang3.StringUtils.isNotBlank(value)){
// do something
}
三方框架或 JDK 使用不當引發空指標異常提前排雷
這一類更多是三方框架或 JDK 的內部機制不清楚導致的踩坑,只有踩了這種類,
才會恍然大悟:“哦,原來這樣啊,下回得注意了”;
所以針對這類問題,更多需要評審參與人的經驗去發現,需要團隊去共創,共建知識體系,例如:在團隊空間維護“ TOP 100 踩坑記”等等;
在上篇文章《為什麼建議使用列舉來替換布林值》中,木宛哥提到過 Boolean
為 null
時產生的第三種結果,易造成 if
條件判斷拆箱引發空指標問題,今天再繼續分享其他:
1.三目運算子拆箱空指標問題
int var1 = 20;
Integer var2 = null;
boolean condition = true;
// 三目運算子拆箱問題,發生 NullPointerException
System.out.println(condition ? var2 : var1);
這裡:condition
為 true
,所以三目運算子選擇了 var2
(即 null
)。即: var2
(Integer
型別)賦值給 num
(Integer
型別)。理論上在這裡應該是 num
被賦值為 null
。
但在 Java 中,三目運算子的返回型別需要透過型別來推導:
- 如果
var1
是int
型別,而var2
是Integer
型別,三目運算子會將它們的型別推導合併,令返回值為int
型別。 - 這意味著,如果
condition
為true
,則會嘗試將var2
(null
)拆箱成int
。由於null
不能拆箱成int
,因此會丟擲NullPointerException
這類典型的問題更多需要在 CR 時提前暴露出來,保證一致的引數型別來避免拆箱;
2.日誌列印使用 fastjson 序列化時造成的空指標問題
大部分程式設計師程式設計開發習慣,喜歡列印引數到日誌裡,但有時候一個不起眼的 log.info
列印日誌有可能導致介面異常;
如下列印日誌,結果 fastjson
序列化異常,發生 NullPointerException
@Test
public void testJSONString() {
Employee employee = new Employee("jack", 100);
//fastjson 序列化異常,發生 NullPointerException
LoggerUtil.info(logger,"{}",JSON.toJSONString(employee));
}
static class Employee {
private EmployeeId employeeId;
private String name;
private Integer salary;
public Employee(String name, Integer salary) {
this.name = name;
this.salary = salary;
}
public String getName() {
return name;
}
public Integer getSalary() {
return salary;
}
public String getEmployeeId() {
return this.employeeId.getId();
}
}
static class EmployeeId {
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
原因在於 fastjson
使用 JSON.toJSONString(employee)
序列化成 JSON 時,底層實際透過解析 get
開頭方法來識別屬性,即:呼叫 get
方法獲取屬性的 value
值;上述程式碼:employeeId
為 null
,但序列化時執行了 getEmployeeId
引發的空指標異常;
所以:特別是大家在實踐 DDD 的時候,因為領域模型往往是充血模型,不僅有資料還包含了行為,對於行為可能習慣有 get
開頭命名,要特別重視在列印領域模型時序列化問題;
3.對 Stream 流操作認知不完善導致的空指標異常
如果 Stream 流中存在空值,需要非常小心。
例如,如果第一個元素恰好為 null
,findFirst()
將丟擲 NullPointerException
。這是因為 findFirst()
返回一個 Optional
,而 Optional
不能包含空值。
Arrays.asList(null, 1, 2).stream().findFirst();//發生 NullPointerException
max()
、min()
和 reduce()
,也表現出類似的行為。如果 null
是最終結果,則會丟擲異常。
List<Integer> list = Arrays.asList(null, 1, 2);
var comparator = Comparator.<Integer>nullsLast(Comparator.naturalOrder());
System.out.println(list.stream().max(comparator));//發生 NullPointerException
再例如:我們在使用 Stream
流式程式設計時,如果流包含 null
,可以轉換為 toList()
或 toSet()
;
然而,toMap()
要注意, 不允許空值(允許空Key):
Employee employee1 = new Employee("Jack", 10000);
Employee employee2 = new Employee(null, 10000);
//toMap的Value不能為空,此處異常
Map<Integer, String> salaryMap = Arrays.asList(employee1, employee2)
.stream()
.collect(Collectors.toMap(Employee::getSalary, Employee::getName));
以及:groupingBy()
不允許空 Key:
Employee employee1 = new Employee("Jack", 10000);
Employee employee2 = new Employee(null, 10000);
//groupingBy的Key不能為空,此處拋異常
Map<String, List<Employee>> result = Stream.of(employee1, employee2)
.collect(Collectors.groupingBy(Employee::getName));
可見在流中使用了空物件存在許多陷阱;所以,在 CR 時,要重點關注 Stream 流的資料來源,避免在流中存在 null
,不確定的話建議用 filter(Objects::nonNull)
將它們過濾掉。
再談空指標防控手段
上一章更多還是從防禦空指標去解問題,但能保證每個人都是認知一樣嗎,同時在 CR 時也會有漏網之魚;下面程式碼我想每個人都會這樣去避免空指標,但難免在某個加班到凌晨的日子,腦袋一抽筋寫反了:(
if("DEFAULT".equals(var)){
//do something
}
所以,在這一章,木宛哥從資料來源切入,回答:“能否資料天生就是存在非空的、方法天生就是不會返回 null”?
從程式角度來看,是合理的;許多變數永遠不包含 null
,許多方法也永遠不返回 null
。我們可以分別稱它們為“非空變數”和“非空方法”(NonNull
);
其他變數和方法在某些情況下可能會包含或返回 null
,它們稱為“可空”(Nullable
);
基於這個理論,在解空指標問題時,提供了另一種方式解法:
- 資料來自三方系統,控制權不在我們,故:不可信任,需要做好防禦程式設計;
- 資料來自自身,控制權在我們,控制資料建立即非空,故:可信任;如下:
儘可能遮蔽 null 值
對輸入值進行校驗——在公共方法和建構函式中。需在每個 set
欄位的入口處新增 Objects.requireNonNull()
呼叫。requireNonNull()
方法會在其引數為 null
時丟擲 NullPointerException
。
public class Employee {
public Employee(String name, Integer salary) {
this.name = Objects.requireNonNull(name);
this.salary = Objects.requireNonNull(salary);
}
}
這樣做有助於在入口處遮蔽 null
值的寫入
如果你的方法接受集合作為輸入,也可以在方法入口遍歷該集合以確保它不包含 null
值:
public void check(Collection<String> data) {
data.forEach(Objects::requireNonNull);
}
注:此處需要視具體集合的大小以及評估效能損耗;
同樣的型別場景,不詳細舉例了:
- 當你的型別是集合時,返回一個空容器,而不是返回
null
,可以避免消費方出現空指標異常; - 使用列舉常量來替換
Boolean
來避免拆箱引入的空指標異常 - 非法資料狀態,直接短路丟擲異常而不是返回
null
;
善用靜態分析工具來輔助
介紹兩個重要的註解:@Nullable
和@NotNull
註解:
@Nullable
註解意味著預期被註釋的變數可能包含null
,或者被註釋的方法可能返回null
;@NotNull
註釋意味著預期的值絕不是null
。併為靜態分析提供了提示
這類註解,可以在靜態分析工具實時分析潛在的異常;
interface Processor {
@NotNull
String getNotNullValue();
@Nullable
String getNullable();
public void process() {
//此處警告:條件永遠為假,不用多次一舉
if (getNotNullValue() == null) {
//do something
}
//此處警告:trim() 呼叫可能導致 NullPointerException
System.out.println(getNullable().trim());
}
}
再談使用 Optional 替代 null 的一些注意事項
為了避免使用 null
,一些開發者傾向於使用 Optional
型別。可以將 Optional
想象成一個盒子,它要麼是空的,要麼包含一個非 null
的值:
獲取Optional物件有三種標準方式:
Optional.empty()
—— 獲取一個空的Optional
Optional.of(value)
—— 獲取一個非空的Optional
,如果值為null
則丟擲NullPointerException
Optional.ofNullable(value)
—— 如果值為null
則獲取一個空的Optional
,否則獲取一個包含值的非空Optional
使用 Optional
來預防空指標,大問題沒有,但有幾個細節需要注意:
1.勿濫用 ofNullable
一些開發者喜歡在所有地方使用 ofNullable()
,因為它被認為是更安全的,它從不丟擲異常。但不能濫用,如果你已經知道你的值永遠不會為null
,最好使用 Optional.of()
。在這種情況下,如果你看到一個異常,你會立即知道錯了並且修復;
2.Optional 造成的程式碼可讀性降低
如下程式碼獲取員工地址,雖然簡潔,但可讀性很差,對於巢狀特別深的情況下,我還是不建議使用 Opinional
,畢竟程式碼除了給自己看還得讓別人也一眼明白意圖
String employeeAddress = Optional.ofNullable(employee)
.map(Employee::getAddress)
.map(Address::getStreet)
.map(Street::getNo)
.map(No::getNumber).orElseThrow(() -> new IllegalArgumentException("非法引數"));
事後的異常監控
事前禁止寫入 null
,事中防禦性程式設計空指標異常,但真的高枕無憂了嗎?
未必,事後所以建立一套好的異常告警機制是非常重要的;
我建議針對關鍵字:NullPointerException
做單獨的日誌採集,同時配上相應的告警級別:理論上出現 1 次空指標異常就應該介入定位;
當然,特別是在釋出週期內,如果 N
分鐘內出現超過 M
次空指標異常那就肯定要快速定位和回滾了;
寫在最後
歡迎關注我的公眾號:程式設計啟示錄,第一時間獲取最新訊息;
微信 | 公眾號 |
---|---|