本文首发于公众号【why 技术】, 关注公众号,有更加优秀的排版方式,阅读体验更佳。
可恶的标题党
首先,我先说一下我发现的《Java 并发编程的艺术》写错的地方吧。
我手上这本《Java 并发编程的艺术》的版次是:2019 年 3 月第 1 版第 14 次印刷。
我浏览目录的时候注意到了其中 3.6.5 小节的标题是:《为什么 final 引用不能从构造函数内“溢出”》
很明显,作者这里是一个笔误。从作者该小节具体的描述也可以看出来,【溢出】应该是【逸出】。
看到这里,你要说我是一个 ” 可恶的标题党 ”,我也不反驳。因为这个错误,结合上下文来看,确实无伤大雅。
但是,只看标题呢?如果只知道 java 有内存溢出,不知道 java 有引用逸出的读者呢?
他们可能抠破脑袋,也想不出 ” 构造函数内的 final 引用 ” 和 ” 内存溢出 ” 之间有什么联系吧?
好了,这个不重要。
因为本文想要阐述的,不是这个笔误,而是这个笔误,背后隐藏的两大知识点:【引用逸出】和【内存溢出】。
主要是接合《Java 并发编程的艺术》、《Java 并发编程实战》、《深入理解 Java 虚拟机》这三本书中的相关内容进行对比,然后展开描述。
同时需要强调的是:我认为,这个小小的笔误,完全不妨碍这本书的优秀性。这是一本提升并发编程能力干货满满的书。
对象 & 引用逸出
在《Java 并发编程实战》的 3.2 小节中是这样定义发布与逸出的:
“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。将一个指向该对象的引用保存到其他代码可以访问到的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。
当某个不应该发布的对象被发布时,这种情况就被成为 ” 逸出(Escape)”。
概念读起来总是让人摸不着头脑。
我们直接看书里面给出的程序清单 3 -5:
如程序清单 3 - 5 所示: 在 initialize 方法中实例化一个新的 HashSet 对象,并将对象的引用保存到 knownSecrets 中以发布该对象。
这段代码有什么问题?
当发布 knownSecrets 对象时,间接地发布了 Secret 对象。因为任何代码都可以遍历这个集合,并获得对这个新 Secret 对象的引用。所以 Secret 对象 ” 逸出 ” 了,这是不安全的。
再看书里给出的另外一个程序清单 3 -6:
如果按照上述方式来发布 states,就会出现问题,因为任何调用者都能修改这个数组的内容。在程序清单 3 - 6 中,数组 states 已经 ” 逸出 ” 了它所在的作用域,因为这个本应该是私有的变量已经被发布了。
当某个对象逸出后,你必须做最坏的打算,必须假设某个类或者线程可能会误用该对象。
同时书中也说到,这也正是需要使用封装的最主要的原因:
封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
this 引用逸出
在《Java 并发编程实战》里面给出了一个 ” 隐式地使 this 引用逸出 ” 的例子。如下所示:
ThisEscape 在发布其内部类 EventListener 时,因为 EventListener 这个内部类包含了对 ThisEscape 实例的引用,所以使 ThisEscape 实例发生了 ”this 引用逸出 ”。
不好理解对不对?我们再看看书中的描述:
对于不正确构造,作者给了一个备注说明:
具体来说,只有当构造函数返回时,this 引用才应该从线程中逸出。构造函数可以将 this 引用保存到某个地方,只要其他线程不会在构造函数完成之前使用它。
也不太好理解对不对?确实是,因为我觉得这个代码片段少了几个关键的引导的地方;而这段话很难提炼出关键词,因为全是关键词。
但是我读到这段话的时候,有一句话直接吸引了我的注意力,仿佛把手举得高高的在喊: 看我,看我!
即使发布对象的语句位于构造函数的最后一行也是如此
作者为什么要感觉是轻描淡写,实际上是在强调 ” 最后一行 ” 呢?
作者没有明说,但是答案是重排序,因为有了重排序,所以一行代码看起来是在最后一行,实际上不是最后一行。
这里我们接合《Java 并发编程的艺术》发生笔误的这一章节里面的例子,来说明【this 引用逸出】和【即使发布对象的语句位于构造函数的最后一行也是如此】这两个问题,代码如下:
假设一个线程 A 执行 writer()方法,另一个线程 B 执行 reader()方法。
这里的操作 2(obj=this)使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2(obj=this)是构造函数的最后一步。
且在程序中操作 2(obj=this)排在操作 1(i=1)后面,执行 read()方法的线程仍然可能无法看到 final 域被初始化后的值。
因为这里的操作 1(i=1)和操作 2(obj=this)之间可能被重排序。实际的执行时序可能如下图所示:
所以《Java 并发编程的艺术》里面的示例代码和多线程下代码的执行时序图就很好的说明了【this 引用逸出带来的问题(线程不安全)】,解答了【《Java 并发编程实战》中没有明说的为什么 ” 即使最后一行 ” 也不行(重排序)】。
这一小节就是我读完《Java 并发编程实战》、《Java 并发编程的艺术》之后,取出书中部分内容再加上自己对于对象 & 引用逸出的理解的总结、输出。
其实《深入理解 Java 虚拟机》里面也有对逃逸描述的相关内容,有兴趣的可以翻阅一下。如下:
《深入理解 Java 虚拟机》目录
内存溢出
如果前面说的引用逸出让你云里雾里,快要瞌睡了。那接下我们要谈的内存溢出,大家应该都是耳熟能详的了。
先上一个来自《深入理解 Java 虚拟机》中第 2 章【Java 内存区域与内存溢出异常】中的一张清晰的、牛逼的、经典的、包罗万象的大图:
Java 虚拟机运行时数据区
这个图包含的知识点可以说是非常多,全是 ” 内功心法 ”,我们只讨论其中的一大分支 — 内存溢出。
所以,本小节内容的目的有两个:
第一,通过代码验证 Java 虚拟机规范中描述的各个运行时区域存储的内容。
第二,希望读者在工作中遇到实际的内存溢出异常时,能根据异常的信息快速判断是哪个区域的内存溢出,知道什么样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。
对于每个区域具体的职能,就不铺开讲了,一铺开,又是一个万字长篇。我在保证质量的前提下,尽量精简字数,让大家读起来不要那么耗时(实在耗时的话,说明我真的用心在写,可以收藏起来或者转发朋友圈慢慢看呀),一进来,一看完,半小时过去了。
话不多说,精彩继续。
程序计数器
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
由于没有 OutOfMemoryError 的情况,所以不做模拟。
虚拟机栈 & 本地方法栈
关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。
常言说的好:Talk is cheap.Show me the code(光说不练假把式)。我们用代码说话:
在《深入理解 Java 虚拟机》笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生 OutOfMemoryError 异常,尝试的结果都是获得 StackOverflowError 异常,测试代码如下所示。
使用 -Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出 StackOverflowError 异常时输出的堆栈深度相应缩小。
虚拟机栈和本地方法栈 OOM 测试 (仅作为第一点测试程序)
运行结果:
在单线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。
那我们怎么去模拟 OutOfMemoryError 异常呢?
我查阅了一些其他的文章,他们的测试不限于单线程,通过不断地创建线程的方式产生内存溢出异常。举出的例子也是书中的例子, 如下:
运行结果:
Exception in thread”main”java.lang.OutOfMemoryError:unable to create new native thread
但是很多文章中没有把书中的特殊说明摆出来,我觉得这里是混淆概念的问题,应该进行特殊说明,如下:
这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
那作者为什么说这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系呢?
其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows 限制为 2GB。虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值。剩余的内存为 2GB(操作系统限制)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈 ” 瓜分 ” 了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
所以,书中提醒读者需要在开发多线程的应用时特别注意,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下(现在用 32 位的应该是极少数了吧),就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过 ” 减少内存 ” 的手段来解决内存溢出的方式会比较难以想到。
方法区溢出
怎么让方法区溢出?
我们不妨先换个问法,方法区里面放的是什么东西?
这样一问,大家都知道:方法区用于存放 Class 的相关信息,比如类名、访问修饰符、常量池、字段描述、方法描述等。
知道它存放的东西是 Class 相关信息了,那我们不停的往里面放入类,不就溢出了吗。
接下来问题又来了,我们怎么在运行时产生大量的类去往方法区里面放呢?
在书中作者给出的示例代码,是借助 CGLib 直接操作字节码运行时生成了大量的动态类。如下:
需要多说一句的是,书中的 JDK 版本是 1.7,我的 JDK 版本是 1.8。因为 JDK1.8 中用 Metaspace 代替了 Permsize,因此在我们设置 VM Args 的时候需要有所变化,正如上面图片展示的那样。
JDK1.8 运行结果:
JDK1.7 运行结果:
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。
这类场景常见有如下几种:
1. 上面提到的程序使用了 CGLib 字节码增强和动态语言
2. 大量 JSP 或动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为 Java 类)
3. 基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
而对于使用 CGLib 字节码增强技术的这种场景,可以说是非常常见了。我们常用的 Spring 框架中就有大量的 CGLib 技术的应用。随便截个源码的图片,比如这个 CglibAopProxy。
Java 堆溢出
这块区域的 OOM 异常,可以说是我们在实际开发的过程中最常见的内存溢出异常情况。
众所周知,Java 堆里面放的是对象实例,按照之前的想法,我们只要不断的创建对象,这样当创建的对象数量足够多的时候,就会产生内存溢出异常。
再读一读上面的话,这个描述对吗?
这样说是不完全正确的。如果我们创建的时对象被垃圾回收机制清除了呢?
所以书中给出的完整的描述是这样的:
java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达的路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大的容量限制后就会产生内存溢出异常。
这里涉及到的 GC Root 和可达性分析算法也是非常重要的知识点。不展开讲了,如果不了解的读者,建议了解一下,都是知识点啊,朋友们。
我们再看书中给出的示例代码:
运行结果(多么熟悉、亲切、辨识度高的异常啊):
Java 堆内存的 OOM 异常是实际应用中常见的内存溢出异常情况。当出现 Java 堆内存溢出时,异常堆栈信息 ”java.lang.OutOfMemoryError” 会跟着进一步提示 ”Java heap space”。
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
内存泄漏解决思路:
如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
内存溢出解决思路:
如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
经过上面的对各个区域的一顿操作后,再来细细品味这一张清晰的、牛逼的、经典的、包罗万象的大图:
Java 虚拟机运行时数据区
每次读完《深入理解 Java 虚拟机》都会回味无穷。对于其作者周志明先生:在下佩服!
最后说两点
送书
第一点:文章提到的《Java 并发编程实战》、《Java 并发编程的艺术》、《深入理解 Java 虚拟机》这三本书,我认为都是非常优秀的,值得反复翻阅的技术书籍。可以关注我后在后台回复关键字【Java】,即可获得这三本书的电子版。但是,对于这类工具书,强烈建议购买实体书,以便做读书笔记和随手翻阅。
所以,我打算自掏腰包送一本书给我的读者。读者可以关注我公众号后在后台回复关键字【书籍】,即可参与抽奖。中奖后的读者可以从《Java 并发编程的艺术》《Java 并发编程实战》《深入理解 Java 虚拟机》三本中任选一本。
加群
第二点:因为加我个人微信的人越来越多,很多人的问题都具有相似性,所以我创建了一个技术分享的群,我们可以在这里交流技术,品味生活,感悟人生。欢迎你进来一起学习,相互交流,共同进步,愿你我一起早日成为真正的大佬。
若群二维码失效,可以加我个人微信(公众号菜单栏有我的微信二维码),我拉你入群。
谢谢您的阅读,感谢您的关注。个人能力有限,文章中难免有纰漏,错误的地方,如果您发现了,烦请指出,我对其加以修改。如果你觉得文章写的不错,你的点赞、转发、赞赏就是对我最大的鼓励。
以上。
PS: 说出来你可能不信,这篇文章我已经很收敛的在写了,还是有 6359 个字,真的是我用心在写。
这篇文章特别耗时,因为在写之前我把文中提到的三本书的相关章节又仔细的阅读了一次,写的过程中也在反复翻阅。快餐时代下,修炼内功心法,还是需要细嚼慢咽。
欢迎关注公众号【why 技术】。在这里我会分享一些技术相关的东西,主攻 java 方向,用匠心敲代码,对每一行代码负责。偶尔也会荒腔走板的聊一聊生活,写一写书评,影评。愿你我共同进步。