錯誤的 Java 代碼:Java 開發人員最常犯的 10 個錯誤

已發表: 2022-03-11

Java 是一種編程語言,最初是為交互式電視開發的,但隨著時間的推移,它已廣泛應用於任何可以使用軟件的地方。 Java 採用面向對象編程的概念設計,消除了其他語言(如 C 或 C++)、垃圾收集和與架構無關的虛擬機的複雜性,創造了一種新的編程方式。 此外,它的學習曲線平緩,似乎成功地堅持了自己的原則——“一次編寫,到處運行”,這幾乎總是正確的; 但是Java問題仍然存在。 我將解決十個我認為是最常見錯誤的 Java 問題。

常見錯誤 #1:忽略現有庫

Java 開發人員忽略無數用 Java 編寫的庫絕對是一個錯誤。 在重新發明輪子之前,請嘗試搜索可用的庫 - 其中許多已經在其存在多年中得到了完善,並且可以免費使用。 這些可能是日誌庫,如 logback 和 Log4j,或網絡相關庫,如 Netty 或 Akka。 一些庫,例如 Joda-Time,已經成為事實上的標準。

以下是我之前的一個項目的個人經驗。 負責 HTML 轉義的代碼部分是從頭開始編寫的。 它多年來一直運行良好,但最終它遇到了用戶輸入,導致它陷入無限循環。 用戶發現服務沒有響應,嘗試使用相同的輸入重試。 最終,服務器上為這個應用程序分配的所有 CPU 都被這個無限循環佔用了。 如果這個天真的 HTML 轉義工具的作者決定使用可用於 HTML 轉義的知名庫之一,例如來自 Google Guava 的HtmlEscapers ,那麼這可能不會發生。 至少,對於大多數有社區支持的流行庫來說,這個錯誤會被社區更早地發現並修復。

常見錯誤 #2:在 Switch-Case 塊中缺少“break”關鍵字

這些 Java 問題可能非常令人尷尬,有時直到在生產環境中運行才被發現。 switch 語句中的失敗行為通常很有用; 但是,如果不希望出現這種行為,則缺少“break”關鍵字可能會導致災難性的結果。 如果您忘記在下面的代碼示例中的“case 0”中放置“break”,程序將編寫“Zero”後跟“One”,因為這裡的控制流將遍歷整個“switch”語句,直到它達到了“休息”。 例如:

 public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }

在大多數情況下,更簡潔的解決方案是使用多態性並將具有特定行為的代碼移動到單獨的類中。 可以使用靜態代碼分析器(例如 FindBugs 和 PMD)檢測諸如此類的 Java 錯誤。

常見錯誤 #3:忘記釋放資源

每次程序打開文件或網絡連接時,Java 初學者在完成使用後釋放資源非常重要。 如果在對此類資源的操作期間引發任何異常,則應採取類似的謹慎態度。 有人可能會爭辯說 FileInputStream 有一個終結器,它在垃圾收集事件上調用 close() 方法。 但是,由於我們無法確定垃圾回收週期何時開始,輸入流可能會無限期地消耗計算機資源。 事實上,Java 7 中專門針對這種情況引入了一個非常有用且簡潔的語句,稱為 try-with-resources:

 private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }

此語句可用於任何實現 AutoClosable 接口的對象。 它確保在語句結束時關閉每個資源。

相關: 8 個基本 Java 面試問題

常見錯誤 #4:內存洩漏

Java 使用自動內存管理,雖然忘記手動分配和釋放內存是一種解脫,但這並不意味著 Java 初學者不應該知道內存在應用程序中是如何使用的。 內存分配問題仍然存在。 只要程序創建了對不再需要的對象的引用,它就不會被釋放。 在某種程度上,我們仍然可以稱之為內存洩漏。 Java 中的內存洩漏可能以多種方式發生,但最常見的原因是持久的對象引用,因為垃圾收集器無法在仍然存在對它們的引用時從堆中刪除對象。 可以通過使用包含一些對象集合的靜態字段定義類來創建這樣的引用,並且在不再需要該集合後忘記將該靜態字段設置為 null。 靜態字段被視為 GC 根,永遠不會被收集。

