โค้ดแอปคือหน่วยความจำ

โค้ดที่คุณเขียนก็เป็นการใช้หน่วยความจำรูปแบบหนึ่ง ระบบจะต้องโหลดทุกคลาส เมธอด และค่าคงที่สตริงในแอปพลิเคชันลงใน RAM เมื่อมีการเรียกใช้ ยิ่งฐานของโค้ดของแอปพลิเคชันมีขนาดใหญ่มากเท่าไร ก็ยิ่งใช้หน่วยความจำมากขึ้นเท่านั้น

หน่วยความจำที่สำรองข้อมูลในไฟล์และการแบ่งหน้าตามต้องการ

Android จะโหลดโค้ดที่สั่งการได้จาก .apk (เช่น ไฟล์ .oat หรือ .so) โดยใช้ mmap ซึ่งหมายความว่าโค้ดเป็นไฟล์สำรอง

ที่สำคัญคือ Android ใช้การแบ่งหน้าตามต้องการ เมื่อแอปเริ่มต้นทำงาน เคอร์เนลจะไม่โหลด APK ทั้งหมดลงใน RAM ทันที แต่จะแมปเฉพาะไฟล์ ลงในพื้นที่ที่อยู่เสมือนของกระบวนการ เมื่อแอปทำงานและ CPU เปลี่ยนไปใช้ฟังก์ชันใหม่ ระบบจะทริกเกอร์ "ข้อผิดพลาดของเพจ" เคอร์เนลจะหยุด เธรดชั่วคราว อ่านโค้ดในหน้าขนาด 4 KB ที่เฉพาะเจาะจงจากที่เก็บข้อมูลลงใน RAM จริง และกลับมาดำเนินการต่อ

แผนภาพแสดงการแบ่งหน้าตามดีมานด์ โดยแสดงหน้าเสมือนที่แมปกับ
หน้า RAM จริงเมื่อมีการเข้าถึงเท่านั้น

ซึ่งหมายความว่าโค้ดที่คุณแพ็กเกจแต่ไม่เคยเรียกใช้จะไม่ใช้หน่วยความจำจริงสำหรับหน้าโค้ดเอง อย่างไรก็ตาม ไลบรารีที่ไม่ได้ใช้จะยังคงเพิ่มขนาดโดยรวมของ APK และอาจเพิ่มหน่วยความจำที่ใช้โดยข้อมูลเมตาภายในของระบบ (เช่น ดัชนี DEX และตัวอธิบายคลาส) อย่างมาก ซึ่งต้องอ่านเพื่อทราบว่ามีโค้ดอยู่ นอกจากนี้ ไลบรารีจำนวนมากยังมี ตัวเริ่มต้นแบบคงที่หรือเฟรมเวิร์กการแทรกทรัพยากร Dependency จะแตะต้องไลบรารีเหล่านั้นในระหว่างการเริ่มต้นแอป ซึ่งทำให้มีการแบ่งหน้าไลบรารีเหล่านั้นลงใน RAM อยู่ดี

การนำหน้าเว็บออกและการชะลอตัว

เนื่องจากระบบจะอ่านหน่วยความจำที่สำรองข้อมูลในไฟล์จากพื้นที่เก็บข้อมูลได้เสมอ เคอร์เนลจึงถือว่าหน้าเหล่านี้ "สะอาด" เมื่อระบบมีหน่วยความจำไม่เพียงพอ เคอร์เนลจะนำออก (ทิ้ง) หน้าโค้ดที่สะอาดเหล่านี้จาก RAM เพื่อให้มีพื้นที่สำหรับ สิ่งอื่นๆ

หากต่อมาแอปต้องเรียกใช้โค้ดนั้นอีกครั้ง CPU จะทำงานผิดพลาด และเคอร์เนลจะต้องอ่านหน้าจากพื้นที่เก็บข้อมูลอีกครั้ง ยิ่งแอปมีโค้ดมากเท่าไร ก็ยิ่งมีโอกาสที่โค้ดจะถูกนำออกมากขึ้นเท่านั้น เมื่อผู้ใช้กลับมาที่แอป ที่ทำงานช้าหลังจากใช้แอปอื่นๆ ผู้ใช้จะพบอาการกระตุกและ การทำงานช้าแบบสุ่มเนื่องจาก CPU จะหยุดทำงานอยู่ตลอดเวลาเพื่อรอให้ระบบเรียกโค้ดจาก ที่เก็บข้อมูลกลับมา

