[譯]記憶體洩露的八種花樣

Geedio發表於2017-11-13

這是很久以前釋出在簡書平臺上的一篇有關記憶體洩漏的譯文。
這篇文章提及的8種記憶體洩漏的場景,現在來看依舊很經典。為了避免記憶體洩漏,開發過程中需要謹慎謹慎再謹慎。同時,保持良好的開發習慣也至關重要。

具有垃圾回收特性的語言(如Java)的優點在於,它使得開發者不需要顯式的對記憶體的分配和回收進行管理。這個特性降低引發段錯誤引發應用崩潰的風險,避免沒有釋放的記憶體長期佔據堆記憶體,從而編寫出更加安全的程式碼。可惜這並不是銀彈,在Java裡還是有其他方式導致記憶體洩露,這意味著我們的Android App依然存在浪費不必要的記憶體,最終由於記憶體不足(OOM)導致Crash的可能性。原文連結

傳統的記憶體洩露方式是:在所有相關的引用離開作用域後,沒有釋放之前申請的記憶體空間。邏輯上的記憶體洩露,是沒有釋放不再需要的物件的引用的結果。如果一個物件的強引用依然存在,垃圾回收器就不能把這個物件從記憶體裡回收。在Android開發裡,Context上下文的洩露就通常就屬於這種洩露。因為Context物件如Activity通常引用了一大堆記憶體,如View的層級和其他資源。如果洩露了Context物件,通常意味著它所引用的所有物件也跟著洩露。Android應用執行在記憶體受限的裝置上,如果有多處地方洩露的話,應用很容易耗光所有的可用記憶體。

如果物件沒有明確的生命週期,那麼檢測邏輯上的記憶體洩露更像是一個主觀的問題。幸運的是,Activity擁有明確定義的生命週期,因此我們能明確的知道一個Activity例項是否已經洩露。Activity的onDestroy()方法在Activity的生命週期的最後被呼叫,意味著它在程式設計意圖上或Android系統排程上需要進行一些記憶體的回收。如果這個方法呼叫完畢後,Activity例項依舊能從堆的根通過強引用鏈被訪問到,垃圾回收器也就無法將它標記為可從記憶體回收——儘管從原本的意圖是將它從記憶體中刪除。因此,我們可以將一個在生命週期結束後依舊存在的Activity物件標記為被洩露。

Activity是一個很重的物件,因此你不應該選擇干預Android框架對它們的排程處理。然而,依舊有方法不經意的導致Activity洩露。在Android上,所有導致記憶體洩露的陷阱都離不開兩個基礎場景。第一個記憶體洩露的類別是程式級別的全域性共享靜態變數,它們的存在狀態不取決於應用的狀態,同時還持有指向Activity的引用鏈。另一個記憶體洩露類別是因為執行緒的執行時間比Activity的生命週期還長,忽視了清除一個指向Activity的強引用鏈。下面我們來看下幾種可能會遇到的記憶體洩露的情況。

1. 靜態Activity

最容易洩露Activity的方式莫過於定義一個類,類的內部通過靜態變數的方式持有Activity,然後在執行中,將Activity例項賦值給這個變數。如果這個靜態變數的引用在Activity的生命週期結束前沒有置空的話,Activity例項就洩露了。因為被靜態變數持有的物件,它將會被保持在記憶體中,在App的執行過程中一直存在。如果有一個靜態變數持有了Activity的引用,那麼這個Activity就無法被垃圾回收器回收。完整程式碼

void setStaticActivity() {
  activity = this;
}

View saButton = findViewById(R.id.sa_button);
saButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
    setStaticActivity();
    nextActivity();
  }
});複製程式碼

Activity記憶體洩露
Activity記憶體洩露

2. 靜態View

另一個類似的場景:如果一個Activity需要經常被訪問,那麼我們可能會選擇使用單例模式,保持一個例項在記憶體裡,以便它可以被快速的使用到。然而,若前所述,干預Activity的生命週期並將它保持在記憶體裡是一件很危險也沒有必要的事情,應該儘可能的避免這麼做。

但如果我們有一個View物件,需要花費很大的代價去建立它,而它在Activity的不同的生命週期裡保持不變,那麼我們能不能把在這個例項存在靜態變數裡,再講他附加到View的層級結構裡去?讓我們來看下。完整程式碼當我們的Activity被回收的時候,大部分的記憶體可以被回收。

void setStaticView() {
  view = findViewById(R.id.sv_button);
}

View svButton = findViewById(R.id.sv_button);
svButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
    setStaticView();
    nextActivity();
  }
});複製程式碼

靜態View記憶體洩露
靜態View記憶體洩露

等下!看到沒。你知道一個attach了的view內部會持有一個指向Context的引用,換句話說,那就是我們的Activity。通過吧一個View設為靜態變數,我們建立了一個能長期持有Activity的引用鏈,導致Activity被洩露了。千萬不要把attach的view設為靜態變數,如果實在必須這麼做,至少保證在Activity的生命週期結束前把它從View的層級結構裡detach)掉。

