揭开Java内存管理的面纱

3,055 阅读15分钟

前言

相对于C、C++这些高性能语言,Java有着让此类程序员羡慕的功能:内存自动管理。似乎这样,Java程序员不用再关心内存,也不用去了解相关知识。但结果真的是这样吗?特别对于我们这种Android程序员来说,对内存可是吃得死死的,一旦出现较为复杂的内存泄露和溢出方面的问题,简直就是噩梦。因此,对Java内存管理有个大体的了解似乎已经成为一个合格的Android程序员必备的技能,就算是新进的Kotlin同样是基于JVM的。不如趁此机会,大家一起来揭开它的面纱。

对象

Java是一门面向对象的编程语言,江湖一直流传着这么一句话:万物皆对象。因此,Java的内存管理也可以理解成为对象的创建与释放。那么,对象到底是什么?男朋友?女朋友?还是?对象和内存到底是什么关系?这里的问题太多,我们一步一步来。

Tips1:全文以常用的虚拟机HotSpot、常用的内存区域Java堆和普通Java对象为例。
Tips2:如果深读过《深入理解Java虚拟机》的同学可以不用看了,请右上角,如果忘了,请继续!

概念

男朋友或者说女朋友你都可以理解成对象,对象是实实在在存在的,比如老爸,老妈,同时伴随着一个抽象的概念,类:它是对对象的抽象,不管是男朋友和女朋友都是人,属于人类。概念差不多就介绍到这,感觉自己在大学上课一样。。。我的天(捂脸)。

对象与内存

创建

程序员没媳妇怎么办?new一个。老简单了,高的,矮的,瘦的,胖的,想要啥就有啥,此生最不后悔的就是当程序员了,虽然头有点冷。

new一个就是一个对象的创建,那么究竟是怎样的一个过程呢?JVM遇到一条new指令的时候,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,类加载检查通过后,可以说一个对象的模型已经出来了,但Java毕竟只是编程语言,还是得分配内存不是?不然怎么操作?

分配

对象内存的分配和现实很多场景都是一样的,比如停车,有些地方可能只有100个车位,先到的停在最前的空位上,就这样按顺序一辆一辆的停下来。这样的分配称为“指针碰撞”。还有一种你想停哪就停哪,只要你插得进去。这样的分配称为“空闲列表”。不管是前者还是后者,停车我们是靠眼睛看的,哪里有空位才停,那么JVM如何“看”的呢?前者是靠一个指针作为指示器,分配多大内存的对象就往后移多大距离,后者会维护一个列表来记录可用内存(可插车位)。

对于并发敏感的同学肯定会提出疑问,在并发的时候如何能正确分配到相应位置? 一般也有两种解决方案,一种是一辆一辆停,保证前一辆停完,下一辆才开始停;另一种是大家说好要停哪一片区域,比如A,B,C停在A区域,那么A,B,C每次去停A区域就行了,跟其它区域没关系(区域指的是线程),如果他们邀了朋友D,那对不起,只能等其他区域人停完,你再停。因此,对象的创建并不是原子操作,切记,切记。

布局

车停哪里,我们已经知道了,那么怎么停?有人喜欢正着停,有人喜欢横着停,有人喜欢倒着停。同样的,对象在内存中是怎么摆放的呢?大体分为3个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

简单地来介绍这3位,毕竟这概念性太强。

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据、如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的示例。对上面部分名词不理解的,我在后续文章可能会解释,毕竟自己也在学习当中,如果想急于知道的同学可以查阅相关资料,姑且当它是概念记住即可。

实例数据就比较好理解了,它是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

对齐填充并不是必然存在的,由于内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。对象头大小是8字节的整数倍,所以实例数据大小不是8字节的整数倍时,就需要对齐填充来补齐。

访问

你停完车,干完事,总得开车回家吧,那总得找到自己的车吧?怎么找?自己停在哪个车位总记得吧?自己的牌照总记得吧?那么我们如何在内存中访问我们的对象呢?大家来看一组图:

前者称为句柄访问,优点很明显,对象移动了只要修改句柄中的指针就行了,不会牵涉到reference;后者称为直接指针访问,优点也很明显,就是快,直接少了句柄这一层。而本文中讨论的HotSpot采用后者。

回收

车炸了怎么办?当然是买辆新的(手动坏笑)。那么我们如何判定一个对象屎没屎呢?在此之前介绍两种引用算法:第一种是引用计数算法,很好理解,给对象一个计数器,初始值为0,有地方引用就加1,失效就减1,计数器为0的说明都是屎了的;第二种是可达性分析算法,也很好理解,从GC Roots开始,向下引用对象,如果一个对象存在一条从GC Roots到本身的路径,那么说明这个对象还活着,否则就屎了。如下图object567就是屎的:

那么哪些可以作为GC Roots呢?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

我们的HotSpot是采用后者,那么为啥没采用前者呢?因为它很难解决对象之间相互循环引用的问题。例如:

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;

那么问题来了,不可达的对象真的屎了吗?当然不会,至少经过2次标记才会宣告一个对象的屎亡。第一次标记是发现对象不可达,同时筛选出没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,那么这些可以认为屎了,可以回收(那么这时候不是只标记一次吗?有没有大佬解答);剩下的对象会被放置F-Quenue的队列中并且GC会对这些对象进行第二次标记,在执行finalize()方法的时候也是拯救自己的时候(只要在方法中合重新建立与引用链上其它对象的关联即可)。大家最好忘记这个方法的存在。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序等。《Effective Java》中也有提到避免此方法。

对象的简单分析差不多就到这里结束了,你以为到这里全部结束了?太天真。

像上面碰到的名词,诸如虚拟机栈、方法区、Java堆等到底是什么玩意?

运行时数据区

