Android 高频八股文十问

时光荏苒,不知不觉中已经入坑 Android 开发6年了,在这些年里也经历过大大小小的各种面试,今天闲来无事,回忆一下,在此列举我遇到的,比较高频的八股文十问,答案仅供参考哈~

JVM 模型

  • 方法区/元空间:存储已被 JVM 加载的类信息(类的结构、字段、方法、接口等)、常量(如字符串常量池)、静态变量、即时编译器编译后的代码等数据。所有线程共享,在 JVM 启动时创建。JDK 7 及之前方法区的实现是 "永久代",物理上属于堆的一部分,JDK 8 及之后永久代被移除,方法区由 "元空间" 实现,元空间使用本地内存。需要回收,主要回收废弃的常量和无用的类,若无法分配内存,会抛出 OutOfMemoryError。
  • :JVM 管理的内存中最大的一块,用于存放对象实例和数组,几乎所有的对象都在这里分配内存。所有线程共享,在 JVM 启动时创建,是垃圾回收器的主要工作区域,若堆无法扩展或分配对象时内存不足,会抛出 OutOfMemoryError。为提高 GC 效率,堆通常被划分为新生代和老年代,新生代存放刚创建的对象或生命周期较短的对象,进一步分为 Eden 区(新对象优先分配)和两个 Survivor 区(用于存活对象的复制),老年代存放生命周期较长的对象。
  • 虚拟机栈:为 Java 方法的执行提供内存支持。每个方法被调用时,JVM 会创建一个栈帧并压入虚拟机栈;方法执行完毕后,栈帧出栈。线程私有,生命周期与线程一致。栈的深度是有限的,若方法调用层级过深(如递归无终止条件),会抛出 StackOverflowError,若栈动态扩展时无法申请到足够内存,会抛出 OutOfMemoryError。
  • 本地方法栈:专门为本地方法(由非 Java 语言编写,如 C/C++)的执行提供内存支持,线程私有,同样可能抛出StackOverflowError(栈深度超限)或OutOfMemoryError(内存不足)。
  • 程序计数器:本质是一个 "行号指示器",记录当前线程正在执行的字节码指令的地址(如果执行的是 Java 方法)。若执行的是本地方法(如 C/C++ 实现),则计数器值为 undefined。每个线程都有独立的程序计数器,互不干扰,生命周期与线程一致。线程切换时,能通过程序计数器恢复到正确的执行位置。是 JVM 中唯一不会抛出 OutOfMemoryError 的区域(内存占用极小)。

GC 机制

判断哪些对象需要回收

可达性分析算法:通过一系列的 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

可作为 GC Roots 的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中 JNI(Native方法)引用的对象

GC 管理的区域是 Java 堆,虚拟机栈、方法区和本地方法栈不被 GC 所管理,因此选用这些区域内引用的对象作为 GC Roots,是不会被 GC 所回收的。

分代收集算法

根据对象存活周期的不同将内存划分为几块并采用不同的垃圾收集算法。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,所谓复制算法,就是将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记---清理"或者"标记---整理"算法来进行回收。所谓"标记---整理"算法,就是先标记出所有需要回收的对象,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

Java 引用类型

强引用

强引用是最常见的引用类型,也是默认的引用方式。当一个对象被强引用关联时,只要强引用存在,垃圾回收器就绝不会回收该对象。即使内存不足(OOM),GC 也不会回收被强引用关联的对象,而是直接抛出内存溢出异常。

java 复制代码
Object strongRef = new Object(); 
// 当强引用被置为 null 时,对象失去强引用,可能被 GC 回收。
strongRef = null; 

软引用

当系统内存充足时,对象不会被回收,当内存不足时,GC 会主动回收该对象,适用于 "缓存场景"(如图片缓存、数据缓存),内存充足时保留缓存提升性能,内存不足时释放缓存避免 OOM。

java 复制代码
// 创建软引用,关联一个字符串对象
SoftReference<String> softRef = new SoftReference<>(new String("缓存数据"));

// 获取对象(内存不足时可能为 null)
String data = softRef.get(); 
if (data != null) {
    // 使用缓存数据
} else {
    // 缓存已被回收,重新加载数据
}

