# 作业帮 Java 面试

大家好,我是小林。

有读者反馈之前看我发了很多互联网大厂的面经,想看看互联网中厂的面试难度,那么这次来看看作业帮的!

我整理了作业帮 25 届开发岗位的校招薪资,情况如下:

  • 22k * 15 = 33w,base 北京

  • 21k * 15 = 31.5w,base 武汉

  • 20k * 15 = 30w ,base 武汉

  • 18k * 15 = 27w,base 武汉

  • 17k * 15 = 25.5w,base 武汉

在武汉拿到 25-30w 年薪还是非常的不错的,通常二线城市是一线城市薪资的 8 折,假设在二线城市是 20k 月薪的话,等同于在一线是 24-25k 的薪资。

在秋招的时候,也有训练营同学拿到作业帮 offer,在武汉开了 27w 年薪,还是在老家,其实算不错的 offer,不过今年互联网大厂薪资都通常比较高,动不动都是 40w 年薪的,对比一下就会觉得 27w 年薪不多,所以同学先是签了保底,后续会继续冲更好的互联网公司。

img

这次来看看作业帮Java 后端的校招面经,这场面上共面试了 60 分钟,并且也是有算法手撕环节。考察的知识点不算多,主要是 Java 和项目相关的知识。看到好几位同学的作业帮面经都是手撕快排,面作业帮的同学一定要在面试之前好好练习快速排序算法

imgf

# Java

# 为什么选择Java,其优势在哪儿?

我的项目是 web 应用场景,Java 在这方面生态非常成熟了,有很多开源框架可用,比如 springboot、spring、mybatis等等,除此之外, Java 语言还有下面这些优势:

  • 跨平台性:Java 通过 Java 虚拟机(JVM)来实现跨平台。Java 源代码被编译成字节码(.class 文件),字节码可以在任何安装了 JVM 的操作系统上运行。例如,一段 Java 程序在 Windows 系统上编译生成字节码后,无需修改就可以在 Linux 系统或者 macOS 系统的 JVM 上运行。
  • 面向对象特性:Java 是一种纯粹的面向对象编程语言,支持封装、继承和多态等 OOP 特性。封装允许将数据和操作数据的方法封装在一个类中,隐藏内部实现细节,提高代码的安全性和可维护性。继承使得子类可以继承父类的属性和方法,实现代码的复用。多态则允许不同的子类对象对相同的方法调用做出不同的响应,增强了程序的灵活性。
  • 内存管理:Java 有自动的垃圾回收机制。GC 会自动检测和回收不再被使用的对象所占用的内存空间,开发者不需要手动释放内存。例如,当一个对象没有任何引用指向它时,GC 会在适当的时候回收该对象占用的内存。

# 对面向对象的理解?

面向对象是一种编程范式,它将现实世界中的事物抽象为对象,对象具有属性(称为字段或属性)和行为(称为方法)。面向对象编程的设计思想是以对象为中心,通过对象之间的交互来完成程序的功能,具有灵活性和可扩展性,通过封装和继承可以更好地应对需求变化。

Java面向对象的三大特性包括:封装、继承、多态

  • 封装:封装是指将对象的属性(数据)和行为(方法)结合在一起,对外隐藏对象的内部细节,仅通过对象提供的接口与外界交互。封装的目的是增强安全性和简化编程,使得对象更加独立。
  • 继承:继承是一种可以使得子类自动共享父类数据结构和方法的机制。它是代码复用的重要手段,通过继承可以建立类与类之间的层次关系,使得结构更加清晰。
  • 多态:多态是指允许不同类的对象对同一消息作出响应。即同一个接口,使用不同的实例而执行不同操作。多态性可以分为编译时多态(重载)和运行时多态(重写)。它使得程序具有良好的灵活性和扩展性。

# 继承和多态有什么区别?

  • 继承主要是一种类与类之间的关系,是代码复用的一种机制。它允许创建一个新的类(子类),这个子类可以继承父类的属性(成员变量)和方法。子类是对父类的一种扩展,它可以在父类的基础上添加新的属性和方法,也可以修改父类中原有的方法。例如,有一个 “动物” 类作为父类,它有属性 “体重” 和方法 “进食”。然后 “猫” 类作为子类继承 “动物” 类,除了继承 “体重” 属性和 “进食” 方法外,还可以添加新的属性如 “毛色”,新的方法如 “抓老鼠”。
  • 多态是强调的是同一个行为(方法)在不同对象中有不同的实现方式。多态是通过方法重写(在继承关系中)或者接口实现来体现的。例如,有一个 “交通工具” 接口,定义了 “移动” 方法。“汽车” 类和 “自行车” 类都实现了这个接口,它们对 “移动” 方法有不同的实现,汽车是通过发动机驱动车轮移动,自行车是通过脚蹬使车轮转动来移动。

