面试刷题网站:
大家好,我是小林。
每年一到 10 月中旬往后,招银网络就开始陆续开奖了。我整理了下 26 届开发岗的薪资情况,你们瞅瞅:
后端开发,总包 27w,杭州,硕士 211
后端开发,总包 27w,深圳,硕士 211
后端开发,总包 30w,深圳,硕士 985
测试开发,总包 22w,成都,硕士 211
后端开发的总包差不多就在 27 到 30 万之间。要是 27 万的总包,拆开来算的话是这样:15.5k 乘以 12 个月,再加上 9 万的津贴和绩效,公积金是按 12% 交的。
我感觉今年招银网络 26 届校招薪资,跟去年基本没差。去年我也统计过 25 届开发岗的开奖情况,这么一对比,简直一模一样。
去年 25届校招招银网络开奖情况
招银网络科技其实是招商银行的软件中心,好多人都说它是银行的内包。
上班时间比正经银行要多几个小时,就拿深圳这边说,早上 8 点半到下午 5 点半,中午有两小时午休,但也会加班。听说一周基本要加三天班,有时候得加到晚上八九点,不过这也看项目情况。周末一般是正常双休,整体来看,加班强度比互联网公司会少一些。
但招银网络在网上的风评不太行,主要是内部挺卷的,据说还有末尾淘汰制。简单说就是,要是你工作绩效一直不怎么样,后面想续签合同就难了,躺平不了一点。
其实跟很多中小厂比,招银网络的薪资待遇还算不错。不过我发现,能拿到招银网络 offer 的同学,学历背景都挺好,基本都是 211、985 的,差点的也是双一流学历,所以它家对学历应该是有一定要求的。
但这些高学历的同学,手上大多都有互联网中大厂的 offer 可以选。跟大厂比起来,招银网络就差点意思了,很难成为他们的第一选择。
就像我里有个同学,今年秋招拿到了招商银行的 offer,但他手上有更好的选择,再加上也考虑到招银网络风评不好,后面大概率会把这个 offer 拒了。不过话说回来,有 offer 还是得先接着,offer 这东西不嫌多,能接就接,手里拿着也能当谈薪的筹码不是。
那话说回来,招银网络的面试难度怎么样呢?
招银网络科技的面试难度相对低一些,比进大厂的概率要高。它的面试流程是两轮技术面加一轮 HR 面,等所有面试都走完,就等着谈薪资、开奖就行。
这次,来一起看看今年招银网络的 Java 后端面经,一面主要是考八股,重点问了 Java、MySQL、网络这些知识点,大家来看看这难度咋样?
招银网络(后端一面)1. ArrayList和LinkedList的区别?
ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。
底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。
插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针。
随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。
使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
线程安全:这两个集合都不是线程安全的,Vector是线程安全的。
List<>等泛型集合类要求填充的必须是引用类型(对象类型),而不能直接使用基本数据类型(如 int、char、double等),否则会编译报错。
这是因为 Java 的泛型机制在设计时就只支持引用类型,不支持基本数据类型。例如,下面的代码会报错:
// 错误示例:List 中直接使用基本数据类型 int
List<int> list = newArrayList<>; // 编译报错
解决的办法是,使用基本数据类型对应的包装类。因此,正确的写法是:
// 正确示例:使用包装类 Integer
List list = newArrayList<>;
list.add(10); // 自动装箱:int -> Integer
intnum = list.get(0); // 自动拆箱:Integer -> int
这么设计的原因是:
泛型的类型擦除机制:Java 泛型在编译后会被擦除为 Object类型,而 Object只能接收引用类型,不能接收基本数据类型。
历史原因:Java 最初设计时基本数据类型和引用类型是严格区分的,泛型是后期(JDK 1.5)才引入的特性,为了兼容已有的类型系统,选择只支持引用类型。
通过使用包装类,结合 Java 的自动装箱(基本类型 → 包装类)和自动拆箱(包装类 → 基本类型)机制,可以很方便地在泛型集合中操作基本数据类型的数据。
3. 值传递和引用传递的区别?
在 Java 中,参数传递只有值传递一种方式,不存在真正的 “引用传递”。但很多人会混淆这两个概念,核心区别在于传递的是 “值的副本” 还是 “引用的副本”。
值传递(Pass by Value)。传递的是实际值的副本,适用于基本数据类型(如 int、char等),修改方法内的参数副本,不会影响原变量的值。例子:
publicstaticvoidmain(String[] args){
intnum = 10;
changeValue(num);
System.out.println(num); // 输出 10(原变量未被修改)
}
publicstaticvoidchangeValue(inta){
a = 20; // 仅修改副本
}
引用传递的误解(本质仍是值传递)。对于对象(引用类型),传递的是对象引用的副本(而非对象本身)。
两个引用(原引用和副本)指向同一个对象,因此通过副本修改对象内部数据,会影响原对象。但如果修改副本的指向(如重新赋值),不会影响原引用的指向。示例:
publicclassPerson{
String name;
Person(String name) { this.name = name; }
}
publicstaticvoidmain(String[] args){
Person p = newPerson("Alice");
changeName(p);
System.out.println(p.name); // 输出 "Bob"(对象内部被修改)
changeReference(p);
System.out.println(p.name); // 仍输出 "Bob"(原引用指向未变)
}
// 修改对象内部数据
publicstaticvoidchangeName(Person obj){
obj.name = "Bob"; // 副本和原引用指向同一个对象
}
// 修改副本的指向(不影响原引用)
publicstaticvoidchangeReference(Person obj){
obj = newPerson("Charlie"); // 副本指向新对象,原引用仍指向旧对象
}
简单来说,Java 中所有参数传递都是值传递:
基本类型传递 “值的副本”,修改副本不影响原值。
引用类型传递 “引用的副本”,通过副本可修改对象内容,但无法改变原引用的指向。
CHAR和VARCHAR都是MySQL中用于存储字符串的字段类型,它们都可以存储字符数据。
存储方式不同:CHAR是固定长度,会占用指定长度的存储空间,存储时会自动填充空格至指定长度,读取时自动截断末尾空格。VARCHAR是可变长度,只占用实际字符串长度加1~2字节(用于记录长度)的存储空间,不会自动填充空格,末尾空格会被保留。
长度限制不同:CHAR的最大长度为255个字符。VARCHAR最大长度为65535个字符。
性能表现不同:CHAR由于长度固定,查询和处理速度更快。VARCHAR因长度可变,查询时需额外计算长度,性能略低,但能节省存储空间。
适用场景不同:CHAR适合存储长度固定的数据,如手机号、身份证号等。VARCHAR适合存储长度不固定的数据,如用户名、描述信息等。
CREATE TABLE user(
phone CHAR(11)-- 无论实际输入多少位(不超过11),都占用11字符空间
);
-- VARCHAR 示例:存储可变长度的用户名(最长20位)
CREATE TABLE user(
username VARCHAR(20)-- 实际占用长度 = 用户名长度 + 1字节
);
字符与字节关系:两者的长度参数都指字符数,具体占用字节数与字符集有关(如UTF8中一个字符可能占1~3字节)。
如果硬要在不适合的场景使用 CHAR类型,可能会带来以下问题:
存储空间浪费:CHAR会占用固定长度的空间,即使存储的字符串实际长度很短,也会占用指定的全部长度。例如,用 CHAR(20)存储仅 3 个字符的字符串,会额外浪费 17 个字符的存储空间,当数据量庞大时,这种浪费会非常显著。
数据处理异常:CHAR存储时会自动在末尾填充空格,读取时又会自动截断末尾空格。如果业务中需要保留字符串末尾的空格(如某些特殊格式的编码),使用 CHAR会导致数据失真,而 VARCHAR能正常保留末尾空格。
索引效率降低:对于长度较长的 CHAR字段(如 CHAR(255)),其索引会占用更多存储空间,导致索引文件变大,从而降低索引的查询效率和更新速度,尤其是在频繁进行索引操作的场景下。
不适合变长数据场景:如果存储的字符串长度差异很大(如用户评论、商品描述等),强制使用 CHAR会同时加剧存储空间浪费和性能损耗,而 VARCHAR能根据实际长度动态调整存储,更适合此类场景。
MySQL 的内存碎片并非单一区域存在,而是主要集中在缓冲池(InnoDB Buffer Pool)和日志缓冲区(如 Redo Log Buffer),其中缓冲池的碎片影响最大 , 因为缓冲池是 MySQL 缓存数据页和索引页的核心区域,一旦这里产生碎片,直接影响数据读取效率。
具体来说,缓冲池的内存管理是按 “页(Page)” 为单位的(默认 16KB),当 MySQL 执行增删改操作时,会出现两种典型的碎片场景:
数据页内部碎片:比如一张表的行数据长度不固定(如包含 VARCHAR 字段),当某行数据被更新后长度变长,原数据页放不下,就会触发 “行溢出”, 把超出部分存到 “溢出页”,原数据页里就会留下一小块空闲空间;或者删除某行数据后,数据页内也会出现零散的空闲区域。这些小块空间因为太小,无法容纳新的完整行数据,就成了 “内部碎片”,导致数据页的实际利用率降低(比如 16KB 的页只存了 10KB 有效数据,剩下 6KB 是碎片)。
缓冲池外部碎片:缓冲池是连续的内存区域,当某些数据页被 “淘汰”(比如 LRU 算法移除不常用的页)后,会释放出对应的内存块,但这些释放的块可能不是连续的 , 比如释放了第 10-11KB、第 15-16KB 的块,中间夹着正在使用的 12-14KB 块,这些零散的空闲块无法组合成一个完整的 16KB 数据页,导致后续需要加载新数据页时,即使总空闲内存足够,也无法利用这些零散块,只能申请新的连续内存,造成内存浪费。
再看内存碎片的核心影响:
最直接的是内存利用率下降:明明缓冲池配置了足够大的内存(比如 8GB),但实际能用来缓存有效数据页的空间却因为碎片变少,导致 MySQL 不得不更频繁地从磁盘读取数据(磁盘 IO 远慢于内存),直接拖慢查询速度。
间接导致缓冲池淘汰效率降低:因为碎片占用了内存,有效缓存的页变少,常用的页可能被频繁淘汰,出现 “缓存命中率下降” 的问题 , 比如刚缓存的索引页因为内存碎片导致空间不足被淘汰,下一次查询又得重新读磁盘。
接下来是关键:如何判断和处理 MySQL 内存碎片?
对于 InnoDB,可通过系统表或命令查看缓冲池的碎片情况。比如执行SHOW ENGINE INNODB STATUS\G,在 “BUFFER POOL AND MEMORY” 部分,关注 “Free buffers”(空闲缓冲块数量)和 “Database pages”(已使用的数据页数量), 如果 Free buffers 数值不低,但同时 MySQL 仍频繁发生磁盘 IO,大概率是存在碎片。
处理碎片的核心方式:
缓冲池预热与碎片整理:MySQL 5.6 + 版本支持缓冲池的 “在线碎片整理”,可通过设置innodb_buffer_pool_dump_pct(控制 dump 的页比例)和innodb_buffer_pool_load_at_startup(启动时加载),让缓冲池在重启或运行中,将零散的空闲块合并;也可以手动执行ALTER TABLE ... ENGINE=InnoDB(即 “表重建”),重建过程中会重新组织数据页,消除表级别的碎片,间接减少缓冲池的碎片。
合理配置缓冲池参数:比如将innodb_buffer_pool_size设置为物理内存的 50%-70%(避免内存溢出),同时启用innodb_buffer_pool_instances(将缓冲池拆分为多个实例,每个实例独立管理,减少单个实例的碎片产生), 比如 8GB 缓冲池拆成 4 个 2GB 实例,每个实例的碎片影响范围更小。
优化业务 SQL:减少频繁的小批量增删改(比如将多次单条插入改为批量插入),避免数据页频繁被修改导致的行溢出;对于 VARCHAR 等变长字段,合理设计长度,减少更新时的行长度变化,从源头减少碎片产生。
HTTP 协议采用的是「请求-应答」的模式,也就是客户端发起了请求,服务端才会返回响应,一来一回这样子。
由于 HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端发送 HTTP 请求,服务端收到后就返回响应,至此「请求-应答」的模式就完成了,随后就会释放 TCP 连接。
如果每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 HTTP 短连接,如下图:
这样实在太累人了,一次连接只能请求一次资源。
能不能在第一个 HTTP 请求完后,先不断开 TCP 连接,让后续的 HTTP 请求继续使用此连接?
当然可以,HTTP 的 Keep-Alive 就是实现了这个功能,可以使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 HTTP 长连接。
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
8. 手写一个深拷贝?
在 Java 中,实现对象深拷贝的方法有以下几种主要方式:
实现 Cloneable 接口并重写 clone 方法
这种方法要求对象及其所有引用类型字段都实现 Cloneable 接口,并且重写 clone 方法。在 clone 方法中,通过递归克隆引用类型字段来实现深拷贝。
classMyClassimplementsCloneable{
privateString field1;
privateNestedClass nestedObject;
@Override
protectedObject clonethrowsCloneNotSupportedException {
MyClass cloned = (MyClass) super.clone;
cloned.nestedObject = (NestedClass) nestedObject.clone; // 深拷贝内部的引用对象
returncloned;
}
}
classNestedClassimplementsCloneable{
privateintnestedField;
@Override
protectedObject clonethrowsCloneNotSupportedException {
returnsuper.clone;
}
}
使用序列化和反序列化
通过将对象序列化为字节流,再从字节流反序列化为对象来实现深拷贝。要求对象及其所有引用类型字段都实现 Serializable 接口。
importjava.io.*;
classMyClassimplementsSerializable{
privateString field1;
privateNestedClass nestedObject;
publicMyClass deepCopy{
try{
ByteArrayOutputStream bos = newByteArrayOutputStream;
ObjectOutputStream oos = newObjectOutputStream(bos);
oos.writeObject(this);
oos.flush;
oos.close;
ByteArrayInputStream bis = newByteArrayInputStream(bos.toByteArray);
ObjectInputStream ois = newObjectInputStream(bis);
return(MyClass) ois.readObject;
} catch(IOException | ClassNotFoundException e) {
e.printStackTrace;
returnnull;
}
}
}
classNestedClassimplementsSerializable{
privateintnestedField;
}
手动递归复制
针对特定对象结构,手动递归复制对象及其引用类型字段。适用于对象结构复杂度不高的情况。
classMyClass{
privateString field1;
privateNestedClass nestedObject;
publicMyClass deepCopy{
MyClass copy = newMyClass;
copy.setField1(this.field1);
copy.setNestedObject(this.nestedObject.deepCopy);
returncopy;
}
}
classNestedClass{
privateintnestedField;
publicNestedClass deepCopy{
NestedClass copy = newNestedClass;
copy.setNestedField(this.nestedField);
returncopy;
}
}
9. 如何实现一个分页查询?
实现 Java 分页查询其实思路挺直接的。首先得明白分页就是不想一次查所有数据,而是按页取,这样能减少数据传输量,页面加载也快。那具体怎么做呢?
首先得确定几个关键参数,比如用户要查第几页(pageNum),每页想显示多少条(pageSize)。有了这两个,就能算出从哪条数据开始查,也就是偏移量,一般是(pageNum-1)*pageSize。
publicPage(intpageNum, intpageSize, longtotal, List list) {
this.pageNum = pageNum;
this.pageSize = pageSize;
this.total = total;
this.list = list;
// 计算总页数
this.totalPages = (int) (total % pageSize == 0? total / pageSize : total / pageSize + 1);
}
然后就是数据库层面的操作了,比如 MySQL 用 LIMIT,后面跟偏移量和每页条数,一步的核心就是让数据库只返回当前页需要的数据,而不是全部。
SELECTid, username, age, email FROMuserLIMIT?, ?
光查当前页数据还不够,还得知道总共有多少条记录,这样才能算出总共有多少页,用户界面上才能显示分页导航。所以通常需要再执行一个 COUNT 查询获取总记录数,总页数就是(总记录数 + pageSize-1)/pageSize,这样算出来比较准确。
SELECTCOUNT(*) FROMuser
拿到当前页数据和总记录数后,就需要把这些信息封装起来,方便返回给前端。一般会建一个分页结果类,里面包含当前页数据列表、当前页码、每页条数、总记录数、总页数,可能还有是否有上一页、下一页这样的判断,前端用这些信息就能展示分页控件了。
实际开发中,我们一般不会自己写原生 SQL 来处理分页,太麻烦了。通常会用一些框架的插件,比如 MyBatis 的 PageHelper,它能拦截 SQL,自动加上分页条件,用起来很方便。不过原理还是一样的,就是帮我们自动计算偏移量,拼接分页 SQL。
// Service 层
publicPageInfo getUserPage(intpageNum, intpageSize){
// 1. 开启分页(参数存入 ThreadLocal)
PageHelper.startPage(pageNum, pageSize);
// 2. 执行原查询(无需手动写分页 SQL)
List userList = userMapper.selectAll; // Mapper 方法正常查询全量数据
// 3. 封装分页结果(包含总记录数、总页数等)
returnnewPageInfo<>(userList);
}
// Mapper 接口(无需任何分页参数)
publicinterfaceUserMapper{
List selectAll;
}
// Mapper XML(正常查询,无需写 LIMIT)
"selectAll" resultType="com.example.User"> SELECT id, name, age FROM user
10. 数据量较大如何分页查询?
当数据量比较大的时候,分页查询就不能简单用常规的 limit 加偏移量的方式了,那样效率会很低。你想啊,当页码特别大的时候,比如查第 1000 页,数据库需要先扫描前面 999 页的所有数据,再取当前页的内容,这对大表来说简直是灾难,扫描的数据量太大,性能会越来越差。
比如用 MySQL,假设有一张 user 表,id 是自增主键且有索引。常规分页可能这么写:
-- 第1页,每页10条
select* fromuserlimit0, 10;
-- 第1000页,这时候offset=9990,需要扫描9990+10条数据
select* fromuserlimit9990, 10;
所以这时候得换思路,一般会用 "基于游标 / 主键的分页"。就是说,我们不用页码和偏移量,而是用上次查询结果的最后一条记录的主键(或者其他有序的唯一字段)作为起点,加上每页条数来查下一页。比如查完第 1 页,记住最后一条的 id 是 100,下一页就查 id>100 的前 10 条。这样数据库可以直接利用主键索引定位,不用扫描前面的无效数据,效率会高很多。
-- 第1页,取id最小的10条
select* fromuserorderbyidasclimit10;
-- 假设上一页最后一条id是10,下一页就从id>10开始取
select* fromuserwhereid> 10orderbyidasclimit10;
-- 再下一页,用上一页最后一条id=20作为条件
select* fromuserwhereid> 20orderbyidasclimit10;