直接背就完了:JAVA八股面试必备(1)刷题笔记打包
又到了金三银四的跳槽季,后台好多朋友私信问Java面试到底怎么准备。说实话,Java八股文这东西,网上资料一大堆,但要么太散,要么太深,要么就是单纯罗列答案不讲人话。我当年准备面试的时候也是翻遍了各路博客,踩了不少坑,才整理出一套自己的核心笔记。
这份笔记不整那些虚头巴脑的,直接把Java基础里最常被问到、也最容易被问倒的硬核知识点全部拎出来。无论是正准备校招的应届生,还是想跳槽涨薪的初级开发,或者是想查漏补缺巩固基础的Java学习者,这份笔记都能帮你省下大量埋头翻资料的时间。
我需要先说明一点,这份"第1期"主要聚焦在 Java面向对象、JVM内存模型、集合框架和并发编程 这几个最核心的板块。这些内容不是说背下来就完事,更重要的是理解背后的"为什么",因为面试官现在太会追问了。你背了HashMap的原理,他能一路追问到红黑树和CAS,所以每个考点我都会尽量把原理拆开,讲清楚来龙去脉和最容易掉进去的坑。
1. 面向对象:不只是封装继承多态
1.1 封装、继承、多态的正确打开方式
面试官上来最喜欢问的不是"什么是面向对象",而是让你结合自己的项目谈谈对三大特性的理解。很多人的回答就是背定义:封装就是把属性和方法打包,继承就是子类继承父类,多态就是同一个方法不同实现。这种回答太干瘪了,基本拿不到加分。
我的理解是,封装的核心在于隐藏实现细节,暴露稳定接口。你的实体类属性全部private,对外提供getter/setter,这不叫封装,这只是最基础的语法层面的封装。真正的封装是一种设计思想:你的类内部逻辑怎么变,外部调用方都不用感知。比如你实现了一个订单服务,内部用了Redis缓存还是本地缓存,外部调用方根本不关心,他只关心你提供的createOrder方法是否稳定可靠。这种通过接口隔离变化的能力,才是封装的价值。
继承最容易踩的坑是滥用。我见过很多代码,为了复用两个方法,硬是造出一个莫名其妙的父类,结果类层次越来越深,改一个父类方法牵一发而动全身。所以面试时你可以主动提出来:继承应该遵循里氏替换原则,子类必须能替换掉父类且行为不发生变化。如果两个类只是有公共代码,优先考虑组合而不是继承。这种回答会让面试官觉得你思考过,而不只是背过一个概念。
多态的底层是动态绑定。JVM在方法调用时,是通过方法区里的方法表来定位实际调用方法的。编译时看的是引用类型,运行时看的是实际对象类型。经典的例子就是Parent p = new Child(); p.method();,执行的一定是Child里重写的method。这里面试官常追问的点是:静态方法、私有方法、final方法、构造方法都没有多态性,因为它们要么是编译期绑定,要么是final修饰无法重写。
1.2 重载和重写别再傻傻分不清
关于重载和重写,我总结了两个记忆锚点:重载是编译期多态,重写是运行期多态;重载看参数列表,重写看继承关系。
重载要求方法名相同,参数列表必须不同(参数类型、个数、顺序至少有一个不同),对返回值和访问修饰符没有硬性要求。这里要注意一个坑:如果两个方法只有返回值不同,是不能构成重载的,编译器会直接报错,因为JVM在方法调用时无法仅仅通过返回值来区分调哪个方法。
重写的要求更严格:发生在继承体系中,子类重写父类方法,方法名、参数列表必须完全一致,返回值可以是父类返回值的子类型(协变返回)。访问修饰符不能比父类更严格,抛出异常也不能比父类更宽泛。还有一个很隐蔽的细节:重写方法的抛出的受检异常必须是父类方法抛出异常的相同类型或子类型,如果你在父类方法中没有抛任何受检异常,子类重写时也不能抛受检异常。
对于重写,还有一个高频考点是动态绑定与静态绑定。JVM调用方法时,静态方法用invokestatic指令,私有方法用invokespecial指令,这些是静态绑定;而普通实例方法用invokevirtual指令,属于动态绑定。所以面试官问到"上面这段代码输出的结果是什么",本质考的就是你对动态绑定的理解。
1.3 String三兄弟的相爱相杀
String相关的题基本每场面试必见,而且变着花样考。先说常量池和字符串池的底层逻辑。
java复制String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1.equals(s3)); // true
==比较的是引用地址,equals比较的是内容。s1和s2都指向字符串常量池中的同一个"hello"对象,所以==为true。new String("hello")会在堆中新建一个对象,所以==为false。但这里有个细节容易被忽略:new String("hello")其实创建了两个对象,如果常量池中还没有"hello"字符串,会先在常量池创建一个,然后在堆中再创建一个,两个对象内容相同但地址不同。
String和StringBuilder、StringBuffer的选择题也是高频。核心结论是:String不可变所以线程安全,StringBuffer加了同步锁所以线程安全但性能差,StringBuilder没有锁所以性能最好但线程不安全。单线程环境拼接字符串,用StringBuilder最快;多线程环境,用StringBuffer。
面试官还会追问一个细节:String为什么设计成不可变?这个问题的答案涉及多个方面。一是安全性,String常被用作方法参数、数据库连接URL等,如果不可变就能防止被修改;二是哈希缓存,String的hashCode被缓存了,不可变才保证哈希值的稳定;三是常量池复用,如果字符串可变,常量池中的复用机制就无法成立。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JVM内存模型:别再说堆和栈分不清
2.1 运行时数据区逐个拆解
JVM内存模型是Java面试的分水岭,基础好的和基础差的在这一题上高下立判。我能给的建议是:用画图的方式把JVM内存结构记在脑子里,面试现场直接画出来比背诵一百遍都管用。
JVM运行时数据区分为线程私有和线程共享两大类。线程私有的有三个:虚拟机栈、本地方法栈、程序计数器。线程共享的有两个:堆、方法区。这里有个容易出错的地方:程序计数器是唯一一个不会出现OutOfMemoryError的区域,它的作用是记录当前线程执行到的字节码行号。由于记录行号只需要很小的空间,它的生命周期跟随线程,所以不存在内存溢出的问题。
虚拟机栈是面试官最爱深挖的区域。栈帧的结构包括局部变量表、操作数栈、动态链接、方法返回地址。每调用一个方法,JVM就会创建一个栈帧压入虚拟机栈;方法执行完毕,栈帧弹出。如果递归调用太深,就会抛出StackOverflowError。局部变量表里存的是基本数据类型、引用类型和returnAddress,操作数栈则是JVM执行字节码指令时的工作区域。
堆是Java内存管理的核心区域,也是垃圾回收的主战场。从内存回收的角度看,堆被分为新生代和老年代,新生代又细分为Eden区和两个Survivor区(默认比例是Eden:Survivor:Survivor = 8:1:1)。对象通常先在Eden区分配,经过一次Minor GC后存活的对象进入Survivor区,每熬过一次GC,年龄加1,达到默认的15岁后晋升到老年代。
方法区在JDK 1.8之后变成了元空间,物理上使用本地内存而不是JVM堆内存。经典的永久代OOM问题在JDK 8之后得到缓解,但也需要正确理解:元空间存的是类的元数据信息、运行时常量池、静态变量等。元空间使用本地内存,默认情况下只受本机内存限制。
2.2 Java对象创建到回收的一生
面试官经常问:"new一个对象,JVM里到底发生了什么?"这个问题从字节码到内存分配,是一条完整的链路。首先是类加载检查:JVM遇到一条new指令时,先检查常量池中能否定位到这个类的符号引用,并检查这个类是否已被加载、解析和初始化过。如果没有,必须先执行类加载过程。然后是分配内存,有指针碰撞和空闲列表两种方式,选择哪种取决于堆内存是否规整,而堆是否规整又取决于GC算法是否带空间压缩整理。分配内存的同时还要考虑线程安全问题,JVM通过CAS搭配失败重试来保证分配的原子性,或者使用线程本地分配缓冲(TLAB)来避免竞争。
内存空间分配完成后,JVM会将分配到的内存空间初始化清零,然后对对象头进行设置:这个对象属于哪个类、对象的哈希码、GC分代年龄、锁状态标志等。到此为止,从JVM的角度看一个对象已经产生了。最后一步是执行构造方法,也就是init方法,按照程序员写的逻辑进行初始化。
对象回收的判断标准是可达性分析算法。从GC Roots出发,通过引用链向下搜索,如果对象到GC Roots没有任何引用链相连,就判定对象不可达,可以被回收。可以作为GC Roots的对象包括:虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象。
这里有个经典问题:被判定为不可达的对象是否一定要被回收?不一定。对象会先经过标记阶段,然后判断是否有必要执行finalize方法。如果对象的finalize方法没有被覆盖或者已经执行过了,就直接回收。这个机制在实际开发中不建议依赖,因为finalize的执行时机不确定,而且性能差,在JDK 9中已经被标记为废弃了。
2.3 类加载机制和双亲委派模型
类加载机制这一块,核心考点就是双亲委派模型。类加载器从顶到下分为启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)。双亲委派的流程是:当一个类加载器收到类加载请求时,它不会自己去尝试加载,而是把这个请求委派给父类加载器去完成,每一层都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有父类加载器反馈自己无法加载这个类时,子加载器才会尝试自己加载。
使用双亲委派的好处是:避免类被重复加载,以及保证Java核心类的安全性。比如java.lang.String类,无论哪个类加载器尝试加载它,最终都会委派给启动类加载器,这样保证了Java运行时中String类只有唯一的一份定义。
面试官在这里会追问:你可以自己写一个java.lang.String类吗?答案是不能,因为你写的这个类在加载时会被父加载器优先加载,也就是加载到真正的JDK自带String类,你写的永远不会被执行。但如果面试官接着问"怎么打破双亲委派模型",你可以说通过自定义ClassLoader重写loadClass方法。经典应用场景包括Tomcat的WebAppClassLoader,它为了隔离不同应用的类,先尝试自己加载,然后再委派给父加载器。
3. 集合框架:HashMap一题定乾坤
3.1 HashMap底层原理全解析
如果说Java面试只准备一个集合知识点,那必须选择HashMap。HashMap几乎是一个必考题,而且追问可以深到怀疑人生。我见过最夸张的面试官,从HashMap一路问到红黑树的插入逻辑和CAS操作,整整追问了二十分钟。
HashMap在JDK 1.8中的底层结构是 数组 + 链表 + 红黑树。数组的每个位置是一个桶(bucket),当发生哈希冲突时,冲突的元素以链表的形式挂在同一个桶上。当链表长度超过阈值8,且整个数组长度大于等于64时,链表会转换成红黑树。为什么要转红黑树?因为链表查找的时间复杂度是O(n),而红黑树是O(log n)。为什么不一开始就用红黑树?因为树节点的开销是普通节点的两倍左右,在冲突不多的情况下链表更节省空间。
HashMap的put流程是面试官最爱让手撕的题目。核心逻辑是这样的:先对key进行hash操作,这个操作不是直接用hashCode,而是(h = key.hashCode()) ^ (h >>> 16),也就是把高16位异或低16位。这样做的目的是让高位的信息能够参与低位的计算,从而降低哈希碰撞的概率。然后通过(n - 1) & hash计算索引位置,这里直接做与运算而不是取模,是因为当n是2的幂次方时,位运算的结果和取模一致,但性能更高。
关于扩容机制,默认初始容量是16,负载因子是0.75。当元素个数超过容量 * 负载因子时触发扩容,扩容后容量翻倍。这里的计算公式是threshold = capacity * loadFactor。为什么负载因子是0.75而不是0.5或者1.0?太小会导致频繁扩容浪费空间,太大会增加哈希冲突概率影响查询效率,0.75是空间和时间的折中,这是官方工程师从数学和工程角度权衡出的经验值。
3.2 ArrayList和LinkedList怎么选
ArrayList和LinkedList的区别是初级岗位的高频题,主要从底层结构、查询、插入删除三个方面对比。ArrayList底层是Object数组,支持随机访问,通过索引查询的时间复杂度是O(1)。LinkedList底层是双向链表,不支持高效的随机访问,需要从头或者尾开始遍历,时间复杂度是O(n)。插入删除方面,如果ArrayList在中间位置插入,需要移动后续所有元素,时间复杂度是O(n);LinkedList在中间位置插入,只需要修改前后节点的指针引用,但查找插入位置需要遍历,所以也是O(n)。
实际开发中最常见的场景是遍历集合,在遍历过程中不能直接删除元素,否则会抛出ConcurrentModificationException。这个异常的原因是迭代器维护了一个modCount期望值,每次遍历时检查集合的实际modCount是否和期望值一致,不一致就说明集合结构发生了变化。正确的删除方式是用迭代器的remove方法,或者使用JDK 1.8的removeIf。
这里还有个细节值得注意:数组和集合的转换。Arrays.asList()返回的是一个固定大小的List,不能执行add和remove操作,因为底层仍然是数组。还有,asList返回的List里的元素是直接引用原数组的,修改数组元素会影响List。把List转数组用list.toArray(),如果传参数组长度小于List长度,会自动创建一个新数组。
3.3 ConcurrentHashMap和fail-fast机制
JDK 1.8的ConcurrentHashMap算是Java并发集合的顶级设计。它的底层结构和HashMap一样是数组加链表或红黑树,区别在于并发控制方式。JDK 1.8放弃了JDK 1.7的分段锁设计,改用CAS + synchronized来保证并发安全。当多个线程同时put,如果相应的桶为空,就通过CAS操作把新节点放入桶中;如果桶不为空,就用synchronized锁住这个桶对应的头节点。这种锁粒度比整个表锁或者分段锁都更细,所以在大多数场景下并发性能优秀。
与fail-fast相对的是fail-safe机制。普通集合如ArrayList、HashMap的迭代器是fail-fast的,也就是快速失败:在迭代过程中如果检测到集合被修改,立即抛出异常。而ConcurrentHashMap的迭代器是弱一致的:它可能反映迭代器创建时元素的状态,也可能反映之后某次修改的状态,但不会抛出ConcurrentModificationException。原因是它遍历时使用的是当前节点快照,不依赖modCount。
判断面试者是否真理解并发容器,面试官通常还会问:ConcurrentHashMap的size()方法时如何统计元素数量的?这个问题不复杂但很能区分人。由于size方法需要统计所有segment或所有桶中的元素数量,而这个过程无法加全局锁,所以JDK 1.7的ConcurrentHashMap先以无锁方式尝试统计两次,如果两次结果一致就直接返回;如果不一致,再加锁重新统计。JDK 1.8则用baseCount配合CounterCell数组来维护元素个数。
4. 并发编程:从synchronized到AQS
4.1 synchronized的锁升级过程
并发编程这块,synchronized是绕不开的。在JDK 1.6之前,synchronized是重量级锁,性能较差;JDK 1.6之后引入了大量锁优化机制,锁升级的路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁的逻辑是:如果一个线程获得了锁,那么该线程再次获取这个锁时,不需要进行任何同步操作,直接把偏向锁的线程ID记录在对象头中即可。这个机制适合只有一个线程访问同步块的场景。如果有另一个线程尝试获取偏向锁,不再适合偏向模式,就会升级为轻量级锁。轻量级锁通过CAS操作来获取锁,如果获取失败,说明存在竞争,就会不断自旋尝试。自旋会消耗CPU,所以当竞争加剧时,锁会升级为重量级锁,也就是进入操作系统的互斥量,通过操作系统线程调度来阻塞和唤醒线程。
面试高频题是:synchronized锁的是什么?这要分情况。修饰普通方法时,锁的是当前实例对象;修饰静态方法时,锁的是类的Class对象;修饰同步代码块时,锁的是括号里指定的对象。还有一个坑:如果同一个类中一个synchronized修饰的静态方法和一个synchronized修饰的普通方法同时执行,它们之间是不会互斥的,因为一个是Class对象锁,一个是实例对象锁,它们不是同一把锁。
4.2 volatile和JMM的可见性
volatile关键字是并发编程里的第二梯队考点。先说作用范围:volatile保证可见性、禁止指令重排序,但不保证原子性。这句话要记住,但不是背答案,要能展开解释。
Java内存模型(JMM)规定,所有变量存储在主内存中,每条线程有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。这就产生了可见性问题:线程A修改了一个变量的值,线程B可能不知道这个变化。volatile通过插入内存屏障指令来保证写操作能立即被其他线程看到,同时禁止读写操作之间的重排序。
经典面试题:volatile能否保证原子性?答案是不能。比如经典的计数器例子,count++这个操作实际上分三步:读取count的值、加1、写回count。volatile只保证了第三步是对其他线程可见的,但三步之间可能被其他线程插入操作,导致数据不一致。
重排序这块有一个经典的单例模式双重检查锁(DCL)问题。为什么双重检查锁必须要加volatile?因为instance = new Singleton()这一步在指令层面不是原子的,它会被拆成:分配内存、初始化对象、将引用指向内存地址。如果不加volatile,编译器和CPU可能重排序为:分配内存、将引用指向内存地址、初始化对象,这样其他线程可能拿到一个还没有完成初始化的半成品对象。volatile禁止了这个重排序,保证引用指向内存地址时对象已经完成初始化。
4.3 线程池的七个参数和执行的完整流程
线程池在面试中的出现频率甚至超过synchronized,因为日常开发太常用了。核心考点是ThreadPoolExecutor的七个参数:核心线程数、最大线程数、空闲线程存活时间、存活时间单位、任务队列、线程工厂、拒绝策略。
提交一个任务时,线程池的执行流程是:先判断核心线程是否已满,没满就创建核心线程执行任务;满了就尝试把任务放入阻塞队列;队列也满了,就判断线程数是否达到最大线程数,没达到就创建非核心线程执行任务;如果都满了,就执行拒绝策略。JDK提供的四种拒绝策略分别是AbortPolicy(直接抛异常)、CallerRunsPolicy(由提交任务所在的线程执行)、DiscardPolicy(直接丢弃任务)、DiscardOldestPolicy(丢弃队列中最老的任务)。自定义拒绝策略可以实现RejectedExecutionHandler接口。
关于线程数如何设置,我给出了一个实际开发中很实用的经验公式。如果是CPU密集型任务,线程数设置为CPU核心数 + 1;如果是IO密集型任务,线程数设置为CPU核心数 * 2,很多文章推荐CPU核心数 / (1 - 阻塞系数),其中阻塞系数通常在0.8到0.9之间。但我的建议是先把公式算出来作为起点,再结合压测结果调节,不要迷信公式。
线程池使用中还有一个高频坑:开发中不要用Executors创建线程池。Executors.newFixedThreadPool内部使用的是无界队列LinkedBlockingQueue,当任务堆积太多,队列无限膨胀会导致OOM。Executors.newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE,如果创建大量线程,也可能导致OOM。阿里Java开发手册明确禁止使用Executors创建线程池,要用ThreadPoolExecutor手动创建传入参数,就是要强制开发者想清楚场景、队列和拒绝策略。
4.4 CAS和AQS是什么关系
CAS(Compare And Swap)是并发编程的底层基石。它包含三个操作数:内存位置V、预期原值A、新值B。执行时,只有V的值等于A,才会把B写入V,整个过程是原子操作。Java中的AtomicInteger、AtomicLong等原子类底层就是通过Unsafe类的compareAndSwapInt实现的。
CAS存在三个经典问题:ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作。ABA问题就是变量从A变成B又变成A,CAS操作无法感知中间的变化。解决方案是使用AtomicStampedReference,通过增加版本号来区分。循环时间长的问题,就是CAS失败后会一直自旋重试,高并发下CPU压力大,JVM可以自动优化,但也需要取舍。只能保证单个变量原子操作的限制可以通过加锁或者AtomicReference来绕过。
AQS(AbstractQueuedSynchronizer)是JDK并发包的一大半基石,ReentrantLock、Semaphore、CountDownLatch都是基于它实现的。AQS的核心思想是:如果被请求的共享资源空闲,就把当前请求线程设置为工作线程;如果资源被占用,就通过CLH队列实现线程的排队等待,同时通过CAS操作来修改共享资源的状态。AQS维护了一个volatile int类型的state变量和一个FIFO双向队列。
面试官问到"ReentrantLock和synchronized的区别"时,可以从几个角度对比:synchronized是隐式锁,使用简单,ReentrantLock需要手动上锁和释放;synchronized是非公平锁,ReentrantLock默认非公平,但可以选择公平;ReentrantLock支持中断响应、支持超时获取锁、支持多个条件队列。但synchronized在JDK 1.6之后性能已经和ReentrantLock没有明显差距,所以如果只是简单场景,直接用synchronized更省事。
5. 异常机制与反射:最容易掉分的两块
5.1 异常体系与最佳实践
Java异常体系这张图你要是能画清楚,面试印象分立刻不同。顶层是Throwable,下面分Error和Exception。Error表示JVM层面的严重错误,比如OutOfMemoryError、StackOverflowError、NoClassDefFoundError,这些不是程序能处理的,不应该去捕获。Exception分两大类:受检异常(Checked Exception)和非受检异常(Unchecked Exception)。受检异常如IOException、ClassNotFoundException,编译器强制要求捕获或抛出;非受检异常就是RuntimeException及其子类,例如NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException。
异常处理的最佳实践里,有几个点值得注意。第一,不要在catch块中吞掉异常,比如catch里只打印一行日志就算结束了,这样上线后问题很难排查。第二,不要用异常控制业务流程,用户输入校验失败不算异常,直接返回校验结果就可以了,用异常控制流程性能差且代码可读性差。第三,finally块中不要写return语句,因为finally的return会覆盖try中的return值。第四,不要捕获和抛出OOM等Error,这属于虚拟机层面的问题,程序无法兜底。
还有一个经典面试题:try、catch、finally、return的执行顺序是什么?先执行try中的代码,如果遇到异常就匹配catch块;无论前面是否出现异常,finally都会执行;finally执行完之后,再返回try或catch中return的值。但如果finally里修改了返回值变量,需要注意基本类型和引用类型的区别,基本类型的修改不影响已保存的返回值,引用类型修改对象的字段则会影响。
5.2 反射机制的三板斧
反射是Java框架的基石,Spring的IOC容器、MyBatis的Mapper代理、Jackson的反序列化,底层都在用反射。核心API就三类:获取Class对象的三种方式(类名.class、对象.getClass()、Class.forName("全类名"))、获取构造器/方法/字段的三个类(Constructor、Method、Field)、设置访问权限的setAccessible方法。
面试官常问:反射的性能为什么差?首先是增加了很多安全检查,比如访问权限检查;其次是方法调用时需要进行额外的参数包装和拆箱;最后是JIT优化受限。但现代JDK对反射做了一些优化,比如方法句柄(MethodHandle)的引入,所以性能差距在缩小。对于真正需要极高性能的场景,可以考虑使用MethodHandle或者缓存反射对象。
反射还有一个和安全相关的话题:可以通过反射绕过泛型的类型检查。Java的泛型在运行时会被擦除,所以List<String>和List<Integer>在运行时是同一个Class。这就导致可以用反射往一个List
6. 面试实战:这样答才能让面试官眼前一亮
6.1 给八股加上"为什么"的深度
背过八股不如讲明白八股。我现在复盘过去几次面试经历,最深刻的体会是:面试官不是要你从论文里背一个答案,而是想知道你有没有真正思考过这些技术设计背后的动机。
比如问到"为什么HashMap的默认容量是16",如果你只回答"官方规定"就前功尽弃了。更好的回答方式是:因为HashMap用位运算替代取模运算,要求容量必须是2的幂次方。16是2的4次方,算是一个在空间和哈希散列效果之间的折中值。继续展开,扩容时容量翻倍仍然是2的幂次方,这样新索引位置不是原位置就是原位置加旧容量,可以避免重新计算每个节点的哈希值。这样一步步推导下来,面试官会认为你是真的理解,而不是死记硬背。
再比如问到"Redis的过期策略和淘汰策略"这类题时,同样不要只喊口号,要结合业务场景说明为什么选择某种策略。Java面试本来就喜欢通过一个点延伸到整个技术生态,如果每次都能把"为什么"讲透,会给人非常扎实的感觉。
6.2 高频追问链与回答套路
我整理了面试过程中最容易出现的追问链,提前打准备了就不会慌。
- 问:HashMap线程安全吗?答:不安全。
- 追问:那多线程环境下用什么?答:ConcurrentHashMap。
- 追问:ConcurrentHashMap为什么安全,底层怎么做?答:JDK 1.8用了CAS加synchronized锁桶。
- 追问:CAS是什么?有什么问题?答:底层是Unsafe的compareAndSwap,问题有ABA、自旋开销、只能单变量。
- 追问:ABA怎么解决?答:AtomicStampedReference加版本号。
这条链是面试官最喜欢的,从HashMap一路问到并发编程,考察的是知识体系。我建议准备面试时自己也做这样的"追问链demo",把核心考点串成一个完整的故事,每一个环节都要能接上,这样不仅准备充分,面试时也不容易出现卡壳。
6.3 这份笔记的复习使用技巧
最后分享一个我自己实践下来的复习方法,也是一点小经验。这份笔记和市面上的资料一样,不要试图一天背完。我的建议是:第一遍通读并画一遍知识脑图,把每个知识点的关键术语和逻辑锚点记录下来,不要细抠;第二遍再动手操作,在IDE里写写HashMap的put流程模拟、线程池的线程执行顺序测试,这些代码敲一遍比背十遍有效得多;第三遍可以自己当面试官对着镜子或录音设备自问自答。
面试前一周,每天抽15分钟过一遍核心术语,比如偏向锁、轻量级锁、CAS、AQS、双亲委派、modCount,看到这些词能立刻在脑内回忆起对应的原理和场景。这个步骤坚持下来,面试的时候你的表达会顺畅非常多,因为八股面试面的不只是记忆力,更是你在高压场景下的知识检索速度。
