Buggy Java Code: 10 ข้อผิดพลาดที่พบบ่อยที่สุดที่นักพัฒนา Java ทำ

เผยแพร่แล้ว: 2022-03-11

Java เป็นภาษาโปรแกรมที่พัฒนาขึ้นในขั้นต้นสำหรับโทรทัศน์แบบโต้ตอบ แต่เมื่อเวลาผ่านไปซอฟต์แวร์ก็แพร่หลายไปทั่วทุกที่ ได้รับการออกแบบโดยใช้แนวคิดของการเขียนโปรแกรมเชิงวัตถุ ยกเลิกความซับซ้อนของภาษาอื่นๆ เช่น C หรือ C++ การรวบรวมขยะ และเครื่องเสมือนที่ไม่เชื่อเรื่องพระเจ้าทางสถาปัตยกรรม Java ได้สร้างวิธีการใหม่ในการเขียนโปรแกรม ยิ่งไปกว่านั้น มันมีช่วงการเรียนรู้ที่อ่อนโยนและดูเหมือนว่าจะยึดติดอยู่กับ moto ของตัวเองได้สำเร็จ - "เขียนครั้งเดียว วิ่งไปทุกที่" ซึ่งเกือบจะเป็นความจริงทุกครั้ง แต่ปัญหาจาวายังคงมีอยู่ ฉันจะแก้ไขปัญหา Java สิบประการที่ฉันคิดว่าเป็นข้อผิดพลาดที่พบบ่อยที่สุด

ข้อผิดพลาดทั่วไป #1: ละเลยห้องสมุดที่มีอยู่

เป็นความผิดพลาดอย่างแน่นอนสำหรับ Java Developers ที่จะเพิกเฉยต่อไลบรารีจำนวนนับไม่ถ้วนที่เขียนด้วย Java ก่อนสร้างวงล้อขึ้นใหม่ พยายามค้นหาห้องสมุดที่มีอยู่ ห้องสมุดหลายแห่งได้รับการขัดเกลามาตลอดหลายปีที่ผ่านมาและใช้งานได้ฟรี สิ่งเหล่านี้อาจเป็นไลบรารีการบันทึก เช่น logback และ Log4j หรือไลบรารีที่เกี่ยวข้องกับเครือข่าย เช่น Netty หรือ Akka ห้องสมุดบางแห่ง เช่น Joda-Time ได้กลายเป็นมาตรฐานโดยพฤตินัย

ต่อไปนี้เป็นประสบการณ์ส่วนตัวจากหนึ่งในโครงการก่อนหน้าของฉัน ส่วนของโค้ดที่รับผิดชอบในการ Escape HTML นั้นเขียนขึ้นตั้งแต่ต้น มันใช้งานได้ดีมาหลายปีแล้ว แต่ในที่สุดก็พบอินพุตของผู้ใช้ซึ่งทำให้มันหมุนวนเป็นวงไม่สิ้นสุด ผู้ใช้ที่พบว่าบริการไม่ตอบสนอง พยายามลองอีกครั้งด้วยข้อมูลเดิม ในที่สุด CPU ทั้งหมดบนเซิร์ฟเวอร์ที่จัดสรรสำหรับแอปพลิเคชันนี้ถูกครอบครองโดยลูปที่ไม่มีที่สิ้นสุดนี้ หากผู้เขียนเครื่องมือ Escape HTML ที่ไร้เดียงสานี้ตัดสินใจใช้หนึ่งในไลบรารี่ที่รู้จักกันดีซึ่งมีให้สำหรับการ Escape HTML เช่น HtmlEscapers จาก Google Guava สิ่งนี้อาจไม่เกิดขึ้น อย่างน้อยที่สุด จริงสำหรับไลบรารียอดนิยมส่วนใหญ่ที่มีชุมชนอยู่เบื้องหลัง ข้อผิดพลาดจะถูกพบและแก้ไขข้อผิดพลาดก่อนหน้านี้โดยชุมชนสำหรับไลบรารีนี้

ข้อผิดพลาดทั่วไป #2: ไม่มีคีย์เวิร์ด 'break' ใน Switch-Case Block

