1. Giới thiệu về profiling
Profiling — giống như một cuộc kiểm tra y khoa cho chương trình của bạn: chúng ta không chỉ xem “nhiệt độ” (monitoring) mà còn tìm xem ứng dụng “đau” ở đâu, thứ gì chạy chậm, nơi nào tiêu tốn quá nhiều bộ nhớ hay tài nguyên.
Profiling — là quá trình thu thập và phân tích thông tin về hoạt động của chương trình nhằm phát hiện các nút thắt (bottlenecks) và những phần mã không hiệu quả. Không giống monitoring, vốn thường theo dõi các chỉ số tổng quát (tải CPU, bộ nhớ, số luồng), profiling cho phép nhìn vào bên trong: biết những phương thức nào được gọi thường xuyên nhất, chúng mất bao nhiêu thời gian, bao nhiêu đối tượng được tạo ra và rò rỉ bộ nhớ xảy ra chính xác ở đâu.
Khi nào profiling thật sự cần thiết?
- Ứng dụng “chậm” nhưng không rõ vì sao.
- Mức tiêu thụ bộ nhớ bất ngờ tăng.
- Sau khi cập nhật mã, một số thứ chạy lâu hơn.
- Cần hiểu vì sao máy chủ thiếu tài nguyên.
Nói thêm, hầu như mọi lập trình viên đều từng tối ưu nhầm chỗ ít nhất một lần. Vì sao? Bởi gần như không thể xác định nút thắt chỉ bằng cảm giác — đó là lý do cần đến profiler.
Các metric chính của profiling
- Thời gian thực thi phương thức (profiling CPU): Phương thức nào chiếm nhiều thời gian nhất? Chương trình “tiêu” CPU ở đâu?
- Sử dụng bộ nhớ (profiling bộ nhớ): Những đối tượng nào được tạo thường xuyên nhất? Chúng nằm trong bộ nhớ lâu hơn mức cần thiết ở đâu?
- Số lượng đối tượng: Có phải chúng ta đang tạo quá nhiều đối tượng cùng loại?
- Luồng: Có quá nhiều luồng không? Có khóa (deadlock, contention) hay không?
- Lời gọi phương thức: Độ sâu stack là bao nhiêu? Có xảy ra đệ quy không lối ra không?
2. Công cụ profiling
Trong thế giới Java có vài công cụ kinh điển (và miễn phí!) cho phép thực hiện profiling. Cùng xem những cái chính.
VisualVM
VisualVM — là công cụ miễn phí đi kèm JDK (bắt đầu từ JDK 6). Nó cho phép:
- Kết nối tới JVM cục bộ và từ xa.
- Xem bộ nhớ, luồng, CPU, và garbage collection.
- Tạo heap dump và phân tích nó.
- Profiling ứng dụng theo CPU và bộ nhớ.
Cách chạy VisualVM?
Thường nó nằm trong thư mục JDK: <path_to_JDK>/bin/jvisualvm
Khởi chạy, chọn tiến trình ứng dụng Java — và bạn có thể quan sát vòng đời của nó như ngắm cá trong bể (chỉ khác là “cá” ở đây là đối tượng và luồng).
JProfiler, YourKit
Đây là những công cụ thương mại nhưng rất mạnh. Chúng cho phép:
- Profiling bộ nhớ, CPU, luồng.
- Phân tích “ảnh chụp” bộ nhớ (heap dump).
- Tìm rò rỉ, khóa dài, phương thức chậm.
- Tích hợp với IDE và CI/CD.
Ban đầu VisualVM là đủ, nhưng khi bạn “lớn” lên các dự án lớn — hãy cân nhắc những công cụ này.
Java Flight Recorder (JFR)
JFR — là công cụ tích hợp trong JDK để thu thập sự kiện về hoạt động của JVM. Nó rất nhẹ, gần như không ảnh hưởng hiệu năng, và cho phép thu thập thông tin về:
- Thời gian chạy phương thức.
- Garbage collection.
- Luồng, khóa, lỗi.
JFR rất phù hợp cho môi trường production khi không thể làm chậm ứng dụng.
3. Thực hành: profiling một ứng dụng đơn giản
Hãy tạo một mini-calculator, có thể thực hiện tính toán dài và lưu lịch sử thao tác (để chúng ta có vòng lặp, collection và thao tác bộ nhớ).
Ví dụ mã: “Máy tính chậm”
import java.util.ArrayList;
import java.util.List;
public class SlowCalculator {
private final List<String> history = new ArrayList<>();
public int add(int a, int b) {
simulateHeavyOperation();
int result = a + b;
history.add(a + " + " + b + " = " + result);
return result;
}
public int multiply(int a, int b) {
simulateHeavyOperation();
int result = a * b;
history.add(a + " * " + b + " = " + result);
return result;
}
public List<String> getHistory() {
return history;
}
// Mô phỏng thao tác "nặng"
private void simulateHeavyOperation() {
for (int i = 0; i < 5_000_000; i++) {
Math.sqrt(i);
}
}
}
Còn đây là lớp chính:
public class Main {
public static void main(String[] args) {
SlowCalculator calc = new SlowCalculator();
for (int i = 0; i < 10; i++) {
calc.add(i, i * 2);
calc.multiply(i, i + 5);
}
System.out.println("Lịch sử thao tác:");
for (String entry : calc.getHistory()) {
System.out.println(entry);
}
}
}
Profiling ứng dụng này như thế nào?
- Biên dịch và chạy ứng dụng.
- Mở VisualVM (jvisualvm).
- Tìm tiến trình của bạn (thường theo tên lớp Main).
- Chuyển sang tab CPU Profiler và nhấn Start.
- Để chương trình chạy một lúc (hoặc nhấn nút “chậm” thêm lần nữa).
- Xem phương thức nào chiếm nhiều thời gian nhất.
Câu hỏi: Theo bạn, phương thức nào sẽ “nặng” nhất?
Trả lời: Dĩ nhiên là simulateHeavyOperation() — vì nó chạy một vòng lặp khổng lồ 5_000_000 lần và gọi Math.sqrt.
4. Các vấn đề hiệu năng thường gặp
Thuật toán chậm
Lý do phổ biến nhất: chọn thuật toán hoặc cấu trúc dữ liệu không phù hợp. Ví dụ, tìm kiếm trong danh sách thay vì dùng HashMap, hoặc sắp xếp nổi bọt thay vì quicksort.
Ví dụ:
// Tìm kiếm chậm
for (String s : list) {
if (s.equals("target")) {
// đã tìm thấy
}
}
Tốt hơn hãy dùng Set hoặc Map để tìm kiếm nhanh.
Rò rỉ bộ nhớ
Rò rỉ bộ nhớ — là tình huống đối tượng vẫn “sống” (còn tham chiếu đến) dù không còn cần thiết. Điều này làm tăng tiêu thụ bộ nhớ và cuối cùng dẫn tới OutOfMemoryError.
public class MemoryLeakDemo {
private static List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
while (true) {
leakyList.add(new byte[1_000_000]); // 1 MB
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
}
}
}
Làm sao tìm rò rỉ?
Tạo heap dump trong VisualVM và xem đối tượng nào chiếm nhiều bộ nhớ nhất và vì sao còn tham chiếu tới chúng.
Tạo đối tượng dư thừa
Nếu trong vòng lặp bạn tạo nhiều đối tượng cùng loại, điều đó không chỉ gây tải cho garbage collector mà còn có thể làm chậm ứng dụng.
for (int i = 0; i < 1_000_000; i++) {
String s = new String("hello"); // không tốt!
}
Tốt hơn hãy dùng hằng hoặc string pool (String pool).
Khóa luồng
Khi nhiều luồng tranh chấp cùng một tài nguyên (ví dụ phương thức synchronized), điều này có thể dẫn tới khóa và giảm hiệu năng.
public synchronized void doWork() {
// ...
}
Cách tìm?
Trong tab Threads của VisualVM bạn có thể thấy những luồng nào đang “treo” và vì sao.
5. Cách tiếp cận tối ưu hóa
Đo trước, tối ưu sau
Quy tắc vàng của tối ưu hóa: Đừng tối ưu cái không hề chậm.
Hãy profiling trước, tìm “điểm nóng”, rồi mới thay đổi mã. Đôi khi phần mã “hiển nhiên” chỉ chiếm 1% thời gian, còn “quái vật” thực sự — lại ở đâu đó trong thư viện hoặc nơi không ngờ tới.
Dùng profiler để tìm điểm nóng
Điểm nóng — là phương thức hoặc đoạn mã chiếm phần lớn thời gian chạy của ứng dụng.
Trong VisualVM điều này thể hiện ở tab CPU Profiler:
- Sắp xếp phương thức theo thời gian thực thi.
- Xem stack trace: ai gọi ai.
- Nhớ rằng đôi khi “thủ phạm” không phải mã của bạn, mà là thư viện hoặc thậm chí JDK.
Ví dụ tối ưu
Ví dụ 1: Thay thuật toán
Nếu bạn thấy nhiều thời gian dành cho tìm kiếm trong danh sách, hãy thay List bằng HashSet.
Set<String> set = new HashSet<>(list);
if (set.contains("target")) {
// nhanh!
}
Ví dụ 2: Giảm số lần cấp phát
Thay vì tạo đối tượng mới trong vòng lặp, hãy tái sử dụng hoặc dùng StringBuilder.
// Không tốt:
for (int i = 0; i < 10000; i++) {
String s = "Kết quả: " + i;
}
// Tốt hơn:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.setLength(0);
sb.append("Kết quả: ").append(i);
String s = sb.toString();
}
Ví dụ 3: Cache
Nếu bạn thấy một phương thức nặng được gọi nhiều lần với cùng tham số, hãy dùng cache.
Map<Integer, Double> sqrtCache = new HashMap<>();
public double cachedSqrt(int x) {
return sqrtCache.computeIfAbsent(x, Math::sqrt);
}
6. Demo: tăng tốc máy tính của chúng ta
Vấn đề: simulateHeavyOperation() tốn nhiều thời gian
Bước 1. Profiling
Trong VisualVM thấy rằng gần như toàn bộ thời gian dành cho Math.sqrt(i) bên trong vòng lặp 5_000_000 lần.
Bước 2. Tối ưu
Nếu đây chỉ là mô phỏng tải — hãy bỏ nó hoặc giảm số vòng lặp.
Nếu đây là logic nghiệp vụ thực — hãy cân nhắc liệu có thể:
- Cache kết quả.
- Dùng thuật toán nhanh hơn.
- Chuyển tính toán sang luồng riêng (nếu không ảnh hưởng người dùng).
Ví dụ tối ưu:
private void simulateHeavyOperation() {
// Trước là 5_000_000, giờ là 100_000
for (int i = 0; i < 100_000; i++) {
Math.sqrt(i);
}
}
Bước 3. Kiểm tra kết quả
Chạy profiling lại — chương trình chạy nhanh hơn, tải CPU giảm.
7. Trực quan hóa: quy trình tối ưu hóa
flowchart TD
A[Khởi chạy ứng dụng]
B["Profiling (VisualVM)"]
C[Xác định điểm nghẽn]
D[Tối ưu hóa mã]
E[Profiling lại]
F[Cải thiện hiệu năng]
A --> B --> C --> D --> E --> F
E --> C
8. Các lỗi thường gặp khi profiling và tối ưu hóa
Lỗi số 1: Tối ưu hóa theo cảm tính. Rất thường xuyên lập trình viên bắt đầu sửa mã mà không đo xem vấn đề thực sự ở đâu. Kết quả — nhiều công sức nhưng hiệu quả tối thiểu.
Lỗi số 2: Profiling trong điều kiện “không thực tế”. Cần profiling trên dữ liệu và tải gần với môi trường thật. Profiling “trên sân tập” có thể không lộ ra vấn đề thực.
Lỗi số 3: Bỏ qua rò rỉ bộ nhớ. Nếu không xem heap dump và phân tích tham chiếu, bạn có thể lâu không nhận ra chương trình “phình to” và sắp sập.
Lỗi số 4: Mải mê tối ưu hóa vi mô. Đừng tốn ngày trời để tăng tốc phần mã chỉ chiếm 0.1% thời gian chạy ứng dụng. Hãy ưu tiên nút thắt chính trước.
Lỗi số 5: Không tính tới luồng và đồng bộ. Trong ứng dụng đa luồng, vấn đề hiệu năng thường không nằm ở thuật toán mà ở khóa và chờ đợi (synchronized, contention).
Lỗi số 6: Quên profiling sau khi thay đổi. Sau tối ưu hóa nhất định phải kiểm tra lại kết quả: đôi khi “tối ưu hóa” còn có thể làm chậm hơn!
GO TO FULL VERSION