效能優化系列
我們為什麼要優化記憶體
在 Android 中我們寫的 .java 檔案,最終會編譯成 .class 檔案, class 又由類裝載器載入後,在 JVM 中會形成一份描述 class 結構的元資訊物件,通過該元資訊物件可以知道 class 的結構資訊 (建構函式、屬性、方法)等。JVM 會把描述類的資料從 class 檔案載入到記憶體,Java 有一個很好的管理記憶體的機制,垃圾回收機制 GC 。為什麼 Java 都給我們提供了垃圾回收機制,程式有時還會導致記憶體洩漏,記憶體溢位 OOM,甚至導致程式 Crash 。接下來我們就對實際開發中出現的這些記憶體問題,來進行優化。
JAVA 虛擬機器
我們先來大概瞭解一下 Java 虛擬機器裡面執行時的資料區域有哪些,如果想深入瞭解 Java 虛擬機器 建議可以購買<<深入理解 Java 虛擬機器>> 或者直接點選我這裡的 PDF 版本 密碼: jmnf
執行緒獨佔區
程式計數器
- 相當於一個執行程式碼的指示器,用來確認下一行執行的地址
- 每個執行緒都有一個
- 沒有 OOM 的區
虛擬機器棧
- 我們平時說的棧就是這塊區域
- java 虛擬機器規範中定義了 OutOfMemeory , stackoverflow 異常
本地方法棧
- java 虛擬機器規範中定義了 OutOfMemory ,stackoverflow 異常
注意
- 在 hotspotVM 中把虛擬機器棧和本地方法棧合為了一個棧區
執行緒共享區
方法區
- ClassLoader 載入類資訊
- 常量、靜態變數
- 編譯後的程式碼
- 會出現 OOM
- 執行時常量池
- public static final
- 符號引用類、介面全名、方法名
java 堆 (本次需要優化的地方)
- 虛擬機器能管理的最大的一塊記憶體 GC 主戰場
- 會出現 OOM
- 物件例項
- 資料的內容
JAVA GC 如何確定記憶體回收
隨著程式的執行,記憶體中的例項物件、變數等佔據的記憶體越來越多,如果不及時進行回收,會降低程式執行效率,甚至引發系統異常。
目前虛擬機器基本都是採用可達性分析演算法,為什麼不採用引用計數演算法呢?下面就說說引用計數法是如果統計所有物件的引用計數的,再對比可達性分析演算法是如何解決引用計數演算法的不足。下面就來看下這 2 個演算法:
引用計數演算法
每個物件有一個引用計數器,當物件被引用一次則計數器加一,當物件引用一次失效一次則計數器減一,對於計數器為 0 的時候就意味著是垃圾了,可以被 GC 回收。
下面通過一段程式碼來實際看下
public class GCTest {
private Object instace = null;
public static void onGCtest() {
//step 1
GCTest gcTest1 = new GCTest();
//step 2
GCTest gcTest2 = new GCTest();
//step 3
gcTest1.instace = gcTest2;
//step 4
gcTest2.instace = gcTest1;
//step 5
gcTest1 = null;
//step 6
gcTest2 = null;
}
public static void main(String[] arg) {
onGCtest();
}
}
複製程式碼
分析程式碼
//step 1 gcTest1 引用 + 1 = 1
//step 2 gcTest2 引用 + 1 = 1
//step 3 gcTest1 引用 + 1 = 2
//step 4 gcTest2 引用 + 1 = 2
//step 5 gcTest1 引用 - 1 = 1
//step 6 gcTest2 引用 - 1 = 1
複製程式碼
很明顯現在 2 個物件都不能用了都為 null 了,但是 GC 確不能回收它們,因為它們本身的引用計數不為 0 。不能滿足被回收的條件,儘管呼叫 System.gc() 也還是不能得到回收, 這就造成了 記憶體洩漏 。當然,現在虛擬機器基本上都不採用此方式。
可達性分析演算法
從 GC Roots 作為起點開始搜尋,那麼整個連通圖中額物件邊都是活物件,對於 GC Roots 無法到達的物件便成了垃圾回收的物件,隨時可能被 GC 回收。
可以作為 GC Roots 的物件
- 虛擬機器棧正在執行使用的引用
- 靜態屬性 常量
- JNI 引用的物件
GC 是需要 2 次掃描才回收物件,所以我們可以使用 finalize 去救活丟失的引用
@Override
protected void finalize() throws Throwable {
super.finalize();
instace = this;
}
複製程式碼
到了這裡,相信大家已經能夠弄明白這 2 個演算法的區別了吧?反正對於物件之間迴圈引用的情況,引用計數演算法無法回收這 2 個物件,而可達性是從 GC Roots 開始搜尋,所以能夠正確的回收。
不同引用型別的回收狀態
強引用
Object strongReference = new Object()
複製程式碼
如果一個物件具有強引用,那垃圾回收器絕不會回收它,當記憶體空間不足, Java 虛擬機器寧願丟擲 OOM 錯誤,使程式異常 Crash ,也不會靠隨意回收具有強引用的物件來解決記憶體不足的問題.如果強引用物件不再使用時,需要弱化從而使 GC 能夠回收,需要:
strongReference = null; //等 GC 來回收
複製程式碼
還有一種情況,如果:
public void onStrongReference(){
Object strongReference = new Object()
}
複製程式碼
在 onStrongReference() 內部有一個強引用,這個引用儲存在 java 棧 中,而真正的引用內容 (Object)儲存在 java 堆中。當這個方法執行完成後,就會退出方法棧,則引用物件的引用數為 0 ,這個物件會被回收。
但是如果 mStrongReference 引用是全域性時,就需要在不用這個物件時賦值為 null ,因為 強引用 不會被 GC 回收。
軟引用 (SoftReference)
如果一個物件只具有軟引用,則記憶體空間足夠,垃圾回收器就不會回收它;如果記憶體空間不足了,就會回收這些物件的記憶體,只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。
軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收器回收, java 虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。
注意: 軟引用物件是在 jvm 記憶體不夠的時候才會被回收,我們呼叫 System.gc() 方法只是起通知作用, JVM 什麼時候掃描回收物件是 JVM 自己的狀態決定的。就算掃描到了 str 這個物件也不會回收,只有記憶體不足才會回收。
弱引用 (WeakReference)
弱引用與軟引用的區別在於: 只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過由於垃圾回收器是一個優先順序很低的執行緒,因此不一定會很快發現那些只具有弱引用的物件。
弱引用可以和一個引用佇列聯合使用,如果弱引用所引用的物件被垃圾回收,Java 虛擬機器就會把這個弱引用加入到與之關聯的引用佇列中。
可見 weakReference 物件的生命週期基本由 GC 決定,一旦 GC 執行緒發現了弱引用就標記下來,第二次掃描到就直接回收了。
注意這裡的 referenceQueuee 是裝的被回收的物件。
虛引用 (PhantomReference)
@Test
public void onPhantomReference()throws InterruptedException{
String str = new String("123456");
ReferenceQueue queue = new ReferenceQueue();
// 建立虛引用,要求必須與一個引用佇列關聯
PhantomReference pr = new PhantomReference(str, queue);
System.out.println("PhantomReference:" + pr.get());
System.out.printf("ReferenceQueue:" + queue.poll());
}
複製程式碼
虛引用顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。
虛引用主要用來跟蹤物件被垃圾回收器回收的活動。虛引用與軟引用和弱引用的一個區別在於: 虛引用必須和引用佇列 (ReferenceQueue) 聯合使用。當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。
總結
引用型別 | 呼叫方式 | GC | 是否記憶體洩漏 |
---|---|---|---|
強引用 | 直接呼叫 | 不回收 | 是 |
軟引用 | .get() | 視記憶體情況回收 | 否 |
弱引用 | .get() | 回收 | 不可能 |
虛引用 | null | 任何時候都可能被回收,相當於沒有引用一樣 | 否 |
分析記憶體常用工具
工具很多,掌握原理方法,工具隨意挑選使用。
top/procrank
meinfo
Procstats
DDMS
MAT
Finder - Activity
LeakCanary
LeakInspector
記憶體洩漏
產生的原因: 一個長生命週期的物件持有一個短生命週期物件的引用,通俗點講就是該回收的物件,因為引用問題沒有被回收,最終會產生 OOM。
下面我們來利用 Profile 來檢查專案是否有記憶體洩漏
怎麼利用 profile 來檢視專案中是否有記憶體洩漏
-
在 AS 中專案以 profile 執行
-
在 MEMORY 介面中選擇要分析的一段記憶體,右鍵 export
Allocations: 動態分配物件個數
Deallocation: 解除分配的物件個數
Total count: 物件的總數
Shalow Size: 物件本身佔用的記憶體大小
Retained Size: GC 回收能收走的記憶體大小
-
轉換 profile 檔案格式
-
將 export 匯出的 dprof 檔案轉換為 Mat 的 dprof 檔案
-
cd /d 進入到 Android sdk/platform-tools/hprof-conv.exe
//轉換命令 hprof-conv -z src des D:\Android\AndroidDeveloper-sdk\android-sdk-windows\platform-tools>hprof-conv -z D:\temp_\temp_6.hprof D:\temp_\memory6.hprof 複製程式碼
-
-
開啟 MemoryAnalyzer.exe 點選左上角 File 選單中的 Open Heap Dupm
-
檢視記憶體洩漏中的 GC Roots 強引用
這裡我們得知是一個 ilsLoginListener 引用了 LoginView,我們來看下程式碼最後怎麼解決的。
程式碼中我們找到了 LoginView 這個類,發現是一個單例中的回撥引起的記憶體洩漏,下面怎麼解決勒,請看第七小點。
-
2種解決單例中的記憶體洩漏
-
將引用置為 null
/** * 銷燬監聽 */ public void unRemoveRegisterListener(){ mMessageController.unBindListener(); } public void unBindListener(){ if (listener != null){ listener = null; } } 複製程式碼
-
使用弱引用
//將監聽器放入弱引用中 WeakReference<IBinderServiceListener> listenerWeakReference = new WeakReference<>(listener); //從弱引用中取出回撥 listenerWeakReference.get(); 複製程式碼
-
-
通過第七小點就能完美的解決單例中回撥引起的記憶體洩漏。
Android 中常見的記憶體洩漏經典案例及解決方法
-
單例
示例 :
public class AppManager { private static AppManager sInstance; private CallBack mCallBack; private Context mContext; private AppManager(Context context) { this.mContext = context; } public static AppManager getInstance(Context context) { if (sInstance == null) { sInstance = new AppManager(context); } return sInstance; } public void addCallBack(CallBack call){ mCallBack = call; } } 複製程式碼
-
通過上面的單列,如果 context 傳入的是 Activity , Service 的 this,那麼就會導致記憶體洩漏。
以 Activity 為例,當 Activity 呼叫 getInstance 傳入 this ,那麼 sInstance 就會持有 Activity 的引用,當 Activity 需要關閉的時候需要 回收的時候,發現 sInstance 還持有 沒有用的 Activity 引用,導致 Activity 無法被 GC 回收,就會造成記憶體洩漏
-
addCallBack(CallBack call) 這樣寫看起來是沒有毛病的。但是當這樣呼叫在看一下勒。
//在 Activity 中實現單例的回撥 AppManager.getInstance(getAppcationContext()).addCallBack(new CallBack(){ @Override public void onStart(){ } }); 複製程式碼
這裡的 new CallBack() 匿名內部類 預設持有外部的引用,造成 CallBack 釋放不了,那麼怎麼解決了,請看下面解決方法
解決方法:
-
getInstance(Context context) context 都傳入 Appcation 級別的 Context,或者實在是需要傳入 Activity 的引用就用 WeakReference 這種形式。
-
匿名內部類建議大家單獨寫一個檔案或者
public void addCallBack(CallBack call){ WeakReference<CallBack> mCallBack= new WeakReference<CallBack>(call); } 複製程式碼
-
-
Handler
示例:
//在 Activity 中實現 Handler class MyHandler extends Handler{ private Activity m; public MyHandler(Activity activity){ m=activity; } // class..... } 複製程式碼
這裡的 MyHandler 持有 activity 的引用,當 Activity 銷燬的時候,導致 GC 不會回收造成 記憶體洩漏。
解決方法:
1.使用靜態內部類 + 弱引用 2.在 Activity onDestoty() 中處理 removeCallbacksAndMessages() @Override protected void onDestroy() { super.onDestroy(); if(null != handler){ handler.removeCallbacksAndMessages(null); handler = null; } } 複製程式碼
-
靜態變數
示例:
public class MainActivity extends AppCompatActivity { private static Police sPolice; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (sPolice != null) { sPolice = new Police(this); } } } class Police { public Police(Activity activity) { } } 複製程式碼
這裡 Police 持有 activity 的引用,會造成 activity 得不到釋放,導致記憶體洩漏。
解決方法:
//1. sPolice 在 onDestory()中 sPolice = null; //2. 在 Police 建構函式中 將強引用 to 弱引用; 複製程式碼
-
非靜態內部類
參考 第二點 Handler 的處理方式
-
匿名內部類
示例:
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); new Thread(){ @Override public void run() { super.run(); } }; } } 複製程式碼
很多初學者都會像上面這樣新建執行緒和非同步任務,殊不知這樣的寫法非常地不友好,這種方式新建的子執行緒
Thread
和AsyncTask
都是匿名內部類物件,預設就隱式的持有外部Activity
的引用,導致Activity
記憶體洩露。解決方法:
//靜態內部類 + 弱引用 //單獨寫一個檔案 + onDestory = null; 複製程式碼
-
未取消註冊或回撥
示例:
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); registerReceiver(mReceiver, new IntentFilter()); } private BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { // TODO ------ } }; } 複製程式碼
在註冊觀察則模式的時候,如果不及時取消也會造成記憶體洩露。比如使用
Retrofit + RxJava
註冊網路請求的觀察者回撥,同樣作為匿名內部類持有外部引用,所以需要記得在不用或者銷燬的時候取消註冊。解決方法:
//Activity 中實現 onDestory()反註冊廣播得到釋放 @Override protected void onDestroy() { super.onDestroy(); this.unregisterReceiver(mReceiver); } 複製程式碼
-
定時任務
示例:
public class MainActivity extends AppCompatActivity { /**模擬計數*/ private int mCount = 1; private Timer mTimer; private TimerTask mTimerTask; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); init(); mTimer.schedule(mTimerTask, 1000, 1000); } private void init() { mTimer = new Timer(); mTimerTask = new TimerTask() { @Override public void run() { MainActivity.this.runOnUiThread(new Runnable() { @Override public void run() { addCount(); } }); } }; } private void addCount() { mCount += 1; } } 複製程式碼
當我們
Activity
銷燬的時,有可能Timer
還在繼續等待執行TimerTask
,它持有Activity 的引用不能被 GC 回收,因此當我們 Activity 銷燬的時候要立即cancel
掉Timer
和TimerTask
,以避免發生記憶體洩漏。解決方法:
//當 Activity 關閉的時候,停止一切正在進行中的定時任務,避免造成記憶體洩漏。 private void stopTimer() { if (mTimer != null) { mTimer.cancel(); mTimer = null; } if (mTimerTask != null) { mTimerTask.cancel(); mTimerTask = null; } } @Override protected void onDestroy() { super.onDestroy(); stopTimer(); } 複製程式碼
-
資源未關閉
示例:
ArrayList,HashMap,IO,File,SqLite,Cursor 等資源用完一定要記得 clear remove 等關閉一系列對資源的操作。 複製程式碼
解決方法:
用完即刻銷燬 複製程式碼
-
屬性動畫
示例:
動畫同樣是一個耗時任務,比如在 Activity 中啟動了屬性動畫 (ObjectAnimator) ,但是在銷燬的時候,沒有呼叫 cancle 方法,雖然我們看不到動畫了,但是這個動畫依然會不斷地播放下去,動畫引用所在的控制元件,所在的控制元件引用 Activity ,這就造成 Activity 無法正常釋放。因此同樣要在Activity 銷燬的時候 cancel 掉屬性動畫,避免發生記憶體洩漏。 複製程式碼
解決方法:
@Override protected void onDestroy() { super.onDestroy(); //當關閉 Activity 的時候記得關閉動畫的操作 mAnimator.cancel(); } 複製程式碼
-
Android 原始碼或者第三方 SDK
示例:
//如果在開發除錯中遇見 Android 原始碼或者 第三方 SDK 持有了我們當前的 Activity 或者其它類,那麼現在怎麼辦了。 複製程式碼
解決方法:
//當前是通過 Java 中的反射找到某個類或者成員,來進行手動 = null 的操作。 複製程式碼
記憶體抖動
什麼是記憶體抖動
記憶體頻繁的分配與回收,(分配速度大於回收速度時) 最終產生 OOM 。
也許下面的錄屏更能解釋什麼是記憶體抖動
可以看出當我點選了一下 Button 記憶體就頻繁的建立並回收(注意看垃圾桶)。
那麼我們找出程式碼中具體那一塊出現問題了勒,請看下面一段錄屏
mButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
imPrettySureSortingIsFree();
}
});
/**
* 排序後列印二維陣列,一行行列印
*/
public void imPrettySureSortingIsFree() {
int dimension = 300;
int[][] lotsOfInts = new int[dimension][dimension];
Random randomGenerator = new Random();
for (int i = 0; i < lotsOfInts.length; i++) {
for (int j = 0; j < lotsOfInts[i].length; j++) {
lotsOfInts[i][j] = randomGenerator.nextInt();
}
}
for (int i = 0; i < lotsOfInts.length; i++) {
String rowAsStr = "";
//排序
int[] sorted = getSorted(lotsOfInts[i]);
//拼接列印
for (int j = 0; j < lotsOfInts[i].length; j++) {
rowAsStr += sorted[j];
if (j < (lotsOfInts[i].length - 1)) {
rowAsStr += ", ";
}
}
Log.i("ricky", "Row " + i + ": " + rowAsStr);
}
}
複製程式碼
最後我們之後是 onClick 中的 imPrettySureSortingIsFree() 函式裡面的 rowAsStr += sorted[j]; 字串拼接造成的 記憶體抖動 ,因為每次拼接一個 String 都會申請一塊新的堆記憶體,那麼怎麼解決這個頻繁開闢記憶體的問題了。其實在 Java 中有 2 個更好的 API 對 String 的操作很友好,相信應該有人猜到了吧。沒錯就是將 此處的 String 換成 StringBuffer 或者 StringBuilder,就能很完美的解決字串拼接造成的記憶體抖動問題。
修改後
/**
* 列印二維陣列,一行行列印
*/
public void imPrettySureSortingIsFree() {
int dimension = 300;
int[][] lotsOfInts = new int[dimension][dimension];
Random randomGenerator = new Random();
for(int i = 0; i < lotsOfInts.length; i++) {
for (int j = 0; j < lotsOfInts[i].length; j++) {
lotsOfInts[i][j] = randomGenerator.nextInt();
}
}
// 使用StringBuilder完成輸出,我們只需要建立一個字串即可, 不需要浪費過多的記憶體
StringBuilder sb = new StringBuilder();
String rowAsStr = "";
for(int i = 0; i < lotsOfInts.length; i++) {
// 清除上一行
sb.delete(0, rowAsStr.length());
//排序
int[] sorted = getSorted(lotsOfInts[i]);
//拼接列印
for (int j = 0; j < lotsOfInts[i].length; j++) {
sb.append(sorted[j]);
if(j < (lotsOfInts[i].length - 1)){
sb.append(", ");
}
}
rowAsStr = sb.toString();
Log.i("jason", "Row " + i + ": " + rowAsStr);
}
}
複製程式碼
這裡可以看見沒有垃圾桶出現,說明記憶體抖動解決了。
注意: 實際開發中如果在 LogCat 中發現有這些 Log 說明也發生了 記憶體抖動 (Log 中出現 concurrent copying GC freed ....)
回收演算法
ps:我覺得這個只是為了應付面試,那麼可以參考這裡,我也只瞭解概念這裡就不用在多寫了,點選看這個帖子吧
標記清除演算法 Mark-Sweep
複製演算法 Copying
標記壓縮演算法 Mark-Compact
分代收集演算法
總結 (只要養成這樣的習慣,至少可以避免 90 % 以上不會造成記憶體異常)
-
資料型別: 不要使用比需求更佔用空間的基本資料型別
-
迴圈儘量用 foreach ,少用 iterator, 自動裝箱也儘量少用
-
資料結構與演算法的解度處理 (陣列,連結串列,棧樹,樹,圖)
- 資料量千級以內可以使用 Sparse 陣列 (Key為整數),ArrayMap (Key 為物件) 雖然效能不如 HashMap ,但節約記憶體。
-
列舉優化
缺點:
- 每一個列舉值都是一個單例物件,在使用它時會增加額外的記憶體消耗,所以列舉相比與 Integer 和 String 會佔用更多的記憶體
- 較多的使用 Enum 會增加 DEX 檔案的大小,會造成執行時更多的 IO 開銷,使我們的應用需要更多的空間
- 特別是分 Dex 多的大型 APP,列舉的初始化很容易導致 ANR
優化後的程式碼:可以直接限定傳入的引數個數
public class SHAPE { public static final int TYPE_0=0; public static final int TYPE_1=1; public static final int TYPE_2=2; public static final int TYPE_3=3; @IntDef(flag=true,value={TYPE_0,TYPE_1,TYPE_2,TYPE_3}) @Target({ElementType.PARAMETER,ElementType.METHOD,ElementType.FIELD}) @Retention(RetentionPolicy.SOURCE) public @interface Model{ } private @Model int value=TYPE_0; public void setShape(@Model int value){ this.value=value; } @Model public int getShape(){ return this.value; } } 複製程式碼
-
static , static final 的問題
- static 會由編譯器呼叫 clinit 方法進行初始化
- static final 不需要進行初始化工作,打包在 dex 檔案中可以直接呼叫,並不會在類初始化申請記憶體
基本資料型別的成員,可以全寫成 static final
-
字串的拼接儘量少用 +=
-
重複申請記憶體問題
- 同一個方法多次呼叫,如遞迴函式 ,回撥函式中 new 物件
- 不要在 onMeause() onLayout() ,onDraw() 中去重新整理UI(requestLayout)
-
避免 GC 回收將來要重新使用的物件 (記憶體設計模式物件池 + LRU 演算法)
-
Activity 元件洩漏
- 非業務需要不要把 activity 的上下文做引數傳遞,可以傳遞 application 的上下文
- 非靜態內部類和匿名內部內會持有 activity 引用(靜態內部類 或者 單獨寫檔案)
- 單例模式中回撥持有 activity 引用(弱引用)
- handler.postDelayed() 問題
- 如果開啟的執行緒需要傳入引數,用弱引接收可解決問題
- handler 記得清除 removeCallbacksAndMessages(null)
-
Service 耗時操作儘量使用 IntentService,而不是 Service
最後思維導圖做一個總結: