Java匹配等於問題點

     你可能會說,判等不就是一行代碼的事情嗎,有什麼好說的。但,這一行代碼如果處理不當,不僅會出現 Bug,還可能會引起內存泄露等問題。涉及判等的 Bug,即使是使用 == 這種錯誤的判等方式,也不是所有時候都會出問題。所以類似的判等問題不太容易發現,可能會被隱藏很久。

       今天,我就 equals、compareTo 和 Java 的數值緩存、字符串駐留等問題展開討論,希望你可以理解其原理,徹底消除業務代碼中的相關 Bug

注意 equals 和 == 的區別

      在業務代碼中,我們通常使用 equals 或 == 進行判等操作。equals 是方法而 == 是操作符,它們的使用是有區別的:

       1, 對基本類型,比如 int、long,進行判等,只能使用 ==,比較的是直接值。因爲基本類型的值就是其數值。

       2,對引用類型,比如 Integer、Long 和 String,進行判等,需要使用 equals 進行內容判等。因爲引用類型的直接值是指針,使用 == 的話,比較的是指針,也就是兩個對象在內存中的地址,即比較它們是不是同一個對象,而不是比較對象的內容。

         這就引出了我們必須必須要知道的第一個結論:比較值的內容,除了基本類型只能使用 == 外,其他類型都需要使用 equals。

        在開篇我提到了,即使使用 == 對 Integer 或 String 進行判等,有些時候也能得到正確結果。這又是爲什麼呢?

         我們用下面的測試用例深入研究下:

          1,使用 == 對兩個值爲 127 的直接賦值的 Integer 對象判等;

          2,使用 == 對兩個值爲 128 的直接賦值的 Integer 對象判等;

          3,使用 == 對一個值爲 127 的直接賦值的 Integer 和另一個通過 new Integer 聲明的值爲 127 的對象判等;

          4,使用 == 對兩個通過 new Integer 聲明的值爲 127 的對象判等;

          5,使用 == 對一個值爲 128 的直接賦值的 Integer 對象和另一個值爲 128 的 int 基本類型判等。

​
Integer a = 127; //Integer.valueOf(127)
Integer b = 127; //Integer.valueOf(127)
log.info("\nInteger a = 127;\n" +
        "Integer b = 127;\n" +
        "a == b ? {}",a == b);    // true

Integer c = 128; //Integer.valueOf(128)
Integer d = 128; //Integer.valueOf(128)
log.info("\nInteger c = 128;\n" +
        "Integer d = 128;\n" +
        "c == d ? {}", c == d);   //false

Integer e = 127; //Integer.valueOf(127)
Integer f = new Integer(127); //new instance
log.info("\nInteger e = 127;\n" +
        "Integer f = new Integer(127);\n" +
        "e == f ? {}", e == f);   //false

Integer g = new Integer(127); //new instance
Integer h = new Integer(127); //new instance
log.info("\nInteger g = new Integer(127);\n" +
        "Integer h = new Integer(127);\n" +
        "g == h ? {}", g == h);  //false

Integer i = 128; //unbox
int j = 128;
log.info("\nInteger i = 128;\n" +
        "int j = 128;\n" +
        "i == j ? {}", i == j); //true

​

       通過運行結果可以看到,雖然看起來永遠是在對 127 和 127、128 和 128 判等,但 == 卻沒有永遠給我們 true 的答覆。原因是什麼呢?

       第一個案例中,編譯器會把 Integer a = 127 轉換爲 Integer.valueOf(127)。查看源碼可以發現,這個轉換在內部其實做了緩存,使得兩個 Integer 指向同一個對象,所以 == 返回 true。


public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

        第二個案例中,之所以同樣的代碼 128 就返回 false 的原因是,默認情況下會緩存[-128, 127]的數值,而 128 處於這個區間之外。設置 JVM 參數加上 -XX:AutoBoxCacheMax=1000 再試試,是不是就返回 true 了呢?


private static class IntegerCache {
    static final int low = -128;
    static final int high;


    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;


        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);


        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }
}

         第三和第四個案例中,New 出來的 Integer 始終是不走緩存的新對象。比較兩個新對象,或者比較一個新對象和一個來自緩存的對象,結果肯定不是相同的對象,因此返回 false。

         第五個案例中,我們把裝箱的 Integer 和基本類型 int 比較,前者會先拆箱再比較,比較的肯定是數值而不是引用,因此返回 true。

        看到這裏,對於 Integer 什麼時候是相同對象什麼時候是不同對象,就很清楚了吧。但知道這些其實意義不大,因爲在大多數時候,我們並不關心 Integer 對象是否是同一個,只需要記得比較 Integer 的值請使用 equals,而不是 ==(對於基本類型 int 的比較當然只能使用 ==)。

         其實,我們應該都知道這個原則,只是有的時候特別容易忽略。以我之前遇到過的一個生產事故爲例,有這麼一個枚舉定義了訂單狀態和對於狀態的描述:


