Java基础面试核心知识点解析:JVM、集合与多线程实战梳理
2026/9/11 16:28:10 网站建设 项目流程

1. JDK、JRE、JVM三个字母被问烂了,但大多数人都答不透

Java面试基础篇,十个面试官九个会从"JDK、JRE、JVM有什么区别"开场。这个问题看着简单,但恰恰是最容易暴露水平的地方——能背出字面意思的人很多,能把"一次编译到处跑"背后的原理讲明白的人却不多。更重要的是,这道题后面往往跟着一连串追问:Java是编译型语言还是解释型语言?javac做了什么?main方法为什么要写成这样?如果前面只是背了定义,到这一层基本就卡壳了。

1.1 三者区别与"一次编译到处跑"的实现原理

先说结论,再讲原理。

  • JVM(Java Virtual Machine):Java虚拟机,是一个运行Java字节码的虚拟计算机规范。它负责把字节码解释/编译成当前操作系统的机器指令。
  • JRE(Java Runtime Environment):Java运行时环境,包含JVM和Java核心类库。如果你只是运行Java程序,装JRE就够了。
  • JDK(Java Development Kit):Java开发工具包,包含JRE以及javac、jar、javadoc等开发工具。你要写代码、编译代码,必须装JDK。

面试官会追问:那为什么同一个.class文件在Windows、Linux、Mac上都能运行?关键在于.class文件里装的是字节码(bytecode),字节码是平台无关的中间指令。而JVM是平台相关的——每个操作系统都有对应版本的JVM实现,JVM把字节码翻译成当前平台的机器指令,翻译这一步由各自操作系统上的JVM完成。所以"一次编译,到处跑"的前提是:目标平台已经安装好了对应的JRE/JVM。冷知识:编译器和JVM在底层其实都是平台相关的,只有字节码是跨平台的,很多新人搞混这个点。

再往深一层,javac把.java源码编译成.class字节码的过程,其实经历了四个阶段:词法分析(把源码拆成token)、语法分析(构建抽象语法树)、语义分析(检查类型、变量使用等是否合法)、字节码生成。面试时能主动说出这条链路,会明显区别于只会背三句话的候选人。

1.2 main方法为什么必须写成public static void main(String[] args)

这个"送分题"翻车率极高。你反着问自己:四个关键字能不能换?

  • public:JVM在外部调用main方法时需要一个公共入口,所以访问权限必须是public,否则虚拟机无法从包外访问到它。
  • static:如果main不是静态方法,JVM需要先创建一个包含main方法的对象实例才能调用它。但这时JVM还没有加载任何业务对象,用static可以避免实例化,直接通过类名调用。
  • void:程序入口本身不向JVM返回结果,结果通过System.exit(0/非0)来表达运行状态,所以返回值是void。
  • String[] args:接收命令行参数。写成String... args也是允许的,本质是数组。

顺带提醒,新人在自己电脑上经常遇到"java: 警告: 源发行版 17 需要目标发行版 17"这种报错,本质是IDE的编译器配置和项目字节码版本不一致。检查一下Project Structure里的SDK版本和Java Compiler的target字节码版本,保持统一就行,这是个环境问题,不是代码问题。

1.3 环境变量配置:JAVA_HOME、PATH、CLASSPATH的真相

我在指导新人时发现,环境变量这块被大量过时教程带偏了。现在装JDK,不配置CLASSPATH也完全能正常编译运行。JDK 1.5之后,javac会自动在当前目录及jar包中搜索类,不再需要手动指定CLASSPATH来覆盖默认类路径。

真正需要配置的是这两个:

  • JAVA_HOME:指向JDK安装目录,很多中间件(比如Tomcat、Maven、IDEA)会通过这个变量寻找JDK。
  • PATH:把%JAVA_HOME%\bin(Windows)或$JAVA_HOME/bin(Linux/macOS)追加进去。这样你在终端敲java -versionjavac -version时,操作系统才能在任意目录下找到javac和java执行文件。

排查命令也很简单:依次执行java -versionjavac -versionecho $JAVA_HOME,哪个命令找不到,就说明PATH或JAVA_HOME没配好。遇到过有人在IDE里能运行,但命令行里javac报不存在,就是PATH的事。

提示:教程里让你把.;%JAVA_HOME%\lib这类CLASSPATH配置复制粘贴的时代早就过了。如果你在别人机器上看到这样的配置,不用学,那是历史遗留。

