1. 程式人生 > >android 效能之記憶體淺析

android 效能之記憶體淺析

前言

對於C++來說,記憶體洩漏就是new出來的物件沒有delete,俗稱野指標;對於Java來說,就是new出來的Object 放在Heap上無法被GC回收;本文通過android中記憶體分配、記憶體洩漏原因、解決方式以及結合記憶體洩漏例項來講講解記憶體洩露。

App的記憶體分配大小

在Android裡,程式記憶體被分為2部分:native和dalvik,dalvik就是我們普通的java使用記憶體,也就是我們上一篇文章分析堆疊的時候使用的記憶體。我們建立的物件是在這裡面分配的,對於記憶體的限制是 native+dalvik 不能超過最大限制。android程式記憶體一般限制在16M,也有的是24M(早期的Android系統G1,就是隻有16M)。準確的說話是 google原生OS的預設值是16M,但是各個廠家的OS會對這個值進行修改。而heapsize是在manifest中設定了largeHeap=true 之後,可以使用的最大記憶體值

結論就是,設定largeHeap的確可以增加記憶體的申請量。但不是系統有多少記憶體就可以申請多少,而是由dalvik.vm.heapsize限制。你可以在app manifest.xml加 largetHeap=true。

AndroidGC如何回收記憶體

Android的一個應用程式的記憶體洩露對別的應用程式影響不大。為了能夠使得Android應用程式安全且快速的執行,Android的每個應用程式都會使用一個專有的Dalvik虛擬機器例項來執行,它是由Zygote服務程序孵化出來的,也就是說每個應用程式都是在屬於自己的程序中執行的。Android為不同型別的程序分配了不同的記憶體使用上限,如果程式在執行過程中出現了記憶體洩漏的而造成應用程序使用的記憶體超過了這個上限,則會被系統視為記憶體洩漏,從而被kill掉,這使得僅僅自己的程序被kill掉,而不會影響其他程序(如果是system_process等系統程序出問題的話,則會引起系統重啟)。

做應用開發的時候,你需要了解系統的GC(垃圾回收)機制是如何執行的,Android裡面使用有向圖作為遍歷回收記憶體的機制。Java將引用關係考慮為圖的有向邊,有向邊從引用者指向引用物件。執行緒物件可以作為有向圖的起始頂點,該圖就是從起始頂點開始的一棵樹,根頂點可以到達的物件都是有效物件,GC不會回收這些物件。如果某個物件 (連通子圖)與這個根頂點不可達(注意,該圖為有向圖),那麼我們認為這個(這些)物件不再被引用,可以被GC回收。

  因此對於我們已經不需要使用的物件,我們可以把它設定為null,這樣當GC執行的時候,就好遍歷到你這個物件已經沒有引用,會自動把該物件佔用的記憶體回收。我們沒法像C++那樣馬上釋放不需要的記憶體,但是我們可以主動告訴系統,哪些記憶體可以回收了。

Android底層核心是基於Linux的,而Linux裡面相對Window來說,有一點很特別的是,會盡量使用系統記憶體載入一些快取資料或者程序間共享資料。Linux本著不用白不用的原則,會盡量使用系統記憶體,加快我們應用的執行速度。當然,如果我們期待某個需要大記憶體的應用,系統也能馬上釋放出一定的記憶體使用,這是系統內部排程實現。如果我們想獲取程式的記憶體資訊可以通過android monitor直觀的看到也可以通過以下程式碼獲取:

privatevoid displayMemory()  
  {     
      final ActivityManager activityManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);     
      ActivityManager.MemoryInfo info = new ActivityManager.MemoryInfo();    
      activityManager.getMemoryInfo(info);     
      Log.i(tag,"系統剩餘記憶體:"+(info.availMem >> 10)+"k");    
      Log.i(tag,"系統是否處於低記憶體執行:"+info.lowMemory); 
      Log.i(tag,"當系統剩餘記憶體低於"+info.threshold+"時就看成低記憶體執行"); 
  } 

另外通過Debug的getMemoryInfo(Debug.MemoryInfo memoryInfo)可以得到更加詳細的資訊。

下面來介紹下java記憶體的分配策略為下面的記憶體洩露分析埋下伏筆:

記憶體分配情況概述

程式執行時的記憶體分配有三種策略,分別是靜態的,棧式的,和堆式的,對應的,三種儲存策略使用的記憶體空間主要分別是靜態儲存區(也稱方法區)、堆區和棧區。

靜態儲存區(方法區):記憶體在程式編譯的時候就已經分配好,這塊記憶體在程式整個執行期間都存在。它主要存放靜態資料、全域性static資料和常量。

棧區:在執行函式時,函式內區域性變數的儲存單元都可以在棧上建立,函式執行結束時這些儲存單元自動被釋放。棧記憶體分配運算內置於處理器的指令集中,效率很高,但是分配的記憶體容量有限。

堆區:亦稱動態記憶體分配。程式在執行的時候用malloc或new申請任意大小的記憶體,程式設計師自己負責在適當的時候用free或delete釋放記憶體(Java則依賴垃圾回收器)。動態記憶體的生存期可以由我們決定,如果我們不釋放記憶體,程式將在最後才釋放掉動態記憶體。 但是,良好的程式設計習慣是:如果某動態記憶體不再使用,需要將其釋放掉。

堆和棧的區別

在函式中(說明是區域性變數)定義的一些基本型別的變數和物件的引用變數都是在函式的棧記憶體中分配。當在一段程式碼塊中定義一個變數時,java就在棧中為這個變數分配記憶體空間,當超過變數的作用域後,java會自動釋放掉為該變數分配的記憶體空間,該記憶體空間可以立刻被另作他用。

堆記憶體用於存放所有由new建立的物件(內容包括該物件其中的所有成員變數)和陣列。在堆中分配的記憶體,由java虛擬機器自動垃圾回收器來管理。在堆中產生了一個數組或者物件後,還可以在棧中定義一個特殊的變數,這個變數的取值等於陣列或者物件在堆記憶體中的首地址,在棧中的這個特殊的變數就變成了陣列或者物件的引用變數,以後就可以在程式中使用棧記憶體中的引用變數來訪問堆中的陣列或者物件,引用變數相當於為陣列或者物件起的一個別名,或者代號。

堆是不連續的記憶體區域(因為系統是用連結串列來儲存空閒記憶體地址,自然不是連續的),堆大小受限於計算機系統中有效的虛擬記憶體(32bit系統理論上是4G),所以堆的空間比較靈活,比較大。棧是一塊連續的記憶體區域,大小是作業系統預定好的,windows下棧大小是2M(也有是1M,在編譯時確定,VC中可設定)。

對於堆,頻繁的new/delete會造成大量記憶體碎片,使程式效率降低。對於棧,它是先進後出的佇列,進出一一對應,不產生碎片,執行效率穩定高。

所以我們可以得出結論:
1.區域性變數的基本資料型別和引用儲存於棧中,引用的物件實體儲存於堆中。因為它們屬於方法中的變數,生命週期隨方法而結束。

2.成員變數全部儲存與堆中(包括基本資料型別,引用和引用的物件實體),因為它們屬於類,類物件終究是要被new出來使用的。

3.我們所說的記憶體洩露,只針對堆記憶體,他們存放的就是引用指向的物件實體。

記憶體洩露的原因

在Java中,記憶體的分配是由程式完成的,而記憶體的釋放是由垃圾收集器(Garbage Collection,GC)完成的,程式設計師不需要通過呼叫函式來釋放記憶體,但它只能回收無用並且不再被其它物件引用的那些物件所佔用的空間。

Java的記憶體垃圾回收機制是從程式的主要執行物件(如靜態物件/暫存器/棧上指向的堆記憶體物件等)開始檢查引用鏈,當遍歷一遍後得到上述這些無法回收的物件和他們所引用的物件鏈,組成無法回收的物件集合,而其他孤立物件(集)就作為垃圾回收。GC為了能夠正確釋放物件,必須監控每一個物件的執行狀態,包括物件的申請、引用、被引用、賦值等,GC都需要進行監控。監視物件狀態是為了更加準確地、及時地釋放物件,而釋放物件的根本原則就是該物件不再被引用。

在Java中,這些無用的物件都由GC負責回收,因此程式設計師不需要考慮這部分的記憶體洩露。雖然,我們有幾個函式可以訪問GC,例如執行GC的函式System.gc(),但是根據Java語言規範定義,該函式不保證JVM的垃圾收集器一定會執行。因為不同的JVM實現者可能使用不同的演算法管理GC。通常GC的執行緒的優先級別較低。JVM呼叫GC的策略也有很多種,有的是記憶體使用到達一定程度時,GC才開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。

但是我們仍然可以去監聽系統的GC過程,以此來分析我們應用程式當前的記憶體狀態。那麼怎樣才能去監聽系統的GC過程呢?其實非常簡單,系統每進行一次GC操作時,都會在LogCat中列印一條日誌,我們只要去分析這條日誌就可以了,日誌的基本格式如下所示:

.D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>,  <Pause_time> 

一般情況下一共有以下幾種觸發GC操作的原因:

  • GC_CONCURRENT: 當我們應用程式的堆記憶體快要滿的時候,系統會自動觸發GC操作來釋放記憶體。

  • GC_FOR_MALLOC: 當我們的應用程式需要分配更多記憶體,可是現有記憶體已經不足的時候,系統會進行GC操作來釋放記憶體。

  • GC_HPROF_DUMP_HEAP: 當生成HPROF檔案的時候,系統會進行GC操作,關於HPROF檔案我們下面會講到。

  • GC_EXPLICIT: 這種情況就是我們剛才提到過的,主動通知系統去進行GC操作,比如呼叫System.gc()方法來通知系統。或者在DDMS中,通過工具按鈕也是可以顯式地告訴系統進行GC操作的。

第二部分Amount_freed,表示系統通過這次GC操作釋放了多少記憶體;

然後Heap_stats中會顯示當前記憶體的空閒比例以及使用情況(活動物件所佔記憶體 / 當前程式總記憶體);

最後Pause_time表示這次GC操作導致應用程式暫停的時間。

GC過程與物件的引用型別是嚴重相關的,我們來看看Java對引用的分類Strong reference, SoftReference, WeakReference, PhatomReference

防止記憶體的洩露,可以使用軟弱引用

在Android應用的開發中,為了防止記憶體溢位,在處理一些佔用記憶體大而且宣告週期較長的物件時候,可以儘量應用軟引用和弱引用技術。軟/弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收器回收,Java虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。利用這個佇列可以得知被回收的軟/弱引用的物件列表,從而為緩衝器清除已失效的軟/弱引用。假設我們的應用會用到大量的預設圖片,比如應用中有預設的頭像,預設遊戲圖示等等,這些圖片很多地方會用到。

如果每次都去讀取圖片,由於讀取檔案需要硬體操作,速度較慢,會導致效能較低。所以我們考慮將圖片快取起來,需要的時候直接從記憶體中讀取。但是,由於圖片佔用記憶體空間比較大,快取很多圖片需要很多的記憶體,就可能比較容易發生OutOfMemory異常。這時,我們可以考慮使用軟/弱引用技術來避免這個問題發生。

結論:

所以我們得出記憶體洩漏的原因:堆記憶體中的長生命週期的物件持有短生命週期物件的強/軟引用,儘管短生命週期物件已經不再需要,但是因為長生命週期物件持有它的引用而導致不能被回收,這就是Java中記憶體洩露的根本原因。

記憶體洩露的檢測

1.可以用android studio裡的android monitor觀察主要表現為記憶體抖動,可用記憶體慢慢變少

2.通過DDMS的heap Viewer

3.MAT工具它是一個 Eclipse 外掛,它是一個快速、功能豐富的JAVA heap分析工具,它可以幫助我們查詢記憶體洩漏和減少記憶體消耗。外掛的下載地址: www.eclipse.org/mat 使用方法介紹http://www.cnblogs.com/larack/p/6071209.html

4.LeakCanary(使用LeakCanary分析記憶體洩露就簡單多了LeakCanary是Square開源了一個記憶體洩露自動探測神器。這個專案的github倉庫地址:https://github.com/square/leakcanary;)

使用方法在build.gradle中引入依賴。按照正常測試,當有記憶體洩露的時候,應用通過系統通知告訴測試,但值得一提的是,LeakCanary並不是萬能的,有些記憶體洩露是檢測不出來的。

常見的記憶體洩露案例

case 1. 單例造成的記憶體洩露

單例的靜態特性導致其生命週期同應用一樣長。

解決方案:

1.將該屬性的引用方式改為弱引用;

2.如果傳入Context,使用Application;

example:

public class ScrollHelper {
    private static ScrollHelper mInstance;
    public ScrollHelper() {
    }

    public static ScrollHelper getInstance() {
        if (mInstance == null) {
            synchronized (ScrollHelper.class) {
                if (mInstance == null) {
                    mInstance = new ScrollHelper();
}
            }
        }
        return mInstance;
}
}
    /**
     * 被點選的View
     */
private View mScrolledView = null;
    public void setScrolledView(View scrolledView) {
        mScrolledView = scrolledView;
}

solution:

/**
 * 被點選的View
 */
private WeakReference<View> mScrolledView = null;
public void setScrolledView(View scrolledView) {
    mScrolledView = new WeakReference<View>(scrolledView);
}

case 2. InnerClass匿名內部類

在Java中,非靜態內部類 和 匿名類 都會潛在的引用它們所屬的外部類,但是,靜態內部類卻不會。如果這個非靜態內部類例項做了一些耗時的操作,就會造成外圍物件不會被回收,從而導致記憶體洩漏。

1.將內部類變成靜態內部類;

2.如果有強引用Activity中的屬性,則將該屬性的引用方式改為弱引用;

3.在業務允許的情況下,當Activity執行onDestory時,結束這些耗時任務;

public class MainActivity extends AppCompatActivity {
    private static TestResource mResource = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
        if(mResource == null){
            mResource = new TestResource();
}
        //...
}
    class TestResource {
        //...
}
}
因為非靜態內部類預設會持有外部類的引用,而該非靜態內部類又建立了一個靜態的例項,該例項的生命週期和應用的一樣長,這就導致了該靜態例項一直會持有該Activity的引用,導致Activity的記憶體資源不能正常回收。正確的做法為:將該內部類設為靜態內部類或將該內部類抽取出來封裝成一個單例,如果需要使用Context,請按照上面推薦的使用Application 的 Context。

case 3. Activity Context 的不正確使用

在Android應用程式中通常可以使用兩種Context物件:Activity和Application。當類或方法需要Context物件的時候常見的做法是使用第一個作為Context引數。這樣就意味著View物件對整個Activity保持引用,因此也就保持對Activty的所有的引用。

假設一個場景,當應用程式有個比較大的Bitmap型別的圖片,每次旋轉是都重新載入圖片所用的時間較多。為了提高螢幕旋轉是Activity的建立速度,最簡單的方法時將這個Bitmap物件使用Static修飾。 當一個Drawable繫結在View上,實際上這個View物件就會成為這份Drawable的一個Callback成員變數。而靜態變數的生命週期要長於Activity。導致了當旋轉螢幕時,Activity無法被回收,而造成記憶體洩露。

解決方案:

1.使用ApplicationContext代替Activity Context,因為Application會隨著應用程式的存在而存在,而不依賴activity的生命週期;

2.對Context的引用不要超過它本身的生命週期,慎重的對Context使用static關鍵字。Context裡如果有執行緒,一定要在onDestory裡及時關掉。

example:

private static Drawable sBackground;
@Override
protected void onCreate(Bundle state) {
    super.onCreate(state);
TextView label = new TextView(this);
label.setText("Leaks are bad");
    if (sBackground == null) {
        sBackground = getDrawable(R.drawable.large_bitmap);
}
    label.setBackgroundDrawable(sBackground);
setContentView(label);
}
solution:
private static Drawable sBackground;
@Override
protected void onCreate(Bundle state) {
    super.onCreate(state);
TextView label = new TextView(this);
label.setText("Leaks are bad");
    if (sBackground == null) {
        sBackground = getApplicationContext().getDrawable(R.drawable.large_bitmap);
}
    label.setBackgroundDrawable(sBackground);
setContentView(label);
}

