從Java的視角看閉包以及內存泄漏
本文轉載自微信公眾號「咸魚正翻身」,作者MDove。轉載本文請聯系咸魚正翻身公眾號。
前言
主要聊幾個點:
- 什么是閉包,為什么有的語言無時無刻都在提閉包這個概念(比如:JS)?
- Java中有沒有閉包?
- 內存泄漏
正文
無論上是Java還是Kotlin咱們基本都沒聽說過閉包這個概念的存在。但是如果我們去了解閉包解決的問題,咱們就會明白閉包:這不就是匿名內部類會持有外部對象的引用嗎?
一、閉包
兩段類似的代碼,先看一段Kotlin代碼:
- val arr = arrayListOf<() -> Unit>()
- for (index in 0..10) {
- arr.add(object : () -> Unit {
- override fun invoke() {
- print(index)
- }
- })
- }
- arr[6].invoke()
輸出結果6,沒什么異議。但是,有趣的來了,這段代碼在JS里:
- var arr=[]
- for(var i = 0; i<10; i++){
- arr[i] = function(){
- console.log(i)
- }
- }
- arr
- arr[6]()
這里運行是10。(據我前端的同學說,這是一道必考的前端面試題??)
為了方便代碼理解,這里針對上述JS代碼展開兩個JS的規則:
變量提升:
for(var i = 0; i<10; i++)里邊的i會進行一個叫做“變量提升”的操作,上述代碼實際是這樣:
- var i
- for(i = 0; i<10; i++){}
作用域:
函數體里的console.log(i)為什么能引用到i,是因為JS是按作用域查找變量,如果當前作用域沒有這個變量就會向父級查找,以此類推。
有了上邊兩個點,大家應該就能get到為啥arr6的時候,通過父作用域找到了i,而此時的i = 10。
那么問題來了,JS里邊怎么讓console.log(i)打印6?答案是:閉包。
- var arr=[]
- for(var i = 0; i<10;i++){
- (function(i){
- arr[i]=function(){
- console.log(i)
- }
- })(i)
- }
- arr[6]()
簡單看一下代碼發生了什么改動?用一個有一個參數的函數包了一下。每次for循環的時候都調用這個函數并傳遞一個當前的i進去。
此后對于console.log(i)來說,父級作用域就是包裹的那個函數,而找到的i也就是正確的i。
這就是JS的閉包。咱們再回憶一下Java是不是也是類似的處理方式?
做法出奇的相似,這里用了一個名為TestKt$main$1的類包裹了我們的Function。并且構造函數里接收我們需要的i。
所以無論上閉包,還是持有外部對象引用。本質想要解決的問題都是:正確的變量引用。這里還有一個題外話:匿名內部類持有外部引用的時候,為啥要加final?
這里了解了二者的實現原理,咱們再來聊一聊二者都會遇到的潛在問題:內存泄漏。
二、內存泄漏
出現內存泄漏的原因也很簡單:
- 函數內要使用外部變量,那么勢必要持有外部變量
- 而函數的執行時機有可能在外部變量生命周期外執行
- 為了保證2步驟的正常,那么原本應該被回收的外部變量就不能被回收了,因為函數還在引用。所以外部變量就內存泄漏了
我們來看一個比較常見的代碼,在一個UI組件里delay一段時間,然后再拿到這個組件里的某個View做delay之后的事情:
- class TestActivity : Activity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_fragment_container)
- window.decorView.postDelayed({
- Log.d("TEST", findViewById<FrameLayout>(R.id.container).toString())
- }, 3000)
- }
- }
這段代碼至少存在兩個相關的問題:
- 3秒內退出這個Activity,在第3秒時會出現空指針異常。
- TestActivity這個實例會被泄漏3秒鐘。
這倆個問題的原因都很直接:因為postDelayed的代碼塊需要調用findViewById,所以隱式的持有了TestActivity實例。而Activity走完onDestroy()內部的View已經被remove了。所以postDelayed的代碼塊雖然能拿到Activity但是已經find不到View了。
由上述的代碼,咱們來客觀的思考內存泄漏:
客觀的看待內存泄漏
個人觀點:內存泄漏不是洪水猛獸。因為我們日常中很多優化手段的本質都會產生內存泄漏。
- 單例的緩存池
很多時候,內存泄漏并不會產生太大的影響,畢竟大家都沒有刻意的針對內存泄漏的場景進行優化過。原因也很簡單:我們一般泄漏的內存都很小。
但也有例外,我猜大家多少都聽說過一個原則:需要傳遞Context的時候優先傳Application的Context。
很多時候Context的背后是Activity/Fragment等UI組件,這些組件相對來說內存占用相對比較大。比如ImageView,ImageView本身不大,但是它會強引用Bitmap這種極大內存的對象。
如果我們Activity/Fragment中碰巧又強引用這種大內存的對象(比如:ImageView)。此Context一旦泄露就是毀滅級的。
因此一些ImageView為了兜底內存泄漏問題,有如下的優化方案。
- override fun onDetachedFromWindow() {
- super.onDetachedFromWindow()
- recycleBackground(this)
- recycleImageView(this)
- }
- private static void recycleBackground(View view) {
- if (view == null) {
- return;
- }
- Drawable drawable = view.getBackground();
- if (drawable != null) {
- drawable.setCallback(null);
- view.setBackground(null);
- }
- }
- private static void recycleImageView(ImageView iv) {
- if (iv == null) {
- return;
- }
- Drawable drawable = iv.getDrawable();
- if (drawable != null) {
- drawable.setCallback(null);
- iv.setImageDrawable(null);
- }
- }
如何解決內存泄漏
我們都知道JVM中的垃圾回收一般使用 :根搜索算法。也就是咱們常聽到的可行性分析。
一句話理解:當該觸發垃圾回收的時候,嘗試確定哪些對象已經不再引用,一波將這些對象帶走就完事了。(而我們的內存泄漏的本質:該被帶走的對象被還活著的對象引用著)
上邊說的簡單,但是會帶來額外的問題:
1. 垃圾的回收不是實時的
- 極端情況下會頻繁觸發gc(比如常說的內存抖動)
2. gc時對全部內存進行可達性分析是很耗時的(而出現gc的時候是會stop-the-world,停掉除gc線程外的所有線程)
針對問題1,JVM的配置里是有一些配置,可以更細粒度的控制回收時機。
針對問題2,也就出現了各式各樣的垃圾回收器,來優化耗時
堆內存和棧內存
為啥要聊這個話題。主要引出來堆/棧內存的區別。
函數中new出來的變量只要不發生逃逸,都會隨棧幀的出入棧來走過自己“華麗的一生”。所以局部變量一般不太需要考慮。
而成員變量都是伴隨著類出現。類的實例化是在堆上,堆上內存的“生老病死”是由gc說的算。正常情況下類中成員變量都是強引用,所以這就構成了引用鏈。只要還掛在GC-Root這條鏈上,那么就意味著可達。這種case從gc的視角來說這些內存就該活著。
強引用和弱引用
根據上述的分析,其實我們已經明白內存泄漏的根本就是本該壽終正寢的對象,由于錯誤的強引用,導致“延年益壽”了。
強/弱引用很好理解:
- 強引用:擁有免死金牌(引用),只要免死金牌不到期,不死不滅
- 弱引用:如同韭菜,需要割(釋放)的時候就被割(釋放)了
而這個錯誤的強引用,在一定情況下可以用弱引用來解決。
解決方案1:弱引用(不推薦)
咱們明確了錯誤的強引用導致內存泄漏,那我們很自然的想到把強引用改成弱引用:
- // 強引用
- val ctx = context
- // 弱引用
- val weakCtx = WeakReference<Context>(context)
當觸發GC的時候,讓GC自己去回收吧。很簡單,改造成本也很小。但是存在問題:
- 弱引用只有觸發GC的時候才會釋放,因此它沒有根本解決存在泄漏的問題,只是一種兜底方案而已。
- GC后發生弱引用回收,此時業務get()就是null,有可能不符合業務場景。
解決方案2:切斷引用
這一條是正路,從根本上解決問題。
但凡需要注冊回調(產生匿名內部類),都要考慮一下這個注冊進去的對象,是不是生命周期比隱式持有的對象長?如果是那就存在內存泄漏。
而解決起來也很簡單,就是把被長生命周期對象強引用的短生命周期對象在合適的時機置為null即可。
三、LeakCanary原理
在一個Activity執行完onDestroy()之后,將它放入WeakReference中,然后將這個WeakReference類型的Activity對象與ReferenceQueque關聯。這時再從ReferenceQueque中查看是否有沒有該對象,如果沒有,執行gc,再次查看,還是沒有的話則判斷發生內存泄露了。最后用HAHA(Headless Android Heap Analyzer)這個開源庫去分析dump之后的heap內存。
- ReferenceQueque:當被 WeakReference 引用的對象的生命周期結束,一旦被 GC 檢查到,GC 將會把該對象添加到 ReferenceQueue 中,待 ReferenceQueue 處理。當 GC 過后對象一直不被加入 ReferenceQueue,說明它可能存在內存泄漏。
- @Synchronized private fun moveToRetained(key: String) {
- removeWeaklyReachableObjects()
- val retainedRef = watchedObjects[key]
- if (retainedRef != null) {
- retainedRef.retainedUptimeMillis = clock.uptimeMillis()
- // 主動gc/判斷是否存在泄漏->dump內存
- onObjectRetainedListeners.forEach { it.onObjectRetained() }
- }
- }
- private fun removeWeaklyReachableObjects() {
- var ref: KeyedWeakReference?
- do {
- ref = queue.poll() as KeyedWeakReference?
- if (ref != null) {
- watchedObjects.remove(ref.key)
- }
- } while (ref != null)
- }
- 最新的庫已經不用HAHA了,新搞了一套。有興趣的同學可以github自行搜索
結語
內存泄漏不是洪水猛獸,但也不應該視而不見。理論上來說不應該寫出存在內存泄漏的代碼,但是如果真的需要,可以問自己兩個問題:
- 這里內存泄漏是必須的嗎?
- 這里內存泄漏的對象大嗎?
如果你的答案是true,那么泄漏也不算什么大事。