Android虚拟机(面试)

一 什么是虚拟机

我们都知道,java是一门跨平台的一门编程语言;而实现这个的关键就是虚拟机,我们在编译的时候会把代码变成字节码文件(.class文件),然后通过虚拟机,根据不同的平台,把字节码文件翻译成平台所能运行的机器码,从而实现跨平台。java平台的虚拟机我们称为JVM,而Android的虚拟机有两个版本,一个是Dalvik,一个是Art;那么JVM和Dalvik,Art有什么区别呢?我们来看一副图
在这里插入图片描述

显而易见,两种虚拟机都要把java文件编译成.class文件,不同的是,JVM会直接把.class文件翻译成机器指令在硬件上运行;而Android虚拟机会把.class文件都过dx.bat指令转换成.dex文件,然后通过aapt打包成Apk文件运行在虚拟机上,实际上就是虚拟机把.dex文件翻译机器指令运行

1.1 .class文件和.dex文件有什么区别?

无图无真相!
在这里插入图片描述
可以看出,.class文件是一个一个的,也就是说一个.java文件对应一个.class;而.dex会把多个.class拆解,把相同类型的数据放到一起,一个dex文件可以含有多个.class文件的信息。同时.class文件有很多冗余信息,dex文件生成的时候会去除很多冗余信息

1.2 Android虚拟机是基于寄存器,JVM是基于虚拟机栈

我们来看看两种虚拟机的指令集的不同之处

JVM基于虚拟机栈的指令集:
在这里插入图片描述

Android虚拟机基于寄存器的指令集:
在这里插入图片描述
可以看出,基于虚拟机栈的指令会更多,而且它没有具体的数据类型,需要凭借频繁的执行load和store指令进行压榨和出栈,这样就会势必多次访问内存,更多的访问cpu,但是栈的每个指令只占一个字节,所以虽然指令很多,但是每个指令的空间比较小。相反我们来看基于寄存器的指令集,我们比较容易看出来意思,因为有具体的数据类型,很显然,指令空间比较大,Dalvik的某些指令需要2个字节,但是这样携带的信息更多,所以指令数就变少了,所以相对于栈,基于寄存器的Dalvik虚拟机会更加效率

1.3 Art虚拟机和Dalvik虚拟机有什么区别?

Dalvik虚拟机:使用的编译器是JNI(Just in time),每次运行一个app的时候,它实时的会将一部分dex文件翻译成机器码。这样它消耗的内存会更少,占用的物理空间也会更少。类似于每次运行代码,都要从压缩包里区,虽然内存少了,但是每次运行加载代码必然会增加cpu的负担,启动效率会变慢

Art虚拟机:使用的是AOT(Ahead of time),每次安装app时,它就将dex翻译成机器码存储到设备的存储器,这样通常安装的时间会变长,但是每次启动app的时候会很快,因为不需要通过JNI编译了。

Art虚拟机相对于Dalvik虚拟机,就是典型的空间换时间,对于移动设备来说,运行速度快是最主要的,虽然消耗的内存和空间会比较大,但是现在的手机基本都是16/256G,所以可以承受

二 JVM的基本构成

在这里插入图片描述

  1. 类加载器(ClassLoader):在JVM启动时或者在类运行时需要将class文件转换成字节码加载到运行时数据区(Runtime Data Area)。
  2. 运行时数据区(Runtime Data
    Area):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为6个区域,程序计数器(Program Counter Register)、Java虚拟机栈(Java Virtual Machine Stack)、本地方法栈(Native Method Stack)、Java堆(Java Heap)、方法区(Methed Area)、直接内存(Direct Memory)。
  3. 执行引擎(Execution Engine):负责将class字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言(主要是C和C++)的接口本地库接口(Native Interface)来实现。
  4. 本地库接口(Native Interface):主要是调用C或C++实现的本地方法及回调结果。

2.1 Java的内存模型

在这里插入图片描述
JVM所管理的内存只要分成五个部分,分别是:
线程共享区域:

  1. 方法区:这里主要给常量池,类信息(.class),常量,静态常量,即时编译器编译后的代码等数据
  2. 堆:用来存储实例对象(new出来的对象内存分配就是在这里),以及数组

线程私有区域:

  1. 虚拟机栈:存放方法运行时的数据,称为栈帧;一个方法对应一个栈帧,运行完出栈。栈帧分别由:局部变量表,操作数栈,动态链接,方法返回地址组成
    在这里插入图片描述
  • 局部变量表:局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
  • 操作数栈:顾名思义,是一个先入后出的栈的数据结构,这个栈专门用来操作数的运算的,当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。 一个栈容量是32位
  • 动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
  • Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
Math math=new Math();
math.compute();//调用实例方法compute()

以上面两行代码为例,解释一下动态连接:math.compute()调用时compute()叫符号,需要通过compute()这个符号去到常量池中去找到对应方法的符号引用,运行时将通过符号引用找到方法的字节码指令的内存地址(在代码层面其实就是多态的体现)

