Java靈魂拷問13個為什麼,你都會哪些?

威哥爱编程發表於2024-11-13

大家好,我是 V 哥。今天看了阿里雲開發者社群關於 Java 的靈魂拷問,一線大廠在用 Java 時,都會考慮哪些問題呢,對於工作多年,又沒有大廠經歷的小夥伴不妨看看,V 哥總結的這13個為什麼,你都會哪些?先贊後看,絕不擺爛。

1. 為什麼禁止使用 BigDecimal 的 equals 方法做等值比較?

BigDecimalequals 方法在等值比較時存在一些問題,通常不建議直接使用它來判斷數值的相等性。下面是主要原因以及推薦的替代方案:

1. equals 方法比較嚴格,包含了精度和符號的比較

BigDecimal.equals 不僅比較數值本身,還會比較精度和符號。例如,BigDecimalequals 方法會認為 1.01.00 是不同的值,因為它們的 scale 不同(即小數位數不同)。例如:

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b)); // 輸出 false

儘管 1.01.00 數值上是相等的,但 equals 方法會因為精度不同返回 false

2. equals 方法會區分正負零

BigDecimal 中,正零 (0.0) 和負零 (-0.0) 是不相等的,而使用 equals 會導致 0.0-0.0 被視為不相等。例如:

BigDecimal zero1 = new BigDecimal("0.0");
BigDecimal zero2 = new BigDecimal("-0.0");

System.out.println(zero1.equals(zero2)); // 輸出 false

這可能會導致誤判,因為在大多數業務邏輯中,我們認為 0.0-0.0 是等值的。

推薦的替代方案:使用 compareTo 方法

為了避免這些問題,建議使用 BigDecimal.compareTo 方法。compareTo 方法僅比較數值的大小,不關注精度和符號。因此,在需要判斷兩個 BigDecimal 是否等值時,使用 compareTo 更為合理:

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.compareTo(b) == 0); // 輸出 true

在這種情況下,1.01.00 被視為相等,即使它們的精度不同,compareTo 也會返回 0

小結

  • 不要使用 equals 方法:它會考慮精度和符號,容易導致誤判。
  • 推薦使用 compareTo 方法:只比較數值,忽略精度和正負零的差異,可以實現更符合業務需求的等值比較。

2. 為什麼禁止使用double直接構造BigDecimal?

在使用 BigDecimal 時,不建議直接使用 double 作為構造引數。這是因為 double 型別在 Java 中的表示是基於二進位制浮點數的,會引入精度誤差,從而導致不準確的結果。例如:

double d = 0.1;
BigDecimal bd = new BigDecimal(d);
System.out.println(bd); // 輸出 0.1000000000000000055511151231257827021181583404541015625

原因解析

  1. 二進位制浮點數的精度問題
    double 使用 IEEE 754 標準表示小數,在二進位制系統中,像 0.1 這樣的小數無法精確表示,導致它在儲存時會變成一個近似值。這個近似值會直接傳遞給 BigDecimal 的構造方法,從而生成帶有誤差的 BigDecimal 值。

  2. 結果不準確,影響業務計算
    在一些金融計算或其他對精度要求高的場景中,直接使用 double 構造 BigDecimal 會帶來潛在的誤差積累,從而影響最終的結果。例如,在多次計算或累加時,誤差可能不斷放大。

推薦的替代方案

  • 使用字串或精確值構造 BigDecimal
    透過傳入字串形式的數字,可以避免精度誤差,因為字串構造器不會引入任何二進位制的近似計算。
  BigDecimal bd = new BigDecimal("0.1");
  System.out.println(bd); // 輸出 0.1
  • 使用 BigDecimal.valueOf(double) 方法
    另一個安全的方式是使用 BigDecimal.valueOf(double),該方法會將 double 轉換為 String 表示,然後構造 BigDecimal,從而避免精度損失。
  BigDecimal bd = BigDecimal.valueOf(0.1);
  System.out.println(bd); // 輸出 0.1

小結

  • 避免直接使用 double 構造 BigDecimal,以免引入二進位制浮點數的精度誤差。
  • 優先使用字串構造器,或使用 BigDecimal.valueOf(double) 以確保精度。

3. 為什麼禁止使用 Apache Beanutils 進行屬性的 copy ?

Apache BeanUtils 是一個早期用於 Java Bean 屬性複製的工具庫,但在現代 Java 開發中通常不推薦使用它來進行屬性的複製,尤其在效能敏感的場景中。原因主要包括以下幾點:

1. 效能問題

Apache BeanUtils.copyProperties() 使用了大量的反射操作,且每次複製都需要對欄位、方法進行查詢和反射呼叫。反射機制雖然靈活,但效能較低,尤其是在大量物件或頻繁複製的場景中,會產生顯著的效能瓶頸。

相比之下,Spring BeanUtilsApache Commons LangFieldUtils 等工具經過最佳化,使用了更高效的方式進行屬性複製。在效能要求較高的場合,MapStructDozer 等編譯期程式碼生成的方式則可以完全避免執行時反射。

2. 型別轉換問題

BeanUtils.copyProperties 在屬性型別不匹配時會隱式地進行型別轉換。例如,將 String 型別的 "123" 轉換為 Integer,如果轉換失敗,會丟擲異常。這種隱式轉換在處理資料時,可能帶來不易察覺的錯誤,而且並不總是適合應用場景。

