07.空指針引起的血案

1. 前言

《手冊》的第 7 頁和 25 頁有兩段關於空指針的描述:html

【強制】Object 的 equals 方法容易拋空指針異常,應使用常量或肯定有值的對象來調用 equals。java

【推薦】防止 NPE,是程序員的基本修養,注意 NPE 產生的場景:git

  1. 返回類型爲基本數據類型,return 包裝數據類型的對象時,自動拆箱有可能產生 NPE。

反例:public int f () { return Integer 對象}, 若是爲 null,自動解箱拋 NPE。程序員

  1. 數據庫的查詢結果可能爲 null。
  2. 集合裏的元素即便 isNotEmpty,取出的數據元素也可能爲 null。
  3. 遠程調用返回對象時,一概要求進行空指針判斷,防止 NPE。
  4. 對於 Session 中獲取的數據,建議進行 NPE 檢查,避免空指針。
  5. 級聯調用 obj.getA ().getB ().getC (); 一連串調用,易產生 NPE。

《手冊》對空指針常見的緣由和基本的避免空指針異常的方式給了介紹,很是有參考價值。github

那麼咱們思考如下幾個問題:spring

  • 如何學習NullPointerException(簡稱爲 NPE)?
  • 哪些用法可能造 NPE 相關的 BUG?
  • 在業務開發中做爲接口提供者和使用者如何更有效地避免空指針呢?

2. 瞭解空指針

2.1 源碼註釋

前面介紹過源碼是學習的一個重要途徑,咱們一塊兒看看NullPointerException的源碼:數據庫

/**
 * Thrown when an application attempts to use {@code null} in a
 * case where an object is required. These include:
 * <ul>
 * <li>Calling the instance method of a {@code null} object.
 * <li>Accessing or modifying the field of a {@code null} object.
 * <li>Taking the length of {@code null} as if it were an array.
 * <li>Accessing or modifying the slots of {@code null} as if it
 *     were an array.
 * <li>Throwing {@code null} as if it were a {@code Throwable}
 *     value.
 * </ul>
 * <p>
 * Applications should throw instances of this class to indicate
 * other illegal uses of the {@code null} object.
 *
 * {@code NullPointerException} objects may be constructed by the
 * virtual machine as if {@linkplain Throwable#Throwable(String,
 * Throwable, boolean, boolean) suppression were disabled and/or the
 * stack trace was not writable}.
 *
 * @author  unascribed
 * @since   JDK1.0
 */
public
class NullPointerException extends RuntimeException {
    private static final long serialVersionUID = 5162710183389028792L;

    /**
     * Constructs a {@code NullPointerException} with no detail message.
     */
    public NullPointerException() {
        super();
    }

    /**
     * Constructs a {@code NullPointerException} with the specified
     * detail message.
     *
     * @param   s   the detail message.
     */
    public NullPointerException(String s) {
        super(s);
    }
}

源碼註釋給出了很是詳盡地解釋:apache

空指針發生的緣由是應用須要一個對象時卻傳入了null,包含如下幾種狀況:設計模式

  1. 調用 null 對象的實例方法。
  2. 訪問或者修改 null 對象的屬性。
  3. 獲取值爲 null 的數組的長度。
  4. 訪問或者修改值爲 null 的二維數組的列時。
  5. 把 null 當作 Throwable 對象拋出時。

實際編寫代碼時,產生空指針的緣由都是這些狀況或者這些狀況的變種。數組

《手冊》中的另一處描述

「集合裏的元素即便 isNotEmpty,取出的數據元素也可能爲 null。」

和第 4 條很是相似。

如《手冊》中的:

「級聯調用 obj.getA ().getB ().getC (); 一連串調用,易產生 NPE。」

和第 1 條很相似,由於每一層均可能獲得null

當遇到《手冊》中和源碼註釋中所描述的這些場景時,要注意預防空指針。

另外經過讀源碼註釋咱們還獲得了 「意外發現」,JVM 也可能會經過Throwable#Throwable(String, Throwable, boolean, boolean)構造函數來構造NullPointerException對象。

2.2 繼承體系

經過源碼能夠看到 NPE 繼承自RuntimeException咱們能夠經過 IDEA 的 「Java Class Diagram」 來查看類的繼承體系。

image.png
能夠清晰地看到 NPE 繼承自RuntimeException,另外咱們選取NoSuchFieldExceptionNoSuchFieldErrorNoClassDefFoundError,能夠看到Throwable的子類型包括ErrorException, 其中 NPE 又是Exception的子類。

