提到用友的秋招Java笔试题(三),很多准备校招的同学第一反应是:一家做ERP和财务软件起家的公司,笔试应该更看重业务逻辑吧?真拿到卷子你会发现完全不是这样。基础语法、集合、并发、JVM、Spring、SQL、手写算法一个都没少,甚至不少题目就是面试“八股文”的笔试版。区别只是,面试可以边聊边引导,笔试完全看你在限定时间内能不能把答案写稳。这套题我后来反复给好几届学弟学妹讲,最适合用来检验Java基础是否扎实,尤其是想冲企业级应用开发岗位的同学,认真做一遍的价值比盲目刷几十道简单题大得多。
1. 这套题的整体画像:用友到底想考你什么
1.1 考点分布与题型结构
用友这套笔试题的题量不算大,以我看到的版本来说,一般分成四个部分:选择题、简答题、手写代码题和SQL题。考试时间大概120分钟,整体节奏偏紧。选择题覆盖Java基础、集合框架、并发、JVM和Spring,简答题则挑两三个核心知识点深入考,比如“HashMap在JDK8里做了什么优化”“Spring AOP的实现原理”;手写题一般是一道算法加一道设计模式,算法常见的是排序或者链表反转,设计模式大概率是单例;SQL题通常会给一个多表关联的业务场景,要求写查询同时解释为什么这样写。
| 答题模块 | 题量 | 考察重点 | 大致分值占比 |
|---|---|---|---|
| 选择题 | 15题左右 | Java基础、集合、并发、JVM、框架概念 | 约40% |
| 简答题 | 4题左右 | 原理理解,比如HashMap、Spring IOC/AOP | 约20% |
| 手写代码题 | 2题左右 | 排序、单例、生产者消费者等 | 约25% |
| SQL题 | 1-2题 | 多表关联、聚合统计、索引优化 | 约15% |
这个结构很值得玩味。选择题量大,是为了在有限时间里面广撒网,判断你知识面够不够;简答题看你是不是只背结论,能不能把“为什么”讲明白;手写代码题直接暴露代码功底;SQL题则是在模拟真实业务里天天要干的事。对于用友这种做ERP、财务软件和云服务的公司来说,这套组合拳非常合理。
1.2 为什么是这些考点?答案在用友的业务场景里
用友的核心产品线集中在企业级应用,这类系统有一个共同特点:数据结构复杂,事务密集,并发访问量虽然不像电商大促那么夸张,但胜在逻辑链路长、状态多。你想想一个财务模块,一张凭证要联动总账、明细账、辅助账,背后还有审批流、权限控制、操作日志。这种场景下,Java集合用得好不好、HashMap和ConcurrentHashMap选得对不对,直接关系到接口性能;JVM内存模型清不清楚,出了问题你连OOM日志都看不懂;Spring用得熟不熟,可能连Bean的初始化顺序都理不清。
所以这套卷子压根不是想为难你,它是在替你未来的同事提前确认一件事:你是不是真的理解Java,还是只会照着教程敲代码。如果只背概念不追底层,很容易在简答题和手写题上翻车。比如“synchronized和ReentrantLock的区别”这道题,几乎每个准备过面试的人都能背两句,但换成“在你负责的订单导出功能里,你会选哪个锁”,很多人就开始含糊了。
2. 高频基础题解析:这些分不能白丢
2.1 String与字符串池:一道题能扯出半个Java运行时
用友这套题里有一道非常经典的选择题:
String s1 = new String("abc"); String s2 = "abc"; String s3 = s1.intern(); System.out.println(s1 == s2); // 输出? System.out.println(s2 == s3); // 输出? System.out.println(s1.equals(s2)); // 输出?答案是false、true、true。很多人第一反应是“int都是这样比较的”,但字符串不一样。双引号直接创建的字符串会放进运行时常量池,new String("abc")则会在堆上额外创建一个对象。s1引用的是堆里的对象,s2引用的是常量池里的对象,所以用==比较肯定不相等。s1.intern()方法会去常量池找有没有字面量“abc”,有就直接返回常量池引用,因此s2和s3指向同一个对象。equals比较的是内容,当然相等。
这道题真正的考点不是这三个输出,而是你有没有理解字符串池的机制。面试时考官还可能追问:如果常量池里一开始没有“abc”呢?intern()会把当前字符串对象加入常量池并返回引用,具体行为还要看JDK版本。在JDK8及之后,常量池在堆里的“元空间”之前的位置,所以intern()调用后,如果常量池不存在,会把堆中字符串的引用复制到常量池(或者说记录引用),细节可以继续深挖。笔试题只要答对层引用关系就够了,但准备面试最好把带引号字符串对象创建了几个这个问题一起想清楚。
2.2 ==、equals与hashCode:面试里最容易被带跑的一组概念
试卷里还有一类题,给你一个自定义类,只重写了equals但没有重写hashCode,然后问放到HashSet里会出现什么现象。答案是:可能出现重复元素。这题考的不只是语法,而是Java对象约定。
默认情况下,Object的equals比较的是引用地址,hashCode返回的也是基于地址的哈希值。如果重写equals,让两个不同对象在业务上相等,但没重写hashCode,它们哈希值可能不同。HashSet添加元素时先根据hashCode定位桶,再和桶内元素equals比较;两个业务相等的对象因为哈希值不同进了不同桶,就都被放进去了。这样集合就出现了“逻辑重复”。
所以用友这类题背后的潜台词是:你在写业务代码的时候,有没有下意识遵守“equals相等则hashCode必须相等”这个约定。尤其是用实体对象做去重、做Map的key时,这个问题特别容易踩坑。我见过一个真实案例,工单系统里把两个字段相同的订单当成了不同key,导致统计报表数据翻倍,最后就是一查发现实体只重写了equals,hashCode没动。笔试考这个,真的不是无聊。
2.3 HashMap底层:从数组+链表到红黑树的演进逻辑
HashMap几乎是用友笔试选择题里的常青树。有一道选择题大概是:JDK8中HashMap在什么情况下链表会转成红黑树?答案是链表长度大于等于8且数组长度大于等于64。很多人只记住了8,忘了64这个前置条件。如果单链表长度到8但数组长度还小于64,会优先触发扩容,而不是转红黑树。
为什么是8?这背后其实有个泊松分布的计算,在负载因子0.75、随机哈希的假设下,链表长度达到8的概率已经非常低。用8作为阈值,是为了在时间和空间上取得平衡。红黑树本身也有维护成本,节点插入和删除要做旋转、变色,规模不够大的时候,链表反而更省。数组长度要求64也是同理,数组太小说明哈希碰撞太严重,应该先扩容分散,而不是直接上树。
再往下追,可能会问到put流程:先计算hash定位数组下标,如果数组位置为空直接放进去;不为空判断key是否相等,相等则覆盖;否则判断当前节点是不是树节点,是则走红黑树插入,不是则遍历链表。遍历过程中如果链表长度达到阈值,就转树。这个过程几乎每年必考。准备的时候可以自己画一遍流程,比死记结论效果好得多。
3. 并发与JVM:拉开分数差距的硬骨头
3.1 synchronized与ReentrantLock:不只考区别,更考选型思路
简答题里很常见的一道:synchronized和ReentrantLock有什么区别。背答案容易,把答案落到场景里难。两者的核心区别可以整理成一张表:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM层面关键字 | JDK API,基于AQS |
| 锁的获取与释放 | 自动 | 需要手动lock/unlock |
| 是否可中断 | 不可以,除非抛异常 | lockInterruptibly支持中断 |
| 是否公平 | 默认非公平 | 构造时可指定公平或非公平 |
| 条件变量 | 配合wait/notify | 支持多个Condition |
| 性能 | 优化后差别不大 | 灵活性更高 |
这里要记住一个关键点,不要觉得ReentrantLock一定比synchronized快。JDK6之后synchronized引入了偏向锁、轻量级锁、重量级锁的升级路径,大多数场景下性能已经够用。选ReentrantLock的理由更多是功能:比如需要超时获取锁、需要公平锁、需要多个等待队列。笔试题里如果问“用哪个”,答案应该是“看需求,没有绝对好坏”。这种开放回答反而能让阅卷人觉得你有实际工程经验。
另外,ReentrantLock的实现基础是AQS(AbstractQueuedSynchronizer),面试官很可能顺着问AQS里的state变量、CLH队列、独占和共享模式。笔试简答一般不用写那么深,但如果你能在答案里自然提到“基于AQS,内部维护了volatile的state和等待队列”,会在基础上加分。
3.2 JVM内存区域与OutOfMemoryError:从笔试题到线上故障
JVM相关的题在用友这套卷子里基本不会缺席。选择题问你:Java运行时数据区包括哪些?答案是程序计数器、虚拟机栈、本地方法栈、堆、方法区(JDK8之后是元空间)。注意方法区和元空间的关系:JDK8之前叫永久代,JDK8之后移到本地内存并改名元空间,字符串常量池也移到了堆里。如果同学现在还回答“永久代在堆里”,基本就暴露年龄了。
更进阶的题目会直接给一段OOM日志,比如:
java.lang.OutOfMemoryError: Java heap space这表示堆内存不够。可能是对象太多,可能是内存泄漏。文章标题热搜里还有一个“insufficient memory”,本质上也是内存分配失败的一种表现。排查思路一般是:先通过jstat或jmap看堆使用情况,再导出堆转储文件,用MAT或者VisualVM分析是否有大对象、是否有对象无法回收。线上CPU飙升时还会配合jstack看线程栈,确认是不是有死循环在疯狂创建对象。
笔试题里还可能问栈溢出和堆溢出的区别。栈溢出常见于无限递归,报StackOverflowError;堆溢出常见于持续创建对象又没法回收,报OutOfMemoryError。这两个概念看起来简单,但很多人会把“方法区OOM”和“堆OOM”混在一起。方法区/元空间OOM通常是因为运行时生成了大量类,比如反射、动态代理使用不当;堆OOM是对象实例太多。分清“类”和“对象”两个层面,这一块的题目基本都能拿稳。
3.3 类加载机制:双亲委派到底在保护什么
有一道题我当时印象深刻:什么是双亲委派模型?为什么需要它?很多人只记得“先让父加载器加载,加载不到自己再加载”,但说不清意义。双亲委派的核心是避免Java类被重复加载,更关键的是保证核心类的安全性。
举个例子,如果我们自己写一个java.lang.String类,再通过自定义类加载器加载,没有双亲委派的话,系统里就可能出现两个不同版本的String,一个来自JDK,一个来自你的工程。这种情况下,类与类之间的兼容性就崩了。有了双亲委派,加载java.lang.String时最终会交给启动类加载器,加载到的始终是JDK自带的String,你写的那个String永远不会被应用类加载器加载。这就是为什么网上很多“自定义java.lang.String”的例子会报SecurityException,因为加载入口就做了限制。
笔试如果出扩展题,可能会问“怎么打破双亲委派”。常见的答案有Tomcat的WebAppClassLoader,它为了隔离不同应用的依赖,会先加载自己目录下的类,再委派给父加载器,也就是“父委托优先”变成了“自身优先”。还有SPI机制,通过线程上下文类加载器让父加载器去加载子加载器路径下的实现类。这些概念不需要全部展开,但至少要知道双亲委派不是强制规定,而是默认实现。
4. 手写代码与分析题:从会背答案到会写代码
4.1 手写单例模式:五道防线,一个都不能少
用友这套笔试题的手写代码题里,单例模式是出镜率很高的一个。很多同学觉得简单,随手就写:
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这种写法在单线程下没问题,但一到多线程环境就会创建多个实例。于是有人改成在方法上加synchronized,这样线程安全了,但每次调用都要加锁,性能差。笔试题真正想看的,多半是双重检查锁(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禁止了指令重排,才能保证安全。第二个是两次if判断。第一次判断是为了避免无意义的加锁,第二次判断是为了防止多个线程同时通过第一次判断后重复创建对象。少了任何一次,锁的意义都会打折。
当然,单例还有静态内部类和枚举两种更优雅的写法。枚举写法在《Effective Java》里被推荐过,因为它既能保证线程安全,又能防止反射和序列化破坏。笔试如果时间充裕,可以把枚举写法写出来,顺便解释一句“它天然防止反射攻击”,能看出你真的读过经典书。
4.2 快速排序:20分钟手写,边界条件才是分水岭
算法手写题里,排序是永恒的主角。用友这套题选择排序的概率很高,尤其是快速排序和冒泡排序。冒泡排序简单,但考官往往不会满足于此,更愿意让你手写快排,因为你写完之后他还能追问“时间复杂度最坏是什么?”、“怎么避免最坏情况?”。
一个标准且不容易出错的快排实现可以这样写:
public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } private static int partition(int[] arr, int left, int right) { int pivot = arr[right]; // 取右端为基准 int i = left; for (int j = left; j < right; j++) { if (arr[j] < pivot) { swap(arr, i++, j); } } swap(arr, i, right); return i; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }这段代码的逻辑是:取数组最右边的值作为基准,用一个i指针记录“比基准小的区域”的边界,遍历j,发现arr[j]小于基准时就交换到前面,最后把基准放到i的位置。这样一次partition之后,基准左边的元素都小于它,右边的都大于它,然后递归两边继续排。
写的时候最容易出问题的点有三个:递归终止条件没写或写错,导致栈溢出;partition里循环边界没控制好,漏掉最后一个元素;数组只有一个元素时,left和right相等,如果不提前返回,会出现无限递归。以前我见过有人把if (left >= right)写成if (left > right),单元素数组时也能过,但双元素数组就可能出错。笔试环境没编译器,这种边界细节只能靠平时练成本能。
4.3 一道SQL题:索引失效场景你踩过几个
SQL题在ERP类公司的笔试题里几乎必考。用友这道题我记得是给了一张订单表和一张用户表,让你统计每个地区订单金额前3名的用户。这类题核心难点是多表关联、分组排序和窗口函数的使用。窗口函数在MySQL 8.0之后很常用,写法是:
SELECT region, user_name, order_amount FROM ( SELECT u.region, u.user_name, SUM(o.amount) AS order_amount, ROW_NUMBER() OVER (PARTITION BY u.region ORDER BY SUM(o.amount) DESC) AS rn FROM orders o JOIN users u ON o.user_id = u.id GROUP BY u.region, u.user_name ) t WHERE rn <= 3;但光会写查询还不够,笔试还喜欢在SQL题里夹带一个索引优化的问题。比如让你判断WHERE salary * 1.1 > 5000这个条件能否用到salary字段的索引。答案是大概率用不上,因为对索引列做运算会让优化器放弃索引;同理,WHERE name LIKE '%张'因为前导通配符,也基本用不上索引。还有一类非常隐蔽的坑,字段是varchar类型,查询条件写成数字时,MySQL会做隐式类型转换,索引也会失效。这些场景如果没踩过,笔试很容易想当然。
5. 踩坑实录与备考建议
5.1 在线笔试环境里最容易翻车的几个问题
用友和其他大厂的校招笔试一样,走在线OJ系统。很多同学不是不会做题,是输在环境上,特别可惜。一个高频问题是代码里写了package语句,OJ要求主类放在默认包下,写了package直接编译失败。另一个高频问题是类名不是Main,OJ的判题程序会按照Main类去找入口,类名写错整个提交直接判零。
还有一个非常现实的问题:本机JDK版本和在线环境JDK版本不一致。比如本机是JDK17,在线环境是JDK8,你写了个var或者用List.of,编译就报错。热搜里那条“源发行版 17 需要目标发行版 17”其实也是类似问题,编译目标和运行环境不匹配。笔试前最好确认自己IDE的编译级别是哪个版本,写代码尽量只用JDK8的语法,稳一点。
还有一个冷门但确实存在的情况:Lombok在编译期报错,提示“You aren't using a compiler supported by lombok”。如果你是线下写代码,可能是IDE的注解处理器没配好;如果是在线OJ,基本可以确定不支持Lombok。所以笔试手写代码题,不要依赖Lombok的@Data、@Slf4j,能手写getter/setter就手写,别拿自己的分数赌环境。
5.2 时间分配与答题顺序:先拿稳再攻坚
120分钟听着长,真写起来很紧。我的建议是:先快速扫一遍所有题目,把一眼就有答案的选择题直接做了,别在纠结题上死磕。选择题平均每题最多1分钟,超过2分钟还没有思路,先标记跳过。简答题控制在20到25分钟,每道题别写成长文,按点答清楚就好。手写代码题至少留40分钟,因为写完还要检查边界条件。SQL题留15分钟,读题要仔细,别漏掉排序条件或分组字段。
顺序上,推荐先做SQL题。为什么?因为SQL题信息量大,越到后面脑子越乱越容易看错表字段;而且SQL题分数占比不低,第一感觉往往最准。然后再做代码题,代码题写完之后心里会踏实很多,后面做选择题的心态完全不一样。最后再回头补前面跳过的题目,那时候即使不会,也能靠排除法蒙一个,不至于空白。
5.3 从这套题延伸出的Java学习路线(个人版)
很多同学让我推荐Java学习路线,我的个人经验是:不要一上来就抱着Spring Boot写项目,先把语言地基打牢。第一阶段吃透Java基础语法、面向对象思想、常用集合、IO和异常。第二阶段啃并发和JVM,这个阶段推荐结合源码读,《Java并发编程的艺术》和《深入理解Java虚拟机》是最好的两本参考书。第三阶段再学框架,Spring的IOC和AOP一定要从设计原理去理解,而不是只写注解。第四阶段学数据库和SQL优化,这是去用友这类toB公司绕不开的技能。
刷题方面,像用友这套卷子里的知识点,基本覆盖了Java面试高频八股文的半壁江山。但我想提醒的是:刷题刷到最后,一定要回到源码。String、ArrayList、HashMap、ConcurrentHashMap这些类,都值得打开IDE看一遍源码。你看过源码之后,笔试题里那些“为什么”根本不用背。
另外,如果还有余力,可以做一个小型的用户管理系统或者订单系统,不用花哨,但要把用户登录、权限校验、订单列表、详情查询这些基础功能实现一遍。这样做的好处是,能把Spring、MyBatis、MySQL、Redis串起来。面试时如果被问到“你项目里怎么处理并发”“怎么优化一个慢查询”,你至少有真实素材可以讲,而不是背标准答案。
这套用友2018秋招Java笔试题(三),我后来给很多人讲过,每次最后都会说一句:笔试不是看你背了多少答案,而是看你在接触一个知识点的时候,有没有想过它为什么这么设计。DCL为什么要加volatile,HashMap为什么是链表长度8才转红黑树,类加载器为什么默认双亲委派。这些想透了,你拿下的就不只是用友一张卷子,而是所有Java笔试的底层逻辑。