在精確的屬性複製需求下,通常希望型別不匹配時直接跳過複製,或明確丟擲錯誤,而不是隱式轉換。例如,Spring BeanUtils.copyProperties 不會進行隱式轉換,適合嚴格的屬性匹配場景。

3. 潛在的安全問題

Apache BeanUtilsPropertyUtils 元件在執行反射操作時存在一定的安全隱患。歷史上,BeanUtilsPropertyUtils 曾有安全漏洞,使惡意使用者可以透過精心構造的輸入利用反射機制執行系統命令或載入惡意類。儘管這些漏洞在現代版本中已得到修復,但該庫的架構和實現仍較為陳舊,難以應對更高的安全需求。

4. 缺乏對巢狀物件的深複製支援

BeanUtils.copyProperties 僅支援淺複製,即只能複製物件的一級屬性,無法遞迴地對巢狀物件進行復制。如果物件包含了複雜的巢狀結構,使用 BeanUtils.copyProperties 很容易出現意外行為或資料丟失。像 MapStructDozer 這樣的工具則提供對巢狀物件的深層複製能力,更適合複雜物件的深度複製需求。

推薦的替代方案

  1. Spring BeanUtils.copyProperties()
    Spring 的 BeanUtils.copyProperties() 提供了更優的效能和更好的型別安全性。它不做型別轉換,且提供了方便的過濾器用於選擇性複製屬性。

  2. MapStruct
    MapStruct 是基於註解的物件對映框架,支援編譯期生成程式碼,完全避免了反射的效能開銷,且支援複雜物件、巢狀屬性的深度複製,是效能要求較高的首選。

  3. Dozer
    Dozer 支援更靈活的對映配置和深複製,適合物件結構複雜的情況。它可以處理巢狀屬性對映、型別轉換,且具有較好的自定義能力。

小結

Apache BeanUtils.copyProperties 不適合現代 Java 開發的效能、安全性和靈活性要求,推薦使用更高效、安全、靈活的框架(如 Spring BeanUtilsMapStruct 等)來代替。

4. 為什麼要求日期格式化時必須有使用 y 表示年,而不能用 Y ?

在日期格式化中,必須使用 y 而不是 Y 來表示年份,這是因為 yY 在 Java 和其他日期格式化工具中代表不同的含義:

  1. y 表示日曆年(Calendar Year)
    y 是標準的表示年份的字元,表示的是通常意義上的公曆年,比如 2024 表示的就是這一年的年份。使用 y 時,日期格式化工具會準確地格式化出對應的年份數值:
   SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
   System.out.println(sdf.format(new Date())); // 輸出: 2024-11-10
  1. Y 表示星期年(Week Year)
    Y 表示的是“星期年”或稱“ISO週年”(ISO week-numbering year),它是一種基於ISO週數的年份表示方式。這種表示法根據每年的第一個星期一所在的周來計算年份,如果某天屬於新一年的第一個完整星期,則會歸為新年的星期年。

    例如,如果某年的最後幾天在下一年開始的第一個星期中,它們可能會被歸入下一年的 week year。同理,如果新年的前幾天在上一年的最後一個完整星期內,這些天的星期年可能會歸屬上一年。這在日期和時間處理中可能導致意外的年份差異。

   SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd");
   System.out.println(sdf.format(new Date())); // 可能輸出與實際年份不同的值

使用 Y 的潛在問題

使用 Y 表示年份會引發一些日期計算的錯誤,因為它依賴於週數的計算方式,不是每次都與實際的公曆年份一致。例如:

  • 2024年12月31日會被視作 2025week year,導致使用 YYYY 格式化時得到 2025-12-31
  • 在跨年計算或特定日期邏輯中使用 Y 表示年份可能會出現錯誤,因為 week year 與通常理解的日曆年並不總是相符。

什麼時候使用 Y

Y 一般僅用於需要符合 ISO 8601 標準的日期格式,特別是包含 ISO 週數(如“2024-W01-1”表示2024年的第一個星期一)的情況,而在一般情況下,我們都應使用 y 來表示日曆年份。

小結

  • 使用 y 來表示常規年份,避免日期格式化錯誤。
  • 避免使用 Y 來表示年份,除非確實需要按照 ISO 週年的格式來解析和顯示年份。

5. 為什麼使用三目運算子時必需要注意型別對齊?

在使用三目運算子時,型別對齊非常重要,因為三目運算子的兩個分支會被型別推斷成一個共同的型別。若兩者型別不同,Java 編譯器會進行型別提升或自動轉換,這可能導致意外的型別變化和潛在的錯誤。以下是需要注意的原因和細節:

1. 三目運算子會自動進行型別提升

三目運算子的返回值型別是根據 truefalse 分支的型別推斷出來的。為了得到一致的結果,Java 會自動將不同的型別提升為更高精度的型別。例如,若一個分支返回 int 而另一個分支返回 double,Java 會將 int 提升為 double

int x = 5;
double y = 10.5;
double result = (x > 0) ? x : y; // 返回 double 型別
System.out.println(result); // 輸出 5.0

