VIVO Android面试题及参考答案
请重写算法题:求数组的全排列。
思路:
要获取一个数组的全排列,我们可以利用回溯算法。具体来说,回溯算法通过递归的方式逐步生成排列,在每一步都将一个元素加入排列中,然后在下一步递归中排除已选元素,回溯的时候撤销选择,尝试其他可能。
步骤:
-
递归生成排列:
- 使用一个辅助数组来记录当前的排列。
- 对于每个位置,我们尝试填充每一个可能的元素,并递归地填充后续的位置。
- 使用回溯的方式,在完成一个排列后,撤回当前选择,继续尝试其他可能性。
-
交换元素:
- 通过交换数组中的元素来生成排列,而不是额外使用空间存储状态。这样可以减少空间复杂度。
时间复杂度:
- 生成全排列的时间复杂度是 O(n!),因为每个元素都需要和其他元素交换一遍,排列的总数为 n!。
空间复杂度:
- 空间复杂度是 O(n),因为递归调用栈的深度是 n(每次递归深度为数组的长度),且我们只需要常数空间来交换数组元素。
Java 代码实现:
import java.util.ArrayList;
import java.util.List;
public class Permutations {
// 主函数,返回所有的全排列
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, new ArrayList<>(), result, new boolean[nums.length]);
return result;
}
// 回溯函数,生成排列
private void backtrack(int[] nums, List<Integer> current, List<List<Integer>> result, boolean[] used) {
// 当当前排列的长度等于nums的长度时,说明找到了一个全排列
if (current.size() == nums.length) {
result.add(new ArrayList<>(current));
return;
}
// 遍历nums数组中的每个元素
for (int i = 0; i < nums.length; i++) {
// 如果该元素已经被使用过,则跳过
if (used[i]) continue;
// 做选择,标记当前元素为已使用
used[i] = true;
current.add(nums[i]);
// 递归生成剩余的排列
backtrack(nums, current, result, used);
// 撤销选择,回溯
used[i] = false;
current.remove(current.size() - 1);
}
}
// 测试主函数
public static void main(String[] args) {
Permutations solution = new Permutations();
int[] nums = {1, 2, 3};
List<List<Integer>> result = solution.permute(nums);
// 打印结果
for (List<Integer> perm : result) {
System.out.println(perm);
}
}
}
请解释 Java 中的三大特性,并举例说明其实际应用。
Java 的三大特性是封装、继承和多态。
封装是指将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象内部信息,而是通过该对象提供的方法来实现对内部信息的操作和访问。例如,我们定义一个 “银行账户” 类(BankAccount):
class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
}
}
}
在这个例子中,账户余额(balance)是私有变量,外部无法直接访问和修改。只能通过存款(deposit)、取款(withdraw)和查询余额(getBalance)这些方法来操作账户余额,这样就保证了数据的安全性和完整性。
继承是指在现有类的基础上创建新类,新类继承现有类的属性和方法。例如,我们有一个 “动物”(Animal)类:
class Animal {
public void eat() {
System.out.println("动物在吃东西");
}
}
然后定义一个 “狗”(Dog)类继承自 Animal 类:
class Dog extends Animal {
public void bark() {
System.out.println("狗在叫");
}
}
狗类继承了动物类的 “吃”(eat)的方法,同时还有自己特有的 “叫”(bark)的方法。这样可以提高代码的复用性,减少代码冗余。
多态是指同一个行为具有多个不同表现形式或形态。在 Java 中,多态主要通过方法重写和方法重载来实现。方法重写是指在子类中重新定义父类中已经定义的方法。例如,继续上面的动物和狗的例子,我们在 Dog 类中重写 eat 方法:
class Dog extends Animal {
@Override
public void eat() {
System.out.println("狗在吃骨头");
}
public void bark() {
System.out.println("狗在叫");
}
}
当我们有一个 Animal 类型的引用指向一个 Dog 对象时,调用 eat 方法会根据对象的实际类型(Dog)来调用 Dog 类中重写后的 eat 方法。例如:
Animal a = new Dog();
a.eat();
会输出 “狗在吃骨头”。方法重载是指在同一个类中定义多个同名方法,但是参数列表不同。例如:
class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
}
这样在调用 add 方法时,编译器会根据传入的参数类型来决定调用哪个 add 方法,这也体现了多态性。
abstract class(抽象类)和 interface(接口)有什么区别?
抽象类是一种不能被实例化的类,它主要用于被其他类继承。抽象类中可以包含抽象方法和非抽象方法。抽象方法是只有方法声明,没有方法体的方法,它强制子类必须重写该方法。
例如,我们定义一个抽象类 “图形”(Shape):
abstract class Shape {
int x;
int y;
public Shape(int x, int y) {
this.x = x;
this.y = y;
}
public abstract double area();
public void move(int newX, int newY) {
x = newX;
y = newY;
}
}
在这个抽象类中,area 方法是抽象方法,它没有方法体,这意味着继承 Shape 类的子类必须实现 area 方法来计算各自图形的面积。move 方法是普通方法,子类可以继承这个方法。
接口是一种完全抽象的类型,它只包含抽象方法和常量。接口中的方法默认都是 public abstract 类型,常量默认是 public static final 类型。接口主要用于定义一组行为规范。
例如,我们定义一个 “可绘制”(Drawable)接口:
interface Drawable {
void draw();
}
任何实现了这个接口的类都必须实现 draw 方法,用于定义如何绘制该对象。
从实现方式来看,一个类只能继承一个抽象类(单继承),但可以实现多个接口。例如,我们有一个 “圆形”(Circle)类,它可以继承 Shape 抽象类并且实现 Drawable 接口:
class Circle extends Shape implements Drawable {
int radius;
public Circle(int x, int y, int radius) {
super(x, y);
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public void draw() {
System.out.println("绘制圆形");
}
}
从方法访问权限上,抽象类中的方法可以有多种访问权限,如 public、protected、private(一般抽象方法是 public 或者 protected)。而接口中的方法默认是 public,并且不能有其他访问权限。
在设计目的上,抽象类通常用于提取一些通用的属性和行为,这些属性和行为在子类中有一些共性,但又有一些差异需要通过抽象方法让子类去实现。接口更多的是用于定义一种契约,让不同的类可以遵循相同的行为规范,实现不同的具体行为。例如,在一个游戏开发中,可能有一个抽象类 “角色”(Character),它包含了角色的基本属性如生命值、攻击力等,以及一些通用的行为如移动(move)。而接口 “可攻击”(Attackable)可以定义攻击(attack)这个行为,不同的角色类可以实现这个接口来定义自己的攻击方式。
final 关键字在变量、函数、类中的作用分别是什么?
在变量方面,当一个变量被声明为 final 时,它的值就不能被改变。如果是基本数据类型,如 int、double 等,一旦初始化后就不能再赋值。例如,在一个方法中定义了一个 final 的 int 变量:
public void someMethod() {
final int num = 10;
// num = 20; 这行代码会导致编译错误
}
对于引用数据类型,如对象,final 表示引用不能被重新赋值,但是对象的内部状态可以改变。例如:
class SomeClass {
private int value;
public SomeClass(int v) {
value = v;
}
public void setValue(int v) {
value = v;
}
}
public void anotherMethod() {
final SomeClass obj = new SomeClass(5);
// obj = new SomeClass(10); 这行代码会导致编译错误
obj.setValue(10); // 这是可以的,对象内部状态可以改变
}
在函数方面,被声明为 final 的方法不能被重写。这在继承关系中很重要,用于确保某个方法的行为不会被子类改变。例如,有一个基类:
class Base {
public final void someFinalMethod() {
System.out.println("这是基类的最终方法");
}
}
class Derived extends Base {
// public void someFinalMethod() { 这行代码会导致编译错误
// System.out.println("试图重写基类的最终方法");
// }
}
在类方面,被声明为 final 的类不能被继承。这通常用于一些安全性或者性能方面的考虑。例如,Java 中的 String 类就是 final 类,这是为了保证字符串的不可变性等诸多特性。如果定义一个 final 类:
final class FinalClass {
// 类的内容
}
// class AnotherClass extends FinalClass { 这行代码会导致编译错误
// // 试图继承final类
// }
static 关键字有什么作用?
在 Java(包括 Android 开发中 Java 部分)中,static 关键字主要有以下几种作用。
首先是用于修饰变量,当一个变量被声明为 static 时,它属于类而不是对象。这意味着所有该类的对象共享这个静态变量。例如,我们有一个 “学生”(Student)类:
class Student {
private static int count;
public Student() {
count++;
}
public static int getCount() {
return count;
}
}
在这个例子中,count 变量是静态的,每次创建一个新的学生对象,count 就会加 1。可以通过类名直接访问这个静态变量,如 Student.getCount ()。而且,不管创建了多少个 Student 对象,它们都共享这一个 count 变量。
其次,static 可以修饰方法。静态方法也属于类,而不是对象。它可以在没有创建对象的情况下被调用,这在一些工具类方法中非常有用。例如,有一个数学工具类(MathUtils):
class MathUtils {
public static int add(int a, int b) {
return a + b;
}
}
可以直接通过 MathUtils.add (3, 4) 来调用这个方法,而不需要创建 MathUtils 类的对象。静态方法只能访问静态变量和其他静态方法,因为非静态的成员是和对象相关联的,在没有对象的情况下无法访问。
另外,static 还可以用于初始化代码块。静态初始化块在类加载时执行,并且只执行一次。它可以用于初始化静态变量等操作。例如:
class SomeClass {
private static int someValue;
static {
someValue = 10;
}
public static int getSomeValue() {
return someValue;
}
}
当类被加载时,静态初始化块中的代码会先执行,将 someValue 初始化为 10,之后可以通过类名访问这个变量。在 Android 开发中,静态变量和静态方法也经常用于存储和获取全局的配置信息或者工具方法,比如存储应用的主题颜色、屏幕宽度等全局变量,或者提供一些通用的文件读取、网络请求等工具方法。
反射能破坏 private 访问修饰符,那设置 private 还有什么意义?
虽然反射可以访问和修改被声明为 private 的成员,但是 private 修饰符仍然有很重要的意义。
从设计理念上来说,private 是一种封装机制。它体现了面向对象编程中的信息隐藏原则。通过将成员变量和方法声明为 private,类的开发者可以明确地划分哪些是内部实现细节,哪些是对外提供的接口。这使得代码的维护和扩展更加容易。例如,一个复杂的计算类,它内部有一些中间计算变量,这些变量的取值和变化规则是内部实现细节,通过将它们设为 private,可以防止外部代码随意地修改这些变量,从而保证计算逻辑的正确性。
从安全角度看,在正常的程序流程中,private 可以防止意外的访问和修改。大多数情况下,程序是按照正常的调用关系和逻辑来执行的。如果没有 private 修饰符的限制,开发人员可能会在不经意间访问和修改了不该修改的成员,导致程序出现错误。比如一个包含用户密码的类,密码存储成员被设为 private,这样在正常的业务逻辑下,其他代码就不能随意访问这个密码,减少了密码泄露的风险。
而且,虽然反射可以突破 private 的限制,但使用反射本身是一种比较高级的、具有一定风险的操作。它需要开发者明确地知道自己在做什么,并且要谨慎使用。在很多开发场景中,如商业应用开发、团队开发等环境下,是不提倡随意使用反射来破坏 private 访问限制的。因为这可能会导致代码的可读性变差,并且如果反射代码出现问题,调试起来会更加困难。
另外,对于代码的使用者来说,private 修饰符提供了一种契约,让使用者知道哪些是不应该直接访问的部分。这就好比一个黑盒子,使用者只需要关注对外提供的接口(如 public 方法),而不需要了解内部复杂的实现细节。如果没有这种限制,代码的使用者可能会对内部实现产生依赖,当内部实现发生变化时(比如修改了 private 成员的含义或者计算方式),就会导致使用者的代码出现问题。
局部变量不赋值为什么不能通过编译?
在大多数编程语言(包括 Java 和 Android 开发中使用的 Java)中,局部变量在使用前必须被赋值,这主要是出于程序的确定性和安全性考虑。
从程序确定性的角度来说,局部变量的初始值是不确定的。与成员变量不同,成员变量如果没有被初始化,编译器会给它一个默认值(例如,对于基本数据类型,int 默认是 0,boolean 默认是 false 等)。而局部变量没有这样的默认初始化机制。如果允许局部变量不赋值就使用,那么在运行时,程序的行为将变得不可预测。例如,有一个简单的方法:
public void someMethod() {
int num;
// 在这里使用num会导致编译错误,因为num还没有被赋值
System.out.println(num);
}
如果编译器允许这样的代码通过,那么当执行到 System.out.println (num) 这一步时,num 的值是不确定的,可能是之前栈内存中的任意一个垃圾值,这会导致程序输出的结果不可控。
从安全性角度看,强制局部变量初始化可以避免很多潜在的错误。例如,在一个复杂的计算方法中,如果局部变量没有初始化就参与运算,可能会导致计算结果错误,甚至引发程序崩溃。假设我们有一个方法来计算两个数的和并乘以一个系数:
public int calculate(int a, int b) {
int coefficient;
int sum = a + b;
int result = sum * coefficient; // 这里coefficient没有赋值,会导致错误
return result;
}
如果编译器允许这样的代码通过,在运行时就会出现错误,因为 coefficient 没有被赋予合理的值。通过要求局部变量必须初始化,可以在编译阶段就发现这种潜在的错误,提高程序的健壮性。而且,这种规定也有助于提高代码的可读性,开发者在看到一个局部变量时,可以明确知道它在使用前已经被赋予了一个确定的值,从而更容易理解代码的逻辑。
请简述懒汉式单例如何保证线程安全?能否对双重检验锁单例进行优化?
懒汉式单例是指在第一次调用获取实例的方法时才创建实例。为了保证线程安全,一种常见的做法是使用同步方法。例如:
class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
在这个例子中,通过在 getInstance 方法上添加 synchronized 关键字,使得在多个线程同时访问这个方法时,只有一个线程能够进入方法内部进行实例的创建。这样就保证了在多线程环境下,只会创建一个实例。不过这种方式的缺点是性能开销较大,因为每次调用 getInstance 方法都需要获取锁。
双重检验锁(DCL)是对懒汉式单例的一种优化。它的基本思路是在同步块外先进行一次 null 检查,然后在同步块内再进行一次 null 检查。例如:
class DoubleCheckedLockingSingleton {
private static volatile DoubleCheckedLockingSingleton instance;
private DoubleCheckedLockingSingleton() {}
public static DoubleCheckedLockingSingleton getInstance() {
if (instance == null) {
synchronized (DoubleCheckedLockingSingleton.class) {
if (instance == null) {
instance = new DoubleCheckedLockingSingleton();
}
}
}
return instance;
}
}
在这里,使用 volatile 关键字是很关键的。因为在没有 volatile 关键字时,由于指令重排的原因,可能会导致其他线程获取到一个还没有完全初始化的实例。通过将 instance 声明为 volatile,可以确保线程在读取这个变量时,总是能获取到最新的、完全初始化的值。
对于双重检验锁单例的优化,可以考虑使用内部类的方式。这种方式利用了类加载机制来保证单例的创建。例如:
class InnerClassSingleton {
private InnerClassSingleton() {}
private static class SingletonHolder {
private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
}
public static InnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
在这个例子中,当第一次调用 getInstance 方法时,会加载 SingletonHolder 内部类,而类加载过程是线程安全的,并且只会加载一次。这样就保证了只有一个实例被创建,同时避免了双重检验锁可能带来的一些复杂问题,如指令重排等,在一定程度上优化了单例的实现方式。
给定一条链表,如何判断其中有环?(例如使用快慢指针方法)
使用快慢指针是判断链表是否有环的一种高效方法。
快慢指针同时从链表头开始,快指针每次移动两步。如果链表没有环,快指针会先到达链表末尾,因为它移动速度快。例如,假设有一个简单的链表节点类定义如下:
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
this.next = null;
}
}
可以用以下方式实现判断是否有环:
public boolean hasCycle(ListNode head) {
if (head == null) {
return false;
}
ListNode slow = head;
ListNode fast = head;
while (fast!= null && fast.next!= null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
原理在于,如果链表有环,快指针会在环内追上慢指针。因为快指针每次比慢指针多走一步,在环内,快指针会逐渐接近慢指针,最终会相遇。就好比在一个环形跑道上,一个人慢跑,另一个人快跑,快跑的人肯定会在某一时刻再次和慢跑的人相遇。
另外,还可以使用哈希表来判断。遍历链表的过程中,将每个节点存入哈希表。每次访问一个新节点时,检查哈希表中是否已经存在该节点。如果存在,就说明链表有环。这种方法的时间复杂度也是线性的,但是需要额外的空间来存储哈希表。而快慢指针方法不需要额外的存储空间,空间复杂度较低。在处理长链表或者对空间要求较高的场景下,快慢指针方法更有优势。
从更深入的角度看,快慢指针的移动速度比例也可以调整。比如快指针每次移动三步等,但要注意特殊情况,比如链表长度和环的长度之间的关系可能会导致快指针跳过慢指针而错过相遇的情况。所以每次移动两步是比较稳妥的一种方式,可以保证在有环的情况下,快指针一定能和慢指针相遇。
请解释并行和并发的区别。
并行和并发是在多任务处理中两个重要的概念。
并发是指多个任务在一段时间内交替执行。在单处理器系统中,由于只有一个处理核心,这些任务实际上是通过时间片轮转等调度方式来共享处理器资源。例如,操作系统中有多个进程或线程需要执行,在并发环境下,处理器会快速地在这些任务之间切换。假设我们有三个任务 A、B、C,处理器可能先执行任务 A 的一部分,然后切换到任务 B 执行一段时间,接着又切换到任务 C,如此循环。这种方式给人一种多个任务同时执行的错觉。在 Java 中,通过多线程可以实现并发编程。例如,有两个线程,一个线程用于读取文件内容,另一个线程用于在屏幕上显示一些信息,这两个线程在单核处理器上就是并发执行的。
并行则是指多个任务真正地在同一时刻同时执行。这需要多个处理器或者多核处理器来支持。例如,在一个四核处理器系统中,可以同时运行四个任务,每个核心负责一个任务。就像有四条独立的生产线同时在工作,每个生产线都在处理自己的任务,互不干扰。如果我们有一个计算密集型的应用,如大型矩阵运算,将任务分配到多个核心上并行执行,可以大大提高运算速度。
从应用场景来看,并发更侧重于处理多个任务的交互和资源共享。比如在一个网络服务器中,需要同时处理多个客户端的请求,这些请求可以并发处理,通过合理的调度来提高系统的响应速度。而并行主要用于提高计算性能,特别是对于那些可以分解成多个独立子任务的计算任务,如大规模数据处理、图形渲染等。
从实现难度来说,并发编程相对复杂。因为在并发环境下,需要考虑资源共享带来的同步问题,如线程安全、死锁等。而并行编程在硬件支持足够的情况下,只要将任务合理地分配到不同的处理器核心上就可以实现,但在实际应用中,也需要考虑任务之间的协调和通信等问题。
请解释线程池的状态以及如何创建线程池。
线程池主要有五种状态:
运行(RUNNING):这是线程池的初始状态和正常工作状态。在这个状态下,可以接受新任务,并且可以处理队列中的任务。例如,在一个 Web 服务器中,线程池处于运行状态时,可以接收并处理来自客户端的 HTTP 请求。
关闭(SHUTDOWN):在这个状态下,线程池不再接受新任务,但会继续处理已经在队列中的任务。就好像一个工厂的生产线不再接收新的订单,但会继续完成已经接收到的订单。
停止(STOP):线程池不仅不再接受新任务,而且会中断正在执行的任务,并且清空任务队列。这是一种比较强硬的停止方式,通常用于紧急情况或者需要快速关闭线程池的场景。
整理(TIDYING):当线程池中的任务全部执行完毕,线程池就会进入整理状态。在这个状态下,会执行一些资源清理和状态转换相关的操作。
终止(TERMINATED):这是线程池的最终状态,表示线程池已经完全关闭,所有资源都已经清理完毕。
在 Java 中,可以使用 Executor 框架来创建线程池。其中,最常用的是通过 ThreadPoolExecutor 类来创建。例如:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
// 创建一个固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
// 将任务提交到线程池
executor.submit(() -> {
// 任务内容
System.out.println("执行任务");
});
// 关闭线程池
executor.shutdown();
在这个例子中,通过 Executors.newFixedThreadPool (5) 创建了一个固定大小为 5 的线程池。然后使用 submit 方法提交任务到线程池。当不再需要线程池时,调用 shutdown 方法来关闭线程池。
还可以使用 ThreadPoolExecutor 的构造函数来更灵活地创建线程池,例如:
int corePoolSize = 3;
int maximumPoolSize = 5;
long keepAliveTime = 10;
TimeUnit unit = TimeUnit.SECONDS;
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(10);
ThreadFactory threadFactory = Executors.defaultThreadFactory();
RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();
ExecutorService executor2 = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
在这里,corePoolSize 表示线程池的核心线程数,maximumPoolSize 是线程池允许的最大线程数,keepAliveTime 和 unit 用于设置线程空闲时间和时间单位,workQueue 是任务队列,用于存储等待执行的任务,threadFactory 用于创建线程,handler 是拒绝策略,用于处理当线程池无法接受新任务时的情况。
请解释 HashMap 的原理。
HashMap 是 Java 中常用的一种数据结构,用于存储键值对。
它的内部实现主要基于数组和链表(在 Java 8 以后,当链表长度达到一定阈值时会转换为红黑树,这里先主要讲链表情况)。
从存储结构看,HashMap 有一个内部数组,称为桶(bucket)。当我们插入一个键值对时,首先会通过一个哈希函数计算键的哈希值。这个哈希函数的目的是将键均匀地分布到不同的桶中。例如,计算键的哈希值后,通过对数组长度取模运算,得到这个键值对应在数组中的索引位置。
假设我们有一个简单的键值对存储场景,键是字符串,值是整数。当插入一个键为 “key1”,值为 1 的键值对时,先计算 “key1” 的哈希值,假设经过哈希函数和取模运算后,得到索引位置为 3,那么这个键值对就会被存储在数组索引为 3 的桶中。
如果在这个桶中已经存在其他键值对(这种情况称为哈希冲突),那么新插入的键值对会以链表的形式挂载到这个桶上。例如,又插入一个键为 “key2”,值为 2 的键值对,经过哈希计算后也得到索引为 3,那么 “key2” - 2 这个键值对就会被添加到索引为 3 的桶对应的链表中。
在查找元素时,也是先计算键的哈希值,找到对应的桶,然后在桶对应的链表中逐个比较键是否相等,直到找到匹配的键或者遍历完链表。
从性能角度看,理想情况下,哈希函数能够将键均匀地分布到各个桶中,这样查找、插入和删除操作的时间复杂度接近常数时间 O (1)。但如果哈希冲突严重,链表会很长,那么查找操作的时间复杂度可能会退化为 O (n),其中 n 是链表的长度。这也是为什么在 Java 8 中引入了红黑树来优化哈希冲突严重的情况,当链表长度大于等于 8 时,链表会转换为红黑树,红黑树的查找时间复杂度为 O (log n),大大提高了性能。
在内存方面,HashMap 需要占用一定的内存来存储数组和链表(或红黑树)。数组的大小会根据存储的键值对数量动态调整,这个调整过程称为扩容。当存储的键值对数量达到一定阈值(加载因子乘以数组长度)时,就会进行扩容操作,扩容后数组大小通常是原来的两倍,同时需要重新计算每个键值对在新数组中的位置,这个过程比较消耗资源。
请简要介绍红黑树。
红黑树是一种自平衡二叉查找树,它在插入和删除操作后能够自动调整保持平衡,从而保证了操作的高效性。
红黑树的节点有颜色属性,节点颜色要么是红色,要么是黑色。它有以下五条性质:
性质 1:每个节点要么是红色,要么是黑色。
性质 2:根节点是黑色。
性质 3:每个叶子节点(NIL 节点,空节点)是黑色。
性质 4:如果一个节点是红色的,则它的子节点必须是黑色。
性质 5:从一个节点到该节点的子孙叶子节点的所有路径上包含相同数目的黑色节点。
这些性质保证了红黑树的平衡性。例如,在插入一个新节点时,可能会破坏红黑树的平衡。假设插入一个红色节点,可能会导致出现连续两个红色节点的情况,违反了性质 4。此时,红黑树会通过一系列的旋转和颜色变换操作来恢复平衡。
从操作时间复杂度来看,红黑树的查找、插入和删除操作的时间复杂度都是 O (log n),其中 n 是树中节点的数量。这是因为它的平衡性保证了树的高度始终保持在对数级别。
在实际应用中,红黑树被广泛用于需要高效查找、插入和删除操作的数据结构中。比如在 Java 中的 TreeMap 和 TreeSet 底层就是红黑树。在 Linux 内核的进程调度中,也使用红黑树来管理进程的优先级队列。当需要频繁地对一组有序数据进行操作时,红黑树能够提供比普通二叉树更好的性能,因为普通二叉树可能会因为插入顺序等因素导致树的高度过高,从而使操作时间复杂度退化为 O (n)。而且红黑树的调整操作相对比较高效,通过少量的旋转和颜色变化就可以恢复平衡,不会导致大量的节点移动等复杂操作。
请简要介绍二叉搜索树及其使用场景(例如查询方便)。
二叉搜索树(BST)是一种二叉树结构,它具有以下特点:对于树中的任意一个节点,其左子树中的所有节点的值都小于该节点的值,而右子树中的所有节点的值都大于该节点的值。
从结构上看,二叉搜索树的节点包含一个数据元素、一个左子节点指针和一个右子节点指针。当插入一个新元素时,会从根节点开始比较。如果新元素小于当前节点的值,就向左子树方向寻找插入位置;如果大于当前节点的值,就向右子树方向寻找。例如,插入元素序列为 5、3、7、2、4、6、8,先插入 5 作为根节点,插入 3 时因为 3 小于 5,所以 3 成为 5 的左子节点,插入 7 时因为 7 大于 5,7 成为 5 的右子节点,以此类推构建二叉搜索树。
查询操作在二叉搜索树中非常高效。当查找一个元素时,从根节点开始,比较要查找元素与当前节点的值。如果相等,就找到了;如果小于当前节点值,就在左子树中继续查找;如果大于,就在右子树中查找。平均情况下,查找、插入和删除操作的时间复杂度为 O (log n),这里的 n 是树中的节点数。因为每次比较都能排除掉树的一半左右。
二叉搜索树的使用场景很广泛。在数据库索引系统中,它可以用于快速查找记录。例如,在一个存储用户信息的数据库中,以用户 ID 作为二叉搜索树的键,可以快速地查找、插入和删除用户记录。在编译器的符号表管理中,也可以用二叉搜索树来存储变量名和对应的属性,方便快速查找变量相关信息。不过,二叉搜索树在最坏情况下,例如插入顺序是有序的,会退化成一条链表,此时操作的时间复杂度会退化为 O (n),为了避免这种情况,可以使用平衡二叉搜索树,如红黑树等。
在遍历方面,二叉搜索树有多种遍历方式。中序遍历可以得到一个升序的节点值序列。先访问左子树,然后访问根节点,最后访问右子树。这对于需要按照一定顺序输出数据的场景很有用,比如输出一个按照字母顺序或者数字大小顺序排列的列表。
请简述快速排序算法,它有哪些缺点?如何改进?
快速排序是一种高效的排序算法。它的基本思想是通过选择一个基准元素,将数组分为两部分,左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素。
具体操作过程是,首先选择一个基准元素,通常是数组的第一个元素或者最后一个元素等。然后设置两个指针,一个从数组的左边开始(left 指针),一个从数组的右边开始(right 指针)。从 right 指针开始,向左移动,找到第一个小于基准元素的元素;然后从 left 指针开始,向右移动,找到第一个大于基准元素的元素。交换这两个元素。重复这个过程,直到 left 指针和 right 指针相遇。最后,将基准元素和相遇位置的元素交换,这样就完成了一次划分。然后对划分后的左右两部分分别进行同样的操作,直到整个数组有序。
快速排序的优点是平均时间复杂度为 O (n log n),这使得它在处理大量数据时效率很高。例如,在对一个包含大量随机数字的数组进行排序时,快速排序能够快速地将数组排序。
然而,快速排序也有缺点。其最坏情况的时间复杂度为 O (n²),这种情况发生在数组已经有序或者逆序的时候。例如,对一个升序排列的数组进行快速排序,如果每次都选择第一个元素作为基准元素,那么每次划分后,其中一部分的元素个数为 0,另一部分为 n - 1,这样就会导致大量的比较和交换操作。
为了改进,可以采用随机选择基准元素的方法。这样可以降低出现最坏情况的概率。另外,当数组规模较小时,可以采用插入排序等简单的排序算法来代替快速排序。因为在小规模数据下,插入排序等简单算法的效率可能更高,而且可以减少递归调用的开销。还可以使用三数取中(选择数组的第一个、中间一个和最后一个元素,取中间值作为基准元素)等策略来优化基准元素的选择,使得划分更加均匀,提高排序效率。
请简述工厂模式和单例模式。
工厂模式是一种创建对象的设计模式。它的主要目的是将对象的创建和使用分离。
简单工厂模式是工厂模式的基础形式。它有一个工厂类,这个工厂类包含一个创建对象的方法。当需要创建对象时,不是在客户端代码中直接使用 new 关键字来创建,而是通过调用工厂类的创建方法来获取对象。例如,假设有一个图形接口(Shape),有圆形(Circle)和矩形(Rectangle)等实现类。可以创建一个简单工厂类(ShapeFactory),它有一个创建图形的方法(createShape),根据传入的参数(如 “circle” 或者 “rectangle”)来决定创建哪种图形对象。
工厂方法模式是对简单工厂模式的进一步扩展。在这个模式中,工厂类是一个抽象类,它有一个抽象的创建方法。具体的创建工作由各个具体的工厂子类来完成。这样做的好处是更加符合开闭原则,当需要增加新的产品(如新增一个三角形图形)时,只需要增加一个新的工厂子类和相应的产品类,而不需要修改原有的工厂类代码。
单例模式是一种确保一个类只有一个实例,并提供一个全局访问点的设计模式。它主要用于一些资源共享或者全局配置等场景。
懒汉式单例是在第一次调用获取实例的方法时才创建实例。例如,一个数据库连接池单例,在第一次需要连接数据库时才创建连接池实例,之后每次获取都是这个实例。为了保证线程安全,可以使用同步方法或者双重检验锁等方式。
饿汉式单例是在类加载时就创建实例。这种方式简单直接,例如,一个系统配置类单例,在系统启动加载类时就创建配置对象,之后可以随时获取这个配置对象来读取系统配置信息。单例模式可以有效地避免资源的浪费和多个实例可能导致的不一致性问题。
请简述快排算法。
快速排序是一种分治算法,用于对数组进行排序。
首先,它会选择一个元素作为基准元素。这个基准元素的选择方式有多种,常见的是选择数组的第一个元素或者最后一个元素。比如有一个数组 [5, 3, 7, 2, 4],假设选择第一个元素 5 作为基准元素。
接着,设置两个指针,一个从数组的最左边开始(称为左指针),一个从数组的最右边开始(称为右指针)。右指针先向左移动,它的任务是找到一个小于基准元素的元素。在这个例子中,右指针从 4 开始向左移动,当移动到 2 时,2 小于 5,找到符合条件的元素。
然后,左指针开始向右移动,它的任务是找到一个大于基准元素的元素。左指针从 3 开始向右移动,当移动到 7 时,7 大于 5,找到符合条件的元素。
找到这两个元素后,交换它们的位置。这样不断地重复这个过程,即右指针继续向左找小于基准元素的元素,左指针继续向右找大于基准元素的元素,然后交换,直到左指针和右指针相遇。
当左指针和右指针相遇后,将基准元素和相遇位置的元素交换。经过这一步,数组就被分成了两部分,左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素。
然后,对划分后的左右两部分分别进行同样的快速排序操作。也就是递归地调用快速排序函数,直到整个数组的元素都有序。
这种分治的思想使得快速排序在平均情况下具有很高的效率,时间复杂度为 O (n log n)。但是在最坏的情况下,比如数组已经是有序的或者逆序的,时间复杂度会退化为 O (n²)。
请解释 run () 和 start () 方法的区别。
在 Java 的线程中,run () 和 start () 方法有着本质的区别。
run () 方法是一个普通的方法,当直接调用线程对象的 run () 方法时,它就像调用普通类的方法一样,会在当前线程中执行 run () 方法里的代码。例如,假设有一个线程类:
class MyThread extends Thread {
public void run() {
System.out.println("线程执行的代码");
}
}
如果这样调用:
MyThread thread = new MyThread();
thread.run();
此时,“线程执行的代码” 会在当前主线程中执行,并没有真正开启一个新的线程来执行。
start () 方法则是用于启动一个新的线程。当调用线程对象的 start () 方法时,JVM 会创建一个新的线程,并调用这个线程对象的 run () 方法来执行具体的任务。还是以刚才的 MyThread 为例,如果这样调用:
MyThread thread = new MyThread();
thread.start();
这时,JVM 会开辟一个新的线程,在这个新线程中执行 run () 方法里的代码。新线程会和原来的主线程并发执行,这就实现了多线程的功能。
start () 方法实际上是通过本地方法(由操作系统提供支持)来创建和启动新线程的。它会执行一系列复杂的操作,包括为新线程分配系统资源、初始化线程的执行环境等。而 run () 方法只是定义了线程需要执行的任务内容,真正让任务在新线程中执行的是 start () 方法。
请解释类和抽象类的区别。
类是面向对象编程中的基本构建块,它用于定义对象的属性和行为。可以通过类创建多个具有相同属性和行为的对象。例如,定义一个 “汽车” 类,它可以有属性如颜色、品牌、速度等,还有行为如启动、加速、刹车等。当创建一个 “汽车” 类的对象时,这个对象就拥有了类中定义的所有属性和方法。
抽象类是一种特殊的类。它不能被直接实例化,其主要目的是为其他类提供一个模板或者说是一个抽象的定义。抽象类中可以包含抽象方法和非抽象方法。抽象方法是没有具体实现的方法,只有方法的声明,它强制子类必须实现这个方法。例如,定义一个 “交通工具” 抽象类,它可能有一个抽象方法 “行驶”,因为不同的交通工具行驶的方式不同,具体的实现留给子类去完成。
从方法实现来看,普通类中的方法都有具体的实现,而抽象类可以有抽象方法,这是抽象类和普通类的一个显著区别。
在继承方面,普通类可以被继承,也可以不被继承,而抽象类主要是用于被继承的。当一个类继承一个抽象类时,必须实现抽象类中的所有抽象方法,除非这个子类也是抽象类。
从设计目的上,类侧重于对具体事物的封装和实现,而抽象类更侧重于提取共性,定义一个抽象的概念或者行为规范。例如,在一个图形绘制系统中,有一个 “图形” 抽象类,它定义了所有图形都有的一些基本属性,如位置、颜色等,还定义了一个抽象的 “绘制” 方法。然后具体的图形类,如 “圆形”“矩形” 等继承这个抽象类,分别实现自己的 “绘制” 方法,这样就可以通过抽象类来规范子类的行为,同时又利用了继承来实现代码的复用。
请解释 8 种基本数据类型。
在 Java 中有 8 种基本数据类型,分别是 4 种整数类型、2 种浮点数类型、1 种字符类型和 1 种布尔类型。
首先是整数类型。byte 类型是字节型,它占用 1 个字节(8 位),取值范围是 - 128 到 127。它通常用于存储一些小范围的整数,比如文件的字节数据等。例如,在读取一个二进制文件时,字节数据可以用 byte 类型来存储。
short 类型是短整型,占用 2 个字节(16 位),取值范围是 - 32768 到 32767。在某些对内存要求比较高,且数值范围不大的情况下可以使用。
int 类型是整型,占用 4 个字节(32 位),取值范围是 - 2147483648 到 2147483647。这是最常用的整数类型,用于存储一般的整数,如计数、索引等。例如,在一个数组中,数组的索引通常就是用 int 类型来表示。
long 类型是长整型,占用 8 个字节(64 位),取值范围是 - 9223372036854775808 到 9223372036854775807。当需要存储比较大的整数,如时间戳等,就可以使用 long 类型。
浮点数类型包括 float 和 double。float 类型是单精度浮点数,占用 4 个字节,它可以表示的数值范围比较大,但精度相对较低。在对精度要求不高,并且需要节省内存的情况下可以使用。例如,一些简单的科学计算模型中,如果对数值精度要求不高,float 就可以满足。
double 类型是双精度浮点数,占用 8 个字节,它的精度比 float 高,能够更准确地表示小数。在大多数数学计算、金融计算等场景下,double 是更合适的选择。
字符类型 char 占用 2 个字节,用于存储单个字符,如字母、数字、符号等。它的取值范围是 0 到 65535,实际上是存储字符的 Unicode 编码。例如,存储一个字母 'A',就可以用 char 类型。
布尔类型 boolean 只有两个值,true 和 false,占用 1 位(实际在内存中一般占用 1 个字节)。它主要用于条件判断,如在控制流程语句中,判断某个条件是否成立。
请解释 B - 树和 B + 树。
B - 树是一种平衡的多路查找树。它的特点是每个节点可以有多个子节点(多路),并且所有叶子节点的深度相同(平衡)。
在结构上,B - 树的节点包含多个关键字和多个子节点指针。例如,一个节点可以存储多个数据元素(关键字),并且有相应的指针指向子节点。假设一个节点可以存储 3 个关键字,那么就会有 4 个子节点指针。当进行查找操作时,从根节点开始,比较要查找的关键字与节点中的关键字。如果找到相等的关键字,就找到了目标;如果要查找的关键字小于节点中的某个关键字,就沿着对应的左子节点继续查找;如果大于,就沿着对应的右子节点继续查找。
B - 树的优势在于减少磁盘 I/O 操作。因为它可以在一个节点中存储多个关键字,使得树的高度相对较低。在磁盘存储中,每次读取一个节点相当于一次磁盘 I/O,较低的树高意味着读取磁盘的次数减少,从而提高了查询效率。
B + 树是 B - 树的一种变体。B + 树的主要特点是所有的数据都存储在叶子节点上,非叶子节点只用于索引。叶子节点之间通过指针连接成一个有序链表。
在查询操作上,B + 树同样从根节点开始,通过索引节点找到对应的叶子节点范围,然后在叶子节点中查找具体的数据。因为叶子节点是一个有序链表,所以范围查询(如查询某个区间内的所有数据)在 B + 树中非常方便。例如,在数据库索引中,当查询一个范围内的记录时,B + 树可以快速地定位到起始叶子节点,然后通过链表顺序遍历叶子节点,获取范围内的所有数据。
B + 树在数据库索引等应用场景中应用广泛。以 MySQL 的 InnoDB 存储引擎为例,它使用 B + 树来构建索引。因为 B + 树的结构特点使得它在存储大量数据并且进行频繁的查询和范围查询时能够提供高效的性能。相比 B - 树,B + 树更适合用于数据库系统这种需要大量数据存储和高效查询的环境。
请介绍一下 JVM 内存划分以及 GC(垃圾回收)算法。
JVM(Java 虚拟机)内存主要划分为以下几个区域。
首先是程序计数器(Program Counter Register),它是一块较小的内存空间。它可以看作是当前线程所执行的字节码的行号指示器。在多线程环境下,它用于记录每个线程正在执行的字节码指令地址。因为 Java 是多线程语言,每个线程都有自己独立的程序计数器,并且这个区域是线程私有的。
其次是 Java 虚拟机栈(Java Virtual Machine Stack),也是线程私有的。它主要用于存储局部变量表、操作数栈、动态链接、方法出口等信息。当一个方法被调用时,一个栈帧就会被压入虚拟机栈,当方法执行完毕后,栈帧弹出。例如,在一个方法中定义的局部变量就存储在局部变量表中,方法中的运算操作会使用操作数栈。
本地方法栈(Native Method Stack)与 Java 虚拟机栈类似,不过它是用于支持本地方法(Native Method)的执行。本地方法是由非 Java 语言(如 C 或 C++)编写的方法,这些方法通过 JNI(Java Native Interface)与 Java 代码进行交互。
堆(Heap)是 JVM 内存中最大的一块区域,并且是所有线程共享的。它主要用于存储对象实例。例如,当使用 “new” 关键字创建一个对象时,这个对象就会在堆中分配内存。堆是垃圾回收的主要区域,因为对象在堆中动态地创建和销毁。
方法区(Method Area)也是所有线程共享的。它用于存储已被虚拟机加载的类信息、常量、静态变量等。例如,类的字节码、常量池中的常量以及类的静态变量都存储在方法区。
在垃圾回收方面,有多种算法。标记 - 清除算法是最基础的一种。它分为两个阶段,首先标记出所有需要回收的对象,然后统一清除这些被标记的对象。不过这种算法会产生内存碎片。
复制算法是将内存分为大小相等的两块,每次只使用其中一块。当需要进行垃圾回收时,将存活的对象复制到另一块内存中,然后把原来使用的那块内存全部清除。这种算法不会产生内存碎片,但内存利用率较低。
标记 - 整理算法结合了标记 - 清除和复制算法的优点。它首先标记出所有存活的对象,然后将这些存活的对象向一端移动,最后清理掉边界以外的内存。这种算法既解决了内存碎片的问题,又提高了内存利用率。
将对象引用设置成 null,虚拟机会立马回收吗?
当把对象引用设置为 null 时,虚拟机不会立刻回收该对象占用的内存。在 Java 中,对象的回收是由垃圾回收器(GC)来管理的。垃圾回收器有自己的一套机制来判断对象是否可以被回收。
首先,垃圾回收器会通过可达性分析算法来确定对象是否可达。当一个对象没有任何有效的引用链能够访问到它时,这个对象才被认为是可回收的。将对象引用设置为 null 只是断开了原来的引用关系,但如果还有其他地方在引用这个对象,它仍然是可达的,不会被回收。
例如,假设有一个类,它有一个实例被多个方法或者其他对象引用。即使在某个局部方法中把这个对象引用设置为 null,只要在其他地方还有引用,这个对象就不会被回收。而且,垃圾回收器会根据自身的调度策略和内存使用情况来决定何时进行回收。它可能会等到内存不足或者达到一定的回收阈值时才会启动回收过程。
另外,即使对象不可达,垃圾回收器也不是简单地立即回收。它可能会采用标记 - 清除、复制或者标记 - 整理等算法来回收内存。这些算法都有自己的步骤和时间开销。比如标记 - 清除算法,需要先标记出所有可回收的对象,然后再进行清除,这中间会涉及到遍历对象图等操作,是需要时间来完成的。所以,把对象引用设置为 null 只是给垃圾回收器一个信号,表示这个引用不再需要,但具体的回收时间是由垃圾回收器决定的。
手动释放对象占用空间有哪些方法?
在一些编程语言中可以手动释放对象占用的空间,但在 Java 等高级语言中,一般不建议手动释放,因为有自动的垃圾回收机制。不过在像 C++ 这样的语言中,手动释放对象空间是比较常见的操作。
在 C++ 中,对于通过 new 操作符分配的内存空间,需要使用 delete 操作符来手动释放。例如,有一个动态分配的整数数组:
int *p = new int[10];
// 使用数组p
delete[] p;
如果是单个对象,使用 delete 而不是 delete []。例如:
class MyClass {
// 类的定义
};
MyClass *obj = new MyClass();
// 使用obj
delete obj;
在 C 语言中,对于使用 malloc 函数分配的内存空间,需要使用 free 函数来释放。例如:
int *p = (int *)malloc(sizeof(int));
// 使用p
free(p);
当涉及到复杂的数据结构,如链表、树等,手动释放空间就需要更加小心。以链表为例,不仅要释放每个节点的数据部分占用的空间,还要释放节点本身的空间。假设我们有一个简单的链表节点结构:
struct ListNode {
int val;
struct ListNode *next;
};
释放链表空间的函数可以这样写:
void freeList(struct ListNode *head) {
struct ListNode *current = head;
struct ListNode *next;
while (current!= NULL) {
next = current->next;
delete current;
current = next;
}
}
在手动释放空间时,一定要确保每个分配的空间都被正确释放,并且避免多次释放同一块空间,否则可能会导致程序崩溃或者出现内存错误。
线程创建方式有哪些?
在 Java 中有多种线程创建方式。
一种是通过继承 Thread 类来创建线程。首先需要定义一个类继承自 Thread 类,然后重写 run 方法。例如:
class MyThread extends Thread {
public void run() {
System.out.println("线程执行的内容");
}
}
// 创建并启动线程
MyThread thread = new MyThread();
thread.start();
在这个例子中,MyThread 类继承了 Thread 类,run 方法中定义了线程要执行的具体内容。通过调用 start 方法来启动线程,start 方法会自动调用 run 方法,并且会开启一个新的线程来执行 run 方法中的代码。
另一种方式是通过实现 Runnable 接口来创建线程。定义一个类实现 Runnable 接口,然后实现 run 方法。例如:
class MyRunnable implements Runnable {
public void run() {
System.out.println("另一种线程执行的内容");
}
}
// 创建并启动线程
Thread thread2 = new Thread(new MyRunnable());
thread2.start();
这种方式将线程要执行的任务(即 run 方法中的内容)和线程对象本身分离。这样做的好处是可以通过一个 Runnable 对象来创建多个线程,更符合面向对象的设计理念,因为接口可以被多个类实现,实现了接口的类可以被更灵活地组合和复用。
在 Android 开发中,还可以使用 AsyncTask 来创建和管理线程。AsyncTask 是一个抽象类,它提供了一种简单的方式来在后台执行任务,并在任务完成后更新 UI。它有几个重要的方法,如 doInBackground 用于在后台执行任务,onPostExecute 用于在任务完成后更新 UI 等。不过,AsyncTask 在使用上有一些限制,比如它的默认线程池大小是有限的,并且它的使用场景主要是一些简单的后台任务与 UI 更新的组合。
如何在子线程创建 handler?主线程呢?looper(消息循环器)怎么创建?在什么时候创建?它和 handler 有什么关系?looper 会死循环吗?如何避免?
在子线程创建 Handler,首先需要为子线程创建一个 Looper。因为 Handler 是依赖于 Looper 来工作的。可以通过以下方式在子线程创建 Looper:
class MyThread extends Thread {
public Handler handler;
@Override
public void run() {
Looper.prepare();
handler = new Handler() {
@Override
public void handleMessage(Message msg) {
// 处理消息的逻辑
}
};
Looper.loop();
}
}
在主线程中,Android 系统已经为我们创建好了 Looper,所以可以直接创建 Handler。例如:
class MainActivity extends AppCompatActivity {
private Handler handler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
handler = new Handler() {
@Override
public void handleMessage(Message msg) {
// 处理消息的逻辑
}
};
}
}
Looper 的创建主要是通过 Looper.prepare () 方法。这个方法会初始化一个 Looper 对象,并且将其与当前线程关联起来。Looper 通常在需要进行消息循环的线程中创建,比如在一个需要接收和处理消息的子线程或者主线程中。
Handler 和 Looper 是紧密相关的。Handler 用于发送和接收消息,而 Looper 则是消息循环的核心。Handler 发送的消息会被添加到 Looper 的消息队列中,Looper 会不断地从消息队列中取出消息,并将消息分发给对应的 Handler 进行处理。
Looper 的 loop () 方法确实是一个死循环。它会一直循环从消息队列中获取消息,直到线程结束或者通过特殊的方式退出。为了避免在某些不需要消息循环的情况下出现死循环,可以在适当的时候调用 Looper.quit () 或者 Looper.quitSafely () 方法来退出 Looper 的循环。例如,当一个子线程的任务完成后,并且不再需要接收和处理消息时,可以调用这些方法来结束 Looper 的循环,从而避免线程一直处于运行状态。
线程间如何实现延时启动?假设有三个线程(A、B、C),C 需要在 A、B 线程结束时才执行,如何设计?
在 Java 中,线程间实现延时启动可以通过多种方式。
一种常见的方式是使用 Thread.sleep () 方法。例如,在一个线程中,如果想要延时一段时间再执行某个任务,可以这样做:
class DelayedThread extends Thread {
@Override
public void run() {
try {
Thread.sleep(1000); // 延时1秒
// 执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
对于三个线程 A、B、C,C 需要在 A、B 线程结束时才执行的情况,可以使用线程的 join () 方法来设计。join () 方法可以让一个线程等待另一个线程执行完毕。
假设已经有三个线程类定义如下:
class ThreadA extends Thread {
@Override
public void run() {
// 线程A的任务
}
}
class ThreadB extends Thread {
@Override
public void run() {
// 线程B的任务
}
}
class ThreadC extends Thread {
@Override
public void run() {
// 线程C的任务
}
}
可以通过以下方式来实现:
public class Main {
public static void main(String[] args) {
ThreadA a = new ThreadA();
ThreadB b = new ThreadB();
a.start();
b.start();
try {
a.join();
b.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
ThreadC c = new ThreadC();
c.start();
}
}
在这个例子中,首先启动线程 A 和线程 B,然后通过 join () 方法让主线程等待线程 A 和线程 B 执行完毕。当线程 A 和线程 B 都结束后,再启动线程 C,这样就实现了线程 C 在 A、B 线程结束后才执行的要求。另外,也可以使用 CountDownLatch 来实现类似的功能。CountDownLatch 是一个同步辅助类,它可以通过计数的方式来控制线程的执行顺序。例如,将计数器初始化为 2(对应线程 A 和 B),当线程 A 和 B 执行完毕时,计数器减为 0,此时等待在 CountDownLatch 上的线程 C 就可以开始执行了。
请解释线程安全的概念,以及如何保证线程安全?
线程安全指的是在多线程环境下,某个代码片段或者数据结构在被多个线程同时访问时,能够始终保持正确的行为和状态,不会出现数据不一致、逻辑错误等问题。
例如,多个线程同时对一个共享的计数器变量进行自增操作,如果没有采取线程安全的措施,可能会出现一个线程读取到旧值、然后多个线程重复对旧值进行自增,导致最终结果不符合预期,这就是线程不安全的情况。
要保证线程安全,有多种方式。
一是使用互斥锁,像 Java 中的 synchronized 关键字。例如,有一个共享的资源类:
class SharedResource {
private int data;
public synchronized void increment() {
data++;
}
public synchronized int getData() {
return data;
}
}
在 increment 方法上添加 synchronized 关键字后,当一个线程进入该方法时,会获取到对象锁,其他线程想要访问这个方法就得等待锁被释放,这样就能保证同一时刻只有一个线程对 data 变量进行操作,从而保证了数据的一致性,实现线程安全。
二是使用原子类,比如 Java.util.concurrent.atomic 包下的 AtomicInteger 等。对于简单的数值操作,使用原子类更加高效。例如:
import java.util.concurrent.atomic.AtomicInteger;
class AnotherSharedResource {
private AtomicInteger atomicData = new AtomicInteger(0);
public void incrementAtomic() {
atomicData.incrementAndGet();
}
public int getAtomicData() {
return atomicData.get();
}
}
原子类内部通过一些底层的原子操作机制(如 CAS,比较并交换)来保证操作的原子性,避免了多线程并发访问的问题。
还可以通过线程安全的设计模式来保证,像线程安全的单例模式。例如使用双重检验锁的单例实现:
class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这里通过合理使用 volatile 关键字和双重 null 检查以及同步块,保证在多线程环境下只有一个实例被创建且能安全地被访问,实现了线程安全。
另外,不可变对象从设计上也是线程安全的。如果一个对象创建后其状态就不能被改变,那么多个线程同时访问它也不会出现数据不一致等问题,像 Java 中的 String 类,它是不可变的,多个线程使用同一个 String 对象不会有线程安全方面的隐患。
线程有哪些状态?
线程在其生命周期中会经历不同的状态,常见的状态有以下几种。
新建(New):当通过创建线程对象(如在 Java 中继承 Thread 类或者实现 Runnable 接口然后创建对应的线程实例)时,线程就处于新建状态。此时线程只是一个对象,还没有开始执行,系统也没有为它分配必要的系统资源,例如 CPU 时间片等。比如创建一个线程类如下:
class MyThread extends Thread {
public void run() {
System.out.println("线程执行的任务");
}
}
MyThread thread = new MyThread();
这个刚创建的 thread 线程对象就处于新建状态。
就绪(Runnable):当调用线程的 start 方法后,线程进入就绪状态。此时线程已经具备了运行的条件,系统已经为它分配了除 CPU 之外的所需资源,等待获取 CPU 时间片来开始执行任务。在多线程环境下,可能有多个线程处于就绪状态,它们会竞争 CPU 资源,谁获取到了 CPU 时间片谁就开始真正执行。例如继续上面的代码,调用 thread.start () 后,thread 线程就变为就绪状态。
运行(Running):线程获取到 CPU 时间片后,开始执行 run 方法中的代码,这时线程处于运行状态。不过,由于 CPU 会在多个就绪线程之间快速切换,所以一个线程的运行状态可能是短暂的,随时可能因为时间片用完等原因又回到就绪状态,等待下一次获取 CPU 时间片继续执行。
阻塞(Blocked):当线程因为某些原因无法继续执行,需要等待某个条件满足时,就会进入阻塞状态。比如线程在等待获取一个被其他线程占用的锁,或者进行输入输出操作(I/O 操作往往比较耗时,在等待 I/O 完成期间线程阻塞)时,都会处于阻塞状态。处于阻塞状态的线程暂时不会参与 CPU 时间片的竞争,直到阻塞的原因解除,比如获取到了等待的锁或者 I/O 操作完成,它才会重新回到就绪状态,等待获取 CPU 时间片继续执行。
死亡(Terminated):线程执行完了 run 方法中的所有任务,或者因为出现异常等原因导致线程提前结束,这时线程就处于死亡状态。处于死亡状态的线程不能再重新启动,它所占用的资源也会被系统回收。例如线程的 run 方法执行完毕正常退出,或者在执行过程中抛出了未捕获的异常导致线程终止,都意味着线程进入了死亡状态。
请简述 Android 生命周期。
在 Android 中,Activity(活动)、Fragment(碎片)以及 Service(服务)等组件都有各自的生命周期。以 Activity 为例来详细说明其生命周期。
启动(onCreate):当 Activity 首次被创建时,会调用 onCreate 方法。这个阶段主要进行一些初始化的操作,比如设置布局(通过调用 setContentView 方法来加载对应的布局文件)、初始化成员变量、绑定数据等。例如:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 可以在这里进行一些初始化操作,如初始化按钮的点击事件等
}
}
启动后(onStart):在 onCreate 执行完后,接着会调用 onStart 方法。此时 Activity 已经可见了,但还不能与用户进行交互,例如可能有个透明的对话框或者导航栏覆盖在它上面,它还处于准备展示给用户的状态,不过已经进入到前台的显示流程中了。
恢复(onResume):当 Activity 完全可以和用户交互,处于前台且获取到了焦点时,会调用 onResume 方法。此时用户可以对 Activity 进行各种操作,比如点击按钮、输入文本等,是 Activity 处于最活跃、最能被用户操作的状态。
暂停(onPause):当有其他 Activity 部分覆盖当前 Activity,或者有一个透明的 Activity 出现在当前 Activity 之上时,当前 Activity 会进入暂停状态,调用 onPause 方法。在这个阶段,Activity 仍然可见,但部分失去了交互能力,不过它需要快速保存一些关键数据,因为随时可能被完全覆盖或者系统回收内存等情况发生,比如暂停时可以暂停一些动画、保存用户输入的部分未提交的数据等。
停止(onStop):如果当前 Activity 被完全覆盖,不再对用户可见,就会调用 onStop 方法进入停止状态。此时 Activity 不再显示在屏幕上,它所占用的系统资源可能会被回收一部分,比如释放一些图片资源等,但对象实例依然存在,并且可以通过一些机制(比如重新回到前台时)恢复之前的状态。
销毁(onDestroy):当 Activity 被系统销毁时,会调用 onDestroy 方法。这可能是因为用户按下了返回键,或者系统内存不足等原因需要回收该 Activity 所占用的资源。在这个阶段,可以进行一些资源释放的最后操作,比如关闭数据库连接、取消网络请求等。
另外,还有 onRestart 方法,当已经停止的 Activity 再次启动时,会先调用 onRestart 方法,然后再按照 onStart、onResume 的顺序执行,完成重新回到前台与用户交互的过程。
Fragment 的生命周期和 Activity 有相似之处,也有其独特的一些阶段,用于更好地配合 Activity 以及实现自身的功能展现和管理。Service 的生命周期则更侧重于后台任务的启动、运行以及停止等阶段,比如有 onCreate 用于初始化,onStartCommand 用于接收启动指令开始执行任务,onDestroy 用于结束服务释放资源等。
当前活跃页面被覆盖时,生命周期如何变化?
当当前活跃页面(以 Activity 为例)被部分覆盖时,比如有一个透明的 Activity 或者对话框出现在它上面,此时当前活跃的 Activity 会进入到暂停(onPause)状态。
在 onPause 阶段,Activity 需要快速执行一些操作来保存当前的关键状态,因为它可能随时会被进一步覆盖或者由于系统内存紧张等原因被回收。例如,正在播放的动画需要暂停,避免消耗过多资源,同时如果用户在页面上有输入但还未提交的数据,要及时保存到合适的地方(比如临时存储到内存变量或者写入本地文件等),防止数据丢失。
如果这个覆盖的元素一直存在,Activity 就会维持在 onPause 状态,它依然可见但失去了部分交互能力,像按钮点击等操作可能无法响应了。
而当这个覆盖的元素完全覆盖了当前活跃的 Activity,使其不再对用户可见时,Activity 会从 onPause 状态进入到停止(onStop)状态。进入 onStop 状态后,系统会考虑回收一部分该 Activity 占用的资源,比如一些不再显示的图片资源可以从内存中释放掉,以节省内存空间供其他组件使用。不过,Activity 的对象实例依然是存在的,相关的成员变量等信息也还保留着,只要后续有机会重新回到前台(比如覆盖的 Activity 被关闭等情况),可以通过之前保存的数据等恢复到原来的状态,重新经历 onRestart、onStart、onResume 等阶段来再次与用户交互。
例如,在一个应用中,当前正在浏览的商品详情页面(Activity)被一个弹出的登录对话框(透明或者半透明)部分覆盖,商品详情页面就进入 onPause 状态,暂停相关动画播放等操作。当登录对话框完全展开,把商品详情页面完全挡住,商品详情页面就进入 onStop 状态,系统可能释放其部分图片资源,等登录完成对话框关闭,商品详情页面又会从 onStop 状态先进入 onRestart 状态,接着 onStart、onResume,恢复到可以和用户正常交互、完全展示的状态。
另外,如果系统内存不足等特殊情况发生,即使 Activity 只是处于 onStop 状态,也有可能被直接销毁(调用 onDestroy 方法),这时候就需要做好更全面的资源释放和数据持久化保存等工作,确保下次重新进入相关页面时能正常恢复功能和展示内容。
请简述自定义 View 的设计过程。
自定义 View 的设计一般有以下几个主要步骤。
确定需求和功能:首先要明确自定义 View 要实现什么样的功能和展示效果。比如是要设计一个可以展示动态图表的 View,还是一个带有特殊样式的按钮等。例如,打算设计一个圆形进度条 View,能够动态显示任务的完成进度,这就是明确的功能需求。
继承合适的基类:根据需求来选择继承的基类。如果是简单的可视化组件,通常可以继承 View 类;如果是有交互功能且基于已有组件拓展的,可能继承自 Button、TextView 等具体的现有 View 类。对于要设计的圆形进度条 View,可以继承 View 类,因为它是一个自定义的可视化展示组件。继承后就可以重写 View 类中的一些方法来实现自己的功能,像重写 onDraw 方法用于绘制图形等。
测量尺寸(onMeasure 方法):在自定义 View 中,需要准确地确定自身的尺寸大小。onMeasure 方法就是用于这个目的,它会根据父容器传递过来的测量规格(MeasureSpec)以及自身的需求来计算出合适的宽高。比如对于圆形进度条 View,可能希望它在布局中是一个固定大小或者根据父容器按比例缩放的大小,那就需要在 onMeasure 方法中进行相应的计算,考虑父容器的限制以及自身期望的尺寸规则,确定最终的宽和高值,并且通过调用 setMeasuredDimension 方法来设置测量后的尺寸。
绘制内容(onDraw 方法):这是自定义 View 非常关键的一步,在 onDraw 方法中使用 Android 提供的绘图工具(如画笔 Paint、画布 Canvas 等)来绘制出想要展示的内容。对于圆形进度条 View,在 onDraw 中可以先绘制出圆形的轮廓,然后根据当前的进度值,绘制出对应的扇形或者弧线段来表示进度,还可以添加文字说明进度百分比等内容,通过各种绘图操作来将设计的可视化效果呈现出来。
处理交互(如果有需要):如果自定义 View 有交互功能,比如点击、滑动等操作,就需要重写相应的触摸事件处理方法,像 onTouchEvent 方法等。例如,对于圆形进度条 View,可能希望用户点击它可以暂停或者重新开始进度展示,那就需要在 onTouchEvent 方法中判断触摸的动作类型(点击、长按等),然后执行相应的逻辑来实现交互功能。
对外提供属性设置接口(可选):为了让使用这个自定义 View 的地方可以方便地对其进行属性配置,比如设置圆形进度条的颜色、初始进度等,可以通过自定义属性以及相应的 setter 方法等方式来实现。可以在 attrs.xml 文件中定义自定义属性,然后在 View 的构造函数等地方获取并应用这些属性,使得外部能够灵活地定制自定义 View 的外观和行为。
通过以上这些步骤,就可以设计出一个功能完善、能满足特定需求的自定义 View,并且可以在 Android 应用的布局中方便地使用它来实现独特的界面展示和交互效果。
请简述 Handler 消息机制,Handler 的消息执行是在哪个线程上?
Handler 消息机制主要用于在 Android 中实现线程间通信。它包含四个主要元素:Message(消息)、Handler(消息处理器)、MessageQueue(消息队列)和 Looper(消息循环器)。
Message 是在线程间传递的消息载体,它可以携带数据,如整型、字符串等。可以通过 Message.obtain () 方法获取一个消息对象,并且通过 arg1、arg2 等属性或者通过 setData 方法来设置消息的数据内容。
Handler 用于发送和处理消息。它可以将消息发送到消息队列中,发送消息的方式有多种,例如通过 sendMessage 方法发送一个消息,或者通过 post 方法发送一个 Runnable 对象。当 Handler 发送消息时,消息会被添加到与 Handler 关联的 Looper 的 MessageQueue 中。在处理消息方面,需要重写 Handler 的 handleMessage 方法,当消息从消息队列中取出被分发到这个 Handler 时,就会执行 handleMessage 方法中的逻辑来处理消息。
MessageQueue 是一个消息队列,用于存储消息。它按照消息添加的先后顺序来排列消息,等待 Looper 来提取消息进行处理。
Looper 则是一个消息循环器,它会不断地从 MessageQueue 中取出消息,并将消息分发给对应的 Handler 进行处理。一个线程只有一个 Looper,通过 Looper.loop () 方法开启消息循环。
Handler 的消息执行线程取决于它所关联的 Looper 所在的线程。如果 Handler 是在主线程中创建的,那么它关联的是主线程的 Looper,消息的执行就在主线程。这是因为在主线程中,系统已经默认创建了一个 Looper,当通过在主线程创建 Handler 发送消息时,消息会被添加到主线程的 MessageQueue 中,然后由主线程的 Looper 来处理这些消息。而如果是在子线程中创建 Handler,需要先为子线程创建 Looper(通过 Looper.prepare () 和 Looper.loop ()),这样 Handler 发送的消息就会在这个子线程中执行,因为它关联的是子线程的 Looper。这种机制使得可以方便地在不同线程之间传递消息并执行相应的任务,比如在子线程完成耗时任务后通过 Handler 发送消息到主线程来更新 UI。
请简述 ContentProvider。
ContentProvider 是 Android 中的一个组件,主要用于在不同的应用之间或者在同一个应用的不同组件之间共享数据。
从功能角度看,它就像是一个数据桥梁。例如,一个应用可能有自己的联系人数据,通过 ContentProvider 可以将这些联系人数据提供给其他应用访问。它使得数据的共享变得安全和可控,因为它可以对数据的访问进行权限管理。
ContentProvider 通过一个标准的接口来实现数据的访问。它以类似于数据库的方式来组织数据,使用 URI(统一资源标识符)来定位不同的数据集合。例如,对于联系人应用,可能有一个类似 “content://contacts/people” 这样的 URI 来表示联系人列表这个数据集合。
当其他应用或者组件想要访问 ContentProvider 提供的数据时,会使用 ContentResolver 这个类。ContentResolver 提供了一系列方法来查询、插入、更新和删除 ContentProvider 中的数据。比如,要查询联系人列表,可以通过以下方式:
ContentResolver resolver = getContentResolver();
Cursor cursor = resolver.query(ContactsContract.Contacts.CONTENT_URI, null, null, null, null);
if (cursor!= null) {
while (cursor.moveToNext()) {
// 处理联系人数据
}
cursor.close();
}
在这个例子中,首先获取 ContentResolver,然后使用 query 方法来查询联系人数据。ContentProvider 会根据请求的 URI 以及传递的参数(如筛选条件等)来返回相应的数据,这些数据通常以 Cursor(游标)的形式返回,通过遍历游标就可以获取和处理具体的数据。
ContentProvider 还可以实现自定义的数据共享。如果要共享自己应用中的数据,可以通过继承 ContentProvider 类来创建自己的 ContentProvider。在这个过程中,需要实现一些关键的方法,如 query 用于查询数据、insert 用于插入数据、update 用于更新数据和 delete 用于删除数据。这样,其他应用就可以通过 ContentResolver 按照标准的方式来访问自定义的 ContentProvider 提供的数据,实现数据在不同应用之间的共享和交互。
哪些场景应该使用 Service?Service 是运行在什么线程上的?
Service 主要用于在后台执行长时间运行的操作,并且不提供用户界面。
一种典型的场景是音乐播放服务。当用户在使用音乐播放应用时,即使切换到其他应用或者手机屏幕关闭,音乐依然可以继续播放。这是因为音乐播放的任务被放在 Service 中执行,它可以独立于应用的 Activity 运行。例如,当用户打开音乐播放应用,Activity 负责展示播放界面和用户交互(如选择歌曲、调整音量等),而音乐的实际播放逻辑(如读取音频文件、解码音频等)则在 Service 中进行,这样即使 Activity 被销毁(比如用户按下返回键退出播放界面),音乐依然可以通过 Service 在后台持续播放。
另外,文件下载任务也适合使用 Service。如果应用需要从网络上下载一个较大的文件,这个下载过程可能会比较长。将下载任务放在 Service 中执行,用户就可以在下载过程中切换到其他应用进行其他操作,而不用担心下载任务会因为 Activity 的生命周期变化而中断。
Service 本身的运行线程取决于具体的实现。默认情况下,Service 是运行在主线程上的。这意味着如果在 Service 中执行一些耗时操作(如网络请求、大量数据的读写等),会导致主线程被阻塞,从而出现应用无响应(ANR)的情况。为了避免这种情况,可以在 Service 中创建子线程来执行耗时操作。例如,在 Service 的 onStartCommand 方法中开启一个新的线程来执行文件下载任务:
public class DownloadService extends Service {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
new Thread(new Runnable() {
@Override
public void run() {
// 执行文件下载任务
}
}).start();
return super.onStartCommand(intent, flags, startId);
}
// 其他Service相关方法
}
这样,文件下载这个耗时操作就在子线程中进行,不会阻塞主线程,保证了应用的流畅性。
SharedPreference 的 apply 和 commit 方法有什么区别?
SharedPreference 是 Android 中用于存储和读取简单键值对数据的一种机制,它提供了 apply 和 commit 两种方法来保存数据。
从操作结果来看,两者都是用于将数据的修改保存到存储介质(通常是 XML 文件)中。但是它们在保存数据的方式和一些细节上有区别。
commit 方法是一个同步操作。当调用 commit 方法时,它会立即将数据的修改提交并写入存储介质。在写入过程中,它会返回一个布尔值来表示写入是否成功。例如:
SharedPreferences sharedPreferences = getSharedPreferences("my_preferences", MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putString("name", "John");
boolean success = editor.commit();
if (success) {
// 数据成功写入
} else {
// 数据写入失败
}
因为 commit 是同步操作,所以如果存储操作比较复杂或者存储介质繁忙(比如写入速度慢或者有其他写入冲突等情况),调用 commit 方法可能会导致调用线程阻塞。这在主线程中使用时可能会影响应用的响应速度,甚至可能导致应用无响应(ANR)的情况。
apply 方法则是一个异步操作。当调用 apply 方法时,它会将修改的数据先记录在内存中,然后异步地将数据写入存储介质。它不会返回写入是否成功的布尔值。例如:
SharedPreferences sharedPreferences = getSharedPreferences("my_preferences", MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putString("name", "John");
editor.apply();
由于 apply 是异步操作,它不会阻塞调用线程,所以在主线程中使用时比较安全,不会因为存储操作而导致应用响应延迟。不过,因为它是异步的,无法立刻确定数据是否真正成功写入存储介质。通常情况下,数据最终会被成功写入,但如果在写入过程中应用被异常关闭或者出现其他严重问题,可能会导致数据丢失。
总的来说,在不关心写入结果并且是在主线程中进行数据存储操作时,apply 方法是更好的选择,可以避免阻塞主线程;而如果需要确定数据是否真正写入成功,特别是在一些对数据完整性要求较高的场景下,可能需要使用 commit 方法。
请介绍一下 Activity 启动模式,单例模式的适用场景。
Activity 启动模式
Activity 启动模式主要用于控制 Activity 的创建和使用方式,有四种主要的启动模式:standard(标准模式)、singleTop(栈顶复用模式)、singleTask(栈内复用模式)和 singleInstance(单实例模式)。
standard 模式是默认的启动模式。在这种模式下,每当有新的 Activity 启动请求,系统就会创建一个新的 Activity 实例并将其添加到任务栈(Task)的顶部。例如,有一个应用包含 Activity A、Activity B,当从 A 启动 B,在 standard 模式下,会创建一个全新的 B 实例放入任务栈,不管之前是否已经创建过 B。这种模式简单直接,但可能会导致任务栈中有多个相同 Activity 的实例,占用较多内存。
singleTop 模式下,如果要启动的 Activity 已经处于任务栈的栈顶,就不会重新创建这个 Activity,而是直接调用它的 onNewIntent 方法来处理新的启动意图。例如,当一个 Activity B 在栈顶,再次启动 B 时,不会创建新的 B 实例,而是复用栈顶的 B,通过 onNewIntent 方法传递新的启动参数,这样可以避免不必要的 Activity 实例创建,适合一些经常需要在栈顶被重复启动的 Activity,比如消息推送的详情展示 Activity,当有新消息推送过来,打开详情页时,如果详情页已经在栈顶,就直接复用,更新内容展示新消息。
singleTask 模式下,系统会在任务栈中查找是否已经存在该 Activity 的实例。如果存在,就会将这个 Activity 之上的其他 Activity 全部出栈,然后将这个 Activity 置于栈顶,并且调用它的 onNewIntent 方法。例如,有一个主界面 Activity A 和一个登录 Activity B,登录成功后会启动 A,若 A 是 singleTask 模式,当从 B 启动 A 时,若任务栈中已经有 A,会把 A 之上的 Activity(如果有)都出栈,让 A 处于栈顶,恢复 A 的状态,这种模式适合作为应用的主界面或者入口点等关键 Activity,保证整个应用流程中只有一个关键 Activity 实例存在,方便用户在应用不同模块间切换后回到主界面。
singleInstance 模式是最特殊的一种。在这种模式下,启动的 Activity 会单独占用一个任务栈。它主要用于一些需要独立运行的 Activity,比如系统的来电显示 Activity,当有电话进来时,来电显示 Activity 单独在一个任务栈中,不会和应用的其他 Activity 混在一个任务栈,这样可以保证来电显示的独立性和稳定性,不受应用内其他 Activity 生命周期的影响。
单例模式适用场景
单例模式适用于整个应用或者一个系统中只需要有一个实例的情况。
在资源共享方面,例如数据库连接池。在应用中,数据库连接是一种比较宝贵的资源,创建和销毁连接都有一定的开销。通过单例模式创建一个数据库连接池,可以保证整个应用只有一个连接池实例,所有需要数据库连接的地方都从这个连接池中获取连接,这样可以有效地管理和共享数据库连接资源,避免创建过多连接导致资源浪费。
在全局配置管理场景下也很有用。比如应用的主题配置,通过单例模式创建一个主题配置类,应用的各个组件(如 Activity、View 等)都可以从这个单例的主题配置类中获取当前的主题设置,当主题需要改变时,只需要在这个单例类中修改配置,所有组件都能获取到新的主题信息,保证了主题配置的一致性。
另外,在一些工具类场景中也适用。例如日志记录工具,整个应用只需要一个实例来记录日志,这个日志记录工具可以通过单例模式实现,方便在应用的各个地方记录日志,并且可以统一管理日志的输出格式、存储位置等属性。
计算机网络 OSI 七层模型各有什么作用以及各层有哪些协议?
物理层
作用:主要负责在物理介质上传输原始的比特流,处理物理介质的电气、机械、功能和规程特性。它是整个网络通信的基础,定义了如电缆类型、接口形状、信号的传输方式(如电平高低代表 0 和 1)等内容。
协议:例如 RS - 232、RS - 449 等串行接口标准,用于规范计算机与调制解调器等设备之间的物理连接;还有以太网的物理层标准 IEEE 802.3,它规定了以太网的物理介质(如双绞线、光纤)和传输速率等物理特性。
数据链路层
作用:将物理层接收到的原始比特流组合成帧,进行差错检测和纠正,以及控制对物理介质的访问。它使得物理层的传输变得可靠,确保帧能够准确地从一个节点传送到相邻节点。
协议:以太网协议(IEEE 802.3)是最常见的,它定义了如何将数据封装成帧、MAC(媒体访问控制)地址的使用等内容;还有 HDLC(高级数据链路控制)协议,它是一种面向位的数据链路层协议,用于同步串行链路的数据传输。
网络层
作用:主要负责将分组从源节点传输到目标节点,进行路由选择和寻址。它能够跨越不同的网络,找到最佳的传输路径,使得数据能够在复杂的网络环境中正确地传输。
协议:IP(互联网协议)是网络层的核心协议,如 IPv4 和 IPv6,用于标识网络中的节点和进行路由;ICMP(互联网控制消息协议)用于在 IP 主机、路由器之间传递控制消息,比如用于检测网络是否连通的 Ping 命令就是基于 ICMP 协议;ARP(地址解析协议)用于将 IP 地址解析为 MAC 地址。
传输层
作用:为不同主机上的应用进程提供端到端的通信服务,包括可靠的(如 TCP)或不可靠的(如 UDP)传输服务,还进行流量控制和差错控制等。
协议:TCP(传输控制协议)是一种面向连接的、可靠的传输协议,通过三次握手建立连接,四次挥手关闭连接,保证数据的可靠传输;UDP(用户数据报协议)是一种无连接的、不可靠的传输协议,它的传输效率高,但不保证数据一定能正确到达目的地。
会话层
作用:负责建立、管理和终止会话,包括会话的同步、对话控制等功能,使得不同主机上的应用程序之间能够进行有效的会话。
协议:NetBIOS(网络基本输入输出系统)会话服务,用于在局域网环境中提供会话管理服务;还有 RPC(远程过程调用)协议的会话部分,用于支持分布式计算环境中的过程调用会话。
表示层
作用:主要处理数据的表示、转换、加密和压缩等功能。它使得不同系统之间能够理解彼此的数据格式,例如将数据从一种编码格式转换为另一种格式。
协议:ASCII、EBCDIC 等字符编码协议属于表示层范畴,用于规定字符的编码方式;还有 SSL(安全套接层)/TLS(传输层安全)协议的部分功能涉及表示层,用于数据的加密和安全传输。
应用层
作用:为用户提供各种网络应用服务,如文件传输、电子邮件、网页浏览等,是用户与网络直接交互的一层。
协议:HTTP(超文本传输协议)用于网页浏览,通过浏览器请求和接收网页资源;FTP(文件传输协议)用于文件的上传和下载;SMTP(简单邮件传输协议)用于发送电子邮件,POP3(邮局协议第 3 版)和 IMAP(互联网邮件访问协议)用于接收电子邮件。
TCP 与 UDP 有什么区别?它们各自的应用场景是什么?
区别
连接性
TCP 是面向连接的协议。在数据传输之前,需要通过三次握手建立连接,确保通信双方都准备好进行数据传输。例如,当浏览器访问网页时,使用 TCP 协议,会先和服务器建立连接。这个过程就像打电话,先拨通号码确认对方接听后才开始通话。
UDP 是无连接的协议。发送数据时不需要先建立连接,直接将数据报发送出去。就像寄信,把信投进邮筒,不确认对方是否准备好接收。
可靠性
TCP 提供可靠的数据传输服务。它通过序列号、确认应答、重传机制等来保证数据能够完整、正确地到达目的地。如果发送的数据没有收到确认应答,会重新发送。
UDP 是不可靠的传输协议。它不保证数据一定能到达目的地,也不保证数据的顺序。数据报可能会丢失、重复或者乱序。
传输效率
TCP 由于要进行连接建立、维护和数据确认等操作,传输效率相对较低。它有较多的控制信息开销。
UDP 没有这些复杂的连接和确认过程,数据报格式简单,传输效率高。
应用场景
TCP 应用场景
文件传输:在 FTP(文件传输协议)和 HTTP(超文本传输协议)等文件传输场景中,需要保证文件内容完整、正确地传输,所以使用 TCP。例如,从服务器下载一个大型软件安装包,TCP 可以确保文件数据没有错误和丢失。
电子邮件:SMTP(简单邮件传输协议)、POP3(邮局协议第 3 版)和 IMAP(互联网邮件访问协议)等电子邮件协议使用 TCP。因为邮件内容包含重要信息,需要可靠的传输来保证邮件完整送达收件人的邮箱。
远程登录:如 SSH(安全外壳协议)和 Telnet 等远程登录协议,需要可靠的连接来保证用户能够准确地在远程服务器上进行操作,所以使用 TCP。
UDP 应用场景
实时视频和音频传输:在视频会议、在线直播等实时多媒体应用中,对实时性要求很高,少量的数据丢失对用户体验影响不大,UDP 的高效传输可以满足这种需求。例如,在网络直播中,偶尔丢失一两个视频帧不会严重影响观看体验,更重要的是保证视频的实时播放。
游戏:很多网络游戏使用 UDP。因为游戏中的一些操作信息,如玩家的位置更新等,对实时性要求很高,而且少量数据丢失可以通过游戏的逻辑来弥补。例如,在一个射击游戏中,玩家位置信息的快速传输比数据的绝对完整性更重要,UDP 能够快速地将这些位置信息发送出去,保证游戏的流畅性。
简单的查询 - 应答服务:对于一些只需要简单查询并快速得到回复的场景,如 DNS(域名系统)查询的部分功能,UDP 可以快速地发送查询请求并接收应答,提高查询效率。
请解释 TCP/IP 网络模型,有在哪里用过 TCP/IP 协议?遇到过什么问题?如何解决的?
TCP/IP 网络模型解释
TCP/IP 网络模型是一个四层的网络模型,包括网络接口层、网络层、传输层和应用层。
网络接口层:它相当于 OSI 模型的物理层和数据链路层的部分功能。主要负责将数据帧从一个网络设备传输到相邻的网络设备,处理物理介质的接入和数据帧的发送与接收。例如,通过以太网网卡将数据发送到网络电缆上,或者通过无线网卡接收无线信号。
网络层:核心协议是 IP(互联网协议)。它的主要功能是进行寻址和路由选择,将数据包从源主机发送到目标主机。通过 IP 地址来标识网络中的各个主机,并且根据路由表来确定数据包的传输路径。例如,当从本地网络发送数据到互联网上的其他主机时,网络层会根据目标主机的 IP 地址,通过路由器等网络设备来转发数据包。
传输层:主要有 TCP(传输控制协议)和 UDP(用户数据报协议)。TCP 提供面向连接的、可靠的端到端数据传输服务,通过三次握手建立连接,保证数据的顺序和完整性;UDP 提供无连接的、高效的传输服务,不保证数据的可靠性。这一层根据应用的需求选择合适的协议来传输数据。
应用层:包含了各种应用协议,如 HTTP(超文本传输协议)用于网页浏览,FTP(文件传输协议)用于文件传输,SMTP(简单邮件传输协议)用于电子邮件发送等。这些协议使得用户能够通过网络进行各种具体的应用操作。
使用场景和问题及解决方法
在网页浏览中使用 TCP/IP 协议。当使用浏览器访问网页时,浏览器通过 HTTP 协议(基于 TCP/IP)向服务器发送请求,服务器返回网页内容。
问题:可能会出现网络延迟导致网页加载缓慢。这可能是因为网络拥塞或者服务器响应速度慢。
解决方法:可以通过优化网络环境,如更换更快的网络接入方式;或者从服务器端进行优化,如增加服务器的带宽、采用内容分发网络(CDN)来缓存网页内容,使得用户能够从距离更近的节点获取内容,减少传输延迟。
在文件传输中也会用到 TCP/IP 协议。例如使用 FTP 协议传输文件。
问题:文件传输过程中可能会出现中断,如网络不稳定导致连接断开。
解决方法:对于基于 TCP 的文件传输协议,因为 TCP 有重传机制,在一定程度上可以自动恢复连接并继续传输。但如果中断次数过多或者网络长时间无法恢复,可以考虑暂停传输,等待网络稳定后重新开始传输,并且可以在客户端和服务器端设置断点续传功能,记录已经传输的部分,从断点处继续传输。
在网络游戏中,部分功能使用 UDP 协议(属于 TCP/IP 模型)。
问题:由于 UDP 的不可靠性,可能会出现游戏数据丢失或者延迟过高的情况。
解决方法:在游戏客户端和服务器端增加数据校验和补传机制。例如,对于重要的游戏数据,如玩家的等级、装备等信息,在服务器端定期备份,当客户端发现数据丢失或者错误时,可以向服务器请求重新发送。同时,可以通过优化网络路由、采用低延迟的网络服务来减少数据延迟。
请介绍一下 NIO(非阻塞 I/O)以及负载均衡。
NIO(非阻塞 I/O)介绍
NIO 是一种 I/O 操作方式,与传统的阻塞 I/O 相对。在传统的阻塞 I/O 中,当一个线程发起一个 I/O 操作(如读取文件或者接收网络数据)时,这个线程会被阻塞,直到 I/O 操作完成。例如,在一个简单的网络服务器中,当服务器使用阻塞 I/O 接收客户端的连接和数据时,服务器线程会一直等待客户端发送数据,在此期间不能做其他事情。
而 NIO 采用了事件驱动的方式,通过选择器(Selector)来管理多个通道(Channel)。通道可以理解为数据的传输管道,如文件通道或者网络通道。选择器可以同时监听多个通道的事件,这些事件包括可读事件、可写事件等。例如,在一个 NIO 的网络服务器中,一个选择器可以同时监听多个客户端连接的通道,当某个客户端通道有数据可读时,选择器会感知到这个事件,然后服务器就可以从这个通道读取数据,而在没有事件发生时,服务器线程可以做其他事情,不会被阻塞。
NIO 的优势在于可以用一个线程来处理多个 I/O 操作,提高了 I/O 处理的效率,尤其在高并发的场景下,如大型网络服务器、高性能文件服务器等。它可以大大减少线程的数量,降低系统的资源消耗,并且提高系统的吞吐量。
负载均衡介绍
负载均衡是一种将工作负载(如网络流量、计算任务等)均匀地分配到多个服务器或者资源上的技术。其目的是提高系统的整体性能、可靠性和可用性。
在网络服务器领域,负载均衡器会接收来自客户端的请求,然后根据一定的算法将这些请求分配到后端的多个服务器上。例如,有一个大型的网站,有多台 Web 服务器来处理用户的访问请求。负载均衡器可以根据服务器的负载情况(如 CPU 使用率、内存使用率、网络带宽使用率等),采用轮询、加权轮询、最小连接数等算法来分配请求。
轮询算法就是按照顺序依次将请求分配到后端服务器,每个服务器轮流处理请求。加权轮询则会考虑不同服务器的性能差异,给性能好的服务器分配更多的请求。最小连接数算法是将请求分配到当前连接数最少的服务器上,这样可以保证每个服务器的负载相对均衡。
负载均衡的应用场景非常广泛。在大型数据中心中,通过负载均衡可以有效地利用服务器资源,避免某台服务器过载而其他服务器闲置的情况。在云计算环境中,也可以使用负载均衡来分配用户的计算任务和存储请求,提高整个云平台的性能和效率。同时,负载均衡还可以提高系统的可靠性,当一台服务器出现故障时,负载均衡器可以将请求自动分配到其他正常的服务器上,保证服务的连续性。
双向链表和单链表有什么区别?
结构上的区别
单链表是一种链式存储结构,每个节点包含一个数据元素和一个指向下一个节点的指针。就像一列火车,每个车厢(节点)通过挂钩(指针)连接下一个车厢,最后一个节点的指针为 null。例如,定义一个单链表节点结构:
class ListNode {
int data;
ListNode next;
}
双向链表每个节点包含一个数据元素、一个指向前一个节点的指针和一个指向后一个节点的指针。可以把它想象成一列有前后挂钩的火车车厢,每个车厢既能连接前面的车厢,也能连接后面的车厢。双向链表节点结构可以定义为:
class DoublyListNode {
int data;
DoublyListNode prev;
DoublyListNode next;
}
操作上的区别
遍历操作
单链表只能从表头开始,沿着指针方向依次访问每个节点,直到到达链表末尾。例如,要遍历一个单链表,从表头节点开始,通过不断访问下一个节点(node = node.next)来遍历整个链表。
双向链表既可以从表头开始向后遍历,也可以从表尾开始向前遍历。这使得在某些情况下,双向链表的遍历更加灵活。比如,在一个双向链表中,如果已经知道表尾节点,要访问倒数第二个节点,可以通过表尾节点的 prev 指针轻松访问。
插入和删除操作
在单链表中插入一个节点,需要先找到插入位置的前驱节点。例如,要在节点 A 之后插入节点 B,需要先找到节点 A,然后将节点 B 插入到 A 和 A 的下一个节点之间。删除节点时,同样需要找到要删除节点的前驱节点,然后修改前驱节点的指针。
在双向链表中,插入和删除操作相对更灵活。插入一个节点时,除了可以像单链表那样通过前驱节点插入,还可以通过后继节点来插入。因为节点有指向前驱和后继的指针,在插入过程中可以同时修改前驱和后继节点的指针来完成插入。删除节点时,双向链表可以直接通过节点本身的 prev 和 next 指针来快速修改链表结构,不需要像单链表那样一定要先找到前驱节点。
应用场景的区别
单链表适用于一些简单的线性数据存储和顺序访问的场景。例如,实现一个简单的栈或者队列,只需要在链表的一端进行插入和删除操作,单链表就可以满足要求。
双向链表更适合需要频繁地在链表中进行双向遍历、插入和删除操作的场景。比如,在一个文本编辑器中,用于存储文本行的链表可以采用双向链表。因为在编辑文本时,可能需要向前或向后查找文本行,并且可能会在任意位置插入或删除文本行,双向链表的特性可以更好地满足这些操作需求。
请简述 Map 的几种遍历方式。
在 Java 中,Map 是一种用于存储键值对的数据结构,它有多种遍历方式。
通过键值对遍历(entrySet)
可以使用 entrySet 方法获取 Map 中所有键值对的集合,这个集合中的元素是 Map.Entry 类型,每个 Map.Entry 对象包含了一个键和对应的一个值。例如,对于一个 HashMap<String, Integer>,可以这样遍历:
import java.util.HashMap;
import java.util.Map;
public class MapTraversal {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put("key1", 1);
map.put("key2", 2);
map.put("key3", 3);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
System.out.println("键是:" + entry.getKey() + ",值是:" + entry.getValue());
}
}
}
这种方式的优点是可以同时获取键和值,在需要对键值对进行操作,如打印、修改或者根据键值对进行计算等场景下非常方便。
仅遍历键(keySet)
使用 keySet 方法可以获取 Map 中所有键的集合,然后通过键来获取对应的的值。还是以上面的 HashMap 为例:
for (String key : map.keySet()) {
System.out.println("键是:" + key + ",值是:" + map.get(key));
}
这种方式在只需要对键进行操作,或者通过键来获取值进行特定操作时比较有用。例如,检查键是否符合某个条件,或者根据键来统计值的某些属性。
仅遍历值(values)
如果只关心 Map 中的值,可以使用 values 方法获取所有值的集合,然后遍历这个集合。例如:
for (Integer value : map.values()) {
System.out.println("值是:" + value);
}
这种方式适用于只需要对值进行操作的场景,如计算所有值的总和、平均值,或者检查值是否满足某个条件等。在某些情况下,可能不需要知道键,只关注值本身的属性。
请遍历一个 ArrayList 有哪些方式?
在 Java 中,ArrayList 是一种常用的动态数组,有多种遍历方式。
普通 for 循环遍历
可以通过索引来遍历 ArrayList。例如,有一个 ArrayList<String>存储了一些字符串:
import java.util.ArrayList;
public class ArrayListTraversal {
public static void main(String[] args) {
ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("元素1");
arrayList.add("元素2");
arrayList.add("元素3");
for (int i = 0; i < arrayList.size(); i++) {
System.out.println(arrayList.get(i));
}
}
}
这种方式的优点是可以方便地获取元素的索引,在需要根据索引进行操作,如修改特定位置的元素、比较相邻元素等场景下很有用。
增强 for 循环(foreach)遍历
增强 for 循环提供了一种简洁的方式来遍历 ArrayList。例如:
for (String element : arrayList) {
System.out.println(element);
}
它不需要显式地使用索引,代码更加简洁明了。在只需要访问每个元素,而不需要对元素的索引进行操作的场景下,这种方式很方便,如打印所有元素、对每个元素进行相同的操作等。
使用迭代器(Iterator)遍历
可以通过获取 ArrayList 的迭代器来进行遍历。例如:
import java.util.Iterator;
Iterator<String> iterator = arrayList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
迭代器提供了一种统一的遍历集合的方式,并且在遍历过程中可以使用迭代器的方法来对集合进行操作。例如,可以在遍历过程中使用 iterator.remove () 方法来删除当前元素,这种方式在某些需要在遍历过程中动态修改集合的场景下非常有用。
请手撕代码:输出 2 到 N 之间的全部素数。
在面试时,要求输出 2 到 N 之间的所有素数是一个经典的问题,通常会考察应聘者对算法和时间空间复杂度的理解。下面是思路的详细解释,以及 Java 代码实现。
思路
- 素数定义:素数是大于1的自然数,且仅能被1和自身整除。
- 方法选择:
- 最直接的解法是对于每个数字
i
,判断它是否为素数。这个判断过程是通过检查i
是否能被小于等于i
的所有整数整除来完成。显然,时间复杂度较高。 - 更高效的方法是 埃拉托斯特尼筛法(Sieve of Eratosthenes)。该方法的思想是:从2开始,将所有2的倍数标记为非素数,然后依次处理3、4、5等,直到处理到 √N 为止。这个方法能够有效减少判断素数的次数。
- 最直接的解法是对于每个数字
时间复杂度和空间复杂度分析
-
埃拉托斯特尼筛法的时间复杂度:
- 时间复杂度是
O(N log log N)
,这个复杂度是通过逐步标记每个合数来实现的,比暴力方法要高效得多。
- 时间复杂度是
-
空间复杂度:
- 空间复杂度是
O(N)
,因为我们需要一个大小为 N 的布尔数组来存储每个数是否是素数。
- 空间复杂度是
Java 实现(埃拉托斯特尼筛法)
import java.util.ArrayList;
import java.util.List;
public class PrimeNumbers {
// 埃拉托斯特尼筛法:输出2到N之间的素数
public static List<Integer> sieveOfEratosthenes(int N) {
// 布尔数组,true表示该数是素数,false表示该数不是素数
boolean[] isPrime = new boolean[N + 1];
// 初始时,所有数字都假设为素数
for (int i = 2; i <= N; i++) {
isPrime[i] = true;
}
// 从2开始,标记所有合数
for (int i = 2; i * i <= N; i++) {
if (isPrime[i]) {
// 如果i是素数,将i的所有倍数标记为非素数
for (int j = i * i; j <= N; j += i) {
isPrime[j] = false;
}
}
}
// 收集所有素数
List<Integer> primes = new ArrayList<>();
for (int i = 2; i <= N; i++) {
if (isPrime[i]) {
primes.add(i);
}
}
return primes;
}
public static void main(String[] args) {
int N = 50; // 可以根据需要修改N的值
List<Integer> primes = sieveOfEratosthenes(N);
// 输出素数
System.out.println("2到" + N + "之间的素数:");
for (int prime : primes) {
System.out.print(prime + " ");
}
}
}
请重写笔试题:用 sql 语句求平均分超过 60 分的学生学号。
假设存在一个名为 students 的表,其中包含学生学号(student_id)、课程成绩(score)等字段,以下是使用 SQL 语句来求平均分超过 60 分的学生学号的方法。
在 MySQL 中:
SELECT student_id
FROM (
SELECT student_id, AVG(score) AS average_score
FROM students
GROUP BY student_id
) AS subquery
WHERE average_score > 60;
首先,在子查询中,使用 GROUP BY student_id 对学生学号进行分组,然后计算每个学生的平均成绩(AVG (score)),并将平均成绩命名为 average_score。这个子查询会返回一个包含学生学号和对应的平均成绩的结果集。