ปัญหา Java เหล่านี้อาจน่าอายมากและบางครั้งก็ยังไม่ถูกค้นพบจนกว่าจะมีการใช้งานจริง พฤติกรรมที่ล้มเหลวในคำสั่งสวิตช์มักจะมีประโยชน์ อย่างไรก็ตาม การไม่มีคีย์เวิร์ด "break" เมื่อไม่ต้องการพฤติกรรมดังกล่าว อาจนำไปสู่ผลลัพธ์ที่เลวร้ายได้ หากคุณลืมใส่ “break” ใน “case 0” ในตัวอย่างโค้ดด้านล่าง โปรแกรมจะเขียนว่า “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"); } }

ในกรณีส่วนใหญ่ วิธีแก้ปัญหาที่สะอาดกว่าคือการใช้ polymorphism และย้ายโค้ดที่มีพฤติกรรมเฉพาะไปเป็นคลาสที่แยกจากกัน ข้อผิดพลาดของ Java เช่นนี้สามารถตรวจพบได้โดยใช้ตัววิเคราะห์รหัสคงที่ เช่น FindBugs และ PMD

ข้อผิดพลาดทั่วไป #3: การลืมทรัพยากรฟรี

ทุกครั้งที่โปรแกรมเปิดไฟล์หรือการเชื่อมต่อเครือข่าย เป็นสิ่งสำคัญสำหรับผู้เริ่มต้นใช้งาน Java ในการเพิ่มทรัพยากรเมื่อคุณใช้งานเสร็จแล้ว ควรใช้ความระมัดระวังในลักษณะเดียวกันนี้หากมีข้อยกเว้นระหว่างการดำเนินการกับทรัพยากรดังกล่าว อาจมีคนโต้แย้งว่า FileInputStream มี Finalizer ที่เรียกใช้เมธอด 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 สามารถเกิดขึ้นได้หลายวิธี แต่สาเหตุที่พบบ่อยที่สุดคือการอ้างอิงอ็อบเจ็กต์นิรันดร์ เนื่องจากตัวรวบรวมขยะไม่สามารถลบอ็อบเจ็กต์ออกจากฮีปในขณะที่ยังมีการอ้างอิงถึงพวกมัน เราสามารถสร้างการอ้างอิงดังกล่าวได้โดยการกำหนดคลาสด้วยฟิลด์สแตติกที่มีคอลเลกชั่นของอ็อบเจ็กต์บางส่วน และลืมตั้งค่าฟิลด์สแตติกนั้นเป็นโมฆะหลังจากที่ไม่ต้องการคอลเลกชั่นแล้ว ฟิลด์สแตติกถือเป็นรากของ 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(); }

ตัวอย่างนี้สร้างงานตามกำหนดการสองงาน งานแรกใช้หมายเลขสุดท้ายจาก deque ที่เรียกว่า "numbers" และพิมพ์ตัวเลขและขนาด deque ในกรณีที่ตัวเลขหารด้วย 51 ลงตัว งานที่สองใส่ตัวเลขลงใน deque งานทั้งสองมีกำหนดการในอัตราคงที่ และรันทุก 10 มิลลิวินาที หากโค้ดถูกรัน คุณจะเห็นว่าขนาดของ deque เพิ่มขึ้นอย่างถาวร ในที่สุดสิ่งนี้จะทำให้ deque เต็มไปด้วยวัตถุที่ใช้หน่วยความจำฮีปที่มีอยู่ทั้งหมด เพื่อป้องกันสิ่งนี้ในขณะที่รักษาความหมายของโปรแกรมนี้ไว้ เราสามารถใช้วิธีการอื่นในการรับตัวเลขจาก deque: “pollLast” ตรงกันข้ามกับวิธีการ "peekLast" "pollLast" จะคืนค่าองค์ประกอบและลบออกจาก deque ในขณะที่ "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 Reference โดยไม่จำเป็น

การหลีกเลี่ยงการใช้ค่า null มากเกินไปถือเป็นแนวทางปฏิบัติที่ดี ตัวอย่างเช่น เป็นการดีกว่าที่จะส่งคืนอาร์เรย์ว่างหรือคอลเลกชันจากเมธอด แทนที่จะเป็นค่า null เนื่องจากสามารถช่วยป้องกัน NullPointerException ได้