這裡返回值 5 被提升為 5.0。雖然程式碼在這個例子中不會出錯,但在某些情況下,這種自動提升會導致意外的精度損失或型別不匹配的問題。

2. 自動拆箱和裝箱可能引發 NullPointerException

在 Java 中,基本型別和包裝型別的對齊需要特別小心。三目運算子會嘗試將包裝型別和基本型別對齊成相同型別,這會導致自動裝箱和拆箱,如果某個分支為 null 且需要拆箱,可能會引發 NullPointerException

Integer a = null;
int b = 10;
int result = (a != null) ? a : b; // 如果 a 為 null,結果會發生自動拆箱,引發 NullPointerException

由於 anull,Java 會嘗試將其拆箱為 int,從而丟擲 NullPointerException。為避免這種情況,可以確保型別對齊,或避免對可能為 null 的物件進行拆箱。

3. 返回值型別不一致可能導致編譯錯誤

如果三目運算子的兩種返回型別無法被編譯器自動轉換為一個相容型別,程式碼會直接報錯。例如:

int x = 5;
String y = "10";
Object result = (x > 0) ? x : y; // 編譯錯誤:int 和 String 不相容

在這種情況下,intString 無法被提升到相同型別,因此會引發編譯錯誤。若確實希望返回不同型別的值,可以手動指定共同的超型別,例如將結果定義為 Object 型別:

Object result = (x > 0) ? Integer.valueOf(x) : y; // 這裡 result 為 Object

4. 型別對齊可以提升程式碼的可讀性

保持三目運算子返回的型別一致,能讓程式碼更加清晰,便於理解和維護。型別對齊可以避免型別轉換和自動提升帶來的混亂,使程式碼更容易預測和理解:

double result = (condition) ? 1.0 : 0.0; // 返回 double

小結

  • 保持型別一致性,確保 truefalse 分支的型別相同,避免意外的型別提升。
  • 小心自動裝箱和拆箱,避免 null 參與三目運算子計算。
  • 在返回不同型別時選擇合適的公共型別,如使用 Object 或顯式轉換。

6. 為什麼建議初始化 HashMap 的容量大小?

初始化 HashMap 的容量大小是為了提高效能和減少記憶體浪費。透過設定合適的初始容量,可以減少 HashMap 的擴容次數,提高程式執行效率。以下是詳細原因和建議:

1. 減少擴容次數,提高效能

HashMap 預設的初始容量為 16,當超過負載因子閾值(預設是 0.75,即達到容量的 75%)時,HashMap 會自動進行擴容操作,將容量擴大為原來的兩倍。擴容涉及到重新計算雜湊並將資料重新分佈到新的桶中,這個過程非常耗時,尤其在元素較多時,擴容會顯著影響效能。

透過設定合適的初始容量,可以避免或減少擴容操作,提高 HashMap 的存取效率。

2. 節省記憶體,避免不必要的記憶體開銷

如果預計要儲存大量資料但沒有指定容量,HashMap 可能會多次擴容,每次擴容會分配新的記憶體空間,並將原有資料複製到新空間中,造成記憶體浪費。如果在建立 HashMap 時能合理估算其容量,則可以一次性分配足夠的空間,從而避免重複分配記憶體帶來的資源浪費。

3. 避免擴容帶來的執行緒安全問題

在併發環境下,頻繁擴容可能導致執行緒不安全,即使是 ConcurrentHashMap 也不能完全避免擴容帶來的效能和一致性問題。初始化合適的容量可以減少併發環境下擴容帶來的風險。

如何估算合適的容量

  1. 預估資料量:如果預計 HashMap 將儲存 n 個元素,可以將初始容量設定為 (n / 0.75),再向上取整為最接近的 2 的冪次方。
   int initialCapacity = (int) Math.ceil(n / 0.75);
   Map<String, String> map = new HashMap<>(initialCapacity);
  1. 取 2 的冪次方HashMap 的容量總是以 2 的冪次方增長,因為在進行雜湊運算時,可以高效利用按位與操作來計算雜湊桶索引。因此,初始容量設為 2 的冪次方會使雜湊分佈更均勻。

示例程式碼

int expectedSize = 1000; // 預估需要儲存的鍵值對數量
int initialCapacity = (int) Math.ceil(expectedSize / 0.75);
HashMap<String, Integer> map = new HashMap<>(initialCapacity);

小結

初始化 HashMap 的容量大小有以下好處:

  • 提高效能:減少擴容次數,最佳化存取效率。
  • 節省記憶體:避免多次擴容引起的記憶體浪費。
  • 提升執行緒安全:在併發環境下減少擴容帶來的執行緒不安全風險。

合理初始化 HashMap 容量對於高效能應用尤為重要,尤其在儲存大量資料時可以顯著提升程式的執行效率。

7. 為什麼禁止使用 Executors 建立執行緒池?

在 Java 中建立執行緒池時,不推薦直接使用 Executors 提供的快捷方法(例如 Executors.newFixedThreadPool()Executors.newCachedThreadPool() 等),而推薦使用 ThreadPoolExecutor 構造方法來手動配置執行緒池。這種做法主要是為了避免 Executors 建立執行緒池時隱藏的風險,確保執行緒池配置符合需求。具體原因如下:

