Java là ngôn ngữ statically typed, nên mọi giá trị đều mang một type mà compiler biết rõ. Type casting là cách một giá trị thuộc type này trở thành giá trị của type khác. Java tự làm giúp bạn một số conversion và từ chối làm phần còn lại nếu bạn không yêu cầu rõ ràng — vì loại thứ hai có thể âm thầm làm thay đổi con số của bạn.
Bài này đi qua toàn bộ các conversion có thể xảy ra giữa các primitive, đúng quy tắc mà compiler áp dụng trong từng trường hợp, và những cái bẫy: (byte) 300 in ra 44, byte + byte không compile được, += cắt giá trị mà không hề cảnh báo, và khác biệt giữa cast với parse. Mọi giá trị và mọi error message bên dưới đều lấy từ lần chạy thật trên OpenJDK 21.0.6.
![]()
Có hai chiều và hai quy tắc: widening xảy ra tự động, còn narrowing bắt buộc phải có cast operator và có thể làm đổi giá trị ban đầu.
Widening và narrowing: hai chiều của một conversion
Một conversion là widening khi type đích biểu diễn được mọi giá trị của type nguồn. Không có gì mất đi nên compiler tự chèn vào — đây chính là conversion implicit. Một conversion là narrowing khi type đích không chứa hết được các giá trị nguồn. Compiler không đoán bừa, nên bạn phải viết cast operator (type) và tự chịu trách nhiệm — đây là conversion explicit.
Chuỗi widening của các primitive là cố định:
byte -> short -> int -> long -> float -> double
char nhập vào chuỗi này tại int: một char widen được sang int, long, float và double, nhưng không sang byte hay short. Mọi bước đi sang phải là tự động; mọi bước đi ngược sang trái đều cần cast.

byte b = 42;
short s = b; // byte -> short
int i = s; // short -> int
long l = i; // int -> long
float f = l; // long -> float
double d = f; // float -> double
char c = 'A';
int ci = c; // char -> int
System.out.println(b + " " + s + " " + i + " " + l + " " + f + " " + d);
System.out.println("char 'A' widened to int: " + ci);
42 42 42 42 42.0 42.0
char 'A' widened to int: 65
Không có một cast operator nào, cũng không có giá trị nào thay đổi. Đó chính là mục đích của widening.
Widening tự động nhưng vẫn mất precision
Hai bước trong chuỗi trên là ngoại lệ mà phần lớn tài liệu bỏ qua. int sang float, và long sang float hoặc double, đều là widening — compiler làm mà không cần cast — nhưng vẫn có thể mất precision. Một float có 24 bit significand nên chỉ lưu chính xác mọi số nguyên tới 2^24 = 16.777.216. Một double có 53 bit nên chính xác tới 2^53. Vượt qua ngưỡng đó, máy lấy giá trị biểu diễn được gần nhất.
int big = 16777217; // 2^24 + 1
float bf = big; // widening: không cần cast
System.out.println("int 16777217 -> float -> " + bf);
System.out.println("back to int: " + (int) bf);
System.out.println("equal? " + (big == (int) bf));
long lbig = 9007199254740993L; // 2^53 + 1
double ld = lbig; // vẫn là widening
System.out.println("long 9007199254740993 -> double -> " + (long) ld);
int 16777217 -> float -> 1.6777216E7
back to int: 16777216
equal? false
long 9007199254740993 -> double -> 9007199254740992
Giá trị đã đổi mà không có gì báo lỗi. Widening chỉ bảo đảm giữ được độ lớn, chứ không bảo đảm giữ đúng giá trị. int sang double thì luôn chính xác, vì 32 bit số nguyên nằm gọn trong 53 bit significand, và short sang float cũng vậy; còn int sang float và mọi thứ đi từ long thì không.
Narrowing số thực là cắt bỏ, không phải làm tròn
Cast operator trên double hay float sẽ bỏ phần thập phân và đi về phía 0. Nó không bao giờ làm tròn.
System.out.println("(int) 3.99 = " + (int) 3.99);
System.out.println("(int) -3.99 = " + (int) -3.99);
System.out.println("(int) 3.5 = " + (int) 3.5);
System.out.println("Math.round(3.99) = " + Math.round(3.99));
System.out.println("Math.round(-3.99) = " + Math.round(-3.99));
System.out.println("Math.round(3.5) = " + Math.round(3.5));
System.out.println("Math.round(-3.5) = " + Math.round(-3.5));
(int) 3.99 = 3
(int) -3.99 = -3
(int) 3.5 = 3
Math.round(3.99) = 4
Math.round(-3.99) = -4
Math.round(3.5) = 4
Math.round(-3.5) = -3
(int) -3.99 ra -3 chứ không phải -4: cắt bỏ nghĩa là đi về phía 0, mà với số âm thì hướng đó là lên trên. Muốn làm tròn thì dùng Math.round. Chú ý return type của nó — Math.round(double) trả về long, còn Math.round(float) trả về int, nên int n = Math.round(3.99); không compile được trong khi int n = Math.round(3.99f); thì được. Math.floor và Math.ceil đều trả về double nên vẫn phải cast thêm một lần.
Một số thực quá lớn so với integer type đích thì không quay vòng — nó dừng lại ở giới hạn của type, còn NaN thành 0:
System.out.println("(int) 1e20 = " + (int) 1e20);
System.out.println("(int) -1e20 = " + (int) -1e20);
System.out.println("(int) Double.NaN = " + (int) Double.NaN);
(int) 1e20 = 2147483647
(int) -1e20 = -2147483648
(int) Double.NaN = 0
Hãy nhớ hành vi này, vì narrowing giữa các integer type hoạt động theo một cách hoàn toàn khác.
(byte) 300 thực sự làm gì
Narrowing từ integer type này sang integer type khác giữ lại các bit thấp của giá trị và vứt bỏ toàn bộ phần phía trên. Không có kiểm tra range, không có exception, cũng không có warning.