ให้พิจารณาวิธีต่อไปนี้ที่สำรวจคอลเลกชันที่ได้จากวิธีอื่นดังแสดงด้านล่าง:

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

หาก getAccountIds() คืนค่า null เมื่อบุคคลไม่มีบัญชี ค่า NullPointerException จะเพิ่มขึ้น ในการแก้ไขปัญหานี้ จำเป็นต้องมีการตรวจสอบค่าว่าง อย่างไรก็ตาม หากแทนที่จะเป็นค่า null มันจะคืนค่ารายการว่าง ดังนั้น NullPointerException จะไม่เป็นปัญหาอีกต่อไป นอกจากนี้ โค้ดยังสะอาดกว่า เนื่องจากเราไม่จำเป็นต้องตรวจสอบตัวแปร accountIds ที่เป็นค่าว่าง

ในการจัดการกับกรณีอื่นๆ เมื่อเราต้องการหลีกเลี่ยงค่าว่าง อาจใช้กลยุทธ์ที่แตกต่างกัน หนึ่งในกลยุทธ์เหล่านี้คือการใช้ตัวเลือกประเภทที่สามารถเป็นวัตถุว่างหรือห่อของค่าบางอย่าง:

 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);

ประเภททางเลือกเป็นส่วนหนึ่งของ Java ตั้งแต่เวอร์ชัน 8 แต่เป็นที่รู้จักกันดีในโลกของการเขียนโปรแกรมเชิงฟังก์ชันมาเป็นเวลานาน ก่อนหน้านี้ มีอยู่ใน Google Guava สำหรับ Java เวอร์ชันก่อนหน้า

ข้อผิดพลาดทั่วไป #7: ละเว้นข้อยกเว้น

มักเป็นการดึงดูดที่จะปล่อยข้อยกเว้นไว้โดยไม่ได้รับการจัดการ อย่างไรก็ตาม แนวทางปฏิบัติที่ดีที่สุดสำหรับนักพัฒนา Java มือใหม่และที่มีประสบการณ์คือการจัดการกับพวกเขา ข้อยกเว้นเกิดขึ้นโดยเจตนา ดังนั้นในกรณีส่วนใหญ่ เราจำเป็นต้องแก้ไขปัญหาที่ทำให้เกิดข้อยกเว้นเหล่านี้ อย่ามองข้ามเหตุการณ์เหล่านี้ หากจำเป็น คุณสามารถทำซ้ำ แสดงกล่องโต้ตอบข้อผิดพลาดให้กับผู้ใช้ หรือเพิ่มข้อความในบันทึก อย่างน้อยที่สุด ควรจะอธิบายว่าทำไมข้อยกเว้นจึงถูกปล่อยไว้โดยไม่ได้รับการจัดการ เพื่อให้นักพัฒนาคนอื่นๆ ทราบเหตุผล

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

วิธีที่ชัดเจนกว่าในการเน้นย้ำถึงความสำคัญของข้อยกเว้นคือการเข้ารหัสข้อความนี้ลงในชื่อตัวแปรของข้อยกเว้น เช่นนี้

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

ข้อผิดพลาดทั่วไป #8: ข้อยกเว้นการปรับเปลี่ยนพร้อมกัน

ข้อยกเว้นนี้เกิดขึ้นเมื่อคอลเล็กชันถูกแก้ไขในขณะที่วนซ้ำโดยใช้วิธีการอื่นนอกเหนือจากที่จัดเตรียมโดยอ็อบเจ็กต์ iterator ตัวอย่างเช่น เรามีรายชื่อหมวกและต้องการลบหมวกที่มีฝาปิดทั้งหมด:

 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