2. 面向对象三特性:说会背容易,能扛追问才算数

"面向对象三大特性是什么"——封装、继承、多态,人人都能答。但面试官往往紧接着问:"那你说说多态底层的动态绑定是怎么发生的?"或者"为什么有人说组合优先于继承?"这时候就分出高下了。我建议你把这三个特性当成三组面试题来复习,而不是三个名词。

2.1 封装:不是字段private就完事

封装的核心不是把属性设为private这么简单,它的本质是信息隐藏和操作接口化。举个最经典的例子:一个User类有age字段,你直接public修饰,调用方随手赋个-1,程序不崩,但数据全乱了。封装之后,外部只能通过setAge(int age)方法赋值,在方法内部可以做非空、范围校验,这才是封装的真正价值。

追问环节经常出现的是Java的四种访问修饰符,不少人只知道public和private,但讲不清protected和default的区别:

修饰符同类同包子类(不同包)任何地方
private可以不行不行不行
default(不写)可以可以不行不行
protected可以可以可以不行
public可以可以可以可以

我会专门强调一句:protected的设计目标就是"继承友好",让子类能访问父类的受保护成员,但又不向外部开放。

2.2 继承:组合优先于继承这个结论怎么来的

继承表达的是is-a关系:Dog is-a Animal。它的好处是代码复用和形成多态。但继承有几个天然缺陷:脆弱的基类问题——父类一改,子类全崩;菱形问题——一个类继承两个父类且两个父类有相同方法,Java用单继承避开了一半问题。

所以业界有个共识:优先使用组合而不是继承。组合表达has-a关系:一个类持有另一个类的引用。比如一辆Car有Engine,你可以通过组合把引擎的启动逻辑委托给Engine对象,而不是继承Engine来复用代码。组合的好处是关系更灵活、耦合更低。

如果面试官继续追问方法重写的规则,你需要答出这几点:

  • 方法名和参数列表必须完全一致;
  • 返回类型可以不同,但必须是原返回类型的子类型(协变返回类型);
  • 访问权限不能比父类更严格,比如父类是public,子类重写不能变成protected或private;
  • 不能抛出比父类更宽泛的受检异常;
  • 父类的构造器不能被子类继承,子类构造器第一行会隐式调用父类无参构造器super()。如果父类只有带参构造器,子类必须显式用super(参数)调用。

最后补一句里氏替换原则(LSP):所有能用到父类对象的地方,换成子类对象都应该能正常工作。这一句说出来,面试官会觉得你真的理解继承不是"为了复用方便就随便extends"。

2.3 多态:运行时才决定的动态绑定机制

多态分两种:编译期多态(方法重载)和运行期多态(方法重写)。面试官问多态,绝大多数情况指的是运行期。

Animal a = new Dog(); a.speak(); // 输出"汪汪"

a的静态类型是Animal,但运行时JVM会根据实际对象类型Dog,调用Dog重写的speak方法。这个过程叫动态绑定后期绑定。底层机制是:类加载的时候,每个类会生成一个虚方法表(vtable),表中存着方法实际入口地址。方法调用时,JVM通过实际类型的方法表找到对应实现,而不是直接调用编译期确定的方法地址。

我面试时还会加一问:"向上转型和向下转型了解吗?"向上转型是安全的,Animal a = new Dog()完全没问题,因为Dog必然有Animal的能力;向下转型有风险,Dog d = (Dog) a之前必须用instanceof判断,否则可能ClassCastException。把这条链路捋顺,多态这题就稳了。

3. String系列:Java基础面试的送分题也是送命题

在Java面试的题库里,哪类题最密集?String和集合绝对并列第一。String看似简单,但面试官可以从不可变性一路追问到常量池、equals/hashCode、StringBuilder优化、intern机制,串起来就是一个小体系。这里我把最常考的集中拆开。

3.1 为什么String被设计成不可变

先把JDK 8和JDK 9+的变化说清楚:JDK 8及之前,String内部是private final char value[];JDK 9开始为了节省内存改用private final byte[] value(配合coder字段区分Latin-1和UTF-16编码)。不管哪个版本,value数组是final的,String类也是final的,没有提供任何修改内部字符的public方法,所以String对象一旦创建,内容不可变。

