Mã ứng dụng là bộ nhớ

Mã mà bạn viết cũng là một dạng sử dụng bộ nhớ. Mọi lớp, phương thức và hằng số chuỗi trong ứng dụng của bạn đều phải được tải vào RAM khi ứng dụng được thực thi. Toàn bộ mã nguồn của ứng dụng càng lớn thì ứng dụng sẽ càng tiêu tốn nhiều bộ nhớ chỉ để tồn tại.

Bộ nhớ được sao lưu bằng tệp và phân trang theo yêu cầu

Android tải mã thực thi từ .apk (chẳng hạn như các tệp .oat hoặc .so) bằng mmap. Điều này có nghĩa là mã được hỗ trợ bằng tệp.

Điều quan trọng là Android sử dụng phân trang theo yêu cầu. Khi ứng dụng của bạn bắt đầu, nhân sẽ không tải toàn bộ APK vào RAM ngay lập tức. Thay vào đó, nó chỉ ánh xạ tệp vào không gian địa chỉ ảo của quy trình. Khi ứng dụng của bạn thực thi và CPU chuyển sang một hàm mới, thì thao tác này sẽ kích hoạt "lỗi trang". Nhân hệ điều hành sẽ tạm dừng luồng, đọc trang mã 4KB cụ thể đó từ bộ nhớ vào RAM vật lý và tiếp tục thực thi.

Sơ đồ minh hoạ việc phân trang theo nhu cầu, cho thấy các trang ảo chỉ được ánh xạ đến các trang RAM thực khi được truy cập

Điều này có nghĩa là mã bạn đóng gói nhưng không bao giờ thực thi sẽ không sử dụng bộ nhớ vật lý cho chính các trang mã. Tuy nhiên, các thư viện không dùng đến vẫn làm tăng kích thước tổng thể của APK và có thể làm tăng đáng kể bộ nhớ mà siêu dữ liệu nội bộ của hệ thống sử dụng (chẳng hạn như chỉ mục DEX và bộ mô tả lớp). Hệ thống phải đọc các thư viện này để thậm chí biết rằng mã tồn tại. Hơn nữa, nhiều thư viện chứa các trình khởi chạy tĩnh hoặc được các khung tiêm phần phụ thuộc chạm vào trong quá trình khởi động ứng dụng, khiến chúng được phân trang vào RAM dù sao đi nữa.

Loại bỏ trang và giảm tốc độ

Vì bộ nhớ dựa trên tệp luôn có thể được đọc lại từ bộ nhớ, nên nhân coi các trang này là "sạch". Khi hệ thống gặp phải tình trạng thiếu bộ nhớ, nhân sẽ loại bỏ (giảm) các trang mã sạch này khỏi RAM để nhường chỗ cho những thứ khác.

Nếu sau này ứng dụng của bạn cần thực thi lại mã đó, thì CPU sẽ gặp lỗi và nhân phải đọc lại trang từ bộ nhớ. Ứng dụng của bạn càng có nhiều mã thì càng dễ bị loại bỏ mã. Khi người dùng quay lại ứng dụng có kích thước lớn của bạn sau khi dùng các ứng dụng khác, họ sẽ gặp phải tình trạng giật và chậm ngẫu nhiên do CPU liên tục bị treo để chờ mã được phân trang lại từ bộ nhớ.

Chi phí của lỗi trang: Mặc dù chi phí này thay đổi rất nhiều dựa trên tốc độ lưu trữ của thiết bị (UFS so với eMMC) và trạng thái của nhân, nhưng một lỗi trang chính (đọc 4 KB từ bộ nhớ) có thể tốn từ 0,5 mili giây đến 5 mili giây. Nếu đường dẫn khởi động của bạn chạm đến 500 trang mã chưa được tối ưu hoá, bạn có thể dễ dàng đưa vào vài trăm mili giây độ trễ I/O thuần tuý cho thời gian khởi động ứng dụng.

Khám phá kích thước mã bằng Compiler Explorer

Để hiểu rõ cách mã Java hoặc Kotlin của bạn chuyển đổi thành mã máy gốc (và do đó là các byte bộ nhớ), bạn có thể sử dụng Compiler Explorer.

Godbolt tích hợp sẵn tính năng hỗ trợ Android. Tính năng này cho phép bạn xem cách các phần khác nhau của chuỗi công cụ Android (D8, R8 và dex2oat) biến đổi mã nguồn của bạn.

Cách sử dụng Compiler Explorer với Android

  1. Truy cập vào godbolt.org.
  2. Chọn Android Java hoặc Android Kotlin trong trình đơn thả xuống ngôn ngữ (trên cùng bên trái).
  3. Trong trình đơn thả xuống của trình biên dịch (ở trên cùng bên phải của ngăn mã), bạn có thể chọn giữa các công cụ sau:
    • d8: Cho thấy mã byte Dalvik (.dex). Đây là cách biểu thị gần nhất với mã gốc của bạn và dễ đọc hơn.
    • r8: Cho biết cách trình tối ưu hoá R8 rút gọn và tối ưu hoá mã byte của bạn.
    • dex2oat: Cho biết mã máy ARM64 cuối cùng thực sự thực thi trên thiết bị. Đây là nơi bạn có thể thấy tác động thực sự của bộ nhớ (4 byte cho mỗi chỉ thị). dex2oat có thể nhắm đến nhiều ISA, nhưng ARM64 là ISA phổ biến nhất cho điện thoại di động.
  4. Nguồn<>Đầu ra làm nổi bật: Khi di chuột qua một dòng mã, dòng mã đó sẽ làm nổi bật các chỉ dẫn mã byte hoặc mã máy tương ứng, giúp bạn dễ dàng theo dõi tác động của các câu lệnh cụ thể.
  5. Quy trình tối ưu hoá: Trong chế độ xem tháo rời, bạn có thể nhấp vào Thêm mới... -> Opt Pipeline. Nhờ đó, bạn có thể xem các bước nội bộ mà trình biên dịch thực hiện. Bạn có thể kiểm tra cách Internal Representation (IR) được chuyển đổi ở mỗi giai đoạn (ví dụ: giữa các bước "Inliner (before)" và "Inliner (after)") trước khi được hạ cấp thành mã máy ARM64 cuối cùng.

Ảnh chụp màn hình giao diện người dùng Compiler Explorer cho thấy một chương trình mẫu và quy trình tối ưu hoá cũng như tháo rời đầu ra dex2oat của chương trình đó, trong đó có bước nội tuyến

Tại sao điều này lại quan trọng đối với bộ nhớ

Mọi chỉ dẫn mà bạn thấy trong đầu ra dex2oat nhắm đến ISA ARM64 đều chiếm 4 byte trong tệp thực thi (.odex hoặc .oat) của ứng dụng.

Hãy thử nhập mã sử dụng các tính năng ngôn ngữ khác nhau và nghiên cứu đầu ra của trình biên dịch:

  • Truy cập mảng so với Trình lặp danh sách:
    • Một vòng lặp mảng đơn giản trên int[] có thể biên dịch thành khoảng 10 chỉ thị (khoảng 40 byte).
    • Vòng lặp foreach trên List ngầm sử dụng Iterator. Điều này có thể dẫn đến 30-40 chỉ dẫn (~160 byte) do các lệnh gọi phương thức bổ sung (hasNext(), next()) và việc phân bổ chính đối tượng trình lặp.
    • Tối ưu hoá R8: Trong điều kiện phù hợp (ví dụ: khi List được chứng minh là ArrayList), trình tối ưu hoá R8 có thể chuyển đổi vòng lặp foreach trở lại thành một vòng lặp được lập chỉ mục đơn giản, loại bỏ chi phí của trình lặp và giảm cả kích thước mã cũng như tình trạng nhồi nhét bộ nhớ trong thời gian chạy.
  • Lệnh gọi phương thức ảo: Liên quan đến việc tải lớp của đối tượng, tìm phương thức trong vtable, rồi phân nhánh. Quá trình này thường mất 4-5 chỉ thị (~20 byte).
  • Lệnh gọi trực tiếp/tĩnh: Thường chuyển thành một lệnh bl (Phân nhánh có liên kết) (4 byte).
  • Kotlin Lambdas: Có thể tạo toàn bộ các lớp ẩn danh và các phương thức cầu nối bổ sung, thêm hàng trăm byte mã và siêu dữ liệu cho một khối chức năng đơn giản.