enum StatusEnum {
    CREATED(1000, "已創建"),
    PAID(1001, "已支付"),
    DELIVERED(1002, "已送到"),
    FINISHED(1003, "已完成");

    private final Integer status; //注意這裏的Integer
    private final String desc;

    StatusEnum(Integer status, String desc) {
        this.status = status;
        this.desc = desc;
    }
}

在業務代碼中,開發同學使用了 == 對枚舉和入參 OrderQuery 中的 status 屬性進行判等:


@Data
public class OrderQuery {
    private Integer status;
    private String name;
}

@PostMapping("enumcompare")
public void enumcompare(@RequestBody OrderQuery orderQuery){
    StatusEnum statusEnum = StatusEnum.DELIVERED;
    log.info("orderQuery:{} statusEnum:{} result:{}", orderQuery, statusEnum, statusEnum.status == orderQuery.getStatus());
}

       因爲枚舉和入參 OrderQuery 中的 status 都是包裝類型,所以通過 == 判等肯定是有問題的。只是這個問題比較隱晦,究其原因在於:

        1, 只看枚舉的定義 CREATED(1000, “已創建”),容易讓人誤解 status 值是基本類型;

        2, 因爲有 Integer 緩存機制的存在,所以使用 == 判等並不是所有情況下都有問題。在這次事故中,訂單狀態的值從 100 開始增長,程序一開始不出問題,直到訂單狀態超過 127 後纔出現 Bug。

        在瞭解清楚爲什麼 Integer 使用 == 判等有時候也有效的原因之後,我們再來看看爲什麼 String 也有這個問題。我們使用幾個用例來測試下:

         對兩個直接聲明的值都爲 1 的 String 使用 == 判等;

         對兩個 new 出來的值都爲 2 的 String 使用 == 判等;

        對兩個 new 出來的值都爲 3 的 String 先進行 intern 操作,再使用 == 判等;

         對兩個 new 出來的值都爲 4 的 String 通過 equals 判等。


String a = "1";
String b = "1";
log.info("\nString a = \"1\";\n" +
        "String b = \"1\";\n" +
        "a == b ? {}", a == b); //true

String c = new String("2");
String d = new String("2");
log.info("\nString c = new String(\"2\");\n" +
        "String d = new String(\"2\");" +
        "c == d ? {}", c == d); //false

String e = new String("3").intern();
String f = new String("3").intern();
log.info("\nString e = new String(\"3\").intern();\n" +
        "String f = new String(\"3\").intern();\n" +
        "e == f ? {}", e == f); //true

String g = new String("4");
String h = new String("4");
log.info("\nString g = new String(\"4\");\n" +
        "String h = new String(\"4\");\n" +
        "g == h ? {}", g.equals(h)); //true

       在分析這個結果之前,我先和你說說 Java 的字符串常量池機制。首先要明確的是其設計初衷是節省內存。當代碼中出現雙引號形式創建字符串對象時,JVM 會先對這個字符串進行檢查,如果字符串常量池中存在相同內容的字符串對象的引用,則將這個引用返回;否則,創建新的字符串對象,然後將這個引用放入字符串常量池,並返回該引用。這種機制,就是字符串駐留或池化。

        再回到剛纔的例子,再來分析一下運行結果:

        第一個案例返回 true,因爲 Java 的字符串駐留機制,直接使用雙引號聲明出來的兩個 String 對象指向常量池中的相同字符串。

       第二個案例,new 出來的兩個 String 是不同對象,引用當然不同,所以得到 false 的結果。

       第三個案例,使用 String 提供的 intern 方法也會走常量池機制,所以同樣能得到 true。

       第四個案例,通過 equals 對值內容判等,是正確的處理方式,當然會得到 true。

      雖然使用 new 聲明的字符串調用 intern 方法,也可以讓字符串進行駐留,但在業務代碼中濫用 intern,可能會產生性能問題。

       寫代碼測試一下,通過循環把 1 到 1000 萬之間的數字以字符串形式 intern 後,存入一個 List:


List<String> list = new ArrayList<>();

@GetMapping("internperformance")
public int internperformance(@RequestParam(value = "size", defaultValue = "10000000")int size) {
    //-XX:+PrintStringTableStatistics
    //-XX:StringTableSize=10000000
    long begin = System.currentTimeMillis();
    list = IntStream.rangeClosed(1, size)
            .mapToObj(i-> String.valueOf(i).intern())
            .collect(Collectors.toList());
    log.info("size:{} took:{}", size, System.currentTimeMillis() - begin);
    return list.size();
}

      在啓動程序時設置 JVM 參數 -XX:+PrintStringTableStatistic,程序退出時可以打印出字符串常量表的統計信息。調用接口後關閉程序,輸出如下:


[11:01:57.770] [http-nio-45678-exec-2] [INFO ] [.t.c.e.d.IntAndStringEqualController:54  ] - size:10000000 took:44907
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :  10030230 = 240725520 bytes, avg  24.000
Number of literals      :  10030230 = 563005568 bytes, avg  56.131
Total footprint         :           = 804211192 bytes
Average bucket size     :   167.134
Variance of bucket size :    55.808
Std. dev. of bucket size:     7.471
Maximum bucket size     :       198

        可以看到,1000 萬次 intern 操作耗時居然超過了 44 秒。

       其實,原因在於字符串常量池是一個固定容量的 Map。如果容量太小(Number of buckets=60013)、字符串太多(1000 萬個字符串),那麼每一個桶中的字符串數量會非常多,所以搜索起來就很慢。輸出結果中的 Average bucket size=167,代表了 Map 中桶的平均長度是 167。

       解決方式是,設置 JVM 參數 -XX:StringTableSize,指定更多的桶。設置 -XX:StringTableSize=10000000 後,重啓應用:


[11:09:04.475] [http-nio-45678-exec-1] [INFO ] [.t.c.e.d.IntAndStringEqualController:54  ] - size:10000000 took:5557
StringTable statistics:
Number of buckets       :  10000000 =  80000000 bytes, avg   8.000
Number of entries       :  10030156 = 240723744 bytes, avg  24.000
Number of literals      :  10030156 = 562999472 bytes, avg  56.131
Total footprint         :           = 883723216 bytes
Average bucket size     :     1.003
Variance of bucket size :     1.587
Std. dev. of bucket size:     1.260
Maximum bucket size     :        10

       可以看到,1000 萬次調用耗時只有 5.5 秒,Average bucket size 降到了 1,效果明顯。

       好了,是時候給出第二原則了:沒事別輕易用 intern,如果要用一定要注意控制駐留的字符串的數量,並留意常量表的各項指標。

     實現一個 equals 沒有這麼簡單

     如果看過 Object 類源碼,你可能就知道,equals 的實現其實是比較對象引用:


public boolean equals(Object obj) {
    return (this == obj);
}

之所以 Integer 或 String 能通過 equals 實現內容判等,是因爲它們都重寫了這個方法。比如,String 的 equals 的實現:


public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

       對於自定義類型,如果不重寫 equals 的話,默認就是使用 Object 基類的按引用的比較方式。我們寫一個自定義類測試一下。

      假設有這樣一個描述點的類 Point,有 x、y 和描述三個屬性:


class Point {
    private int x;
    private int y;
    private final String desc;

    public Point(int x, int y, String desc) {
        this.x = x;
        this.y = y;
        this.desc = desc;
    }
}

     定義三個點 p1、p2 和 p3,其中 p1 和 p2 的描述屬性不同,p1 和 p3 的三個屬性完全相同,並寫一段代碼測試一下默認行爲:


Point p1 = new Point(1, 2, "a");
Point p2 = new Point(1, 2, "b");
Point p3 = new Point(1, 2, "a");
log.info("p1.equals(p2) ? {}", p1.equals(p2));
log.info("p1.equals(p3) ? {}", p1.equals(p3));

        通過 equals 方法比較 p1 和 p2、p1 和 p3 均得到 false,原因正如剛纔所說,我們並沒有爲 Point 類實現自定義的 equals 方法,Object 超類中的 equals 默認使用 == 判等,比較的是對象的引用。

        我們期望的邏輯是,只要 x 和 y 這 2 個屬性一致就代表是同一個點,所以寫出瞭如下的改進代碼,重寫 equals 方法,把參數中的 Object 轉換爲 Point 比較其 x 和 y 屬性:


class PointWrong {
    private int x;
    private int y;
    private final String desc;