为什么要不可变?四个关键原因:

  1. 字符串常量池复用。如果String可变,常量池里共享的字符串被一个引用改了,所有引用都会受污染。
  2. 安全性。String被大量用作网络参数、文件路径、反射的类名,可变字符串会造成严重安全隐患。
  3. 线程安全。不可变对象天然线程安全,不需要同步。
  4. 适合做HashMap的key。HashMap依赖key的hashCode缓存,String不可变保证hash值稳定。

经典追问:"new String("a")创建了几个对象?"答案是:如果常量池里还没有"a",会先创建一个常量池对象,再在堆中创建一个new对象;如果常量池已有"a",只创建一个堆对象。面试后再补一个:"String s = "a" + "b"创建了几个对象?"编译器会直接优化成常量池中的一个"ab",一个对象都没有创建在堆上。

3.2 ==、equals与hashCode的三角关系

这道题是所有Java面试题的"母题"之一。

  • ==:比较引用是否指向同一个对象,也就是内存地址是否相等;
  • equals():Object默认实现就是==,所以String类必须重写equals才能比较内容;
  • hashCode():返回对象的哈希值,是HashMap/HashSet定位的依据。

String重写equals的思路可以记住:先判断引用是否相同,再判断类型是否是String,再逐字符比较。回答时能说出"先比较length,长度不同直接false,避免逐字符扫描"这种细节会加分。

然后就是那个几乎必问的问题:为什么重写equals必须重写hashCode?

因为对象有两个"身份":逻辑等值是equals判断的,散列定位是hashCode判断的。约定是:equals相等,hashCode必须相等。反过来,hashCode相等,equals不一定相等,这叫哈希碰撞。

如果只重写equals不重写hashCode会怎样?举个例子,你创建了两个字段值相同的Person对象,equals返回true,但hashCode是Object默认的不同值。当你把Person放进HashSet时,第一个对象根据hash落到桶A,第二个对象根据不同的hash落到桶B。HashSet看到两个不同hash,直接判定是不同对象,于是同一个"逻辑对象"被存放了两次——这彻底破坏了Set不可重复的语义。

提示:HashMap用hash定位桶时,hashCode是先用来帮助equals快速判断的"粗筛条件",所以在散列集合里,hashCode和equals必须配合。

3.3 String、StringBuilder、StringBuffer到底怎么选

三者区别是必须背下来的:

  • String:不可变,拼接字符串会生成新对象,适合少量操作、内容不变的场景。
  • StringBuffer:JDK 1.0就有,方法加了synchronized,线程安全但性能略差。
  • StringBuilder:JDK 1.5引入,线程不安全,单线程下性能最优。

但面试官不会满足于背表格,他会问"字符串拼接的内部是怎么实现的"。如果你回答"编译器会用StringBuilder优化",要留个心眼——这只对单条拼接语句成立,比如String s = "a" + "b" + "c"会被编译成"abc"常量。但是循环里:

String s = ""; for (int i = 0; i < 10000; i++) { s += i; }

每次循环都会new一个StringBuilder对象,循环一万次就是一万次对象创建,代价极大。所以真正写代码时,循环拼接用手动创建StringBuilder。

3.4 intern():字符串面试题的高频加餐

String.intern()是一个native方法:如果常量池中存在该字符串,直接返回池中的引用;否则把该字符串放入常量池并返回引用。这个方法的坑在于:JDK 7之后常量池移到了堆中,intern的语义有细微变化。面试基本考一道题:String s1 = new String("a") + "bc"; String s2 = s1.intern();问s1 == s2是否为true。这类题太绕,基础篇阶段你只要记住intern的作用是手动把字符串加入常量池、返回池中引用就够了,不必深挖到版本差异。

4. 集合框架:HashMap的底层原理和并发安全隐患

集合是Java基础面试的绝对大头,尤其是HashMap。如果面试时间只有一个小时,面试官至少会在HashMap上花十五分钟。从存储结构问到hash算法,再问扩容,再问线程安全,一条龙下来,基础是否扎实一目了然。这一节把这几个问题一次说完。

4.1 集合框架地图与List的高频对比

先在心里建一张图:Collection接口派生出List、Set、Queue;Map是独立体系。平时最常用的List实现是ArrayList和LinkedList,对比维度:

对比项ArrayListLinkedList
底层结构动态数组双向链表
随机访问O(1),实现了RandomAccessO(n)
插入/删除尾部O(1)均摊,中间O(n)(需要移动元素)头部/尾部O(1),中间需遍历,O(n)
内存占用连续空间,可能预留容量每个节点还有前后指针,占用更高
适用场景频繁随机访问频繁头尾增删

ArrayList的扩容机制也是一个独立考点:初始容量10,每次扩容为原来的1.5倍(新容量 = 旧容量 + 旧容量右移一位),通过Arrays.copyOf把旧数据拷贝到新数组。追问"为什么是1.5倍"——太大会浪费内存,太小会导致频繁扩容复制数组,1.5倍是空间和时间的一个折中。

4.2 HashMap底层存储结构:数组、链表、红黑树三件套

JDK 1.8的HashMap由数组 + 链表 + 红黑树组成。数组是主体,每个位置叫桶(bucket);当多个key的哈希碰撞到同一个桶时,用链表串起来;链表长度超过阈值8且数组容量大于等于64时,链表转红黑树,把查找从O(n)降到O(logn)。

HashMap的hash函数值得单独说一句:key.hashCode()结果先做一次扰动计算h ^ (h >>> 16),把高位信息混入低位,降低哈希碰撞。索引计算则是(n - 1) & hash——因为数组长度是2的幂,n-1的二进制全是低位1,与hash做位与等价于取模,效率更高。

put的完整流程要能讲成一段话:

  1. 计算key的hash值,通过(n-1)&hash得到桶下标;
  2. 如果数组为null或长度0,先resize扩容;
  3. 桶为空,直接new Node放入;
  4. 桶不为空,比较key是否equals,相等则覆盖value;
  5. 不相等且当前桶是红黑树节点,走红黑树插入;
  6. 否则在链表尾部插入(尾插法,JDK 1.7是头插法,并发下头插容易成环);
  7. 链表长度超过8,尝试转红黑树(还要看数组长度是否达到64);
  8. 元素个数++后超过 threshold = 容量 * loadFactor,触发扩容。

接下来是三个高频追问:

  • 加载因子为什么是0.75?这是空间和时间折中的经典结果。太高(如1)空间利用率高但碰撞概率大,查询变慢;太低(如0.5)频繁扩容浪费空间。0.75是大量实验后Java官方选定的平衡点。
  • 为什么链表树化的阈值是8?源码注释里给出了统计依据:HashMap的哈希函数足够散列时,桶中元素个数服从泊松分布,链表长度达到8的概率约为千万分之六,极低。所以阈值8是为了在"极端冲突"时才动用红黑树,正常情况链表足够用。退化阈值是6,避免树和链表频繁切换。
  • HashMap的key有什么要求?equals和hashCode要按约定实现;不要把可变对象当key,否则插入后hashCode变化,就再也查不到了。

4.3 线程安全三兄弟:HashTable、Collections.synchronizedMap、ConcurrentHashMap

HashMap本身线程不安全,并发下put可能丢数据,甚至JDK 1.7扩容时形成循环链表导致死循环(JDK 1.8改为尾插后死循环问题从扩容角度大幅缓解,但仍有覆盖丢失问题)。所以并发场景要换工具:

  • HashTable:直接在方法上加synchronized,简单粗暴。所有线程争抢同一把锁,并发度极低,基本淘汰。
  • Collections.synchronizedMap:包装类,同样锁整张表,性能一般。
  • ConcurrentHashMap:正确方案。JDK 1.7用分段锁,每段独立加锁;JDK 1.8改用CAS + synchronized,锁粒度细化到单个桶。读操作大多不加锁,符合并发读多写少的实际场景。

另一个集合并发经典问题是fail-fast:在ArrayList遍历过程中如果调用list.remove(),会抛出ConcurrentModificationException。原因是迭代器内部维护modCount,每次next都会校验modCount是否改变。Java 8之后也可以用removeIf,底层使用迭代器的remove方法,不会抛异常。

5. 异常体系:除了try-catch,面试官更想听你怎么设计异常

异常这块的面试题看起来比集合少,但问起来非常"实战"。常见的连环问是:先问你异常分类,再问你受检异常和非受检异常的区别,然后让你写try-catch-finally的执行顺序,最后问"线上代码里catch环节最容易犯什么错"。

5.1 异常家族关系与受检异常之争