# Java的垃圾回收机制?

Java 的垃圾回收负责回收那些不再被程序使用的对象所占用的内存空间,使程序员不需要手动释放内存,从而避免了因手动内存管理不当而导致的内存泄漏和悬空指针等问题,常见的垃圾回收算法如下:

  • 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
  • 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
  • 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
  • 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。

# Java的垃圾自动回收是怎么判断的?

在Java中,判断对象是否为垃圾(即不再被使用,可以被垃圾回收器回收)主要依据两种主流的垃圾回收算法来实现:引用计数法和可达性分析算法

引用计数法(Reference Counting)

  • 原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
  • 缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收。

可达性分析算法(Reachability Analysis)

null

Java虚拟机主要采用此算法来判断对象是否为垃圾。

  • 原理:从一组称为GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、本地方法栈中JNI(Java Native Interface)引用的对象、活跃线程的引用等。

# Java的内存管理是怎么做的?

根据 JVM8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

null

JVM的内存结构主要分为以下几个部分:

  • 元空间:元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
  • Java 虚拟机栈:每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
  • 本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行Java方法,本地方法站执行native方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
  • 程序计数器:程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有”内存。
  • 堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。堆是JVM内存占用最大,管理最复杂的一个区域。其唯一的用途就是存放对象实例:所有的对象实例及数组都在对上进行分配。jdk1.8后,字符串常量池从永久代中剥离出来,存放在队中。
  • 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通脱一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

# 全局静态变量与临时变量在内存的什么地方存储?

  • 局部变量:方法中的局部变量存在于栈内存。每当程序调用一个方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因。
  • 成员变量:对象实例的引用存储在栈内存中,对象实例存储在堆内存中。所以,对象中声明的成员变量存储在堆中。(成员变量不会随着某个方法执行结束而销毁)
  • 全局静态变量:全局静态变量存储在方法区(在 Java 8 之后为元空间)。方法区主要用于存储已被虚拟机加载的类信息,包括类的版本、字段、方法、接口等,以及常量、静态变量、即时编译器编译后的代码等。全局静态变量的生命周期是从类被加载开始,一直到类被卸载结束。只要类被加载到 Java 虚拟机中,静态变量就会一直存在于内存中,它可以被类的所有实例共享访问。

# int和long是多少位,多少字节的?

  • int类型是 32 位(bit),占 4 个字节(byte),int 是有符号整数类型,其取值范围是从 -2^31 到 2^31-1 。例如,在一个简单的计数器程序中,如果使用int类型来存储计数值,它可以表示的最大正数是 2,147,483,647。如果计数值超过这个范围,就会发生溢出,导致结果不符合预期。
  • long类型是 64 位,占 8 个字节,long类型也是有符号整数类型,它的取值范围是从 -2^63 到 2^63 -1 ,在处理较大的整数数值时,果int类型的取值范围不够,就需要使用long类型。例如,在一个文件传输程序中,文件的大小可能会很大,使用int类型可能无法准确表示,而long类型就可以很好地处理这种情况。

# 其他

# 手机和电脑是分别是多少位的?

现在的手机和电脑基本都是 64 位的了。

# 你知道手机的架构吗,有哪些?

不太了解

# Redis

# 为什么用redis,解决了什么问题?

主要是因为 Redis 具备「高性能」和「高并发」两种特性

1、Redis 具备高性能

假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。

img

如果 MySQL 中的对应数据改变的之后,同步改变 Redis 缓存中相应的数据即可,不过这里会有 Redis 和 MySQL 双写一致性的问题。

2、 Redis 具备高并发

单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。

所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

# 与redis对标的技术是什么?

还有Memcached,它也是内存数据库,和 Redis 一样,它的数据存储在内存中,通过减少磁盘 I/O 操作来实现快速的数据读写。例如,当一个应用程序向 Memcached 请求一个数据时,它会根据键来查找对应的内存位置,如果找到就直接返回值。

