又到秋招季,后台好几个学弟学妹问我:“用友的Java笔试题到底怎么刷?网上能找到的真题太零散了。”这话我深有体会。我当年参加用友2018秋招笔试(二)的时候,也吃过信息零散的亏。那套卷子整体风格其实很有代表性:题量不大,但知识点覆盖面很广,很多题看着像是考基础,实际每一道都在挖坑。这篇文章我就把自己做这套题的过程、踩过的坑、复盘后的方法论全部整理出来,给正在准备Java校招的同学一个可以直接参考的刷题方向。
这套笔试适合谁看?主要面向准备Java后端开发岗校招的同学,尤其是目标是用友或者其他传统软件厂商研发岗的。它考的不是刁钻的算法,而是你Java基础扎不扎实、能不能看懂生产环境下的代码。如果你正在刷Java面试题,又不想死记八股文,那这篇复盘应该能帮你少走不少弯路。
1. 一套典型Java笔试题的命题思路拆解
1.1 用友校招笔试的题型结构与出题风格
先说当时拿到的卷面整体印象。用友2018秋招Java笔试题(二)的题型大概分四块:单选题、多选题、程序阅读题和简答/编程题。单多选占大头,程序阅读题次之,最后有一两道编程题,整体时间给得不算特别宽裕。
这个结构其实很能说明问题。传统软件厂商的研发岗,尤其是做ERP、云产品这类To B业务的,日常工作中大量时间是在读别人的代码、改历史遗留代码、排查线上问题。所以他们笔试不跟你玩“纯算法竞赛”那一套,而是更看重三样东西:基础语法熟不熟练、能否读得懂带业务含义的代码、遇到异常和并发问题有没有成体系的排查思路。
单选和多选覆盖的知识点相当常规,基本都是Java核心基础:基本数据类型、运算符优先级、String与包装类、集合框架、异常机制、多线程基础、JVM内存区域、类加载顺序、SQL基础。单看这些名词,很多同学会觉得“就这?”。但如果真这么想,就太小看这套题了。
我当时最快的感受是:题目本身很少直接问“什么是HashMap”,而是给你一段代码,问输出是什么、或者哪一行会报错。这种考法比“默写概念”狠得多,它考察的是你是不是真的理解底层的执行机制,而不是背住了术语定义。
1.2 为什么这些考点会被选中:从岗位要求反推考察意图
站在出题人角度想一想,为什么用友会选这些考点?核心原因在于岗位日常任务。用友的产品线很长,从U8、T3到U9 Cloud、NC Cloud,背后都是大型Java服务端系统。这类系统的特点是什么?一是生命周期长,代码可能要维护十年以上;二是并发和事务场景多,财务数据、库存数据都不能出错;三是涉及大量数据库交互,SQL功底必须过关。
所以笔试里反复出现的几个主题,全部对应实际工作场景:
- 集合类和HashMap相关题目,对应的是你日常写业务代码时最常打交道的容器;如果连扩容机制和线程安全性都搞不清楚,生产环境一出问题根本无从下手。
- 多线程和锁相关题目,对应的是ERP系统里批量任务、定时调度、库存扣减这类高并发场景。
- JVM和类加载相关题目,对应的是线上OOM(内存溢出)、类冲突、HotSwap(热部署)这些实战问题。搜索引擎里能看到大量“用友NC uploadservlet命令执行”、“用友U8接口”相关的内容,说明这类企业系统对运行时稳定性的要求非常高。
- SQL和事务相关题目,对应的是财务模块、进销存模块里最核心的数据一致性需求。
把这个逻辑想明白之后,你复习的目标就不是“把Java题海刷完”,而是“搞清楚生产环境下Java最常出问题的几个点”。这样的复习效率会高很多。
2. 高频考点深度解析:Java基础部分的“暗坑”
2.1 面向对象:重载、重写、动态绑定最容易丢分
用友这套笔试卷子里,面向对象部分的题量不小,但考的并不直接,最常见的形式是:给出父子类各一个方法,让你判断调用哪一个。
这里面最核心的概念是重载(Overload)和重写(Override)的区别,以及Java运行时动态绑定的机制。很多背过八股文的同学能脱口而出“重载是编译期多态,重写是运行期多态”,但一到具体题目就懵。原因很简单:他们没有把“编译看左边,运行看右边”这九个字内化成代码直觉。
举个例子,当时有一道程序阅读题大概是这样的:
class Parent { public void print(String s) { System.out.println("Parent String"); } } class Child extends Parent { public void print(Object o) { System.out.println("Child Object"); } } public class Test { public static void main(String[] args) { Parent p = new Child(); p.print("hello"); } }输出是什么?答案是“Parent String”。原因在于:Child里的print(Object o)并不是对print(String s)的重写,而是重载。两个方法的参数类型不同,所以和方法重写没关系。变量p的静态类型是Parent,编译期就已经确定了要调用的是Parent中定义的print(String)方法。虽然运行时对象实际是Child,但Child并没有重写print(String),所以只能调用父类的实现。
这道题错的人非常多。很多同学一看到new Child()就想当然选择“动态绑定”,忽略了动态绑定只对重写方法生效。这个坑如果不亲自踩一遍,光靠背概念很难真正避开。
另一个常考的点是构造方法的调用顺序。子类构造时,第一行必然是super(),哪怕你没写,编译器也会自动加。所以创建子类对象的时候,一定是父类静态代码块 -> 子类静态代码块 -> 父类普通代码块 -> 父类构造器 -> 子类普通代码块 -> 子类构造器。这个顺序题,用友笔试基本年年有,只是换层皮。
2.2 集合框架:HashMap与Hashtable的底层差异
集合框架几乎是所有Java笔试题的重头戏,用友也不例外。当时卷子里有一道多选,问的是HashMap和Hashtable的区别。选项里混了好几个容易混淆的说法,比如“HashMap线程安全”、“Hashtable允许null键”、“HashMap在Java 8之后引入红黑树”等等。
正确的版本是:
- HashMap线程不安全,Hashtable线程安全(Hashtable的方法大多用synchronized修饰)。
- HashMap允许key和value为null,Hashtable不允许。
- HashMap继承自AbstractMap,Hashtable继承自Dictionary。
- Java 8之后,HashMap在链表长度超过8且数组长度大于等于64时,会把链表转成红黑树,优化冲突严重时的查找性能。
Hashtable线程安全是有代价的,它直接在方法上加重量级锁,并发度低,所以现在生产环境基本都用ConcurrentHashMap。但笔试题不会管你生产环境用什么,它就是要考你这些基础差别,别答混。
HashMap还有一个高频考点是扩容机制。默认初始容量16,负载因子0.75。意思是当元素个数超过16 × 0.75 = 12时,就会触发扩容,容量翻倍到32。这里很多同学会记错,把“达到12个元素”记成“数组长度达到12”,其实是元素数量,不是数组位置。
Java 8之后HashMap的put过程,也是一个经典的程序阅读题素材:
- 计算key的hashCode,再通过
(h = key.hashCode()) ^ (h >>> 16)做扰动,降低哈希碰撞概率。 (n - 1) & hash计算出数组下标,其中n是数组长度。因为n是2的幂,所以位运算等价于取模,但效率更高。- 如果该位置没有元素,直接放入;如果有,则遍历链表/红黑树。
- 如果key已存在,覆盖旧值;如果不存在,尾插法插入新节点。
- 插入后检查size是否超过threshold,超过则扩容。
这个流程不一定直接以代码题出现,但非常可能以“某段代码反复put,问最终size是多大”的形式出题。我印象中当时就有一道和put过程相关的选择题,涉及key相同覆盖value的问题,答案很容易错算成里面有多个元素,其实是同一个key被覆盖了。
2.3 异常与多线程:从基础概念到并发实战考点
用友这套卷子的异常题不算难,但有一个点考得特别细:checked exception(受检异常)和unchecked exception(非受检异常)的区分标准。
规则很简单:RuntimeException及其子类是非受检异常,除此以外的Exception子类是受检异常。受检异常要求方法必须声明throws或者用try-catch处理,否则编译不通过;非受检异常不需要强制处理。
当时有道题问下面哪个属于受检异常,选项有NullPointerException、ArrayIndexOutOfBoundsException、IOException、ArithmeticException。正确答案是IOException。但很多人会错选NullPointerException,因为实际开发中空指针出现得太频繁,误以为它是受检的。这就是概念和执行经验的区别。
多线程部分,用友出题偏向基础但注重应用。比如考了Thread和Runnable的区别、sleep()和wait()的区别、synchronized的作用范围、volatile的可见性和有序性。其中有一道程序阅读题,问的是多线程环境下某个共享变量i++最终的值。这题考的就是i++不是原子操作,即使加了volatile也不能保证线程安全。因为volatile只保证可见性和有序性,不保证原子性。要保证i++线程安全,得用AtomicInteger、synchronized、Lock等机制。
这块考点我建议复习的时候不要只看概念,最好自己动手写几个并发程序,观察一下输出结果。比如写一个10个线程各执行1000次i++的程序,你会发现最终结果经常不是10000。亲眼看到这个结果之后,你对“非原子操作”的理解会深很多。
3. 实操复盘:几道典型题目的完整作答过程
3.1 经典题1:基础语法与运算符优先级
先来一道我印象很深的题。原文大概意思是,以下代码输出什么:
public class Test { public static void main(String[] args) { int i = 1; i = i++; int j = i++; int k = i + ++i * i++; System.out.println(i); System.out.println(j); System.out.println(k); } }这道题考两个点:i++和++i的区别,以及运算符优先级。
先看int i = 1; i = i++;。很多人会想,i++不就是先取i再自增吗?那i先取出1,再自增为2,再把1赋给i,所以i还是1?对,但理解还不够本质。
在Java里,i++做的事情是:先把i的当前值压入操作数栈,然后对局部变量表中的i加1。赋值的时候是把操作数栈里的值取出来赋给i。所以i = i++的结果是,i先变成2,然后被覆盖回1。最终i还是1。
接下来int j = i++,此时i是1,先取1给j,然后i自增为2。所以j = 1,i = 2。
再看int k = i + ++i * i++;。这行要非常小心,涉及优先级和求值顺序。
优先级规则:后缀自增i++的优先级最高,然后是一元运算符++i,然后是乘除法,最后是加减法。所以这行代码先计算++i和i++。
计算过程是这样的:
- 初始时,i = 2。
- 开始计算
i + ++i * i++。 - 先算左边的
i,此时i的值是2,取出放到一边。 - 再算
++i,i变成3,得到3。 - 再算
i++,当前i是3,先取出3,然后i自增为4。 - 乘法:3 × 3 = 9。
- 加法:2 + 9 = 11。
所以k = 11,i最终是4,j = 1。
最终输出:
4 1 11这道题我在考场上做的时候,左边那个i到底什么时候取值纠结了很久。Java的求值顺序是“先左后右”,但这里要注意变量取值和自增发生的时间点。我当时在草稿纸上画了操作数栈和局部变量表的推演过程才敢下笔。建议你做这类题的时候也养成画栈的习惯,不要凭感觉,尤其涉及到多个自增混在一起的时候。
3.2 经典题2:String、包装类与equals判等
用友这套题里还有一类必考题:String相关。考察点在字符串常量池、==和equals区别、包装类缓存机制。
有一道程序阅读题大致的形态如下:
public class Test { public static void main(String[] args) { String s1 = "abc"; String s2 = new String("abc"); String s3 = "a" + "bc"; String s4 = new String("abc").intern(); System.out.println(s1 == s2); System.out.println(s1 == s3); System.out.println(s1 == s4); } }输出是什么?
s1 == s2:false。s1指向常量池里的"abc",s2指向堆里的一个String对象,地址不同。s1 == s3:true。s3是编译期常量拼接,编译器会直接优化成"abc",所以s3和s1都指向常量池的同一个对象。s1 == s4:true。intern()方法会检查常量池里是否已有"abc",有则返回常量池引用。
这道题的核心是理解Java的字符串常量池机制。其中"a" + "bc"因为两个都是编译期常量,编译器会直接合成"abc";但如果其中有一个是变量,比如String x = "a"; String s = x + "bc";,那就是运行时拼接,会生成新的String对象,s1 == s就是false。
包装类也有类似的考点。当时有道题考的是Integer缓存:
Integer a = 127; Integer b = 127; Integer c = 128; Integer d = 128; System.out.println(a == b); System.out.println(c == d);第一行输出true,第二行输出false。原因在于Integer默认缓存了-128到127的值,在这个范围内的自动装箱会直接返回缓存对象。所以a和b指向同一个对象;而128超出了缓存范围,每次装箱都是新建对象,c和d地址不同。
这类题是典型的“背过就会,没背就懵”。但如果只记住结论不记原理,还是容易翻车。底层原理其实很简单:Integer.valueOf(int)方法里有一个缓存数组,范围默认是-128到127,可以通过JVM参数调整上限。用友这套题考到这个层面,说明出题人希望候选人不仅知道结果,还理解自动装箱背后的机制。
3.3 经典题3:多线程交替打印
最后说说编程题。我记得当时有一道手写代码题,要求用两个线程交替打印数字,比如线程1打印奇数、线程2打印偶数,输出1到100。
这道题思路不难,核心是线程间协作。我当时的解法是用Object的wait()和notify():
public class PrintTask { private static final Object lock = new Object(); private static int num = 1; public static void main(String[] args) { Thread t1 = new Thread(() -> { while (num <= 100) { synchronized (lock) { if (num % 2 == 1) { System.out.println(Thread.currentThread().getName() + ": " + num); num++; lock.notify(); } else { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } }, "奇数线程"); Thread t2 = new Thread(() -> { while (num <= 100) { synchronized (lock) { if (num % 2 == 0) { System.out.println(Thread.currentThread().getName() + ": " + num); num++; lock.notify(); } else { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } }, "偶数线程"); t1.start(); t2.start(); } }思路是:两个线程竞争同一把锁,拿到锁后判断当前数是否是自己该打印的。如果是,打印后num++并notify()唤醒对方线程;如果不是,就wait()释放锁并进入等待状态。这样两个线程交替执行。
这题有两个容易扣分的地方。
第一,wait()方法必须放在synchronized代码块里面,否则会抛IllegalMonitorStateException。很多同学笔试写代码时容易忽略这一点。
第二,循环判断条件是while (num <= 100)而不是if (num <= 100)。虽然在这个场景下用if也能跑,但wait()存在“虚假唤醒”的可能,用while才是更规范、更健壮的写法。面试官通常看到while会加分。
当然,这题还有别的解法,比如用ReentrantLock加Condition,或者用Semaphore。我也见过有同学用AtomicInteger自旋实现的,也能跑,但效率上不如阻塞唤醒的方式优雅。如果笔试时间充裕,可以适当补充一两种解法,向面试官展示你对并发工具类的掌握广度。
3.4 经典题4:SQL查询与简单算法手写
除了Java基础,这套题还有一类必考内容:SQL。用友是做企业软件起家的,SQL功底对研发岗来说是硬指标。当时有一道SQL题,大概是给定员工表和部门表,查询每个部门薪资最高的员工。这类题几乎是所有数据库笔试题的经典款。
用窗口函数的写法是:
SELECT d.dept_name, e.emp_name, e.salary FROM ( SELECT emp_name, dept_id, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employee ) e JOIN department d ON e.dept_id = d.dept_id WHERE e.rn = 1;如果没有窗口函数,用子查询也能写,逻辑是:先找出每个部门的最高薪资,再关联员工表。思路不复杂,但要注意如果最高薪资有并列,需求是“查一个人”还是“查所有并列的人”,写法不一样。
算法那块,用友笔试的难度放在互联网大厂面前算温柔的了。我记得当时考了一道冒泡排序的变体,要求统计排序过程中交换的次数。这题本质上就是在考你冒泡排序的实现有没有真正吃透。如果只会对着模板默写,一旦题目稍微变一变就容易卡壳。
我建议复习排序算法时不要只背代码,要把每一轮排序之后数组的状态画出来,知道为什么这样比较、为什么这样交换、什么时候可以提前终止。用友这类企业笔试题不追求你掌握了多少冷门算法,但要求你会的东西是真会。
4. 笔试现场的常见问题与排查技巧实录
4.1 时间分配:先拿稳哪些分
笔试现场最容易出的问题就是时间不够用。用友这套题,单选多选大概三十多道,程序阅读五六道,简答加编程两三道。如果你在某一道偏题上纠结太久,后面编程题很可能会写不完。
我的建议是:先做有把握的题,再做需要思考的题,最后啃实在不会的题。单选题和多选题是第一优先级,它们分值高、耗时短;程序阅读题第二优先级,这类题通常需要画栈推演,比较费时间;编程题放在最后,至少留出30到40分钟。
编程题如果实在写不完,也要把思路写上去,比如伪代码、步骤注释、核心方法名,让阅卷人看到你有解决问题的思路。笔试阅卷不是只看最终结果,解题过程的逻辑是否清晰也很重要。
还有一个细节:如果笔试是线上进行的,注意保存代码。我当年就见过有同学写了一大半,结果页面刷新,代码全没了。如果是本地IDE写,记得随手Ctrl+S;如果是在线编辑器,尽量每隔几分钟把代码复制到本地草稿箱。
4.2 陷阱题识别清单
刷完这套题,我总结出几个反复出现的“陷阱信号”,你可以做一份自查清单,遇到这些信号时多留个心眼:
- 看到
i++和++i同时在一条表达式里,先不要急着算,把操作数栈画出来再说。 - 看到
new String("xxx")和字符串常量比较,先想一想常量池和堆的区别。 - 看到
Integer比较大小,下意识想一下范围是否在-128到127之间。 - 看到多线程操作共享int变量,第一反应不是“该用volatile”,而是“i++不是原子的”。
- 看到父子类方法调用,第一反应不是“动态绑定”,而是先判断这是重写还是重载。
- 看到
try-catch-finally,要特别小心finally里如果有return,它会吞掉try或catch里的返回值。
我当年就栽在最后一条上。finally里加return是极其糟糕的写法,但笔试偏偏喜欢考。当时的题大概是说,try块里return 1,finally块里return 2,问最终返回什么。答案是2,因为finally的return会覆盖之前的返回值。这种题考的不是你写代码多优雅,就是考你对JVM执行流程的掌握程度。
4.3 踩坑实录与复习建议
最后分享一点真实的踩坑经历。我第一次做这套题之前,也刷过不少“Java面试八股文”,感觉自我良好。结果一做题发现,很多题并不直接问你“HashMap的底层数据结构是什么”,而是直接扔一段代码让你判断输出。这种方式让我意识到:概念记住不等于真的懂,你真的得在代码层面理解它。
后来我调整了复习方式,不再背题,而是用“代码验证法”。每学一个知识点,就写一个小Demo去验证。比如学了字符串常量池,就写代码用==比较各种String对象,再通过javap -c看字节码指令;学了JVM内存区域,就写一个不断创建对象的程序,然后通过JVM参数调小堆内存,亲手触发一次OOM。这个过程比背十遍八股文都有用。
对于准备用友这类传统软件厂商的校招同学,我还有一个额外建议:多去了解产品和技术栈相关的常见问题。用友的NC、U8、U9 Cloud这些产品,网上有大量实操手册和问题记录,比如“U8未检测到合法许可”、“T3启动时报错2147024770”、“NC的uploadservlet相关安全隐患”等。这些内容不一定直接考,但如果能在面试环节聊出几句,会让面试官觉得你对公司产品真的有了解,不是临时抱佛脚。
用友2018秋招Java笔试题(二)整体难度不算高,但它很有代表性,考察的都是Java程序员日常开发中真正用得上的底子。如果你能把这份卷子里的每一道题都弄明白,不光是背答案,而是理解背后的原理,再去做其他公司的Java笔试题也不会太慌。我一直觉得,笔试的最终目的不是拿满分,而是通过一次测试,逼自己把基础补扎实。基础这关过了,后面谈项目、谈设计才站得住脚。