ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java面试中那些容易被忽略的基础问题盘点

Java面试中那些容易被忽略的基础问题盘点 一段代码在面试官眼里是什么是经验、是功底也是漏洞。大多数人面试前刷题把精力放在算法、并发、分布式上却忘了Java最基础的那些语法特性恰恰是区分“会用”和“懂原理”的分水岭。你以为你会的实际上只是会写。今天这篇文章不聊高深框架专门盘点那些在Java面试中高频出现、却最容易被忽略的基础问题每个问题背后都是真实场景的坑。String看似简单处处连环坑先问一个new String(abc)创建了几个对象标准的答案是如果常量池中没有abc则创建两个对象一个在堆中一个在常量池。但如果你深究起来字节码层面有new指令、ldc指令还有String构造器的调用这里面的细节足够让多数人卡壳。更隐蔽的是String的substring方法在JDK6和JDK7的底层实现完全不同。JDK6里面substring会保留原字符数组的引用如果你截取一个超大字符串的一小部分那个大数组就不会被回收这算是一个经典的内存泄漏陷阱。很多面试官会问到这个点但实际上知道的人很少。字符串拼接的优化同样被忽略。在循环中直接使用拼接字符串编译器会生成StringBuilder但如果在循环体内创建StringBuilder每次循环都会new一个新对象这比循环外建一个StringBuilder再append要慢得多。别小看这个细节线上JVM的GC压力往往就是这么堆起来的。还有String.intern()的语义JDK6和JDK7把字符串常量池从永久代移到了堆导致intern方法的行为发生变化你能讲清PermGen和Metaspace的区别吗这些基础知识比你会背HashMap红黑树更考验功底。equals和hashCode一个约定两种命运不遵守equals和hashCode的约定你的对象在HashMap里就是个定时炸弹。两个对象equals返回true但hashCode不同HashMap会把他们放进不同的桶equals方法完全派不上用场。反过来hashCode相同但equals不同则会造成哈希冲突。这个约定几乎所有Java程序员都知道但你真的理解为什么吗更深一层的问题是为什么重写equals必须同时重写hashCode因为HashMap的get流程是先比较hashCode定位桶再用equals在桶内寻找。如果两个相等的对象hashCode不同那么第一个对象put的时候在桶A第二个对象get的时候却去桶B找等于白找。再往深挖为什么String类重写了equals和hashCode而StringBuffer没有因为String不可变重写的hashCode可以被缓存而StringBuffer可变一旦把hashCode缓存下来内容变了哈希值也该变维护成本太高。这背后影射的是不可变对象的设计思想。面试官如果追问你“hashCode为什么用31作为乘数”你能答出“31是奇素数且可以优化为移位减一”吗这些细节看起来是常识但很多人只是背答案。Java传参值传递还是引用传递“Java是值传递还是引用传递”这问题太经典了教科书都写清楚是值传递。但现实中的面试题往往这么出传一个List对象给方法在方法里list.add(...)原来的list变不变传一个User对象修改它的name属性原对象变不变答案是都会变。于是很多人就懵了不是说值传递吗这里的关键在于变量存储的是对象的引用值这个“值”本身就是一块内存地址。你把引用值复制了一份给方法参数两个引用指向同一个对象通过任一引用修改对象对象自然被改了。真正的坑在于“重新赋值”和“修改对象”的区别。你在方法里写list new ArrayList()外面的list不受影响因为你只是改了参数变量的引用指向。如果非要让外面感知到list被重新赋值只能通过返回新对象或者使用一个包装类来间接实现。大多数面试者可以解释清楚但一到写代码就犯错尤其是写交换函数时老觉得Java里能写swap。这种“值传递的引用幻觉”是所有C转Java的程序员最容易踩的坑。泛型一次编译处处擦除泛型擦除是Java泛型最大的伤疤也是面试官最爱挖的深坑。ListString和ListInteger在运行时是同一个Class对象都是List.class。所以你不能写if (list instanceof ListString)因为编译都过不去。更不能通过getClass来区分泛型参数。那么问题来了为什么Java选择擦除而不是像C#那样保留泛型历史原因是为了兼容老字节码。但擦除带来的一系列问题泛型数组不能直接创建ListString[]是不允许的因为数组运行时类型检查在擦除后会失效可能引发堆污染。泛型方法中的类型推断也容易被忽略。Collections.emptyList()如果左边不标类型返回的是ListObject。还有通配符? extends T和? super T的PECS原则生产者用extends消费者用super。这个原则理解起来不难但很多人只是记住“读用extends写用super”一旦问为什么这么设计就支支吾吾了。本质上是编译器为了保证类型安全把类型检查从运行时提前到了编译期。你还需要知道擦除之后桥方法是怎么生成的——子类泛型方法重写父类方法时编译器可能需要生成一个桥方法来维持多态。这些都是面试中很容易忽略的基础题。异常受检异常何时该用finally里没有return异常处理是Java基础里的老演员了但问得深了没几个人能答利索。首先受检异常和非受检异常在编译期就有不同的命运受检异常强制你捕获或抛出非受检异常是RuntimeException的子类需要程序员自己判断。可别小看这个区别Spring的Transactional默认只对RuntimeException回滚遇到受检异常却不会回滚。很多人事务失效就是因为抛出了受检异常。然后是try-catch-finally的执行顺序。如果try中有一个returnfinally会在return之前执行但如果finally里也有returntry中的return会被丢弃。这不是技巧这是坑。更隐蔽的是在finally中抛出的异常会覆盖try中抛出的异常。Java 7之后引入了try-with-resources会自动关闭资源但关闭方法抛出的异常会被抑制这时你可以从主异常的getSuppressed里拿到它们。很多人压根没用过getSuppressed这个方法。其实这些细节都在Java基础范围内但除非你踩过坑否则面试时很难想起来。static和初始化类加载顺序的暗流“静态代码块、构造代码块、构造函数执行顺序是什么”这种题很古老但依然常考。答案是父类静态块、子类静态块、父类构造块、父类构造器、子类构造块、子类构造器。但有个细节经常被忽略构造代码块是在每个构造器之前执行且每次实例化都会执行静态代码块只执行一次。如果你能加一个类变量赋值就变成了一道复杂的顺序题。更深入的问题为什么静态方法不能调用非静态方法这根本不是“静态方法不能调用”的问题而是静态上下文没有this对象无法确定调用哪个实例。同理静态方法中不能直接使用非静态字段。这些看起来简单但理解其背后的对象模型比死记结论有用。还有static final字段的初始化时机是编译期常量还是运行期常量如果是编译期常量使用它的地方会被直接替换成字面量即使你修改了原类的常量不重新编译使用方旧值还会存在。这个问题在模块化开发中很常见。volatile和线程安全的幻觉面试造火箭工作拧螺丝。volatile这个话题足以筛掉一大批人。volatile能保证可见性但不能保证原子性。i不是原子操作即使变量是volatile多线程下还是会丢数据。这个几乎人人会背可一追问“为什么可见性靠volatile就能实现”很多人只能答“涉及到Java内存模型”再具体就没了。实际上volatile通过内存屏障禁止了指令重排序并且在写操作后强制刷新到主内存在读操作前强制从主内存加载。另一个容易忽略的是volatile和synchronized在语义上的本质区别volatile是轻量级的同步它不提供互斥所以多个线程同时写同一个volatile变量时依然会出现race condition。还有一个冷知识volatile可以保证64位的long/double的读写原子性吗在Java规范里非volatile的long/double的读写不保证原子性因为分为高32位和低32位两次操作。但用了volatile后就强制原子了。很多人只知道“双重检查锁为什么要加volatile”是因为要防止重排序导致拿到未初始化对象但不知道底层原理。对象拷贝浅拷贝、深拷贝和偶数的坑拷贝问题在Java面试中出现的频率不低。Object的clone()方法是浅拷贝它直接复制值放到引用类型上就复制引用两个对象共享同一个引用。所以需要重写clone方法并调用super.clone()但这还不够如果你有数组或集合成员需要手动拷贝内部对象才能做到深拷贝。更高阶的做法是用序列化实现深拷贝但要注意transient字段不会参与序列化而且性能很差。这里有一个异常冷门的考点为什么clone方法在Object中是protected因为Java不希望所有的类都能被外部克隆如果你想让外部能调用clone必须实现Cloneable接口并把clone方法改成public。但Cloneable接口本身是空的没有定义任何方法它只是一个标记接口。这种设计其实是不太优雅的因为标记接口没有强制约束你完全可以实现Cloneable却保持protected的clone然后调用不了。这种接口的“标记”性质引出了另一个话题Annotation为什么是更好的标记机制包装类与缓存和equals的经典陷阱Integer的比较是很多初级程序员百思不得其解的谜题。Integer i1100, i2100i1i2返回true但i3200, i4200i3i4返回false。原因是IntegerCache缓存了-128到127之间的Integer对象。如果你用new Integer(100)创建一个对象那它就不在缓存里了就会返回false。这个缓存范围可以通过JVM参数-XX:AutoBoxCacheMax调整很多面试者都不知道这一点。同样容易被忽略的还有Character的缓存范围是0~127Byte的缓存范围是-128~127全部Short和Long都是-128~127而Float和Double没有缓存。为什么浮点类型不缓存因为它们不是整数且连续值太多缓存没有意义。这些基础知识虽然简单但如果你在面试时能讲出JVM参数调整缓存上限就已经超过了绝大多数候选人。接口与抽象类选错就出事故很多面试题问“接口和抽象类的区别”标准答案背得滚瓜烂熟。但一给具体场景就抓瞎。抽象类是模板设计的基础它的核心价值是“代码复用”接口的核心价值是“能力约定”是给外部的一个契约。Java 8之后接口可以写默认方法和静态方法Java 9有了私有方法接口和抽象类的界限变模糊了但设计意图没变。你在一个业务系统中滥用接口每加一个实现就要改所有工厂这种“接口污染”比不写接口更可怕。还有一个容易忽略的点接口中的字段默认是public static final的方法默认是public abstract的。这意味着接口中定义的字段是全局常量可以被实现类直接访问但如果你在实现类里写了一个同名字段不会报错但接口的字段需要加上接口名来访问。这种细节在实际代码中往往会引发困惑。更关键的是抽象类中的构造方法不能是private因为子类构造时必定调用父类构造器私有的构造器导致子类无法实例化。内部类静态与实例的边界内部类是Java面向对象设计的隐秘角落。非静态内部类持有外部类的隐式引用这个引用是导致内存泄漏的常见元凶。很多人写Android时在Activity里创建了一个匿名内部类结果让Activity被长期持有而无法回收。静态内部类则不持有外部类引用因此更安全。但面试官更爱问的是为什么非静态内部类里不能有static成员因为内部类需要依赖外部类的实例才存在而static成员属于类本身二者在生命周期上矛盾。如果你非要在内部类里定义static final的常量那必须是编译期常量基本类型或字符串的final字面量这样可以直接放常量池不需要依赖外部实例。匿名内部类还涉及“变量捕获”的规则。匿名内部类或者局部内部类引用的外部局部变量必须是final或者相当于finaleffectively final的。这是为什么因为Java语言规范规定局部变量是存活在栈中的而内部类是存活在堆里的栈的变量可能在内部类执行前就被销毁了。Java采取的方式是复制一份到内部类中为了保持复制后的值与外部变量的同步干脆要求它不能被修改。这个解释比“为了线程安全”更本质。lambda与Optional语法糖背后的坑很多公司的代码里已经开始大量使用lambda。但面试基础题不会只问你lambda怎么用而是问它的捕获语义。lambda表达式中的this不是指代lambda本身而是指代其外部类或对象。因为lambda本质是方法调用没有自己的this。另外lambda表达式中引用的外部局部变量也必须是effectively final和匿名内部类一样。但有个区别lambda在底层是invokedynamic调用并不像匿名内部类那样编译成class文件这涉及到方法句柄和调用点。Stream是另一个容易忽略的点它是一次性的多次消费会抛IllegalStateException。而且Stream是懒加载的不执行终结操作中间操作不会真正触发计算。但真正致命的是中间操作的“有状态”与“无状态”之分。distinct()和sorted()是有状态操作它们需要缓存所有元素而filter和map是无状态操作可以在一次遍历中处理。如果你在parallelStream里混合使用了有状态操作性能会退化得很难看这里面的原理是流式计算的内部状态。Optional更是一个让人又爱又恨的东西。很多人以为Optional就是替代null的但Optional的滥用会让代码更混乱。如果你用Optional.get()而不先检查等同于直接访问null如果你用Optional作为方法参数说明你的API设计有缺陷。正确的用法应该是作为返回值向调用者传达“结果可能缺失”的信号。Optional是值对象本身不可序列化它不能作为类的字段做持久化存储这一点经常被忽略。数组与集合协变和泛型的老账数组是协变的而泛型是不变的。这句话什么意思String[]是Object[]的子类所以你可以把String数组赋值给Object数组变量但ListString不是ListObject的子类型对泛型来说这是不安全的。数组协变是有历史原因的因为Java从1.0起就这么设计了为了类型安全数组在运行时可以做检查。String[]和Object[]的运行时类型是数组的真实类型所以你往Object数组里放Integer会抛ArrayStoreException。但泛型擦除后就没有运行时的类型信息所以泛型不允许协变。数组和集合在遍历删除时也有不同。在for-each遍历List时删除元素会抛出ConcurrentModificationException这在面试中几乎必考。但很多人不知道为什么。因为ArrayList的Iterator有一个modCount字段而集合自身有modCount每次修改集合都会让两者不一致。如果你用Iterator自己的remove方法它会同步更新expectedModCount因此不会抛异常。这个知识点虽然基础但一旦深入解释很多人就露馅了。序列化不是简单的implements Serializable序列化在分布式通信、缓存持久化中都常用但基础问题往往被忽略。transient关键字表示该字段不参与序列化这是最基础的用法。但你知道吗static字段也不会被序列化因为static属于类不属于对象。如果你想自定义序列化格式可以写readObject和writeObject方法。还有一个容易掉进去的坑serialVersionUID。如果类版本不匹配反序列化会抛InvalidClassException。不显式声明serialVersionUID编译器会根据类结构自动生成一个一旦类增加字段签名就会变化导致老的反序列化失败。所以规范要求显式声明。序列化会调用构造函数吗不会。反序列化是通过ObjectInputStream直接创建对象的它绕过构造器。这意味着你有没有对字段做非空校验反序列化中都检查不到。这个特性有时候被用来实现了一种“绕过构造器创建对象”的技巧面试官可能会问除了new还有什么方式能创建对象反射、clone、反序列化、Unsafe。这些都是基础但容易被忽略。类加载与双亲委派打破沙锅问到底类加载机制同样是重灾区。双亲委派模型的核心思想是类加载器收到加载请求时先委托给父加载器加载只有当父加载器加载不了时才自己尝试加载。这样一来java.lang.Object永远由启动类加载器加载保证了核心类库的安全。但如果你自定义了一个类加载器且不遵守双亲委派可以实现“类隔离”或“热替换”。典型例子就是Tomcat的WebappClassLoader为了隔离web应用而打破了双亲委派。为什么打破因为一个web容器里要跑多个应用每个应用可能依赖不同版本的同一个库让父加载器加载就会冲突。更基础的问题是两个类是否相等的条件是什么必须是同一个类加载器加载的同一个Class对象。即使两个类的字节码完全一样只要类加载器不同它们在运行时就是两个类instanceof也会失败。这个事实很多人不知道直到遇到ClassCastException才恍然大悟。还有getClass()和class字面量的区别Class.forName会触发静态初始化而getClass()只是获取运行时类型。这些细节都是Java基础的地基。自动装箱NPE在你不知不觉时发生自动装箱就是把基本类型转成包装类型拆箱就是反过来。但很多人不知道拆箱过程中会发生NPE。比如Integer i null; int i2 i;这行代码会抛空指针异常因为i在拆箱时调用i.intValue()而i是null。这种坑在实战中太常见了从map里get一个不存在的key自动拆箱成int直接NPE。更隐蔽的是三目运算符的类型转换陷阱。boolean flag true; Integer i 1; int j 2;那么flag ? i : j的结果是什么类型答案是int因为三目运算符会把Integer拆箱为int。如果你某个分支是Integer null结果是NPE。这个题目很经典但很多人栽跟头因为它涉及自动拆箱和类型提升的规则。包装类的equals和也容易混淆比如new Integer(1).equals(1)返回true因为equals方法会先比较类型再把int拆箱其实Integer.equals会接受Object类型参数你传1会自动装箱成Integer所以true。但new Integer(1) 1也是true因为两边一个是Integer一个是int触发拆箱。这些规则不算难但突然考你还是会错。所以下次面试官问你一个最基础的题目别急着回答先想想它背后有多少层窗户纸。能把这层纸捅破你离offer就更近一步。基础题从来不是送分题它是筛选器。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进