news 2026/9/10 4:18:24

Java基础体系化梳理:从集合框架到JVM内存,一文搞定

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java基础体系化梳理:从集合框架到JVM内存,一文搞定

先声明一下,这篇不是从零开始的Java教程,而是给已经学过Java、但总感觉知识不成体系的人准备的"地毯式整理"。我见过太多人,刷了半年面试题,问到ArrayList扩容机制能背得滚瓜烂熟,但你让他从零写一个单例模式,打开IDE却犹豫半天。基础这东西,不梳理成体系,背再多题也是散沙。

这篇文章会把Java基础里真正核心的东西全部过一遍,从环境配置、语法细节、面向对象,到集合、异常、多线程、Lambda、JVM内存常见错误,每一块都按"是什么、为什么、怎么用、坑在哪"的逻辑来讲。适合准备Java面试的人、刚入门想系统打基础的人,以及写了两年代码想回头查漏补缺的人。

1. 先建立全局观:Java基础到底覆盖哪些知识模块

很多人学Java最大的问题不是不努力,而是不知道该往哪个方向使劲。Java基础这个概念太宽泛了,如果不把边界划清楚,很容易今天看看JVM调优,明天学学Spring源码,最后哪个都没学扎实。

先把最核心的模块列出来,这是我自己带新人时最喜欢用的划分方式:

模块核心内容面试/实战重要性
环境与工具链JDK、Maven/Gradle、IDE、Git入门必备,不考但不会就卡死
语法基础数据类型、运算符、流程控制、数组高频基础,笔试必考
面向对象封装、继承、多态、接口、抽象类灵魂核心,必须吃透
常用类库String、包装类、日期时间、枚举日常编码高频使用
集合框架List、Set、Map及其实现类面试题最密集的区域
异常处理异常层次、try-catch、自定义异常编码规范考察重点
多线程线程创建、同步、锁、线程池、CompletableFuture进阶必备,中高级必问
函数式编程Lambda、Stream、方法引用代码简洁化必备
JVM基础内存结构、垃圾回收、常见异常排查排查问题必备能力

这个表看起来简单,但它决定了你学习的顺序和优先级。我见过太多新手一上来就啃《深入理解Java虚拟机》,结果连强引用弱引用都分不清,因为他连堆和栈是干什么的都没概念。正确的顺序应该是:语法基础 → 面向对象 → 常用类库 → 集合 → 异常 → 多线程 → Lambda/Stream → JVM基础。前面四步是地基中的地基,后面才是加分项。

这里多说一句,《Java基础》和《Java面试题》是两个截然不同的东西。基础是体系化的知识网络,面试题是散落的要点。博文最后我会给一条以面试为导向的查漏补缺清单,但前提是,你先通过前面这些章节把网络建好,散落的点才有地方挂靠。

2. 环境配置:从JDK选型到Lombok报错的完整处理

2.1 JDK版本怎么选:LTS版本才是绝大多数人的正解

环境配置是很多Java初学者第一个崩溃点,不是它有多难,而是版本太多、路径太乱,没人给你讲清楚取舍。

先说版本。目前主流使用的JDK版本是8、11、17、21这几个LTS(长期支持)版本。Oracle对每个LTS版本提供多年支持,非LTS版本比如9、10、12-16、18-19,都是过渡版本,一般不建议在生产环境和学习中使用。我自己开发目前用的是JDK 17,新项目基本都会切到17甚至21,但很多企业线上跑的还是JDK 8,尤其是老项目。所以面试的时候"JDK 8和JDK 11有什么区别"这种问题才那么常见,因为企业里真的两种版本都有。

在你本机装JDK的时候,我建议直接装最新LTS版本(目前是21),然后把JDK 8也装上备用。这两者切换靠环境变量JAVA_HOME来做。Windows下配置JAVA_HOME的步骤:

  1. 下载JDK安装包,安装到无空格无中文的路径下,比如D:\Java\jdk-21
  2. 新建系统变量JAVA_HOME,值填JDK安装路径。
  3. 编辑Path变量,新增%JAVA_HOME%\bin
  4. 保存后打开命令行,输入java -version验证。

Linux/Mac下一般用包管理器或者手动解压到/usr/local/java,然后在~/.bashrc~/.zshrc里添加:

export JAVA_HOME=/usr/local/java/jdk-21 export PATH=$JAVA_HOME/bin:$PATH

配置完记得执行source ~/.bashrc让配置生效。

2.2 项目编译报错"源发行版 17 需要目标发行版 17"

这个报错在IDEA里太常见了,本质上就是项目编译用的JDK版本和IDE的Java语言级别设置不匹配。我们常说"一次编写,到处运行",但如果你A机器用JDK 8编译,B机器用JDK 17跑,不同的字节码版本和不同的语言特性支持,就会在编译阶段出问题。

解决路径:

  1. 打开File -> Project Structure -> Project,把SDKLanguage level改成一致。
  2. 打开File -> Settings -> Build, Execution, Deployment -> Compiler -> Java Compiler,检查字节码版本。
  3. 如果你的项目是Maven管理的,检查pom.xml里的maven.compiler.sourcemaven.compiler.target属性,或者直接通过<properties>设置:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties>

很多人的报错根源是IDEA默认用了JDK 21,而项目配置还停留在JDK 8,两边不一致导致的。把四处(Project SDK、Language level、Compiler settings、Maven配置)全部统一成同一个版本,问题就消失了。

2.3 Lombok报错:"You aren't using a compiler supported by lombok"

这个报错在SpringBoot项目里也有很高的出现率。Lombok是通过注解处理器在编译期生成getter/setter/构造器等方法,它依赖特定版本的javac编译器。当你升级JDK到一个Lombok还没适配的版本时,就会抛出这个警告,然后你会发现代码里所有的getter/setter全部标红了。

我的处理经验:

  1. 先看Lombok版本,老版本Lombok对JDK 21的支持不够好,先升级最新版Lombok。
  2. 在Maven里显式加上依赖,避免依赖传递导致的版本不一致:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.36</version> <scope>provided</scope> </dependency>
  1. IDEA里到Settings -> Plugins确认Lombok插件已安装并启用,同时到Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors勾选Enable annotation processing

很多Lombok的怪问题都是这个注解处理开关没开导致的,尤其是从别人那里克隆项目的时候,默认配置可能被改了。

3. 语法地基:数据类型、运算符和表达式的底层细节

3.1 基本类型和引用类型:两类数据的存储方式完全不同

Java的数据类型分两大类:基本类型和引用类型。基本类型就8种:byteshortintlongfloatdoublecharboolean

有一个核心区别必须刻在脑子里:基本类型变量存的是值本身,引用类型变量存的是对象的地址。这直接决定了赋值行为和比较行为。

int a = 100; int b = 100; System.out.println(a == b); // true,比较的是数值本身 String s1 = new String("java"); String s2 = new String("java"); System.out.println(s1 == s2); // false,比较的是地址 System.out.println(s1.equals(s2)); // true,比较的是内容

==对基本类型比较值,对引用类型比较地址,这是Java基础里最容易被问到"陷阱"的问题。

再挖一层,包装类的缓存机制更是一个考点。看这段代码:

Integer x = 127; Integer y = 127; System.out.println(x == y); // true Integer m = 128; Integer n = 128; System.out.println(m == n); // false

原因是Integer内部缓存了-128到127之间的对象,在这个范围里Integer.valueOf()直接返回缓存对象,超出范围就new新对象。如果你拿==去比较两个Integer128,比的是地址,自然不相等。正确做法是用equals()比较包装类内容。阿里巴巴开发规范里也明确要求:所有整型包装类对象之间值的比较,全部使用equals方法

3.2 运算符和表达式:优先级、短路与自动类型提升

运算符本身不难,但有两个最常见的坑。

第一个是短路逻辑&&||有短路特性,左边表达式已经能决定结果时,右边表达式不再执行。这其实是好事,可以防止NullPointerException:

if (str != null && str.length() > 0) { // 只有当str非空时才执行length() }

如果这里用的是&,那么即使str是null也会执行str.length(),直接NPE。&是位运算符,&&是短路逻辑运算符,两者运算符优先级也不同,&&优先级比&低。

第二个是自动类型提升:不同类型混合运算时,小的会向大的提升。byteshortchar参与运算时都会先提升为int。所以:

byte a = 10; byte b = 20; // byte c = a + b; // 编译报错,a+b已经是int了 byte c = (byte)(a + b); // 需要强转

我在带新人时经常用一句话总结:"小转大自动转,大转小要强转,运算中间全转int"。记住这句话,绝大多数类型转换问题都能解决。

3.3 标识符命名规则与数组越界异常

标识符命名规则是Java基础里最简单但最容易忽略的部分。规则本身不复杂:字母、数字、下划线、$符号组成,不能以数字开头,不能用Java关键字。但企业项目里真正讲究的是规范,不是合法。比如类名用大驼峰(HelloWorld),方法名用驼峰(getUserName),常量用全大写下划线(MAX_SIZE)。这些规范在笔试和代码评审里都会被关注。

数组是高频使用的数据结构,ArrayIndexOutOfBoundsException(数组越界)是新手最常见的异常之一。它发生在访问不存在的索引时,比如数组长度是5,访问arr[5]就越界了。Java数组索引从0开始,所以合法范围是0arr.length-1

顺便提一个实用技巧:遍历数组时优先用增强for或者Arrays.toString()来打印调试,避免手写索引时犯越界错误:

int[] arr = {1, 2, 3, 4, 5}; System.out.println(Arrays.toString(arr));

4. 面向对象核心:封装、继承、多态怎么理解才不心虚

4.1 从生活例子理解三大特性

面向对象编程(OOP)是Java的核心思想,三大特性是封装、继承、多态。面试时很多人在背定义,但一旦被追问"多态解决了什么问题"就卡壳。

我用一个手机的例子来讲:

  • 封装:手机不需要你看到内部电路,你只需知道按哪个键开机、插哪个口充电。在Java里就是用private隐藏字段,用public方法暴露行为。核心目的是隐藏实现细节,保证数据安全
  • 继承:手机有"智能手机"这个子类,它天然拥有"打电话、发短信"这些父类能力,再增加自己特有的"装App"能力。核心目的是代码复用,建立层次关系
  • 多态:同一个充电动作,苹果手机用Lightning接口充,安卓用Type-C充,华为用他自己的快充协议充。你调用同一个"充电"方法,实际执行的动作根据对象类型不同而变化。核心目的是面向抽象编程,扩展性更强

多态的三个必要条件:继承、方法重写、父类引用指向子类对象。这里有一个高频考点:父类引用调用子类特有方法会编译失败,因为编译看的是引用类型。你必须强转成子类才能调用子类独有方法。

4.2 重载与重写:面试里的一对孪生考点

重载(Overload)发生在同一个类里,方法名相同但参数列表不同。重写(Override)发生在父子类之间,子类重新实现父类的方法。两者的对比最容易混:

对比维度方法重载方法重写
发生位置同一个类中子类和父类之间
方法名相同相同
参数列表必须不同必须相同
返回类型可以不同必须相同或是父类返回类型的子类
访问修饰符不限制不能比父类更严格
异常不限制不能抛出新且更宽泛的检查异常

判断重载还是重写,第一步看它在"同一个类"还是"父子类"之间。这几乎是送分题,但面试时被"快速回答"一逼问,很多人会乱。

4.3 final、static、abstract关键字的正确理解

这三个关键字单独考不难,但组合在一起就很容易绕晕。

final可以修饰类、方法、变量,分别表示类不能被继承、方法不能被重写、变量只能赋值一次。static修饰的成员属于类而不属于实例,所有实例共享同一份。abstract修饰的类是抽象类,不能实例化,只能被继承;抽象方法没有方法体,必须在子类中实现。

一个经典的考点:final修饰的引用变量,不能改变引用指向,但可以改变引用指向的对象的内容

final StringBuilder sb = new StringBuilder("hello"); sb.append(" world"); // 合法,对象内容可以改变 // sb = new StringBuilder("new"); // 不合法,引用不能重新指向

理解这句话的关键是分清"引用"和"对象"。final锁的是引用变量这个"遥控器",但遥控器指向的"电视"你随便换台。

4.4 抽象类和接口的区别

JDK 8之后这个区别变复杂了,因为接口引入了默认方法(default method)和静态方法。做题和面试时要按版本说:

传统对比(JDK 7及以前):

  • 抽象类可以有构造器、普通成员变量、非抽象方法;接口只能有抽象方法和常量。
  • 一个类只能继承一个抽象类,但可以实现多个接口。
  • 抽象类强调的是"是什么"的关系(is-a),接口强调的是"能做什么"的能力(can-do)。

JDK 8之后的差异缩小了,接口可以有default方法和static方法,解决了接口演进必须修改所有实现类的问题。但核心设计思想没变:抽象类是模板复用,接口是能力约定。实际开发中我的习惯是:如果两段代码有大量公共字段和通用逻辑,用抽象类;如果只是定义行为和能力契约,用接口。

4.5 equals和hashCode:为什么重写equals必须重写hashCode

这个结是Java面向对象里最经典的"约定"问题。默认情况下Objectequals==比较地址,而hashCode返回对象的哈希值。当你需要让两个不同对象被认为"相等"(比如根据业务主键判断),你要重写equals

hashCode有个规范约定:如果两个对象通过equals比较相等,那么它们的hashCode必须相等。反过来不成立。

为什么?因为HashMap、HashSet等集合在存储元素时,先通过hashCode定位哈希桶,再通过equals判断桶内是否存在相同元素。如果你只重写equals不重写hashCode,两个业务相等的对象hashCode不同,会被放到不同的桶里,HashSet就会认为它们不相等,导致集合里出现重复元素。

一个典型的正确写法:

public class User { private Long id; private String name; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(id, user.id) && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(id, name); } }

