
前阵子帮一个刚入行的朋友过代码发现他框架用得很熟但一落到String、集合、日期这类最基础的Java API就卡壳。问他原因他说API太多了记不住也没人告诉他哪些值得重点记。这其实是我见过最常见的问题。Java的API数量庞大到没有任何人能全部背下来但日常开发和面试真正反复出现的永远是那么一批。这篇文章就是把用得上、问得多的API按包整理出来做一份可以收藏的清单也给刚开始学Java的人一条清晰的路线。1. 为什么我要总结这份Java API清单从背不完到按包吃透1.1 我整理清单的思路按包分类而不是按字母硬背很多初学者一上来就翻官方文档或者买一本几百页的工具书从头啃。结果通常是前50页翻得仔细越往后越潦草最后只记住了一个System.out.println。我的思路完全反过来Java的API是按照功能分包组织的学习的时候也按包来每个包搞清楚三件事——这个包负责解决什么问题、里面哪几个类出场率最高、这些类的主要方法怎么用。这样记下来的是一个结构不是一堆孤立的名字。可以大概分一下包名负责领域日常肉眼可见的高频类java.lang语言核心自动导入String、Object、Integer、Math、Systemjava.util集合、日期旧API、工具类ArrayList、HashMap、Collections、Arraysjava.timeJDK 8开始的新日期时间LocalDate、LocalDateTime、Instantjava.io / java.nio文件读写、IO流File、Files、BufferedReader、Pathjava.util.concurrent并发编程ThreadPoolExecutor、ConcurrentHashMapjava.lang是唯一不需要import的包因为它太基础了编译器隐式导入。剩下的包里java.util和java.time在日常编码中几乎天天见java.util.concurrent在稍微像样一点的项目里也躲不掉。我发现有个特别管用的学习方法每学一个类就顺手写一个10行的demo把它的构造方式、常用方法、边界情况全试一遍。比如学String就把拼接、截取、替换、比较、正则匹配都写一遍运行一次比看十遍文档都牢靠。1.2 这份清单适合谁三种读者三种用法纯新手按文章顺序从头过一遍每个代码示例都亲手敲一遍不要复制。敲之前先猜输出结果敲完再对照错了说明理解有偏差这比一次敲对收获大。准备面试的重点看第2章、第3章、第7章。这三章对应的是Java面试里出现频率最高的String、集合和经典易错题基本每次面试都会撞上。工作了一阵想查漏补缺的可以重点扫第4章、第5章、第6章看看自己是不是还在用旧API硬扛。特别是现在还写new Date()和SimpleDateFormat的老代码值得认真看一遍第4章。我自己的习惯是每半年翻一遍这类清单每次翻都能发现自己以前写得很蠢的代码。这不丢人反而说明在进步。下面就直接进入正题。2. java.lang包字符串、包装类与Object是面试和开发的双重重灾区2.1 String不可变与常量池为什么说它是HashMap的天然钥匙String可能是Java里被用得最多的类没有之一。先说一个很多人在实际中踩过的大坑字符串拼接。String s ; for (int i 0; i 10000; i) { s s i; // 每次循环都会创建新的字符串对象 }这段代码在循环里每次执行s s i都会创建一个新的String对象再把整个字符串内容复制一遍。数据量小的时候感觉不出来一旦循环几万次耗时和内存占用会明显上升。原因就是String被设计成了不可变类——一旦创建内容永远不能改。所以任何修改字符串的操作本质都是创建新对象 复制内容。那不可变到底带来什么好处最典型的是作为HashMap的key。HashMap用key的hashCode来定位存储位置如果key在放进去之后内容还能变那hashCode就变了get的时候按新的hashCode根本找不到原来的数据。String不可变就意味着它的hashCode一旦算出来就不会变用它做key天然安全。Integer和Long这类包装类不可变也是同一个道理。再说字符串常量池。直接写字面量的时候比如String a hello和String b helloa和b指向的是常量池里同一个对象所以a b为true。但是加上new String(hello)之后它会在堆上新创建一个对象即使内容相同和常量池里的也不是同一个引用。这个机制后面第7章还会配合面试题展开。实际开发里有两点经验第一循环拼接字符串不要用要用StringBuilder或StringBuffer单线程场景优先StringBuilder性能比StringBuffer稍微好一点第二频繁比较字符串内容统一用equals不要用。代码里一出现String类型的比较基本就是隐患。2.2 equals与hashCode的约定改一个不动另一个会出事Object的默认equals是比较引用地址也就是是不是同一个对象。但是业务里判断两个对象相等通常是看字段值比如两个User对象的id一样就认为是一个人。这时候就得重写equals。问题来了如果只重写equals不重写hashCodeHashSet和HashMap就会出问题。先记住约定两个对象equals返回true那它们的hashCode必须相等反过来hashCode相等不一定equals相等这叫哈希碰撞。这个约定是集合类工作的基础。举个例子。有一个User类只重写了equals没重写hashCodepublic class User { private String 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); } }然后执行HashSetUser set new HashSet(); set.add(new User(1, 张三)); set.contains(new User(1, 张三)); // 大概率返回false原因很直接HashSet判断元素是否存在先算hashCode定位到桶再用equals比较桶内元素。这里两个User对象的hashCode不同因为用的是Object默认的hashCode跟内存地址相关第一个和第二个落到不同桶里根本不会触发equals比较。结果就是内容相同但Set认为不存在。解决方式也简单用Objects.hash(id)重写hashCode让hashCode和equals的判定基准一致。这在IDE里通常一键生成但面试时经常要手写核心就是equals里用哪些字段hashCode里就用哪些字段。我见过不少线上Bug查到最后都是这一类低级问题非常值得重视。2.3 包装类、Math与System小知识点也有大坑基本类型和包装类之间的自动装箱拆箱日常写着舒服底下其实有坑。最经典的就是Integer缓存Integer默认缓存-128到127之间的值所以Integer a 100; Integer b 100; a b为true换成Integer a 128; Integer b 128; a b就是false了。这个详细分析放第7章这里先记住结论包装类型做比较一律用equals别用。Math类里有一个容易被忽略的坑Math.abs(Integer.MIN_VALUE)返回的还是负数因为Integer的范围是-2147483648到2147483647绝对值超出了最大值直接溢出回绕了。做金额、ID这类计算时如果依赖绝对值记得考虑溢出边界。System类最常用的其实就几个。System.currentTimeMillis()返回当前时间戳的毫秒值做耗时统计和过期时间判断都靠它。System.arraycopy是数组复制的高效底层实现ArrayList扩容时就用的它。至于System.gc()——我明确建议不要在业务代码里调用。让JVM自己决定什么时候回收比手动触发靠谱得多手动调gc通常只会让应用卡顿。其实java.lang包里还有一个容易被忽略但非常好用的类Objects。它提供了Objects.equals、Objects.hash、Objects.requireNonNull这一组静态方法能省掉大量手写空指针判断的代码。JDK 7以后加入平时写工具方法时多用它代码会干净很多。3. java.util集合框架先学会选型再谈背方法3.1 List、Set、Map怎么选一张表讲清判据集合框架是java.util包里最核心的部分。很多人背了一堆方法但遇到实际场景还是不会选。我一般用几个问题来逼自己判断允不允许重复要不要保持插入顺序要不要按某种规则排序单线程还是多线程先列一个最基础的对照表接口常用实现特点适合场景ListArrayList数组结构随机访问快尾部增删快按下标读取、末尾追加ListLinkedList双向链表头尾增删快随机访问慢频繁头插头删、队列场景SetHashSet哈希表无序去重快速判重SetLinkedHashSet哈希表链表去重且保持插入顺序需要有序去重的集合SetTreeSet红黑树自动排序需要有序且不允许重复MapHashMap哈希表无序key不可重复键值对快速查找MapLinkedHashMap哈希表链表保持插入顺序需要按插入顺序遍历的MapMapTreeMap红黑树按键排序需要按key有序遍历选型逻辑其实就三步。第一List还是Map数据是单个对象序列还是键-值的映射这一点最直接。第二要不要去重要就排除List进Set分支进一步问自己判重后要不要保持插入顺序要就LinkedHashSet要排序就TreeSet单纯去重就HashSet。第三Map需不需要有序遍历需要按key排序选TreeMap需要按插入顺序选LinkedHashMap其他一律HashMap。我说一个实际感受很多场景其实ArrayList和HashMap就够了80%的需求用这两兄弟能解决剩余20%才轮到TreeSet、LinkedHashMap这些。不要一上来就上最复杂的健壮性和可读性都要考虑。3.2 高频方法的底层逻辑HashMap扩容与ArrayList扩容背方法不如懂原理。两个集合的底层扩容机制是面试老熟人也是开发中性能问题的高发区。HashMap在JDK 8里的结构是数组加链表加红黑树。put的时候大致走这几步先用key的hashCode做扰动计算高16位与低16位异或再用哈希值和数组长度减一做按位与得到数组下标如果这个位置没有元素直接放入如果已有元素就用equals比较key相同则覆盖value不同则挂到链表后面链表长度超过8且数组容量达到64时链表转成红黑树把查询复杂度从O(n)降到O(log n)。扩容是HashMap最影响性能的操作之一。默认初始容量16加载因子0.75意思是元素个数超过16乘以0.75等于12这个阈值就会触发扩容容量加倍到32所有元素重新哈希并搬移。这个重新哈希再搬家的过程是O(n)的如果数据量很大扩容那一刻会明显卡顿。所以如果能预估数据量初始化时直接new HashMap(预期容量)而不是等它自己扩容能省很多事。ArrayList的原理类似内部是个Object数组初始容量是10。每次add时如果数组满了就扩容成原来的1.5倍oldCapacity (oldCapacity 1)然后通过Arrays.copyOf把老数据复制到新数组里。频繁add大量数据时如果已知大概数量初始化时直接指定容量new ArrayList(预计大小)也能减少扩容次数。这里我建议关注一个细节加载因子0.75是个权衡值。太高了节省空间但哈希冲突增多查询变慢太低了查询快但浪费空间。日常业务直接用默认值就行这是一个站在工程角度调优后的经验值不用刻意改。3.3 Collections和Arrays工具类每天都会用到的两个辅助类Collections是对集合的静态工具类Arrays是对数组的静态工具类。这俩的命名很对称用法也很有规律。Collections用得多的几个Collections.sort(List)对List排序底层其实调的是Arrays.sort然后赋值回ListCollections.reverse(List)反转顺序Collections.binarySearch(List, key)做二分查找注意前提是List必须已经排好序否则结果不对。还有一个很实用但容易忽略的Collections.unmodifiableList(list)返回一个只读的List包装修改时会抛UnsupportedOperationException。代码里如果不希望外部调用方改动某集合这是最简洁的保护手段。Arrays的常用方法里最推荐三个。Arrays.toString(int[])和Arrays.deepToString(Object[])能直接把数组内容打印出来调试的时候非常方便比手写循环拼接不知道强到哪里。Arrays.sort可以给数组排序支持传Comparator定制规则。Arrays.asList(T...)则可以把一组元素直接转成List。但是注意这个List有坑后面第7章会详细说。有一个从实战中得到的经验排序几乎天天用但Comparator写法很多人不熟。想按某个字段升序可以写Comparator.comparing(User::getAge)想倒序就加.reversed()它天然支持链式拼接代码可读性比匿名内部类强非常多。JDK 8之后能这么写就尽量这么写别再用old school的匿名类写法。4. 日期时间API从Date到LocalDateTime老代码迁移的细节值得注意4.1 旧日期API的三宗罪java.util.Date的年龄比Java本身都大当年设计得比较随意在今天看来有不少反直觉的设定。第一宗罪年份从1900开始计算。new Date(2024, 5, 1)并不是2024年6月1日而是3924年。第二宗罪月份从0开始。Calendar.JANUARY的值是0所以Calendar里设置月份的时候谁都可能没注意到二月其实是1。第三宗罪SimpleDateFormat线程不安全。它内部有个Calendar类型的共享字段多线程同时格式化时会出现数据错乱不少人就在生产环境见过格式化出来完全不对的时间字符串。这三宗罪每一个我都踩过或者给别人排过。特别是SimpleDateFormat网上教程一堆但没人提醒它是非线程安全的。如果你在项目里定义了static的SimpleDateFormat供全局共用又恰好有并发请求访问那恭喜你你获得了一个偶发bug。所以主题很明确老代码里还在用Date加Calendar加SimpleDateFormat的值得认真考虑迁到JDK 8的新API。新环境新项目直接不要再碰旧的这套了。4.2 LocalDateTime全家桶怎么用顺手JDK 8引入了java.time包设计思路是彻底改变日期和时间拆成不同类不可变线程安全。三个类搞清楚就够日常用。LocalDate只管年月日适合生日、排班这类场景LocalTime只管时分秒适合考勤、定时LocalDateTime是日期时间的组合用得最多。如果需要精确到带时区的时刻用OffsetDateTime或ZonedDateTime如果想取系统当前时间戳用Instant。常用方法基本都能猜出来now()取当前时间of(2024, 6, 1, 12, 30)手动指定plusDays(1)加一天minusMonths(2)减两个月isBefore和isAfter判断先后getYear、getMonthValue、getDayOfMonth取具体字段。格式化推荐DateTimeFormatter它和旧的SimpleDateFormat最大的区别是线程安全可以放心定义为static常量给所有人共用。定义一个自己的格式可以这样DateTimeFormatter fmt DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); LocalDateTime now LocalDateTime.now(); String str now.format(fmt); LocalDateTime parsed LocalDateTime.parse(2024-06-01 12:30:00, fmt);两个时间段相关的类也要提一下Duration用于秒和纳秒级别的时间差Period用于年月日级别的日期差。比如计算两个LocalDateTime之间的差用Duration.between(start, end)计算两个LocalDate之间的年月日差用Period.between。注意别用混了Duration只有时分秒单位Period只有年月日单位。4.3 新旧API互转的兼容细节老系统不可能一夜之间全换完新旧API必须能互相转换。这里的互转最容易出错的是时区问题。Date和Instant是直接对应的Date.toInstant()就能转成InstantDate.from(instant)反过来也行。LocalDateTime和Instant之间不能直接转必须先指定时区LocalDateTime.atZone(ZoneId.of(Asia/Shanghai)).toInstant()取回时间用instant.atZone(zoneId).toLocalDateTime()。这里的教训是转的时候如果漏了时区得到的时间大概率是不对劲的而且这种错很难肉眼发现。还有一个特别的java.sql.Date用于数据库日期它和LocalDate互转可以直接用java.sql.Date.valueOf(LocalDate)和Date.toLocalDate()。Java.sql.Timestamp对应LocalDateTime同样有valueOf和toLocalDateTime。经常用MyBatis之类框架的时候这些转换在底层自动发生但一旦需要手写SQL参数映射知道怎么显式转换会省很大力气。我的建议是项目里的时间字段能用LocalDateTime就用LocalDateTime数据库层面用datetime类型传输层面用标准ISO格式字符串跟外部系统交互时统一转换成Instant或带时区格式。这套规范定下来时间相关的坑基本能避开一大半。5. IO与NIO文件读写API的选择顺序和编码陷阱5.1 从File到Path日常路径操作用哪一套java.io.File是Java老牌的文件操作类能创建文件、判断存在性、遍历目录但有两个不舒服的地方很多方法返回boolean而不是抛异常出错时根本不知道失败原因大量重复的路径拼接代码很丑。JDK 7引入的java.nio.file.Path和Files在易用性上友好很多。Path表示路径常见创建方式是Paths.get(/tmp/data, log.txt)它会自动处理分隔符Windows和Linux的写法都能应对。把路径拼接这种事情变得很自然。Files是文件操作的终极工具类我天天都在用// 读取所有行 ListString lines Files.readAllLines(Paths.get(data.txt), StandardCharsets.UTF_8); // 写文件 Files.write(Paths.get(out.txt), lines, StandardCharsets.UTF_8); // 复制文件 Files.copy(Paths.get(a.txt), Paths.get(b.txt), StandardCopyOption.REPLACE_EXISTING); // 删除文件不存在时不报错 Files.deleteIfExists(Paths.get(old.txt));这套API最大的好处是方法名就是操作意图参数语义清晰异常处理规范统一而且大量方法支持可选参数比如COPY_ATTRIBUTES、ATOMIC_MOVE这些。我在新代码里已经完全不碰File类了File只出现在老项目的兼容层里。日常开发里Files.walk可以递归遍历目录Files.lines可以按流式方式读大文件配合try-with-resources使用逐行处理不会爆内存。这两个在写文件扫描、日志分析脚本时特别好用。5.2 字节流与字符流文本文件该走哪条路Java的IO流分两大体系字节流(InputStream/OutputStream)处理二进制数据字符流(Reader/Writer)处理文本数据。选错会造成乱码选对了才能正确读写。一个反复被问的问题读文本文件时到底用FileInputStream还是FileReader我的回答是如果只是按行读纯文本首选Files.newBufferedReader其次是InputStreamReader配合BufferedReader最后才是裸的FileReader。因为FileReader在JDK 8及更早版本里默认使用平台字符集在不同操作系统上读同一个文件结果可能完全不同。更推荐的做法是显式指定编码。新版JDKJava 18开始默认文件编码变成UTF-8改善了这些历史问题但在涉及跨环境部署的旧项目中还是养成读写时永远显式传StandardCharsets.UTF_8的习惯更稳妥。一个标准的读文件写法try (BufferedReader reader Files.newBufferedReader(Paths.get(data.txt), StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { // 处理每一行 } }注意try-with-resources这样读完之后流会自动关闭不用手动在finally里写close代码干净也不容易漏。老写法里那些InputStream、Reader嵌套好几层再手动close的写法能简化就简化。写文件也类似Files.newBufferedWriter配合UTF_8即可。千万不要用new FileWriter(path)不指定编码的写法它在跨平台场景下迟早给你造一个乱码Bug。5.3 乱码问题大多数情况下是编码没统一乱码的根源永远只有一句话文件实际编码和程序读文件时用的编码不一致。数据本身没有丢只是字节被按错误的编码表解读了。解决乱码只要做两步排查。第一步确认文件本身是什么编码。Linux下用file命令Windows的好编辑器右下角一般会显示编码格式。第二步确认程序读取时指定的是不是同一编码。如果两边都统一成UTF-8乱码自动消失。有一个实践中经常遇到的场景旧的业务系统导出了GBK编码的CSV文件新程序用UTF-8去读中文全变成锟斤拷这是GBK字节被按UTF-8读的经典产物。很多人第一反应是数据坏了其实文件没坏。正确做法是用GBK读出来再用UTF-8输出try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(old.csv), Charset.forName(GBK))); BufferedWriter writer Files.newBufferedWriter(Paths.get(new.csv), StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { writer.write(line); writer.newLine(); } }这种转换在数据迁移场景里非常常见。你只需要记住一个原则输入流和输出流分别指定正确的编码数据到内存里都已经是String了不会乱乱只乱在字节和字符转换的一瞬间。6. 并发编程API线程池、锁与并发容器的高频用法6.1 线程池参数为什么推荐手动创建ThreadPoolExecutor先给一个明确的结论业务代码里不要直接new Thread。每次new Thread都会创建操作系统线程线程创建和销毁是有开销的而且线程数量不可控一旦请求量上来线程数爆炸机器就先倒下了。正确做法是用线程池复用线程。但很多教程推荐的Executors.newFixedThreadPool(10)有一点隐患它的任务队列默认是无界的LinkedBlockingQueue。如果任务积压队列会无限增长最终结果是内存被任务对象撑爆。所以我个人更推荐手动创建ThreadPoolExecutor把参数写明白ThreadPoolExecutor pool new ThreadPoolExecutor( 5, // corePoolSize核心线程数 10, // maximumPoolSize最大线程数 60L, TimeUnit.SECONDS, // 非核心线程的空闲存活时间 new ArrayBlockingQueue(100), // 任务队列有界 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );七个参数怎么理解想象一个银行柜台核心线程数是固定柜台窗口最大线程数是能临时加开的窗口数量任务队列是等候区的座位拒绝策略是座位满、窗口也全部开满时还有人来该怎么处理。CallerRunsPolicy的意思是让提交任务的线程自己跑这个任务起到天然限流的作用。AbortPolicy则直接抛异常这是默认策略而DiscardPolicy是悄悄丢弃一般不建议。开发中还有一条经验线程池的线程名要可辨识。用DefaultThreadFactory创建的线程名统一是pool-1-thread-1排查问题时非常难受。建议自己实现ThreadFactory给线程起个业务名。有了可辨识的名字线上日志一翻就知道是哪个业务线程在跑。6.2 synchronized与Lock怎么取舍锁的选择是并发编程里一个绕不开的问题。synchronized是JVM层面的关键字用起来最简单修饰方法或者用代码块缩小锁定范围。Lock是java.util.concurrent.locks包下的接口最常用的是ReentrantLock。我选型的标准是简单同步用synchronized需要复杂控制用Lock。什么算复杂控制比如获取锁时不想无限等待用tryLock(2, TimeUnit.SECONDS)在限定时间内拿不到锁就放弃比如需要多个条件变量ReentrantLock的newCondition能精确唤醒某一类线程synchronized只能配合wait/notify做粗粒度唤醒比如想要公平锁ReentrantLock构造时传truesynchronized是不支持公平性的。还有一点容易被忽略synchronized在异常时自动释放锁Lock需要程序员在finally里手动unlock。Lock写起来啰嗦但换来的是更灵活的控制力。有一段代码可以当成模板ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }记住unlock必须放在finally里否则一旦临界区抛异常锁永远不释放后续线程全部卡死。这个Bug我在线上排查遇到过现象是接口突然全部超时日志里线程全部BLOCKED一看就是lock没释放。6.3 并发容器与CompletableFuture的实战组合HashMap在多线程环境下是不安全的这点几乎人人都知道。并发场景的替换方案首选ConcurrentHashMap。它的设计比Hashtable先进很多内部通过CAS加synchronized锁住单个桶位JDK 8之后读操作通常无锁所以并发读性能很好而且不允许多线程直接改它的时候抛ConcurrentModificationException。读多写少的场景还有一个选择CopyOnWriteArrayList。它每次修改都复制底层数组所以读操作完全不用加锁适合配置项、白名单这种改了重启也不心疼、读频率极高的场景。生产者消费者模型里BlockingQueue几乎是标配。它是线程安全的队列put往队列里放元素时会阻塞直到有空间take取元素时会阻塞直到队列非空。这种阻塞能力天然实现了生产者消费者之间的速率匹配不需要额外写wait/notify。再往上升一级JDK 8的CompletableFuture非常适合做异步编排。它把多个异步任务的串行、并行、异常处理都变成了链式调用CompletableFuture.supplyAsync(() - queryData(user1)) .thenApply(data - enrich(data)) .thenAccept(result - System.out.println(result)) .exceptionally(ex - { System.out.println(出错 ex.getMessage()); return null; });supplyAsync里干耗时间的活thenApply做数据处理thenAccept消费结果exceptionally兜底异常。代码最终被读成一个流程可读性比一堆回调嵌套强太多了。异步编程不是越绕越深而是尽量让代码看起来像同步流程。7. 那些年我们一起踩过的API坑经典面试题与实战避坑清单7.1 经典面试题String的和equals结果分析这道题简直是Java面试的入场券并且可以定一个百试百灵的结论除非你能确认两个变量指向的是同一个对象比如同一个字面量赋值否则比较字符串一律用equals。底下这段代码是我面试别人时几乎必问的String s1 hello; String s2 hello; String s3 new String(hello); System.out.println(s1 s2); // true都指向常量池中的同一个对象 System.out.println(s1 s3); // falsenew 在堆上新建了一个对象 System.out.println(s1.equals(s3)); // trueequals 比较的是内容原理上Java在类加载和运行期间会维护一个字符串常量池字面量hello每次出现都复用同一个对象。s1和s2因此是同一个引用。而new String(hello)每次都在堆上新建对象内容和常量池里的相同但引用不同。进一步问Intern方法s3.intern()返回常量池中的引用如果常量池里没有内容相同的字符串则把它放入常量池再返回。所以String s4 s3.intern()之后s1 s4也是true。实际业务里这个知识点用来排查真实问题的机会不多但理解它有助于理解Java内存模型。真正要说的是永远别指望String的来比较内容。一次GroupBy、一次Map合并只要用了翻车概率直接拉满。7.2 Integer的比较缓存机制让多少人翻过车面试里还有一个几乎必出的坑包装类的比较。先看代码Integer x 127; Integer y 127; System.out.println(x y); // true Integer a 128; Integer b 128; System.out.println(a b); // false同一个代码模式结果却相反原因在于Integer的内部缓存。JVM在启动时会缓存-128到127的Integer对象这个范围的值直接返回缓存对象所以127的比较是同一个引用。128超出缓存范围自动装箱时每次new一个新的Integer对象比较的就是不同引用自然false。更隐蔽的操作是Integer a 128; int b 128; a b。这种包装类和基本类型比较时会发生拆箱操作a自动拆成int值所以结果是true。很多人以为这个true说明包装类和基本类型没问题但实际上它拆箱了和前面的情况根本不是一回事。这个坑的实战危害在于拿Integer做Map.get返回值和某个变量用比较数据量小的时候全对一旦数值超过127就开始诡异出错。而且这种Bug通常不是必现的特别难定位。我个人的铁律只要一方的类型是包装类比较就写.equals或者先对包装类做null判断再拆箱。只有双方都是基本类型时才放心用。7.3 遍历删除与Arrays.asList的隐藏限制集合操作里有两类限制容易被忽视一个是遍历时删除元素一个是Arrays.asList生成的假List。先看遍历删除的经典写法ListString list new ArrayList(List.of(a, b, c, b)); for (String s : list) { if (b.equals(s)) { list.remove(s); // 抛 ConcurrentModificationException } }这个异常的原因在于ArrayList的迭代器维护了一个expectedModCount用来和集合的modCount比对。任何结构性修改add、remove都会让modCount加1。foreach本质是用迭代器访问元素迭代过程中调list.remove会修改modCount迭代器在next时发现expectedModCount和modCount不一致立刻抛异常防止遍历结果不确定。正确的删除方式是使用Iterator自身的remove或者直接用removeIf。JDK 8之后我都是用一行解决list.removeIf(s - b.equals(s));再说Arrays.asList。Arrays.asList(a, b, c)返回的不是java.util.ArrayList而是Arrays内部的一个私有List底层直接引用原数组长度固定。对它调用add或remove会直接抛UnsupportedOperationException。如果只是想转成真正的ArrayList包装一层即可new ArrayList(Arrays.asList(...))。还有一个小坑如果对基本类型数组做Arrays.asList比如Arrays.asList(1,2,3)因为泛型不能是基本类型int[]会被当成一个整体对象得到的List长度为1元素是整个数组。想正确转基本类型数组在JDK 8之后可以直接用StreamArrays.stream(new int[]{1,2,3}).boxed().collect(Collectors.toList())。7.4 我的一个排查经历线上OOM背后竟是API用法问题最后分享一个实际遇到过的线上问题它把前面好几类API坑全串起来了。曾经有个服务每天固定时段出现响应变慢然后OOM多方排查都怀疑框架层有问题但加日志细看后发现堆栈里的高频对象是字符数组。追下去定位到一段历史代码读取一个几十MB级别的文件里面逐行处理每行都做字符串拼接并且用的是第2章里说的循环加字符串加号写法。一次请求要创建几十万次中间字符串而且这些数组引用被长期保存在一个HashMap缓存里导致GC完全回收不掉。表面上是内存不够本质上是API的误用。修复动作很简单字符串拼接改用StringBuilder文件读取改成Files.lines流式处理缓存容器的value改成不可变对象并设了容量上限。改动很小问题彻底消失。这个案例给我的触动很大多数线上疑难杂症并不是框架缺陷而是API层面用错了。Java的API设计都是合理的但用错了位置、用错了方式代价会被放大成线上事故。这也是为什么我坚持把常用API的规范用法看得特别重——它不是八股文是真能救命的。最后再分享一个我个人的习惯这份清单每次翻都能翻出新问题因为在不同阶段看同一段API理解深度完全不同。我建议你也动手整理一份自己的API速查笔记把踩过的坑按包填进去做成我用错过的API清单。这东西比收藏一万篇别人的总结都管用。