case 4. Handler引起的記憶體洩漏

當Handler中有延遲的的任務或是等待執行的任務佇列過長,由於訊息持有對Handler的引用,而Handler又持有對其外部類的潛在引用,這條引用關係會一直保持到訊息得到處理,而導致了Activity無法被垃圾回收器回收,而導致了記憶體洩露。

解決方案:

1.可以把Handler類放在單獨的類檔案中,或者使用靜態內部類便可以避免洩露;

2.如果在handler內部去呼叫所在的Activity,那麼可以在handler內部使用弱引用的方式去指向所在的Activity,使用static+WeakReference的方式斷開Handler和Activity之間存在的引用關係。

3.在onDestory()裡面執行removeCallbacksAndMessages(null);

example:

public class SampleActivity extends Activity {

    private final Handler mLeakyHandler = new Handler() {
        @Override
public void handleMessage(Message msg) {
            // ...
}
    }

    @Override
protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
// Post a message and delay its execution for 10 minutes.
mLeakyHandler.postDelayed(new Runnable() {
            @Override
public void run() { /* ... */ }
        }, 1000 * 60 * 10);
// Go back to the previous Activity.
finish();
}
}

在該 SampleActivity 中聲明瞭一個延遲10分鐘執行的訊息 Message,mLeakyHandler 將其 push 進了訊息佇列 MessageQueue 裡。當該 Activity 被 finish() 掉時,延遲執行任務的 Message 還會繼續存在於主執行緒中,它持有該 Activity 的 Handler 引用,所以此時 finish() 掉的 Activity 就不會被回收了從而造成記憶體洩漏(因 Handler 為非靜態內部類,它會持有外部類的引用,在這裡就是指 SampleActivity)。

修復方法:在 Activity 中避免使用非靜態內部類,比如上面我們將 Handler 宣告為靜態的,則其存活期Activity 的生命週期就無關了。同時通過弱引用的方式引入 Activity,避免直接將 Activity 作為 context 傳進去,見下面程式碼:

solution:

public class SampleActivity extends Activity {

    /**
     * Instances of static inner classes do not hold an implicit
     * reference to their outer class.
     */
private static class MyHandler extends Handler {
        private final WeakReference<SampleActivity> mActivity;
        public MyHandler(SampleActivity activity) {
            mActivity = new WeakReference<SampleActivity>(activity);
}

        @Override
public void handleMessage(Message msg) {
            SampleActivity activity = mActivity.get();
            if (activity != null) {
                // ...
}
        }
    }

    private final MyHandler mHandler = new MyHandler(this);
/**
     * Instances of anonymous classes do not hold an implicit
     * reference to their outer class when they are "static".
     */
private static final Runnable sRunnable = new Runnable() {
        @Override
public void run() { /* ... */ }
    };
@Override
protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
// Post a message and delay its execution for 10 minutes.
mHandler.postDelayed(sRunnable, 1000 * 60 * 10);
// Go back to the previous Activity.
finish();
}
}

case 5. 註冊監聽器的洩漏

系統服務可以通過Context.getSystemService 獲取,它們負責執行某些後臺任務,或者為硬體訪問提供介面。如果Context 物件想要在服務內部的事件發生時被通知,那就需要把自己註冊到服務的監聽器中。然而,這會讓服務持有Activity 的引用,如果在Activity onDestory時沒有釋放掉引用就會記憶體洩漏。

解決方案:

1.使用ApplicationContext代替Activity Context;

2.在Activity執行onDestory時。呼叫反註冊;

case 6. Cursor,Stream沒有close,View沒有recyle(Stream,Bitmap,BraodcastReceiver,ContentObserver,File)