Bằng cách sử dụng Compiler Explorer, bạn có thể thấy các tính năng ngôn ngữ phức tạp (chẳng hạn như lambda Kotlin, API luồng hoặc việc sử dụng nhiều kiểu chung) ảnh hưởng đến kích thước đã biên dịch cuối cùng của ứng dụng như thế nào và cách các trình tối ưu hoá như R8 có thể chống lại chi phí của các lớp trừu tượng ngôn ngữ trong một số trường hợp. Công cụ này có thể giúp bạn đưa ra những lựa chọn thoả hiệp sáng suốt trong quá trình thiết kế và triển khai một ứng dụng.

Nói chung, mã trong ứng dụng của bạn càng phức tạp thì mức sử dụng bộ nhớ càng cao. Ngược lại, mã đơn giản hơn (hoặc mã được R8 đơn giản hoá) sẽ dẫn đến một biểu thức nhỏ hơn dưới dạng các chỉ dẫn và byte của CPU trong bộ nhớ và RAM.

Đo lường tác động của mã bằng meminfoshowmap

Bạn có thể sử dụng các công cụ bộ nhớ Android tiêu chuẩn để xem mã của ứng dụng đang tiêu thụ bao nhiêu bộ nhớ.

dumpsys meminfo

Khi bạn chạy adb shell dumpsys meminfo <package>, danh mục Code (Mã) trong phần App Summary (Tóm tắt ứng dụng) sẽ cung cấp thông tin tổng quan về bộ nhớ liên quan đến mã:

 App Summary
                       Pss(KB)
                        ------
           Java Heap:     3244
         Native Heap:     5412
                Code:    24512  # <--- Sum of .so, .dex, .oat, .art, etc.

showmap

Để có chế độ xem chi tiết hơn, hãy sử dụng showmap. Thao tác này cho thấy các vùng nằm ngoài những tệp cụ thể đang được ánh xạ vào bộ nhớ.

adb shell showmap $(pidof <package>) | grep -E "\.oat|\.odex|\.dex|\.apk"

Bạn sẽ thấy các mục cho mã đã biên dịch của ứng dụng:

   size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS object
------- -------- -------- -------- -------- -------- -------- -------- -------- ----------------
  12288     8192     8192     8192        0        0        0        0        0 /data/app/.../base.odex

Mã không dùng đến và R8

Vì mọi phương thức được thực thi đều chiếm bộ nhớ, nên việc có một ứng dụng "cồng kềnh" với các hoạt động khởi tạo không cần thiết hoặc các thư viện không dùng đến có thể ảnh hưởng nghiêm trọng đến hiệu suất khởi động và mức sử dụng bộ nhớ cơ bản.

Đó là lý do các công cụ như R8 (ProGuard) là rất quan trọng. R8 phân tích mã byte của ứng dụng và xoá mọi lớp hoặc phương thức không bao giờ được gọi ("xoá mã không dùng").

Bài tập thực hành: chi phí của việc tăng kích thước

Để minh hoạ tác động của kích thước mã, hãy xem xét một thử nghiệm so sánh 2 bản dựng của một ứng dụng chứa 300 lớp được tạo (mỗi lớp có 500 phương thức):

  • CodeBloat (Chưa được tối ưu hoá): Bản dựng chuẩn, chưa được tối ưu hoá chứa tất cả các lớp được tạo và chuỗi duy nhất.
  • CodeBloatOptimized: Cùng một mã nguồn, nhưng được biên dịch khi bật tính năng rút gọn R8.

1. Biên dịch trước khi thực thi (AOT)

Để tối đa hoá tác động của bộ nhớ được sao lưu bằng tệp, chúng ta sẽ sử dụng công cụ cmd package compile để biên dịch trước thời gian (AOT) các ứng dụng thành tệp .oat.

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell cmd package compile -m speed -f com.android.codebloat.optimized

Xin lưu ý rằng đây là một ví dụ giả tạo. Thông thường, các ứng dụng sẽ sử dụng chế độ biên dịch speed-profile (xem thêm bên dưới).

