1. 先看清这份题的底牌
老规矩,先说结论:用友2017秋招笔试题(四),是一套典型的国内大厂校招笔试题,考察方向集中在Java基础、数据结构与算法、数据库设计和基础逻辑能力,难度中等偏上,但远没有到ACM竞赛那种劝退级别。对当时投递用友的应届生来说,这套题更像是一块试金石——不是看你会不会写多炫的代码,而是看你有没有扎实的工程基础,能不能理解一个企业级ERP厂商日常开发中真正用得上的技术点。
为什么特意说“用友”这个背景?因为用友做的是To B的软件,服务的是企业的财务、人力、供应链、制造这些核心业务环节。这类公司招人的时候,笔试题目通常带有明显的“务实”倾向:不考那些花里胡哨的奇技淫巧,而是紧紧围绕Java生态、数据库、业务建模这些企业开发最常见的技术栈来出题。2017年又处于互联网和传统软件行业竞争最激烈、企业数字化转型刚刚全面提速的时间节点,所以这套题里能看到不少针对并发、集合、SQL优化这些实际开发痛点的考察。
我记得当时有不少同学在牛客网上分享这套题,大部分人的感受是:选择题不难,但陷阱多;编程题看着眼熟,但想在限定时间内写出健壮、考虑完善的代码,其实并不容易;最后的数据库设计题,则是直接检验你有没有做过后端业务系统的经验。说白了,这套题刷的不是手速,而是内功。如果你能把这套题的每一个考点都吃透,那对你的校招备战来说,收益远远超出用友这一家公司的范围。
下面我就把这份笔试题(四)按题型拆开,结合当年的题目内容和我自己后续工作中的复盘,把核心考点、解题思路、容易丢分的细节一点点捋清楚。
2. 选择题里的高频考点:基础不牢,地动山摇
2.1 String、StringBuilder、字符串池的相爱相杀
这套题的选择题部分,几乎每年都会有一道跟String相关的题。2017这套(四)里出现的是这样的考点组合:给你一段代码,让你判断创建了几个对象、内存中发生了什么、最终输出结果是什么。这类题考察的核心有两个:字符串常量池和字符串不可变性。
举个例子,题目里很可能出现这种代码:
String a = "hello"; String b = new String("hello"); String c = b.intern(); System.out.println(a == b); // false System.out.println(a == c); // true System.out.println(b == c); // false这段代码的考点非常密集。第一行String a = "hello",JVM会先去字符串常量池里找有没有字面量"hello",如果没有就创建一个,并把引用交给a。第二行new String("hello"),对象本身是在堆上创建的,但注意:这个构造方法的入参"hello"依然会先查常量池,所以此时常量池里已经有了"hello"这个对象。于是b指向堆上那个新对象,而a指向常量池里的对象,a和b的引用当然不相等。
intern()方法的语义是从常量池中返回当前字符串的引用,如果常量池里已经有内容相同的字符串,就直接返回那个引用;否则先加入常量池再返回。所以c和a指向同一个常量池对象,c和b自然不相等。
这类题丢分的原因往往不是不懂原理,而是被题目里那些迷惑性的输出项带偏。我建议刷这类题的时候,脑子里一定要带着一张内存分布图:栈、堆、方法区(常量池挂在方法区,JDK 7以后挪到了堆里,但逻辑上依然是常量池)各放什么东西。把引用和对象的边界理清了,String的题基本就是送分题。
但这里有一个进阶考点,2017这套题选了一个变种:final修饰的String拼接。比如:
String a = "hello"; final String b = "he"; final String c = "llo"; String d = "hello"; String e = b + c; System.out.println(d == e); // true当两个字符串都是final常量且值是编译期常量时,b + c在编译阶段就会直接优化成"hello",所以e和d指向常量池中的同一个对象。但如果把final去掉,那b + c就会在运行期通过StringBuilder拼接,生成新的String对象,结果就是false。这种细节如果没实测过,很容易在考场上想当然。
2.2 HashMap:JDK 7和JDK 8的内存模型差异
用友这种做企业软件的公司,对HashMap的考察频率非常高,因为ERP系统里做缓存、做批量数据聚合、做权限映射,到处都在用Map。2017这套题里,我记得印象比较深的是一个关于HashMap扩容的判断题:问HashMap在什么情况下会出现死循环,以及JDK 8解决了这个问题没有。
HashMap的死循环问题,根因在于JDK 7及以前,扩容时采用的是“头插法”迁移链表节点。假设线程A和线程B同时对HashMap做put操作,都触发了扩容,旧数组里的链表元素在迁移过程中可能形成一个环。这样下一次查询的时候,如果恰好命中这个环上的桶,就会在链表上无限循环,最终导致CPU 100%。JDK 8优化了这个过程,改用“尾插法”,也就是迁移时保持链表的原始顺序,并且引入了红黑树去优化长链表的查询效率,所以JDK 8虽然不会因为扩容产生环,但依然不是线程安全的,并发写时依然存在丢数据、覆盖值的问题。
这道题给校招生的警示是:不要把HashMap背得滚瓜烂熟,却不知道它在多线程环境下该用谁替代。正确的替代方案是ConcurrentHashMap,但2017年的考纲里对ConcurrentHashMap的段锁机制问得不多,更多是停留在“是否线程安全”这个层面。如果你在笔试备注里写上“JDK 8 HashMap在并发场景依然不能使用,应该用ConcurrentHashMap或线程安全的集合工具类”,那这题就稳了,而且会显得你有实战意识。
2.3 SQL优化与索引失效的经典场景
选择题还有一道让我印象很深的SQL题,考的是索引失效的条件。2017年的题干大致是:有一张员工表,字段包括emp_id、emp_name、hire_date、department_id,其中hire_date建有普通索引,问下面几个查询里哪个用到了索引。
四个选项大概是:
A. SELECT * FROM emp WHERE hire_date + 1 = '2024-01-01'; B. SELECT * FROM emp WHERE hire_date = '2024-01-01' AND department_id = 10; C. SELECT * FROM emp WHERE hire_date = '2024-01-01' OR department_id = 10; D. SELECT * FROM emp WHERE hire_date LIKE '%2024%';这个题的核心就是索引失效场景的识别。A选项对索引列做了运算,索引失效;C选项因为OR条件中有一个列没有索引,会导致整个查询退化为全表扫描;D选项的前导模糊匹配也走不了索引。只有B选项能正常用到hire_date的索引。
别小看这道题,我在实际开发中遇到不少人写SQL时会在索引列上做函数运算,比如WHERE DATE(create_time) = CURDATE(),这等于让数据库放弃索引。正确的写法应该是范围条件:WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。企业级报表系统里,一张千万级的订单表如果因这种SQL语句走全表扫描,慢查询日志分分钟刷屏。这套题的出题人很懂实际业务痛点,所以在这一题上设置了两个“看起来对”的干扰项,专门坑那些只知道背“索引能加速”却不知道底层原理的人。
3. 编程题实操:手写代码和评委眼中的得分点
3.1 合并两个有序数组:不要只满足于能跑
编程题第一题,很多版本的2017用友秋招题里都有,这套(四)也不例外:给定两个升序排列的int数组,要求合并成一个升序数组并返回。看起来就是归并排序的合并步骤,几乎是送分题,但当年这道题卡住了不少人,原因是题目里有一句话——尽量不使用额外空间。
如果不限制空间,最常见的写法是新建一个数组,用双指针逐个比较填充,时间复杂度O(m+n),空间复杂度O(m+n)。但题目既然这么问,考察意图就很明显:你能不能原地合并。
原地合并的经典做法是把两个数组合并到第一个足够长的数组里,从后往前填充。比如:
public void merge(int[] nums1, int m, int[] nums2, int n) { int i = m - 1; int j = n - 1; int k = m + n - 1; while (j >= 0) { if (i >= 0 && nums1[i] > nums2[j]) { nums1[k--] = nums1[i--]; } else { nums1[k--] = nums2[j--]; } } }为什么要从后往前?因为nums1开辟的空间在尾部,从前往后覆盖的话,会覆盖掉nums1尚未比较的元素,而nums2的元素必须在比较完之后才能落位,所以从后往前可以安全地利用数组尾部的空闲区。这个思路和直插排序的移位逻辑有点类似,核心是“把空间留到最后用”。
我当时刷这道题的时候,第一个版本也写成了创建新数组,后来检查题目要求才发现空间限制。这个教训我一直记着:笔试里读题比写代码更重要。题目里每一个附加条件都不是白写的,都是为了引导你往特定方向思考。
这道题还有一个容易被忽略的边界:两个数组都为空时怎么办,其中一方为空时怎么办。我建议在校招冲刺阶段,每做完一道算法题,都养成“边界条件自测”的习惯。像这道题,你至少要在心里跑一遍:nums1为空、nums2为空、所有nums1元素都比nums2大、所有nums2元素都比nums1大,这四种情况代码是否都能正确处理。企业里的代码Review非常关注这些边界,因为生产环境的数据永远不会像LeetCode示例那么友好。
3.2 括号匹配:栈结构的教科书应用
第二道编程题是判断一个字符串中的括号是否正确匹配。括号有三种:小括号、中括号、大括号。这是一道典型的栈应用问题,算法思路不复杂:遇到左括号就入栈,遇到右括号就判断它是否与栈顶的左括号匹配,匹配则弹栈,不匹配直接返回false;遍历结束后,如果栈为空,说明括号全部匹配,否则说明有多余的左括号。
面试官真正期待的高质量代码,不仅是算法正确,还包括代码结构清晰。下面是我自己比较满意的一版:
public boolean isValid(String s) { if (s == null || s.length() == 0) { return true; } if (s.length() % 2 == 1) { return false; } Map<Character, Character> map = new HashMap<>(); map.put(')', '('); map.put(']', '['); map.put('}', '{'); Stack<Character> stack = new Stack<>(); for (char c : s.toCharArray()) { if (map.containsKey(c)) { if (stack.isEmpty() || stack.peek() != map.get(c)) { return false; } stack.pop(); } else { stack.push(c); } } return stack.isEmpty(); }注意里面两个细节:一是长度不为偶数直接返回false,这叫“提前短路”,提交的时候能省不少执行时间;二是用Map来映射左右括号,代码可读性比一堆switch-case强得多。如果写得顺手,还可以进一步把Stack换成ArrayDeque,因为JavaStack类的性能并不是最优的,ArrayDeque在单线程环境下更轻量。
这道题表面上考的是算法,实际上考的是“工具选型”意识。用友的笔试虽然没有指定只能用JCF自带的栈,但你在代码中选用什么容器、怎么组织逻辑,都会被阅卷的人看在眼里。企业里面写代码,从来不是“能跑就行”,而是“好读、好改、好测”。
3.3 单例模式:多线程下的双重检查锁
2017这套题里还有一道编程/简答题,实现一个单例模式,并且要考虑多线程情况。这题在学校里几乎人手都会背,但一旦限定“多线程安全”并且“性能不能太差”,很多人就露馅了。
最基础的方式是synchronized修饰整个getInstance方法,代码短、线程安全,但每次获取实例都要经历锁竞争,高并发下性能堪忧。更常见也容易被面试官追问的是双重检查锁,也就是常说的DCL(Double Checked Lock):
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; } }DCL的核心在于两个判断:第一次判断是为了避免无谓地进入同步代码块,第二次判断是为了保证多线程同时通过第一层检查后,只有一个线程能创建实例。这里必须加上volatile,否则第二个线程可能在使用instance时,看到的是一个“构造尚未完成”的半初始化对象。原因是instance = new Singleton()这行代码在底层分为三步:分配内存、调用构造器、把引用指向内存。如果不加volatile,JVM的指令重排序可能导致第3步先于第2步执行,另一个线程恰好判断instance != null,拿到的就是一个还没构造完成的对象。
这个考点被无数面试官津津乐道,不是因为它难,而是因为它能完美检验一个人对并发模型、内存可见性、指令重排序的理解深度。对校招生来说,能写出DCL并讲清volatile的作用,已经能拿到很高的技术印象分。
再补充一个选择题里容易出现的“单例相关”坑:利用反射可以强行调用私有构造器,或者用序列化+反序列化破坏单例。用友这套题的原题没往下延伸,但面试环节经常会问,我建议你在准备笔试时就把这些扩展点一起看了,因为笔试中你写在备注里的思考,往往就是面试官追问的线索。
4. 数据库设计题:用友最看重的业务建模能力
4.1 订单表设计:从需求到建表的完整推导
这套题(四)的大题部分,给出的是一个简化版的“企业订单系统”场景,要求设计订单表和订单明细表,并回答几个业务查询相关的SQL问题。之前说过,用友的核心赛道是ERP,而订单/采购/库存这些都是ERP里最经典的数据模型,这道题属于典型的业务建模考察。
我看到的题目描述大概是这样的:一个客户可以下多个订单,一个订单包含多个商品,每个商品在订单中有购买数量和成交单价。要求设计表结构,并在此基础上完成几个查询。这类题没有标准答案,但优秀答案都有一些共通的建模原则。
首先,订单主表和订单明细表必须分离。为什么?从范式角度看,如果一张表里同时放订单信息和商品信息,第一条订单如果有5个商品,那订单信息就要重复存储5遍,不仅冗余大,而且容易产生数据不一致。更重要的是,企业订单的“订单头”和“订单行”天然就不是一对一的关系——订单头记录的是客户、下单时间、总金额、订单状态这类一次性信息,订单行记录的是每一个商品的具体金额、折扣、发货状态。两者生命周期虽然相关,但变更频率和查询维度完全不同。
我的表结构设计参考如下:
CREATE TABLE t_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID', customer_id BIGINT NOT NULL COMMENT '客户ID', order_no VARCHAR(32) NOT NULL COMMENT '订单编号', order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付,1已支付,2已发货,3已完成,4已取消', total_amount DECIMAL(12,2) NOT NULL COMMENT '订单总金额', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_order_no (order_no), KEY idx_customer_time (customer_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_order_item ( item_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', product_id BIGINT NOT NULL COMMENT '商品ID', product_name VARCHAR(128) NOT NULL COMMENT '商品快照名称', price DECIMAL(12,2) NOT NULL COMMENT '成交单价', quantity INT NOT NULL COMMENT '购买数量', subtotal DECIMAL(12,2) NOT NULL COMMENT '小计金额', KEY idx_order_id (order_id), CONSTRAINT fk_order_item_order FOREIGN KEY (order_id) REFERENCES t_order(order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个细节是阅卷人特别容易给分的点:
- 金额字段一律用
DECIMAL,绝对不要用FLOAT或DOUBLE。这个知识点在选择题里可能也出现了,但放到设计题里更能体现工程经验。因为浮点数在计算机里是近似存储,计算金额会产生不可控的精度误差,财务系统对一分钱都不能错。 - 订单号加了唯一索引。订单ID是自增主键,但对外暴露的订单编号通常有业务含义,比如时间戳+序列号,必须唯一约束。
product_name字段做了快照冗余。为什么不直接关联商品表?因为商品名称、价格可能会变,而订单里记录的应该是下单那一刻的商品信息。企业级订单系统里这叫“数据快照”,既能保证历史订单可追溯,又避免每次查询都去关联商品表,性能更优。- 在
(customer_id, create_time)上建了联合索引。因为“查某个客户的历史订单”是最常见的查询场景,联合索引能一次性覆盖两个过滤字段,避免回表和文件排序。
4.2 事务与并发:写SQL时脑子里要有锁
设计题的最后一部分,通常是写几条SQL,其中有一条是查出“下单金额最高的前3位客户”。这个需求看起来简单,写出来的SQL却大有讲究:
SELECT customer_id, SUM(total_amount) AS total FROM t_order WHERE order_status IN (1, 2, 3) GROUP BY customer_id ORDER BY total DESC LIMIT 3;这道题考察的是聚合函数、分组、排序、取前N条的综合运用。但如果你停在“能写出来”这个层面,还不够。我当时在解题过程里额外标注了一条:SUM(total_amount)时,只统计有效状态的订单,因为待支付或已取消的订单不应该计入真实成交金额。这个细节想清楚之后,整道题的分数档次就拉开了。
再到更深一层,事务隔离级别和锁的问题也值得展开。一个ERP系统里,订单创建往往和库存扣减强相关。在并发情况下,两个用户同时买最后一个库存商品,数据库层面怎么保证不超卖?这就要用到行锁或乐观锁。2017年的笔试虽然没直接问锁,但我在面试环节被追问过“Inventory(库存)-8这个操作在并发场景下怎么保证安全”。用乐观锁的典型方案是在库存表上加一个version字段:
UPDATE t_inventory SET quantity = quantity - 8, version = version + 1 WHERE product_id = 1001 AND version = 2;如果更新影响的行数为0,说明版本已变化,需要重新读取库存再试。这种解法现在成了后端基础题标配,但在2017年的校招里,能主动往这个方向回答的人不多,说出来真的很加分。
5. 这套题踩过的坑:真实考生的血泪教训
5.1 时间分配:选择不要太恋战
用友2017这套笔试题的总时长大约90分钟,选择题+编程题+设计题。很多考生栽在时间分配上,尤其是在前面几个偏理论的选择题上反复纠结。比如String那题,明明已经把“引用相等”和“内容相等”区分开了,却因为题目里多了一个concat()方法的结果而陷入自我怀疑,一纠结就是七八分钟。
我个人的建议是:选择题每道题控制在2分钟以内,超过直接先蒙一个并标记,回头有时间再细算。笔试题量大,编程题和设计题的采分点更密集,前面恋战的时间成本太高。你得清楚自己的目标是总分最大化,而不是每一题都完美。
5.2 代码题最怕“能跑但不完整”
我身边当年一起刷题的人里,有不止一个在合并有序数组那道题上,写出了标准新建数组解法,但完全没考虑题目“原地合并”的要求。这种情况,哪怕功能完全正确,拿到的分也要打折扣。笔试阅卷不是机器判题那么简单,重点看你的思路和代码习惯。
所以,每次写完代码,务必自查三件事:边界条件处理没有、空间复杂度是否满足题目限制、异常输入是否会抛错。把这三件事养成肌肉记忆,你会发现在LeetCode上刷题的正确率都能提升不少。
5.3 数据库题别只写表结构,要写设计说明
很多同学在做数据库设计题的时候,只把建表语句一贴就完事了。这是非常大的失分点。用友这种企业的技术官看重的不是你能不能敲出SQL,而是你懂不懂为什么这么设计。我当时在每张表的关键字段后面都加了COMMENT,并且单独列了一段文字说明,解释为什么用DECIMAL、为什么加冗余快照字段、为什么建联合索引。加起来也就一百来字,但给阅卷人的信号是:这个人不仅会写代码,还知道每行代码背后的成本和收益。
笔试不是写论文,但适度的“解释性注释”绝对是加分项。企业开发里,代码Review同样推崇“注释解释为什么,而不是解释是什么”。
5.4 会用API,但要懂底层的“为什么”
2017年的Java生态,Stream还不像现在这样普及,但Lambda表达式和Java 8新特性已经开始大规模进入校园招聘考题。这套题的选择题里虽然没有直接考Stream,但我在用Stream重写合并有序数组这类题目时,自己探索了一版:
int[] merged = IntStream.concat(Arrays.stream(nums1), Arrays.stream(nums2)) .sorted() .toArray();这个写法很简洁,但笔试慎用。原因很简单:出题人想考察的是双指针算法的思维过程,你用内置排序虽然结果对,却把你最想展示的思维能力藏起来了。这也算是一个微观体现——在笔试场景里,尽量用“能暴露你思考过程”的方式答题,而不是用“最简洁的API”答题。
6. 刷题之后的延伸:这套题如何帮你面对后续面试
把一套笔试题吃透的价值,绝不只在于笔试本身。用友这套题覆盖的考点——String内存模型、HashMap并发问题、SQL索引优化、单例模式、业务表设计——几乎就是后端校招面试中Java基础+数据库+项目经验三条主线的最小公约数。按照这套题去梳理自己的知识体系,比漫无目的地刷一百道LeetCode更高效。
我建议你把每道错题都整理成一张“知识点-具体问题-我的理解-面试追问方向”的表格,比如:
| 题目考点 | 核心问题 | 理解深度 | 可能追问方向 |
|---|---|---|---|
| String不可变性 | new String("a")创建几个对象? | 必须能画内存图 | intern()机制细节 |
| HashMap并发 | JDK 7死循环根因 | 理解头插法/尾插法差异 | ConcurrentHashMap分段锁原理 |
| SQL索引失效 | 函数运算导致索引失效 | 能举出三个具体场景 | 联合索引最左前缀原则 |
| 双重检查锁 | 为什么必须volatile | 能说出指令重排序 | 静态内部类单例优缺点 |
| 订单表设计 | 主表和明细表分离 | 能解释范式与快照冗余 | 分库分表后如何保持一致性 |
每次面试前只看这张表,过一遍自己的掌握程度,哪里卡壳就回到完整笔记里补。这套方法我从校招一直用到了工作后的跳槽准备,稳定好用。
用友2017秋招笔试题(四)这份卷子,放到今天来看,很多考点依然不过时。数据结构与算法是基本功,SQL优化和业务建模是企业开发的日常,并发和内存模型则决定了你能不能在真实的分布式场景里写出安全可靠的代码。如果你现在正处于校招期,我强烈建议你认真对待这套题里的每一处细节,不要只满足于“做对了”,而是要求自己“讲得清”。
今天先聊到这里,这套题的完整代码和扩展题解我都整理在本地笔记里了,后面找个时间再和大家说说如何把这些知识点串联成一套自己的面试话术。如果你也在刷这套题,欢迎在评论区聊聊你的思路和困惑。