弱引用

只要 GC 运行,无论内存是否充足,都会回收被弱引用关联的对象,可用于临时数据缓存或避免内存泄漏场景等。

java 复制代码
// 创建弱引用,关联一个对象
WeakReference<String> weakRef = new WeakReference<>(new String("临时数据"));

String data = weakRef.get(); 

虚引用

虚引用是强度最弱的引用类型,无法通过虚引用获取对象(get() 永远返回 null),唯一作用是跟踪对象被 GC 回收的状态。必须与引用队列配合使用,当虚引用关联的对象被 GC 回收时,虚引用本身会被加入到引用队列中,可通过队列感知对象的回收。

java 复制代码
// 创建引用队列
ReferenceQueue<String> queue = new ReferenceQueue<>();
// 创建虚引用,关联对象和队列。
PhantomReference<String> phantomRef = new PhantomReference<>(new String("跟踪对象"), queue);

// 虚引用的 get() 永远返回 null
String data = phantomRef.get(); 

// 当对象被回收后,phantomRef 会被加入 queue。
Reference<? extends String> ref = queue.poll(); 
if (ref != null) {
    // 处理对象已被回收的逻辑
}

HashMap 机制

HashMap 的底层存储结构是数组(称为 "哈希桶"),数组中的每个元素是一个链表,当链表长度超过阈值(默认8),且数组长度 ≥ 64 时,链表会转为红黑树,红黑树的引入将查询时间复杂度从链表的 O(n) 优化为 O(log n)。

当有键值对(key - value)要存储时,首先通过哈希函数计算键(key)的哈希值,然后将哈希值转换为数组的索引,来确定键值对应该存储在数组的哪个位置(桶)中。当不同的键通过哈希函数得到相同的数组索引时,就会发生哈希冲突。发生冲突时,就会通过链表的形式来解决冲突。因为一个桶上可能存在多个键值对,所以在查找的时候,会先通过 key 的哈希值先定位到桶,再遍历桶上的所有键值对,找出 key 相等的键值对,从而来获取 value。

动态扩容

HashMap的容量(数组长度)是动态增长的,目的是避免哈希冲突过于频繁。

  • 触发条件:当元素数量超过 "扩容阈值"(threshold = 容量 × 负载因子)时触发扩容,默认负载因子为 0.75。
  • 扩容过程:新建一个容量为原数组两倍的新数组,将旧数组中的元素重新计算索引,更新阈值(新阈值 = 新容量 × 负载因子)。

为什么重写 equals 方法时也要重写 hashCode ?

这俩存在强关联性,核心目的是保证 "逻辑相等" 的对象在哈希表(如 HashMap、HashSet)中能被正确识别。哈希表(如 HashMap)存储数据时,会先通过 hashCode 定位对象的存储位置,再通过 equals 判断桶内是否有相同的对象。倘若两个对象 equals 相等但 hashCode 不同:哈希表会把它们分到不同的桶里,导致明明 "相等" 的对象被当成不同的键(比如在 HashMap 中会被同时存储,违反了 "键唯一" 的预期)。

如果两个对象通过 equals 比较为 "相等",那么它们的 hashCode 必须返回相同的值;反之,hashCode 相同的对象,equals 不一定相等(这是哈希冲突的正常情况)。

HashMap 和 Hashtable 的区别?

  • HashMap 是线程不安全的,多线程并发操作时可能导致数据错乱。Hashtable 是线程安全的。其所有方法都被 synchronized 修饰,多线程环境下可直接使用,不会出现数据不一致问题。
  • HashMap 允许 key 和 value 为 null。其中,key 为 null 时固定存储在数组索引 0 的位置(仅允许一个 null 键),value 可以有多个 null。Hashtable 不允许 key 或 value 为 null。若传入 null,会直接抛出 NullPointerException。
  • Hashtable 继承自 Dictionary 类,同时实现 Map 接口。HashMap 继承自 AbstractMap 类,实现 Map 接口。

实际开发中,优先使用 HashMap;若需线程安全,推荐 ConcurrentHashMap 而非 Hashtable