ค่าใช้จ่ายของข้อผิดพลาดของหน้า: แม้ว่าค่าใช้จ่ายจะแตกต่างกันอย่างมากตามความเร็วในการจัดเก็บของอุปกรณ์ (UFS กับ eMMC) และสถานะเคอร์เนล แต่ข้อผิดพลาดของหน้าหลัก (อ่าน 4KB จากที่เก็บข้อมูล) อาจมีค่าใช้จ่ายตั้งแต่ 0.5 มิลลิวินาทีถึง 5 มิลลิวินาที หากเส้นทางการเริ่มต้น แตะต้องโค้ดที่ไม่ได้เพิ่มประสิทธิภาพ 500 หน้าที่แตกต่างกัน คุณก็อาจทำให้เวลาเริ่มต้นของแอปมีเวลาในการตอบสนอง I/O ที่แท้จริงหลายร้อยมิลลิวินาทีได้อย่างง่ายดาย

การสำรวจขนาดโค้ดด้วย Compiler Explorer

หากต้องการสร้างสัญชาตญาณเกี่ยวกับวิธีที่โค้ด Java หรือ Kotlin แปลงเป็นโค้ดเครื่องดั้งเดิม (และไบต์ในหน่วยความจำ) คุณสามารถใช้ Compiler Explorer ได้

การรองรับ Android อยู่ใน Godbolt โดยตรง ซึ่งจะช่วยให้คุณเห็นว่าส่วนต่างๆ ของ Toolchain ของ Android (D8, R8 และ dex2oat) เปลี่ยนซอร์สโค้ดของคุณอย่างไร

วิธีใช้ Compiler Explorer กับ Android

  1. ไปที่ godbolt.org
  2. เลือก Android Java หรือ Android Kotlin จากเมนูแบบเลื่อนลงของภาษา (ด้านซ้ายบน)
  3. ในเมนูแบบเลื่อนลงของคอมไพเลอร์ (ด้านขวาบนของแผงโค้ด) คุณสามารถเลือก เครื่องมือต่างๆ ได้ดังนี้
    • d8: แสดงไบต์โค้ด Dalvik (.dex) ซึ่งเป็นตัวแทนที่ใกล้เคียงที่สุดกับโค้ดต้นฉบับและอ่านง่ายกว่า
    • r8: แสดงวิธีที่เครื่องมือเพิ่มประสิทธิภาพ R8 ลดขนาดและเพิ่มประสิทธิภาพ ไบต์โค้ด
    • dex2oat: แสดงรหัสเครื่อง ARM64 สุดท้ายที่ดำเนินการจริงในอุปกรณ์ คุณจะเห็นผลกระทบต่อหน่วยความจำที่แท้จริงได้ที่นี่ (4 ไบต์ต่อคำสั่ง) dex2oat สามารถกำหนดเป้าหมาย ISA ที่แตกต่างกันได้ แต่ ARM64 เป็น ISA ที่พบบ่อยที่สุดสำหรับโทรศัพท์มือถือ
  4. การไฮไลต์แหล่งที่มา<>เอาต์พุต: การวางเมาส์เหนือบรรทัดของโค้ดจะไฮไลต์คำสั่งไบต์โค้ดหรือรหัสเครื่องที่เกี่ยวข้อง ทำให้ติดตามผลกระทบของคำสั่งที่เฉพาะเจาะจงได้ง่าย
  5. ไปป์ไลน์การเพิ่มประสิทธิภาพ: ในมุมมองการแยกชิ้นส่วน คุณสามารถคลิกเพิ่ม ใหม่... -> Opt Pipeline ซึ่งจะช่วยให้คุณเห็นขั้นตอนภายในที่คอมไพเลอร์ใช้ คุณสามารถตรวจสอบวิธีแปลงการแสดงภายใน (IR) ในแต่ละขั้นตอน (เช่น ระหว่างขั้นตอน "Inliner (ก่อน)" กับ "Inliner (หลัง)") ก่อนที่จะลดระดับเป็นรหัสเครื่อง ARM64 ขั้นสุดท้าย

ภาพหน้าจอของ UI ของ Compiler Explorer ที่แสดงโปรแกรมตัวอย่างและ
การแยกชิ้นส่วนเอาต์พุต dex2oat และไปป์ไลน์การเพิ่มประสิทธิภาพพร้อมขั้นตอนการแทรก
ที่แสดง

เหตุใดเรื่องนี้จึงสำคัญต่อความทรงจำ

ทุกคำสั่งที่คุณเห็นในเอาต์พุต dex2oat ที่กำหนดเป้าหมายเป็น ISA ของ ARM64 จะใช้4 ไบต์ในไฟล์ที่เรียกใช้งานได้ของแอป (.odex หรือ .oat)