那麼爲何ExceptionError有什麼區別?Excption又分爲哪些類型呢?

咱們能夠分別去java.lang.Exceptionjava.lang.Error的源碼註釋中尋找答案。

經過Exception的源碼註釋咱們瞭解到,Exception分爲兩類一種是非受檢異常(uncheked exceptions)即java.lang.RuntimeException以及其子類;而受檢異常(checked exceptions)的拋出須要再普通函數或構造方法上經過throws聲明。

經過java.lang.Error的源碼註釋咱們瞭解到,Error表明嚴重的問題,不該該被程序try-catch。編譯時異常檢測時,Error也被視爲不可檢異常(uncheked exceptions)。

你們能夠在 IDEA 中分別查看ExceptionError的子類,瞭解本身開發中常遇到的異常都屬於哪一個分類。

咱們還能夠經過《JLS》第 11 章Exceptions對異常進行學習。

其中在異常的類型這裏,講到:

不可檢異常( unchecked exception)包括運行時異常和 error 類。

可檢異常(checked exception)不屬於不可檢異常的全部異常都是可檢異常。除 RuntimeException 和其子類,以及 Error 類以及其子類外的其餘 Throwable 的子類。

image.png
還有更多關於異常的詳細描述,,包括異常的緣由、異步異常、異常的編譯時檢查等,你們能夠本身進一步學習。

3. 空指針引起的血案

3.1 最多見的錯誤姿式

@Test
    public void test() {
        Assertions.assertThrows(NullPointerException.class, () -> {
            List<UserDTO> users = new ArrayList<>();
            users.add(new UserDTO(1L, 3));
            users.add(new UserDTO(2L, null));
            users.add(new UserDTO(3L, 3));
            send(users);
        });

    }

    // 第 1 處
    private void send(List<UserDTO> users) {
        for (UserDTO userDto : users) {
            doSend(userDto);
        }
    }

    private static final Integer SOME_TYPE = 2;

    private void doSend(UserDTO userDTO) {
        String target = "default";
        // 第 2 處
        if (!userDTO.getType().equals(SOME_TYPE)) {
            target = getTarget(userDTO.getType());
        }
        System.out.println(String.format("userNo:%s, 發送到%s成功", userDTO, target));
    }

    private String getTarget(Integer type) {
        return type + "號基地";
    }

在第 1 處,若是集合爲null則會拋空指針;

在第 2 處,若是type屬性爲null則會拋空指針異常,致使後續都發送失敗。

你們看這個例子以爲很簡單,看到輸入的參數有null本能地就會考慮空指針問題,可是本身寫代碼時你並不知道上游是否會有null

3. 2 無結果仍返回對象

實際開發中有些同窗會有一些很是 「個性」 的寫法。

爲了不空指針或避免檢查到 null 參數拋異常,直接返回一個空參構造函數建立的對象。

相似下面的作法:

/**
 * 根據訂單編號查詢訂單
 *
 * @param orderNo 訂單編號
 * @return 訂單
 */
public Order getByOrderNo(String orderNo) {

    if (StringUtils.isEmpty(orderNo)) {
        return new Order();
    }
    // 查詢order
    return doGetByOrderNo(orderNo);
}

因爲常見的單個數據的查詢接口,參數檢查不符時會拋異常或者返回null。 極少有上述的寫法,所以調用方的慣例是判斷結果不爲null就使用其中的屬性。

這個哥們這麼寫以後,上層判斷返回值不爲null, 上層就放心大膽得調用實例函數,致使線上報空指針,就形成了線上 BUG。

3.3 新增 @NonNull 屬性反序列化的 BUG

假若有一個訂單更新的 RPC 接口,該接口有一個OrderUpdateParam參數,以前有兩個屬性一個是id一個是name。在某個需求時,新增了一個 extra 屬性,且該字段必定不能爲null

採用 lombok 的@NonNull註解來避免空指針:

import lombok.Data;
import lombok.NonNull;

import java.io.Serializable;

@Data
public class OrderUpdateParam implements Serializable {
    private static final long serialVersionUID = 3240762365557530541L;

    private Long id;

    private String name;

     // 其它屬性
  
    // 新增的屬性
    @NonNull
    private String extra;
}

上線後致使沒有使用最新 jar 包的服務對該接口的 RPC 調用報錯。

咱們來分析一下緣由,在 IDEA 的 target - classes 目錄下找到 DEMO 編譯後的 class 文件,IDEA 會自動幫咱們反編譯:

public class OrderUpdateParam implements Serializable {
    private static final long serialVersionUID = 3240762365557530541L;
    private Long id;
    private String name;
    @NonNull
    private String extra;

    public OrderUpdateParam(@NonNull final String extra) {
        if (extra == null) {
            throw new NullPointerException("extra is marked non-null but is null");
        } else {
            this.extra = extra;
        }
    }

    @NonNull
    public String getExtra() {
        return this.extra;
    }
    public void setExtra(@NonNull final String extra) {
        if (extra == null) {
            throw new NullPointerException("extra is marked non-null but is null");
        } else {
            this.extra = extra;
        }
    }
  // 其餘代碼

}

咱們還可使用反編譯工具:JD-GUI對編譯後的 class 文件進行反編譯,查看源碼。

因爲調用方調用的是不含extra屬性的 jar 包,而且序列化編號是一致的,反序列化時會拋出 NPE。

Caused by: java.lang.NullPointerException: extra

​        at com.xxx.OrderUpdateParam.<init>(OrderUpdateParam.java:21)

RPC 參數新增 lombok 的@NonNull註解時,要考慮調用方是否及時更新 jar 包,避免出現空指針。

3.4 自動拆箱致使空指針

前面章節講到了對象轉換,若是咱們下面的GoodCreateDTO是咱們本身服務的對象, 而GoodCreateParam是咱們調用服務的參數對象。

@Data
public class GoodCreateDTO {
    private String title;

    private Long price;

    private Long count;
}

@Data
public class GoodCreateParam implements Serializable {

    private static final long serialVersionUID = -560222124628416274L;
    private String title;

    private long price;

    private long count;
}

其中GoodCreateDTOcount屬性在咱們系統中是非必傳參數,本系統可能爲null

若是咱們沒有拉取源碼的習慣,直接經過前面的轉換工具類去轉換。

咱們潛意識會認爲外部接口的對象類型也都是包裝類型,這時候很容易由於轉換出現 NPE 而致使線上 BUG。

public class GoodCreateConverter {

    public static GoodCreateParam convertToParam(GoodCreateDTO goodCreateDTO) {
        if (goodCreateDTO == null) {
            return null;
        }
        GoodCreateParam goodCreateParam = new GoodCreateParam();
        goodCreateParam.setTitle(goodCreateDTO.getTitle());
        goodCreateParam.setPrice(goodCreateDTO.getPrice());
        goodCreateParam.setCount(goodCreateDTO.getCount());
        return goodCreateParam;
    }
}

當轉換器執行到goodCreateParam.setCount(goodCreateDTO.getCount());會自動拆箱會報空指針。

GoodCreateDTOcount屬性爲null時,自動拆箱將報空指針。

再看一個花樣踩坑的例子

咱們做爲使用方調用以下的二方服務接口:

public Boolean someRemoteCall();

而後自覺得對方確定會返回TRUEFALSE,而後直接拿來做爲判斷條件或者轉爲基本類型,若是返回的是null,則會報空指針異常:

if (someRemoteCall()) {
           // 業務代碼
 }

你們看示例的時候可能認爲這種狀況很簡單,本身開發的時候確定會注意,可是每每事實並不是如此。

但願你們能夠掌握常見的可能發生空指針場景,在開發是注意預防。

3.5 分批調用合併結果時空指針

你們再看下面這個經典的例子。

由於某些批量查詢的二方接口在數據較大時容易超時,所以能夠分爲小批次調用。

下面封裝一個將List數據拆分紅每size個一批數據,去調用functionRPC 接口,而後將結果合併。

public static <T, V> List<V> partitionCallList(List<T> dataList, int size, Function<List<T>, List<V>> function) {

        if (CollectionUtils.isEmpty(dataList)) {
            return new ArrayList<>(0);
        }
        Preconditions.checkArgument(size > 0, "size 必須大於0");

        return Lists.partition(dataList, size)
                .stream()
                .map(function)
                .reduce(new ArrayList<>(),
                        (resultList1, resultList2) -> {
                            resultList1.addAll(resultList2);
                            return resultList1;
                        });


    }

看着挺對,沒啥問題,其實則否則。

設想一下,若是某一個批次請求無數據,不是返回空集合而是 null,會怎樣?

很不幸,又一個空指針異常向你飛來 …

此時要根據具體業務場景來判斷如何處理這裏可能產生的空指針異常

