1. 为什么“Java基础”值得反复回炉
1.1 从一次代码评审说起
前阵子帮一个朋友看他的项目代码,一个简单的订单状态流转逻辑,写了将近两百行,嵌套了四层 if-else,中间还夹着几个魔法数字和硬编码字符串。我问他为什么不抽个枚举或者状态机,他说:“Java基础没打牢,枚举语法我记不太清,怕写错。”
这个场景我见过太多次了。很多人学Java,上来就奔着框架去,Spring Boot、MyBatis、Redis 一顿操作,能跑起来一个增删改查就觉得自己会了。结果一到需要写点底层逻辑、排查线上问题、或者做性能优化的时候,就露怯了。Java基础这四个字,听起来像是新手村的内容,但实际上,工作三五年之后回头再看,你会发现很多所谓的“高级问题”,根子都在基础上。
这一章的内容,我打算把Java基础里那些容易被忽略、但在实际开发中反复出现的核心点,掰开揉碎了讲一遍。不是教科书式的语法罗列,而是从“为什么这么设计”“实际怎么用”“踩过哪些坑”的角度来聊。适合刚入门的新手建立正确的认知框架,也适合有一定经验的开发者查漏补缺。
1.2 基础不牢的典型症状
先列几个我观察到的“基础不牢”的典型表现,你可以对照看看自己有没有中招:
- 写代码时频繁查语法,比如
String.format的占位符是%s还是%d,switch能不能用字符串,try-with-resources的写法记不住。 - 遇到
NullPointerException只会加if (obj != null),不知道Optional或者注解处理器的存在。 - 分不清
==和equals的使用场景,对hashCode和equals的契约关系一知半解。 - 集合类只用
ArrayList和HashMap,不知道LinkedList、TreeMap、ConcurrentHashMap的适用场景和底层差异。 - 对异常处理的理解停留在
try-catch包裹一切,分不清受检异常和非受检异常的设计意图。 - 多线程代码只会
synchronized加在方法上,不知道ReentrantLock、volatile、Atomic系列的存在。
如果你中了三条以上,那这一章的内容值得你花时间看完。如果你一条都没中,那也可以看看我总结的一些实战经验和避坑技巧,说不定能帮你省下几个小时的调试时间。
1.3 本章的内容地图
这一章我会围绕以下几个核心模块展开:
- 数据类型与运算:基本类型的坑、自动装箱拆箱的陷阱、浮点数的精度问题。
- 字符串处理:String 的不可变性、StringBuilder 与 StringBuffer 的选择、字符串常量池的机制。
- 面向对象核心:继承与组合的取舍、接口与抽象类的设计意图、多态的实现原理。
- 集合框架:常用集合的底层结构、遍历方式的性能差异、线程安全集合的选用。
- 异常处理:异常分类、自定义异常的最佳实践、异常链的保留。
- 并发基础:线程生命周期、锁机制、原子类的使用场景。
每个模块我都会给出实际可运行的代码示例、参数选择的依据、以及我在实际项目中踩过的坑。你可以把这章内容当作一个“Java基础实战手册”来用,遇到相关问题的时候翻出来对照。
2. 数据类型与运算:那些年我们踩过的坑
2.1 基本类型的边界与溢出
Java 的基本数据类型有八种:byte、short、int、long、float、double、char、boolean。每种类型都有固定的位数和取值范围,这个大家都知道。但实际写代码的时候,溢出问题往往出现在你意想不到的地方。
先看一个经典的例子:
int a = Integer.MAX_VALUE; int b = a + 1; System.out.println(b); // 输出 -2147483648Integer.MAX_VALUE是 2147483647,加一之后变成了 -2147483648。这是因为 int 是 32 位有符号整数,最高位是符号位,溢出之后符号位翻转了。这种问题在计算金额、统计数量的时候特别危险。
那怎么避免?两个思路:
第一,预估数据范围,选择合适的类型。比如统计网站访问量,用long而不是int,因为int最大只能到 21 亿左右,一个热门站点一天的 PV 就可能超过这个数。
第二,使用Math.addExact()这类方法,溢出时直接抛异常,而不是静默地给你一个错误结果:
try { int result = Math.addExact(Integer.MAX_VALUE, 1); } catch (ArithmeticException e) { System.out.println("溢出了,需要处理"); }注意:
Math.addExact、Math.subtractExact、Math.multiplyExact这些方法在 JDK 8 就已经有了,但很多人不知道。在金融、统计等对精度要求高的场景,建议优先使用。
2.2 自动装箱拆箱的隐藏开销
自动装箱(Autoboxing)和拆箱(Unboxing)是 Java 5 引入的特性,让基本类型和包装类型之间的转换变得“无感”。但这种“无感”有时候会带来性能和逻辑上的问题。
先看性能问题。下面这段代码:
Long sum = 0L; for (long i = 0; i < 1000000; i++) { sum += i; }这段代码看起来没什么问题,但实际上每次循环都会创建一个新的Long对象(因为sum是Long类型,+=操作会先拆箱再装箱)。一百次循环还好,一百万次循环就会创建一百万个临时对象,给 GC 带来不必要的压力。如果把sum改成long类型,性能会有明显提升。
再看逻辑问题。包装类型的比较:
Integer a = 127; Integer b = 127; System.out.println(a == b); // true Integer c = 128; Integer d = 128; System.out.println(c == d); // false为什么 127 是 true,128 是 false?因为 Java 的Integer类内部有一个缓存池,默认缓存 -128 到 127 之间的值。在这个范围内,自动装箱返回的是同一个对象;超出这个范围,每次都会 new 一个新对象。所以用==比较包装类型,结果是不确定的。
实操心得:包装类型的比较,永远用
equals(),不要用==。如果确实需要比较数值,先拆箱成基本类型再比。这个坑我在代码评审里见过不下十次,每次都是“本地测试没问题,上线就出 bug”。
2.3 浮点数精度:为什么 0.1 + 0.2 != 0.3
这个问题几乎每个Java程序员都遇到过:
System.out.println(0.1 + 0.2); // 输出 0.30000000000000004原因在于,浮点数采用 IEEE 754 标准存储,很多十进制小数无法用二进制精确表示。0.1 在二进制里是一个无限循环小数,存储的时候就已经有精度损失了,运算之后误差会累积。
那实际开发中怎么处理?如果涉及金额计算,绝对不要用float或double,用BigDecimal:
BigDecimal a = new BigDecimal("0.1"); BigDecimal b = new BigDecimal("0.2"); System.out.println(a.add(b)); // 输出 0.3注意BigDecimal的构造方法,要用字符串参数的版本,不要用double参数的版本:
// 错误示范 BigDecimal wrong = new BigDecimal(0.1); System.out.println(wrong); // 输出 0.1000000000000000055511151231257827021181583404541015625 // 正确示范 BigDecimal right = new BigDecimal("0.1"); System.out.println(right); // 输出 0.1BigDecimal的divide方法也要注意,如果除不尽会抛ArithmeticException,需要指定精度和舍入模式:
BigDecimal result = new BigDecimal("10").divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP); System.out.println(result); // 输出 3.33常见问题:
BigDecimal的equals方法会比较精度,new BigDecimal("1.0")和new BigDecimal("1.00")用equals比较返回 false。如果只想比较数值,用compareTo方法。
3. 字符串处理:不可变性的利与弊
3.1 String 为什么设计成不可变
String是 Java 里使用频率最高的类,没有之一。它的一个核心设计就是不可变性(Immutable)——一旦创建,内容就不能修改。这个设计带来了几个好处:
第一,线程安全。多个线程同时访问一个String对象,不需要加锁,因为它的状态不会变。
第二,可以缓存哈希值。String的hashCode在第一次调用时计算并缓存,之后直接返回缓存值。这让String非常适合作为HashMap的键。
第三,字符串常量池的实现基础。因为不可变,相同的字符串字面量可以共享同一个对象,节省内存。
但不可变性也带来了性能问题。字符串拼接:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; }这段代码每次循环都会创建一个新的String对象,一万次循环创建一万个对象,性能极差。正确的做法是用StringBuilder:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();3.2 StringBuilder 与 StringBuffer 的选择
StringBuilder和StringBuffer的 API 几乎一样,区别在于StringBuffer的方法是synchronized的,线程安全但性能稍低;StringBuilder不是线程安全的,但性能更好。
那实际开发中怎么选?我的经验是:绝大多数场景用StringBuilder。因为字符串拼接通常发生在单个方法内部,是局部变量,不存在线程共享的问题。只有当你需要在一个多线程共享的对象里拼接字符串时,才需要考虑StringBuffer。
但即使是那种场景,我也更倾向于用StringBuilder配合外部加锁,或者用ThreadLocal来避免共享。StringBuffer的全局锁在并发量高的时候会成为瓶颈。
实操心得:JDK 9 之后,
String的底层实现从char[]改成了byte[]加编码标记,StringBuilder和StringBuffer也跟着改了。这个改动对使用者是透明的,但在做内存分析的时候要注意,一个包含纯 ASCII 字符的字符串,内存占用比以前少了一半。
3.3 字符串常量池与 intern 方法
字符串常量池是 JVM 为了节省内存而设计的一块特殊区域。直接赋值的字符串字面量会进入常量池:
String a = "hello"; String b = "hello"; System.out.println(a == b); // true,指向常量池同一个对象 String c = new String("hello"); System.out.println(a == c); // false,c 是堆上的新对象 System.out.println(a == c.intern()); // true,intern 返回常量池中的引用intern()方法的作用是:如果常量池中已经有内容相同的字符串,返回池中的引用;如果没有,把当前字符串加入池中再返回引用。
那什么时候用intern()?一个典型的场景是处理大量重复的字符串标识,比如从数据库读出来的状态码、类型标识。用intern()可以大幅减少内存占用。但要注意,常量池的大小是有限的,JDK 7 之后常量池移到了堆里,受堆大小限制。如果 intern 的字符串太多,可能导致常量池膨胀,反而影响性能。
4. 面向对象核心:继承、接口与多态
4.1 继承与组合的取舍
面向对象三大特性:封装、继承、多态。继承是用的最多、也最容易用错的一个。
先看一个反面例子。假设要设计一个“鸟”的类体系:
class Bird { void fly() { System.out.println("飞"); } } class Ostrich extends Bird { // 鸵鸟不会飞,但继承了 fly 方法 }鸵鸟继承了Bird,但不会飞,这就导致了设计上的矛盾。如果强行在Ostrich里重写fly方法抛异常,又违反了里氏替换原则。
更好的做法是用组合或者接口:
interface Flyable { void fly(); } class Bird { } class Ostrich extends Bird { } class Sparrow extends Bird implements Flyable { public void fly() { System.out.println("飞"); } }设计原则:继承表达的是“is-a”关系,组合表达的是“has-a”关系。如果一个关系用“is-a”描述起来很别扭,那就该考虑组合了。另外,继承的层次不要太深,一般不超过三层,否则代码会变得难以理解和维护。
4.2 接口与抽象类的设计意图
接口和抽象类都能定义抽象方法,那什么时候用哪个?我的判断标准很简单:
- 如果多个类之间没有“is-a”关系,但需要共享一组行为契约,用接口。比如
Comparable、Runnable。 - 如果多个类之间有明确的层次关系,且需要共享一些公共的实现代码,用抽象类。比如
AbstractList、InputStream。
JDK 8 之后接口可以有默认方法(default method),这让接口和抽象类的界限变得模糊了一些。但核心区别还在:接口不能有实例字段,抽象类可以有;一个类只能继承一个抽象类,但可以实现多个接口。
实际开发中,我倾向于优先使用接口。接口更灵活,更符合“面向接口编程”的原则。只有在确实需要共享状态或大量公共代码的时候,才用抽象类。
4.3 多态的实现原理与实战价值
多态是面向对象最核心的概念之一。简单说就是:父类引用指向子类对象,调用方法时执行的是子类的实现。
List<String> list = new ArrayList<>(); list.add("hello");这里list是List接口类型,实际对象是ArrayList。调用add方法时,执行的是ArrayList的实现。这就是多态。
多态的价值在于解耦。你的代码依赖的是接口,而不是具体实现。将来要换一个实现类,只需要改一行代码:
List<String> list = new LinkedList<>();其他代码完全不用动。这个特性在框架设计中体现得淋漓尽致,比如 Spring 的依赖注入,就是基于多态实现的。
注意事项:多态调用方法时,看的是实际对象的类型;但访问字段时,看的是引用变量的类型。这个差异在面试中经常被问到,实际开发中也要注意,尽量不要在父类和子类中定义同名字段。
5. 集合框架:选对了事半功倍
5.1 常用集合的底层结构对比
Java 集合框架是日常开发中使用最频繁的工具之一。选错集合类型,轻则性能下降,重则出现并发问题。下面这张表是我总结的常用集合对比:
| 集合类 | 底层结构 | 线程安全 | 适用场景 |
|---|---|---|---|
| ArrayList | 动态数组 | 否 | 随机访问多、增删少 |
| LinkedList | 双向链表 | 否 | 增删多、随机访问少 |
| HashMap | 数组+链表/红黑树 | 否 | 键值对存储,无序 |
| TreeMap | 红黑树 | 否 | 键值对存储,需要排序 |
| LinkedHashMap | 数组+链表+双向链表 | 否 | 键值对存储,保持插入顺序 |
| HashSet | 基于 HashMap | 否 | 去重,无序 |
| ConcurrentHashMap | 分段锁/CAS | 是 | 高并发键值对存储 |
| CopyOnWriteArrayList | 数组+写时复制 | 是 | 读多写少的并发列表 |
选集合的时候,先问自己三个问题:需不需要线程安全?需不需要排序?读多还是写多?回答完这三个问题,基本就能确定用哪个了。
5.2 遍历方式的性能差异与选择
集合的遍历方式有好几种:普通 for 循环、增强 for 循环、迭代器、Stream API。不同方式在不同场景下性能差异明显。
对于ArrayList,普通 for 循环通过索引访问是最快的,因为底层是数组,随机访问是 O(1)。增强 for 循环底层用的是迭代器,性能略低但差距不大。
对于LinkedList,普通 for 循环通过索引访问是灾难性的,因为每次get(i)都要从头遍历链表,时间复杂度是 O(n),整个遍历下来就是 O(n²)。这种情况下必须用迭代器或增强 for 循环。
// LinkedList 的错误遍历方式 LinkedList<String> list = new LinkedList<>(); for (int i = 0; i < list.size(); i++) { String s = list.get(i); // 每次都是 O(n) } // 正确方式 for (String s : list) { // 迭代器遍历,O(1) 每次 }实操心得:如果你不确定用哪种遍历方式,就用增强 for 循环,它在大多数场景下都是安全且性能可接受的。只有在明确知道是
ArrayList且对性能有极致要求时,才用索引遍历。
5.3 HashMap 的容量与扩容机制
HashMap是使用频率最高的集合类之一,但很多人对它的扩容机制不太了解,导致在初始化时没有设置合适的容量,频繁触发扩容,影响性能。
HashMap的默认初始容量是 16,负载因子是 0.75。当元素数量超过容量 × 负载因子时,就会触发扩容,容量翻倍,并重新计算所有元素的位置(rehash)。
如果你预估要存 1000 个元素,用默认容量的话,会经历 16→32→64→128→256→512→1024 共七次扩容。每次扩容都要 rehash,开销不小。正确的做法是初始化时指定容量:
// 预估 1000 个元素,1000 / 0.75 = 1333,向上取最近的 2 的幂,即 2048 Map<String, String> map = new HashMap<>(2048);注意:
HashMap的容量永远是 2 的幂。即使你传入 1000,内部也会调整成 1024。所以直接传 2 的幂可以避免内部调整。
6. 异常处理:别让 try-catch 变成万能胶
6.1 受检异常与非受检异常的设计意图
Java 的异常分为两大类:受检异常(Checked Exception)和非受检异常(Unchecked Exception)。受检异常必须被捕获或声明抛出,非受检异常(RuntimeException及其子类)不需要。
这个设计一开始是为了强制开发者处理可恢复的错误,比如IOException、SQLException。但实际开发中,很多受检异常根本无法恢复,只能往上抛,导致代码里充斥着无意义的try-catch。
我的经验是:受检异常适合那些调用方确实有能力处理的场景,比如文件不存在时可以提示用户重新选择。如果调用方处理不了,那就应该用非受检异常,让错误往上传播,最终由全局异常处理器统一处理。
6.2 自定义异常的实践
实际项目中,通常需要定义一套业务异常体系。我的做法是:
public class BusinessException extends RuntimeException { private final String code; public BusinessException(String code, String message) { super(message); this.code = code; } public BusinessException(String code, String message, Throwable cause) { super(message, cause); this.code = code; } public String getCode() { return code; } }继承RuntimeException而不是Exception,这样不会强制调用方捕获。同时保留cause参数,方便追踪异常链。
常见问题:捕获异常后重新抛出时,一定要保留原始异常信息。我见过不少代码写成
throw new BusinessException("操作失败"),把原始的SQLException丢掉了,排查问题的时候完全不知道底层发生了什么。正确的写法是throw new BusinessException("操作失败", e)。
6.3 try-with-resources 的正确使用
JDK 7 引入了 try-with-resources 语法,用于自动关闭实现了AutoCloseable接口的资源。这个语法比传统的finally块关闭资源更简洁、更安全。
// 传统写法 BufferedReader reader = null; try { reader = new BufferedReader(new FileReader("file.txt")); // 使用 reader } catch (IOException e) { // 处理异常 } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { // 忽略 } } } // try-with-resources 写法 try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) { // 使用 reader } catch (IOException e) { // 处理异常 }try-with-resources 的好处是:如果try块和close方法都抛异常,close的异常会被抑制(suppressed),不会覆盖原始异常。这个细节在排查问题时很重要。
7. 并发基础:从线程安全说起
7.1 线程的生命周期与状态转换
Java 线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。理解这些状态的转换,对排查死锁、线程饥饿等问题很有帮助。
一个常见的误区是认为RUNNABLE就代表线程在运行。实际上,RUNNABLE包含了操作系统层面的“就绪”和“运行”两种状态。线程可能正在等待 CPU 时间片,也可能真的在执行。
排查线程问题时,我通常用jstack命令导出线程快照,然后看线程的状态和调用栈。如果大量线程处于BLOCKED状态,说明有锁竞争;如果处于WAITING,可能是等待某个条件。
7.2 synchronized 与 ReentrantLock 的选择
synchronized是 Java 内置的锁机制,使用简单,JVM 会自动释放锁。ReentrantLock是 JDK 提供的显式锁,功能更丰富,支持公平锁、可中断锁、超时获取锁。
那什么时候用哪个?我的建议是:
- 如果只是简单的互斥访问,用
synchronized,代码更简洁,不容易出错。 - 如果需要公平锁、可中断、超时获取这些高级特性,用
ReentrantLock。 - 如果需要读写分离,用
ReentrantReadWriteLock。
// synchronized 写法 public synchronized void increment() { count++; } // ReentrantLock 写法 private final ReentrantLock lock = new ReentrantLock(); public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } }注意事项:
ReentrantLock的unlock()必须放在finally块里,否则如果业务代码抛异常,锁永远不会释放,导致死锁。这个坑我踩过一次,排查了大半天。
7.3 原子类与 CAS 机制
AtomicInteger、AtomicLong、AtomicReference这些原子类,底层用的是 CAS(Compare-And-Swap)机制,不需要加锁就能保证线程安全。
AtomicInteger count = new AtomicInteger(0); count.incrementAndGet(); // 原子自增CAS 的原理是:比较当前值和预期值,如果相等就更新,否则重试。这个操作由 CPU 指令直接支持,所以效率很高。
但 CAS 也有缺点:ABA 问题、自旋开销、只能保证单个变量的原子性。ABA 问题可以用AtomicStampedReference解决,自旋开销在竞争激烈时会比较明显。
实操心得:原子类适合竞争不激烈的场景。如果并发量很高,
LongAdder比AtomicLong性能更好,因为它采用了分段累加的思想,减少了 CAS 冲突。
8. 基础不牢,地动山摇
写了这么多,其实核心就一句话:Java 基础不是“学完就扔”的东西,而是需要反复回炉、不断深化的内功。框架会更新,工具会迭代,但语言层面的核心机制——类型系统、字符串处理、面向对象、集合框架、异常处理、并发基础——这些东西十年二十年都不会有根本性的变化。
我在实际项目中的体会是,那些看似“高级”的问题,比如性能瓶颈、并发 bug、内存泄漏,追根溯源往往都是基础没打牢。一个对HashMap扩容机制了如指掌的人,不会在初始化时犯低级错误;一个理解String不可变性的人,不会在循环里用+=拼接字符串;一个清楚equals和hashCode契约的人,不会写出诡异的集合行为。
最后分享一个小技巧:如果你觉得自己基础还行,但又说不清楚某个知识点的细节,那就试着把它讲给别人听。讲的过程中卡壳的地方,就是你真正需要补的地方。这个方法我用过很多次,每次都能发现自己知识体系里的漏洞。
这个内容后续还可以这样扩展:针对每个模块做更深入的源码分析,比如HashMap的红黑树转换阈值、ConcurrentHashMap的分段锁演进、ThreadPoolExecutor的参数调优。这些内容我会在后续章节里陆续展开。