這種內存洩漏背後的另一個潛在原因是一組對象相互引用,導致循環依賴,因此垃圾收集器無法決定是否需要這些具有交叉依賴引用的對象。 另一個問題是使用 JNI 時非堆內存中的洩漏。

原始洩漏示例可能如下所示:

 final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }

此示例創建兩個計劃任務。 第一個任務從名為“numbers”的雙端隊列中獲取最後一個數字,並打印數字和雙端隊列大小,以防該數字可被 51 整除。第二個任務將數字放入雙端隊列。 這兩個任務都以固定的速率安排,每 10 毫秒運行一次。 如果代碼被執行,你會看到雙端隊列的大小一直在增加。 這最終將導致雙端隊列被消耗所有可用堆內存的對象填充。 為了在保留該程序的語義的同時防止這種情況,我們可以使用不同的方法從雙端隊列中獲取數字:“pollLast”。 與“peekLast”方法相反,“pollLast”返回元素並將其從雙端隊列中刪除,而“peekLast”只返回最後一個元素。

要了解有關 Java 內存洩漏的更多信息,請參閱我們揭開此問題的神秘面紗的文章。

常見錯誤 #5:過多的垃圾分配

當程序創建大量短期對象時,可能會發生過多的垃圾分配。 垃圾收集器連續工作,從內存中刪除不需要的對象,這會對應用程序的性能產生負面影響。 一個簡單的例子:

 String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));

在 Java 開發中,字符串是不可變的。 因此,在每次迭代中都會創建一個新字符串。 為了解決這個問題,我們應該使用一個可變的 StringBuilder:

 StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));

雖然第一個版本需要相當長的時間來執行,但使用 StringBuilder 的版本產生的結果要少得多。

常見錯誤 #6:在不需要的情況下使用空引用

避免過度使用 null 是一種很好的做法。 例如,最好從方法返回空數組或集合而不是 null,因為它可以幫助防止 NullPointerException。

考慮以下方法,它遍歷從另一個方法獲得的集合,如下所示:

 List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }

如果當某人沒有帳戶時 getAccountIds() 返回 null,則會引發 NullPointerException。 要解決此問題,將需要進行空檢查。 但是,如果它返回空列表而不是 null,則 NullPointerException 不再是問題。 此外,代碼更簡潔,因為我們不需要對變量 accountIds 進行空值檢查。

為了處理想要避免空值的其他情況,可以使用不同的策略。 其中一種策略是使用 Optional 類型,它可以是空對像或某個值的包裝:

 Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }

事實上,Java 8 提供了更簡潔的解決方案:

 Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);

自版本 8 以來,可選類型一直是 Java 的一部分,但它在函數式編程世界中早已為人所知。 在此之前,它在早期版本的 Java 的 Google Guava 中可用。

常見錯誤 #7:忽略異常

不處理異常通常很誘人。 但是,對於初學者和經驗豐富的 Java 開發人員來說,最好的做法是處理它們。 異常是故意拋出的,所以在大多數情況下,我們需要解決導致這些異常的問題。 不要忽視這些事件。 如有必要,您可以重新拋出它,向用戶顯示錯誤對話框,或將消息添加到日誌中。 最起碼應該解釋一下為什麼這個異常沒有被處理,以便讓其他開發者知道原因。

 selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }

突出異常無關緊要的一種更清晰的方法是將此消息編碼為異常的變量名稱,如下所示:

 try { selfie.delete(); } catch (NullPointerException unimportant) { }

常見錯誤 #8:並發修改異常

當使用迭代器對象提供的方法以外的方法對其進行迭代時修改集合時會發生此異常。 例如,我們有一個帽子列表,我們想要刪除所有有耳罩的帽子:

 List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }

如果我們運行此代碼,將引發“ConcurrentModificationException”,因為代碼在迭代時修改了集合。 如果使用同一列表的多個線程之一嚐試修改集合,而其他線程對其進行迭代,則可能會發生相同的異常。 多線程並發修改集合是很自然的事情,但應該使用並發編程工具箱中的常用工具,例如同步鎖,並發修改採用的特殊集合等。如何解決這個Java問題存在細微差別在單線程情況和多線程情況下。 下面簡要討論了在單線程場景中可以處理的一些方法:

收集對象並在另一個循環中刪除它們

在一個列表中收集帶有耳罩的帽子,以便稍後從另一個循環中移除它們是一個明顯的解決方案,但需要一個額外的集合來存儲要移除的帽子:

 List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }

使用 Iterator.remove 方法

這種方法更簡潔,不需要創建額外的集合:

 Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }

使用 ListIterator 的方法

當修改後的集合實現 List 接口時,使用列表迭代器是合適的。 實現 ListIterator 接口的迭代器不僅支持刪除操作,還支持添加和設置操作。 ListIterator 實現了 Iterator 接口,因此該示例看起來與 Iterator remove 方法幾乎相同。 唯一的區別是帽子迭代器的類型,以及我們使用“listIterator()”方法獲取該迭代器的方式。 下面的代碼片段顯示瞭如何使用“ListIterator.remove”和“ListIterator.add”方法將每頂帽子替換為帶闊邊帽的耳罩:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }

使用 ListIterator,可以將 remove 和 add 方法調用替換為對 set 的單個調用:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }

使用 Java 8 中引入的流方法 在 Java 8 中,程序員能夠將集合轉換為流並根據某些標準過濾該流。 這是一個流 api 如何幫助我們過濾帽子並避免“ConcurrentModificationException”的示例。

 hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));

“Collectors.toCollection”方法將創建一個帶有過濾帽子的新 ArrayList。 如果過濾條件由大量項目滿足,這可能會出現問題,從而導致很大的 ArrayList; 因此,應謹慎使用。 使用 Java 8 中的 List.removeIf 方法 Java 8 中提供的另一個解決方案,顯然是最簡潔的,是使用“removeIf”方法:

 hats.removeIf(IHat::hasEarFlaps);

而已。 在後台,它使用“Iterator.remove”來完成該行為。

使用專門的集合

如果一開始我們決定使用“CopyOnWriteArrayList”而不是“ArrayList”,那麼完全沒有問題,因為“CopyOnWriteArrayList”提供了不變的修改方法(如set、add、remove)集合的支持數組,而是創建它的新修改版本。 這允許對集合的原始版本進行迭代並同時對其進行修改,而不會出現“ConcurrentModificationException”的風險。 該集合的缺點很明顯 - 每次修改都會生成一個新集合。

還有針對不同情況調整的其他集合,例如“CopyOnWriteSet”和“ConcurrentHashMap”。

並發集合修改的另一個可能錯誤是從集合創建流,並在流迭代期間修改支持集合。 流的一般規則是避免在流查詢期間修改底層集合。 以下示例將顯示處理流的錯誤方法:

 List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));

peek 方法收集所有元素並對每個元素執行提供的操作。 在這裡,該操作試圖從基礎列表中刪除元素,這是錯誤的。 為避免這種情況,請嘗試上述一些方法。

常見錯誤 #9:違反合同

有時,標準庫或第三方供應商提供的代碼依賴於應該遵守的規則才能使事情正常進行。 例如,它可能是 hashCode 和 equals 契約,當遵循它時,可以保證 Java 集合框架中的一組集合以及使用 hashCode 和 equals 方法的其他類的工作。 不遵守合約並不是那種總是會導致異常或破壞代碼編譯的錯誤; 它更棘手,因為有時它會在沒有任何危險跡象的情況下更改應用程序行為。 錯誤的代碼可能會進入生產版本並導致一大堆不良影響。 這可能包括不良的 UI 行為、錯誤的數據報告、較差的應用程序性能、數據丟失等。 幸運的是,這些災難性的錯誤並不經常發生。 我已經提到了 hashCode 和 equals 合約。 它用於依賴散列和比較對象的集合,如 HashMap 和 HashSet。 簡單來說,合約包含兩個規則:

  • 如果兩個對象相等,那麼它們的哈希碼應該相等。
  • 如果兩個對象具有相同的哈希碼,那麼它們可能相等也可能不相等。