1. 不透明的任務佇列長度導致OOM風險

  • newFixedThreadPool()newSingleThreadExecutor() 使用的是無界佇列 LinkedBlockingQueue。無界佇列可以存放無限數量的任務,一旦任務量非常大,佇列會迅速佔用大量記憶體,導致 OutOfMemoryError(OOM)。

  • newCachedThreadPool() 使用的是 SynchronousQueue,該佇列沒有儲存任務的能力,每個任務到來時必須立即有一個空閒執行緒來處理任務,否則將建立一個新執行緒。當任務到達速度超過執行緒銷燬速度時,執行緒數量會快速增加,導致 OOM

2. 執行緒數無法控制,導致資源耗盡

newCachedThreadPool() 建立的執行緒池中,執行緒數沒有上限,短時間內大量請求會導致執行緒數暴增,耗盡系統資源。newFixedThreadPool()newSingleThreadExecutor() 雖然限制了核心執行緒數,但未限制任務佇列長度,依然可能耗盡記憶體。

在業務需求不確定或任務激增的場景下,建議明確限制執行緒池的最大執行緒數和佇列長度,以更好地控制系統資源的使用,避免因執行緒數無法控制導致的效能問題。

3. 缺乏合理的拒絕策略控制

  • Executors 建立的執行緒池預設使用 AbortPolicy 拒絕策略,即當執行緒池達到飽和時會丟擲 RejectedExecutionException 異常。
  • 不同的業務場景可能需要不同的拒絕策略,例如可以使用 CallerRunsPolicy(讓提交任務的執行緒執行任務)或 DiscardOldestPolicy(丟棄最舊的任務)來平衡任務處理。

手動建立 ThreadPoolExecutor 時,可以指定適合業務需求的拒絕策略,從而更靈活地處理執行緒池滿載的情況,避免異常或系統效能下降。

4. 靈活配置核心引數

使用 ThreadPoolExecutor 的構造方法可以手動設定以下引數,以便根據業務需求靈活配置執行緒池:

  • corePoolSize:核心執行緒數,避免空閒執行緒被頻繁銷燬和重建。
  • maximumPoolSize:最大執行緒數,控制執行緒池能使用的最大資源。
  • keepAliveTime:非核心執行緒的存活時間,適合控制執行緒銷燬頻率。
  • workQueue:任務佇列型別和長度,便於管理任務積壓的情況。

這些引數的合理配置可以有效平衡執行緒池的效能、資源佔用和任務處理能力,避免使用預設配置時不符合需求的情況。

推薦的執行緒池建立方式

建議直接使用 ThreadPoolExecutor 構造方法配置執行緒池,例如:

int corePoolSize = 10;
int maximumPoolSize = 20;
long keepAliveTime = 60L;
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    TimeUnit.SECONDS,
    workQueue,
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒絕策略
);

小結

使用 Executors 建立執行緒池會帶來不易察覺的風險,可能導致系統資源耗盡或任務堆積,手動配置 ThreadPoolExecutor 可以更好地控制執行緒池的行為,使其符合實際業務需求和資源限制。因此,為了系統的健壯性和可控性,建議避免使用 Executors 快捷方法來建立執行緒池。

8. 為什麼要求謹慎使用 ArrayList 中的 subList 方法?

在使用 ArrayListsubList 方法時需要謹慎,因為它有一些潛在的陷阱,容易導致意外的錯誤和難以排查的異常。以下是 subList 需要小心使用的原因和注意事項:

1. subList 返回的是檢視,而不是獨立副本

ArrayListsubList 方法返回的是原列表的一部分檢視(view),而不是一個獨立的副本。對 subList 的修改會直接影響原列表,反之亦然:

ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List<Integer> subList = list.subList(1, 4);
subList.set(0, 10); // 修改 subList
System.out.println(list); // 原列表也受到影響:[1, 10, 3, 4, 5]

這種共享檢視的機制在某些場景中可能引發意外的修改,導致資料被意外改變,從而影響到原始資料結構的完整性和正確性。

2. subList 的結構性修改限制

當對 ArrayList 本身(而非 subList 檢視)進行結構性修改(addremove 等改變列表大小的操作)後,再操作 subList 會導致 ConcurrentModificationException 異常。這是因為 subList 和原 ArrayList 之間共享結構性修改的狀態,一旦其中一個發生修改,另一方就會失效:

ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List<Integer> subList = list.subList(1, 4);
list.add(6); // 修改原列表的結構
subList.get(0); // 丟擲 ConcurrentModificationException

這種限制意味著 subList 不適合在列表頻繁變化的場景中使用,否則很容易引發併發修改異常。

3. subListArrayList 的 removeAll 等操作可能導致錯誤

subList 生成的檢視列表可能會在批次刪除操作中出現問題,例如呼叫 removeAll 方法時,subList 的行為不一致或發生異常。對於 ArrayListsubList,一些批次修改方法(如 removeAllretainAll)可能會在刪除檢視元素後,導致 ArrayList 產生不可預料的狀態,甚至引發 IndexOutOfBoundsException 等異常。

4. 推薦的安全使用方式

如果需要一個獨立的子列表,可以透過 new ArrayList<>(originalList.subList(start, end)) 來建立一個子列表的副本,從而避免 subList 的共享檢視問題:

ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
ArrayList<Integer> subListCopy = new ArrayList<>(list.subList(1, 4)); // 建立副本
list.add(6); // 修改原列表
subListCopy.get(0); // 安全,不會受到影響

