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