Boxing, Unboxing & Wrapper Classes
Learn Java Data Types, Type Semantics, Object Model & Data Representation - Part 016
Boxing, unboxing, wrapper classes, primitive-wrapper conversion contexts, wrapper identity traps, Integer cache, null unboxing failures, overload surprises, allocation cost, collection boundaries, and API design guidance.
Part 016 — Boxing, Unboxing & Wrapper Classes
Target part ini: memahami autoboxing bukan sebagai “fitur convenience” saja, tetapi sebagai conversion mechanism yang mengubah primitive value menjadi object wrapper dan sebaliknya. Salah memahami boxing/unboxing menghasilkan bug identity, NPE tersembunyi, overload surprise, allocation overhead, dan contract yang ambigu antara “0” dan “absent”.
1. Mental Model Utama
Java punya primitive types:
int count = 10;
boolean active = true;
double ratio = 0.25;
Dan wrapper classes:
Integer count = 10;
Boolean active = true;
Double ratio = 0.25;
Primitive value bukan object. Wrapper adalah object.
int = primitive value
Integer = reference to object wrapping an int value
Diagram:
Autoboxing membuat konversi tampak halus:
Integer x = 42; // boxing
int y = x; // unboxing
Namun secara desain, ini tetap crossing boundary:
primitive world <-> object world
2. Wrapper Classes
Mapping primitive ke wrapper:
| Primitive | Wrapper |
|---|---|
boolean | Boolean |
byte | Byte |
short | Short |
char | Character |
int | Integer |
long | Long |
float | Float |
double | Double |
void | Void |
Wrapper class berguna untuk:
- collection generic:
List<Integer> - nullable representation:
Integer countOrNull - reflection API
- method reference dan functional interface tertentu
- parsing/conversion helper:
Integer.parseInt,Long.toUnsignedString - object-based APIs yang tidak menerima primitive
Tetapi wrapper bukan replacement gratis untuk primitive.
int a = 1;
Integer b = 1;
a dan b punya semantics berbeda:
| Area | Primitive | Wrapper |
|---|---|---|
| Null | Tidak bisa | Bisa null |
| Identity | Tidak punya object identity | Punya reference identity |
| Memory | Compact | Object/reference overhead |
| Generic | Tidak bisa langsung | Bisa |
| Equality | == value comparison | == reference comparison |
| Default field value | 0, false, etc. | null |
3. Boxing Conversion
Boxing conversion mengubah primitive value menjadi wrapper object.
int primitive = 100;
Integer boxed = primitive;
Secara konseptual:
Integer boxed = Integer.valueOf(primitive);
Gunakan valueOf, bukan constructor wrapper.
Integer good = Integer.valueOf(100);
// Integer bad = new Integer(100); // constructor wrapper lama sudah tidak dianjurkan
Autoboxing terjadi di context tertentu, misalnya assignment atau method invocation.
Integer a = 10;
List<Integer> values = new ArrayList<>();
values.add(10);
values.add(10) membutuhkan Integer, sehingga 10 dibox.
4. Unboxing Conversion
Unboxing conversion mengubah wrapper menjadi primitive value.
Integer boxed = Integer.valueOf(100);
int primitive = boxed;
Secara konseptual:
int primitive = boxed.intValue();
Masalah utama: unboxing null menghasilkan NullPointerException.
Integer maybeCount = null;
int count = maybeCount; // NullPointerException
Bug ini sering tersembunyi:
Boolean enabled = config.getFeatureEnabled();
if (enabled) { // unboxing; NPE jika enabled null
activate();
}
Lebih eksplisit:
if (Boolean.TRUE.equals(enabled)) {
activate();
}
Atau desain contract agar null tidak mungkin:
record FeatureConfig(boolean enabled) {}
5. Wrapper Identity Trap
Jangan pakai == untuk membandingkan wrapper value.
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b); // bisa false
System.out.println(a.equals(b)); // true
Kenapa?
a dan b adalah reference ke object wrapper. == pada reference membandingkan identity, bukan numeric value.
Beberapa wrapper value kecil bisa terlihat “aman” karena caching.
Integer x = 127;
Integer y = 127;
System.out.println(x == y); // true pada implementasi umum dan range wajib tertentu
Integer p = 128;
Integer q = 128;
System.out.println(p == q); // sering false
Lesson:
Wrapper caching is an implementation/contract detail for efficiency, not a domain equality model.
Rule sederhana:
Objects.equals(a, b); // null-safe wrapper equality
Untuk numeric comparison, unbox dengan null policy eksplisit.
int left = requireNonNull(a, "a");
int right = requireNonNull(b, "b");
return Integer.compare(left, right);
6. Boolean Wrapper Trap
Boolean sering digunakan untuk tiga state:
Boolean approved;
Mungkin artinya:
true: approvedfalse: rejectednull: not reviewed
Ini valid jika memang tri-state domain, tetapi sering muncul tanpa sengaja karena framework/persistence/JSON.
Kode berbahaya:
if (approved) {
publish();
}
Jika approved == null, terjadi NPE.
Lebih jelas dengan enum:
enum ReviewDecision {
NOT_REVIEWED,
APPROVED,
REJECTED
}
Atau dengan result type:
record ReviewState(ReviewDecision decision, Instant decidedAt) {}
Gunakan Boolean hanya jika:
- boundary memang nullable
- null punya arti eksplisit
- semua consumer tahu policy-nya
- conversion ke domain type dilakukan sedini mungkin
7. Primitive Default vs Wrapper Default
Field primitive punya default value.
class Metrics {
int count; // 0
boolean enabled; // false
}
Wrapper field punya default null.
class Metrics {
Integer count; // null
Boolean enabled; // null
}
Perbedaan ini mempengaruhi framework binding.
Misalnya JSON request:
{}
Jika bind ke:
record Request(int limit) {}
Missing limit bisa menjadi 0 tergantung framework/constructor binding strategy.
Jika bind ke:
record Request(Integer limit) {}
Missing bisa terlihat sebagai null, tetapi sekarang setiap usage harus punya null policy.
Desain lebih defensible:
record PageRequest(int limit) {
PageRequest {
if (limit < 1 || limit > 100) {
throw new IllegalArgumentException("limit must be 1..100");
}
}
}
Atau explicit optional boundary:
record RawPageRequest(Integer limit) {}
record PageRequest(int limit) {
static PageRequest from(RawPageRequest raw) {
int resolved = raw.limit() == null ? 50 : raw.limit();
return new PageRequest(resolved);
}
}
8. Boxing Dalam Collections
Generic collections tidak bisa langsung memakai primitive type parameter.
// List<int> invalid
List<Integer> values = new ArrayList<>();
values.add(1); // boxing
int first = values.get(0); // unboxing
Ini nyaman tetapi punya cost:
- object allocation atau cached wrapper reuse
- reference indirection
- memory overhead
- null element risk
- unboxing NPE risk
- GC pressure pada workload besar
Untuk data kecil/domain-level, List<Integer> baik-baik saja.
Untuk numeric hotspot besar, primitive array sering lebih tepat.
int[] values = new int[size];
Untuk stream primitive, gunakan primitive specialization.
IntStream.range(0, 1_000_000).sum();
Daripada:
Stream.iterate(0, i -> i + 1)
.limit(1_000_000)
.mapToInt(Integer::intValue)
.sum();
9. Boxing Dalam Streams
Ada dua keluarga stream:
Stream<Integer> objectStream;
IntStream intStream;
LongStream longStream;
DoubleStream doubleStream;
Gunakan primitive stream untuk operasi numeric intensif.
int total = orders.stream()
.mapToInt(Order::itemCount)
.sum();
Ini lebih tepat daripada:
Integer total = orders.stream()
.map(Order::itemCount) // Stream<Integer>
.reduce(0, Integer::sum);
Bukan karena setiap boxing pasti buruk, tetapi karena numeric aggregation secara natural primitive.
Boundary umum:
List<Integer> ids = IntStream.rangeClosed(1, 100)
.boxed()
.toList();
boxed() adalah crossing dari primitive stream ke object stream. Gunakan saat memang perlu collection of wrappers.
10. Boxing Dalam Overload Resolution
Autoboxing dapat mempengaruhi method overload.
void handle(int value) {
System.out.println("int");
}
void handle(Integer value) {
System.out.println("Integer");
}
handle(1); // int
Exact primitive match lebih spesifik daripada boxing.
Contoh lain:
void handle(long value) {
System.out.println("long");
}
void handle(Integer value) {
System.out.println("Integer");
}
handle(1); // long, widening primitive lebih dipilih daripada boxing
Varargs bisa membuat lebih rumit:
void f(Integer value) {}
void f(int... values) {}
f(1); // Integer overload biasanya lebih specific daripada varargs
Desain API guideline:
- jangan menyediakan overload primitive dan wrapper kecuali sangat perlu
- jangan gabungkan primitive/wrapper/varargs overload yang ambigu
- gunakan nama method berbeda jika semantics berbeda
- test overload behavior dengan compile-time examples
11. Arithmetic Dengan Wrapper
Wrapper dalam arithmetic akan di-unbox.
Integer a = 10;
Integer b = 20;
Integer c = a + b;
Konseptual:
Integer c = Integer.valueOf(a.intValue() + b.intValue());
Ada unboxing dan boxing lagi.
Jika a null:
Integer a = null;
int b = a + 1; // NPE
Compound operations juga unbox/box.
Integer count = 0;
count++; // unbox, increment, box
Dalam loop panas:
Integer sum = 0;
for (int value : values) {
sum += value;
}
Lebih baik:
int sum = 0;
for (int value : values) {
sum += value;
}
12. Wrapper Dalam Map dan Cache Key
Wrapper sering dipakai sebagai key.
Map<Integer, CaseRecord> casesById = new HashMap<>();
Ini valid, tetapi perhatikan:
- null key mungkin valid di
HashMap, tetapi biasanya buruk untuk domain ID Integerbukan domain type;CaseIdlebih defensible- numeric range dan source harus jelas
Lebih baik:
record CaseId(long value) {
CaseId {
if (value <= 0) {
throw new IllegalArgumentException("CaseId must be positive");
}
}
}
Map<CaseId, CaseRecord> casesById = new HashMap<>();
Boxing menyelesaikan kebutuhan object key, tetapi tidak menyelesaikan semantic correctness.
13. Wrapper Equality Dengan Floating Point
Double dan Float punya semantics khusus karena floating point punya NaN dan signed zero.
Double a = Double.NaN;
Double b = Double.NaN;
System.out.println(a == b); // reference comparison
System.out.println(a.equals(b)); // true for Double equals semantics
Primitive comparison berbeda:
System.out.println(Double.NaN == Double.NaN); // false
Signed zero juga harus hati-hati.
Double p = 0.0;
Double n = -0.0;
System.out.println(p.equals(n)); // false
Rule:
- jangan gunakan
Doublesebagai domain equality key tanpa memahami NaN/signed zero - untuk measurement, simpan unit dan precision policy
- untuk money, gunakan
BigDecimalatau domain money type, bukanDouble
14. Parsing: Wrapper vs Primitive Utility
Wrapper class menyediakan parsing.
int limit = Integer.parseInt("100"); // returns int
Integer boxed = Integer.valueOf("100"); // returns Integer
Gunakan parseXxx jika butuh primitive.
long id = Long.parseLong(rawId);
Gunakan valueOf jika butuh wrapper.
Long id = Long.valueOf(rawId);
Jangan biarkan NumberFormatException bocor sebagai domain error jika input berasal dari user/API.
static CaseId parseCaseId(String raw) {
try {
return new CaseId(Long.parseLong(raw));
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("caseId must be a valid integer", ex);
}
}
15. Optional Primitive Types
Java menyediakan primitive optional untuk beberapa tipe:
OptionalInt maybeInt;
OptionalLong maybeLong;
OptionalDouble maybeDouble;
Gunakan saat absence perlu dimodelkan tanpa wrapper null.
OptionalInt score = findScore(userId);
int resolved = score.orElse(0);
Namun jangan pakai optional field secara sembarangan untuk domain entity. Optional paling kuat sebagai return type untuk query yang mungkin tidak menemukan hasil.
Contoh baik:
OptionalInt findRetryAfterSeconds(Response response) { ... }
Contoh yang perlu dipertimbangkan ulang:
record Policy(OptionalInt maxAttempts) {}
Sering lebih jelas:
enum AttemptPolicyKind { UNLIMITED, LIMITED }
record AttemptPolicy(AttemptPolicyKind kind, int maxAttempts) {}
Atau:
sealed interface AttemptPolicy permits UnlimitedAttempts, LimitedAttempts {}
record UnlimitedAttempts() implements AttemptPolicy {}
record LimitedAttempts(int maxAttempts) implements AttemptPolicy {}
16. API Design: Primitive, Wrapper, Optional, atau Value Object?
Gunakan primitive jika:
- value selalu ada
- tidak butuh null
- domain invariant sederhana
- performance/compactness penting
record RetryPolicy(int maxAttempts) {
RetryPolicy {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be positive");
}
}
}
Gunakan wrapper jika:
- API/framework membutuhkan object
- generic collection membutuhkan type parameter
- boundary raw data perlu membedakan missing dari explicit value
- null policy dikonversi cepat ke domain type
record RawRequest(Integer limit) {}
Gunakan OptionalInt/OptionalLong/OptionalDouble jika:
- method return mungkin absent
- caller harus memilih default/error path
- tidak ingin nullable wrapper
OptionalLong findLastProcessedOffset(StreamId streamId);
Gunakan value object jika:
- angka punya arti domain
- ada invariant
- unit penting
- equality harus semantic
- API butuh self-documenting type
record TimeoutMillis(long value) {
TimeoutMillis {
if (value < 0) {
throw new IllegalArgumentException("timeout must not be negative");
}
}
}
Decision diagram:
17. Performance Mental Model
Autoboxing can be optimized by JIT in some contexts, but source-level design must not assume optimization always removes cost.
Boxing can cost:
- allocation
- object header
- reference storage
- pointer chasing
- cache misses
- GC pressure
- unboxing checks
Example:
List<Integer> values = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
values.add(i); // many boxed Integers
}
For hot numeric storage:
int[] values = new int[1_000_000];
for (int i = 0; i < values.length; i++) {
values[i] = i;
}
But do not overfit all domain code to primitive arrays. Maintainability and semantic correctness matter.
Cost model:
Domain correctness first.
Measured hotspot optimization second.
Primitive storage when data volume or loop intensity justifies it.
18. Boxing Dalam Logging Dan Varargs
Logging API sering menerima Object....
logger.info("processed {} records", count);
Jika count primitive, ia bisa dibox karena varargs object array membutuhkan Object.
Biasanya ini acceptable untuk normal logging, tetapi di hot loop debug-heavy code, cost bisa muncul.
for (int i = 0; i < records.length; i++) {
logger.debug("record index {}", i); // boxing if executed
}
Gunakan level guard jika expensive argument construction terjadi.
if (logger.isDebugEnabled()) {
logger.debug("record index {} payload {}", i, expensivePayloadSummary(record));
}
Boxing integer kecil mungkin bukan bottleneck utama; expensive string/object construction sering lebih besar. Tetap pahami bahwa Object... adalah crossing ke object world.
19. Concurrency: Wrapper Bukan Mutable Counter
Wrapper immutable.
Integer count = 0;
count++; // creates/reuses another Integer reference conceptually
Jangan gunakan wrapper sebagai mutable shared counter.
class Counter {
Integer value = 0;
void increment() {
value++; // not atomic
}
}
Gunakan primitive dengan synchronization jika sesuai:
class Counter {
private int value;
synchronized void increment() {
value++;
}
}
Atau concurrency primitive:
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
Untuk high-contention metrics:
LongAdder adder = new LongAdder();
adder.increment();
Wrapper membantu representasi object, bukan atomicity.
20. Boundary Anti-Patterns
20.1 Nullable Wrapper Dalam Domain Core
record CasePolicy(Integer escalationDays) {}
Masalah:
- null berarti apa?
- unlimited?
- default?
- missing config?
- not applicable?
Lebih baik:
sealed interface EscalationPolicy permits NoEscalation, TimedEscalation {}
record NoEscalation() implements EscalationPolicy {}
record TimedEscalation(int days) implements EscalationPolicy {
TimedEscalation {
if (days < 1) {
throw new IllegalArgumentException("days must be positive");
}
}
}
20.2 Wrapper ==
if (caseId == otherCaseId) { ... }
Jika caseId adalah Long, ini reference comparison.
Fix:
if (Objects.equals(caseId, otherCaseId)) { ... }
Lebih baik:
record CaseId(long value) {}
20.3 Accidental NPE In Condition
Boolean allowed = permissionService.isAllowed(user, action);
if (allowed) { ... }
Fix:
if (Boolean.TRUE.equals(allowed)) { ... }
Lebih baik ubah service contract:
enum AuthorizationDecision { ALLOW, DENY, INDETERMINATE }
20.4 Hot Loop Boxing
Integer total = 0;
for (Order order : orders) {
total += order.amountInCents();
}
Fix:
long total = 0;
for (Order order : orders) {
total += order.amountInCents();
}
Lebih baik domain:
record MoneyCents(long value) {}
21. Testing Boxing Bugs
Tambahkan test untuk boundary null.
@Test
void nullBooleanDoesNotMeanFalseSilently() {
Boolean enabled = null;
assertThrows(NullPointerException.class, () -> {
if (enabled) {
throw new AssertionError();
}
});
}
Test wrapper equality.
@Test
void wrapperIdentityIsNotValueEquality() {
Integer a = 1000;
Integer b = 1000;
assertNotSame(a, b);
assertEquals(a, b);
}
Test mapper/default semantics.
@Test
void rawLimitMissingResolvesToDefault() {
RawPageRequest raw = new RawPageRequest(null);
PageRequest request = PageRequest.from(raw);
assertEquals(50, request.limit());
}
22. Review Checklist
Primitive vs Wrapper
- Apakah value bisa absent?
- Jika absent, apakah null policy eksplisit?
- Apakah primitive default
0/falsebisa menyembunyikan missing input? - Apakah wrapper default
nullbisa menghasilkan NPE?
Equality
- Apakah ada
==pada wrapper? - Apakah
Objects.equalslebih tepat? - Apakah floating wrapper dipakai sebagai key?
- Apakah domain ID masih
Long/Integer, bukan value object?
Performance
- Apakah boxing terjadi di hot loop?
- Apakah
List<Integer>dipakai untuk jutaan angka? - Apakah stream object bisa diganti primitive stream?
- Apakah logging varargs di hot path menghasilkan boxing/array allocation?
API Surface
- Apakah overload primitive/wrapper membingungkan?
- Apakah nullable wrapper bocor dari DTO ke domain core?
- Apakah
OptionalIntlebih tepat untuk return absence? - Apakah value object lebih tepat daripada primitive/wrapper?
Boundary
- Apakah JSON missing vs explicit null vs zero dibedakan?
- Apakah database nullable column dipetakan ke domain type dengan jelas?
- Apakah default value diterapkan di satu boundary, bukan tersebar?
- Apakah validation terjadi sebelum unboxing?
23. Latihan Deliberate Practice
Latihan 1 — Predict Output
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c.equals(d));
Expected reasoning:
a == bsering/wajib true untuk cached small values sesuai range tertentuc == djangan diasumsikan truec.equals(d)true
Rule yang harus dibawa:
Never use wrapper identity as domain equality.
Latihan 2 — Remove Nullable Boolean
Kode awal:
record Review(Boolean approved) {}
Refactor menjadi:
enum ReviewDecision {
NOT_REVIEWED,
APPROVED,
REJECTED
}
record Review(ReviewDecision decision) {
Review {
Objects.requireNonNull(decision, "decision");
}
}
Latihan 3 — DTO Boundary Conversion
Raw request:
record RawSearchRequest(Integer limit, Integer offset) {}
Domain request:
record SearchRequest(int limit, int offset) {
SearchRequest {
if (limit < 1 || limit > 100) {
throw new IllegalArgumentException("limit must be 1..100");
}
if (offset < 0) {
throw new IllegalArgumentException("offset must not be negative");
}
}
static SearchRequest from(RawSearchRequest raw) {
int limit = raw.limit() == null ? 20 : raw.limit();
int offset = raw.offset() == null ? 0 : raw.offset();
return new SearchRequest(limit, offset);
}
}
Tambahkan test untuk:
- missing limit
- explicit limit 0
- explicit null offset
- negative offset
Latihan 4 — Replace Hot Loop Boxing
Kode awal:
Integer total = 0;
for (int amount : amounts) {
total += amount;
}
Refactor:
int total = 0;
for (int amount : amounts) {
total += amount;
}
Kemudian pertimbangkan overflow:
long total = 0;
for (int amount : amounts) {
total += amount;
}
Atau:
int total = 0;
for (int amount : amounts) {
total = Math.addExact(total, amount);
}
24. Mini Capstone: Type-Safe Pagination
Problem:
API menerima query parameter:
limit: optional integer
page: optional integer
Requirement:
- default
limit = 50 - max
limit = 200 - default
page = 1 - page minimal 1
- domain core tidak boleh menerima nullable wrapper
DTO:
record RawPagination(Integer limit, Integer page) {}
Domain:
record PageNumber(int value) {
PageNumber {
if (value < 1) {
throw new IllegalArgumentException("page must be >= 1");
}
}
}
record PageLimit(int value) {
private static final int MAX = 200;
PageLimit {
if (value < 1 || value > MAX) {
throw new IllegalArgumentException("limit must be 1.." + MAX);
}
}
}
record Pagination(PageLimit limit, PageNumber page) {
static Pagination from(RawPagination raw) {
Objects.requireNonNull(raw, "raw");
int limit = raw.limit() == null ? 50 : raw.limit();
int page = raw.page() == null ? 1 : raw.page();
return new Pagination(new PageLimit(limit), new PageNumber(page));
}
}
Mengapa ini lebih kuat?
- wrapper hanya hidup di boundary DTO
- default policy terpusat
- domain type tidak nullable
PageLimitdanPageNumbertidak bisa tertukar dengan mudah- validation terjadi sebelum business logic
25. Ringkasan
Boxing/unboxing membuat Java nyaman menghubungkan primitive dan object world, tetapi convenience ini punya harga semantik.
Prinsip utama:
- Primitive value bukan object.
- Wrapper adalah reference type dan bisa
null. - Boxing mengubah primitive menjadi wrapper.
- Unboxing wrapper
nullmenghasilkan NPE. ==pada wrapper membandingkan reference identity.- Wrapper caching tidak boleh dijadikan equality model.
- Collections generic memakai wrapper, bukan primitive.
- Numeric hot path perlu memperhatikan boxing cost.
- Nullable wrapper harus dibatasi di boundary dan dikonversi ke domain type.
- Value object sering lebih kuat daripada primitive/wrapper telanjang.
Top 1% engineer tidak hanya tahu bahwa
Integermembungkusint. Mereka tahu kapan wrapper memperjelas boundary, kapan wrapper mencemari domain, kapan unboxing bisa meledak, dan kapan primitive/value object adalah model yang lebih defensible.
26. Referensi Resmi
- Java Language Specification, Java SE 25, §5.1.7 Boxing Conversion
- Java Language Specification, Java SE 25, §5.1.8 Unboxing Conversion
- Java Language Specification, Java SE 25, Chapter 5: Conversions and Contexts
- Java SE 25 API:
java.langpackage summary and wrapper classes - Java SE 25 API:
java.util.OptionalInt,OptionalLong,OptionalDouble
You just completed lesson 16 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.