用友2018秋招Java笔试题复盘:基础、集合、JVM与多线程要点解析
最近整理面试题库的时候,把用友2018秋招Java笔试题(一)完整做了一遍。所谓秋招八股文,网上总结很多,但真到笔试场景里,很多java基础题还是会因为“平时太熟”而翻车。这套题整体不偏不怪,几乎没有框架、中间件这类追新内容,绝大多数考点都落在java基础、java容器、面向对象编程java,外加一道手写算法、几道多线程和JVM相关题。对一个想做Java后端方向的人来说,这套卷子算是很典型的校招笔试样本。这篇文章就按我实际做题的顺序,把每类题背后的原理、易错点,以及刷完之后想明白的东西完整记录一遍,希望能给正在准备Java笔试的同学提供一个可参考的复盘思路。
1. 先看整体:这套卷子在考什么、难度如何
1.1 考点分布与分值倾向
整套题做完,我对它的第一印象是:考察面广,但每一块的深度都不至于逼你“背源码”。它的知识点分布大体可以分成下面几个模块:
| 考察模块 | 典型题目方向 | 难度 | 分值倾向 |
|---|---|---|---|
| Java基础语法 | 基本数据类型、运算符、表达式、标识符命名规则 | 低 | 单选/填空 |
| 面向对象编程Java | 封装继承多态、抽象类和接口、重载与重写 | 中 | 单选/简答 |
| Java常用类 | String、包装类、枚举、Object方法 | 中 | 单选/代码输出 |
| Java容器 | ArrayList、HashMap、HashSet、迭代器 | 中高 | 单选/代码输出 |
| 异常处理 | 异常的体系、try-catch-finally、受检异常与非受检异常 | 中 | 单选/代码输出 |
| JVM基础 | 内存区域、类加载、OOM场景 | 中高 | 单选/简答 |
| 多线程 | 线程创建方式、synchronized、volatile、并发问题 | 中高 | 简答/编程 |
| 简单算法 | 排序、数组处理、字符串处理 | 中 | 手写代码 |
这个结构其实很能反映传统软件企业的校招出题习惯:他们不太指望应届生一上来就精通分布式、微服务,而是想确认你有没有扎实的Java语言功底。因为框架可以入职后学,但语言基础不牢固,后面带起来会非常痛苦。
1.2 为什么校招笔试总爱考基础题
很多同学刷题时会有一种错觉:基础题太简单,不值得花时间。但用友这套题给我最大的提醒恰恰相反——它用大量“看起来简单”的题目,筛掉了概念模糊的人。比如String的==比较、Integer的缓存范围、HashMap的扩容时机,这些题目代码写出来都不到十行,但每个点背后都连着JVM内存、集合源码、语法规则一整条知识链。笔试题的难点不在于“会不会写”,而在于“能不能准确说出为什么”。如果你只是背过结论,而不是真正理解底层机制,换个问法立刻就会露馅。
1.3 做题时间分配的建议
这套题的题量属于中等偏上,我实际做完一遍大约是70到80分钟。如果是在真实笔试环境里,我的策略是:单选题不超过1分钟一题,不确定的先跳过;代码输出题控制在3到5分钟内,手算不出来就画栈帧图;两道算法题至少留出20分钟。毕竟笔试看的是总分,时间要优先花在能稳定拿分的题目上,不要为一道偏题死磕。
2. 基础语法与面向对象:题目看着简单,扣分点全在细节
2.1 基本数据类型与引用类型传参,考的是JVM栈帧意识
这套题里有一道很典型的考察方法参数传递的题目,大致是给出一个交换两个int变量值的方法,问main方法里输出什么:
public class SwapTest { public static void main(String[] args) { int a = 10; int b = 20; swap(a, b); System.out.println(a + "," + b); } public static void swap(int x, int y) { int tmp = x; x = y; y = tmp; } }答案当然是10,20。但很多人在考场上会犹豫,原因在于对“值传递”和“引用传递”的概念没有真正落到JVM层面。Java的参数传递只有一种:值传递。对于基本数据类型,传来的就是栈帧里的一个副本,你在swap方法里怎么改,都不会影响main方法的局部变量。对于引用类型,传来的是对象引用地址的一个副本,所以在方法里通过这个引用修改对象内部状态,外面的对象会感知到;但如果对引用变量本身重新赋值,外面是感知不到的。画一张简易的栈帧图,这个问题就非常清晰了。
更进阶的版本是Integer包装类的传参,绝大多数人会在这一步踩坑:
Integer i1 = 127; Integer i2 = 127; System.out.println(i1 == i2); // true Integer i3 = 128; Integer i4 = 128; System.out.println(i3 == i4); // false原因涉及Integer的内部缓存:IntegerCache默认缓存了-128到127范围内的对象。Integer i1 = 127走的是自动装箱,实际调用Integer.valueOf(127),命中了缓存对象,所以i1 == i2是true。而128超出缓存范围,每次valueOf都会new一个新对象,所以i3 == i4是false。这道题如果平时没注意过源码,笔试现场很容易凭感觉猜。
2.2 ==、equals与String常量池,一道题串起一串考点
关于String,这套题里几乎可以确认必有一道这样的代码输出题:
String s1 = "hello"; String s2 = new String("hello"); String s3 = "hello"; System.out.println(s1 == s2); // false System.out.println(s1.equals(s2)); // true System.out.println(s1 == s3); // true==比较的是引用地址,equals在String类中被重写为比较字符序列。字符串字面量在编译期就会放入常量池,s1和s3指向同一个池中的对象,所以s1 == s3为true。new String("hello")则在堆中额外创建了一个新对象,引用地址和常量池对象不同,所以s1 == s2为false。
如果再深挖一层,new String("hello")到底创建了几个对象?答案是:如果常量池中已经有“hello”,那只创建堆中一个对象;如果常量池里还没有,那么在创建堆对象之前,JVM会先把“hello”放入字符串常量池,这样就一共创建了两个对象。这种问法在笔试里也经常出现,要会分情况讨论。
还有一道常见变形题,涉及字符串拼接:
String a = "hello"; String b = "world"; String c = a + b;这里要分编译期和运行期。如果拼接的双方都是编译期常量,比如"hello" + "world",编译器会直接优化成"helloworld"放常量池;如果里面含有变量,底层会通过StringBuilder或StringBuffer做拼接,生成的新字符串在堆上,不在常量池。这也是为什么循环里做字符串累加性能差的原因,因为每次循环都可能new出新的StringBuilder和String对象。
2.3 重载、重写与多态,编译期和运行期要分开看
面向对象编程Java这个板块,最容易丢分的是“重载和重写结合多态”的题目。比如下面这种:
class Animal { public void eat() { System.out.println("Animal eat"); } } class Dog extends Animal { public void eat() { System.out.println("Dog eat"); } public void bark() { System.out.println("Dog bark"); } } public class Test { public static void main(String[] args) { Animal a = new Dog(); a.eat(); // 输出什么? } }答案输出Dog eat。因为eat()方法是重写,方法的分派在运行期发生,JVM根据对象的实际类型(Dog)来决定调用Dog的eat方法,这叫动态绑定。但如果你写成a.bark(),编译直接报错,因为编译器根据引用类型Animal只能确定Animal类中声明的方法,static类型决定了编译期的可见范围。
重载则是另一个维度:重载是同一个类中方法名相同、参数列表不同,它在编译期就能确定调用哪个方法。所以如果有个方法叫eat(Animal a)和eat(Dog d),调用时传Dog对象,编译期就会选择参数类型更具体的eat(Dog d)方法。这种“编译期重载 + 运行期重写”的组合题,在笔试题里非常常见,一定要把两个阶段分开考虑。
2.4 抽象类和接口,考试喜欢问“你怎么选”
用友这套题里有一道简答题,问抽象类和接口的区别。这类题属于Java八股文里的经典款,但答得好不好,区别在于能不能说出“设计意图”而不是罗列语法差异。
从语法层面看,抽象类可以有构造方法、普通成员变量、具体方法,可以含有抽象方法;接口在Java 8之后可以有default方法和static方法,Java 9之后可以有私有方法,但本质上接口强调的是“能力契约”,抽象类强调的是“血缘关系”。从设计角度看,当你需要定义“一类事物是什么”并且有公共状态要维护时,用抽象类;当你只需要定义“一类事物能做什么”,而且这个能力可能被完全无关的类实现时,用接口。比如Bird继承抽象类Animal体现分类体系,而Flyable接口让飞机和鸟都能拥有飞行能力。这个设计意图讲清楚,面试官才会觉得你是真的理解面向对象,而不只是在背表格。
3. 集合框架:HashMap 基本是必考大题
3.1 ArrayList与LinkedList:不要死记“读快写慢”
集合这里,用友的笔试题常见开头是“ArrayList和LinkedList的区别”。很多教程喜欢用一句话概括:ArrayList适合随机访问,LinkedList适合频繁插入删除。这句话对,但有误导性。
ArrayList底层是动态数组,通过索引访问的时间复杂度是O(1),在尾部添加元素也通常是O(1);但在中间插入或删除元素时,需要移动后面的元素,平均是O(n)。LinkedList底层是双向链表,头尾插入删除是O(1),但中间插入删除,前提是你已经持有对应节点的引用;否则按索引操作时,仍然需要从头遍历到目标位置,复杂度还是O(n)。所以如果笔试里问“在已有链表节点引用的情况下,频繁删除要不要用LinkedList”,答案是比ArrayList更合适;但如果只是按索引随机访问,LinkedList反而更慢。审题要看清楚“前提条件”,这是集合题最大的坑。
3.2 HashSet如何保证元素不重复
HashSet去重的底层其实是HashMap。它内部维护了一个HashMap字段,把添加的元素作为key,value统一是一个固定对象。因为HashMap的key不可能重复,所以HashSet天然去重。但去重判断有两个步骤:先调用元素的hashCode()计算哈希,找到对应的哈希桶;再调用equals()逐一比较桶内元素。只有hashCode相同且equals为true时,才判定为重复对象。很多人只知道要重写equals,忘了同时重写hashCode,结果导致两个逻辑相等的对象被放进了不同桶,HashSet完全失去去重能力。笔试题如果有“自定义类放进HashSet需要注意什么”,回答核心就是这一句:equals和hashCode必须一起重写,且满足equals相等的两个对象hashCode必须相等。
3.3 HashMap的哈希、冲突、扩容与为什么容量是2的幂
HashMap几乎是每一套Java笔试的必考题,用友这套也不例外。要答好HashMap,不能只背结论,建议按“存数据的过程”梳理一遍。
当调用put(key, value)时,HashMap先通过key的hashCode()计算出一个int值,然后对这个哈希值做一次扰动处理,让高位也参与后续的数组下标计算,目的就是减少哈希碰撞。之后根据hash & (table.length - 1)计算数组下标,这里的位运算等价于hash % table.length,但前提是数组长度必须是2的幂。这正是HashMap容量默认为16、指定初始容量也会自动调整成2的幂的原因:用位运算替代取模,性能更高;同时2的幂在扩容时的元素迁移也更好处理,可以按新增一个高位比特位决定元素落在原位置还是原位置+旧容量。
当key发生哈希碰撞时,JDK 8开始会先把冲突元素以链表方式挂在同一个桶上,链表长度超过8且数组长度大于等于64时,链表会转成红黑树,避免极端情况下查询退化成O(n)。如果数组长度没到64,即使链表超长,也只会先扩容而不是转树。这一点笔试很容易出判断题。
扩容机制也是高频问题。默认加载因子是0.75,也就是说当元素个数超过容量 × 0.75时,HashMap会触发扩容,把数组长度扩大为原来的两倍。为什么0.75?这是一个时间与空间的折中:加载因子太大,比如1.0,空间利用率高但哈希冲突严重,查询变慢;加载因子太小,比如0.5,冲突少但浪费大量空间。0.75在大多数场景下是均衡值,所以官方默认用它。
3.4 fail-fast机制:迭代器中诡异的ConcurrentModificationException
集合题目里,代码输出题很喜欢考这一个场景:
List<String> list = new ArrayList<>(); list.add("a"); list.add("b"); for (String s : list) { list.remove(s); }这段代码运行时会抛出ConcurrentModificationException,原因就是ArrayList内部的迭代器是fail-fast设计。迭代器内部维护了一个expectedModCount,初始值等于集合的modCount。modCount是结构性修改计数器,每次添加、删除元素都会加1。在迭代过程中,如果集合被外部修改(不是通过迭代器自身的remove方法),modCount和expectedModCount就会不一致,迭代器在下一轮next()时发现差值,就立刻抛出异常。这本质上是一种“快速失败”的安全机制,避免在有并发修改的情况下继续读到脏数据。如果确实需要在遍历中删除,应该使用Iterator.remove(),或者把要删除的元素收集起来循环结束后统一删除。
4. JVM内存与异常:笔试中无声的拉分题
4.1 运行时数据区划分与内存溢出场景
JVM基础题最常考的就是运行时数据区。每块区域存什么、为什么会溢出,要能背得准确。我按题目的常见问法整理过一个对照:
| 区域 | 存储内容 | 典型异常 |
|---|---|---|
| 程序计数器 | 当前线程执行字节码的行号指示器 | 无异常 |
| 虚拟机栈 | 栈帧,局部变量表、操作数栈、方法返回地址等 | StackOverflowError,栈深度不足;OutOfMemoryError,申请栈空间不足 |
| 本地方法栈 | 为native方法服务 | 同虚拟机栈 |
| 堆 | 几乎所有对象实例和数组 | OutOfMemoryError: Java heap space |
| 方法区/元空间 | 类信息、常量、静态变量、JIT缓存等 | OutOfMemoryError: Metaspace |
热词里有一个高频报错:java: outofmemoryerror: insufficient memory。实际中如果遇到这种提示,先别急着眼花缭乱加堆内存,要看清楚是哪个区域的问题。如果是Java heap space,优先排查是否有大对象没有释放、一次性加载了过多数据、或者存在内存泄漏;如果是Metaspace,优先排查类加载器是否过多、动态生成类是否过多。面试时能说出“先定位区域再调整参数”这个思路,比只背-Xmx要加分很多。
4.2 类加载过程与双亲委派机制
类加载考得多的流程是:加载、验证、准备、解析、初始化。这五步里面,“准备”阶段是最容易出概念的坑。准备阶段为类的静态变量分配内存并设置默认值,比如static int a = 100,在准备阶段a的值是0,到初始化阶段才会被赋值为100。但如果这个静态变量是编译期常量,比如static final int b = 100,准备阶段就会直接赋值为100,因为它在编译期就写入了ConstantValue属性。
双亲委派机制也属于高频简答题。简单说,类加载器收到类加载请求后,不会自己先去加载,而是先委托给父类加载器,一直向上委托到启动类加载器。只有父加载器无法加载时,子加载器才会尝试自己加载。这样做的好处是保证核心类库的加载一致性,比如你写一个java.lang.String类,不管类加载器怎么加载,最终都会由启动类加载器加载到rt.jar里的String,避免核心类被篡改。笔试如果要你简述,把这三句话讲清楚就够拿分了。
4.3 try-catch-finally的执行顺序与返回值陷阱
异常板块的代码输出题,十有八九考finally。看下面这段代码:
public static int test() { int i = 0; try { return i++; } finally { System.out.println("finally"); } }方法返回什么?答案是0。很多人答错是因为以为i++之后返回值会变成1。这里的关键是:return i++是先取i的当前值0作为返回值,然后才让局部变量i自增为1。finally里的代码会在方法返回前执行,但如果没有在finally里return,那么之前确定的返回值不会被改变。也就是说,返回值的确定发生在finally执行之前,finally里你修改普通局部变量(不是返回的那个值)不会影响最终结果。
但如果finally里也写了return,情况就不同了:
public static int test() { int i = 0; try { return i++; } finally { return 3; } }这时的返回值是3。因为finally中的return会覆盖try中的return,而且不推荐这样写,因为会造成返回结果难以预测,代码可读性很差。面试题里出现这种代码,多半是考察你是否知道finally的优先级和return的执行时机。另外还要注意一点:finally里的代码也不是绝对会执行,比如在try里调用System.exit(0)会直接终止JVM,或者发生死循环,finally都不会被执行到。
4.4 受检异常与非受检异常:throws和throw别混用
异常体系的基础题,核心是区分受检异常和非受检异常。受检异常是除了RuntimeException及其子类以外的Exception子类,比如IOException、SQLException,编译器强制要求处理,要么try-catch,要么在方法签名上声明throws。非受检异常是RuntimeException及其子类,比如NullPointerException、ArrayIndexOutOfBoundsException,编译器不强制处理,但程序运行时如果发生了却没有捕获,线程会直接终止。
笔试题里容易挖坑的是throws和throw的区别。throw是抛出异常对象,用在方法体内,后面跟的是一个异常实例:throw new RuntimeException("...")。throws是方法签名的一部分,声明这个方法可能抛出哪些异常,后面跟的是异常类型。比如public void readFile() throws IOException。注意,如果一个方法声明抛出受检异常,调用方要么处理,要么继续向上声明,编译器会检查这一点。非受检异常则不需要一定声明。理解了这两点,异常选择题基本不会掉坑。
5. 手写算法:冒泡排序和快速排序的考场正确姿势
5.1 冒泡排序:理解交换逻辑比背模板重要
用友这套笔试题的编程题,手写排序的概率很高,冒泡是基础中的基础。冒泡排序的思路是每次比较相邻两个元素,如果顺序错误就交换,这样每一轮下来,最大值会像气泡一样“冒”到数组末尾。核心实现如下:
public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { 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 tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) { break; } } }这里的优化点在于swapped标志位:如果某一轮内层循环没有任何交换,说明数组已经有序,直接break,提前结束。笔试时能写出这个优化,说明你理解冒泡的本质是“相邻元素比较交换”,而不是死记模板。冒泡排序时间复杂度是O(n²),最好情况下(原数组有序)优化后能达到O(n),空间复杂度O(1),稳定排序。
5.2 快速排序:边界条件是手写题最大的坑
快速排序用Java实现也是高频手写题。它的核心思想是分治:选一个基准元素,把数组分成小于等于基准和大于等于基准两部分,然后递归排序左右两边。常见写法:
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 tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } // 基准归位 arr[left] = arr[i]; arr[i] = pivotValue; return i; }写这个代码,最容易错的地方是边界条件。比如递归的终止条件是left >= right,不是left > right;内层两个while循环里都必须再加i < j条件,否则可能会越界;最后基准归位时,要把基准元素和arr[i]交换,返回的i就是基准元素在数组中的最终位置。这里建议在纸上跑一遍长度为5或6的数组,把每一轮i、j变化写出来,比死记代码有用得多。
5.3 手写题的追加追问:复杂度和稳定性
面试官经常会追问:冒泡和快排的时间复杂度分别是多少?稳定吗?为什么?冒泡时间复杂度平均和最坏都是O(n²),稳定。快排平均O(n log n),最坏O(n²),不稳定。快排的最坏情况其实是每次选择的基准都接近当前序列的最大或最小值,比如对已经有序的数组固定取第一个元素作为基准,会退化成O(n²)。所以实际工程中会用三数取中法、随机基准等方式优化。
笔试和面试的手写题,并不是“代码跑通就结束”,你需要对复杂度推导有概念。快排的O(n log n)可以这么简单理解:每一轮partition都要遍历数组,复杂度O(n),如果能稳定地把数组对半分,递归深度是log n层,所以总体是O(n log n)。如果能说出“基准选择越均分,性能越好”这一层,就会让面试官觉得你是真懂,而不只是背了结论。
5.4 白板编程的常见失误清单
结合我自己刷题和真实笔试踩过的坑,手写代码最常犯的错误有这几个:
- 没有判空。
arr为null或长度为0/1时,方法一进来就空指针或越界。 - 边界条件差一。比如冒泡的
arr.length - 1 - i,少个-1就可能数组越界;快排的left >= right写成left > right会漏掉单元素数组的终止条件。 - 交换变量时忘记用临时变量,或者用了位运算结果写错。
- 内层循环条件不收敛,比如快排两个内层while没有限制
i < j,导致指针越界。 - 写完之后不检查一遍特殊情况。如果笔试时间允许,第一时间拿一个有序数组和一个逆序数组在脑子里跑一遍。很多低级错误能被这个方法拦住。
这个清单我每次刷题都会看一遍,尤其快排这种分治递归的题,边界一旦出错,整个代码就是0分。
6. 多线程与并发:从创建线程到内存可见性
6.1 创建线程的几种方式,面试官真正想听的是什么
多线程板块,用友这套题里有一道简答题:创建线程有几种方式?常见答案通常是三种:继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask。实际上更完整的答法是四种,第四种是通过线程池提交任务。
直接继承Thread类的问题在于,Java是单继承,你继承了Thread就不能再继承其他类,职责上也不符合“把任务和线程分开”的思想。实现Runnable接口更灵活,但Runnable的run方法没有返回值,也抛不出受检异常。Callable接口的call方法有返回值,配合FutureTask可以拿到异步执行结果,适合需要返回结果的场景。而线程池(ThreadPoolExecutor)是生产环境最推荐的创建方式,因为它能复用线程、控制并发数、管理生命周期。面试官问这个题,不希望你只背三种方式,更希望你最后落点到“生产环境用线程池”这个结论。
简单示例:
// 通过Callable + FutureTask Callable<Integer> task = () -> { Thread.sleep(1000); return 42; }; FutureTask<Integer> futureTask = new FutureTask<>(task); new Thread(futureTask).start(); Integer result = futureTask.get(); // 阻塞等待结果6.2 线程安全问题的本质:原子性、可见性、有序性
线程安全的考题,核心逃不出三个词:原子性、可见性、有序性。
原子性说的是一个操作或多个操作要么全部执行且不被打断,要么全部不执行。比如i++在字节码层面不是一个原子操作,它包含读取i、加1、写回三步,多线程同时执行就可能产生丢失更新。解决办法是加锁、使用原子类比如AtomicInteger,或者用synchronized保证代码块的原子性。
可见性说的是一个线程修改变量后,其他线程能不能立刻看到。在多核CPU下,线程可能把变量缓存到本地内存,没有及时刷回主内存,导致其他线程读到旧值。volatile关键字就是解决可见性的,它能保证一个线程写volatile变量后,该变量会立即刷回主内存,并且其他线程读到该变量时会失效本地缓存重新从主内存加载。但volatile不能保证原子性,典型例子是volatile int count,多个线程执行count++依然会丢数据。
有序性说的是编译器和CPU为了优化可能对指令重排,多线程下重排可能导致不可预期的结果。内存屏障是限制重排的手段,synchronized和volatile在底层都会引入内存屏障来约束重排序。笔试如果只要求选概念,知道这三个词的含义和各自对应场景,基本就够用了;但如果要深问,就要能举出双重检查锁单例为什么用volatile修饰instance,因为防止的是“对象已经分配内存但还没初始化完成”这类指令重排问题。
6.3 synchronized与volatile,什么时候用谁
synchronized和volatile的对比,在Java面试题里属于必问。我用一个表来对比:
| 维度 | synchronized | volatile |
|---|---|---|
| 作用 | 保证原子性、可见性、有序性 | 只保证可见性、有序性,不保证原子性 |
| 使用位置 | 修饰方法或代码块 | 修饰变量 |
| 性能影响 | 涉及锁的竞争与释放,开销较大 | 比锁轻量,但也不能滥用 |
| 适用场景 | 多线程对共享变量进行复合操作,如累加、读写后修改 | 读多写少、写入新值不依赖旧值的场景,如状态标志位 |
一个经典场景是控制线程停止:
private volatile boolean running = true; public void stop() { running = false; } public void run() { while (running) { // do something } }这里用volatile就足够,因为running的写入不依赖它当前的值,且不需要保证原子性;如果用synchronized会显得笨重,性能也差。
6.4 一道经典多线程笔试题:两个线程交替打印1到100
这套题的多线程编程题,大概率会考“线程交替执行”类。最典型的就是两个线程交替打印1到100。可以借助synchronized + wait/notify实现:
public class PrintNumber { private static int number = 1; private static final Object lock = new Object(); public static void main(String[] args) { Runnable task = () -> { while (true) { synchronized (lock) { if (number > 100) { break; } System.out.println(Thread.currentThread().getName() + ": " + number); number++; lock.notifyAll(); try { if (number <= 100) { lock.wait(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } }; new Thread(task, "线程A").start(); new Thread(task, "线程B").start(); } }这里有几个细节值得说明。第一,wait()和notifyAll()必须在synchronized代码块内使用,否则会抛IllegalMonitorStateException。第二,wait()会让出锁并进入等待,所以两个线程能交替持有锁。第三,判断number > 100要放在while循环内部,并且在每轮唤醒后也要重新判断,否则可能打印出101。第四,捕获InterruptedException后务必恢复中断状态,Thread.currentThread().interrupt(),这是很多面试官会追问的细节。
如果笔试要求更高,还可以用LockSupport.park/unpark或者Semaphore实现,但原理一致:控制好线程的等待和唤醒时机。多线程编程题,关键不在于把代码写得多炫,而在于你能不能讲清楚“为什么这样写是线程安全的”。
复盘完这套题,我自己最直观的感觉是:基础不牢,地动山摇。很多题平时看答案解析觉得都会,但真到闭卷手写的时候,每一个细节都会暴露出来。我自己踩过的坑是HashMap的扩容条件记反了、finally里有return时覆盖返回值这一点总是记不住、快排边界第一遍写错了。建议准备秋招的同学可以按这套卷子的模块做一张自测清单,把每道题写成带“为什么”的笔记,而不是只记答案。只要能做到拿到任何一道基础题都能讲出底层的链路,笔试基本就稳了。