2. Chạy quảng cáo và so sánh

Để xem một quy trình khởi động thực sự nguội, trong đó hệ thống phải đọc mã từ bộ nhớ, chúng ta sẽ thả bộ nhớ đệm trang của nhân trước khi khởi chạy từng ứng dụng. Điều này đòi hỏi quyền truy cập gốc.

Khởi chạy ứng dụng chưa được tối ưu hoá:

adb shell am force-stop com.android.codebloat
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5 # Wait for the background thread to load classes
adb shell dumpsys meminfo -s com.android.codebloat

Bây giờ, hãy làm tương tự cho ứng dụng được tối ưu hoá:

adb shell am force-stop com.android.codebloat.optimized
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat.optimized/com.android.codebloat.MainActivity
sleep 5
adb shell dumpsys meminfo -s com.android.codebloat.optimized
Kết quả

Nếu xem hàng Code (Mã) trong phần App Summary, bạn sẽ thấy sự khác biệt lớn:

  • Chưa tối ưu hoá Code: ~30.000 KB (30 MB)
  • Được tối ưu hoá Code: ~2.000 KB (2 MB)

Vì R8 xác định rằng 500 phương thức bên trong các lớp đó không bao giờ thực sự làm bất cứ điều gì hữu ích (phương thức doSomething() chỉ gọi method0() và kết quả bị bỏ qua), nên R8 đã loại bỏ hầu hết mã được tạo nhân tạo khỏi APK cuối cùng.

3. Xem tác động trong Perfetto

Tác động của việc phình to mã có thể thấy rõ trong giai đoạn tải ban đầu của ứng dụng. Cụ thể, hãy tìm lát cắt bindApplication trên luồng chính và các lát cắt lồng nhau bắt đầu bằng madvising, cho biết hệ thống đang chuẩn bị tải các tệp từ APK và mã đã biên dịch của APK (.odex).

Trong quá trình khởi động nguội có tương tác, hệ thống sẽ mmap() và mã madvise() cũng như các dữ liệu khác trong những tệp này cần thiết để ứng dụng tải và chạy. Giá trị sau "size=" trong các lát madvising cho biết lượng dữ liệu cần được tải. Quá trình tìm nạp trước mã của ứng dụng này được thực hiện để tăng tốc quá trình khởi động ứng dụng.

Từ so sánh này, chúng ta có thể thấy rằng lượng mã ứng dụng cần được tải từ bộ nhớ vào RAM lớn hơn nhiều trong trường hợp ứng dụng có kích thước lớn, dẫn đến thời lượng dài hơn và góp phần làm cho ứng dụng khởi động chậm hơn. Ngoài ra, dấu vết của quá trình khởi động ứng dụng có kích thước lớn cho thấy các lát để tải tệp DEX thứ cấp (classes2.dex, classes3.dex) mà ứng dụng có kích thước lớn buộc phải "tràn" vào vì không vừa trong một tệp DEX.

So sánh (khởi động nguội trên Pixel 10a)
Chỉ số Chưa được tối ưu hoá (CodeBloat) Đã tối ưu hoá (CodeBloatOptimized)
base.odex madvise size ~7,9 MB (2,0 mili giây) ~16 KB (0,003 mili giây)
base.apk madvise size ~2,4 MB (2,4 mili giây) ~4 KB (0,001 mili giây)
classes2.dex madvise size ~7,3 MB (8,6 mili giây) Không áp dụng
classes3.dex madvise size ~7,3 MB (8,0 mili giây) Không áp dụng
Tổng thời lượng madvising Khoảng 21 mili giây ~0,004 mili giây
Hiệu suất tải ứng dụng chưa được tối ưu hoá

Ảnh chụp màn hình giao diện người dùng Perfetto cho thấy quy trình com.android.codebloat với các lát madvising cho tệp DEX chính và phụ

Tối ưu hoá hiệu suất tải ứng dụng

Ảnh chụp màn hình giao diện người dùng Perfetto cho thấy quy trình com.android.codebloat.optimized với một lát cắt madvising nhỏ duy nhất