Objects.equalsObjects.hash可以省去一长串判空逻辑,这是JDK 7之后推荐的做法。

4.6 枚举类型:不只是常量,还能带行为

枚举(enum)在Java里是一个完整的类,可以定义字段、构造函数、方法。很多人只把它当常量用过,其实枚举在面试中经常考"为什么用枚举实现单例是安全的"。

先看一个实用的枚举写法,给每个枚举值附加属性和行为:

public enum OrderStatus { CREATED(1, "已创建"), PAID(2, "已支付"), SHIPPED(3, "已发货"), COMPLETED(4, "已完成"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 根据code反查枚举 public static OrderStatus getByCode(int code) { for (OrderStatus status : values()) { if (status.code == code) { return status; } } throw new IllegalArgumentException("未知状态码: " + code); } }

这种写法把状态码和描述集中管理,比散落的魔法值可靠得多。枚举还有天然的线程安全性,枚举类在类加载时创建实例,JVM保证了实例只会被创建一次,所以枚举实现的单例既简单又安全。这是单例模式的最佳实践。

5. 集合框架:List、Map、Set的原理和排序算法实现

5.1 ArrayList的扩容机制:从源码看懂为什么它底层是数组

ArrayList是面试问得最多的集合类之一。看过源码的人都知道,它底层就是一个Object[]数组,通过动态扩容来支持任意数量的元素。

关键点在于扩容逻辑。当add的时候发现数组满了,它会按以下流程处理:

// JDK 17中的相关源码,略有简化 private Object[] grow(int minCapacity) { int oldCapacity = elementData.length; if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { int newCapacity = ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1); // = oldCapacity * 1.5 return elementData = Arrays.copyOf(elementData, newCapacity); } else { return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }

注意oldCapacity >> 1相当于除以2,所以ArrayList默认扩容后长度是原来的1.5倍。每次扩容都要做一次Arrays.copyOf,也就是把原数组拷贝到新数组,这一步的复杂度是O(n)。如果频繁扩容,性能损耗不容忽视。所以如果你能预判数据量,应该在构造时指定初始容量:

List<String> list = new ArrayList<>(1000);

ArrayListLinkedList的区别面试也常考。简单说:ArrayList基于动态数组,随机访问快(O(1)),中间插入删除慢(O(n),因为要移动元素);LinkedList基于双向链表,中间插入删除快(O(1))但需要先遍历找到位置,随机访问慢(O(n))。日常开发里绝大多数场景都用ArrayList,LinkedList的优势区间其实很窄。

5.2 HashMap:从哈希表到红黑树

HashMap是Java集合框架里最核心也最复杂的类。先记住它的核心结构:

  • JDK 7及以前:数组 + 链表
  • JDK 8及以后:数组 + 链表 + 红黑树

流程是这样的:put(key, value)时先计算key的hashCode,再用扰动函数让高位参与运算,得出数组下标,定位到对应桶。如果桶为空直接放进去;如果桶不为空,遍历链表判断key是否存在,存在则覆盖值,不存在则追加到链表尾部。

什么时候链表转红黑树?链表长度超过TREEIFY_THRESHOLD = 8且数组长度大于等于MIN_TREEIFY_CAPACITY = 64时,链表转红黑树。之所以用8作为阈值,源码注释里有一个统计学解释:随机哈希码下,链表节点数达到8的概率极低(大约一亿分之六),所以用8是一个时间空间上的折中。为什么转成红黑树?因为链表查找到最坏情况是O(n),红黑树保证最坏O(log n),防止哈希碰撞被恶意利用时性能退化。

还有一个关键参数:负载因子默认0.75。它表示当元素个数超过数组长度乘以负载因子时,触发扩容。0.75是空间利用率和查询效率的折中:太小浪费空间,太大容易造成更多哈希冲突。

5.3 排序算法:冒泡排序与快速排序的Java实现

排序算法在笔试手写题里出现率极高,冒泡和快排又是其中最多的两个。

冒泡排序的思路很直观:每一轮从头到尾比较相邻元素,逆序就交换,最大的元素像气泡一样"浮"到末尾。代码如下:

public static void bubbleSort(int[] arr) { int n = arr.length; // 外层循环控制比较轮数 for (int i = 0; i < n - 1; i++) { boolean swapped = false; // 内层循环,已排好的尾部不需要再比较 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } // 如果没有发生交换,说明已经有序,提前结束 if (!swapped) { break; } } }

加上swapped标志位后,最好情况(原数组有序)时间复杂度降到O(n),平均和最坏都是O(n²)。

快速排序的核心是分治法:每次选一个基准值(pivot),把数组分成"小于等于基准"和"大于基准"两部分,然后递归排序。经典实现:

public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivot = partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot + 1, right); } private static int partition(int[] arr, int left, int right) { // 取最右边的数作为基准值 int pivotValue = arr[right]; int i = left; for (int j = left; j < right; j++) { if (arr[j] < pivotValue) { swap(arr, i, j); i++; } } swap(arr, i, right); return i; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }

这个写法叫"挖坑填数法"的一种变体:i用来记录"已处理的小于基准值的元素边界",遍历完一次后,把基准值放到i的位置,此时i左边全部小于等于基准,右边全部大于基准。快排平均时间复杂度O(n log n),最坏情况O(n²)——当数组基本有序且每次选的基准刚好是最大或最小值时就会出现。所以工程上一般会用随机选基准或三数取中法来优化。

5.4 遍历Map的几种方式

Map遍历是个很实际的编码问题,面试也会问。推荐方式如下:

Map<String, Integer> map = new HashMap<>(); map.put("java", 1); map.put("spring", 2); // 方式1:推荐,同时获取key和value for (Map.Entry<String, Integer> entry : map.entrySet()) { System.out.println(entry.getKey() + " = " + entry.getValue()); } // 方式2:只取key或value时用 for (String key : map.keySet()) { System.out.println(key); } // 方式3:JDK 8的forEach map.forEach((key, value) -> System.out.println(key + " = " + value));

注意entrySet()遍历比keySet()再用map.get(key)效率高,因为后者多了一次根据key查value的操作,相当于每个键查两遍。

6. 异常机制:什么时候抛、什么时候吞、怎么设计异常

6.1 Java异常类的完整层次

Java异常体系是一个以Throwable为根节点的层次结构:

Throwable ├── Error(错误,程序无法处理) │ ├── OutOfMemoryError │ ├── StackOverflowError │ └── ... └── Exception(异常,程序可以处理) ├── RuntimeException(运行时异常,非受检) │ ├── NullPointerException │ ├── ArrayIndexOutOfBoundsException │ ├── IllegalArgumentException │ └── ... └── 其他受检异常(Checked Exception) ├── IOException ├── SQLException └── ...

Error表示JVM层面的严重错误,比如栈溢出StackOverflowError、内存不足OutOfMemoryError。程序一般无法恢复,不应该捕获处理。

受检异常(Checked Exception)必须显式捕获或声明抛出,否则编译不通过。典型的如IOExceptionSQLException

运行时异常(RuntimeException)也叫非受检异常,不需要显式捕获。它通常是程序逻辑错误导致的,比如空指针、数组越界、类型转换错误。

受检异常和运行时异常的使用争议很大。阿里开发规范里有一条倾向:不要用异常处理正常的业务逻辑,受检异常在业务代码中尽量避免。我个人的经验是:能够通过代码前置判断避免的,不要抛出异常。比如入参可能为null,先做if判断或者用Objects.requireNonNull,而不是等它抛NPE再捕获。

6.2 try-catch-finally与try-with-resources

标准的异常处理结构是try-catch-finallyfinally块里的代码无论是否发生异常都会执行,通常用来释放资源。但如果你在finally里写return或者抛异常,会覆盖try块的返回值或异常,这是很隐蔽的坑。

public static String test() { try { return "try"; } finally { // 永远不要这样做 return "finally"; // 会覆盖try的返回值 } }

JDK 7引入了try-with-resources,只要资源实现了AutoCloseable接口,就能自动关闭,代码更简洁也避免忘记关闭。InputStream、Connection、Statement等都实现了该接口:

try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))) { String line = reader.readLine(); System.out.println(line); } catch (IOException e) { e.printStackTrace(); }

