ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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的步骤下载JDK安装包安装到无空格无中文的路径下比如D:\Java\jdk-21。新建系统变量JAVA_HOME值填JDK安装路径。编辑Path变量新增%JAVA_HOME%\bin。保存后打开命令行输入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跑不同的字节码版本和不同的语言特性支持就会在编译阶段出问题。解决路径打开File - Project Structure - Project把SDK和Language level改成一致。打开File - Settings - Build, Execution, Deployment - Compiler - Java Compiler检查字节码版本。如果你的项目是Maven管理的检查pom.xml里的maven.compiler.source和maven.compiler.target属性或者直接通过properties设置properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties很多人的报错根源是IDEA默认用了JDK 21而项目配置还停留在JDK 8两边不一致导致的。把四处Project SDK、Language level、Compiler settings、Maven配置全部统一成同一个版本问题就消失了。2.3 Lombok报错You arent using a compiler supported by lombok这个报错在SpringBoot项目里也有很高的出现率。Lombok是通过注解处理器在编译期生成getter/setter/构造器等方法它依赖特定版本的javac编译器。当你升级JDK到一个Lombok还没适配的版本时就会抛出这个警告然后你会发现代码里所有的getter/setter全部标红了。我的处理经验先看Lombok版本老版本Lombok对JDK 21的支持不够好先升级最新版Lombok。在Maven里显式加上依赖避免依赖传递导致的版本不一致dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.36/version scopeprovided/scope /dependencyIDEA里到Settings - Plugins确认Lombok插件已安装并启用同时到Settings - Build, Execution, Deployment - Compiler - Annotation Processors勾选Enable annotation processing。很多Lombok的怪问题都是这个注解处理开关没开导致的尤其是从别人那里克隆项目的时候默认配置可能被改了。3. 语法地基数据类型、运算符和表达式的底层细节3.1 基本类型和引用类型两类数据的存储方式完全不同Java的数据类型分两大类基本类型和引用类型。基本类型就8种byte、short、int、long、float、double、char、boolean。有一个核心区别必须刻在脑子里基本类型变量存的是值本身引用类型变量存的是对象的地址。这直接决定了赋值行为和比较行为。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 运算符和表达式优先级、短路与自动类型提升运算符本身不难但有两个最常见的坑。第一个是短路逻辑和||有短路特性左边表达式已经能决定结果时右边表达式不再执行。这其实是好事可以防止NullPointerExceptionif (str ! null str.length() 0) { // 只有当str非空时才执行length() }如果这里用的是那么即使str是null也会执行str.length()直接NPE。是位运算符是短路逻辑运算符两者运算符优先级也不同优先级比低。第二个是自动类型提升不同类型混合运算时小的会向大的提升。byte、short、char参与运算时都会先提升为int。所以byte a 10; byte b 20; // byte c a b; // 编译报错ab已经是int了 byte c (byte)(a b); // 需要强转我在带新人时经常用一句话总结小转大自动转大转小要强转运算中间全转int。记住这句话绝大多数类型转换问题都能解决。3.3 标识符命名规则与数组越界异常标识符命名规则是Java基础里最简单但最容易忽略的部分。规则本身不复杂字母、数字、下划线、$符号组成不能以数字开头不能用Java关键字。但企业项目里真正讲究的是规范不是合法。比如类名用大驼峰(HelloWorld)方法名用驼峰(getUserName)常量用全大写下划线(MAX_SIZE)。这些规范在笔试和代码评审里都会被关注。数组是高频使用的数据结构ArrayIndexOutOfBoundsException数组越界是新手最常见的异常之一。它发生在访问不存在的索引时比如数组长度是5访问arr[5]就越界了。Java数组索引从0开始所以合法范围是0到arr.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面向对象里最经典的约定问题。默认情况下Object的equals用比较地址而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.equals和Objects.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)。如果频繁扩容性能损耗不容忽视。所以如果你能预判数据量应该在构造时指定初始容量ListString list new ArrayList(1000);ArrayList和LinkedList的区别面试也常考。简单说ArrayList基于动态数组随机访问快O(1)中间插入删除慢O(n)因为要移动元素LinkedList基于双向链表中间插入删除快O(1)但需要先遍历找到位置随机访问慢O(n)。日常开发里绝大多数场景都用ArrayListLinkedList的优势区间其实很窄。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遍历是个很实际的编码问题面试也会问。推荐方式如下MapString, Integer map new HashMap(); map.put(java, 1); map.put(spring, 2); // 方式1推荐同时获取key和value for (Map.EntryString, Integer entry : map.entrySet()) { System.out.println(entry.getKey() entry.getValue()); } // 方式2只取key或value时用 for (String key : map.keySet()) { System.out.println(key); } // 方式3JDK 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必须显式捕获或声明抛出否则编译不通过。典型的如IOException、SQLException。运行时异常RuntimeException也叫非受检异常不需要显式捕获。它通常是程序逻辑错误导致的比如空指针、数组越界、类型转换错误。受检异常和运行时异常的使用争议很大。阿里开发规范里有一条倾向不要用异常处理正常的业务逻辑受检异常在业务代码中尽量避免。我个人的经验是能够通过代码前置判断避免的不要抛出异常。比如入参可能为null先做if判断或者用Objects.requireNonNull而不是等它抛NPE再捕获。6.2 try-catch-finally与try-with-resources标准的异常处理结构是try-catch-finally。finally块里的代码无论是否发生异常都会执行通常用来释放资源。但如果你在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 自定义异常的最佳实践自定义异常通常继承RuntimeException或Exception。绝大多数业务场景我建议继承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 ThreadExecutorService 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与Locksynchronized是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简洁很多。面试和项目中都在高频使用。一个典型的用法并行查询多个接口再合并结果。CompletableFutureString f1 CompletableFuture.supplyAsync(() - { // 模拟远程调用 return 结果1; }); CompletableFutureString f2 CompletableFuture.supplyAsync(() - { // 模拟远程调用 return 结果2; }); // 两个异步任务都完成后合并结果 CompletableFutureString combined f1.thenCombine(f2, (r1, r2) - r1 r2); System.out.println(combined.join());如果没用CompletableFuture你得用ExecutorService.submit提交两个任务拿到两个Future再阻塞调用get()代码更啰嗦。thenApply、thenAccept、exceptionally这些方法分别是转换结果、消费结果、异常恢复记住这三个就能覆盖大半场景。使用CompletableFuture.supplyAsync时默认使用ForkJoinPool.commonPool线程数等于CPU核数。如果任务涉及IO阻塞建议传入自定义线程池避免公共池被占满影响其他异步任务。8. 函数式编程Lambda表达式和Stream API的实际应用8.1 Lambda表达式的语法和本质Lambda表达式的本质是函数式接口的匿名实现类。所谓函数式接口就是只有一个抽象方法的接口比如Runnable、Comparator、Consumer。语法格式(参数列表) - { 方法体 }一个经典例子排序时把匿名类简化为Lambda// 匿名内部类写法 list.sort(new ComparatorString() { 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。ListUser users getUsers(); ListString 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: MetaspaceJDK 8之后的方法区OOM通常是动态生成大量类导致的。排查OOM的实用工具jmap -heap pid查看堆内存配置和使用情况jmap -dump:formatb,fileheap.hprof pid导出堆转储文件jvisualvm或MAT分析堆转储查看哪个对象占用了大量内存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后天研究ElasticsearchJava本身却没吃透。我的建议是分三步走第一步把基础重用手写一遍。不依赖IDE的自动补全手写一个包含类、继承、接口、多态的小项目。比如动物管理系统定义一个Animal抽象类派生出Dog、Cat子类用接口定义Swimable、Flyable能力再写一个Zoo类用List管理动物遍历时调用各自的方法。这个小项目能覆盖面向对象60%的知识点。第二步深入框架背后的基础。Spring的IOC容器本质是什么反射加工厂。AOP本质是什么动态代理。你在Java基础里学的反射、注解、动态代理正好是理解Spring的前提。如果基础没打好就学Spring就会变成只会调用注解但不知道原理的配置工人。第三步用面试题检验薄弱点。给自己列一个清单HashMap的实现原理、ArrayList扩容机制、线程池的七参数、Lock和synchronized的区别、Java的内存模型、类加载机制、GC算法。每个问题能否在5分钟内清晰完整地回答不能说明对应的基础知识还有漏洞。提示复习时不要背答案要找答案背后的为什么。比如你背HashMap的负载因子是0.75这可以应付初级面试但如果你能说出0.75是空间和时间权衡的结果高级面试官才会认可你是真的懂。我最后分享一个自己踩过的坑刚开始学多线程时我花了很多时间抠volatile和synchronized的底层汇编实现结果一头雾水。后来才明白对一个还没写过并发代码的人来说最该先解决的是什么时候需要加锁和怎么设计线程安全的代码而不是去啃什么内存屏障。基础学习要按能用 → 懂原理 → 会优化的顺序推进跳级学习只会浪费时间。Java基础是一条绕不过去的长路但它的每一块知识都是有边界的。这篇文章梳理的框架如果能帮你找到路线让你知道接下来该往哪里使劲那就不算白写。真到面试或写代码的时候你会发现所有的高楼大厦靠的都是这些地基。
返回列表