ลองป้อนโค้ดที่ใช้ฟีเจอร์ภาษาต่างๆ และศึกษาเอาต์พุตของคอมไพเลอร์

  • การเข้าถึงอาร์เรย์เทียบกับตัววนซ้ำของลิสต์
    • ลูปอาร์เรย์อย่างง่ายใน int[] อาจคอมไพล์เป็นคำสั่งประมาณ 10 รายการ (ประมาณ 40 ไบต์)
    • ลูป foreach เหนือ List จะใช้ Iterator โดยนัย ซึ่งอาจส่งผลให้มีคำสั่ง 30-40 รายการ (~160 ไบต์) เนื่องจากมีการเรียกใช้เมธอดเพิ่มเติม (hasNext(), next()) และการจัดสรรออบเจ็กต์ตัววนซ้ำเอง
    • การเพิ่มประสิทธิภาพ R8: ภายใต้เงื่อนไขที่เหมาะสม (เช่น เมื่อพิสูจน์แล้วว่า List เป็น ArrayList) เครื่องมือเพิ่มประสิทธิภาพ R8 จะเปลี่ยนลูป foreach ให้กลับมาเป็นลูปที่จัดทำดัชนีอย่างง่ายได้ ซึ่งจะช่วยลดค่าใช้จ่ายของตัววนซ้ำ และลดทั้งขนาดโค้ดและการเสียหน่วยความจำขณะรันไทม์
  • การเรียกเมธอดเสมือน: เกี่ยวข้องกับการโหลดคลาสของออบเจ็กต์ การค้นหาเมธอดใน vtable และการแยกสาขา โดยปกติแล้วจะใช้คำสั่ง 4-5 คำสั่ง (~20 ไบต์)
  • การเรียกแบบคงที่/โดยตรง: มักจะแปลเป็นคำสั่ง bl (Branch with Link) เดียว (4 ไบต์)
  • Kotlin Lambdas: สามารถสร้างคลาสที่ไม่ระบุชื่อทั้งหมดและเมธอดบริดจ์เพิ่มเติม ซึ่งจะเพิ่มโค้ดและค่าใช้จ่ายของข้อมูลเมตาหลายร้อยไบต์สำหรับบล็อกฟังก์ชันอย่างง่าย

การใช้ Compiler Explorer จะช่วยให้คุณเห็นว่าฟีเจอร์ภาษาที่ซับซ้อน (เช่น แลมบ์ดาของ Kotlin, API สตรีม หรือการใช้ Generics อย่างหนัก) ส่งผลต่อขนาดที่คอมไพล์แล้วขั้นสุดท้าย ของแอปพลิเคชันอย่างไร และเครื่องมือเพิ่มประสิทธิภาพ เช่น R8 สามารถลด ค่าใช้จ่ายของการแยกภาษาในบางกรณีได้อย่างไร เครื่องมือนี้จะช่วยให้คุณ ตัดสินใจได้อย่างรอบคอบในการออกแบบและใช้งานแอป

โดยทั่วไปแล้ว ความซับซ้อนที่มากขึ้นในโค้ดของแอปจะทำให้มีการใช้หน่วยความจำมากขึ้น ในทางกลับกัน โค้ดที่เรียบง่ายกว่าหรือโค้ดที่ R8 ทำให้เรียบง่ายลงจะส่งผลให้มีการแสดงข้อมูลที่เล็กลงเป็นคำสั่ง CPU และไบต์ในที่เก็บข้อมูลและ RAM

การวัดผลกระทบของโค้ดด้วย meminfo และ showmap

คุณสามารถใช้เครื่องมือหน่วยความจำมาตรฐานของ Android เพื่อดูว่าโค้ดของแอปใช้หน่วยความจำไปเท่าใด

dumpsys meminfo

เมื่อเรียกใช้ adb shell dumpsys meminfo <package> หมวดหมู่โค้ดในส่วนข้อมูลสรุปของแอปจะแสดงมุมมองระดับสูงของหน่วยความจำที่เกี่ยวข้องกับโค้ด

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

showmap

หากต้องการดูมุมมองที่ละเอียดยิ่งขึ้น ให้ใช้ showmap ซึ่งจะแสดงภูมิภาคที่อยู่นอกไฟล์ที่เฉพาะเจาะจงซึ่งแมปกับหน่วยความจำ

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

คุณจะเห็นรายการสำหรับโค้ดที่คอมไพล์แล้วของแอปพลิเคชัน

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

โค้ดที่ไม่ได้ใช้และ R8

เนื่องจากทุกเมธอดที่เรียกใช้จะใช้หน่วยความจำ การมีแอปที่ "บวม" พร้อมการเริ่มต้นที่ไม่จำเป็นหรือไลบรารีที่ไม่ได้ใช้จะส่งผลต่อประสิทธิภาพการเริ่มต้นและการใช้งานหน่วยความจำพื้นฐานอย่างมาก

ด้วยเหตุนี้ เครื่องมืออย่าง R8 (ProGuard) จึงมีความสำคัญอย่างยิ่ง R8 จะวิเคราะห์ไบต์โค้ดของแอปพลิเคชันและนำคลาสหรือ เมธอดที่ไม่เคยเรียกใช้ ("การลบโค้ดที่ไม่ได้ใช้") ออก

แบบฝึกหัดภาคปฏิบัติ: ค่าใช้จ่ายของ Bloat