    public PointWrong(int x, int y, String desc) {
        this.x = x;
        this.y = y;
        this.desc = desc;
    }

    @Override
    public boolean equals(Object o) {
        PointWrong that = (PointWrong) o;
        return x == that.x && y == that.y;
    }
}

        爲測試改進後的 Point 是否可以滿足需求,我們定義了三個用例:

        比較一個 Point 對象和 null;

        比較一個 Object 對象和一個 Point 對象;

        比較兩個 x 和 y 屬性值相同的 Point 對象。


PointWrong p1 = new PointWrong(1, 2, "a");
try {
    log.info("p1.equals(null) ? {}", p1.equals(null));
} catch (Exception ex) {
    log.error(ex.getMessage());
}

Object o = new Object();
try {
    log.info("p1.equals(expression) ? {}", p1.equals(o));
} catch (Exception ex) {
    log.error(ex.getMessage());
}

PointWrong p2 = new PointWrong(1, 2, "b");
log.info("p1.equals(p2) ? {}", p1.equals(p2));

    通過日誌中的結果可以看到,第一次比較出現了空指針異常,第二次比較出現了類型轉換異常,第三次比較符合預期輸出了 true。


[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:32  ] - java.lang.NullPointerException
[17:54:39.120] [http-nio-45678-exec-1] [ERROR] [t.c.e.demo1.EqualityMethodController:39  ] - java.lang.ClassCastException: java.lang.Object cannot be cast to org.geekbang.time.commonmistakes.equals.demo1.EqualityMethodController$PointWrong
[17:54:39.120] [http-nio-45678-exec-1] [INFO ] [t.c.e.demo1.EqualityMethodController:43  ] - p1.equals(p2) ? true

    通過這些失效的用例,我們大概可以總結出實現一個更好的 equals 應該注意的點:

     考慮到性能,可以先進行指針判等,如果對象是同一個那麼直接返回 true;

      需要對另一方進行判空,空對象和自身進行比較,結果一定是 fasle;

      需要判斷兩個對象的類型,如果類型都不同,那麼直接返回 false;

     確保類型相同的情況下再進行類型強制轉換,然後逐一判斷所有字段。

    修復和改進後的 equals 方法如下:


@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PointRight that = (PointRight) o;
    return x == that.x && y == that.y;
}    

      改進後的 equals 看起來完美了,但還沒完。我們繼續往下看。

    hashCode 和 equals 要配對實現

       我們來試試下面這個用例,定義兩個 x 和 y 屬性值完全一致的 Point 對象 p1 和 p2,把 p1 加入 HashSet,然後判斷這個 Set 中是否存在 p2:


PointWrong p1 = new PointWrong(1, 2, "a");
PointWrong p2 = new PointWrong(1, 2, "b");

HashSet<PointWrong> points = new HashSet<>();
points.add(p1);
log.info("points.contains(p2) ? {}", points.contains(p2));

        按照改進後的 equals 方法,這 2 個對象可以認爲是同一個,Set 中已經存在了 p1 就應該包含 p2,但結果卻是 false。 

       出現這個 Bug 的原因是,散列表需要使用 hashCode 來定位元素放到哪個桶。如果自定義對象沒有實現自定義的 hashCode 方法,就會使用 Object 超類的默認實現,得到的兩個 hashCode 是不同的,導致無法滿足需求。

      要自定義 hashCode,我們可以直接使用 Objects.hash 方法來實現,改進後的 Point 類如下:


class PointRight {
    private final int x;
    private final int y;
    private final String desc;
    ...
    @Override
    public boolean equals(Object o) {
        ...
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
}

    改進 equals 和 hashCode 後,再測試下之前的四個用例,結果全部符合預期。


[18:25:23.091] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:54  ] - p1.equals(null) ? false
[18:25:23.093] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:61  ] - p1.equals(expression) ? false
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:67  ] - p1.equals(p2) ? true
[18:25:23.094] [http-nio-45678-exec-4] [INFO ] [t.c.e.demo1.EqualityMethodController:71  ] - points.contains(p2) ? true