Mức độ ảnh hưởng của tình trạng phình to mã tuỳ thuộc vào kích thước của ứng dụng, đặc điểm của thiết bị người dùng và mức tải của hệ thống.

PerfettoSQL để phân tích quá trình tải

Bạn có thể sử dụng các truy vấn sau để trích xuất những chỉ số này từ dấu vết.

1. Thời lượng khởi động ứng dụng

Chỉ số này cho biết thời gian từ khi một Hoạt động của ứng dụng được khởi chạy cho đến khi Hoạt động đó vẽ khung hình đầu tiên.

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT package, dur, startup_type
FROM android_startups
WHERE package LIKE 'com.android.codebloat%';

Xem phần: Tìm hiểu các trạng thái khởi động ứng dụng

Thời gian khởi động của ứng dụng chịu ảnh hưởng của nhiều yếu tố khác ngoài những yếu tố được đề cập trong hướng dẫn này!

2. Trích xuất kích thước và thời lượng madvising

Truy vấn này phóng to phần madvising mà chúng ta đã thấy ở trên.

INCLUDE PERFETTO MODULE slices.with_context;

SELECT
  name,
  dur/1e6 AS dur_ms
FROM thread_slice
WHERE process_name LIKE 'com.android.codebloat%'
  AND name LIKE 'madvising %';
3. Bảng chi tiết về trạng thái luồng chính (tổng thời lượng theo trạng thái)

Truy vấn này cho biết khoảng thời gian mà luồng chính của ứng dụng đã dành cho các trạng thái khác nhau.

SELECT
  p.name AS process_name,
  state,
  sum(dur)/1e6 AS total_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
GROUP BY p.name, state;

Bạn có thể tinh chỉnh truy vấn để chỉ xem xét các trạng thái luồng chính trong thời gian khởi động của ứng dụng.

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT
  p.name AS process_name,
  ts.state,
  -- Calculate only the duration that falls within the startup window
  SUM(
    MAX(0,
      MIN(ts.ts + ts.dur, s.ts + s.dur) - MAX(ts.ts, s.ts)
    )
  ) / 1e6 AS startup_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
-- Join on the package name to align thread states with the correct startup
JOIN android_startups s ON s.package = p.name
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
  -- Only select thread states that overlap with the startup interval
  AND ts.ts + ts.dur > s.ts
  AND ts.ts < s.ts + s.dur
GROUP BY 1, 2
ORDER BY startup_dur_ms DESC;

Điều này có thể bộc lộ một số vấn đề thú vị, chẳng hạn như:

  • Thời gian chạy (R) đã trôi qua nhiều nhưng không chạy: Điều này cho thấy quá trình khởi động ứng dụng bị trì hoãn do tranh chấp CPU, tức là luồng chính của ứng dụng không thể chạy vì các luồng khác (có thể là từ các ứng dụng khác) đang chiếm dụng CPU.
  • Thời gian ngủ có thể bị gián đoạn (D) kéo dài: Điều này thường cho thấy tốc độ I/O chậm hoặc áp lực bộ nhớ đang làm chậm quá trình khởi động ứng dụng.
  • Thời gian ngủ (S) dài: Điều này có nghĩa là luồng chính đang chờ các luồng khác thực hiện công việc. Đôi khi, điều này cho thấy sự tranh chấp khoá trong đường dẫn khởi động của ứng dụng (tức là luồng chính bị chặn trên một tài nguyên độc quyền do một luồng khác trong ứng dụng chiếm giữ).
4. Bộ nhớ tối đa được hỗ trợ bởi tệp (tệp RSS)

Chỉ số này tương quan tốt với lượng mã và dữ liệu mà ứng dụng tải khi khởi động. Một ứng dụng "cồng kềnh" hơn sẽ đạt đến một số lượng cao hơn ở đây, gây áp lực bộ nhớ lên hệ thống. Áp lực như vậy có thể làm chậm quá trình khởi động ứng dụng, vì hệ thống phải cố gắng đáp ứng các yêu cầu phân bổ hoặc chuyển hướng thời gian CPU từ việc tập trung vào khởi động ứng dụng sang thu hồi bộ nhớ từ các quy trình khác để đáp ứng nhu cầu tức thì của ứng dụng khởi động.