ConcurrentHashMap 是线程安全的键值对集合,专为高并发场景设计。它不是锁整个表,而是锁数组中的单个桶(链表 / 红黑树的头节点),当插入元素时,先尝试直接插入(若桶为空),失败则说明桶已被占用,此时对桶的头节点加 synchronized 锁,保证同一桶内的操作互斥。不同桶的操作可以完全并发,锁冲突只发生在同一桶内的线程之间,并发效率大幅提升。

浅拷贝和深拷贝的区别

  • 浅拷贝:当拷贝一个对象时,对于基本数据类型字段(如int、float、boolean等),会直接复制其值;但对于引用类型字段(如对象、数组等),仅复制其引用地址(即指向原对象中引用类型的内存地址),因此,原对象和拷贝对象的引用类型字段会指向同一个内存地址,修改其中一个的引用类型字段,会影响另一个。
  • 深拷贝:拷贝对象时,不仅会复制基本数据类型字段的值,对于引用类型字段,会递归复制其指向的实际对象,即创建一个新的引用类型对象,并复制原对象中引用类型的所有内容,因此,原对象和拷贝对象的引用类型字段会指向不同的内存地址,修改其中一个的引用类型字段,不会影响另一个。
java 复制代码
public class Address {

    private String city;

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }
}
java 复制代码
public class User implements Cloneable {
    private int age; // 基本类型
    private Address address; // 引用类型

