1. 试卷整体拆解:校招服务端笔试题到底在考什么
先说点实在的。服务端开发这个岗位,每年校招笔试筛人比例都很高,金山办公这套卷子基本能代表国内一线互联网和软件公司服务端岗的出题风格。我做这套题已经是好几年前的事了,但回看它涵盖的知识点、出题角度,放到现在依然不过时,所以干脆写一篇完整的拆解复盘,给正在准备校招的同学做参考。
整套试卷最典型的特点就是“选择题量大、编程题区分度极高”。选择题覆盖了Java/C++语言基础、数据结构与算法、操作系统、计算机网络、数据库、Linux常用命令这几大块;编程题一般是2到4道,涉及数组、链表、二叉树、动态规划、多线程,偶尔会有一道偏工程场景的小设计题。如果你只是把《剑指Offer》刷一遍就去考,选择题容易失分,因为选择题考的是细节,不是模板题;如果你只靠背八股文,编程题又会暴露真实代码能力。所以这套题对“基础扎实 + 代码熟练”两手抓的要求非常明确。
什么人适合认真做一遍这套题?第一类是正在冲刺校招的应届生,尤其是投递Java/C++方向服务端岗位的同学,它能帮你快速校准复习方向;第二类是准备跳槽、但还想补一补基础的后端开发,笔试题目其实是最浓缩的基础知识大纲;第三类是带新人的技术主管,完全可以直接拿这套题当团队新人摸底测试,比闲聊管用得多。
先看我整理的题型分布参考表,不同场次略有出入,但整体逻辑是这样:
| 模块 | 常见题型 | 分值占比(约) | 主要考察点 |
|---|---|---|---|
| 语言基础 | 单选/多选 | 15% - 20% | 关键字、集合类、内存模型、异常 |
| 数据结构与算法 | 单选 + 编程 | 30% - 35% | 数组、链表、树、排序、动态规划 |
| 操作系统 | 单选 | 10% - 15% | 进程线程、死锁、内存管理 |
| 计算机网络 | 单选 | 10% - 15% | TCP/IP、HTTP、Socket |
| 数据库与Linux | 单选/简答 | 10% - 15% | SQL、索引、事务、常用命令 |
| 多线程与设计 | 编程/简答 | 10% - 15% | 线程安全、并发工具、方案设计 |
从出题逻辑上看,这套题其实是在模拟一个服务端工程师日常处理问题的底层能力。选择题考“你知不知道”,编程题考“你能不能写出来”,简答题考“你能不能讲清楚”。尤其值得注意,卷子里经常出现的多线程编程题,对应的是真实服务端场景中的高并发处理;数据库设计题对应的是业务表结构设计;Linux命令题对应的是线上排查基本功。这些都不是死记硬背能解决的。
所以我的建议是:如果你时间有限,复习优先级应该是“数据结构与算法 > 语言基础 > 操作系统/网络 > 数据库/Linux”。
2. 高频考点逐个拆透:选择题拿满分的底层逻辑
2.1 Java/C++语言基础:最喜欢抠细节
语言基础选择题是整个卷子里最“阴”的部分,因为它考的往往是你平时写代码不会注意的细节。以Java为例,几乎每年必考的就是“equals和==的区别”。很多同学知道equals比较内容、==比较引用,但一旦换成String对象和字符串常量池的组合,就开始懵。
比如这道经典变形题:
String a = "abc"; String b = new String("abc"); String c = a + ""; System.out.println(a == b); // false System.out.println(a.equals(b)); // true System.out.println(a == c); // falseString d = "ab" + "c"; System.out.println(a == d); // true这里的关键是:使用双引号直接声明的字符串会进入常量池,new String一定会创建新对象,而“+”拼接如果是常量之间的编译期运算,编译器会直接优化成“abc”,如果是变量参与拼接,则是在运行期生成新对象。理解了字符串常量池,这类题就不会再错了。
Java集合类同样是高频区。HashMap的原理、扩容机制、为什么线程不安全,这几个问题基本是必考。HashMap在JDK 7和JDK 8中的数据结构变化要注意:JDK 7是数组加链表,头插法;JDK 8是数组加链表加红黑树,尾插法,链表长度超过8且数组长度大于等于64时会转红黑树。这个阈值和条件非常容易出成多选题,少选一个条件就丢分。
C++方向的同学则要重点看“数组和指针”相关题目。数组名在表达式里会退化成指向首元素的指针,但sizeof(数组名)不会退化,这是经典陷阱。比如:
int arr[5]; int *p = arr; cout << sizeof(arr) << endl; // 20,整个数组的大小 cout << sizeof(p) << endl; // 8(64位系统),指针的大小指针加减运算也经常考:p + 1不是地址加1,而是加一个int的大小,也就是4字节。二维数组的指针操作更是重灾区,比如int a[3][4]中a、a[0]、&a[0][0]的值可能相同,但类型和步长完全不同。建议复习时把这几个概念对比着记忆,不要只记答案。
2.2 操作系统与计算机网络:概念题的重灾区
操作系统选择题的难度一般,但覆盖面特别广。进程和线程的区别、死锁产生的四个必要条件(互斥、占有且等待、不可剥夺、循环等待)、进程间通信方式(管道、消息队列、共享内存、信号量、Socket)都是重点。死锁的题目喜欢让你判断“破除的是哪个条件”,比如一次性分配所有资源就是破坏“占有且等待”,允许抢占就是破坏“不可剥夺”。
页面置换算法也常考,LRU、FIFO、OPT。这里有个很多同学容易忽略的点:LRU是基于“过去”的访问情况来预测未来的,它需要记录访问顺序,一般用哈希表加双向链表实现,时间复杂度O(1);而FIFO只要一个队列就行,但可能出现Belady异常——分配页框数增多,缺页次数反而增加,LRU和OPT不会出现这种情况。
计算机网络的重点非常集中:TCP三次握手、四次挥手、TCP和UDP的区别、HTTP状态码、浏览器输入URL后的全过程。三次握手为什么是三次而不是两次,一定要能讲清楚:为了实现可靠传输,双方都需要确认自己的发送能力和对方的接收能力正常。两次握手只能保证一方确认了另一方的能力,无法让双方都确认,所以最少要三次。
TIME_WAIT这个知识点几乎是必考。主动关闭连接的一方会进入TIME_WAIT状态,持续2MSL。原因是:第一,保证最后一个ACK能被对方收到,如果丢失可以重传;第二,让本连接产生的所有报文段在网络中消失,避免影响新连接。面试笔试都喜欢考“为什么是2MSL”,而不是“TIME_WAIT是什么”。
HTTP部分重点记状态码:200正常、301永久重定向、302临时重定向、304未修改、400请求错误、401未认证、403禁止访问、404不存在、500服务器内部错误、502网关错误、503服务不可用。我见过一道题把301和302换成两个具体场景让你选,很多同学分不清“永久”和“临时”的区别,实际上301会把搜索引擎权重转移过去,302不会。
2.3 数据库与Linux:服务端岗位的加分项
选择题里数据库主要考索引、事务和SQL。索引为什么用B+树而不是红黑树或哈希表,这个问过无数次。核心答案:B+树非叶子节点只存索引,一个节点能存更多索引,树的高度更低;叶子节点用链表连接,适合范围查询;所有查询都要走到叶子节点,查询性能稳定。相比之下,哈希索引适合等值查询但不适合范围查询,红黑树树高比B+树高,磁盘IO次数更多。
事务的ACID四个特性要背熟:原子性、一致性、隔离性、持久性。更常考的是隔离级别和对应的问题:读未提交(脏读)、读已提交(不可重复读)、可重复读(幻读)、串行化。MySQL默认是可重复读,InnoDB引擎通过MVCC加间隙锁解决了大部分幻读问题,但只有串行化才能彻底避免幻读。
SQL题基本离不开GROUP BY、HAVING、子查询和联表查询。注意HAVING和WHERE的区别:WHERE在分组前过滤行,HAVING在分组后过滤组;WHERE不能使用聚合函数,HAVING可以。比如“查询平均成绩大于80分的班级”,必须用HAVING AVG(score) > 80,而不是WHERE AVG(score) > 80。
Linux常考命令建议分类整理:
- 文件操作:ls、cd、cp、mv、rm、find、tar
- 文本处理:grep、sed、awk、sort、uniq、wc
- 性能排查:top、ps、free、df、netstat、iostat、jstack、jmap
- 权限管理:chmod、chown、useradd
- 网络工具:ping、telnet、curl、traceroute
其中awk和sed是重灾区,很多人平时只用grep,笔试一考awk就懵。至少要知道awk -F指定分隔符、'{print $1}'打印第一列这种基本用法,服务端日常排查日志离不开它。
3. 编程题实战复盘:四道有代表性的题,从思路到代码
编程题是整个笔试的决胜局。我复盘当年那场考试和后续校招常见的同类题目,选了四道最有代表性的来完整推演一遍。每一道我都会按“读题 → 思路 → 实现 → 复杂度 → 优化方向”的顺序写,你按这个顺序练,考场就不慌。
3.1 手写线程安全的单例模式
单例模式是Java服务端笔试出现频率最高的一道题,因为它既能考设计模式,又能考多线程,一箭双雕。最简单的要求是“写一个线程安全的单例”,高级一点的会问“为什么用双重检查锁还要加volatile”。
推荐写法是静态内部类方式:
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }这种写法利用类加载机制保证线程安全,且实现了懒加载。如果你选择写双重检查锁DCL,那么一定要写成:
public 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,这是致命的。因为instance = new Singleton()不是原子操作,它分三步:分配内存、初始化对象、把引用指向内存。如果不加volatile,在并发情况下,另一个线程可能拿到一个“半初始化”的对象。volatile在这里就是禁止指令重排序,保证初始化完成前引用不会被暴露出去。
3.2 求大数据量下的Top K问题
这道题可以出成多种形式:给定一个整数数组求第K大的数,或者给定一个海量日志文件统计出现次数最多的K个IP。前者的经典解法是快速选择算法,平均时间复杂度O(n);后者的经典解法是哈希分片加小顶堆。
我先说面试笔试都更容易接受的堆解法。求最大的K个元素,用小顶堆;求最小的K个元素,用大顶堆。为什么求最大反而用最小堆?因为堆顶是堆中最小的元素,当新元素比堆顶大时,替换堆顶并调整,堆里始终维护着当前最大的K个数。
求数组第K大的标准写法:
public int findKthLargest(int[] nums, int k) { PriorityQueue<Integer> minHeap = new PriorityQueue<>(k); for (int num : nums) { if (minHeap.size() < k) { minHeap.offer(num); } else if (num > minHeap.peek()) { minHeap.poll(); minHeap.offer(num); } } return minHeap.peek(); }时间复杂度O(n log k),空间复杂度O(k)。这里注意一个笔试容易考的点:PriorityQueue默认是小顶堆,如果要求找最小的K个数,需要传入Comparator.reverseOrder()变成大顶堆。
如果是海量数据的场景,比如内存装不下所有IP,还需要再加一步哈希分片:把数据按IP的哈希值取模分到多个小文件中,分别统计每个小文件里IP的出现次数,再用堆汇总。这个思路本身就是服务端做MapReduce的雏形,答出来会加分不少。
3.3 多线程交替打印数字
多线程编程题是服务端笔试的特色题,因为它直接考察并发编程的基本功。最常见的场景是两个线程交替打印1到100,要求A线程打印奇数,B线程打印偶数。这道题考的就是线程通信。
用synchronized加wait/notify可以写,用Lock和Condition更好,因为Condition可以有多个等待队列,适合多线程协作。我给你一个经典的Lock实现:
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class PrintAlternate { private static int num = 1; private static ReentrantLock lock = new ReentrantLock(); private static Condition oddCond = lock.newCondition(); private static Condition evenCond = lock.newCondition(); public static void main(String[] args) { Thread t1 = new Thread(() -> { while (true) { lock.lock(); try { if (num > 100) break; if (num % 2 == 0) { oddCond.await(); } else { System.out.println(Thread.currentThread().getName() + ": " + num); num++; evenCond.signal(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } }, "奇数线程"); Thread t2 = new Thread(() -> { while (true) { lock.lock(); try { if (num > 100) break; if (num % 2 != 0) { evenCond.await(); } else { System.out.println(Thread.currentThread().getName() + ": " + num); num++; oddCond.signal(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } }, "偶数线程"); t1.start(); t2.start(); } }这个写法有几个容易踩的坑:第一,await和signal必须放在锁内调用,否则会抛IllegalMonitorStateException;第二,await之后线程会释放锁,被signal唤醒后会重新抢锁;第三,判断条件最好用while而不是if,避免虚假唤醒,这是Java官方文档明确建议的。
3.4 设计一个简单的接口限流器
有些套卷的编程题会更偏工程,比如“设计一个固定窗口限流器,限制每个用户每秒最多访问100次”。它不单独考某个算法,而是考你的工程抽象能力。
固定窗口限流实现最简单,思路是:每个用户维护一个窗口开始时间和窗口内请求计数。当请求到来时,如果当前时间距离窗口开始时间超过1秒,就重置窗口;否则判断计数是否超过阈值。用ConcurrentHashMap保存每个用户的计数器,保证线程安全。
public class FixedWindowRateLimiter { private final int maxCount; private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>(); private final long windowSizeMillis = 1000L; public FixedWindowRateLimiter(int maxCount) { this.maxCount = maxCount; } public boolean allow(String userId) { long now = System.currentTimeMillis(); Window window = windows.computeIfAbsent(userId, k -> new Window(now, 0)); synchronized (window) { if (now - window.startTime >= windowSizeMillis) { window.startTime = now; window.count = 0; } if (window.count < maxCount) { window.count++; return true; } return false; } } static class Window { long startTime; int count; Window(long startTime, int count) { this.startTime = startTime; this.count = count; } } }这个方案有一个典型问题:固定窗口在窗口临界点可能出现“两倍流量穿透”。比如第999ms和第1001ms各来100个请求,按窗口算都合法,但实际间隔只有2ms。如果能答出这个缺点,并进一步提出滑动窗口、令牌桶或漏桶算法来改进,这道题基本就满分了。校招笔试并不要求你真的实现一个生产级限流器,但要求你有系统设计的敏感度。
4. 现场答题的正确姿势:从踩坑到稳定输出
4.1 时间分配:别让选择题吃掉编程题的时间
笔试时间一般在90到120分钟,选择题40到50道,编程题2到4道。我发现很多同学挂在时间上:选择题每道都纠结,到编程题只剩15分钟,能写出个冒泡排序就不错了。合理的分配方式我建议这样:
| 题型 | 建议时长 | 策略 |
|---|---|---|
| 选择题 | 30 - 40分钟 | 会做的直接选,不会的先标记跳过 |
| 编程题 | 45 - 60分钟 | 先做最有把握的,再做分值高的 |
| 检查 | 10 - 15分钟 | 复查编译、边界、类名、输入输出 |
| 机动 | 5 - 10分钟 | 处理突发情况 |
选择题基本是平均每题1分钟的量级,遇到读一遍没思路的,果断跳过,不要恋战。等你把编程题写完,回头再看,往往能凭直觉选出正确答案。这是在保护最值钱的编程题时间。
4.2 读题审题:把题目翻译成数据结构
编程题丢分最冤的不是不会做,而是没读懂题意。我复盘过自己的笔试经历,很多错题是因为漏看了条件,比如“数组长度最大10^5”暗示你要用O(n log n)而不是O(n²),“输入可能有重复元素”暗示你要处理边界,“非空字符串”暗示你不需要判空。
读完题先别急着写代码,花1到2分钟在草稿纸上做三件事:第一,列出输入输出的数据类型和范围;第二,自己构造两个样例,一个是正常情况,一个是边界情况;第三,写下你想用的数据结构和算法,以及大致时间复杂度。这一步做好,写代码时思路完全不一样。
比如看到“找两个有序数组的中位数”,第一反应是合并后取中位数O(m+n),但如果题目要求O(log(m+n)),就要想到二分查找切分数组。这就是在审题阶段决定的事,而不是等代码写一半才发现超时。
4.3 从暴力解到最优解的演进策略
考场上最忌讳的是“一定要写出最优解”。我的建议是反向操作:先想清楚暴力解法怎么写,再逐步优化。因为暴力解能保证你有保底分,也能给你提供优化思路的基准。
以“两数之和”为例,暴力法是两层循环O(n²),但枚举到第二层时你会自然地想“为什么我一定要再遍历一次?能不能用哈希表把已经见过的数存起来?”优化就这样发生了。但如果你一上来就盯着最优解想,卡住20分钟,最后交白卷,才真的是血亏。
笔试系统一般按测试用例给分,通过部分用例也有分。所以暴力解提交上去,能过30%到50%的用例,已经是有效的分数。等暴力解通过后,如果还有时间,再优化也不迟。
4.4 写完代码后的五分钟检查清单
我把这五年的笔试经验浓缩成一份检查清单,每次交卷前按顺序过一遍,能减少大部分低级失误:
- 类名、方法签名是否和题目要求一致?OJ对这种硬性要求非常严格。
- 是否处理了空数组、数组长度为1、最大整数边界这些情况?整数溢出在反转整数、累加求和的题目里特别常见。
- 循环里有没有可能出现死循环或数组越界?重点检查while循环的边界条件。
- 递归写法有没有终止条件?递归深度是否可能超过栈上限?
- 有没有多余的系统输出?在线笔试一旦多了System.out.println调试信息,直接判错。
- 提交前自己跑一遍构造的用例,再跑一遍题目给的示例。
不要小看这个清单。我见过太多人,算法思路完全正确,因为类名写成Main而不是题目要求的Solution,整个题0分;也有人因为多打了一个调试输出,被判格式错误。这些分丢得毫无技术含量,检查一遍就能避免。
4.5 代码规范也是隐形分数
虽然OJ是机器判题,但很多公司会调出你的代码人工复审。尤其是金山办公这种对办公软件质量要求极高的公司,代码风格也是考核维度之一。
至少要做到变量命名有意义、关键逻辑写注释、缩进统一。比如不要写int a、int b,而是写int firstNum、int secondNum;不要在方法里堆一坨超过50行的逻辑,能拆函数就拆函数。这些习惯在笔试时看似无用,但在后面的面试环节聊代码时,会直接影响面试官对你的印象分。
5. 常见问题与避坑手册:这些都是亲手踩出来的
5.1 选择题的正确率总是上不去
很多人选择题复习有个误区——只看不练,知识点堆了一堆,一上考场就被选项绕进去了。选择题正确率低,多半是因为你对知识点的记忆是“懂”,而不是“准”。比如HashMap扩容因子是0.75,底层数组长度必须是2的幂,这两句话单独拎出来都懂,但放在四个选项中混合,你就容易选错。
我建议你建一个“易错点清单”,专门记录那些你原来不知道、或者容易混的知识点。格式很简单:左边是问题,右边是答案和一句话解释。每周过一遍。这个方法比重复刷题效率高很多。
5.2 本地能跑,OJ上就是过不了
笔试环境常见的几个坑:
本地编译器用的JDK版本和OJ不一致,比如本地用Java 11,OJ用Java 8,一些API会不兼容。这个难检查,但提交前可以注意不要使用过于新的语法特性。
输入输出格式问题,尤其是循环读多行输入。最稳的办法是用BufferedReader读整行,再用split拆分,而不是用Scanner一个个读。Scanner处理大量数据时既慢又容易出错。
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String line; while ((line = reader.readLine()) != null) { // 处理每一行 }C/C++同学还要注意数组申请大小。很多题目会给出数据范围,申请栈上数组时如果范围是10^6以上,建议用new或vector,避免递归调用时栈溢出。
5.3 简答题和设计题怎么拿分
简答题分值虽然不高,但往往是拉开差距的地方。答题的核心是“结构化”。比如问“进程和线程的区别”,不要把背的内容全部倒出来,而是分成几个维度:资源开销、通信方式、调度单位、系统崩溃影响,每个维度一两句话就够。阅卷人喜欢看到清晰的分点,而不是一篇小作文。
设计题如果不会做,也要尽量把思路写出来。例如“让你设计一个短网址服务”,即使你没做过,至少也要写出:短网址生成算法(哈希或自增ID加进制转换)、存储方案(数据库表设计)、重定向逻辑(301跳转)、缓存设计(热点数据放Redis)。能写出这四个模块,即使细节不完美,也会拿到大部分分。空着不写,才是真正的0分。
5.4 笔试通过后,面试还会追着什么问
提醒一句:笔试通过不是终点,而是面试的起点。面试官手里一定有你的笔试代码,他大概率会在面试时追着你问。问什么?会问你当时这道题为什么这么设计,有没有考虑更优解,如果数据量扩大十倍怎么办。
所以笔试结束后,我强烈建议你把做过的题目重新整理一遍,尤其是那些“写出了但不太确定”的题。提前想想如果面试官问“还能怎么优化”,你怎么回答。这道填空题,比继续刷新题更有价值。
6. 关于复习这套题的几点个人体会
最后说点偏个人的心得。我复习这些题目时最大的体会是:笔试本质上是一面镜子,它照出来的不是你的智商,而是你的准备程度和习惯。很多题,比如单例模式、Top K、交替打印,第一次见到觉得难,但只要你认真整理过一遍,下次遇到就是送分题。而真正拉开差距的,往往是那些“明明复习过但没复习透”的点。
我建议准备校招的同学不要只刷题,每做完一套卷子,花两倍的时间去复盘。把选择题里每一个不确定的选项都查明白,把编程题的暴力解和最优解都写一遍,把设计题往生产环境的方向多想一步。这个过程很枯燥,但相信我,它比多做三套新题都管用。
还有一个小技巧,笔试前可以自己模拟一次完整考试,严格计时90分钟,用和真实环境类似的方式答题。我第一次裸考笔试时,连续写了三道编程题后手指酸痛、脑子发木,后面题目完全不在状态。后来养成每周模拟一次的习惯,真正上场时节奏就完全不一样了。
这些题目本身并不神秘,神秘的是你对待它们的方式。认真拆完这套题,你对服务端开发岗位到底需要什么样的知识结构,心里会非常清楚。