侧边栏壁纸
博主头像
why

一个主要敲代码,经常怼文章,偶尔拍视频的成都人。

  • 累计撰写 181 篇文章
  • 累计创建 11 个标签
  • 累计收到 88 条评论

讲真,我发现这本书有个地方写错了!

why
why
2019-10-13 / 0 评论 / 1 点赞 / 173 阅读 / 5,675 字
温馨提示:
关注公众号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虚拟机》中第2章【Java内存区域与内存溢出异常】中的一张清晰的、牛逼的、经典的、包罗万象的大图:

这个图包含的知识点可以说是非常多,全是"内功心法",我们只讨论其中的一大分支---内存溢出。

所以,本小节内容的目的有两个:

  • 第一,通过代码验证Java虚拟机规范中描述的各个运行时区域存储的内容。
  • 第二,希望读者在工作中遇到实际的内存溢出异常时,能根据异常的信息快速判断是哪个区域的内存溢出,知道什么样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。

程序计数器

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

由于没有OutOfMemoryError的情况,所以不做模拟。

虚拟机栈&本地方法栈

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

常言说的好:Talk is cheap.Show me the code(光说不练假把式)。我们用代码说话:

在《深入理解Java虚拟机》笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生OutOfMemoryError异常,尝试的结果都是获得StackOverflowError异常,测试代码如下所示。

使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出StackOverflowError异常时输出的堆栈深度相应缩小。

运行结果:

在单线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是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虚拟机》都会回味无穷。对于其作者周志明先生:在下佩服!

最后说一句

好了,看到了这里了,关注一下我的公众号[why技术]吧,文章写好后第一时间会先发布在公众号里面。

写文章很累的,需要一点正反馈。你的关注,就是强有力的正反馈!

才疏学浅,难免会有纰漏。如果你发现有写错的地方,欢迎留言指出来。

抱拳了,铁子!

0

评论区