.

  • 方法的返回地址:方法返回的字节码指令
  1. 程序计数器:程序计数器是线程私有的一块较小的内存空间,其可以看做是当前线程所执行的字节码的行号指示器。如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的字节码指令的地址;如果正在执行的是 Native 方法,则计数器的值为空。

  2. 本地方法栈:它和虚拟机栈很类似,只不过虚拟机栈服务对象时java方法,而本地方法栈服务对象native方法

三 Java 类的加载过程

一个类的完整生命周期:
在这里插入图片描述

类加载过程包括五个步骤:加载、验证、准备、解析、初始化,其中验证、准备、解析统称为连接阶段。如下:
在这里插入图片描述

3.1 加载

将.class文件加载到java虚拟机,并且为这个类的方法区创建class对象。注意加载阶段和连接阶段有一部分是交叉进行的,这一步主要完成下面三件事:

  1. 通过全类名获取到此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

3.2 连接

3.2.1 验证

在class字节流里会有专门的验证class包含的文件是Java虚拟机的规范,验证这一步骤就是检查这个字节

3.2.2 准备

为类的static变量在方法区分配内存;将上述变量的初始值设置为0而非开发者定义的值(特殊情况:若static变量加了final,则值被设置为开发者定义的值);

3.2.3 解析

将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

  • 符号引用:字符串,能根据这个字符串定位到指定的数据,比如java/lang/StringBuilder
  • 直接引用:内存地址

具体可以分为:

  • 类或接口的解析
  • 类方法解析
  • 接口方法解析
  • 字段解析

我们来看几个经常发生的异常,就与这个阶段有关:

java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。
java.lang.NoSuchMethodError 找不到相关方法时的错误。

3.3 初始化

初始化静态变量和静态代码块

  1. 生成类构造器()方法。()方法由编译器自动收集静态变量和静态代码块合并产生。
  2. 执行()方法。虚拟机会保证在子类类构造器()执行之前,父类的类构造()执行完毕。由于父类的构造器()先执行,也就意味着父类中定义的静态代码块/静态变量的初始化要优先于子类的静态代码块/静态变量的初始化执行。静态代码块/静态变量的初始化顺序与代码书写的顺序一致。特别地,类构造器()对于类或者接口来说并不是必需的,如果一个类中没有静态代码块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器()。

四 java类的加载机制

4.1 类加载器分别有

BootstrapClassLoader:启动类加载器,java的系统api基本都在这个加载器里加载
ExtensionClassLoader:拓展类加载器
ApplicationClassLoader:应用程序类加载器
自定义类加载器:
在这里插入图片描述

双亲委派机制:即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载时,首先将加载任务委托给父类加载器,依次递归 (本质上就是loadClass函数的递归调用),因此所有的加载请求最终都应该传送到顶层的启动类加载器中。如果父类加载器可以完成这个类加载请求,就成功返回;只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载(源码查看ClassLoader.loadClass(String name, boolean resolve))。

public class ClassLoader {
    
    
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    
    
        // 检查需要加载的类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        
        if (c == null) {
    
    
            try {
    
    
                // 若没有加载,则调用父加载器的loadClass()方法加载
                if (parent != null) {
    
    
                    c = parent.loadClass(name, false);
                } else {
    
    
                    // 若父类加载器为空,则使用启动类加载器BootstrapClassLoader加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
    
    
                // 若父类加载器加载失败会抛出ClassNotFoundException, 
                //说明父类加载器无法完成加载请求 
            }
            if (c == null) {
    
    
                // 在父类加载器无法加载时 
                // 再调用本身的findClass方法进行类加载 
                c = findClass(name);
            }
        }
        
        if (resolve) {
    
    
            resolveClass(c);
        }
        return c;
    }
}

4.2 双亲委派机制的优点和缺点

优点:
1)避免同一个类被重复加载(缓存);
2)避免Java 的核心 API 被篡改;

缺点:
1)应用类访问系统类自然是没有问题,但是系统类访问应用类就会出问题。比如在系统类里有一个接口,这个接口在应用中实现,该接口里有一个工厂方法用来实例化这个借口,而接口和工厂方法都
在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。
2)我们经常要操作数据库,Mysql和oracle都有自己的JDBC的实现,这时候如果再使用启动类加载器里定义的SPI规则就不合适了,因为它的手只能摸到<JAVA_HOME>\lib中,其他的它无能为力。这就违反了自下而上的委托机制。Java就搞了个线程上下文类加载器,通过setContextClassLoader()默认情况就是应用程序类加载器然后Thread.current.currentThread().getContextClassLoader()获得类加载器来加载。

如果我们不想用双亲委派模型怎么办?
为了避免双亲委托机制,我们可以自己定义一个类加载器,打断loadClass的递归调用,重写loadClass()即可。

双亲委派机制具体可以点击这里

4.3 如何自定义加载器及使用场景

继承ClassLoader,并重写findClass(String name)。

自定义类加载器使用场景?
1)加密:由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载:
2)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类:

五 Java对象的创建过程

当遇到关键字new指令时,Java对象创建过程便开始,整个过程如下:在这里插入图片描述

