漫谈 JVM —— 内存
JVM 是什么呢?说的直白点就是 Java 代码运行的地方,全称 Java Virtural Machine,Java 虚拟机。有的人就会奇怪了,为什么 Java 程序员需要了解这个东西?毕竟大多数情况下,“能跑”就行。
能跑真的行吗?你说在一个小公司里,“能跑”就行那是肯定的,业务必定是优先的。可是发展到规模大了之后,“能跑”好像就没那么简单了。规模大了,程序突然崩溃了,却不知道什么情况,这可以叫“能跑”吗?好像不是这样。所以成熟一些公司招程序员,肯定是要懂 JVM 的,不然代码写出了问题,JVM 崩了,都不知道是为什么——打个比方,系统抛出了一个 OOM 了,原因为 null(还真不是 heap),某程序员却不知道为什么 OOM 了,这显然不合适。或许某程序员觉得,这个东西交给运维、或者处在牛 A 和牛 B 之间的人。如果这么想的话,你的解决问题的能力永远无法提升。
那为什么 Java 有虚拟机,C++ 没有呢?因为 C++ 自己管理指针,而 Java 不需要你管理指针—— JVM 帮你管理了。不需要管理指针可以让你的代码更加面向对象(面向对象这几个字,天天被人吹,估计也没几个人懂,有的人竟然吹面向对象而不知道设计模式),让你专注于业务代码。这一切的基础,是你要知道指针是什么。
当然知道指针是什么也是一件复杂的事情。从 C++ 来看,指针可能是 *、& 相关运算符,可是初学的人往往搞不懂设计这些运算符的原因。所以计算机有一门课程叫做汇编,汇编会告诉你这些运算符是怎么来的——和内存地址有关。这就是计算机专业和非计算机专业的不同——计算机专业知道代码时怎么跑的,而非计算机专业不懂得这些,然而这些会限制一个人能不能越走越远。比方说我看《深入理解 Java 虚拟机》难度并不是很高,可能对于不了解指针的人来说,不了解内存的人来说,这本书写的让人感觉云里雾里。
那管理指针有什么用呢?记住了指针,就能记住这块区域存的是什么。执行一段 A 代码,存了一些数据;执行一段 B 代码,存了一些数据;A 代码执行完了,存的那些数据也没有意义了——就是垃圾。垃圾是要回收的,在有限的内存里,垃圾会一直积累,直到占满所有的内存。JVM 帮你做了这些。
那 JVM 是怎么保存这些数据的呢?如果让各个人做可能有不同的方法,现在假设我们有十块内存空间。每一个对象可能占用一块或者多块空间。假设,对象 A 保存第一块,对象 B 保存第二块、第三块。这个时候,程序员小 A 觉得累了,明天再来看这篇文章(看完这段话假设已经过了一天)。第二天,程序员小 A 发现自己记不得那块保存了什么对象、哪个对象占了那几块。这个时候需要一个数据结构去保存对象的位置——也就是对象开始的指针。小 B (就你事情多)这之后问了,我再对象 A 所占块之后空一个区域,表示这个对象结束了,不行吗?那我就想问一句,空对象小 B 你怎么表示呢?小 B 说,空对象用特殊标记啊!那这个特殊标记又怎么特殊标记呢,etc......似乎用空区域表示对象结束不是一个好办法。
所以现在有两个概念——一个是用来存储对象的,一个是用来存储引用的。这两个不同性质的概念应该存在不同的区域。在真实的 Java 虚拟机里,也是这么分的,简单的来说,引用存在 VM Stack 里,实例对象存在 Heap 里。当然,还有一些区域存着别的东西,Native Method Stack(NMS)、Programme Counter Register(PCR)、Method Area(MA)等。你的程序不可能只保存数据,类、方法信息需要保存在 MA 中;你程序执行到哪里了,保存在 PCR 中;Java 有一些代码需要用 C++ 实现,C++ 对象所需要的空间,保存在 NMS 中。
话说回来,我们保存了这些对象,现在需要回收垃圾了。那就涉及到一些问题:
- 回收什么?——垃圾
- 怎么找到所有的垃圾?
- 怎么回收?
怎么找到所有的垃圾?拿得从,程序是怎么跑起来的来看。Java 程序是多线程的,只要跑起来肯定有线程,这些线程的信息被保存到 VM Stack 中。所以什么对象是活跃的?在 VM Stack 中对象肯定是活跃的,这些肯定不是垃圾。还有一些被 static 引用的也不是垃圾,因为很可能下次就会被其他人引用。
听起来似乎简单,实际上并没有那么简单—— VM Stack 里保存了活跃对象 A,A 保存了 B、D,B 保存了 C 等等。这样的结构是树。如何遍历一颗树找到所有的结点那是就是树相关的算法——深度优先搜索或者广度优先搜索了,涉及到数据结构和算法的知识,这里就不多说了。这在 Java 虚拟机中称之为可达性算法。
相对于可达性算法,还有一种算法叫引用计数法。我们维护一个引用列表,A 引用了 B,A 引用次数 + 1;如果没人引用,那肯定是垃圾。似乎这也算起来更简单些,但是小 C 发现了问题:如果有人引用,那它就不是垃圾了吗?其实,存在 A 引用 B, B 引用 A,但是所有运行着的线程都不需要这两个对象,所以这两个也是垃圾。所以引用计数法不是特别靠谱。
说一件事情可能让各位很惊悚,Python 在用引用计数法。引用计数法不会让虚拟机“停下”,可以一边执行一边计算垃圾;而其他的算法,例如标记—清除法,需要实时计算引用,会暂停所有线程去分析。Java 有个著名的问题——Stop The World,STW。在 GC 的时候,服务是不可用的——这对于普通业务来说,还好;对于游戏来说,这是不可接受的,通常这个时间至少达到 100 ms。打个农药突然给你增加延迟 100 ms,你能接受吗?所以,永远没有银弹。
接下来,是怎么回收。想一想现实中垃圾是怎么清楚的,对,现实中。地上有一堆垃圾,丢掉就行了呗。好的,最原始的垃圾清楚方式就是标记—清除。我知道这个地方没用,我就把引用扔掉。这有个问题,比如你有十片地方,空着的地方是 0,没空的地方是 1。原来的分布式是 1111111111,垃圾清楚之后,现在的地方分布式 1010101010。好了,现在来了个大家伙,要占用两个位置,发现没地方放了。果真是没地方放吗?这就和收拾家里一样,挪挪总是有位置摆东西的,我收拾成 1111100000 不就好了,这样我就有两个位置了。这就是标记—清除的缺点——会产生内存碎片。小 D 这时候会说,那我不清除了,我直接把有用的复制到前面来不就好了吗?是啊,这就是标记—整理算法。
然而,90% 的对象都是朝生夕死的,就跟家里的东西一样,大部分都是用完就扔的。这个时候就有人提出了复制算法。具体算法不想多说了,简要来说就是分成两个区域,一个区域保存存活的对象 + 后来产生的对象;另一个区域空下,为下次 GC 做准备,这个区域通常很小,为 10%,正好是存活对象的比例。这样的算法实现起来简单,只要复制到空闲区域,不用担心区域内是不是已经内容、如何移动内容(可能还需要多次移动),而标记—整理算法显然要考虑这些。
JVM 还做了更多的优化,根据对象不同的属性,分成老年代、年轻代,当然算法还不一样。此外还进化出 CMS、G1 算法,这里一句两句讲不清楚,这里就不多提了,为了吞吐量而优化。但一切的一切,有一条重要的原则是 JVM 开发者没有遗忘的——根据实际情况出发。为什么会产生分代?实际情况就是很多对象朝生夕死。
这篇漫谈主要说的是 JVM 内存相关的一些东西,想到什么就写了什么,主要讲了自己的一些感受,具体、准确的分析还请看书、看资料。