小結

使用 ArrayListsubList 方法需要注意以下幾點:

  • 檢視機制subList 只是原列表的檢視,修改其中一個會影響另一個。
  • 結構性修改限制:結構性修改原列表後再訪問 subList 會丟擲 ConcurrentModificationException
  • 批次操作問題subList 的批次操作可能引發不可預料的錯誤。
  • 建議建立副本:如需獨立操作子列表,最好建立 subList 的副本以避免潛在問題。

謹慎使用 subList 可以避免意外的錯誤,提高程式碼的健壯性。

9. 為什麼禁止在 foreach 迴圈裡進行元素的 remove/add 操作?

在 Java 中,禁止在 foreach 迴圈中進行元素的 removeadd 操作,主要是因為這種操作可能導致 ConcurrentModificationException 異常,或者導致迴圈行為不符合預期。具體原因如下:

1. ConcurrentModificationException 異常

當你在 foreach 迴圈中直接修改集合(例如 removeadd 元素),會導致併發修改問題。foreach 迴圈底層使用了集合的 Iterator 來遍歷元素。大多數集合類(如 ArrayListHashSet 等)都會維護一個 modCount 計數器,表示集合的結構變更次數。當你在遍歷時修改集合的結構(如刪除或新增元素),modCount 會發生變化,而 Iterator 會檢測到這種結構性修改,從而丟擲 ConcurrentModificationException 異常,防止程式在多執行緒環境中出現意外行為。

例如:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) {
    if (s.equals("b")) {
        list.remove(s);  // 會丟擲 ConcurrentModificationException
    }
}

在上面的程式碼中,foreach 迴圈遍歷 list 時,如果刪除了元素 b,它會修改 list 的結構,從而導致 Iterator 檢測到併發修改,丟擲異常。

2. 不可預測的行為

即使沒有丟擲 ConcurrentModificationException,在 foreach 迴圈中修改集合也會導致不可預測的行為。例如,removeadd 操作會改變集合的大小和內容,可能會影響迭代的順序或導致遺漏某些元素,甚至造成死迴圈或跳過某些元素。

例如:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) {
    if (s.equals("b")) {
        list.add("e");  // 修改集合的大小
    }
    System.out.println(s);
}

在這個例子中,add 操作會向 list 中新增一個新元素 "e",從而修改了集合的結構。因為 foreach 迴圈的內部實現使用了迭代器,它可能不會考慮到修改後的新元素,導致輸出順序或遍歷結果與預期不同。

3. 迭代器的 remove() 方法

如果需要在迴圈中刪除元素,推薦使用 Iterator 顯式地進行刪除操作。Iterator 提供了一個安全的 remove() 方法,可以在遍歷時安全地刪除元素,而不會引發 ConcurrentModificationException

例如:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String s = iterator.next();
    if (s.equals("b")) {
        iterator.remove();  // 使用 Iterator 的 remove() 方法
    }
}

使用 Iterator.remove() 可以安全地在遍歷時刪除元素,而不會丟擲併發修改異常。

小結

foreach 迴圈中直接進行 removeadd 操作是不安全的,主要有以下原因:

  • ConcurrentModificationException:直接修改集合會觸發迭代器的併發修改檢測,導致異常。
  • 不可預測的行為:修改集合的結構可能導致元素遺漏、順序錯亂或程式邏輯出錯。
  • 使用 Iterator 替代:使用 Iteratorremove() 方法可以避免這些問題,實現安全的元素刪除操作。

因此,正確的做法是使用 Iterator 顯式地處理元素的刪除或修改,而不是直接在 foreach 迴圈中進行修改。

10. 為什麼禁止工程師直接使用日誌系統 (Log4j、Logback) 中的 API ?

在很多工程實踐中,禁止工程師直接使用日誌系統(如 Log4j、Logback)中的 API,主要是出於以下幾個原因:

1. 日誌配置與實現的分離

直接使用日誌系統的 API 可能會導致日誌記錄邏輯與應用的業務邏輯緊密耦合,使得日誌配置和實現的分離變得困難。現代的日誌框架(如 Log4j、Logback)允許透過外部配置檔案(如 log4j.xmllogback.xml)靈活配置日誌級別、輸出格式、輸出位置等,而不是硬編碼到應用程式碼中。直接使用日誌 API 會導致日誌的配置與業務程式碼繫結在一起,不易修改和維護。

建議的做法:透過使用日誌框架的日誌抽象介面(如 org.slf4j.Logger)來記錄日誌,而不是直接依賴具體的日誌實現。這種方式提供了更大的靈活性,日誌實現可以在執行時透過配置檔案更換而無需修改程式碼。

2. 靈活性與可擴充套件性問題

如果工程師直接使用日誌庫的 API,專案在需要切換日誌框架(比如從 Log4j 轉換到 Logback 或其他框架)時,需要修改大量的程式碼,增加了系統的耦合度和維護難度。另一方面,使用日誌抽象層(如 SLF4J)可以避免這一問題,因為 SLF4J 是一個日誌抽象層,底層可以切換具體的日誌實現而無需改變業務程式碼。