在嘗試從哈希圖中檢索對象時,違反合約的第一條規則會導致問題。 第二條規則表示具有相同哈希碼的對像不一定相等。 讓我們檢查一下違反第一條規則的影響:

 public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }

如您所見,Boat 類重寫了 equals 和 hashCode 方法。 但是,它違反了約定,因為 hashCode 每次調用時都會為同一個對象返回隨機值。 以下代碼很可能在哈希集中找不到名為“Enterprise”的船,儘管我們之前添加了這種船:

 public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }

合同的另一個例子涉及 finalize 方法。 這是官方 java 文檔中描述其功能的引述:

finalize 的一般約定是,當 JavaTM 虛擬機確定不再有任何方法可以讓任何線程(尚未終止)訪問該對象時調用它,除非是由於其他準備完成的對像或類的完成所採取的行動。 finalize 方法可以採取任何行動,包括使該對象再次可供其他線程使用; 然而,finalize 的通常目的是在對像被不可撤銷地丟棄之前執行清理操作。 例如,代表輸入/輸出連接的對象的 finalize 方法可能會執行顯式 I/O 事務以在對像被永久丟棄之前中斷連接。

可以決定使用 finalize 方法來釋放文件處理程序等資源,但這不是一個好主意。 這是因為 finalize 何時被調用沒有時間保證,因為它是在垃圾收集期間調用的,而 GC 的時間是不確定的。

常見錯誤 #10:使用原始類型而不是參數化類型

根據 Java 規範,原始類型是未參數化的類型,或者不是從 R 的超類或超接口繼承的類 R 的非靜態成員。在 Java 中引入泛型類型之前,沒有原始類型的替代品. 它從 1.5 版開始支持泛型編程,而泛型無疑是一個重大改進。 然而,由於向後兼容的原因,留下了一個可能破壞類型系統的陷阱。 讓我們看下面的例子:

 List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

在這裡,我們有一個定義為原始 ArrayList 的數字列表。 由於它的類型沒有用類型參數指定,我們可以在其中添加任何對象。 但在最後一行中,我們將元素轉換為 int,將其加倍,並將加倍的數字打印到標準輸出。 這段代碼編譯時不會出錯,但是一旦運行就會引發運行時異常,因為我們試圖將字符串轉換為整數。 顯然,如果我們隱藏必要的信息,類型系統將無法幫助我們編寫安全的代碼。 為了解決這個問題,我們需要指定我們要存儲在集合中的對象的類型:

 List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

與原始版本的唯一區別是定義集合的行:

 List<Integer> listOfNumbers = new ArrayList<>();

固定代碼無法編譯,因為我們試圖將字符串添加到預期僅存儲整數的集合中。 編譯器將顯示錯誤並指向我們嘗試將字符串“Twenty”添加到列表的行。 對泛型類型進行參數化總是一個好主意。 這樣,編譯器就能夠進行所有可能的類型檢查,並且將由類型系統不一致導致的運行時異常的可能性降到最低。

結論

Java 作為一個平台簡化了軟件開發中的許多事情,它依賴於復雜的 JVM 和語言本身。 然而,它的特性,比如刪除手動內存管理或體面的 OOP 工具,並不能消除普通 Java 開發人員面臨的所有問題。 與往常一樣,像這樣的知識、實踐和 Java 教程是避免和解決應用程序錯誤的最佳方法——因此了解您的庫、閱讀 java、閱讀 JVM 文檔和編寫程序。 也不要忘記靜態代碼分析器,因為它們可以指出實際的錯誤並突出顯示潛在的錯誤。

相關:高級 Java 類教程:類重載指南