一、前言

希望能在我们平时开发写代码的时候,能够知道当前写的这段代码,内存方面是如何分配的。
我们深知,一个Java程序员在很多时候根本不用操心内存的释放,而是依靠JVM去管理,以前写C++代码的时候,却要时刻记着new的空间要及时释放掉,不然程序很容易出现内存溢出的情况。因为,Java在这方面确实方便了许多,让我们有更多精力去考虑业务方面的实现。但是,这并不意味着我们就能肆无忌惮的使用内存,因为:

1.JVM并不会及时的去清理内存
2.我们无法通过代码去控制JVM去清理内存

这就要求我们平时在开发过程中,要了解JVM的垃圾回收机制,合理安排内存。那么怎么样才能合理安排内存呢?那么就需要我们了解JVM的内存分配机制,而后才能真正控制好,让程序运行在我们鼓掌之中。

二、JVM内存模型

平时我们对于Java内存都有一个比较粗略的概念,就是分堆和栈,但实际上还是复杂得多,以下给出完整内存模型:

相对应区域的内容为:

程序计数器PC

这一个区域我概括了以下几个要点:

1.这一区域不会出现OOM(Out Of Memory)错误的情况
2.属于线程私有,因为每一个线程都有自己的一个程序计数器,来表示当前线程执行的字节码行号
3.标识Java方法的字节码地址,而不是Native方法
4.处于CPU上,我们无法直接操作这块区域

虚拟机栈

这个区域也是我们平时口中说的堆栈的栈,关于这个块区域有如下要点:

1.属于线程私有,与线程的生命周期相同
2.每一个java方法被执行的时候,这个区域会生成一个栈帧
3.栈帧中存放的局部变量有8种基本数据类型,以及引用类型(对象的内存地址)
4.java方法的运行过程就是栈帧在虚拟机栈中入栈和出栈的过程
5.当线程请求的栈的深度超出了虚拟机栈允许的深度时,会抛出StackOverFlow的错误
6.当Java虚拟机动态扩展到无法申请足够内存时会抛出OutOfMemory的错误

本地方法栈

这个区域,属于线程私有,顾名思义,区别于虚拟机栈,这里是用来处理Native方法(Java本地方法)的,而虚拟机栈是处理Java方法的。对于Native方法,Object中就有不少的Native的方法,hashCode,wait等,这些方法的执行很多时候都是借助于操作系统。

这一区域也有可能抛出StackOverFlowError 和 OutOfMemoryError

Java堆

我们平时说得最多,关注得最多的一个区域,就是他了。我们后期进行的性能优化主要针对这部分内存,GC的主战场,这个地方存放的几乎所有的对象实例和数组数据。而在虚拟机栈中分配的只是引用,这些引用会指向堆中真正存储的对象。这里我大概进行了如下概括:

1.Java堆属于线程共享区域,所有的线程共享这一块内存区域
2.从内存回收角度,Java堆可被分为新生代和老年代,这样分能够更快的回收内存
3.从内存分配角度,Java堆可划分出线程私有的分配缓存区(Thread Local Allocation Buffer,TLAB),这样能够更快的分配内存
4.当Java虚拟机动态扩展到无法申请足够内存时会抛出OutOfMemory的错误

方法区

方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。GC在该区域出现的比较少。概括如下:

1.方法区属于线程共享区域
2.习惯性叫他永久代
3.垃圾回收很少光顾这个区域,不过也是需要回收的,主要针对常量池回收,类型卸载
4.常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性, 里面可以存放编译期生成的常量
5.运行期间的常量也可以添加进入常量池中,比如string的intern()方法。

运行时常量池

运行时常量池也是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。单独拿出来说明一下,是因为我们平时使用String比价多,涉及到这一块的知识,但这一块区域不会抛出OutOfMemoryError

三、垃圾标记算法

