Bài trước nói về Spring Boot là gì: một bản đóng gói của Spring container với các default đã được chọn sẵn, để một project bắt đầu bằng đúng một class và một annotation. Đó là câu mang tính khái niệm cuối cùng trong bài này. Đây là bài thực hành, và khi đọc xong bạn phải có một ứng dụng đang chạy trên máy mình, trả lời được request HTTP.
Toàn bộ nội dung bên dưới được tạo ra bằng cách chạy thật. Các lệnh đã được chạy, project đã được tạo, build và khởi động, các endpoint đã được gọi bằng curl, và mọi lỗi trong phần troubleshooting đều được tái hiện có chủ ý. Các dòng log và message lỗi được copy thẳng từ terminal, không phải viết theo trí nhớ.
![]()
Mục tiêu là Spring Boot 4.1.1 trên OpenJDK 21.0.6, build bằng Gradle. Máy của bạn chỉ cần cài đúng một thứ: một JDK.
Cài đặt JDK
Spring Boot 4 yêu cầu tối thiểu Java 17. Series này dùng Java 21 vì đây là bản Long Term Support mà Boot 4 được thiết kế xoay quanh, và vì Spring Initializr đưa nó ra như một lựa chọn chính thức. Bản OpenJDK nào cũng được — Temurin, Corretto, Zulu, Microsoft, Liberica, bản của Homebrew. Chúng đều là cùng một JDK, chỉ khác hạ tầng build phía sau.
macOS
Cách ngắn nhất là Homebrew:
brew install openjdk@21Homebrew cài formula này ở chế độ keg-only, nghĩa là nó cố tình không đưa vào PATH, vì đây là một version thay thế của formula openjdk. Lúc cài xong, Homebrew in ra chính xác việc cần làm:
For the system Java wrappers to find this JDK, symlink it with
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdkHãy chạy dòng đó. Nếu không, /usr/bin/java sẽ không thấy JDK bạn vừa cài, và bạn sẽ mất hai mươi phút để tự hỏi tại sao. Trên Mac chip Intel, prefix là /usr/local thay vì /opt/homebrew.
Nếu bạn thích một installer có chữ ký và tự đăng ký đúng chỗ, hãy dùng bản Adoptium/Temurin — brew install --cask temurin@21, hoặc file .pkg từ adoptium.net. Bản cask cài thẳng vào /Library/Java/JavaVirtualMachines nên không cần bước symlink.
Windows
winget install --id EclipseAdoptium.Temurin.21.JDKHoặc tải .msi từ adoptium.net. Ở bước custom setup của installer, hãy bật Set JAVA_HOME variable — Temurin mặc định tự thêm vào PATH nhưng không đụng tới JAVA_HOME, và thiếu JAVA_HOME chính là nguyên nhân của hầu hết các báo cáo kiểu "chạy trong IDE thì được, ra terminal thì lỗi". Sau khi cài xong phải mở một terminal mới: environment variable chỉ được đọc lúc process khởi động, nên cửa sổ đang mở sẽ không thấy giá trị mới.
Linux
sudo apt install openjdk-21-jdk # Debian, Ubuntu
sudo dnf install java-21-openjdk-devel # Fedora, RHEL, Rocky
sudo pacman -S jdk21-openjdk # ArchĐể ý hậu tố -jdk và -devel. Package openjdk-21-jre hay java-21-openjdk chỉ cho bạn runtime mà không có compiler — đủ để chạy một file jar, không đủ để build ra nó.
Kiểm tra lại bản cài đặt
Hai lệnh, và cả hai đều phải trả lời:
$ java -version
openjdk version "21.0.6" 2025-01-21
OpenJDK Runtime Environment Homebrew (build 21.0.6)
OpenJDK 64-Bit Server VM Homebrew (build 21.0.6, mixed mode, sharing)
$ javac -version
javac 21.0.6java là runtime còn javac là compiler. Nếu java -version chạy được mà javac -version báo "command not found" thì bạn đã cài JRE chứ không phải JDK. Cài lại package JDK rồi thử lại.
Một chi tiết nhỏ: java -version in ra stderr chứ không phải stdout. Đó không phải bug, và đó là lý do java -version | grep 21 trông như không in ra gì cả.
Khi JAVA_HOME trỏ nhầm JDK
Đây là vấn đề setup phổ biến nhất, và nó xuất hiện ngay khi máy bạn có hai JDK — chuyện sẽ xảy ra ngay khi một project cần 17 còn project khác cần 21. Các công cụ không đồng ý với nhau: java trong shell lấy từ PATH, còn Gradle, Maven và phần lớn IDE đọc JAVA_HOME. Khi hai nơi trỏ về hai bản cài khác nhau, triệu chứng trông hoàn toàn vô lý.
Trên macOS, xem thực sự đang có gì:
$ /usr/libexec/java_home -V
Matching Java Virtual Machines (2):
21.0.6 (arm64) "Homebrew" - "OpenJDK 21.0.6" /opt/homebrew/Cellar/openjdk@21/21.0.6/libexec/openjdk.jdk/Contents/Home
17.0.14 (arm64) "Homebrew" - "OpenJDK 17.0.14" /opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/HomeRồi cố định bản bạn muốn, trong ~/.zshrc:
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH="$JAVA_HOME/bin:$PATH"Tương đương trên hai nền tảng còn lại:
| macOS | Windows (PowerShell) | Linux | |
|---|---|---|---|
| Liệt kê JDK đang có | /usr/libexec/java_home -V | Get-ChildItem 'C:\Program Files\Eclipse Adoptium' | update-alternatives --list java |
JAVA_HOME hiện tại | echo $JAVA_HOME | echo $env:JAVA_HOME | echo $JAVA_HOME |
| Binary nào đang chạy | which java | where.exe java | readlink -f $(which java) |
| Đổi sang bản khác | export JAVA_HOME=$(/usr/libexec/java_home -v 21) | setx JAVA_HOME "C:\Program Files\Eclipse Adoptium\jdk-21" | sudo update-alternatives --config java |
| Ghi vào đâu cho lâu dài | ~/.zshrc | System environment variables, rồi mở terminal mới | ~/.bashrc hoặc /etc/environment |
Phép kiểm tra quyết định: chạy java -version và echo $JAVA_HOME cạnh nhau, xem hai kết quả có mô tả cùng một bản cài hay không. Nếu không, terminal và build tool đang dùng hai compiler khác nhau, và lỗi bạn nhận được sẽ nói về class file version chứ không nói về vấn đề thật.
Chọn IDE: IntelliJ IDEA hay VS Code
Cái nào cũng chạy được. Project sinh ra là một Gradle project thuần, nên thực ra không bắt buộc phải có IDE — terminal là đủ. Cái mà IDE mang lại là một debugger bạn sẽ thực sự dùng, và khả năng navigate khiến một codebase dùng framework trở nên dễ đọc.
| IntelliJ IDEA | VS Code | |
|---|---|---|
| Cần cài gì | IntelliJ IDEA (từ 2025.3 là một sản phẩm duy nhất — không còn chia Community/Ultimate) | VS Code + Extension Pack for Java (Microsoft) + Spring Boot Extension Pack (VMware) |
| Bản miễn phí có | Java, Gradle, Maven, debugger, JUnit, wizard tạo project Spring Boot và highlight Spring cơ bản | tất cả những thứ trên; các extension đều miễn phí |
| Bản trả phí thêm gì | subscription Ultimate; tài liệu của JetBrains ghi "Spring support is limited in IntelliJ IDEA without the Ultimate subscription" | không có |
| Tạo project | New Project → Spring Boot, chính là form Initializr nằm trong IDE | Ctrl+Shift+P → Spring Initializr: Create a Gradle Project |
| Chạy ứng dụng | mũi tên xanh ở gutter cạnh main, hoặc task Gradle bootRun | Spring Boot Dashboard ở sidebar |
| Cách import | đọc build.gradle và tải dependency ngay khi mở | tương tự, qua extension Gradle for Java |
| Khuyến nghị thật lòng | ít thứ phải lắp ráp hơn cho người mới; import Gradle ổn định hơn | hợp hơn nếu bạn vốn đã sống trong VS Code và muốn một editor cho mọi thứ |
Có ba thiết lập nên sửa trước khi bắt đầu, với cả hai IDE:
- Project SDK / Java runtime: 21. Trong IntelliJ là Project Structure → Project → SDK; trong VS Code là
java.configuration.runtimestrong settings. Một IDE lặng lẽ để 17 trong khi Gradle dùng 21 sẽ gạch đỏ những dòng code mà terminal compile không hề báo lỗi. - File encoding: UTF-8. Cả hai đều mặc định như vậy rồi, nhưng một project thừa kế từ setup cũ thì chưa chắc, và triệu chứng là ký tự bị vỡ trong string literal chứ không phải một lỗi rõ ràng.
- Annotation processing: bật. Hôm nay bạn chưa cần. Bạn sẽ cần ngay lần đầu thêm Lombok, và lỗi "cannot find symbol: method getName()" trên một class
@Dataluôn luôn là do thiết lập này.
Spring Initializr, từng field một
Spring Initializr là một code generator ở start.spring.io. Nó không làm gì huyền bí: nó viết ra một build file, một cây thư mục, một class có main, một test rỗng, và các script wrapper của build tool. Mỗi field trên form quyết định một dòng cụ thể trong một file cụ thể.