System.out.println("(byte) 300 = " + (byte) 300);
System.out.println("(byte) 200 = " + (byte) 200);
System.out.println("(byte) 128 = " + (byte) 128);
System.out.println("(byte) -200 = " + (byte) -200);
System.out.println("(short) 70000 = " + (short) 70000);
System.out.println("(short) 40000 = " + (short) 40000);
System.out.println("(int) 4294967298L = " + (int) 4294967298L);
(byte) 300 = 44
(byte) 200 = -56
(byte) 128 = -128
(byte) -200 = 56
(short) 70000 = 4464
(short) 40000 = -25536
(int) 4294967298L = 2
Lấy 300 làm ví dụ. Dưới dạng int 32 bit nó là 00000000 00000000 00000001 00101100. Một byte chỉ có 8 bit, nên (byte) giữ lại đúng 00101100 và vứt 24 bit đầu đi. 00101100 là 44, và vì bit cao nhất bằng 0 nên kết quả là số dương: 44.
Với 200, 8 bit thấp là 11001000. Bit cao nhất bằng 1, mà bit cao nhất của một byte chính là sign bit, nên đúng 8 bit đó giờ được đọc thành số âm: 200 − 256 = −56. Cùng phép tính này giải thích mọi dòng phía trên — (short) 70000 là 70000 − 65536 = 4464, và (int) 4294967298L là 4294967298 − 4294967296 = 2.
⚠️ Một narrowing cast là lời hứa bạn đưa cho compiler rằng giá trị vừa đủ chỗ. Không có gì kiểm tra lời hứa đó lúc runtime.
(byte) 300không phải lỗi, nó là một con số khác.
Chuyển đổi giữa char và int
char là một integer type 16 bit không dấu, chỉ khác ở chỗ nó in ra thành ký tự. Cả hai chiều đều dùng được và đều đáng biết.
System.out.println("(int) 'A' = " + (int) 'A');
System.out.println("(char) 66 = " + (char) 66);
System.out.println("(char) ('a' + 1) = " + (char) ('a' + 1));
System.out.println("'a' + 1 = " + ('a' + 1));
System.out.println("'7' - '0' = " + ('7' - '0'));
(int) 'A' = 65
(char) 66 = B
(char) ('a' + 1) = b
'a' + 1 = 98
'7' - '0' = 7
'a' + 1 ra 98 chứ không phải 'b', vì phép tính trên char cho ra int (xem phần kế tiếp). Thêm cast vào là nó quay lại thành ký tự.
Bây giờ đến phần hay gây nhầm lẫn. Dòng này compile được:
char c = 'a' + 1; // hợp lệ
còn dòng này thì không:
char a = 'a';
char c = a + 1; // error
CharFail.java:4: error: incompatible types: possible lossy conversion from int to char
char c = a + 1;
^
1 error
Khác biệt không nằm ở char hay int, mà ở constant và variable. 'a' + 1 là constant expression tính được lúc compile, và Java cho phép narrowing một constant khi chứng minh được nó vừa với type đích. a + 1 đọc một variable nên compiler không chứng minh được gì và đòi (char)(a + 1). Cũng chính quy tắc này khiến byte ok = 100; compile được còn byte no = 200; thì không — 200 không nằm vừa trong một byte.
Numeric promotion: tại sao byte cộng byte lại ra int
Đây là quy tắc mà ai cũng phải bất ngờ ít nhất một lần:

