ARTICLE DETAIL

资讯详情

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

Java面试十问:HashMap、并发、JVM与线程池核心原理精讲

Java面试十问:HashMap、并发、JVM与线程池核心原理精讲 又是一年金三银四后台找我做模拟面试的朋友明显多起来了。有人把简历投了一圈结果一上来自我介绍还行面试官随口问一句“HashMap 的 put 流程你讲讲”就开始东一句西一句拼凑听得出知识点是背的但没串成体系。这期本来是给我工作室学员整理的复习提纲整理完发现这十个问题几乎覆盖了最近两个月高频出现的 Java 基础面试点干脆再完善一下发出来。内容定位很明确给正在准备 Java 面试的人一份“面试官视角”的十问十答。每一问不光给标准答案还会告诉你面试官为什么这样问、追问会往哪个方向走、以及我平时模拟面试时比较认可的表述方式。适合正在备战校招、社招或者只是想系统性过一遍 Java 基础的人。好直接开整。1. 这十道题是怎么选出来的1.1 面试官问基础题背后的逻辑很多候选人一听到“八股文”三个字就皱眉觉得面试官在故意刁难。但换个角度想基础题是面试里信息量最大的部分。项目可以包装简历可以美化但一问到底层机制基本就知道这个人平时写代码是“用了就行”还是“真琢磨过”。面试官问基础题其实是在做两个判断。第一你有没有程序员的基本素养。Java 的 String、集合、并发、JVM这些是每天都要打交道的工具不要求你背源码但机制得懂个大概不然遇到线上问题连排查方向都没有。第二你有没有把知识串成体系的能力。比如问 HashMap好一点的候选人会直接讲到扩容、树化、为什么线程不安全顺带还能扯到 ConcurrentHashMap这就是体系感。只背单个答案、问一句蹦一句的大概率是临时突击面试官心里会打一个问号。所以我的态度很明确八股文该背还是得背关键是要理解为背后的“为什么”。你真正理解了面试的时候自然能讲出自己的节奏而不是像复读机一样吐词条。1.2 十问总览与复习节奏这十道题不是随机凑的我是按“使用频率 面试频率 串联能力”三个维度筛的。整个清单如下序号问题主要考察点高频追问方向1 和 equals 的区别为什么重写 equals 要重写 hashCode对象比较、散列集合原理hashCode 碰撞、Integer 缓存2面向对象三大特性怎么理解封装、继承、多态多态底层原理、重载算不算多态3String、StringBuilder、StringBuffer 怎么选字符串不可变、线程安全拼接 的原理、String.intern4ArrayList 和 LinkedList 的区别集合底层结构、复杂度扩容机制、subList 坑5HashMap 的 put 流程JDK 1.8 改了什么散列、扩容、红黑树负载因子、树化阈值、容量为什么是 2 的幂6HashMap 线程不安全那并发用啥并发缺陷、ConcurrentHashMap 原理size()、value 能否为 null7volatile 和 synchronized 的区别并发三性、内存屏障DCL 单例、锁升级8线程池七个参数任务提交后怎么走线程池机制、拒绝策略Executors 的坑、参数设置9JVM 运行时内存怎么划分对象怎么分配内存结构、GCOOM 类型、Full GC 排查10反射是什么注解为什么总跟反射一起出现反射 API、框架原理反射性能、动态代理复习节奏上我建议不要囫囵吞枣一天刷完。先把这十道题背一遍结论然后花两天时间把每道题涉及的源码或机制画出来最后一天对着镜子或录音练“讲题”。这样一轮下来基础这块基本能稳住。下面逐个拆。2. 语言基本功对象比较、面向对象与字符串2.1 第一问 和 equals 的区别为什么一定要重写 hashCode这道题基本是 Java 面试的开胃菜几乎每场必问。但恰恰是这道题很多人第一句话就说不严谨。上来就说“ 比较地址equals 比较内容”前半句对后半句不够准确因为 Object 的 equals 默认就是 也是比较地址只有 String、Integer 这些类重写了才是比较内容。我的标准答法是这样 比较的是栈里的值基本类型比数值引用类型比堆内存地址。equals 是 Object 的方法默认行为和 一样也是比地址但很多类重写了它比如 String 重写后比较的是字符序列Integer 重写后比较的是数值。所以用 equals 前要搞清楚这个类有没有重写。接着说 hashCode 的部分这是重点。Java 的散列集合比如 HashMap、HashSet先通过 hashCode 定位到桶再用 equals 在桶内找目标。Java 官方约定equals 相等的两个对象hashCode 必须相等hashCode 相等的两个对象equals 不一定相等。为什么不重写 hashCode 会出问题举个很经典的例子你定义了一个 User 类只重写了 equals没重写 hashCode然后把两个“业务上相等”的 User 对象往 HashSet 里放。HashSet 先用 hashCode 找桶两个对象 hashCode 不同被扔进不同的桶于是集合里就出现了两个逻辑上相等的对象。这在业务里就是 bug。面试官听到这里基本会追问两个点。第一hashCode 相等equals 一定相等吗答案是不一定因为哈希碰撞。不同对象的 hashCode 可能映射到同一个值所以散列集合里桶内还会再用 equals 做精确比较。第二Integer 的 问题。面试官可能问Integer a 127Integer b 127a b 是 true 还是 false答案是 true因为 Integer 默认缓存了 -128 到 127。那换成 128 呢就是 false因为超出了缓存范围会 new 两个对象。这个坑我当年第一次面试就踩过后来总结成一句话包装类型比较大小一律用 equals别用 。2.2 第二问面向对象三大特性怎么答才显得有设计感这道题十个人里九个能说出“封装、继承、多态”但只有少数人能答出设计层面的思考。面试官问这道题不是想听你背定义而是想看你是不是真的在写面向对象代码。我的建议是每个特性都加一句“它解决了什么问题”。封装把数据和操作数据的方法绑在一起对外只暴露必要接口隐藏内部实现。解决的是“调用方不需要关心内部细节”的问题降低耦合。比如你封装一个 HttpClient 工具类内部怎么建连接、怎么处理超时调用方不关心只要传入参数拿到结果就行。继承子类复用父类的能力并在必要时扩展或覆盖。它解决代码复用的问题但使用不当会让类层次变深、耦合变强。所以现在工程上更倾向于“组合优于继承”能用接口组合解决的就别硬造继承树。说句题外话面试里如果能在继承这个话题上主动点出“继承不是越多越好”面试官会高看你一眼。多态同一操作在不同对象上有不同表现由父类引用指向子类对象运行时由 JVM 根据实际对象类型动态绑定。多态是“面向接口编程”的基础也是设计模式能够落地的前提。面试官追问多态底层原理时可以补一句Java 方法默认是虚方法JVM 运行时会查类的方法表根据对象真实类型完成方法分派static、final、private 方法不走动态绑定因为它们不能被覆盖。还有一个高频变体重载算多态吗这个要看你怎么定义。重载发生在编译期JVM 在编译时根据参数列表确定调用哪个方法叫静态分派重写发生在运行期是动态分派。如果面试官问“多态有哪些体现”你可以说重写体现的是运行期多态重载是编译期的静态多态这样显得知识边界清楚。2.3 第三问String、StringBuilder、StringBuffer 到底怎么选这道题考的是字符串的基础功底也是区分“用过”和“懂过”的分水岭。先讲各自的特性再讲为什么。String 是不可变类。它是 final 的内部从 JDK 9 开始用 byte[] 存储配合一个 coder 字段标记是 LATIN1 还是 UTF16 编码。不可变带来的好处是可以放在常量池共享、hashCode 可以缓存、天然线程安全。代价是每次拼接都会产生新对象。StringBuilder 和 StringBuffer 都是可变类底层也是 byte[]不同的是 StringBuffer 的关键方法加了 synchronized线程安全但性能比 StringBuilder 差。实际开发里单线程拼接字符串用 StringBuilder 就够了如果确实要在多线程环境共享可变字符串才考虑 StringBuffer但说实话这种场景非常少真遇到一般也是自己加锁控制而不是直接丢给 StringBuffer。面试官通常会问String a a b c 底层是怎么做的这个要分版本答。JDK 8 及以前javac 会把 编译成 new StringBuilder().append(a).append(b).append(c).toString()。问题在于如果你在循环里写 str str item每次迭代都会 new 一个 StringBuilder循环一万次就是一万个临时对象性能很差。所以老程序员常说不要在循环里用 拼字符串。JDK 9 之后这个实现改成了 invokedynamic调用 StringConcatFactory性能有所改善但习惯上大家还是建议在循环里显式用 StringBuilder。再往下考就是 String.intern()。这个方法是把字符串放进常量池如果你以后开发中遇到字符串驻留、常量池相关的排查能想到 intern 基本就是领域内行。但面试时提一嘴就行不用展开太多除非面试官继续追问。3. 集合框架从底层原理看高频题目3.1 第四问ArrayList 和 LinkedList 区别别只背对答案网上流传的那套“ArrayList 查询快增删慢LinkedList 增删快查询慢”属于教科书式答案不算错但答得不完整而且容易把面试者带沟里去。真实情况要复杂一些。ArrayList 底层是 Object[]默认容量 10。细节是 JDK 8 之后默认构造函数不再直接 new 一个 size 为 10 的数组而是懒加载第一次 add 时才扩容到 10。扩容时新容量是旧容量的 1.5 倍即 oldCapacity (oldCapacity 1)然后用 Arrays.copyOf 把旧数组元素搬过去底层走的是 System.arraycopy这是一个本地方法内存搬运效率很高。LinkedList 底层是双向链表每个节点存着前后指针和数据本身。头尾插入是 O(1)但按索引插入或访问是 O(n)因为要遍历链表。这个点很重要说“LinkedList 增删快”是有前提的只有在已知节点位置或者头尾操作时才快随机中间插还是得从头遍历找位置。再往下ArrayList 尾插其实非常快均摊 O(1)只有触发扩容时才 O(n)而中间插需要搬移元素最坏 O(n)但因为有 System.arraycopy实际在小集合上并没那么慢。所以选型时真正要看的是场景频繁按索引随机访问、尾部追加选 ArrayList频繁在头部或按迭代器位置插入删除才考虑 LinkedList。我还经常提醒一句现在写代码LinkedList 出现频率其实不高很多“栈和队列”的需求可以直接用 ArrayDeque它比 LinkedList 更高效。这里有两个经典的坑面试官特别爱追。第一个ArrayList.subList() 返回的是视图不是新集合。对 subList 的修改会直接反映到原 list 上如果 subList 持有期间原 list 发生结构性修改再操作 subList 会抛 ConcurrentModificationException。第二个Arrays.asList() 返回的是固定长度的内部 ArrayList不是 java.util.ArrayList调用 add 会抛 UnsupportedOperationException。很多人把这两点混在一起其实它们完全不同一个是视图问题一个是不可变长度问题。3.2 第五问HashMap 的 put 流程和 JDK 1.8 优化点HashMap 是 Java 面试的“题眼”几乎必考而且可以无限深挖。最稳妥的答题顺序是先讲整体结构再讲 put 流程最后补充扩容和树化细节。整体结构一句话HashMap 数组 链表 红黑树。默认初始容量 16负载因子 0.75当元素个数超过 容量 × 负载因子 时触发扩容容量翻倍。put 流程我习惯按步骤说计算 key 的 hash。先拿 key.hashCode()再把高 16 位异或低 16 位也就是 h ^ (h 16)。这叫扰动函数目的是让高位也参与寻址降低碰撞概率。通过 (n - 1) hash 计算桶下标。这里要求 n 必须是 2 的幂因为 (n - 1) hash 等价于 hash % n但位运算比取模快。如果桶为空直接 new Node 放进去。如果不为空分情况链表就遍历找相同 key找到则覆盖 value没找到就尾插JDK 1.8 是尾插法JDK 1.7 是头插法。如果某个桶的链表长度达到 8并且数组长度达到 64就会把链表转成红黑树如果数组长度不到 64先扩容而不是树化。插入完成后 size 1超过 threshold 就扩容。面试官紧接着会问几个为什么这里等于送分答好了能拉开差距。负载因子为什么是 0.75这是空间利用率和时间成本之间的折中。HashMap 源码注释里用泊松分布计算过负载因子 0.75 时桶内链表长度到 8 的概率大概是千万分之一所以 8 这个树化阈值并不是拍脑袋定的而是概率学计算的结果。容量为什么是 2 的幂因为下标算法是 (n - 1) hashn 是 2 的幂时n - 1 的低位全是 1那 hash 的低位扰动就能均匀散列如果 n 不是 2 的幂这个位运算就起不到均匀效果。JDK 1.8 扩容的优化点也很常问。1.7 扩容时链表每个节点都要重新 hash、重新定位1.8 做了优化只需要看元素 hash 与原容量 oldCap 做与运算结果 0 留在原位置结果 1 就移动到“原位置 oldCap”。这个优化避免了大量 rehash也顺带解决了多线程扩容时可能出现的循环链表问题。但要注意1.8 解决的是死循环问题不代表线程安全。3.3 第六问HashMap 线程不安全体现在哪并发场景用什么先说 HashMap 为什么线程不安全。JDK 1.7 里扩容用头插法多线程并发扩容会让链表出现环下次 get 时就可能死循环。JDK 1.8 改成尾插法死循环问题没有了但数据覆盖和丢失还是存在的。比如两个线程同时 put都经过了“桶为空”的判断然后都执行 table[i] newNode(...)后写的就把先写的覆盖了key 就丢了。size 的统计也不是原子的多线程 put 之后 size 经常偏小。那并发场景用什么答案是 ConcurrentHashMap。这个要分版本答。JDK 1.7 的 ConcurrentHashMap 用的是分段锁默认分成 16 个 Segment每次操作锁一个 Segment相当于把一个大锁拆成 16 个小锁并发度是 16。JDK 1.8 放弃分段锁改成了 CAS synchronized。流程是如果桶为空用 CAS 原子地把节点放进去不加锁如果桶不为空锁住桶的头节点再把节点插入。锁粒度从“段”细到“单个桶”并发度大幅提升。另外 1.8 的 size() 用 baseCount 加 counterCells 数组来减少竞争而不是像 1.7 那样先尝试无锁统计不行再锁全部 Segment。这里还有一个高频考点ConcurrentHashMap 的 key 和 value 都不能为 null而 HashMap 可以为 null。原因是并发环境下get 返回 null 时无法区分是“key 不存在”还是“value 就是 null”这个歧义会带来很大的不确定性所以干脆禁止。4. 并发、线程池与 JVM 必考盘4.1 第七问volatile 和 synchronized 的区别与选择并发三性是理解这道题的基础原子性、可见性、有序性。volatile 保证可见性和有序性不保证原子性。所谓可见性就是线程修改变量后立即刷回主内存其他线程读的时候发现本地缓存失效必须重新到主内存读。底层是通过内存屏障和 lock 前缀指令实现的。所谓有序性就是禁止编译器或 CPU 对相关指令重排序。synchronized 三个都保证互斥所以原子锁释放时把工作内存刷新到主内存所以可见同一时刻只有一个线程执行所以有序。代价是重量级虽然 JVM 做了锁升级优化但整体开销还是比 volatile 大。经典的配套提问是双重检查锁单例为什么用 volatile。代码大概是这样的public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }new Singleton() 在字节码层面不是一步完成的大概分三步分配内存、调用构造器初始化、把引用赋值给变量。问题在于第二步和第三步可能被重排序也就是先赋值引用、后执行构造器。这时另一个线程进来看到 instance 不为 null直接返回了但对象尚未初始化完成一用就抛异常。volatile 的作用就是禁止这个重排序保证引用赋值一定发生在对象完整初始化之后。选型上我的经验是状态标志位、单例、可见性为主且不涉及复合操作的场景用 volatile多线程做累加、对共享数据做“读-改-写”这类复合操作老老实实用 synchronized 或 JUC 里的原子类。面试官如果追问锁升级可以简单说偏向锁、轻量级锁、重量级锁的升级路径JDK 15 之后默认已经不再启用偏向锁锁竞争不激烈时主要在轻量级锁之间切换。4.2 第八问线程池七个参数与任务执行流程线程池是并发包里的核心也是面试官验证候选人实际工程能力的好抓手。先背参数再讲流程最后讲为什么不能用 Executors。ThreadPoolExecutor 的七个参数是参数含义corePoolSize核心线程数空闲时默认也不回收maximumPoolSize最大线程数keepAliveTime非核心线程空闲存活时间unit存活时间的单位workQueue任务队列threadFactory线程工厂用来起名字handler拒绝策略任务提交后的执行流程一句话说清楚先尝试创建核心线程执行核心线程满了任务进队列队列满了尝试创建非核心线程线程数达到 maximumPoolSize 后执行拒绝策略。四种拒绝策略要记牢AbortPolicy直接抛 RejectedExecutionException默认策略。CallerRunsPolicy谁提交谁执行把任务退回给调用线程起到天然限流的作用。DiscardPolicy静默丢弃。DiscardOldestPolicy丢弃队列里最老的未处理任务再尝试提交。面试官接着会问为什么 Java 规范里不让用 Executors 提供的快捷方法坑点有两个。newFixedThreadPool 和 newSingleThreadExecutor 用的是无界 LinkedBlockingQueue任务可以无限堆积一旦积压过多就 OOMnewCachedThreadPool 的 maxPoolSize 是 Integer.MAX_VALUE线程数理论上无限大高并发下会把线程和内存打爆。实际项目中应该手动 new ThreadPoolExecutor核心参数怎么定也要心里有数。网上流传“CPU 密集型就 CPU 核数 1IO 密集型就 CPU 核数 × 2”这只能当起点真正上线前要压测调整。队列建议用有界队列比如 ArrayBlockingQueue容量根据系统能容忍多少积压来定拒绝策略我一般选 CallerRunsPolicy宁可让提交线程自己干也别静默丢任务。再补一个容易被问到的点核心线程能不能被回收默认不能但可以设置 allowCoreThreadTimeOut(true)这样核心线程空闲超过 keepAliveTime 也会被回收适合任务波动大的场景。4.3 第九问JVM 运行时内存怎么划分对象怎么分配这道题考的是 Java 程序员对运行环境的理解。先背运行数据区再讲对象分配最后如果能聊几句 OOM说明你真的排查过线上问题。JVM 运行时内存区域分两块线程私有和线程共享区域是否共享主要作用异常程序计数器线程私有当前线程执行字节码的行号指示器无虚拟机栈线程私有栈帧存局部变量、操作数栈等StackOverflowError本地方法栈线程私有本地方法调用StackOverflowError堆线程共享对象分配的主要区域GC 主战场OutOfMemoryError方法区线程共享类元信息、常量、静态变量JDK 8 后改为元空间使用本地内存OutOfMemoryError对象的一生这个也常考。一个普通对象 new 出来一般先放在新生代的 Eden 区。Eden 区满的时候触发 Minor GC存活对象用复制算法搬到 Survivor 的 From 区age 加 1之后每次 Minor GCFrom 和 To 区交换存活对象每躲过一次 GCage 就加 1默认到 15 岁就晋升到老年代。还有两种情况会直接进老年代对象大到 Eden 放不下Survivor 区里同龄对象总大小超过一半时按动态年龄规则晋升。面试官到这里通常会追问 Full GC 排查。给你一个最简单的排查链路jps 找到 Java 进程 pid。jstat -gcutil pid 1000 每秒钟打印一次 GC 情况重点看 FGC 的次数和 FGCT 的时间。如果 Full GC 频繁用 jmap -dump:formatb,fileheap.hprof pid 导出堆快照。用 MAT 或 jvisualvm 打开快照看谁占着大对象、谁没被回收。顺带说一句很多人以为 OutOfMemoryError 都是堆不够其实 OOM 分好几种Java heap space 是堆满GC overhead limit exceeded 是 GC 回收效果太差unable to create new native thread 是创建的线程超过系统限制还有标题里那种 insufficient memory本质上是 native 内存或进程地址空间不够常出现在 C 堆内存不足或线程数过多的时候。如果面试时能把 OOM 的类型说出来会让人觉得你真的处理过线上问题。4.4 第十问反射是什么注解为什么总跟反射一起出现到了第十问已经从语言特性扩展到框架层面了。反射说白了就是程序在运行的时候还能动态拿到类的结构——构造器、方法、字段、注解甚至调用私有方法。反射的入口是 Class 对象获取方式有三种Class.forName(com.example.User)、User.class、user.getClass()。反射能做什么常见的 API 你要说得出来getDeclaredMethods 拿方法、getDeclaredFields 拿字段、getDeclaredConstructor 拿构造器、setAccessible(true) 打开私有访问权限、newInstance 反射创建对象。还有一个容易被忽略的反射可以拿到泛型信息通过 getGenericType 拿 ParameterizedType这在一些做通用框架的场景里非常有用。注解和反射的关系一句话解释注解本身只是标记如果 RetentionPolicy 是 RUNTIME它会在字节码里保留但如果不通过反射去读它就是个摆设。比如 Override 是编译期校验用的源码注解Autowired 是运行期注解Spring 容器启动时用反射扫描类、读取字段上的 Autowired然后完成依赖注入。同理MyBatis 的 Mapper 接口、JSON 序列化库底层都是反射在干活的。面试时说到“注解是给框架看的”基本就点到了实质。面试官最可能追问反射为什么慢怎么优化我一般这样答反射慢在动态解析和类型检查比如每次调用方法都要查找方法对象JIT 也不能像普通调用那样做深度优化。优化手段主要是缓存 Method 对象避免重复查找加上 setAccessible(true) 跳过安全检查。但从工程角度讲反射多发生在框架启动阶段比如 Spring 初始化 Bean 的时候真正运行期热路径上的反射调用很少。如果你发现线上某个接口又慢又频繁走反射那就要考虑改成缓存或代理了。再补一个环境相关的经验很多人在本地跑 Spring Boot 项目遇到过 lombok 不生效或者报 “You arent using a compiler supported by lombok” 这类错误本质是 javac 版本和 lombok 版本不匹配注解处理器没跑起来。这不是面试重点但属于“面试官顺手问一句你遇到过没”的题知道是版本兼容问题比一脸懵要好。5. 从“背题”到“讲题”面试实战怎么串5.1 答题节奏结论先行、逐层展开、落到例子十道题的内容讲完了但最后这部分才是拉开差距的地方。同样的知识点有人能讲得让面试官频频点头有人讲得让面试官皱眉头差别就在节奏上。我总结的答题节奏是“结论先行、逐层展开、落到例子”。先说一句话结论让面试官知道你有答案再拆条件或场景体现思考的完整性最后举一个具体的例子收尾给这次回答画上句号也方便面试官顺着例子追问。比如问 HashMap 的 put 流程不要开口就从“首先要计算 hash 值”一路背书到“最后可能树化”。先来一句“HashMap 是数组加链表加红黑树实现的散列表put 大概分四步算 hash、定位桶、处理冲突、必要时扩容”这个结论 10 秒说清楚。然后面试官自然会对某个环节追问你再展开细讲。这种“总-分-例”的结构是面试官最熟悉的交流节奏也显得你思路清晰。5.2 被追问到不会的问题怎么办模拟面试时我发现一个很常见的情况问到某个点候选人真的不会然后就开始沉默或者胡编。这两种都是面试大忌。正确做法是诚实承认但必须补上一段“我能联想到什么思路”。比如面试官问 volatile 底层的 lock 前缀指令和内存屏障细节你确实没深入研究可以这样回答“这块我了解得比较浅我清楚 volatile 通过内存屏障防止指令重排序、保证可见性也用过 DCL 单例但 lock 指令这层我没有真正看过 JVM 源码如果后面需要我可以再补。”这个回答的价值在于你画出了自己的知识边界同时展示了解决问题的方向感面试官通常不会因为这个挂人反而会欣赏你的坦诚。最怕的是瞎编。技术面试是深聊你编一个东西面试官追两轮就穿帮穿帮后你前面的回答都会被怀疑。宁可少说不可乱说。5.3
返回列表