
用友2018秋招Java笔试题三这套卷子我在2018年秋招的时候完整刷过一遍当时用友还处在传统ERP厂商向云服务转型的关键期笔试命题风格也很有意思不追求偏题怪题但非常注重基础细节和边界情况。时隔几年回头看这批题目放在今天的校招面试里依然不过时尤其是里面涉及的面向对象、集合容器、并发、JVM、手写排序这些点几乎就是Java面试八股文的原型。如果你正在准备校招笔试或者想检验一下自己的Java基础到底扎不扎实这套题值得认真过一遍。我会从命题逻辑、考点拆解、典型题目实操、失分点复盘四个维度来写尽量还原我当时答题时的真实思路。1. 先说结论用友这套题考的不是“会不会”而是“稳不稳”1.1 用友笔试的命题风格用友属于典型的To B企业软件厂商产品线覆盖ERP、财务软件、人力云、供应链等技术栈以Java为主服务端的并发量、数据一致性、系统稳定性要求都很高。所以在笔试命题上用友不同于互联网大厂那种“算法题定生死”的风格更偏向考察候选人的Java基础功底、对语言特性的理解深度以及写出的代码是否具备工程可用性。2018秋招Java笔试题三整体难度中等偏上题量不算大但覆盖面很广。从基础语法、面向对象设计、集合框架、异常处理、多线程、JVM内存到Lambda表达式、枚举类型、手写排序算法几乎把Java核心知识点都扫了一遍。这套题目最大的特点就是“细节陷阱多”——很多题表面上看是在考基础实际上是在考你对底层机制是否真的理解。1.2 拿到卷子先干什么我当时拿到这套卷子第一反应不是急着做题而是先把所有题目快速浏览了一遍给题目分类。哪些是看一眼就能出答案的基础题哪些是需要在草稿纸上推演的程序输出题哪些是需要手写完整代码的编程题心里先有个谱。这样做的好处是能合理分配时间避免在一道题上卡太久导致后面的编程题来不及写。这套卷子的题型分布大概是选择题含多选占四成程序输出题占三成编程题占两成简答题占一成。选择题部分比较友好但程序输出题和编程题才是真正的分水岭很多平时觉得自己Java学得不错的同学往往就栽在这些看似不难但很容易踩坑的题目上。2. 基础语法与面向对象考点拆解2.1 面向对象三特性笔试怎么问都不怕用友的笔试题对面向对象非常看重毕竟企业级软件的核心就是业务建模而业务建模的基础就是面向对象思想。选择题里通常会考察封装、继承、多态的基本概念比如以下哪个关键字可以防止类被继承方法重载和重写的区别是什么父类构造器与子类构造器的调用顺序这些题目本身不难但容易在细节上设陷阱。比如构造器的调用顺序很多人知道子类构造器会默认调用父类无参构造器但如果父类没有无参构造器只定义了有参构造器子类就必须显式调用super(参数)否则编译不通过。这类题考察的就是你对继承机制的理解是否到位。多态的考察也很有代表性通常会给出一个父类引用指向子类对象的代码片段问调用某个方法时输出什么。核心原则是编译看左边运行看右边。方法调用时JVM会根据对象的实际类型来决定调用哪个方法而不是根据引用类型。我印象很深的一道题是class Animal { public void eat() { System.out.println(Animal eating); } } class Dog extends Animal { public void eat() { System.out.println(Dog eating); } } public class Test { public static void main(String[] args) { Animal a new Dog(); a.eat(); } }输出结果显然是“Dog eating”但考场上有个同学选了“Animal eating”原因是把方法重载和重写搞混了。这里要特别提醒编译时编译器确实会检查Animal类有没有eat方法但运行时JVM会根据对象的真实类型Dog来动态绑定方法调用这才是多态的精髓。2.2 运算符、类型转换与表达式运算符和表达式这部分用友的题目偏向实际编码场景而不是纯粹的概念记忆。比如自增运算符的优先级问题int i 1; i i; System.out.println(i);这道题的输出结果是1不是2。原因是i作为一个表达式它的值是自增之前的值1然后这个值被赋给i覆盖了自增后的结果。这类题目在笔试中经常出现考察的核心是“表达式的值”和“变量的值”是两个不同的概念。类型转换也是高频考点尤其是隐式转换和强制转换的区别。long参与运算时int会自动提升为long而把double赋值给float时如果没有显式加f后缀编译就会报错。还有一个经典陷阱byte a 1; byte b 2; byte c a b;这行代码编译不通过因为a和b做加法运算时会先被提升为int类型结果也是int类型不能直接赋给byte变量必须强转。如果a和b是final修饰的常量情况又不一样了编译器会在编译期直接计算出结果3此时可以赋值给byte。这些细节如果不实际操作过很容易踩坑。2.3 String、包装类与缓存池String是Java笔试的“常青树”用友这套题自然也不会放过。考察点主要集中在字符串常量池、equals与的区别、String的不可变性。一个很典型的题目是String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); System.out.println(s1 s3); System.out.println(s1.equals(s3));s1和s2都指向常量池中的同一个对象所以比较结果为trues3通过new创建了一个新对象指向堆内存中不同的对象所以s1 s3为false而equals比较的是内容所以是true。这道题的本质是考察字符串常量池的机制。当代码中出现字符串字面量时JVM会先检查常量池中是否存在相同内容的字符串存在就返回引用不存在就创建一个新的。而new String()是直接创建一个新的String对象。包装类也有类似的缓存机制。Integer的缓存范围是-128到127在这个范围内通过Integer.valueOf()创建的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。原因是127在缓存范围内a和b指向同一个Integer对象128超出缓存范围c和d各自创建了新的对象。这里要注意使用比较包装类对象时永远不要依赖缓存机制应该用equals方法。2.4 数组边界与枚举实用写法数组越界异常ArrayIndexOutOfBoundsException在笔试题里出现的频率非常高用友这套题也不例外。通常的考法是给一段数组遍历代码让你判断程序运行时会抛出什么异常。比如int[] arr new int[5]; for (int i 0; i arr.length; i) { arr[i] i; }数组的下标范围是0到length-1当i等于arr.length时arr[i]就越界了。这种错误在笔试手写代码时非常容易出现因为很多人写循环时习惯用而不是或者忘了减一。我在写冒泡排序和快排时也犯过类似的错误后面会详细说。枚举类型在Java笔试中不太起眼但用友的题目里出现过一道关于枚举的简答题要求写出一个带有属性和方法的枚举类。这实际上是在考察你是否真正用过枚举而不只是停留在“枚举就是一组常量”的认知上。当时我写的答案大致是public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), DONE(3, 已完成); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }为什么要这样设计因为在实际业务中订单状态不只是一个名字还关联着状态码和描述信息。如果只用普通常量来定义这些映射关系就只能散落在各个类中无法集中管理。用枚举配合构造方法、属性、getter方法就能把状态码和描述信息内聚在一起这也是企业级开发中非常常见的做法。3. 集合、异常、并发与JVM高频题3.1 集合容器HashMap必问集合框架在笔试中的权重非常高用友这套题里出现频率最高的就是HashMap。考察点集中在HashMap的底层数据结构、put方法的执行流程、哈希冲突如何解决、扩容机制、为什么线程不安全。HashMap在JDK 1.8中的底层结构是数组加链表加红黑树。当链表长度超过8且数组长度大于等于64时链表会树化为红黑树目的是把查询时间复杂度从O(n)降为O(log n)。但这里要注意一个容易被忽略的条件如果数组长度还没达到64即使链表长度超过8也不会树化而是先进行扩容。put方法的执行流程大概是先对key进行哈希运算得到哈希值然后通过扰动函数让高位也参与寻址减少哈希冲突再通过(n-1) hash计算出在数组中的下标位置如果这个位置为空直接放入新节点如果冲突了就遍历链表或红黑树存在相同key就覆盖不存在就插入。在多线程环境下HashMap的put操作可能导致死循环和数据丢失。JDK 1.7中因为头插法和扩容时转移链表的方式并发扩容可能形成环形链表JDK 1.8改为尾插法解决了死循环问题但数据丢失问题仍然存在。所以笔试中问“HashMap为什么线程不安全”可以从并发扩容时多个线程同时操作同一个槽位来回答。面试时还经常会把HashMap和Hashtable、ConcurrentHashMap放在一起对比。Hashtable是线程安全的但所有方法都用synchronized修饰并发性能很差ConcurrentHashMap在JDK 1.8中采用CAS加synchronized的方式锁的粒度细到了单个桶并发性能有了大幅提升。3.2 异常处理从数组越界说起异常处理在Java笔试中往往是作为一个独立的考察点出现用友的题目更倾向于让你判断一段代码的输出结果或者指出代码中的异常处理问题。一个常见的错误示范是try { int result 10 / 0; } catch (Exception e) { System.out.println(catch); } finally { System.out.println(finally); }这段代码的输出是catch和finally。但如果在catch块中return了finally块还会执行吗答案是会finally在任何情况下都会执行除非JVM在try或catch块中退出了。这是笔试中经常挖的坑。还有一个容易被忽视的问题异常处理的顺序。如果catch多个异常子类异常必须放在父类异常前面否则编译报错。比如try { int[] arr new int[1]; arr[1] 1; } catch (Exception e) { System.out.println(Exception); } catch (ArrayIndexOutOfBoundsException e) { System.out.println(ArrayIndexOutOfBoundsException); }这段代码编译都过不了因为ArrayIndexOutOfBoundsException是Exception的子类编译器会提示“已捕获的异常从未被抛出”。这类题目考察的就是你对异常继承体系的掌握程度。针对数组越界异常本身我想多说一句。在实际编码中数组越界不仅仅是笔试中的题目更是日常开发中最常见的运行时异常之一。常见的发生场景包括循环边界写错、处理空数组时取下标0、从列表中取元素时没有判断列表大小、在流式处理中取最后一个元素时越界。排查数组越界问题的第一步永远是检查下标变量的取值范围尤其是边界值处是否会产生等于length的情况。3.3 多线程基础题多线程是Java面试的重灾区用友笔试也不例外。2018年的题目中多线程相关的内容主要集中在synchronized和ReentrantLock的区别、线程的几种状态、volatile关键字的作用。synchronized和ReentrantLock的区别可以从这几个维度来回答synchronized是Java内置的关键字ReentrantLock是JDK提供的类synchronized不需要手动释放锁ReentrantLock必须调用unlock方法释放锁一般配合finally使用synchronized是非公平锁ReentrantLock可以指定公平锁或非公平锁ReentrantLock支持尝试获取锁、可中断获取锁、支持多个条件变量。volatile关键字则经常和synchronized放在一起考察volatile保证的是可见性和有序性不保证原子性。经典的例子就是public class Counter { private volatile int count 0; public void increment() { count; } }count不是一个原子操作它分为读取count、计算count1、写回count三步。即使count被volatile修饰多个线程同时执行increment也可能会丢失更新。所以volatile适合的场景是“一个线程写多个线程读”的状态标志不适合做计数器。线程的状态转换也是一个高频考点。Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。笔试题目通常会给出一个操作序列让你判断线程处于什么状态或者从什么状态转换到什么状态。比如调用wait()方法后线程会从RUNNABLE转为WAITING状态调用sleep()方法后线程进入TIMED_WAITING状态。3.4 JVM内存与OOMJVM是Java笔试中的进阶考点用友这套题涉及的内容不算特别深但基础的运行时数据区、内存溢出问题是跑不掉的。结合我当时在备考中遇到的热搜词这里有一个需要特别关注的点java: outofmemoryerror: insufficient memory这其实就是JVM内存不足时的表现。JVM运行时数据区分为线程共享和线程私有两大类。线程共享的是堆和方法区也叫元空间线程私有的是虚拟机栈、本地方法栈、程序计数器。堆是存储对象实例的主要区域也是垃圾回收的主战场。如果堆内存不足就会抛出OutOfMemoryError: Java heap space。方法区用于存储类的元数据、静态变量、常量池等信息如果加载的类过多或者动态生成类过多可能抛出OutOfMemoryError: Metaspace。虚拟机栈是线程私有的每个方法从调用到结束对应一个栈帧的入栈和出栈。如果栈深度超过虚拟机允许的最大深度就会抛出StackOverflowError。笔试中常考的一个例子就是无限递归public void recursive() { recursive(); }调用recursive方法会无限向栈中压入栈帧最终导致StackOverflowError。OOM的排查思路在笔试简答题中也出现过。标准回答是先用jstat或jmap查看堆内存使用情况确认是哪个区域的内存溢出再通过dump堆转储文件用MAT或JProfiler分析大对象然后检查代码中是否存在内存泄漏比如静态集合类持有对象导致无法回收、数据库连接没有关闭、IO流没有关闭等。如果是配置问题可以调大-Xmx和-Xms参数但要记住调大堆内存只是缓解症状真正的问题还需要从代码层面解决。4. 编程题实战冒泡、快排与Lambda题4.1 冒泡排序从“会写”到“写对”用友笔试的编程题里考了冒泡排序题目要求是手写一个冒泡排序方法并对传入的整数数组进行升序排列。这道题看起来简单但实际写起来很容易出错。我自己的完整实现是public static void bubbleSort(int[] arr) { if (arr null || arr.length 1) { return; } for (int i 0; i arr.length - 1; i) { boolean swapped false; for (int j 0; j arr.length - 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; } } }这里有几个容易忽略的细节必须判断arr是否为null否则空指针异常直接崩掉。数组长度为0或1时可以直接返回不需要排序。外层循环只需要执行n-1次因为当n-1个元素都到达正确位置后剩下的一个自然也就位了。内层循环的范围是0到n-1-i因为每轮都会把当前范围内的最大值冒泡到末尾末尾的i个元素已经有序不需要再参与比较。swapped标志位是优化关键。如果某一轮内层循环没有发生任何交换说明数组已经有序可以直接break避免无意义的后续轮次。这个优化在笔试中是一个不错的加分点。我当时还试过把排序过程中数组的中间状态打印出来方便验证逻辑是否正确。如果你在笔记本上练习手写排序建议也养成这个习惯用具体的数组数据来验证你的实现比空想可靠得多。4.2 快速排序笔试题里的经典变体快速排序在笔试题中也是常客用友这套题里要求手写快排的核心逻辑。快排最重要的一点是理解分区函数的作用选取一个基准值pivot把小于等于基准值的元素放到左边大于基准值的元素放到右边然后递归处理左右两个子区间。我当时写的代码是public static void quickSort(int[] arr, int left, int right) { if (left right) { return; } int pivot partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot 1, right); } private static int partition(int[] arr, int left, int right) { int pivotValue arr[left]; int i left; int j right; while (i j) { while (i j arr[j] pivotValue) { j--; } while (i j arr[i] pivotValue) { i; } if (i j) { int temp arr[i]; arr[i] arr[j]; arr[j] temp; } } arr[left] arr[i]; arr[i] pivotValue; return i; }这里最容易出错的地方是分区过程中内层while循环的边界判断。两个内层循环都必须加上i j的条件否则i和j可能越过彼此导致分区结果错误。另一个坑是扫描方向先从右边找到第一个小于等于基准值的元素再从左边找到第一个大于等于基准值的元素然后交换。如果先从左往右会把一些本应属于右边的元素留在左边。快速排序的时间复杂度平均是O(n log n)最坏情况下退化为O(n²)比如对已经有序的数组做快排每次基准值都选到了最小或最大元素。笔试中如果你被问到如何优化可以提三点随机选择基准值、三数取中法、在子区间足够小时改用插入排序。4.3 Lambda与函数式编程大题用友2018这套笔试题出现了一道Lambda相关的编程题当时Java 8已经普及Lambda属于工作中要用的基础知识。题目大意是给定一个字符串列表要求通过Lambda表达式或Stream API过滤出长度大于3的字符串并转为大写最后按长度排序输出。我当时给出的答案是ListString words Arrays.asList(java, spring, ok, mybatis, go); ListString result words.stream() .filter(s - s.length() 3) .map(String::toUpperCase) .sorted((s1, s2) - Integer.compare(s1.length(), s2.length())) .collect(Collectors.toList()); System.out.println(result);这里有几个考点需要拆解filter方法接收一个Predicate函数式接口写法是s - s.length() 3这就是一个Lambda表达式。map方法接收一个Function函数式接口把字符串转换成大写。这里用了方法引用String::toUpperCase它是Lambda表达式s - s.toUpperCase()的简写形式。sorted方法接收一个Comparator函数式接口。这里用Lambda表达式定义了比较逻辑注意一定要用Integer.compare方法而不能直接返回s1.length() - s2.length()因为差值可能溢出。collect(Collectors.toList())把Stream转换回List这是终止操作不加这句话前面的操作都不会真正执行。顺便说一句很多人第一次接触Stream API时会觉得链式调用很难记住其实核心就一句话Stream操作分为中间操作和终止操作中间操作是惰性的不调用终止操作前面的操作都不会执行。filter、map、sorted都是中间操作collect、forEach、count都是终止操作。理解惰性求值这个概念Stream API就不难了。4.4 完整答题示例与踩坑提醒笔试编程题最怕的不是不会写而是写完了才发现编译不过或运行结果不对。根据我的经验手写Java代码时需要特别注意以下几个方面类名和方法签名必须和题目要求一致。用友笔试的编程题有时会给出方法签名比如public static int[] sortArray(int[] arr)如果你改成public void sortArray就算逻辑写对了也拿不到分。import语句容易被忽略。平时在IDE里写代码自动导入的import很多但笔试是手写ArrayList、List、Collectors这些类都需要手动import忘了写就会编译失败。边界条件一定要处理。数组为null、列表为空、字符串为null这些都要考虑至少不能抛出空指针异常。写排序算法时元素个数为0和1的特殊情况也要兜住。代码风格要整洁。变量命名、缩进、注释都会影响阅卷官的主观判断。面试官看一份乱糟糟的代码和看一份清晰规范的代码感受完全不一样。编程题还有一些额外的加分项比如在冒泡排序中加入swapped优化标志在快排中选择随机基准值这些都能向面试官证明你不是单纯背代码而是真正理解了算法。笔试题考察的不只是结果正确性还有你对细节的处理能力。5. 复盘与准备建议5.1 我看到的常见失分点刷完这套题结合后来在一些面试交流群里看到的讨论我发现大部分人的失分点其实非常集中整理出来供大家对照自查失分点具体表现规避方法字符串比较用判断字符串内容时使用没有用equals牢记比较引用equals比较内容数组/集合循环越界循环边界写成 arr.length或list.size()养成写 arr.length的习惯检查边界值Integer比较用在128以上比较Integer对象值包装类比较一律用equals手写排序不判空数组为null时直接访问下标代码开头先做空值和长度判断Lambda和Stream不会用写了filter却没有终止操作理解中间操作和终止操作的区别多线程概念混淆volatile保证原子性、run和start分不清用实验验证不要只背概念异常处理顺序错误子类异常写在父类异常后面先catch子类异常再catch父类异常5.2 给准备校招的同学的复习优先级如果你是在准备校招Java方向不要一上来就刷一堆偏题怪题先把基础打牢。我的建议是按照这个优先级来安排复习第一阶段Java基础语法包括运算符、类型转换、流程控制、数组、String、包装类。这些是笔试的基础盘分值占比最高。第二阶段面向对象包括封装继承多态、接口和抽象类、内部类、枚举。用友这类To B企业非常看重面向对象建模能力。第三阶段集合框架重点掌握HashMap的原理、ArrayList和LinkedList的对比、HashSet的去重机制。第四阶段并发与JVM重点掌握synchronized、volatile、线程状态转换、JVM内存区域、垃圾回收基本算法。第五阶段Java 8新特性包括Lambda、Stream API、Optional、新日期API。第六阶段算法编程冒泡、快排、二分查找、链表反转这类基础算法笔试编程题基本就是这些难度。面试准备过程中强烈建议自己动手写代码验证不要只看书或者看视频。“我以为我懂了”和“我真的能写出来”是两回事。比如Integer缓存、字符串常量池、finally与return的执行顺序这些知识点随便画个图都能看懂但如果没有自己敲一遍代码考场上遇到类似题还是会犹豫。5.3 用友笔试题的应对策略最后结合用友这套笔试说几个实战层面的应对策略第一不要在选择题上浪费太多时间。用友的选择题难度不高会就是会不会想太久也还是不会。建议一眼扫过去有把握的直接选拿不准的先标记等后面的答题时间有多余再回头想。第二程序输出题一定要动笔模拟。遇到程序输出题不要凭感觉在脑子里跑把变量表画出来一行一行执行尤其是涉及到循环、对象引用变化、字符串拼接时画表能大幅降低出错的概率。我自己的经验是用纸笔模拟出“当前变量值”和“堆中对象状态”两个维度基本不会漏掉关键步骤。第三编程题先写注释再写实现。拿到编程题先在代码块里写出大概的步骤注释比如“1. 判断空数组2. 外层循环控制轮次3. 内层循环比较相邻元素”。这样做的好处是让思路先结构化然后再往注释里填充具体代码即使最后代码没写完阅卷官也能看到你的解题思路。第四简答题不要只写结论要写理由。用友的简答题通常要求简述某个机制的原理比如“简述HashMap的扩容机制”。如果你只写“扩容是当元素个数超过阈值时重新分配桶数组”得分不会高。更好的答法是阈值怎么计算、扩容为什么要重新哈希、JDK 1.8如何通过高低位拆分优化转移流程。写清楚因果关系才能拿到高分。我在实际复习这套题的时候发现一个规律用友的Java笔试不会考特别前沿的东西像什么响应式编程、微服务治理、分布式事务这些几乎没有涉及它考的就是那些最核心、最基础、也最容易被忽视的Java知识。这其实反映了企业软件公司的人才筛选逻辑——先把基础扎实的人挑出来复杂的技术可以入职后再培养。所以如果时间有限与其去追一些炫酷的新框架不如把集合源码、并发基础、JVM内存模型这三大块吃透这套题你拿个不错的分数问题不大。最后再分享一个小技巧做笔试复盘时不要只看正确答案就算了把错题对应的知识点在源码层面追一下。比如当时我做错了一道关于HashMap红黑树转换的题就去翻了JDK源码里的treeifyBin方法把树化的两个条件彻底搞清楚了。这种“折腾到底”的习惯比刷十套卷子都有用。