今天开始学习性能调优,跟着网上大神的blog整理。方向是刘望舒大神的《Android进阶解密》
性能调优有分很多种:
既然要深入到这些优化去,仅仅是掌握一些工具 TraceView、Lint、LeakCanary是不够的,我们要去学习更多的知识、框架,从系统源码、虚拟机即低层的角度去看待这些优化。
所以在去学工具框架之前,我们有必要从头梳理一遍Android本身。第一步,就是理解Java虚拟机。
我们常用的 JDK、JRE都是建立在 JVM的基础上。它有各种指令集和运行时数据区域。虽然叫做Java虚拟机,但其实在它之上运行的语言可不仅仅是Java,还包括Koltin、Groovy、Scala等。所以就算你用Kotlin开发Android,你也会和JVM打交道。
本篇也并不完全的解析JVM,也没有必要,只是从Android开发的角度,我们需要去理解JVM的哪些东西。
需要学的大概是:
注意的是,Android中的Dalvik和ART并不属于JVM。
当我们执行一个Java程序时,它的执行流程如图所示:
图中可以看出,JVM执行流程分为两个部分,分别是编译时环境和运行时环境,当一个Java文件经过Java编译器编译后会生成一个 .class
文件,这个 .class
会交由JVM来处理。
Jvm和Java语言没有什么必然的联系,它只跟特定的二进制文件 Class文件有关。所以任何语言只要能编译出 .class
文件,就能被JVM识别且执行。
这里讲的结构,并不是JVM物理上的结构,而且是其实现逻辑,是抽象层面上的结构。
我说我是个车轮,是因为我走路的时候把自己当成车轱辘来滚,而不是我真的是个轮子。
按照Java虚拟机规范,抽象的JVM如图所示:
可以看出Java虚拟机包括 运行时数据区域、执行引擎、本地库接口和 本地方法库。类加载子系统并不时JVM的内部结构。
在这些区域里,像 方法区、Java堆、本地库接口,垃圾回收器、即时编译器都是线程共享的。
Java文件被编译后生成了 Class文件,这种二进制格式的文件不依赖与特定的硬件和操作系统。
每一个class文件都对应着唯一的类或接口的定义信息,但是类或者接口并不一定定义在文件中,比如类可以通过类加载器直接生成。之前说过,任何语言只要能编译成Class文件,就可以被Java虚拟机识别并且执行,Class文件的重要性可见一斑。
下面我们来学习Class文件格式:
ClassFile {
u4 magic; // 魔数,表明当前文件是.class文件,固定0xCAFEBABE
u2 minor_version; // Class文件的副版本号
u2 major_version; //Class文件主版本号
u2 constant_pool_count; // 常量池计数
cp_info constant_pool[constant_pool_count-1]; // 常量池内容
u2 access_flags; // 类/接口访问标识
u2 this_class; // 当前类索引
u2 super_class; // 父类索引
u2 interfaces_count; // 接口计数器
u2 interfaces[interfaces_count]; // 接口表
u2 fields_count; // 字段计数器
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法计数器
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 属性计数器
attribute_info attributes[attributes_count]; // 属性表
}
其中:uX 代表 X字节的无符号类型。比如u4就是4字节的无符号类型
一个Java文件被加载到JVM内存中到从内存中被卸载的过程被称为类的生命周期。
类的生命周期包括的阶段分别是:加载、链接、初始化、使用和卸载。其中链接包括验证、准备和解析。因此类的生命周期被分为了7个阶段,顺序如下所示。
其中前三个阶段称为类的加载阶段。
在《深入理解JVM》中,上述第一点,加载阶段(非类加载阶段)主要做了3件事情:
其中第一件事情就是由Java虚拟机外部的类加载子系统来完成的。
类加载子系统通过多种类加载器来查找和加载Class文件到JVM中,JVM有两种类加载器,分别是系统加载器和自定义加载器。之前对类加载机制做过理解:Java ClassLoader总结
这里就复制其中比较关键的东西吧:
%JAVA_HOME%/jre/lib
, -Xbootclasspath
参数指定的路径以及%JAVA_HOME%/jre/classes中的类。%JAVA_HOME%/jre/lib/ext
这个路径下所有的classes目录以及java.ext.dirs
系统变量指定路径中的类库。Classpath
目录,以及系统属性java.class.path
所指定位置的类或者jre文档,它也是Java的默认加载器。关于ClassLoader的学问我们后边再写一篇,加深理解
Java的内存不仅仅是堆内存和栈内存。
1.程序计数器
为了保证程能够连续的执行下去,处理器必须具有某些手段来确定下一条指令的地址。而程序计数器正是起到这种作用。
程序计数器也叫PC寄存器,是一块较小的内存空间。在虚拟机概念模型中,字节码解释器的工作时就时通过改变程序计数器来选取下一个条需要执行的字节码指令的。
JVM的多线程是通过轮流切换并分配处理器执行时间的方式来实现的。在一个确定的时刻只有一个处理器执行一条线程中的指令。为了在线程切换后能恢复到正确的执行位置,每个线程都会有一个独立的程序计数器,因此程序计数器是私有的。
如果线程执行的方法不是native方法,则程序计数器保存在正在执行的字节码指令地址,否则程序计数器的值为空。程序计数器是JVM规范中唯一没有任何OOM情况的数据区域
2.Java虚拟机栈
每一条Java虚拟机线程都有一个线程私有的Java虚拟机栈。它的生命周期与线程相同。
Java虚拟机栈存储线程中Java方法调用的状态,比如局部变量、参数、返回值以及运算的中间结果等。
一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储局部变量、操作数栈、动态链接、方法出口等信息。当线程调用一个Java方法时,虚拟机就压入一个新的栈帧到该线程的Java虚拟机栈中,在该方法执行完成后,这个栈帧就从Java虚拟机栈中弹出。
Java虚拟机规范中定义了两种异常情况:
因为大部分JVM都是可以扩展的,所以相比于爆栈,我们见到OOM的情况更多。
3.本地方法栈
JVM可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈。
它与JVM栈类似,只不过本地方法栈是用来支持Native方法的,如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无需支持本地方发展。Jvm可以自由的实现本地方法栈,比如 HotSpot VM将本地方发展和Java虚拟机栈合二为一。
本地方法栈也会抛出 StackOverflowError和OutOfMemoryError的异常。
4.Java堆
Java堆是被所有线程共享的运行时内存区域。Java堆用来存放对象实例。
几乎所有的对象实例都在这里分配内存。Java堆存储的对象被垃圾收集器管理,这些受管理的对象无法显式的销毁。
从内存回收的角度来分,Java堆可以粗略的分为新生代和老年代。
从内存分配的角度来分,Java堆中可能划分出多个线程私有的分配缓冲区。
Java虚拟机规范中定义了一种异常情况:如果在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展式时,也会抛出OutOfMemoryError异常。
5.方法区
方法区是被线程共享时的内存区域,用来存储已经被Java虚拟机加载的类的结构信息。包括运行时常量池、字段和方法信息、静态变量等数据。方法区是Java堆的逻辑组成部分,它一样在物理上不用连续,并且可以选择在方法区中不实现垃圾收集。
方法区并不等同于永久代,只是因为HotSpot VM使用永久代来实现方法区,对于其他的JVM,比如J9和JRockit等,并不存在永久代等概念。
如果方法区不满足内存分配需求时,JVM也会抛出OOM异常。
6.运行时常量池
并不是运行时数据区域的一份子,而是方法区的一部分。
在前面的Class文件结构中我们看到了,Class文件不仅包含类的版本号、接口、字段等,还包含常量池。
它用来存放编译时期生成的字面量和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。
运行时常量池可以理解为是类或接口的常量池的运行时表现形式。
对象的创建是我们经常要做的事情,通常是通过new指令来完成一个对象的创建,当虚拟机接收到一个new指令时,它会做如下的操作:
init()
,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建出来的。PS:单从上面就可以知道,创建一个对象其实也会造成一定的COST,所以看了这些东西,你还会轻易的去new对象吗?你还会再onDraw() 里面去new对象吗?所以也请把对象的创建看成是一个轻微级的操作来看!
我们已经知道对象被创建了,堆又给对象分配了空间,那么对象在堆内存是如何进行布局的呢,它长的是什么样的呢?就是上一节所讲的,对象头是啥?
以HotSpot VM为例,对象在堆内存的布局分为三个区域:
Mark Word在HotSpot中的实现类为markOop.hpp
,markOop被设计成一个非固定的数据结构,这是为了在极小的空间中存储尽量多的数据。
32位虚拟机的markOop格式如下:
//32位的markOop
hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
size:32 ------------------------------------------>| (CMS free block)
PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
数据的解释为:
Mark Word经常被用于研究锁的状态,我之前在做关于对象锁的理解时,也有写过这种东西,只是角度不同,是从锁追溯到Mark Word,而这里是从Mark Word追溯到锁,这里对锁就不多做细讲了,这里有两篇:Java中的几种锁机制
Synchronized的锁优化
这里小结一下:
一个进程的启动就能产生一个JVM,一个JVM上有多个线程。在程序运行的时候,JVM上堆会分配了很多个对象的内存空间。
当一个线程想要使用堆上的某一个对象时,会先去访问这个对象的Mark Word,看看这个锁,这个类锁、这个对象锁是不是能用(就是是不是被别的线程使用了),如果可以用,那就用,如果用不了,就根据锁的状态进行 自旋/等待 or …
oop-klass是用来描述Java对象实例的一种模型,它分为两个部分:
在HotSpot中就采用了 oop-klass模型,oop实际上是一个家族,JVM内部会定义很多oop类型,如下所示:
typedef class markOopDesc* markOop; //oop标记对象
typedef class oopDesc* oop; //oop家族的顶级父类
typedef class instanceOopDesc* instanceOop; //表示Java类实例
typedef class arrayOopDesc* arrayOopDesc*; //数组对象
typedef class objArrayOopDesc* objectArrayOopDes //引用类型数组对象
typedef class typeArrayOopDesc* typeArrayOop; //基本类型数组对象
其中oopDesc是所有oop的顶级父类,arrayOopDesc是objArrayOopDesc和typeArrayOopDesc的父类。
instanceOopDesc*和arrayOopDesc都可以用来描述对象头。
还定义了 klass家族:
class Klass; //klass家族的父类
class InstanceKlass; //描述Java类的数据结构
class InstanceMirrorKlass; //描述java.lang.Class实例
class InstanceClassLoaderKlass; //特殊的InstanceKalss,不添加任何字段
class InstanceRefKlass; //描述Java.lang.ref.Reference的子类
class ArrayKlass; //描述Java数组信息
class ObjectArrayKlass; //描述Java中引用类型数组的数据结构
class TypeArrayKlass; //描述Java中基本类型数组的数据结构
其中Klass是klass家族的顶级父类。ArrayKlass是ObjArrayKlass和TypeArrayKlass的父类。
可以发现 oop家族的成员和klass家族成员有着对应的关系。
比如instanceOopDesc对应InstanceKlass。objArrayOopDesc对应ObjeArrayKlass。
我们来看看oop的顶级父类 oopDesc的定义:
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
wideKlassOop _klass;
narrowOop _compressed_klass;
} _metadata;
// Fast access to barrier set. Must be initialized.
static BarrierSet* _bs;
...
}
oopDesc中包含了连个数据成员:mark和 _metadata。
其中markOop
类型的mark对象指的是前面讲到的Mark Word。
metadata
是一个共用体,其中的klass是普通指针,_compressed_klass
是压缩类指针。这两个就是对象头中的元数据指针。他们根据对应关系都会指向instanceKlass,instanceKlass用来描述元数据。instanceKlass继承自klass
,我们来看看 klass的定义:
jint _layout_helper; //对象布局的综合描述符
Symbol* _name; //类名
oop _java_mirror; //类的镜像类
Klass* _super; //父类
Klass* _subklass; //第一个子类
Klass* _next_sibling; //下一个兄弟节点
jint _modifier_flags; //修饰符标识
AccessFlags _access_flags; //访问权限标识
可以看到klass描述了元数据。具体来说就是Java类在Java虚拟机中对等的C++类型描述。
这样继承自klass的instanceKlass同样可以用来描述元数据。
了解了oop-klass模型,我们就可以分析JVM是如何通过栈帧中的对象引用找到对应的对象实例的。
在Java对象被类被加载到虚拟机中后,Java对象在Java虚拟机中有7个阶段:
finalize()
,则会调用该方法finalize()
仍然处于不可达状态时,或者对象没有重写finalize()
,则该对象进入终结阶段,并等待垃圾回收器回收该对象空间。至于不可见和不可达状态,我们就要在垃圾回收器算法中取解释它。
之前写过,而且还挺详细挺准确,所以这节不会赘述。
在这里:GC算法与种类,通过这篇去学习GC。
在看之前,先要知道的是,垃圾标记算法用于标记对象现在是处于什么阶段。它有两种方式:引用计数法和根搜索算法。
而垃圾收集算法是收集垃圾的机制。它分为标记-清除法、复制算法、标记-压缩算法和分代收集算法
到这里算是介绍完了。JVM体系是异常庞大的,这一篇Blog讲的是JVM非常少的一部分内容。
但是对于Android开发者来说,或者,对于想要了解优化Android性能的开发者来说,掌握Java虚拟机的结构、oop-klass模型、GC机制对于Android开发者来说已经够用了。如果想要深入理解JVM的知识,那就需要去看专门讲JVM的书。