การใช้ตัววนซ้ำรายการมีความเหมาะสมเมื่อคอลเลกชันที่แก้ไขใช้อินเทอร์เฟซรายการ ตัววนซ้ำที่ใช้อินเทอร์เฟซ ListIterator ไม่เพียงแต่สนับสนุนการดำเนินการลบเท่านั้น แต่ยังเพิ่มและตั้งค่าการดำเนินการอีกด้วย ListIterator ใช้อินเทอร์เฟซ Iterator ดังนั้นตัวอย่างจะมีลักษณะเกือบเหมือนกับวิธีการลบ Iterator ข้อแตกต่างเพียงอย่างเดียวคือประเภทของ hat iterator และวิธีที่เราได้รับ iterator นั้นด้วยเมธอด “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 การเรียกวิธีการลบและเพิ่มสามารถแทนที่ด้วยการเรียกเพียงครั้งเดียวเพื่อตั้งค่า:

 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 โปรแกรมเมอร์มีความสามารถในการแปลงคอลเล็กชันเป็นสตรีมและกรองที่สตรีมตามเกณฑ์บางอย่าง นี่คือตัวอย่างวิธีที่ stream api สามารถช่วยเรากรอง hat และหลีกเลี่ยง “ConcurrentModificationException”

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

วิธีการ “Collectors.toCollection” จะสร้าง ArrayList ใหม่พร้อมหมวกที่กรองแล้ว นี่อาจเป็นปัญหาได้หากเงื่อนไขการกรองต้องเป็นไปตามเงื่อนไขจำนวนมาก ส่งผลให้ ArrayList มีขนาดใหญ่ จึงควรใช้ด้วยความระมัดระวัง ใช้ List.removeIf วิธีที่นำเสนอใน Java 8 โซลูชันอื่นที่มีอยู่ใน Java 8 และชัดเจนที่สุดคือการใช้วิธีการ "removeIf":

 hats.removeIf(IHat::hasEarFlaps);

แค่นั้นแหละ. ภายใต้ประทุน จะใช้ "Iterator.remove" เพื่อทำให้พฤติกรรมสำเร็จ

ใช้คอลเลกชันพิเศษ

หากในตอนเริ่มต้น เราตัดสินใจใช้ “CopyOnWriteArrayList” แทน “ArrayList” ก็จะไม่มีปัญหาแต่อย่างใด เนื่องจาก “CopyOnWriteArrayList” มีวิธีการแก้ไข (เช่น ตั้งค่า เพิ่ม และลบ) ที่ไม่เปลี่ยนแปลง อาร์เรย์สำรองของคอลเล็กชัน แต่ให้สร้างเวอร์ชันที่แก้ไขใหม่ดีกว่า ซึ่งทำให้สามารถทำซ้ำคอลเล็กชันเวอร์ชันดั้งเดิมและแก้ไขได้พร้อมกัน โดยไม่มีความเสี่ยงจาก "ConcurrentModificationException" ข้อเสียของคอลเลกชั่นนั้นชัดเจน - การสร้างคอลเลกชั่นใหม่พร้อมการปรับเปลี่ยนแต่ละครั้ง

มีคอลเล็กชันอื่นๆ ที่ปรับแต่งสำหรับกรณีต่างๆ เช่น “CopyOnWriteSet” และ “ConcurrentHashMap”

ข้อผิดพลาดที่เป็นไปได้อีกประการหนึ่งกับการแก้ไขคอลเล็กชันพร้อมกันคือการสร้างสตรีมจากคอลเล็กชัน และระหว่างการวนซ้ำของสตรีม ให้แก้ไขคอลเล็กชันสำรอง กฎทั่วไปสำหรับสตรีมคือการหลีกเลี่ยงการแก้ไขคอลเล็กชันพื้นฐานระหว่างการสืบค้นสตรีม ตัวอย่างต่อไปนี้จะแสดงวิธีจัดการสตรีมที่ไม่ถูกต้อง:

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

วิธีการแอบมองจะรวบรวมองค์ประกอบทั้งหมดและดำเนินการตามที่ระบุกับองค์ประกอบแต่ละรายการ ในที่นี้ การดำเนินการกำลังพยายามลบองค์ประกอบออกจากรายการพื้นฐาน ซึ่งเป็นข้อผิดพลาด เพื่อหลีกเลี่ยงปัญหานี้ ให้ลองใช้วิธีการบางอย่างที่อธิบายไว้ข้างต้น

ข้อผิดพลาดทั่วไป #9: การละเมิดสัญญา