Từng field quyết định điều gì:
- Project — build tool nào được sinh ra. Cứ để Gradle - Groovy, đây là default hiện tại của trang và cũng là thứ series này dùng. Bạn nhận được
build.gradle,settings.gradlevà các script wrappergradlew. Bài 4 sẽ nói về chính build file đó và bản Maven tương đương. - Language — Java, Kotlin hay Groovy. Chọn Java.
- Spring Boot — version. Danh sách trộn lẫn bản GA với bản
SNAPSHOTvà bảnM(milestone). Hãy chọn con số trơn. BảnSNAPSHOTđược build lại liên tục và có thể đổi giữa hai lần build trong cùng một ngày; bảnMlà pre-release, API vẫn có thể dịch chuyển. Hôm nay default là 4.1.1, đúng thứ bạn cần. - Group — namespace theo tên miền đảo ngược,
com.examplekhi học. Nó thànhgroup = 'com.example'trong build file và là phần đầu của tọa độ artifact khi publish. - Artifact — tên project dưới dạng build artifact,
demo. Nó thànhrootProject.name = 'demo'và là tên file jar. - Name — tên cho người đọc, quyết định class main được sinh ra:
democho raDemoApplication.java. - Package name — package gốc,
com.example.demo. Field này quan trọng hơn vẻ ngoài của nó: nó quyết định cả cây thư mục dướisrc/main/javalẫn phạm vi package được scan để tìm component của bạn. Hãy đặt class của bạn bên dưới nó. (Bài 4 giải thích rule scan; phần troubleshooting bên dưới cho bạn xem triệu chứng khi làm sai.) - Packaging — Jar, luôn luôn, trừ khi bạn được giao sẵn một servlet container để deploy vào. Jar nhúng Tomcat của chính nó và chạy bằng
java -jar. War là gói để deploy vào một Tomcat do người khác vận hành, một mô hình deploy mà nếu cần thì bạn đã biết là mình cần. - Java — đổi field này. Form hiện tại mặc định 17; hãy đặt thành 21. Nó thành
JavaLanguageVersion.of(21)trong build file, tức là thứ Gradle dùng để chọn compiler. - Dependencies — thêm Spring Web. Đây là thứ cho bạn một server nhúng và
@RestController.
⚠️ Boot 4 đã đổi tên web starter. Tick "Spring Web" bây giờ sinh ra
spring-boot-starter-webmvc, và test starter tương ứng làspring-boot-starter-webmvc-test. Trên Boot 3 chúng làspring-boot-starter-webvàspring-boot-starter-test. Nếu bạn làm theo một tutorial cũ rồi dán khối dependency của nó vào project Boot 4, hoặc dán khối của Boot 4 vào project Boot 3, build sẽ không resolve được artifact. Hãy tin file được sinh ra, đừng tin tutorial.
Ba cách tạo project
Form trên web. Vào start.spring.io, điền các field bên trên, bấm Generate rồi giải nén file tải về. Cạnh nút đó có nút Explore cho bạn xem toàn bộ file sẽ được sinh ra ngay trên trình duyệt trước khi tải — đáng bấm thử một lần.
Wizard trong IDE. IntelliJ: File → New → Project → Spring Boot. VS Code: Ctrl+Shift+P → Spring Initializr: Create a Gradle Project. Cả hai là cùng một form, gọi cùng một API, và cả hai đều mở thẳng project ra khi xong.
Một lệnh curl. Trang này là một HTTP API, và mỗi field trên form là một query parameter:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o demo.zip
unzip demo.zip -d demo
cd demoLệnh unzip in ra những gì đã được ghi:
Archive: demo.zip
creating: demo/src
creating: demo/src/test
creating: demo/src/test/java
creating: demo/src/test/java/com
creating: demo/src/test/java/com/example
creating: demo/src/test/java/com/example/demo
inflating: demo/src/test/java/com/example/demo/DemoApplicationTests.java
creating: demo/src/main
creating: demo/src/main/java
creating: demo/src/main/java/com
creating: demo/src/main/java/com/example
creating: demo/src/main/java/com/example/demo
inflating: demo/src/main/java/com/example/demo/DemoApplication.java
creating: demo/src/main/resources
inflating: demo/src/main/resources/application.properties
creating: demo/src/main/resources/templates
creating: demo/src/main/resources/static
inflating: demo/settings.gradle
creating: demo/gradle
creating: demo/gradle/wrapper
inflating: demo/gradle/wrapper/gradle-wrapper.properties
inflating: demo/gradle/wrapper/gradle-wrapper.jar
inflating: demo/gradlew
inflating: demo/.gitattributes
inflating: demo/.gitignore
inflating: demo/build.gradle
inflating: demo/HELP.md
inflating: demo/gradlew.batMười hai file. Đó là toàn bộ project. Cách này đáng biết vì nó script hóa được, và vì nó cho thấy trang web không làm điều gì mà bạn không làm được từ terminal.
Chạy ứng dụng
Xem thử trong PATH có gì:
$ which mvn gradle
mvn not found
gradle not foundKhông build tool nào được cài, và cũng không cần cài. Project sinh ra mang theo một wrapper: gradlew trên Unix, gradlew.bat trên Windows, cộng với gradle/wrapper/gradle-wrapper.properties ghi chính xác version:
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zipWrapper tải đúng bản Gradle đó ở lần chạy đầu tiên và cache vào ~/.gradle. Mọi người clone repository về đều nhận được Gradle 9.7.1, bất kể trên máy họ đang cài gì. Đó là lý do luôn gõ ./gradlew chứ không bao giờ gõ gradle.
Nó cũng có nghĩa là lần build đầu rất chậm — nó đang tải một bản phân phối Gradle trước khi làm bất cứ việc gì:
$ ./gradlew build
Fetching distribution.
Downloading https://services.gradle.org/distributions/gradle-9.7.1-bin.zip
..............10%..............20%...............30%..............40%...............50%..............60%...............70%..............80%..............90%...............100%
Welcome to Gradle 9.7.1!
...
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 28s
7 actionable tasks: 7 executedHai mươi tám giây, phần lớn là tải về và resolve dependency. Lần build thứ hai của cùng project đó, không sửa gì, xong trong 374 ms và báo 7 actionable tasks: 7 up-to-date. Nếu lần build đầu trông như bị treo thì nó đang tải; hãy cho nó một phút trước khi kết luận là hỏng.
Giờ khởi động:
./gradlew bootRun
Đây là output thật, trên máy này, với thư mục làm việc được rút gọn thành /Users/you/demo:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-09-11T10:11:08.693+07:00 INFO 15312 --- [demo] [ main] com.example.demo.DemoApplication : Starting DemoApplication using Java 21.0.6 with PID 15312 (/Users/you/demo/build/classes/java/main started by you in /Users/you/demo)
2026-09-11T10:11:08.694+07:00 INFO 15312 --- [demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
2026-09-11T10:11:08.883+07:00 INFO 15312 --- [demo] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-09-11T10:11:08.888+07:00 INFO 15312 --- [demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-09-11T10:11:08.888+07:00 INFO 15312 --- [demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-09-11T10:11:08.899+07:00 INFO 15312 --- [demo] [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 193 ms
2026-09-11T10:11:08.984+07:00 INFO 15312 --- [demo] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-09-11T10:11:08.986+07:00 INFO 15312 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 0.407 seconds (process running for 0.51)Hãy đọc kỹ một lần, vì bạn sẽ còn đọc cả nghìn lần nữa:
| Dòng | Nó nói gì với bạn |
|---|---|
Banner ASCII và :: Spring Boot :: (v4.1.1) | version Boot thực sự đang nằm trên classpath — thứ đầu tiên cần kiểm tra khi code trong tutorial không compile |
Starting DemoApplication using Java 21.0.6 with PID 15312 | JVM mà Gradle thực sự chọn, và process id để kill nó khi cần |
[demo] ở mọi dòng | giá trị của spring.application.name trong application.properties |
No active profile set, falling back to 1 default profile: "default" | không có profile nào được yêu cầu, nên chỉ config mặc định có hiệu lực |
Tomcat initialized with port 8080 (http) | object server nhúng đã tồn tại và đã được cho biết muốn port nào. Nó chưa bind |
Root WebApplicationContext: initialization completed in 193 ms | container đã dựng xong các bean. Code của bạn nếu hỏng thì thường hỏng ở đây |
Tomcat started on port 8080 (http) with context path '/' | port đã được bind. Từ khoảnh khắc này ứng dụng nhận connection |
Started DemoApplication in 0.407 seconds (process running for 0.51) | sẵn sàng. Số đầu là ứng dụng, số sau tính cả thời gian khởi động JVM |
Con số 0.407 là của máy này trong ngày hôm đó — hãy coi mọi con số startup bạn thấy ở bất cứ đâu, kể cả của chính bạn, là mang tính tham khảo chứ không phải benchmark.
Có hai dòng đang thiếu, và chính sự vắng mặt đó mới là điều đáng nói. Gửi request đầu tiên và chúng xuất hiện:
Initializing Spring DispatcherServlet 'dispatcherServlet'
Completed initialization in 0 msServlet điều phối request được khởi tạo lazy, ở request đầu tiên chứ không phải lúc startup. Nếu bạn không bao giờ thấy hai dòng đó thì chưa từng có ai gọi vào ứng dụng của bạn.
Dừng bằng Ctrl+C. Trước đó process vẫn sống và vẫn giữ port 8080.
Chạy từ IDE
Mở DemoApplication.java, bấm mũi tên xanh ở gutter cạnh main, và bạn nhận được đúng log đó trong cửa sổ run của IDE, kèm debugger đã gắn sẵn. Khác biệt chỉ có vậy: bootRun và run configuration của IDE đều kết thúc bằng việc gọi SpringApplication.run trong cùng một JVM như khi chạy từ terminal.
Bên VS Code, thứ tương đương là Spring Boot Dashboard ở sidebar, liệt kê mọi ứng dụng Boot trong workspace kèm nút start, stop và debug.
Đừng chạy cả hai cùng lúc. Cái thứ hai sẽ không khởi động được, vì lý do ở phần troubleshooting.
Chạy file JAR đã đóng gói
bootRun là để dev. Thứ bạn đem đi deploy là một file jar:
./gradlew build
java -jar build/libs/demo-0.0.1-SNAPSHOT.jarBạn sẽ thấy hai file trong build/libs: demo-0.0.1-SNAPSHOT.jar khoảng 19 MB, và demo-0.0.1-SNAPSHOT-plain.jar vài kilobyte. Hãy chạy file to — nó chứa class của bạn và mọi dependency và Tomcat nhúng, đó là lý do nó tự chạy được và cũng là lý do nó nặng 19 MB. Bài 4 sẽ mở nó ra và giải thích cơ chế.
Log khởi động giống hệt, trừ đúng một chi tiết đáng để ý:
Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6 with PID 14061 (/Users/you/demo/build/libs/demo-0.0.1-SNAPSHOT.jar started by you in /Users/you/demo)Chạy từ file jar, Boot đọc được version trong manifest của jar và in ra v0.0.1-SNAPSHOT. Chạy bằng bootRun, nó đang chạy trên các file class rời nên không có version nào để báo.
Endpoint đầu tiên
Project vừa sinh ra trả về 404 cho mọi URL, vì nó chưa có controller nào:
$ curl -s -i http://localhost:8080/
HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
{"timestamp":"2026-09-11T03:08:56.159Z","status":404,"error":"Not Found","path":"/"}Bản thân cái JSON đó đã là tín hiệu tốt: server đang chạy và đang trả lời, error handler mặc định của nó hoạt động. Giờ hãy cho nó thứ để trả lời. Tạo src/main/java/com/example/demo/HelloController.java, nằm cạnh DemoApplication.java:
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
public record Greeting(String message, String recipient, int id) {}
@GetMapping("/hello")
public String hello() {
return "Hello, Spring Boot!";
}
@GetMapping("/greeting")
public Greeting greeting(@RequestParam(defaultValue = "World") String name) {
return new Greeting("Hello, " + name + "!", name, 1);
}
}Ba annotation, không có gì thêm. @RestController nói rằng class này xử lý request HTTP và giá trị trả về của nó là response body chứ không phải tên view. @GetMapping("/hello") ánh xạ request GET tới path đó vào method đó. @RequestParam bind một parameter trên query string vào argument.
Khởi động lại rồi gọi thử:
$ curl -s -i http://localhost:8080/hello
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 19
Hello, Spring Boot!Endpoint thứ hai trả về một object thay vì một string, và điều đó làm response đổi khác:
$ curl -s -i http://localhost:8080/greeting
HTTP/1.1 200
Content-Type: application/json
Content-Length: 54
{"message":"Hello, World!","recipient":"World","id":1}
$ curl -s "http://localhost:8080/greeting?name=Hoang"
{"message":"Hello, Hoang!","recipient":"Hoang","id":1}
Bạn không viết JSON, không viết code serialisation, không set content type. Một String quay ra thành text/plain; một record quay ra thành application/json với mỗi component là một key, theo đúng thứ tự khai báo. Jackson nằm trên classpath — version 3.1.5, được web starter kéo về gián tiếp — và Boot cấu hình nó vì nó có ở đó. Toàn bộ mẹo chỉ có thế, và đó chính là lý do framework này tồn tại.
Đổi port
Hoặc trong src/main/resources/application.properties:
server.port=9090Hoặc trên command line:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=9090Log xác nhận là nó có hiệu lực:
Tomcat started on port 9090 (http) with context path '/'Xử lý những lỗi bạn chắc chắn sẽ gặp
| Triệu chứng | Nghĩa là gì | Cách sửa |
|---|---|---|
Web server failed to start. Port 8080 was already in use. | một process khác — thường là bản copy của chính app này mà bạn quên tắt — đang giữ port | tắt nó, hoặc chạy ở port khác |
UnsupportedClassVersionError ... class file version 65.0 | build bằng Java 21 nhưng đang chạy bằng JVM cũ hơn | trỏ JAVA_HOME về 21 rồi chạy lại |
Cannot find a Java installation ... matching: {languageVersion=21 | Gradle không tìm thấy JDK 21 nào để compile | cài JDK 21, hoặc hạ toolchain trong build.gradle |
bash: ./gradlew: Permission denied | script wrapper mất bit executable | chmod +x gradlew |
Plugin [id: 'org.springframework.boot', version: '4.1.1'] was not found | lần build đầu không ra được mạng | online một lần cho build chạy xong, sau đó nó đã được cache |
| Controller mới trả về 404 | package của nó nằm ngoài phạm vi được scan | chuyển class vào dưới com.example.demo |
Port 8080 đã bị chiếm. Khởi động instance thứ hai trong khi cái đầu vẫn chạy và Boot từ chối, với một message dễ hiểu hiếm thấy:
***************************
APPLICATION FAILED TO START
***************************
Description:
Web server failed to start. Port 8080 was already in use.
Action:
Identify and stop the process that's listening on port 8080 or configure this application to listen on another port.Tìm thủ phạm bằng lsof -ti tcp:8080 trên macOS hoặc Linux, hoặc netstat -ano | findstr :8080 trên Windows, rồi kill process id nó in ra. Gần như luôn luôn là một lần chạy trước đó của chính ứng dụng này, bạn mở từ IDE rồi quên mất.
Sai Java version, lúc run. Build bằng 21, chạy bằng 17, và JVM từ chối file class trước khi code của bạn kịp thực thi:
Exception in thread "main" java.lang.UnsupportedClassVersionError: com/example/demo/DemoApplication has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0Class file version 65 là Java 21 và 61 là Java 17; lấy số đó trừ 44 ra version Java. Cách sửa là JAVA_HOME, quay lại phần đầu bài.
Sai Java version, lúc build. Dạng đặc thù của Gradle cho cùng một vấn đề: build file yêu cầu toolchain 21, và không tìm thấy JDK 21 nào.
* What went wrong:
Could not determine the dependencies of task ':bootJar'.
> Could not resolve all dependencies for configuration ':runtimeClasspath'.
> Failed to calculate the value of task ':compileJava' property 'javaCompiler'.
> Cannot find a Java installation on your machine (Mac OS X 26.4.1 aarch64) matching: {languageVersion=21, vendor=any vendor, implementation=vendor-specific, nativeImageCapable=false}. Toolchain auto-provisioning is not enabled.Hãy cài JDK 21 — Gradle sẽ tự tìm thấy nó ở các đường dẫn chuẩn mà không cần được chỉ. Lỗi tương đương khi không đi qua cơ chế toolchain của Gradle là chính javac từ chối target:
$ javac -source 21 -target 21 Foo.java
error: invalid source release: 21
$ javac --release 21 Foo.java
error: release version 21 not supportedgradlew: Permission denied. Wrapper là một shell script, và vài cách di chuyển project — một tool giải nén làm rơi permission, một lần checkout trên filesystem không có bit executable, một lần copy từ Windows sang — sẽ xóa mất bit đó:
$ ./gradlew --version
bash: ./gradlew: Permission deniedchmod +x gradlewTrên Windows bạn dùng gradlew.bat nên chuyện này không xảy ra.
Không có mạng ở lần build đầu. Mọi dependency, và cả Gradle, đều được tải một lần rồi cache lại. Trước khi cache đó tồn tại thì bạn bắt buộc phải online, và kiểu lỗi phụ thuộc vào thứ nào tải hụt trước. Wrapper không tải được Gradle:
Downloading https://services.gradle.org/distributions/gradle-9.7.1-bin.zip
Attempt 1/1 failed. Reason: services.gradle.invalid
Exception in thread "main" java.net.UnknownHostException: services.gradle.invalidHoặc build không tải được plugin của Boot:
* What went wrong:
Plugin [id: 'org.springframework.boot', version: '4.1.1'] was not found in any of the following sources:
- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (could not resolve plugin artifact 'org.springframework.boot:org.springframework.boot.gradle.plugin:4.1.1')Nếu ngồi sau proxy công ty, hãy đặt systemProp.https.proxyHost và systemProp.https.proxyPort trong ~/.gradle/gradle.properties. Khi một lần build đã thành công, các lần sau chạy offline được.
Controller trả về 404. Bạn đã thêm @RestController, ứng dụng khởi động sạch sẽ, mà URL vẫn 404. Đặt class đó vào một package nằm ngoài com.example.demo và bạn nhận được đúng thứ này:
$ curl -s -i http://localhost:8080/outside
HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-11T03:12:48.399Z","status":404,"error":"Not Found","path":"/outside"}Class đó đã được compile và đóng gói; chỉ là không ai nhìn tới nó. Component scanning bắt đầu từ package của class có @SpringBootApplication và chỉ đi xuống, nên com.example.other là vô hình với một project gốc com.example.demo. Hãy chuyển class vào dưới package gốc. Bài 4 sẽ giải thích rule này tử tế.
FAQ
Có phải cài Maven hay Gradle để build project Spring Boot không?
Không. Project sinh ra đã có sẵn wrapper — gradlew / gradlew.bat, hoặc mvnw / mvnw.cmd với project Maven — và nó tự tải đúng version build tool mà project được tạo ra cùng. Luôn chạy ./gradlew, đừng chạy gradle, để mọi người build project đều dùng chung một version.
Nên chọn Java version nào cho Spring Boot 4?
- Boot 4 yêu cầu tối thiểu 17 và Initializr vẫn mặc định 17, nhưng 21 là bản LTS hiện tại và cũng nằm ngay trên form đó. Hãy đổi field trước khi bấm Generate; đổi sau nghĩa là phải sửa
build.gradlerồi import lại project.
Tại sao build file sinh ra là spring-boot-starter-webmvc chứ không phải spring-boot-starter-web?
Vì Boot 4 đã đổi tên. Tick "Spring Web" cho ra spring-boot-starter-webmvc, và test starter tương ứng là spring-boot-starter-webmvc-test. Tutorial viết cho Boot 3 trở về trước dùng tên cũ. Hãy giữ tên trong file được sinh ra và sửa tutorial, không phải ngược lại.
IntelliJ IDEA Community có đủ để làm Spring Boot không?
Câu hỏi này giờ đã lỗi thời: từ 2025.3, IntelliJ IDEA là một sản phẩm duy nhất thay vì hai edition, và bản miễn phí đã có wizard tạo project Spring Boot, Gradle, debugger và highlight Spring cơ bản. Chừng đó phủ hết mọi thứ trong bài này. Tài liệu của JetBrains vẫn ghi "Spring support is limited in IntelliJ IDEA without the Ultimate subscription", nhưng điều đó nói về phần navigate và hỗ trợ editor chuyên sâu cho Spring, không phải về việc build và chạy được hay không.
demo-0.0.1-SNAPSHOT.jar và demo-0.0.1-SNAPSHOT-plain.jar khác nhau ở đâu?
File plain chỉ chứa class đã compile của bạn, vài kilobyte. File còn lại chứa class của bạn cộng mọi dependency cộng Tomcat nhúng, nên nó khoảng 19 MB và chạy độc lập được bằng java -jar. Hãy chạy file to.
Tại sao ứng dụng khởi động xong rồi thoát ngay?
Gần như luôn là thiếu web starter. Đổi spring-boot-starter-webmvc thành spring-boot-starter thuần và cả lần chạy chỉ dài ba dòng log — banner, Starting DemoApplication, Started DemoApplication in 0.229 seconds — không có Tomcat started on port 8080 ở giữa, rồi JVM thoát. Đó là hành vi đúng với một ứng dụng không phải web: không có gì đang listen trên socket, nên không có gì giữ process sống. Hãy kiểm tra web starter có trong build.gradle không, và bạn đã import lại project sau khi thêm nó chưa.
Kết luận
Giờ bạn đã có một JDK mà terminal và build tool đồng ý với nhau, một project do Spring Initializr sinh ra với các field được chọn có chủ đích chứ không phải nhận bừa default, một ứng dụng khởi động dưới một giây, và hai endpoint — một trả text, một trả JSON mà bạn không viết một dòng serialisation nào. Log khởi động không còn là một bức tường chữ: bạn chỉ được ra dòng nào là lúc bean được dựng, dòng nào là lúc port được bind, và biết hai dòng nào chỉ xuất hiện sau request đầu tiên.
Bài tiếp theo mở ra những gì generator đã viết: cấu trúc project và build tool — Gradle và Maven, starter dependency là gì, @SpringBootApplication phân rã ra thành những gì, và bên trong file JAR 19 MB đó có gì.