SELECT
  p.name AS process_name,
  max(c.value)/1024.0/1024.0 AS max_rss_file_mb
FROM counter c
JOIN process_counter_track t ON c.track_id = t.id
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.name = 'mem.rss.file'
GROUP BY p.name;

Các chế độ biên dịch và bộ nhớ ART

Android Runtime (ART) có thể biên dịch mã xử lý ứng dụng của bạn ở một trong số các chế độ khác nhau, còn được gọi là bộ lọc trình biên dịch. Bộ lọc trình biên dịch đã chọn có tác động trực tiếp đến mức sử dụng bộ nhớ của ứng dụng.

  • verify: ART chỉ thực hiện quy trình xác minh mã byte. Không thực hiện biên dịch AOT. Mã được thực thi thông qua Trình thông dịch hoặc được trình biên dịch JIT biên dịch trong thời gian chạy.
    • Tác động đến bộ nhớ: Kích thước nhỏ nhất trên đĩa. Mức sử dụng bộ nhớ mã gốc được đẩy vào JIT Cache (bộ nhớ có sửa đổi ẩn danh).
  • speed: ART thực hiện quy trình biên dịch AOT đầy đủ cho tất cả các phương thức.
    • Tác động đến bộ nhớ: Kích thước .odex lớn nhất. Tối đa hoá mức sử dụng bộ nhớ được sao lưu bằng tệp (sạch).
  • speed-profile: ART chỉ biên dịch những phương thức được đánh dấu là "hot" trong hồ sơ JIT.
    • Tác động đến bộ nhớ: Phương pháp cân bằng. Chỉ mã quan trọng nhất được biên dịch AOT.

Bộ lọc phổ biến nhất là speed-profile, được dùng khi cài đặt ứng dụng người dùng. Thư mục này được định cấu hình trong các thuộc tính hệ thống pm.dexopt.installpm.dexopt.bg-dexopt, đồng thời thường được đặt trong build/make/target/product/runtime_libart.mk.

Một số ứng dụng hệ thống sẽ sử dụng quy trình biên dịch speed và cũng sẽ biên dịch tại thời gian xây dựng hình ảnh hệ thống. verify thường chỉ được dùng trong các trường hợp sử dụng phát triển.

Trường hợp sử dụng Bộ lọc trình biên dịch điển hình
Quá trình phát triển verify
Ảnh hệ thống speed
Ứng dụng của người dùng speed-profile

Bài tập thực hành: chế độ biên dịch và bộ nhớ

Chúng ta có thể dùng ứng dụng CodeBloat để xem mức độ ảnh hưởng của các bộ lọc này đến bộ nhớ. Cách tái tạo các phép đo này:

  1. Buộc biên dịch lại ứng dụng sang chế độ mục tiêu.
  2. Buộc dừng và khởi động nguội ứng dụng.
  3. Đợi luồng nền hoàn tất việc chạm vào các lớp (xem logcat hoặc đợi 5 giây).
  4. Chạy adb shell dumpsys meminfo com.android.codebloat.

Chế độ: verify (không có AOT)

adb shell cmd package compile -m verify -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

Ở chế độ verify, phần Tóm tắt ứng dụng cho thấy: * PSS mã: ~8.000 KB * Dalvik khác (JIT): ~25.000 KB

Vì không có mã nào được biên dịch AOT, nên thời gian chạy phải biên dịch JIT các phương thức nóng vào Bộ nhớ đệm JIT. Bộ nhớ đệm này xuất hiện dưới dạng bộ nhớ ẩn danh không hợp lệ (Dalvik Other).

Chế độ: speed (AOT đầy đủ)

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

Ở chế độ speed, kết quả thay đổi đáng kể: * PSS mã: ~24.000 KB * Dalvik Other (JIT): ~5.000 KB

Giờ đây, mã của ứng dụng được liên kết từ tệp .odex dưới dạng bộ nhớ sạch do tệp hỗ trợ. Điều này làm giảm áp lực lên bộ nhớ đệm JIT và giúp bộ nhớ đủ điều kiện để bị loại bỏ khi chịu áp lực, thay vì bị "kẹt" dưới dạng RAM có sửa đổi.

Chế độ: speed-profile (AOT có chọn lọc)

Các ứng dụng hiện đại có thể đi kèm với một hồ sơ cơ sở baseline.prof. ART sử dụng thông tin này để chỉ biên dịch có chọn lọc mã cần thiết cho quá trình khởi động nhanh và tiết kiệm bộ nhớ.

Trong bài tập này, chúng ta sẽ tạo một cấu hình cơ sở để liệt kê các lớp khởi động của ứng dụng. Tuy nhiên, trên thực tế, trình biên dịch cũng có thể nhận được các cấu hình từ các nguồn bên ngoài, chẳng hạn như từ cửa hàng ứng dụng ("cấu hình đám mây"), có thể cung cấp các cấu hình JIT từ cộng đồng cho các ứng dụng, bất kể nhà phát triển có đi kèm một cấu hình cơ sở mà họ đã tạo hay không.

Tạo và sử dụng hồ sơ trên thiết bị

Để xem tác động của speed-profile, bạn có thể tạo hồ sơ của riêng mình trên thiết bị:

  1. Đặt lại và bắt đầu:

    adb shell am force-stop com.android.codebloat
    
  2. Tương tác: Khởi động ứng dụng và để ứng dụng chạy trình tự khởi động.

  3. Dump Profile (Kết xuất hồ sơ):

    adb shell kill -s SIGUSR1 $(pidof com.android.codebloat)
    

    (Thao tác này buộc ứng dụng ghi hồ sơ hiện tại vào ổ đĩa).

  4. Hồ sơ cài đặt:

    adb shell cp /data/misc/profiles/cur/0/com.android.codebloat/primary.prof \
    /data/misc/profiles/ref/com.android.codebloat/primary.prof
    
  5. Biên dịch:

    adb shell cmd package compile -m speed-profile -f com.android.codebloat
    

Khi khởi chạy lại, bạn sẽ thấy số dư: Mã PSS sẽ thấp hơn speed (ví dụ: ~16.000 KB) vì chỉ các phương thức khởi động "nóng" được biên dịch, phần còn lại sẽ do trình thông dịch hoặc JIT xử lý chỉ khi chúng thực sự được sử dụng.

Hãy xem:

Nghiên cứu chuyên sâu về mã đã biên dịch

Nếu bạn muốn xem chính xác những hướng dẫn mà ART đang tạo, hãy tham khảo art/DISASSEMBLY_GUIDE.md.

Trang này cung cấp hướng dẫn chi tiết về cách sử dụng:

  • oatdump: Để xem các chỉ dẫn ARM64 trong tệp .odex hiện có.
  • dex2oat: Để mô phỏng quá trình biên dịch bằng các cờ gỡ lỗi chi tiết.

Bài tập: nội tuyến mã

Một lý do khiến mã đã biên dịch có thể tăng lên một cách bất ngờ là do phương thức nội tuyến. Trình biên dịch có thể quyết định sao chép nội dung của một phương thức nhỏ, thường xuyên được gọi trực tiếp vào các phương thức gọi của nó.

Trong ứng dụng CodeBloat, phương thức doSomething() trong mọi lớp được tạo chỉ cần gọi method0(). Khi được biên dịch ở chế độ speed, trình biên dịch Tối ưu hoá của ART có thể sẽ nội tuyến method0() thành doSomething().

Bài tập: Xác minh bằng cách sử dụng oatdump trên thiết bị của bạn:

# 1. Find the path to the application's APK and compiled .odex file
adb shell pm path com.android.codebloat
# Output: package:/data/app/~~.../base.apk

adb shell "dumpsys package com.android.codebloat | grep 'location is' | head -n 1"
# Example output: [location is /data/app/~~.../oat/arm64/base.odex]

# 2. Run oatdump (substituting the correct path to base.odex)
adb shell oatdump --oat-file=/data/app/~~.../oat/arm64/base.odex \
                  --class-filter=com.android.codebloat.GeneratedClass0

Tìm phương thức doSomething trong đầu ra. Nếu được nội tuyến, bạn sẽ thấy hướng dẫn tải hằng số chuỗi dài trực tiếp trong doSomething, thay vì hướng dẫn bl nhắm đến method0.

