Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~44 دقیقه مطالعه~38 min read
Jackson و JSONJackson & JSON
از صفر: JSON چیست، Jackson چطور آن را به آبجکت جاوا و برعکس تبدیل میکند، سه سبک پردازش، آنوتیشنهای اصلی، تاریخها، رکوردها، سریالایزر سفارشی، و بمبِ امنیتیِ default typing — با تشبیه، کدِ اجراشدنی و سؤالات مصاحبه.From zero: what JSON is, how Jackson turns it into Java objects and back, the three processing styles, core annotations, dates, records, custom serializers, and the default-typing security bomb — with analogies, runnable code, and interview questions.
هر برنامهای که با دنیای بیرون حرف میزند — یک API بدهد، از API دیگری بگیرد، پیام به صف بفرستد، یا فقط یک فایل پیکربندی بخواند — لحظهای میرسد که باید یک آبجکت جاوا را به متن تبدیل کند، یا برعکس. زبان مشترکِ این تبادل، اغلب JSON است و در دنیای جاوا، ابزارِ درجهیکِ این کار Jackson است. اگر اسپرینگبوت نوشتهای، هر بار که یک کنترلر یک آبجکت برمیگرداند، پشت پرده Jackson دارد کار میکند — چه بدانی چه ندانی.
این فصل را قدمبهقدم میرویم: اول با تشبیه میفهمیم اصلاً «سریالایز» یعنی چه، بعد واژهٔ فنیاش را میبینیم، بعد وصلش میکنیم به کدِ واقعی و سرِ آخر به سؤال مصاحبه. عجله نکن؛ قرار است این ابزار را برای همیشه بفهمی نه اینکه حفظش کنی.
اول میفهمیم JSON و سریالایز/دیسریالایز چه هستند. بعد با Jackson و سه ماژول اصلیاش آشنا میشویم. بعد قلبِ کتابخانه یعنی ObjectMapper را باز میکنیم و سه سبک پردازش (streaming، tree، data-binding) را مقایسه میکنیم. بعد سراغ آنوتیشنهای اصلی، تاریخها و java.time، رکوردها، و سریالایزر/دیسریالایزرِ سفارشی میرویم. سپس مهمترین بخش امنیتی: دیسریالایزِ چندریختی (polymorphic) و بمبِ default typing و زنجیرههای گجت. در آخر کارایی، تفاوتهای Jackson 3 و یک دسته سؤال مصاحبه.
بخش ۰ — سه واژهای که باید از صفر بفهمی
«سریالایز» و «دیسریالایز» یعنی چه؟
یک آبجکت جاوا زنده داخل حافظهٔ برنامهات، مثل یک مبلِ سرهمشده توی اتاق است: سهبعدی، اشغالکنندهٔ فضا، وابسته به این خانهٔ خاص (آدرس حافظه). حالا میخواهی این مبل را برای دوستت بفرستی. نمیتوانی خودِ مبل را از سیم تلفن رد کنی!
پس چه میکنی؟ بازش میکنی، تخت و صاف میکنی، توی یک جعبهٔ مسطح با یک برگهٔ راهنما میگذاری و میفرستی. این «تختکردن برای انتقال» یعنی سریالایز (serialization).
دوستت جعبه را میگیرد و از روی راهنما دوباره مبل را سرهم میکند. این یعنی دیسریالایز (deserialization).
پس دو واژهٔ فنی را چسباندیم به یک تصویر:
- سریالایز: آبجکتِ زندهٔ درون حافظه → یک رشتهٔ متنی/بایتی مسطح که میشود ذخیره یا ارسالش کرد. در دنیای Jackson به این میگویند write یا marshalling.
- دیسریالایز: آن متن/بایت → دوباره یک آبجکت زندهٔ جاوا. در Jackson به این میگویند read یا unmarshalling.
«JSON» دقیقاً چیست؟
JSON مخفف JavaScript Object Notation است، ولی امروز ربطی به جاوااسکریپت ندارد و زبانِ مشترکِ تبادل داده در کل وب است. فقط چند نوعِ داده دارد و همین سادگی، قدرتش است:
{
"id": 42,
"name": "Ali",
"active": true,
"balance": 1250.75,
"roles": ["admin", "editor"],
"address": { "city": "Tehran", "zip": "12345" },
"deletedAt": null
}
همینجا کل گرامرِ JSON را داری: آبجکت ({} با جفتهای کلید:مقدار)، آرایه ([])، رشته (داخل ")، عدد، بولین (true/false) و null. تمام. نه کلاس، نه تاریخ، نه تایپِ سفارشی. کارِ Jackson این است که این چند نوعِ ساده را به آبجکتهای غنیِ جاوا (و برعکس) پل بزند.
جاوا یک سریالایزِ باینریِ داخلی دارد (java.io.Serializable). ولی خروجیاش فقط برای جاوا خواناست، شکننده است، و از قضا یکی از بزرگترین منابعِ آسیبپذیریِ RCE در تاریخ جاواست. JSON برعکس: متنی، خوانا برای انسان، زبانمستقل (پایتون/جاوااسکریپت/گو همه میخوانند) و ساده. برای همین برای APIها JSON برنده است.
«data binding» یعنی چه؟
وقتی میگوییم Jackson «data binding» میکند، یعنی فیلدهای JSON را بهصورت خودکار به فیلدها/متدهای یک کلاس جاوا میبندد (bind). تو فقط شکلِ داده (کلاس/رکورد) را تعریف میکنی، Jackson با بازتاب (reflection) فیلدها را نگاه میکند و خودش نگاشت را انجام میدهد. این «چسباندنِ خودکار» همان چیزی است که کار را از هزار خط کدِ دستی نجات میدهد.
سریالایز = آبجکت زنده → متن مسطح. دیسریالایز = متن → آبجکت زنده. JSON = زبانِ متنیِ ساده و زبانمستقلِ تبادل داده، فقط با ۶ نوعِ پایه. data binding = چسباندنِ خودکار فیلدهای JSON به کلاس جاوا با کمکِ reflection.
بخش ۱ — با Jackson آشنا شویم
Jackson یک کتابخانهٔ منفرد نیست، یک خانواده است. سه ماژولِ اصلیاش را باید بشناسی، چون اسمهایشان همهجا برمیگردد:
| ماژول | Artifact | کارش چیست |
|---|---|---|
| Core (Streaming) | jackson-core |
موتورِ سطحپایین: خواندن/نوشتنِ توکنبهتوکنِ JSON. کلاسهای JsonParser و JsonGenerator. |
| Annotations | jackson-annotations |
فقط آنوتیشنها: @JsonProperty, @JsonIgnore, ... . هیچ منطقی ندارد، فقط برچسب. |
| Databind | jackson-databind |
لایهٔ سطحبالا: ObjectMapper، data binding و tree model. به دو تای بالا وابسته است. |
در Maven معمولاً فقط databind را اضافه میکنی و آن دو تای دیگر خودکار میآیند:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.0</version>
</dependency>
گروهِ Maven اسمش com.fasterxml.jackson است چون شرکتِ سازنده FasterXML نام دارد. ربطی به XML بودن ندارد؛ Jackson اصولاً JSON است (هرچند ماژولهایی برای XML/YAML/CSV هم دارد). این نکته را در مصاحبه اشتباه نگیر.
دو نسخهٔ مهم: Jackson 2 و Jackson 3
امروز (میانهٔ ۲۰۲۶) دو خطِ نسخهٔ زنده وجود دارد و باید هر دو را بشناسی:
- Jackson 2.x — خطِ جاافتاده و پرکاربرد. آخرین نسخهاش 2.20 (شهریور ۲۰۲۵) است؛ اسپرینگبوت ۳ روی همین است. groupId اش
com.fasterxml.jackson.*و پکیجِ جاوایشcom.fasterxml.jackson.*. حداقل جاوای موردنیاز: جاوا ۸. - Jackson 3.0 — نسلِ بعدی که مهر ۲۰۲۵ (اکتبر) GA شد و در ۲۰۲۶ خطِ 3.1 آمد. اسپرینگبوت ۴ روی این است. اینجا پکیج عوض شده به
tools.jackson.*(تا ۲ و ۳ کنار هم زندگی کنند)، جاوا ۱۷ لازم است، وObjectMapperتغییرناپذیر (immutable) شده. جزئیاتش را در بخش آخر میبینیم.
همهٔ ماژولهای Jackson باید مینور یکسان داشته باشند (مثلاً همه 2.20، نه یکی 2.20 و یکی 2.15). قاطیِ نسخهها خطاهای عجیبِ NoSuchMethodError میدهد. راهِ درست: از BOM استفاده کن (jackson-bom) یا بگذار اسپرینگبوت نسخهها را مدیریت کند و خودت شماره ننویس.
بخش ۲ — ObjectMapper: اسبِ بارکشِ Jackson
ObjectMapper قلبِ Jackson است. تقریباً هر کاری از همینجا شروع میشود. بیایید سادهترین سناریو را ببینیم: یک کلاس معمولی (بهاصطلاح POJO یعنی Plain Old Java Object، یک آبجکتِ سادهٔ بیادعا).
import com.fasterxml.jackson.databind.ObjectMapper;
public class User {
private long id;
private String name;
private boolean active;
// Jackson برای data binding به getter/setter یا فیلد دسترسی میخواهد
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}
ObjectMapper mapper = new ObjectMapper();
// سریالایز: آبجکت -> رشتهٔ JSON
User u = new User();
u.setId(42);
u.setName("Ali");
u.setActive(true);
String json = mapper.writeValueAsString(u);
// {"id":42,"name":"Ali","active":true}
// دیسریالایز: رشتهٔ JSON -> آبجکت
String input = "{\"id\":7,\"name\":\"Sara\",\"active\":false}";
User parsed = mapper.readValue(input, User.class);
System.out.println(parsed.getName()); // Sara
همین. دو متدِ اصلی که ۹۰٪ کارَت با آنهاست: writeValueAsString(...) برای نوشتن و readValue(...) برای خواندن. نسخههای دیگرِ همینها هم هست: writeValue(File/OutputStream, obj) برای نوشتن مستقیم روی فایل/استریم، و readValue(File/InputStream/byte[], Class) برای خواندن از منابع مختلف.
ObjectMapper را مثل یک مترجمِ حرفهای در نظر بگیر که هم فارسی به انگلیسی میکند هم برعکس. تو فقط متن و «قالبِ مقصد» را میدهی؛ او با نگاهکردن به ساختار (reflection) خودش میفهمد چه چیزی به چه چیزی نگاشت شود. تنظیماتش (features، ماژولها) مثل «لهجه و سبکِ ترجمه» است که یک بار تنظیم میکنی و همیشه استفاده میکنی.
نکتهٔ حیاتیِ کارایی و thread-safety
ساختنِ ObjectMapper گران است: کلی کش و ماژول را راه میاندازد. اگر داخل هر متد یا هر درخواست یکی بسازی، کارایی برنامهات را میکُشی. راهِ درست: یک نمونهٔ مشترک بساز و همهجا استفاده کن (مثلاً یک static final یا در اسپرینگ یک @Bean).
خبر خوب: بعد از اینکه پیکربندیاش تمام شد، ObjectMapper برای خواندن/نوشتن thread-safe است. یعنی چند ترد همزمان بیخطر ازش استفاده میکنند. فقط حین پیکربندی (ثبت ماژول، فعالکردن feature) نباید همزمان از تردهای دیگر استفاده شود.
اگر میخواهی تضمینِ کاملِ ایمنی و کمی سرعت بیشتر داشته باشی، Jackson دو خواهرِ کاملاً تغییرناپذیر (immutable) و thread-safe دارد که از mapper میگیری:
ObjectMapper mapper = new ObjectMapper();
// یک بار بساز، بینهایت بار استفاده کن — کاملاً thread-safe
ObjectWriter writer = mapper.writerFor(User.class).withDefaultPrettyPrinter();
ObjectReader reader = mapper.readerFor(User.class);
String pretty = writer.writeValueAsString(u);
User back = reader.readValue(input);
دو Feature ای که همان روز اول لازمت میشود
ObjectMapper mapper = new ObjectMapper();
// ۱) اگر JSON فیلدی داشت که در کلاست نیست، خطا نده — نادیده بگیر
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// ۲) خروجی زیبا و تودرتو (فقط برای دیباگ/لاگ؛ در پروداکشن نه، حجم میبرد)
mapper.enable(SerializationFeature.INDENT_OUTPUT);
در Jackson 2 این feature بهصورت پیشفرض روشن است: اگر JSONِ ورودی فیلدی داشته باشد که کلاست ندارد، UnrecognizedPropertyException پرت میشود. این موقعهایی که APIِ طرفِ مقابل یک فیلد جدید اضافه میکند، کلاینتِ تو را ناگهان میشکند. برای APIهای عمومی معمولاً بهتر است خاموشش کنی. جالب است که در Jackson 3 این پیشفرض برعکس شده و خاموش است.
بخش ۳ — سه راهِ کار با JSON: streaming، tree، data-binding
اینجا یکی از مهمترین تصمیمهای معماری است. Jackson سه سبکِ کاملاً متفاوت برای کار با JSON دارد و انتخابِ درست، تفاوتِ کارایی و خوانایی است.
تصور کن یک کتابِ هزارصفحهای داری:
- streaming: صفحهبهصفحه ورق میزنی و فقط چیزی را که لازم داری یادداشت میکنی و رد میشوی. کلِ کتاب هیچوقت توی ذهنت نیست. سریعترین و کمحافظهترین، ولی باید خودت حواست به جایجای باشی.
- tree: کلِ کتاب را اسکن میکنی و یک نقشهٔ درختی ازش در ذهن میسازی که هر جایش را میتوانی بروی. انعطافپذیر، ولی کلِ داده در حافظه است.
- data-binding: کتاب را میدهی به یک دستیار و میگویی «این را بریز توی این فرمِ آماده». او پُرش میکند و تحویلت میدهد. راحتترین و تمیزترین، پرکاربردترین حالت.
۱) Streaming API — سطحپایین، سریع، توکنبهتوکن
اینجا مستقیم با JsonParser (خواندن) و JsonGenerator (نوشتن) کار میکنی. Jackson JSON را به دنبالهای از توکن میشکند (START_OBJECT, FIELD_NAME, VALUE_STRING, ...) و تو یکییکی جلو میروی:
JsonFactory factory = new JsonFactory();
// نوشتن دستی
StringWriter sw = new StringWriter();
try (JsonGenerator gen = factory.createGenerator(sw)) {
gen.writeStartObject();
gen.writeNumberField("id", 42);
gen.writeStringField("name", "Ali");
gen.writeEndObject();
}
// {"id":42,"name":"Ali"}
// خواندن دستی
try (JsonParser p = factory.createParser("{\"id\":42,\"name\":\"Ali\"}")) {
while (p.nextToken() != JsonToken.END_OBJECT) {
if (p.currentToken() == JsonToken.FIELD_NAME) {
String field = p.currentName();
p.nextToken(); // برو روی مقدار
if (field.equals("name")) {
System.out.println("name = " + p.getText());
}
}
}
}
کِی از این استفاده کنی؟ وقتی داده خیلی بزرگ است (چند گیگابایت لاگ یا فایلِ export) و نمیخواهی همه را در حافظه بریزی، یا وقتی حداکثرِ کارایی برایت حیاتی است. قیمتش: کدِ پرحرف و مستعدِ خطا.
۲) Tree Model — کلِ JSON بهشکلِ درخت
اینجا Jackson کلِ JSON را میخواند و به یک درختِ JsonNode تبدیل میکند. مثل DOM در HTML. برای وقتی که ساختار ناشناخته یا پویا است یا فقط چند فیلد را میخواهی بدون ساختنِ کلاس:
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(input);
long id = root.get("id").asLong();
String city = root.path("address").path("city").asText("unknown"); // path امنتر است
boolean isAdmin = false;
for (JsonNode role : root.path("roles")) {
if (role.asText().equals("admin")) isAdmin = true;
}
// ساختِ درختِ قابلویرایش
ObjectNode out = mapper.createObjectNode();
out.put("ok", true);
out.putArray("items").add(1).add(2).add(3);
get("x") اگر فیلد نباشد null برمیگرداند و بعدش .asText() میترکد (NPE). ولی path("x") یک MissingNode برمیگرداند که امن است و .asText("default") رویش کار میکند. برای پیمایشِ ساختارهای نامطمئن، همیشه path را ترجیح بده.
۳) Data Binding — پرکاربردترین حالت
همان چیزی که در بخش ۲ دیدیم: JSON را مستقیم به POJO/رکورد میبندی. ۹۵٪ کارِ روزمره همین است. برای تایپهای جنریک (مثل List<User>) باید از TypeReference استفاده کنی، وگرنه بهخاطرِ type erasure جاوا، نوعِ داخلِ لیست گم میشود:
// این کار نمیکند آنطور که فکر میکنی — نوعِ داخلی گم میشود
List<User> wrong = mapper.readValue(jsonArray, List.class); // List<LinkedHashMap>!
// راهِ درست: TypeReference نوعِ کامل را نگه میدارد
List<User> users = mapper.readValue(jsonArray,
new TypeReference<List<User>>() {});
Map<String, User> byName = mapper.readValue(jsonMap,
new TypeReference<Map<String, User>>() {});
اگر List.class بدهی، Jackson نمیداند داخلش User است، پس هر عنصر را به LinkedHashMap تبدیل میکند و بعداً که User صدا میزنی ClassCastException میگیری. این یکی از پرتکرارترین باگهای مبتدیهاست. برای هر چیزِ جنریک از TypeReference استفاده کن.
جدول مقایسه
| ویژگی | Streaming | Tree | Data-binding |
|---|---|---|---|
| کلاسهای اصلی | JsonParser/JsonGenerator |
JsonNode/ObjectNode |
ObjectMapper + POJO |
| سطح | پایین | متوسط | بالا |
| کارایی | بیشترین | متوسط | خیلی خوب |
| مصرف حافظه | کمترین | زیاد (کلِ درخت) | متوسط |
| راحتیِ کد | کمترین | متوسط | بیشترین |
| ساختارِ پویا/ناشناخته | سخت | عالی | نیازمند کلاس |
| کِی؟ | دادههای عظیم، سرعتِ حیاتی | JSONِ نامنظم/پویا | حالتِ عادی (اکثر مواقع) |
هر سه سبک روی یک موتورِ streaming مشترک ساخته شدهاند. tree و data-binding در باطن از همان JsonParser استفاده میکنند و فقط لایهای راحتتر رویش میگذارند. برای همین میتوانی سبکها را قاطی کنی: مثلاً با data-binding کلِ آبجکت را بخوانی ولی یک فیلدِ خاص را بهشکلِ JsonNode نگه داری.
بخش ۴ — آنوتیشنهای اصلی: کنترلِ دقیقِ نگاشت
دنیای واقعی همیشه مرتب نیست: اسمِ فیلد در JSON با اسمِ فیلد در جاوا فرق دارد، بعضی فیلدها نباید سریالایز شوند، بعضی nullها را نمیخواهی و... . آنوتیشنها همان اهرمهای کنترل هستند. مهمترینها را با کد ببینیم:
public class Account {
// اسمِ فیلد در JSON با اسمِ جاوا فرق دارد
@JsonProperty("user_name")
private String userName;
// این فیلد اصلاً در JSON نیاید (نه موقع خواندن نه نوشتن)
@JsonIgnore
private String passwordHash;
// چند اسمِ ورودیِ مجاز هنگام خواندن (خروجی همان اصلی است)
@JsonAlias({"email", "mail", "emailAddress"})
private String email;
// فرمتِ تاریخ
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate birthDate;
// ... getter/setterها
}
بیایید مهمترینها را دستهبندی کنیم:
| آنوتیشن | کارش |
|---|---|
@JsonProperty("name") |
نگاشتِ نامِ فیلد بین JSON و جاوا؛ همچنین برای دیدنِ فیلدهای private |
@JsonIgnore |
این فیلد را کاملاً نادیده بگیر |
@JsonIgnoreProperties({...}) |
روی کلاس: چند فیلدِ خاص را نادیده بگیر؛ یا ignoreUnknown=true |
@JsonInclude(...) |
چه چیزهایی در خروجی بیایند (مثلاً nullها را حذف کن) |
@JsonCreator |
کدام سازنده/متد برای ساختِ آبجکت هنگام خواندن استفاده شود |
@JsonFormat |
فرمتِ تاریخ/عدد/enum |
@JsonAlias |
نامهای ورودیِ جایگزین هنگام خواندن |
@JsonValue |
یک متد که مقدارِ کلِ آبجکت را نمایندگی میکند (برای enumها عالی) |
@JsonNaming |
یک استراتژیِ نامگذاری برای کلِ کلاس (مثلاً snake_case) |
@JsonAnyGetter/@JsonAnySetter |
برای فیلدهای پویا/ناشناخته |
@JsonInclude — خلاصشدن از nullها
پیشفرض، Jackson همهٔ فیلدها را مینویسد، حتی nullها. اغلب نمیخواهی خروجی پُر از "x": null باشد:
@JsonInclude(JsonInclude.Include.NON_NULL) // فقط فیلدهای غیرِ null
public class Response {
public String data;
public String error; // اگر null باشد، اصلاً در خروجی نمیآید
}
// یا سراسری روی کل mapper:
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
گزینهها: NON_NULL (nullها حذف)، NON_EMPTY (رشته/لیستِ خالی هم حذف)، NON_ABSENT (برای Optional)، NON_DEFAULT (مقادیرِ پیشفرض حذف).
@JsonCreator — وقتی سازندهات آرگومان دارد
اگر کلاست getter/setter و سازندهٔ بدونآرگومان ندارد (مثلاً آبجکتِ immutable است)، Jackson نمیداند چطور بسازدش. با @JsonCreator سازنده را نشانش میدهی و با @JsonProperty هر آرگومان را به فیلدِ JSON وصل میکنی:
public final class Point {
private final int x;
private final int y;
@JsonCreator
public Point(@JsonProperty("x") int x,
@JsonProperty("y") int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
}
جاوا بهصورت پیشفرض نامِ آرگومانهای متد را در بایتکد نگه نمیدارد (arg0, arg1 میشوند). پس Jackson نمیداند x کدام است. دو راه: یا با @JsonProperty("x") صریح بگویی، یا ماژول jackson-module-parameter-names را اضافه کنی و با فلگِ -parameters کامپایل کنی تا نامها حفظ شوند. در Jackson 3 این ماژول داخلی شده و لازم نیست جدا اضافهاش کنی.
@JsonValue — enumهای تمیز
public enum Status {
ACTIVE("A"), INACTIVE("I"), BANNED("B");
private final String code;
Status(String code) { this.code = code; }
@JsonValue // این کدِ کوتاه را در JSON بنویس
public String getCode() { return code; }
@JsonCreator // و از همین کد بخوان
public static Status fromCode(String code) {
for (Status s : values())
if (s.code.equals(code)) return s;
throw new IllegalArgumentException("bad code: " + code);
}
}
// Status.ACTIVE -> "A" (نه "ACTIVE")
بخش ۵ — تاریخها و java.time: منبعِ نصفِ دردسرها
تاریخ در JSON وجود ندارد! (یادت هست؟ JSON فقط عدد/رشته/بولین دارد.) پس Jackson باید تصمیم بگیرد LocalDateTime را چطور بنویسد. و اینجا یک تلهٔ کلاسیک هست.
اگر تایپهای java.time (مثل LocalDate, LocalDateTime, Instant) را بدونِ تنظیمِ درست سریالایز کنی، Jackson آنها را بهشکلِ آرایهای از اعداد یا timestampِ عددی مینویسد:
{ "createdAt": [2026, 7, 20, 14, 30, 0] }
این تقریباً هیچوقت آن چیزی نیست که میخواهی. خواستهات معمولاً ISO-8601 است: "2026-07-20T14:30:00".
چرا اینطور میشود؟ چون Jacksonها بهصورتِ پیشفرض java.time را نمیشناسند و باید ماژولِ JavaTimeModule را ثبت کنی، و feature بدنامِ WRITE_DATES_AS_TIMESTAMPS را خاموش کنی:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // java.time را بشناس
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // بهجای عدد، رشتهٔ ISO-8601
// از Jackson 2.10 راهِ کوتاهتر: کشفِ خودکار همهٔ ماژولها
ObjectMapper m2 = JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
خروجیِ درست:
{ "createdAt": "2026-07-20T14:30:00" }
اسپرینگبوت بهصورتِ خودکار JavaTimeModule را ثبت میکند و WRITE_DATES_AS_TIMESTAMPS را خاموش میکند. برای همین در پروژهٔ اسپرینگ معمولاً تاریخهایت درست ISO میشوند بیاینکه کاری کنی. ولی اگر یک new ObjectMapper() دستی جایی بسازی، این پیکربندی را از دست میدهی — این باگ خیلی رایج است.
برای کنترلِ فرمتِ یک فیلدِ خاص، @JsonFormat:
public class Event {
@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd HH:mm:ss",
timezone = "Asia/Tehran")
private ZonedDateTime startsAt;
}
در Jackson 3 ماژولِ java.time (JSR-310) و ماژولِ parameter-names و Optional همگی داخلی و پیشفرض شدهاند، و تاریخها بهصورتِ پیشفرض ISO-8601 نوشته میشوند. یعنی کلِ این دردسر در نسل ۳ از بین رفته. ولی چون بیشترِ پروژهها هنوز روی 2.x اند، این تنظیم را باید بلد باشی.
بخش ۶ — رکوردها و جاوای مدرن
از جاوا ۱۴/۱۶ رکوردها آمدند: کلاسهای دادهٔ immutable و کمحرف. خبر خوب: Jackson از نسخهٔ 2.12 (پایان ۲۰۲۰) رکوردها را بهصورتِ بومی پشتیبانی میکند. یعنی این کار میکند، بیهیچ آنوتیشنی:
public record UserDto(long id, String name, boolean active) {}
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new UserDto(42, "Ali", true));
// {"id":42,"name":"Ali","active":true}
UserDto back = mapper.readValue(json, UserDto.class); // کار میکند!
چرا رکوردها با Jackson اینقدر تمیزند؟ چون رکورد سازندهٔ متعارف (canonical constructor) دارد و Jackson مستقیم از آن استفاده میکند — درست مثل الگوی @JsonCreator که در بخش ۴ دیدیم، ولی خودکار. رکوردها DTOهای ایدهآلاند: immutable، thread-safe، بدونِ boilerplate.
Jackson برای رکوردها نامِ کامپوننتها را از طریقِ APIِ Reflectionِ رکورد میگیرد، پس معمولاً بدونِ فلگِ -parameters هم کار میکند. ولی اگر اسمهای JSON با اسمهای رکورد فرق دارند، روی خودِ کامپوننت آنوتیشن بزن:
public record UserDto(
long id,
@JsonProperty("full_name") String name,
boolean active) {}
هر دو boilerplate را کم میکنند، ولی رکورد بخشی از خودِ زبان است و همیشه immutable. Lombok یک پردازندهٔ آنوتیشن است و انعطافِ بیشتری (mutable، builder سفارشی) دارد. برای DTOهای سادهٔ فقطخواندنی که با Jackson رد و بدل میشوند، رکورد اغلب انتخابِ تمیزتر است.
بخش ۷ — سریالایزر و دیسریالایزرِ سفارشی
گاهی رفتارِ پیشفرضِ Jackson کافی نیست: میخواهی یک تایپِ خاص را به شکلِ خاصی بنویسی. مثلاً یک Money را همیشه بهشکلِ رشتهٔ "$12.50" سریالایز کنی. اینجا سریالایزرِ سفارشی مینویسی.
// سریالایزر: آبجکت -> JSON
public class MoneySerializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen,
SerializerProvider sp) throws IOException {
gen.writeString("$" + value.getAmount());
}
}
// دیسریالایزر: JSON -> آبجکت
public class MoneyDeserializer extends JsonDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctx)
throws IOException {
String raw = p.getText().replace("$", "");
return new Money(new BigDecimal(raw));
}
}
حالا اینها را با یک SimpleModule ثبت میکنی تا سراسری اعمال شوند:
SimpleModule module = new SimpleModule();
module.addSerializer(Money.class, new MoneySerializer());
module.addDeserializer(Money.class, new MoneyDeserializer());
ObjectMapper mapper = JsonMapper.builder()
.addModule(module)
.build();
یا اگر فقط برای یک فیلد میخواهی، مستقیم روی فیلد:
public class Invoice {
@JsonSerialize(using = MoneySerializer.class)
@JsonDeserialize(using = MoneyDeserializer.class)
private Money total;
}
JsonSerializer پایهای است. اگر به قابلیتهای بیشتری (مثلِ مدیریتِ null, دسترسی به تایپ) نیاز داری، از StdSerializer<T> ارثبری کن که یک زیرکلاسِ مجهزتر است. برای اکثر کارها همان JsonSerializer کافی است.
اگر با ترکیبی از @JsonProperty, @JsonFormat, @JsonInclude مسئله حل میشود، آنوتیشن را ترجیح بده (سادهتر، خواناتر). سریالایزرِ سفارشی را برای منطقِ واقعی نگه دار: فرمتِ کاملاً غیرمعمول، محاسبه حین سریالایز، سازگاری با یک APIِ قدیمیِ عجیب.
بخش ۸ — دیسریالایزِ چندریختی و بمبِ امنیتی
این مهمترین بخشِ امنیتیِ کلِ فصل است. با دقت بخوان؛ در مصاحبهٔ سنیور تقریباً حتماً میپرسند.
مسئله: چندریختی (polymorphism)
فرض کن یک فیلد از نوعِ Animal داری که میتواند Dog یا Cat باشد. وقتی سریالایز میکنی، فقط فیلدها میروند در JSON — ولی نوعِ واقعی گم میشود. موقعِ خواندن، Jackson از کجا بداند Dog بسازد یا Cat؟
راهِ درست و امنِ حلش، آنوتیشنِ @JsonTypeInfo است که یک «برچسبِ نوع» صریح در JSON میگذارد:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Dog.class, name = "dog"),
@JsonSubTypes.Type(value = Cat.class, name = "cat")
})
public abstract class Animal { public String name; }
public class Dog extends Animal { public boolean goodBoy; }
public class Cat extends Animal { public int lives; }
خروجی حالا برچسبِ نوع دارد و بیابهام است:
{ "type": "dog", "name": "Rex", "goodBoy": true }
نکتهٔ کلیدی: اینجا تو صریحاً و با یک لیستِ بسته (@JsonSubTypes) گفتهای کدام نوعها مجازند. Jackson فقط Dog یا Cat میسازد، نه هر کلاسِ دیگری.
بمب: Default Typing و زنجیرههای گجت
حالا فرض کن بهجای این لیستِ بسته، به Jackson بگویی «برای هر آبجکتی، نوعِ کاملِ کلاس جاوا را در JSON بنویس و موقعِ خواندن هم هر کلاسی که در JSON بود بساز». به این میگویند default typing:
// ❌❌❌ هرگز این کار را با ورودیِ نامطمئن نکن
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping(); // منسوخ و خطرناک
خروجی چیزی شبیه این میشود که نامِ کاملِ کلاس را در خودِ داده دارد:
["com.example.Dog", { "name": "Rex" }]
چرا این یک فاجعهٔ امنیتی است؟
وقتی default typing روشن است، مهاجم میتواند در JSON بنویسد: «لطفاً یک آبجکت از کلاسِ X بساز». اگر در classpathِ برنامهات یک کلاسِ «خطرناک» موجود باشد که حین ساختهشدن یا مقداردهی، کارِ خطرناکی میکند — مثلاً یک DataSource که به یک URLِ دلخواه وصل میشود و کد از راه دور بارگذاری میکند — مهاجم با ساختنِ همان کلاس میتواند کدِ دلخواه روی سرورِ تو اجرا کند (RCE).
به این کلاسهای سوءاستفادهپذیر میگویند گجت (gadget)، و به دنبالهٔ آبجکتهایی که با هم یک حملهٔ کامل میسازند زنجیرهٔ گجت. کتابخانههای معروفی مثلِ نسخههای آسیبپذیرِ commons-collections، c3p0، logback و... پر از این گجتها بودهاند.
این دقیقاً منشأ یک سریِ طولانی از CVEهای بدنامِ jackson-databind است — از CVE-2017-7525 (اولین موجِ بزرگ) به بعد. تیمِ Jackson سالها با یک لیستِ سیاهِ (blocklist) از کلاسهای خطرناک بازی «موش و گربه» کردند: هر بار یک گجتِ جدید کشف میشد، یک CVE و یک نسخهٔ جدید میآمد. ولی blocklist ذاتاً بازنده است چون همیشه گجتِ بعدی هست.
راهِ درست: PolymorphicTypeValidator و allowlist
از Jackson 2.10 روش عوض شد: enableDefaultTyping() منسوخ شد و جایش activateDefaultTyping(PolymorphicTypeValidator, ...) آمد که مجبورت میکند یک اعتبارسنجِ نوع بدهی — یعنی یک لیستِ سفید (allowlist) از کلاسها/پکیجهای مجاز:
// اگر واقعاً به default typing نیاز داری، حداقل محدودش کن
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.myapp.model.") // فقط پکیجِ خودم
.build();
ObjectMapper mapper = JsonMapper.builder()
.activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
.build();
یک درسِ تازه و مهم: در ۲۰۲۶ آسیبپذیریِ CVE-2026-54512 نشان داد که PolymorphicTypeValidator فقط کلاسِ ظرفِ بیرونی را چک میکرد، نه آرگومانهای جنریکِ داخلش را. اگر allowlist ات حتی یک کلاسِ ظرفِ عمومی مثلِ java.util.ArrayList را اجازه میداد، مهاجم میتوانست تایپی مثل java.util.ArrayList<com.evil.Gadget> بدهد: نامِ ظرف از چک رد میشد ولی گجتِ داخلی ساخته میشد. این در نسخههای 2.18.8، 2.21.4 و 3.1.4 وصله شد. درسش: همیشه به آخرین patch بهروز بمان.
default typing را با دادهٔ نامطمئن هرگز روشن نکن. برای چندریختی، از @JsonTypeInfo با @JsonSubTypes (لیستِ بسته و صریح) استفاده کن، نه از default typing. اگر مجبوری، PolymorphicTypeValidator با allowlistِ تنگ بده و jackson-databind را همیشه بهروز نگه دار. این تفاوتِ «یک باگِ ساده» و «RCE روی سرورِ پروداکشن» است.
معمولاً برای ذخیرهٔ آبجکتهای دلخواه بدونِ دانستنِ نوعشان از قبل — مثلاً بعضی پیکربندیهای قدیمیِ Redis/کش یا صف که آبجکتِ جاوا را بهشکلِ JSON نگه میدارند. راهِ امنتر: از سریالایزیشنِ نوعدار ولی با allowlistِ محدود استفاده کن، یا اصلاً DTOهای مشخص با @JsonTypeInfo تعریف کن.
بخش ۹ — کارایی و بهترینروشها
ObjectMapperرا یک بار بساز و به اشتراک بگذار (بزرگترین بُردِ کارایی).- برای دادهٔ خیلی بزرگ streaming را انتخاب کن، نه tree/databind (که کلِ داده را در حافظه میریزند).
- بهجای
writeValueAsStringبعدgetBytes، مستقیمwriteValueAsBytesبده تا یک تبدیلِ اضافه حذف شود. - از
ObjectReader/ObjectWriterِ ازپیشساخته و immutable برای مسیرهای داغ استفاده کن. - در Jackson 2، ماژولِ Afterburner یا Blackbird با تولیدِ بایتکد سرعت را بالا میبرد. در Jackson 3 موتور بازنویسی شده و بخشی از این بهینهسازیها داخلی شده.
- در Jackson 3 مفهومِ RecyclerPool برای بازاستفاده از بافرها آمده که در محیطهای با تردِ زیاد (مثل Loom/virtual threads) کمک میکند.
۱) پیکربندیِ سراسری در برابر محلی: feature را روی mapperِ مشترک تنظیم کن، نه اینکه هر بار mapper تازه بسازی (که هم کند است هم پیکربندی را از دست میدهد).
۲) نشتی از طریق JsonNode: نگهداشتنِ درختهای JsonNode بزرگ در حافظه برای مدت طولانی، مصرفِ رم را بالا میبرد. اگر فقط چند فیلد را میخواهی، بخوانشان و درخت را رها کن.
بخش ۱۰ — Jackson 3: چه چیزی عوض شد؟
اگر به پروژهٔ جدید یا اسپرینگبوت ۴ میروی، اینها را باید بدانی:
| موضوع | Jackson 2.x | Jackson 3.x |
|---|---|---|
| پکیج/groupId | com.fasterxml.jackson.* |
tools.jackson.* (بهجز annotations) |
| حداقل جاوا | جاوا ۸ | جاوا ۱۷ |
| ObjectMapper | mutable | immutable، فقط با builder() |
| کلاسِ اصلی | ObjectMapper |
JsonMapper (زیرکلاسِ ObjectMapper، مخصوصِ JSON) |
| java.time / parameter-names / Optional | ماژولِ جدا | داخلی و پیشفرض |
| پیشفرضِ تاریخ | timestampِ عددی | ISO-8601 رشته |
FAIL_ON_UNKNOWN_PROPERTIES |
روشن | خاموش |
// سبکِ Jackson 3 — سازندهٔ تغییرناپذیر
import tools.jackson.databind.json.JsonMapper;
JsonMapper mapper = JsonMapper.builder()
.configure(SerializationFeature.INDENT_OUTPUT, true)
.build();
String json = mapper.writeValueAsString(new UserDto(42, "Ali", true));
jackson-annotations تنها ماژولی است که پکیجش عوض نشد (همان com.fasterxml.jackson.annotation). این عمداً است تا کدهایی که فقط آنوتیشن دارند راحتتر مهاجرت کنند و ۲ و ۳ کنار هم زندگی کنند. اسپرینگبوت ۴ بهصورتِ پیشفرض روی Jackson 3 است ولی سازگاریِ عقبرو با ۲ را هم فراهم کرده.
سؤالات مصاحبه
سریالایز یعنی تبدیلِ یک آبجکتِ زندهٔ جاوا به یک نمایشِ متنی/بایتیِ مسطح (اینجا JSON) که بتوان ذخیره یا ارسالش کرد؛ در Jackson با writeValueAsString, writeValueAsBytes, یا writeValue(out, obj). دیسریالایز عکسِ آن است: از JSON یک آبجکتِ زندهٔ جاوا میسازد؛ با readValue(src, Class) یا readValue(src, TypeReference). مفهومِ زیربنایی «data binding» است: نگاشتِ خودکارِ فیلدهای JSON به فیلدها/سازندهٔ کلاس با کمکِ reflection.
Streaming (JsonParser/JsonGenerator): سطحپایین، توکنبهتوکن، سریعترین و کمحافظهترین؛ مناسبِ دادهٔ بسیار بزرگ یا مسیرهای فوقِ داغ، به قیمتِ کدِ پرحرف. Tree (JsonNode): کلِ JSON را به یک درخت میآورد؛ مناسبِ ساختارِ پویا/ناشناخته یا وقتی فقط چند فیلد را میخواهی؛ ولی کلِ داده در حافظه است. Data binding (ObjectMapper + POJO/record): بالاترین سطح و پرکاربردترین؛ تمیز و راحت، برای حالتِ عادی. هر سه روی یک موتورِ streaming مشترک ساخته شدهاند و میتوان ترکیبشان کرد.
ساختنِ ObjectMapper گران است (راهاندازیِ کشها و ماژولها)؛ ساختنِ مکرر کارایی را بهشدت پایین میآورد و پیکربندیها را از دست میدهی. راهِ درست: یک نمونهٔ مشترک (static final یا @Bean). بعد از اتمامِ پیکربندی، mapper برای عملیاتِ خواندن/نوشتن thread-safe است و چند ترد میتوانند همزمان ازش استفاده کنند؛ فقط حین پیکربندی (ثبت ماژول/تغییر feature) نباید همزمان استفاده شود. برای تضمینِ کاملِ ایمنی از ObjectReader/ObjectWriterِ immutable استفاده کن.
بهخاطرِ type erasure، در زمانِ اجرا نوعِ داخلِ لیست (User) از بین رفته و Jackson فقط List را میبیند؛ پس هر عنصر را به LinkedHashMap تبدیل میکند و بعداً موقعِ کست به User خطای ClassCastException میگیری. راهِ درست استفاده از TypeReference است که نوعِ کاملِ جنریک را در زمانِ اجرا نگه میدارد: mapper.readValue(json, new TypeReference<List<User>>() {}). همین برای Map<String, User> و هر تایپِ جنریکِ دیگر صادق است.
چون در Jackson 2 بهصورتِ پیشفرض ماژولِ java.time ثبت نشده و feature ای به نامِ WRITE_DATES_AS_TIMESTAMPS روشن است که تاریخ را بهشکلِ عدد/آرایه مینویسد. راهِ حل: ماژولِ JavaTimeModule را ثبت کن و WRITE_DATES_AS_TIMESTAMPS را خاموش کن (یا findAndAddModules())، تا خروجی ISO-8601 شود. در اسپرینگبوت این خودکار انجام شده. در Jackson 3 این ماژول داخلی است و پیشفرض ISO-8601 است، پس مشکل از بین رفته. برای فرمتِ یک فیلدِ خاص از @JsonFormat(shape=STRING, pattern=...) استفاده کن.
default typing یعنی Jackson نامِ کاملِ کلاسِ جاوا را داخلِ JSON بنویسد و موقعِ خواندن هر کلاسی که در داده بود بسازد. خطرش این است که مهاجم با دادهٔ نامطمئن میتواند بگوید «یک آبجکت از کلاسِ خطرناکِ X بساز»؛ اگر آن کلاس (یک گجت) حین ساخت/مقداردهی کارِ خطرناکی کند، به اجرای کد از راه دور (RCE) میرسیم — منشأ CVEهای معروفِ jackson-databind از 2017-7525 به بعد. راهِ امن: از @JsonTypeInfo با @JsonSubTypes (لیستِ بستهٔ صریح) استفاده کن، نه default typing. اگر مجبوری، activateDefaultTyping با یک PolymorphicTypeValidatorِ allowlist بده و همیشه به آخرین patch بهروز بمان.
blocklist یعنی «هرچه مجاز است مگر این کلاسهای خطرناکِ مشخص». مشکلش این است که ذاتاً بازنده است: همیشه یک گجتِ کشفنشدهٔ بعدی هست، پس بازیِ بیپایانِ CVE و وصله راه میافتد (سرگذشتِ jackson-databind تا نسخهٔ 2.10). allowlist برعکس: «هیچچیز مجاز نیست مگر این کلاسها/پکیجهای مشخص». چون سطحِ حمله را به یک مجموعهٔ کوچکِ کنترلشده محدود میکند، ذاتاً امنتر است. برای همین از 2.10 با PolymorphicTypeValidator مدلِ allowlist اجباری شد. (نکته: CVE-2026-54512 نشان داد حتی allowlist اگر آرگومانهای جنریک را چک نکند نشتی میدهد.)
اگر مسئله با ترکیبی از آنوتیشنهای آماده (@JsonProperty, @JsonFormat, @JsonInclude, @JsonValue) حل میشود، همان را انتخاب کن؛ سادهتر و خواناتر است و منطقِ سریالایز را از کد جدا میکند. سریالایزر/دیسریالایزرِ سفارشی (JsonSerializer/JsonDeserializer یا StdSerializer) را برای وقتی نگه دار که منطقِ واقعی لازم است: فرمتِ کاملاً غیرمعمول، محاسبه حین سریالایز، سازگاری با یک APIِ عجیبِ قدیمی، یا نگاشتِ یک تایپِ خارجی که نمیتوانی آنوتیشنگذاریاش کنی. ثبتِ سراسری با SimpleModule انجام میشود، یا موضعی با @JsonSerialize(using=...).
چون سازندهٔ بدونآرگومان و setter نداری، Jackson بهتنهایی نمیداند چطور بسازدش. راهحل @JsonCreator روی سازندهٔ آرگوماندار است، و روی هر آرگومان @JsonProperty("name") تا Jackson بداند کدام فیلدِ JSON به کدام آرگومان میرود (چون نامِ آرگومانها بهصورتِ پیشفرض در بایتکد نیستند). جایگزین: ماژولِ parameter-names + کامپایل با -parameters. برای رکوردها این کاملاً خودکار است چون سازندهٔ متعارف دارند و Jackson (از 2.12) بومی پشتیبانیشان میکند.
کنترل میکند که چه فیلدهایی در خروجیِ سریالایز بیایند. NON_NULL فیلدهای null را حذف میکند؛ NON_EMPTY علاوه بر null، رشته/کالکشنِ خالی را هم حذف میکند؛ NON_ABSENT برای Optionalِ خالی؛ NON_DEFAULT فیلدهایی که هنوز مقدارِ پیشفرضِ نوعشان را دارند حذف میکند؛ و ALWAYS (پیشفرض) همه را مینویسد. میتوان روی فیلد، روی کلاس، یا سراسری روی mapper اعمالش کرد. کاربردِ رایج: کوچککردنِ پاسخِ API با حذفِ nullها.
پکیج و groupId از com.fasterxml.jackson به tools.jackson عوض شد (بهجز jackson-annotations)، تا ۲ و ۳ کنار هم قابلِ استفاده باشند. حداقلِ جاوا از ۸ به ۱۷ رفت. ObjectMapper تغییرناپذیر شد و فقط با JsonMapper.builder() ساخته میشود. ماژولهای java.time, parameter-names, و Optional داخلی و پیشفرض شدند و تاریخها پیشفرض ISO-8601 میشوند. پیشفرضِ FAIL_ON_UNKNOWN_PROPERTIES از روشن به خاموش تغییر کرد. Jackson 3.0 در اکتبر ۲۰۲۵ GA شد و اسپرینگبوت ۴ رویش است.
چون در Jackson 2 پیشفرض روشن است و اگر سرویسِ طرفِ مقابل یک فیلدِ جدید به JSON اضافه کند که کلاسِ تو ندارد، دیسریالایز با UnrecognizedPropertyException میترکد و کلاینتِ تو ناگهان میشکند. برای سازگاریِ روبهجلو (forward compatibility) در سیستمهای توزیعشده، معمولاً بهتر است فیلدهای ناشناخته را نادیده بگیری (disable(FAIL_ON_UNKNOWN_PROPERTIES) یا @JsonIgnoreProperties(ignoreUnknown=true)). در Jackson 3 این پیشفرض عاقلانهتر شده و خاموش است.
هر دو یک فیلد را از یک JsonNode میگیرند، ولی رفتارشان وقتی فیلد وجود ندارد فرق میکند: get("x") مقدارِ null (رفرنسِ جاوا) برمیگرداند، پس زنجیرهکردنِ .asText() رویش NullPointerException میدهد. اما path("x") یک MissingNodeِ ویژه برمیگرداند که امن است: isMissingNode() روی آن true است و .asText("default") مقدارِ پیشفرض میدهد بدونِ خطا. برای پیمایشِ ساختارهای نامطمئن یا اختیاری، همیشه path امنتر است.
JSON زبانِ متنیِ ساده و زبانمستقلِ تبادلِ داده است و Jackson استانداردِ جاوا برای پلزدنِ بینِ JSON و آبجکت. سریالایز آبجکت را به متن و دیسریالایز متن را به آبجکت تبدیل میکند، از طریقِ data binding. قلبِ کتابخانه ObjectMapper است که باید یک بار ساخته و به اشتراک گذاشته شود (thread-safe پس از پیکربندی). سه سبکِ کار: streaming (سریع/کمحافظه)، tree (پویا/انعطافپذیر)، data-binding (راحت/پرکاربرد). آنوتیشنها (@JsonProperty, @JsonIgnore, @JsonInclude, @JsonCreator, @JsonFormat) کنترلِ دقیقِ نگاشت را میدهند. تاریخها در Jackson 2 نیازمندِ JavaTimeModule و خاموشکردنِ WRITE_DATES_AS_TIMESTAMPS اند (در Jackson 3 داخلی و ISO-8601). رکوردها DTOهای تمیزِ immutable اند و از 2.12 بومی پشتیبانی میشوند. مهمترین درسِ امنیتی: default typing را با دادهٔ نامطمئن هرگز روشن نکن (خطرِ گجت و RCE)؛ برای چندریختی از @JsonTypeInfo/@JsonSubTypes استفاده کن، و jackson-databind را همیشه بهروز نگه دار.
منابع: Jackson 3.0 GA (cowtowncoder)، راهنمای مهاجرت به Jackson 3، معیارهای CVEِ دیسریالایزِ چندریختی، CVE-2026-54512، پشتیبانی رکورد در Jackson 2.12.
Every program that talks to the outside world — exposing an API, calling another API, publishing to a queue, or just reading a config file — eventually has to turn a Java object into text, or the reverse. The lingua franca of that exchange is usually JSON, and in the Java world the tool of choice is Jackson. If you have ever written Spring Boot, then every time a controller returns an object, Jackson is quietly at work behind the scenes — whether you knew it or not.
We will take this step by step: first an analogy to grasp what "serialize" even means, then the technical term, then wire it to real code, and finally to interview questions. Don't rush — the goal is to understand this tool for good, not memorize it.
First we understand JSON and serialize/deserialize. Then we meet Jackson and its three core modules. Then we open up the heart of the library, ObjectMapper, and compare the three processing styles (streaming, tree, data-binding). Next: core annotations, dates and java.time, records, and custom serializers/deserializers. Then the most important security topic: polymorphic deserialization, the default typing bomb, and gadget chains. Finally performance, what's new in Jackson 3, and a set of interview questions.
Part 0 — Three words you must understand from scratch
What do "serialize" and "deserialize" mean?
A live Java object sitting in your program's memory is like an assembled couch in a room: three-dimensional, taking up space, tied to this particular house (its memory address). Now you want to ship that couch to a friend. You can't push the whole couch down a phone line!
So what do you do? You take it apart, flatten it, put it in a flat box with an instruction sheet, and ship it. That "flattening for transport" is serialization.
Your friend receives the box and, following the instructions, reassembles the couch. That's deserialization.
So we've pinned two technical words to one picture:
- Serialize: a live in-memory object → a flat text/byte string you can store or send. In Jackson-speak this is write or marshalling.
- Deserialize: that text/bytes → a live Java object again. In Jackson-speak this is read or unmarshalling.
What exactly is JSON?
JSON stands for JavaScript Object Notation, but today it has nothing to do with JavaScript and is the universal data-exchange language of the web. It has only a handful of data types, and that simplicity is its power:
{
"id": 42,
"name": "Ali",
"active": true,
"balance": 1250.75,
"roles": ["admin", "editor"],
"address": { "city": "Tehran", "zip": "12345" },
"deletedAt": null
}
That's the entire grammar of JSON right there: objects ({} with key:value pairs), arrays ([]), strings (in "), numbers, booleans (true/false), and null. That's it. No classes, no dates, no custom types. Jackson's job is to bridge these few simple types to rich Java objects and back.
Java has a built-in binary serializer (java.io.Serializable). But its output is Java-only, brittle, and — famously — one of the biggest sources of RCE vulnerabilities in Java history. JSON is the opposite: text, human-readable, language-independent (Python/JavaScript/Go all read it), and simple. For APIs, JSON wins.
What does "data binding" mean?
When we say Jackson does "data binding," we mean it automatically binds JSON fields to the fields/methods of a Java class. You just declare the shape (a class/record); Jackson uses reflection to inspect the fields and maps them for you. That automatic binding is exactly what saves you from a thousand lines of manual glue code.
Serialize = live object → flat text. Deserialize = text → live object. JSON = the simple, language-independent, text data-exchange language with only 6 base types. Data binding = automatically wiring JSON fields to a Java class via reflection.
Part 1 — Meet Jackson
Jackson is not a single library — it's a family. You must know its three core modules, because their names come up everywhere:
| Module | Artifact | What it does |
|---|---|---|
| Core (Streaming) | jackson-core |
The low-level engine: token-by-token reading/writing of JSON. Classes JsonParser and JsonGenerator. |
| Annotations | jackson-annotations |
Just annotations: @JsonProperty, @JsonIgnore, … No logic, only labels. |
| Databind | jackson-databind |
The high-level layer: ObjectMapper, data binding, and the tree model. Depends on the two above. |
In Maven you usually add only databind and the other two come transitively:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.0</version>
</dependency>
The Maven group is com.fasterxml.jackson because the company behind it is named FasterXML. It has nothing to do with being XML; Jackson is fundamentally JSON (though it has modules for XML/YAML/CSV too). Don't get this wrong in an interview.
Two important lines: Jackson 2 and Jackson 3
As of mid-2026 there are two live version lines, and you should know both:
- Jackson 2.x — the established, widely used line. The latest is 2.20 (August 2025); Spring Boot 3 rides on it. groupId and Java package are both
com.fasterxml.jackson.*. Minimum Java: 8. - Jackson 3.0 — the next generation, which went GA in October 2025, with the 3.1 line following in 2026. Spring Boot 4 rides on it. Here the package was renamed to
tools.jackson.*(so 2 and 3 can coexist), Java 17 is required, andObjectMapperbecame immutable. Details in the last part.
All Jackson modules must share the same minor version (e.g. all 2.20, not one 2.20 and one 2.15). Mixing them yields cryptic NoSuchMethodErrors. The right way: use the BOM (jackson-bom), or let Spring Boot manage the versions and don't hard-code numbers yourself.
Part 2 — ObjectMapper: Jackson's workhorse
ObjectMapper is the heart of Jackson. Almost everything starts here. Let's see the simplest scenario: a plain class (a so-called POJO, Plain Old Java Object — an unpretentious plain object).
import com.fasterxml.jackson.databind.ObjectMapper;
public class User {
private long id;
private String name;
private boolean active;
// Jackson needs getters/setters or field access for data binding
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}
ObjectMapper mapper = new ObjectMapper();
// Serialize: object -> JSON string
User u = new User();
u.setId(42);
u.setName("Ali");
u.setActive(true);
String json = mapper.writeValueAsString(u);
// {"id":42,"name":"Ali","active":true}
// Deserialize: JSON string -> object
String input = "{\"id\":7,\"name\":\"Sara\",\"active\":false}";
User parsed = mapper.readValue(input, User.class);
System.out.println(parsed.getName()); // Sara
That's it. Two core methods handle 90% of your work: writeValueAsString(...) to write and readValue(...) to read. There are overloads too: writeValue(File/OutputStream, obj) writes straight to a file/stream, and readValue(File/InputStream/byte[], Class) reads from various sources.
Think of ObjectMapper as a professional translator who goes both English→Persian and back. You just hand over the text and the "target form"; by inspecting the structure (reflection) it works out what maps to what. Its settings (features, modules) are like the translator's "dialect and style" — you configure it once and reuse it forever.
The critical performance and thread-safety point
Constructing an ObjectMapper is expensive: it spins up caches and modules. If you build one inside every method or every request, you kill your app's performance. The right way: build one shared instance and reuse it (e.g. a static final, or an @Bean in Spring).
Good news: once fully configured, ObjectMapper is thread-safe for reading/writing. Many threads can use it concurrently and safely. Only during configuration (registering modules, enabling features) must it not be touched from other threads.
If you want a full safety guarantee plus a little more speed, Jackson has two fully immutable, thread-safe siblings you obtain from the mapper:
ObjectMapper mapper = new ObjectMapper();
// Build once, use infinitely — fully thread-safe
ObjectWriter writer = mapper.writerFor(User.class).withDefaultPrettyPrinter();
ObjectReader reader = mapper.readerFor(User.class);
String pretty = writer.writeValueAsString(u);
User back = reader.readValue(input);
Two features you'll need on day one
ObjectMapper mapper = new ObjectMapper();
// 1) If the JSON has a field your class doesn't — don't fail, ignore it
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// 2) Pretty, indented output (for debug/logs only; not in prod — it bloats size)
mapper.enable(SerializationFeature.INDENT_OUTPUT);
In Jackson 2 this feature is on by default: if the incoming JSON has a field your class lacks, an UnrecognizedPropertyException is thrown. That means the moment the other side's API adds a new field, your client suddenly breaks. For public APIs it's usually better to turn it off. Notably, Jackson 3 flipped this default to off.
Part 3 — Three ways to work with JSON: streaming, tree, data-binding
This is one of the most important architectural decisions. Jackson offers three completely different styles for working with JSON, and choosing right is the difference between performance and readability.
Imagine a thousand-page book:
- streaming: you flip page by page, jot down only what you need, and move on. The whole book is never in your head at once. Fastest and lowest-memory, but you must track your position yourself.
- tree: you scan the whole book and build a tree map of it in your mind that you can jump around. Flexible, but the whole thing is in memory.
- data-binding: you hand the book to an assistant and say "pour this into this ready-made form." They fill it and hand it back. Easiest and cleanest, the most common case.
1) Streaming API — low-level, fast, token-by-token
Here you work directly with JsonParser (reading) and JsonGenerator (writing). Jackson breaks JSON into a sequence of tokens (START_OBJECT, FIELD_NAME, VALUE_STRING, …) and you advance through them one at a time:
JsonFactory factory = new JsonFactory();
// Manual writing
StringWriter sw = new StringWriter();
try (JsonGenerator gen = factory.createGenerator(sw)) {
gen.writeStartObject();
gen.writeNumberField("id", 42);
gen.writeStringField("name", "Ali");
gen.writeEndObject();
}
// {"id":42,"name":"Ali"}
// Manual reading
try (JsonParser p = factory.createParser("{\"id\":42,\"name\":\"Ali\"}")) {
while (p.nextToken() != JsonToken.END_OBJECT) {
if (p.currentToken() == JsonToken.FIELD_NAME) {
String field = p.currentName();
p.nextToken(); // move onto the value
if (field.equals("name")) {
System.out.println("name = " + p.getText());
}
}
}
}
When do you use this? When the data is very large (multi-gigabyte logs or export files) and you don't want to hold it all in memory, or when maximum throughput is critical. The price: verbose, error-prone code.
2) Tree Model — the whole JSON as a tree
Here Jackson reads the entire JSON into a JsonNode tree. Like the DOM for HTML. Use it when the structure is unknown or dynamic, or when you only want a few fields without defining a class:
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(input);
long id = root.get("id").asLong();
String city = root.path("address").path("city").asText("unknown"); // path is safer
boolean isAdmin = false;
for (JsonNode role : root.path("roles")) {
if (role.asText().equals("admin")) isAdmin = true;
}
// Building an editable tree
ObjectNode out = mapper.createObjectNode();
out.put("ok", true);
out.putArray("items").add(1).add(2).add(3);
get("x") returns null if the field is missing, and then .asText() blows up with an NPE. But path("x") returns a MissingNode, which is safe and supports .asText("default"). For traversing uncertain structures, always prefer path.
3) Data Binding — the most common case
Exactly what we saw in Part 2: bind JSON straight to a POJO/record. 95% of daily work is this. For generic types (like List<User>) you must use a TypeReference, otherwise Java's type erasure loses the element type:
// This does NOT do what you think — the inner type is lost
List<User> wrong = mapper.readValue(jsonArray, List.class); // List<LinkedHashMap>!
// The right way: TypeReference preserves the full type
List<User> users = mapper.readValue(jsonArray,
new TypeReference<List<User>>() {});
Map<String, User> byName = mapper.readValue(jsonMap,
new TypeReference<Map<String, User>>() {});
If you pass List.class, Jackson doesn't know it's User inside, so it turns each element into a LinkedHashMap; later, casting to User throws ClassCastException. This is one of the most common beginner bugs. For anything generic, use TypeReference.
Comparison table
| Feature | Streaming | Tree | Data-binding |
|---|---|---|---|
| Core classes | JsonParser/JsonGenerator |
JsonNode/ObjectNode |
ObjectMapper + POJO |
| Level | Low | Medium | High |
| Performance | Highest | Medium | Very good |
| Memory use | Lowest | High (whole tree) | Medium |
| Code convenience | Lowest | Medium | Highest |
| Dynamic/unknown shape | Hard | Excellent | Needs a class |
| When? | Huge data, critical speed | Irregular/dynamic JSON | The normal case (most of the time) |
All three styles are built on one shared streaming engine. Tree and data-binding internally use the same JsonParser and just add a more convenient layer on top. That's why you can mix styles: e.g. data-bind the whole object but keep one specific field as a raw JsonNode.
Part 4 — Core annotations: precise control over mapping
The real world is never tidy: the field name in JSON differs from the field name in Java, some fields shouldn't be serialized, you don't want nulls, and so on. Annotations are the control levers. Let's see the important ones in code:
public class Account {
// The JSON field name differs from the Java name
@JsonProperty("user_name")
private String userName;
// This field must never appear in JSON (neither on read nor write)
@JsonIgnore
private String passwordHash;
// Several allowed input names when reading (output uses the primary name)
@JsonAlias({"email", "mail", "emailAddress"})
private String email;
// Date format
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
private LocalDate birthDate;
// ... getters/setters
}
Let's categorize the important ones:
| Annotation | What it does |
|---|---|
@JsonProperty("name") |
Maps the field name between JSON and Java; also exposes private fields |
@JsonIgnore |
Completely ignore this field |
@JsonIgnoreProperties({...}) |
On the class: ignore specific fields; or ignoreUnknown=true |
@JsonInclude(...) |
What ends up in the output (e.g. drop nulls) |
@JsonCreator |
Which constructor/method to use when building the object on read |
@JsonFormat |
Format of dates/numbers/enums |
@JsonAlias |
Alternative input names when reading |
@JsonValue |
A method that represents the whole object's value (great for enums) |
@JsonNaming |
A naming strategy for the whole class (e.g. snake_case) |
@JsonAnyGetter/@JsonAnySetter |
For dynamic/unknown fields |
@JsonInclude — getting rid of nulls
By default, Jackson writes every field, even nulls. Often you don't want output full of "x": null:
@JsonInclude(JsonInclude.Include.NON_NULL) // only non-null fields
public class Response {
public String data;
public String error; // if null, it won't appear in the output at all
}
// or globally on the whole mapper:
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
Options: NON_NULL (drop nulls), NON_EMPTY (also drop empty strings/lists), NON_ABSENT (for empty Optional), NON_DEFAULT (drop default values).
@JsonCreator — when your constructor has arguments
If your class has no getters/setters and no no-arg constructor (e.g. it's an immutable object), Jackson doesn't know how to build it. With @JsonCreator you point it at the constructor, and with @JsonProperty you wire each argument to a JSON field:
public final class Point {
private final int x;
private final int y;
@JsonCreator
public Point(@JsonProperty("x") int x,
@JsonProperty("y") int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
}
By default Java does not keep method argument names in the bytecode (they become arg0, arg1). So Jackson can't tell which is x. Two fixes: state it explicitly with @JsonProperty("x"), or add the jackson-module-parameter-names module and compile with the -parameters flag so names are preserved. In Jackson 3 this module is built in, so you don't need to add it separately.
@JsonValue — clean enums
public enum Status {
ACTIVE("A"), INACTIVE("I"), BANNED("B");
private final String code;
Status(String code) { this.code = code; }
@JsonValue // write this short code in JSON
public String getCode() { return code; }
@JsonCreator // and read from that same code
public static Status fromCode(String code) {
for (Status s : values())
if (s.code.equals(code)) return s;
throw new IllegalArgumentException("bad code: " + code);
}
}
// Status.ACTIVE -> "A" (not "ACTIVE")
Part 5 — Dates and java.time: the source of half your headaches
Dates don't exist in JSON! (Remember? JSON only has number/string/boolean.) So Jackson has to decide how to write a LocalDateTime. And here lurks a classic trap.
If you serialize java.time types (like LocalDate, LocalDateTime, Instant) without the right setup, Jackson writes them as an array of numbers or a numeric timestamp:
{ "createdAt": [2026, 7, 20, 14, 30, 0] }
This is almost never what you want. You usually want ISO-8601: "2026-07-20T14:30:00".
Why does this happen? Because Jackson 2 doesn't know java.time by default — you must register the JavaTimeModule and disable the notorious WRITE_DATES_AS_TIMESTAMPS feature:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // teach it java.time
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // ISO-8601 strings, not numbers
// Since Jackson 2.10, the shorter way: auto-discover all modules
ObjectMapper m2 = JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
Correct output:
{ "createdAt": "2026-07-20T14:30:00" }
Spring Boot automatically registers JavaTimeModule and disables WRITE_DATES_AS_TIMESTAMPS. That's why in a Spring project your dates usually come out as proper ISO without any effort. But if you hand-build a new ObjectMapper() somewhere, you lose this configuration — a very common bug.
To control the format of a specific field, use @JsonFormat:
public class Event {
@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd HH:mm:ss",
timezone = "Asia/Tehran")
private ZonedDateTime startsAt;
}
In Jackson 3, the java.time (JSR-310) module, the parameter-names module, and Optional support are all built in and default, and dates are written as ISO-8601 by default. So this whole headache is gone in the 3.x generation. But since most projects are still on 2.x, you must know this configuration.
Part 6 — Records and modern Java
Since Java 14/16, records arrived: immutable, terse data classes. Good news: Jackson has supported records natively since version 2.12 (late 2020). So this just works, with no annotations:
public record UserDto(long id, String name, boolean active) {}
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new UserDto(42, "Ali", true));
// {"id":42,"name":"Ali","active":true}
UserDto back = mapper.readValue(json, UserDto.class); // it works!
Why are records so clean with Jackson? Because a record has a canonical constructor and Jackson uses it directly — exactly like the @JsonCreator pattern from Part 4, but automatic. Records are ideal DTOs: immutable, thread-safe, no boilerplate.
Jackson gets a record's component names via the Record Reflection API, so it usually works even without the -parameters flag. But if the JSON names differ from the record names, annotate the component itself:
public record UserDto(
long id,
@JsonProperty("full_name") String name,
boolean active) {}
Both cut boilerplate, but a record is part of the language itself and always immutable. Lombok is an annotation processor with more flexibility (mutable, custom builders). For simple read-only DTOs exchanged via Jackson, a record is often the cleaner choice.
Part 7 — Custom serializers and deserializers
Sometimes Jackson's default behavior isn't enough: you want a specific type written a specific way — e.g. serialize a Money as the string "$12.50". That's where you write a custom serializer.
// Serializer: object -> JSON
public class MoneySerializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen,
SerializerProvider sp) throws IOException {
gen.writeString("$" + value.getAmount());
}
}
// Deserializer: JSON -> object
public class MoneyDeserializer extends JsonDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctx)
throws IOException {
String raw = p.getText().replace("$", "");
return new Money(new BigDecimal(raw));
}
}
Now register them with a SimpleModule so they apply globally:
SimpleModule module = new SimpleModule();
module.addSerializer(Money.class, new MoneySerializer());
module.addDeserializer(Money.class, new MoneyDeserializer());
ObjectMapper mapper = JsonMapper.builder()
.addModule(module)
.build();
Or if you only want it for one field, put it right on the field:
public class Invoice {
@JsonSerialize(using = MoneySerializer.class)
@JsonDeserialize(using = MoneyDeserializer.class)
private Money total;
}
JsonSerializer is the base. If you need more (like null handling, access to type info), extend StdSerializer<T>, a more equipped subclass. For most work, plain JsonSerializer is enough.
If a combination of @JsonProperty, @JsonFormat, @JsonInclude solves it, prefer annotations (simpler, more readable). Save custom serializers for real logic: a truly unusual format, computation during serialization, compatibility with a weird legacy API.
Part 8 — Polymorphic deserialization and the security bomb
This is the most important security section of the whole chapter. Read it carefully; a senior interview will almost certainly ask about it.
The problem: polymorphism
Suppose you have a field of type Animal that could be a Dog or a Cat. When you serialize, only the fields go into JSON — but the actual type is lost. On the way back, how does Jackson know to build a Dog or a Cat?
The correct and safe way to solve it is the @JsonTypeInfo annotation, which puts an explicit "type tag" in the JSON:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Dog.class, name = "dog"),
@JsonSubTypes.Type(value = Cat.class, name = "cat")
})
public abstract class Animal { public String name; }
public class Dog extends Animal { public boolean goodBoy; }
public class Cat extends Animal { public int lives; }
The output now carries a type tag and is unambiguous:
{ "type": "dog", "name": "Rex", "goodBoy": true }
The key point: here you have explicitly, with a closed list (@JsonSubTypes), declared which types are allowed. Jackson will only build a Dog or a Cat, nothing else.
The bomb: Default Typing and gadget chains
Now suppose that instead of this closed list, you tell Jackson: "for any object, write the full Java class name into the JSON, and on read, build whatever class the JSON says." This is called default typing:
// DON'T do this with untrusted input
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping(); // deprecated and dangerous
The output looks something like this, carrying the fully-qualified class name inside the data itself:
["com.example.Dog", { "name": "Rex" }]
Why is this a security disaster?
When default typing is on, an attacker can write into the JSON: "please build an object of class X." If a "dangerous" class exists on your classpath that does something dangerous while being constructed or populated — e.g. a DataSource that connects to an arbitrary URL and loads code remotely — the attacker, by building that class, can run arbitrary code on your server (RCE).
Such exploitable classes are called gadgets, and the sequence of objects that together form a full attack is a gadget chain. Well-known libraries like vulnerable versions of commons-collections, c3p0, logback, and others have been full of these gadgets.
This is precisely the origin of a long string of infamous jackson-databind CVEs — from CVE-2017-7525 (the first big wave) onward. For years the Jackson team played whack-a-mole with a blocklist of dangerous classes: every time a new gadget was found, a new CVE and a new release followed. But a blocklist is inherently losing, because there's always a next gadget.
The right way: PolymorphicTypeValidator and allowlist
From Jackson 2.10 the approach changed: enableDefaultTyping() was deprecated and replaced by activateDefaultTyping(PolymorphicTypeValidator, ...), which forces you to supply a type validator — i.e. an allowlist of permitted classes/packages:
// If you truly need default typing, at least restrict it
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.myapp.model.") // only my own package
.build();
ObjectMapper mapper = JsonMapper.builder()
.activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
.build();
A recent, important lesson: in 2026 the vulnerability CVE-2026-54512 showed that PolymorphicTypeValidator only checked the outer container class, not the generic type arguments nested inside it. If your allowlist permitted even a single generic container like java.util.ArrayList, an attacker could supply a type like java.util.ArrayList<com.evil.Gadget>: the container name passed the check, but the nested gadget got instantiated. It was patched in versions 2.18.8, 2.21.4, and 3.1.4. The lesson: always stay on the latest patch.
Never enable default typing with untrusted data. For polymorphism, use @JsonTypeInfo with @JsonSubTypes (an explicit closed list), not default typing. If you must, supply a PolymorphicTypeValidator with a tight allowlist and keep jackson-databind up to date. This is the difference between "a simple bug" and "RCE on your production server."
Usually to store arbitrary objects without knowing their type in advance — e.g. some old Redis/cache or queue setups that keep Java objects as JSON. The safer path: use typed serialization but with a restricted allowlist, or just define specific DTOs with @JsonTypeInfo.
Part 9 — Performance and best practices
- Build
ObjectMapperonce and share it (the biggest performance win). - For very large data, choose streaming, not tree/databind (which pull all data into memory).
- Instead of
writeValueAsStringthengetBytes, usewriteValueAsBytesdirectly to remove a conversion. - Use pre-built, immutable
ObjectReader/ObjectWriteron hot paths. - In Jackson 2, the Afterburner or Blackbird module speeds things up via bytecode generation. In Jackson 3 the engine was rewritten and some of these optimizations are built in.
- Jackson 3 introduces a RecyclerPool concept for buffer reuse, which helps in high-thread-count environments (e.g. Loom/virtual threads).
1) Global vs local configuration: set features on the shared mapper, don't build a fresh mapper each time (which is both slow and loses configuration).
2) Leaking via JsonNode: holding large JsonNode trees in memory for a long time drives up RAM use. If you only need a few fields, read them and drop the tree.
Part 10 — Jackson 3: what changed?
If you're starting a new project or moving to Spring Boot 4, you should know these:
| Topic | Jackson 2.x | Jackson 3.x |
|---|---|---|
| Package/groupId | com.fasterxml.jackson.* |
tools.jackson.* (except annotations) |
| Minimum Java | Java 8 | Java 17 |
| ObjectMapper | mutable | immutable, builder-only |
| Main class | ObjectMapper |
JsonMapper (subclass of ObjectMapper, JSON-specific) |
| java.time / parameter-names / Optional | separate modules | built in and default |
| Date default | numeric timestamp | ISO-8601 string |
FAIL_ON_UNKNOWN_PROPERTIES |
on | off |
// Jackson 3 style — immutable builder
import tools.jackson.databind.json.JsonMapper;
JsonMapper mapper = JsonMapper.builder()
.configure(SerializationFeature.INDENT_OUTPUT, true)
.build();
String json = mapper.writeValueAsString(new UserDto(42, "Ali", true));
jackson-annotations is the one module whose package did not change (still com.fasterxml.jackson.annotation). This is deliberate, so annotation-only code migrates more easily and 2 and 3 can coexist. Spring Boot 4 defaults to Jackson 3 but also provides backward compatibility with 2.
Interview questions
Serialize means converting a live Java object into a flat text/byte representation (here, JSON) that can be stored or transmitted; in Jackson via writeValueAsString, writeValueAsBytes, or writeValue(out, obj). Deserialize is the reverse: building a live Java object from JSON; via readValue(src, Class) or readValue(src, TypeReference). The underlying concept is "data binding": automatically mapping JSON fields to a class's fields/constructor using reflection.
Streaming (JsonParser/JsonGenerator): low-level, token-by-token, fastest and lowest-memory; suits very large data or ultra-hot paths, at the cost of verbose code. Tree (JsonNode): pulls the whole JSON into a tree; suits dynamic/unknown structures or grabbing a few fields; but the whole thing is in memory. Data binding (ObjectMapper + POJO/record): highest level and most common; clean and easy, the normal case. All three are built on one shared streaming engine and can be mixed.
Constructing an ObjectMapper is expensive (spinning up caches and modules); creating one repeatedly tanks performance and loses your configuration. The right way: one shared instance (static final or @Bean). Once configured, the mapper is thread-safe for read/write operations, and many threads can use it concurrently; only during configuration (registering modules/changing features) must it not be used concurrently. For a full safety guarantee, use the immutable ObjectReader/ObjectWriter.
Because of type erasure, at runtime the list's element type (User) is gone and Jackson only sees List; so it turns each element into a LinkedHashMap, and later casting to User throws ClassCastException. The fix is TypeReference, which retains the full generic type at runtime: mapper.readValue(json, new TypeReference<List<User>>() {}). The same applies to Map<String, User> and any other generic type.
Because in Jackson 2 the java.time module isn't registered by default and a feature called WRITE_DATES_AS_TIMESTAMPS is on, which writes dates as numbers/arrays. The fix: register JavaTimeModule and disable WRITE_DATES_AS_TIMESTAMPS (or findAndAddModules()), so output becomes ISO-8601. Spring Boot does this automatically. In Jackson 3 this module is built in and the default is ISO-8601, so the problem is gone. For a specific field's format, use @JsonFormat(shape=STRING, pattern=...).
Default typing means Jackson writes the fully-qualified Java class name into the JSON and, on read, builds whatever class the data names. The danger: with untrusted data an attacker can say "build an object of dangerous class X"; if that class (a gadget) does something dangerous during construction/population, you get remote code execution (RCE) — the origin of the famous jackson-databind CVEs from 2017-7525 onward. The safe way: use @JsonTypeInfo with @JsonSubTypes (an explicit closed list), not default typing. If you must, supply activateDefaultTyping with an allowlist PolymorphicTypeValidator and always stay on the latest patch.
Blocklist means "everything is allowed except these specific dangerous classes." The problem: it's inherently losing — there's always an undiscovered next gadget, so you get an endless CVE-and-patch game (jackson-databind's story up to 2.10). Allowlist is the opposite: "nothing is allowed except these specific classes/packages." Because it restricts the attack surface to a small controlled set, it's inherently safer. That's why from 2.10 the allowlist model became mandatory via PolymorphicTypeValidator. (Note: CVE-2026-54512 showed even an allowlist leaks if it doesn't check generic type arguments.)
If the problem is solved by a combination of ready-made annotations (@JsonProperty, @JsonFormat, @JsonInclude, @JsonValue), use those; they're simpler, more readable, and keep serialization concerns out of code. Reserve custom serializers/deserializers (JsonSerializer/JsonDeserializer or StdSerializer) for real logic: a truly unusual format, computation during serialization, compatibility with a weird legacy API, or mapping a third-party type you can't annotate. Register globally with SimpleModule, or locally with @JsonSerialize(using=...).
Since there's no no-arg constructor and no setters, Jackson alone can't build it. The solution is @JsonCreator on the argument-taking constructor, with @JsonProperty("name") on each argument so Jackson knows which JSON field maps to which argument (because argument names aren't in the bytecode by default). Alternative: the parameter-names module + compiling with -parameters. For records this is fully automatic because they have a canonical constructor and Jackson (since 2.12) supports them natively.
It controls which fields end up in the serialized output. NON_NULL drops null fields; NON_EMPTY also drops empty strings/collections; NON_ABSENT is for empty Optional; NON_DEFAULT drops fields still holding their type's default value; and ALWAYS (the default) writes everything. It can be applied on a field, on a class, or globally on the mapper. Common use: shrinking API responses by dropping nulls.
The package and groupId changed from com.fasterxml.jackson to tools.jackson (except jackson-annotations), so 2 and 3 can be used side by side. Minimum Java went from 8 to 17. ObjectMapper became immutable and is built only via JsonMapper.builder(). The java.time, parameter-names, and Optional modules became built in and default, and dates default to ISO-8601. The FAIL_ON_UNKNOWN_PROPERTIES default flipped from on to off. Jackson 3.0 went GA in October 2025 and Spring Boot 4 rides on it.
Because in Jackson 2 it's on by default, and if the other service adds a new field to the JSON that your class doesn't have, deserialization blows up with UnrecognizedPropertyException, breaking your client suddenly. For forward compatibility in distributed systems, it's usually better to ignore unknown fields (disable(FAIL_ON_UNKNOWN_PROPERTIES) or @JsonIgnoreProperties(ignoreUnknown=true)). Jackson 3 made this default saner: off.
Both fetch a field from a JsonNode, but they differ when the field is missing: get("x") returns a Java null, so chaining .asText() on it throws NullPointerException. But path("x") returns a special MissingNode that's safe: isMissingNode() is true on it, and .asText("default") yields a default without error. For traversing uncertain or optional structures, path is always safer.
JSON is the simple, language-independent text data-exchange language, and Jackson is the Java standard for bridging JSON and objects. Serialize turns an object into text and deserialize turns text into an object, via data binding. The library's heart is ObjectMapper, which should be built once and shared (thread-safe after configuration). Three styles: streaming (fast/low-memory), tree (dynamic/flexible), data-binding (easy/common). Annotations (@JsonProperty, @JsonIgnore, @JsonInclude, @JsonCreator, @JsonFormat) give precise mapping control. Dates in Jackson 2 need JavaTimeModule and disabling WRITE_DATES_AS_TIMESTAMPS (built in and ISO-8601 in Jackson 3). Records are clean immutable DTOs, natively supported since 2.12. The most important security lesson: never enable default typing with untrusted data (gadget/RCE risk); use @JsonTypeInfo/@JsonSubTypes for polymorphism, and keep jackson-databind up to date.
Sources: Jackson 3.0 GA (cowtowncoder), Migrating to Jackson 3, Jackson Polymorphic Deserialization CVE Criteria, CVE-2026-54512, Records support in Jackson 2.12.