    @Override
    public User clone() { //浅拷贝
        try {
            User clone = (User) super.clone();
            return clone;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

    public User deepCopy() {  //深拷贝
        Gson gson = new Gson();
        String json = gson.toJson(this);
        User clone = gson.fromJson(json, new TypeToken<User>() {
        });
        return clone;
    }
}

Activity,Window,View 三者之间的关系

Activity、Window、View 三者是UI 展示与交互的核心组件,它们的关系可以用 "层次依赖" 来概括。

核心定位

  • Activity:Android 四大组件之一,是用户交互的 "载体",负责管理界面的生命周期、业务逻辑。
  • Window:抽象类,具体实现为 PhoneWindow,是 "窗口" 的概念,是 Activity 与 View 之间的 "桥梁",负责提供 UI 绘制的载体。
  • View:所有 UI 元素的基类,是具体的可视化组件,负责实际的绘制和用户交互响应。

依赖关系

  • Activity 持有 Window:每个 Activity 在创建时会初始化一个 Window 对象,通过 getWindow() 可获取该 Window。Activity 的生命周期会间接影响 Window 的状态(如 Activity 销毁时,Window 也会被回收)。
  • Window 承载 View:Window 是 View 的容器,它内部维护了一个顶级 View - DecorView(整个 View 树的根节点)。DecorView 包含标题栏和内容栏,在 Activity 中通过 setContentView(view) 设置的布局,最终会被添加到 DecorView 的内容栏中。
  • View 依附于 Window 才能显示:View 本身无法独立显示,必须通过 Window 关联到屏幕。Window 通过 WindowManager 将 DecorView 添加到系统的窗口管理服务中,最终由系统把 View 绘制到屏幕上。

Activity 的启动模式

Standard(默认模式)

  • 特点:每次启动 Activity 时,系统都会创建一个新的实例,并将其压入当前任务栈的栈顶。
  • 适用场景:大多数普通页面(如列表项详情页、设置页等),需要多次创建的场景。

SingleTop

  • 特点:启动 Activity 时,系统会先检查当前任务栈的栈顶是否已存在该 Activity 的实例,若存在则不创建新实例,而是调用该实例的 onNewIntent() 方法,复用现有实例。若不存在则创建新实例并压入栈顶。
  • 适用场景:需要频繁启动,但栈顶复用即可满足需求的页面,例如推送通知打开的详情页(若已在栈顶,直接刷新内容)。

SingleTask

  • 特点:启动 Activity 时,系统会检查整个任务栈中是否存在该 Activity 的实例,若存在则将该实例上方的所有 Activity 全部出栈,使该实例成为栈顶,并调用其 onNewIntent()。若不存在:则创建新实例并压入当前任务栈。
  • 适用场景:需要唯一实例,且希望 "返回时直接回到该页面" 的场景,例如应用的主界面。

SingleInstance

  • 特点:该 Activity 会独占一个任务栈,整个系统中只有一个实例,且所在任务栈中只有它自己。启动时,若实例不存在,系统会创建一个新的任务栈,将该 Activity 实例放入其中。若实例已存在,无论哪个应用启动它,都会直接切换到该实例所在的任务栈,并调用其 onNewIntent()。
  • 适用场景:系统级,需要全局唯一的页面。例如电话拨号界面(无论从哪个应用启动拨号,都是同一个实例)

所有启动模式的生命周期回调遵循统一规则:

新实例创建:onCreate() → onStart() → onResume()

复用已有实例:onNewIntent() → onResume()(若从后台唤醒,会先有 onRestart() → onStart())

Service 的两种启动方式有什么区别

核心用途

  • startService:主要用于让 Service 在后台独立执行耗时任务,不依赖启动它的组件(如 Activity)。
  • bindService:主要用于让启动它的组件与 Service 建立双向通信,Service 的生命周期与客户端绑定。

生命周期差异

  • startService:生命周期为:onCreate() → onStartCommand()(每次启动都会调用) → onDestroy()。一旦启动,Service 会独立运行,即使启动它的组件(如 Activity)被销毁,Service 仍可继续存在,直到通过 stopService(外部调用)或 stopSelf(Service 自身调用)停止,才会触发 onDestroy()。
  • bindService:onCreate() → onBind()(返回 Binder 对象,仅首次绑定调用) → onUnbind() → onDestroy()。Service 的生命周期与绑定的客户端强关联。当所有绑定的客户端都通过 unbindService() 解绑后,Service 会自动销毁(触发 onUnbind() 和 onDestroy()),若客户端被销毁(如 Activity finish),系统会自动为其解绑,进而可能导致 Service 销毁。

通信能力差异

  • startService:启动组件与 Service 之间无直接通信通道。若需传递数据,只能通过启动时的 Intent 携带,无法实时交互。
  • bindService:通过 ServiceConnection 接口和 Binder 对象实现双向通信。客户端可获取 Service 返回的 Binder 对象,直接调用 Service 中的方法。

为什么子线程不能更新 UI

  1. UI 组件的线程不安全特性: UI 组件内部没有实现线程同步机制,如果允许多个线程同时操作 UI,会导致 UI 状态混乱,线程同步机制会增加 UI 操作的性能开销,而 UI 操作通常需要高频高效执行,为了兼顾性能,Android 设计时选择不支持多线程直接操作 UI。
  2. 单线程模型的设计约束:所有 UI 操作必须在主线程中执行,这是一种简化 UI 开发的设计范式。这种设计避免了开发者手动处理复杂的线程同步问题,降低了开发难度。
  3. 系统的强制校验机制:当任何线程尝试执行 UI 操作(如 setText()、setVisibility())时,最终会通过 ViewRootImpl 触发 checkThread() 检查,若检测到操作线程不是主线程,会直接抛出异常,明确禁止子线程更新 UI。

requestLayout 和 invalidate 的区别

Android View 的绘制流程分为三个核心阶段:

measure(测量尺寸)→ layout(确定位置)→ draw(绘制内容)。

  • invalidate:仅触发 draw 阶段(重绘),不影响 measure 和 layout。
  • requestLayout:触发 measure 和 layout 阶段(重新测量尺寸、确定位置),可能间接触发 draw(若布局变化导致外观改变)。
相关推荐
Lee川2 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i4 小时前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有4 小时前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有5 小时前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫6 小时前
Looper.loop() 循环机制
面试
AAA梅狸猫6 小时前
Handler基本概念
面试
Wect6 小时前
浏览器缓存机制
前端·面试·浏览器
掘金安东尼7 小时前
Fun with TypeScript Generics:玩转 TS 泛型
前端·javascript·面试
掘金安东尼7 小时前
Next.js 企业级落地
前端·javascript·面试
掘金安东尼7 小时前
React 性能优化完全指南 2026
前端·javascript·面试