Java面试中那些容易忽略的基础知识点梳理
你写了十年JavaHashMap源码倒背如流JVM调优参数信手捏来以为自己面试稳了。直到面试官轻飘飘问了一句“重写equals()为什么必须重写hashCode()不重写会发生什么”你愣了一下脑子里闪过“因为哈希表需要”但深入说不清楚。面试结束你意识到自己栽在了最基础的地方。这不是个例。Java面试中80%的淘汰都发生在那些你“以为自己会但实际模糊”的知识点上。很多开发者把精力花在炫酷的微服务、高并发框架上却忽略了语言本身的底层契约。今天我们就把这些容易被忽略的基础点一个一个揪出来说清楚。equals和hashCode一个都不能少Object类的equals默认比较内存地址而hashCode返回的是对象的内存地址转换后的整数。一旦你重写了equals比如根据ID判断用户是否相等就必须同时重写hashCode保证“两个相等的对象hashCode必须相等”。忽略这个契约的后果很严重把对象放进HashSet或HashMap时如果equals相等但hashCode不同同一个对象会被放入不同的桶导致重复数据甚至get不到值。更隐蔽的是如果你只重写了equals而没重写hashCode当对象被用作HashMap的键时明明两个逻辑相等的key却因为hash不同而指向不同的Entryset里也能同时存在两个相同逻辑的对象。黄金法则重写equal时永远重写hashCode否则HashMap会把你扔进地狱。String.intern()字符串常量池的隐形炸弹String是面试高频区但很多人只记得“String不可变”和“字符串常量池”。真正能分出水平的是intern()方法的细节。当你调用abc.intern()它会去常量池找有没有字面量等于abc的字符串有则返回引用无则把当前字符串放入常量池并返回引用。有一个经典坑题String s1 new String(1); s1.intern(); String s2 1; System.out.println(s1 s2); // false (JDK6/7/8 结果不同)实际上JDK7以后常量池移到了堆intern()行为发生变化。new String(1)会在堆创建两个对象常量池中的1和堆上的s1s1引用堆上对象而intern()返回常量池中的引用所以s1s2为false。如果换成new String(1).intern()那又是另一番景象。String.intern()是面试官最爱挖的坑不理解底层实现很容易掉进去。自动装箱与Integer缓存比较的陷阱Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false这个结果让很多人意外。原因在于自动装箱会调用Integer.valueOf()而该方法默认缓存了-128到127的值。超出这个范围会new新的Integer对象。面试中扩展的问题还包括缓存范围是否可以修改当然可以通过JVM参数-XX:AutoBoxCacheMax200就能调整。永远不要用比较两个Integer对象除非你确切知道它们在缓存范围内。正确做法是用equals()或直接拆箱比较int值。类似的缓存陷阱也存在于Short、Long、Byte、Character0-127和Booleantrue/false。Double和Float没有缓存因为浮点数无限不存在有限个缓存。静态方法能被重写吗——方法隐藏的诡异现象很多人答不能。面试官追问那下面这段代码输出什么class Parent { public static void staticMethod() { System.out.println(Parent static); } public void instanceMethod() { System.out.println(Parent instance); } } class Child extends Parent { public static void staticMethod() { System.out.println(Child static); } public void instanceMethod() { System.out.println(Child instance); } } Parent p new Child(); p.staticMethod(); // Parent static p.instanceMethod(); // Child instance静态方法调用看引用类型实例方法看实际类型。这就是方法隐藏Hiding不是重写Override。你以为是多态其实静态方法属于类编译时就确定了。忽略这个细节会在代码中写出不直观的逻辑。切记静态方法不存在多态永远不要用父类引用调用子类的静态方法那样只会暴露你的基础不牢。finally中的return别让异常处理成灾难public int test() { try { return 1; } finally { return 2; } }输出多少2。finally块中的return会覆盖try或catch中的return。更可怕的是如果finally里return会吞掉try/catch抛出的异常。假设try块中抛出了Exception但finally块里return了一个值那么异常就被无声无息地吃掉了调用者还以为一切正常。public void work() { try { throw new RuntimeException(error); } finally { return; // 异常被吞 } }在finally中写return是反模式应该被代码审查工具直接禁掉。正确的做法是不要在finally里使用return如果需要清理资源用try-with-resources替代。泛型擦除你以为的泛型在运行时并不存在Java的泛型是在编译时实现的运行时类型信息会被擦除Type Erasure。这意味着ListString和ListInteger在字节码中都是List只能看到原始类型。由此带来一系列限制不能instanceof泛型类型if (list instanceof ListString)编译报错。不能创建泛型数组new T[10]报错因为运行时不知道T是什么。不能使用new T()因为类型参数无法被用于实例化。泛型擦除最容易被忽略的后果是方法重载时的冲突。比如你定义两个方法void foo(ListString)和void foo(ListInteger)在编译后签名是一样的无法共存。另一个陷阱桥方法Bridge Method。当子类实现一个泛型接口时编译器会生成桥方法来保持多态这可能导致反射调用时的意外行为。泛型擦除意味着你的类型检查只有在编译期有效运行时必须自己处理类型转换异常。双亲委派模型破坏者ContextClassLoader面试常问类加载机制大多数人都能说出双亲委派先请求父类加载器父类无法加载才自己加载。但面试官随即问什么时候需要打破双亲委派怎么打破现实中最常见的破坏是线程上下文类加载器Thread Context ClassLoader。JNDI、JDBC、JPA等SPI机制都用到了它。因为JNDI的核心类在rt.jar中由Bootstrap ClassLoader加载但JNDI要加载的驱动实现如MySQL驱动在classpath里Bootstrap ClassLoader无法直接加载。解决方案是让线程上下文类加载器来加载父类加载器主动请求子类加载器去加载类这就破坏了双亲委派的“先父后子”原则。应用服务器如Tomcat为了隔离不同应用的类也会打破双亲委派使用WebAppClassLoader优先加载自己WEB-INF/classes下的类。双亲委派模型不是万能的当遇到SPI、热部署、模块隔离时必须理解如何安全地打破它。volatile不是银弹不能保证原子性volatile关键词保证可见性和禁止指令重排但不保证原子性。这是基础中的基础却经常被高并发面试者翻车。private volatile int count 0; public void increment() { count; }多个线程并发调用increment时count不是原子操作它包含读、改、写三步。即使volatile保证了读的可见性和写的原子性单次写但整个操作不是原子的结果可能小于预期。volatile只能保证单个读/写操作的原子性不能保证复合操作的原子性。要解决必须用synchronized、Lock或AtomicInteger。还有一个易忽略的点volatile修饰引用类型时只能保证引用本身的可见性不能保证引用指向的对象内部字段的可见性。面试官可能会问一个volatile的HashMap线程安全吗不安全因为HashMap内部的链表结构变动可能对另一个线程不可见。ThreadLocal内存泄漏弱引用也不是万能的ThreadLocal被广泛用于保存线程上下文但它有一个经典陷阱——内存泄漏。ThreadLocalMap的Entry继承自WeakReferencekey是弱引用value是强引用。当ThreadLocal对象不再被外部强引用时GC会回收这个Entry的key弱引用但value依然被Entry强引用。只要Thread一直存活比如线程池中的线程这个Entry就无法被回收导致value对象内存泄漏。解决方案每次使用完ThreadLocal后必须调用remove()清除。但很多人图方便把remove写在finally块里或者干脆不写以为GC能搞定。事实上只有调用了removeentry才会从map中移除否则就等着内存慢慢涨吧。ThreadLocal最佳实践一条原则——用完即删永不清零。枚举实现单例Java中最完美的单例方式单例模式你能说出几种饿汉、懒汉、DCL、静态内部类。但最优雅且被Java官方推荐的是——枚举单例。public enum Singleton { INSTANCE; public void doSomething() {} }为什么枚举单例是完美的因为它自动处理了三个痛点线程安全枚举类的初始化由JVM保证静态成员只会在类加载时初始化一次天然线程安全。防止反射破坏反射不能通过newInstance创建枚举实例会抛出异常。防止序列化破坏枚举在序列化时Java做了特殊处理反序列化时只会返回已存在的INSTANCE不会创建新对象。大多数开发者只会用懒汉式或DCL但一旦面试官追问“你的单例能防反射吗”就卡壳了。枚举单例是唯一能同时防御反射和序列化攻击的Java原生方案。try-with-resources资源自动关闭的前世今生Java 7引入了try-with-resources但很多人对它的认知停留在“语法糖”层面。面试官可能会问如果我同时关闭多个资源关闭顺序是怎样的try (FileInputStream fis new FileInputStream(a.txt); BufferedReader br new BufferedReader(new InputStreamReader(fis))) { // ... }资源会在try块结束后逆序关闭。也就是先关闭br再关闭fis。这个顺序很重要因为br可能缓存在内存中的数据需要flush到fis如果先关fis再关br可能导致数据丢失或异常。还有一个隐藏点try-with-resources会抑制异常。如果在try块中抛出了异常同时资源关闭时也抛出异常那么关闭异常会被抑制Suppressedtry块中的异常依然传播。你可以通过Throwable.getSuppressed()获取被抑制的异常。而在传统try-finally中如果finally抛出异常会覆盖try中的异常导致原始异常丢失。try-with-resources不仅简化代码还优雅地解决了异常屏蔽问题。finalize方法别用但面试可能问虽然业界早已弃用finalize但面试官有时会故意问finalize()方法什么时候被执行它有什么用潜在问题是什么finalize()在对象被GC判定为不可达后会调用一次但调用时机不确定甚至可能永远不调用。更糟糕的是如果你在finalize()中复活了对象让this又可达了那对象会逃离GC但下一次GC时不会再调用finalize()。这种肮脏手法只会导致内存问题和性能隐患。Java 9已经废弃了finalize()取而代之的是Cleaner和PhantomReference。绝对不要在业务代码中依赖finalize来释放资源用try-with-resources和AutoCloseable才是正道。面试官想听的是你对“对象终结机制”的理解而不是你真的会用它。PECS原则Producer Extends, Consumer Super泛型通配符的使用规则历来是面试难点。如果你写过类似List? extends T和List? super T大概率被卡过。PECS全称是“Producer Extends, Consumer Super”出自《Effective Java》。如果你要从集合中读取数据作为生产者用? extends T。如果你要向集合中写入数据作为消费者用? super T。如果既要读又要写不需要通配符直接用类型参数T。常见误用List? extends Number list new ArrayListInteger(); list.add(3.14);编译报错因为你不能向? extends Number添加除了null以外的任何元素因为具体类型未知。反过来List? super Integer list new ArrayListNumber(); list.add(5);是可以的因为你可以添加Integer或子类但读取时只能读到Object类型。记住PECS口诀泛型通配符再也不迷糊。面试中能清晰讲出PECS说明你对泛型的上界下界有深度理解。总结这些知识点之所以容易被忽略是因为它们藏在日常编码的角落平时不出问题一出现就让你措手不及。基础不牢地动山摇。当你把JVM调优聊得天花乱坠时面试官可能正在心里摇头“连finalize的坑都不知道还敢说做过三年Java”真正的资深工程师不是靠几个框架名头而是能把语言本身的契约烂熟于心。回到开头的问题——equals和hashCode到底为什么必须同时重写现在你不仅能说清楚还能举出HashSet重复键、HashMap get为null的具体场景。这就是Java面试中“容易忽略”的基础点背后真正的价值。