• 重学Android基础系列篇(五):Android虚拟机指令


    前言

    本系列文章主要是汇总了一下大佬们的技术文章,属于Android基础部分,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~

    [非商业用途,如有侵权,请告知我,我会删除]

    DD一下: Android进阶开发各类文档,也可关注公众号获取。

    1.Android高级开发工程师必备基础技能
    2.Android性能优化核心知识笔记
    3.Android+音视频进阶开发面试题冲刺合集
    4.Android 音视频开发入门到实战学习手册
    5.Android Framework精编内核解析
    6.Flutter实战进阶技术手册
    7.近百个Android录播视频+音视频视频dome
    .......
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    Android虚拟机指令

    1.指令集解读

    1.1 JVM 跨语言与字节码

    JVM是跨语言的平台,很多语言都可以编译成为遵守规范的字节码,这些字节码都可以在Java虚拟机上运行。Java虚拟机不关心这个字节码是不是来自于Java程序,只需要各个语言提供自己的编译器,字节码遵循字节码规范,比如字节码的开头是CAFEBABY

    将各种语言编译成为字节码文件的编译器,称之为前端编译器。而Java虚拟机中,也有编译器,比如即时编译器,此处称为后端编译器。

    Java虚拟机要做到跨语言,目前来看应该是当下最强大的虚拟机。但是并非一开始设计要跨语言。

    1.1.1 跨语言的平台有利于什么?

    由于有了跨语言平台,多语言混合编程就更加方便了,通过特定领域的语言去解决特定领域的问题。

    比如并行处理使用Clojure语言编写,展示层使用JRuby/Rails,中间层用Java编写,每一应用层都可以使用不同的语言编写,接口对于开发者是透明的。不同语言可以相互调用,就像是调用自己语言原生的API一样。它们都运行在同一个虚拟机上。

    1.1.2 何为字节码?

    字节码狭义上是java语言编译而成,但是由于JVM是支持多种语言编译的字节码的,而字节码都是一个标准规范,因为我们应该称其为JVM字节码。

    不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同操作系统上的不同JVM中运行。

    因此,Java虚拟机实际上和Java语言并非强制关联的关系,虚拟机只和二级制文件(Class文件)强关联。

    1.2 class字节码解读
    1.2.1 Class类文件结构

    Class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的地排列在文件之中,中间没有添加任何分隔符,这使得整个class文件中存储的内容几乎全部都是程序的必要的数据。当遇到需要占用8字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8字节进行存储。

    Class文件格式只有俩种数据类型:“无符号数”和“表”。

    • 无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照utf-8编码构成的字符串值。
    • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性的以“_info” 结尾。表用于描述有层次关系的复合结构的数据,整个class文件本质上也可以是一张表,按严格顺序排列构成。

    如下图,为class类结构:

    2.1.1 class文件格式:

    • 魔数和class文件的版本:每个class文件的头4个字节被称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的class文件。紧接着魔数的四个字节存储的是class文件的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号。Java的版本号是从第45开始的。
    • 常量池,紧接着主、次版本号之后的是常量池的入口,常量池可以比喻成class文件里面的源仓库,它是class文件结构中与其他项目关联最多的数据,通常也是占用class文件空间最大的数据项目之一,另外,他还是class文件中第一个出现的表类型的数据项目。常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值。这个容量的计数是从1开始的不是从0开始。常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java层面的常量概念,如文本字符串、被声明为final的常量值等。符号引用则包括下面几类常量:
      • 被模块导出或者开放的包
      • 类和接口的全限定名
      • 字段的名称和描述符
      • 方法的名称和描述符
      • 方法句柄和方法类型
      • 动态调用点和动态常量

    常量池中每一项常量都是一个表,截至到jdk13,常量表中分别有17种不同类型的常量。

    • 访问标志(access_flag):在常量池结束之后,紧接着的2个字节代表访问标志,这个表示用于是被一些类或者接口层次的访问信息,包括:这个class是类还是接口;是否定义为public;是否定义为abstract类型,等等。access_flag一共有16种标志位可以使用,当前只定义了9个,没有使用的标志位一律为0。
      在这里插入图片描述

    • 类索引(this_class)、父类索引(super_class)与接口索引集合(interfaces);类索引和父类索引都是一个u2类型的数据集合,接口索引集合是一组u2类型的数据集合,class文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字后的接口顺序从左到右排列在接口索引集合中。

    • 字段表(field_class)用于描述接口或者类中声明的变量。包括类级别变量和实例级别的变量,但不包括在方法内部申明的局部变量。字段可以包括的修饰符有字段的作用域(public、protect)、实例变量还是类变量(static)、可变性(final)、并发可见性(volatile,是否从主内存读写)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)。上面各个修饰符要么有,要么没有,很适合使用标志位来表示。而字段和字段类型,只能引用常量池中的常量来描述。跟随着access_flag的标志的是两项索引值:name_index和description_index。它们都是对常量池的引用,分别代表字段的简单名称以及字段和方法的描述符。全限定名:类似:org/test/testclass;简单名称就是指没有类型和参数修饰的方法或者字段名称:类似 inc() inc、字段m m;方法和字段的描述符比较复杂。



      基本类型以及代表无返回值的void类型都用一个大写的字符表示,而对象则使用字段L加对象的全限定名来表示。对于数组,每一个维度将使用一个前置的[字符来描述,例如:java.lang.String -> [[Ljava.lang.String; 用来描述方法时,按照先参数列表后返回值的顺序描述,例如:int indexof(char[] source, int first) ->([CI)I。字段表集合不会列出从父类或者父接口继承而来的字段,但有可能出现Java代码不存的字段。

    • 方法表描述;class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一样的方式,方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性表集合。如果父类方法在子类中没有重写,方法表集合中就不会出现来自父类的方法信息。有可能出现编译器自己的方法.

    • 属性表:class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景的专用信息。下面为部分属性表信息。

    1.2.2 字节码与数据类型

    Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数据(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(称为操作数)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包括操作数,只有一个操作码,指令参数都放在操作数栈中。Java虚拟机的操作码为一个字节(0-255),这意味着指令集的操作码总数不能超过256条。class文件格式放弃了编译后代码的操作数对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体的数据结构。

    如下为Java虚拟机指令集支持的数据类型。

    • 加载与存储指令:用于将数据在栈桢中的局部变量和操作数栈之间来回传输。例如:iload(将一个局部变量加载到操作数栈)、istore(将一个数值从操作数栈存到局部变量表)、bipush(将常量加载到操作数栈)

    • 运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作数栈顶。例如:iadd、isub、imul、idiv、irem、ineg。

    • 类型转换指令:可以将两种不同的数值类型相互转换。

    • 对象创建与访问指令:虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建之后,就可以使用对象访问指令获取对象实例的字段或者数组元素

      • 创建类指令:new;创建数组指令:newarray,anewarray,multianewarray
      • 访问类字段和实例字段:getfield,putfield,getstatic,putstatic
      • 把一个数组元素加载到操作数栈的指令:baload,calod,等等
      • 将一个操作数栈的元素存储到数组元素的指令:bastore,castore等等
      • 取数组长度:arraylength;检查类实例类型的指令:instanceof,checkcast;
    • 操作数栈指令:出栈(pop)、互相(swap)

    • 控制转移指令:ifeq、iflt等等

    • 方法调用和返回指令;invokevirtual(调用对象实例方法,根据对象的实际类型进行分配)、invokeinterface(调用接口方法,在运行时找一个实现了这个接口方法的对象)、invokespecoal(特殊处理的实例方法,类似私用方法、父类方法、初始化方法)、invokestatic(类静态方法)、invokedynamic(运行时动态解析出调用点限定符所引用的方法)。其分配逻辑由用户所设定的引导方法设定。返回指令:ireturn

    • 异常处理指令:Java虚拟机中处理异常采用异常表来完成。

    • 同步指令:Java虚拟机支持方法级别和方法内部一段指令序列的同步,这俩种都是使用monitro来实现的,同步一段指令序列通常由java语言中的synchronized语句块来表示,Java虚拟机中的指令有monitorenter和monitorexit来支持synchronized的语义。

    1.3 Hotspot Dalvik ART关系对比
    1.3.1 Dalvik简介

    1、Google自己设计的用于Android平台的虚拟机;

    2、支持已转化为dex格式的java应用程序运行;dex是专为Dalvik设计的一种压缩格式

    3、允许在有限的内存中同时运行多个虚拟机实例,并未每一个Dalvik应用作为一和独立的Linux进程运行;

    4、5.0以后,Google直接删除Dalvik,取而代之的是ART。

    1.3.2 Dalvik与JVM区别

    1、Dalvik是基于寄存器,JVM基于栈;

    2、Dalvik运行dex文件,JVM运行java字节码;

    3、自Android2.2以后,Dalvik支持JIT(即时编译技术)。

    1.3.3 ART(Android Runtime)

    1、在Dalvik下,应用每次运行,字节码都需要通过即时编译器转化为机器码,这样会拖慢应用的运行效率;

    2、在ART下,应用第一次安装时,字节码就会预先变异成机器码,使其真正成为本地应用。这个过程叫做预编译(AOT),这样,每次启动和执行的时候都会更快。

    Dalvik与ART区别最大的不同就是:Dalvik是即时编译,每次运行前都先编译;而ART采用预编译。

    ART优缺点

    优点:

    1、系统性能显著提升;

    2、应用启动更快,运行更快,体验更流畅;

    3、更长的电池续航能力;

    4、支持更低的硬件。

    缺点:

    1、机器码占用存储空间更大;

    2、应用安装时间变长。

    1.3.4 Dex

    Dex文件是Dalvik的可执行文件,Dalvik是针对嵌入式设备设计的java虚拟机,所以Dex文件和Class文件的结构上有很大区别。为了更好的利用嵌入式你设备的资源,Dalvik在java程序编译后,还需要用dx工具将编译产生的数个Class文件整合成一个Dex文件。这样其中的各个类就可以共享数据,减少冗余,使文件结构更加紧凑。

    一个设备在执行Dex文件之前,需要优化该Dex文件并生成对应的Odex文件,然后该Odex文件被Dalvik执行。Odex文件本质是个Dex文件,只是针对目标平台做了相关优化,包括对内部字节码进行一系列处理,主要为字节码验证,替换优化及空方法消除。

    1.3.5 Dalvik和Art区别

    安卓可以运行多个app,对应运行了多个dalvik实例,每一个应用都有一个独立的linux进程,独立的进程可以防止虚拟机崩溃造成所有程序都关闭。就像一条电灯泡上的电灯都是并联关系的,一个灯泡坏了其他灯泡不受影响,一个程序崩溃了其他程序也不受影响。

    1. Art一次编译,终身受用,提高app加载速度,运行速度,省电;不过安装时间略长,占Rom体积略大
    2. Dalvik占用Rom体积小,安装略快,不过加载app时间长,运行慢,更加耗电。
    1.4 栈的存储结构和运行原理
    1.4.1 栈中存储的是什么?

    1.每个线程都有自己的栈,栈中存储的是栈帧。 2.在这个线程上正在执行的每个方法都各自对应一个栈帧。方法与栈帧是一对一的关系。 3.栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

    1.4.2 栈的运行原理

    1.JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈。 2.在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。 3.执行引擎运行的字节码只对当前栈帧进行操作。 4.如果该方法调用的其他的方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

    栈的运行原理图: 如下图所示,有四个方法,方法1调用方法2,2调用3,3调用4。 这时栈中会有4个栈帧。当前栈帧是方法4对应的栈帧,位于栈顶。 方法执行完成,将依次出栈。出栈顺序为4,3,2,1。
    在这里插入图片描述
    5.栈帧是线程私有的,其它的线程不能引用另外一个线程的栈帧。
    6.当前方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
    7.Java函数返回方式有两种,使用return或者抛出异常。不管哪种方式,都会导致栈帧被弹出。
    在这里插入图片描述

    1.5 栈帧的内部结构

    1.每个栈帧中存储着局部变量表

    2.操作数栈

    3.动态链接(指向运行时常量池的方法引用)

    4.方法返回地址(或方法正常退出或者异常推出的意义)

    5.一些附加信息

    在JAVA虚拟机中以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机方法调用和执行的数据结构。它也是虚拟机运行时数据区中的栈中的栈元素。

    从JAVA程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。但对于执行引擎来讲,在活动线程中,只有栈顶的方法才是在运行的,即只有栈顶的方法是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为"当前方法",执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

    栈帧中存储着方法的局部变量表,操作数栈,动态连接和方法返回地址。下面对这几个部分进行一一介绍。

    1.5.1 局部变量表

    局部变量表示一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位,一个变量槽占用32位长度的内存空间,即栈中8个类型数据中除double和long需要占用两个变量槽之外,其余均占用一个变量槽。

    需要注意的是,局部变量表是建立在线程的堆栈中的,即线程私有的数据,即对于变量槽的读写是线程安全的。

    另外局部变量表中变量槽0通常存着this对象引用,其他数据从变量槽1开始存储,通过字节码指令store存入局部变量表,需要调用时,可通过load指令取出。同时为了节省栈帧占用的内存空间,局部变量表的变量槽是可以重用的,其作用域不一定会覆盖整个方法体,如果当前字节码的PC计数器已经超出某个变量的作用域,那么这个变量槽就可以交给其他变量来重用。

    可以参照下面这段代码:

    ​
    public  void method1(){
            int a = 0;
            int b = 2;
            int c = a+b;
        }
        public  void method2(){
            int d = 0;
            int e = 2;
            int f = d+e;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    public void method1();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=4, args_size=1
             0: iconst_0
             1: istore_1
             2: iconst_2
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: istore_3
             8: return
          LineNumberTable:
            line 9: 0
            line 10: 2
            line 11: 4
            line 12: 8
     
      public void method2();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=4, args_size=1
             0: iconst_0
             1: istore_1
             2: iconst_2
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: istore_3
             8: return
          LineNumberTable:
            line 14: 0
            line 15: 2
            line 16: 4
            line 17: 8
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    可以看到在两个不同的方法中,method2的d,e,f变量复用了method1中的a,b,c对应的变量槽。

    这样虽然可以节省开销,却也会带来一定的问题,参考下面的代码:

    public static void main(String[] args) {
            {
                byte[] b = new byte[64*1024*1024];
            }
            System.gc();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    [GC (System.gc())  68813K->66384K(123904K), 0.0017888 secs]
    [Full GC (System.gc())  66384K->66225K(123904K), 0.0074844 secs]
    
    • 1
    • 2

    可以看到,本来应该被回收的数组b却并没有被回收,这主要是由于局部变量表的变量槽中依然还保存着对b的引用(虽然已经出了作用域,但该变量槽并没有被复用,因此引用关系依然保持),使得其无法被垃圾回收。可通过在代码块下方插入int a =0来复用相应的变量槽,打破引用关系,或者将b置为null,这两种方法均可以实现对b的回收。

    另外局部变量表中的对象必须要进行赋值,不可以像类变量那样由系统赋予默认值

    public class A{
        int a;//系统赋值a = 0
        public void method(){
            int b;//错误,必须要赋值
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    1.5.2 操作数栈

    操作数占主要用于方法中变量之间的运算,其主要原理是遇到运算相关的字节码指令(如iadd)时,将最接近栈顶的两个元素弹出进行运算。操作数栈的具体工作流程可参照下面以这段代码:

    public  void method1(){
            int a = 0;
            int b = 2;
            int c = a+b;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5


    此外在虚拟机栈中,两个栈帧会重叠一部分,即让下面栈帧的部分操作数与上面栈帧的局部变量表的一部分重叠在一起,这样不仅可以节省空间,亦可以在调用方法时,直接共用一部分数据,无需进行额外参数的复制传递。

    1.5.3 动态连接

    每个栈帧都包含一个指向运行时常量池中该栈帧所属的方法的引用,持有这个引用是为了支持方法调用过程中的动态连接,即每一次运行期间都要动态地将常量池中方法的符号引用转换为直接引用。

    1.5.4 方法返回地址

    方法在执行完毕后,有两种方式退出这个方法。一是执行引擎遇到任意一个方法返回的字节码指令(return)。二是方法执行过程中出现了异常,并且在方法的异常表中没有找到对应的异常处理器,在方法退出后,必须返回最初方法被调用的位置,程序才能继续执行。而主调方法的PC计数器的值就可以作为返回地址,,栈帧中会保存着这个计数器的值。

    1.6 Jclasslib与HSDB工具应用分析
    1.6.1 jclasslib应用分析

    下面要隆重介绍的是一款可视化的字节码查看插件:jclasslib。

    大家可以直接在 IDEA 插件管理中安装(安装步骤略)。

    使用方法

    1. 在 IDEA 打开想研究的类。
    2. 编译该类或者直接编译整个项目( 如果想研究的类在 jar 包中,此步可略过)。
    3. 打开“view” 菜单,选择“Show Bytecode With jclasslib” 选项。
    4. 选择上述菜单项后 IDEA 中会弹出 jclasslib 工具窗口。
      在这里插入图片描述
      那么有自带的强大的反汇编工具 javap 还有必要用这个插件吗?

    这个插件的强大之处在于:

    1. 不需要敲命令,简单直接,在右侧方便和源代码进行对比学习。
    2. 字节码命令支持超链接,点击其中的虚拟机指令即可跳转到 jvms 相关章节,超级方便。

    该插件对我们学习虚拟机指令有极大的帮助。

    1.6.2 HSDB的使用

    HSDB全称是HotSpotDebugger, HotSpot虚拟机的调试工具,在使用的时候,需要程序处在暂停的状态,可以直接使用Idea的debug工具. 使用HSDB可以看到堆栈里面相关的内容,

    启动HSDB

    无论哪种方式启动,都需要先知道当前java程序的进程号,我们使用jps命令,如下图所示:

    然后我们使用命令 jhsdb hsdb --pi

  • 相关阅读:
    为博客园开发了一套脚手架及模板——实时预览页面定制效果
    SSM框架,MyBatis-Plus的学习(下)
    Python 任务系统6 按演进的思路来看:从零散的任务到集中管理
    linux-checklist命令行
    Android automotive车载开发(5)-----系统架构
    基于FPGA的图像拼接算法实现,包括tb测试文件和MATLAB辅助验证
    Ansys Zemax|在设计抬头显示器(HUD)时需要使用哪些工具?
    ECCV2022 | 多模态融合检测新范式!基于概率集成实现多模态目标检测
    阿里云服务器部署安装hadoop与elasticsearch踩坑笔记
    GEE案例——如何使用长时序影像实现多波段图像加载(不同层土壤湿度)
  • 原文地址:https://blog.csdn.net/m0_64420071/article/details/127677878