
1. 这套题到底在考什么校招Java笔试题的典型样本说实话一看到用友2018秋招Java笔试题五这个标题我第一反应是有点怀念。2018年那会儿校招Java笔试还处在八股文和手撕代码比例相对平衡的阶段不像现在有的厂子一上来就是两三道hard级算法题。但这套题放到今天依然值得一刷原因很简单它考的东西全在Java基础的核心圈里几乎没有偏题怪题每一道都能映射到实际开发中会遇到的场景。用友作为老牌企业管理软件厂商它的Java笔试题向来以实用、重基础、贴近业务著称。毕竟用友的很多产品线像U8、NC、U9 Cloud底层都是Java技术栈业务场景涉及财务、供应链、制造等领域对开发人员的基础扎实程度要求很高。所以这套题不只在筛会写Java的人更在筛理解Java的人。这套题适合谁刷两类人。第一类是正在准备校招、社招的Java求职者用来检测自己的基础是否扎实第二类是工作两三年但没系统梳理过基础的开发者你会发现很多题你好像会但一写就错刷一遍能帮你把知识盲区补上。我下面会按题型分类逐题拆解不光是给答案更重要的是讲清楚每个考点背后的原理和坑在哪。2. 基础语法题看似送分实则暗藏杀机2.1 数据类型与装箱拆箱的经典陷阱先来看一道很典型的题问下面这段代码输出什么public class Main { public static void main(String[] args) { Integer a 100; Integer b 100; Integer c 200; Integer d 200; System.out.println(a b); System.out.println(c d); } }答案是 true 和 false。很多人第一反应是对象比较 肯定都是 false但这里考的是 Integer 缓存机制。Java 在 Integer 类内部维护了一个缓存池默认缓存 -128 到 127 之间的 Integer 对象。当你用Integer a 100这种方式赋值时编译器会将其翻译成Integer.valueOf(100)而 valueOf 方法会先检查缓存池命中则直接返回缓存对象。100 在缓存范围内所以 a 和 b 指向同一个对象200 超出范围每次都会 new 一个新的 Integer 对象c 和 d 指向不同对象结果是 false。这道题真正的价值在于它引出了一系列问题为什么设计这个缓存因为 -128 到 127 是最常用的数值区间缓存可以显著减少对象创建。面试官如果接着问缓存范围能调吗答案是可以通过 JVM 参数-XX:AutoBoxCacheMax200可以调整上限。但这个知识点在实际开发中有一个更重要的警示用 比较 Integer 是不可靠的永远应该用 equals 或者intValue()比较。我当年在实际开发中就踩过类似的坑。一个订单金额字段用了 Integer 包装类型在某个对账逻辑里直接用 比较结果金额小的时候没问题超过 127 就偶尔出 bug。排查了很久才意识到是缓存边界的问题。自那以后我定了一条团队规范包装类型比较一律用 equals基本类型才允许用 。2.2 字符串不可变性的连锁考点另一道高频题是关于 String 的String s1 hello; String s2 hello; String s3 new String(hello); System.out.println(s1 s2); System.out.println(s1 s3); System.out.println(s1.equals(s3));答案依次是 true、false、true。这里考的是字符串常量池和不可变性。String s1 hello会在字符串常量池中创建一个对象s2也指向同一个池中对象。而new String(hello)会在堆上额外创建一个新对象所以 s1 和 s3 的引用不同。equals 比较的是内容自然是 true。这道题背后值得深挖的是 String 为什么设计成不可变。原因有三个一是安全性String 经常作为 HashMap 的 key、网络连接的 host 等如果可变会导致严重问题二是常量池共享需要不可变性否则多个引用指向同一对象时互相影响三是线程安全不可变对象天然线程安全。从这道题可以引出一个很重要的实际经验在循环中拼接字符串一定要用 StringBuilder。因为每次用拼接字符串都会生成新的 String 对象循环次数多了会创建大量中间对象触发频繁 GC。我在代码评审里见过太多这种写法了String result ; for (int i 0; i 10000; i) { result i; // 每次循环都创建新对象性能极差 }改成StringBuilder.append()之后性能差距是数量级的。2.3 运算符优先级笔试里的阴间题这套题里还有一道让我印象深刻的运算符题int i 5; int j i i; System.out.println(i i , j j);答案是 i7, j12。分析过程i先取 i 的当前值 5 参与运算然后 i 变为 6i先把 i 从 6 变为 7再取 7 参与运算。所以 j 5 7 12运算结束后 i 7。这种题在真实开发中几乎不会主动去写但笔试就喜欢考因为它能看出你对先取值还是先自增的理解是否清晰。我的建议是理解规则但不要主动在代码里写这种表达式。代码是给人读的不是给编译器秀操作的。面试时如果被问到这种题答完之后如果能补一句实际开发中我会拆开写避免歧义反而是加分项。3. 面向对象与核心API笔试的主战场3.1 继承与多态的运行时行为面向对象这块是Java笔试的必考区这套题里有一道经典题class Animal { public void eat() { System.out.println(Animal eating); } } class Dog extends Animal { public void eat() { System.out.println(Dog eating); } public void bark() { System.out.println(Dog barking); } } public class Main { public static void main(String[] args) { Animal a new Dog(); a.eat(); // a.bark(); // 这行能编译通过吗 } }a.eat()输出 Dog eating这是动态绑定JVM 在运行时根据实际对象类型调用方法。而a.bark()编译不通过因为编译时看的是引用类型 AnimalAnimal 类中没有 bark 方法。这里面的核心考点是编译时类型与运行时类型的区别以及方法重写和重载的区别。重写是子类用相同签名覆盖父类方法实现多态重载是同一类中方法名相同但参数列表不同是编译时行为。很多候选人能把概念背得滚瓜烂熟但一到写代码就露馅说明只是背了定义没真正理解分派机制。实际开发中多态最大的价值是面向接口编程。比如用友这类ERP系统里处理不同类型的单据采购单、销售单、库存单时可以定义一个统一的单据处理接口各业务模块实现接口派发时根据类型调用对应实现。这样做的好处是新增业务类型时不用修改已有代码只加新实现类就行符合开闭原则。3.2 equals与hashCode的协约关系hashCode 和 equals 是一对分不开的组合这道题几乎是Java笔试的必请嘉宾class Person { private String name; private int age; // 构造方法、getter/setter省略 // 只重写了equals没重写hashCode }问把 Person 对象放入 HashSet 后再查一个内容相同的 Person 对象能否查到答案是不一定大概率查不到。因为 HashSet 判断元素是否重复时先调用 hashCode 定位到桶再调用 equals 比较。如果 hashCode 没有重写默认是 Object 类的实现基于对象的内存地址生成。两个内容相同的 Person 对象 hashCode 不同会被放入不同的桶equals 根本没有机会被调用。重写 equals 就必须重写 hashCode这是 Java 官方的协约要求。不遵守这个约定HashMap、HashSet、Hashtable 这些依赖哈希的集合全会出问题。实际开发中这类 bug 很难排查因为问题不是每次必现而是取决于对象在哈希结构中的分布。顺便说一句现在开发者都用 IDE 自动生成 equals 和 hashCode很少手写。但 2018 年那会儿很多人还是手写所以笔试题特别爱考这个点。我在面试别人时也常问这个能讲清楚为什么必须一起重写的人基础通常不会差。3.3 集合框架ArrayList与LinkedList的选型之争这道题考的是集合的底层原理ListString list1 new ArrayList(); ListString list2 new LinkedList();问两者在插入、删除、随机访问上的性能差异及底层原因。ArrayList 底层是数组随机访问是 O(1)按下标 get 直接通过内存地址偏移计算位置插入和删除需要移动后续元素平均 O(n)。LinkedList 底层是双向链表随机访问需要从头遍历O(n)但在已知节点位置的情况下插入和删除只需要修改前后节点的指针O(1)。这道题我特别想强调一点现在实际开发中 LinkedList 的使用场景越来越少。很多人在中间插入多的场景选 LinkedList但现代 CPU 的缓存机制对数组更友好连续内存的遍历效率远高于链表节点分散在内存中的遍历。我做过简单测试在 10 万元素规模下即使 LinkedList 的优势场景——中间插入实际性能也未必比 ArrayList 好。JDK 的 ArrayList 在扩容时也是批量复制并不像想象中那么慢。更别提对 LinkedList 做随机访问时的噩梦。所以我的建议是默认用 ArrayList只有当你确定需要频繁在头部插入删除且不依赖随机访问时才考虑 LinkedList。或者干脆用 ArrayDeque它在头部和尾部操作的性能都比 LinkedList 好。这道题的价值不光是让你记住底层原理更是训练你的选型思维——数据结构选型会直接影响线上性能。3.4 异常处理受检异常与非受检异常的边界异常部分这道题也很有代表性try { // 某段代码 } catch (Exception e) { e.printStackTrace(); }问这段代码有什么问题以及受检异常checked exception和非受检异常unchecked exception的区别。受检异常是编译器强制要求处理或向外抛出的异常比如 IOException、SQLException它们继承自 Exception 但不由 RuntimeException 派生。非受检异常包括 RuntimeException 及其子类如 NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException编译器不强制处理。在面试时我最想听到的答案还包含第三层什么时候该用异常什么时候不该用。异常处理是有开销的try-catch 语句本身不影响性能但异常对象的创建和堆栈填充成本很高。不要在常规业务逻辑里用异常控制流程比如用 NumberFormatException 判断字符串是否数字这种写法又慢又丑。实际开发中我们一般约定业务异常用自定义异常系统异常由全局异常处理器兜底避免在每个方法里一层层写 try-catch。回到上面那段代码两个问题。一是e.printStackTrace()在生产环境是大忌因为打印到 stdout 的日志对排查问题几乎没有帮助且在高并发场景下大量堆栈打印会拖垮应用。正确做法是使用日志框架记录比如 Log4j 或 Logback。二是 catch 范围太宽把异常全吞掉再打印等于什么问题都没处理调用方完全不知道出了什么错。正确的异常处理应该是能处理的就处理并恢复不能处理的就抛出或包装成更有业务含义的异常。4. 多线程与并发校招笔试的分水岭4.1 Thread与Runnable创建线程的两个经典入口多线程这块儿在 2018 年用友的笔试里已经占了不少比重毕竟企业管理软件经常要处理并发导入、批量任务这类场景。其中一题是这样的// 方式一 class MyThread extends Thread { Override public void run() { System.out.println(Thread running); } } // 方式二 class MyRunnable implements Runnable { Override public void run() { System.out.println(Runnable running); } }问两种方式的区别以及推荐哪种。区别有三层。第一Java 是单继承继承 Thread 类会占用唯一的继承名额而实现 Runnable 接口不占用。第二通过 Runnable 方式可以把任务逻辑和线程分开任务可以给多个线程执行或者配合线程池复用继承 Thread 的话任务是写死在类里的复用性差。第三Runnable 接口可以配合各种执行框架比如 ExecutorService而 Thread 类本身无法直接交给线程池管理。实际推荐是 Runnable 或者直接使用 Callable有返回值、能抛异常。Java 8 之后还可以用 Lambda 表达式更简洁地创建任务ExecutorService executor Executors.newFixedThreadPool(10); executor.submit(() - { // 业务逻辑 });不过这里有个坑要注意题目还经常追一句Thread.start()和Thread.run()的区别。start 是真正启动一个线程run 只是普通方法调用。我见过有候选人因为没搞清楚这个在面试时答串了。4.2 synchronized与锁机制并发题的核心战役并发这块必考 synchronized 的用法和原理。这套题里有一道关于同步方法的题class Counter { private int count 0; public synchronized void increment() { count; } }问两个线程同时调用同一个 Counter 实例的 increment 方法count 最终是否一定是 2答案是不一定。虽然 increment 加了 synchronized方法是线程安全的但问题出在调用方式上。如果两个线程各自 new 一个 Counter 实例那两个线程锁的是不同的对象互不相干每个 count 都是 1。如果两个线程共享同一个 Counter 实例那 count 才会是 2。这其实是考察 synchronized 锁的是对象的 monitor不是锁方法本身。静态 synchronized 方法锁的是 Class 对象实例 synchronized 方法锁的是当前实例对象synchronized 代码块锁的是指定对象。三种锁的粒度不同锁错了对象就等于没加锁。2018 年那会儿 synchronized 还经常被误解为性能差后来 JDK 6 做了大量优化引入了偏向锁、轻量级锁、锁升级机制性能已经不差。现在的实际开发中synchronized 和 Lock 的选择原则是能用 synchronized 就用 synchronized代码简单不易出错需要超时、可中断、多条件等待时用 Lock。4.3 volatile面试官最爱的一问一答volatile 是并发题里的常客这道题问的是public class Flag { private static volatile boolean flag true; public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { while (flag) { // 循环等待 } System.out.println(Flag changed, exit); }); t.start(); Thread.sleep(1000); flag false; } }问如果没有 volatile 修饰 flag这段代码会不会出问题会出问题但不是一定死循环而是可能死循环。volatile 的作用有两个一是保证内存可见性线程对变量的修改会立即刷回主内存其他线程读取时能看到最新值二是禁止指令重排序。但 volatile 不保证原子性比如volatile int x; x这种复合操作还是线程不安全的因为 x 包含读、加、写三步。这个例子恰好是 volatile 的典型正确用法——一个线程写入标志位另一个线程读取标志位。注意这个概念单写多读场景。多线程写同一个 volatile 变量时需要使用原子类或者锁。我自己在项目里写关闭钩子、服务降级开关时就用 volatile 修饰一个布尔变量配合一个线程周期检查简单高效。这里有一个 JAVA 并发面试的必问点volatile 和 synchronized 的区别。核心区别在三点volatile 不保证原子性synchronized 保证volatile 只能修饰变量synchronized 能修饰方法和代码块volatile 不会阻塞线程synchronized 可能阻塞。5. 编程题手撕代码是躲不过去的坎5.1 冒泡排序的实现与优化这套笔试题最后一般会有一道编程题最常见的是手写排序算法。冒泡排序是基础中的基础public static void bubbleSort(int[] arr) { if (arr null || arr.length 2) { return; } int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }注意两处优化一是内层循环边界是n - 1 - i因为每一轮结束后最大值已经沉底不用再比较二是使用 swapped 标记如果某一轮没有发生任何交换说明数组已经有序提前退出。这两个优化面试时一定要写出来因为代码能力不只是写对还包括写好。我在实际写代码时很少用冒泡排序但在笔试中它依然有价值因为它考察的是最基本的数组操作、循环嵌套、边界处理能力。很多候选人能背出代码但一改条件就写错比如从大到小排序就慌了说明没有真正理解思路只是在背模板。理解了两两比较、大的往后浮这个核心思想改方向只是改一个比较符号的事。5.2 字符串处理题现场分析一题还有一道常见的字符串编程题我在这里出一道类似的给定一个字符串统计每个字符出现的次数输出结果按字符出现次数从高到低排序。这道题考了三个点HashMap 的使用、遍历逻辑、排序。我给出一个参考答案import java.util.*; public class CharCount { public static void countAndSort(String str) { if (str null || str.isEmpty()) { return; } MapCharacter, Integer freqMap new HashMap(); for (char c : str.toCharArray()) { freqMap.put(c, freqMap.getOrDefault(c, 0) 1); } ListMap.EntryCharacter, Integer entryList new ArrayList(freqMap.entrySet()); entryList.sort((e1, e2) - e2.getValue() - e1.getValue()); for (Map.EntryCharacter, Integer entry : entryList) { System.out.println(entry.getKey() : entry.getValue()); } } public static void main(String[] args) { countAndSort(hello world); } }这里getOrDefault是 Java 8 的 API用得很顺手。排序那行用了 Lambda 表达式如果候选人用的是Collections.sort配合匿名内部类也 OK说明对 API 理解在同一个水平。这类题考察的是对集合框架的综合运用能力不要求最优解但要求代码整洁、逻辑清晰、注意空指针判断。顺着这个题我多说一句字符编码的坑。如果是统计中文要清楚 Java 中的 char 是 UTF-16 编码一个中文字符是一个 char但某些生僻字和 emoji 是代理对占两个 char。笔试一般不会考那么深但实际开发中统计字符长度时要想清楚需求是按 Unicode 码点还是按 char 数量两种统计结果可能不同。5.3 手写代码时的三个加分细节编程题做题时有几个细节能直接影响考官的主观评价第一边界条件的处理。数组为 null、数组长度为 0、字符串为空这些情况是否提前处理。很多代码能处理正常输入但一遇到边界条件就抛出空指针异常这是印象分的大杀器。第二代码的可读性。变量命名是否语义化有没有不必要的复杂表达。考官都是老程序员一段代码扫一眼就能判断出这个人写代码的习惯。规范清晰的代码永远比一个巧妙但晦涩的解法得分高。第三主动分析复杂度。写完代码后补一句时间复杂度和空间复杂度是很大的加分项。这说明你有算法分析意识能考虑到代码在数据量变大时的表现。我面试时候选人在手写算法后主动讲复杂度我会在评语里特别标注。6. 从笔试到面试这套题传递出的用人信号6.1 用友这类企业到底想要什么样的Java开发刷完这套题不要只对答案要尝试站在出题人角度去理解它的意图。用友这类做企业管理软件的公司业务系统常年跑在客户机房或者私有云上系统要处理财务数据、业务单据这些都是容不得出错的场景。所以它的笔试风格非常务实不问冷门概念不考奇技淫巧就考那些你在实际开发中每天都在用、但可能从未深究的东西。从题目结构上能看出明显的考察层次基础语法题筛掉半吊子面向对象题看候选人的抽象思维集合框架题看数据结构的掌握程度多线程题看对并发场景的理解编程题看基本编码能力。这套题不是想难倒你而是想确认一件事这个人能不能直接上手干活。这对候选人来说是好事。相比刷一堆花里胡哨的脑筋急转弯基础扎实的人在这种题目面前能稳定发挥。如果你能用好这套题来检视自己把上面这些考点对应的原理都搞透哪怕明天就要去面试你也会从容很多。6.2 从会做题到会干活我还有几句真心话我在帮团队筛选简历、出面试题的过程中越来越深刻地感受到笔试只是起点。笔试能筛掉完全不会的人但筛不出真正优秀的人。真正优秀的候选人在笔试中的表现通常不是题题都会而是会的扎实、不会的不乱编。举一个真实的场景。曾经有个候选人笔试题答得一般有一道多线程的题甚至答错了。但面试过程中我问他一个线上问题怎么排查他能非常清晰地说出思路先看日志、再查 Thread Dump、分析锁竞争、用 JVisualVM 看内存。说明人家有实战经验笔试只是不擅长闭卷考试而已。最后我们发了 offer他入职后在处理并发问题上确实很有经验。所以我的建议是这套题用来查漏补缺很好但别把自己陷进刷题—背答案—再刷题的循环里。Java 的学习路线应该是基础语法 → 集合框架 → JVM → 并发 → 框架 → 项目实践每一层都有对应的实践内容。笔试只是检验第一层到第四层的掌握程度后面还有很长的路要走。6.3 最后分享一个复习思路我个人带新人时习惯让他们用一个三遍法来过这套题。第一遍不看任何资料完全闭卷做一遍把自己不会的题目标出来第二遍对照文档和源码逐个搞懂原理每道题都要能讲清楚为什么是这个答案第三遍一周后重新做一遍检测是否真正掌握了。如果第三遍还有错题那就说明不是不会而是有误解这种题要特别重视。还有一个实实在在的技巧学任何知识点时都试着给自己出题。比如看到 ArrayList 的扩容机制不要只记住1.5 倍要能推导出为什么是 1.5 倍而不是 2 倍——因为 1.5 倍在扩容后能留出约三分之一的空间给新元素同时避免频繁扩容浪费内存。能自己推导出这些才算真的懂了。这套题是 2018 年的到现在好几年过去了Java 也发布了多个新版本但核心的基础知识并没有本质变化。它能保持价值正是因为它考察的是那些万年不变的东西。把这些基础打牢不管面试怎么变你都能接得住。