Java không có hỗ trợ JSON nào trong standard library. Không có java.json, không có JsonParser trong java.base, không có gì JDK cho sẵn cho định dạng mà mọi HTTP API trên đời đều nói. Thứ mà hệ sinh thái chốt lại là hai thư viện: Gson của Google và Jackson của FasterXML. Cả hai làm cùng một việc — biến một object graph Java thành một JSON document và ngược lại — và cả hai chỉ cách bạn ba dòng code.
Phần thú vị không nằm ở ba dòng đó. Nó nằm ở tất cả những gì mapper tự quyết định thay bạn sau khi ba dòng đó chạy: một field null được ghi ra hay bị bỏ, một key mà class của bạn không có là lỗi hay chỉ là chuyện nhỏ, một List của type bạn sống sót thế nào qua erasure, chuyện gì xảy ra với LocalDate, và trong số đó quyết định nào báo lỗi ngay tại chỗ bạn làm sai. Phần lớn là không.
![]()
Mọi output và mọi message lỗi bên dưới đều lấy từ việc compile và chạy code thật trên OpenJDK 21.0.6 (arm64) với Gson 2.10.1, jackson-databind / jackson-core / jackson-annotations 2.17.3, jackson-datatype-jsr310 2.17.3, và — chỉ cho phần XML — jackson-dataformat-xml 2.17.3 cùng woodstox-core 6.6.2 và stax2-api 4.2.2. Hành vi khác nhau giữa các version, nên những con số trong các coordinate đó là quan trọng. Bài viết không có bất kỳ con số thời gian nào: hai thư viện được so sánh trên hình dạng API, default, khối lượng dependency và tính năng — những thứ vẫn đúng khi bạn chạy trên máy của bạn thay vì máy của tôi.
Một JSON mapper thực sự làm gì
Binding là một mapping giữa một JSON document và một object graph Java, áp dụng cho từng cái tên một. toJson và writeValueAsString đi khắp object graph rồi sinh ra text. fromJson và readValue đi khắp text rồi dựng lại object graph. Đó là cùng một mapping đọc theo hai chiều, nên một lỗi ở chiều này thường lộ ra thành một bất ngờ ở chiều kia.

Để làm được vậy, mapper phải trả lời bốn câu hỏi cho mọi giá trị nó gặp:
| Câu hỏi | Nó quyết định điều gì |
|---|---|
| JSON key nào ứng với thành phần nào trong Java? | khớp theo tên, cộng annotation đổi tên hoặc naming policy |
| Còn một key không có thành phần nào khớp? | lờ đi, hoặc fail |
| Còn một thành phần không có key nào khớp? | để nguyên giá trị mặc định, hoặc fail |
| Instance được tạo ra bằng cách nào? | constructor rỗng, canonical constructor của record, creator có annotation, hay cấp phát bằng reflection |
Gson và Jackson đều trả lời cả bốn. Chúng trả lời khác nhau ở hai câu ngay từ mặc định, và hai câu đó là nguồn gốc của phần lớn sự bối rối trong mảng này.
Đưa Gson và Jackson vào classpath
Gson là một artifact duy nhất, không có runtime dependency nào — POM của nó khai báo đúng một dependency là JUnit, ở test scope. Module databind của Jackson là ba artifact, vì jackson-databind phụ thuộc jackson-core (lớp streaming) và jackson-annotations (bộ annotation) ở compile scope. Mọi thứ ngoài JSON lõi — hỗ trợ java.time, XML, YAML, Kotlin — là thêm một module nữa chồng lên.
| Gson | Jackson | |
|---|---|---|
| Coordinate | com.google.code.gson:gson:2.10.1 | com.fasterxml.jackson.core:jackson-databind:2.17.3 |
| Số jar kéo theo | 1 | 3 |
| Dung lượng trên đĩa | 277 KB | tổng 2.2 MB |
| Điểm vào | Gson / GsonBuilder | ObjectMapper / JsonMapper.builder() |
| Streaming reader | com.google.gson.stream.JsonReader | com.fasterxml.jackson.core.JsonParser |
| Tree node | JsonElement / JsonObject | JsonNode |
| Cấu hình | các method của builder | feature, module và annotation |
Với một build tool thì các coordinate ở trên là đủ. Không có build tool thì classpath phải viết tay:
javac -cp gson-2.10.1.jar RoundTrip.java
java -cp gson-2.10.1.jar:. RoundTrip
javac -cp jackson-databind-2.17.3.jar:jackson-core-2.17.3.jar:jackson-annotations-2.17.3.jar RoundTrip.java
java -cp jackson-databind-2.17.3.jar:jackson-core-2.17.3.jar:jackson-annotations-2.17.3.jar:. RoundTripCả hai điểm vào đều được tài liệu ghi là dùng chung được. Javadoc của Gson nói một instance Gson là thread-safe; javadoc của Jackson nói một ObjectMapper hoàn toàn thread-safe miễn là mọi cấu hình diễn ra trước bất kỳ lời gọi đọc hay ghi nào. Tạo một cái, cấu hình một lần, giữ trong một field static final. Tạo mapper mới cho mỗi request là kiểu lãng phí vô tình phổ biến nhất ở mảng này.
Cùng một round trip trong cả hai thư viện
Một model, cố tình rất bình thường: primitive, một String, một boolean, một List và một object lồng bên trong.
class Address {
String city;
String zip;
Address() {}
Address(String city, String zip) { this.city = city; this.zip = zip; }
public String getCity() { return city; }
public void setCity(String c) { city = c; }
public String getZip() { return zip; }
public void setZip(String z) { zip = z; }
@Override public String toString() { return "Address[" + city + ", " + zip + "]"; }
}
public class Model {
long id;
String name;
String email;
boolean active;
List<String> roles;
Address address;
// constructor rỗng, constructor đủ tham số, getter, setter, toString
}Model u = new Model(7L, "Mai", "mai@example.com", true,
List.of("admin", "editor"), new Address("Da Nang", "550000"));
Gson gson = new Gson();
String gj = gson.toJson(u);
ObjectMapper mapper = new ObjectMapper();
String jj = mapper.writeValueAsString(u);
System.out.println("equal strings? " + gj.equals(jj));
Model back1 = gson.fromJson(gj, Model.class);
Model back2 = mapper.readValue(jj, Model.class);gson : {"id":7,"name":"Mai","email":"mai@example.com","active":true,"roles":["admin","editor"],"address":{"city":"Da Nang","zip":"550000"}}
jackson: {"id":7,"name":"Mai","email":"mai@example.com","active":true,"roles":["admin","editor"],"address":{"city":"Da Nang","zip":"550000"}}
equal strings? true
gson -> Model[id=7, name=Mai, email=mai@example.com, active=true, roles=[admin, editor], address=Address[Da Nang, 550000]]
jackson -> Model[id=7, name=Mai, email=mai@example.com, active=true, roles=[admin, editor], address=Address[Da Nang, 550000]]Output giống nhau từng byte, object dựng lại y hệt nhau. Nested object và collection không cần cấu hình gì ở cả hai thư viện: mapper đi vào Address và vào List<String> vì type khai báo đã nói cho nó biết bên trong có gì. Cả hai điểm vào cũng đọc ghi được stream và file chứ không chỉ String — gson.fromJson(new FileReader(f), Model.class) và mapper.readValue(new File(f), Model.class) — và bạn nên chọn cách đó thay vì nuốt cả file vào một String trước.
Sự giống nhau đó là điều cuối cùng hai thư viện còn đồng ý với nhau.
Chỗ hai bên khác nhau ở default
Cùng một class, cùng hai lời gọi, cùng một chuỗi input. Chỉ console là khác.