这种写法在编译层面会自动加上关闭资源的finally逻辑,不需要你手动close。看字节码层面甚至会发现它处理了关闭时再抛异常的叠加场景,比手写try-finally严谨得多。

6.3 自定义异常的最佳实践

自定义异常通常继承RuntimeExceptionException。绝大多数业务场景我建议继承RuntimeException,这样不需要在每个方法上声明throws,代码更干净。一个实用的业务异常类长这样:

public class BusinessException extends RuntimeException { private final int code; public BusinessException(String message) { super(message); this.code = 500; } public BusinessException(int code, String message) { super(message); this.code = code; } public int getCode() { return code; } }

抛出时这样用:

if (order == null) { throw new BusinessException(400, "订单不存在,orderId=" + orderId); }

把code和message带进去,方便上层统一处理错误响应。实战中建议在异常消息里带上关键参数信息,方便线上排查日志。

6.4 异常处理三原则

  • 不要catch后什么都不做。打印一行e.printStackTrace()至少能留痕迹,空catch是最恶劣的写法。
  • 不要用异常控制业务流。比如用try-catch去做条件判断,性能差且逻辑混乱。
  • 不要用catch (Exception e)代替所有错误处理。捕获范围太大容易吞掉不该吞的异常,让问题更难发现。

7. 多线程入门:从Thread到CompletableFuture的进阶路径

7.1 线程创建的四种方式

创建线程的方式面试常考,看起来是四种:继承Thread、实现Runnable、实现Callable、用线程池。但实际上核心是两类:任务(Runnable/Callable)和线程(Thread)分离,以及线程池复用线程

推荐写法是通过ExecutorService提交任务,而不是直接new Thread:

ExecutorService executor = Executors.newFixedThreadPool(4); executor.submit(() -> System.out.println("task running")); executor.shutdown();

阿里开发规范里明确建议不要使用Executors的快捷方法创建线程池,因为newFixedThreadPool用的是无界队列,任务量过大时可能堆积导致OOM。更安全的做法是直接用ThreadPoolExecutor显式指定参数:

ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲存活时间 new ArrayBlockingQueue<>(100), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );

7.2 synchronized与Lock

synchronized是JVM层面的内置锁,可以修饰方法或代码块。修饰静态方法锁的是类对象,修饰实例方法锁的是当前实例。它的核心作用是保证多线程环境下同一时刻只有一个线程执行临界区代码。

Lock是JDK 5引入的接口,最常用的是ReentrantLock。相比synchronized,它更灵活:支持尝试获取锁(tryLock)、可中断、支持公平锁。

Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }

记住:Lock必须手动解锁,一定要放在finally里。这是容易出问题的地方,synchronized是自动解锁,Lock不是。

