|
|
|
|
公众号矩阵

从Java的视角看闭包以及内存泄漏

无论上是Java还是Kotlin咱们基本都没听说过闭包这个概念的存在。但是如果我们去了解闭包解决的问题,咱们就会明白闭包:这不就是匿名内部类会持有外部对象的引用吗?

作者: MDove 来源:咸鱼正翻身 |2021-03-07 17:17

本文转载自微信公众号「咸鱼正翻身」,作者MDove。转载本文请联系咸鱼正翻身公众号。   

前言

主要聊几个点:

  • 什么是闭包,为什么有的语言无时无刻都在提闭包这个概念(比如:JS)?
  • Java中有没有闭包?
  • 内存泄漏

正文

无论上是Java还是Kotlin咱们基本都没听说过闭包这个概念的存在。但是如果我们去了解闭包解决的问题,咱们就会明白闭包:这不就是匿名内部类会持有外部对象的引用吗?

一、闭包

两段类似的代码,先看一段Kotlin代码:

  1. val arr = arrayListOf<() -> Unit>() 
  2. for (index in 0..10) { 
  3.     arr.add(object : () -> Unit { 
  4.         override fun invoke() { 
  5.             print(index
  6.         } 
  7.  
  8.     }) 
  9. arr[6].invoke() 

输出结果6,没什么异议。但是,有趣的来了,这段代码在JS里:

  1. var arr=[] 
  2. for(var i = 0; i<10; i++){ 
  3.     arr[i] = function(){ 
  4.         console.log(i) 
  5.     } 
  6. arr 
  7. arr[6]() 

这里运行是10。(据我前端的同学说,这是一道必考的前端面试题??)

为了方便代码理解,这里针对上述JS代码展开两个JS的规则:

变量提升:

for(var i = 0; i<10; i++)里边的i会进行一个叫做“变量提升”的操作,上述代码实际是这样:

  1. var i 
  2. for(i = 0; i<10; i++){} 

作用域:

函数体里的console.log(i)为什么能引用到i,是因为JS是按作用域查找变量,如果当前作用域没有这个变量就会向父级查找,以此类推。

有了上边两个点,大家应该就能get到为啥arr6的时候,通过父作用域找到了i,而此时的i = 10。

那么问题来了,JS里边怎么让console.log(i)打印6?答案是:闭包。

  1. var arr=[] 
  2. for(var i = 0; i<10;i++){ 
  3.     (function(i){ 
  4.         arr[i]=function(){ 
  5.             console.log(i) 
  6.         } 
  7.     })(i) 
  8. arr[6]() 

简单看一下代码发生了什么改动?用一个有一个参数的函数包了一下。每次for循环的时候都调用这个函数并传递一个当前的i进去。

此后对于console.log(i)来说,父级作用域就是包裹的那个函数,而找到的i也就是正确的i。

这就是JS的闭包。咱们再回忆一下Java是不是也是类似的处理方式?

做法出奇的相似,这里用了一个名为TestKt$main$1的类包裹了我们的Function。并且构造函数里接收我们需要的i。

所以无论上闭包,还是持有外部对象引用。本质想要解决的问题都是:正确的变量引用。这里还有一个题外话:匿名内部类持有外部引用的时候,为啥要加final?

这里了解了二者的实现原理,咱们再来聊一聊二者都会遇到的潜在问题:内存泄漏。

二、内存泄漏

出现内存泄漏的原因也很简单:

  1. 函数内要使用外部变量,那么势必要持有外部变量
  2. 而函数的执行时机有可能在外部变量生命周期外执行
  3. 为了保证2步骤的正常,那么原本应该被回收的外部变量就不能被回收了,因为函数还在引用。所以外部变量就内存泄漏了

我们来看一个比较常见的代码,在一个UI组件里delay一段时间,然后再拿到这个组件里的某个View做delay之后的事情:

  1. class TestActivity : Activity() { 
  2.     override fun onCreate(savedInstanceState: Bundle?) { 
  3.         super.onCreate(savedInstanceState) 
  4.         setContentView(R.layout.activity_fragment_container) 
  5.  
  6.         window.decorView.postDelayed({ 
  7.             Log.d("TEST", findViewById<FrameLayout>(R.id.container).toString()) 
  8.         }, 3000) 
  9.     } 

这段代码至少存在两个相关的问题:

  1. 3秒内退出这个Activity,在第3秒时会出现空指针异常。
  2. 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为了兜底内存泄漏问题,有如下的优化方案。

  1. override fun onDetachedFromWindow() { 
  2.     super.onDetachedFromWindow() 
  3.     recycleBackground(this) 
  4.     recycleImageView(this) 
  5.  
  6. private static void recycleBackground(View view) { 
  7.     if (view == null) { 
  8.         return
  9.     } 
  10.     Drawable drawable = view.getBackground(); 
  11.     if (drawable != null) { 
  12.         drawable.setCallback(null); 
  13.         view.setBackground(null); 
  14.     } 
  15.  
  16. private static void recycleImageView(ImageView iv) { 
  17.     if (iv == null) { 
  18.         return
  19.     } 
  20.     Drawable drawable = iv.getDrawable(); 
  21.     if (drawable != null) { 
  22.         drawable.setCallback(null); 
  23.         iv.setImageDrawable(null); 
  24.     } 

如何解决内存泄漏

我们都知道JVM中的垃圾回收一般使用 :根搜索算法。也就是咱们常听到的可行性分析。

一句话理解:当该触发垃圾回收的时候,尝试确定哪些对象已经不再引用,一波将这些对象带走就完事了。(而我们的内存泄漏的本质:该被带走的对象被还活着的对象引用着)

上边说的简单,但是会带来额外的问题:

1. 垃圾的回收不是实时的

  • 极端情况下会频繁触发gc(比如常说的内存抖动)

2. gc时对全部内存进行可达性分析是很耗时的(而出现gc的时候是会stop-the-world,停掉除gc线程外的所有线程)

针对问题1,JVM的配置里是有一些配置,可以更细粒度的控制回收时机。

针对问题2,也就出现了各式各样的垃圾回收器,来优化耗时

堆内存和栈内存

为啥要聊这个话题。主要引出来堆/栈内存的区别。

函数中new出来的变量只要不发生逃逸,都会随栈帧的出入栈来走过自己“华丽的一生”。所以局部变量一般不太需要考虑。

而成员变量都是伴随着类出现。类的实例化是在堆上,堆上内存的“生老病死”是由gc说的算。正常情况下类中成员变量都是强引用,所以这就构成了引用链。只要还挂在GC-Root这条链上,那么就意味着可达。这种case从gc的视角来说这些内存就该活着。

强引用和弱引用

根据上述的分析,其实我们已经明白内存泄漏的根本就是本该寿终正寝的对象,由于错误的强引用,导致“延年益寿”了。

强/弱引用很好理解:

  • 强引用:拥有免死金牌(引用),只要免死金牌不到期,不死不灭
  • 弱引用:如同韭菜,需要割(释放)的时候就被割(释放)了

而这个错误的强引用,在一定情况下可以用弱引用来解决。

解决方案1:弱引用(不推荐)

咱们明确了错误的强引用导致内存泄漏,那我们很自然的想到把强引用改成弱引用:

  1. // 强引用 
  2. val ctx = context 
  3. // 弱引用 
  4. 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,说明它可能存在内存泄漏。
  1. @Synchronized private fun moveToRetained(key: String) { 
  2.   removeWeaklyReachableObjects() 
  3.   val retainedRef = watchedObjects[key
  4.   if (retainedRef != null) { 
  5.     retainedRef.retainedUptimeMillis = clock.uptimeMillis() 
  6.     // 主动gc/判断是否存在泄漏->dump内存 
  7.     onObjectRetainedListeners.forEach { it.onObjectRetained() } 
  8.   } 
  9.  
  10. private fun removeWeaklyReachableObjects() { 
  11.   var ref: KeyedWeakReference? 
  12.   do { 
  13.     ref = queue.poll() as KeyedWeakReference? 
  14.     if (ref != null) { 
  15.       watchedObjects.remove(ref.key
  16.     } 
  17.   } while (ref != null
  • 最新的库已经不用HAHA了,新搞了一套。有兴趣的同学可以github自行搜索

结语

内存泄漏不是洪水猛兽,但也不应该视而不见。理论上来说不应该写出存在内存泄漏的代码,但是如果真的需要,可以问自己两个问题:

  1. 这里内存泄漏是必须的吗?
  2. 这里内存泄漏的对象大吗?

如果你的答案是true,那么泄漏也不算什么大事。

【编辑推荐】

  1. 搭建Java金融借贷系统【附源码】(毕设)
  2. Java中的内存溢出问题
  3. Disruptor高并发无锁框架 java并发编程 无锁发队列 零基础入门
  4. Java时间操作类库-Joda-Time
  5. Java编译和反编译那些事
【责任编辑:武晓燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

数据湖与数据仓库的分析实践攻略

数据湖与数据仓库的分析实践攻略

助力现代化数据管理:数据湖与数据仓库的分析实践攻略
共3章 | 创世达人

6人订阅学习

云原生架构实践

云原生架构实践

新技术引领移动互联网进入急速赛道
共3章 | KaliArch

35人订阅学习

数据中心和VPDN网络建设案例

数据中心和VPDN网络建设案例

漫画+案例
共20章 | 捷哥CCIE

225人订阅学习

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微