Field null
Gson bỏ chúng đi. Jackson ghi chúng ra.
class Account {
public String user = "mai";
public String nickname = null;
public Integer age = null;
}gson default : {"user":"mai"}
gson serializeNulls : {"user":"mai","nickname":null,"age":null}
jackson default : {"user":"mai","nickname":null,"age":null}
jackson NON_NULL ann: {"user":"mai"}
jackson NON_NULL glb: {"user":"mai"}Cả hai chỉ cách nhau một lời gọi. new GsonBuilder().serializeNulls().create() khiến Gson ghi chúng ra; @JsonInclude(JsonInclude.Include.NON_NULL) đặt trên class, hoặc setSerializationInclusion(JsonInclude.Include.NON_NULL) đặt trên mapper, khiến Jackson bỏ chúng đi. Chuyện này quan trọng hơn vẻ ngoài của nó: một bên tiêu thụ API mà phân biệt "field vắng mặt" với "field null một cách tường minh" — chẳng hạn một endpoint PATCH — sẽ đọc hai document ở trên thành hai request khác nhau.
Key lạ
Gson lờ đi một key mà class không có. Jackson từ chối nó.
String extra = "{\"name\":\"Mai\",\"age\":30,\"nickname\":\"m\"}";gson unknown key : User[name=Mai, age=30]
jackson unknown key FAILED: com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
Unrecognized field "nickname" (class Pitfalls$User), not marked as ignorable (2 known properties: "name", "age"])
jackson lenient : User[name=Mai, age=30]Sự nghiêm khắc của Jackson là một tính năng thật khi bạn sở hữu cả bên sinh ra document, vì gõ sai một key sẽ thành lỗi ném ra ngay thay vì một giá trị thiếu trong im lặng. Nó là một gánh nặng thật khi bạn tiêu thụ API của người khác và họ thêm một field mới. Tắt toàn cục bằng disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) hoặc tắt cho từng class bằng @JsonIgnoreProperties(ignoreUnknown = true).
Input hỏng và input thừa
Tiếng đồn là Gson dễ dãi còn Jackson nghiêm khắc. Ở hai trường hợp biên bên dưới thì ngược lại.
== trailing content {"name":"Mai","age":30} EXTRA
gson !! JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 1 column 26 path $
jackson -> User[Mai,30]
== empty string
gson -> null
jackson !! MismatchedInputException: No content to map due to end-of-inputreadValue của Jackson dừng lại khi giá trị đầu tiên kết thúc và không nhìn tới phần đứng sau, trừ khi bạn bật DeserializationFeature.FAIL_ON_TRAILING_TOKENS — với nó thì chính input đó cho ra JsonParseException: Unrecognized token 'EXTRA'. Gson từ chối token thừa ngay từ mặc định và trả null cho một document rỗng. Về việc ép kiểu giá trị thì hai bên đồng ý: "age":"30" dạng chuỗi được cả hai nhận vào một int, còn "age":"old" fail ở cả hai, lần lượt là JsonSyntaxException bọc một NumberFormatException và InvalidFormatException.
Đổi tên, bỏ qua và pretty print
Tên trong JSON và tên trong Java không bắt buộc phải trùng nhau, và có những field không bao giờ nên rời khỏi process.
class GsonUser {
@SerializedName("user_name") String userName = "mai";
transient String password = "s3cret";
String email = "mai@example.com";
}
class JacksonUser {
@JsonProperty("user_name") public String userName = "mai";
@JsonIgnore public String password = "s3cret";
public String email = "mai@example.com";
}gson : {"user_name":"mai","email":"mai@example.com"}
gson pretty:
{
"user_name": "mai",
"email": "mai@example.com"
}
jackson : {"email":"mai@example.com","user_name":"mai"}
jackson pretty:
{
"email" : "mai@example.com",
"user_name" : "mai"
}
gson read back userName=lan password=s3cret
jackson read back userName=lan password=s3cretCó ba chi tiết trong output đó đáng gọi tên ra.
Gson loại một field đánh dấu transient, tái sử dụng đúng từ khóa mà ngôn ngữ đã có cho ý "không thuộc phần trạng thái được lưu"; Jackson cần @JsonIgnore, vì nó nhìn vào getter và setter chứ không nhìn vào modifier của field. Cả hai đều để nguyên password khi đọc ngược trở lại, và đó đúng là hành vi bạn muốn cho một field không bao giờ nên đến từ ngoài mạng.
Pretty printer mặc định của Jackson ghi một dấu cách trước dấu hai chấm — "email" : "..." — trong khi Gson ghi "email": "...". Vô hại, và bất ngờ một cách rất đáng tin cậy ở lần đầu tiên một test so sánh với file mẫu.
Và Jackson đã đảo thứ tự property. Chuyện đó không ngẫu nhiên: một property có tên tường minh bằng @JsonProperty được ghi ra sau những property mà tên được suy ra.
no annotations : {"zeta":"1","alpha":"2","mid":"3"}
one @JsonProperty: {"zeta":"1","mid":"3","alpha_x":"2"}Nếu thứ tự key quan trọng với bạn, hãy ghim nó bằng @JsonPropertyOrder thay vì trông chờ vào thứ tự khai báo.
Với quy ước áp cho cả model, hãy dùng naming policy thay vì gắn annotation lên từng field:
Gson snake = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create();
ObjectMapper m = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);gson snake_case : {"first_name":"Mai","last_login_host":"vn-1"}
jackson snake_case: {"first_name":"Mai","last_login_host":"vn-1"}Generics và type erasure
Đây là câu hỏi về JSON được hỏi nhiều nhất trong Java, và lời giải có cùng một hình dạng ở cả hai thư viện: một object Class không mang theo được type argument, nên bạn phải luồn trọn vẹn type vào bằng đường khác.
Truyền List.class thì compile được, chạy được, và cho ra một thứ không phải cái bạn hỏi.
static final String JSON = "[{\"name\":\"Mai\",\"age\":30},{\"name\":\"Lan\",\"age\":25}]";
List<User> gsonWrong = gson.fromJson(JSON, List.class);
List<User> jacksonWrong = mapper.readValue(JSON, List.class);gson parsed size = 2
jackson parsed size = 2
gson element type = com.google.gson.internal.LinkedTreeMap
jackson element type = java.util.LinkedHashMap
gson CCE: class com.google.gson.internal.LinkedTreeMap cannot be cast to class Erasure$User (com.google.gson.internal.LinkedTreeMap and Erasure$User are in unnamed module of loader 'app')
jackson CCE: class java.util.LinkedHashMap cannot be cast to class Erasure$User (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; Erasure$User is in unnamed module of loader 'app')Hãy đọc kỹ đoạn đó, vì chính cơ chế mới là thứ làm cái bug này khó hiểu. Cả hai lần parse đều thành công. List có đúng số phần tử. Thứ nó chứa là một map chung chung cho mỗi phần tử — LinkedTreeMap với Gson, LinkedHashMap với Jackson — bởi vì khi element type đã bị xóa thì không còn gì nói cho mapper biết phải dựng cái gì. Không có gì được ném ra ở thời điểm đó.
ClassCastException đến muộn hơn, tại dòng code đầu tiên đối xử với một phần tử như một User. Compiler chèn checkcast ở đó chứ không phải ở chỗ parse, nên stack trace chỉ vào đoạn code vô tội nào đó ở phía sau, thường là ở một class khác và đôi khi ở một thread khác. Số dòng được báo chẳng liên quan gì tới chỗ bạn làm sai.
Cách sửa là một type literal dạng subclass: một anonymous subclass mà type argument của superclass còn sống sót vào file class, nơi thư viện đọc lại được bằng reflection.
List<User> g = gson.fromJson(JSON, new TypeToken<List<User>>(){}.getType());
List<User> j = mapper.readValue(JSON, new TypeReference<List<User>>(){});
List<User> j2 = mapper.readValue(JSON,
mapper.getTypeFactory().constructCollectionType(List.class, User.class));
User[] ga = gson.fromJson(JSON, User[].class);
User[] ja = mapper.readValue(JSON, User[].class);
Map<String, User> gm = gson.fromJson(MAP_JSON, new TypeToken<Map<String, User>>(){}.getType());
Map<String, User> jm = mapper.readValue(MAP_JSON, new TypeReference<Map<String, User>>(){});gson TypeToken -> [User[Mai,30], User[Lan,25]] element User
jackson TypeReference -> [User[Mai,30], User[Lan,25]] element User
jackson JavaType -> [User[Mai,30], User[Lan,25]]
gson User[].class -> [User[Mai,30], User[Lan,25]]
jackson User[].class -> [User[Mai,30], User[Lan,25]]
gson Map -> {a=User[Mai,30]} value User
jackson Map -> {a=User[Mai,30]} value UserCặp {} ở cuối trong new TypeToken<List<User>>(){} không phải để trang trí — nó chính là thứ tạo ra anonymous subclass. Bỏ nó đi thì Gson chẳng còn gì để đọc. Cũng để ý dòng thứ tư và thứ năm: một array type là một Class bình thường, không dính vấn đề erasure, nên User[].class chạy được ở cả hai thư viện mà không cần type literal nào. Nếu hình dạng dữ liệu của bạn cho phép, đó là lựa chọn ít rườm rà nhất.
Ngày và giờ
Cái bẫy kinh điển thứ hai. Không thư viện nào xử lý java.time ngay từ đầu, và cả hai đều fail rõ ràng chứ không âm thầm — sau phần trước thì đó là một sự nhẹ nhõm.
class Event {
public String name = "launch";
public LocalDate day = LocalDate.of(2026, 9, 16);
public Instant at = Instant.parse("2026-09-16T08:30:00Z");
}gson FAILED: com.google.gson.JsonIOException
Failed making field 'java.time.LocalDate#year' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.
jackson FAILED: com.fasterxml.jackson.databind.exc.InvalidDefinitionException
Java 8 date/time type `java.time.LocalDate` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: Dates$Event["day"])Hai message mô tả hai vấn đề khác nhau. Chiến lược dựa trên reflection của Gson đâm thẳng vào module encapsulation: java.time.LocalDate nằm trong java.base, các field của nó không mở cho unnamed module, và setAccessible bị từ chối. Jackson biết type đó không được hỗ trợ và gọi tên luôn module hỗ trợ nó.
Câu trả lời của Gson là một TypeAdapter cho mỗi type, đăng ký trên builder:
class LocalDateAdapter extends TypeAdapter<LocalDate> {
public void write(JsonWriter out, LocalDate v) throws IOException {
if (v == null) out.nullValue(); else out.value(v.format(DateTimeFormatter.ISO_LOCAL_DATE));
}
public LocalDate read(JsonReader in) throws IOException {
return LocalDate.parse(in.nextString(), DateTimeFormatter.ISO_LOCAL_DATE);
}
}
Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class, new LocalDateAdapter())
.registerTypeAdapter(Instant.class, new InstantAdapter())
.create();Câu trả lời của Jackson là thêm một artifact, com.fasterxml.jackson.datatype:jackson-datatype-jsr310, đăng ký như một module. Và đây là phần không ai cảnh báo bạn: chỉ đăng ký module thôi thì chưa phải là cách sửa mà bạn muốn.
gson adapters : {"name":"launch","day":"2026-09-16","at":"2026-09-16T08:30:00Z"}
gson round trip : 2026-09-16 / 2026-09-16T08:30:00Z
jackson module : {"name":"launch","day":[2026,9,16],"at":1789547400.000000000}
jackson ISO strings : {"name":"launch","day":"2026-09-16","at":"2026-09-16T08:30:00Z"}
jackson round trip : 2026-09-16 / 2026-09-16T08:30:00ZKhi chỉ đăng ký module mà không làm gì thêm, LocalDate được ghi thành một mảng ba phần tử còn Instant thành một epoch second có phần thập phân. Cả hai đều round trip đúng qua chính Jackson, và cả hai đều vô dụng với một client JavaScript, một lệnh grep trong log hay một con người. Tắt SerializationFeature.WRITE_DATES_AS_TIMESTAMPS sẽ chuyển cả module sang chuỗi ISO-8601, và đó gần như luôn là ý bạn muốn:
ObjectMapper m = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();Cái builder đó là hình mẫu đáng chép vào dự án: một chỗ duy nhất, bốn quyết định, và cái nào cũng được đưa ra tường minh thay vì thừa hưởng.
Đọc mà không cần class: tree API
Đôi khi chẳng có class nào để bind — một webhook có hình dạng thay đổi, một khối config, hay chỉ một field trong một response lớn. Cả hai thư viện đều có tree model cho việc đó.
JsonObject o = JsonParser.parseString(j).getAsJsonObject();
o.getAsJsonObject("user").get("name").getAsString();
o.getAsJsonObject("user").getAsJsonArray("roles").get(0).getAsString();
JsonNode n = mapper.readTree(j);
n.at("/user/name").asText();
n.at("/user/roles/0").asText();
n.path("missing").asText("<default>");gson name = Mai
gson role0 = admin
gson absent= null
jackson name = Mai
jackson role0 = admin
jackson absent= true
jackson path = <default>at() của Jackson nhận một JSON Pointer, và đó là thứ tiện nhất trong cả hai API để với vào sâu trong một document, đồng thời nó trả về một missing node thay vì null cho một đường dẫn không tồn tại. path() làm điều tương tự theo từng bậc, nên một chuỗi path().path() không bao giờ ném NullPointerException giữa chừng. get() của Gson trả về null thật, nên bạn phải kiểm tra dọc đường.
Tree API cũng là nơi có một cái bẫy về số không có type. JSON có một kiểu số; Java có sáu. Phải có ai đó chọn.
Map<String, Object> gm = gson.fromJson(j, Map.class);
Map<String, Object> jm = mapper.readValue(j, Map.class);gson count = 42.0 (Double)
gson ratio = 1.5 (Double)
gson big = 9.007199254740992E15 (Double)
jackson count = 42 (Integer)
jackson ratio = 1.5 (Double)
jackson big = 9007199254740993 (Long)Mặc định của Gson cho một số không có type là Double, luôn luôn. Số 42 của bạn quay về thành 42.0, in ra là 42.0, và nối chuỗi thành 42.0. Tệ hơn, 9007199254740993 vượt quá khả năng biểu diễn chính xác của một double và quay về thành 9.007199254740992E15 — một con số khác đi trong im lặng, và đó đúng là kiểu hỏng bạn gặp với ID lớn từ một hệ thống khác. Jackson chọn Integer, Long hay Double theo đúng thứ mà giá trị đó cần.
Gson đã có cách sửa từ bản 2.9:
Gson fixed = new GsonBuilder()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();gson LONG_OR_DOUBLE count = 42 (Long)
gson LONG_OR_DOUBLE big = 9007199254740993 (Long)Chuyện này không ảnh hưởng gì tới một bind có type: một field khai báo long sẽ nhận một long ở cả hai thư viện. Nó chỉ cắn khi type đích là Object, Map hay một tree node — mà đó chính xác là chỗ người ta với tay tới khi đang làm cho nhanh.
Những cái bẫy lộ ra ở chỗ khác
Bốn cái bẫy, và điểm chung là ba trong bốn cái đưa cho bạn một object rồi để bạn tự phát hiện vấn đề sau đó.

