每年秋招季,后台都会收到一堆关于“Java笔试题”的私信。前阵子有个读者发来一套“用友2018秋招Java笔试题(四)”,我打开扫了一遍,发现这套题虽然年代有点久,但考察的知识点相当经典——集合、并发、JVM、Spring、数据库基本都覆盖了,而且很多题目放到现在的面试里依然能打。我用了一下午把整套题重做了一遍,又翻了当年的面经和讨论帖,今天就把这套题的完整拆解和答案思路整理出来,给正在备战秋招的同学做个参考。
先说结论:用友的笔试题整体难度中等偏上,不搞偏题怪题,但非常看重基础扎实程度。如果你能把这套题吃透,再去应付大部分中大型企业的Java后端笔试,基本不会慌。
1. 套题印象与备考背景
1.1 用友笔试题的特点
用友作为国内老牌企业管理软件厂商,技术栈偏传统Java后端,涉及大量企业级应用开发场景。这套2018秋招笔试题的特点是:
- 基础题占比高,约60%的题目考察Java SE核心知识,包括字符串、集合、异常、泛型、IO等。
- 并发与JVM是拉开差距的关键,这部分题目占比约25%,而且问法比较细,比如volatile的可见性原理、synchronized的锁升级过程。
- 框架与数据库考察偏应用,Spring IoC/AOP、事务传播行为、SQL优化这些内容都有涉及,但不会让你手写框架源码。
我当年备考时也刷过类似套题,一个很深的感受是:这类笔试题其实是在筛选“基础功扎实且能快速上手业务开发”的人。用友这类企业级软件公司,日常开发大量依赖Spring生态和数据库,所以笔试重点就集中在这些方向。
1.2 本文的组织方式
下面我按考点模块来拆解这套题,每个模块先交代题目考察的核心知识点,再讲清楚解题思路和背后的原理,最后补充一些实际开发中会用到的经验。你可以直接按模块刷,也可以从头到尾过一遍,看自己哪个板块最薄弱。
2. Java基础考点逐题拆解
2.1 字符串与常量池的经典陷阱
这套题第一道让我印象深刻的题目是:
String s1 = new String("abc"); 创建了几个对象?
这是Java面试的“老祖宗题”了,但每次换套卷子都会出现。答案是:如果常量池中没有“abc”,会创建两个对象——一个在堆中由new创建,一个在常量池中由类加载阶段创建;如果常量池中已有“abc”,则只创建一个堆对象。
类似地,还有一道关于String s1 = "abc"和String s2 = new String("abc")比较的题,考察的是==和equals的区别。这题的核心逻辑是:==比较的是内存地址,equals在String类中被重写为比较字符内容。
我把字符串相关考点做了个表,方便你复习:
| 代码 | 对象位置 | 说明 |
|---|---|---|
| String s = "abc" | 常量池 | 直接复用常量池对象 |
| String s = new String("abc") | 堆+常量池 | 可能创建1个或2个对象 |
| String s = s1 + s2 | 堆 | 字符串拼接在运行时生成新对象 |
| final String s = "a" + "b" | 常量池 | 编译期常量折叠 |
这里有一个我当年踩过的坑:用String的+做循环拼接,每循环一次就会创建一个StringBuilder和String对象,大量循环时内存开销非常大。所以实际开发中循环拼接字符串一定要用StringBuilder或StringBuffer,这也是笔试常考的点。
2.2 equals和hashCode的约定
这套题里有一道问“重写equals为什么必须重写hashCode”的题。标准答案有两个层面:
第一,hashCode和equals的约定是:如果两个对象equals相等,则hashCode必须相等;但hashCode相等,equals不一定相等。
第二,集合类如HashSet、HashMap依赖hashCode定位存储位置,如果两个equals相等的对象hashCode不同,它们在HashSet中会被当作两个对象存储,导致集合出现重复元素。
我建议你拿个具体例子验证一下:自定义一个Person类,只重写equals不重写hashCode,然后往HashSet里添加两个name相同的Person对象,你会发现set.size()返回2。这在业务中用用户ID做去重时会直接产生线上bug,所以笔试考这个点非常务实。
2.3 重载与重写的区别及易错点
还有一个基础题是“重载和重写的区别”。这个题本身不难,但有一个变体非常容易错:
以下代码输出什么?
public class Parent { public void method(String s) { System.out.println("Parent String"); } } public class Child extends Parent { public void method(Object o) { System.out.println("Child Object"); } }调用child.method("hello")会输出什么?答案是“Parent String”。原因是:Child中定义的是重载方法method(Object),不是重写;调用时编译器根据实参类型String选择最匹配的方法——父类的method(String)显然比method(Object)更具体。
这个知识点考察的是方法重载的“编译期静态分派”机制。编译器在编译阶段就确定了要调用哪个方法,依据是参数的静态类型。实际写代码时我经常见有人在这种问题上踩坑,尤其是定义接口和实现类的时候,方法签名不一致导致重载而非重写,行为完全不符合预期。
3. 集合与数据结构题目解析
3.1 HashMap的底层原理
用友这套题里HashMap是大户,至少出了3道:底层数据结构、put流程、扩容机制。
HashMap在JDK 1.8中的底层结构是“数组+链表+红黑树”。put一个键值对时,先通过key.hashCode()经过扰动函数(高16位异或低16位)得到hash值,再通过(n - 1) & hash定位到数组槽位;如果槽位为空则直接放入;否则遍历链表查找相同key,找到则覆盖值,找不到则尾插法添加新节点;当链表长度超过8且数组长度大于等于64时,链表会转成红黑树。
扩容则是当数组元素个数超过阈值(capacity * loadFactor,默认是16 * 0.75 = 12)时,容量翻倍,元素重新rehash到新数组中。
我见过很多面试攻略把HashMap源码背得滚瓜烂熟,但是对“为什么链表长度是8才转红黑树”这个问题说不出所以然。这里有个概率论背景:在随机hash函数下,链表长度达到8的概率极低(大约是千万分之六),所以设置8是为了兼顾时间和空间的平衡。如果频繁出现链表长度到8的情况,说明hash函数设计有问题,而不是红黑树阈值设低了。
3.2 ArrayList扩容机制与Fail-Fast
ArrayList相关题目也很典型。ArrayList的默认初始容量为10,每次扩容为原来的1.5倍(即newCapacity = oldCapacity + (oldCapacity >> 1))。当你调用add方法添加元素时,会先检查是否需要扩容,如果需要就通过Arrays.copyOf把原数组拷贝到新数组。
这套题里有一个扩展问:ArrayList和LinkedList的区别。除了常用的“数组 vs 双向链表”“随机访问 vs 插入删除”之外,还有一个隐藏考点——LinkedList实现了Deque接口,可以当作双端队列使用;ArrayList实现了RandomAccess接口,可以支持高效的随机访问。实际开发中如果只有遍历需求,两者性能差距不大,但如果频繁在头部插入删除,LinkedList优势明显,而如果你需要按下标随机访问,ArrayList几乎是唯一选择。
另外Fail-Fast机制也考了一道:为什么ArrayList迭代时不能直接调用list.remove()?原因是迭代器内部维护了一个modCount计数器,每次结构性修改(add/remove)都让modCount自增;迭代器在next()时会检查modCount和expectedModCount是否一致,不一致就抛ConcurrentModificationException。这个机制本质是一种“快速失败”的容错策略,而不是线程安全的解决方案。
3.3 ConcurrentHashMap的线程安全实现
并发集合的题目里考了ConcurrentHashMap和HashTable的区别。HashTable直接用synchronized锁整个表,线程安全但并发度极低;JDK 1.8的ConcurrentHashMap通过CAS + synchronized保证线程安全,锁粒度细化到数组槽位,读操作不加锁,利用volatile保证可见性。
针对ConcurrentHashMap最好再补充一个底层细节:put时,如果槽位为空,用CAS直接插入;如果槽位不为空,synchronized锁住槽位的头节点,再执行插入或更新。这种锁粒度比HashTable锁整个表要轻量得多,所以并发性能优秀。
如果你在准备面试背八股文,ConcurrentHashMap属于“必须能说清楚”的级别。建议顺着这个思路准备:从HashTable的不足引入,到JDK 1.7的分段锁,再到JDK 1.8的CAS+synchronized,把这个演进过程捋一遍,面试官一般不会追问太多。
4. 并发编程题目的深挖
4.1 volatile的可见性与有序性
用友这套笔试里有一道比较硬核的并发题:
volatile能保证原子性吗?为什么?
答案很明确:不能。volatile保证可见性和有序性(禁止指令重排序),但不保证原子性。以volatile int count为例,count++这个操作实际上是“读-改-写”三步,不是原子的。多个线程同时执行count++时,即使加volatile也仍然会有数据丢失的问题。要保证原子性,必须用synchronized、Lock或AtomicInteger。
实际开发中volatile的典型应用场景是“状态标志位”:一个线程修改flag,其他线程读取flag判断是否继续执行。这种场景下volatile就够了,不需要加锁,性能更好。
另外,volatile还有一个容易被忽略的语义:volatile写会保证该写操作及其之前的操作不会被重排序到写之后,也就是所谓的“写屏障”。这也是双重检查锁单例需要volatile修饰instance的原因——防止指令重排序导致对象未完全初始化就被其他线程使用。
4.2 synchronized的锁升级过程
另一道题是“synchronized的锁升级过程”。JDK 1.6之后,synchronized经历了从重量级锁到偏向锁、轻量级锁、重量级锁的优化。
具体过程是:无锁状态 → 偏向锁(一个线程反复获取锁) → 轻量级锁(多个线程交替获取锁,通过CAS自旋) → 重量级锁(竞争激烈,等待队列阻塞)。
这里有个细节值得展开:偏向锁在竞争激烈时会触发“偏向锁撤销”,这个过程需要等待安全点(SafePoint),是有性能开销的。所以在高并发场景中,可以考虑通过JVM参数-XX:-UseBiasedLocking关闭偏向锁。我记得Java 15之后偏向锁已经被默认禁用并逐步废弃,就是因为在高并发环境下它反而带来不必要的复杂度。
面试如果问到你这里,你还可以补充一句:锁升级是JVM自动完成的,程序员无法干预,但可以通过合理设计减少锁竞争,比如缩小同步代码块范围、减少锁的持有时间。
4.3 线程池的核心参数与拒绝策略
这套题里线程池考了一道典型的参数题:创建一个ThreadPoolExecutor需要哪些参数?
核心参数有7个:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(非核心线程空闲存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
执行逻辑是:当提交任务时,如果运行的线程数小于corePoolSize,直接创建新线程执行;如果大于等于corePoolSize,任务进入工作队列;如果队列满了且线程数小于maximumPoolSize,创建非核心线程执行;如果队列满了且线程数达到maximumPoolSize,执行拒绝策略。
四种拒绝策略分别是:AbortPolicy(默认,抛RejectedExecutionException)、CallerRunsPolicy(调用者线程执行)、DiscardPolicy(直接丢弃)、DiscardOldestPolicy(丢弃最旧的任务)。
实际开发中我用CallerRunsPolicy比较多,因为AbortPolicy会直接抛异常导致任务丢失,而CallerRunsPolicy相当于把任务“推回”给提交线程执行,能起到天然的限流作用。不过要注意,这种策略下如果提交线程是用户请求线程,可能会阻塞请求,需要评估是否接受这个延迟。
5. JVM与性能调优题目
5.1 JVM内存区域划分
用友这套题里JVM部分出了两道比较经典的区域划分题:
第一道是“JVM运行时数据区哪些是线程共享的,哪些是线程私有的”。堆和方法区是线程共享的,虚拟机栈、本地方法栈、程序计数器是线程私有的。Java 8之后方法区被元空间(Metaspace)取代,使用本地内存而非JVM堆内存。
第二道是“什么情况下会抛出OutOfMemoryError”。常见的场景有:堆内存不足(new大量对象且无法回收)、元空间不足(大量动态生成类)、栈深度超出限制(StackOverflowError虽然跟OOM不同,但也是栈相关异常)。我在搜索引擎的热搜词里看到有人搜“java: outofmemoryerror: insufficient memory”,说明这类问题在实际运行中非常常见。
从实际排查经验来看,堆内存OOM的排查思路一般是:先用jstat查看GC频率,再用jmap dump堆快照,最后用MAT或JProfiler分析大对象和泄漏路径。笔试不会考这么细,但面试问OOM排查时能说出来这套流程会非常加分。
5.2 GC垃圾回收算法与回收器
GC算法的题目是:描述一下标记-清除、标记-复制、标记-整理的原理和优缺点。
标记-清除是最基础的算法,缺点是会产生内存碎片;标记-复制将内存分为两块,每次只用一块,GC时把存活对象复制到另一块,适合新生代;标记-整理是把存活对象向一端移动,然后清理边界以外的内存,适合老年代,避免了内存碎片但移动对象有开销。
HotSpot默认的收集器组合是PS + PO(Parallel Scavenge + Parallel Old),但如果追求低延迟,比如互联网在线业务,一般会选CMS或G1。G1是JDK 9之后的默认垃圾回收器,特点是把堆划分为多个Region,可以设定预期的GC停顿时间,通过维护一个优先列表来回收收益最大的Region。
笔试如果考到GC,能把“哪些对象可以作为GC Roots”答全也非常关键:虚拟机栈(栈帧中的局部变量表)中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、JNI(Native方法)中引用的对象、JVM内部的引用(如基本数据类型对应的Class对象、常驻的异常对象)、所有被synchronized持有的对象、JMXBean等。
5.3 类加载过程与双亲委派模型
类加载的题目考察的是:类加载分为哪几个阶段?
加载、验证、准备、解析、初始化。其中准备阶段是为类变量分配内存并设置默认初始值(注意是默认值,比如int是0,static final常量是直接赋值的);初始化阶段是执行类构造器 方法,也就是静态语句块和静态变量赋值的时机。
双亲委派模型也考了一道:为什么需要双亲委派?
核心目的是防止核心类库被篡改。比如你自定义一个java.lang.String类,如果不用双亲委派,应用类加载器会直接加载你写的String,导致整个ClassLoader体系崩溃。有了双亲委派,加载java.lang.String时,应用类加载器会先委派给启动类加载器去加载JDK自带的String,从而保证核心类库的安全。
有一个实际开发中会出现的问题:很多框架(比如Spring、Tomcat)为了实现热部署,都会打破双亲委派模型,用自定义类加载器优先加载Web应用中的类。Tomcat的每个Web应用都有一个独立的WebAppClassLoader,它先加载自己应用中的类,加载不到再委托给父类加载器,这样才能实现不同应用间类隔离和热部署。
6. Spring与数据库考点
6.1 IoC与AOP的核心思想
用友的笔试题对Spring非常重视,这套题里至少出了4道Spring相关题目。
关于IoC(控制反转)与DI(依赖注入)的题,核心要答出三点:一是IoC容器负责对象的创建和生命周期管理;二是对象之间的依赖关系由容器在运行时动态注入,而不是通过new硬编码;三是这样做的好处是降低了组件之间的耦合度,方便单元测试和模块替换。
AOP(面向切面编程)的题则会问:Spring AOP的实现原理是什么?答案是动态代理。如果目标类实现了接口,Spring使用JDK动态代理;如果目标类没有实现接口,Spring使用CGLIB代理生成子类。JDK动态代理是基于接口的,要求目标类必须实现接口,且只能代理接口中定义的方法;CGLIB则是通过生成目标类的子类来代理,可以代理类中的非final方法。
实际开发中我踩过一个坑:Spring事务默认使用代理机制,如果类内部方法之间直接调用(比如A方法调用同类中的B方法,B上有@Transactional),事务是不生效的。这是因为代理对象拦截的是外部调用,内部this调用并不会走代理。解决办法是注入自身的代理对象,或者把B方法拆到另一个Bean中。
6.2 Spring事务的传播行为
Spring事务传播行为是笔试高频题。有几个必须掌握的:
- PROPAGATION_REQUIRED:默认,有事务就加入,没有就新建。
- PROPAGATION_REQUIRES_NEW:挂起当前事务,新建一个事务执行。
- PROPAGATION_NESTED:嵌套事务,基于Savepoint实现,内层事务回滚不影响外层事务已提交的部分。
- PROPAGATION_SUPPORTS:有事务就加入,没有就以非事务方式执行。
这里经常考一个场景:A方法(外层事务)调用B方法(REQUIRES_NEW新事务),B方法抛异常并回滚,A方法捕获异常未向上抛出,A事务最终是提交还是回滚?
答案是A事务正常提交,B事务独立回滚。这个机制在实际开发中可以用来做“子任务失败不影响主流程”的场景,比如批量导入一批数据,每一条记录单独一个事务,失败了只回滚该条记录,不影响整体。
另外一个高频考题是“事务失效的场景有哪些”。我总结几个常见原因:方法不是public的(Spring事务默认只能代理public方法);类没有被Spring管理(没有@Component等注解);通过this调用同类方法;异常被catch吞掉没有抛出去;数据库表不支持事务(比如MyISAM引擎);传播行为设置不正确。
6.3 SQL优化与索引失效
数据库题里必有一道SQL优化。这套题考的是:
索引为什么能提高查询性能?什么情况下索引会失效?
索引的本质是B+树数据结构,查询时通过树查找把O(n)的全表扫描降到O(log n)的树搜索。
索引失效的典型场景包括:对索引列使用函数或运算(如where age + 1 = 20);隐式类型转换(字符串列不加引号,导致索引列发生类型转换);like模糊查询以%开头;使用OR连接非索引列;联合索引不满足最左前缀原则。
这里我想多说一句:笔试只需要你答出索引失效的场景,但实际开发中判断SQL是否走索引,最靠谱的方式是执行EXPLAIN查看执行计划。重点关注type列(system > const > eq_ref > ref > range > index > ALL)和possible_keys、key列。如果看到type为ALL,说明全表扫描,SQL大概率有问题。
另外,关于最左前缀原则我举一个例子:联合索引(a, b, c),查询条件只有b和c时索引失效;只有a和c时,只有a能用到索引。设计联合索引时,要把区分度最高的列放在最左边,而且尽量把查询频率最高的列放前面。
7. 设计模式与综合题
7.1 单例模式的五种写法
用友这套题里设计模式考了单例模式。题目是:手写一个线程安全的单例。
最推荐的写法是双重检查锁(DCL)+ volatile:
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的可见性,防止指令重排序导致未初始化完成的对象被其他线程读取。
另一种推荐写法是静态内部类:
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }静态内部类利用了类加载机制保证线程安全:Holder类只有在getInstance被调用时才会被加载,初始化由JVM保证线程安全,而且做到了懒加载。
枚举单例也值得一提:枚举本身就是线程安全的,且天然防止反射和序列化破坏。在Effective Java中被认为是单例模式的最佳实践,但很多公司开发规范里不太用,因为可读性不够直观,我一般只在教材级别的项目里才会用到。
7.2 手写代码题的常见套路
这套笔试题的最后通常有一道手写代码题,常见题型是“手写一个冒泡排序”或“手写一个快速排序”。虽然简单,但很多人在考场上因为紧张写错边界条件。
快速排序的经典实现:
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 pivot = arr[left]; while (left < right) { while (left < right && arr[right] >= pivot) { right--; } arr[left] = arr[right]; while (left < right && arr[left] <= pivot) { left++; } arr[right] = arr[left]; } arr[left] = pivot; return left; }笔试写快排时注意三点:一是终止条件left >= right不能写成left == right,否则会栈溢出;二是partition中的等号不能去掉,否则遇到重复元素会进入死循环;三是递归深度为log n到n之间,最坏情况(数组已有序)时间复杂度退化为O(n²),可以考虑三数取中法优化。
如果你在搜索引擎看到“冒泡排序java”和“快速排序java实现”都是热搜词,说明这是绝大部分人的复习痛点。手写题没其他技巧,就是考前一周每天默写两遍,直到肌肉记忆。
8. 常见问题与避坑实录
8.1 我当年刷这套题踩过的坑
第一个坑来自字符串拼接。我在看字符串相关题目时,一直以为String的+操作是通过StringBuilder.append实现的,这个理解没问题。但我忽略了一点:如果在循环中使用+拼接,编译器每次循环都会new一个StringBuilder,性能极差。用友这套题的后续面试环节里,面试官就追问了“StringBuffer和StringBuilder的区别”,以及“什么场景下用StringBuffer”。
StringBuffer的方法是synchronized修饰的,线程安全但速度慢;StringBuilder是线程不安全的,但单线程下性能更好。结论是:大部分业务场景用StringBuilder就够了,只有在多线程共享同一个可变字符串对象时才用StringBuffer,但这种场景在实际项目中极少见。
第二个坑是HashMap的size和capacity搞混。capacity是桶的数量,size是实际存的元素个数,threshold = capacity * loadFactor是扩容阈值。有一道题问“HashMap默认大小是16,loadFactor是0.75,那放多少元素后触发扩容”,答案是13个(因为12就达到阈值了,第13个放进去的时候触发扩容)。我当年在这个临界值上犹豫了很久,后来自己写代码验证才发现,put第13个元素时才会触发扩容,但严格来说扩容发生的时间点是“即将放入第13个元素时”。
第三个坑是线程池的“核心线程数设置为多少合适”。这类题笔试不太考,但面试几乎必问。核心逻辑是:CPU密集型任务设置成CPU核数+1;IO密集型任务设置成CPU核数 * 2左右(实践中常用公式是CPU核数 / (1 - 阻塞系数))。更细一点的说法是:IO密集型的线程数可以设置为CPU核数的2倍甚至更高,因为线程大部分时间在等待IO,CPU利用率并不高。但这只是一个“起始参数”,真正上线前必须做压测,根据压测结果调整。
8.2 时间分配与做题顺序建议
这类笔试通常限时60到90分钟,题量大概20到30道选择题加1到2道手写题。我建议的时间分配策略是:
先说总原则:先把会做的题全部搞定,难题留到最后。
具体来说,前10分钟扫一遍所有题目,标记出自己完全不会的和没有把握的;然后从基础题开始做,遇到卡壳超过2分钟的题目直接跳过。JVM和并发题目放在中间做,因为这类题需要仔细思考,放在最后容易因为时间压力导致粗心。手写代码题至少留出15分钟,先写核心逻辑,再处理边界条件。
我见过很多同学在选择题上死磕,结果最后手写题时间不够,这是最亏的。选择题不会可以蒙,手写题写不出来就是零分,要分清权重。
8.3 拿到Offer后这些知识依然有用
最后说一点题外话。很多人刷笔试题只是为了过笔试,拿到Offer后就把这些内容扔到脑后。但以我做后端开发这些年的经验来看,这套题里70%以上的知识点在日常开发中都会反复用到:
- 集合的底层原理,关系到你写代码时能不能选对数据结构。
- 线程池参数设置,关系到生产环境接口的吞吐量和稳定性。
- JVM内存模型,关系到线上OOM排查时能不能快速定位问题。
- Spring事务传播行为,关系到数据一致性方案的设计。
- SQL索引优化,关系到慢查询治理和数据库性能。
我个人在实际操作中最深的体会是:刷题不是为了应付考试,而是借这个机会把Java基础体系重新梳理一遍。你会发现,很多平时写代码时“感觉应该是对的”但说不清为什么的地方,刷完题之后豁然开朗。这种“知其然也知其所以然”的感觉,才是刷题最大的价值。
如果你现在正在准备秋招,建议把这套题当作一个自测工具,做完之后把错题对应到自己的薄弱模块,再针对性地看源码、做笔记、刷相关题目。记住,面试官问的永远不会是题目本身,而是题目背后的原理和你的思考过程。