หากต้องการแสดงให้เห็นถึงผลกระทบของขนาดโค้ด ให้พิจารณาการทดสอบที่เปรียบเทียบ 2 บิลด์ของแอปพลิเคชันที่มีคลาสที่สร้างขึ้น 300 คลาส (แต่ละคลาสมี 500 เมธอด)

  • CodeBloat (ไม่ได้เพิ่มประสิทธิภาพ): บิลด์มาตรฐานที่ไม่ได้เพิ่มประสิทธิภาพซึ่งมีคลาสที่สร้างขึ้นทั้งหมดและสตริงที่ไม่ซ้ำกัน
  • CodeBloatOptimized: ซอร์สโค้ดเดียวกัน แต่คอมไพล์ด้วยการลดขนาด R8 ที่เปิดใช้

1. การคอมไพล์ล่วงหน้า (AOT)

เราจะใช้เครื่องมือ cmd package compile เพื่อคอมไพล์แอปเป็นไฟล์ .oat ล่วงหน้า (AOT) เพื่อเพิ่มผลกระทบของหน่วยความจำที่สำรองไว้ในไฟล์ให้สูงสุด

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

โปรดทราบว่านี่เป็นตัวอย่างที่สังเคราะห์ขึ้น โดยปกติแล้ว แอปจะใช้ speed-profileโหมดการคอมไพล์ (ดูเพิ่มเติมด้านล่าง)

2. เปิดตัวและเปรียบเทียบ

หากต้องการดูการเริ่มต้นแบบ cold อย่างแท้จริง ซึ่งระบบต้องอ่านโค้ดจากที่เก็บข้อมูล เราจะ ทิ้งแคชหน้าของเคอร์เนลก่อนเปิดตัวแต่ละแอป ซึ่งต้องใช้สิทธิ์เข้าถึงระดับรูท

เปิดแอปที่ยังไม่ได้เพิ่มประสิทธิภาพ

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

ตอนนี้ให้ทำแบบเดียวกันกับแอปที่เพิ่มประสิทธิภาพแล้ว

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
ผลลัพธ์

หากดูแถวโค้ดในส่วนApp Summary คุณจะเห็นความแตกต่างอย่างมาก ดังนี้

  • ยังไม่ได้เพิ่มประสิทธิภาพ Code: ~30,000 KB (30 MB)
  • เพิ่มประสิทธิภาพ Code: ~2,000 KB (2 MB)

เนื่องจาก R8 พบว่าเมธอด 500 รายการภายในคลาสเหล่านั้นไม่เคย ทำสิ่งที่เป็นประโยชน์จริงๆ (เมธอด doSomething() เรียกใช้เฉพาะ method0() และระบบไม่สนใจผลลัพธ์) จึงได้ลบโค้ดที่สร้างขึ้นโดยอัตโนมัติเกือบทั้งหมด ออกจาก APK สุดท้าย

3. ดูผลกระทบใน Perfetto

ผลกระทบของโค้ดที่มากเกินไปจะเห็นได้อย่างชัดเจนในระหว่างระยะการโหลดเริ่มต้นของ แอปพลิเคชัน โดยเฉพาะอย่างยิ่ง ให้มองหา Slice bindApplication ใน เทรดหลัก และ Slice ที่ซ้อนกันซึ่งเริ่มต้นด้วย madvising ซึ่งบ่งบอกว่า ระบบกำลังเตรียมโหลดไฟล์จาก APK และโค้ดที่คอมไพล์แล้ว (.odex)

ใน Cold Start แบบอินเทอร์แอกทีฟ ระบบจะmmap()และmadvise()โค้ด รวมถึงข้อมูลอื่นๆ จากไฟล์เหล่านี้ที่จำเป็นต่อการโหลดและเรียกใช้แอป ค่าหลัง "size=" ในชิ้นส่วน madvising จะระบุปริมาณข้อมูลที่ต้องโหลด การดึงข้อมูลโค้ดของแอปมาล่วงหน้าจะช่วยเร่งการเริ่มต้นแอป

จากการเปรียบเทียบ เราจะเห็นว่าปริมาณโค้ดของแอปที่ต้อง โหลดจากพื้นที่เก็บข้อมูลไปยัง RAM นั้นมากกว่าในกรณีของแอปที่มีขนาดใหญ่มาก ซึ่งส่งผล ให้ระยะเวลานานขึ้นและทำให้แอปเริ่มทำงานช้าลง นอกจากนี้ ร่องรอยการเริ่มต้นของแอปที่ขยายขนาดแสดงส่วนต่างๆ สำหรับการโหลดไฟล์ DEX รอง (classes2.dex, classes3.dex) ซึ่งแอปที่ขยายขนาดถูกบังคับให้ "กระจาย" ไปยัง เนื่องจากไม่สามารถใส่ไว้ในไฟล์ DEX ไฟล์เดียวได้