示例

// 不推薦:直接使用 Log4j 的 API
import org.apache.log4j.Logger;
Logger logger = Logger.getLogger(MyClass.class);
logger.info("This is a log message");

// 推薦:透過 SLF4J 介面來記錄日誌
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Logger logger = LoggerFactory.getLogger(MyClass.class);
logger.info("This is a log message");

使用 SLF4J 可以在不同的環境中靈活切換日誌實現,而無需修改程式碼。

3. 日誌記錄與除錯不一致

如果工程師直接使用日誌框架的 API,可能會在日誌記錄時不遵循一致的日誌策略。例如,日誌的級別、格式、日誌輸出的內容等可能不統一,導致日誌資訊混亂、不易追蹤。透過統一的日誌抽象介面(如 SLF4J)和規範的日誌記錄策略(透過 AOP 或日誌框架自帶的特性)可以保持日誌的一致性和規範性。

最佳實踐

  • 透過統一的日誌管理類或工具類來封裝日誌記錄方法,確保所有日誌記錄都遵循統一的格式和規範。
  • 在日誌中統一使用適當的日誌級別(如 DEBUGINFOWARNERROR)和標準格式。

4. 日誌的效能影響

日誌記錄可能對應用的效能產生一定的影響,尤其是在日誌記錄過於頻繁或日誌輸出內容過多的情況下。透過直接使用日誌框架的 API,可能無法靈活控制日誌輸出的頻率、內容或過濾策略,從而造成效能問題。很多日誌框架(如 Log4j 和 Logback)提供了高階的配置選項,如非同步日誌、日誌快取等特性,可以顯著提高效能。

推薦做法

  • 使用日誌框架提供的非同步日誌功能來提高效能。
  • 配置適當的日誌級別,避免在生產環境中輸出過多的除錯資訊。

5. 日誌管理的統一性與規範

在團隊開發中,直接使用日誌框架的 API 會導致不同開發人員在不同模組中記錄日誌時不遵循統一規範,導致日誌格式不統一、資訊不一致,甚至產生重複的日誌記錄。透過日誌管理工具類或封裝類,可以確保所有開發人員遵循統一的日誌記錄策略。

示例

  • 建立一個統一的 LoggerFactory 工廠類來生成日誌記錄物件。
  • 統一定義日誌級別和輸出格式,確保日誌輸出一致。

小結

禁止工程師直接使用日誌系統(如 Log4j、Logback)中的 API,主要是為了:

  1. 解耦日誌實現與業務邏輯:透過使用日誌抽象層(如 SLF4J),可以更輕鬆地切換日誌框架,避免硬編碼。
  2. 提高靈活性與可維護性:避免在應用中重複使用框架 API,提高日誌配置的靈活性和一致性。
  3. 規範日誌記錄行為:透過封裝日誌記錄,確保日誌級別、格式和內容的統一,增強可讀性和可追蹤性。
  4. 最佳化效能:透過配置日誌框架的高階功能(如非同步日誌),提高日誌系統的效能,減少對應用的影響。
  5. 統一日誌管理:避免團隊成員在不同模組中使用不一致的日誌記錄方式,確保日誌輸出的標準化。

最好的做法是透過日誌抽象層(如 SLF4J)進行日誌記錄,同時透過日誌管理工具類進行統一的配置和呼叫,確保日誌的高效、規範和靈活性。

11. 為什麼建議開發者謹慎使用繼承?

在物件導向程式設計(OOP)中,繼承是一種常見的程式碼複用方式,它允許一個類繼承另一個類的屬性和行為。然而,雖然繼承可以提高程式碼的複用性,但過度或不當使用繼承可能會導致程式碼的複雜性增加,進而帶來一些潛在的問題。因此,建議開發者在使用繼承時要謹慎,以下是一些關鍵原因:

1. 增加了類之間的耦合性

繼承會導致子類和父類之間形成緊密的耦合關係。子類依賴於父類的實現,這意味著如果父類發生變化,可能會影響到所有繼承自該父類的子類,導致修改和維護變得更加困難。這種緊密耦合關係也限制了子類的靈活性,因為它必須遵循父類的介面和實現。

例子

class Animal {
    void eat() {
        System.out.println("Animal is eating");
    }
}

class Dog extends Animal {
    @Override
    void eat() {
        System.out.println("Dog is eating");
    }
}

如果父類 Animal 做了改動(如修改 eat() 方法的實現),Dog 類也會受到影響。這樣的耦合會增加後期維護的複雜度。

2. 破壞了封裝性(Encapsulation)

繼承可能破壞封裝性,因為子類可以直接訪問父類的成員(欄位和方法),尤其是當父類成員被設定為 protectedpublic 時。這種情況可能導致子類暴露不應被外界訪問的細節,破壞了資料的封裝性。

例子

class Vehicle {
    protected int speed;
}

class Car extends Vehicle {
    void accelerate() {
        speed += 10; // 直接訪問父類的 protected 欄位
    }
}

在這種情況下,Car 類直接訪問了父類 Vehiclespeed 欄位,而不是透過公共介面來修改它,導致封裝性降低。

3. 繼承可能會導致類的層次結構不合理