ในบางครั้ง โค้ดที่ได้รับจากไลบรารีมาตรฐานหรือโดยผู้จำหน่ายบุคคลที่สามจะขึ้นอยู่กับกฎเกณฑ์ที่ควรปฏิบัติตามเพื่อให้สิ่งต่างๆ เป็นไปได้ ตัวอย่างเช่น อาจเป็น hashCode และเท่ากับสัญญาที่เมื่อปฏิบัติตาม ทำให้รับประกันการทำงานสำหรับชุดคอลเล็กชันจากเฟรมเวิร์กคอลเล็กชัน Java และสำหรับคลาสอื่นๆ ที่ใช้เมธอด hashCode และเท่ากับ การไม่เชื่อฟังสัญญาไม่ใช่ข้อผิดพลาดที่นำไปสู่การยกเว้นหรือทำลายการรวบรวมโค้ดเสมอ มันยุ่งยากกว่าเพราะบางครั้งมันก็เปลี่ยนพฤติกรรมของแอปพลิเคชันโดยไม่มีสัญญาณอันตราย รหัสที่ผิดพลาดอาจหลุดเข้าไปในรุ่นที่ใช้งานจริงและทำให้เกิดผลกระทบที่ไม่ต้องการจำนวนมาก ซึ่งอาจรวมถึงพฤติกรรม UI ที่ไม่ดี รายงานข้อมูลที่ไม่ถูกต้อง ประสิทธิภาพของแอปพลิเคชันที่ไม่ดี การสูญหายของข้อมูล และอื่นๆ โชคดีที่ข้อบกพร่องร้ายแรงเหล่านี้ไม่ได้เกิดขึ้นบ่อยนัก ฉันได้กล่าวถึง hashCode แล้วและเท่ากับสัญญา ใช้ในคอลเลกชันที่อาศัยการแฮชและการเปรียบเทียบวัตถุ เช่น 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); } }

อย่างที่คุณเห็น class Boat มีการแทนที่เท่ากับและวิธี hashCode อย่างไรก็ตาม มันผิดสัญญา เนื่องจาก hashCode ส่งคืนค่าสุ่มสำหรับอ็อบเจกต์เดียวกันทุกครั้งที่มีการเรียก รหัสต่อไปนี้มักจะไม่พบเรือลำที่ชื่อ "Enterprise" ใน hashset แม้ว่าเราจะเพิ่มเรือประเภทนั้นไปก่อนหน้านี้:

 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"))); }

อีกตัวอย่างหนึ่งของสัญญาเกี่ยวข้องกับวิธีการสรุปผล นี่คือข้อความอ้างอิงจากเอกสารทางการของจาวาที่อธิบายฟังก์ชันของมัน:

สัญญาทั่วไปของการสรุปผลคือการเรียกใช้หากและเมื่อเครื่องเสมือน JavaTM ได้พิจารณาแล้วว่าไม่มีวิธีการใดๆ ที่เธรดใดๆ สามารถเข้าถึงวัตถุนี้ (ที่ยังไม่ตาย) ได้อีกต่อไป ยกเว้นเป็นผลจาก การดำเนินการโดยการสรุปวัตถุหรือคลาสอื่นซึ่งพร้อมที่จะสรุป วิธีสุดท้ายอาจดำเนินการใดๆ รวมถึงการทำให้วัตถุนี้พร้อมใช้งานอีกครั้งสำหรับเธรดอื่น อย่างไรก็ตาม จุดประสงค์ปกติของการสรุปผลคือเพื่อดำเนินการล้างข้อมูลก่อนที่อ็อบเจ็กต์จะถูกละทิ้งโดยไม่สามารถเพิกถอนได้ ตัวอย่างเช่น วิธีการสรุปผลสำหรับอ็อบเจ็กต์ที่แสดงการเชื่อมต่ออินพุต/เอาต์พุตอาจทำธุรกรรม I/O อย่างชัดเจนเพื่อทำลายการเชื่อมต่อก่อนที่อ็อบเจ็กต์จะถูกละทิ้งอย่างถาวร