3. 內部類

除了這,讓我們在我們的Activity類裡在定義一個類,也就是內部類。為了提高程式碼的可讀性和健壯性,封裝程式邏輯,我們可能會這麼做。如果我們建立了一個這樣的內部類的例項,並通過靜態變數持有了它,會怎樣呢?你應該能猜到這又是一個記憶體洩露的點。

void createInnerClass() {
    class InnerClass {
    }
    inner = new InnerClass();
}

View icButton = findViewById(R.id.ic_button);
icButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        createInnerClass();
        nextActivity();
    }
});複製程式碼

內部類導致的記憶體洩露
內部類導致的記憶體洩露

不幸的是,由於內部類可以直接訪問到它的外部類的變數,這個特性意味著內部類會隱式的持有一個對它的外部類的引用,這間接導致了我們不小心又洩露了Activity。

4. 匿名類

同樣的,匿名類也持有一個指向它申明的地方所在的類的引用。如果你在Activity內定義和例項化一個AsyncTask匿名類,那也可能發生記憶體洩露

void startAsyncTask() {
    new AsyncTask<Void, Void, Void>() {
        @Override protected Void doInBackground(Void... params) {
            while(true);
        }
    }.execute();
}

super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
View aicButton = findViewById(R.id.at_button);
aicButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        startAsyncTask();
        nextActivity();
    }
});複製程式碼

AsyncTask的記憶體洩露
AsyncTask的記憶體洩露

5. Handler

同樣的原則也適用於後臺任務:定義一個匿名的Runnable,然後將它加入Handler的處理佇列裡。這個Runnable物件會隱含的持有一個指向它定義的時候所在的Activity的引用,然後它會作為一個訊息物件加入到Handler的訊息佇列裡去。在Activity生命週期結束之後,只要這個訊息還沒被Activity處理,那就有一條引用鏈指向我們的Activity物件,使得Activity物件無法被回收,進而洩露。

void createHandler() {
    new Handler() {
        @Override public void handleMessage(Message message) {
            super.handleMessage(message);
        }
    }.postDelayed(new Runnable() {
        @Override public void run() {
            while(true);
        }
    }, Long.MAX_VALUE >> 1);
}


View hButton = findViewById(R.id.h_button);
hButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        createHandler();
        nextActivity();
    }
});複製程式碼

Handler導致的記憶體洩露
Handler導致的記憶體洩露

6. 執行緒

類似的問題我們可以在執行緒定時任務(TimerTask)裡發現。

void spawnThread() {
    new Thread() {
        @Override public void run() {
            while(true);
        }
    }.start();
}

View tButton = findViewById(R.id.t_button);
tButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
      spawnThread();
      nextActivity();
  }
});複製程式碼

執行緒使用不當導致記憶體洩露
執行緒使用不當導致記憶體洩露

7. 定時任務

只要它們是通過匿名類的方式定義和例項化的,即便是工作在另外的執行緒,依舊會在Activity被destroy之後,存在一條指向Activity的引用鏈,導致Activity洩露。

void scheduleTimer() {
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            while(true);
        }
    }, Long.MAX_VALUE >> 1);
}

View ttButton = findViewById(R.id.tt_button);
ttButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        scheduleTimer();
        nextActivity();
    }
});複製程式碼

TimerTask導致的記憶體洩露
TimerTask導致的記憶體洩露

8. 系統服務

最後,還有一些系統服務可以通過上下文Context物件上的getSystemService)方法獲取到。這些服務執行在他們各自的程式裡,協助應用執行某種型別的的後臺任務,或者和裝置的硬體進行互動。如果Context物件需要系統服務內的某個事件發生的時候通知到這個Context,那麼它需要把自身作為一個監聽器註冊給系統服務。系統服務也由此持有了一個對Activity物件的應用。如果我們在Activity的生命週期結束的時候忘了去反註冊這個監聽器,就會發生洩露。

void registerListener() {
       SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
       Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ALL);
       sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_FASTEST);
}

View smButton = findViewById(R.id.sm_button);
smButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        registerListener();
        nextActivity();
    }
});複製程式碼

感測器管理器導致的記憶體洩露
感測器管理器導致的記憶體洩露


我們已經見識到了一系列記憶體洩露,也知道他們是多麼容易不小心就洩露一堆的記憶體。記住,儘管最壞的可能性也就是導致你的應用因為記憶體不足而崩潰,也不一定會一直這樣。但是它會吃掉你應用內的一大部分不必要的記憶體。在這個時候,你的應用將會缺少記憶體來生成別的物件,進而導致垃圾回收器頻繁的執行,以便釋放記憶體給新的物件使用。垃圾回收是一個非常昂貴(耗時)的操作,還會產生使用者可感知的卡頓。因此,需要對可能存在的記憶體洩露保持警惕,並時常對記憶體洩露進行測試。

相關文章