繼承往往會導致不合理的類層次結構,特別是在試圖透過繼承來表達“是一個”(is-a)關係時,實際情況可能並不符合這種邏輯。濫用繼承可能會使類之間的關係變得複雜和不直觀,導致程式碼結構混亂。

例子
假設我們有一個 Car 類和一個 Truck 類,都繼承自 Vehicle 類。如果 CarTruck 共享很多方法和屬性,這樣的設計可能是合適的。但是,如果 CarTruck 之間差異很大,僅透過繼承來構建它們的關係,可能會導致繼承層次過於複雜,程式碼閱讀和理解變得困難。

4. 繼承可能導致不易發現的錯誤

由於子類繼承了父類的行為,任何對父類的修改都有可能影響到子類的行為。更糟糕的是,錯誤或不一致的修改可能在父類中發生,而這些錯誤可能不會立即暴露出來,直到程式執行到某個特定的地方,才會顯現出錯誤。

例子
假設你修改了父類的某個方法,但忘記更新或調整子類中相應的重寫方法,這可能會導致難以發現的錯誤。

5. 繼承限制了靈活性(不可重用性問題)

繼承建立了一個父類與子類之間的固定關係,這意味著如果你想在一個完全不同的上下文中重用一個類,你可能不能透過繼承來實現。在某些情況下,組合比繼承更為靈活,允許你將多個行為組合到一個類中,而不是透過繼承來強行構建類的層次結構。

例子

// 組合而非繼承
class Engine {
    void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private Engine engine = new Engine(); // 透過組合來使用 Engine
    void start() {
        engine.start();
    }
}

透過組合,可以靈活地使用不同的元件,而不需要繼承整個類。這樣做的優點是更具擴充套件性和靈活性。

6. 繼承限制了方法的重用(可維護性差)

如果你過度依賴繼承,你的程式碼會容易受到父類實現的限制,難以靈活地新增新功能或進行擴充套件。例如,在繼承鏈中新增新的功能可能會導致一大堆方法的修改和重寫,而不透過繼承,可以更輕鬆地將功能作為獨立模組來重用。

7. 使用介面和組合更優

相比繼承,介面(Interface)組合(Composition) 更符合物件導向設計的原則。介面允許類只暴露所需的功能,而不暴露實現細節,組合則允許你將多個不同的行為組合在一起,使得系統更加靈活和可擴充套件。透過介面和組合,可以避免繼承的許多問題。

推薦設計模式

  • 策略模式(Strategy Pattern):透過介面和組合來替代繼承。
  • 裝飾器模式(Decorator Pattern):使用組合和代理來擴充套件行為,而非透過繼承。

小結

儘管繼承是物件導向程式設計中的一個重要特性,但濫用繼承可能帶來許多問題,特別是在以下幾個方面:

  • 增加類之間的耦合,降低靈活性;
  • 破壞封裝性,暴露不應訪問的內部實現;
  • 可能導致類層次結構複雜,增加理解和維護的難度;
  • 限制程式碼的重用和擴充套件性。

因此,推薦優先使用組合而非繼承,並儘可能使用介面來實現靈活的擴充套件。如果必須使用繼承,確保它能夠清晰地表達“是一個”的關係,並避免過深的繼承層次。

12. 為什麼禁止開發人員修改 serialVersionUID 欄位的值?

serialVersionUID 是 Java 中用來標識序列化版本的一個靜態欄位。它的作用是確保在反序列化時,JVM 可以驗證序列化的類與當前類的相容性,以避免版本不相容導致的錯誤。儘管 serialVersionUID 可以由開發人員手動定義,禁止開發人員修改 serialVersionUID 欄位的值 的原因如下:

1. 序列化與反序列化相容性

serialVersionUID 的主要作用是保證在序列化和反序列化過程中,類的版本相容性。它是用來標識類的版本的,如果序列化和反序列化過程中使用的類的 serialVersionUID 不匹配,就會丟擲 InvalidClassException

  • 不匹配的 serialVersionUID 會導致序列化的資料與當前類不相容,導致反序列化失敗。
  • 修改 serialVersionUID 的值會改變類的版本標識,導致已序列化的資料在反序列化時不能成功讀取,特別是在類結構發生改變(例如新增或刪除欄位)時。

例如:

// 類的第一次版本
public class MyClass implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    // 其他欄位和方法
}

// 類的第二次修改版本
public class MyClass implements Serializable {
    private static final long serialVersionUID = 2L;  // 修改了 serialVersionUID
    private String name;
    private int age;  // 新增欄位
    // 其他欄位和方法
}

如果修改了 serialVersionUID,而之前序列化的資料是使用版本 1 的類進行序列化的,反序列化時會因為 serialVersionUID 不匹配而導致失敗。

2. 避免不必要的版本衝突

Java 會根據類的欄位、方法等資訊自動生成 serialVersionUID,這個值是基於類的結構計算出來的。如果開發人員修改了 serialVersionUID,可能會破壞 Java 自動生成的版本控制機制,從而導致版本控制不一致,增加了維護複雜性。

如果手動修改 serialVersionUID,容易出現以下幾種問題:

  • 由於類結構沒有變化,修改 serialVersionUID 可能會導致已序列化的資料無法恢復。
  • 如果不同的開發人員修改了 serialVersionUID,可能會在不同的機器或系統間引起序列化不一致。

