ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java面试中那些容易被忽略的细节,值得提前自查

Java面试中那些容易被忽略的细节,值得提前自查 面试官问你“ArrayList和LinkedList区别”时你背完复杂度后他补一句“那ArrayList扩容时右移一位代表什么”很多人会卡住。真正的分水岭就在这些细节里——它们散落在源码、JLS和IDE的警告中不背八股的人发现不了背了八股的人经不起追问。本文就围绕Java面试里那些容易被忽略的细节给你一份“自查清单”。集合框架里那些“扩容与视图”的暗坑ArrayList的扩容不是简单变成两倍而是int newCapacity oldCapacity (oldCapacity 1)也就是1.5倍。但这里有个隐藏分支如果oldCapacity右移后加上自身仍然小于minCapacity比如初始容量为1扩容需求为2时101不够就会直接用minCapacity作为新容量。很多人只看“1.5倍”这个结论却没有读grow方法里这个兜底逻辑。另外无参构造器创建的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA第一次add才会分配默认容量10而不是new的时候就有10个元素的空间。Arrays.asList()也有一个公认的“盲区”。它返回的不是java.util.ArrayList而是Arrays的内部私有静态类继承AbstractList底层仍引用原数组。你不能add和remove甚至修改原数组会直接改变这个“列表”的内容。更隐蔽的是如果你把asList的结果再传给一个接受List的方法而那个方法内部做了结构修改会抛UnsupportedOperationException。建议用new ArrayList(Arrays.asList(...))复制一层。subList更是“视图”的典型。subList并非快照而是原List的窗口。当你对原List做结构性修改后再遍历subList会触发modCount不一致而抛ConcurrentModificationException。很多人以为subList是个独立副本这是致命的误判。如果读者正在做集合相关面试题建议把“视图”和“复制”彻底分开。HashMap里的数字规则也需要逐一核对。负载因子0.75不是随意拍的它是空间与时间权衡后的经验值。树化阈值8也有前置条件链表长度大于8且数组长度不小于64时才转红黑树否则优先扩容。如果只背“大于8转红黑树”面试官很容易用一个“数组长度16链表长度9”的场景让你当场做题。另外容量总是2的幂是通过tableSizeFor实现的put时用(n-1)hash计算下标这样既快又能减少碰撞。hash扰动是另一个易漏点。key.hashCode()先被高16位与低16位异或再参与下标计算。这意味着即使两个hashCode低位相同它们的高位差异也能被引入从而分散到不同桶。这个函数是“扰动函数”名字可能不记得但原理要能讲清楚。如果你注意到这些说明你确实读过源码。并发场景下HashMap根本没有“安全”之说。JDK1.7头插法扩容可能造成环形链表JDK1.8改成尾插法解决了这个死循环但并发put仍会丢失数据。很多人以为JDK8之后HashMap并发安全了这完全是错觉。要并发安全请用ConcurrentHashMap它用的是CASsynchronized锁桶而不是整表锁。并发多线程里那些“临界状态”的迷思volatile最容易被忽略的是它的“引用”语义。volatile保证引用本身可见且禁止相关重排序但不保证引用指向的对象的内部状态对所有线程可见。如果你声明private volatile ListString list;另一个线程对这个list执行addvolatile并不能保证add之后的元素对其他线程立即可见。还有volatile不保证原子性这个大家都知道但为什么很多人还误用volatile做计数器因为他们把“可见”错误理解为“同步”。synchronized的锁升级是JVM面试的常客但很少有人说清楚“版本差异”。偏向锁在JDK15开始默认禁用因为它的维护成本在现代并发场景中反而成了负担。如果你还在背“无锁、偏向锁、轻量级锁、重量级锁”四步曲请补充一句“高版本JDK默认偏向锁关闭”。轻量级锁通过CAS把Mark Word中的锁记录替换失败则膨胀成重量级锁重量级锁依赖操作系统监视器会涉及用户态内核态切换。ThreadLocal的坑集中在内存泄漏上。ThreadLocalMap的Entry以WeakReference引用ThreadLocal但value是强引用。当ThreadLocal对象不再被强引用时key变成null但value依然被Thread持有如果线程长期存活比如线程池value永远无法回收。这就是为什么规范说用完要remove()。另一个冷门细节是InheritableThreadLocal只能传给子线程而且是在创建子线程时拷贝如果你在线程池中复用线程InheritableThreadLocal的值并不会随每次任务更新这在传递TraceId时坑过很多人。wait和sleep的区别看似简单但“锁的释放”只是表面。wait()是Object方法必须在同步块或同步方法中调用释放锁并等待sleep()是Thread静态方法不释放锁。更微妙的是Thread.interrupted()会清除中断标志而isInterrupted()不会。如果你不知道这一点实现一个响应中断的循环时就可能出现永不退出的情况。JVM对象与类加载的“次元壁”对象创建流程new指令先检查类是否已加载然后分配内存接着把内存初始化为零值再设置对象头最后执行构造器。大部分人都漏了“初始化为零值”这一步可这正是字段默认值能直接使用的根本原因。在没有执行构造器之前对象的最后一个字段已经是0只是你没看到。在并发分配内存时JVM用CAS和TLAB两种方式来保证线程安全。对象头大小在这种题中频繁出现。64位JVM开启压缩指针时普通对象头12字节Mark Word 8字节 Klass Pointer 4字节数组对象头16字节额外4字节长度。一个只含int字段的对象对象头12416字节正好对齐但有两个int字段时12820对齐后变成24字节。这些问题能帮你估算几百万个对象到底吃掉多少堆很多jvm调优经验都是从这里推出来的。类加载双亲委派的“好”大家都知道但“破”的时机才是考点。JNDI、JDBC、各种SPI都需要打破双亲委派用线程上下文类加载器加载外部实现。Tomcat对每个web应用建一个独立的webapp类加载器优先加载应用自身目录的类这也是打破。还有一个细节ClassLoader.loadClass默认不会解析类真正连接阶段比如验证、准备、解析发生在JVM内部并不由loadClass管控。字符串常量池的位置也变了。JDK7以后字符串常量池从方法区迁移到堆中于是intern()的语义产生了很多历史题目。比如String a new String(aa).intern();返回的是池中的引用但如果是String b new String(aa) b; b.intern();在JDK7里可能返回b对象本身的引用。这种差异只有结合“常量池在堆”才能解释清楚。异常处理和资源关闭的“B面”try-with-resources在Java 7出现要求资源实现AutoCloseable。资源关闭顺序是逆序也就是后打开的先关闭。如果try块中抛了异常同时资源关闭也抛异常关闭异常会被记为“suppressed”附加到原始异常上而不是覆盖它。很多人知道finally里不能冲掉return值但说不好Suppressed机制其实这是对异常有效性的重要保障。finally中的return会吞掉try中的return。如果你在finally里写了return那么try里任何返回值都会被忽略同时try里抛出的异常也会被吞掉。但如果你在finally里修改一个外部变量而return的是try里已经计算好的局部变量那修改不会影响返回值。这个技巧在代码审查中经常出现却很少有人真正去跑一遍验证。catch多种异常时catch (A | B e)中的e隐式final不能重新赋值。这可能是Java 7出来以后仍有很多人在用的“冷知识”。此外多catch的顺序必须从具体到抽象否则父类异常会把子类异常遮住编译器直接报错。面试官更喜欢的追问是“Error和Exception有什么区别”答案不是“Error不能捕获”而是Error代表JVM层面的严重问题如OutOfMemoryError、StackOverflowError通常不应该被业务代码捕获Exception是程序可处理的条件。Spring核心中的“代理人”与“事务”死角Bean的生命周期有个顺序容易被忽略BeanFactoryPostProcessor在Bean实例化之前执行它处理的是BeanDefinition而BeanPostProcessor围绕Bean实例的初始化和销毁做前后处理。很多人把BeanFactoryPostProcessor和BeanPostProcessor混为一谈实际上一个管定义、一个管实例。还有InitializingBean的afterPropertiesSet和PostConstruct的先后顺序PostConstruct最先然后afterPropertiesSet然后init-method。这些顺序如果记不住就在纸上画一遍再写上场景。循环依赖的三级缓存确实经典。一级缓存存放完整Bean二级缓存存放早期暴露的Bean三级缓存存放ObjectFactory能生成代理对象。核心是提前暴露ObjectFactory让代理对象也能被循环引用。但构造器注入无法解决因为构造器执行前根本不存在“早期Bean”prototype也无法解决因为缓存只针对单例。如果你用Lazy注入构造器参数Spark会生成一个代理对象绕过循环依赖这个是很多人不知道的替代方案。Transactional的失效场景里最容易被忽略的是同类内部调用。一个类的方法A内部直接调用注解了Transactional的方法BB上的事务不会生效因为Spring事务基于代理内部调用绕过了代理。此外方法非public、异常被try-catch吞掉、rollbackFor默认只回滚RuntimeException和Error编译期异常默认不回滚都是高频考点。如果你在一个事务方法里自己catch了异常并返回了正常值Spring会见不到异常于是提交事务数据就出现了半成品。泛型擦除与反射的“幻觉”Java泛型是编译期擦除ListString和ListInteger运行时class相同都是List.class。但“完全擦除”是不准确的类的Signature属性仍然记录了泛型声明所以Gson可以通过new TypeTokenListString(){}用匿名内部类捕获这个签名。反射方法getGenericReturnType也能读到泛型信息。这个区别解释了为什么你没法在运行时判断一个List是否为String类型但可以判断一个方法返回的是不是ListString。泛型数组的坑new T[]编译不过因为数组是协变的而泛型是不变的。你只能先创建Object数组再强制转换(T[]) new Object[n]但这会触发unchecked警告。为什么编译器这么顽固因为数组运行时知道它的实际类型而泛型擦除后JVM无法保证放进数组的对象类型安全所以干脆禁止直接创建。面试中能讲到这一层比答案本身更值钱。IO/NIO里“文件与网络”的边界NIO的全称是New IO而不是Non-blocking IO。非阻塞只是NIO的特性之一它还包括Selector、Buffer、Channel。Selector的select()返回的是就绪通道数量而且selectedKeys需要手动remove。如果不remove key下一次select会再次触发同一个就绪事件导致死循环。这是NIO编程最常见的问题之一也是面试官爱问的“你写过NIO吗”的测谎题。零拷贝也常被误解。零拷贝不是完全零拷贝而是避免了用户态和内核态之间多余的CPU拷贝。在Java中使用FileChannel.transferTo()底层走sendfile数据从内核文件页缓存直接发给网卡省去了读入用户缓冲和写出用户缓冲两次拷贝。但DMA拷贝依然存在。如果面试官追问“什么时候不能用零拷贝”可以提传输的数据必须已落盘且不能被修改某些场景还需要同步。文件路径分隔符也有讲究。File.separator是平台相关的路径分隔符Windows是反斜杠Unix是正斜杠File.pathSeparator则是多路径之间的分隔符Windows分号Unix冒号。在Spring的ClassPathResource里路径必须用斜杠因为classloader的url都是正斜杠你如果用File.separator去拼classpathWindows上会找不到资源。这种细节只有被坑过的人才会注意。JDK新版本里“大家都听过但没读过”的特性Java 8的default方法允许接口有实现但多接口同名default会冲突。一个类同时实现两个接口且两个接口有相同签名和默认实现该类必须手动重写否则编译错误。这很反直觉因为人们以为接口增加默认方法是纯粹向后兼容实际上它也给多继承引入了新的“菱形问题”。还有一个lambda捕获规则lambda外部的变量必须是effectively final也就是没有被重新赋值。如果你写了int i0; i1; Runnable r()-System.out.println(i);编译直接失败。Java 10/11的var局部变量类型推断听起来简单但有几个禁地。var不能用于字段、方法返回类型、catch参数也不能用于lambda参数除非显式写出类型。Java 11允许var出现在lambda参数中但必须带括号比如(var x) - x而不是var x - x。很多人简历上写“熟练Java 11”却在这类语法细节上栽跟头。还有var不会改变运行时的静态类型它只是编译器推断的语法糖。Java 14的record和switch表达式可以聊聊“record的字段是private final整个类final且继承java.lang.Record”。record不能显式继承其他类也不能被其他类继承它天生就是数据载体。switch表达式用-替代冒号且表达式必须穷举所有分支或用default否则编译不过。对比传统的switch语句可以没有default这是两者的核心差异。这些变化虽然小但能体现你是否真的跟进新版本还是在背简历。面试中那些容易被忽略的细节真正让人拉开差距的是你能不能把你用过的东西往深挖一层。背熟“ArrayList是数组”的人很多能说出grow兜底逻辑的人很少。建议你在面试前把常用的API源码翻一翻尤其注意那些“否则”“如果不够”“异常抑制”等角落。每一个看似多余的判断都是另一个面试题的答案。与其背二十个八股结论不如把一个结论的质疑链完整走一遍。这样的自查才是最有含金量的准备。
返回列表