Memcached 相比 Redis:

  • 数据结构简单性:Memcached 只支持简单的键值对数据结构,不像 Redis 有丰富的数据结构(如列表、集合、有序集合等)。这使得 Memcached 在一些只需要简单缓存功能的场景下更加轻量级,但在复杂的数据操作场景下(如实现排行榜、消息队列等功能)不如 Redis 灵活。
  • 持久化功能缺失:Memcached 没有内置的数据持久化功能。这意味着一旦服务器重启或者出现故障,存储在 Memcached 中的数据将会丢失。而 Redis 提供了多种持久化方式(如 RDB 和 AOF),可以在一定程度上保证数据的安全性。
  • 没有原生的集群模式:Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;

# 项目

  • 看你项目上使用vue3,其中遇到过什么问题吗?

  • 做某某项目的过程中遇到过什么比较大的问题,是怎么解决的?

# 手撕

# 快排

快速排序是一种基于分治思想的高效排序算法,它的基本思路如下:

  • 第一步**选择基准值:**从待排序的数组中选择一个元素作为基准值。这个基准值的选择方式有多种,常见的是选择数组的第一个元素、最后一个元素或者中间元素等。例如,我们可以简单地选择数组的第一个元素作为基准值。
  • 第二步划分操作:通过一趟排序将待排序的数组分割成两部分,使得左边部分的所有元素都小于等于基准值,右边部分的所有元素都大于等于基准值。具体做法是设置两个指针,一个从数组的左边开始向右移动(通常称为left指针),一个从数组的右边开始向左移动(通常称为right指针)。然后,从左到右找到第一个大于基准值的元素,从右到左找到第一个小于基准值的元素,交换这两个元素的位置。不断重复这个过程,直到left指针和right指针相遇,此时将基准值与相遇位置的元素交换,这样就完成了一次划分操作,确定了基准值在排序后数组中的正确位置。
  • 第三步递归排序子数组:对划分后的左右两个子数组分别重复上述的选择基准值和划分操作,也就是递归地调用快速排序算法,直到子数组的长度为 1 或者 0(表示已经有序),此时整个数组就完成了排序。

以下是使用 Java 语言实现快速排序算法的代码:

public class QuickSort {
    public static void quickSort(int[] arr, int low, int high) {
        if (low < high) {
            // 划分操作,获取基准值的最终位置索引
            int pivotIndex = partition(arr, low, high);
            // 对基准值左边的子数组进行递归排序
            quickSort(arr, low, pivotIndex - 1);
            // 对基准值右边的子数组进行递归排序
            quickSort(arr, pivotIndex + 1, high);
        }
    }

    private static int partition(int[] arr, int low, int high) {
        int pivot = arr[low];  // 选择第一个元素作为基准值
        int left = low + 1;
        int right = high;

        while (true) {
            // 从左到右找到第一个大于基准值的元素
            while (left <= right && arr[left] <= pivot) {
                left++;
            }
            // 从右到左找到第一个小于基准值的元素
            while (left <= right && arr[right] > pivot) {
                right--;
            }
            if (left > right) {
                break;
            }
            // 交换两个元素的位置
            swap(arr, left, right);
        }
        // 将基准值与相遇位置的元素交换
        swap(arr, low, right);
        return right;
    }

    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    public static void main(String[] args) {
        int[] arr = {5, 3, 8, 6, 7, 2};
        quickSort(arr, 0, arr.length - 1);
        for (int num : arr) {
            System.out.print(num + " ");
        }
    }
}

在上述代码中:

  • quickSort方法是快速排序的主方法,它接收一个整数数组以及要排序的起始索引和结束索引。首先判断起始索引是否小于结束索引,如果是,则进行划分操作得到基准值的索引,然后分别对基准值左右两边的子数组进行递归调用quickSort方法。
  • partition方法实现了划分操作,按照前面描述的思路,通过双指针移动和元素交换来确定基准值的正确位置,并返回这个位置的索引。
  • swap方法用于交换数组中两个指定位置的元素,辅助partition方法完成元素的交换操作。

时间复杂度分析:

  • 快速排序在平均情况下的时间复杂度是 O(nlogn),但在最坏的情况下(例如数组本身已经有序或者逆序,每次选择的基准值恰好是最大或者最小的元素),时间复杂度会退化为 O(n^2)。
  • 快速排序的空间复杂度取决于递归调用的栈深度,在平均情况下,递归栈深度为 O(logn),所以空间复杂度为 O(logn)。在最坏情况下,递归栈深度为 O(n),空间复杂度则变为O(n)。

对了,最新的互联网大厂后端面经都会在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

img