เราอาจตัดสินใจใช้วิธีการขั้นสุดท้ายเพื่อเพิ่มทรัพยากร เช่น ตัวจัดการไฟล์ แต่นั่นอาจเป็นความคิดที่ไม่ดี นี่เป็นเพราะไม่มีเวลารับประกันว่าจะมีการเรียกใช้การสรุปผลเมื่อใด เนื่องจากมีการเรียกใช้ระหว่างการรวบรวมขยะ และเวลาของ GC นั้นไม่สามารถกำหนดได้

ข้อผิดพลาดทั่วไป #10: การใช้ประเภท Raw แทนการใช้พารามิเตอร์แบบหนึ่ง

ประเภท Raw ตามข้อกำหนดของ Java คือประเภทที่ไม่มีการกำหนดพารามิเตอร์หรือสมาชิกที่ไม่คงที่ของคลาส R ที่ไม่ได้รับการสืบทอดจาก superclass หรือ superinterface ของ R ไม่มีทางเลือกอื่นสำหรับประเภท raw จนกว่าจะมีการแนะนำประเภททั่วไปใน Java . รองรับการเขียนโปรแกรมทั่วไปตั้งแต่เวอร์ชัน 1.5 และ generics มีการปรับปรุงที่สำคัญอย่างไม่ต้องสงสัย อย่างไรก็ตาม เนื่องด้วยเหตุผลความเข้ากันได้แบบย้อนหลัง หลุมพรางจึงเหลืออยู่ซึ่งอาจทำให้ระบบประเภทเสียหายได้ ลองดูตัวอย่างต่อไปนี้:

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

ที่นี่เรามีรายการตัวเลขที่กำหนดเป็น ArrayList ดิบ เนื่องจากไม่ได้ระบุประเภทของประเภทด้วยพารามิเตอร์ type เราจึงสามารถเพิ่มวัตถุใดๆ ลงไปได้ แต่ในบรรทัดสุดท้าย เราใส่องค์ประกอบเป็น 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<>();

โค้ดคงที่ไม่สามารถคอมไพล์ได้ เนื่องจากเรากำลังพยายามเพิ่มสตริงลงในคอลเล็กชันที่คาดว่าจะเก็บเฉพาะจำนวนเต็มเท่านั้น คอมไพเลอร์จะแสดงข้อผิดพลาดและชี้ไปที่บรรทัดที่เราพยายามเพิ่มสตริง "ยี่สิบ" ลงในรายการ คุณควรกำหนดประเภททั่วไปให้เป็นพารามิเตอร์เสมอ ด้วยวิธีนี้ คอมไพเลอร์จะทำการตรวจสอบประเภทที่เป็นไปได้ทั้งหมด และโอกาสในการเกิดข้อยกเว้นรันไทม์ที่เกิดจากความไม่สอดคล้องของระบบประเภทจะลดลง

บทสรุป

Java ในฐานะแพลตฟอร์มช่วยลดความซับซ้อนหลายอย่างในการพัฒนาซอฟต์แวร์ โดยอาศัยทั้ง JVM ที่ซับซ้อนและภาษา อย่างไรก็ตาม คุณลักษณะต่างๆ เช่น การลบการจัดการหน่วยความจำด้วยตนเองหรือเครื่องมือ OOP ที่เหมาะสม ไม่ได้ขจัดปัญหาและปัญหาทั้งหมดที่นักพัฒนา Java ทั่วไปต้องเผชิญ เช่นเคย ความรู้ แนวปฏิบัติ และบทช่วยสอนเกี่ยวกับ Java แบบนี้เป็นวิธีที่ดีที่สุดในการหลีกเลี่ยงและแก้ไขข้อผิดพลาดของแอปพลิเคชัน ดังนั้นให้รู้จักไลบรารีของคุณ อ่าน java อ่านเอกสาร JVM และเขียนโปรแกรม อย่าลืมเกี่ยวกับตัววิเคราะห์โค้ดแบบคงที่ด้วย เนื่องจากอาจชี้ไปที่จุดบกพร่องจริงและเน้นจุดบกพร่องที่อาจเกิดขึ้นได้

ที่เกี่ยวข้อง: การสอนคลาส Java ขั้นสูง: คู่มือการโหลดคลาสซ้ำ