ในการเปรียบเทียบ (Cold Start ใน Pixel 10a)
เมตริก ไม่ได้เพิ่มประสิทธิภาพ (โค้ดบวม) เพิ่มประสิทธิภาพ (CodeBloatOptimized)
base.odex madvise size ~7.9 MB (2.0 มิลลิวินาที) ~16 KB (0.003 มิลลิวินาที)
base.apk madvise size ~2.4 MB (2.4 มิลลิวินาที) ~4 KB (0.001 มิลลิวินาที)
classes2.dex madvise size ~7.3 MB (8.6 มิลลิวินาที) ไม่มี
classes3.dex madvise size ~7.3 MB (8.0 มิลลิวินาที) ไม่มี
ระยะเวลาmadvisingทั้งหมด ~21 มิลลิวินาที ~0.004 มิลลิวินาที
ประสิทธิภาพการโหลดแอปที่ไม่ได้เพิ่มประสิทธิภาพ

ภาพหน้าจอของ UI ของ Perfetto แสดงกระบวนการ com.android.codebloat พร้อม
ชิ้น madvising สำหรับไฟล์ DEX หลักและรอง

ประสิทธิภาพการโหลดแอปที่เพิ่มประสิทธิภาพ

ภาพหน้าจอของ UI ของ Perfetto แสดงกระบวนการ com.android.codebloat.optimized
 ที่มี Slice madvising
 ขนาดเล็กเพียงรายการเดียว

ผลกระทบของโค้ดที่มากเกินไปจะแตกต่างกันไปตามขนาดของแอป ลักษณะของ อุปกรณ์ของผู้ใช้ และโหลดของระบบ

PerfettoSQL สำหรับการวิเคราะห์การโหลด

คุณใช้การค้นหาต่อไปนี้เพื่อดึงข้อมูลเมตริกเหล่านี้จากร่องรอยได้

1. ระยะเวลาเริ่มต้นของแอป

ซึ่งแสดงเวลาตั้งแต่ตอนที่เปิดใช้กิจกรรมของแอปจนกระทั่งกิจกรรม วาดเฟรมแรก

INCLUDE PERFETTO MODULE android.startup.startups;

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

ดูหัวข้อ ทำความเข้าใจสถานะการเริ่มต้นแอปต่างๆ

ระยะเวลาเริ่มต้นของแอปขึ้นอยู่กับหลายปัจจัยนอกเหนือจากที่กล่าวถึงในคู่มือนี้

2. ดึงข้อมูลmadvisingขนาดและความยาว

คำค้นหานี้จะซูมเข้าไปที่madvisingส่วนที่เราเห็นด้านบน

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. รายละเอียดสถานะของเทรดหลัก (ระยะเวลารวมต่อสถานะ)

คําค้นหานี้แสดงระยะเวลาที่เทรดหลักของแอปใช้ในสถานะต่างๆ

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;

คุณสามารถปรับแต่งการค้นหาให้ดูเฉพาะสถานะของเทรดหลักในระหว่างระยะเวลาเริ่มต้นของแอปได้

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;

ซึ่งอาจทำให้เห็นปัญหาที่น่าสนใจบางอย่าง เช่น

  • ใช้เวลาในการเรียกใช้ (R) นานแต่ไม่ได้ทำงาน: แสดงว่าการเริ่มต้นของแอป ล่าช้าเนื่องจากการแย่งชิง CPU กล่าวคือ เธรดหลักของแอป ไม่สามารถทำงานได้เนื่องจากเธรดอื่นๆ (อาจมาจากแอปอื่นๆ) กำลัง ใช้ CPU อยู่
  • ใช้เวลานานในโหมดพักที่ขัดจังหวะได้ (D): โดยปกติแล้วสิ่งนี้บ่งชี้ว่า I/O ช้า หรือหน่วยความจำมีภาระงานสูงซึ่งทำให้การเริ่มต้นแอปหยุดชะงัก
  • ใช้เวลาในการสลีป (S) นาน: แสดงว่าเทรดหลักกำลังรอให้เทรดอื่นๆ ทำงาน บางครั้งสิ่งนี้บ่งบอกถึงการแย่งชิงล็อก ในเส้นทางการเริ่มต้นของแอป (เช่น เทรดหลักถูกบล็อกใน ทรัพยากรเฉพาะที่เทรดอื่นในแอปใช้งานอยู่)
4. หน่วยความจำที่สำรองข้อมูลในไฟล์สูงสุด (ไฟล์ RSS)

