Java thread คือเส้นทางการทำงานอิสระเส้นหนึ่งภายในโปรแกรม ทุกโปรแกรม Java เริ่มต้นด้วย thread เดียวคือตัวที่รัน main แล้วเราสร้าง thread เพิ่มได้เพื่อให้หลายงานเดินหน้าไปพร้อมกัน เช่น ดาวน์โหลดไฟล์ไปพร้อมกับที่หน้าจอยังตอบสนองได้ หรือให้ server รับ client หลายคนในเวลาเดียวกัน แต่ multithreading ก็มาพร้อมปัญหาใหม่ ข้อมูลที่ใช้ร่วมกันอาจเสียหาย และการหยุด thread ต้องทำให้ถูกวิธี
บทความนี้อธิบายความต่างระหว่าง process กับ thread, วิธีสร้าง thread สองแบบ (และทำไม Runnable มักเป็นตัวเลือกที่ดีกว่า), state ของ thread, การหยุด thread อย่างปลอดภัย, Timer เทียบกับ scheduled executor และ ExecutorService ซึ่งเป็นวิธีที่โค้ด Java ใน production ส่วนใหญ่ใช้รันงานพร้อมกัน ตัวอย่างใช้ Java 17 ขึ้นไป และมีหมายเหตุในจุดที่ Java 21 เปลี่ยนไป
การทำงานแบบลำดับกับแบบพร้อมกัน
โปรแกรมแบบ sequential (single task) แต่ละขั้นต้องรอขั้นก่อนหน้าเสร็จก่อน ถ้าขั้นที่สองเป็นการเรียกเครือข่ายที่ใช้เวลา 3 วินาที ทุกอย่างที่ตามมาก็ต้องรอ 3 วินาทีไปด้วย
ส่วนโปรแกรมแบบ multithread งานที่ไม่ขึ้นต่อกันจะรันบน thread แยกกัน บน CPU หลาย core มันรันขนานกันได้จริง ถ้ามี core เดียว OS จะสลับไปมาระหว่าง thread เร็วพอจนดูเหมือนทำงานพร้อมกัน
Process กับ thread ต่างกันอย่างไร
| Process | Thread | |
|---|---|---|
| Memory | มี address space ของตัวเอง | ใช้ heap ร่วมกับ thread อื่นใน process เดียวกัน |
| ต้นทุนการสร้าง | สูง | ต่ำกว่ามาก |
| การสื่อสาร | Pipe, socket, ไฟล์ | Object ที่แชร์กันใน memory |
| ถ้าพัง | Process อื่นไม่ได้รับผลกระทบ | Exception ที่ไม่ถูกจับจะจบแค่ thread นั้น แต่ state ที่แชร์กันซึ่งเสียหายไปแล้วกระทบทุกคน |
บางครั้ง thread ถูกเรียกว่า lightweight process หรือ execution context การที่ thread ใช้ memory ร่วมกันทำให้สื่อสารกันได้ถูก และก็เป็นต้นเหตุของ bug ด้าน concurrency ส่วนใหญ่ด้วย
การสร้าง Java thread
แบบที่ 1: extend Thread
public class MyThread extends Thread {
private final String label;
public MyThread(String label) {
this.label = label;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(label + ": " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
public static void main(String[] args) {
new MyThread("A").start();
new MyThread("B").start();
}
}ผลลัพธ์จะสลับกันไปมา (A: 0, B: 0, B: 1, A: 1...) และลำดับเปลี่ยนไปทุกครั้งที่รัน เพราะ thread scheduler เป็นคนตัดสินว่า thread ไหนได้ทำงานเมื่อไร อย่าเขียนโค้ดที่พึ่งลำดับแบบใดแบบหนึ่ง เว้นแต่จะประสานงานระหว่าง thread อย่างชัดเจน
แบบที่ 2: implement Runnable (แนะนำ)
Runnable แยก งาน ออกจาก thread ที่รันงาน:
public class Printer implements Runnable {
private final String label;
public Printer(String label) {
this.label = label;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(label + ": " + i + " on " + Thread.currentThread().getName());
}
}
}
Thread a = new Thread(new Printer("A"), "worker-a");
a.start();เหตุผลที่ควรเลือก Runnable:
- Class ของเรายัง extend class อื่นได้ เพราะ Java ให้มี superclass ได้แค่ตัวเดียว
- งานเดียวกันนำไปรันบน
Threadธรรมดา, thread pool หรือ scheduler ได้โดยไม่ต้องแก้ - ตัวงานโฟกัสที่หน้าที่ของมัน ไม่ต้องยุ่งกับการจัดการ thread
Anonymous class และ lambda
ถ้างานสั้น ๆ ไม่จำเป็นต้องตั้งชื่อ class Anonymous inner class ประกาศและสร้าง object ของ class ได้ในนิพจน์เดียว:
Thread c = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("C from an anonymous class");
}
});เนื่องจาก Runnable มี abstract method แค่ตัวเดียว lambda จึงทำแบบเดียวกันได้สั้นกว่า และเป็นรูปแบบที่เห็นในโค้ดสมัยใหม่:
Thread b = new Thread(() -> System.out.println("B says hi"), "worker-b");
b.start();start() กับ run()
เรียก start() เสมอ มันจะสร้าง thread ใหม่ แล้ว thread นั้นค่อยเรียก run() ถ้าเราเรียก run() เอง method จะทำงานบน thread ปัจจุบันเหมือนเรียก method ทั่วไป ไม่มี thread ใหม่เกิดขึ้น และถ้าเรียก start() ซ้ำสองครั้งกับ Thread ตัวเดิมจะได้ IllegalThreadStateException
State ของ Java thread
ตำรามักอธิบายไว้ 5 state คือ New, Runnable, Running, Blocked, Dead แต่ enum Thread.State ของ Java จริง ๆ มี 6 ค่า และไม่มี state "running" แยกออกมา:
Thread.State | ความหมาย | ชื่อในตำรา |
|---|---|---|
NEW | สร้างแล้ว แต่ยังไม่ได้เรียก start() | New |
RUNNABLE | กำลังทำงาน หรือพร้อมและรอ CPU อยู่ | Runnable + Running |
BLOCKED | รอเข้า block synchronized ที่ thread อื่นถือ lock อยู่ | Blocked |
WAITING | รอแบบไม่มีกำหนด เช่น join() หรือ wait() | Blocked |
TIMED_WAITING | รอแบบมี timeout เช่น sleep(100) | Blocked |
TERMINATED | run() ทำงานจบแล้ว | Dead |
ดู state ได้ด้วย thread.getState() ซึ่งช่วยตอน debug ส่วน join() ทำให้ thread ปัจจุบันรอจนอีก thread ทำงานจบ:
Thread a = new Thread(new Printer("A"));
System.out.println(a.getState()); // NEW
a.start();
a.join(); // wait for a to finish
System.out.println(a.getState()); // TERMINATEDข้อมูลที่แชร์กันและ race condition
เมื่อสอง thread อัปเดตตัวแปรเดียวกัน การอัปเดตบางครั้งอาจหายไป:
static int unsafeCount = 0;
static final AtomicInteger safeCount = new AtomicInteger();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
unsafeCount++; // read, add, write: not atomic
safeCount.incrementAndGet(); // atomic
}
};
// run task on two threads, then join both
// unsafeCount: often less than 200000
// safeCount: always 200000count++ มีสามขั้นตอนคือ อ่าน บวก แล้วเขียนกลับ สอง thread อาจอ่านได้ค่าเดียวกันแล้วเขียนผลเดียวกันกลับไป ทำให้การบวกหายไปหนึ่งครั้ง แก้ได้ด้วย AtomicInteger, block synchronized หรือ lock หรือดีที่สุดคือออกแบบให้ไม่ต้องแชร์ state ที่เปลี่ยนค่าได้ตั้งแต่แรก bug ประเภทเดียวกันในงานจริงอธิบายไว้ใน การป้องกัน race condition ในระบบชำระเงิน
วิธีหยุด Java thread อย่างปลอดภัย
Thread.stop() ถูก deprecate มาตั้งแต่ Java 1.2 และตั้งแต่ Java 20 จะ throw UnsupportedOperationException ทันที เหตุผลที่มันอันตรายคือมันฆ่า thread ณ จุดไหนก็ได้และปล่อย lock ทั้งหมดที่ถืออยู่ ทำให้ object ที่แชร์กันอาจค้างอยู่ในสภาพอัปเดตไม่ครบ วิธีที่ปลอดภัยคือแบบ cooperative เราขอให้ thread หยุด แล้ว thread ตรวจคำขอนั้นเองและจบการทำงานอย่างเรียบร้อย มีสองวิธีมาตรฐาน
ใช้ flag แบบ volatile
public class Poller implements Runnable {
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
pollOnce();
}
// clean up here, then return
}
private void pollOnce() { /* check for work */ }
}volatile สำคัญมาก ถ้าไม่ใส่ worker thread อาจไม่เห็นค่าที่เปลี่ยนไปเลย เพราะ JIT อาจยกการอ่านค่าออกไปไว้นอก loop ข้อจำกัดของ flag คือมันไม่ปลุก thread ที่กำลัง sleep หรือรออยู่
ใช้ interrupt
thread.interrupt() จะตั้ง interrupt flag ของ thread ถ้า thread นั้นกำลัง block อยู่ใน sleep(), wait() หรือ join() คำสั่งนั้นจะ throw InterruptedException ออกมาทันที:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("working...");
Thread.sleep(500);
} catch (InterruptedException e) {
// sleep() cleared the interrupt flag when it threw; set it again
Thread.currentThread().interrupt();
}
}
System.out.println("worker cleaned up and exited");
});
worker.start();
Thread.sleep(1_200);
worker.interrupt(); // wakes the thread if it is sleeping
worker.join(); // wait for it to finishกฎสองข้อ:
- อย่ากลืน
InterruptedExceptionด้วยcatchเปล่า ๆ ให้จบการทำงาน หรือคืนค่า flag ด้วยThread.currentThread().interrupt()เพื่อให้โค้ดชั้นบนมองเห็น - การอ่าน socket แบบดั้งเดิมไม่สนใจ interrupt เมื่อรันบน platform thread thread ที่ค้างอยู่ใน
socket.getInputStream().read()จะไม่ตื่น ให้ปิด socket จาก thread อื่นแทน การอ่านจะ throwSocketExceptionออกมา เรื่องนี้ต้องระวังใน server อย่าง แอป chat ด้วย Java socket
Timer และ TimerTask เทียบกับ ScheduledExecutorService
Timer รัน TimerTask ครั้งเดียวหรือเป็นรอบ ๆ ตามช่วงเวลาที่กำหนด บน background thread ตัวเดียว:
Timer timer = new Timer("clock", true); // daemon: will not keep the JVM alive
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
System.out.println("tick " + System.currentTimeMillis());
}
}, 0, 1_000);
// later: timer.cancel();ใช้งานได้ แต่มีจุดอ่อน ทุก task ใช้ thread เดียวกัน task ที่ช้าจึงทำให้ task อื่นเลื่อนออกไป และถ้า task ไหนเกิด exception ที่ไม่ถูกจับ thread ของ Timer จะตาย ทำให้ task ที่ตั้งเวลาไว้ทั้งหมดถูกยกเลิก โค้ดใหม่ควรใช้ ScheduledExecutorService:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("tick " + System.currentTimeMillis()),
0, 1, TimeUnit.SECONDS);
// later: scheduler.shutdown();ExecutorService: วิธีมาตรฐานในการรันงาน
การสร้าง new Thread ทุกครั้งที่มีงานนั้นแพงและไม่มีเพดาน ExecutorService จัดการ thread pool ให้ เราแค่ส่งงานเข้าไป แล้วมันจะใช้ thread ชุดเดิมซ้ำ ๆ ในการรัน Callable คล้าย Runnable แต่คืนค่าได้ และเราได้ค่านั้นกลับมาผ่าน Future ในตัวอย่างนี้ fetchLength แทน blocking call ใด ๆ ก็ได้:
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List<Callable<Integer>> tasks = new ArrayList<>();
for (String url : List.of("/a", "/bb", "/ccc")) {
tasks.add(() -> fetchLength(url));
}
for (Future<Integer> result : pool.invokeAll(tasks)) {
System.out.println(result.get()); // blocks until that task is done
}
} finally {
pool.shutdown(); // stop accepting new tasks
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt tasks still running
}
}ต้อง shutdown executor ทุกครั้ง thread ของมันไม่ใช่ daemon thread ถ้าลืมปิด pool จะทำให้ JVM ไม่ยอมจบ และ shutdownNow() ทำงานผ่านการ interrupt นี่จึงเป็นอีกเหตุผลที่ task ของเราควรตอบสนองต่อ interrupt
ตั้งแต่ Java 21 ขึ้นไป virtual thread ทำให้การใช้ thread ต่อหนึ่งงานมีต้นทุนต่ำ แม้จะมีงานที่ block อยู่เป็นหมื่นงาน และตั้งแต่ Java 19 ExecutorService เป็น AutoCloseable แล้ว try-with-resources จึงรอให้งานทั้งหมดเสร็จให้เอง:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(1_000);
return id;
});
}
} // close() waits for all tasks to finish| ความต้องการ | ใช้อะไร |
|---|---|
| ฝึกเขียน หรืองาน background ครั้งเดียว | new Thread(runnable).start() |
| งานสั้น ๆ จำนวนมาก | Executors.newFixedThreadPool(n) |
| งานที่ใช้ CPU หนัก | Fixed pool ขนาดเท่ากับ Runtime.getRuntime().availableProcessors() |
| งาน I/O ที่ block จำนวนมาก (Java 21+) | Executors.newVirtualThreadPerTaskExecutor() |
| งานที่ทำเป็นรอบหรือหน่วงเวลา | ScheduledExecutorService |
ถ้าคุณเขียน Go ด้วย Go concurrency ใน production แสดงให้เห็นว่า goroutine และ channel จัดการปัญหาแบบเดียวกันนี้อย่างไร
คำถามที่พบบ่อย
Thread กับ Runnable ใน Java ต่างกันอย่างไร
Thread คือ object ที่รันโค้ดบนเส้นทางการทำงานแยกออกมา ส่วน Runnable คืองานที่จะรัน การ implement Runnable ทำให้ class ของเรายัง extend class อื่นได้ และส่งให้ executor รันได้
ถ้าเรียก run() แทน start() จะเกิดอะไรขึ้น
โค้ดจะทำงานบน thread ปัจจุบันเหมือนเรียก method ธรรมดา ไม่มี thread ใหม่ถูกสร้าง จึงไม่มีอะไรทำงานพร้อมกัน
จะหยุด thread ใน Java ได้อย่างไร
ขอให้มันหยุดแบบ cooperative ด้วย flag แบบ volatile หรือ interrupt() แล้วให้ thread ตรวจคำขอนั้นและจบการทำงานเอง อย่าใช้ Thread.stop() ซึ่งใช้ไม่ได้แล้วใน Java เวอร์ชันปัจจุบัน
แอป Java ควรมีกี่ thread
งานที่ใช้ CPU เป็นหลัก ใช้ประมาณเท่าจำนวน core ของ CPU ส่วนงานที่เน้น I/O ใช้มากกว่านั้นได้ เพราะ thread ใช้เวลาส่วนใหญ่ไปกับการรอ ตั้งแต่ Java 21 virtual thread ทำให้แทบไม่ต้องมานั่งปรับขนาด pool สำหรับงาน I/O อีก
Checklist สำหรับ Java thread
- Implement
RunnableหรือCallableแล้วรันด้วยExecutorService - เริ่ม thread ด้วย
start()ไม่ใช่run() - ป้องกัน state ที่แชร์และเปลี่ยนค่าได้ด้วย atomic,
synchronizedหรือ lock หรือเลี่ยงการแชร์ไปเลย - หยุด thread แบบ cooperative ด้วย flag แบบ
volatileหรือinterrupt()ห้ามใช้Thread.stop() - อย่ากลืน
InterruptedExceptionให้จบการทำงานหรือคืนค่า flag - โค้ดใหม่ใช้
ScheduledExecutorServiceแทนTimer - Shutdown executor ทุกครั้ง หรือใช้ try-with-resources บน Java 19 ขึ้นไป
ถ้าทีมของคุณเจอปัญหา concurrency ในระบบ backend ที่เขียนด้วย Java หรือ Go ทีม Vectorkub ช่วย review และแก้ไขได้
