แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ Java — แบบแผนยุคเธรดเสมือน
· Go Komura · มัลติเธรด, Java, แอปธุรกิจ, การตรวจสอบบั๊ก, การออกแบบ
«อยากขนานงานแบตช์ธุรกิจใน Java» «แคชร่วมในเว็บแอป Spring ของเรานาน ๆ ครั้งเสีย» «ได้รับมรดกแอป Swing เก่าที่เต็มไปด้วย new Thread» — Java มีมัลติเธรดฝังในภาษาตั้งแต่ JDK 1.0 เติบโตเป็นกล่องเครื่องมือ java.util.concurrent และด้วย เธรดเสมือน ใน JDK 21 ได้เขียนความเข้าใจเดิมของการเขียนโปรแกรมพร้อมกันอีกครั้ง ชุดเครื่องมืออุดมขนาดนี้ การเลือกเครื่องมือจึงกลายเป็นคุณภาพของการออกแบบเอง
บทความนี้คือ ฉบับ Java ของชุดมัลติเธรดในงานจริง มุ่งนักพัฒนาที่เขียนระบบธุรกิจ งานแบตช์ และแอปเซิร์ฟเวอร์ใน Java มันแมปหลักการออกแบบมัลติเธรด — อย่าสร้างเธรดตรง ๆ ลดสถานะร่วมที่เปลี่ยนได้ มีวินัยล็อก ออกแบบวิธีหยุดก่อนสิ่งอื่น — ลงเครื่องมือของ Java (เน้นรุ่น LTS JDK 21 เป็นต้นไป) และรวบการเลือกยุคเธรดเสมือนกับกับดักเฉพาะ Java จากแหล่งปฐมภูมิ ณ สิงหาคม 2026 เขียนให้อ่านเดี่ยวได้ หลักเดียวกันที่นำไปใช้กับภาษาอื่นอยู่ในบทความคู่ «ฉบับ .NET» «ฉบับ C++» และ «ฉบับ C»
1. สรุปก่อนเลย
- อย่าเขียน
new Threadในโค้ดธุรกิจ — กฎเดียวกันใช้ใน Java ส่งงานให้ExecutorServiceและให้ไลบรารีจัดการวงจรชีวิตเธรด1 - ส่งงานที่รอ I/O เป็นหลักไปเธรดเสมือน เธรดเสมือนที่เป็นทางการใน JDK 21 ใช้หนึ่งต่อหนึ่งงานและ ห้ามพูลเด็ดขาด จำกัดการทำงานพร้อมกันด้วย
Semaphoreไม่ใช่ขนาดพูล23 - เธรดเสมือนเป็นเครื่องมือ throughput ไม่ใช่เครื่องมือเร่งการคำนวณ การขนานแบบ CPU-bound ยังเป็นงานของเธรดแพลตฟอร์มขนาดราวจำนวนคอร์ — พูลคงที่หรือ parallel stream เช่นเดิม3
- ล็อกบนอ็อบเจกต์ล็อก
private finalหรือReentrantLockเฉพาะsynchronized(this)และการล็อกอ็อบเจกต์ที่เปิดสาธารณะอาจชนกับโค้ดภายนอก ถ้าต้องการได้ล็อกแบบมีเวลา (tryLock) ใช้ReentrantLock - ใน JDK 21-23 การบล็อกในบล็อก
synchronizedปักเธรดเสมือน JDK 24 (JEP 491) แก้แล้ว แยกคำเตือนเก่าจากความเป็นจริงปัจจุบัน34 volatileรับประกันการมองเห็นและการจัดลำดับ ไม่ใช่ความเป็นอะตอม ใช้AtomicInteger/LongAdderสำหรับตัวนับ และล็อกสำหรับสถานะประกอบ5- การหยุดแบบร่วมมือผ่าน interruption คือวิธีถูกต้องเพียงอย่างเดียวในการหยุดเธรด
Thread.stop/suspend/resumeโยนUnsupportedOperationExceptionแล้ว อย่ากลืนInterruptedException— คืนสถานะหรือโยนขึ้นไป6 - หยุด
ExecutorServiceด้วยแพทเทิร์นสองขั้น: shutdown → awaitTermination → shutdownNowshutdownNowเป็น best-effort (อิมพลีเมนต์มาตรฐานทำงานผ่าน interruption) จึงสมมติว่างานของคุณตอบ interruption1 - UI ของ Swing เป็นของ EDT (Event Dispatch Thread) โดยเฉพาะ ขออัปเดตจากเธรดอื่นผ่าน
SwingUtilities.invokeLater7
2. ทำไมมัลติเธรดจึงยาก — เรซคอนดิชัน เดดล็อก และโมเดลหน่วยความจำ
ต้มปัญหาที่มัลติเธรดนำเข้า ไม่ว่าภาษาใด เหลือสองชนิด
เรซคอนดิชัน คือบั๊กที่ผลขึ้นกับลำดับที่หลายเธรดถึงชิ้นโค้ดหนึ่ง ตัวอย่างคลาสสิกคือตัวนับร่วม: นิพจน์เดียว count++ จริง ๆ แตกเป็นสามขั้น — อ่าน บวก เขียนกลับ ถ้าสองเธรดเข้าสามขั้นนี้พร้อมกัน การเพิ่มของเธรดหนึ่งถูกเขียนทับและหายเมื่ออีกเธรดเขียน ผลเปลี่ยนทุกครั้งที่รัน และไม่มีทางทำนายว่าจะได้ผลใด
sequenceDiagram
participant A as เธรด A
participant M as ตัวแปรร่วม count
participant B as เธรด B
Note over M: count = 10
A->>M: อ่าน (10)
B->>M: อ่าน (10)
A->>A: บวกในเครื่อง (11)
B->>B: บวกในเครื่อง (11)
A->>M: เขียนกลับ (11)
B->>M: เขียนกลับ (11)
Note over M: เกิดสองการเพิ่ม,<br/>แต่ count = 11 — การเพิ่มของเธรด A หาย
ภาพ 1: เรซคอนดิชันคลาสสิกที่การเพิ่มบนตัวนับร่วมหาย ถ้าอีกเธรดแทรกระหว่างสามขั้นของ count++ การเขียนกลับครั้งหลังทับอีกครั้ง
เดดล็อก คือสถานะที่สองเธรดต่างรอ Lock ที่อีกฝ่ายถืออยู่ จึงไม่มีใครเดินต่อได้ เธรด A ถือล็อก 1 และรอ Lock 2 เธรด B ถือล็อก 2 และรอ Lock 1 — แค่นั้นพอให้ทั้งคู่หยุดตลอดกาล
flowchart LR
A["เธรด A<br/>ถือล็อก 1"] -->|"รอการปล่อยล็อก 2"| B["เธรด B<br/>ถือล็อก 2"]
B -->|"รอการปล่อยล็อก 1"| A
ภาพ 2: การรอเป็นวงของเดดล็อก วินาทีที่ลูกศรรอสร้างลูป ทุกเธรดในลูปนั้นหยุดตลอดกาล
ทั้งคู่ขึ้นกับจังหวะ การสอดแทรกที่โผล่ครั้งในหลายหมื่นรอบบนเครื่องพัฒนาอาจเกิดทุกวันบนเซิร์ฟเวอร์ผลิตที่มีจำนวนคอร์และโหลดต่างกัน «พอติดดีบักเกอร์ก็ไม่เกิดซ้ำ» และ «หายเมื่อเพิ่มล็อก» ทั้งคู่เพราะการสังเกตเปลี่ยนจังหวะ — พฤติกรรมคลาสสิกของบั๊กเรซ นั่นคือเหตุที่ทุกหลักในบทความนี้ชี้ไปทาง ลดจุดที่ต้องซิงก์ ก่อนกังวลเรื่องซิงก์ให้ถูก
2.1. ข้อสมมติเฉพาะ Java — โมเดลหน่วยความจำกับ happens-before
ทับนั้น สิ่งเฉพาะ Java คือวิธีที่ข้อมูลร่วมถูกมองถูกนิยามด้วย ความสัมพันธ์ happens-before ใน Java Memory Model (JMM)
การอ่านเขียนตัวแปรร่วมโดยไม่ซิงก์ไม่กลายเป็น «พฤติกรรมไม่กำหนด» แบบ C++ แต่สามารถเกิดได้อย่างชอบธรรมที่ ค่ายังคงถูกมองเห็น หรือการเขียนปรากฏนอกลำดับ ข้อผิดพลาดความสอดคล้องของหน่วยความจำที่ «ลูปกำลังดูธง boolean แต่ค่าที่อีกเธรดเปลี่ยนไม่เคยมองเห็น» คือพฤติกรรมที่ JMM อนุญาต ไม่ใช่บั๊ก JVM5 เครื่องมือที่กันเรื่องนี้คือกลไกที่สร้าง happens-before — synchronized volatile และคลาสใน java.util.concurrent ใช้คอลเลกชันพร้อมกันให้ถูกแล้วไลบรารีรับประกัน «happens-before ระหว่างการอัปเดตกับการดึงถัดไป» ให้คุณ8
กล่าวคือแนวทางปฏิบัติของ Java สรุปได้ว่า อย่าฉลาดกับตัวแปรร่วมดิบ ใช้เครื่องมือใน java.util.concurrent สำหรับการแบ่งปัน และให้ไลบรารีสร้าง happens-before
3. วิธีสร้างเธรด — ExecutorService และเธรดเสมือน
3.1. แยกงานออกจากวิธีรัน
ExecutorService คือสิ่งที่แบกหลัก «อย่าสร้างเธรดเอง» ใน Java มันแยกงาน (Runnable / Callable) จากวิธีดำเนินการ — กี่เธรด คิวไหน — และปล่อยการสร้าง ใช้ซ้ำ และทิ้งเธรดให้ไลบรารี1
ตั้งแต่ JDK 21 การเลือกวิธีรันกลายเป็นทางเลือกทวิภาคเรียบง่าย23
flowchart TB
S["งานที่อยากรันพร้อมกัน"] --> Q1{"อะไรขับงาน?"}
Q1 -->|"รอ I/O เป็นหลัก<br/>เรียก HTTP, DB, ไฟล์"| VT["เธรดเสมือน<br/>Executors.newVirtualThreadPerTaskExecutor<br/>หนึ่งต่องาน ห้ามพูล"]
Q1 -->|"คำนวณแบบ CPU-bound"| PT["พูลคงที่ของเธรดแพลตฟอร์ม<br/>Executors.newFixedThreadPool - ราวจำนวนคอร์<br/>หรือ parallel stream"]
VT --> LIMIT["จำกัดการทำงานพร้อมกันไปบริการภายนอก<br/>ด้วย Semaphore ไม่ใช่ขนาดพูล"]
ภาพ 3: การเลือกวิธีทำงานตั้งแต่ JDK 21 ลากเส้นก่อน — «เปลี่ยนวิธีรอ I/O ขนานสำหรับ CPU» — แล้วส่งงาน I/O-bound ให้เธรดเสมือน งาน CPU-bound ให้พูลแบบเดิม
มีข้อควรระวังหนึ่งฝั่ง CPU-bound Executors.newFixedThreadPool จำกัดจำนวนเธรด แต่ คิวไม่มีขอบ ในบริการอายุยาวที่การส่งงานแซงการประมวลผลตลอด จำกัดแค่เธรดที่จำนวนคอร์ — งานกองในคิวและข้อมูลของมันยังกินหน่วยความจำ ในชุดแบบนั้น ใช้ ThreadPoolExecutor โดยตรงเพื่อตั้ง คิวมีขอบบวกนโยบายปฏิเสธ หรือวางการควบคุมรับเข้าอย่าง Semaphore ฝั่งผู้ส่งเพื่อให้กดกลับได้ (หลักเดียวกับการคุยเรื่องคิวในตอน 4)
3.2. อย่าใช้เธรดเสมือนผิด
เธรดเสมือนคือเธรดเบาที่หลุดจากเธรด OS: ระหว่าง การดำเนินการบล็อกใน JDK (I/O ล็อก นอน และทำนองนั้นในไลบรารีมาตรฐาน) พวกมันปล่อยเธรด OS จึงทำให้ JVM เดียวรันได้เป็นล้าน ถึงอย่างนั้น พวกมันไม่ปล่อยสำหรับ ทุก ชนิดบล็อก ถ้าเธรดเสมือนบล็อกขณะรันโค้ดเนทีฟ (JNI) หรือฟังก์ชันต่างประเทศ มันยังปักติดเธรดพาหะ สิ่งที่ JDK 24 (JEP 491 ด้านล่าง) แก้คือการปักจาก synchronized การปักที่ขอบเนทีฟยังอยู่ ดังนั้นการบรรทุกเธรดเสมือนจำนวนมากด้วยการดำเนินการที่บล็อกนานผ่านไดรเวอร์ JNI หรือ API อุปกรณ์จะหมดเธรดพาหะ แต่ตามที่คู่มือทางการย้ำ พวกมัน ไม่ใช่ «เธรดที่เร็วกว่า» ความเร็วรันโค้ดไม่เปลี่ยน — สิ่งที่ให้คือมาตราส่วน (throughput)3
มีสามวินัยในการใช้3
- อย่าพูล เธรดเสมือนถูกและใช้แล้วทิ้ง «จำนวนงาน = จำนวนเธรดเสมือน» คือสถานะที่ถูก การใส่เธรดเสมือนใน
newFixedThreadPoolคือความผิด — ใช้รูปtry (var executor = Executors.newVirtualThreadPerTaskExecutor()) - จำกัดการทำงานพร้อมกันด้วย
Semaphoreแสดงข้อจำกัดอย่าง «เชื่อมต่อพร้อมกันไป API ภายนอกได้ไม่เกิน 10» ด้วยเซมาฟอร์ ไม่ใช่ขนาดพูล - อย่าใช้กับงาน CPU-bound เธรดแพลตฟอร์มขนาดราวจำนวนคอร์ยังเป็นเครื่องมือที่ถูกสำหรับการขนานการคำนวณ เช่นเดิม
สังเกตว่าสิ่งที่รันในเธรดเสมือนคือโค้ดซิงโครนัสธรรมดา แทนที่จะเขียนโค้ดใหม่แบบ async/await ของ .NET ปรัชญาออกแบบของเธรดเสมือนคือให้คุณรันโค้ดตรง «หนึ่งเธรดต่อหนึ่งคำขอ» ไม่เปลี่ยน ในมาตราส่วนมหาศาล2
3.3. ความเข้าใจผิดที่พบบ่อย — «ไม่ต้องพูล» ใช้เฉพาะเธรดเสมือน
อย่าอ่านวินัย «อย่าพูล» เป็นว่า «Java ไม่มีพูลเธรด (หรือมันไร้ประสิทธิภาพ)» ความจริงตรงข้าม: พูลของ Java เป็นส่วนสุกของไลบรารีมาตรฐานตั้งแต่ JDK 5 (2004) พูลอเนกประสงค์ที่ตั้งละเอียดได้ ThreadPoolExecutor (สร้างผ่านโรงงานต่าง ๆ ของ Executors) ForkJoinPool แบบขโมยงาน (อินสแตนซ์ร่วม commonPool() คือเป้าหมายรันปริยายของ parallel stream และ CompletableFuture) และ ScheduledThreadPoolExecutor สำหรับรันเป็นระยะ — เหล่านี้ยังเป็นตัวหลักสำหรับงาน CPU-bound
พูลโดยพื้นฐานคือการเพิ่มประสิทธิภาพที่ยืนบน «การสร้างและถือเธรด OS แพง จึงใช้ซ้ำ» เธรดเสมือนลบข้อสมมตินี้ด้วยการทำให้ต้นทุนสร้างใกล้ศูนย์ จึงไม่มีเหตุผลจะใช้ซ้ำอีก — ความเข้าใจที่แม่นไม่ใช่ว่าพูลไร้ประสิทธิภาพ แต่เธรดเบาพอจนการเพิ่มประสิทธิภาพด้วยพูลไม่จำเป็น และใต้เธรดเสมือน ตัวจัดตารางของ JDK รันชุดเธรดพาหะ (เธรด OS) จำนวนราวคอร์ เป็น ForkJoinPool แบบขโมยงาน2 กล่าวคือรูป «จัดการงานพร้อมกันมหาศาลด้วยพูลเธรด OS เล็ก» ยังอยู่ เพียงการจัดการพูลนั้นย้ายจากมือนักพัฒนาไป JVM พูดได้ว่า Java ถึงจุดหมายเดียวกับ async/await ของ .NET ที่คืนเธรดสู่พูล ณ จุด await โดยไม่เปลี่ยนรูปโค้ด
4. ลดสถานะร่วมที่เปลี่ยนได้ — แบ่ง ทำให้คงที่ คอลเลกชันพร้อมกัน และคิว
การแย่งเกิดเมื่อมีทั้ง «หลายเธรด» และ «ข้อมูลร่วมที่เปลี่ยนได้» จำนวนเธรดถูกกำหนดด้วยความต้องการ ดังนั้นสิ่งที่ออกแบบตัดได้คือส่วนร่วม มีสามตระกูลเทคนิค — แบ่ง ทำให้คงที่ และส่งข้อมูล — และนี่คือวิธีเขียนใน Java
แบ่ง สำหรับการรวมแบบขนาน แทนที่แต่ละเธรดเขียนตัวแปรผลรวมร่วม ให้แต่ละเธรดสร้างผลบางส่วนแล้วรวมท้าย reduce / collect ของ parallel stream ให้รูปนี้เป็นกรอบ และ LongAdder ที่กล่าวด้านล่างก็เป็นการอิมพลีเมนต์กลยุทธ์แบ่ง — แยกเซลล์ภายในเพื่อกระจายการแย่ง และรวมเมื่ออ่าน การลดความถี่ที่เขียนสถานะร่วมมาก่อนการเขียนการซิงก์ให้ถูก
ทำให้คงที่ สร้างข้อมูลด้วย record และคอลเลกชันคงที่ (List.copyOf / Map.copyOf) ที่ไม่ถูกเขียนใหม่หลังสร้าง แล้วแบ่งปันได้โดยไม่ซิงก์ สำหรับคอนฟิกและข้อมูลหลัก แพทเทิร์นมาตรฐานคือ: เมื่อต้องแทน สร้างอ็อบเจกต์ใหม่แล้วสลับเรฟเฟอเรนซ์ volatile อย่างไรก็ตาม «ดูเหมือนอ่านอย่างเดียว» กับ «คงที่» คนละเรื่อง ตัวเข้าถึงของ record คืน เรฟเฟอเรนซ์ดิบ ของส่วนประกอบ และการคัดลอกของ List.copyOf ก็ตื้น (ไม่ทำสำเนาวัตถุสมาชิก) ดังนั้น ถ้าสมาชิกเปลี่ยนได้ ผู้ถือนามแฝงเขียนเนื้อในได้ และการแย่งยังอยู่ ปลอดภัยที่จะแบ่งปันโดยไม่ซิงก์เมื่อกราฟวัตถุทั้งก้อน — รวมสมาชิก — คงที่ ถ้ามีสมาชิกที่เปลี่ยนได้ ส่งสำเนาลึกหรือดันสมาชิกไปทาง record / ชนิดคงที่ด้วย
ใช้การดำเนินการประกอบบนคอลเลกชันพร้อมกัน สำหรับแพทเทิร์น «สร้างถ้าไม่มี แล้วแทรก» ของ ConcurrentHashMap ใช้ computeIfAbsent เมธอดนี้ รันทั้งการเรียกแบบอะตอม และถ้าคีย์ไม่มี ฟังก์ชันแมปถูกเรียกพอดีครั้งเดียวในครั้งเรียกนั้น8 การรับประกันต่างจาก ConcurrentDictionary.GetOrAdd ของ .NET (โรงงานอาจรันมากกว่าครั้งใต้การแย่ง) — จุดที่คนย้ายระหว่างสองภาษาง่ายจะสับสน แต่ไม่ใช่ «พอดีครั้งเดียวตลอดชีวิตคีย์» ถ้าฟังก์ชันคืน null หรือโยน ไม่มีการลงทะเบียนแมป และฟังก์ชันรันอีกในการเรียกถัดไป (เช่นกันถ้าเอนทรีถูกลบหลังลงทะเบียน) ถ้าการเริ่มต้นทนผลข้างเคียงซ้ำไม่ได้ ออกแบบให้ฟังก์ชันสำเร็จและคืนค่าไม่เป็น null ราคาของการเป็นอะตอมคือการอัปเดตบางส่วนจากเธรดอื่นถูกบล็อกขณะคำนวณ ดังนั้น เก็บฟังก์ชันแมปให้สั้น และอย่าอัปเดตแมปนี้จากในฟังก์ชัน (การอัปเดตแบบเรียกซ้ำที่ตรวจพบอาจโยน IllegalStateException)8
// แพทเทิร์นมาตรฐานของตัวนับความถี่: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
ส่งข้อมูลผ่านคิว ส่งการไหลของข้อมูลระหว่างเธรดผ่าน BlockingQueue ด้วย ArrayBlockingQueue ที่ให้ความจุ put บล็อกเมื่อเต็ม ให้แรงกดกลับตามธรรมชาติ — รูปเดียวกับแชนเนลมีขอบในฉบับ .NET แม้ยุคเธรดเสมือน การออกแบบลากเส้นชัดระหว่างผู้ผลิตกับผู้บริโภคนี้ยังได้ผล
5. วินัยล็อก — synchronized และ ReentrantLock
5.1. ล็อกบนอะไร และอย่าทำอะไรขณะถือล็อก
คิดหน่วยล็อกไม่ใช่ «ช่วงโค้ด» แต่เป็น «ข้อมูล» แมปอ็อบเจกต์ล็อกหนึ่งต่อชุดข้อมูลที่เปลี่ยนได้ที่ต้องการปกป้อง และเอาล็อกนั้นที่ ทุก จุดที่แตะข้อมูลนั้น — ความจริงของบั๊กเรซ มักคือแมปนี้พังที่ไหนสักแห่ง หลีกเลี่ยง synchronized(this) และ synchronized(SomeClass.class) เพราะโค้ดภายนอกล็อกอ็อบเจกต์เดียวกันได้ แทนนั้น จับคู่ข้อมูลที่ต้องการปกป้องหนึ่งต่อหนึ่งกับ private final Object lock = new Object(); ที่ไม่ถูกเปิดออกนอก
อีกสองวินัยทับนั้น ประการแรก อย่าทำอะไรช้า หรืออะไรที่แตะโลกภายนอก ขณะถือล็อก I/O เรียกลิสเนอร์ หรือรันโค้ดที่ไม่รู้จักขณะยังถือล็อกทั้งยืดเวลาถือและเสี่ยงให้ผู้ถูกเรียกลองเอาล็อกอื่น สร้างการรอเป็นวงจากภาพ 2 ประการที่สอง ตรึงลำดับการได้ล็อกหลายตัว ที่เอาล็อกสองตัวขึ้นไป ตั้งกฎว่าทุกเธรดเอาในลำดับเดียวกัน และจุดที่รับประกันลำดับไม่ได้ เตรียมทาง «ยอมแล้วลองใหม่ถ้าไม่ได้» ด้วย tryLock(timeout) ที่กล่าวด้านล่าง
synchronized พอสำหรับ «การกีดกันสั้นและเรียบง่าย» ไป ReentrantLock เมื่อต้องการสิ่งต่อไปนี้
- การได้ล็อกแบบมีเวลา ผ่าน
tryLock(timeout)(เปลี่ยนแฮงถาวรเป็นความล้มเหลวที่ล็อกและจัดการได้) - นโยบายความเป็นธรรม หลาย
Conditionหรือเมื่ออยากแยกการได้และการปล่อยล็อกไปคนละเมธอด
เมื่อใช้ ReentrantLock อย่าทำลายแพทเทิร์น try ทันทีหลัง lock() และ unlock() ในบล็อก finally (Java ไม่มีสิ่งเทียบเท่า RAII ของ C++ ดังนั้นแพทเทิร์นนี้คือวินัยทั้งก้อน)
5.2. เธรดเสมือนกับการปัก — สิ่งที่เปลี่ยนใน JDK 24
เมื่อเธรดเสมือนถูกนำเข้าครั้งแรก (JDK 21-23) มีข้อจำกัดที่ การบล็อกในบล็อก synchronized ปักเธรดเสมือนติดเธรด OS (ปล่อยเธรด OS ไม่ได้ สูญประโยชน์มาตราส่วน) และแนะนำให้แทนจุดบล็อกบ่อยหรือนานด้วย ReentrantLock3 ข้อจำกัดนั้น ถูกแก้เมื่อ JEP 491 ของ JDK 24 เขียนการอิมพลีเมนต์มอนิเตอร์ใหม่ และ synchronized ไม่ปักเธรดเสมือนอีก4 ถ้าอยู่ JDK 24 หรือใหม่กว่า การแทน synchronized แบบกลไกเพื่อกันปักไม่จำเป็นแล้ว คุ้มที่จะตรวจว่าแนวทางเก่าขององค์กรยังติดคำเตือนยุค JDK 21 อยู่หรือไม่
5.3. อะตอมกับ volatile อยู่ตรงไหน
การอัปเดตอะตอมของตัวแปรเดียวรับผิดชอบโดย AtomicInteger / AtomicLong / AtomicReference (หรือ สำหรับสถิติที่แค่เพิ่มความถี่สูง LongAdder ที่ทนการแย่ง) volatile รับประกันการมองเห็นและการจัดลำดับ (happens-before) ไม่ใช่ความเป็นอะตอมของการดำเนินการประกอบ5 ข้อสรุปเดียวกับฉบับ .NET และ C++ ใช้ใน Java ด้วย: ใช้อะตอมสำหรับธงและค่าเดี่ยว ล็อกสำหรับสถานะประกอบ และอย่าพยายามให้ volatile ทำคนเดียว
6. ออกแบบวิธีหยุด — interruption เป็นภาษาที่ใช้ร่วม
6.1. มารยาทของ interrupt
การหยุดและการยกเลิกใน Java รวมศูนย์ที่ interruption t.interrupt() ตั้งสถานะ interrupt ของเธรดเป้าหมาย และถ้าเป้าหมายบล็อกใน sleep / wait / join หรือทำนองนั้น จะโยน InterruptedException เพื่อปลุกทันที (แล้วสถานะ interrupt ถูกล้าง)6 Thread.stop / suspend / resume กลไกบังคับในอดีต ไม่ปลอดภัยโดยพื้นฐาน ดังนั้นการเรียกตอนนี้ได้ UnsupportedOperationException6
flowchart TB
OWNER["ผู้เรียกหยุดมัน - เรียก t.interrupt"] --> ST["สถานะ interrupt ถูกตั้ง"]
ST --> A["เธรดที่คำนวณ:<br/>โพล Thread.interrupted ในลูป"]
ST --> B["บล็อกใน sleep / wait / join:<br/>InterruptedException ทำงานและปลุกทันที<br/>สถานะถูกล้าง"]
A --> E["เก็บกวาดและจบเอง"]
B --> C{"catch ทำอะไร?"}
C -->|"จบเองได้"| E
C -->|"จบไม่ได้ - เช่นในไลบรารี"| R["Thread.currentThread.interrupt<br/>คืนสถานะและทิ้งสัญญาณ"]
R --> E
ภาพ 4: การหยุดแบบร่วมมือผ่าน interruption การกลืน InterruptedException ทำให้สัญญาณหยุดหาย — พอจับแล้วเลือก «จบ» หรือ «คืน»
มีวินัยเดียวที่ต้องจำในงานจริง: อย่าเขียนโค้ดที่จับ InterruptedException แล้วไม่ทำอะไร ถ้าจบในความรับผิดชอบของตัวเองได้ จบที่นั่น ถ้าไม่ได้ คืนสถานะด้วย Thread.currentThread().interrupt() แล้วส่งสัญญาณต่อไปยังผู้เรียก (ดู FAQ)
6.2. การปิดสองขั้นของ ExecutorService
API ปิดของ ExecutorService นั่งบนโมเดล interruption shutdown() หยุดรับงานใหม่และให้งานที่ส่งแล้วรันจนจบ shutdownNow() พยายามหยุดงานที่กำลังรัน ในฐานะสเปกอินเทอร์เฟซนี่คือ best-effort และถูกบันทึกชัดว่าอิมพลีเมนต์มาตรฐาน (เช่น ThreadPoolExecutor) มักยกเลิกผ่าน Thread.interrupt() — แปลว่า งานที่ไม่ตอบ interruption จะไม่หยุดแม้ด้วย shutdownNow และถ้าใช้อิมพลีเมนต์ Executor กำหนดเอง ต้องดูเอกสารว่ามันยกเลิกอย่างไร (ส่ง interrupt เลยไหม)1 แพทเทิร์นหยุดมาตรฐานที่เอกสารทางการแสดงคือแพทเทิร์นสองขั้นต่อไปนี้1
/** จริงเมื่อปิดเสร็จ อย่าไปปล่อยทรัพยากรร่วมขณะนี้เป็นเท็จ */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // ขั้น 1: หยุดรับงานใหม่และรอให้จบ
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// ขั้น 2: ขอยกเลิก งานที่ถูกดึงลงก่อนรัน
// ถูกคืน จึงทำเครื่องหมาย Future ของมันว่ายกเลิกเพื่อปลุกผู้เรียกที่บล็อกที่ get()
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // ปิดไม่เสร็จ รายงานในรูปที่แยกจากความสำเร็จได้
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // คืนสถานะ interrupt ของเธรดนี้ด้วย
return false; // เส้นทางนี้ก็อาจทิ้งการปิดไม่สมบูรณ์
}
}
flowchart TB
S["shutdown<br/>หยุดรับงานใหม่"] --> W1{"awaitTermination<br/>รอให้จบ"}
W1 -->|"จบในกำหนด"| DONE["ปิดเสร็จ"]
W1 -->|"หมดเวลา"| NOW["shutdownNow<br/>ส่ง interrupt ไปงานที่รัน<br/>จะตอบหรือไม่ขึ้นกับงาน"]
NOW --> W2{"awaitTermination<br/>รออีกครั้ง"}
W2 -->|"เสร็จ"| DONE
W2 -->|"ยังไม่จบ"| LOG["บันทึกเป็นความผิดปกติ<br/>สงสัยงานที่เมิน interrupt"]
ภาพ 5: การปิดสองขั้นของ ExecutorService การออกแบบเป็นขั้น «รออย่างสุภาพ → ขอผ่าน interruption → ถ้ายังไม่จบ สังเกตเป็นความผิดปกติ»
มีข้อจำกัดหนึ่งในการยกเลิก Runnable ที่ shutdownNow() คืน สิ่งที่กลับมาคืออ็อบเจกต์ที่นั่งในคิวการรัน — สำหรับ submit ธรรมดา นั่นคือ FutureTask เดียวกับที่ส่งให้ผู้เรียก แต่สำหรับงานที่ส่งผ่านห่ออย่าง ExecutorCompletionService มันคือ ห่อในคิว อ็อบเจกต์ต่างจาก Future ของผู้เรียก ในโครงนั้น การยกเลิกด้านบนจะไม่ทำ Future ของผู้เรียกให้เสร็จ ดังนั้นออกแบบให้เก็บรายการ Future ของตัวเองตอนส่งและยกเลิกพวกนั้นตอนปิด (หรือคืนงานที่ถูกดึงลงให้เจ้าของ)
close() (AutoCloseable) มีตั้งแต่ JDK 19 ห่อ «เรียก shutdown แล้วรอให้จบ» เป็นรูปที่เขียนด้วย try-with-resources และ try (var executor = ...) คู่กับ newVirtualThreadPerTaskExecutor ของเธรดเสมือนคือรูปพื้นฐานสมัยใหม่1 แต่ close() ไม่ใช่ตัวแทนแพทเทิร์นสองขั้นด้านบน เพราะมัน รอให้จบโดยไม่มีเวลา ถ้าแม้แต่งานเดียวไม่ตอบ interruption หรือไม่เคยจบ เธรดที่พยายามปิดมันบล็อกตลอดกาล เป็นเครื่องมือเหมาะกับขอบเขตที่งานมีจำกัดและรับประกันว่ารันจนจบ (ส่งที่นั่น รอที่นั่น) สำหรับจุดอย่างเส้นทางปิดแอป ที่อยากให้ จบเสมอในเวลาจำกัด ใช้แพทเทิร์นสองขั้นแบบมีเวลาแทน การยกเลิกงานเดี่ยวก็ผ่าน interruption เช่นกัน ด้วย Future.cancel(true)
7. เธรด UI — EDT ของ Swing
แอปเดสก์ท็อป ไม่ว่าภาษาหรือเฟรมเวิร์ก ตามกฎว่า UI เป็นของเธรดที่จัดการมันโดยเฉพาะ ใน Swing เธรดเฉพาะนั้นคือ Event Dispatch Thread (EDT): เมธอดของคอมโพเนนต์ Swing ตามกฎไม่ปลอดภัยต่อเธรด และการแตะจากหลายเธรดชวนการแทรกแซงเธรดและข้อผิดพลาดความสอดคล้องของหน่วยความจำ ขออัปเดตหน้าจอจากเธรดอื่นผ่าน SwingUtilities.invokeLater ไป EDT และในทางกลับกัน เพราะรันงานนานบน EDT ทำให้ UI ค้าง จ่ายงานหนักออกเธรดคนงานผ่าน SwingWorker หรือคล้ายกัน7 JavaFX ตามรูปเดียวกัน: อัปเดต UI ถูกขอบนเธรดแอปผ่าน Platform.runLater
8. การตรวจสอบและการดีบัก — ดัมป์เธรดเป็นอาวุธ
อย่าคาดว่าการทดสอบจะหาบั๊กเรซ การทดสอบธรรมดานับรอบที่การแย่งบังเอิญไม่เกิดเป็นความสำเร็จ คิดแนวรับสามชั้น
แนวแรกคือการออกแบบ ในการรีวิว ตรวจด้วยตาราง: ข้อมูลที่เปลี่ยนได้อะไรถูกแบ่งปัน ล็อกใดปกป้องแต่ละรายการ (แมปจากตอน 5.1) ลำดับการได้ล็อกเป็นเอกลักษณ์ไหม มีบล็อก catch ใดกลืน InterruptedException ไหม และเส้นทางหยุด (shutdown/interruption) ถึงทุกงานไหม
ที่สอง ใช้ดัมป์เธรดให้เป็น Java มีเครื่องมือมาตรฐานจับ «สถานะทุกเธรด ณ วินาทีที่แข็งนี้»: jstack (หรือ jcmd <pid> Thread.print) พิมพ์สแต็กเทรซ และตัวเลือก -l เพิ่มข้อมูลล็อกด้วย9 สังเกตว่ารูปดัมป์ดั้งเดิมนี้ สำหรับเธรดแพลตฟอร์ม ไม่รวมเธรดเสมือนของแอป เมื่อไล่คำขอที่บล็อกในโครงที่ใช้เธรดเสมือน (ตอน 3) ใช้ jcmd <pid> Thread.dump_to_file -format=json <file> ซึ่งดัมป์เธรดเสมือนได้ด้วย2 ขั้นตอนพื้นฐานสอบสวนแฮงคือเอาดัมป์สองสามครั้งห่างกันไม่กี่วินาทีแล้วจับคู่ว่าเธรดว่างแต่ละตัวรอ Lock ใด และใครถือล็อกนั้น ถ้าล็อกการหมดเวลาของ tryLock(timeout) (ตอน 5.2) คุณยังทำทริกเกอร์การเอาดัมป์อัตโนมัติได้
ที่สาม เขย่าใต้โหลด การทดสอบความเครียด — รันนานด้วยการขนานมากกว่าจำนวนคอร์ สุ่มลำดับประมวลผล ฉีดความล่าช้าเทียม — เป็นวิธีปฏิบัติให้มีโอกาสมากขึ้นที่จะจับ «โดน» ของเรซคอนดิชันบนเครื่องพัฒนา รันอย่างน้อยหนึ่งทดสอบด้วยปริมาณข้อมูลและจำนวนเธรดระดับผลิตก่อนปล่อย
9. ทิศทางของ concurrency ใน Java — Structured Concurrency
มองครึ่งก้าวข้างหน้า เพื่อปิด สร้างบนสมมติเธรดเสมือน Structured Concurrency (StructuredTaskScope) — ที่ถือหลายงานย่อยเป็นหน่วยงานเดียวและจัดโครงสร้างการแพร่ความล้มเหลวและการยกเลิก — กำลังพัฒนา และ ณ สิงหาคม 2026 ยังเป็นฟีเจอร์พรีวิว ถูกปรับเป็นรูป API บน StructuredTaskScope.open() ในพรีวิวที่ห้าของ JDK 25 (JEP 505) และต่อเป็นพรีวิวที่หก (JEP 525) ใน JDK 26 ปัจจุบัน1011 ขณะเดียวกัน Scoped Values การแบ่งปันบริบทคงที่ที่แก้ปัญหาของ ThreadLocal ถูกทำให้เป็นที่สุดใน JDK 2512 หลักของบทความนี้ — ขอบงานชัด แบ่งปันแบบคงที่ หยุดแบบร่วมมือ — สอดคล้องกับทิศทางที่ API ใหม่เหล่านี้มุ่งด้วย
10. สรุป — รายการตรวจของ Java
- ยังมี
new Threadในโค้ดธุรกิจไหม (สร้างบนExecutorService/ เธรดเสมือนไหม) - งาน I/O-bound และ CPU-bound ถูกส่งไปกลไกดำเนินการต่างกันไหม (กิ่งในภาพ 3)
- เธรดเสมือนไม่ถูกพูล และการทำงานพร้อมกันจำกัดด้วย
Semaphoreไหม - ข้อมูลร่วมคงที่ (
record/List.copyOf) หรือสร้างบนเครื่องมือของjava.util.concurrentไหม - ไม่มี
synchronized(this)หรือล็อกบนอ็อบเจกต์ที่เปิดสาธารณะไหม - ใช้การดำเนินการประกอบของ
ConcurrentHashMap(computeIfAbsentฯลฯ) และเก็บฟังก์ชันแมปให้สั้นไหม - ไม่คาดความเป็นอะตอมจาก
volatileไหม (ตัวนับใช้คลาส Atomic /LongAdderไหม) - ไม่มีแม้แต่บล็อก catch เดียวที่กลืน
InterruptedExceptionไหม - การหยุด
ExecutorServiceตามแพทเทิร์นสองขั้นแบบมีเวลาไหม (และจุดที่ใช้close()จำกัดเฉพาะขอบเขตที่รับประกันว่างานจบไหม) - อัปเดต UI ของ Swing/JavaFX รวมที่ EDT / เธรดแอปไหม
Java เป็นหนึ่งในภาษาที่พร้อมด้วยเครื่องมือ concurrency ที่สุด และการมาของเธรดเสมือนเปิดทางให้ขยายโค้ดซิงโครนัสตรงโดยไม่เขียนใหม่ นั่นคือเหตุที่การจับการแบ่งงานระหว่างเครื่องมือให้ถูก — อันไหนเพื่อ throughput อันไหนเพื่อการกีดกัน และอะไรสัญญาณหยุด — คือเนื้อแท้ของการออกแบบมัลติเธรดใน Java
บทความที่เกี่ยวข้อง
- แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ .NET — สิ่งที่ต้องตัดสินใจก่อนเพิ่มเธรด
- แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ C++ — กำจัดอุบัติเหตุด้วยโครงสร้างผ่าน RAII และ jthread
- แนวปฏิบัติมัลติเธรดในงานจริง: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
- A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
ด้านให้คำปรึกษาที่เกี่ยวข้อง
KomuraSoft LLC รับรีวิวการออกแบบมัลติเธรดสำหรับระบบธุรกิจและงานแบตช์ใน Java สอบสวนสาเหตุรากของข้อบกพร่องที่เกี่ยวกับ concurrency อย่างสถานะร่วมเสียและอาการ «นาน ๆ ครั้งไม่หยุด / ค้าง» (วิเคราะห์ดัมป์เธรด) และให้คำปรึกษาเทคนิคเรื่องการนำเธรดเสมือนมาใช้
ลิงก์อ้างอิง
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). ว่า shutdown() ให้งานที่ส่งแล้วรันจนจบพร้อมหยุดการส่งใหม่ ว่า shutdownNow() พยายามหยุดงานที่รันและคืนรายการงานที่รอการรัน แม้ว่าอิมพลีเมนต์ทั่วไปยกเลิกผ่าน Thread.interrupt() โดยไม่รับประกันเกิน best-effort ดังนั้นงานที่ไม่ตอบ interruption จะไม่จบ ว่ารอให้จบด้วย awaitTermination ได้ ว่า close() (AutoCloseable จาก Java 19) เรียก shutdown และรอให้จบ ใช้กับ try-with-resources ได้ และว่าการปิดสองขั้น shutdown → awaitTermination → shutdownNow ถูกแสดงเป็นตัวอย่างการใช้ ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. ว่าเธรดเสมือนกลายเป็นฟีเจอร์ทางการใน JDK 21 ว่าเป็นเธรดเบาที่ลดความพยายามเขียน บำรุง และสังเกตแอปพร้อมกัน throughput สูงอย่างมาก ว่าปรัชญาออกแบบคือขยายโค้ดซิงโครนัสตรง «หนึ่งคำขอ หนึ่งเธรด» โดยไม่เปลี่ยน ว่าตัวจัดตารางเธรดเสมือนของ JDK เป็น ForkJoinPool แบบขโมยงานโหมด FIFO โดยความขนานปริยายเท่าจำนวนโปรเซสเซอร์ที่มี และว่ามีรูปดัมป์เธรดใหม่ที่รวมเธรดเสมือนเพิ่มเป็น jcmd Thread.dump_to_file (ข้อความธรรมดาและ JSON) ขณะที่ดัมป์เธรดดั้งเดิมไม่รวมเธรดเสมือน ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. ว่าเธรดเสมือนเป็นเธรดเบาที่รันไทม์ Java อิมพลีเมนต์และปล่อยเธรด OS ระหว่าง I/O บล็อก ว่าเป็นฟีเจอร์เพื่อมาตราส่วน (throughput) ไม่ใช่ความเร็ว (ความหน่วง) และไม่เหมาะกับงานหนัก CPU ว่าอย่าพูลเธรดเสมือนและใช้หนึ่งต่องาน (newVirtualThreadPerTaskExecutor) ว่าใช้ Semaphore ไม่ใช่พูลเธรดเพื่อจำกัดการทำงานพร้อมกัน ว่าการบล็อกใน synchronized ณ JDK 21 ทำให้ปักติดเธรด OS จึงแนะนำแทนจุดบ่อยหรือนานด้วย ReentrantLock และว่าตรวจการปักได้ด้วย -Djdk.tracePinnedThreads ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. ว่าการอิมพลีเมนต์มอนิเตอร์ของ JVM ถูกเขียนใหม่สำหรับ JDK 24 เพื่อรองรับเธรดเสมือน ดังนั้นการบล็อกในบล็อกหรือเมธอด synchronized ไม่ปักเธรดเสมือนติดเธรดพาหะอีก และว่าหมายความว่ามาตรการยุค JDK 21-23 «แทน synchronized ด้วย ReentrantLock» โดยหลักไม่จำเป็นแล้ว ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. ว่าข้อผิดพลาดความสอดคล้องของหน่วยความจำเกิดเมื่อหลายเธรดมีมุมมองไม่สอดคล้องต่อข้อมูลเดียวกัน ว่ากุญแจหลีกเลี่ยงคือความสัมพันธ์ happens-before (การรับประกันว่าการเขียนหน่วยความจำของคำสั่งหนึ่งมองเห็นได้จากอีกคำสั่ง) และว่า synchronized volatile และ Thread.start / join เป็นต้น สร้าง happens-before ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). ว่า Thread.stop / suspend / resume ไม่ปลอดภัยโดยพื้นฐาน (ล็อกถูกปล่อยในสถานะไม่สอดคล้อง วัตถุที่พังมองเห็นได้ suspend อาจชวนเดดล็อก) จึงถูกเลิกใช้เพื่อลบ และตอนนี้โยน UnsupportedOperationException เมื่อเรียก ว่า interrupt() ตั้งสถานะ interrupt และปลุกเธรดที่บล็อกใน sleep / wait / join โดยโยน InterruptedException (ซึ่งล้างสถานะ interrupt) และความต่างที่ interrupted() กับ isInterrupted() ปฏิบัติต่อสถานะนั้น ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. ว่าโค้ดจัดการเหตุการณ์ของ Swing รันบน Event Dispatch Thread (EDT) ว่าเมธอดของวัตถุ Swing ส่วนใหญ่ไม่ปลอดภัยต่อเธรด ดังนั้นการเรียกจากหลายเธรดชวนการแทรกแซงเธรดและข้อผิดพลาดความสอดคล้องของหน่วยความจำ แปลว่าการเข้าถึงคอมโพเนนต์ Swing ตามกฎควรทำบน EDT ว่าของานบน EDT จากเธรดอื่นผ่าน SwingUtilities.invokeLater / invokeAndWait และว่างานที่รันบน EDT ต้องจบเร็ว ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). ว่าทั้งการเรียกเมธอด computeIfAbsent รันแบบอะตอม โดยฟังก์ชันแมปถูกเรียกพอดีครั้งเดียวเมื่อคีย์ไม่มี ว่าการอัปเดตบางส่วนจากเธรดอื่นถูกบล็อกระหว่างคำนวณ จึงควรเก็บสั้นและเรียบง่าย ว่าฟังก์ชันแมปห้ามแก้แมปนี้เอง และการอัปเดตแบบเรียกซ้ำที่ตรวจพบได้ IllegalStateException และว่าการดึง (get) ไม่บล็อก โดยมี happens-before ระหว่างการอัปเดตของคีย์หนึ่งกับการดึงถัดไป ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). ว่า jstack พิมพ์สแต็กเทรซ (ชื่อคลาส ชื่อเมธอด เลขบรรทัด) ของทุกเธรดในโพรเซส Java ที่ระบุ ว่าตัวเลือก -l เปิดการแสดงละเอียดที่รวมข้อมูลล็อกเพิ่ม และว่าใช้คู่กับเครื่องมือวินิจฉัยอื่นอย่าง jcmd ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). ว่า API structured concurrency ถือกลุ่มงานย่อยที่เกี่ยวข้องเป็นหน่วยงานเดียวและจัดโครงสร้างการแพร่ข้อผิดพลาดและการยกเลิก ว่า StructuredTaskScope ถูกปรับเป็นรูปที่เปิดผ่านเมธอดโรงงานสแตติก (open) และว่า ณ JDK 25 เป็นพรีวิวที่ห้า ยังไม่ใช่ฟีเจอร์สุดท้าย ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). ว่า structured concurrency ต่อเป็นพรีวิวที่หกใน JDK 26 ด้วย — คือ ณ สิงหาคม 2026 ยังเป็นฟีเจอร์พรีวิวใน JDK ปัจจุบัน ต้องเปิดฟีเจอร์พรีวิวเพื่อใช้ ↩
-
OpenJDK, JEP 506: Scoped Values. ว่า Scoped Values ถูกทำให้เป็นที่สุดใน JDK 25 และว่าเป็นกลไกแบ่งปันข้อมูลบริบทคงที่อย่างปลอดภัยและมีประสิทธิภาพในและข้ามเธรด ให้ทางแก้ปัญหาของ ThreadLocal (การเปลี่ยนได้ การจัดการวงจรชีวิต และต้นทุนการสืบทอด) ↩
บทความที่เกี่ยวข้อง
บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง
แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C — เขียนอย่างปลอดภัยแบบ Win32 API
แนวทางที่ลงตัวของมัลติเธรดใน C กับ Win32 คือสร้างเธรดด้วย _beginthreadex, ล็อก SRW และตัวแปรเงื่อนไข, ฟังก์ชัน Interlocked และการออกแบบหย...
แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C++ — ตัดอุบัติเหตุด้วยโครงสร้างด้วย RAII และ jthread
ใน C++ มัลติเธรดคือโลกที่ data race คือพฤติกรรมไม่กำหนด บทความนี้ไล่กับดักของดีสตรักเตอร์ std::thread การออกแบบการหยุดด้วย jthread และ st...
Win32 Thread Pool API — คอนเคอร์เรนซีโดยไม่สร้างเธรดเอง ด้วย CreateThreadpoolWork
โค้ดเนทีฟของคุณกระจายการเรียก CreateThread ไปทั่วหรือไม่ บทความนี้อธิบาย Win32 thread pool API ที่ออกแบบใหม่ใน Vista — ออบเจ็กต์สี่ชนิด w...
แอปที่พังเมื่อรีซูมจากสลีป — อีเวนต์พลังงานของ Windows ทำงานอย่างไร และสร้างแอปธุรกิจที่รอดได้อย่างไร
เปิดแล็ปท็อปแล้วการเชื่อมต่อของแอปธุรกิจตาย — สาเหตุคือการออกแบบที่ไม่เคยคิดถึงสลีป บทความนี้ครอบคลุมลำดับการแจ้ง WM_POWERBROADCAST พฤติก...
DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ "อย่าทำอะไรในการเริ่มต้น DLL"
ทำไมคุณต้องไม่เรียก LoadLibrary หรือซิงโครไนซ์กับเธรดอื่นจาก DllMain บทความนี้อธิบายจากแหล่งปฐมภูมิว่า loader lock ทำให้การแจ้ง DLL ทุกคร...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
คำถามที่พบบ่อย
คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้
- มีเธรดเสมือนแล้ว ยังต้องมีพูลเธรด (ExecutorService) อีกไหม?
- ขึ้นกับกรณีใช้ Virtual thread เป็นกลไกสำหรับรันงานจำนวนมหาศาลที่รอ I/O เป็นหลัก พวกมันไม่ได้ทำให้โค้ดเร็วขึ้น แต่ยก throughput สำหรับงาน I/O-bound ใช้เธรดเสมือนหนึ่งต่อหนึ่งงาน (Executors.newVirtualThreadPerTaskExecutor) และอย่าพูลเธรดเสมือนเด็ดขาด ในทางกลับกัน การขนานการคำนวณที่กิน CPU เต็ม พูลเธรดแพลตฟอร์มที่จำกัดราวจำนวนคอร์ (หรือ parallel stream) ยังเป็นเครื่องมือที่ถูก เช่นเดิม และถ้าต้องการจำกัดจำนวนการเชื่อมต่อพร้อมกันไปยังบริการภายนอก คำแนะนำยุคเธรดเสมือนคือจำกัดด้วย Semaphore ไม่ใช่ขนาดพูล
- ควรใช้ synchronized หรือ ReentrantLock?
- สำหรับการกีดกันสั้นและเรียบง่าย synchronized ก็พอ และโค้ดยังกะทัดรัด เลือก ReentrantLock เมื่อต้องการความสามารถอย่างการได้ล็อกแบบมีเวลาผ่าน tryLock นโยบายความเป็นธรรม หรือหลาย Condition มีข้อควรระวังทางประวัติเมื่อใช้คู่กับเธรดเสมือน: ใน JDK 21-23 มีปัญหาที่การบล็อกในบล็อก synchronized จะปักเธรดเสมือนติดเธรด OS จึงแนะนำให้แทนที่จุดบล็อกบ่อยหรือนานด้วย ReentrantLock JDK 24 (JEP 491) เขียนการอิมพลีเมนต์มอนิเตอร์ใหม่และแก้ข้อจำกัดนี้ จาก JDK 24 เป็นต้นไปไม่ต้องแทน synchronized เพราะเหตุปัก
- การเติม volatile ทำให้สิ่งนั้นปลอดภัยต่อเธรดไหม?
- ไม่ Java ของ volatile สร้างความสัมพันธ์ happens-before ระหว่างการเขียนตัวแปรนั้นกับการอ่าน รับประกันการมองเห็น (ว่าเธรดอื่นเห็นการเขียนล่าสุด) และการจัดลำดับ แต่ไม่รับประกันความเป็นอะตอมของการดำเนินการประกอบอย่าง «อ่าน คำนวณ เขียนกลับ» เพิ่มตัวนับ volatile int ด้วย ++ จากหลายเธรดแล้วการบวกหาย ใช้ AtomicInteger / AtomicLong (หรือ LongAdder สำหรับการรวมความถี่สูง) สำหรับตัวนับ และล็อกเมื่อปกป้องหลายตัวแปรพร้อมกัน volatile เหมาะเกือบเฉพาะสถานการณ์อย่างธงสถานะง่าย ๆ — ที่เธรดหนึ่งเขียน ที่เหลือแค่อ่าน
- จับ InterruptedException แล้วเมินได้ไหม?
- ไม่ได้ Interruption คือสัญญาณมาตรฐานของ Java สำหรับหยุดและยกเลิก และการกลืนมันสร้างเธรดที่ไม่หยุด เมื่อ InterruptedException ถูกโยน สถานะ interrupt ถูกล้างไปแล้ว ดังนั้นถ้าทำงานให้จบเองไม่ได้ ให้คืนสถานะด้วย Thread.currentThread().interrupt() เพื่อทิ้งสัญญาณให้ผู้เรียก หรือโยนข้อยกเว้นขึ้นไปตามเดิม บล็อก catch ว่างที่ไม่ทำอะไรคือสาเหตุคลาสสิกของบั๊กที่การปิดไม่เกิดผลหรือ shutdownNow ถูกเมิน
- หยุดเธรดด้วย Thread.stop ไม่ได้หรือ?
- ไม่ได้ Thread.stop ไม่ปลอดภัยโดยพื้นฐาน (ปล่อยล็อกทิ้งไว้ในสถานะไม่สอดคล้อง เปิดวัตถุที่พังให้เธรดอื่นเห็น) จึงถูกเลิกใช้มานาน และการเรียกใน Java ปัจจุบันโยน UnsupportedOperationException เช่นเดียวกันกับ Thread.suspend / resume วิธีที่ชอบธรรมเพียงอย่างเดียวในการหยุดเธรดคือการหยุดแบบร่วมมือผ่าน interruption ถ้าใช้ ExecutorService แล้ว shutdown แค่หยุดรับงานใหม่และรอให้จบ — ไม่ส่ง interrupt ไปยังงานที่กำลังรัน ที่พยายามหยุดงานที่รันคือ shutdownNow และเป็น best-effort (อิมพลีเมนต์มาตรฐานทำงานผ่าน interruption)