一、效能優化
- 流暢性優化
- 啟動時間優化—在Application的onCreate的時候,會有很多SDK選擇在這裡進行初始化,在加上自己寫的一些庫也在這裡初始化,這樣主執行緒在初始化的時候將會不堪重負,導致啟動很久白屏,所以在初始化的時候應當進行
- 根據庫進行分步延遲載入
- 多執行緒載入
- 後臺任務載入
- UI優化—UI層級過深,在進行測量和定位的時候將會佔用更多的CPU資源,也會導致渲染週期加長,在Android的渲染機制中,每16ms將會發起一次垂直同步訊號,進行渲染,如果在16ms以內還無法更新到surface,畫面將會顯示上一次的畫面,這樣看起來就會卡頓。解決措施:
- 減少佈局層級
- 使用懶載入標籤ViewStub
- 避免使用include,改成使用merge標籤
- 儘量避免使用複雜的向量動畫和向量圖形,繪製向量圖形的需要佔用cpu資源,也會導致卡頓,複雜的向量圖形可以使用點陣圖,GPU會進行快取。
- 避免大量的IO — 大檔案IO是非常佔用CPU的耗時操作,必要時可以進行分佈,分片的IO操作,對於不需要運算元據庫的資料應當使用檔案儲存,小檔案讀寫比資料庫更快,也避免資料庫冗餘。
- 避免頻繁GC — 避免頻繁大量的建立物件,當記憶體緊張時,會頻繁GC,申請大記憶體的物件,也會有可能觸發GC,GC時會佔用CPU,導致畫面卡頓
- 合理的使用執行緒 — 執行緒的切換是又開銷的,頻繁的切換執行緒是會使用效能降低,應當建立cpu核數相當的執行緒池,合理分配執行緒,和使用協程
- 避免過多的複雜計算 — 作為前端也不應該進行復雜的運算,又不是超算,密集的複雜計算也會佔用更多的cpu資源。
- 穩定性優化
影響App穩定性常見的有兩個場景 Crash 和 ANR,它會導致App異常退出。所以解決App的穩定性應該列為最高優先順序。如何避免異常的發生,可以從這幾個方面入手 - 編碼階段。人非機器,即使是機器也會出錯,所以應該使用更多的工具輔助,在編碼的時候儘量把異常情況排除掉。
- 空指標異常。最常見的異常就是空指標異常的,我建議使用kotlin,有空安全型別。
- 記憶體洩漏,發生記憶體洩漏的主要原因的生命週期不一致的物件相互引用,比如線上程中,handler,靜態單例裡引用了Activity,Activity銷燬後,沒有被釋放。要解決的這個除了改變程式設計習慣,也可以使用一些協同Activity的生命週期的工具類來使用執行緒和handler,在Activity銷燬的時候把Activity的引用釋放,避免不規範的建立執行緒,handler,導致記憶體洩漏。
- OOM。在App中常見的是載入大圖等記憶體的大戶。所以圖片要進行壓縮,讀取的時候不要直接將大圖載入記憶體中,先獲取圖片資訊,在設定壓縮比例inSampleSize在載入,最好使用Glide,Picasso這些優秀的開源庫載入,他們有對圖片快取管理。
- 至於其他的bug,如果時間允許可以編寫單元測試,也可以使用類似Android Lint,Findbugs的工具排查。多人團隊開發的,可以互相審查程式碼,一來可以看出自己沒有察覺的bug,二來也能熟悉他人的程式碼。
- Carsh資訊監控上報。這個很多第三方平臺都有,app的必需品,這裡就不打廣告了。如果要自己寫的話,Java層,除了設定UncaughtExceptionHandler之外,還需要獲取AMS.getProcessesInErrorState,native層的話需要設定sigaction 和使用libunwind這個庫了。
- 包體積的大小的優化
- 只使用一套高解析度的資源圖,使用工具對圖片進行壓縮,圖片使用webp格式。
- 對於so檔案只使用v7a平臺的。當然這是犧牲效能為代價的處理方式。下列內容轉自:https://www.cnblogs.com/yingsong/p/6709322.html
- armeabiv-v7a: 第7代及以上的 ARM 處理器。2011年15月以後的生產的大部分Android裝置都使用它.
- arm64-v8a: 第8代、64位ARM處理器,很少裝置,三星 Galaxy S6是其中之一。
- armeabi: 第5代、第6代的ARM處理器,早期的手機用的比較多。
- x86: 平板、模擬器用得比較多。
- x86_64: 64位的平板。
- 使用7z打包。可以參考微信的AndResGuard
二、記憶體模型
Linux的程式記憶體模型是由使用者空間和核心空間組成。
- 核心空間。在這裡CPU可以訪問任何外圍裝置,比如什麼鍵盤,顯示器,網路卡,當然這些在CPU的眼裡都是一段實體地址。換句話說,在核心空間CPU可以訪問所有的實體地址。這個核心空間是所有程式共享的。
- 使用者空間。在這裡CPU的訪問是受限的,比如作業系統給它分配了2G的空間,它也就只能訪問這2G的地址了。這個是程式獨享的,其他的程式無法訪問這個空間。
在應用程式中,如果直接操作外圍裝置,訪問時也不知道其他程式有沒有在訪問,也不知道哪一段可以用的,大家你爭我搶的,都亂套了,而且也不安全。所以需要一位管理者--作業系統。作業系統將真實的實體地址隔離起來,給每個程式分配一段虛擬地址,通過mmap將真實地址和虛擬地址起來,比如虛擬地址是0x00,那麼它真實的實體地址可能是0x1c。在真實實體地址它可能不是一段連續的地址,但是在虛擬地址是連續的就可以了。
虛擬空間還可以進行細分:
核心空間(程式管理,儲存管理,檔案管理,裝置管理,網路系統等)
----------
棧
FileMapping
堆
BSS
Data
text
複製程式碼
- 核心空間。這裡主要是一些程式管理,儲存管理,檔案管理,裝置管理,網路系統等。由於這部分是所有程式共享的,為了更高效率的通訊,在Android中設計了一塊匿名共享記憶體,只要將資料從使用者空間拷貝到這裡其他程式就可以獲取,這樣就可以實現高效率的程式間通訊。具體可以看看微信的MMKV的原理,Binder也是這個原理。
- 使用者空間
- 棧。這一塊不是很大,主要儲存一些方法的地址,區域性變數表,返回地址等。所以遞迴很容易就StackOverFlow。
- 檔案地址對映塊。這裡記錄了虛擬地址對實際檔案實體地址的對映,包括動態連結庫檔案。記憶體檔案對映的物理儲存器來自一個已經存在於磁碟上的檔案,而且在對該檔案進行操作之前必須首先對檔案進行對映。使用記憶體對映檔案處理儲存於磁碟上的檔案時,將不必再對檔案執行I/O操作,使得記憶體對映檔案在處理大資料量的檔案時能起到相當重要的作用。
- 堆。這個區間是我們要重點關注的,因為它完全由我們程式設計師來控制。native申請的空間為native heap,Java申請的空間則為dalvik heap。在Android系統中,有對Java程式申請堆記憶體空間進行限制,這個閾值在不同手機上不同,比如48MB。超過了這個值就會發生OOM。如果想要突破這個限制,有兩個方法
- 申請大記憶體。android:largeHeap=”true”
- 建立子程式。android:process
三、JVM 記憶體模型
程式由n個執行緒組成,在JVM中,又對程式以執行緒為單位對記憶體進行劃分。
- 棧[私有] :
- Java虛擬機器棧
- 棧幀
- 區域性變數表
- 運算元棧
- 動態連結
- 方法返回地址
- 附加資訊
- Java堆:
- 新生代
- 老年代
- 方法區:
- class資訊:
- 類和介面的全限定名
- 屬性名稱和描述符
- 方法名稱和描述符
- 執行時常量池
在作業系統看來,JVM是一個程式,而Java程式只是執行在程式上的程式,所以JVM需要模擬程式執行的環境。
(圖片來源:csdn-驍兵)
- Java虛擬機器棧。Java棧由很多個棧幀組成,每一個棧幀代表一個方法,而棧幀由區域性變數表,運算元棧,動態連結,返回值地址以及一些附加資訊組成,棧是方法的生存之地,當方法被呼叫的時候:
- 將呼叫方的地址入棧,也就是方法返回地址
- 給方法開闢棧幀,具體這個棧幀的需要多大的空間,在class檔案就可以得到。
- 初始化棧幀空間。
- 將引數壓入區域性變數表。
- 將引數和區域性變數壓入區域性變數表。
- 操作棧和程式計數器工作。
- 執行到方法返回指令,回到呼叫點。
- 區域性變數表。方法的執行其實就是值的存取,運算。所以方法需要以棧為基,在區域性變數表中,以slot為單位,一個蘿蔔一個坑,用來存放int,short,float,boolean,char,byte,引用地址和返回值地址等。long 和 double 這兩個不一樣,一個蘿蔔兩個坑,因為他們是64位的,前面的是32位的。如果時基本資料型別,值儲存在棧中,其他引用型別存在堆中,引用地址則儲存在棧中,比如int[]。至於初始化區域性變數表時需要多少坑位,在方法編譯成class之後就定下來了。為了節省空間,坑位也會複用,比如a變數出了作用域,後面定義的b變數就會複用。
public class Test { public void test(int b, int a) { int x = 6; if (b > 0) { String str = "VeCharm"; } int y = a; int c = y + b; } } ---------------- javac Test.java javap -v Test ---------------- class資訊: Last modified 2019-3-31; size 347 bytes MD5 checksum b0e2fc2ec7a2d576136a693c77213446 Compiled from "Test.java" public class com.vecharm.lychee.sample.api.Test minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: ... { public com.vecharm.lychee.sample.api.Test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 public void test(int, int); descriptor: (II)V flags: ACC_PUBLIC Code: stack=2, locals=6, args_size=3 0: bipush 6 2: istore_3 3: iload_1 4: ifle 11 7: ldc #2 // String VeCharm 9: astore 4 11: iload_2 12: istore 4 14: iload 4 16: iload_1 17: iadd 18: istore 5 20: return LineNumberTable: line 7: 0 line 8: 3 line 9: 7 line 11: 11 line 12: 14 line 13: 20 StackMapTable: number_of_entries = 1 frame_type = 252 /* append */ offset_delta = 11 locals = [ int ] } SourceFile: "Test.java" 複製程式碼
- 看test方法,我們來逐步分析這些JVM指令
- bipush 6。將 6 push操作棧,當int取值-1~5採用iconst指令,取值-128~127採用bipush指令,取值-32768~32767採用sipush指令,取值-2147483648~2147483647採用 ldc 指令。
- istore_3。將6這個值從操作棧彈出,存入區域性變數表3號坑,為啥是3號坑而不是1和2,因為這兩個坑被引數b,和引數a棧了。
- iload_1。將區域性變數表中的1號坑的值push操作棧,1號坑的是b的值。
- ifle 11。將操作棧彈出b的值,ifle這條指令的意思當棧頂int型數值小於等於0時跳轉,跳轉到11偏移地址。
- ldc #2。將int、float或String型常量值從常量池中推送至操作棧棧頂。
- astore 4。將操作棧棧頂的值彈出存入區域性變數表4號坑,istore就是存int值和布林值,fstore就是存float值,astore是存引用地址的。
- iload_2。取出2號坑的值push操作棧。
- istore 4。將操作棧頂的值存入4號坑,4號坑之前str已經用過了,但是出了作用域已經無用,所以可以複用。
- iload 4。取出4號坑的值push操作棧。
- iload_1。將區域性變數表中的1號坑的值push操作棧,現在操作棧有兩個值了,
- iadd。將操作棧的值相加。
- istore 5。將結果存入5號坑。
- invokestatic:呼叫static方法。
- invokespecial:只能呼叫三類方法:<init>方法;final方法;private方法;super.method()。因為這三類方法的呼叫物件在編譯時就可以確定。
- invokevirtual:呼叫虛方法。
- invokeInterface:呼叫介面方法,會在執行時再確定一個實現此介面的物件。
- invokeDynamic:執行動態方法,它允許應用級別的程式碼來確定執行哪一個方法呼叫,先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。
- 方法資訊儲存在方法區的類資訊裡面。
- 執行時常量池
- 字面量
- 欄位符號引用/直接引用
- 方法符號引用/直接引用
- 屬性
- 欄位資料。存放名稱,型別,修飾符,屬性。
- 方法資料。存放名稱,返回型別,引數型別,修飾符,屬性。
- 方法程式碼。
- 簽名和標誌位
- 位元組碼
- 操作棧大小,本地變數表大小,本地變數表
- 行號
- 異常表。
- 開始點
- 終結點
- 異常處理程式碼的位置
- 異常類在常量池的索引
- Classloader。
public class Test {
public void test(int b, int a) {
int x = 6;
if (b > 0) {
String str = "VeCharm";
}
int y = a;
int c = y + b;
}
}
----------------
javac Test.java
javap -v Test
----------------
class資訊:
...
Constant pool:
#1 = Methodref #4.#14 // java/lang/Object."<init>":()V
#2 = String #15 // VeCharm
#3 = Class #16 // com/vecharm/lychee/sample/api/Test
#4 = Class #17 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 test
#10 = Utf8 (II)V
#11 = Utf8 StackMapTable
#12 = Utf8 SourceFile
#13 = Utf8 Test.java
#14 = NameAndType #5:#6 // "<init>":()V
#15 = Utf8 VeCharm
#16 = Utf8 com/vecharm/lychee/sample/api/Test
#17 = Utf8 java/lang/Object
...
SourceFile: "Test.java" 複製程式碼
- 執行時常量池。每一個類都分配一個執行時常量池,用來儲存類的一些資料,按照型別分類。
- 常見的常量池的資料項型別:
CONSTANT_Utf8 UTF-8編碼的Unicode字串 CONSTANT_Integer int型別字面值 CONSTANT_Float float型別字面值 CONSTANT_Long long型別字面值 CONSTANT_Double double型別字面值 CONSTANT_Class 對一個類或介面的符號引用 CONSTANT_String String型別字面值 CONSTANT_Fieldref 對一個欄位的符號引用 CONSTANT_Methodref 對一個類中宣告的方法的符號引用 CONSTANT_InterfaceMethodref 對一個介面中宣告的方法的符號引用 CONSTANT_NameAndType 對一個欄位或方法的部分符號引用 - 編譯後的程式碼。一個Java類被編譯成class程式碼,編譯的時候並不能確定類的地址,只能用符號代替,編譯後的class檔案,在ClassLoad而之後將會被提取分類儲存在方法區,方法區儲存的是類的資訊,堆中儲存的是類的物件,obj.getClass獲取的資訊就是在方法區的。方法區也會溢位,當方法區的資訊超過了閾值也會OOM,比如使用動態代理MethodInterceptor。
看到這想必就已經知道了一個從一個Java檔案到記憶體是如何運作的了。類從載入到虛擬機器記憶體中開始到解除安裝記憶體為止,它的整個生命週期包括:載入,驗證,準備,解析,初始化,使用,和解除安裝7個階段,其中驗證,準備,解析3個部分被稱為連線。
載入,驗證,準備,初始化和解除安裝這5個階段是確定的,類的載入過程是必須按照順序來,而解析階段這個可以在初始化之後開始,這是為了支援執行時繫結(動態繫結)。
- 遇到new,getStatic,putStatic,invokeStatic這4條指令時,如果沒有初始化,則需要先觸發器初始化。
- 反射類的時候,會去常量池查查,如果沒有就會載入,初始化。
- 當初始化一個類的,作為一個它的父類,如果沒有初始化就會先進行初始化。
- 當虛擬機器啟動時,會先初始化使用者指定的主類。
- 使用MethodHandle。
說到底,程式設計就是編的只是資料和指令,來總結一下流程。
- 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
- 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
- 在記憶體中生存一個代表這個類的java.lang.Class物件,作為方法區這個類各種資料的訪問入口。這個物件比較特殊,它存在方法區,不在堆區。並設定載入此class的ClassLoader引用。
- 驗證。驗證程式碼的安全性。這個階段是否嚴謹,直接決定了Java虛擬機器是否能承受惡意程式碼的攻擊。
- 準備。正式為類變數分配記憶體並設定類變數的初始值的階段,這些變數所使用的記憶體都將在方法區進行分配,這時候分配的變數都是靜態變數,不是例項變數,例項變數會在物件例項化時隨著物件一起分配在Java堆中。
- 解析。解析階段時虛擬將常量池內的符號引用替換為直接引用的過程。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定載入到記憶體中,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。
- 初始化。靜態方法使用<cinit>,例項物件使用<init>,物件存進Java堆。
- 尋找main方法執行,之後就是一個方法堆著一個方法的用了。
四、垃圾回收機制
在記憶體模型中,我們需要重點關注的就是Heap。因為它是由我們來控制的,處理不當容易發生OOM。記憶體處理的步驟無非也就三個: 申請,整理,清除。管理記憶體打個比方就和管理賣戲票的,觀眾臺也就幾十個座位,都是寶貴的資源。vip大戶,裡邊走,直接坐貴席。其他的買計時票看,每隔一定時間把到時的人清出去,但經常有人到時賴著不走,隔一段時間催他才走。有時候座滿了,只能把到時的賴皮清出去,不想走可以交錢。有時候人家三五成群的買票,自然要調配一下,清理出一些連座的給客戶對吧。如果是一大幫人來看,更是歡迎,vip裡面請。
在Java的堆模型中劃分為三個區。
- 新生代。這個區域的物件活動頻繁,朝生暮死的。能活下來的物件最終會被轉移到老年代,為了管理這些物件,新生代還進行更細的劃分。
- Eden
- From Survivor
- To Survivor
管理物件的生命週期
生存還是毀滅,是通過這個物件到GC Root的可到達性來決定的。能作為GC Root的物件有四種。
- 虛擬機器棧引用的物件
- 方法區中常量引用的物件
- 方法區中靜態屬性引用的變數
- 本地方法棧中native方法引用的物件
引用型別有四種,強引用,軟引用,弱引用,虛引用。
- 新生代物件的整理--複製整理法。這個區域由於活動頻繁,容易更快的產生記憶體碎片,整理的時候還不能有大動作,所以這裡使用複製法,對cpu停頓小,代價是佔用一定的空間。
- 如果發生Minor GC的時候,將Eden 存活下來的物件複製到 From Survivor ,物件在From Survivor每躲過一次GC 年齡就會+1,達到一定的程度,就會被移動到老年代,否則還沒死的話,就會移動到To Survivor ,如果To Survivor放不下了,這個物件會被移動到老年代。最後清空Eden 和 From Survivor,接著將To 和 From 交換,當To Survivor滿了就會將這些移動到老年代。
- 如何保證新生代物件被老年代引用的時候不被gc?有些新生代物件會被老年代物件引用,然而老年代空間很大,如果每次Minor GC 都掃描一遍老年代,效率將大大降低,所有在老年代會劃分一個小區域來管理卡表,這寫卡表記錄了老年代和新生代的引用,也就是說這些老年代被當成新生代的GC roots。
- 初始化標記。這時候會暫停“全世界(stop-the-world)”,開始進行標記。僅僅標記GC Roots能直接關聯到的物件。
- 併發標記。從GC Roots開始進行可達性分析,找出存活物件,耗時長,就是進行追蹤引用鏈的過程,可與使用者執行緒併發執行。
- 重新標記。修正併發標記階段因使用者執行緒繼續執行而導致標記發生變化的那部分物件的標記記錄。這個階段也會再次暫停所有事件。
- 並行清理。最後執行清理,這個階段也是並行的。
結語,限於篇幅,只是初略的整理了一下大致的流程,參考《深入Java虛擬機器》等。