1)类加载检查:检查对应的类是否已被加载完成。虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程;
2)分配内存:把一块确定大小的内存从 Java 堆中划分出来。分配方式根据堆内存是否规整有指针碰撞(规整)和空闲列表(不规整)两种,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定;
3)初始化零值:虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值;
4)设置对象头:虚拟机对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式;
5)执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

5.1 内存定位访问方式

1)句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
在这里插入图片描述
2)直接指针:如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
在这里插入图片描述
这两种访问方式各有优势,使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

六 堆内存中对象内存的分配策略

在这里插入图片描述
现在主流的垃圾回收器基本采用的是分代回收算法;堆内存会分为新生代和老年代。这两个分区根据自己的特点采用不同的垃圾收集算法
1)新生代
新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区;首先会在eden区分配内存,当内存不足的时候,虚拟机会发生一次gc,接着会将eden区存活对象复制到survivor0区,然后清空eden区,当这个survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后交换survivor0区和survivor1区的角色(即下次垃圾回收时会扫描Eden区和survivor1区),即保持survivor0区为空,如此往复。每经过一次立即回收,对象的年龄加1。特别地,当survivor1区也不足以存放eden区和survivor0区的存活对象时,或者存活的对象的年龄超过阈值(可以通过参数 -XX:MaxTenuringThreshold 来设置,默认15)时, 就将存活对象直接存放到老年代
2)老年代
老年代的内存也比新生代大很多(大概比例是1:2)。老年代存放的大部分是一些生命周期较长的对象。另外,为了避免为大对象分配内存时由于分配机制带来的复制而降低效率,大对象直接进入老年代。当老年代满时会触发老年代GC。

那么问题来了,如何判断内存需要回收?新生代和老年代的回收算法又是怎么样的呢?

6.1 判断对象回收

1)引用计数法:通过判断对象的引用数量来决定对象是否可以被回收,任何引用计数为0的对象实例可以被当作垃圾收集。引用计数法效率高,但很难解决对象之间相互循环引用的问题,因此目前主流的虚拟机中并没有选择这个算法来管理内存;为了解决循环引用带来的不可回收的问题,就出来了可达性分析法

2)可达性分析法
在这里插入图片描述
该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

第一次标记:对象在经过可达性分析后发现没有与GC Roots有引用链,则进行第一次标记并进行一次筛选,筛选条件是:该对象是否有必要执行finalize()方法。没有覆盖finalize()方法或者finalize()方法已经被执行过都会被认为没有必要执行。 如果有必要执行:则该对象会被放在一个F-Queue队列,并稍后在由虚拟机建立的低优先级Finalizer线程中触发该对象的finalize()方法,但不保证一定等待它执行结束,因为如果这个对象的finalize()方法发生了死循环或者执行时间较长的情况,会阻塞F-Queue队列里的其他对象,影响GC。
第二次标记:GC对F-Queue队列里的对象进行第二次标记,如果在第二次标记时该对象又成功被引用,则会被移除即将回收的集合,否则会被回收。

在Java语言中,可作为GC Roots的对象包含以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。(可以理解为:引用栈帧中的本地变量表的所有对象)
  2. 方法区中静态属性引用的对象(可以理解为:引用方法区该静态属性的所有对象)
  3. 方法区中常量引用的对象(可以理解为:引用方法区中常量的所有对象)
  4. 本地方法栈中(Native方法)引用的对象(可以理解为:引用Native方法的所有对象)

6.2 判断运行时常量的回收

加入常量池里有‘abc’这个常量,如果没有任何String 引用指向这个常量,则说明这是一个废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池

6.3 判断方法区无用的类

类需要同时满足下面 3 个条件才能算是“无用的类”:
1)该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
2)加载该类的ClassLoader已经被回收;
3)该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

七.垃圾回收算法

7.1 标志-清除

顾名思义,把要回收的对象标志出来,标志完统一回收;这是最基础的算法,后续的算法都是对它的补充;这种算法的最大缺陷就是会把内存空间碎片化,当遇到大内存对象的时候可能会因为分配不出足够的连续内存,从而导致内存不足,时机上内存是充足的;其次,标记和清除的效率都不高;

在这里插入图片描述

7.2 复制算法

为解决标志=清除算法造成内存空间碎片化的问题,复制算法通过把内存一分为而,将存活的对象复制一遍,放入另一半空的内存中去。这种算法适用于对象存活率低的场景,比如新生代。事实上,现在商用的虚拟机都采用这种算法来回收新生代,在实践中会把新生代内存划分为块较大的Eden空间和两块较小的Survivor空间。
在这里插入图片描述

7.3 标记-整理

这种算法的标记过程类似标记-清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,适用于对象存活率高的场景,比如老年代。

在这里插入图片描述

7.4 分代收集算法

根据对象的存活周期将它们放到不同的区域,并在不同的区域采用不同的收集算法。目前主流的Java虚拟机的垃圾收集器都采用分代收集算法,新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记-清除算法或者标记-整理算法。

猜你喜欢

转载自blog.csdn.net/qq_39431405/article/details/121561713