异常体系的根是Throwable,下面两大分支:

  • Error:JVM层面的严重问题,比如OutOfMemoryError、StackOverflowError。程序不该catch也不该处理,因为这类错误出现后程序基本无法恢复。
  • Exception:程序层面的异常,再分两类:
    • 受检异常(Checked Exception):如IOException、SQLException。编译器强制要求你在方法上throws或try-catch,否则编译不过。
    • 非受检异常(RuntimeException及其子类):如NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException。编译器不强制处理。

面试考"受检异常好还是非受检好"时,别站队,给判断标准:如果调用方能从异常中恢复,用受检异常提醒他处理;如果调用方无从处理,就用非受检异常快速失败。但说实话,现在Spring的开发模式下,很多团队倾向于用非受检异常,因为受检异常会污染方法签名,到处throws让人烦躁,而且很多受检异常(比如IOException)在Web应用里并没有真正的恢复场景。

5.2 try-catch-finally执行顺序与return的坑

经典题:try块里有return,finally块还会执行吗?

答案:。Java保证了finally一定在执行完try或catch后才返回。执行时机是:try中return表达式的值先算好压栈,然后执行finally,最后返回。如果finally里也有return,finally的return会覆盖try的return——这是最典型的错误写法,绝对不要在finally里return。

有三个情况finally不会执行:System.exit()调用、JVM崩溃、执行try语句的线程被杀死。

Java 7之后我更推荐try-with-resources

