ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java常用API深度解析:从字符串到集合框架的实战避坑指南

2026/9/9 21:19:56 拓冰建站 浏览量
Java常用API深度解析:从字符串到集合框架的实战避坑指南 1. 为什么我建议重新系统过一遍Java常用API先聊个真实场景。前阵子帮团队做Code Review看到一段用SimpleDateFormat格式化日期的代码线程池一并发就直接抛异常排查了半天才发现问题。后来问了一圈组里好几个新同事都在用老掉牙的Date和Calendar处理时间问他们为什么不用LocalDateTime回答是没系统学过只知道有这个东西。这个场景其实很典型——不是大家不愿意用好的API而是从入门到工作大部分人的Java常用API知识都是零散拼凑的网上搜一段学一段遇到能跑就复制从不深究背后的设计逻辑。这道题我太熟悉了。Java常用API是整个Java生态里使用频率最高、面试最常考、日常开发最容易踩坑的主题之一。无论你是准备校招社招面试还是查漏补底提升代码质量或者纯粹想搞清楚HashMap底层到底怎么回事都绕不开这块内容。很多人觉得API就是查文档的事但真正的差距在于同样一个功能有人写出来的代码又稳又清晰有人写出来的代码过两周自己都看不懂运行时还会出各种诡异问题。这篇文章不打算给你罗列一本Java API百科词典那没意义官方文档写得比我全。我按自己这些年实际开发加面试官的视角把Java常用API里真正高频、真正容易出错、真正面试常问的内容拆开讲清楚。你会看到每个API背后的设计动机、适用场景、典型坑点以及我实际项目中总结出来的用法。文章顺序不按包名排按使用场景和认知逻辑走这样读下来脑子里能形成一张清晰的决策图。先提前声明这篇文章覆盖的范围以JDK自带的核心类库为主重点在java.lang、java.util、java.time、java.io、java.nio.file以及Stream相关API。第三方库不展开但会在相关位置点一句它们解决的问题方便你建立坐标系。2. 字符串相关API不可变设计带来的取舍与性能陷阱2.1 String的不可变性到底保护了什么你在任何一本Java入门书里都会看到String是不可变的但很少有人讲清楚为什么不可变这么重要。实际开发中这个特性直接影响三件事字符串常量池、线程安全、hashCode缓存。先说常量池。因为String不可变JVM才能放心地把相同内容的字符串字面量指向同一个对象这就是字符串常量池的意义。你写String a hello;和String b hello;a和b指向的是同一个对象a b返回true。这个机制偷懒掉了大量内存重复分配。但如果你用new String(hello)那就强制在堆上新建一个对象常量池里那份还在两份内存。所以日常开发不要动不动new String()编译器会把它变成对常量池的复用但这个习惯本身不好尤其是在循环里。再说线程安全。String对象内容一旦创建就不能改所有线程拿到同一个String对象时不需要加锁就能安全共享。HTTP请求头、配置项、SQL语句模板这些东西天然是共享的如果用可变对象承载你得处处提防并发修改问题。String帮你把这块复杂度直接消掉了。最后是hashCode。String重写了hashCode()因为内容不变所以哈希值算一次就能缓存住。这直接决定了String作为HashMap的key时性能非常好——扩容、存取时算哈希不用重复计算。这也是为什么HashMapString, Object在业务代码里到处都是很少人考虑这个问题但底层确实是这个设计在支撑。2.2 字符串拼接为什么还有人在用拼接字符串拼接是写代码最频繁的操作。运算符在Java里被重载过a b实际上是编译器在背后创建了StringBuilder来完成的。那么问题来了既然编译器自己会转成StringBuilder那我们写代码的时候是不是随便用就行分场景看。单条语句里的拼接比如String s 用户: name , age: age编译器会优化成一条StringBuilder.append链这么写简洁明了没有任何性能问题。麻烦的是循环内拼接String result ; for (int i 0; i 10000; i) { result String.valueOf(i); // 反编译后会看到每次循环都 new 一个 StringBuilder }这段代码反编译后循环体内每一次都会创建一个新的StringBuilder对象循环一万次就是一万个临时对象GC压力直接拉满。正确做法是把StringBuilder提到循环外面StringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();这里我多说一句StringBuilder和StringBuffer的区别。StringBuffer是JDK 1.0就有的所有方法用synchronized修饰线程安全但性能较差StringBuilder是JDK 1.5加入的方法不加锁单线程场景性能更高。日常开发里局部变量拼接字符串用StringBuilder就够了因为局部变量根本不会被多线程共享。只有当你真的要把一个可变的字符串缓冲区暴露给多个线程时才该考虑StringBuffer而这种情况在业务代码里几乎没有。2.3 split、substring、replaceAll这些高频方法的坑split方法有个容易忽略的细节它接收的是正则表达式不是普通字符串。所以当你需要按点号分割IP地址时写192.168.1.1.split(.)会得到空数组因为.在正则里匹配任意字符。正确写法是split(\\.)。同理按竖线分割要写split(\\|)按反斜杠分割要写split(\\\\)。这个坑面试时经常考实际开发里也经常有人栽。substring在JDK 7u6之前有个内存泄漏问题——子串会持有原字符串的字符数组引用如果截取一个超大字符串的小片段整个大数组就一直被引用着无法释放。这个问题在JDK 7u6之后修复了新版本substring会复制内容不再共享底层数组。如果你维护老项目看到有人用new String(str.substring(...))这种写法那是从老版本时代流传下来的防御性代码现在可以放心去掉。replace和replaceAll的区别也是老生常谈。replace(CharSequence, CharSequence)的两个参数都当作字面量处理不识别正则replaceAll(String, String)的第一个参数是正则。比如你要把所有[替换成{用replace([, {)没问题用replaceAll([, {)会直接抛PatternSyntaxException因为[是正则里的字符类开头。这个异常很隐蔽字符串内容来自运行时变量时尤其容易触发。最后说一个我实测过的性能细节。字符串内容替换如果替换规则很多条不要用一堆replace串行处理每调一次都会产生新的中间字符串对象。先想想能不能用一次正则搞定或者用StringUtils.replaceEach这种支持多组替换的工具方法实在不行就用Pattern预编译后循环匹配替换。对于高频执行的代码路径字符串处理的性能差别可以到数量级。3. 集合框架的使用套路接口优先、选型决策和底层逻辑3.1 为什么变量类型要尽量用接口而不是实现类新手写代码喜欢这样声明ArrayListString list new ArrayList();。这也算能跑但不够好。真正到项目里你更希望看到的是ListString list new ArrayList();。原因很简单你关心的是这是一个可以按顺序存取的集合而不是它具体是数组实现的还是链表实现的。用接口声明变量带来的直接好处是方便替换实现。比如你一开始用ArrayList后面发现这个列表经常在头部插入元素性能太差想换成LinkedList。如果声明时用的是List接口只需要改new后面那一处如果声明的是ArrayList那所有用到这个变量的地方都要检查——其实检查也没用因为编译器已经不允许你换了。这个原则对Map同样适用MapString, Object map new HashMap();而不是HashMapString, Object map ...。在方法参数和返回值类型上更是要尽量用接口这样调用方不会跟具体的实现类耦合。我见过太多代码一个方法签名写死返回ArrayList后来想改成LinkedList或者返回只读集合调用方全部编译报错。注意集合类的get(index)、add(index, element)这些按索引操作的方法ArrayList和LinkedList的性能差异极大。ArrayList按索引随机访问是O(1)LinkedList是O(n)。反之在头部插入元素LinkedList是O(1)ArrayList需要移动所有后续元素是O(n)。理解你的数据结构访问模式比盲目选一个听说性能好的实现类重要得多。3.2 HashMap的负载因子0.75、扩容机制与并发问题HashMap是面试八股文里出现频率最高的类但很多人只会背数组加链表红黑树负载因子0.75一问为什么是0.75就卡住了。这个值不是拍脑袋定的是空间和时间的一个平衡点。负载因子越大空间利用率越高但哈希冲突概率也越大链表变长查询性能下降负载因子越小冲突越少但数组频繁扩容浪费空间。0.75是JDK团队在大量测试下得出的经验最优值。扩容触发条件是size capacity * loadFactor数组长度默认16插入第13个键值对时触发扩容每次扩容为原来的两倍。扩容时所有元素要重新计算哈希并放到新数组里这是一个O(n)操作。如果你能提前预估数据规模初始化时就指定容量可以避免多次扩容。比如你知道大概要放100个键值对直接new HashMap(128)或者new HashMap(100 / 0.75f)关键是容量要选一个能让实际元素数不触发扩容的值。关于Java 8之后的红黑树化当某个桶里的链表长度超过8并且数组长度超过64时链表转成红黑树查询复杂度从O(n)降到O(log n)。很多人只记住了这条但没理解一个隐藏条件——为什么数组长度要求是64因为如果数组还很小优先通过扩容让元素分散到更多桶里比转树更划算。这个设计思路在源码注释里有详细说明看源码时留意一下面试能答出这层意思会明显加分。HashMap不是线程安全的这一点必须反复强调。并发场景下有两个选择Collections.synchronizedMap()返回的同步包装Map或者ConcurrentHashMap。前者粗暴地在每个方法上加synchronized并发高时锁竞争激烈后者采用锁分段Java 8之前或CAS加锁Java 8之后机制读操作几乎不加锁写操作只锁对应的桶并发性能好得多。日常开发并发场景无脑选ConcurrentHashMap即可。3.3 ArrayList的扩容机制和循环删元素的坑ArrayList底层是Object数组默认初始容量10满了之后扩容为原来的1.5倍oldCapacity (oldCapacity 1)。扩容会创建新数组并复制旧元素频繁扩容影响性能。如果你能预估元素数量构造时传入初始容量能省掉大量复制操作。这个点在处理大数据集时非常明显我实测过一个十万级的列表如果初始容量给得合适插入耗时能降一半左右。循环里删除元素是另一个经典坑。直接for循环从前往后边遍历边remove会因为元素前移导致漏删。我见过最典型的错误写法ListString list new ArrayList(Arrays.asList(a, b, b, c)); for (int i 0; i list.size(); i) { if (b.equals(list.get(i))) { list.remove(i); } }这个循环删除后索引1的元素被删掉索引2的b自动前移到索引1但下一轮循环i已经变成2跳过了这个元素导致漏删。正确解法有几种一是倒序删除从后往前遍历删除元素不影响前面元素的索引二是用Iterator的remove()方法三是Java 8之后直接用removeIf一行搞定list.removeIf(b::equals);这个坑网上有无数人在踩面试也爱考值得重视。3.4 Arrays和Collections工具类的实用操作Arrays.asList()这个API要特别小心它返回的是一个固定长度的列表内部使用的是原数组不能调用add和remove否则抛UnsupportedOperationException。很多人误以为得到一个ArrayList其实它返回的是Arrays内部一个私有静态类。如果你需要一个真正可变的列表要这样写new ArrayList(Arrays.asList(...))。Collections工具类有几个在日常开发中实用性极强的APICollections.sort(list)对List排序要求元素实现Comparable接口或者传入Comparator。Collections.reverse(list)反转列表顺序。Collections.emptyList()、Collections.singletonList(element)返回不可变列表返回空集合时优先用这个而不是new ArrayList()少创建对象。Collections.unmodifiableList(list)返回只读视图任何修改操作都会抛异常。这个API适合暴露内部集合给外部时防止被误改。工具类用得好代码能简洁一大截。比如需求是把一个列表倒序输出新手可能写个双重循环自己交换老手一行Collections.reverse搞定。这些都是日常高频操作值得花点时间熟悉。4. 日期时间API从Date和Calendar到java.time的演进与正确姿势4.1 老API的问题根源可变性、偏移量和线程不安全每个Java程序员应该都经历过被SimpleDateFormat坑的时候。我说两个最经典的问题。第一Date和Calendar都是可变的。Date有setTime()方法Calendar有set()方法你传一个Date对象给别人处理别人里面改了一下你外面的值就变了。这在并发或复杂业务流程里很容易引入隐性Bug。第二月份从0开始。Calendar的1月是012月是11Date的getMonth()同样如此新手写new GregorianCalendar(2024, 12, 1)实际上是2025年1月1日。这个设计是从C语言的tm结构继承过来的历史遗留到了java.time才彻底改掉。第三SimpleDateFormat线程不安全。它内部有一个Calendar成员变量做中间计算多线程共用同一个实例时格式化结果会错乱。你在网上搜到的解法五花八门每次new一个实例、加synchronized、用ThreadLocal。这些都能用但根本解法是换API——DateTimeFormatter是线程安全的不可变类可以放心定义为静态常量。4.2 LocalDate、LocalDateTime、Instant的适用场景区分java.time包是JDK 8引入的核心类有LocalDate日期不含时间和时区、LocalTime时间不含日期和时区、LocalDateTime日期和时间不含时区、Instant时间戳含时区信息和ZonedDateTime带时区的完整日期时间。选型逻辑其实很清晰只需要生日、节假日这种日期用LocalDate。只需要上下班时间点用LocalTime。需要完整的日期加时间但不需要跨时区比如记录一场本地活动的开始时间用LocalDateTime。需要记录一个绝对时间点比如日志时间戳、数据创建时间且可能有跨时区转换需求用Instant。需要明确表示北京时间早上9点这种带时区的语义用ZonedDateTime。一个容易混的点LocalDateTime名字里有Local但它不代表本地时区它就是一个没有任何时区信息的日期时间。真正代表本地时区的是ZonedDateTime比如ZonedDateTime.now()返回的是你当前系统时区的完整时间。系统开发中记录日志时间戳时我的建议是统一用Instant或LocalDateTime数据库里存UTC时间展示时再转本地时区。尽量避免在存储层就混入时区偏移否则排查跨天数据时容易出大问题。4.3 日期格式化、解析和运算的常用写法DateTimeFormatter的用法很直接LocalDateTime now LocalDateTime.now(); DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); String text now.format(formatter); // 2025-01-15 14:30:00 LocalDateTime parsed LocalDateTime.parse(text, formatter);和SimpleDateFormat不同DateTimeFormatter是线程安全的可以直接作为静态字段使用。此外它还内置了多个预定义格式比如DateTimeFormatter.ISO_LOCAL_DATE_TIME、ISO_INSTANT日常能用预定义的优先用预定义的减少自定义pattern的出错概率。日期运算方面LocalDate提供了一系列plusXxx和minusXxx方法LocalDate today LocalDate.now(); LocalDate nextWeek today.plusWeeks(1); LocalDate lastMonth today.minusMonths(1); LocalDate firstDayOfMonth today.withDayOfMonth(1);注意plus系列方法返回的是新对象不会修改原对象这是不可变设计的直接体现。你调用today.plusDays(1)后today还是原来的值这个行为和String很像。两个日期之间相差多少天用ChronoUnitlong daysBetween ChronoUnit.DAYS.between(startDate, endDate);这个方法内部处理了闰年、大小月不用自己算。4.4 时区转换的正确打开方式时区处理是日期时间API里最容易出错的环节。核心原则是存储和传输用InstantUTC展示和交互用ZonedDateTime。一个标准的转换流程Instant now Instant.now(); // UTC时间戳 ZonedDateTime beijing now.atZone(ZoneId.of(Asia/Shanghai)); ZonedDateTime newYork now.atZone(ZoneId.of(America/New_York));关键点在于Instant永远表示同一个时间点atZone只是给它套了一个时区的皮展示出来的本地时间不同但本质还是同一个时刻。这样设计的好处是不管用户在哪里只要知道他所在的ZoneId就能在任何时区正确展示。实际开发中还有个高频需求接收前端传来的日期字符串通常是2025-01-15 14:30:00这种没有时区信息的格式。这个字符串到底代表哪个时区的时间需要和前端约定清楚。我的经验是如果是用户本地的操作时间前端传Instant字符串ISO 8601格式带时区偏移后端直接解析成Instant再转存为UTC如果是固定的业务时间比如活动开始时间前后端统一用LocalDateTime传输存储时只存值不存时区。5. 文件读写与IO操作从File到Files工具类的现代化改造5.1 传统IO的基础套路字节流、字符流和缓冲流Java的IO体系初看很复杂各种InputStream、OutputStream、Reader、Writer但核心脉络其实就两条按字节读写和按字符读写。字节流是InputStream/OutputStream处理图片、音视频、压缩包这类二进制数据。字符流是Reader/Writer处理文本内容底层做字符编码转换。这两套体系的关系是字符流在字节流之上包了一层把字节按指定的字符集解码成字符。日常开发读写文件效率最高且代码最简洁的写法是配合缓冲流try (BufferedReader reader new BufferedReader(new FileReader(input.txt)); BufferedWriter writer new BufferedWriter(new FileWriter(output.txt))) { String line; while ((line reader.readLine()) ! null) { writer.write(line); writer.newLine(); } }这里用到了try-with-resources语法JVM自动关闭资源比手写finally块里close()清爽得多也避免了自己忘关流导致文件句柄泄漏的问题。这个语法从JDK 7就有了新代码应该坚持用。readLine()按行读取文本是这个写法最顺手的地方。处理大文件时切忌一次性Files.readAllLines读进来文件一大内存直接爆掉。按行流式处理才是大文件的正确姿势。5.2 Files工具类复制、移动、删除的现代写法JDK 7引入的java.nio.file.Files类把大量文件操作的样板代码收敛成了静态方法这一层API值得深入学习日常开发里绝大多数文件操作都能用它完成。Files.copy(Paths.get(origin.txt), Paths.get(target.txt), StandardCopyOption.REPLACE_EXISTING); Files.move(Paths.get(origin.txt), Paths.get(archive/origin.txt), StandardCopyOption.REPLACE_EXISTING); Files.delete(Paths.get(temp.txt)); Files.deleteIfExists(Paths.get(may_not_exist.txt));注意StandardCopyOption.REPLACE_EXISTING这个选项如果目标文件已存在且你没有传这个选项copy和move会抛FileAlreadyExistsException。这是设计上防止误覆盖文件的安全机制但新手经常被这个异常吓到。Files.readAllLines和Files.write适合小文件的快速读写ListString lines Files.readAllLines(Paths.get(data.txt), StandardCharsets.UTF_8); Files.write(Paths.get(out.txt), lines, StandardCharsets.UTF_8);这里有个隐蔽的坑new String(bytes, UTF-8)或者Files.readString如果不显式指定字符集会使用JVM默认字符集。不同环境的默认字符集可能不一样比如Windows下可能是GBKLinux下是UTF-8。跨平台部署的代码一定要显式指定StandardCharsets.UTF_8否则会出现本地正常服务器乱码的经典问题。5.3 遍历目录的两种方式与各自的应用场景Files.list和Files.walk是两个容易混淆但也都很实用的API。Files.list只列出一层目录返回目录下的直接子项Files.walk递归遍历所有层级。实际项目里搜索特定后缀文件时Files.walk配合Stream特别顺手try (StreamPath paths Files.walk(Paths.get(/data/logs))) { ListPath logFiles paths .filter(Files::isRegularFile) .filter(path - path.toString().endsWith(.log)) .collect(Collectors.toList()); }这里注意三点第一Files.walk返回的Stream要放在try-with-resources里它持有目录句柄不关闭会泄漏资源第二Files.isRegularFile用来过滤目录项不然递归结果里会混入目录自身第三Files.walk默认最多遍历Integer.MAX_VALUE层几乎等于不限制所以要先想清楚目录结构的深度必要时用带maxDepth参数的重载方法限制递归深度。5.4 常见IO异常与规避方法NoSuchFileException是文件不存在别和FileNotFoundException搞混它俩继承关系不同。AccessDeniedException是权限不够。DirectoryNotEmptyException是删除非空目录时抛的。删除非空目录不能直接Files.delete要先递归删除所有子文件和子目录或者用Files.walk配合sorted(Comparator.reverseOrder())先删子项再删父目录try (StreamPath paths Files.walk(dir)) { paths.sorted(Comparator.reverseOrder()).forEach(path - { try { Files.delete(path); } catch (IOException e) { throw new UncheckedIOException(e); } }); }这里sorted(reverseOrder())是关键Files.walk默认先输出父目录如果不反转顺序删父目录时子目录非空会抛异常。反转后先删最深层的目录再逐层往上删一切正常。这个技巧我记了很久确实实用。6. Stream与Lambda集合处理链的现代写法6.1 惰性求值机制区分中间操作和终止操作Stream是Java 8的另一大件很多人学Stream只学了一堆方法名没搞懂底层的执行机制结果写出来的代码性能莫名其妙差。核心概念是惰性求值Stream上的中间操作filter、map、sorted等不会立即执行它们只是搭建一个流水线只有当终止操作collect、forEach、count等被调用时整条流水线才开始真正处理数据。这个设计的实际意义是优化比如一个十万元素的列表你要先过滤再取前五个用filterlimit(5)Stream不会傻乎乎地过滤完所有十万个元素再取前五个而是处理到找到五个满足条件的元素就停。这在处理大量数据时性能差异非常显著。写Stream链时有个好习惯把中间操作想象成一条流水线每一步都在描述对流过元素的变换而不是循环里的一步步操作。这样写出来的代码可读性会高很多。6.2 高频中间操作与收集器filter、map、flatMap、collectfilter接收一个Predicate返回布尔值的Lambda保留满足条件的元素。map接收一个Function把每个元素转换成另一种类型。这两个是最基础的看个组合例子ListUser users loadUsers(); ListString names users.stream() .filter(u - u.getAge() 18) .map(User::getName) .collect(Collectors.toList());flatMap是面试重灾区很多人不理解它和map的区别。map的每个输入元素产生一个输出元素flatMap的每个输入元素可以产生零个、一个或多个输出元素并且自动把所有输出摊平到同一个流里。经典场景是把多个列表合并成一个列表ListListString nested Arrays.asList( Arrays.asList(a, b), Arrays.asList(c, d)); ListString flat nested.stream() .flatMap(List::stream) .collect(Collectors.toList()); // [a, b, c, d]收集器Collectors工具类里除了toList()还有几个高频方法Collectors.joining(, )把字符串流拼接成一个字符串比手动拼接优雅得多。Collectors.groupingBy(Function)按某个属性分组返回MapK, ListV。Collectors.partitioningBy(Predicate)按true/false分两组是一种特殊的groupingBy。Collectors.toMap(Function, Function)转成Map注意key重复时会抛IllegalStateException需要传第三个参数合并函数。groupingBy我觉得是实际业务里含金量最高的收集器。比如按部门分组统计人数一行搞定MapString, Long countByDept employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.counting()));6.3 流的短路操作与count、anyMatch等终止操作除了collectStream还有一些终止操作使用频率很高尤其anyMatch、allMatch、noneMatch都是返回布尔值boolean hasAdult users.stream().anyMatch(u - u.getAge() 18); boolean allAdult users.stream().allMatch(u - u.getAge() 18);anyMatch找到第一个满足条件的元素就返回true不会继续遍历这是短路行为。这种写法比传统的for循环加break简洁得多而且语义更清晰。reduce是Stream的归约操作把整个流变成一个值。求和、求最大值最小值都能用但我更推荐用专门的mapToIntsum/max/min可读性更好int totalAge users.stream().mapToInt(User::getAge).sum(); OptionalInt maxAge users.stream().mapToInt(User::getAge).max();6.4 parallelStream不是银弹何时用、何时千万别用parallelStream把流转化为并行流利用ForkJoinPool公共线程池并行处理数据。看着很美但有两个最常见的坑。第一线程安全问题。并行流里的每个线程可能同时操作外部共享的变量比如往一个普通的ArrayList里添加元素会丢数据或抛异常。线程安全集合如CopyOnWriteArrayList、ConcurrentHashMap可以但开销也不小。第二公共线程池的挪用。parallelStream默认使用的是ForkJoinPool.commonPool()这个池是所有并行流共用的。如果你的多个接口都用了parallelStream线程池资源会互相挤占极端情况下响应时间飙升。我的实践经验是数据量太小不要用并行化有额外开销几千条数据串行反而更快数据量达到十万、百万级别且处理逻辑是CPU密集型的计算比如大量正则匹配、加解密parallelStream才真正有价值IO密集型的操作数据库查询、HTTP请求并行化要去用专用的线程池而不是parallelStream。7. Optional、包装类与比较器容易被忽视但面试常考的API细节7.1 Optional的正确使用边界什么时候用什么时候别用Optional是JDK 8为解决空指针问题引入的容器类但它的正确使用姿势存在大量误解。我看到过很多新手把字段类型定义成OptionalString错得离谱或者把Optional当作一个必须到处传的参数类型也是错的。Optional的正确使用场景是方法的返回值用来明确表达这个操作可能没有结果。比如根据ID查用户可能找不到public OptionalUser findById(Long id) { // 查不到时返回 Optional.empty() }这样的好处是强制调用方处理可能不存在的情况。配合orElse、orElseGet、orElseThrow使用User user findById(123L).orElse(defaultUser); User user findById(123L).orElseGet(() - createDefaultUser()); User user findById(123L).orElseThrow(() - new IllegalArgumentException(user not found));注意orElse和orElseGet的区别orElse无论Optional空不空都会先计算出参数值orElseGet只在Optional为空时才执行。如果orElse里的默认值创建成本很高比如查数据库就必须用orElseGet。但Optional也有误用场景。不要用Optional包裹集合类型的返回值返回空集合就好——Collections.emptyList()比OptionalListT更干净。不要在实体类字段里用Optional会导致序列化、JSON解析各种问题。不要把Optional当作方法参数传递调用方传null时Optional并不能自动拦住。7.2 包装类的缓存机制与比较陷阱这是面试题高频区Integer的比较。直接看代码Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false答案是true和false原因是Integer有一个缓存机制valueOf方法对-128到127之间的值会返回缓存中的同一个对象所以比较为true超出这个范围则每次new一个新对象所以比较为false。自动装箱Integer a 127底层就是Integer.valueOf(127)同样受缓存影响。这个机制在业务代码里的实际影响是你从数据库或接口拿到两个数值用去比较小范围值时碰巧对大范围值时错得莫名其妙。所以包装类型的比较一律用equalsInteger c 128; Integer d 128; System.out.println(c.equals(d)); // true如果两个变量都是从int转换来的直接拆箱成int比较也可以但为了统一和安全推荐全用equals。这个缓存机制在Character0到127、Boolean两个常量、Short、Byte上也存在Long同样有-128到127的缓存。7.3 Comparator的链式组合用法排序是集合操作的高频场景Comparator提供了链式组合的API能把复杂的排序规则写得非常清晰。比如按年龄升序年龄相同再按姓名降序ListUser sorted users.stream() .sorted(Comparator.comparing(User::getAge) .thenComparing(Comparator.comparing(User::getName).reversed())) .collect(Collectors.toList());注意几个细节。Comparator.comparing的第一个参数是提取排序键的函数默认按自然序排可以传第二个参数指定比较器comparing(User::getAge, Comparator.reverseOrder())。reversed()会反转整个比较器的顺序。空值处理用Comparator.nullsFirst(comparator)或nullsLast(comparator)可以指定排序时null值放在最前还是最后避免调用getXXX()时抛空指针。实际业务里最头疼的排序场景是多条件动态排序比如前端传来排序字段和方向。用Comparator链可以优雅地动态拼接ComparatorUser comparator Comparator.comparing(User::getId); if (age.equals(sortField)) { comparator comparator.thenComparing(User::getAge); } if (desc.equals(sortOrder)) { comparator comparator.reversed(); // 注意这只是对前一个条件反转 }这里thenComparing每次追加一个条件逻辑从头到尾保持清晰比在一堆if else里写Collections.sort加自定义Comparator要容易维护得多。我在实际项目里见过太多病态的排序代码——自己实现比较逻辑写了一大堆if判断结果边界情况全踩坑。Comparator链式写法之所以值得推荐不只是因为它简洁更是因为它把排序规则的每个维度独立出来逻辑可读性、可维护性都高出一个档次。把这套API吃透排序相关的代码基本不会再出幺蛾子。