引言
Android开发中经常会遇到各种内存问题,比如内存溢出,内存泄露,栈溢出等常见的问题,也会经常听到关于内存中的堆的概念和栈的概念,要想更好的解决这些问题,还是得站在一个整体的高度对这些东西有一个全面的认识
jvm虚拟机
每一个Android的app进程都是运行在一个独立的jvm虚拟机上面,通过adb shell ps可以简单的看出用户进程的父进程都是zygote进程,往上追溯就能发现zygote进程里面初始化了一个独立的jvm虚拟机,所以简单的理解就是每个用户进程都是运行在独立的jvm环境中,这样也保证了一个app的崩溃不会影响其他app进程的运行。
不管是java文件还是kotlin文件,通过编译后都是.class的java字节码文件,jvm的类加载机制加载.class文件,规定了一套自己的内存模型,所以Android的应用程序中的内存分配也就跟jvm的内存模型严格对应起来。
抛开jvm中的类加载器和执行引擎,单纯的分析和内存相关的运行时数据区,主要分为线程共享的内存区域和线程独立的内存区域,而在线程共享的区域里面就有我们最常见的堆区,还有一个方法区,而线程独占的区域里面就是常见的概念栈
方法区
每一个jvm有一个方法区,生命周期跟jvm一致,里面放的数据有
类信息(.class类文件解析的信息,用于后续对象的实例)
常量/静态变量(static/static final修饰的变量和常量也分配在方法区)
即时编译期编译的代码(编译就是将.java文件编译成.class文件过程后的内容)
堆区
数组
对象实例(基本上所有通过new关键字实例的对象都分配在对象,当然也存在对象逃逸在栈上分配)
在堆上的对象也是所谓的gc垃圾回收主要回收的目标,而堆里面又有不同的细分区域,在后面会分析到
这一部分是线程共享的,不同的线程都可以进行实例化对象,实例化对象就需要找到对应的.class文件,所以方法区中的是线程共享的,而对象的实例化分配在堆上,不同的线程中持有的是对象的引用,这部分基本上就是一个内存地址,所以这部分也是线程可以共享的
虚拟机栈
这部分是线程私有的,意思就是不同的线程就会有自己的虚拟机栈,而这个栈里面又是存放的一个一个的栈帧
- 栈帧:就是线程中运行的方法,当调用其他方法的时候,就会产生一个新的栈帧压入虚拟机栈,当运行完对应的方法,方法出栈,接着运行之前压栈的方法,保证了方法的执行顺序
//举个栗子
public void main() {
a();
b();
}
private void a() {}
private void b() {}
比如线程执行到main()方法,就会在对应线程的虚拟机栈中产生一个main()方法的栈帧,然后执行到对应的a()/b()方法,又会产生两个栈帧压栈
栈帧的示意结构,不同的方法就会产生不同的栈帧,然后栈帧的结构里面有包含了【局部变量表/操作数栈/动态链接/正常错误返回】
局部变量表
局部变量表就是保存方法中局部变量引用,局部变量的作用域就是在对应的方法体中,属于栈帧私有的变量
操作数栈
虽然更虚拟机栈都是栈,但是一般说的对象分配分配中的栈是指虚拟机栈,这个操作数栈的理解,就跟Java的字节码指令有关系了,会将计算数和符合不断的压栈出栈进行计算,这部分就不细讲了
本地方法栈
本地方法栈就是一些native方法的调用栈,也是线程私有的
程序计数器
这个跟cpu的调度有关,时间片轮转会让不同的线程都会有执行的机会,然后线程在运行的时候,当时间片运行结束,就会挂起,这会就需要记录当前执行的程序字节码位置,以便后续得到时间片后继续运行
小结
上面就分析了jvm运行时数据区的不同分区,jvm就是一套内存模型规范,所以程序运行时就会把不同的数据存储在不同的内存地址区域
堆和栈
上面知道了堆和栈性质,那么对比分析一下
- 用途
堆:用来存储java中实例化的对象,不管是成员变量,局部变量还是类变量,他们引用的实例化对象都是存储在堆中
栈:用来存储方法的调用过程,以栈帧的形式在栈上存储,栈上可以存储方法调用过程中的局部变量,但是仅限于基本数据类型的变量和对象的引用,这部分数据是分配在栈上的,随着方法执行完,栈帧出栈,对应的变量就会被自动释放
- 跟线程的关系
堆:堆中的对象是所以线程共享的,分配在堆上的对象可以被所有线程访问
栈:栈是归属于单个线程,所以栈内存中的变量也不用考虑线程同步的问题,属于线程的私有内存
- 大小
堆:堆的大小跟jvm设计相关,可以通过-Xmx设置堆区内存可被分配的上限,使用-Xms设置堆区的初始大小
栈:栈帧的局部变量表和操作数栈的大小也是编译时确定的,取决于jvm虚拟机的实现,栈的深度也是有限的,如果栈帧超出了限制,就会抛出常见的StackOverFlow的错误
深入堆
一般在开发中,打交道最多的还是堆上的内存对象,内存溢出,内存泄露等问题更多的是出现在堆上的对象没法回收,堆上的内存满了,无法继续分配导致,当然由于机器的内存是固定的大小,也会出现栈上的内存溢出,或者方法区的内存溢出
new对象
当遇到new关键字去实例化一个对象,就会进行类加载检测,如果类加载器中检测出对应的.class文件,就会去堆上分配内存,分配了内存会会对内存空间进行初始化,比如对基本类型设置初始值,然后才是设置对应对象中的赋值和引用
对象的大小
总是在说对象会在堆上占内存,那么一个对象到底占多数内存,对象是由三部分组成的,对象头+实例数据+对齐
对象头
对象头大小占8个字节,里面会包含有对象的哈希值,GC的分代年龄,锁状态,线程持有的锁,偏向线程id等信息,还会包含类型指针,如果对象是数组,对象头中还会记录数组的长度
实例数据
实例数据中不同的基本类型所占的空间大小不一样
对齐
由于计算机的位数,为了更方便的存取对象,会对对象大小进行一个8字节整数倍的对齐
对象的分配
实例化了对象后,就会将对象进行一个堆上的分配,这会就得提到堆上对应的分区,都是一些非常常见的概念
堆内存区域划分了多个区域用来存储不同的对象,这样的目的主要是为了更效率的垃圾回收,主要有新生代和老年代,新生代里面又分了eden区,survive区
整体来说,新生代和老年代的大小是个1:2的关系,新生代中eden区和survive区是一个8:2的关系,survive区又分为了两个一样大小的S0和S1区域
堆上的这么划分,主要就是为了垃圾回收的效率,当一个分区内存分配满了后,都会触发gc,而gc回收又是一个占cpu的行为,也可能停止用户线程,所以更好的分配策略和回收算法,可以提供更好的用户体验
step1
对象的创建基本都是直接分配在新生代的eden区,除了一些大对象会直接进入老年区,很明显的eden区的内存空间有限,分配大对象可能很容易触发gc,所以大对象会直接进入老年代
step2
当eden区满了后,如果之前实例化的对象还存活,就会被复制到S0区域,由于对象的生命特性,eden区中绝大多数的对象都会被回收清理掉,所以8:1:1的设计也是为了更好的空间利用,当从eden去移动到S0区域的时候,对象头上面的分代年龄就会+1
step3
当继续触发gc进行垃圾回收,存活在S0中的对象,就会被标记整理到S1区域中,S0区域格式化清空,然后对象的分代年龄继续+1,随着gc的进行,一直重复在survive区中来回交换
step4
当对象的分代年龄达到15的时候(一般情况,可以设置),长时间的回收不掉,就会把对象移动到老年代,所以老年代中的对象生命周期都比较长,到后续对象直到生命周期结束前都在老年代中,直到被回收
持久代
堆上的内存分为新生代和老年代,对应的还有一个持久代的概念,这个就是最上面jvm内存模型里面的方法区,用来存放类文件和静态类型数据,放在一起对比概念叫法
gc
最后需要提到的概念就是gc,对象实例化后会占据内存,如果内存不够分配,就会触发gc,去回收可以会回收的内存,怎么判断这个可以回收的内存,就是常见的根可达算法,GC Roots
GC Roots
可以作为GC Roots的变量就是一些长期存活或者短期内不会被回收的变量
静态变量/常量池(方法区中,gc回收在堆区,方法区中能跟进程生命周期一致)
线程栈变量(线程的局部变量在栈上存活,方法没有执行完,栈帧不会被回收释放)
JNI指针(也就是本地方法栈,跟线程栈理解类似)
如果一个对象的引用链最头部是一个GC Roots对象,那么对应引用链上的对象就不可被回收,反之,如果没有被GC Roots持有,那就是gc回收的对象
引用类型
上面提到的gc roots引用关系,就是一般的=赋值的强引用,Java中处理强引用,还有其他类型的引用,也一并记录
强引用-> =赋值的操作,如果有gc roots的引用关系,就不可被回收,即使抛出OOM,也不会去回收
软引用-> SoftReference,在内存不足的时候,触发gc,如果gc后还是不足,就会回收软引用对象
弱引用->WeakReference,触发gc就会被回收
虚引用->PhantomRefrence,这个用的比较少,直接通过虚引用get对象会是一个null值,这个的用途设计到一个ReferenceQueue,就不详细分析了
gc清理方式
在知道了哪些对象可以回收的情况下,最后需要了解的概念就是gc在不同的堆内存分代中的回收方式
可能最上面对于新生代的8:1:1的空间划分存在疑惑,这点也主要和清理方式有关
复制算法
复制算法就是把内存空间分成两等分,拿一份作为预留空间,在需要gc回收的时候,将其中回收的区域内存活的对象,复制到对应预留区联系的内存空间内,这样的回收方式后都是连续的内存空间,但是预留一半的,会造成空间的利用率降低,这就是典型的堆区新生代中的survive区1:1的原因,这部分就使用了复制算法进行清理
那么为什么是8:1:1,新生代还得划分一个eden区,而不是5:5直接使用复制算法
因为通常堆中的对象生命周期都比较短,一次gc后存活的对象很少,为了最大程度的提高空间利用率,就使用了8:1:1的比例
从eden取到S0的整理也是复制算法,将还存活的对象直接复制到survive中
标记清理
标记整理算法,就是遍历堆中的存活对象和可清理对象,对需要清除的对象进行标记,然后再统一对这些标记的对象进行回收,这种算法的优点就是不需要内存复制,内存也是100%利用,但是缺点就是会有内存碎片,整理后的内存不是连续的空间
标记整理
相比于标记清理产生内存碎片,标记整理算法,会把存活的对象复制移动到连续的内存空间,这样空间的利用率是100%,但是效率会低很多,整理的时候,比如两个对象中只要1m的空间,整理一个2m的对象过来,还得进行对象的移动
对象的整理会反向触发对象的引用地址的变更,所以对象的移动会有额外的开销
总结
从jvm的内存模型分区,到对象的分配,和对象的回收都进行了一遍梳理,具如果有错误和不足还望路过的大佬指点一二