Một dòng log là bằng chứng duy nhất còn sót lại sau khi một request kết thúc. Stack frame đã mất, heap đã mất, người dùng đã rời đi — thứ còn lại chỉ là những gì process kịp ghi xuống lúc nó còn chạy. Điều đó biến logging thành một quyết định thiết kế chứ không phải việc làm thêm khi đi debug, và stack logging của Java có đúng một điểm kỳ lạ về cấu trúc khiến hầu như ai cũng vấp lần đầu: cái API mà bạn viết code lên trên không hề chứa dòng code nào biết in ra gì cả.
Bài viết đi qua cả hai nửa của tiêu đề. Trước hết là logging: facade pattern đằng sau SLF4J, mỗi level thực sự dùng để làm gì, vì sao cú pháp placeholder tồn tại, cách cấu hình Logback và Log4j 2, structured logging với MDC, và cách log một exception mà không phá nát stack trace của nó. Sau đó là debug: đọc stack trace cho đúng, đặt breakpoint từ terminal, và soi một JVM đang chạy.
![]()
Toàn bộ nội dung bên dưới được compile và chạy trên OpenJDK 21.0.6 (arm64) với slf4j-api 2.0.13, slf4j-simple 2.0.13, logback-classic và logback-core 1.5.12, cùng Log4j 2.24.1 (log4j-api, log4j-core, log4j-slf4j2-impl). Mọi dòng log, warning và stack trace được trích ở đây đều là output thật lấy nguyên văn từ những lần chạy đó.
Vì sao System.out.println không còn đủ
System.out.println là cách hoàn toàn ổn để nhìn một giá trị khi bạn đang viết dở một method. Nó hết đủ ngay khi code rời khỏi máy của bạn.
public class PrintlnDemo {
static void settle(int orderId) {
System.out.println("settling " + orderId);
System.out.println("done");
}
public static void main(String[] args) {
settle(4711);
}
}settling 4711
doneHãy nhìn vào những thứ output đó không có. Không có timestamp, nên bạn không biết done xảy ra sau một mili giây hay sau một tiếng. Không có tên thread, nên khi có hai request chạy song song các dòng đan vào nhau và không có gì cho biết dòng nào của ai. Không có nguồn, nên done có thể đến từ bất kỳ class nào trong bốn mươi class của project. Không có level, nên một dòng ghi nhận tiến độ bình thường và một lỗi nghiêm trọng trông y hệt nhau với mọi công cụ đọc file.
Hai điểm tệ nhất lại là hai điểm bạn nhận ra sau cùng. Bạn không tắt được nó: cách duy nhất để im lặng một println trên production là sửa source rồi deploy lại. Và bạn không định tuyến được nó: nó đi ra standard output và không đi đâu khác, nên java -cp out PrintlnDemo > /dev/null không phải là ghi vào file — nó xóa sạch output luôn. Một logging framework tồn tại để trả lại cho bạn sáu trường thông tin còn thiếu và hai khả năng điều khiển còn thiếu đó.
SLF4J: một API không có implementation
SLF4J (Simple Logging Facade for Java) là một facade. Jar slf4j-api chứa interface Logger và class LoggerFactory, và gần như không chứa gì có thể ghi một dòng ra bất kỳ đâu. Lúc khởi động, LoggerFactory dùng ServiceLoader của JDK để tìm một implementation của org.slf4j.spi.SLF4JServiceProvider trên classpath. Nó tìm thấy cái nào thì lệnh log.info của bạn rốt cuộc làm việc của cái đó.
Lớp gián tiếp này chính là toàn bộ mục đích: một library chỉ cần depend vào slf4j-api, còn application dùng library đó mới là bên quyết định lúc deploy rằng output đi qua Logback, Log4j 2 hay chẳng đi đâu cả. Nó cũng là thứ gây rối nhất trong logging của Java, vì cùng một file source lại chạy theo ba kiểu hoàn toàn khác nhau tùy vào những jar mà code không hề nhắc tên.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class FacadeDemo {
private static final Logger log = LoggerFactory.getLogger(FacadeDemo.class);
public static void main(String[] args) {
System.out.println("logger class = " + log.getClass().getName());
log.info("order {} accepted", 4711);
log.warn("retrying payment for order {}", 4711);
}
}Compile chỉ cần mỗi API:
javac -cp slf4j-api-2.0.13.jar -d out FacadeDemo.java
Không có binding: log im lặng thành no-op
Chạy class đã compile với đúng mỗi API và không gì khác:
java -cp out:slf4j-api-2.0.13.jar FacadeDemoSLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
logger class = org.slf4j.helpers.NOPLoggerCả hai lệnh log đều đã chạy. Không lệnh nào in ra gì. NOPLogger implement mọi method của Logger bằng một thân rỗng, nên chương trình vẫn đúng, vẫn nhanh và im lặng tuyệt đối. Ba dòng SLF4J(W) kia là cảnh báo duy nhất bạn nhận được, chúng đi ra standard error, và trong một container mà không ai đọc stderr thì chúng vô hình. Một service "tự dưng hết log" sau khi đổi dependency thường chính là trường hợp này.
Một binding: provider quyết định format
Thêm đúng một provider. slf4j-simple là cái nhỏ nhất — một jar duy nhất, không cần file config, output ra standard error:
java -cp out:slf4j-api-2.0.13.jar:slf4j-simple-2.0.13.jar FacadeDemologger class = org.slf4j.simple.SimpleLogger
[main] INFO FacadeDemo - order 4711 accepted
[main] WARN FacadeDemo - retrying payment for order 4711Đổi sang Logback, thứ cần hai jar — logback-classic là provider, còn logback-core là engine bên dưới nó:
java -cp out:slf4j-api-2.0.13.jar:logback-classic-1.5.12.jar:logback-core-1.5.12.jar FacadeDemologger class = ch.qos.logback.classic.Logger
10:56:07.801 [main] INFO FacadeDemo -- order 4711 accepted
10:56:07.802 [main] WARN FacadeDemo -- retrying payment for order 4711Cùng một file class, cùng bytecode, nhưng Logger cụ thể khác nhau và format dòng khác nhau — kể cả dấu phân cách -- trong pattern mặc định có sẵn của Logback, trong khi slf4j-simple dùng một dấu -. Không có gì trong source chọn cái nào cả.
| Binding jar | Backend | Cấu hình bằng | Dùng khi nào |
|---|---|---|---|
slf4j-simple | tích hợp sẵn | chỉ system property | test, tool nhỏ |
slf4j-nop | không có | không có gì | cố ý bịt log của một library |
logback-classic | Logback | logback.xml | mặc định cho phần lớn application |
log4j-slf4j2-impl | Log4j 2 | log4j2.xml | khi bạn cần tính năng của Log4j 2 |
jul-to-slf4j | đẩy vào SLF4J | không có | gom output của java.util.logging |
Hai binding: cái tìm thấy trước thắng
Giờ đặt Logback và cầu nối Log4j 2 lên classpath cùng lúc — đúng chuyện xảy ra khi hai dependency của bạn mỗi bên kéo theo lựa chọn riêng của nó:
java -cp out:slf4j-api-2.0.13.jar:logback-classic-1.5.12.jar:logback-core-1.5.12.jar:\
log4j-slf4j2-impl-2.24.1.jar:log4j-api-2.24.1.jar:log4j-core-2.24.1.jar FacadeDemoSLF4J(W): Class path contains multiple SLF4J providers.
SLF4J(W): Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider@4f3f5b24]
SLF4J(W): Found provider [org.apache.logging.slf4j.SLF4JServiceProvider@15aeb7ab]
SLF4J(W): See https://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J(I): Actual provider is of type [ch.qos.logback.classic.spi.LogbackServiceProvider@4f3f5b24]
logger class = ch.qos.logback.classic.LoggerSLF4J không fail. Nó chọn một cái, nói cho bạn biết đã chọn cái nào, rồi chạy tiếp. Đưa mấy jar Log4j 2 lên trước mấy jar Logback trên cùng dòng lệnh thì câu trả lời đổi luôn:
SLF4J(W): Class path contains multiple SLF4J providers.
SLF4J(W): Found provider [org.apache.logging.slf4j.SLF4JServiceProvider@4f3f5b24]
SLF4J(W): Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider@15aeb7ab]
SLF4J(W): See https://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J(I): Actual provider is of type [org.apache.logging.slf4j.SLF4JServiceProvider@4f3f5b24]
logger class = org.apache.logging.slf4j.Log4jLoggerThứ tự trên classpath mới là thứ quyết định. Đó là lý do triệu chứng ngoài thực tế nhìn rất lạ: file logback.xml của bạn tự nhiên bị bỏ qua, không có lỗi nào hiện ra, và manh mối duy nhất là một warning trên standard error. Cách sửa luôn là loại bỏ binding bạn không muốn chứ không phải tranh nhau thứ tự ưu tiên — và việc giữ cho cây dependency không có hai binding trùng nhau là việc của build tool, cũng chính là chỗ bài kế tiếp bắt đầu.
Log level trong Java là gì và threshold hoạt động ra sao?
SLF4J định nghĩa năm level, xếp từ ít nghiêm trọng đến nghiêm trọng nhất: trace, debug, info, warn, error. Mỗi logger có một threshold, và lệnh log ở level thấp hơn threshold sẽ bị bỏ đi.
Ý quan trọng là level là một quyết định định tuyến, không phải một ý kiến về mức nghiêm trọng. Bạn không chấm điểm mức độ bực bội của mình với sự kiện đó; bạn đang khai báo dòng log này thuộc về nhóm người đọc nào, để sau này một file config quyết định nhóm nào được ship ra. error nghĩa là phải có người xử lý. warn nghĩa là hệ thống vẫn chạy tiếp nhưng đã phải trả giá. info nghĩa là một cột mốc trong luồng chạy bình thường. debug nghĩa là chi tiết bạn cần khi tái hiện một bug. trace nghĩa là nhiễu từng bước, thứ bạn chỉ bật vài phút rồi tắt.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LevelsDemo {
private static final Logger log = LoggerFactory.getLogger(LevelsDemo.class);
public static void main(String[] args) {
log.trace("entering settle() with {} arg(s)", args.length);
log.debug("loaded 3 pending orders");
log.info("settlement run started");
log.warn("gateway slow, using cached rate");
log.error("settlement failed for order {}", 4711, new IllegalStateException("no funds"));
System.out.println("isDebugEnabled = " + log.isDebugEnabled());
}
}
Chạy nó với một file config Logback có threshold ở root là info:
java -cp out:slf4j-api-2.0.13.jar:logback-classic-1.5.12.jar:logback-core-1.5.12.jar \
-Dlogback.configurationFile=logback-info.xml LevelsDemo11:06:40.130 INFO LevelsDemo - settlement run started
11:06:40.131 WARN LevelsDemo - gateway slow, using cached rate
11:06:40.131 ERROR LevelsDemo - settlement failed for order 4711
java.lang.IllegalStateException: no funds
at LevelsDemo.main(LevelsDemo.java:12)
isDebugEnabled = falseSửa đúng một từ trong file config thành trace rồi chạy lại y nguyên file class đó:
11:06:40.260 TRACE LevelsDemo - entering settle() with 0 arg(s)
11:06:40.260 DEBUG LevelsDemo - loaded 3 pending orders
11:06:40.260 INFO LevelsDemo - settlement run started
11:06:40.260 WARN LevelsDemo - gateway slow, using cached rate
11:06:40.260 ERROR LevelsDemo - settlement failed for order 4711
java.lang.IllegalStateException: no funds
at LevelsDemo.main(LevelsDemo.java:12)
isDebugEnabled = trueViệc isDebugEnabled lật từ false sang true chính là cùng cái threshold đó nhìn từ bên trong chương trình. Method này là cách bạn hỏi framework xem có đáng bỏ công làm việc cho một dòng log hay không, và điều đó quan trọng ở phần tiếp theo.
Parameterised logging và lý do tồn tại của placeholder
Mọi method của SLF4J đều nhận một message có placeholder {} và các giá trị truyền riêng:
log.debug("user {} did {}", id, action);Giải thích thường gặp là "nó nhanh hơn nối chuỗi", đúng nhưng mô tả sai bản chất. Tính chất thật sự là với placeholder thì các argument không hề bị biến thành string nếu level không được bật. Với nối chuỗi, string do chính chỗ gọi dựng ra, trước cả khi logger được hỏi ý kiến, nên công việc vẫn diễn ra bất kể có gì được in ra hay không.
Chuyện đó rất dễ chứng minh: cho một object một toString() đếm số lần nó bị gọi.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ParamDemo {
private static final Logger log = LoggerFactory.getLogger(ParamDemo.class);
static final class Cart {
static int toStringCalls = 0;
private final int items;
Cart(int items) { this.items = items; }
@Override public String toString() {
toStringCalls++;
return "Cart[items=" + items + "]";
}
}
public static void main(String[] args) {
Cart cart = new Cart(3);
for (int i = 0; i < 1000; i++) {
log.debug("cart state: " + cart);
}
System.out.println("after 1000 concatenated calls, toString() ran " + Cart.toStringCalls + " times");
Cart.toStringCalls = 0;
for (int i = 0; i < 1000; i++) {
log.debug("cart state: {}", cart);
}
System.out.println("after 1000 parameterised calls, toString() ran " + Cart.toStringCalls + " times");
System.out.println("isDebugEnabled = " + log.isDebugEnabled());
}
}Với threshold ở root là info, tức là cả 2000 lệnh debug kia đều bị bỏ:
after 1000 concatenated calls, toString() ran 1000 times
after 1000 parameterised calls, toString() ran 0 times
isDebugEnabled = falseMột nghìn lần gọi toString(), một nghìn lần cấp phát StringBuilder và một nghìn object String vứt đi, tất cả cho output không bao giờ được ghi. Bản dùng placeholder không làm một cái nào trong số đó. Đó là lý do cụ thể cho cú pháp {}, và nó là tính chất về công việc có xảy ra hay không, không phải về việc format chạy nhanh cỡ nào.
Có một giới hạn đáng biết. Placeholder hoãn được toString(), nhưng nó không hoãn được việc tính chính biểu thức argument — cái đó xảy ra trước khi gọi method, giống mọi argument khác trong Java:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DeferDemo {
private static final Logger log = LoggerFactory.getLogger(DeferDemo.class);
static int scans = 0;
static String expensiveSummary() {
scans++;
return "summary";
}
public static void main(String[] args) {
log.debug("cache summary: {}", expensiveSummary());
System.out.println("plain placeholder call -> scans = " + scans);
scans = 0;
log.atDebug().addArgument(() -> expensiveSummary()).log("cache summary: {}");
System.out.println("fluent supplier call -> scans = " + scans);
scans = 0;
if (log.isDebugEnabled()) {
log.debug("cache summary: {}", expensiveSummary());
}
System.out.println("guarded call -> scans = " + scans);
}
}plain placeholder call -> scans = 1
fluent supplier call -> scans = 0
guarded call -> scans = 0Vậy quy tắc là: luôn dùng {}; chỉ thêm guard isDebugEnabled(), hoặc dạng fluent addArgument(Supplier) của SLF4J 2, khi bản thân việc tạo ra argument đã đắt.
Cấu hình Logback và Log4j 2
Cấu hình là chỗ hai implementation khác nhau, và với phần lớn application thì đó là chỗ duy nhất chúng khác nhau. Cả hai đều đọc từ classpath theo mặc định — logback.xml và log4j2.xml — và cả hai đều có thể trỏ vào một file cụ thể bằng system property, đúng cách mà mọi ví dụ ở đây được chạy.
logback.xml tối thiểu
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
</configuration>Ba khái niệm, và đó là ba khái niệm giống nhau ở mọi framework. Appender là đích đến — console, file, socket. Layout hay encoder biến một event thành text; chuỗi pattern là một ngôn ngữ nhỏ trong đó %d là timestamp, %-5level là level căn trái trong năm ký tự, %logger{20} là tên logger rút gọn còn hai mươi ký tự, %msg là message đã format và %n là ký tự xuống dòng của nền tảng. Logger có một level và một tập appender, còn root là cái mà mọi logger khác kế thừa.
log4j2.xml tối thiểu
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="com.example.repo" level="debug"/>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>Tên element khác nhau, mô hình y hệt — và tiện là ngôn ngữ pattern gần như giống hệt luôn. Thuộc tính status="WARN" điều khiển phần chẩn đoán nội bộ của chính Log4j 2, thứ đầu tiên cần bật lên khi một file config đang bị bỏ qua.
Level theo package
Element Logger ở trên là tính năng bạn sẽ dùng nhiều nhất: một level đặt trên một tên logger sẽ áp dụng cho tên đó và mọi thứ nằm dưới nó. Vì tên logger theo quy ước là tên class đầy đủ, một tên logger chính là một tiền tố package, và bạn có thể bật debug cho đúng một package mà không chết chìm trong log của mọi package khác.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class PackageLevelDemo {
private static final Logger app = LoggerFactory.getLogger("com.example.web.OrderController");
private static final Logger repo = LoggerFactory.getLogger("com.example.repo.OrderRepository");
public static void main(String[] args) {
app.debug("controller debug line");
app.info("controller info line");
repo.debug("select * from orders where id = {}", 4711);
repo.info("repository info line");
}
}Chạy với file config Log4j 2 ở trên:
10:57:42.124 INFO com.example.web.OrderController - controller info line
10:57:42.127 DEBUG com.example.repo.OrderRepository - select * from orders where id = 4711
10:57:42.127 INFO com.example.repo.OrderRepository - repository info lineDòng debug của controller biến mất vì com.example.web kế thừa threshold info từ root; dòng của repository sống sót vì com.example.repo đã được nâng lên debug. Đoạn tương đương bên Logback chỉ một dòng và cho ra output giống hệt từng byte trừ timestamp:
<logger name="com.example.repo" level="debug"/>Rolling file, nói thẳng
ConsoleAppender là lựa chọn đúng cho một service chạy trong container mà platform tự gom output. Khi bạn thực sự ghi ra file, thứ bạn cần là một appender biết xoay vòng, để file bị giới hạn kích thước và file cũ bị dọn đi thay vì làm đầy ổ đĩa lúc ba giờ sáng.
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>2KB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>20KB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="FILE"/>
</root>
</configuration>Các con số ở đây cố tình nhỏ đến mức vô lý — 2KB mỗi file, 20KB tổng cộng — để việc xoay vòng xảy ra sau một trăm dòng thay vì sau một gigabyte. Ghi 120 dòng với config đó cho ra:
app.2026-09-10.0.log.gz
app.2026-09-10.1.log.gz
app.logBa file: file đang ghi và hai file lưu trữ đã xoay vòng, được nén gzip vì fileNamePattern kết thúc bằng .gz. Trên production bạn sẽ viết maxFileSize là 100MB và totalSizeCap là 3GB; cơ chế thì đúng y cái vừa thấy. Hai điểm nên nói thẳng: maxHistory đếm theo chu kỳ (ở đây là ngày) chứ không phải theo số file, và totalSizeCap chỉ được áp dụng lúc xoay vòng, nên giữa hai lần xoay một file vẫn có thể vượt qua nó.
Structured logging và MDC
Một dòng log có hai loại người đọc. Con người đọc vài chục dòng trong lúc xử lý sự cố; máy đọc hàng triệu dòng trong một search index. Văn xuôi phục vụ loại thứ nhất và thất bại với loại thứ hai.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class StructuredDemo {
private static final Logger log = LoggerFactory.getLogger(StructuredDemo.class);
public static void main(String[] args) {
log.info("The order for customer 812 was rejected because the card expired");
log.atInfo()
.addKeyValue("orderId", 4711)
.addKeyValue("customerId", 812)
.addKeyValue("reason", "card_expired")
.log("order rejected");
}
}Với một pattern Logback kết thúc bằng %msg %kvp%n, thứ render ra các cặp key-value mà fluent API của SLF4J 2 gắn vào:
10:57:57.490 INFO StructuredDemo - The order for customer 812 was rejected because the card expired
10:57:57.491 INFO StructuredDemo - order rejected orderId="4711" customerId="812" reason="card_expired"Dòng đầu chỉ tìm được nếu bạn đoán trúng câu chữ ai đó đã viết. Dòng sau có một message cố định để đếm và các key cố định để lọc và gom nhóm, và nó sống sót qua cái ngày có người viết lại câu đó. Bạn không cần đến JSON layout mới hưởng được phần lớn lợi ích này — một message cố định cộng với các cặp key="value" đã đủ thân thiện với grep và với index rồi.

Nửa còn lại của structured logging là context mà bạn không muốn truyền như parameter vào từng method. MDC (Mapped Diagnostic Context) là một map gắn vào thread hiện tại; pattern có thể in bất kỳ key nào trong đó, nên một request id set một lần sẽ xuất hiện trên mọi dòng sau đó.
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class MdcDemo {
private static final Logger log = LoggerFactory.getLogger(MdcDemo.class);
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> log.info("pool thread warmed up")).get();
MDC.put("requestId", "r-8f31");
log.info("handling POST /orders");
log.info("validated payload");
pool.submit(() -> log.info("charging the card on a pool thread")).get();
Map<String, String> ctx = MDC.getCopyOfContextMap();
pool.submit(() -> {
MDC.setContextMap(ctx);
try {
log.info("charging the card, context carried across");
} finally {
MDC.clear();
}
}).get();
log.info("responded 201");
MDC.clear();
log.info("outside the request scope");
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}Pattern đọc key bằng %X, và :-none cung cấp giá trị mặc định khi key không tồn tại:
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] [req=%X{requestId:-none}] %logger{20} - %msg%n</pattern>10:57:13.553 INFO [pool-1-thread-1] [req=none] MdcDemo - pool thread warmed up
10:57:13.555 INFO [main] [req=r-8f31] MdcDemo - handling POST /orders
10:57:13.555 INFO [main] [req=r-8f31] MdcDemo - validated payload
10:57:13.555 INFO [pool-1-thread-1] [req=none] MdcDemo - charging the card on a pool thread
10:57:13.556 INFO [pool-1-thread-1] [req=r-8f31] MdcDemo - charging the card, context carried across
10:57:13.556 INFO [main] [req=r-8f31] MdcDemo - responded 201
10:57:13.556 INFO [main] [req=none] MdcDemo - outside the request scopeDòng thứ tư là bug. Task được submit lúc requestId đang được set, nhưng MDC là thread-local, nên pool thread chạy nó có map rỗng của riêng nó và field in ra none. Không có gì fail, không có warning nào — request id chỉ đơn giản là ngừng xuất hiện ở giữa chừng request, một thứ cực kỳ khó chịu khi phát hiện ra lúc đang xử lý sự cố. Dòng thứ năm là cách sửa: chụp lại map bằng MDC.getCopyOfContextMap() trên thread gọi, gọi MDC.setContextMap bên trong task, rồi clear trong finally để pool thread không mang giá trị đó sang một task không liên quan sau này.
Khối finally không phải tùy chọn. Pool thread được tái sử dụng; một giá trị bỏ quên trong MDC của nó sẽ được in ra trên request của người khác. Lập luận đó cũng áp dụng cho dòng cuối trong main — luôn dọn cái scope mà bạn đã set.
Làm sao log exception mà không mất stack trace?
Mọi method của SLF4J đều có overload mà argument cuối cùng là một Throwable. Nếu bạn truyền exception vào đó, framework in ra stack trace. Nếu bạn nối e.getMessage() vào chuỗi text thay vì vậy, bạn giữ lại đúng một câu và vứt đi toàn bộ phần cho biết lỗi đến từ đâu.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ExceptionLogDemo {
private static final Logger log = LoggerFactory.getLogger(ExceptionLogDemo.class);
static int parseQuantity(String raw) {
return Integer.parseInt(raw);
}
static void loadOrder(String raw) {
try {
parseQuantity(raw);
} catch (NumberFormatException e) {
throw new IllegalStateException("order 4711 has a broken quantity", e);
}
}
public static void main(String[] args) {
try {
loadOrder("12x");
} catch (Exception e) {
log.error("could not load order: " + e.getMessage()); // loses the trace
log.error("could not load order {}", 4711, e); // keeps the trace
}
}
}10:57:25.893 ERROR ExceptionLogDemo - could not load order: order 4711 has a broken quantity
10:57:25.894 ERROR ExceptionLogDemo - could not load order 4711
java.lang.IllegalStateException: order 4711 has a broken quantity
at ExceptionLogDemo.loadOrder(ExceptionLogDemo.java:15)
at ExceptionLogDemo.main(ExceptionLogDemo.java:21)
Caused by: java.lang.NumberFormatException: For input string: "12x"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at ExceptionLogDemo.parseQuantity(ExceptionLogDemo.java:8)
at ExceptionLogDemo.loadOrder(ExceptionLogDemo.java:13)
... 1 common frames omittedDòng đầu cho bạn biết có một order bị hỏng. Dòng sau cho bạn biết nó hỏng ở ExceptionLogDemo.java:15, vì parseQuantity bị đưa cho chuỗi "12x" ở dòng 13, và NumberFormatException gốc đến từ Integer.parseInt. Một trong hai dòng đó hành động được; dòng còn lại thì không.
Hai chi tiết trong output đó đáng gọi tên. Argument throwable không bị một placeholder {} tiêu thụ — log.error("could not load order {}", 4711, e) có hai argument cho một placeholder, và SLF4J xử lý Throwable đứng cuối như một trường hợp đặc biệt. Còn ... 1 common frames omitted không phải là cắt bớt: nó có nghĩa các frame còn lại của nguyên nhân trùng khớp với các frame đã in ở phía trên.
java.util.logging và Log4j 1
java.util.logging (JUL) đã có sẵn trong JDK từ Java 1.4 và không cần dependency nào cả. Nó cũng hiếm khi được chọn, và output mặc định cho thấy vì sao:
import java.util.logging.Level;
import java.util.logging.Logger;
public class JulDemo {
private static final Logger log = Logger.getLogger(JulDemo.class.getName());
public static void main(String[] args) {
log.info("settlement run started");
log.fine("this is JUL's debug level and is off by default");
log.log(Level.SEVERE, "settlement failed", new IllegalStateException("no funds"));
}
}Sep 10, 2026 10:59:23 AM JulDemo main
INFO: settlement run started
Sep 10, 2026 10:59:23 AM JulDemo main
SEVERE: settlement failed
java.lang.IllegalStateException: no funds
at JulDemo.main(JulDemo.java:10)Hai dòng cho mỗi bản ghi theo mặc định, một bộ tên level không trực quan (SEVERE, WARNING, INFO, CONFIG, FINE, FINER, FINEST), cấu hình qua file properties cộng system property, và không có lời gọi parameterised nào hoãn được việc format theo kiểu {}. Nó vẫn dùng được, và nếu bạn viết một library nhỏ với quy tắc cứng là không dependency thì đó là lựa chọn thành thật. Ngoài trường hợp đó, jul-to-slf4j tồn tại đúng để output JUL của một dependency được đẩy vào cùng một pipeline với mọi thứ khác.
Log4j 1.x lại là chuyện khác: nó end of life từ tháng 8 năm 2015 và không nhận bản vá nào nữa. Đây không phải chuyện sở thích — một logging framework không còn ai bảo trì là một dependency không có ai vá lỗi. Nếu gặp nó, đích di trú là Log4j 2 (vốn là bản viết lại chứ không phải bản nâng cấp) hoặc Logback qua SLF4J, và log4j-1.2-api tồn tại như một cầu nối để các chỗ gọi cũ vẫn compile được trong khi backend bên dưới đã đổi. Chính lịch sử của Log4j 2 là lý do nên quan tâm đến việc dùng phiên bản mới ở đây: lỗ hổng Log4Shell công bố tháng 12 năm 2021 (CVE-2021-44228) ảnh hưởng tới log4j-core ở một dải rộng các bản 2.x; bản vá đầu tiên ra ở 2.15.0 và các vấn đề phát sinh sau đó được đóng lại tới 2.17.1, và nó là lý do một thư viện logging xứng đáng được đối xử cẩn thận về dependency y như bất cứ thứ gì chạm vào dữ liệu không tin cậy. Hãy dùng bản hiện hành — bài này dùng 2.24.1 — và để build tool báo cho bạn khi một dependency nào đó ghim một bản cũ.
Debug một chương trình Java ngoài phạm vi log
Log cho bạn biết chương trình đã quyết định ghi lại những gì. Debug là việc bạn làm khi cái state đáng quan tâm chưa từng được ghi lại.
Đọc một stack trace
Stack trace được in với frame trong cùng ở trên đầu: dòng trên cùng là nơi exception được tạo ra, và mỗi dòng bên dưới là chỗ gọi của dòng phía trên. Bản năng là đọc dòng đầu rồi dừng, nhưng đó thường là frame sai — đỉnh của một NullPointerException rất hay nằm sâu trong JDK hoặc trong một framework.
public class BugDemo {
static int total(int[] prices) {
int sum = 0;
for (int i = 0; i <= prices.length; i++) {
sum += prices[i];
}
return sum;
}
public static void main(String[] args) {
System.out.println(total(new int[] { 300, 250, 175 }));
}
}Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
at BugDemo.total(BugDemo.java:5)
at BugDemo.main(BugDemo.java:11)Hãy đọc theo thứ tự này. Type và message của exception trước: Index 3 out of bounds for length 3 đã nói cho bạn biết vòng lặp chạy dư một lần, trước cả khi bạn nhìn vào một frame nào. Rồi tìm frame trên cùng thuộc code của bạn — ở đây là BugDemo.total(BugDemo.java:5) — vì đó là dòng bạn sửa được. Rồi đọc xuống dưới để lấy giá trị: BugDemo.main(BugDemo.java:11) là nơi mảng được tạo, và đó là cách bạn biết input là gì. Trong output của ExceptionLogDemo phía trên cũng áp dụng thứ tự đọc đó, kèm thêm quy tắc là các khối Caused by: được in từ ngoài vào trong, nên khối Caused by: cuối cùng mới chứa lỗi gốc.
Breakpoint không cần IDE: jdb
Breakpoint trong IDE là công cụ nhanh nhất ở đây và bạn nên dùng nó. Nhưng jdb có sẵn trong JDK, chạy được qua terminal trên một máy từ xa, và làm lộ rõ breakpoint thực chất là cái gì. Compile với -g để tên biến local còn lại, rồi điều khiển nó bằng lệnh:
javac -g -d out BugDemo.java
jdb -classpath out BugDemo> stop at BugDemo:5
Deferring breakpoint BugDemo:5.
It will be set after the class is loaded.
> run
Set deferred breakpoint BugDemo:5
Breakpoint hit: "thread=main", BugDemo.total(), line=5 bci=10
main[1] where
[1] BugDemo.total (BugDemo.java:5)
[2] BugDemo.main (BugDemo.java:11)
main[1] locals
Method arguments:
prices = instance of int[3] (id=450)
Local variables:
sum = 0
i = 0Ba lệnh cont sau đó, lần dừng thứ tư cho thấy state chứng minh cái bug:
main[1] locals
Method arguments:
prices = instance of int[3] (id=450)
Local variables:
sum = 725
i = 3
main[1] cont
Exception occurred: java.lang.ArrayIndexOutOfBoundsException (uncaught)"thread=main", BugDemo.total(), line=5 bci=13sum = 725 là tổng đúng còn i = 3 đã vượt một bước qua cuối mảng — điều kiện vòng lặp đáng lẽ phải là i < prices.length. Bộ từ vựng hữu ích của jdb khá nhỏ: stop at Class:line và stop in Class.method đặt breakpoint, run, cont và step di chuyển, locals, print expr và dump obj để soi, where in stack, còn watch Class.field dừng lại ở mỗi lần ghi vào một field.
Có hai khái niệm đáng nhớ tên kể cả khi bạn đặt chúng trong IDE. Conditional breakpoint chỉ dừng khi một biểu thức đúng, đó là cách bạn bắt được đúng một vòng lặp trong năm mươi nghìn vòng bị sai mà không phải step qua 49.999 vòng còn lại. Watchpoint — lệnh watch của jdb — dừng khi một field bị đọc hoặc bị ghi thay vì dừng ở một dòng code, đó là công cụ cho tình huống "giá trị này sai mà không biết ai ghi vào".
Soi một JVM đang chạy: jcmd, jstack và jmap
Không thứ nào ở trên giúp được với một process đang chạy trên production và không được phép restart. Cho việc đó, JDK có sẵn các tool attach vào một JVM đang sống theo pid. Tìm pid bằng jps -l, rồi:
jcmd <pid> help # every diagnostic command this JVM supports
jstack <pid> # a full thread dump
jmap -histo:live <pid> # a live-object histogram by classjcmd <pid> help là điểm khởi đầu: trên OpenJDK 21 nó liệt kê Thread.print, GC.class_histogram, GC.heap_info, GC.heap_dump, VM.flags, VM.system_properties, JFR.start và khoảng ba mươi lệnh nữa. jstack và jmap -histo là hai lớp vỏ tiện dụng của hai lệnh trong số đó.
Một thread dump trả lời câu hỏi "ngay lúc này từng thread đang làm gì". Mỗi mục nêu tên thread, trạng thái của nó và in stack của nó:
"order-poller" #20 [28163] daemon prio=5 os_prio=31 ... waiting on condition
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep0(java.base@21.0.6/Native Method)
at java.lang.Thread.sleep(java.base@21.0.6/Thread.java:509)
at LiveApp.lambda$main$0(LiveApp.java:11)
at LiveApp$$Lambda/0x00000078010009f8.run(Unknown Source)
at java.lang.Thread.runWith(java.base@21.0.6/Thread.java:1596)(Hai bộ đếm cpu= và elapsed= trong dòng header phía trên đã được lược bỏ; chúng là số liệu kế toán theo thread, không phải kết quả đo hiệu năng.) Một bài trước trong khóa này đã dùng thread dump để chẩn đoán deadlock, nên ở đây chỉ cần nói thêm rằng dump cũng là cách trả lời những câu hỏi bình thường hơn nhiều: đang có những thread nào, một pool đã bão hòa chưa, và cái thread bạn tưởng đang làm việc có thực sự đang làm việc hay đang park. Hãy lấy hai dump cách nhau vài giây — một thread kẹt ở cùng một frame trong cả hai là vấn đề rất khác với một thread đã đi tiếp.
jmap -histo:live trả lời câu hỏi "cái gì đang làm đầy heap". Nó chạy một lần full GC trước, nên chỉ những object còn reachable mới được đếm:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 207829 6745768 [B (java.base@21.0.6)
2: 207748 4985952 java.lang.String (java.base@21.0.6)
3: 1047 1089288 [Ljava.lang.Object; (java.base@21.0.6)
4: 1507 186312 java.lang.Class (java.base@21.0.6)
5: 1335 44096 [I (java.base@21.0.6)Đó là một process đang giữ một list 200.000 string, và histogram cho thấy đúng như vậy: 207.748 instance String và 207.829 byte[] ([B) tương ứng, vì từ Java 9 một String lưu ký tự trong một byte[]. Histogram sẽ không nói cho bạn biết ai đang giữ chúng — muốn vậy cần một heap dump qua jcmd <pid> GC.heap_dump và một công cụ phân tích heap — nhưng nó thu hẹp một chỗ rò rỉ về một type chỉ bằng một lệnh, và biến thể -histo:live phải trả giá bằng một lần full GC, nên hãy dùng nó có chủ đích trên một service đang tải nặng.
Class đó đến từ jar nào? -verbose:class
-verbose:class khiến JVM in ra từng class nó load cùng với file mà class đó đến từ. Nó rất ồn — một chương trình hello-world trống không cũng load 447 class trên OpenJDK 21.0.6 — nhưng lọc qua grep thì nó trả lời được những câu hỏi mà không công cụ nào khác trả lời gọn được, bao gồm cả câu đố về binding ở đầu bài này:
java -verbose:class -cp out:slf4j-api-2.0.13.jar:logback-classic-1.5.12.jar:\
logback-core-1.5.12.jar:log4j-slf4j2-impl-2.24.1.jar:log4j-api-2.24.1.jar:\
log4j-core-2.24.1.jar FacadeDemo 2>&1 | grep ServiceProvider[0.021s][info][class,load] org.slf4j.spi.SLF4JServiceProvider source: jars/slf4j-api-2.0.13.jar
[0.021s][info][class,load] org.slf4j.helpers.SubstituteServiceProvider source: jars/slf4j-api-2.0.13.jar
[0.023s][info][class,load] org.slf4j.helpers.NOP_FallbackServiceProvider source: jars/slf4j-api-2.0.13.jar
[0.031s][info][class,load] ch.qos.logback.classic.spi.LogbackServiceProvider source: jars/logback-classic-1.5.12.jar
[0.033s][info][class,load] org.apache.logging.slf4j.SLF4JServiceProvider source: jars/log4j-slf4j2-impl-2.24.1.jarMọi provider trên classpath, và đúng jar mà từng cái đến từ đó. Cũng mẹo đó cho biết trong hai jar thì jar nào đã cung cấp class bị trùng khi một NoSuchMethodError xuất hiện lúc runtime — class load được bình thường, chỉ là nó đến từ bản copy sai.
Những thứ không được log
Một file log sẽ được sao chép vào search index, nhân bản qua nhiều node, backup lại, và đọc bởi những người chưa bao giờ được cấp quyền vào dữ liệu production. Mọi thứ trong đó phải sống sót được cuộc hành trình ấy.
Đừng bao giờ log thông tin đăng nhập hay bí mật: password, API key, bearer token, session id, private key. Đừng bao giờ log nguyên request hay response body trên một đường đi có mang chúng, vì "log payload cho dễ debug" chính là cách một password kết thúc trong một index mà cả trăm người tìm kiếm được. Đừng log dữ liệu cá nhân mà bạn sẽ không thoải mái khi phải export ra — họ tên đầy đủ, địa chỉ, số định danh, số thẻ — và nhớ rằng một id kèm timestamp thường đã đủ để đối chiếu.
Cái bẫy nằm ở chỗ bạn hiếm khi cố tình log một bí mật. Bạn log một object, và toString() của nó làm việc đó thay bạn:
record LoginRequest(String email, String password) {}
record SafeLoginRequest(String email, String password) {
@Override public String toString() {
return "SafeLoginRequest[email=" + email + ", password=***]";
}
}10:59:59.149 INFO SecretsDemo - login attempt: LoginRequest[email=a@example.com, password=hunter2]
10:59:59.155 INFO SecretsDemo - login attempt: SafeLoginRequest[email=a@example.com, password=***]toString() do compiler sinh ra cho một record in mọi component, nên bản đầu tiên đã làm rò password vào log bằng một lời gọi trông hoàn toàn vô hại. Hãy override toString() trên mọi type có mang bí mật, log định danh thay vì object trên những đường đi nhạy cảm, và coi "object này in ra cái gì" là một phần trong contract công khai của type.
⚠️ Logging thường là đường xuất dữ liệu rộng nhất của một hệ thống, và là đường không ai đi review. Một lệnh
log.debugin ra request body đủ sức vô hiệu hóa mọi lớp kiểm soát truy cập đặt trước database.
FAQ
SLF4J, Logback và Log4j 2 khác nhau ở chỗ nào?
SLF4J là một API và chỉ có vậy — interface Logger mà code của bạn gọi, không có khả năng ghi một dòng ra bất cứ đâu. Logback và Log4j 2 là các implementation làm việc ghi đó, mỗi cái có file cấu hình và bộ tính năng riêng. Code của bạn depend vào slf4j-api; application của bạn thêm đúng một jar binding (logback-classic, hoặc log4j-slf4j2-impl cho Log4j 2) để quyết định implementation nào chạy. Logback do chính tác giả SLF4J viết và là mặc định phổ biến; Log4j 2 là một project Apache riêng với phần lõi được viết lại. Cả hai đều được điều khiển bằng cùng những chuỗi pattern trong các ví dụ trên và cho ra output chỉ khác nhau ở timestamp.
Vì sao application của tôi không in ra log nào cả?
Nguyên nhân khả dĩ nhất là không có SLF4J provider nào trên classpath. Hãy nhìn standard error tìm dòng SLF4J(W): No SLF4J providers were found. — khi không có provider, LoggerFactory trả về một NOPLogger với các method rỗng, nên mọi lệnh log đều thành công trong im lặng. Nguyên nhân khả dĩ thứ hai là threshold: một config root level="warn" bỏ hết mọi lệnh info và debug. Thứ ba là một file config không được đọc, thứ mà Logback sẽ báo cho bạn nếu bạn thêm <configuration debug="true"> và Log4j 2 sẽ báo qua thuộc tính status của nó.
Nên dùng log.debug với nối chuỗi hay với placeholder?
Luôn dùng placeholder. Với nối chuỗi, message do chỗ gọi dựng ra trước khi logger được hỏi ý kiến, nên công việc vẫn diễn ra kể cả khi level bị tắt — bộ đếm trong bài này ghi nhận 1000 lần gọi toString() cho 1000 lệnh debug bị bỏ, so với 0 lần ở bản dùng placeholder. Placeholder không hoãn được việc tính chính biểu thức argument, nên nếu việc tạo ra argument là đắt, hãy bọc lời gọi trong if (log.isDebugEnabled()) hoặc dùng dạng fluent của SLF4J 2 là log.atDebug().addArgument(() -> expensive()).log("..."), thứ cũng đưa bộ đếm về 0.
Làm sao giữ giá trị MDC qua một thread pool?
Chụp lại và khôi phục nó một cách tường minh. MDC dựa trên một map thread-local, nên một task submit vào executor chạy trên một thread có map riêng của nó, thường là rỗng — trong lần chạy ở trên, pool thread in ra [req=none] trong khi thread gọi in ra [req=r-8f31]. Hãy chụp một bản trên thread gọi bằng MDC.getCopyOfContextMap(), gọi MDC.setContextMap(snapshot) làm câu lệnh đầu tiên trong task, và MDC.clear() trong khối finally để pool thread không mang giá trị đó sang một task không liên quan. Bọc executor một lần để mọi task submit vào đều làm việc này sẽ tốt hơn là phải nhớ ở từng chỗ gọi.
Cách log exception đúng trong Java là gì?
Truyền throwable làm argument cuối: log.error("could not load order {}", orderId, e). SLF4J xử lý Throwable đứng cuối như trường hợp đặc biệt nên nó không cần placeholder {}, và binding sẽ in ra toàn bộ stack trace kể cả mọi khối Caused by:. Nối e.getMessage() vào message chỉ giữ lại một câu và vứt bỏ các frame, đó là khác biệt giữa việc biết một order bị hỏng và việc biết nó hỏng ở dòng 15 vì parseQuantity bị đưa cho "12x". Đừng vừa log vừa rethrow cùng một exception — bạn sẽ nhận đúng stack trace đó hai lần từ hai chỗ khác nhau.
Làm sao soi một process Java đang chạy?
Theo pid, bằng các tool có sẵn trong JDK. jps -l liệt kê các JVM đang chạy; jcmd <pid> help liệt kê mọi lệnh chẩn đoán mà JVM đó hỗ trợ; jstack <pid> (hoặc jcmd <pid> Thread.print) dump mọi thread kèm trạng thái và stack; jmap -histo:live <pid> in histogram theo class của các object còn reachable, thu hẹp một vấn đề bộ nhớ về một type chỉ bằng một lệnh; và jcmd <pid> GC.heap_dump ghi ra một heap dump đầy đủ để phân tích offline. Không lệnh nào trong số này đòi process phải được khởi động với flag đặc biệt, và -histo:live ép một lần full GC, nên hãy coi nó là một thao tác xâm lấn trên service đang tải nặng.
Sau Log4Shell thì Log4j còn dùng được không?
Các bản Log4j 2 hiện hành vẫn được bảo trì tích cực và là lựa chọn bình thường; ví dụ ở đây dùng 2.24.1. Log4Shell (CVE-2021-44228, tháng 12 năm 2021) ảnh hưởng tới log4j-core ở một dải các bản 2.x, được vá lần đầu ở 2.15.0 và đóng lại hoàn toàn ở 2.17.1, nên điều nó đòi hỏi là dùng phiên bản hiện hành chứ không phải tránh xa project này. Log4j 1.x thì khác hẳn: nó end of life từ 2015 và không nhận bản vá nào, nên phải di trú khỏi nó chứ không phải ghim lại nó. Biện pháp thực tế là một build tool có thể báo cáo phiên bản log4j-core nào thực sự được resolve trong cây dependency của bạn, vì một dependency gián tiếp có thể ghim một bản cũ mà bạn không hề biết.
Kết luận
Ý duy nhất khiến logging trong Java trở nên dễ hiểu là: slf4j-api chỉ là interface và không hơn. Mọi thứ kỳ lạ đều suy ra từ đó — không có binding thì lệnh log của bạn vẫn compile, vẫn chạy và không in gì; có một binding thì jar đó chọn format cho bạn; có hai binding thì cái đứng trước trên classpath thắng trong im lặng và file config của bạn bị bỏ qua. Khi điều đó đã rõ, phần còn lại chỉ là một danh sách thói quen ngắn — dùng placeholder {} để lệnh bị bỏ không tốn gì, truyền throwable làm argument cuối để stack trace sống sót, đặt level theo package thay vì theo toàn cục, đặt key cố định vào dòng log thay vì văn xuôi, khôi phục MDC bằng tay khi bàn giao qua thread khác, và đừng bao giờ để toString() của một object quyết định cái gì rời khỏi hệ thống của bạn.
Nửa debug cũng là đúng kỷ luật đó nhưng chĩa vào một process thay vì vào một file. Hãy đọc stack trace theo message và theo frame trên cùng thuộc code của bạn, chứ không phải theo frame sâu nhất. Dùng conditional breakpoint khi lỗi chỉ xảy ra ở một vòng trong năm mươi nghìn vòng, và dùng watchpoint khi một field bị sai mà không ai nhận đã ghi vào. Và nhớ rằng một JVM trên production không phải hộp đen: jps, jcmd, jstack, jmap và -verbose:class sẽ cho bạn biết từng thread đang làm gì, cái gì đang làm đầy heap, và một class thực sự đến từ jar nào, mà không phải restart bất cứ thứ gì.
Mọi ví dụ ở đây đều được chạy bằng cách đặt jar lên classpath bằng tay, đúng là việc bạn không nên làm đến lần thứ hai. Bài kế tiếp nói về Maven và Gradle: khai báo dependency, giải quyết các xung đột phiên bản vốn là nguồn gốc sinh ra hai SLF4J binding trùng nhau, và dựng một project tự lo lấy classpath của chính nó.