若是在某個場景中,返回值爲 null 是必定不容許的行爲,能夠在 function 函數中對結果進行檢查,若是結果爲 null,可拋異常。

若是是容許的,在調用 map 後,能夠過濾 null :

// 省略前面代碼
.map(function)
.filter(Objects::nonNull)
// 省略後續代碼

4. 預防空指針的一些方法

NPE形成的線上 BUG 還有不少種形式,如何預防空指針很重要。

下面將介紹幾種預防 NPE 的一些常見方法:

image.png

4.1 接口提供者角度

4.1.1 返回空集合

若是參數不符合要求直接返回空集合,底層的函數也使用一致的方式:

public List<Order> getByOrderName(String name) {
    if (StringUtils.isNotEmpty(name)) {
        return doGetByOrderName(name);
    }
    return Collections.emptyList();
}

4.1.2 使用 Optional

Optional是 Java 8 引入的特性,返回一個Optional則明確告訴使用者結果可能爲空:

public Optional<Order> getByOrderId(Long orderId) {
    return Optional.ofNullable(doGetByOrderId(orderId));
}

若是你們感興趣能夠進入Optional的源碼,結合前面介紹的codota工具進行深刻學習,也能夠結合《Java 8 實戰》的相關章節進行學習。

4.1.3 使用空對象設計模式

該設計模式爲了解決 NPE 產生緣由的第 1 條 「調用null對象的實例方法」。

在編寫業務代碼時爲了不NPE常常須要先判空再執行實例方法:

public void doSomeOperation(Operation operation) {
    int a = 5;
    int b = 6;
    if (operation != null) {
        operation.execute(a, b);
    }
}

《設計模式之禪》(第二版)554 頁在拓展篇講述了 「空對象模式」。

能夠構造一個NullXXX類拓展自某個接口, 這樣這個接口須要爲null時,直接返回該對象便可:

public class NullOperation implements Operation {

    @Override
    public void execute(int a, int b) {
        // do nothing
    }
}

這樣上面的判空操做就再也不有必要, 由於咱們在須要出現null的地方都統一返回NullOperation,並且對應的對象方法都是有的:

public void doSomeOperation(Operation operation) {
    int a = 5;
    int b = 6;
    operation.execute(a, b);
}

4.2 接口使用者角度

講完了接口的編寫者該怎麼作,咱們講講接口的使用者該如何避免NPE

4.2.1 null 檢查

正如《代碼簡潔之道》第 7.8 節 「別傳 null 值」 中所要表達的意義:

能夠進行參數檢查,對不知足的條件拋出異常。

直接在使用前對不能爲null的和不知足業務要求的條件進行檢查,是一種最簡單最多見的作法。

經過防護性參數檢測,能夠極大下降出錯的機率,提升程序的健壯性:

@Override
    public void updateOrder(OrderUpdateParam orderUpdateParam) {
        checkUpdateParam(orderUpdateParam);
        doUpdate(orderUpdateParam);
    }

    private void checkUpdateParam(OrderUpdateParam orderUpdateParam) {
        if (orderUpdateParam == null) {
            throw new IllegalArgumentException("參數不能爲空");
        }
        Long id = orderUpdateParam.getId();
        String name = orderUpdateParam.getName();
        if (id == null) {
            throw new IllegalArgumentException("id不能爲空");
        }
        if (name == null) {
            throw new IllegalArgumentException("name不能爲空");
        }
    }

JDK 和各類開源框架中能夠找到不少這種模式,java.util.concurrent.ThreadPoolExecutor#execute就是採用這種模式。

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
     // 其餘代碼
     }

以及org.springframework.context.support.AbstractApplicationContext#assertBeanFactoryActive

protected void assertBeanFactoryActive() {
   if (!this.active.get()) {
      if (this.closed.get()) {
         throw new IllegalStateException(getDisplayName() + " has been closed already");
      }
      else {
         throw new IllegalStateException(getDisplayName() + " has not been refreshed yet");
      }
   }
}

4.2.2 使用 Objects

可使用 Java 7 引入的 Objects 類,來簡化判空拋出空指針的代碼。

使用方法以下:

private void checkUpdateParam2(OrderUpdateParam orderUpdateParam) {
    Objects.requireNonNull(orderUpdateParam);
    Objects.requireNonNull(orderUpdateParam.getId());
    Objects.requireNonNull(orderUpdateParam.getName());
}

原理很簡單,咱們看下源碼;