資源性物件比如(Cursor,File檔案等)往往都用了一些緩衝,我們在不使用的時候,應該及時關閉它們,以便它們的緩衝及時回收記憶體。它們的緩衝不僅存在於 java虛擬機器內,還存在於java虛擬機器外。如果我們僅僅是把它的引用設定為null,而不關閉它們,往往會造成記憶體洩漏。因為有些資源性物件,比如SQLiteCursor(在解構函式finalize(),如果我們沒有關閉它,它自己會調close()關閉),如果我們沒有關閉它,系統在回收它時也會關閉它,但是這樣的效率太低了。因此對於資源性物件在不使用的時候,應該呼叫它的close()函式,將其關閉掉,然後才置為null. 在我們的程式退出時一定要確保我們的資源性物件已經關閉。

case 7. 集合中物件沒清理造成的記憶體洩漏

我們通常把一些物件的引用加入到了集合容器(比如ArrayList)中,當我們不需要該物件時,並沒有把它的引用從集合中清理掉,這樣這個集合就會越來越大。如果這個集合是static的話,那情況就更嚴重了。如果這個集合類是全域性性的變數 (比如類中的靜態屬性,全域性性的 map 等即有靜態引用或 final 一直指向它),那麼沒有相應的刪除機制,很可能導致集合所佔用的記憶體只增不減。所以要在退出程式之前,將集合裡的東西clear,然後置為null,再退出程式。

case 8. WebView造成的洩露

當我們不要使用WebView物件時,應該呼叫它的destory()函式來銷燬它,並釋放其佔用的記憶體,否則其佔用的記憶體長期也不能被回收,從而造成記憶體洩露。
解決方案:

為webview開啟另外一個程序,通過AIDL與主執行緒進行通訊,WebView所在的程序可以根據業務的需求選擇合適的時機進行銷燬,從而達到記憶體的完整釋放。關於webview的更多總結可以參考:webView詳解及記憶體洩露

case 9. 構造Adapter時,沒有使用快取的ConvertView

初始時ListView會從Adapter中根據當前的屏幕布局例項化一定數量的View物件,同時ListView會將這些View物件 快取起來。
當向上滾動ListView時,原先位於最上面的List Item的View物件會被回收,然後被用來構造新出現的最下面的List Item。
這個構造過程就是由getView()方法完成的,getView()的第二個形參View ConvertView就是被快取起來的List Item的View物件(初始化時快取中沒有View物件則ConvertView
是null)。

case10. 儘量避免使用static成員變數

如果成員變數被宣告為 static,那我們都知道其生命週期將與整個app程序生命週期一樣。這會導致一系列問題,如果你的app程序設計上是長駐記憶體的,那即使app切到後臺,這部分記憶體也不會被釋放。按照現在手機app記憶體管理機制,佔記憶體較大的後臺程序將優先回收,yi’wei如果此app做過程序互保保活,那會造成app在後臺頻繁重啟。當手機安裝了你參與開發的app以後一夜時間手機被消耗空了電量、流量,你的app不得不被使用者解除安裝或者靜默。
這裡修復的方法是: 不要在類初始時初始化靜態成員。可以考慮lazy初始化。 架構設計上要思考是否真的有必要這樣做,儘量避免。如果架構需要這麼設計,那麼此物件的生命週期你有責任管理起來。

case11.避免override finalize()

1、finalize 方法被執行的時間不確定,不能依賴與它來釋放緊缺的資源。時間不確定的原因是: 虛擬機器呼叫GC的時間不確定 Finalize daemon執行緒被排程到的時間不確定
2、finalize 方法只會被執行一次,即使物件被複活,如果已經執行過了 finalize 方法,再次被 GC 時也不會再執行了,原因是:
含有 finalize 方法的 object 是在 new 的時候由虛擬機器生成了一個 finalize reference 在來引用到該Object的,而在 finalize 方法執行的時候,該 object 所對應的 finalize Reference 會被釋放掉,即使在這個時候把該 object 復活(即用強引用引用住該 object ),再第二次被 GC 的時候由於沒有了 finalize reference 與之對應,所以 finalize 方法不會再執行。
3、含有Finalize方法的object需要至少經過兩輪GC才有可能被釋放。