Trực quan hoá quá trình tối ưu hoá (CFG)

Để biết chính xác thời điểm trình biên dịch quyết định nội tuyến phương thức, bạn có thể tạo Đồ thị luồng điều khiển (CFG). Điều này cho thấy trạng thái của mã ở mọi giai đoạn của quy trình tối ưu hoá, với mọi hoạt động chuyển đổi trên Biểu thị trung gian (IR) của trình biên dịch cho đến khi mã được hạ cấp xuống ISA mục tiêu (ví dụ: ARM64).

  1. Chạy dex2oat bằng cờ kết xuất: Sử dụng cờ --verbose-methods để giới hạn đầu ra cho các phương thức cụ thể; nếu không, tệp .cfg cho một ứng dụng lớn có thể tăng lên vài gigabyte.

    # Substitution of actual paths required:
    adb shell dex2oat64 --dex-file=/data/app/~~.../base.apk \
                        --oat-file=/data/local/tmp/dump.odex \
                        --compiler-filter=speed \
                        --dump-cfg=/data/local/tmp/codebloat.cfg \
                        --verbose-methods=doSomething
    
  2. Kéo và xem: Kéo tệp .cfg vào máy trạm của bạn rồi mở bằng IR Hydra.

  3. Tìm Inliner: Trong IR Hydra, hãy tải các cấu phần phần mềm biên dịch và tìm kiếm doSomething. So sánh biểu thức trước và sau khi truyền Inliner. Bạn sẽ thấy biểu đồ mở rộng khi các chỉ dẫn từ method0 được hợp nhất vào phương thức gọi.

Ngoài ra, hãy dùng công cụ Opt Pipeline trong Compiler Explorer (như mô tả trong phần trên) và nhập mã tương tự để xem quá trình chuyển đổi tương tự được thực hiện ở lượt Inliner.

Bài tập: các trường biến động và rào cản bộ nhớ

Trong ứng dụng MemoryLab, trường mGarbageSink được đánh dấu là volatile. Điều này đảm bảo rằng trình biên dịch không tối ưu hoá các hoạt động phân bổ rác của chúng ta.

public volatile byte[] mGarbageSink;

Trong quá trình tháo rời ARM64, bạn sẽ thấy rằng mọi hoạt động lưu trữ vào trường này đều đi kèm với một Rào cản bộ nhớ (dmb ish) hoặc sử dụng các chỉ dẫn Tải-Thu nhận/Lưu trữ-Phát hành (ldar/stlr). Điều này đảm bảo khả năng hiển thị luồng nhưng thêm một số chỉ dẫn bổ sung vào mọi quyền truy cập, làm tăng nhẹ kích thước mã so với một trường thông thường.

Bài tập: Tìm các quyền truy cập vào trường và các rào cản bộ nhớ liên quan trong quá trình tháo rời.

Bài tập: kiểm tra việc tạm ngưng ngầm

Nếu tháo rời một vòng lặp, chẳng hạn như vòng lặp trong generateAllocationChurn, bạn sẽ nhận thấy một chỉ dẫn lạ ở cuối phần nội dung vòng lặp:

ldr x21, [x21]

Đây là Implicit Suspend Check (Kiểm tra trạng thái tạm ngưng ngầm). ART sử dụng tính năng này để cho phép Trình thu gom rác tạm dừng các luồng một cách an toàn. Thường thì thanh ghi x21 sẽ tự trỏ đến chính nó. Khi cần tạm ngưng luồng, GC sẽ "làm hỏng" vị trí bộ nhớ đó. Lần tiếp theo luồng thực thi ldr đó, luồng sẽ kích hoạt một lỗi mà thời gian chạy sẽ bắt và dùng để chuyển luồng sang trạng thái tạm ngưng.

Mẫu này được lặp lại trong mọi vòng lặp và khi bắt đầu mọi phương thức, góp phần vào tổng kích thước mã của ứng dụng.

Bài tập: Tìm tất cả các lệnh kiểm tra tạm ngưng ngầm ẩn trong quá trình tháo rời phương thức và cố gắng liên kết chúng với mã nguồn ban đầu.


← WebView | ↑ Lên | Threads →