前言
内存优化,不管是Android还是Java,不可避免的需要对内存有一定了解,必备。最近做内存优化,有必要再回顾下基础。
资料
目录
一、Java运行的内存区域
1、JVM加载类的过程
1.1 Java类加载器
双亲委派机制,首先将加载任务委托给父类加载,依次递归,父类可以完成这个类的加载请求,就成功返回,只有当父类加载器无法完成时,子类加载器才会去尝试自己加载。
启动类加载器(BootStrap) 或者 称 根类加载器
由C++实现,将JAVA_HOME/lib下面的核心类库或-Xbootclasspath选项指定的jar包等虚拟机识别的类库加载到内存中。由于启动类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用。
扩展类加载器(Extension)
由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的,负责将JAVA_HOME /lib/ext或者由系统变量-Djava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器,Java语言实现,父类加载器为null。
系统类加载器(System)
由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,负责将用户类路径(java -classpath或-Djava.class.path变量所指的目录,即当前类所在路径及其引用的第三方类库的路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。没有特别说明,一般情况用户自定义的类加载都以此类加载器作为父类加载器。它的父类加载器为扩展类加载器。
1.2 类加载的过程
加载 Class Loading 过程
-
通过一个类的全限定名来获取定义此类的二进制流
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
-
在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口
验证 ( 加载阶段与连接阶段是交叉进行的 )
-
确保
Class文件的字节流包含的信息符号当前虚拟机的要求,并且不会危害虚拟机自身的安全 -
文件格式验证 (字节流是否符合
Class文件格式规范)-
是否以魔数
0xCAFEBABE开头 -
主、次版本号是否在当前虚拟机处理范围之内
-
常量池的常量中是否有不被支持的常量类型,检查
tag标志 -
指向常量的各种索引值中是否有指向不存在的常量或不符号类型的常量
-
CONSTANT_UTF8_info型的常量中是否有不符号UTF8编码的数据 -
Class文件中各个部分以及文件本身是否有被删除的或附加的其他信息
-
-
元数据验证 (对字节码描述信息的验证,保证不存在不符合
Java语言规范的元数据)-
这个类是否有父类
-
这个类的父类是否继承了不允许被继承的类
-
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
-
类中的字段、方法是否与父类产生矛盾
-
-
字节码验证 (主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段主要是对方法体进行校验分析)
-
保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作
-
保证跳转指令不会跳转到方法体以外的字节码指令上
-
保证方法体中的类型转换是有效的
-
JDK 1.6后的优化,检查"Code"属性的属性表”StackMapTable"属性的状态,JDK 1.7后类型检查失败后不能退回到类型推导。
-
-
符号引用验证
-
符号引用中通过字符串描述的全限定名是否能找到对应的类
-
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
-
符号引用中的类、字段、方法的访问型是否可被当前类访问
-
准备 (正式为类变量(static 修饰)分配内存并设置类变量初始值(int i -> 0),这些变量所使用的内存都将在方法区中进行分配)
解析 (虚拟机将常量池内的符号引用替换为直接引用的过程 )
初始化
-
"<clinit>()"方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。 -
初始化阶段是执行类构造器
"<clint>()"方法的过程。
2、Java运行时内存区域
2.1 方法区
用于存储被虚拟机加载的类信息,常量 ,static变量、即时编译器编译后的代码缓存。常量池也是在方法区的。
类型信息包括类的完整的全路径名称、类型修饰符,
方法信息包括方法名称、方法的返回值类型、方法参数和类型、方法修饰符、方法字节码、异常表(每个异常处理的开始位置、结束位置、代码在程序计数器的偏移地址等)
常量池中包含各种字面量和对类型域和方法的符号引用,可以将常量池看作成一张表
当然,运行时常量池也是方法区的一部分,在类加载到虚拟机后,就会创建对应的运行时常量池,JVM会为每个已加载的类型(类或接口)维护一个常量池,常量池中的数据和数组一样,通过索引访问。这里需要注意,如果构建运行时常量池时,所需要的内存空间超过了方法区所能提供的最大值,JVM就会抛 OutOfMemoryError 异常
JDK 1.6 及之前,Hotspot方法区有永久代,静态变量就存放在永久代上
Jdk 1.7 时,有永久代,但是已经逐步去除永久代,字符串常量池、静态变量移除,保存在堆中
JDK 1.8 及之后,无永久代,类型信息、字段、方法、常量保存在本地的元空间,但字符串常量池、静态变量仍然在堆空间。
至于永久代为什么会被元空间替换?
首先需要明白,元空间,它是一个与堆不相连的本地内存区域,最大可分配内存就是系统可用内存。这样的话,方法区的OOM就不存在了,而且对方法区的调优也比较难,这样改动大概率可以减少一些致命错误。
至于为什么运行时常量池的StringTable也调整到堆中,说是方法区的回收效率很低,在Full GC的时候才会触发,Full GC一般在老年代时会触发,然后就导致了StringTable 的回收率不高,而我们开发中会有大量的字符串被创建,回收效率低的话,会导致方法区内存不足。
2.2 堆
几乎所有对象实例 (经过逃逸分析的不会) 都在堆上分配、数组
常用地分配大小指令
-Xmx 堆最大值
-Xms 堆最小值
-Xmn 新生代的大小
-XX:NewSize 新生代最小值
-XX:MaxNewSize 新生代最大值
逃逸分析
官方解释为
逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收,由于其被其它变量引用。正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;故由于无法回收,即成为逃逸。
看下面代码,分配的内存在栈上
public class Test {
public static void main(String[] args) {
//一千万次 没有垃圾回收
for(int i = 0;i< 10000000;i++){
allocate();
}
}
static void allocate(){
Demo demo = new Demo(2021,2021.0f);
}
static class Demo{
int a;
float b;
public Demo(int a, float b) {
this.a = a;
this.b = b;
}
}
}
2.3 Java虚拟机栈
用于实现方法的调用,每次方法调用就对应栈中的一个栈帧。
线程共享的只有方法区和堆,其它是线程私有的,方法的调用对应着栈帧的出栈和入栈,无论方法正常完成还是异常完成都算作方法结束。
栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表(局部变量表)、操作数栈和指向当前方法所属的类的运行时常量池的引用。每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
局部变量表
- 虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。一个局部变量可以保存一个类型为boolean、byte、char、short、int、float以及对象的引用变量,变量除了作用域会自动释放。
操作数栈
- 它是一个后入先出栈(
LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的max_stacks数据项中,操作数栈的深度都不会超过max_stacks中设置的最大值。
动态链接
- 在一个
class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。 - 这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
方法返回值
- 当一个方法开始执行时,可能有两种方式退出,一种是正常完成退出出口,另一种是异常完成退出出口
- 正常完成退出,可以理解为将返回值传递给方法调用者。
- 异常完成退出,是指方法执行过程中遇到异常,这个异常没有在方法体内部处理,导致方法退出。
- 无论是
Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
对于栈帧的出栈、入栈,可以查看Java虚拟机-Class文件结构
2.4 本地方法栈
本地方法栈类似于虚拟机栈,只是保存的是本地方法的调用,在HotSpot虚拟机的实现中,虚拟机和本地方法栈被合并在一起。
当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单地动态连接并直接调用 native 方法。
2.5 程序计数器
相当于PC寄存器的功能,占用内存很小,可以理解为当前线程执行的字节码的行号指示器,各线程之间独立存储,互不影响。它能保证线程被中断后恢复执行时按中断时的指令进行执行下去。
主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
程序计数器也是JVM中唯一不会OOM(OutOfMemory)的内存区域
3、直接内存
这块内存不是虚拟机运行时数据区的一部分,手机内存剩余多少G?10个G,好的,那么它就可以最大为10个G,但是如果直接内存消耗内存过大,JVM的最大堆内存分配等可能会受影响,毕竟容量固定。
虽然说直接内存不受JVM控制,但是如果超过了指定内存,也是会OOM的。
二、堆内存
1、内存溢出
常见的内存溢出有 栈溢出、堆溢出、方法区溢出、本机直接内存溢出四种类型
针对此类溢出问题,都要开始排查代码,排查参数等,一般为了防护程序,都需要建立一套内存监控体系,超过指定阈值告警,这时候就要开始根据日志分析问题、优化问题
2 、虚拟机优化技术
2.1、编译优化技术
- 方法内联
将方法内代码复制到我们调用的代码中,例如下面这种
public static void main(String[] args) {
int c = allocate(1,2);
// int c = 1 + 2;
}
static int allocate(int a,int b){
return a+b;
}
因为每次方法调用都对应着栈帧的入栈和出栈,所以我们可以直接 在编译时候确定的代码,直接复制到main方法中,如注释处代码 int c= 1 + 2
2.2、栈的优化技术
- 栈帧之间数据共享
A方法调用B方法,A方法的参数是在局部变量表里面,B方法的参数是在操作数栈。就是A和B的这两块共享一块区域来传递参数
类似代码如下,main方法执行期间,调用了 get 方法,参数传递了一个 10。
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.get(10);
}
public int get(int x){
int z = x+1;
return z;
}
}
3、对象创建过程
先看下一个常见的问题,下面Person对象实例化过程
Person person = new Person();
-
类加载
-
JVM遇到一条字节码new的指令,检查类是否有加载,是否能在常量池中定位到符号引用,需要加载到内存中。其实就是类加载的验证阶段,确保Class文件是正确的,例如魔数、主次版本号、符号引用等, -
再是准备阶段,即分配内存。内部会通过指针碰撞(通过指针不断尝试移动对象大小距离,大多在内存连续场景下)、空闲列表(当内存不连续时,维护一个空闲列表,清晰直到可用内存区域) 两种方式。对于内存分配的并发安全问题,内部有CAS失败重试、本地线程分配缓冲两种方式。准备阶段的内存分配后,会为类变量、常量赋初始默认值,
static final类型的,会直接赋上具体值。 -
然后就是解析阶段,将常量池内的符号引用替换为直接引用的过程
-
最后是初始化阶段,对象的初始化
4、对象内存的布局空间
对象内存结构,由三部分构成,分别是对象头、对象实例、对齐填充。
对象头包括两部分,第一部分是markword,用于存储对象自身运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。第二部分是类类型指针,即对象指向它的元数据指针,虚拟机通过这个指针来确定这个对象是哪个实例的。**如果是数组对象的话,那么对象头中还必须有一块数据记录数组长度。**对象实例这部分则是对象真正存储的有效信息,页时程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的还是在子类中定义的,都需要记录下来。对齐填充不是必须的,仅仅起着占位符的作用,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
5、对象访问定位
比如之前Person对象创建过程中的对象是如何定位的,为什么这个存在栈上、那个存放堆上能访问
如果对象移动了,对象实例数据地址变了,句柄池会自动去定位新的对象实例地址。因为对象创建很频繁,所以这块自动指针定位开销会比较大,所以一般都是用直接指针这种方式。
6、判断对象的存活
引用计数法
一个地方引用它就加1,失效就减1,=0就说明不可能再被引用,如果两个对象互相引用,这就没法回收了
可达性分析
通过判断一个对象到GC ROOT没有引用链相连时,则不可达
可以作为GC ROOT的有 静态变量、线程栈变量、常量池、JNI指针等等,典型的Handler内存泄漏,就是因为Thread持有了Activity引用
7、四种引用
- 强引用 生命周期长,永不回收
- **软引用 ** 生命周期短,内存不足时,才会回收。当对象回收后,会将软引用放到我们的
ReferenceQueue中。例如图片加载,有些图片不是非得显示,只需要显示屏幕内的,可以使用软引用。常见图片缓存、网页缓存。 - 弱引用 生命周期比软引用更短,一旦发现弱引用对象,无论当前内存空间是否充足,都会将弱引用回收。当内存回收后,会将弱引用放到我们的
ReferenceQueue中。一些缓存、不是很重要的数据也完全可以用弱引用。 - 虚引用 生命周期最短,如果一个对象仅持有虚引用,那么它相当于没有引用,在任何时候都可能被垃圾回收器回收。配合
ReferenceQueue使用,能在这个对象被收集器回收时收到一个系统通知。
8、对象的分配策略
-
新生代占空间的
1/3,老年代占空间的2/3 -
对象首先在
Eden区分配,Eden区和From区、To区 比例 8:1:1,**为什么会是这个比例呢?**因为这个比例能使内存空间利用率达到90%,加上对象都是朝生夕死的、创建频繁。所以复制算法用于新生代还是比较合适的。这里需要注意一种情况,如果新生代内存不足于分配某个大对象,那么会利用空间分配担保,直接进入老年代。 -
垃圾收集器主要回收堆,每次
Minor GC后,即新生代GC,存活的对象会进入到From区,GC年龄+1,然后Eden扩大对象,又触发了GC,Eden区存活的移动到To区,From存活的也移动到To区,年龄+1,如果再触发了GC,To区存活的将移动到From区,直到到了设定的GC年龄后,存活的对象将进入到老年代。并且Eden、From、To区任何一个区域满了都会触发GC。 -
经过
15次后,存活对象从新生代会移动到老年代。 -
Full GC的话会回收新生代、老年代、方法区
9、垃圾收集器
9.1、垃圾收集算法
-
复制算法
- 将可用内存按让容量分为相等的两块,每次都只使用其中一块, 不足,将内存缩小了一半,没有大量内存回收时,太过浪费了。 (适用新生代,内存没有那么多要回收的)。
-
标记-清除算法
- 利用率百分之百,不需要内存复制,但是可能会有大量不连续的内存碎片。
-
标记-整理算法
- 让存活的对象移向一端。 适用老年代,利用率也是百分之百,没有内存碎片,需要进行内存复制,效率一般。
9.2、常见的垃圾收集器
单线程与多线程、并发收集器配合使用
至于单线程和多线程间的切换,刚开始内存占用比较小,到后面内存越来越大,就会从单线程切换到多线程
对比多线程的Parallel Scavenge 、Parallel Old和 Serial 、Serial Old,算法都一样,差别主要在于前者是并行的垃圾收集器,回收效率高。
家里打扫卫生的时候,一般都会叫你别制造垃圾了,然后开始清理卫生,否则卫生啥时候能打扫好。同理,垃圾收集器也是这么干的。
不管单线程还是多线程,在执行GC时,都会暂停所有用户线程。
但有时候有客人来了,你不可能要人家别嗑瓜子了,所以你只能默默让家人别磕了。同理,引申出了并发垃圾收集器,垃圾回收器线程和用户线程可以同时工作。例如CMS,不过CMS只适用于老年代,G1 适合新生代、老年代。
9.3、CMS垃圾收集器
CMS(Concurrent Mark Seep)使用的是 标记-清除算法,目前只针对老年代
-
初始标记,暂定所有用户线程,时间短
-
并发标记,同时进行
-
重新标记,会暂停所有用户线程,时间短
-
并发清除,同时进行。如果这个节点产生了垃圾,是不会处理的,这些就是浮动垃圾,等待下次GC流程。
9.4、G1垃圾收集器
区别最大的是,最终标记后,需要再次筛选回收,主要是为了追求停顿时间,减少卡顿。
新生代采用复制算法,老年代采用标记-整理算法。
内存布局也不一样了,减少了一个复制对象的过程,例如下面每个框框内,可能可以回收80%,60%等。分配大对象时,超过了一个框框所占的内存大小,通过分配连续的H空间。
三、Android内存分配
在Android系统中,没有为内存提供交换区,它使用 paging与 memory-mapping(mmapping)的机制来管理内存,其中堆是一块匿名共享内存,C层进行管理。
Zygote进程的作用
-
启动虚拟机
-
注册
JNI函数 -
JNI调用到Java层 -
预加载资源、常用的类、主题相关资源
-
fork出SystemServer(单线程fork) -
进入
SocketLoop循环等待消息
这块有个资源共享,Zygote和子进程会共享这些预加载的资源,这块是如何做到共享的?
- 大多数情况下,
Android通过显式的分配共享内存区域(例如ashmem或gralloc)来实现动态RAM区域能够在不同进程之间进行共享的机制。比如,Window Surface在App与Screen Compositor之间使用共享的内存,Cursor Buffers在Content Provider与Clients之间共享内存。
Dalvik与ART的区别
-
寄存器是
CPU组成部分,寄存器是有限存储容量的高速存储部件,可用来暂存指令、数据和位址。JVM中对于操作,都需要将数据在局部变量表和操作数栈移动。 -
Dalvik基于寄存器的虚拟机,寄存器也存放在运行时栈中,本质是一个数组,与JVM类似,在Dalvik VM中,每个线程都有自己的PC和调用栈,方法调用的活动记录以帧为单位保存在调用栈中。相比JVM,Dalvik的程序指令数会减少很多,数据移动次数也减少很多。因为它把操作数栈和局部变量表合成了一个虚拟寄存器。 -
Dalvik执行的是dex字节码,解释成机器码执行。从Android 2.2开始,,支持了JIT即时编译,在程序运行过程中进行选择热点代码进行编译或者优化-
而
ART是android 4.4中引入的一个开发者选项,也是Andorid 5.0及更高版本的默认Android运行。ART虚拟机执行的是本地机器码。Android运行时从Dalvik虚拟机替换成ART虚拟机,并不要求开发者将自己的应用直接编译成机器码,APK仍然是一个包含DEX字节码的文件。 -
ART虚拟机执行的本地机器码从哪里来?Dalvik下安装时,会执行一次优化,将dex字节码进行优化成odex文件,而ART下将引用的dex文件码翻译成本地机器吗,时机也是在应用安装的时候,ART引入预先编译机制,使用设备自带的dex2oat工具来编译应用,dex中的字节码被编译成本地机器码。 -
Android N开始 混合编译。AOT解释和JIT-
安装时不进行任何的
AOT编译,安装加快了。运行过程中解释执行,对经常执行的方法进行JIT,经过JIT编译的方法记录到Profile配置文件中。 -
当设备闲置或充电时,编译守护进程会运行,根据
Profile文件对常用代码进行AOT编译。待下次运行时直接使用。
-
-
Dalvik 与 ART 内存分配模型
Android应用切换内存管理
-
Android系统并不会在用户切换应用的时候执行交换内存操作。Android会把那些不包含Foreground组件的应用进程放到LRU Cache中。例如,当用户开始启动一个应用时,系统会为它创建一个进程。但是当用户离开此应用,进程不会立即被销毁,而是被放到系统的Cache当中。如果用户后来再切换回到这个应用,此进程就能够被马上完整地恢复,从而实现应用的快速切换。 -
如果你的应用中有一个被缓存的进程,这个进程会占用一定的内存空间,它会对系统的整体性能有影响。因此,当系统开始进入
Low Memory的状态时,它会由系统根据LRU的规则与应用的优先级,内存占用情况以及其他因素的影响综合评估之后决定是否被杀掉。
四、Android垃圾收集器
Dalvik时代,主要是标记-清除(Mark-Sweep)算法和 CMS中类似算法。在CMS的基础上,有了一个分代的CMS,增加了GCMap来增加JIT。这个时代,Android系统并不会堆堆中空闲的内存区域做碎片整理,系统仅仅会在新的内存分配之前检查Heap尾端剩余空间是否足够,如果不够会触发GC操作,从而腾出更多空闲的空间。
Dalvik领盒饭后,ART实现了标记-整理算法、复制算法和CMS算法,CMS是默认的算法,引入了一些技术来解决碎片问题。Android N 后引入了 Concurrent Copying (CC)算法,CC也做了个分代,其中也包括增量垃圾收集器。