try (FileInputStream fis = new FileInputStream("a.txt")) { // 使用fis } catch (IOException e) { log.error("读取文件失败", e); }

好处是:只要是实现了AutoCloseable的资源,使用结束后自动调用close(),代码简洁,而且关闭异常不会覆盖主异常——try-with-resources会用抑制异常(suppressed exception)记录关闭阶段的异常,这在finally里手动close是做不到的。

5.3 自定义异常与线上代码的三个坏习惯

自定义异常要回答好,关键是区分两个选择:继承RuntimeException还是Exception?我的建议是绝大多数业务异常继承RuntimeException,因为业务异常通常没有调用方能恢复,让事务回滚、快速失败更适合。

实际写代码时最常见的三个坏习惯:

  1. 空catch块。吞掉异常是最坏的情况,线上排查时你根本不知道哪里错了,只能靠猜。
  2. 只log e.getMessage()。这样打印出来往往是一个"null pointer"之类的空话,真正的堆栈完全看不到。正确做法是log.error("xxx操作失败", e),把异常对象整个传进去,日志系统会打印堆栈。
  3. new异常时不带原始cause。捕获IOException后抛自定义异常时,要写throw new BusinessException("xxx", e),把根因传进去。否则到了上层,你只知道业务失败,不知道为什么失败。

6. 泛型与反射:基础篇里的两座大山

泛型和反射属于那种"平时写业务代码用得少,但一用就离不开"的知识。面试官考这两个主题,真实意图是看你有没有理解Java的运行机制和框架底层的原理。Spring的依赖注入、MyBatis的Mapper代理、JDK动态代理都建立在这两座山上。

6.1 泛型的本质是类型擦除

一句话答核心:Java泛型是编译期的语法糖,运行时不携带类型参数信息。这就是所谓的类型擦除(Type Erasure)。

List<String> stringList = new ArrayList<>(); List<Integer> integerList = new ArrayList<>(); System.out.println(stringList.getClass() == integerList.getClass()); // true

两个list在运行时Class完全相同,都是ArrayList.class。String和Integer的类型信息在编译后就被擦除了,List内部实际存放的是Object(如果指定了上界,就擦除到上界类型)。

那"类型安全"是怎么保证的?编译器在编译期检查你只能往List 里放String,并且在读取时自动插入强制类型转换。真正在字节码层面,list.add和list.get都变成了Object的操作和强转。

进阶考点是桥方法。比如子类实现一个泛型接口Comparator<String>,编译器会额外生成一个参数为Object的桥方法,先强转再调用真正的compare方法,目的是保证多态在泛型擦除后依然成立。把这个说出来,面试官会觉得你阅读过Java官方文档的泛型章节。

注意,反射可以绕过泛型检查:通过getMethod拿到add方法后invoke,往List 里放Integer是能成功的。这个知识点经常和"反射是否破坏安全"一起考,可以主动提一下。

6.2 通配符与PECS原则

泛型通配符的经典考法:

  • List<?>:无界通配符,任何类型都可以放,但只能读不能写(写什么类型都不安全)。
  • List<? extends Number>:可以放Number及其子类的List,只能读,不能写。因为实际类型可能是List ,你写个Double进去就类型不安全了。
  • List<? super Integer>:可以放Integer及其父类的List,可以写Integer,但读出来只能当Object用

记忆法则PECS:Producer Extends, Consumer Super。如果参数只从集合中读取(生产者),用extends;如果参数只往集合中写入(消费者),用super。JDK里Collections.copy(List<? super T> dest, List<? extends T> src)就是最经典的应用场景。

6.3 反射:动态获取类型信息的四个核心操作

获取Class对象的三种方式,必背:

Class<?> clazz1 = Person.class; // 方式一:类名.class Class<?> clazz2 = person.getClass(); // 方式二:实例.getClass() Class<?> clazz3 = Class.forName("com.example.Person"); // 方式三:全限定名

有了Class对象,可以继续做四类事情:查看类结构(getDeclaredFields/getDeclaredMethods)、创建实例(clazz.getDeclaredConstructor().newInstance())、调用方法(method.invoke(obj, args))、访问字段(field.set(obj, value))。

一个完整的反射调用示例:

Class<?> clazz = Class.forName("com.example.User"); Object user = clazz.getDeclaredConstructor().newInstance(); Method setName = clazz.getDeclaredMethod("setName", String.class); setName.invoke(user, "张三"); Field name = clazz.getDeclaredField("name"); name.setAccessible(true); // 突破private限制 System.out.println(name.get(user));

面试官通常会追问反射的缺点:一是性能开销大,反射涉及动态解析类型、安全校验,比直接调用慢一个数量级,所以框架会用缓存或运行时生成字节码(如Spring使用CGLIB)来规避;二是破坏封装,setAccessible(true)可以绕过private访问限制,给安全带来隐患;三是代码可读性差,大量的反射代码很难阅读和调试。

这里有个判断面试深浅的分水岭:初级候选人知道反射能"看到私有字段",高级候选人知道反射调用私有字段在生产代码里要慎重,并且能解释Spring中@Autowired为什么借助反射+缓存来提高性能。

7. 多线程基础入门:并发篇的地基在这里

很多面试者到了JUC(java.util.concurrent)部分开始背锁、背线程池,但问到底层基础概念反而含糊。我自己的看法是:基础篇的多线程没必要追到AQS那么深,但下面四个问题必须烂熟,否则并发篇就是空中楼阁。

7.1 创建线程的四种方式与线程状态机

  • 继承Thread类重写run方法;
  • 实现Runnable接口传给Thread;
  • 实现Callable接口(能返回结果、能抛异常)配合FutureTask;
  • 使用线程池ExecutorService。

面试在聊第四种方式时,重点不是"怎么new一个线程",而是为什么推荐用线程池:减少线程创建销毁的开销、控制并发数量防止资源耗尽、方便统一管理(定时、调度、监控)。

线程的6种状态要能说出来,并解释状态间怎么流转:NEW(新建未启动)、RUNNABLE(可运行/运行中)、BLOCKED(等待锁)、WAITING(无限期等待)、TIMED_WAITING(有期限等待)、TERMINATED(终止)。

sleep和wait的区别是必考题:sleep不释放锁,wait会释放锁;sleep是Thread的静态方法,wait是Object的方法;wait必须要在synchronized块中调用,sleep则没有这个限制;wait需要被notify/notifyAll唤醒(或带超时参数自动醒来)。

7.2 synchronized与volatile:可见性、有序性、原子性三角

synchronized的三种用法:

// 1. 同步实例方法:锁的是this对象 public synchronized void method1() { } // 2. 同步静态方法:锁的是当前类的Class对象 public static synchronized void method2() { } // 3. 同步代码块:锁是指定的对象 public void method3() { synchronized (lock) { } }

底层原理:每个Java对象头里有一个Monitor锁(监视器锁),线程进入同步代码块前要获取Monitor,执行完释放。被synchronized修饰的代码块编译后会多出monitorenter和monitorexit指令。

volatile要讲清楚两件事:可见性有序性

  • 可见性:一个线程修改共享变量会被主内存同步,其他线程读的时候能立刻看到最新值。原理是volatile变量在读后会强制从主内存刷新,写前会强制回写主内存,并禁止指令重排序。
  • 不保证原子性:count++在字节码层面是读、加、写三步,volatile只保证读和写各自的可见性,不保证三步操作作为一个整体不被中断。所以volatile不能替代synchronized解决i++并发问题。

面试时给一道经典可见性题目:一个线程死循环while (!flag)读一个普通boolean flag,另一个线程改flag为true,程序不会停;把flag声明为volatile后,程序能正常退出。能讲清楚这个例子,volatile的可见性就理解到位了。

7.3 从synchronized到ReentrantLock:Lock不是替代品,是补充

Lock接口(主要是ReentrantLock)和synchronized的区别,用表格对比更清楚:

维度synchronizedReentrantLock
获取锁方式隐式获取、释放显式lock()/unlock(),需在finally中释放
可中断性不可中断lockInterruptibly()支持中断等待
超时获取不支持tryLock(timeout)支持
公平锁非公平构造器可选公平/非公平
条件队列wait/notifynewCondition(),支持多个等待队列
锁绑定每次只有一个条件一个Lock可关联多个Condition

注意别答成"Lock比synchronized更高级所以应该用Lock"——在JDK 6之后,synchronized经过锁升级优化(无锁->偏向锁->轻量级锁->重量级锁),在很多情况下性能并不差,而且synchronized语法更简洁、自动释放锁更安全。Lock的价值在于需要超时、可中断、公平性等高级特性时才值得用。

最后补充两个协作工具够用即可:CountDownLatch让主线程等待一组线程执行完再继续;CyclicBarrier让一组线程相互等待,全部到达后再同时执行。基础篇能区分各自适用场景就行,具体源码分析放到并发篇。

8. Java 8新特性:从Lambda到Stream,高频必问

现在面试Java,不问Java 8新特性几乎不可能。Lambda、Stream、Optional、接口默认方法已经不只是语法糖,而是日常开发的主旋律。不懂Lambda,你连公司现成的代码都可能看不懂。这一节把核心考点过一遍。

8.1 Lambda表达式与函数式接口

Lambda的底层是函数式接口的实例。所谓函数式接口,就是只有一个抽象方法的接口,通常用@FunctionalInterface注解标注。Java 8内置了一批函数式接口,最常用的四个:

接口输入输出典型场景
Function<T,R>TR转换类型
PredicateTboolean过滤判断
ConsumerTvoid消费处理
SuppliervoidT延迟生成

Lambda语法三要素:参数列表、箭头、方法体。几个典型写法:

// 传统匿名类 Runnable r1 = new Runnable() { @Override public void run() { System.out.println("hello"); } }; // Lambda Runnable r2 = () -> System.out.println("hello"); // 方法引用:更简洁的Lambda list.forEach(System.out::println); list.stream().map(String::toUpperCase).collect(Collectors.toList());

方法引用四种形式要能区分:类名::静态方法(如Integer::parseInt)、对象::实例方法(如System.out::println)、类名::实例方法(如String::toUpperCase)、构造器引用(如ArrayList::new)。

8.2 Stream API:中间操作是惰性的

Stream的核心理念是管道式操作:数据源 -> 中间操作 -> 终止操作。中间操作包括filter、map、distinct、sorted、limit、skip;终止操作包括forEach、collect、reduce、count、anyMatch等。

关键结论:中间操作是惰性求值的,没有终止操作,Stream不会执行任何实际计算。比如:

list.stream().filter(x -> x > 10).map(x -> x * 2); // 这行代码什么都不会做

加上collect后,整条链路才会真正执行。这个特性的实际价值是:Stream可以在一次遍历中完成所有操作,而不是每个中间操作都遍历一次集合,避免中间集合的创建。

分组统计是面试里常见的实操题:

Map<String, Long> countByType = list.stream() .collect(Collectors.groupingBy(Item::getType, Collectors.counting()));

parallelStream并行流也要提一句:底层用ForkJoinPool公共线程池,数据量小时并行反而更慢,而且并行流中如果涉及共享可变状态,会有线程安全问题。正确姿势是数据量大、元素处理耗时、且操作无状态时才考虑。

8.3 Optional、接口默认方法与接口新能力

Optional是用来解决NullPointerException的工具类。问题是很多新人把它用成了"包装null",其实Optional的核心是强迫调用方处理可能为空的情况,而不是让你optional.get()直接取值。

常用方法差异:

  • orElse(x): 为null时返回x,无论是否为空,x表达式都会先被求值
  • orElseGet(() -> ...): 为null时才执行lambda,是惰性的;
  • orElseThrow(() -> new BusinessException(...)): 为null时抛出异常,这是我在业务代码中推荐最多的写法。

接口的默认方法(default method)和新接口静态方法(static method)是Java 8解决集合库演进问题的关键。举例:要给Collection接口新增stream()方法,如果直接加抽象方法,所有实现Collection的类全部要修改,实现类数量巨大且不可控。用default方法提供默认实现,已有的ArrayList、LinkedList等实现类不用改一行代码就能继承默认实现,这就是默认方法存在的意义。追问时答出这一点,就说明你理解了它的设计动机,而不是只背了"接口里可以写方法体"。

9. 基础篇复习时,最容易翻车的几个细节

前面几章把Java基础的大头过了,但这些年我面试和带新人时,发现有些零散细节特别容易翻车。它们单独拿出来不算一个体系,但每一题都能在简历上划一道口子。这一章就给这些"边角料"收个口。

9.1 数组越界与遍历时的坑

ArrayIndexOutOfBoundsException是Java程序员第一个见到的异常之一,但面试并不止于"访问了不存在的下标"。常见追问包括:

  • 增强for循环编译后是什么样?其实是基于Iterator的语法糖,底层用Iterator遍历。数组版本的增强for会被编译成普通for循环 + 下标访问。
  • 遍历时为什么不能remove?ArrayList的Iterator在next()时会检查modCount,一旦在遍历过程中调用list.remove()(而不是iterator.remove()),modCount变化就会抛出ConcurrentModificationException。正确做法是使用iterator.remove(),或者用Java 8的removeIf。
  • for(int i = 0; i < list.size(); i++)每次循环都调用size()是不是低效?ArrayList的size()是直接返回字段值,O(1),不用担心;但如果你有更好的写法,可以先把size()存成局部变量。

9.2 运算符和表达式的经典陷阱

  • 短路与&&、短路或|| vs 位与&、位或|:&&当左侧false时右侧不执行,&则两侧都执行。特例是,&和|在boolean运算中也有非短路语义,面试题经常用if (a != null & a.equals("x"))来挖npe陷阱。
  • 三目运算符的空指针陷阱
boolean flag = false; Integer num = 1; int result = flag ? num : 100; // 正常 Integer result2 = flag ? null : 100; // 如果flag=true,自动拆箱,NPE

第二个例子中,三目运算符的两个分支类型需要统一,null被强制拆箱成int时抛NullPointerException。这是一个非常隐蔽的坑,代码评审时我抓到过好几次。

  • ++i和i++:++i先自增再返回,i++先返回再自增。在循环里性能差异微乎其微,所以这个问题通常只考"返回值区别"。

9.3 标识符、枚举与BigDecimal:看起来基础,实际是底线

标识符命名规则:Java标识符以字母、下划线_、美元符$开头,后续字符可以是字母、数字、下划线、美元符;不能以数字开头;不能是Java关键字。关于"$"这个字符有一个冷面试点:编译器允许,但规范强烈不建议使用,因为内部类生成的类名会包含$。

枚举(enum):很多人以为枚举只是"常量列表",你说到"enum本质上是一个继承java.lang.Enum的final类,可以定义字段、方法、构造器"就比别人深一层。枚举可以用于switch、单例模式(天然防止反射和序列化破坏)、状态机建模。面试问"枚举如何保证单例安全",要能答出反序列化和反射都会破坏普通单例,而枚举的序列化是专门处理的,反射也无法创建枚举实例。

BigDecimal为什么不能替换成double做金额计算:double的二进制浮点表示无法精确表达0.1这样的十进制小数,会出现0.30000000000000004这种误差。金额计算必须用new BigDecimal(String)而不是new BigDecimal(double),构造器传double仍然有误差,正确写法是new BigDecimal("0.1")。这也是线上支付系统里一个真实的血泪教训。

回到开头那句话:八股不是背完就完事。我带候选人时发现,能在大脑中画出HashMap put流程图的人,比把源码注释背得滚瓜烂熟的人更靠谱。原因很简单——画图的过程逼着你去理清因果链条。我的建议是,这一篇你读完别急着做下一件事,先合上屏幕,拿一张白纸,把九章的标题写下来,然后凭记忆在每章下面默写核心问题和答案链条。写不出来的就是你的薄弱点,回头再翻一遍。这个方法不需要额外的资料,也不用花钱,但效率比自己闷头刷十遍题高出太多。基础篇地基打牢了,后面的JVM、并发、Spring源码才接得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询