เมตริกนี้มีความสัมพันธ์อย่างมากกับปริมาณโค้ดและข้อมูลที่แอปโหลดเมื่อเริ่มต้น แอปที่ "มีขนาดใหญ่" จะมีตัวเลขที่สูงขึ้นที่นี่ ซึ่งทำให้ระบบมีแรงกดดันด้านหน่วยความจำ แรงกดดันดังกล่าวอาจทำให้การเริ่มต้นแอปช้าลง เนื่องจากระบบพยายามตอบสนองคำขอการจัดสรร หรือเปลี่ยนเวลา CPU จากการมุ่งเน้นที่การเริ่มต้นแอปไปเป็นการเรียกคืนหน่วยความจำจากกระบวนการอื่นๆ เพื่อตอบสนองความต้องการเร่งด่วนของแอปที่เริ่มต้น

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;

โหมดการคอมไพล์ ART และหน่วยความจำ

Android Runtime (ART) สามารถคอมไพล์โค้ดของแอปพลิเคชันของคุณในโหมดต่างๆ ได้หลายโหมด หรือที่เรียกว่าตัวกรองคอมไพเลอร์ ตัวกรองคอมไพเลอร์ที่เลือกจะมีผลโดยตรงต่อหน่วยความจำที่ใช้ของแอป

  • verify: ART จะตรวจสอบเฉพาะไบต์โค้ด ไม่มีการคอมไพล์ AOT ระบบจะเรียกใช้โค้ดผ่านตัวแปลหรือคอมไพล์ที่รันไทม์ โดยคอมไพเลอร์ JIT
    • ผลกระทบต่อหน่วยความจำ: ขนาดบนดิสก์เล็กที่สุด ระบบจะส่งการใช้งานหน่วยความจำของโค้ดแบบเนทีฟไปยัง JIT Cache (หน่วยความจำที่มีการแก้ไขแบบไม่ระบุตัวตน)
  • speed: ART จะทำการคอมไพล์ AOT แบบเต็มของทุกเมธอด
    • ผลกระทบต่อหน่วยความจำ: ขนาด .odex ที่ใหญ่ที่สุด เพิ่มการใช้งานหน่วยความจำที่สำรองข้อมูลในไฟล์ (สะอาด)ให้สูงสุด
  • speed-profile: ART จะคอมไพล์เฉพาะเมธอดที่ทำเครื่องหมายเป็น "ฮอต" ในโปรไฟล์ JIT
    • ผลกระทบต่อหน่วยความจำ: แนวทางที่สมดุล ระบบจะคอมไพล์เฉพาะโค้ดที่สำคัญที่สุดด้วย AOT

ตัวกรองที่ใช้กันมากที่สุดคือ speed-profile ซึ่งใช้เมื่อติดตั้งแอปของผู้ใช้ โดยกำหนดค่านี้ได้ในพร็อพเพอร์ตี้ของระบบ pm.dexopt.install และ pm.dexopt.bg-dexopt และมักจะตั้งค่าใน build/make/target/product/runtime_libart.mk

แอปของระบบบางแอปจะใช้การคอมไพล์ speed และจะคอมไพล์ในเวลาบิลด์อิมเมจระบบด้วย verify มักใช้ในกรณีการใช้งานเพื่อการพัฒนาเท่านั้น

กรณีการใช้งาน ตัวกรองคอมไพเลอร์ทั่วไป
การพัฒนา verify
อิมเมจระบบ speed
แอปของผู้ใช้ speed-profile

แบบฝึกหัดภาคปฏิบัติ: โหมดการคอมไพล์และหน่วยความจำ

เราสามารถใช้แอป CodeBloat เพื่อดูว่าฟิลเตอร์เหล่านี้ส่งผลต่อหน่วยความจำอย่างไร หากต้องการ ทำซ้ำการวัดเหล่านี้ ให้ทำดังนี้

  1. บังคับคอมไพล์แอปอีกครั้งเป็นโหมดเป้าหมาย
  2. บังคับให้แอปหยุดทำงานและเริ่มแอปใหม่
  3. รอให้เธรดเบื้องหลังแตะคลาสเสร็จ (ดู logcat หรือ รอ 5 วินาที)
  4. วิ่ง adb shell dumpsys meminfo com.android.codebloat

