Android面试指南(七)
目录
一、JVM概览
二、JVM面试题精选
2.1、Android平台的虚拟机是基于栈的吗?
2.2、为什么dex文件比class文件更适合移动端?
2.3、能否自己实现一个叫java.lang.Object的类?
2.4、所有被new出来的实例,都是放在堆中的吗?
2.5、GC为什么会导致应用程序卡顿
2.6、双重检测的单例为什么要加volatile关键字?
一、JVM概览
JVM的概念:Java虚拟机是一种规范,它是个抽象概念
- 并不是真正的“机器”,而是由软件实现
- 可以有多种不同的实现方式,比如HotSpot VM(Sun JDK、Open JDK)
JVM的职责:在硬件上运行JVM语言(可以编译成Java字节码的语言)
- 执行字节码指令
- 加载字节码中的Class结构
- 分配和回收代码运行时的内存
二、JVM面试题精选
2.1、Android平台的虚拟机是基于栈的吗?
推荐阅读:带你认识JVM
Android平台的虚拟机并非基于栈结构,Android采用基于寄存器的虚拟机设计。
①、Java运行时数据区,结构如下:
- 堆:存放对象实例数据,垃圾回收主要作用域
- 方法区:存储加载的类信息,线程共享
- 线程私有区域:
- 程序计数器:记录字节码执行位置
- 虚拟机栈:管理方法调用状态
- 本地方法栈:支持Native方法执行
②、数据结构“栈”简介:
栈的核心特性为先进后出(LIFO),典型应用场景包括:
- 方法调用栈:后调用的方法先结束执行
- Android Activity管理:栈顶Activity唯一可见,关闭后下层Activity才显示
- 虚拟机栈:通过栈帧记录方法执行状态,每个栈帧对应一个方法调用
③、运行时栈帧三要素:
- 局部变量表:存储方法参数与局部变量(非静态方法包含this引用)
- 操作数栈:编译期确定容量,用于字节码执行时的中间结果存储
- 方法出口:记录返回地址与返回值
- 注意:题目中"基于栈的虚拟机"特指操作数栈而非虚拟机栈
④、基于栈的虚拟机编译后特征:
- Java代码扩展为字节码
- 字节码单元长度:1-2字节不等(如iadd为单字节,bipush为双字节)
- 字节码包括 机器码(二进制指令)和助记符(人类可读符号)
⑤、Dalvik虚拟寄存器的核心设计:
- 虚拟寄存器替代操作数栈
- 插槽数量由编译期计算确定
- 保留程序计数器功能
编译器优化机制:
- 寄存器复用:无后续引用的变量可被覆盖
- 内存节约:动态调整插槽数量确保最少资源占用
⑥、两种虚拟机的设计对比
基于栈的虚拟机设计具有以下特点:
- 压缩代码体积:JVM设计初衷要求跨平台并支持嵌入式设备,早期设备内存和存储空间有限,需减少代码体积。同时支持远程传输字节码,压缩体积可降低传输开销。
- 字节码实现简单:生成字节码过程更简单,编译器实现难度降低。操作时无需考虑寄存器绝对地址,仅需通过入栈出栈完成数据操作。
- 指令简洁性:单条指令体积小,例如操作栈元素无需指定寄存器地址。
- 高可移植性:基于寄存器的虚拟机需将虚拟寄存器映射到物理寄存器,增加移植难度;而栈式设计无需考虑平台寄存器差异,指令通用性强。
基于寄存器的虚拟机设计具有以下特点:
- 性能与内存效率:指令条数少,数据移动和临时结果存放次数减少,虚拟寄存器可映射到物理寄存器
- 无需考虑可移植性:程序统一运行于Android系统,与栈式设计跨平台需求形成对比
- 代码体积优化:通过DEX文件格式压缩字节码
2.2、为什么dex文件比class文件更适合移动端?
推荐阅读:class文件与dex文件解析
1)、class文件
class文件物理结构为连续字节单元,存储字节码指令及数据。每个单元对应特定指令或信息,如aaa()这个方法的字节码指令。
class文件逻辑结构包含以下部分:
- 文件头:存储文件基础信息
- 常量池:作为资源仓库,存储字符串、符号引用等
- 类信息:定义类的元数据
- 字段表:记录成员变量信息
- 方法表:存储方法定义及字节码
- 属性表:保存注解、源文件名等附加信息
①、文件头
文件头包含三个字段:
- 魔术字(4字节):标识合法class文件(固定为0xCAFEBABE)
- 次版本号(2字节)
- 主版本号(2字节)
②、常量池
常量池首字段为容量计数器(2字节),数值为实际常量数+1。后续存储cp_info类型常量,包括字面量(字符串、数值)和符号引用(类名、方法描述符)。
cp_info存储数据类型分为两类:
- 字面量:代码中直接量(字符串、final常量值)
- 符号引用:类/接口全限定名、字段/方法描述符
常量池中的字符串存储通过两级引用实现:
- CONSTANT_String_info(tag=8):仅存储指向UTF-8常量的索引
- CONSTANT_Utf8_info(tag=1):实际存储字符串值(含长度字段及字节数组)
③、类信息
类信息包含:
- 访问标志(2字节):记录public/final等修饰符
- this_class/super_class(各2字节):指向常量池中的类定义
- 接口计数及引用:记录实现接口数量及常量池索引
④、字段表
字段表结构:
- 字段计数器
- field_info数组:含字段名、类型(均引用常量池)、修饰符及属性(如final常量的初始化值)
⑤、方法表
方法表核心结构:
- code属性:包含操作数栈深度(stack)、局部变量表大小(locals)及字节码指令
- LineNumberTable:映射字节码与源码行号
- LocalVariableTable:记录局部变量生命周期(start/length)、名称及类型
⑥、属性信息
属性信息的结构由属性名和属性值组成。示例中包含三个属性:
- SourceFile:指向编写代码的源文件JAVA文件
- 注解属性:标记类为已过时
- 运行时可见的圆柱体:记录类上添加的圆柱体注解
2)、dex文件
dex文件通过Dx工具将多个class文件合并生成。与class文件对比差异如下:
- class文件结构:单个JAR包包含多个独立class文件
- dex文件结构:单个APK/AR文件仅含1-3个dex文件
dex文件结构分为三部分:
- 文件头:包含元数据信息
- 索引区:包含string/tab/proto/field/method等索引区域
- 数据区:存储类定义及实际数据
①、文件头:
dex文件头结构包含以下关键字段:
- 魔术和版本号:占8字节
- 校验码:检测文件损坏或篡改
- 签名值:采用SHA-1签名验证文件完整性
- 文件总大小/头大小:定位索引区起始位置
- 字节序标记:标识高位/低位字节排列方式
- size/off字段:精确划分文件逻辑区域
②、索引区数据结构
索引区通过偏移量实现数据定位,核心结构包括:
string_ids:
- 字符串索引:4字节偏移量字段,指向数据区字符串实际存储位置
type_ids:
类型索引:4字节字段,关联字符串索引区的类型名称
field_ids:
- 字段索引:包含三个关键部分
- class idx/type idx:指向类型索引
- name索引:关联字符串索引区的字段名
proto_ids:
- 方法原型索引:包含三部分结构
- 短描述符:关联字符串索引
- 返回值类型:指向类型索引
- 参数类型偏移量:定位参数列表
- 作用:实现参数/返回值相同方法的原型复用
3)、索引区和数据区
索引区存储各类索引指针,数据区存储实际值。两者共同实现类似class文件常量池的资源管理功能。
4)、class文件和dex文件对比
对比维度 | class文件 | dex文件 |
常量池机制 | 每个类独立常量池导致代码冗余 | 全局数据区+索引实现常量复用 |
文件组织方式 | 每个类独立文件 | 多类合并为1-3个文件 |
字节码架构 | 基于栈的指令集 | 基于虚拟寄存器的指令集 |
优化效果 | 无压缩优势 | 减少安装包体积及IO操作开销 |
2.3、能否自己实现一个叫java.lang.Object的类?
推荐阅读:带你认识ClassLoader
可以通过编译,但不会被加载。本题考察自定义类加载器与双亲委派模型的关系。核心考点在于理解class loader的加载机制以及双亲委派模型的实现原理。
1)、字节码加载
字节码通过类加载器加载到方法区,形成类的运行时数据结构。
2)、方法区
①、方法区的作用
方法区存储类的元信息,包括常量池、字段、方法和属性等逻辑结构。
②、方法区的实现
方法区在JAVA 8前实现为永久代,与堆共享线程但无垃圾回收机制;JAVA 8后改为元空间,直接分配于本地内存且无容量限制。
3)、Java双亲委派模型
类加载器层级 | 加载内容 | 关联关系 |
启动类加载器 | jre/lib核心类库(如rt.jar) | 无父加载器 |
扩展类加载器 | jre/lib/ext目录库 | 父加载器为启动类加载器 |
应用类加载器 | 用户编写的代码 | 父加载器为扩展类加载器 |
自定义类加载器 | 额外class文件 | 父加载器为应用类加载器 |
4)、Android双亲委派模型
类加载器层级 | 加载内容 | 关联关系 |
BootClassLoader | framework层class | 无父加载器 |
PathClassLoader | 用户代码编译的dex文件 | 父加载器为BootClassLoader |
DexClassLoader | 自定义dex文件 | 父加载器为PathClassLoader |
5)、ClassLoader的源码
findClass方法由子类实现具体加载逻辑,loadClass方法通过递归委派父加载器优先加载,未命中缓存时调用自身findClass。
6)、加载自己实现的Object类
自定义Object类在双亲委派模型下无法被加载,因父加载器会优先加载系统核心类库中的Object类。
7)、双亲委派模型的优点
- 层次化优先级:确保核心类库优先加载
- 避免重复加载:已加载类直接返回缓存结果
- 沙箱安全机制:防止外部代码篡改核心逻辑
2.4、所有被new出来的实例,都是放在堆中的吗?
本题核心:
- 堆栈内存区别:堆是线程共享的运行时数据区,栈是线程私有的运行空间
- 栈存储优势:自动内存管理、无GC开销、访问速度更快
- 适用条件:实例生命周期不超过方法作用域且不涉及多线程访问时适合栈存储
1)、栈中是否能存放实例
概念 | 实现方式 | 可行性 |
标量替换 | 将对象字段平移到局部变量表 | 可实现堆栈数据等价转换 |
栈上分配 | 在栈帧中直接分配实例空间 | 通过局部变量表存储实例字段 |
方法存储 | 类方法始终存放在方法区 | 与实例存储位置无关 |
2)、栈中存放实例的好处
- 栈帧销毁自动回收实例:方法执行完毕后栈帧出栈,实例随之销毁
- 无GC开销:实例回收不依赖垃圾收集机制,减少系统资源消耗
- 性能优势:避免了堆内存分配和回收带来的性能损耗
3)、什么情况实例适合存放在堆中
场景特征 | 存储要求 | 原因分析 |
实例被方法返回 | 必须存于堆中 | 保证方法外部可访问 |
实例被多线程共享 | 必须存于堆中 | 栈是线程私有空间 |
实例生命周期限于方法内部 | 可存于栈中 | 满足栈上分配条件 |
逃逸分析用于判定实例作用域范围 | 决定存储位置 | 分析实例是否逃逸方法作用域 |
逃逸分析:分析实例是否逃逸出作用域
2.5、GC为什么会导致应用程序卡顿
推荐阅读:带你认识JVM
GC(垃圾回收)是JVM面试中的重要知识点。GC导致应用程序卡顿的核心原因是GC线程触发用户工作线程停止(STW机制)。分析该问题需掌握以下关键点:
- STW(Stop-The-World)机制:垃圾回收时暂停所有用户线程
- JAVA堆特性:垃圾回收主要发生在堆内存区域
- GC知识体系:掌握回收策略、检测算法及实现技术
GC线程与用户工作线程
线程交互场景 | 问题本质 | 后果 |
GC开始时实例非垃圾,GC过程中引用被删除 | 回收不彻底 | 残留垃圾需下次GC清理 |
GC开始时实例未建立引用,GC过程中被引用 | 误回收有效对象 | 引发运行时空指针异常 |
上述问题统称为并行GC的脏实例问题,是STW机制需要解决的核心矛盾。 |
STW(Stop-The-World)是解决脏实例问题的强制措施,其特性包括:
- 同步暂停:GC执行时冻结所有用户线程
- 卡顿根源:线程暂停导致用户感知延迟
- 优化方向:
- 减少GC频率(降低STW触发次数)
- 缩小回收范围(如分代回收策略)
- 并行/并发回收(多线程协同或与用户线程交错执行)
1)、分代回收
分代回收是JVM经典优化策略,其内存划分与运作机制如下:
- 年轻代(占堆1/3):
- 新生区(Eden,占年轻代8/10):新对象初始分配区域
- 幸存区(S0/S1,各占年轻代1/10):存放经历GC存活的对象
- 老年代(占堆2/3):存储长生命周期对象
- 晋升规则:
- 同龄对象占幸存区超50%时晋升
- 分代年龄超过阈值(默认15)时晋升
- 大对象直接分配至老年代
- GC触发逻辑:
- Young GC:清理年轻代(新生区+幸存区)
- Full GC:清理整个堆(含老年代)
分代回收是基于以下经验形成的优化策略:
- 短生命周期对象占多数:大多数实例在年轻代即被回收
- 长生命周期对象筛选机制:存活时间长的实例会被转移到老年代,降低回收频率
- 跨代引用现象稀少:老年代引用年轻代的情况属于少数现象,确保分代策略可行性
新生区的关键特性包括:
- 新实例分配原则:新创建实例优先分配至新生区,大对象直接进入老年代
- 空间管理要求:必须保持新生区无严重内存碎片
- 回收机制:每次Young GC会清空整个新生区,确保内存连续性和回收效率
幸存区的运行机制:
特性 | 实现方式 | 优势 | 缺陷 |
空间结构 | 两个等大区域轮换使用 | 算法实现简单 | 浪费50%空间 |
回收流程 | 存活实例复制到to区后清空原区域 | 避免内存碎片 | 空间利用率低 |
适用场景 | 作为年轻代到老年代的过渡区域 | 保证内存连续性 | 需严格控制区域大小 |
老年代的核心特征:
- 存储对象类型:长生命周期实例和大对象
- 回收频率:触发GC的频率最低
- 设计意义:通过集中管理特殊对象,确保年轻代保持轻量级GC
分代回收的主要优势包括:
- 差异化回收机制:根据对象生命周期采用不同回收策略
- 算法适配性:各代可选择最适合的垃圾回收算法
- 性能优化:避免全堆回收带来的性能损耗
2)、垃圾检测算法
分代回收机制仅说明GC会清除垃圾实例保留非垃圾实例,判断实例是否为垃圾需依赖垃圾检测算法。
①、引用计数法
- 原理:每个实例维护引用计数器,被引用时加1,解除引用时减1,计数器为0的实例判定为垃圾
- 优点:实现简单,OC语言采用类似机制
- 缺陷:
- 循环引用问题:互相引用的实例计数器永不为0导致内存泄漏
- 效率问题:需遍历堆中所有实例检查计数器
②、可达性分析法
- 原理:从GC Roots出发遍历实例,未被访问到的实例判定为垃圾
- 特点:可解决循环引用问题,JAVA主流垃圾检测算法
- 类比:实例如气球,GC Roots为地面,无连接的气球(实例)会被GC(风)回收
③、GC Roots
GC Roots类型 | 说明 |
局部变量表 | 当前执行方法的参数、临时变量 |
方法区静态变量 | 类静态变量 |
方法区常量池 | 常量引用的堆实例 |
本地方法栈变量 | JNI调用native方法引用的堆实例 |
同步锁持有对象 | 被锁对象 |
3)、垃圾回收算法
①、复制算法
- 流程:
- 将内存分为等大两区域,仅使用其一
- 标记阶段识别存活实例
- 将存活实例复制至另一区域并连续排列
- 清空原区域
- 优点:保证内存连续性,算法逻辑简单
- 缺点:浪费50%内存空间,新生代幸存区采用类似机制
②、标记清除法
- 流程:直接释放标记的垃圾实例内存
- 优点:效率高,无移动/复制操作
- 缺点:内存碎片化严重,安卓平台主要采用此算法
③、标记整理法
- 流程:释放垃圾实例后整理存活实例至连续空间
- 优点:解决内存碎片问题
- 缺点:运行效率低于标记清除法,实现复杂度更高
- 注意:三种算法无绝对优劣,选择取决于对内存碎片或停顿时间的容忍度
4)、垃圾回收器
垃圾回收器可分为串行、并行、并发三类,每类有代表性实现。
①、串行回收器
- 特点:单线程GC,工作线程完全暂停
- 代表:
- Serial:年轻代,复制算法,JDK - 3前唯一年轻代回收器
- Serial Old:老年代,标记整理法,Client模式默认回收器
- 缺点:STW时间最长
②、并行回收器
- 特点:多线程GC提升吞吐量,JAVA 8默认回收器
- 代表:
- ParNew:年轻代,复制算法
- Parallel Old:老年代,标记整理法
③、并发回收器
回收器 | 特点 | 算法 |
CMS | 分代模型,追求短停顿,标记阶段分初始标记(STW)、并发标记、重新标记(STW) | 标记清除法 |
G1 | 分区回收,逻辑分代,可预期STW | 区域间复制+整体标记整理 |
ZGC | JDK11引入,无分代概念 | 染色指针技术 |
5)、G1分区回收
- 内存划分:将堆分为等大小区域,含逻辑年轻代、老年代及Humongous区域(存放大对象)
- 算法特性:
- 区域间采用复制算法
- 整体表现为标记整理算法
- 优势:
- 细粒度回收:仅处理部分区域
- 可预测停顿:支持设置最大GC时间参数
6)、Android平台的GC
①、Dalvik
Dalvik虚拟机采用标记清除算法,而Android Runtime引入分代回收策略。
Dalvik早期采用全站回收机制,无分区或分代设计,且GC过程全程STW(Stop-The-World),平均单次STW耗时约100毫秒,这是早期安卓系统卡顿的主要原因。
安卓2.3版本引入并行CMS算法和分区回收机制,单次GC的STW时间缩短至5毫秒以内,显著提升系统流畅度。
Dalvik堆包含以下核心组件:
- Active堆与Zygote堆:分别存储新实例和系统预加载实例
- 两个Heap Bitmap:
- Live Bitmap:记录上次GC存活实例
- Mark Bitmap:记录本次GC标记结果
- Mark栈:辅助实现地址有序的增量标记算法
- Card Table:以128字节为单位跟踪堆修改,解决并发标记的脏实例问题
②、ART
ART堆结构升级:
- Image Space:映射系统类库镜像,进程间共享
- Allocation Space:替代Active堆,存储新实例
- Large Object Space:离散地址空间专存大对象
ART的GC策略分级:
- Sticky GC:仅回收两次GC间新产生的垃圾
- Partial GC:回收Allocation Space和Large Object Space
- Full GC:覆盖除Image Space外的全部堆区域
GC策略升级路径:
- Sticky GC → - Partial GC → - 堆扩容 → - 回收软引用 → - Full GC → - OOM异常
2.6、双重检测的单例为什么要加volatile关键字?
双重检测单例模式是JVM面试中的常见题目,重点考察volatile关键字在并发编程中的作用。该题目涉及并发编程与JMM(Java内存模型)的核心知识点。
1)、指令重排序
- 指令重排序的定义:编译器或处理器为优化性能而改变指令执行顺序
- 指令重排序对单例模式的影响:可能导致对象未完全初始化就被使用
- volatile关键字的作用机制:通过内存屏障禁止指令重排序
- JMM规范对指令重排序的限制
2)、计算机硬件内存模型
- CPU执行速度与内存读写速度不匹配导致性能瓶颈
- 高速缓存作为CPU与内存之间的缓冲层
- 多线程环境下缓存一致性问题:当多个CPU核心同时操作同一变量时可能出现数据不一致
- 缓存一致性协议用于解决多核CPU的缓存同步问题
- JVM作为软件实现的计算机需要解决类似的线程安全问题
3)JMM
Java内存模型(JMM)的核心要点包括:
- 每个线程拥有独立的工作内存
- 主内存与工作内存通过数据副本交换信息
- JMM规范定义了变量在主内存与工作内存间的交互规则
- 工作内存的实现方式可能包括硬件缓存、寄存器或编译器优化
- JMM抽象模型统一了不同硬件平台的内存访问行为
4)、并发编程3大要素
①、原子性
并发编程原子性的关键点:
- 操作不可分割性是原子性的核心特征
- synchronized关键字可保证代码块原子性
- 锁机制是实现原子性的主要手段
②、可见性
变量可见性的实现方式:
- volatile关键字强制变量修改立即同步到主内存
- synchronized关键字在加锁时清空工作内存并重新读取主内存值
- 锁释放前会将工作内存变量刷新回主内存
③、有序性
程序有序性的保障机制:
- synchronized关键字保证代码块执行顺序的可预期性
- volatile关键字通过内存屏障防止指令重排序
- happens-before原则是JMM有序性的理论基础
5)、双重检测单例的分析
双重检测的单例模式也称为懒汉式单例模式。假设不加volatile关键字,分析其潜在问题。
①、原子性分析
- 双重检测单例的原子性问题:通过将第二重检测和实例创建代码置于synchronized同步块中,可保证原子性。
- 不加volatile关键字的影响:即使未加volatile,并发调用getInstance时创建多个实例的概率较低。
②、可见性分析
- synchronized的可见性保证:加锁前清空工作内存并同步主内存变量值,释放锁前刷新变量值至主内存。
- 同步块内代码的可见性:可视为直接使用主内存中的最新变量值,因此可见性问题较少出现。
③、有序性分析
- synchronized同步块的有序性:代码层面逻辑有序,但指令重排序可能导致未加volatile时单例模式失效。
- volatile的职责:禁止指令重排序,需进一步分析重排序的具体影响。
④、指令重排序
- 指令重排序的定义:编译器和处理器为提高性能可能调整指令顺序,但需遵循以下原则:
- as-if-serial原则:单线程下执行结果不变。
- happens-before原则:不破坏指令间的依赖关系。
- 可重排序的指令类型:无依赖关系的指令(如独立变量赋值)。
- 不可重排序的指令类型:存在数据依赖或控制依赖的指令。
总结:
- volatile的作用:防止指令重排序,避免并发时获取未完全初始化的实例。
- 结论:双重检测单例模式中,volatile关键字不可或缺。
OK,今天的内容就到这里了,咱们下期再会!