capstone — เปิด HTTP ด้วย JDK เปล่า แล้วทำงานพร้อมกันด้วย virtual thread
เจ็ดบทที่ผ่านมา orderctl ทำงานทีละใบ — อ่านออเดอร์จาก JSON ตรวจความถูกต้อง
คิดเงิน เขียนสถานะลง disk แล้วจบ
บทนี้เปลี่ยนสองอย่างพร้อมกัน มันเปิด port ให้คนอื่นยิงเข้ามา และมันคิดเงินหลายใบพร้อมกันได้
QuoteกับQuotes.quote— ใบเสนอราคา และตรรกะของPOST /quoteที่ไม่รู้จัก HTTP เลยQuoteHandler— ตัวรับคำขอที่ปิดกับดักสี่ข้อของcom.sun.net.httpserverServe— เปิด port แล้วผูก executor ที่แจก virtual thread ให้ทุกคำขอBatchกับLedger— คิดเงินหลายใบพร้อมกัน และยอดสะสมที่หลายสายเขียนได้
ฝั่ง HTTP ไม่ต้องเพิ่ม dependency สักบรรทัด · แต่ฝั่งเทสต์ต้องเพิ่มเสมอ เพราะ JDK ไม่มี test framework มาให้ ซึ่งเป็นข้อที่คอร์ส Go ไม่ต้องพูดถึงเลย
JDK มีเซิร์ฟเวอร์ HTTP ให้แล้ว และมันเขียนขอบของตัวเองไว้
หัวข้อที่มีชื่อว่า “JDK มีเซิร์ฟเวอร์ HTTP ให้แล้ว และมันเขียนขอบของตัวเองไว้”ชื่อ package ขึ้นต้นด้วย
com.sunแต่ JEP ยืนยันว่ารองรับจริง ขณะที่เอกสารของ module เขียนข้อจำกัดไว้เอง
com.sun.net.httpserver อยู่ใน module jdk.httpserver ซึ่ง export ออกมาตามปกติ
คุณจึงเรียกมันได้โดยไม่ต้องเติม --add-exports และไม่ต้องแตะ pom.xml
ชื่อที่ขึ้นต้นด้วย com.sun เป็นสัญญาณของ internal API ในที่อื่น · ที่นี่ไม่ใช่
JEP 408 เขียนไว้ว่า “The package is officially supported, and we extend it with APIs
to simplify server creation and enhance request handling.”
แต่เอกสารของ module เดียวกันเขียนขอบของตัวเองไว้สองที่ และผู้อ่านต้องเห็นทั้งสองด้าน
- API Note ของ module อธิบายว่า API และ SPI ชุดนี้ออกแบบมาเพื่อรองรับเซิร์ฟเวอร์ ขั้นต่ำและความหมายพื้นฐานของ HTTP เป็นหลัก
- Implementation Note ของ module เขียนตรงว่าการออกแบบและการทำจริงของเซิร์ฟเวอร์ “does not intend to be a full-featured, high performance HTTP server”
- หน้าสรุปของ package เขียนว่า “The API provides a partial implementation of RFC 2616 (HTTP 1.1) and RFC 2818 (HTTP over TLS).”
สามข้อนี้ไม่ได้แปลว่าห้ามใช้ มันแปลว่าคุณต้องรู้ว่าอะไรที่มันไม่ทำให้ แล้วเขียนส่วนนั้นเอง ซึ่งเป็นเนื้อหาของหัวข้อถัดไปทั้งหัวข้อ
ทางเลือกที่ยังพัฒนาอยู่จริงมีหลายตัว และทุกตัวเพิ่ม dependency เข้ามา เส้นแบ่งที่ใช้ได้จริงมีสามข้อ
- คุณต้องการ HTTP/2 —
com.sun.net.httpserverพูด HTTP/1.1 อย่างเดียว และ JEP 517 ที่เพิ่ม HTTP/3 ใน JDK 26 เขียน Non-Goal ไว้เองว่าไม่ทำฝั่งเซิร์ฟเวอร์ - คุณต้องการ TLS ที่ตั้งค่าง่าย — ที่นี่ตั้งได้ แต่ต้องประกอบ
HttpsConfiguratorเอง - คุณต้องการ routing ที่ดู HTTP method ให้ — ที่นี่ไม่มี และคุณจะเห็นราคาของมันในอีกสองย่อหน้า
ถ้าไม่เข้าสามข้อนี้ ของในกล่องทำงานได้จริง และมันคือเหตุผลที่บทนี้ไม่เพิ่มอะไรเลย
handler ตัวแรก กับกับดักสี่ข้อที่ต้องปิดพร้อมกัน
หัวข้อที่มีชื่อว่า “handler ตัวแรก กับกับดักสี่ข้อที่ต้องปิดพร้อมกัน”
HttpHandlerมี method เดียว แต่ลำดับการเรียก หน่วยของความยาว ค่าที่แปลว่าไม่มี body และContent-Typeผิดได้ทีละข้อ
HttpHandlerHttpHandlerตัวรับคำขอของ `com.sun.net.httpserver` · เส้นทางจับแบบ prefix ล้วนและไม่ดู HTTP method ส่วนความผิดพลาดที่หลุดออกมาถูกกลืนเงียบ คือ interface ที่มี method เดียวคือ handle(HttpExchange)
· ทุกอย่างที่เกี่ยวกับคำขอหนึ่งใบอยู่ใน HttpExchange ตัวนั้น
package orderctl;
import com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;import java.io.OutputStream;import java.nio.charset.StandardCharsets;import java.util.function.Function;
/** ตัวรับคำขอของ POST /quote · ตรรกะทั้งหมดอยู่ในฟังก์ชันที่ส่งเข้ามาทาง constructor */public final class QuoteHandler implements HttpHandler {
private final Function<String, String> logic;
public QuoteHandler(Function<String, String> logic) { this.logic = logic; }
@Override public void handle(HttpExchange exchange) throws IOException { try (exchange) { // AutoCloseable ตั้งแต่ JDK 14 if (!"POST".equals(exchange.getRequestMethod())) { // routing ไม่ดู method ให้ exchange.sendResponseHeaders(405, -1); // -1 = ไม่มี body เลย (0 = chunked) return; } String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
int status; String reply; try { reply = logic.apply(body); status = 200; } catch (RuntimeException e) { // ถ้าหลุดออกไป JDK จะกลืนเงียบ reply = "{\"error\":\"bad order\"}"; status = 400; }
byte[] out = reply.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.sendResponseHeaders(status, out.length); // จำนวนไบต์ ไม่ใช่ length() ของ string try (OutputStream stream = exchange.getResponseBody()) { stream.write(out); } } }}สี่บรรทัดที่ดูจุกจิกในนั้นคือกับดักสี่ข้อ และทุกข้อล้มแบบที่อ่านไม่ออกว่าเกิดอะไรขึ้น
sendResponseHeadersต้องมาก่อนแตะ stream — ตั้ง header หลังจากนั้นไม่มีผลแล้ว- ความยาวที่ส่งเข้าไปเป็นจำนวนไบต์ ไม่ใช่จำนวนตัวอักษร
-1แปลว่าไม่มี body ส่วน0แปลว่า chunked · ตอบ 405 ด้วย0แล้วลืมปิด stream ทำให้ client ค้างจน timeout แทนที่จะได้คำตอบทันทีContent-Typeต้องตั้งเอง JDK ไม่เดาให้ · ไม่ตั้งแล้ว client ที่อ่าน header ตัวนี้จะเดาผิด และภาษาไทยใน body จะเพี้ยนที่ปลายทาง
ตรรกะจริงไม่ได้อยู่ในนี้เลย มันถูกส่งเข้ามาทาง constructor เป็น Function<String, String>
ซึ่งเป็นการตัดสินใจที่จะได้ผลตอบแทนคืนในหัวข้อเทสต์
// src/main/java/orderctl/Quote.java — ชนิดใหม่ตัวเดียวที่บทนี้เพิ่มpackage orderctl;
public record Quote(String id, long totalSatang) { }ตรรกะของ POST /quote อยู่ใน file ของตัวเอง · Order กับ Line มาจากบทที่ 3
ส่วน mapper เป็นตัวเดียวกับที่บทที่ 3 ตั้งไว้
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี Order.java กับ Quote.java อยู่ข้างกัน// src/main/java/orderctl/Quotes.java — ตรรกะของ POST /quotepackage orderctl;
import tools.jackson.databind.json.JsonMapper;
public final class Quotes {
private static final JsonMapper MAPPER = JsonMapper.builder().build();
private Quotes() { }
public static String quote(String body) { Order order = MAPPER.readValue(body, Order.class); return MAPPER.writeValueAsString(new Quote(order.id(), order.grossSatang())); }}ยอดรวมไม่ได้ถูกคิดใหม่ในนี้ · order.grossSatang() กับ line.subtotalSatang()
เขียนไว้แล้วตั้งแต่บทที่ 2 กับบทที่ 3 และบทนี้เรียกใช้ของเดิม
catch (RuntimeException e) ใน handler ครอบทั้งความผิดพลาดของโดเมนและของ JSON ได้พอดี
เพราะ Jackson 3 เปลี่ยนความผิดพลาดของตัวเองเป็น unchecked หมดแล้ว
JSON ที่ขาดกลางคันจึงมาถึงในรูป
tools.jackson.core.exc.UnexpectedEndOfInputException ซึ่งเป็น RuntimeException
· ใน Jackson 2 มันเป็น checked และต้องประกาศ throws ขึ้นไปทั้งสาย
ความยาวเป็นไบต์ ไม่ใช่จำนวนตัวอักษร
หัวข้อที่มีชื่อว่า “ความยาวเป็นไบต์ ไม่ใช่จำนวนตัวอักษร”ข้อนี้เป็นบั๊กที่ตัวอย่างภาษาอังกฤษล้วนไม่มีวันเจอ เพราะสองเลขนั้นเท่ากันพอดีเมื่อทุกตัวอักษรเป็น ASCII
package orderctl;
import java.nio.charset.StandardCharsets;
public final class ByteCount {
public static void main(String[] args) { String sku = "ชาไทยเย็น"; System.out.println(sku.length()); System.out.println(sku.getBytes(StandardCharsets.UTF_8).length); }}$ mvn -q compile exec:java -Dexec.mainClass=orderctl.ByteCount927ส่งเลข 9 ไปให้ sendResponseHeaders ทั้งที่ต้องเขียน 27 ไบต์ แล้วผลไม่ใช่ body ที่ขาดหาย
ผลคือ สายขาด · เซิร์ฟเวอร์ล้มด้วย java.io.IOException: too many bytes to write to stream
แล้วปิด connection ทิ้ง · อ่านที่ socket ตรงๆ แล้วนับได้ 0 ไบต์ ทั้ง header และ body
จึงไม่มีอะไรออกไปถึงผู้เรียกเลยสักบรรทัด
client ที่ใช้ java.net.http.HttpClient จึงได้
java.io.IOException: HTTP/1.1 header parser received no bytes ซึ่งไม่มีคำว่าไบต์ผิดอยู่ในนั้นเลย
เส้นทางจับแบบ prefix และไม่ดู HTTP method
หัวข้อที่มีชื่อว่า “เส้นทางจับแบบ prefix และไม่ดู HTTP method”server.createContext("/quote", handler) ไม่ได้แปลว่าเส้นทางนี้เท่านั้น
มันแปลว่า ทุกเส้นทางที่ขึ้นต้นด้วยข้อความนี้ และไม่ว่า method อะไรก็เข้าตัวเดียวกัน
วัดจากเซิร์ฟเวอร์ที่หัวข้อนี้เขียนขึ้นจริง โดยแนบ body ที่ถูกต้องไปทุกคำขอ บน OpenJDK 25.0.4.1 macOS arm64 เมื่อ 2026-08-27
| คำขอ | ได้อะไร | ใครเป็นคนตัดสิน |
|---|---|---|
POST /quote | 200 | routing ส่งเข้า handler |
POST /quotes | 200 | routing ส่งเข้า handler ตัวเดียวกัน เพราะจับแบบ prefix |
POST /quote/A-1 | 200 | เหมือนกัน · path ที่เหลือเป็นเรื่องของคุณเอง |
GET /quote | 405 | บรรทัดที่คุณเขียนเอง ไม่ใช่ routing |
DELETE /quote | 405 | บรรทัดเดียวกันนั้น |
ถอด if (!"POST".equals(...)) ออก แล้ว DELETE /quote ที่แนบ body ถูกต้องมาด้วย
จะได้ 200 พร้อมใบเสนอราคา · routing ไม่เคยเป็นคนห้าม
ข้อยกเว้นที่หลุดจาก handler ถูกกลืนเงียบ
หัวข้อที่มีชื่อว่า “ข้อยกเว้นที่หลุดจาก handler ถูกกลืนเงียบ”
Exchange.run()จับExceptionแล้ว log ที่ระดับที่ปิดอยู่ หน้าจอเซิร์ฟเวอร์จึงว่างเปล่า
โยน IllegalStateException ออกจาก handle แล้วดูหน้าจอเซิร์ฟเวอร์ · ไม่มีอะไรขึ้นเลย
ส่วน client ได้ java.io.IOException: HTTP/1.1 header parser received no bytes ตัวเดิม
กลไกอยู่ใน sun.net.httpserver.ServerImpl$Exchange.run() ซึ่งจับ Exception
แล้วเขียน log ที่ระดับ FINER · ระดับนั้นปิดอยู่โดยปริยาย ทุกอย่างจึงหายไป
เปิดมันขึ้นมาชั่วคราวได้ด้วยการตั้งค่า logging ของ JDK
handlers=java.util.logging.ConsoleHandlercom.sun.net.httpserver.level=ALLjava.util.logging.ConsoleHandler.level=ALLแล้วรันเซิร์ฟเวอร์ตัวเดิมอีกครั้งโดยเติม -Djava.util.logging.config.file=log.properties
เข้าไป แล้วยิงคำขอที่ทำให้ handler ระเบิด
Aug 27, 2026 3:33:55 PM sun.net.httpserver.ServerImpl$Exchange runFINER: ServerImpl.Exchangejava.lang.IllegalStateException: โยนออกมาเลย at orderctl.QuoteHandler.handle(QuoteHandler.java:28) [... ห้าบรรทัดของ Filter$Chain, AuthFilter และ LinkHandler ...] at jdk.httpserver/sun.net.httpserver.ServerImpl$Exchange.run(ServerImpl.java:900) at java.base/java.util.concurrent.ThreadPerTaskExecutor$TaskRunner.run(ThreadPerTaskExecutor.java:291) at java.base/java.lang.VirtualThread.run(VirtualThread.java:460)บรรทัดบนสุดคือ file ของคุณเอง เลขบรรทัดจึงไม่ตรงกับข้างบนนี้ · สามบรรทัดล่างคือของ JDK และมันบอกครบว่าใครเป็นคนกลืน
นี่คือเครื่องมือ debug ไม่ใช่ทางแก้ · ทางแก้จริงคือจับความผิดพลาดไว้ใน handler เองเสมอ
อย่างที่ QuoteHandler จับ RuntimeException แล้วแปลงเป็นสถานะ HTTP ที่ผู้เรียกอ่านออก
Exchange.run() จับเฉพาะ Exception · OutOfMemoryError หรือ StackOverflowError
ถูก log แล้วโยนต่อ ไม่ได้ถูกกลืน
แปลว่าอาการ “หน้าจอว่างเปล่า” ที่หัวข้อนี้อธิบาย ครอบเฉพาะความผิดพลาดของ code คุณเอง · ความผิดพลาดระดับ JVM ยังส่งเสียง
เส้นบนสุดที่ควรจับจึงเป็น Exception ส่วน catch (Throwable) ไม่ใช่
มันจะกลืนของที่ JDK ตั้งใจปล่อยให้ดัง
บรรทัดเดียวที่เปลี่ยนทั้งเซิร์ฟเวอร์
หัวข้อที่มีชื่อว่า “บรรทัดเดียวที่เปลี่ยนทั้งเซิร์ฟเวอร์”ไม่เรียก
setExecutorแปลว่าเสิร์ฟทีละคำขอ และเรียกหลังstartแล้วโยนทิ้งทันที
javadoc ของ HttpServer.setExecutor อธิบายว่าถ้าไม่เรียกมันก่อน start()
หรือเรียกด้วย null เซิร์ฟเวอร์จะใช้ตัวปริยายซึ่งทำงานบน thread ที่ start() สร้างขึ้น
thread เดียวหมายถึงคำขอที่สองรอคำขอแรกจบก่อนเสมอ
บรรทัดที่แก้ข้อนั้นแจก virtual threadvirtual threadthread ที่ตัวจัดคิวของ JVM ดูแลเอง ราคาถูกจนสร้างเป็นล้านตัวได้ (final ที่ Java 21) · เป็น daemon และไม่มีชื่อโดยปริยาย ให้ทุกคำขอ — thread ที่ตัวจัดคิวของ JVM ดูแลเอง ราคาถูกจนสร้างเป็นล้านตัวได้ และเป็นของที่เสร็จสมบูรณ์ที่ Java 21
package orderctl;
import com.sun.net.httpserver.HttpHandler;import com.sun.net.httpserver.HttpServer;
import java.io.IOException;import java.net.InetSocketAddress;import java.util.concurrent.Executors;
/** เปิด port แล้วผูก handler เข้ากับเส้นทาง /quote */public final class Serve {
private Serve() { }
public static HttpServer start(int port, HttpHandler handler) throws IOException { // new InetSocketAddress(port) เฉยๆ เท่ากับ 0.0.0.0 คือเปิดให้ทั้งวง HttpServer server = HttpServer.create(new InetSocketAddress("localhost", port), 0); server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // ขาดบรรทัดนี้ = ทีละคำขอ server.createContext("/quote", handler); server.start(); System.out.println("orderctl ฟังที่ http://localhost:" + server.getAddress().getPort()); return server; }}start รับ handler เข้ามาแทนที่จะสร้างเอง ตัวที่ประกอบสองชิ้นเข้าด้วยกันจึงเป็น main
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เติมลงใน Serve ข้างบน เพื่อให้ exec:java เรียกได้public static void main(String[] args) throws IOException { start(8080, new QuoteHandler(Quotes::quote));}ลำดับสี่บรรทัดนั้นบังคับ — create แล้ว setExecutor แล้ว createContext แล้ว start
สลับสองตัวกลางได้ แต่ย้าย setExecutor ไปหลัง start() แล้วมันโยน
java.lang.IllegalStateException: server already started ทันที
flowchart LR
C1["คำขอที่ 1"] --> S{"executor<br/>ที่ผูกไว้"}
C2["คำขอที่ 2"] --> S
C8["คำขอที่ 8"] --> S
S -->|"ไม่เรียก setExecutor"| T["thread เดียวที่ start สร้าง<br/>เสิร์ฟทีละใบ ต่อคิวกัน"]
S -->|"newVirtualThreadPerTaskExecutor"| V["virtual thread ใบละตัว<br/>ทั้งแปดใบเดินพร้อมกัน"]
T --> R1["เวลารวม ≈ ผลบวกของทุกใบ"]
V --> R2["เวลารวม ≈ ใบที่ช้าที่สุด"]
คำบรรยายภาพ: คำขอทุกใบผ่าน executor ตัวเดียวกัน · เส้นบนคือค่าปริยายที่มี thread เดียว เวลารวมจึงเป็นผลบวก · เส้นล่างแจก virtual thread ให้ทุกใบ เวลารวมจึงเท่ากับใบที่ช้าที่สุด
วัดบน OpenJDK 25.0.4.1 macOS arm64 (18 core) เมื่อ 2026-08-27 ด้วย handler ที่หน่วง 300 ms ทุกใบ ยิงจาก client ในโปรเซสเดียวกัน
| สิ่งที่วัด | ไม่มี setExecutor | มี newVirtualThreadPerTaskExecutor |
|---|---|---|
| 8 คำขอ | ≈2,590 ms | ≈315 ms |
| 500 คำขอ | ไม่ได้วัด | ≈394 ms |
ตัวเลขวินาทีจริงไม่ข้ามเครื่อง สิ่งที่ข้ามได้คือรูปของเส้น · เส้นแรกเป็นเส้นตรงตามจำนวนคำขอ เส้นที่สองแทบแบนจาก 8 ถึง 500
javadoc ของ Thread อธิบายว่า virtual thread เหมาะกับงานที่ใช้เวลาส่วนใหญ่ไปกับการบล็อก
โดยเฉพาะการรอ I/O และเขียนต่อว่ามันไม่ได้ตั้งใจให้ใช้กับงานที่กิน CPU ยาวๆ
orderctl เข้าเงื่อนไขแรกพอดี เพราะเวลาส่วนใหญ่หมดไปกับการรอสาย ไม่ใช่การคำนวณ
อีกสองข้อที่กัดตอน debug — virtual thread ไม่มีชื่อโดยปริยาย และมันเป็น daemon เสมอ · thread dump จึงเต็มไปด้วยรายการที่ไม่มีชื่อ และไม่มีตัวไหนกัน JVM ไม่ให้จบ
ExecutorService คือ WaitGroup ของ Java
หัวข้อที่มีชื่อว่า “ExecutorService คือ WaitGroup ของ Java”
close()บล็อกจนงานจบ ส่วนexecuteกับsubmitเก็บข้อยกเว้นไว้คนละที่
ExecutorServiceExecutorServiceตัวรับงานไปทำพร้อมกัน · เป็น `AutoCloseable` ตั้งแต่ Java 19 และ `close()` บล็อกจนงานจบ จึงทำหน้าที่เดียวกับ WaitGroup ของ Go เป็น AutoCloseable ตั้งแต่ Java 19 และ close() ของมัน
บล็อกจนงานที่ส่งเข้าไปแล้วจบครบ
นั่นทำให้ try (var pool = ...) ทำหน้าที่เดียวกับ wg.Wait() ของ Go แบบตรงตัว
· ต่างกันที่ Go ต้องเรียกเอง ส่วน Java ผูกไว้กับวงเล็บปีกกา
package orderctl;
import java.util.List;import java.util.concurrent.ExecutionException;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.function.ToLongFunction;
/** คิดเงินหลายใบพร้อมกัน · close() ของ try-with-resources รอทุกงานจบ */public final class Batch {
private Batch() { }
public static long chargeAll(List<String> ids, ToLongFunction<String> charge) throws InterruptedException, ExecutionException {
List<Future<Long>> pending; try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { pending = ids.stream().map(id -> pool.submit(() -> charge.applyAsLong(id))).toList(); } long sum = 0; for (Future<Long> f : pending) { sum += f.get(); // ต้องเรียก get() ไม่งั้นความผิดพลาดหายไปตลอดกาล } return sum; }}บรรทัด f.get() ไม่ใช่ของประดับ · มันคือจุดเดียวที่ความผิดพลาดของงานแต่ละใบโผล่ออกมา
| วิธีส่งงาน | ข้อยกเว้นไปอยู่ที่ไหน | คุณเห็นมันเมื่อไร |
|---|---|---|
execute(Runnable) | ไปถึง uncaught exception handler ของ thread | เห็นทันทีที่ stderr |
submit(Callable) | ถูกเก็บไว้ใน Future | เห็นต่อเมื่อเรียก get() |
งานที่ submit แล้วไม่มีใครเรียก get() คือความผิดพลาดที่หายไปตลอดกาล
· มันไม่ขึ้นที่ไหนเลย ไม่มี log ไม่มี stderr และโปรแกรมเดินต่อเหมือนทุกอย่างปกติ
และเมื่อคุณเรียก get() ความผิดพลาดจริงยังไม่โผล่มาตรงๆ · มันมาห่ออยู่ใน
ExecutionException ซึ่งต้อง getCause() อีกชั้นถึงจะเจอตัวจริง
จาก C# คุณจะมองหา async กับ await และจำมาว่าการเรียก .Result ที่บล็อกคือ
anti-pattern · Java ไม่มีทั้งสองอย่างและไม่มีการระบายสีฟังก์ชันเลย
· f.get() ที่บล็อกอยู่บน virtual thread คือรูปที่ตั้งใจ ไม่ใช่ของที่ต้องเลี่ยง
จาก JavaScript Promise.all([a, b]) คืน array ของผลลัพธ์
· CompletableFuture.allOf(a, b) คืน CompletableFuture<Void> คุณต้องกลับไปอ่านทีละตัวเอง
และการแกะสาเหตุของ CompletableFuture มีเงื่อนไข — exceptionally ได้
CompletionException เฉพาะเมื่อความผิดพลาดลอยออกมาจากฟังก์ชันของ stage
· ถ้ามาจาก completeExceptionally(...) ตรงๆ คุณจะได้ตัวจริงและ getCause() เป็น null
package orderctl;
import java.util.concurrent.CompletionException;
/** แกะสาเหตุจริงออกมา · ใช้ได้ทั้งเมื่อมีตัวห่อและเมื่อไม่มี */public final class Unwrap {
private Unwrap() { }
public static Throwable cause(Throwable e) { return (e instanceof CompletionException && e.getCause() != null) ? e.getCause() : e; }}คอร์สนี้ใช้ virtual thread เป็นทางหลัก ไม่ใช่ CompletableFuture
เพราะ code ที่บล็อกอ่านง่ายกว่า code ที่ต่อ stage และไม่ต้องจำกฎการแกะข้างบนเลย
สถานะที่แชร์กัน และการจำกัดสายด้วย Semaphore
หัวข้อที่มีชื่อว่า “สถานะที่แชร์กัน และการจำกัดสายด้วย Semaphore”
mergeอ่านและเขียนเป็นก้าวเดียว ส่วนการจำกัดจำนวนสายไม่ได้ทำด้วยการลดจำนวน thread
เมื่อหลาย virtual thread เขียนยอดสะสมตัวเดียวกัน get แล้ว put ไม่พอ
เพราะมันคือสองก้าวที่มีช่องให้แทรกตรงกลาง
package orderctl;
import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;
/** ยอดสะสมที่หลาย virtual thread เขียนพร้อมกันได้ */public final class Ledger {
private final ConcurrentHashMap<String, Long> perSku = new ConcurrentHashMap<>(); private final AtomicLong total = new AtomicLong();
/** merge อ่านและเขียนเป็นก้าวเดียว · get แล้ว put คือสองก้าวที่แทรกกลางได้ */ public void record(String sku, long satang) { perSku.merge(sku, satang, Long::sum); total.addAndGet(satang); }
public long total() { return total.get(); }
/** คืนสำเนา ไม่ใช่ตัวจริง · ผู้เรียกจึงวนมันได้โดยไม่ต้องรู้กติกาข้างใน */ public Map<String, Long> snapshot() { return Map.copyOf(perSku); }}ConcurrentHashMap กับ AtomicLong เป็นของที่ Java มีมาให้ ต่างจาก Go ที่ต้องห่อ
map ธรรมดาด้วย sync.Mutex เอง — และ map ของ Go ที่ถูกเขียนพร้อมกันโดยไม่มีตัวกั้น
ฆ่าโปรเซสทั้งตัวด้วย fatal error ที่ recover รับไม่ได้
ส่วนการจำกัดจำนวนสายที่ยิงออกไปพร้อมกัน ห้ามทำด้วยการใช้ pool ขนาดเล็ก · JEP 444 เขียนเองว่าให้ใช้เครื่องมือที่ออกแบบมาเพื่อการนั้น “such as semaphores.”
package orderctl;
import java.util.concurrent.Semaphore;import java.util.function.Supplier;
/** จำกัดจำนวนสายที่ยิงออกไปพร้อมกัน โดยไม่ลดจำนวน virtual thread */public final class Gate {
private final Semaphore permits;
public Gate(int limit) { this.permits = new Semaphore(limit); }
public <T> T call(Supplier<T> task) throws InterruptedException { permits.acquire(); try { return task.get(); } finally { permits.release(); } }}ความต่างอยู่ที่ว่าใครรอ · pool ขนาดเล็กทำให้ งาน เข้าคิวรอ thread
ส่วน Semaphore ปล่อยให้ทุกงานมี thread ของตัวเอง แล้วให้ thread รอใบอนุญาต
สำนวนที่คุณจะค้นเจอคือ “เปลี่ยน synchronized เป็น ReentrantLock เพื่อแก้ pinning”
พร้อมกับให้ตั้ง -Djdk.tracePinnedThreads=full เพื่อหาจุดที่ตรึง
JEP 491 ที่ลงใน JDK 24 จบงานนั้นแล้ว และ system property ตัวนั้นถูกถอดออกไป ตั้งแล้วไม่มีผลอะไรเลย · แต่ผู้อ่านที่ยังติดอยู่ที่ 21 หรือ 23 ยังเจอปัญหาเดิมจริง
เหตุผลที่เหลือของ ReentrantLock มีสามข้อ และไม่มีข้อไหนเกี่ยวกับ pinning เลย
- กำหนดเวลารอสูงสุดได้
- ถูก interrupt ได้
- มี
Conditionได้หลายตัวต่อหนึ่งล็อก
เว็บนี้มีหน้าที่วัดเรื่องนี้ข้ามหลายรุ่นพร้อมช่องควบคุมอยู่แล้ว อ่านต่อได้ที่ JEP 491 แก้ปัญหา pinning แล้ว และเหลือบานที่ไม่มีใครพูดถึง
เทสต์ endpoint โดยไม่ต้องปลอม HttpExchange
หัวข้อที่มีชื่อว่า “เทสต์ endpoint โดยไม่ต้องปลอม HttpExchange”
HttpExchangeเป็น abstract class ที่มี abstract method 17 ตัว การปลอมมันจึงไม่ใช่ทางที่คุ้ม
javap com.sun.net.httpserver.HttpExchange บน JDK 25 แสดง public abstract method
17 ตัว · เขียน stub ให้ครบทุกตัวเพื่อเทสต์ตรรกะบรรทัดเดียวคือการเขียนของทิ้ง 17 ชิ้น
ชั้นที่ถูกคือ เทสต์ Quotes.quote ตรงๆ เพราะมันเป็นฟังก์ชันที่รับ string คืน string
และไม่รู้จัก HTTP เลย · นั่นคือเหตุผลที่ QuoteHandler รับตรรกะเข้ามาทาง constructor
เมื่อจำเป็นต้องตรวจของที่อยู่ในชั้น HTTP จริง เช่น สถานะ 405 หรือ header ให้เปิดเซิร์ฟเวอร์จริงแล้วผูก port 0 ให้ระบบเลือก port ว่างให้
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ต้องมี junit-jupiter ใน pom ก่อน@BeforeAllstatic void start() throws Exception { server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); // 0 = ให้ OS เลือก server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); server.createContext("/quote", new QuoteHandler(Quotes::quote)); server.start(); base = URI.create("http://localhost:" + server.getAddress().getPort()); client = HttpClient.newHttpClient();}port ตายตัวทำให้เทสต์สองชุดรันพร้อมกันไม่ได้ และแดงด้วย BindException
ที่ไม่เกี่ยวกับสิ่งที่เทสต์ตรวจเลย · 0 ปิดช่องนั้นทั้งช่อง
javadoc ของ HttpServer.stop เขียนไว้ว่า “Once stopped, a HttpServer cannot be re-used.”
แปลว่า @AfterAll ที่เรียก stop แล้วเก็บตัวแปรเดิมไว้ให้ชุดถัดไป จะได้ของที่ใช้ไม่ได้
· ต้อง create ใหม่ทุกครั้ง
อีกข้อที่ควรรู้ตอนอ่านผลเทสต์ — HttpClient เจรจา HTTP/2 ก่อนแล้วค่อยถอยลง HTTP/1.1
· ยิงเข้า com.sun.net.httpserver จึงได้ response.version() เป็น HTTP_1_1 เสมอ
และ close(), shutdown(), awaitTermination() ของ HttpClient เป็นของที่มาที่ Java 21
· tutorial ที่เขียนก่อนหน้านั้นจะไม่มีรูป try (HttpClient c = ...) ให้เห็น
ที่ที่ Go ชนะ และที่ที่ Java ชนะ
หัวข้อที่มีชื่อว่า “ที่ที่ Go ชนะ และที่ที่ Java ชนะ”สี่แถวที่บทนี้แตะจริง และทุกแถวมีราคาทั้งสองฝั่ง
คอร์ส Go บทที่ 8 ปิดกล่องเปิดบทด้วยประโยคว่าทุกอย่างยัง standard library ล้วน ไม่มี router ภายนอกและไม่มี library ของเทสต์เพิ่ม · คอร์สนี้พูดได้แค่ครึ่งแรก
| เรื่อง | Go ให้มาในกล่อง | Java ให้มาในกล่อง | ราคาที่แต่ละฝั่งจ่าย |
|---|---|---|---|
| routing | mux.HandleFunc("POST /orders", h) อ่าน method และตัวแปรใน path ได้ ตอบ 405 ให้เอง | createContext("/quote", h) จับ prefix ล้วน ไม่ดู method | Java ต้องเขียนบรรทัดตรวจ method เอง · pattern ของ Go เป็นข้อความล้วนที่พังตอนลงทะเบียน ไม่ใช่ตอนแปล |
| เทสต์ระดับ HTTP | net/http/httptest ยิงผ่าน mux ได้โดยไม่เปิด port | ไม่มีอะไรเลย และไม่มี test framework ด้วย | Java ต้องเปิด port จริงด้วย port 0 และเพิ่ม junit-jupiter ใน pom.xml ทุกโปรเจกต์ |
| การจับ data race | go test -race ชี้บรรทัดให้ | ไม่มีใน toolchain มาตรฐาน | ของ Go จับเฉพาะ race ที่เกิดจริงตอนรัน และทำให้ช้าลงชัดเจน · Java ชดเชยด้วยวินัย คือใช้ ConcurrentHashMap กับ AtomicLong แทนตัวแปรดิบ |
| ความล้มเหลวของสายเดียว | เอกสารอธิบายว่าเซิร์ฟเวอร์ recover panic ใน ServeHTTP แล้วเขียน stack trace ลง log · แต่ panic ใน goroutine ธรรมดาฆ่าโปรเซสทั้งตัวด้วย exit code 2 | ข้อยกเว้นฆ่าแค่ thread นั้น โปรแกรมเดินต่อ | Go ดังเกินไป — goroutine ใบเดียวใน worker pool ล้มแล้วทั้งโปรแกรมตาย · Java เงียบเกินไป — submit ที่ไม่มีใคร get() ทำให้ไม่มีใครรู้เลย |
แถวสุดท้ายคือแถวที่สรุปทั้งคอร์สได้ดีที่สุด — สองภาษาเลือกโหมดความล้มเหลวคนละแบบ และทั้งสองแบบต้องการวินัยคนละชุดจากคนเขียน
จุดที่ทั้งคู่เหมือนกันมีข้อหนึ่ง ไม่มีภาษาไหนมี async กับ await และไม่มีการระบายสีฟังก์ชัน
· code ที่บล็อกจึงยังเป็น code ที่ถูกทั้งสองฝั่ง
orderctl ทำอะไรได้แล้วบ้าง
หัวข้อที่มีชื่อว่า “orderctl ทำอะไรได้แล้วบ้าง”แปดบทต่อกันเป็นเครื่องมือตัวเดียว และตลอดทางเพิ่ม dependency ที่ประกาศเองสองตัว
| บท | สิ่งที่เพิ่มเข้ามา |
|---|---|
| 1 | JDK 25 ที่ติดตั้งแล้ว และโปรแกรมแรกที่รันด้วย java Hello.java ล้วน |
| 2 | ชนิดของโดเมนทั้งชุด Line Order Status Discount ที่ตรวจตัวเองได้ |
| 3 | โครง Maven, pom.xml ที่เขียนเองทุกบรรทัด และการอ่าน JSON เข้า record |
| 4 | ยอดรวม ส่วนลด และการสรุปยอดตามหมวดด้วย collection กับ stream |
| 5 | interface ที่ต้องเอ่ยชื่อ และเส้นแบ่งระหว่างมันกับ abstract class |
| 6 | checked exception, การรายงานทุกข้อที่ผิดพร้อมกัน และ try-with-resources |
| 7 | บรรทัดคำสั่ง exit code สถานะบน disk แบบ all-or-nothing และเทสต์ชุดแรก |
| 8 | POST /quote ที่เปิดด้วย JDK เปล่า และการคิดเงินหลายใบพร้อมกัน |
สิ่งที่เพิ่มเข้ามาตลอดแปดบทมีสองอย่างเท่านั้น — Jackson ที่บทที่ 3 เพราะ Java SE ไม่มี JSON และ JUnit Jupiter ที่บทที่ 7 เพราะ JDK ไม่มี test framework
ทั้งสองข้อไม่ใช่ความบกพร่องของคอร์ส มันคือข้อเท็จจริงของภาษาที่คอร์ส Go ไม่ต้องเผชิญเลย และมันคือราคาที่บทที่ 1 สัญญาไว้ว่าจะไม่แกล้งทำเป็นไม่เห็น
หน้าต่างแรก แปลทั้งโปรเจกต์ให้จบ แล้วเปิด port
$ mvn -q clean verify$ mvn -q compile exec:java -Dexec.mainClass=orderctl.Serveorderctl ฟังที่ http://localhost:8080หน้าต่างที่สอง ยิงเข้าไป · category กับ unitSatang เป็นชื่อที่บทที่ 4 กับบทที่ 3 ตั้งไว้
และ Line ปฏิเสธคำขอที่ขาดสองตัวนี้ตั้งแต่ constructor
$ curl -sS -XPOST \ -d '{"id":"A-1","lines":[{"sku":"ชาไทยเย็น","category":"drink","qty":2,"unitSatang":3500}]}' \ localhost:8080/quote{"id":"A-1","totalSatang":7000}สิ่งที่คอร์สนี้ไม่ใช้ และไปต่อจากตรงนี้
หัวข้อที่มีชื่อว่า “สิ่งที่คอร์สนี้ไม่ใช้ และไปต่อจากตรงนี้”เรื่องที่ตัดออกมีเหตุผลที่ตรวจได้ และสามก้าวถัดไปต่อจากสิ่งที่เพิ่งเรียนจบ
คุณจะค้นเจอ StructuredTaskScope ในบทความเรื่อง virtual thread แทบทุกหน้า
คอร์สนี้ไม่ใช้มัน และเหตุผลไม่ใช่เรื่องรสนิยม
JEP 533 ระบุสถานะไว้ว่าเป็น Seventh Preview ที่ Release 27 และเขียนวิธีใช้ไว้เองว่า
“Compile the program with javac --release 27 --enable-preview Main.java […]”
การเปิดธงนั้นมีราคาสามข้อที่ผู้อ่านต้องรู้ก่อน
- ต้องใส่ธงทั้งตอนแปล และ ตอนรัน · ลืมข้างใดข้างหนึ่งแล้วแดง
- class file ที่ได้รันได้บน JDK รุ่นที่แปลมันเท่านั้น ต่อให้ใส่ธงตอนรันบนรุ่นอื่นก็แดง
- API ยังเปลี่ยนทุกรุ่นจริง · public constructor กลายเป็น static factory ที่ 25 แล้วรอบถัดๆ มายังขยับต่อ
เมื่อไรที่มันเลิกเป็น preview มันจะเป็นของที่ควรใช้ · วันนี้ยังไม่ใช่
สามก้าวถัดไปที่ต่อจากบทนี้ตรงที่สุด
- virtual thread pinning หลัง JEP 491 — การวัดจริงข้ามหลายรุ่น พร้อมเคสที่ยังตรึงอยู่ทุกรุ่น ซึ่งเป็นช่องควบคุมของการวัดไปในตัว
java.util.concurrentทั้ง package —CountDownLatch,BlockingQueueและเครื่องมือที่บทนี้ไม่ได้แตะ- คอร์ส Go จากศูนย์ บทที่ 8 — ของชิ้นเดียวกันในอีกภาษา อ่านคู่กับตารางสี่แถวข้างบนแล้วคุณจะเห็นราคาทั้งสองฝั่งพร้อมกัน
คุณเขียน Java เป็นแล้วในความหมายที่ใช้ได้จริง — ประกาศชนิดของโดเมนที่ผิดไม่ได้ จัดการความผิดพลาดตามกติกาของภาษา และเปิด service ที่รับคำขอพร้อมกันได้
สิ่งที่คอร์สนี้ไม่ได้ให้คือประสบการณ์กับ framework · นั่นเป็นคอร์สของตัวเอง และการเริ่มที่นั่นก่อนจะทำให้คำถาม “ภาษาตัดสินใจอย่างไร” ถูกกลบด้วยคำถามอื่น
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- JEP 408: Simple Web Server (เข้าถึง 2026-08-27) — “The package is officially supported, and we extend it with APIs to simplify server creation and enhance request handling.”
- jdk.httpserver module summary (Java SE 25) (เข้าถึง 2026-08-27) — Implementation Note “does not intend to be a full-featured, high performance HTTP server”
- com.sun.net.httpserver package summary (Java SE 25) (เข้าถึง 2026-08-27) — “The API provides a partial implementation of RFC 2616 (HTTP 1.1) and RFC 2818 (HTTP over TLS).”
- HttpServer (Java SE 25) (เข้าถึง 2026-08-27) — ค่าปริยายของ executor ใช้ thread ที่
start()สร้าง · “Once stopped, a HttpServer cannot be re-used.” - Thread (Java SE 25) (เข้าถึง 2026-08-27) — virtual thread เหมาะกับงานที่บล็อกเป็นส่วนใหญ่ และ “are not intended for long running CPU intensive operations.”
- JEP 444: Virtual Threads (เข้าถึง 2026-08-27) — “Instead use constructs specifically designed for that purpose, such as semaphores.”
- JEP 491: Synchronize Virtual Threads without Pinning (Updated 2025/11/27 · เข้าถึง 2026-08-27) — Status Closed/Delivered · Release 24
- JEP 517: HTTP/3 for the HTTP Client API (เข้าถึง 2026-08-27) — Release 26 · Non-Goal “It is not a goal to provide a server-side implementation of the HTTP/3 protocol.”
- JEP 533: Structured Concurrency (Seventh Preview) (Updated 2026/08/19 · เข้าถึง 2026-08-27) — Release 27 · “Compile the program with
javac --release 27 --enable-preview Main.java[…]” jdk.httpserver/sun/net/httpserver/ServerImpl.javaในlib/src.zipของ OpenJDK 25.0.4.1 (อ่าน 2026-08-27) —class Exchangeเริ่มที่บรรทัด 667 ·run()ที่บรรทัด 684 · บรรทัด 903 จับExceptionแล้วlogger.log(Level.TRACE, ...)ซึ่งตรงกับFINERของjava.util.logging· บรรทัด 908 จับThrowableแล้วthrow tต่อ- net/http (Go) (เข้าถึง 2026-08-27) — “It recovers the panic, logs a stack trace to the server error log, and either closes the network connection or sends an HTTP/2 RST_STREAM”
การวัดเวลาทั้งหมดในบทนี้ทำบน OpenJDK 25.0.4.1 · macOS arm64 · 18 core เมื่อ 2026-08-27 · ตัวเลขไม่ข้ามเครื่อง ให้ดูรูปของเส้นแทน
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ส่งงานเข้า executor ด้วย `pool.submit(...)` แล้วงานนั้นโยนข้อยกเว้นออกมา · ไม่มีใครเรียก `get()` เลย จะเกิดอะไรขึ้น