Việc promote xảy ra với toán hạng, trước khi toán tử kịp chạy:
byte a = 10;
byte b = 20;
byte c = a + b; // error
PromoFail.java:5: error: incompatible types: possible lossy conversion from int to byte
byte c = a + b;
^
1 error
Hai toán hạng đều là byte, kết quả là 30, vậy mà vẫn không compile được. Java áp dụng binary numeric promotion trước mọi phép toán: toán hạng nào hẹp hơn int — tức byte, short và char — đều được widen lên int trước đã. Vậy a + b có type int, và gán một int vào một byte là narrowing. Bạn phải tự viết cast:
byte c = (byte) (a + b); // 30
Khi cả hai toán hạng đã rộng ít nhất bằng int thì type rộng hơn thắng:
| Nếu một toán hạng là | Toán hạng còn lại được promote lên | Type kết quả |
|---|---|---|
double | double | double |
ngược lại, float | float | float |
ngược lại, long | long | long |
còn lại (kể cả byte, short, char) | int | int |
In ra class thật của từng expression sẽ thấy rõ:
byte + byte -> Integer
short + short-> Integer
char + char -> Integer
byte + short -> Integer
int + long -> Long
long + float -> Float
float+ double-> Double
int + double -> Double
char + int -> Integer
Cast ẩn trong compound assignment
b = b + 300 và b += 300 không phải là một. Cái đầu không compile được; cái sau compile ngon lành và âm thầm cắt giá trị.
byte b = 10;
b += 300;
System.out.println("byte b = 10; b += 300; -> " + b);
byte q = 10;
q += 1.9; // bên phải là double, vẫn compile được
System.out.println("byte q = 10; q += 1.9; -> " + q);
int i = 10;
i += 3.75;
System.out.println("int i = 10; i += 3.75; -> " + i);
short s = 32767;
s += 1;
System.out.println("short s = 32767; s += 1;-> " + s);
byte b = 10; b += 300; -> 54
byte q = 10; q += 1.9; -> 11
int i = 10; i += 3.75; -> 13
short s = 32767; s += 1;-> -32768
Java Language Specification định nghĩa E1 op= E2 chính là E1 = (T)(E1 op E2), với T là type của E1. Nghĩa là mọi compound assignment operator đều chứa sẵn một narrowing cast vô hình. b += 300 thực chất là b = (byte)(10 + 300), mà (byte) 310 bằng 54. q += 1.9 là q = (byte)(10 + 1.9), cắt 11.9 xuống còn 11.
Dạng viết đầy đủ thì không có cast đó, nên bị từ chối:
CompoundFail.java:4: error: incompatible types: possible lossy conversion from int to byte
b = b + 300;
^
1 error
Hai dòng code trông như nhau, một cái báo lỗi compile và một cái cho ra đáp án sai. Nếu bạn dùng += với byte, short hay char, hãy tự viết cast ra để chỗ cắt giá trị hiện rõ trên màn hình.
boolean không convert sang bất cứ thứ gì
boolean không phải là số trong Java, và không cast nào biến nó thành số được. Cả hai chiều đều không tồn tại:
int n = (int) true;
boolean b = (boolean) 1;
BoolFail.java:3: error: incompatible types: boolean cannot be converted to int
int n = (int) true;
^
BoolFail.java:4: error: incompatible types: int cannot be converted to boolean
boolean b = (boolean) 1;
^
2 errors
Cùng quy tắc đó chặn luôn if (1), với message y hệt: int cannot be converted to boolean. Nếu bạn từ C sang thì đây là thói quen cần bỏ — Java không có truthiness. Muốn biến số thành boolean thì so sánh: n != 0. Muốn biến boolean thành số thì dùng conditional expression: b ? 1 : 0.
Overflow khi cast và overflow khi tính toán
Cả hai đều quay vòng, và không cái nào báo cho bạn biết:

Cùng một cú pháp, hai quy tắc khác nhau:
int max = Integer.MAX_VALUE;
System.out.println("Integer.MAX_VALUE + 1 = " + (max + 1));
long l = 3_000_000_000L;
System.out.println("(int) 3000000000L = " + (int) l);
int a = 100000, b = 100000;
System.out.println("int 100000*100000 = " + (a * b));
System.out.println("with a long operand = " + ((long) a * b));
Integer.MAX_VALUE + 1 = -2147483648
(int) 3000000000L = -1294967296
int 100000*100000 = 1410065408
with a long operand = 10000000000
Dòng thứ ba là ca kinh điển: cả hai toán hạng đều là int nên phép nhân chạy trong 32 bit và tràn trước khi kết quả được gán đi đâu. Cast kết quả sau đó không cứu được gì. Cast một toán hạng trước — (long) a * b — mới đẩy cả expression lên long và cho ra đáp án đúng.
Khi một giá trị bắt buộc phải nằm vừa và bạn muốn biết chắc thay vì đoán, hãy dùng các method exact. Math.toIntExact throw thay vì quay vòng:
try {
int n = Math.toIntExact(3_000_000_000L);
} catch (ArithmeticException e) {
System.out.println("Math.toIntExact -> " + e);
}
System.out.println(Math.addExact(Integer.MAX_VALUE, 1));
Math.toIntExact -> java.lang.ArithmeticException: integer overflow
Exception in thread "main" java.lang.ArithmeticException: integer overflow
at java.base/java.lang.Math.addExact(Math.java:911)
Math.addExact, subtractExact, multiplyExact và negateExact cũng hành xử như vậy. Message trong mọi trường hợp đúng là integer overflow.
Ép kiểu không phải là parsing
Đây là chỗ ngốn thời gian của người mới nhiều nhất. Cast diễn giải lại một giá trị mà compiler đã hiểu sẵn; nó không đọc text. String không phải là số, nên không có cast nào nối hai thứ đó lại:

Một cái hỏng lúc compile, cái kia hỏng lúc chạy:
String s = "42";
int n = (int) s;
ParseFail.java:4: error: incompatible types: String cannot be converted to int
int n = (int) s;
^
1 error
(String) n hỏng theo chiều ngược lại với int cannot be converted to String. Text đi qua các method parse và format:
System.out.println(Integer.parseInt("42") + 1);
System.out.println(Double.parseDouble("3.5"));
System.out.println(Long.parseLong("9999999999"));
System.out.println(String.valueOf(42));
System.out.println(42 + "");
43
3.5
9999999999
42
42
Integer.parseInt rất khó tính. Bất cứ chuỗi nào không phải số nguyên hợp lệ đều throw NumberFormatException lúc runtime, và message trả nguyên input về cho bạn:
Integer.parseInt("42abc");
Exception in thread "main" java.lang.NumberFormatException: For input string: "42abc"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
"3.5" và " 42" hỏng y như vậy — một dấu cách ở đầu cũng không được tự bỏ đi. "" cho ra For input string: "" còn null cho ra Cannot parse null string. Nếu text có thể là rác, hãy catch NumberFormatException; không có cast nào cứu bạn ở đây cả.
Autoboxing và unboxing cũng là conversion
Integer là object, int là primitive, và compiler tự chuyển đổi giữa hai bên giúp bạn. Chiều này gọi là autoboxing, chiều kia là unboxing:
Integer boxed = 42; // autoboxing: int -> Integer
int back = boxed; // unboxing: Integer -> int
Unboxing thực chất là một method call trá hình — boxed.intValue() — nên nó throw khi reference bằng null:
Integer n = null;
int bad = n;
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "n" is null
at Q3.main(Q3.java:4)
Tên variable xuất hiện được là nhờ class được compile kèm debug information (javac -g, Maven và Gradle bật sẵn); không có nó thì message chỉ ghi một slot dạng "<local3>". Đây là NullPointerException phổ biến nhất trong loại code không hề dereference gì một cách tường minh.
Boxing cũng không cộng dồn với widening. Long x = 5; hỏng với incompatible types: int cannot be converted to Long, vì phép gán chỉ cho phép hoặc một widening primitive conversion hoặc một boxing conversion, chứ không cho widening rồi boxing tiếp. Hãy viết Long x = 5L;.
Ép kiểu giữa các reference type
Cast cũng tồn tại cho object và đi theo quy tắc khác. Gán một subtype vào variable kiểu supertype — upcasting — là implicit và luôn an toàn. Đi chiều ngược lại — downcasting — bắt buộc phải có cast tường minh và được kiểm tra lúc runtime, throw ClassCastException khi object không thật sự thuộc type đó, ví dụ class java.lang.Integer cannot be cast to class java.lang.String. Cast giữa các reference type chỉ có ý nghĩa khi đã có inheritance, nên series này sẽ nói về nó cùng với inheritance chứ không phải ở đây.
Tôi có X và cần Y
| Tôi có | Tôi cần | Dùng | Ghi chú |
|---|---|---|---|
int | long, float, double | gán thẳng | widening, implicit |
double | int, cắt bỏ phần lẻ | (int) d | bỏ phần thập phân, đi về phía 0 |
double | int, làm tròn | (int) Math.round(d) | Math.round(double) trả về long |
float | int, làm tròn | Math.round(f) | Math.round(float) đã trả về int |
double | int, luôn xuống / luôn lên | (int) Math.floor(d) / (int) Math.ceil(d) | cả hai trả về double |
int | byte, short, char | (byte) n | giữ các bit thấp, không bao giờ throw |
long | int, bắt buộc chính xác | Math.toIntExact(l) | throw thay vì quay vòng |
char | int | gán thẳng | 'A' thành 65 |
int | char | (char) n | 66 thành B |
byte + byte | một byte | (byte) (a + b) | tổng có type int |
int và int | thương có phần lẻ | (double) a / b | cast một toán hạng trước khi chia |
String | int | Integer.parseInt(s) | cast không compile được |
String | double | Double.parseDouble(s) | throw NumberFormatException nếu là rác |
| số bất kỳ | String | String.valueOf(x) | x + "" cho kết quả như nhau |
int | Integer | gán thẳng | autoboxing |
Integer | int | gán thẳng | unboxing, NullPointerException nếu null |
boolean | một số | không có conversion nào | viết b ? 1 : 0 |
| số | boolean | không có conversion nào | viết n != 0 |
FAQ
Tại sao (byte) 300 in ra 44 mà không throw?
Vì narrowing giữa các integer type được định nghĩa là giữ lại các bit thấp, chứ không phải kiểm tra range. 300 là ...00000001 00101100; một byte giữ 00101100, tức 44. JVM không kiểm tra gì cả, và đó chính là lý do cast này bắt buộc phải viết tường minh.
Cast làm tròn hay cắt bỏ?
Nó cắt bỏ và đi về phía 0. (int) 3.99 ra 3 còn (int) -3.99 ra -3. Muốn làm tròn thì dùng Math.round, nhớ rằng Math.round(double) trả về long.
Tại sao viết được byte b = 100; nhưng không viết được byte b = a + 1;?
100 là constant tính được lúc compile và chứng minh được là vừa với một byte, mà Java cho phép narrowing một constant nằm trong range. a + 1 có variable ở trong nên expression mang type int và compiler không chứng minh được nó vừa. Hãy viết (byte) (a + 1), hoặc dùng constant expression như byte b = 100 + 1;.
Tại sao (int) "42" không compile được?
String và int là hai type không liên quan nhau, nên không có conversion nào để cast thực hiện. Compiler nói incompatible types: String cannot be converted to int. Lấy số ra từ text là parsing chứ không phải casting: Integer.parseInt("42").
Cast double sang int có throw lúc runtime không?
Không. Nó dừng lại ở giới hạn: (int) 1e20 cho Integer.MAX_VALUE, (int) -1e20 cho Integer.MIN_VALUE, và (int) Double.NaN cho 0. Không có gì được throw, nên nếu giá trị ngoài range là một bug trong chương trình thì bạn phải tự kiểm tra range.
Khi nào nên dùng Math.toIntExact thay cho (int)?
Bất cứ khi nào một long không vừa int đồng nghĩa với việc có gì đó sai. (int) l quay vòng âm thầm và đưa cho bạn một con số sai nhưng trông rất hợp lý; Math.toIntExact(l) throw ArithmeticException: integer overflow ngay tại chỗ sai. Điều tương tự áp dụng cho Math.addExact và Math.multiplyExact trong các phép tính.
Kết luận
Ép kiểu trong Java rút lại chỉ còn vài quy tắc ngắn. Widening đi sang phải theo byte -> short -> int -> long -> float -> double và xảy ra tự động, với int sang float cùng mọi thứ đi từ long bị mất precision trên đường đi. Narrowing đi sang trái, bắt buộc có cast operator, cắt số thực về phía 0 và chỉ giữ các bit thấp của số nguyên. Phép tính promote mọi thứ hẹp hơn int lên int trước khi chạy, += giấu sẵn một narrowing cast, boolean không convert sang thứ gì, và text đi qua parseInt chứ không bao giờ đi qua cast.
Bài tiếp theo trong series: class String — vì sao string là immutable, cái giá của điều đó, và những method bạn sẽ dùng mỗi ngày.