看到這裏,你可能會覺得自己實現 equals 和 hashCode 很麻煩,實現 equals 有很多注意點而且代碼量很大。不過,實現這兩個方法也有簡單的方式,一是後面要講到的 Lombok 方法,二是使用 IDE 的代碼生成功能。IDEA 的類代碼快捷生成菜單支持的功能如下:

        注意 compareTo 和 equals 的邏輯一致性

        除了自定義類型需要確保 equals 和 hashCode 要邏輯一致外,還有一個更容易被忽略的問題,即 compareTo 同樣需要和 equals 確保邏輯一致性。

        我之前遇到過這麼一個問題,代碼裏本來使用了 ArrayList 的 indexOf 方法進行元素搜索,但是一位好心的開發同學覺得逐一比較的時間複雜度是 O(n),效率太低了,於是改爲了排序後通過 Collections.binarySearch 方法進行搜索,實現了 O(log n) 的時間複雜度。沒想到,這麼一改卻出現了 Bug。

        我們來重現下這個問題。首先,定義一個 Student 類,有 id 和 name 兩個屬性,並實現了一個 Comparable 接口來返回兩個 id 的值


@Data
@AllArgsConstructor
class Student implements Comparable<Student>{
    private int id;
    private String name;

    @Override
    public int compareTo(Student other) {
        int result = Integer.compare(other.id, id);
        if (result==0)
            log.info("this {} == other {}", this, other);
        return result;
    }
}

然後,寫一段測試代碼分別通過 indexOf 方法和 Collections.binarySearch 方法進行搜索。列表中我們存放了兩個學生,第一個學生 id 是 1 叫 zhang,第二個學生 id 是 2 叫 wang,搜索這個列表是否存在一個 id 是 2 叫 li 的學生:


@GetMapping("wrong")
public void wrong(){

    List<Student> list = new ArrayList<>();
    list.add(new Student(1, "zhang"));
    list.add(new Student(2, "wang"));
    Student student = new Student(2, "li");

    log.info("ArrayList.indexOf");
    int index1 = list.indexOf(student);
    Collections.sort(list);
    log.info("Collections.binarySearch");
    int index2 = Collections.binarySearch(list, student);

    log.info("index1 = " + index1);
    log.info("index2 = " + index2);
}

  代碼輸出的日誌如下:


[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:28  ] - ArrayList.indexOf
[18:46:50.226] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:31  ] - Collections.binarySearch
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:67  ] - this CompareToController.Student(id=2, name=wang) == other CompareToController.Student(id=2, name=li)
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:34  ] - index1 = -1
[18:46:50.227] [http-nio-45678-exec-1] [INFO ] [t.c.equals.demo2.CompareToController:35  ] - index2 = 1

     我們注意到如下幾點:

      binarySearch 方法內部調用了元素的 compareTo 方法進行比較;

      indexOf 的結果沒問題,列表中搜索不到 id 爲 2、name 是 li 的學生;

      binarySearch 返回了索引 1,代表搜索到的結果是 id 爲 2,name 是 wang 的學生。

     修復方式很簡單,確保 compareTo 的比較邏輯和 equals 的實現一致即可。重新實現一下 Student 類,通過 Comparator.comparing 這個便捷的方法來實現兩個字段的比較:


@Data
@AllArgsConstructor
class StudentRight implements Comparable<StudentRight>{
    private int id;
    private String name;

    @Override
    public int compareTo(StudentRight other) {
        return Comparator.comparing(StudentRight::getName)
                .thenComparingInt(StudentRight::getId)
                .compare(this, other);
    }
}

    其實,這個問題容易被忽略的原因在於兩方面:

         一是,我們使用了 Lombok 的 @Data 標記了 Student,@Data 註解(詳見這裏)其實包含了 @EqualsAndHashCode 註解(詳見這裏)的作用,也就是默認情況下使用類型所有的字段(不包括 static 和 transient 字段)參與到 equals 和 hashCode 方法的實現中。因爲這兩個方法的實現不是我們自己實現的,所以容易忽略其邏輯。

        二是,compareTo 方法需要返回數值,作爲排序的依據,容易讓人使用數值類型的字段隨意實現。

       我再強調下,對於自定義的類型,如果要實現 Comparable,請記得 equals、hashCode、compareTo 三者邏輯一致

      小心 Lombok 生成代碼的“坑”

       Lombok 的 @Data 註解會幫我們實現 equals 和 hashcode 方法,但是有繼承關係時,

       Lombok 自動生成的方法可能就不是我們期望的了。

       我們先來研究一下其實現:定義一個 Person 類型,包含姓名和身份證兩個字段:


@Data
class Person {
    private String name;
    private String identity;

    public Person(String name, String identity) {
        this.name = name;
        this.identity = identity;
    }
}

     對於身份證相同、姓名不同的兩個 Person 對象:


