ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用友2018秋招Java笔试题复盘:基础、集合、JVM与多线程要点解析

用友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字段把添加的元素作为keyvalue统一是一个固定对象。因为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集合题目里代码输出题很喜欢考这一个场景ListString 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 CallableInteger task () - { Thread.sleep(1000); return 42; }; FutureTaskInteger 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面试题里属于必问。我用一个表来对比维度synchronizedvolatile作用保证原子性、可见性、有序性只保证可见性、有序性不保证原子性使用位置修饰方法或代码块修饰变量性能影响涉及锁的竞争与释放开销较大比锁轻量但也不能滥用适用场景多线程对共享变量进行复合操作如累加、读写后修改读多写少、写入新值不依赖旧值的场景如状态标志位一个经典场景是控制线程停止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时覆盖返回值这一点总是记不住、快排边界第一遍写错了。建议准备秋招的同学可以按这套卷子的模块做一张自测清单把每道题写成带“为什么”的笔记而不是只记答案。只要能做到拿到任何一道基础题都能讲出底层的链路笔试基本就稳了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表