Key thiếu để lại giá trị mặc định, không phải lỗi
gson missing key : User[name=Mai, age=0]
jackson missing key : User[name=Mai, age=0]Không thư viện nào phàn nàn. age khai báo là int, int không có null, nên field giữ nguyên giá trị mặc định 0 — và 0 là một tuổi hợp lý, một mức giá hợp lý, một số lượng hợp lý. Hãy khai báo những số có thể vắng mặt là Integer và Long để một key thiếu phân biệt được với một số 0 thật, rồi validate sau khi bind chứ đừng hy vọng mapper làm hộ.
Một private field không có getter
class Hidden {
private String secretNote = "kept";
private String shown = "visible";
public String getShown() { return shown; }
public void setShown(String s) { shown = s; }
}gson private field : {"secretNote":"kept","shown":"visible"}
jackson private field : {"shown":"visible"}Gson reflect trên các field khai báo, nên private không phải một ranh giới và secretNote đi thẳng ra ngoài mạng. Jackson mặc định làm việc từ getter và setter, nên một field không có accessor đơn giản là không phải một property. Nếu bạn đang chuyển một service từ bên này sang bên kia, đây là khác biệt dễ làm rò rỉ thứ gì đó nhất.
Record thì ổn; class không có constructor rỗng thì tệ hơn bạn nghĩ
Record không cần xử lý đặc biệt ở cả hai thư viện tại các version này — Jackson hỗ trợ native từ 2.12, còn Gson từ 2.10 khi chạy trên Java 16 trở lên.
gson record write : {"x":3,"y":4}
jackson record write : {"x":3,"y":4}
gson record read : Point[x=3, y=4]
jackson record read : Point[x=3, y=4]Một class thường chỉ có constructor đủ tham số mới là chỗ hai bên chia tay nhau:
gson no-arg absent : NoDefault[Mai,30]
jackson no-arg absent FAILED: com.fasterxml.jackson.databind.exc.InvalidDefinitionException
Cannot construct instance of `Access$NoDefault` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)Jackson từ chối và bảo bạn thêm một constructor rỗng hoặc một creator có annotation. Gson thành công — và chính việc thành công mới là vấn đề, vì nó cấp phát instance mà không chạy bất kỳ constructor nào:
class Order {
private final String id;
private final int quantity;
private final List<String> lines;
public Order(String id, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
this.id = id;
this.quantity = quantity;
this.lines = new ArrayList<>();
}
public int lineCount() { return lines.size(); }
}gson built: Order[id=A-1, quantity=-5, lines=null]
lineCount threw: java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because "this.lines" is nullPhần validate không hề chạy, nên quantity là -5. Field mà constructor luôn khởi tạo lại là null, vì chẳng có constructor nào chạy. Object trông vẫn ổn cho tới khi có ai gọi một method trên nó, rồi stack trace chỉ vào lineCount, một đoạn code hoàn toàn đúng. Mọi invariant mà constructor của bạn bảo vệ đều bị vô hiệu ngay khi một mapper dựng object bằng reflection. Hãy cho class một constructor rỗng rồi validate tường minh sau khi bind, hoặc dùng record và để canonical constructor làm việc đó.
JSON không tin cậy và polymorphic type handling
Đây đúng là lập luận mà object serialization có sẵn của Java thua cuộc. Một định dạng cho phép document đi vào tự chọn class nào được tạo ra tức là giao một phần luồng điều khiển của bạn cho người viết document đó, và mọi class với tới được từ classpath của bạn trở thành bề mặt tấn công. Serialization có sẵn của Java mang tính chất đó ngay trong bản chất, và đó là lý do nó không được khuyến khích cho bất cứ thứ gì vượt qua một ranh giới tin cậy.
Binding JSON thông thường không có tính chất đó. readValue(json, User.class) dựng ra một User và không gì khác, bất kể document nói gì. Default typing của Jackson chính là tính năng phá vỡ điều đó:
ObjectMapper m = new ObjectMapper()
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL);written : ["DefaultTyping$Holder",{"payload":["DefaultTyping$Thing",{"label":"x"}]}]Tên class Java bây giờ là một phần của document, và ở chiều đọc vào thì chính document quyết định dựng cái gì. Đó là cơ chế, và đó là lý do mọi cảnh báo bảo mật nghiêm túc về Jackson trong nhiều năm qua đều nói về default typing chứ không phải về binding.
Hãy để nó tắt trừ khi bạn kiểm soát cả hai đầu. Từ bản 2.10 các overload không kiểm tra đã biến khỏi con đường được khuyến nghị: activateDefaultTyping bắt buộc có một PolymorphicTypeValidator, và một type ngoài danh sách cho phép sẽ bị từ chối.
blocked : InvalidTypeIdException: Could not resolve type id 'java.util.ArrayList' as a subtype of `java.lang.Object`: Configured `PolymorphicTypeValidator` (of type `com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolutionKhi bạn thật sự cần polymorphism, hãy khai báo một thế giới đóng. @JsonTypeInfo cùng @JsonSubTypes liệt kê các subtype được phép ngay trên base type và ghi ra một discriminator ngắn thay vì một tên class:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
@JsonSubTypes.Type(value = Card.class, name = "card"),
@JsonSubTypes.Type(value = Bank.class, name = "bank")
})
abstract class Payment { public int amount; }jackson write : {"payment":{"kind":"card","amount":100,"last4":"4242"}}
jackson read : Card amount=100
gson write : {"payment":{"last4":"4242","amount":100}}
gson read FAILED: com.google.gson.JsonIOException
Abstract classes can't be instantiated! Register an InstanceCreator or a TypeAdapter for this type. Class name: Poly$PaymentDocument mang theo "kind":"card", mapper tra nó trong một danh sách cố định, và một giá trị lạ là lỗi chứ không phải một lần nạp class. Jar lõi của Gson không có thứ tương đương: nó ghi ra các field của subclass tại runtime mà không kèm discriminator nào, rồi không đọc lại được chính output của mình. Polymorphism trong Gson nghĩa là một TypeAdapterFactory viết tay, hoặc RuntimeTypeAdapterFactory nằm ở dự án gson-extras riêng chứ không nằm trong gson-2.10.1.jar.
Streaming một document quá lớn để bind
Bind cả document sẽ dựng trọn object graph trong bộ nhớ trước khi bạn chạm vào bất cứ phần nào. Với một response vài trăm kilobyte thì đó là đánh đổi đúng. Với một file export thì không, và cách nó hỏng rất đột ngột.
Cả hai thư viện đều lộ ra một pull parser nằm dưới lớp binding, và cả hai đều cho bạn bind từng record một từ bên trong nó — nhờ vậy bạn có object có type mà không bao giờ giữ quá một record:
static long gsonStream(String path) throws IOException {
long high = 0;
Gson gson = new Gson();
try (JsonReader r = new JsonReader(new BufferedReader(new FileReader(path)))) {
r.beginArray();
while (r.hasNext()) {
Row row = gson.fromJson(r, Row.class); // mỗi lần một record
if (row.score > 95) high++;
}
r.endArray();
}
return high;
}
static long jacksonStream(String path) throws IOException {
long high = 0;
JsonFactory f = new ObjectMapper().getFactory();
try (JsonParser p = f.createParser(new File(path))) {
if (p.nextToken() != JsonToken.START_ARRAY) throw new IOException("expected an array");
while (p.nextToken() == JsonToken.START_OBJECT) {
Row row = p.readValueAs(Row.class); // mỗi lần một record
if (row.score > 95) high++;
}
}
return high;
}Trên một file 43.6 MB gồm một triệu record, cùng một JVM và cùng dữ liệu:
$ java -Xmx16m ... Streaming gson big.json
gson: rows with score > 95 = 40000
$ java -Xmx16m ... Streaming jackson big.json
jackson: rows with score > 95 = 40000
$ java -Xmx64m ... Streaming bind big.json
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.fasterxml.jackson.core.util.TextBuffer.setCurrentAndReturn(TextBuffer.java:925)
at com.fasterxml.jackson.core.json.UTF8StreamJsonParser._finishAndReturnString(UTF8StreamJsonParser.java:2512)
at com.fasterxml.jackson.core.json.UTF8StreamJsonParser.getText(UTF8StreamJsonParser.java:294)Streaming chạy xong trong một heap 16 MB. Lời gọi readValue tương đương vào một List cùng type thì chết ở 64 MB và cần tới 96 MB mới hoàn thành. Tỷ lệ đó không phải điều đáng nhớ — nó phụ thuộc hoàn toàn vào document — nhưng hình dạng thì có: chi phí của streaming bị chặn bởi một record, còn chi phí của bind bị chặn bởi cả file. Hãy với tay tới pull parser khi document lớn hoặc không có giới hạn, khi bạn chỉ cần vài field trong mỗi record, hoặc khi record xử lý xong là bỏ được ngay. Hãy bind cả document khi nó nhỏ, khi bạn cần truy cập ngẫu nhiên khắp nó, hoặc khi code rõ ràng hơn nhờ vậy — tức là phần lớn trường hợp.
XML trên một JDK hiện đại
JAXB — javax.xml.bind, cách bind XML bằng annotation từng đi kèm JDK — đã bị gỡ khỏi Java 11 cùng với phần còn lại của các module Java EE. Trên OpenJDK 21 nó đơn giản là không tồn tại:
$ javap javax.xml.bind.JAXBContext
Error: class not found: javax.xml.bind.JAXBContext
$ javap jakarta.xml.bind.JAXBContext
Error: class not found: jakarta.xml.bind.JAXBContextThứ còn lại là module java.xml, vẫn rất hiện diện và không cần dependency nào cả:
$ java --list-modules | grep xml
java.xml@21.0.6
java.xml.crypto@21.0.6
jdk.xml.dom@21.0.6Nó cho bạn DOM, SAX, StAX, XPath và XSLT. Với cỡ XML mà code Java thực tế hay gặp — một file config, một payload SOAP cũ, một feed — thì DOM cộng XPath là đủ, còn StAX phủ trường hợp streaming đúng như JsonReader làm với JSON:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Document doc = dbf.newDocumentBuilder().parse(f);
XPath xp = XPathFactory.newInstance().newXPath();
System.out.println("host = " + xp.evaluate("/server/host/text()", doc));
NodeList routes = (NodeList) xp.evaluate("/server/routes/route", doc, XPathConstants.NODESET);
for (int i = 0; i < routes.getLength(); i++) {
Element e = (Element) routes.item(i);
System.out.println("route " + e.getAttribute("method") + " " + e.getAttribute("path"));
}
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);Với document này:
<?xml version="1.0" encoding="UTF-8"?>
<server>
<host>api.example.com</host>
<port>8443</port>
<routes>
<route path="/users" method="GET"/>
<route path="/orders" method="POST"/>
</routes>
</server>host = api.example.com
port = 8443
route GET /users
route POST /orders
StAX counted 2 route elementsBa thiết lập factory đó không phải trang trí tùy chọn. Một XML parser mặc định sẽ resolve các khai báo entity trong document, và đó chính là lớp lỗ hổng XXE — một document đọc file cục bộ hoặc mở kết nối mạng ngay trong lúc được parse. JSON không có tính năng tương đương nên cũng không có vấn đề tương đương. Hãy tắt DTD và external entity trên mọi parser chạm vào input không do bạn viết ra.
Nếu bạn muốn binding XML chứ không chỉ parse XML, Jackson có sẵn, dưới dạng thêm một module nữa trên cùng bộ máy ObjectMapper:
XmlMapper xm = new XmlMapper();
Server s = xm.readValue(xml, Server.class);bound : api.example.com:8443 [GET /users, POST /orders]
re-xml : <Server><host>api.example.com</host><port>8443</port><routes><routes><path>/users</path><method>GET</method></routes><routes><path>/orders</path><method>POST</method></routes></routes></Server>
as json: {"host":"api.example.com","port":8443,"routes":[{"path":"/users","method":"GET"},{"path":"/orders","method":"POST"}]}Chiều đọc chạy được mà không cần annotation nào. Chiều ghi thì không round trip sạch, và output cho thấy vì sao bind XML khó hơn bind JSON: root element thành Server chứ không phải server, và list sinh ra routes lồng trong routes vì XML không có array native — một wrapper element và một item element là hai cái tên khác nhau, và Jackson cần @JacksonXmlRootElement, @JacksonXmlElementWrapper cùng @JacksonXmlProperty để biết cái nào là cái nào. Attribute hay child element là quyết định thứ hai mà JSON không bao giờ phải đưa ra, và mixed content là quyết định thứ ba.
Phần việc thêm đó là bản tóm tắt thành thật về XML trên JVM hôm nay. Nó không bị deprecated, hỗ trợ trong JDK vẫn vững và miễn phí, và bạn sẽ gặp nó trong POM của Maven, config XML của Spring, endpoint SOAP và những feed cũ. Nhưng service mới thì không chọn nó: JSON là thứ HTTP API nói, thứ trình duyệt parse sẵn, và thứ có đúng một cách ánh xạ hiển nhiên sang object graph thay vì ba cách mơ hồ.
FAQ
Nên dùng Gson hay Jackson?
Jackson nếu bạn đang ở trong một framework vốn đã kèm nó — Spring Boot, Quarkus và Micronaut đều mặc định dùng nó — hoặc nếu bạn cần annotation, module, XML, YAML, hay xử lý nghiêm khắc với field lạ. Gson nếu bạn muốn một jar nhỏ, không dependency kéo theo và không phải gắn annotation lên model, và đó là lý do nó là lựa chọn quen thuộc trên Android và trong các tool nhỏ. Về tính năng thì Jackson lớn hơn rất nhiều; về việc đưa một object bình thường ra vào, hai bên tương đương.
Vì sao list object của tôi quay về thành list các map?
Vì bạn truyền List.class và element type đã bị xóa. Cả hai thư viện đổ vào list những map chung chung — LinkedTreeMap với Gson, LinkedHashMap với Jackson — và không bên nào ném lỗi ở thời điểm đó. Hãy dùng new TypeToken<List<User>>(){}.getType() với Gson hoặc new TypeReference<List<User>>(){} với Jackson, nhớ giữ cặp ngoặc ở cuối, hoặc bind vào User[].class vốn không cần type literal nào.
Vì sao LocalDate của tôi thành một mảng ba số?
Vì bạn đã đăng ký JavaTimeModule nhưng để SerializationFeature.WRITE_DATES_AS_TIMESTAMPS bật, và đó là mặc định của Jackson. Chính nó tạo ra "day":[2026,9,16] và "at":1789547400.000000000 ở trên. Tắt feature đó thì cũng module ấy ghi ra chuỗi ISO-8601.
Gson và ObjectMapper có thread-safe không?
Cả hai đều được tài liệu ghi là dùng chung được: javadoc của Gson nói một instance Gson là thread-safe, javadoc của Jackson nói một ObjectMapper hoàn toàn thread-safe với điều kiện mọi cấu hình diễn ra trước lần đọc hoặc ghi đầu tiên. Cấu hình một lần, để trong một field static final, dùng lại khắp nơi. Cấu hình lại một mapper đang được dùng mới là phần không an toàn.
Làm sao để Jackson thôi ném lỗi với một key lạ?
disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) trên mapper tắt nó ở mọi nơi; @JsonIgnoreProperties(ignoreUnknown = true) tắt nó cho một class. Hãy ưu tiên annotation khi chỉ một phần model của bạn tiêu thụ payload của bên thứ ba, vì công tắc toàn cục cũng giấu luôn cả lỗi gõ sai trong document do chính bạn sinh ra.
Vì sao Gson biến số nguyên của tôi thành double?
Vì bạn deserialize vào Object, Map hay một tree node, và chiến lược số mặc định của Gson cho một giá trị không có type là Double. 42 thành 42.0, còn một ID lớn hơn khoảng chín triệu tỷ thì mất chính xác trong im lặng. Hoặc bind vào một field có type để type khai báo quyết định, hoặc đặt setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) trên builder.
Jackson và Gson có deserialize vào record được không?
Được, cả hai, ở các version dùng trong bài. Jackson hỗ trợ record từ 2.12 còn Gson từ 2.10 trên Java 16 trở lên; cả hai đều ghi {"x":3,"y":4} cho một record hai thành phần và đọc thẳng lại qua canonical constructor. Điều đó cũng khiến record là câu trả lời gọn nhất cho vấn đề constructor bị bỏ qua, vì canonical constructor thật sự chạy và mọi validate trong đó vẫn có hiệu lực.
Deserialize JSON không tin cậy có an toàn không?
Bind vào một type cụ thể do bạn chỉ định là an toàn theo nghĩa quan trọng nhất: document cung cấp giá trị, code của bạn cung cấp class. Thứ không an toàn là mọi cấu hình cho phép document tự chọn type — default typing của Jackson là thứ cần biết. Hãy để nó tắt, dùng @JsonTypeInfo cùng một danh sách @JsonSubTypes tường minh khi cần polymorphism, và validate object thu được thay vì tin rằng parse thành công nghĩa là dữ liệu hợp lý.
Kết luận
Binding JSON trong Java là một mapping giữa một document và một object graph, và cả hai thư viện đều làm tốt tới mức round trip trong bài này cho ra output giống nhau từng byte. Thứ tách biệt chúng là các default. Gson bỏ field null, lờ key lạ, đọc thẳng private field, biến số không có type thành double, và sẽ dựng một object mà không chạy constructor của nó. Jackson ghi null, ném lỗi với key lạ, làm việc từ getter và setter, chọn kiểu số hợp lý, và từ chối dựng một object mà nó không có creator. Không bộ nào sai, và không bộ nào đoán ra được — nên thứ đầu tiên đáng viết trong bất kỳ dự án nào là một mapper duy nhất đã cấu hình sẵn ở một chỗ, với từng quyết định trong số đó được đưa ra có chủ đích.
Những cái bẫy đáng mang theo là những cái không hỏng ở chỗ bạn làm sai. Một generic type thiếu TypeToken hay TypeReference vẫn parse thành công và trả về cho bạn các map, rồi ClassCastException rơi xuống ở đoạn code chẳng liên quan sau đó. Một key thiếu để lại 0 trong một int và chẳng ai phàn nàn. Một constructor đang bảo vệ invariant thì không chạy khi Gson cấp phát instance, nên object trông hợp lệ mà đã hỏng. java.time fail rõ ràng, và đó là hành vi thân thiện nhất trong cả bài, nhưng đăng ký module mà không tắt output dạng timestamp thì cho ra [2026,9,16] rồi gọi đó là một ngày. Và khi document đủ lớn, câu trả lời đúng không còn là bind nữa: streaming làm xong cùng một việc trong heap 16 MB mà bind cả document không làm nổi trong 64 MB.
Đó là dữ liệu trên đường truyền. Bài tiếp theo quay sang dữ liệu lúc nằm yên: bài 26 nói về JDBC — connection, Statement so với PreparedStatement, result set, transaction, và kiểu SQL injection mà việc nối chuỗi mời gọi.