3. 影響序列化相容性

Java 提供了兩種主要的相容性規則:

  • 相容性向前:如果類的欄位或方法發生改變,但沒有改變 serialVersionUID,則反序列化是可以工作的。
  • 相容性向後:如果你修改了類的結構(如欄位變動、方法簽名改變等),並且保持相同的 serialVersionUID,反序列化仍然可以工作。

如果不小心修改了 serialVersionUID,可能導致以下情況:

  • 向前相容性:新版本的類不能相容老版本的物件,導致反序列化失敗。
  • 向後相容性:老版本的類無法反序列化新版本的物件。

4. 自動生成 vs 手動指定

  • 自動生成的 serialVersionUID:Java 會根據類的結構自動生成 serialVersionUID,這樣如果類的結構發生變化,serialVersionUID 會自動變化,確保不相容的版本之間不會出現意外的反序列化行為。
  • 手動指定 serialVersionUID:手動修改 serialVersionUID 可能導致版本控制不一致,特別是在多人開發、分散式部署的環境中,容易出現反序列化失敗的問題。

5. 避免非預期的反序列化問題

手動修改 serialVersionUID 可能會導致資料丟失或反序列化時丟擲異常。例如,如果開發人員錯誤地修改了 serialVersionUID,系統在嘗試反序列化時可能會因為 serialVersionUID 不匹配而無法成功載入物件,導致異常的發生。

小結

禁止開發人員修改 serialVersionUID 欄位的值,主要是為了:

  • 確保序列化與反序列化的相容性,避免版本不匹配導致反序列化失敗。
  • 避免不必要的版本衝突和資料丟失,特別是在類結構修改時。
  • 保持 Java 自動管理 serialVersionUID 的優勢,保證類的版本一致性和可維護性。

如果確實需要修改 serialVersionUID,應確保修改後的版本與已經序列化的資料相容,並遵循合理的版本管理策略。

13. 為什麼禁止開發人員使用 isSuccess 作為變數名?

禁止開發人員使用 isSuccess 作為變數名,主要是為了遵循更好的程式設計規範和提高程式碼的可讀性、可維護性。這個變數名問題的核心在於其容易引起歧義和混淆。具體原因如下:

1. 不符合布林值命名約定

在 Java 中,通常使用 ishas 開頭的變數名來表示布林值(boolean 型別)。這類命名通常遵循特定的語義約定,表示某個條件是否成立。例如:

  • isEnabled 表示某個功能是否啟用;
  • hasPermission 表示是否有許可權。

問題

  • isSuccess 看起來像一個布林值(boolean 型別),但它實際上可能並不直接表示一個布林值,而是一個狀態或結果。這種命名可能會導致混淆,開發者可能誤以為它是布林型別的變數,而實際上它可能是一個描述狀態的物件、字串或者其他型別的資料。

2. 語義不明確

isSuccess 這個名字表面上表示“是否成功”,但是它缺少具體的上下文,導致語義不夠明確。真正表示是否成功的布林值應該直接使用 boolean 型別的變數,並且使用清晰明確的命名。

例如:

  • isCompleted:表示某個任務是否完成。
  • isSuccessful:表示某個操作是否成功。

這些命名能更明確地表達布林變數的含義,避免理解上的歧義。

3. 與標準的 is 字首混淆

is 字首通常用來表示“是否”某個條件成立,適用於返回布林值的方法或者變數。isSuccess 這樣的命名會讓開發人員誤以為它是一個布林值,或者一個 boolean 型別的值,但實際上它可能是一個複雜型別或者其他非布林型別,造成不必要的混淆。

例如:

boolean isSuccess = someMethod(); // 看起來是布林值,但實際型別可能不同

這種情況可能導致開發人員產生誤解,認為 isSuccess 代表的是布林值,但它可能是某個表示成功的物件、列舉或者其他資料型別。

4. 更好的命名建議

為了避免歧義和混淆,開發人員應使用更加明確且符合命名規範的名稱。以下是一些命名的改進建議:

  • 如果是布林值,命名為 isSuccessfulwasSuccessful
  • 如果是表示結果的物件,使用更具體的名稱,例如 operationResultstatusCode,以表明它是一個描述操作結果的變數。

5. 提升程式碼的可讀性和可維護性

清晰且具有意義的命名能夠幫助團隊成員或未來的開發者更快地理解程式碼的意圖。如果變數名過於模糊(如 isSuccess),就可能讓人對其實際含義產生疑問,尤其是在閱讀較大或複雜的程式碼時。良好的命名能夠提升程式碼的可讀性和可維護性。

小結

  • isSuccess 這樣的命名不清晰,容易與布林型別的變數產生混淆,進而影響程式碼的可讀性。
  • 命名應儘量明確,避免使用容易引起歧義的名稱,特別是在布林值型別的命名時。
  • 建議使用更具描述性的名稱,如 isSuccessfulwasSuccessful,更清晰地表達變數的意義。

最後

以上是 V 哥精心總結的13個 Java 程式設計中的小小編碼問題,也是V 哥日常編碼中總結的學習筆記,分享給大家,如果內容對你有幫助,請不要吝嗇來個小讚唄,關注威哥愛程式設計,Java 路上,你我相伴前行。

相關文章