Person person1 = new Person("zhuye","001");
Person person2 = new Person("Joseph","001");
log.info("person1.equals(person2) ? {}", person1.equals(person2));

        使用 equals 判等會得到 false。如果你希望只要身份證一致就認爲是同一個人的話,可以使用 @EqualsAndHashCode.Exclude 註解來修飾 name 字段,從 equals 和 hashCode 的實現中排除 name 字段:


@EqualsAndHashCode.Exclude
private String name;

      修改後得到 true。打開編譯後的代碼可以看到,Lombok 爲 Person 生成的 equals 方法的實現,確實只包含了 identity 屬性:


public boolean equals(final Object o) {
    if (o == this) {
        return true;
    } else if (!(o instanceof LombokEquealsController.Person)) {
        return false;
    } else {
        LombokEquealsController.Person other = (LombokEquealsController.Person)o;
        if (!other.canEqual(this)) {
            return false;
        } else {
            Object this$identity = this.getIdentity();
            Object other$identity = other.getIdentity();
            if (this$identity == null) {
                if (other$identity != null) {
                    return false;
                }
            } else if (!this$identity.equals(other$identity)) {
                return false;
            }

            return true;
        }
    }
}

       但到這裏還沒完,如果類型之間有繼承,Lombok 會怎麼處理子類的 equals 和 hashCode 呢?我們來測試一下,寫一個 Employee 類繼承 Person,並新定義一個公司屬性:


@Data
class Employee extends Person {

    private String company;
    public Employee(String name, String identity, String company) {
        super(name, identity);
        this.company = company;
    }
}

      在如下的測試代碼中,聲明兩個 Employee 實例,它們具有相同的公司名稱,但姓名和身份證均不同:


Employee employee1 = new Employee("zhuye","001", "bkjk.com");
Employee employee2 = new Employee("Joseph","002", "bkjk.com");
log.info("employee1.equals(employee2) ? {}", employee1.equals(employee2));  

        很遺憾,結果是 true,顯然是沒有考慮父類的屬性,而認爲這兩個員工是同一人,說明 @EqualsAndHashCode 默認實現沒有使用父類屬性。

      爲解決這個問題,我們可以手動設置 callSuper 開關爲 true,來覆蓋這種默認行爲:


@Data
@EqualsAndHashCode(callSuper = true)
class Employee extends Person {

        修改後的代碼,實現了同時以子類的屬性 company 加上父類中的屬性 identity,作爲 equals 和 hashCode 方法的實現條件(實現上其實是調用了父類的 equals 和 hashCode)。

     重點回顧 

      現在,我們來回顧下對象判等和比較的重點內容吧。

       首先,我們要注意 equals 和 == 的區別。業務代碼中進行內容的比較,針對基本類型只能使用 ==,針對 Integer、String 在內的引用類型,需要使用 equals。Integer 和 String 的坑在於,使用 == 判等有時也能獲得正確結果。

       其次,對於自定義類型,如果類型需要參與判等,那麼務必同時實現 equals 和 hashCode 方法,並確保邏輯一致。如果希望快速實現 equals、hashCode 方法,我們可以藉助 IDE 的代碼生成功能,或使用 Lombok 來生成。如果類型也要參與比較,那麼 compareTo 方法的邏輯同樣需要和 equals、hashCode 方法一致。

       最後,Lombok 的 @EqualsAndHashCode 註解實現 equals 和 hashCode 的時候,默認使用類型所有非 static、非 transient 的字段,且不考慮父類。如果希望改變這種默認行爲,可以使用 @EqualsAndHashCode.Exclude 排除一些字段,並設置 callSuper = true 來讓子類的 equals 和 hashCode 調用父類的相應方法。

        在比較枚舉值和 POJO 參數值的例子中,我們還可以注意到,使用 == 來判斷兩個包裝類型的低級錯誤,確實容易被忽略。所以,我建議你在 IDE 中安裝阿里巴巴的 Java 規約插件(詳見這裏),來及時提示我們這類低級錯誤

 

   思考與討論

       1,在實現 equals 時,我是先通過 getClass 方法判斷兩個對象的類型,你可能會想到還可以使用 instanceof 來判斷。你能說說這兩種實現方式的區別嗎?

       2,在第三節的例子中,我演示了可以通過 HashSet 的 contains 方法判斷元素是否在 HashSet 中,同樣是 Set 的 TreeSet 其 contains 方法和 HashSet 有什麼區別嗎?

     有關對象判等、比較,你還遇到過其他坑嗎?歡迎在評論區與我留言分享你的想法,一起交流。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章