当前位置: 首页 > 后端技术 > Java

从局部变量入手,关于一个莫名其妙的引用和一个坑!_0

时间:2023-04-01 18:31:58 Java

今天给大家带来一些有趣的基础知识。它有多基本?先给大家一段代码:请问,在上面的代码中,方法执行完成后位于method方法中的object对象是否可以被垃圾回收?还想什么呢,这肯定是可以的,因为这是一个局部变量,它的作用域就在方法之间。JVM在执行一个方法时,会为该方法创建一个栈帧,然后将其压入栈中,方法执行完毕后弹出。一旦方法栈帧被弹出,栈帧中的局部变量就相当于不存在了,因为没有一个变量指向Java堆内存。换句话说:它死了,无法访问。这是一个基础知识点,骗你的吗?那我现在换个说法:你说method方法执行完后,executorService对象能不能被垃圾回收?别想的复杂,这个东西和刚才的Object一样,也是局部变量,肯定是可以回收的。不过接下来我要开始做事了:我让线程池执行一个任务,相当于激活了线程池,但是这个线程池还是一个局部变量。那么问题来了:在上面的示例代码中,executorService对象是否可以被垃圾回收?这时候你就需要抱住脑袋想一想……别憋着,先说结论:不能回收。那么我想提出的问题就出来了:这也是一个局部变量,为什么不能回收呢?为什么知道线程池里有活动线程,那么直觉上不应该被回收。但至于证据,你得拿出完整的证据链。好吧,我问你,一个对象被判断为垃圾并可以被回收的依据是什么?这时候你的脑子里一定会立刻浮现出“可访问性分析算法”这七个字,刷完之后你会想到这样一个画面:一定很自然,就像看到肯德基立马想到vme50一样。该算法的基本思想是使用一系列称为“GCRoots”的根对象作为起始节点集。从这些节点开始,按照引用关系向下查找。搜索过程所经过的路径称为“引用链”(ReferenceChain),如果一个对象与GCRoots之间没有引用链接,或者用图论的术语来说,当对象从GCRoots中不可达时,证明该对象不能再次使用。所以如果你想推断executorService不会被回收,那么你必须推断GCRoot对executorService对象是可达的。那么哪些对象可以作为GCRoot呢?这是一个古老的成见,但我不会说太多。看看本文关心的部分:livethread,可以作为GCRoot。因此,由于我在线程池中运行了一个线程,即使它完成了任务,它也只是在这里等待,它仍然是一个活线程:因此,只要我们能够找到这样一个链接,我们就可以证明executorService是部分变量,不会被回收:livethread(GCRoot)->executorService代码对应一个livethread,一个调用start方法的Thread,这个Thread是一个实现了Runnable接口的对象。这个实现Runnable接口的对象对应线程池中的代码:java.util.concurrent.ThreadPoolExecutor.Worker那么我们可以把上面的链接说得更具体一些:Worker(livethread)->ThreadPoolExecutor(executorService)就是找到从Worker类到ThreadPoolExecutor类的引用关系。有的同学立马站起来回答:嗨,就这?我以为这有多残忍?这个我很熟悉,不就是这样吗?你看,ThreadPoolExecutor类有一个成员变量叫做workers。我只是笑笑:是的,然后呢?回答的同学马上回答:那证明ThreadPoolExecutor类持有workers的引用?我继续问:没有错,然后呢?同学喃喃自语:这不就完了吗?是的,结束了,今天的采访结束了,回去等通知吧。我的问题是:找到Worker类到ThreadPoolExecutor类的引用关系。你把它倒过来了。有同学又要说了:这道题直接看Worker类,看看里面有没有ThreadPoolExecutor对象的成员变量。抱歉,真的没有这样的事情:怎么回事?可以回收吗?但是如果ThreadPoolExecutor对象被回收了,而Worker类还存在,线程池没有了,线程还在,这不奇怪吗?奇怪,奇怪……看到这位同学陷入了自我怀疑的状态,我才激活了一个“别想太多”的技能:坐下!听我说!开始上课后,我们先把线程池忘掉。我给大家做一个简单的demo。回归本源,分析起来会容易一些:publicclassOuter{privateintnum=0;publicintgetNum(){返回数字;}publicvoidsetNum(intnum){this.num=num;}}//内部类classInner{privatevoidcallOuterMethod(){setNum(18);直接访问外部类的变量和方法。这种写法你应该没有异议。有时候在日常开发中会写内部类。我们再深入思考一下:为什么Inner类可以直接使用父类呢?因为非静态内部类持有对外部类的引用。这句话很重要。可以说,我写这篇文章就是因为这句话。接下来,我将证明这一点。如何证明?很简单,javac编译一波,答案都藏在Class里了。可以看到,Outer.java反编译后,出来了两个Class文件:分别是:对非静态内部类的外部类的引用。好了,理论知识到位,验证完毕。现在让我们回顾一下线程池:Worker类是ThreadPoolExecutor类的内部类,所以它持有ThreadPoolExecutor类的引用。这样这个链接就建立起来了,executorService对象就不会被回收了。Worker(livethread)->ThreadPoolExecutor(executorService)如果你不相信我,让我告诉你另一件事。我的IDEA里面有个插件叫Profile。程序运行后,可以分析里面的内存:我很容易找到内存中存活的ThreadPoolExecutor对象,按照Class排序:点进去看看,这是核心线程数和最大线程数我定义的都是3个,只激活了一个线程池:我们需要验证的链接也可以直接从GCRoot中找到:那么,我们回到最初的问题:在上面的示例代码中,executorService对象是否可以被垃圾收集?答案是否定的,因为线程池中有活跃线程,而活跃线程就是GCRoot。这个活动线程实际上是一个Woker对象,它是ThreadPoolExecutor类的一个内部类,持有对外部类ThreadPoolExecutor的引用。所以,executorService对象是“可达的”,它不能被回收。理由,就这么一个理由。那么,问题又来了:应该怎么做才能让这个本地线程池回收呢?调用shutdown方法kill活线程,也就是killGCRoot,整个不可达。垃圾回收线程看了一眼:哎~好家伙,过来,你呢。扩展一下,看我前面说的结论:非静态内部类持有外部类的引用。强调了一个“非静态”,万一是静态内部类呢?将Inner标记为static后,Outer类的setNum方法就不让你直接使用了。如果要用的话,就得把Inner的代码改成这样:或者改成这样:也就是必须显示出来持有一个外部的内部对象。来,大胆猜猜为什么?是不是因为静态内部类没有持有外部类的引用,两者之间根本没有任何关系?我们还是可以从class文件中找到答案:当我们给内部类加上static后,它就不再持有外部引用了。至此我们可以得出另一个结论:静态内部类不持有对外部类的引用。那么文本的第一个扩展点就出来了。即《Effective Java(第三版)》中的第24条:比如还是线程池的源码,里面的拒绝策略也是一个内部类,用static修饰:为什么不像工人阶级?这告诉我:当我们使用内部类时,尽量使用静态内部类,以免莫名其妙地持有一个外部类的引用,而不是使用它。如果它不起作用,那真的没什么大不了的。真正可怕的是:内存泄漏。比如网上的这个测试用例:Inner类不是静态内部类,所以它持有对外部类的引用。但是,在Inner类中,不需要使用外部类的变量或方法,比如这里的数据。试想一下,如果数据变量是一个很大的值,那么在构建内部类的时候,由于引用的存在,不小心多占了一部分本该释放的内存。所以在这个测试用例运行之后,很快就出现了OOM:如何断开这个“未知”的引用呢?前面说了,解决方法是使用静态内部类:只需要在Inner类中加上static关键字,其他不做任何改动,问题就解决了。但是这个static不是想都没想就直接加上的。之所以可以加在这里,是因为Inner类根本没有使用Outer类的任何变量和属性。所以,重申《Effective Java(第三版)》中的#24:静态内部类优于非静态内部类。你看,他用的是“betterthan”,意思是优先考虑,而不是强迫。让我们扩展术语“静态内部类”。我记得我刚开始接触的时候是这么叫的,或者说大家都是这么叫的。然后在写文章的时候一直在JLS里面找“StaticInnerClass”之类的关键词,但是真的找不到。在InnerClass部分,StaticInnerClass这三个词并没有连续出现:docs.oracle.com/javase/spec...直到我找到了这个地方:docs.oracle.com/javase/tuto...在Java官方教程中,有一个关于内部类的提示:嵌套类分为两类:非静态类和静态类。非静态嵌套类称为内部类。声明为静态的嵌套类称为静态嵌套类。看到这句话,我立马反应过来。大家习以为常的StaticInnerClass,其实并没有这个名字。嵌套,嵌套。我认为这里有翻译问题。首先,在一个类中定义另一个类的操作在官方文档中被称为嵌套类。没有static的嵌套类称为内部类。从用法上来说,要实例化一个内部类,必须先实例化外部类。代码必须这样写://先创建内部类OuterClassouterObject=newOuterClass();//然后才能创建内部类OuterClass.InnerClassinnerObject=outerObject.newInnerClass();它就像我的肾脏,是我身体的一部分,它在我体内。加了static的嵌套类就叫静态嵌套类,和Inner完全没有关系。这样嵌套也很有表现力。这意味着我可以独立存在而不依附于某个阶级。我依附于你,只为借个壳,筑巢。做一个分数,就像我的手机,一直在我身上,但它不在我的内心,它也可以脱离我而独立存在。所以,一个内部,一个嵌套。一个肾,一部手机,能一样吗?当然,如果换个手机非要用肾的话……这种翻译问题也让我想起了之前在知乎看到的一个类似的问题:为什么很多编程语言都把0设置为第一个元素?下标索引而不是直观的1?这是一个简洁而有启发性的答案:您也可以扩展它。接下来,就让我们把目光聚焦在《Java并发编程实战》这本书上吧。还有一段与本文相关的代码。乍一看,这段代码迷惑了无数人。按照书上的说法,这段代码有问题,会导致this引用逃逸。第一次看的时候发呆,看了几遍没看懂:然后就跳过了。。。过了很久才明白作者的意思想表达。现在我将带您通过这段代码来理解它。我先把书上的代码补全,全部代码是这样的:public?class?ThisEscape?{????public?ThisEscape(EventSource?source)?{????????source.registerListener(new?EventListener()?{????????????public?void?onEvent(Event?e)?{????????????????doSomething(e);????????????}});????}????void?doSomething(Event?e)?{????}????interface?EventSource?{????????void?registerListener(EventListener?e);????}????interface?EventListener?{????????void?onEvent(Event?e);????}????interface?Event?{????}}复制代码代码要是你一眼看不明白,没关系,mainlyfocusingontheEventListenerthing,youseeitisactuallyaninterface,right?Okay,letmechangethestyleforyou,andchangeittoamorefamiliarwayofwriting:bothRunnableandEventListenerareinterfaces,sothereisnoessentialdifferencebetweenthiswayofwritingandthesamplecodeinthebook.Butitdoeslookalittlefamiliar.Andinfact,thisEventSourceinterfacedoesnotaffectwhatIwillshowyouattheend,soIalsogetridofit,andthecodecanbesimplifiedtothis:publicclassThisEscape{publicThisEscape(){newRunnable(){@Overridepublicvoidrun(){doSomething();}}};}voiddoSomething(){}}Intheno-argumentconstructionoftheThisEscapeclass,thereisanimplementationoftheRunnableinterface,whichiscalledananonymousinnerclass.看到内部类,看到书中提到的this的转义,想起刚才持有外部类引用的非静态内部类,是不是想到了什么?为了验证你的想法,我通过javac编译了这个类,然后检查了它的类文件如下:我们看到了this关键字,所以“thisescape”中的this指的是书中的类ThisEscape。逃避,会带来什么问题?我们来看这段代码:由于ThisEscape对象在构造方法完成之前通过匿名内部类“逃逸”,所以在外部使用的时候,比如doSomething方法,可能是一个没有完全完成的对象初始化。对象,这可能会导致问题。我想读者只要抓住“内部类”和“这是谁”这两个重点,就会更容易吸收书中的这个案例。关于“这个转义”的问题,书中也给出了相应的解决方案:做个攻略,就不细说了,有兴趣的可以自己去看。