1. 为什么2021年的Java基础总结今天依然值得看?
每次看到“Java基础知识点总结”这样的标题,很多朋友可能会想:这都什么年代了,Java都迭代到21了,还看2021年的总结?是不是过时了?作为一个从Java 5时代一路写过来的老码农,我得说,恰恰相反。2021年这个时间点,正好是Java 8作为绝对主流、Java 11开始普及、而Java 17尚未大规模上线的“黄金窗口期”。这个时期的Java基础,沉淀了从Java 5到Java 11这十几年间最核心、最稳定、也最被企业级开发验证过的语言特性和编程思想。它不像Java 8之前那样“古朴”,也不像Java 17之后引入了太多需要特定场景才能理解的预览特性。这份总结,更像是一份“经典Java核心语法与API的稳定态地图”。
对于初学者,它能帮你避开那些过于前沿但实际应用还不广的语法糖,直击面试和工作中最高频的考点。对于有经验的开发者,它是一次系统性的“回炉重造”,帮你理清那些习以为常但可能理解并不透彻的概念,比如泛型的类型擦除到底擦除了什么、String的不可变性在内存中是如何体现的。今天,我们就抛开那些华而不实的框架和中间件,回到代码本身,把Java这门语言的基石彻底摸透。这份总结的目标不是罗列API,而是带你理解每一个知识点背后的设计意图和运行机制,让你写出更健壮、更高效、也更容易被同事理解的代码。
2. 面向对象:从“会用”到“懂为什么这么用”
面向对象是Java的魂,但很多人学了几年,可能还停留在“知道有封装、继承、多态”的层面。我们得往深了挖。
2.1 类与对象:不止是new一下
创建一个对象,new关键字背后发生了什么?这直接关系到JVM的内存管理和初始化顺序。
- 类加载:当JVM首次遇到
new MyClass()时,如果MyClass尚未被加载,则会触发类加载过程(加载、验证、准备、解析、初始化)。在“准备”阶段,会为类变量(static变量)分配内存并设置默认初始值(0, false, null等)。 - 内存分配:在堆(Heap)中为新生对象分配内存空间。内存大小在类加载完成后就可确定。
- 初始化零值:将对象内存空间中的所有实例变量设置为对应数据类型的零值。这一步确保了即使构造器里没赋值,字段也有默认值。
- 设置对象头:JVM会在对象头中存储一些元数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。这是实现同步、GC和对象标识的基础。
- 执行构造器:这才是我们写的
构造器被调用的时刻。它会按照代码逻辑对实例变量进行显式初始化。这里有个关键顺序:先执行父类构造器(隐式或显式调用super()),再按声明顺序初始化实例变量(包括实例变量赋值和实例代码块),最后执行构造器体内的代码。
注意:很多人混淆了“默认初始化”和“显式初始化”。在构造器执行前,实例变量已经被JVM赋予了零值(默认初始化)。构造器里的赋值是“显式初始化”。理解这个顺序对排查一些诡异的
NullPointerException很有帮助,比如在构造器里调用了一个可被重写的方法,而该方法依赖子类尚未初始化的字段。
2.2 继承的深水区:构造器链与初始化顺序
继承不只是“复用代码”,它建立了一种严格的初始化顺序约束。看这段代码:
class Parent { private String parentField = initParentField(); static { System.out.println("Parent static block"); } { System.out.println("Parent instance block"); } Parent() { System.out.println("Parent constructor"); } private String initParentField() { System.out.println("Parent field init"); return "parent"; } } class Child extends Parent { private String childField = initChildField(); static { System.out.println("Child static block"); } { System.out.println("Child instance block"); } Child() { System.out.println("Child constructor"); } private String initChildField() { System.out.println("Child field init"); return "child"; } }执行new Child()的输出顺序是:
Parent static block Child static block Parent field init Parent instance block Parent constructor Child field init Child instance block Child constructor这个顺序是铁律:父类静态 -> 子类静态 -> 父类实例变量/代码块 -> 父类构造器 -> 子类实例变量/代码块 -> 子类构造器。掌握它,你就能理解为什么在父类构造器中调用可重写方法是危险的(此时子类字段还是零值)。
2.3 多态与动态绑定:方法调用的真相
“编译看左边,运行看右边”是口诀,但背后的机制是动态绑定(Dynamic Binding)。对于实例方法(非static、非private、非final),JVM在运行时根据对象的实际类型来决定调用哪个方法。这个信息存储在方法区(元空间)的类信息中,具体是通过虚方法表(Virtual Method Table)来实现的。每个类都有一个vtable,里面记录了该类所有虚方法的实际入口地址。当调用obj.method()时,JVM会获取obj实际类型的vtable,找到对应方法的地址进行调用。而private、static、final方法以及构造器,由于不具备多态性,采用的是静态绑定,在编译期就确定了调用的目标。
2.4 抽象类与接口的演进与选择
2021年,Java的接口已经非常强大了。在Java 8之前,接口和抽象类的选择很清晰:接口定义行为契约,抽象类提供部分实现。Java 8为接口引入了默认方法(default method)和静态方法,这使得接口也能提供方法实现。那么现在该如何选?
抽象类:
- 状态:可以拥有实例变量(字段)。
- 构造器:可以有构造器(虽然不能直接实例化),用于初始化抽象类定义的状态。
- 访问控制:方法可以是
public,protected,private。 - 使用场景:当你需要定义一些共享的状态或行为,并且这些状态/行为在子类间有共同的初始化逻辑时。例如,模板方法模式中,抽象类定义算法骨架。
接口:
- 状态:只能有静态常量(
public static final)。 - 构造器:没有构造器。
- 方法:Java 8+后,可以有抽象方法、默认方法、静态方法。Java 9+还可以有私有方法。
- 使用场景:定义能力或角色。一个类可以实现多个接口,从而实现多重“类型”。这是定义行为契约、支持策略模式、函数式编程(通过
@FunctionalInterface)的首选。
- 状态:只能有静态常量(
简单决策树:如果需要定义状态或强制性的公共构造逻辑,用抽象类。如果只是定义行为或类型,优先使用接口。现代Java开发中,接口的使用频率远高于抽象类。
3. 核心API与集合框架:高效使用的底层逻辑
Java的API设计哲学是“提供足够的抽象,同时暴露必要的控制”。理解其底层,才能避免性能陷阱。
3.1 String:为什么说“几乎”不可变?
String的不可变性是Java安全的基石之一。所谓不可变,是指一旦一个String对象被创建,其内部的字符数组(final char[] value,Java 9后是byte[])引用和内容就不可更改。所有看似修改的操作(如concat,substring在旧版本中,replace),实际上都是创建了一个全新的String对象。
带来的好处:
- 安全性:作为参数传递时,不用担心被意外修改。广泛用于网络连接、文件路径、类加载等关键场景。
- 线程安全:不可变对象天生线程安全。
- 缓存哈希值:
String的hashCode()会缓存第一次计算的结果,因为值永不变,这大大提升了作为HashMap键的性能。 - 字符串常量池:这是实现不可变性的最大收益。JVM有一块特殊内存区域(方法区的一部分),用于存储字面量字符串和显式
intern()的字符串。当创建字面量字符串时,JVM会先检查池中是否存在,存在则返回引用,不存在则创建并放入池中。这节省了大量内存,并使得字符串比较可以用==(但仅限于字面量和intern()后的,不推荐在常规代码中用==比较字符串内容)。
注意点:String的不可变是针对对象本身,但String类型的引用是可变的。String s = "a"; s = "b";这里s引用指向了新的对象,而非修改了旧对象。
3.2 集合框架选型:不止是ArrayList和HashMap
集合框架的选用,核心是权衡访问、插入、删除的性能以及内存开销和线程安全性。
| 集合类型 | 底层实现 | 特点与适用场景 | 注意事项 |
|---|---|---|---|
| ArrayList | 动态数组 | 随机访问快(O(1)),尾部插入快。但中间插入/删除慢(需移动元素)。默认初始容量10,扩容1.5倍。 | 预估数据量,构造时指定初始容量(new ArrayList<>(1000)),避免多次扩容拷贝。非线程安全。 |
| LinkedList | 双向链表 | 头部/中间插入、删除快(O(1)),但随机访问慢(O(n))。实现了Deque接口,可作队列/栈。 | 内存开销大(每个元素需存储前后节点引用)。绝大多数场景ArrayList更优,除非有大量中间位置的增删。 |
| HashMap | 数组+链表/红黑树 | 基于哈希表的键值对。Java 8后,链表长度>8且数组容量>=64时,链表转为红黑树(提升最坏情况性能)。默认负载因子0.75。 | 键对象必须正确重写hashCode()和equals()。高并发下需用ConcurrentHashMap。允许null键和值。 |
| LinkedHashMap | 哈希表+双向链表 | 在HashMap基础上维护了插入顺序或访问顺序的链表。可用来实现LRU缓存(通过重写removeEldestEntry)。 | 比HashMap稍慢,内存开销略大。 |
| TreeMap | 红黑树 | 基于红黑树的有序键值对(按键的自然顺序或Comparator排序)。增删查改时间复杂度O(log n)。 | 键对象必须实现Comparable接口或在构造时提供Comparator。 |
| HashSet | 基于HashMap | 内部封装了一个HashMap,值存储在一个固定的Object对象上。 | 本质是HashMap的键集,所有HashMap的注意事项都适用。 |
| ConcurrentHashMap | 分段锁/ CAS | 高并发下的线程安全HashMap。Java 7采用分段锁,Java 8改为synchronized锁链表头/树根+CAS。性能远优于Hashtable。 | 迭代器是弱一致性的(不保证能反映创建后的所有修改)。size()方法返回值是近似值。 |
关键原理补充:
- HashMap扩容:当元素数量超过
容量 * 负载因子时触发。创建一个新数组(2倍原容量),然后遍历旧数组所有元素,重新计算哈希值,分配到新数组的新位置。这是一个相对耗时的操作。 - hashCode()与equals()的契约:这是集合框架正确工作的基石。必须保证:1) 两个对象
equals为true,则它们的hashCode必须相等;2) 反之,hashCode相等,equals不一定为true(哈希冲突)。违反此契约会导致HashMap、HashSet等无法正确工作。
3.3 泛型与类型擦除:编译器的“魔法”
泛型是Java 5引入的重大特性,但它采用的是“类型擦除”来实现的,这是为了兼容老版本字节码。理解擦除,才能理解泛型的局限。
什么是类型擦除?在编译后,所有的泛型类型信息都会被移除(擦除)。例如,List<String>和List<Integer>在运行时都是List(原始类型)。类型参数会被替换为它们的边界(未指定边界则替换为Object)。
带来的影响与应对:
- instanceof 检查:你不能写
list instanceof List<String>,因为运行时没有String这个类型信息。只能检查list instanceof List。 - 不能创建泛型数组:
new T[]是不允许的,因为运行时不知道T的具体类型。通常用new Object[]然后强制转换,或者使用ArrayList等集合。 - 泛型类中的静态成员:静态变量和方法属于类,而非实例。因此,
MyClass<T>中的静态成员不能使用类型参数T。所有实例共享同一个静态成员。 - 获取泛型实际类型:虽然被擦除了,但可以通过反射获取父类或接口的泛型参数化类型(ParameterizedType),这就是Spring等框架实现依赖注入时获取
Repository<User>中User类型的方法。
4. 异常处理:不仅仅是try-catch
异常处理是构建健壮程序的关键,但用好它需要理解其体系和最佳实践。
4.1 异常体系:Error vs. Exception
Throwable是所有错误和异常的父类。它有两个直接子类:Error和Exception。
- Error:表示JVM本身的严重错误,如
OutOfMemoryError,StackOverflowError,VirtualMachineError。应用程序通常无法处理也不应该捕获这些错误,它们意味着程序该终止了。 - Exception:程序运行时可能出现的异常情况,可以被捕获和处理。它又分为:
- 受检异常:继承自
Exception但不继承RuntimeException。编译器强制要求处理(要么try-catch,要么在方法签名中用throws声明)。如IOException,SQLException。代表“可预期的异常情况”。 - 非受检异常:继承自
RuntimeException。编译器不强制要求处理。如NullPointerException,IllegalArgumentException,ArrayIndexOutOfBoundsException。通常代表编程错误(空指针、下标越界)或参数不合法。
- 受检异常:继承自
设计哲学:受检异常用于可恢复的、期望调用者处理的情况(如文件未找到,可能提示用户重选)。非受检异常用于程序错误,通常意味着代码有bug,应该修复代码而不是捕获。
4.2 try-with-resources:优雅的资源管理
在Java 7之前,关闭资源(如InputStream,Connection,Socket)的代码非常冗长且容易遗漏,通常放在finally块中。try-with-resources语句彻底简化了这一切。
// 老式写法 BufferedReader br = null; try { br = new BufferedReader(new FileReader("file.txt")); // ... 使用br } catch (IOException e) { // 处理异常 } finally { if (br != null) { try { br.close(); } catch (IOException e) { /* 忽略或记录 */ } } } // try-with-resources 写法 (Java 7+) try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) { // ... 使用br } catch (IOException e) { // 处理异常 }原理:在try后的括号中声明的资源,必须实现AutoCloseable接口(它只有一个close()方法)。无论try块是正常结束还是异常退出,JVM都会自动调用这些资源的close()方法,并且后声明的资源会先关闭。如果try块和close()都抛出了异常,try块的异常会被抑制,可以通过Throwable.getSuppressed()方法获取被抑制的异常(通常是close()抛出的)。
4.3 异常处理的最佳实践与反模式
- 只捕获你能处理的异常:不要用
catch (Exception e)来捕获所有异常然后什么都不做(“吞掉异常”)或只打印堆栈。这会让问题在沉默中恶化。 - 提供有意义的异常信息:抛出自定义异常或包装异常时,务必包含清晰的错误信息和可能的原因。
throw new IllegalArgumentException("参数userId不能为null,当前值:" + userId); - 优先使用非受检异常:现代框架(如Spring)更倾向于使用非受检异常。这减少了代码的侵入性(不需要到处写
throws),将“是否处理”的选择权交给调用者。对于确实需要调用者知晓的“业务异常”,可以定义自己的BusinessException继承RuntimeException。 - 避免在finally块中return:这会导致
try或catch块中抛出的异常被覆盖,丢失关键的调试信息。 - 异常性能:创建异常对象(尤其是填充堆栈轨迹)是一个相对昂贵的操作。不要用异常来控制正常的程序流程(比如用
throw来跳出循环)。
5. 并发编程基石:线程、锁与内存模型
并发是Java面试和高级开发绕不开的坎。理解其基础,比盲目使用高级并发工具更重要。
5.1 线程的生命周期与基本操作
线程状态在Thread.State枚举中定义:
- NEW:已创建但未启动(
start())。 - RUNNABLE:正在JVM中执行或等待操作系统CPU调度。
- BLOCKED:等待获取一个监视器锁(synchronized锁)以进入同步块/方法。这是与其他线程竞争锁时的状态。
- WAITING:无限期等待,直到被其他线程显式唤醒。调用
Object.wait(),Thread.join(),LockSupport.park()会进入此状态。 - TIMED_WAITING:有限时间的等待。调用
Thread.sleep(long),Object.wait(long),Thread.join(long)等。 - TERMINATED:线程已执行完毕。
关键方法辨析:
start()vsrun():start()会启动新线程,在新线程中调用run()方法。直接调用run()只是在当前线程执行该方法,不会启动新线程。sleep()vswait():sleep()是Thread的静态方法,让当前线程休眠,不释放锁。wait()是Object的实例方法,必须在synchronized块内调用,会让当前线程等待并释放锁,直到其他线程调用同一对象的notify()/notifyAll()。
yield():提示调度器当前线程愿意让出CPU,但调度器可以忽略此提示。不能依赖它来做线程同步。
5.2 synchronized关键字:内置锁的细节
synchronized是Java最基本的互斥同步手段。它可以修饰实例方法、静态方法、代码块。
- 锁对象:
- 修饰实例方法:锁是当前实例对象(
this)。 - 修饰静态方法:锁是当前类的
Class对象。 - 修饰代码块:锁是
synchronized(obj)括号里配置的对象。
- 修饰实例方法:锁是当前实例对象(
- 可重入性:同一个线程可以多次获取同一把锁。JVM会维护一个计数器,获取时+1,释放时-1,减到0时其他线程才能获取。这避免了线程自己把自己锁死。
- 内存语义:
synchronized不仅能保证原子性,还能保证可见性。线程在进入同步块时,会清空工作内存,从主内存重新读取共享变量。退出同步块时,会把工作内存中修改过的变量刷新回主内存。这遵循了Java内存模型(JMM)的happens-before规则。
局限性:synchronized是独占锁,性能开销相对较大,且无法中断一个正在等待锁的线程,也无法尝试获取锁(获取不到就阻塞),锁的释放必须由获得锁的线程在同步块结束时进行。这些局限性催生了java.util.concurrent.locks.Lock接口。
5.3 volatile关键字:轻量级的同步
volatile是比synchronized更轻量的同步机制,但它只能保证可见性和有序性,不能保证原子性。
- 可见性:当一个线程修改了
volatile变量的值,新值会立即被刷新到主内存。当其他线程读取该变量时,会从主内存中读取最新的值,而不是使用自己工作内存中的旧值。 - 有序性:禁止指令重排序。普通变量仅保证在方法执行过程中所有依赖赋值结果的地方能获取到正确结果,但不保证变量赋值操作的顺序与程序代码中的执行顺序一致(指令重排序优化)。
volatile变量会在此变量前后插入内存屏障,防止其前后的指令被重排序。
典型用法:
- 状态标志:
private volatile boolean running;一个线程通过running = false;来通知另一个线程停止。另一个线程循环检查while (running) { ... }。 - 单例模式的双重检查锁定:
public class Singleton { private static volatile Singleton instance; // 必须volatile private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 非原子操作,可能发生指令重排 } } } return instance; } }这里volatile的关键作用是防止指令重排。instance = new Singleton();这行代码不是原子操作,它分为:1) 分配内存,2) 初始化对象,3) 将引用指向内存地址。如果没有volatile,步骤2和3可能被重排,导致其他线程在第一次检查时拿到一个未完全初始化的对象。volatile禁止了这种重排。
5.4 Java内存模型与happens-before原则
Java内存模型定义了线程如何以及何时可以看到其他线程修改过的共享变量,以及如何同步地访问共享变量。其核心是happens-before原则,它定义了操作之间的偏序关系,保证了内存可见性。
几条重要的happens-before规则:
- 程序次序规则:同一个线程中,前面的操作happens-before于后面的操作。
- 管程锁定规则:一个unlock操作happens-before于后续对同一个锁的lock操作。
- volatile变量规则:对一个volatile变量的写操作happens-before于后续对这个变量的读操作。
- 线程启动规则:
Thread.start()happens-before于该线程的每一个动作。 - 线程终止规则:线程中的所有操作都happens-before于其他线程检测到该线程已经终止(通过
Thread.join()或Thread.isAlive()返回false)。 - 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
理解happens-before,就能理解为什么在某些情况下不加锁也能保证可见性(比如通过volatile变量或线程启动/终止规则),这是构建高效并发程序的理论基础。
6. I/O与NIO:从阻塞到非阻塞的演进
Java的I/O体系庞大,理解其演进脉络比死记API更重要。
6.1 传统BIO:流的世界
Java传统的I/O基于“流”(Stream),它是单向的。核心抽象是InputStream和OutputStream(字节流),以及它们的装饰器Reader和Writer(字符流,基于字节流和字符集编码)。
关键点:
- 装饰器模式:这是Java I/O库的经典设计模式。
BufferedInputStream、DataInputStream等本身也是InputStream,它们包装另一个InputStream,为其增加缓冲、读取基本数据类型等功能。这种设计非常灵活。 - 资源关闭:必须关闭流,否则会导致文件句柄或网络连接泄漏。务必使用
try-with-resources。 - 缓冲的重要性:直接读取单个字节/字符性能极差。务必使用缓冲流(
BufferedInputStream/BufferedReader)来包装底层流,它们内部维护了一个缓冲区,减少了实际的系统调用次数。
BIO的瓶颈:传统的ServerSocket编程是阻塞式的。当一个线程调用ServerSocket.accept()、socket.read()时,如果连接或数据没有就绪,线程会被挂起,直到事件就绪。这意味着每个连接都需要一个独立的线程来处理。在连接数很高时,线程上下文切换的开销巨大,系统资源被迅速耗尽。这就是著名的C10K问题。
6.2 NIO:面向缓冲区的非阻塞I/O
Java NIO(New I/O,在Java 1.4引入)解决了BIO的线程模型问题。它的核心是通道(Channel)、缓冲区(Buffer)和选择器(Selector)。
- Buffer:一个可以读写数据的容器。所有数据都通过Buffer处理。有
ByteBuffer,CharBuffer等。关键属性:容量(capacity)、位置(position)、限制(limit)。 - Channel:类似于流,但可以双向读写,且必须和Buffer配合使用。主要实现有
FileChannel、SocketChannel、ServerSocketChannel。 - Selector:多路复用器。一个Selector可以轮询多个Channel上的事件(连接就绪、读就绪、写就绪)。当某个Channel有事件就绪时,Selector会通知应用程序,然后由应用程序处理。一个线程可以管理多个Channel,这是实现高并发的关键。
NIO的工作模式:
- 创建Selector,并将需要监听的Channel注册到Selector上,指定感兴趣的事件(
SelectionKey.OP_ACCEPT,OP_READ,OP_WRITE)。 - 调用
Selector.select(),它会阻塞,直到有注册的事件发生。 - 获取发生事件的
SelectionKey集合,遍历处理。 - 在处理
OP_ACCEPT事件时,接受连接,并将新的SocketChannel也注册到Selector。 - 在处理
OP_READ事件时,从Channel读取数据到Buffer进行处理。
NIO的复杂性:NIO的API比BIO复杂得多,需要手动管理Buffer的状态(flip, clear, compact),并且网络编程需要处理半包、粘包等问题。虽然解决了线程模型问题,但编程模型变得复杂,这也是后来Netty等框架流行的原因——它们封装了NIO的复杂性,提供了更易用的API。
6.3 NIO.2:真正的异步I/O
Java 7引入了NIO.2,主要提供了AsynchronousFileChannel和AsynchronousSocketChannel,支持真正的异步I/O操作(AIO)。其核心是Future和CompletionHandler两种编程模式。
- Future模式:调用异步方法立即返回一个
Future对象,后续可以通过Future.get()阻塞等待结果,或轮询Future.isDone()。AsynchronousFileChannel channel = AsynchronousFileChannel.open(path); ByteBuffer buffer = ByteBuffer.allocate(1024); Future<Integer> future = channel.read(buffer, 0); // 立即返回 // ... 做其他事情 Integer bytesRead = future.get(); // 阻塞直到读取完成 - CompletionHandler模式:调用异步方法时传入一个回调接口
CompletionHandler,当操作完成(成功或失败)时,会调用对应的回调方法。channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { // 读取成功,处理数据 } @Override public void failed(Throwable exc, ByteBuffer attachment) { // 读取失败,处理异常 } });
AIO理论上性能更高,因为它将I/O操作完全交给操作系统,应用线程无需等待。但在Linux上,底层实现仍可能使用epoll模拟,且编程模型复杂,在实际生产环境中(特别是网络编程)的应用不如基于NIO的Netty广泛。在文件I/O场景下,AIO能发挥一定优势。
7. JVM基础:程序如何运行
了解JVM的基本结构,是理解Java程序性能、内存问题和异常的基础。
7.1 运行时数据区
JVM在执行Java程序时会把它管理的内存划分为若干个不同的区域。
- 程序计数器:线程私有。指向当前线程正在执行的字节码指令的地址。分支、循环、跳转、异常处理都依赖它。
- Java虚拟机栈:线程私有。生命周期与线程相同。每个方法执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。我们常说的“栈内存”就是指这里。局部变量表存放基本数据类型和对象引用。
- 本地方法栈:与虚拟机栈类似,但为JVM用到的Native方法服务。
- Java堆:线程共享。几乎所有对象实例和数组都在这里分配内存。是垃圾收集器管理的主要区域,因此常被称为“GC堆”。从内存回收角度可分为新生代(Eden, Survivor0, Survivor1)和老年代。
- 方法区:线程共享。存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。在HotSpot VM中,方法区常被称为“永久代”,但在Java 8中,永久代被移除,取而代之的是元空间,它使用本地内存,不再受JVM最大堆参数限制。
- 运行时常量池:方法区的一部分。存放编译期生成的各种字面量和符号引用。
7.2 垃圾回收的基本思想
垃圾回收(GC)的目标是回收堆内存中不再使用的对象。核心问题是:如何判断对象“不再使用”?
- 引用计数法:给对象添加一个引用计数器,有引用时+1,引用失效时-1,为0时可回收。简单但无法解决循环引用问题(A引用B,B引用A,但外部再无引用它们)。
- 可达性分析算法:Java采用的方法。以一系列称为“GC Roots”的对象作为起始点,向下搜索,搜索走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用。
- GC Roots包括:虚拟机栈中引用的对象、本地方法栈中JNI引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象等。
即使通过可达性分析不可达,对象也并非“非死不可”。它会被第一次标记,并进行一次筛选(判断是否有必要执行finalize()方法)。如果对象重写了finalize()且未被JVM调用过,它会被放入一个队列,由低优先级的Finalizer线程去执行它的finalize()方法。注意:finalize()方法运行代价高,不确定性大,无法保证调用顺序,不推荐使用。释放资源请用try-with-resources或显式调用close()。
7.3 常见的垃圾收集器
了解不同收集器的特点,有助于在特定场景下进行JVM调优。
- Serial / Serial Old:单线程收集器。进行GC时,必须暂停所有其他工作线程(“Stop The World”)。简单高效,是Client模式下的默认新生代收集器。
- ParNew:Serial的多线程版本,是许多Server模式下的首选新生代收集器,因为只有它能与CMS收集器配合工作。
- Parallel Scavenge / Parallel Old:JDK 8默认的收集器组合。目标是达到一个可控制的吞吐量(运行用户代码时间 / (运行用户代码时间 + GC时间))。适合后台运算、不需要太多交互的任务。
- CMS:以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法,过程分为:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记仍需“Stop The World”。它是一款优秀的收集器,但会产生内存碎片,且对CPU资源敏感。
- G1:JDK 9及之后的默认收集器。它将堆划分为多个大小相等的独立区域,跟踪各个区域垃圾堆积的价值大小,在后台维护一个优先列表,优先回收价值最大的区域。是一款面向服务端应用的收集器,能同时兼顾低停顿和高吞吐量。
- ZGC / Shenandoah:新一代的低延迟垃圾收集器,目标是将停顿时间控制在10ms以内,适用于超大堆内存(TB级别)的场景。
对于大多数应用,使用JDK 8的默认Parallel收集器或升级到JDK 11+使用G1收集器,并合理设置堆大小(-Xms,-Xmx)和新生代比例(-XX:NewRatio),通常就能获得不错的性能。深入调优需要结合具体的应用特点和监控数据。
8. 新特性掠影:从Java 8到Java 11
虽然我们聚焦“经典”,但了解关键的新特性演进是必要的。这里重点提一下Java 8和Java 11中影响最深远的几个特性。
8.1 Java 8:函数式编程与Stream API
Java 8是近年来最重要的更新,它引入了Lambda表达式和Stream API,彻底改变了Java的编程风格。
- Lambda表达式:本质是一个匿名函数,允许把函数作为一个方法的参数。语法:
(parameters) -> expression或(parameters) -> { statements; }。它使得行为参数化变得非常简洁,是函数式接口(只有一个抽象方法的接口)的实例。 - 函数式接口:使用
@FunctionalInterface注解标注,如Runnable,Comparator, 以及Java 8新增的Predicate<T>,Function<T,R>,Consumer<T>,Supplier<T>等。Lambda表达式极大地简化了匿名内部类的书写。 - Stream API:用于处理集合数据的声明式编程模型。它允许你以声明的方式处理数据,并可以透明地进行并行处理。
Stream操作分为中间操作(返回Stream,可链式调用,如filter, map, sorted)和终端操作(产生结果或副作用,如collect, forEach, count)。Stream是惰性求值的,只有终端操作被调用时,中间操作才会执行。List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David"); List<String> result = names.stream() .filter(name -> name.length() > 3) // 中间操作 .map(String::toUpperCase) // 中间操作 .sorted() // 中间操作 .collect(Collectors.toList()); // 终端操作
8.2 Java 9-11:模块化与实用增强
- Java 9 模块化:引入了JPMS,允许开发者将代码组织成明确的模块,通过
module-info.java声明模块的依赖和导出。目的是解决“类路径地狱”,增强封装性和安全性。但对于大多数应用级开发,影响不大,更多是库和框架开发者需要关注。 - Java 10 局部变量类型推断:引入了
var关键字,允许在声明局部变量时省略显式类型,由编译器推断。var list = new ArrayList<String>(); // 推断为 ArrayList<String> var stream = list.stream(); // 推断为 Stream<String>var只能用于局部变量,必须有初始化器,不能用于方法参数、返回类型、字段等。它让代码更简洁,但需谨慎使用,避免降低可读性。 - Java 11 HTTP Client:提供了一个全新的、支持HTTP/2和WebSocket的HTTP Client API,用于替代老旧的
HttpURLConnection。它是异步的,且API更现代、易用。HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://example.com")) .build(); // 同步 HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); // 异步 CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); - Java 11 单文件源代码程序:允许直接运行单个
.java文件,无需先编译。java HelloWorld.java。这对于学习和小脚本非常方便。
回顾这份2021年的基础总结,你会发现它涵盖的正是Java语言中最稳定、最核心、最经得起时间考验的部分。无论框架如何变迁,这些基础构成了你理解一切上层建筑的基石。扎实掌握它们,你就能更快地理解新特性,更稳地排查复杂问题,写出更高质量的代码。技术总是在更新,但底层的原理和思想,往往历久弥新。