public static <T> T requireNonNull(T obj) {
    if (obj == null)
        throw new NullPointerException();
    return obj;
}

4.2.3 使用 commons 包

咱們可使用 commons-lang3 或者 commons-collections4 等經常使用的工具類輔助咱們判空。

4.2.3.1 使用字符串工具類:org.apache.commons.lang3.StringUtils

public void doSomething(String param) {
    if (StringUtils.isNotEmpty(param)) {
        // 使用param參數
    }
}

4.2.3.2 使用校驗工具類:org.apache.commons.lang3.Validate

public static void doSomething(Object param) {
    Validate.notNull(param,"param must not null");
}
public static void doSomething2(List<String> parms) {
    Validate.notEmpty(parms);
}

該校驗工具類支持多種類型的校驗,支持自定義提示文本等。

前面已經介紹了讀源碼是最好的學習方式之一,這裏咱們看下底層的源碼:

public static <T extends Collection<?>> T notEmpty(final T collection, final String message, final Object... values) {
    if (collection == null) {
        throw new NullPointerException(String.format(message, values));
    }
    if (collection.isEmpty()) {
        throw new IllegalArgumentException(String.format(message, values));
    }
    return collection;
}

該若是集合對象爲 null 則會拋空NullPointerException若是集合爲空則拋出IllegalArgumentException

經過源碼咱們還能夠了解到更多的校驗函數。

4.2.4 使用集合工具類:org.apache.commons.collections4.CollectionUtils

public void doSomething(List<String> params) {
    if (CollectionUtils.isNotEmpty(params)) {
        // 使用params
    }
}

4.2.5 使用 guava 包

可使用 guava 包的com.google.common.base.Preconditions前置條件檢測類。

一樣看源碼,源碼給出了一個範例。原始代碼以下:

public static double sqrt(double value) {
    if (value < 0) {
        throw new IllegalArgumentException("input is negative: " + value);
    }
    // calculate square root
}

使用Preconditions後,代碼能夠簡化爲:

public static double sqrt(double value) {
   checkArgument(value >= 0, "input is negative: %s", value);
   // calculate square root
 }

Spring 源碼裏不少地方能夠找到相似的用法,下面是其中一個例子:

org.springframework.context.annotation.AnnotationConfigApplicationContext#register

public void register(Class<?>... annotatedClasses) {
    Assert.notEmpty(annotatedClasses, "At least one annotated class must be specified");
    this.reader.register(annotatedClasses);
}

org.springframework.util.Assert#notEmpty(java.lang.Object[], java.lang.String)

public static void notEmpty(Object[] array, String message) {
    if (ObjectUtils.isEmpty(array)) {
        throw new IllegalArgumentException(message);
    }
}

雖然使用的具體工具類不同,核心的思想都是一致的。

4.2.6 自動化 API

4.2.6.1 使用 lombok 的@Nonnull註解

public void doSomething5(@NonNull String param) {
      // 使用param
      proccess(param);
 }

查看編譯後的代碼:

public void doSomething5(@NonNull String param) {
      if (param == null) {
          throw new NullPointerException("param is marked non-null but is null");
      } else {
          this.proccess(param);
      }
  }

4.2.6.2 使用 IntelliJ IDEA 提供的 @NotNull 和 @Nullable 註解

maven 依賴以下:

<!-- https://mvnrepository.com/artifact/org.jetbrains/annotations -->
<dependency>
    <groupId>org.jetbrains</groupId>
    <artifactId>annotations</artifactId>
    <version>17.0.0</version>
</dependency>

@NotNull 在參數上的用法和上面的例子很是類似。

public static void doSomething(@NotNull String param) {
    // 使用param
    proccess(param);
}

咱們能夠去該註解的源碼org.jetbrains.annotations.NotNull#exception裏查看更多細節,你們也可使用 IDEA 插件或者前面介紹的 JD-GUI 來查看編譯後的 class 文件,去了解 @NotNull 註解的做用。

5. 總結

本節主要講述空指針的含義,空指針常見的中槍姿式,以及如何避免空指針異常。下一節將爲你揭祕 當 switch 遇到空指針,又會發生什麼奇妙的事情。

參考資料


  1. 阿里巴巴與 Java 社區開發者.《 Java 開發手冊 1.5.0:華山版》.2019:7,25↩︎
  2. James Gosling, Bill Joy, Guy Steele, Gilad Bracha, Alex Buckley.《Java Language Specification: Java SE 8 Edition》. 2015↩︎
相關文章
相關標籤/搜索