7.3 CompletableFuture:异步编程的新时代

CompletableFuture是JDK 8加入的异步编程工具,它把回调、异步执行、结果组合揉在一起,用起来比Future简洁很多。面试和项目中都在高频使用。

一个典型的用法:并行查询多个接口,再合并结果。

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> { // 模拟远程调用 return "结果1"; }); CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> { // 模拟远程调用 return "结果2"; }); // 两个异步任务都完成后,合并结果 CompletableFuture<String> combined = f1.thenCombine(f2, (r1, r2) -> r1 + r2); System.out.println(combined.join());

如果没用CompletableFuture,你得用ExecutorService.submit提交两个任务,拿到两个Future,再阻塞调用get(),代码更啰嗦。thenApplythenAcceptexceptionally这些方法分别是"转换结果、消费结果、异常恢复",记住这三个就能覆盖大半场景。

使用CompletableFuture.supplyAsync时默认使用ForkJoinPool.commonPool,线程数等于CPU核数。如果任务涉及IO阻塞,建议传入自定义线程池,避免公共池被占满影响其他异步任务。

8. 函数式编程:Lambda表达式和Stream API的实际应用

8.1 Lambda表达式的语法和本质

Lambda表达式的本质是函数式接口的匿名实现类。所谓函数式接口,就是只有一个抽象方法的接口,比如RunnableComparatorConsumer

语法格式:

(参数列表) -> { 方法体 }

一个经典例子,排序时把匿名类简化为Lambda:

// 匿名内部类写法 list.sort(new Comparator<String>() { @Override public int compare(String o1, String o2) { return o1.compareTo(o2); } }); // Lambda写法 list.sort((o1, o2) -> o1.compareTo(o2)); // 方法引用写法,更简洁 list.sort(String::compareTo);

方法引用String::compareTo是Lambda的语法糖,当Lambda体只是调用一个已存在的方法时用它,可读性更强。

8.2 Stream API:集合操作的声明式风格

Stream是Java 8的另一个大杀器,它让你用"声明式"的方式处理集合,而不是写一堆for循环。核心操作分三类:

  • 创建list.stream()Stream.of()Arrays.stream(arr)
  • 中间操作filter(过滤)、map(转换)、sorted(排序)、distinct(去重)
  • 终止操作collect(收集)、forEach(遍历)、reduce(聚合)

一个综合案例:从用户列表中筛选出年龄大于18的用户姓名,按年龄排序后收集为List。

List<User> users = getUsers(); List<String> names = users.stream() .filter(u -> u.getAge() > 18) .sorted(Comparator.comparing(User::getAge)) .map(User::getName) .collect(Collectors.toList());

对比传统for循环,这段代码一行就是一步操作,读起来像在描述"我要做什么"而不是"我要怎么遍历"。这就是Stream的价值。

有个细节面试常问:Stream流只能被消费一次。直接用list.stream()每次创建新流没问题,但如果把流保存到变量里用两次,第二次使用会抛IllegalStateException: stream has already been operated upon or closed

9. JVM内存基础:OutOfMemoryError从理论到排查

9.1 JVM运行时数据区

JVM内存结构是Java进阶的必修课。运行时数据区主要分以下几块:

区域存放内容线程私有?常见异常
程序计数器当前线程执行的字节码行号
虚拟机栈局部变量表、操作数栈、方法调用StackOverflowError
本地方法栈native方法调用StackOverflowError
对象实例、数组否,线程共享OutOfMemoryError
方法区类信息、常量、静态变量否,线程共享OutOfMemoryError

栈管运行,堆管存储这句话虽然不算严谨但很好记。每个方法调用都会在虚拟机栈中创建一个栈帧,方法执行完栈帧出栈。递归调用过深导致栈帧过多,就抛出StackOverflowError;堆里对象太多放不下,就抛出OutOfMemoryError

9.2 常见的OutOfMemoryError类型

java.lang.OutOfMemoryError: Java heap space是最常见的堆内存溢出,原因是堆里对象太多或单个对象太大。另一种在开发环境也很常见,java.lang.OutOfMemoryError: Metaspace(JDK 8之后的方法区OOM),通常是动态生成大量类导致的。

排查OOM的实用工具:

  • jmap -heap <pid>:查看堆内存配置和使用情况
  • jmap -dump:format=b,file=heap.hprof <pid>:导出堆转储文件
  • jvisualvmMAT:分析堆转储,查看哪个对象占用了大量内存
  • jstat -gc <pid> 1000:每秒打印一次GC情况,观察内存回收趋势

排查思路一般是:先确认是堆溢出还是非堆溢出,导出堆转储文件,用MAT分析找出占用大头是哪些对象,再回到代码里定位这些对象是谁创建的、为什么没被释放。

9.3 内存溢出的常见代码原因

  • 集合持有强引用未释放:把对象加进static List后一直不清理。
  • 大对象太多:比如一次性用new byte[100MB]申请超大数组。
  • 流未关闭:IO流和数据库连接没关,底层持有大量资源。
  • ThreadLocal使用不当:线程池里的线程复用导致ThreadLocal中的值一直存在,最终OOM。

最后补充一个我踩过的坑:用ThreadLocal存了大量数据,却没有在finally里remove()。线程池里的线程是复用的,上一个任务存的数据还在ThreadLocal里,下一个任务拿到的是脏数据。更严重的内存泄漏场景是做拦截器时不清理,配合线程池就出现越来越多无法回收的ThreadLocalMap项。

10. 一条主线走完Java基础后,接下来怎么继续

基础梳理完了,但我还想说一个很实际的话题:学完基础之后的路该怎么走。很多人在这条路上浪费了大量时间在无意义的"广度"上,今天学Redis,明天看Kafka,后天研究Elasticsearch,Java本身却没吃透。

我的建议是分三步走:

第一步,把基础重用手写一遍。不依赖IDE的自动补全,手写一个包含类、继承、接口、多态的小项目。比如"动物管理系统",定义一个Animal抽象类,派生出DogCat子类,用接口定义SwimableFlyable能力,再写一个Zoo类用List管理动物,遍历时调用各自的方法。这个小项目能覆盖面向对象60%的知识点。

第二步,深入框架背后的基础。Spring的IOC容器本质是什么?反射加工厂。AOP本质是什么?动态代理。你在Java基础里学的反射、注解、动态代理,正好是理解Spring的前提。如果基础没打好就学Spring,就会变成"只会调用注解但不知道原理"的配置工人。

第三步,用面试题检验薄弱点。给自己列一个清单:HashMap的实现原理、ArrayList扩容机制、线程池的七参数、Lock和synchronized的区别、Java的内存模型、类加载机制、GC算法。每个问题能否在5分钟内清晰完整地回答?不能,说明对应的基础知识还有漏洞。

提示:复习时不要背答案,要找答案背后的"为什么"。比如你背"HashMap的负载因子是0.75",这可以应付初级面试,但如果你能说出"0.75是空间和时间权衡的结果",高级面试官才会认可你是真的懂。

我最后分享一个自己踩过的坑:刚开始学多线程时,我花了很多时间抠volatilesynchronized的底层汇编实现,结果一头雾水。后来才明白,对一个还没写过并发代码的人来说,最该先解决的是"什么时候需要加锁"和"怎么设计线程安全的代码",而不是去啃什么内存屏障。基础学习要按"能用 → 懂原理 → 会优化"的顺序推进,跳级学习只会浪费时间。

Java基础是一条绕不过去的长路,但它的每一块知识都是有边界的。这篇文章梳理的框架如果能帮你找到路线,让你知道接下来该往哪里使劲,那就不算白写。真到面试或写代码的时候,你会发现,所有的高楼大厦,靠的都是这些地基。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 4:15:39

嵌入式Linux下Modbus RTU开发实战:串口配置与协议实现

在嵌入式Linux上做工业通信&#xff0c;Modbus RTU几乎是一个绕不开的话题。无论是接一个温湿度传感器、采集一路模拟量&#xff0c;还是跟PLC、仪表对上数据&#xff0c;这套基于RS485的串口协议凭借简单、稳定、生态成熟&#xff0c;依然是现场设备接入的首选方式之一。 这篇…

作者头像 李华
网站建设 2026/9/10 4:15:09

嵌入式AI生成代码验证体系:从静态分析到实车路试的完整实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华