“java基础知识点复习笔记(上)”——看到这个标题,我第一反应是:又要准备跳槽或者准备校招了吧。说实话,这些年我面试过不少人,也被别人面试过,一个Java开发者基础扎不扎实,往往聊不到十分钟就能看出来。基础这东西,平时写业务代码感觉不到它的存在,但一到面试、一到排查线上问题、一到做代码重构,它就会跳出来考验你。
这份笔记我整理了很久,定位是给两类人看的:一类是准备Java面试的开发者,需要把零散的知识点系统过一遍;另一类是工作了两三年、平时CRUD写得多但想查漏补缺的朋友。内容不会像教科书那样面面俱到,而是把高频考点、容易踩坑的点、面试官真正想听的点拎出来,配合代码示例和实战经验来讲。
既然是“上”篇,我先聚焦最核心的地基部分:数据类型、面向对象、字符串、集合框架、异常处理。这些都是Java体系里最基础也最常考的内容,把这些吃透了,再往JVM、并发、Spring那些深水区走,才不会心虚。
1. 数据类型:基础中的基础,却藏着最多的坑
1.1 八大基本类型与包装类,你真的分清楚了吗
Java的基础数据类型是八种:byte、short、int、long、float、double、char、boolean。这是面试开场最常见的问题,但很多人答得并不完整,只会背“int 4字节、long 8字节”,一旦问到float和double为什么不能直接用==比较,就卡住了。
先给一张我复习时自己整理的速查表:
| 类型 | 字节数 | 默认值 | 取值范围(大致) | 应用场景 |
|---|---|---|---|---|
| byte | 1 | 0 | -128 ~ 127 | 二进制流处理、节约内存的数组 |
| short | 2 | 0 | -32768 ~ 32767 | 较少使用,兼容老协议时见过 |
| int | 4 | 0 | 约±21亿 | 最常用的整数类型 |
| long | 8 | 0L | 很大 | 时间戳、ID、大数值计算 |
| float | 4 | 0.0f | 约±3.4E38 | 科学计算,精度有限 |
| double | 8 | 0.0d | 约±1.7E308 | 默认浮点类型 |
| char | 2 | '\u0000' | 0 ~ 65535 | 字符处理 |
| boolean | 未明确定义 | false | true/false | 逻辑判断 |
注意boolean的字节数,Java虚拟机规范里没有明确规定,实际实现中JVM通常用int代替boolean,所以一个boolean在数组中可能占1字节,单独声明时可能占4字节。这个细节面试官偶尔会挖,能答出来会加分。
包装类是另一个高频考点,重点是自动装箱与拆箱的底层实现。Integer i = 100这行代码,编译后实际是Integer.valueOf(100);而int j = i编译后是i.intValue()。所以每一种包装类都对应一个静态工厂方法和一个xxxValue方法,这是基础中的基础,必须记牢。int的默认值是0,但Integer的默认值是null,我见过太多人在写实体类时用int做字段类型,数据库一查出来没有值,直接NPE,这是真实项目里最常见的低级错误。
1.2 Integer缓存、浮点精度与==陷阱
Integer缓存是面试题里的常青树。Integer.valueOf()方法在 -128 到 127 之间会返回缓存的同一个对象,超出这个范围才new新对象。所以Integer a = 100; Integer b = 100; a == b返回true,而把100换成128就返回false。这个坑我在代码评审里见到过不止一次,有人用==比较两个包装类,小数值没问题,一上线数据大了就开始出bug。
解决办法很简单:两个包装类比较,一律用equals(),或者用Objects.equals()。但这里还有个新坑,就是包装类的equals对类型敏感,new Integer(100).equals(100L)返回false,因为一边是Integer一边是Long。所以如果要比较不同类型数值,建议先把它们转成同一个类型再比较,或者用BigDecimal。
浮点精度问题是另一个必须掌握的考点。0.1 + 0.2 == 0.3在Java里返回false,因为浮点数在二进制中无法精确表示,这是计算机组成原理决定的。面试官通常在这个问题后面会追问:那怎么办?标准答案是涉及金额计算时用BigDecimal,而且BigDecimal构造时必须传字符串,否则new BigDecimal(0.1)得到的仍然是不精确的值。我自己写金融相关代码时,会统一封装一个金额工具类,内部用BigDecimal,任何金额计算都不允许用double直接做。
2. 面向对象:从概念背诵到真正理解
2.1 封装、继承、多态,面试官真正想听的是什么
面向对象三大特性,几乎每个Java面试都会问。但大部分人的回答就是背定义:“封装就是把属性私有化,继承就是子类继承父类,多态就是一个接口多种实现。”这些没错,但不够。面试官问三大特性,其实是想听你对“设计”的理解。
封装的核心不是private修饰符,而是“隐藏实现细节,暴露稳定接口”。我复盘自己的开发经验,封装做得好不好,直接表现在需求变更时的改动量上。比如一个订单金额计算逻辑,如果只有getOrderAmount()这个方法对外暴露,里面怎么调整折扣、怎么计算税费,调用方根本不用关心,这就是封装的意义。
继承在面试里的重点,往往会引导到“继承与组合怎么选”这个问题上。继承是Is-A关系,组合是Has-A关系。我见过很多代码滥用继承,比如为了复用两个方法就extends一个类,结果父类一改动,子类莫名其妙出bug。实际项目里,我倾向优先使用组合,因为组合更灵活,不会打破封装。但继承在框架代码里确实随处可见,比如Spring的@Service类通常继承一个抽象基类,Template Method模式就是靠继承实现的。面试时能讲出这个权衡,比单纯背概念要加分得多。
多态是我认为三大特性中最核心的。Java的多态体现在方法重载和方法重写,但底层原理是动态绑定——虚拟机在运行时根据对象的实际类型来调用方法。这里面试官常会追问:重载和重写的区别?重载是编译期决定的,看的是引用类型和参数列表;重写是运行期决定的,看的是实际对象类型。所以Father f = new Son(); f.method();调用的到底是Father的method还是Son的method?答案是Son的(如果Son重写了method的话),因为JVM在运行时才做动态分派。这个考点我面试别人时几乎必问,答得清楚的人,说明对多态的理解是到位的。
2.2 抽象类与接口:Java 8之后,界限已经模糊了
抽象类和接口的区别是经典面试题,但很多人的答案是老版本Java的答案。Java 8之后接口可以有default方法和static方法,Java 9之后接口还可以有private方法,再加上接口的字段只能是public static final常量,抽象类可以有各种成员变量——两者的边界确实模糊了,但设计语义仍然不同。
我的理解是:抽象类描述的是“是什么”,接口描述的是“能做什么”。比如AbstractList和List接口的关系就很典型。AbstractList是抽象类,它把List接口的部分方法实现了,留给子类去扩展;而List接口定义了列表该有的行为契约。一个类只能继承一个抽象类,但可以实现多个接口,这是Java设计上的硬性约束。
面试时如果你能主动提到Java 8的default方法带来的一个经典问题——菱形继承,会让面试官觉得你是有思考的。一个类实现了两个接口,两个接口都有同名的default方法,编译会报错,必须重写这个方法并手动指定调用哪个接口的default方法。代码是这样:
interface A { default void hello() { System.out.println("A.hello"); } } interface B { default void hello() { System.out.println("B.hello"); } } class C implements A, B { @Override public void hello() { A.super.hello(); // 指定调用A的默认方法 } }这个知识点不常考,但能答出来会明显提升面试的印象分。我做技术方案评审时,也会用这个案例提醒团队:接口新增default方法要考虑兼容性,不能想加就加。
2.3 重载与重写:别把这两个概念搞混了
重载和重写的区别,几乎每次笔试都会出选择题,但很多人还是会踩坑。我总结一句话:重载是同一个类里方法名相同、参数列表不同;重写是子类方法覆盖父类方法,方法签名必须一致(返回类型可以是协变类型,访问修饰符不能比父类更严格,抛出的异常不能比父类更宽泛)。
实际项目里重写的坑点我遇到过一个很经典的:子类重写方法时把参数类型改了,比如父类是method(String s),子类写成了method(Object o),这其实变成了重载而不是重写,加上@Override注解编译器立刻就会报错。所以我会跟团队说:重写方法必须加@Override注解,这不是可选项,是强制规范。
重载的另一个注意点是自动装箱的参与:method(int i)和method(Integer i)同时存在时,调用method(1)会优先匹配method(int),因为基本类型的匹配优先级更高,不会触发装箱操作。而method(null)在这种情况下会报歧义错误,因为null既可以装箱成Integer也可以匹配Object等类型,编译器无法判断。这个考点我在字节的笔试里见过,属于没写过就容易栽的题。
3. 字符串:Java中最特殊的一个类
3.1 String为什么是不可变的
String的不可变性(immutability)是面试高频题,也是理解JVM字符串常量池的前提。String类被final修饰,字符数组也是final的,所有修改字符串的方法返回的是新对象,原对象不变。这个设计不是随意的,背后有几个原因:
一是字符串常量池的复用。如果字符串可变,那么两个指向同一个字符串常量的引用,一个把内容改了,另一个也会跟着变,这将摧毁常量池的安全性和缓存意义。二是安全性,String在Java里被广泛用作参数、类名、URL等,不可变可以避免恶意修改。三是线程安全,不可变对象天然线程安全,不需要额外加锁。
实际开发中,我遇到过因为拼接字符串导致的内存问题。在一个循环里用+做字符串拼接,循环几千次,每次都会在堆里产生新的字符串对象,GC压力很大。正确的做法是用StringBuilder或者StringBuffer。面试官可能会问这两者的区别:StringBuilder线程不安全,StringBuffer线程安全(方法加了synchronized),单线程场景下StringBuilder性能更好。
这里给个我自己的实用建议:不要在方法内使用StringBuffer,因为方法内没有多线程竞争,StringBuffer的同步开销毫无意义;但如果字符串是类的成员变量且可能被多个线程操作,那就必须用StringBuffer或者加锁。这个细节面试时主动提出来,面试官会认为你有真实的并发编程经验。
3.2 常量池与intern(),追根溯源的一道好题
常量池相关最经典的一道面试题是:String s = new String("abc")创建了几个对象?答案是可能两个:一个是常量池里的"abc"(如果之前没有的话),一个是堆里new出来的对象。如果常量池里已经有"abc",则只创建一个。这个题考察的是JVM内存结构和字符串驻留机制,回答时最好把两种情况分开说清楚。
intern()方法的作用是把字符串放入常量池,如果常量池已有相同内容的字符串,则返回池中的引用。实际操作中,我很少主动调intern(),但有个场景很有用:在大量重复字符串的场景下,比如从数据库查出来一万条数据,每条都有相同的状态字段,用intern()可以极大降低内存占用。但要小心,JDK 7之前intern的字符串放在永久代,容易OOM;JDK 7之后移到了堆里,风险低了但也不是完全没风险。面试能说到这里,基本就很完整了。
关于字符串比较的区分,我还要强调一点:"abc".equals(s)和s.equals("abc")在s可能为null时,前者更安全,因为常量字符串的equals不会抛NPE。我见过项目里因为s.equals("xx")这种写法,在s为null时直接抛异常,后来排查发现是数据源里出现了一条脏数据。代码规范上,我推荐字符串比较统一写成常量在前,或者直接用Objects.equals(s, "abc")。
3.3 字符串拼接:编译器的优化与反例
字符串拼接还有一个隐藏考点:编译器对+的优化。Java 8及之前,字符串常量拼接如"a" + "b"在编译期就合并成"ab"了,所以"a" + "b" == "ab"是true。但涉及变量的拼接str + "b",编译后实际上是new StringBuilder().append(str).append("b").toString()。Java 9之后引入了invokedynamic和StringConcatFactory,优化方式变了,但结果仍然是生成新字符串。
一个常见的错误认知是:循环里用+拼接和StringBuilder性能差不多,因为编译器会优化。实际上,在循环体内每次都会创建新的StringBuilder,性能反而更差。我在一次性能排查中发现,一个字符串拼接逻辑在循环里执行了五万次,耗时直接拖慢了接口响应,改成循环外创建StringBuilder后,耗时几乎降为原来的三分之一。所以结论是:循环内拼接字符串,一定要手动用StringBuilder。
4. 集合框架:核心高频考点的聚集地
4.1 集合体系结构:先建立整体认知
Java集合框架从顶层看分为两大体系:Collection(存放单列元素)和Map(存放键值对)。Collection下面又分为List、Set、Queue三大接口。List是允许重复、有序的集合;Set是不允许重复的集合;Queue是队列。Map则是独立的体系,每个元素是一个键值对。
面试官一般会先从宏观问起:ArrayList和LinkedList的区别。这个题的基础答案是ArrayList底层是数组,随机访问快、插入删除慢;LinkedList底层是双向链表,插入删除快、随机访问慢。但深入一点的考法是:具体在什么场景下用哪个?我的实战经验是,开发中90%以上的情况都选ArrayList,因为遍历、索引的需求占绝大多数;LinkedList的插入删除快是有前提条件的,它在中间插入时需要先遍历找到位置,复杂度是O(n),真正快的只有头尾操作。
这里我给一个面试加分点:LinkedList在Java 6之前实现的是纯双向循环链表,所以addFirst和addLast都是O(1);Java 7之后去掉了循环结构,变成普通双向链表,但对实际使用影响不大。能答出这个版本差异的候选人,说明是真的看过源码的。
4.2 HashMap原理:面试必问的重头戏
HashMap是Java集合里最核心的考点,几乎每场面试必问。面试官会连环追问:底层数据结构是什么?put的流程是什么?扩容机制是什么?为什么用红黑树?我按这个顺序把核心逻辑梳理一遍。
HashMap在JDK 1.8之后的底层是数组+链表+红黑树。put一个键值对时,先通过hash(key)算出哈希值,再用(n - 1) & hash定位到数组下标(这里n是数组长度,这个位运算等价于取模,但效率更高)。如果该位置没有元素,直接放入;如果有元素,比较key是否相同,相同则覆盖,不同则插入链表尾部(尾插法)。当链表长度超过8时,链表会转化为红黑树,这是为了把极端情况下的查询复杂度从O(n)降到O(log n)。
扩容是另一个高频考点。HashMap的默认初始容量是16,负载因子是0.75,当元素个数超过容量 * 负载因子时,容量翻倍为原来的两倍。为什么负载因子是0.75,这是个权衡:太小了浪费空间,太大了增加哈希冲突概率。而且HashMap的容量要求是2的幂次方,因为只有容量是2的幂时,(n - 1) & hash才能均匀分布。
关于HashMap为什么用红黑树而不是平衡二叉树,我补充一个理解:红黑树不是严格平衡的,它只保证从根到叶子的最长路径不超过最短路径的两倍,这样插入和删除的代价低,查询效率也能接受。AVL树是严格平衡的,插入删除时需要更多旋转操作,实际性能反而不好。这个细节能答出来,说明你真的研究过数据结构。
4.3 线程安全集合:fail-fast与并发容器的选择题
集合相关还有一个面试必考点:哪些集合是线程安全的?ArrayList线程不安全,Vector线程安全(已过时),HashMap线程不安全,Hashtable线程安全(已过时),ConcurrentHashMap线程安全(推荐)。
Vector和Hashtable虽然线程安全,但实现方式是在方法上直接加synchronized,多线程竞争时并发度极低,性能很差。现代Java开发里基本不用它们,替代方案分别是CopyOnWriteArrayList和ConcurrentHashMap。
ConcurrentHashMap在JDK 1.8之后的实现是CAS+synchronized锁头节点,锁粒度比Hashtable锁整个对象细得多,所以并发性能更好。CopyOnWriteArrayList则是读时不加锁、写时复制整个数组,适合读多写少的场景,比如监听器列表。
fail-fast机制也值得单独记一下:ArrayList和HashMap的迭代器是fail-fast的,意思是迭代过程中如果结构被修改(增删元素),会抛出ConcurrentModificationException。但这个机制不保证一定发生,它只是用modCount属性做快速检测,属于并发修改的保护手段,不是强一致性保证。实际项目里如果在遍历集合时需要删除元素,不要用集合的remove()方法,而应该用迭代器的remove()方法,这样不会触发fail-fast。这个点我代码评审时经常纠正别人。
5. 异常处理:写好异常,项目少出问题
5.1 异常体系结构:受检与非受检的边界在哪
Java的异常体系顶层是Throwable,下面分Error和Exception两个分支。Error代表JVM层面的严重错误,比如StackOverflowError、OutOfMemoryError,程序一般不该去捕获它们,也基本捕获不了。Exception下面又分为受检异常(Checked Exception)和非受检异常(RuntimeException)。
受检异常是编译期强制要求处理的异常,比如IOException、SQLException,不处理就编译不过。非受检异常是运行时异常,比如NullPointerException、IllegalArgumentException、IndexOutOfBoundsException,编译期不强制处理。这是Java的一个设计特点,很多现代语言(如C#、Kotlin)并不区分受检异常。面试时如果面试官问“你对受检异常怎么看”,可以表达自己的观点:受检异常强制调用方处理,但有时会导致代码里充斥着try-catch而忽略了真正的错误处理逻辑。
我自己的经验是,Service层抛出受检异常会导致Controller层被迫处理,代码很脏。所以很多实际项目的做法是把业务异常定义成RuntimeException的子类,配合@RestControllerAdvice做统一异常处理。比如定义BizException extends RuntimeException,抛的时候带错误码和错误消息,全局异常处理器统一捕获并返回给前端。这样代码干净、调用方无感、错误信息还能统一管理。
5.2 善用try-with-resources与正确的异常处理姿势
JDK 7引入了try-with-resources语法,让资源管理变得优雅了很多。以前关闭流是这样写的:
BufferedReader reader = null; try { reader = new BufferedReader(new FileReader("test.txt")); // 读文件... } catch (IOException e) { log.error("读取文件失败", e); } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { log.error("关闭流失败", e); } } }用try-with-resources之后:
try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))) { // 读文件... } catch (IOException e) { log.error("读取文件失败", e); }写法简化了很多,而且实现了AutoCloseable的资源会自动关闭,关闭顺序和资源声明顺序相反。这里有个容易忽略的细节:try-with-resources语法在编译后生成的字节码,会在try块中发生异常时,把close()抛出的异常作为suppressed异常附加到主异常上,而不是覆盖主异常。用getSuppressed()方法可以拿到这些被抑制的异常。面试能提到这个,说明你真的读过相关源码。
关于异常处理的另一个常见问题是:catch了异常但是不做任何处理,只打个空日志或者干脆不写。这是我最讨厌的代码之一。正确的做法有三个层次:向上抛出让上层处理、记录完整日志(堆栈信息)后转成业务异常抛出、catch后做降级处理并记录日志。原则是:要么处理,要么抛出,绝不能静默吞掉。因为这个坑,我排查线上问题时花费的冤枉时间太多了,一条日志里看到catch块里只写了e.printStackTrace(),日志文件里还只是无用的堆栈,完全无法定位问题所在。
5.3 日志与异常:把堆栈信息记录完整
说到日志,我这些年踩过的坑就是:很多人catch异常后,日志只记录异常消息,比如log.error("xxx失败:" + e.getMessage()),这在多线程、高并发的环境里,根本没有办法定位到具体是哪一行代码出的问题。正确做法是用log.error("操作xxx失败,参数xxx", e),把异常对象作为最后一个参数传进去,这样日志里会输出完整的堆栈信息。第二个参数的意义在于:异常波基和上下文参数一起输出,排查问题时能还原现场。
还有一个容易被忽视的点:不要吞掉检查型异常的读取操作。我在数据库操作、文件操作相关的代码里见过太多免检型的catch,结果异常被吞了,后续代码继续运行,整个业务数据都错了。不管是什么异常,如果一个catch块决定不抛出、不处理,那么至少要记录日志并返回一个合理的fallback值,而且要打warning甚至error级别,让监控系统能感知到。
6. 复习自查:用这些题检验你的基础牢不牢
整理完上面的知识点,我出一套自查题,大家复习完之后可以对着检验一下自己的掌握程度:
Integer a = 127, b = 127; a == b结果是?如果换成128呢?为什么?float f = 1.1;这行代码能编译通过吗?为什么?- 重写和重载的本质区别是什么?
@Override注解的作用是什么? - 抽象类和接口在Java 8之后还有什么本质区别?实际项目中怎么选?
String s1 = new String("abc"); String s2 = "abc"; s1 == s2结果是?s1.equals(s2)结果是?- 字符串拼接在循环体内做和循环体内用StringBuilder,性能差距有多大?为什么?
- HashMap的put流程是什么?链表转红黑树的条件和原因是什么?
- ArrayList在遍历时能不能删除元素?安全的方式是什么?
- 受检异常和非受检异常的区别是什么?业务异常应该继承哪一类?
- try-with-resources为什么比传统finally写法更好?suppressed异常是什么?
这十道题如果都能答清楚,Java基础的上半部分基本是过关的。答不清楚的地方,就回去把对应的章节再翻一遍。我自己复习时有个习惯,不是背书,而是每看一个知识点,就尝试用自己的话讲给旁边的人听,讲不出来就是没掌握。这个方法比闷头刷题有效得多。
这篇“上”篇覆盖了数据类型、面向对象、字符串、集合和异常处理,写完之后我自己也重新理清了几个以前模糊的概念,特别是异常处理那块和HashMap红黑树转化的细节。后续如果大家需要,我可以继续整理“下”篇,把JVM内存模型、类加载机制、多线程并发、泛型与反射这些进阶内容补齐。复习Java基础这件事,看起来是在应付面试,但实际上,把这些底层逻辑弄明白了,写业务代码的时候你会发现自己对很多问题都有了一种“原来如此”的掌控感。