垃圾收集器(Garbage Collection),通常被称作GC。GC主要做了两个工作:

  • 内存的划分和分配,
  • 对垃圾进行回收。

关于内存的划分和分配,目前Java虚拟机内存的划分是依赖于GC设计的,比如现在GC都是采用了分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域,被细分为新生代和老年代,再细致一点新生代又可以划分为Eden空间、From Survivor空间、To Survivor空间等,这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。关于对垃圾进行回收,被引用的对象是存活的对象,而不被引用的对象是死亡的对象(也就是垃圾),GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。

在对垃圾进行回收前,GC要先标记出垃圾,那么如何标记呢?目前有两种垃圾标记算法:

  • 引用计数算法
  • 根搜索算法

1、引用计数算法:

​ 引用计数算法的基本思想就是每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1,引用失效时就减1。当引用计数器中的值变为0,则该对象就不能被使用,变成了垃圾。

​ 目前主流的Java虚拟机没有选择引用计数算法来为垃圾标记,主要原因是引用计数算法没有解决对象之间相互循环引用的问题。举个例子,在下面代码的注释1和注释2处,tom和mike相互引用,除此之外这两个对象无任何其他引用,实际上这两个对象已经死亡,应该作为垃圾被回收,但是由于这两个对象互相引用,引用计数就不会为0,如果Java虚拟机采用了引用计数算法,垃圾收集器就无法回收它们。

class Student {Student friend;}//Student s1 = new Student();Student s2 = new Student();s1.friend = s2;// 1s2.friend = s1;// 2s1 = null;s2 = null;

优点
引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点
无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

2、根搜索算法

​ 这个算法的基本思想就是选定一些对象作为GC Roots,并组成根对象集合,然后以这些GCRoots的对象作为起始点,向下搜索,如果目标对象到GC Roots是连接着的,我们则称该目标对象是可达的,如果目标对象不可达则说明目标对象是可以被回收的对象,如图下图所示。

从上图可以看出,ObjF、ObjD和ObjE都是不可达的对象,其中ObjD和ObjE虽然互相引用,但是因为它们到GC Roots是不可达的,所以它们仍旧被判定为可回收的对象,这样根搜索算法就解决了引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。

四、JVM内存源码示例说明

package senduo.com.memory.allocate;/** * ***************************************************************** * * 文件作者:ouyangshengduo * * 创建时间:2017/8/11 * * 文件描述:内存分配调用过程演示代码 * * 修改历史:2017/8/11 9:39************************************* **/public class MemoryAllocateDemo {public static void main(String[] args){ //JVM自动寻找main方法/** * 执行第一句代码,创建一个Test实例test,在栈中分配一块内存,存放一个指向堆区实例对象的指针 */Test test = new Test();/** * 执行第二句代码,声明定义一个int型变量(8种基本数据类型),在栈区直接分配一块内存存储这个变量的值 */int date = 9;/** * 执行第三句代码,创建一个BirthDate实例bd1,在栈中分配一块内存,存放一个指向堆区实例对象的指针 */BirthDate bd1 = new BirthDate(13,6,1991);/** * 执行第四句代码,创建一个BirthDate实例bd2,在栈中分配一块内存,存放一个指向堆区实例对象的指针 */BirthDate bd2 = new BirthDate(30,4,1991);/** * 执行第五句代码,方法test1入栈帧,执行完出栈 */test.test1(date);/** * 执行第六句代码,方法test2入栈帧,执行完出栈 */test.test2(bd1);/** * 执行第七句代码,方法test3入栈帧,执行完出栈 */test.test3(bd2);}}

参考

  • Android 虚拟机、对象、变量的内存分配

  • Android性能调优篇之探索JVM内存分配

  • Android 从内存模型深究内存优化本质

  • 深入理解Java虚拟机——Java内存区域

  • android studio profiler 内存分析用法【官网】

  • 内存优化 · 基础论 · 初识Android内存优化

  • Android内存优化深入解析