Trong phát triển Java, lập trình hiệu suất cao không chỉ đơn thuần là viết code chạy nhanh, mà còn liên quan đến tỷ lệ sử dụng tài nguyên (CPU, bộ nhớ, I/O) và sự ổn định của hệ thống. Hiệu suất cao thường đồng nghĩa với việc giảm áp lực thu gom rác (GC), giảm thiểu cạnh tranh khóa, tối ưu cấu trúc dữ liệu và tránh tạo đối tượng không cần thiết.
Dưới đây là các phương pháp cốt lõi, quy định chi tiết và ví dụ minh họa cho lập trình Java hiệu suất cao, được phân thành năm khía cạnh: Quản lý bộ nhớ và đối tượng, Bộ sưu tập và cấu trúc dữ liệu, Lập trình đồng thời, I/O và xử lý luồng, và Xử lý chuỗi ký tự.
Một, Quản lý bộ nhớ và đối tượng (Memory & Object Management)
Nguyên tắc cốt lõi: Giảm tần suất phân bổ đối tượng, giảm áp lực GC, tránh rò rỉ bộ nhớ.
1. Tránh tạo đối tượng tạm thời trong vòng lặp
- Quy định: Di chuyển việc tạo đối tượng ra khỏi vòng lặp, hoặc tái sử dụng đối tượng.
- Nguyên lý: Tạo đối tượng trong vòng lặp dẫn đến việc tạo ra một lượng lớn đối tượng "sinh ra rồi chết" trong thời gian ngắn, kích hoạt GC Minor thường xuyên, thậm chí dẫn đến Stop-The-World (STW).
Ví dụ không tốt:
public long tinhTong(List<Integer> danhSach) {
long tong = 0;
for (Integer i : danhSach) {
// Mỗi lần lặp đều tự động đóng gói/mở gói, và nếu logic phức tạp có thể tạo đối tượng tạm
// new BigDecimal ở đây là ví dụ điển hình về tạo đối tượng lớn trong vòng lặp
BigDecimal giaTri = new BigDecimal(i);
tong += giaTri.longValue();
}
return tong;
}
Ví dụ tốt:
public long tinhTong(List<Integer> danhSach) {
long tong = 0;
// Nếu cần tính toán phức tạp, nên khởi tạo đối tượng có thể tái sử dụng ngoài vòng lặp
for (Integer i : danhSach) {
// Sử dụng trực tiếp kiểu dữ liệu nguyên thủy để tránh tạo đối tượng
tong += i;
}
return tong;
}
// Nếu bắt buộc phải dùng BigDecimal, hãy xem xét việc tái sử dụng ngoài vòng lặp (lưu ý an toàn luồng)
2. Sử dụng kiểu dữ liệu nguyên thủy thay cho lớp bao bọc
- Quy định: Trong các tình huống xử lý tập trung, ưu tiên sử dụng
int,long,doublethay vìInteger,Long,Double. - Nguyên lý: Lớp bao bọc chứa chi phí tiêu đề đối tượng (khoảng 12-16 byte) + chi phí tham chiếu, và liên quan đến các lệnh CPU tự động đóng gói/mở gói.
Ví dụ không tốt:
// Sử dụng danh sách lớp bao bọc, chiếm nhiều bộ nhớ, và có chi phí mở gói
List<Long> danhSachId = new ArrayList<>();
for (long i = 0; i < 1000000; i++) {
danhSachId.add(i); // Tự động đóng gói: new Long(i)
}
Ví dụ tốt:
// Sử dụng mảng kiểu nguyên thủy hoặc thư viện bên thứ ba (như fastutil, HPPC)
long[] danhSachId = new long[1000000];
for (long i = 0; i < 1000000; i++) {
danhSachId[(int)i] = i; // Không có chi phí đóng gói
}
// Hoặc sử dụng LongArrayList của fastutil
3. Cảnh giác với rò rỉ bộ nhớ ngầm
- Quy định: Khi các đối tượng có vòng đời dài (như Map tĩnh, bộ nhớ đệm) giữ tham chiếu đến các đối tượng có vòng đời ngắn, cần phải dọn tay một cách thủ công.
- Nguyên lý: Chuỗi GC Roots không bị cắt đứt, dẫn đến đối tượng không thể được thu gom.
Ví dụ không tốt:
public class BoNhoDem {
private static final Map<String, Object> kho = new HashMap<>();
public void luu(String khoa, Object giaTri) {
kho.put(khoa, giaTri);
// Quên xóa, theo thời gian kho sẽ mở rộng vô hạn, dẫn đến OOM
}
}
Ví dụ tốt:
import java.util.WeakHashMap; // Phương án A: Sử dụng tham chiếu yếu
// Hoặc
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
public class BoNhoDemAnToan {
// Phương án B: Sử dụng thư viện bộ nhớ đệm chuyên nghiệp với chiến lược loại bỏ (khuyến nghị)
private final Cache<String, Object> kho = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(java.time.Duration.ofMinutes(5))
.build();
public void luu(String khoa, Object giaTri) {
kho.put(khoa, giaTri);
}
}
Hai, Bộ sưu tập và cấu trúc dữ liệu (Collections & Data Structures)
Nguyên tắc cốt lõi: Phân bổ dung lượng trước, chọn cấu trúc dữ liệu phù hợp, giảm thiểu mở rộng và xung đột băm.
1. Chỉ định dung lượng khi khởi tạo bộ sưu tập
- Quy định: Nếu biết kích thước xấp xỉ của bộ sưu tập, hãy chỉ định
initialCapacitytrong hàm tạo. - Nguyên lý: Tránh việc mảng được mở rộng nhiều lần (resize) và sao chép phần tử (System.arraycopy). Mở rộng HashMap còn dẫn đến rehash.
Ví dụ không tốt:
// Dung lượng mặc định là 16, hệ số tải 0.75. Khi đặt 1000 phần tử, sẽ trải qua nhiều lần mở rộng (16->32->64...->1024)
// Mỗi lần mở rộng đều đi kèm với việc tạo mảng mới và di chuyển dữ liệu, gây tổn thất hiệu suất rất lớn
List<String> danhSach = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
danhSach.add("item" + i);
}
Ví dụ tốt:
// Ước tính kích thước, phân bổ bộ nhớ đủ ngay từ đầu
List<String> danhSach = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
danhSach.add("item" + i);
}
// HashMap cũng cần tính toán dung lượng: expectedSize / loadFactor + 1
Map<String, String> bang = new HashMap<>((int)(10000 / 0.75f) + 1);
2. Chọn đúng triển khai Map
- Quy định:
- Ít cặp khóa-giá trị và khóa đơn giản:
HashMap. - Đọc nhiều, ghi ít trong môi trường đồng thời cao:
ConcurrentHashMap(JDK8+ hiệu suất rất tốt). - Chỉ cần khóa không cần giá trị:
HashSet(nền tảng là HashMap) hoặc bitmap chuyên dụng. - Truy vấn phạm vi/sắp xếp:
TreeMap(O(logN)), nhưng nếu chỉ cần sắp xếp một lần, nên sử dụngArrayList+Collections.sort(O(NlogN) nhưng hằng số nhỏ hơn).
Ví dụ không tốt:
// Tình huống: Chỉ cần kiểm tra sự tồn tại của phần tử, lại sử dụng HashMap<String, Boolean>
Map<String, Boolean> bangTonTai = new HashMap<>();
bangTonTai.put("khoa", true);
// Lãng phí không gian cho một đối tượng Boolean
Ví dụ tốt:
// Sử dụng HashSet hoặc BitSet cực đoan hơn (nếu khóa là số nguyên)
Set<String> tapTonTai = new HashSet<>();
tapTonTai.add("khoa");
Ba, Lập trình đồng thời (Concurrency)
Nguyên tắc cốt lõi: Giảm độ nhỏ của khóa, tận dụng thuật toán không khóa, tránh chuyển đổi ngữ cảnh.
1. Ưu tiên sử dụng LongAdder thay vì AtomicLong (đếm đồng thời cao)
- Quy định: Trong tình huống ghi đồng thời cao, sử dụng
LongAdder. - Nguyên lý:
AtomicLongdựa trên CAS, khi cạnh tranh cao thì tỷ lệ thất bại CAS lớn, CPU lãng phí quay vòng;LongAddersử dụng cộng đoạn (mảng Cell), chỉ hợp nhất khi tính tổng, giảm đáng kể cạnh tranh.
Ví dụ không tốt:
private final AtomicLong dem = new AtomicLong(0);
public void tang() {
// Trong đồng thời cao, tỷ lệ thất bại CAS cao, CPU trống chạy
dem.incrementAndGet();
}
Ví dụ tốt:
private final LongAdder dem = new LongAdder();
public void tang() {
// Các luồng cập nhật Cell khác nhau, gần như không có xung đột
dem.increment();
}
public long getDem() {
return dem.sum(); // Đọc hơi chậm hơn, nhưng viết cực nhanh
}
2. Thu hẹp phạm vi khóa (Lock Striping / Fine-grained Locking)
- Quy định: Chỉ khóa các đoạn mã cần thiết, tránh khóa cả phương thức hoặc khối logic lớn.
- Nguyên lý: Giảm thời gian chờ của luồng, tăng throughput.
Ví dụ không tốt:
public synchronized void xuLy(List<DuLieu> danhSach) {
// Khóa cả phương thức, bao gồm cả thao tác I/O tốn thời gian và tính toán không liên quan
DuLieu duLieu = layTuDB(); // I/O bị chặn, khóa bị chiếm dụng lâu
bienDoi(duLieu);
luu(duLieu);
}
Ví dụ tốt:
private final Object khoa = new Object();
public void xuLy(List<DuLieu> danhSach) {
DuLieu duLieu = layTuDB(); // Thực hiện I/O ngoài khóa
synchronized (khoa) {
// Chỉ khóa khu vực tới hạn sửa đổi tài nguyên chung
bienDoi(duLieu);
luu(duLieu);
}
}
3. Sử dụng pool luồng, nghiêm cấm new Thread() thủ công
- Quy định: Sử dụng
ThreadPoolExecutorhoặcExecutors(cần lưu ý các vấn đề) để tạo pool luồng, và cấu hình tham số hợp lý. - Nguyên lý: Chi phí tạo và hủy luồng lớn, tạo luồng vô hạn dẫn đến OOM hoặc CPU chuyển đổi quá mức.
Ví dụ không tốt:
// Mỗi yêu cầu đều tạo luồng mới, tài nguyên hệ thống nhanh chóng cạn kiệt
public void xuLyYeuCau() {
new Thread(() -> {
lamViecNang();
}).start();
}
Ví dụ tốt:
// Định nghĩa pool luồng trước, tái sử dụng luồng
private static final ExecutorService dichVu = new ThreadPoolExecutor(
10, 50, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy() // Chiến lược từ chối để防止 mất nhiệm vụ
);
public void xuLyYeuCau() {
dichVu.submit(() -> lamViecNang());
}
Bốn, I/O và xử lý luồng (I/O & Streams)
Nguyên tắc cốt lõi: Giảm số lần gọi hệ thống, sử dụng đệm, tận dụng NIO.
1. Sử dụng luồng đệm (Buffered Streams)
- Quy định: Khi đóng gói
InputStream/OutputStream/Reader/Writer, hãy sử dụng phiên bảnBuffered. - Nguyên lý: Giảm số lần gọi hệ thống (syscall) đĩa/mạng. Đọc 1KB một lần nhanh hơn nhiều lần đọc 1 byte.
Ví dụ không tốt:
// Mỗi read() có thể kích hoạt một lần gọi hệ thống
try (FileInputStream fis = new FileInputStream("large.txt")) {
int duLieu;
while ((duLieu = fis.read()) != -1) { // Đọc từng byte, rất chậm
xuLy(duLieu);
}
}
Ví dụ tốt:
// Duy trì bộ đệm bên trong, đọc theo lô
try (FileInputStream fis = new FileInputStream("large.txt");
BufferedInputStream bis = new BufferedInputStream(fis)) {
byte[] boDem = new byte[8192];
int doDai;
while ((doDai = bis.read(boDem)) != -1) { // Đọc theo lô
xuLy(boDem, doDai);
}
}
2. Sử dụng thận trọng Java Stream API (trong tình huống hiệu suất cực đoan)
- Quy định: API Stream code đẹp, nhưng trong đường dẫn nóng (Hot Path) cực kỳ nhạy cảm, vòng lặp
fortruyền thống thường nhanh hơn. - Nguyên lý: Stream liên quan đến tạo đối tượng Lambda, chi phí gọi giao diện, chi phí xây dựng pipeline. JDK tối ưu sau này khoảng cách thu hẹp, nhưng trong việc duyệt và cộng đơn giản,
forvẫn hơn.
Ví dụ (tình huống nhạy cảm với micro-benchmark):
// Trong tình huống gọi mỗi triệu lần một giây, chi phí của Stream không thể xem nhẹ
long tong = danhSach.stream()
.filter(x -> x > 0)
.mapToLong(Long::valueOf)
.sum();
Ví dụ tốt:
long tong = 0;
for (long x : danhSach) {
if (x > 0) {
tong += x;
}
}
// Lưu ý: Với logic nghiệp vụ phức tạp không phải đường dẫn nóng, ưu thế đọc code của Stream lớn hơn tổn thất hiệu suất vi mô, nên ưu tiên đảm bảo tính đọc hiểu.
Năm, Xử lý chuỗi ký tự (String Handling)
Nguyên tắc cốt lõi: Tận dụng tính không thể thay đổi, tránh nối chuỗi thường xuyên, chú ý chuyển đổi mã hóa.
1. Sử dụng StringBuilder trong vòng lặp
- Quy định: Trong vòng lặp hoặc sửa đổi chuỗi thường xuyên, phải sử dụng
StringBuilder(đơn luồng) hoặcStringBuffer(đa luồng, ít dùng). - Nguyên lý: String là bất biến, toán tử
+trong vòng lặp sẽ tạo ra nhiều đối tượng String trung gian.
Ví dụ không tốt:
String ketQua = "";
for (int i = 0; i < 1000; i++) {
ketQua += i; // Biên dịch thành new StringBuilder().append(ketQua).append(i).toString()
// Mỗi lần lặp đều bỏ旧的 StringBuilder và String
}
Ví dụ tốt:
StringBuilder sb = new StringBuilder(1000 * 4); // Ước tính dung lượng
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String ketQua = sb.toString();
2. Sử dụng thận trọng với pool hằng chuỗi và intern()
- Quy định: Không gọi tùy ý
string.intern(), trừ khi bạn thực sự hiểu rõ mình đang làm gì (như tiết kiệm bộ nhớ cho nhiều chuỗi dài trùng lặp). - Nguyên lý:
intern()sẽ đặt chuỗi vào pool hằng số trong permanent generation/metaspace của JVM, thao tác liên quan đến khóa đồng bộ, và dễ dẫn đến Metaspace OOM.
Ví dụ không tốt:
// Khi phân tích nhiều log, intern mỗi trường dẫn đến metaspace đầy và giảm hiệu suất
String danhMuc = parseCategory(line).intern();
Ví dụ tốt:
// Tạo chuỗi bình thường, để JVM G1/ZGC xử lý chuỗi trùng lặp (GC hiện đại tối ưu chuỗi ngắn rất tốt)
// Hoặc sử dụng ánh xạ từ điển tùy chỉnh (Map<String, Integer>) để phân loại hóa
String danhMuc = parseCategory(line);
Sáu, Tổng hợp và danh sách thực hành tốt nhất
| Loại | Hành động chính | Lợi ích hiệu suất |
|---|---|---|
| **Đối tượng** | Tạo đối tượng ngoài vòng lặp, tái sử dụng pool đối tượng | Giảm tần suất GC, giảm thời gian STW |
| **Kiểu dữ liệu** | Kiểu nguyên thủy > Lớp bao bọc | Giảm sử dụng bộ nhớ, loại bỏ đóng gói/mở gói |
| **Bộ sưu tập** | Khởi tạo chỉ định dung lượng (capacity) |
Tránh mở rộng và sao chép mảng, rehash |
| **Đồng thời** | LongAdder > AtomicLong (ghi đồng thời cao) |
Giảm quay vòng CAS, tăng利用率 CPU |
| **Đồng thời** | Thu hẹp phạm vi synchronized |
Giảm thời gian chờ bị chặn của luồng |
| **I/O** | Phải sử dụng luồng Buffered |
Giảm số lần gọi hệ thống (Syscalls) |
| **Chuỗi** | Nối chuỗi trong vòng lặp dùng StringBuilder |
Tránh độ phức tạp tạo đối tượng O(N^2) |
| **Log** | Sử dụng vị trí giữ chỗ log.info("id={}", id) |
Tránh tạo chuỗi khi không in log |
Bảy, Làm thế nào để xác minh hiệu suất?
Đừng tối ưu hóa một cách mù quáng. Đo lường tốt hơn phỏng đoán.
- JMH (Java Microbenchmark Harness): Viết micro benchmark, so sánh khoa học hiệu suất giữa hai cách viết.
- Công cụ Profiler: Sử dụng Async Profiler, JVisualVM, Arthas để xem phương pháp热点 CPU và phân bổ bộ nhớ.
- Phân tích GC log: Quan sát tần suất GC và thời gian dừng, xác định xem có phải do tạo quá nhiều đối tượng không.
Ví dụ: Sử dụng JMH để kiểm tra
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ChuoiBenchmark {
@Param({"100", "1000"})
public int kichThuoc;
@Benchmark
public String testConcat() {
String s = "";
for (int i = 0; i < kichThuoc; i++) {
s += i;
}
return s;
}
@Benchmark
public String testBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < kichThuoc; i++) {
sb.append(i);
}
return sb.toString();
}
}
Kết quả chạy thường cho thấy testBuilder nhanh hơn testConcat vài cấp độ độ lớn.
Tuân thủ các quy định và mẫu này có thể cải thiện đáng kể tốc độ phản hồi và throughput của ứng dụng Java, đồng thời duy trì sự ổn định của hệ thống.