โหมด: verify (ไม่มี 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

ในโหมด verify สรุปแอปจะแสดงข้อมูลต่อไปนี้ * PSS ของโค้ด: ~8,000 KB * Dalvik อื่นๆ (JIT): ~25,000 KB

เนื่องจากไม่มีการคอมไพล์โค้ด AOT รันไทม์จึงต้องคอมไพล์ JIT เมธอดที่ใช้บ่อยลงในแคช JIT ซึ่งจะปรากฏเป็นหน่วยความจำที่ไม่ระบุชื่อที่สกปรก (Dalvik Other)

โหมด: speed (AOT แบบเต็ม)

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

ในโหมด speed ผลลัพธ์จะเปลี่ยนแปลงอย่างมาก ดังนี้ * Code PSS: ~24,000 KB * Dalvik Other (JIT): ~5,000 KB

ตอนนี้ระบบจะแมปโค้ดของแอปพลิเคชันจากไฟล์ .odex เป็นหน่วยความจำที่สำรองข้อมูลในไฟล์ ที่สะอาด ซึ่งจะช่วยลดแรงกดดันในแคช JIT และทำให้หน่วยความจำมีสิทธิ์ถูกนำออกภายใต้แรงกดดัน แทนที่จะ "ติดอยู่" เป็น RAM ที่มีการแก้ไข

โหมด: speed-profile (AOT แบบเลือก)

แอปที่ทันสมัยอาจรวมโปรไฟล์พื้นฐานของ baseline.prof ART ใช้ข้อมูลนี้เพื่อ คอมไพล์เฉพาะโค้ดที่จำเป็นสำหรับการเริ่มต้นระบบที่รวดเร็วและประหยัดหน่วยความจำ

ในแบบฝึกหัดนี้ เราจะสร้างโปรไฟล์พื้นฐานเพื่อแสดงรายการคลาสเริ่มต้นของแอป อย่างไรก็ตาม ในความเป็นจริงแล้ว คอมไพเลอร์อาจได้รับโปรไฟล์จากแหล่งที่มาภายนอก เช่น จากแอปพลิเคชันสโตร์ ("โปรไฟล์ในระบบคลาวด์") ซึ่งสามารถให้โปรไฟล์ JIT ที่รวบรวมจากมวลชนสำหรับแอปได้ ไม่ว่านักพัฒนาแอปจะรวมโปรไฟล์พื้นฐานที่สร้างขึ้นมาด้วยหรือไม่ก็ตาม

การสร้างและการใช้โปรไฟล์ในอุปกรณ์

หากต้องการดูผลลัพธ์ของ speed-profile คุณสามารถสร้างโปรไฟล์ของคุณเองในอุปกรณ์ได้โดยทำดังนี้

  1. รีเซ็ตและเริ่ม:

    adb shell am force-stop com.android.codebloat
    
  2. โต้ตอบ: เริ่มแอปและปล่อยให้แอปเรียกใช้ลำดับการเริ่มต้น

  3. โปรไฟล์ดัมพ์:

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

    (การดำเนินการนี้จะบังคับให้แอปเขียนโปรไฟล์ปัจจุบันลงในดิสก์)

  4. ติดตั้งโปรไฟล์

    adb shell cp /data/misc/profiles/cur/0/com.android.codebloat/primary.prof \
    /data/misc/profiles/ref/com.android.codebloat/primary.prof
    
  5. คอมไพล์:

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

เมื่อเปิดตัวอีกครั้ง คุณจะเห็นว่า Code PSS จะต่ำกว่า speed (เช่น ~16,000 KB) เนื่องจากมีการคอมไพล์เฉพาะวิธีการเริ่มต้นที่ "ร้อน" ส่วนที่เหลือจะได้รับการจัดการโดยอินเทอร์พรีเตอร์หรือ JIT ก็ต่อเมื่อมีการใช้งานจริง เท่านั้น

ดู

เจาะลึกโค้ดที่คอมไพล์แล้ว

หากต้องการดูว่า ART สร้างคำสั่งใดบ้าง โปรดดู art/DISASSEMBLY_GUIDE.md

โดยจะให้คำแนะนำโดยละเอียดเกี่ยวกับการใช้สิ่งต่อไปนี้

  • oatdump: หากต้องการดูวิธีการ ARM64 ภายในไฟล์ .odex ที่มีอยู่
  • dex2oat: เพื่อจำลองการคอมไพล์ด้วยแฟล็กการแก้ไขข้อบกพร่องแบบละเอียด

แบบฝึกหัด: การแทรกโค้ด

สาเหตุหนึ่งที่ทำให้โค้ดที่คอมไพล์แล้วมีขนาดเพิ่มขึ้นโดยไม่คาดคิดคือการแทรกเมธอด คอมไพเลอร์อาจตัดสินใจคัดลอกเนื้อหาของเมธอดขนาดเล็กที่เรียกใช้บ่อย ลงในผู้เรียกใช้โดยตรง

ในCodeBloatแอปของเรา เมธอด doSomething() ในทุกคลาสที่สร้างขึ้น จะเรียกใช้ method0() โดยตรง เมื่อคอมไพล์ในโหมด speed คอมไพเลอร์การเพิ่มประสิทธิภาพของ ART มีแนวโน้มที่จะแทรก method0() ลงใน doSomething()

แบบฝึกหัด: ยืนยันโดยใช้ oatdump ในอุปกรณ์

# 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

มองหาdoSomethingเมธอดในเอาต์พุต หากมีการแทรกไว้ คุณจะเห็น วิธีการโหลดค่าคงที่สตริงยาวภายใน doSomething โดยตรง แทนที่จะเป็นคำสั่ง bl ที่กำหนดเป้าหมายเป็น method0

การแสดงภาพการเพิ่มประสิทธิภาพ (CFG)

หากต้องการดูเวลาที่คอมไพเลอร์ตัดสินใจแทรกเมธอดอย่างแน่นอน คุณสามารถสร้างกราฟการไหลของการควบคุม (CFG) ได้ ซึ่งจะแสดงสถานะของโค้ดในทุกขั้นตอนของ ไปป์ไลน์การเพิ่มประสิทธิภาพ โดยมีการแปลงทุกครั้งใน การแสดงผลระดับกลาง (IR) ของคอมไพเลอร์จนกว่าโค้ดจะลดลงเป็น ISA เป้าหมาย (เช่น ARM64)

  1. เรียกใช้ dex2oat พร้อมด้วยแฟล็กการดัมพ์: ใช้แฟล็ก --verbose-methods เพื่อจำกัดเอาต์พุตให้เฉพาะเมธอดที่ต้องการ มิเช่นนั้นไฟล์ .cfg สำหรับแอปขนาดใหญ่อาจมีขนาดเพิ่มขึ้นเป็นหลายกิกะไบต์

    # 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. ดึงและดู: ดึงไฟล์ .cfg ไปยังเวิร์กสเตชันแล้วเปิดด้วย IR Hydra

  3. ค้นหา Inliner: ใน IR Hydra ให้โหลดอาร์ติแฟกต์การคอมไพล์และค้นหา doSomething เปรียบเทียบการแสดงก่อนและหลังการส่งผ่าน Inliner คุณจะเห็นกราฟขยายออกเมื่อมีการผสานรวมคำสั่งจาก method0เข้ากับผู้โทร

หรือจะใช้เครื่องมือ Opt Pipeline ใน Compiler Explorer (ตามที่อธิบายไว้ในส่วนด้านบน) แล้วป้อนโค้ดที่คล้ายกันเพื่อดูการแปลงที่คล้ายกันซึ่งดำเนินการในพาส Inliner ก็ได้

แบบฝึกหัด: ฟิลด์ที่เปลี่ยนแปลงได้และตัวกั้นหน่วยความจำ

ในแอป MemoryLab ระบบจะทำเครื่องหมายช่อง mGarbageSink เป็น volatile ซึ่งจะช่วยให้มั่นใจได้ว่าคอมไพเลอร์จะไม่เพิ่มประสิทธิภาพการจัดสรรขยะของเรา

public volatile byte[] mGarbageSink;

ในการแยกชิ้นส่วน ARM64 คุณจะเห็นว่าการจัดเก็บทุกครั้งในฟิลด์นี้จะ มาพร้อมกับMemory Barrier (dmb ish) หรือใช้ คำสั่ง Load-Acquire/Store-Release (ldar/stlr) ซึ่งจะช่วยให้มั่นใจได้ว่าเธรด จะมองเห็นได้ แต่จะเพิ่มคำสั่งพิเศษ 2-3 รายการในการเข้าถึงทุกครั้ง ซึ่งจะเพิ่ม ขนาดโค้ดเล็กน้อยเมื่อเทียบกับฟิลด์ปกติ

แบบฝึกหัด: ค้นหาการเข้าถึงฟิลด์และ Barrier หน่วยความจำที่เชื่อมโยงในการ แยกชิ้นส่วน

แบบฝึกหัด: การตรวจสอบการระงับโดยนัย

หากแยกชิ้นส่วนลูป เช่น ลูปใน generateAllocationChurn คุณจะเห็น คำสั่งที่น่าสนใจที่ส่วนท้ายของเนื้อหาลูป

ldr x21, [x21]

นี่คือการตรวจสอบการระงับโดยนัย ART ใช้สิ่งนี้เพื่อให้ Garbage Collector หยุดเธรดชั่วคราวได้อย่างปลอดภัย โดยปกติแล้วรีจิสเตอร์ x21 จะชี้ไปยังตัวมันเอง เมื่อ GC ต้องระงับเธรด ก็จะ "ทำลาย" ตำแหน่งหน่วยความจำนั้น ในครั้งถัดไปที่เธรดดำเนินการ ldr ระบบจะทริกเกอร์ข้อผิดพลาด ซึ่งรันไทม์จะตรวจจับและใช้เพื่อเปลี่ยนเธรดเป็นสถานะระงับ

รูปแบบนี้จะซ้ำในทุกๆ ลูปและที่จุดเริ่มต้นของทุกๆ เมธอด ซึ่งส่งผลต่อขนาดโค้ดทั้งหมดของแอปพลิเคชัน

แบบฝึกหัด: ค้นหาการตรวจสอบการระงับโดยนัยทั้งหมดในการแยกชิ้นส่วนเมธอด และพยายามเชื่อมโยงการตรวจสอบเหล่านั้นกับซอร์สโค้ดเดิม


← WebView | ↑ ขึ้น | Threads →