- Hiểu quan hệ class ↔ object: bản thiết kế và thực thể.
- Nắm giá trị mặc định của trường và vì sao nó khác biến cục bộ.
- Viết constructor: mặc định, nạp chồng, gọi
this(...)để tránh lặp. - Hiểu
thisgiải quyết việc gì và khi nào bắt buộc dùng. - Nạp chồng phương thức (overload) và varargs.
- Thuộc bốn mức truy cập và quy tắc chọn.
- Áp dụng đóng gói đúng cách — không phải "sinh getter/setter cho mọi trường".
- Phân biệt thành viên
staticvà thành viên instance. - Nắm thứ tự khởi tạo đầy đủ: trường tĩnh → khối tĩnh → trường instance → khối instance → constructor.
- Override
toString(),equals(),hashCode()— và hợp đồng giữa hai cái sau. - Hiểu vòng đời object và garbage collection ở mức thực dụng.
1. Class và object
Class là bản thiết kế: nó mô tả một object sẽ có dữ liệu gì (trường) và làm được gì (phương thức). Object là một thực thể cụ thể được tạo ra từ bản thiết kế đó, chiếm một vùng nhớ riêng trên heap.
public class SinhVien {
// --- TRƯỜNG (field) — dữ liệu của mỗi object ---
private String maSo;
private String hoTen;
private double diem;
// --- CONSTRUCTOR — chạy khi tạo object ---
public SinhVien(String maSo, String hoTen, double diem) {
this.maSo = maSo;
this.hoTen = hoTen;
this.diem = diem;
}
// --- PHƯƠNG THỨC — hành vi ---
public String xepLoai() {
return switch ((int) diem / 1) {
case 10, 9 -> "Giỏi";
case 8, 7 -> "Khá";
case 6, 5 -> "Trung bình";
default -> "Yếu";
};
}
// --- GETTER — cho phép đọc trường private ---
public String getHoTen() { return hoTen; }
public double getDiem() { return diem; }
}
public class Main {
public static void main(String[] args) {
// new gọi constructor và cấp phát bộ nhớ trên heap
SinhVien sv1 = new SinhVien("SV001", "Nguyễn Việt Anh", 8.5);
SinhVien sv2 = new SinhVien("SV002", "Trần Bình", 9.2);
// Hai object ĐỘC LẬP — mỗi cái có vùng nhớ riêng
System.out.println(sv1.getHoTen() + ": " + sv1.xepLoai());
System.out.println(sv2.getHoTen() + ": " + sv2.xepLoai());
// Mảng object — mỗi phần tử là một tham chiếu
SinhVien[] lop = new SinhVien[3];
System.out.println(lop[0]); // null — chưa tạo object nào!
lop[0] = sv1;
// lop[1].getHoTen(); // 💥 NullPointerException
}
}
Mảng object chỉ chứa tham chiếu
new SinhVien[3] tạo một mảng ba ô trống, mỗi ô chứa null — nó
không tạo ra ba đối tượng SinhVien. Bạn phải tự tạo từng object và gán
vào. Đây là lỗi NullPointerException phổ biến nhất của người mới học Java, và nó là hệ quả
trực tiếp của việc mảng là kiểu tham chiếu (chương 2).
2. Trường và giá trị mặc định
public class MacDinh {
// TRƯỜNG — được JVM tự khởi tạo giá trị mặc định
int a; // 0
long b; // 0L
double c; // 0.0
boolean d; // false
char e; // ' ' (ký tự rỗng)
String f; // null ⚠️
int[] g; // null ⚠️
void thuNghiem() {
int cucBo;
// System.out.println(cucBo);
// ❌ error: variable cucBo might not have been initialized
}
}
| Trường (field) | Biến cục bộ | |
|---|---|---|
| Khai báo ở | Trong class, ngoài phương thức | Trong phương thức/khối |
| Giá trị mặc định | Có — 0/false/null | Không — phải gán trước khi dùng |
| Lưu ở | Heap (cùng object) hoặc Metaspace (nếu static) | Stack |
| Vòng đời | Bằng vòng đời object | Chỉ trong khối bao quanh |
| Có mức truy cập | ✅ private/public… | ❌ Không |
Vì JVM phải khởi tạo toàn bộ vùng nhớ của object khi cấp phát trên heap — để lại rác từ lần dùng trước là lỗ hổng bảo mật. Biến cục bộ nằm trên stack, nơi việc gán mặc định sẽ tốn thời gian vô ích ở mọi lời gọi phương thức. Thay vào đó, trình biên dịch kiểm tra tĩnh rằng bạn đã gán trước khi đọc — hiệu quả hơn và bắt được nhiều bug hơn.
3. Constructor
public class SanPham {
private final String ma;
private final String ten;
private long gia;
private int soLuong;
// ① Constructor đầy đủ — nơi ĐẶT MỌI KIỂM TRA HỢP LỆ
public SanPham(String ma, String ten, long gia, int soLuong) {
if (ma == null || ma.isBlank())
throw new IllegalArgumentException("Mã sản phẩm không được rỗng");
if (gia < 0)
throw new IllegalArgumentException("Giá không được âm: " + gia);
this.ma = ma;
this.ten = ten;
this.gia = gia;
this.soLuong = soLuong;
}
// ② Constructor rút gọn — GỌI LẠI constructor đầy đủ bằng this(...)
public SanPham(String ma, String ten, long gia) {
this(ma, ten, gia, 0); // ⚠️ PHẢI là câu lệnh ĐẦU TIÊN
}
// ③ Constructor sao chép
public SanPham(SanPham khac) {
this(khac.ma, khac.ten, khac.gia, khac.soLuong);
}
}
class A {
// Không viết constructor nào → javac TỰ THÊM: public A() { }
}
new A(); // ✅ chạy được (bạn đã thấy điều này qua javap ở chương 1)
class B {
int x;
public B(int x) { this.x = x; }
}
// new B(); // ❌ error: constructor B in class B cannot be applied
// to given types; required: int, found: no arguments
new B(5); // ✅
// Muốn có cả hai → viết tường minh
class C {
int x;
public C() { this(0); }
public C(int x) { this.x = x; }
}
Hệ quả thực tế: nhiều thư viện (Jackson, Hibernate, JavaBeans) yêu cầu constructor không tham số để tạo object bằng phản chiếu. Thêm một constructor có tham số mà quên bổ sung constructor rỗng sẽ khiến chúng báo lỗi lúc chạy.
3.1. Từ khoá this
public class TaiKhoan {
private String chuSoHuu;
private long soDu;
// ① Phân biệt trường và tham số cùng tên — công dụng phổ biến nhất
public TaiKhoan(String chuSoHuu, long soDu) {
this.chuSoHuu = chuSoHuu; // this.x = TRƯỜNG, x = THAM SỐ
this.soDu = soDu;
// Nếu viết `chuSoHuu = chuSoHuu;` → gán tham số cho chính nó, trường vẫn null!
}
// ② Gọi constructor khác — phải là câu lệnh đầu tiên
public TaiKhoan(String chuSoHuu) {
this(chuSoHuu, 0);
}
// ③ Trả về chính object — cho phép nối chuỗi phương thức (fluent API)
public TaiKhoan nap(long tien) {
this.soDu += tien;
return this;
}
public TaiKhoan rut(long tien) {
if (tien > soDu) throw new IllegalStateException("Không đủ số dư");
this.soDu -= tien;
return this;
}
// ④ Truyền chính mình cho object khác
public void dangKy(NganHang nh) {
nh.them(this);
}
}
// Nhờ ③ ta viết được:
new TaiKhoan("An").nap(1_000_000).rut(200_000).nap(50_000);
4. Phương thức, nạp chồng và varargs
// Cấu trúc: [mức truy cập] [static] [final] KIỂU_TRẢ_VỀ tên(tham số) { }
public static int cong(int a, int b) {
return a + b;
}
// --- NẠP CHỒNG (overload): cùng tên, KHÁC danh sách tham số ---
static int cong(int a, int b) { return a + b; }
static double cong(double a, double b) { return a + b; }
static int cong(int a, int b, int c) { return a + b + c; }
static String cong(String a, String b) { return a + b; }
// ❌ KHÔNG nạp chồng được nếu chỉ khác KIỂU TRẢ VỀ
// static double cong(int a, int b) { }
// error: method cong(int,int) is already defined
// --- VARARGS: số lượng tham số tuỳ ý ---
static int tong(int... so) { // so thực chất là int[]
int t = 0;
for (int x : so) t += x;
return t;
}
tong(); // 0
tong(1, 2, 3); // 6
tong(new int[]{1, 2}); // 3 — truyền thẳng mảng cũng được
// ⚠️ Quy tắc varargs: PHẢI là tham số CUỐI CÙNG, và chỉ có MỘT
static void ghiLog(String mucDo, String... thongDiep) { } // ✅
// static void sai(String... a, int b) { } // ❌
static void in(Object o) { System.out.println("Object"); }
static void in(String s) { System.out.println("String"); }
Object o = "tôi là chuỗi";
in(o); // "Object" ⚠️ — chọn theo kiểu KHAI BÁO, không phải kiểu thực tế
in((String) o); // "String"
// Thứ tự ưu tiên khi chọn phương thức nạp chồng:
// 1. Khớp chính xác 2. Mở rộng kiểu 3. Autoboxing 4. Varargs
static void f(long x) { System.out.println("long"); }
static void f(Integer x) { System.out.println("Integer"); }
static void f(int... x) { System.out.println("varargs"); }
f(5); // "long" — mở rộng int→long được ưu tiên hơn autoboxing và varargs
Đây là điểm khác biệt căn bản với override (chương 5): nạp chồng được quyết định lúc biên dịch theo kiểu khai báo, còn override được quyết định lúc chạy theo kiểu thực tế của object.
5. Bốn mức truy cập
| Mức | Cùng class | Cùng package | Class con (khác package) | Mọi nơi |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| (không ghi gì) — package-private | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
public class TaiKhoan {
private long soDu; // chỉ class này
int maNoiBo; // cả package (mặc định)
protected String loaiTaiKhoan; // + class con ở package khác
public String chuSoHuu; // mọi nơi
private void tinhLai() { } // chi tiết cài đặt
public long getSoDu() { return soDu; } // API công khai
}
// Class cũng có mức truy cập (chỉ public hoặc package-private)
public class CongKhai { } // dùng được từ package khác
class NoiBo { } // chỉ trong package này
private, chỉ nới rộng khi có lý do cụ thể. Mọi thứ
public là một lời hứa — bạn không được đổi nó mà không phá vỡ code của người khác.
Trường thì gần như luôn private; protected chỉ dùng khi thực sự thiết kế cho việc
kế thừa.
protected rộng hơn bạn tưởng
Nhiều người nghĩ protected nghĩa là "chỉ class con". Thực ra nó là
"class con hoặc cùng package" — tức là rộng hơn package-private. Nếu bạn muốn
"chỉ class con, không cho package", Java không có mức đó.
6. Đóng gói — và cách làm sai phổ biến
// ❌ CÁCH SAI 1: trường public — ai cũng phá được trạng thái
class TaiKhoanSai {
public long soDu;
}
TaiKhoanSai tk = new TaiKhoanSai();
tk.soDu = -999_999; // không ai ngăn được
// ❌ CÁCH SAI 2: private + getter/setter cho MỌI trường
// → về bản chất vẫn là public, chỉ dài dòng hơn
class TaiKhoanVoNghia {
private long soDu;
public long getSoDu() { return soDu; }
public void setSoDu(long s) { this.soDu = s; } // vẫn đặt được -999999
}
// ✅ ĐÚNG: che trạng thái, mở HÀNH VI có ý nghĩa nghiệp vụ
public class TaiKhoan {
private long soDu;
private final List<String> lichSu = new ArrayList<>();
public long getSoDu() { return soDu; } // chỉ đọc, không có setter
public void nap(long tien) {
if (tien <= 0)
throw new IllegalArgumentException("Số tiền nạp phải dương");
soDu += tien;
lichSu.add("Nạp " + tien);
}
public void rut(long tien) {
if (tien <= 0)
throw new IllegalArgumentException("Số tiền rút phải dương");
if (tien > soDu)
throw new IllegalStateException(
"Không đủ số dư: cần %d, có %d".formatted(tien, soDu));
soDu -= tien;
lichSu.add("Rút " + tien);
}
}
public class Lop {
private final List<SinhVien> danhSach = new ArrayList<>();
// ❌ Trả thẳng list nội bộ → bên ngoài sửa được, private thành vô nghĩa
public List<SinhVien> getDanhSach() { return danhSach; }
}
Lop lop = new Lop();
lop.getDanhSach().clear(); // 💥 xoá sạch dữ liệu nội bộ!
// ✅ Ba cách sửa
public List<SinhVien> getDanhSach() {
return Collections.unmodifiableList(danhSach); // khung nhìn chỉ đọc
}
public List<SinhVien> getDanhSach() {
return List.copyOf(danhSach); // bản sao bất biến (Java 10+)
}
// Hoặc tốt nhất: đừng lộ collection ra ngoài, mở method có ý nghĩa
public int siSo() { return danhSach.size(); }
public void them(SinhVien sv) { danhSach.add(Objects.requireNonNull(sv)); }
public Optional<SinhVien> tim(String ma) { ... }
Lưu ý final trên trường danhSach chỉ ngăn bạn gán lại một list khác —
nó không ngăn ai đó sửa nội dung list. Đây chính là điều đã nói ở chương 2: final khoá tham
chiếu, không khoá object.
7. static — thuộc về class, không thuộc object
public class SinhVien {
// TRƯỜNG TĨNH — chỉ có MỘT bản, chia sẻ cho mọi object
private static int soLuongDaTao = 0;
public static final String TRUONG = "Đại học ABC"; // hằng số
// TRƯỜNG INSTANCE — mỗi object một bản riêng
private final String maSo;
public SinhVien(String maSo) {
this.maSo = maSo;
soLuongDaTao++; // tăng biến đếm CHUNG
}
// PHƯƠNG THỨC TĨNH — gọi qua tên CLASS, không cần object
public static int getSoLuongDaTao() {
return soLuongDaTao;
// return maSo; // ❌ không truy cập được thành viên instance!
// this.x // ❌ không có `this` trong ngữ cảnh tĩnh
}
// PHƯƠNG THỨC INSTANCE — truy cập được CẢ HAI loại
public String moTa() {
return maSo + " @ " + TRUONG; // ✅
}
}
// Dùng:
new SinhVien("SV001");
new SinhVien("SV002");
System.out.println(SinhVien.getSoLuongDaTao()); // 2 — qua tên CLASS ✅
System.out.println(SinhVien.TRUONG);
static | Instance | |
|---|---|---|
| Thuộc về | Class | Từng object |
| Số bản trong bộ nhớ | Một | Bằng số object |
| Tạo lúc nào | Khi class được nạp | Khi gọi new |
| Gọi qua | TenClass.thanhVien | obj.thanhVien |
| Truy cập được thành viên instance | ❌ Không | ✅ Có |
Có this | ❌ Không | ✅ Có |
| Ghi đè được (override) | ❌ Không (chỉ bị "che") | ✅ Có |
| Ví dụ | Math.max(), Integer.parseInt() | list.add(), s.length() |
static- Hàm tiện ích thuần — kết quả chỉ phụ thuộc tham số, không phụ thuộc trạng thái object:
Math.sqrt(),Integer.parseInt(). - Hằng số —
public static final. - Factory method —
List.of(),Optional.empty(),LocalDate.now(). - Bộ đếm/bộ nhớ đệm dùng chung — cẩn thận với đa luồng (chương 12).
Đừng dùng static làm biến toàn cục để né việc truyền tham số. Trạng thái
tĩnh có thể thay đổi được là nguồn của bug khó tìm: khó test (không cô lập được), khó đa luồng (chia sẻ
không đồng bộ), và tạo phụ thuộc ngầm giữa các phần của chương trình.
8. Thứ tự khởi tạo — bảy bước
public class ThuTu {
static int a = in("1. trường tĩnh");
static { // KHỐI TĨNH
in("2. khối tĩnh");
}
int b = in("3. trường instance");
{ // KHỐI KHỞI TẠO INSTANCE
in("4. khối instance");
}
public ThuTu() {
in("5. constructor");
}
static int in(String s) { System.out.println(s); return 0; }
public static void main(String[] args) {
in("--- new lần 1 ---");
new ThuTu();
in("--- new lần 2 ---");
new ThuTu();
}
}
/* Kết quả:
1. trường tĩnh ← chỉ MỘT LẦN, khi class được nạp
2. khối tĩnh ← chỉ MỘT LẦN
--- new lần 1 ---
3. trường instance ← mỗi lần new
4. khối instance ← mỗi lần new
5. constructor ← mỗi lần new
--- new lần 2 ---
3. trường instance
4. khối instance
5. constructor
*/
2. Khối tĩnh
newđặt giá trị mặc định
super()chạy trước
6. Khối khởi tạo
constructor
new. Bước 4 (gọi constructor lớp cha) sẽ được đào sâu ở
Chương 5.
class Bay {
int a = tinh(); // chạy TRƯỚC dòng dưới
int b = 10;
int tinh() {
return b * 2; // ⚠️ b vẫn là 0 (giá trị mặc định) → trả về 0
}
}
System.out.println(new Bay().a); // 0, không phải 20
Trường được khởi tạo theo thứ tự khai báo từ trên xuống. Gọi một phương thức trong lúc khởi tạo trường là nguy hiểm vì phương thức đó có thể đọc trường chưa được gán. Nguyên tắc an toàn: đặt mọi logic khởi tạo vào constructor, nơi thứ tự rõ ràng và mọi trường đã sẵn sàng.
9. Ba phương thức kế thừa từ Object
Mọi class trong Java đều ngầm kế thừa java.lang.Object. Ba phương thức của nó bạn sẽ override
thường xuyên.
9.1. toString()
public class SanPham {
private final String ma, ten;
private final long gia;
@Override
public String toString() {
return "SanPham{ma='%s', ten='%s', gia=%,d}".formatted(ma, ten, gia);
}
}
SanPham sp = new SanPham("SP01", "Laptop", 25_000_000);
System.out.println(sp); // println tự gọi toString()
System.out.println("Đã mua: " + sp); // nối chuỗi cũng vậy
// Không override → dùng bản mặc định của Object, gần như vô dụng:
// com.vidu.SanPham@1b6d3586 (tên class @ mã băm dạng hex)
@Override — chú thích nhỏ, giá trị lớn// Không có @Override — gõ sai tên vẫn biên dịch được!
public String tostring() { ... } // chữ 's' thường → tạo method MỚI
// println vẫn dùng bản mặc định, bạn debug cả buổi
@Override
public String tostring() { ... }
// ✅ error: method does not override or implement a method from a supertype
@Override không đổi hành vi chương trình — nó chỉ bảo trình biên dịch kiểm tra
rằng bạn thực sự đang ghi đè. Luôn viết nó. Chi phí là 9 ký tự, lợi ích là loại bỏ hẳn một lớp bug.
9.2. equals() và hashCode()
import java.util.Objects;
public class SanPham {
private final String ma;
private final String ten;
@Override
public boolean equals(Object o) {
if (this == o) return true; // tối ưu: cùng object
if (!(o instanceof SanPham khac)) return false; // Java 16+: gán luôn
return Objects.equals(ma, khac.ma); // so theo KHOÁ nghiệp vụ
}
@Override
public int hashCode() {
return Objects.hash(ma); // PHẢI dùng đúng những trường như equals
}
}
a.equals(b) thì
a.hashCode() == b.hashCode(). Chiều ngược lại không bắt buộc (hai object khác nhau
có thể trùng mã băm — gọi là va chạm). Override một cái mà quên cái kia là bug nghiêm trọng nhất trong
Java cơ bản.
hashCode()// Class chỉ override equals(), KHÔNG override hashCode()
Set<SanPham> kho = new HashSet<>();
kho.add(new SanPham("SP01", "Laptop"));
kho.add(new SanPham("SP01", "Laptop"));
System.out.println(kho.size()); // 2 ⚠️ đáng lẽ phải là 1!
System.out.println(kho.contains(new SanPham("SP01", "Laptop"))); // false ⚠️
HashSet và HashMap dùng hashCode() để tìm ngăn (bucket)
trước, rồi mới dùng equals() để so trong ngăn đó. Hai object "bằng nhau" nhưng mã băm khác
nhau sẽ rơi vào hai ngăn khác nhau và không bao giờ được so sánh — nên chúng cùng tồn tại trong Set.
Chương 8 sẽ mổ xẻ cơ chế này.
equals/hashCode- Dùng khoá nghiệp vụ — thứ định danh object trong bài toán của bạn (mã sản phẩm, số CMND), không phải mọi trường.
- Tránh trường có thể thay đổi. Nếu object đang nằm trong
HashSetmà bạn đổi một trường tham gia tính hash, nó sẽ "biến mất" khỏi Set — tìm không ra, xoá không được. - Trường tham gia
hashCodephải là tập con hoặc bằng tập trường củaequals. - Từ Java 16,
recordsinh sẵn cả ba phương thức đúng chuẩn — Chương 5.
10. Vòng đời object và garbage collection
public void viDu() {
SanPham a = new SanPham("SP01", "Laptop"); // object #1 trên heap
SanPham b = new SanPham("SP02", "Chuột"); // object #2
a = b; // object #1 KHÔNG còn tham chiếu nào → thành "rác"
// GC sẽ thu hồi vào lúc nào đó — bạn không kiểm soát khi nào
b = null; // object #2 vẫn sống vì `a` còn trỏ vào nó
} // hết phương thức → `a` biến mất → object #2 cũng thành rác
Bạn không cần giải phóng bộ nhớ — nhưng vẫn rò rỉ được
Java có garbage collector tự động thu hồi object không còn tham chiếu nào trỏ tới. Bạn
không có free() hay delete như C/C++, và cũng không có con trỏ treo (dangling
pointer).
Nhưng rò rỉ bộ nhớ vẫn xảy ra, chỉ theo cách khác: khi bạn vô tình giữ tham chiếu tới object không còn cần nữa. Ba nguồn phổ biến nhất:
- Collection tĩnh không bao giờ dọn — một
static Maplàm cache mà không có cơ chế hết hạn sẽ phình mãi. - Listener không huỷ đăng ký — object đã "chết" nhưng vẫn nằm trong danh sách listener.
- Tài nguyên không đóng — luồng, kết nối, tệp (chương 6, 11, 13).
finalize() — đừng dùng
Java từng có Object.finalize() cho phép chạy code trước khi GC thu hồi object. Nó đã bị
đánh dấu lỗi thời từ Java 9 và loại bỏ dần, vì hoàn toàn không đáng tin: không đảm bảo
chạy, không đảm bảo thời điểm, và có thể "hồi sinh" object. Để giải phóng tài nguyên, dùng
try-with-resources và AutoCloseable — chương 6.
11. Cheat sheet chương 4
| Tình huống | Cách làm |
|---|---|
| Khai báo trường | private mặc định; thêm final nếu không đổi sau khởi tạo |
| Kiểm tra dữ liệu đầu vào | Trong constructor, ném IllegalArgumentException |
| Nhiều constructor | Gọi this(...) ở dòng đầu để tránh lặp code |
| Cần constructor rỗng | Viết tường minh — nó biến mất khi bạn có constructor khác |
| Cùng tên khác tham số | Nạp chồng (overload) — không phân biệt được bằng kiểu trả về |
| Số tham số tuỳ ý | void f(int... a) — phải là tham số cuối |
| Hàm tiện ích thuần | public static |
| Hằng số | public static final + UPPER_SNAKE_CASE |
| Trả về collection nội bộ | List.copyOf() hoặc Collections.unmodifiableList() |
| In object dễ đọc | Override toString() + @Override |
| So sánh theo nội dung | Override cả equals() và hashCode() |
Viết equals nhanh | if (!(o instanceof X other)) return false; (Java 16+) |
Viết hashCode nhanh | Objects.hash(truong1, truong2) |
| Lớp chỉ chứa dữ liệu | Dùng record — chương 5 |
| Giải phóng tài nguyên | try-with-resources — không bao giờ finalize() |
Bài tập
Lớp PhanSo bất biến
Xây dựng lớp PhanSo hoàn chỉnh:
- Trường
private final int tuSo, mauSo; - Constructor tự rút gọn (chia cho ƯCLN) và chuẩn hoá dấu âm về tử số. Mẫu số bằng 0 → ném
ArithmeticException. - Constructor một tham số:
new PhanSo(5)= 5/1. - Các phép toán trả về phân số mới:
cong,tru,nhan,chia,nghichDao,luyThua(int n). toString():"3/4", nhưng mẫu số 1 thì chỉ in"3".equals()+hashCode()đúng chuẩn:1/2phải bằng2/4.double giaTri()trả về giá trị thập phân.
Viết main chứng minh: (1/2 + 1/3) × 6 = 5, và 1/2 với 2/4 chỉ chiếm một ô trong HashSet.
Gợi ý
ƯCLN bằng thuật toán Euclid:
static int ucln(int a, int b) { return b == 0 ? Math.abs(a) : ucln(b, a % b); }.
Chuẩn hoá dấu: nếu mauSo < 0 thì nhân cả tử và mẫu với −1. Nhờ rút gọn ngay trong
constructor, equals chỉ cần so tử với tử, mẫu với mẫu — đây là ví dụ đẹp về việc
constructor bảo đảm bất biến (invariant) của lớp.
Hệ thống ngân hàng mini
Xây dựng ba lớp làm việc cùng nhau:
TaiKhoan : soTaiKhoan (final), chuSoHuu, soDu (private)
nap(), rut(), chuyenKhoan(TaiKhoan dich, long tien)
static int demTaiKhoan — đếm tổng số tài khoản đã tạo
static String taoSoTaiKhoan() — sinh mã "TK00001", "TK00002"...
GiaoDich : maGiaoDich, loai, soTien, thoiGian, soDuSau
bất biến hoàn toàn (mọi trường final, không setter)
NganHang : quản lý mảng/danh sách tài khoản
tim(String soTk), tongTaiSan(), taiKhoanGiauNhat()
Yêu cầu bắt buộc:
chuyenKhoanphải nguyên tử theo nghĩa: nếu bất kỳ bước nào thất bại thì cả hai tài khoản giữ nguyên số dư.- Không chuyển khoản cho chính mình.
- Mỗi tài khoản lưu lịch sử giao dịch; getter trả về bản sao không sửa được.
- Không có setter cho
soDu— chỉ đổi được quanap/rut.
Gợi ý
Nguyên tử: kiểm tra toàn bộ điều kiện trước, chỉ khi tất cả hợp lệ mới thực hiện thay
đổi. Nếu rut() thành công mà nap() thất bại thì tiền bốc hơi — hãy
thứ tự sao cho điều đó không xảy ra được.
taoSoTaiKhoan(): String.format("TK%05d", ++demTaiKhoan).
Ở môi trường đa luồng, cách này vẫn chưa an toàn — chương 12 sẽ quay lại đúng bài toán này với
synchronized và AtomicInteger.
Đoán thứ tự khởi tạo
Không chạy code, viết ra output đầy đủ. Sau đó chạy để kiểm chứng.
public class Do {
static String s1 = p("A");
String s2 = p("B");
static { p("C"); }
{ p("D"); }
String s3 = p("E");
static String s4 = p("F");
Do() { p("G"); }
Do(int x) { this(); p("H"); }
static String p(String s) { System.out.print(s); return s; }
public static void main(String[] a) {
p("|");
new Do(1);
p("|");
new Do();
}
}
Trả lời thêm: vì sao "C" in ra sau "A" nhưng trước "F"? Vì sao "H" in sau "G"?
Gợi ý
Hai quy tắc quyết định tất cả: (1) thành viên tĩnh (trường và khối) chạy theo
thứ tự xuất hiện trong mã nguồn, một lần duy nhất khi nạp class; (2)
trong mỗi lần new, trường và khối instance chạy theo thứ tự khai báo, rồi mới tới
thân constructor. Và this() chạy trước mọi thứ khác trong constructor gọi
nó.
Sửa lớp vi phạm đóng gói
Lớp dưới mắc sáu lỗi thiết kế. Tìm và sửa từng lỗi, giải thích hậu quả của mỗi lỗi.
public class DonHang {
public String maDon;
public List<SanPham> danhSach = new ArrayList<>();
public double tongTien;
public static List<DonHang> tatCaDon = new ArrayList<>();
public DonHang(String maDon) {
maDon = maDon;
tatCaDon.add(this);
}
public void setTongTien(double t) { tongTien = t; }
public String tostring() {
return maDon + ": " + tongTien;
}
}
Gợi ý
Danh sách kiểm: (1) trường public; (2) maDon = maDon thiếu
this → trường vẫn null; (3) double cho tiền; (4) lộ
List nội bộ ra ngoài; (5) static List giữ mọi đơn hàng mãi mãi → rò
rỉ bộ nhớ, đồng thời this bị "thoát" khỏi constructor khi object chưa khởi tạo
xong; (6) tostring viết sai chữ hoa và thiếu @Override.
Bản sửa nên có: mọi trường private final khi có thể, long cho tiền,
tongTien() tính từ danh sách thay vì lưu riêng (tránh dữ liệu không đồng bộ), và
bỏ hẳn static List.
Chứng minh hợp đồng equals/hashCode
Tạo lớp Diem với hai trường int x, y, rồi làm ba thí nghiệm:
- Không override gì cả — đưa hai
Diem(1,2)vàoHashSet. Kích thước là bao nhiêu? - Chỉ override
equals— lặp lại. Kích thước?contains()trả về gì? Vì sao? - Override cả hai — lặp lại. Kết quả đúng chưa?
Thí nghiệm thứ tư, quan trọng nhất:
// Đổi x, y thành trường KHÔNG final và thêm setter
Set<Diem> s = new HashSet<>();
Diem d = new Diem(1, 2);
s.add(d);
System.out.println(s.contains(d)); // ?
d.setX(99); // sửa trường tham gia hashCode
System.out.println(s.contains(d)); // ? object VẪN nằm trong set!
System.out.println(s.size()); // ?
s.remove(d);
System.out.println(s.size()); // ? xoá được không?
Giải thích hiện tượng và rút ra quy tắc thiết kế.
Gợi ý
Thí nghiệm 4 cho thấy object bị "kẹt" trong Set: nó vẫn ở đó (size = 1) nhưng
contains trả false và remove không xoá được — vì Set tìm
ở ngăn ứng với mã băm mới, còn object nằm ở ngăn ứng với mã băm cũ.
Quy tắc rút ra: trường tham gia equals/hashCode nên
final. Đây chính là lý do record (chương 5) bắt buộc mọi thành phần
phải bất biến.
Quiz
Vì sao trường có giá trị mặc định mà biến cục bộ thì không?
Xem đáp án
Trường nằm trong object trên heap. Khi cấp phát, JVM bắt buộc phải xoá
sạch vùng nhớ đó — để lại dữ liệu từ object cũ là lỗ hổng bảo mật. Nên việc đặt 0 /
false / null là "miễn phí", tận dụng luôn thao tác xoá.
Biến cục bộ nằm trên stack. Khởi tạo mặc định ở mọi lời gọi phương thức sẽ tốn thời
gian vô ích trong hàng triệu lời gọi. Thay vào đó, trình biên dịch thực hiện phân tích gán xác
định (definite assignment analysis) và báo lỗi
variable x might not have been initialized nếu bạn đọc trước khi gán.
Kết quả là Java loại bỏ được cả hai vấn đề: biến chưa khởi tạo bị bắt lúc biên dịch, còn object thì luôn ở trạng thái xác định.
Chuyện gì xảy ra khi bạn viết một constructor có tham số?
Xem đáp án
Constructor mặc định biến mất. Trình biên dịch chỉ tự thêm
public X() { } khi class không có constructor nào. Viết một constructor bất
kỳ là bạn đã "nhận trách nhiệm" và new X() sẽ báo lỗi biên dịch.
Muốn có cả hai, phải viết tường minh — và cách gọn nhất là để constructor rỗng gọi lại constructor
đầy đủ: public X() { this(giaTriMacDinh); }.
Hệ quả thực tế: nhiều thư viện tạo object bằng phản chiếu và yêu cầu constructor không tham số — Jackson khi đọc JSON, Hibernate khi ánh xạ bản ghi database, chuẩn JavaBeans. Thêm constructor có tham số mà quên giữ lại constructor rỗng sẽ khiến chúng ném lỗi lúc chạy, không phải lúc biên dịch.
Thành viên static khác thành viên instance thế nào? Vì sao phương thức static không truy cập được trường instance?
Xem đáp án
static thuộc về class: chỉ tồn tại một bản duy nhất, được tạo khi class
được nạp, và tồn tại kể cả khi chưa có object nào. Thành viên instance thuộc về từng
object: mỗi new tạo một bản riêng.
Phương thức static không truy cập được trường instance vì nó không biết
object nào. Câu hỏi "trả về maSo của ai?" không có câu trả lời khi bạn gọi
SinhVien.getSoLuong() — không có object nào trong ngữ cảnh, và vì thế cũng không có
this.
Chiều ngược lại thì được: phương thức instance truy cập thoải mái cả thành viên tĩnh, vì thành viên
tĩnh luôn tồn tại.
Đây cũng là lý do main phải static (chương 1): lúc khởi động chưa có object
nào tồn tại.
Nêu thứ tự khởi tạo đầy đủ khi gọi new X().
Xem đáp án
Một lần duy nhất, khi class được nạp lần đầu:
- Trường
static— theo thứ tự khai báo trong mã nguồn. - Khối
static { }— xen kẽ với bước 1 theo đúng thứ tự xuất hiện.
Mỗi lần new:
- Cấp phát bộ nhớ trên heap, đặt mọi trường về giá trị mặc định (
0/null). - Gọi constructor lớp cha (
super()) — ngầm định nếu bạn không viết. Đệ quy lên tớiObject. - Trường instance — theo thứ tự khai báo.
- Khối khởi tạo instance
{ }— xen kẽ với bước 5. - Thân constructor.
Nếu constructor gọi this(...), constructor được gọi sẽ chạy trọn vẹn (kể cả bước 5-6)
trước khi thân của constructor gọi tiếp tục.
Hệ quả thực tế: gọi phương thức trong lúc khởi tạo trường là nguy hiểm, vì phương thức đó có thể đọc trường khai báo phía dưới và nhận về giá trị mặc định. Hãy đặt logic khởi tạo trong constructor.
Bốn mức truy cập là gì? Xếp từ hẹp tới rộng.
Xem đáp án
Từ hẹp tới rộng:
private— chỉ trong cùng class (kể cả class lồng bên trong).- (không ghi gì) package-private — cùng package.
protected— cùng package hoặc class con ở package khác.public— mọi nơi.
Điểm hay bị hiểu sai: protected rộng hơn package-private, không phải
hẹp hơn — nó bao gồm cả cùng package. Java không có mức "chỉ class con, không cho package".
Class cấp cao nhất chỉ có hai lựa chọn: public hoặc package-private (không thể
private hay protected).
Nguyên tắc: bắt đầu từ private, chỉ nới rộng khi có lý do cụ thể. Mọi
thứ public là một lời hứa tương thích mà bạn phải giữ.
Đóng gói có phải là "để trường private rồi sinh getter/setter cho tất cả" không?
Xem đáp án
Không. Getter/setter cho mọi trường về bản chất tương đương với trường
public — chỉ dài dòng hơn. Nếu setSoDu(-999999) vẫn chạy được thì bạn chưa
bảo vệ được gì.
Đóng gói thật là che giấu trạng thái và chỉ mở ra hành vi có ý nghĩa nghiệp vụ:
- Thay
setSoDu(long)bằngnap(long)vàrut(long)— hai thao tác này có quy tắc riêng và ghi được lịch sử. - Chỉ có getter, không có setter, cho trường mà bên ngoài không được đổi trực tiếp.
- Không trả về collection nội bộ — dùng
List.copyOf()hoặc mở các phương thứcthem()/xoa()/tim(). - Đặt mọi kiểm tra hợp lệ trong constructor, để object không bao giờ ở trạng thái sai.
Câu hỏi kiểm tra: "Nếu tôi đổi cách lưu trữ bên trong, code bên ngoài có phải sửa không?" Nếu có, bạn chưa đóng gói.
Vì sao override equals() thì phải override hashCode()?
Xem đáp án
Vì hợp đồng của Object quy định: nếu a.equals(b) thì
a.hashCode() phải bằng b.hashCode(). Vi phạm hợp đồng này làm hỏng mọi
cấu trúc dữ liệu dựa trên băm.
Cụ thể: HashMap và HashSet tìm phần tử theo hai bước — dùng
hashCode() để xác định ngăn (bucket), rồi dùng equals() để so
trong ngăn đó. Hai object "bằng nhau" nhưng mã băm khác nhau sẽ rơi vào hai ngăn khác nhau và
không bao giờ được so sánh. Kết quả: cả hai cùng tồn tại trong HashSet, và
contains() trả false với một object có nội dung y hệt.
Chiều ngược lại không bắt buộc — hai object khác nhau được phép trùng mã băm (va chạm), và
HashMap xử lý được điều đó.
Viết nhanh: Objects.hash(truong1, truong2). Trường tham gia nên final —
đổi chúng khi object đang nằm trong Set sẽ khiến object "biến mất". Từ Java 16,
record sinh sẵn cả hai phương thức đúng chuẩn.
Java có garbage collector thì có bị rò rỉ bộ nhớ không?
Xem đáp án
Có — chỉ theo cách khác với C/C++. GC thu hồi object không còn tham chiếu nào trỏ tới. Rò rỉ trong Java nghĩa là bạn vô tình giữ tham chiếu tới object không còn cần nữa, nên GC không được phép dọn.
Ba nguồn phổ biến nhất:
- Collection tĩnh không dọn — một
static Maplàm cache mà không có cơ chế hết hạn hoặc giới hạn kích thước sẽ phình mãi cho tới khi hết heap. - Listener/callback không huỷ đăng ký — object đã "chết" về mặt logic nhưng vẫn nằm trong danh sách listener của một object sống lâu.
- Tài nguyên không đóng — luồng, kết nối database, tệp. Ngoài bộ nhớ, chúng còn giữ tài nguyên hệ điều hành.
Triệu chứng: bộ nhớ tăng dần rồi OutOfMemoryError: Java heap space. Công cụ chẩn đoán:
heap dump + phân tích bằng Eclipse MAT hoặc VisualVM.
Điều Java đảm bảo là bạn không gặp con trỏ treo, giải phóng hai lần, hay quên
free() — ba lớp bug nghiêm trọng nhất của C/C++.
Tổng kết
Sau chương 4, bạn đã nắm:
- Class ↔ object: bản thiết kế và thực thể; mảng object chỉ chứa tham chiếu (mặc định
null). - Trường có giá trị mặc định, biến cục bộ thì không — và lý do kỹ thuật đằng sau.
- Constructor: nơi đặt mọi kiểm tra hợp lệ;
this(...); constructor mặc định biến mất khi có constructor riêng. this: phân biệt trường với tham số, gọi constructor khác, trả về chính mình cho fluent API.- Nạp chồng quyết định lúc biên dịch theo kiểu khai báo; varargs phải là tham số cuối.
- Bốn mức truy cập và nguyên tắc "bắt đầu từ
private". - Đóng gói thật = che trạng thái + mở hành vi nghiệp vụ; cẩn thận rò rỉ tham chiếu collection.
static: một bản cho cả class; không cóthis; dùng cho tiện ích, hằng số, factory.- Thứ tự khởi tạo bảy bước và cái bẫy đọc trường chưa gán.
toString/equals/hashCode: luôn dùng@Override; hợp đồng equals-hashCode là bắt buộc.- Vòng đời object: GC tự động nhưng rò rỉ vẫn xảy ra khi giữ tham chiếu thừa; không dùng
finalize().
Kết nối
- Chương 5 (OOP nâng cao) —
extends,super()trong chuỗi khởi tạo, override vs overload, vàrecordsinh sẵnequals/hashCode/toString. - Chương 6 (Ngoại lệ) —
IllegalArgumentExceptiontrong constructor;AutoCloseablethay chofinalize(). - Chương 7-8 (Collections & Map) — vì sao hợp đồng
equals/hashCodequyết địnhHashSetvàHashMapcó hoạt động đúng hay không. - Chương 9 (Generics) — class tổng quát
class Hop<T>. - Chương 12 (Đa luồng) — trạng thái tĩnh chia sẻ giữa các luồng; bất biến là chiến lược an toàn nhất.
- Chương 13 (JDBC) — mẫu DAO và lớp thực thể (entity) dựa trên đóng gói đúng cách.
- Trụ cột OOP (số 6) — Java là ví dụ cụ thể nhất cho bốn tính chất: đóng gói (chương này), kế thừa, đa hình, trừu tượng hoá (chương 5).
- Python Chương 8 — đối chiếu: Python không có
privatethật, dùng quy ước_xvà name mangling__x;@dataclasstương đươngrecord.