国际惯例,No picture,say a J8!

看到这张图,大家肯定知道我要干什么了。。。我也不愿意啊,写到这感觉是篇说明文了,我的天,贼尴尬。

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指针。例如平时的分支、循环、跳转、异常处理、线程恢复等基础功能要依赖这个计数器完成。从图上我们可知,它是线程私有的,也就说每个线程都会有一个独立的程序计数器且互不影响。而且它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

Java虚拟机栈

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。细心的朋友,会发现局部变量表在对象的访问章节图中出现过,重要的是当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,换句话说,局部变量表所需的内存空间是在编译期间就完成分配的。

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果无法扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java(也就说字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。因此与Java虚拟机栈抛出的异常状况也是一样的。

Java堆

你可以认为几乎所有的对象实例都在堆上分配的。难道不是所有的?这是一个优化技术,试想一下,如果一个对象无法被别的方法或者线程通过任何途径访问到,为何不直接分配在栈上呢?

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,这也意味着,如果逻辑上没有足够的内存完成分配且堆也无法扩展,那么将会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。但它除了和Java堆一样不需要连续的内存和无法选择固定大小或者可扩展内存将抛出OutOfMemoryError异常外,还可以选择不实现垃圾收集。

运行时数据区介绍的差不多了,这里补充一个概念叫直接内存,在jdk1.4加入的NIO有用到,感兴趣的可以看看。大家肯定注意到每个区域(除程序计数器)都有抛内存溢出的状况,以后有人问到, 何时会产生OOM,就不要再说内存不够的时候了,很伤感情。

垃圾收集算法

上面提到的Java堆可以说是虚拟机管理的内存中最大的一块了,是GC光顾的常客,因此也叫“GC堆”。GC顾名思义就是垃圾回收,这也是Java一大优势,不用的内存可以自动回收。既然是垃圾回收可以有垃圾回收装置啊,扫地的还用扫帚呢。

图中是我们HotSpot的垃圾收集器,上边是新生代,下边是老年代,具体的垃圾收集器来历作用我就是不介绍,没有必要,本文希望读者有个大概的了解。那么,有垃圾器,总得有方法吧,喝饮料还用吸管呢,吸管什么原理大家没点13数吗?那么在这里大体介绍几种算法的思想。

标记-清除算法

见名知义啊,先标记需要回收的对象,然后一次性清除标记的对象。它可以说是最基础的收集算法,就算是后面介绍的算法都是在它基础上加以改进的。既然改进,那么肯定有无法忍受的缺点,它除了效率不高外,还有个严重的问题,就算会产生大量不连续的内存碎片,从刚刚我们提到的Java对OOM的原因可知,非常容易无法分配而第二次执行垃圾回收,或者直接OOM。执行过程如图所示:

复制算法

这个算法很好理解,将可用内存化为两块,每次只用其中一块,当要回收的时候,把可用的对象复制到另外一块,然后把原先那块一次性清理掉,可用说在效率上大大的提高,但有个致命的弱点就是内存减半。

复制算法执行过程如图所示:

标记-整理算法

复制算法理论上效率很高,但是你想想如果存在100个对象,其中98个都可用的,那么你得复制98个对象,极端情况100个都存活,你还得复制全部一遍,这是无法接受的。该算法针对标记-清楚算法产生大量内存碎片做了改进,先把可用对象移到一端,然后直接清理掉端边界以外的内存。执行过程如图所示:

分代收集算法

从我们刚刚分析来看,复制算法貌似更适合朝生夕屎的对象,而剩余的两个算法更适合“百岁”对象。前者那些对象所在区域我们就叫做新生代,后者对象所在区域就叫做老年代。我们的分代算法就是根据新生代和老年代采用不同的算法而已。

那么,这里有个问题,老年代的对象到底怎么来?换句话问,怎样才能进入老年代?首先,分析一个特例:大对象直接进入老年代;然后是正常步骤:对象A在分配的时候优先分配在新生代的Eden空间,当Eden空间不够分配内存的时候,将进行一次Minor GC,此后对象A仍然存活且能被Survivor空间容纳,那么将移至Survivor空间,并将其年龄计数器置为1,此后,对象A每度过一次Minor GC且存活,年龄就加1,当达到最大年龄(MaxTenuringThreshold)时,将被荣升到老年代(鼓掌鼓掌)。当然这也不是绝对的,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接晋级。

关于本文主要内容差不多就到这了,最后留下一个很关键的问题,垃圾回收器到底什么时候进行垃圾回收,又是如何进行的?这里有个很牛逼的名词叫“Stop The World”。

杂谈

首先,我想说深入理解Java虚拟机(第2版)真的是一本不错的书,我这种小菜鸡根本没机会认识这种大神,也谈不上打广告,看过的同学应该都知道。其次,本文所有的内容均来自于该书,甚至有一字不差的一段话。本文可以说是我读完该书第二部分:自动内存管理机制的笔记。本文很多都属于概念性知识,就比如地球为什么叫地球?这种属于约定俗成的东西,但对于我们Android程序员来说,最好是能够对其有个大概的了解,但不是所有同学都看过该书(买了,也不一定看),因此我分享了该文章,其中有部分是自己的理解,如果有问题我及时改正,最好大家还是买原著仔细阅读,我这里抛砖引玉一下- -!

每天都学习一点点也是极好的。既然是学习,对象肯定是有前辈已经总结了的,你应该做的是将其理解,并转为自己的东西(用自己的思想把它翻译出来,本质不变),不然就叫做探索。还有一句话就是好记性不如烂笔头,老师肯定说过这句话,当时一句都没进我法耳。

最后,感谢一直支持我的人!

在这里,提前祝大家新年快乐!

传送门

Github:github.com/crazysunj/

博客:crazysunj.com/