1. 集合框架的实操纠偏:今天才真正搞懂 ArrayList 和 HashMap 的脾性
Java 学到第 7 天,正好处在一个神奇的分水岭:语法基础基本过了一遍,能写点小玩意儿,可一旦去碰集合、线程、Redis 这类偏实战的东西,各种离谱问题就开始冒头了。今天本来计划是复习集合框架,结果一个 Redis 报错和一个线程等待需求直接把我拉进了实战泥潭——现在回头看,这一天收获反而比前六天加起来都大。
先说集合这一块。之前跟着教程敲的时候,满脑子只有一个念头:容器嘛,ArrayList 装东西,HashMap 存键值对,用就完事了。但今天动手做一个小工具,需要按条件过滤一批用户数据,我才发现集合不只是“往里塞、往外取”这么简单。选错容器、用错遍历方式,轻则代码丑得没法看,重则直接抛 ConcurrentModificationException,半天定位不到原因。
1.1 集合选型:别什么都往 ArrayList 里塞
我今天的场景是这样的:有一批订单记录,要求按金额大小排序后取前 10 个。第一反应是用 ArrayList 存下来,再调 Collections.sort() 排序。这个思路没错,但如果数据是“频繁插入删除”的,ArrayList 的底层数组就得反复搬运元素,效率差得明显。换成 LinkedList 会好一些,但如果是随机访问,LinkedList 又比 ArrayList 慢。
| 集合类型 | 底层结构 | 插入/删除 | 随机访问 | 适用场景 |
|---|---|---|---|---|
| ArrayList | 动态数组 | 尾部快,中间慢 | 极快 | 查询多、尾部追加多 |
| LinkedList | 双向链表 | 两端快,中间慢 | 慢 | 频繁头尾操作 |
| HashMap | 哈希表 + 链表/红黑树 | 平均 O(1) | 按键 O(1) | 键值映射、快速查找 |
| TreeMap | 红黑树 | O(log n) | O(log n) | 按键有序遍历 |
我的场景里,订单数据是多次追加进入的,最后查询频率高,所以 ArrayList + 排序是合理的。但我在插入时没有预估容量,导致数组多次扩容,一定程度上拖慢了性能。正确做法是能估算大小时就直接指定初始容量:
List<Order> orderList = new ArrayList<>(expectedSize);这个细节平时没人提,等到数据量上万的时候才有体感。今天用 10 万条订单测试,预分配容量比不预分配快了将近三分之一,扩容操作真的很伤。
1.2 遍历删除大坑:for-each 删除元素为什么会报 ConcurrentModificationException
然后我踩了一个非常经典的坑:想从集合里删掉所有金额为 0 的订单,很自然地写了 for-each 循环,里面调 list.remove(),一跑就报 ConcurrentModificationException。很多人第一次遇到这个异常会直接懵掉,明明只有一个线程,哪来的“并发修改”?
这其实是 Java 集合里的 fail-fast 机制在起作用。for-each 遍历时用的迭代器会维护一个 modCount 字段,每次集合结构发生修改(add/remove)时 modCount 都会变。迭代器在遍历前记录期望的 modCount,遍历过程中发现值对不上,就立刻抛异常,防止你在遍历时修改集合导致数据错乱。为什么防止?因为 ArrayList 的 remove 会移动元素,遍历到的下一个位置可能已经被跳过或重复,结果不可预期。
正确写法是用显式的 Iterator,并调用 iterator.remove():
Iterator<Order> iterator = orderList.iterator(); while (iterator.hasNext()) { Order order = iterator.next(); if (order.getAmount() == 0) { iterator.remove(); } }等一下,为什么 iterator.remove() 就安全?因为这个方法内部会同步更新 itr 的 expectedModCount,让迭代器自己感知到这次修改是合法的。这个细节我今天才算真正明白,之前只是机械地“背答案”。
如果是在 Java 8 以上,还可以直接用 Collection.removeIf(),一行搞定:
orderList.removeIf(order -> order.getAmount() == 0);2. RedisTemplate 踩坑实录:increment() 抛出“not integer or out of range”的完整排查过程
今天最大的时间黑洞,是一个不算难但非常折磨人的 Redis 报错。起因很简单:需要用 Redis 维护一个计数器,每来一条订单就把某个 key 加 1。我用的 Spring Boot + RedisTemplate,代码就这一行:
redisTemplate.opsForValue().increment("order:count:today");结果一跑,控制台直接甩出来一行错误,前面还带着一大串堆栈信息。核心内容我截了出来:
JedisDataException: ERR value is not an integer or out of range2.1 报错现场与复现方式
我第一反应是“这 key 里的值可能不是数字”。但仔细一想不对,这个 key 是第一次使用,以前从来没人往里写过东西啊,为什么 Redis 告诉我不是整数?
为了复现,我写了这么一段测试代码:
redisTemplate.opsForValue().set("order:count:today", "order_001"); redisTemplate.opsForValue().increment("order:count:today");结果第二次调用 increment() 时同样报错。原因一下就清楚了:这个 key 被写入过一个字符串类型的值,Redis 底层存储的是字符串,而 INCR 命令要求值是“可以被解析为十进制 64 位整数”的字符串。order_001显然不符合要求,于是报出 ERR value is not an integer or out of range。
问题又来了:我在哪里写入过字符串?排查后发现,是有一次调试时用redisTemplate.opsForValue().set("order:count:today", "order_001")存了一个样本数据,后来忘了清理。这种“自己埋雷自己踩”的坑,在开发环境里太常见了。
2.2 根因剖析:Redis 的值存储模型决定了 increment 的合法性
这里要理解一个底层事实:Redis 的所有 value 在底层都是字节数组,所谓的整数类型只是 Redis 对字符串的一种“解释方式”。当你执行 INCR 命令时,Redis 会把 value 解析成 long 类型,然后加 1,再存回去。如果解析不了,就抛错。
另外还要注意一个隐藏边界:64 位有符号整数的范围是-9223372036854775808到9223372036854775807。如果 key 当前值已经接近这个上限,再执行 increment() 也会报 out of range。一般的业务计数器不会触达这个边界,但如果要做一个全局自增序列,还是要心里有数。
| 场景 | 报错原因 | 解决方式 |
|---|---|---|
| key 中存了非数字字符串 | 无法解析成整数 | 删除该 key,或修正数据 |
| key 中存的是小数(2.5) | 不是整数 | 业务上用整数存储,或用 DECR/INCR 的替代方案 |
| 值超过 long 范围 | 溢出 | 改用 String 类型存储并自己处理加法逻辑 |
| 键不存在 | 按 0 处理 | 不会报错,直接返回 1 |
2.3 解决办法:清理脏数据 + 防止再踩
定位到原因后,解决办法就很直接了。因为确认这个 key 是可以直接重建的,我执行了删除操作:
redisTemplate.delete("order:count:today");再跑 increment(),返回 1,问题解决。为了以后不再踩同样的坑,我做了一个约定:计数类 key 统一加前缀counter:,与业务数据 key 的命名空间隔离;写入数据时严格检查 value 类型;如果是用redis-cli手动调试过的 key,要及时清理。
这里还有一个调试小技巧:当报错信息看着莫名其妙的时候,优先去 Redis 里看看这个 key 到底是什么类型:
TYPE order:count:todayTYPE 命令会直接告诉你这个 key 的 value 类型。如果返回的是string,再直接 GET 看一下内容,多半就是脏数据在捣乱。这种“先看数据,再查代码”的排查顺序,能省下大量时间。
3. 多线程协作入门:让多个线程跑完之后再汇总结果
Redis 的坑填完之后,我接着处理一个多线程需求:有一个统计任务,要并行从 3 个数据源拉数据,等全部拉完后统一汇总。Java 里的多线程协作方式有很多种,今天选用的是CountDownLatch。
3.1 CountDownLatch 的核心逻辑:数数,数到 0 再放行
CountDownLatch 的用法可以用一个场景来类比:一个教练等 10 个运动员跑完步,等到所有人都冲线了,教练才开始总结训话。每个运动员冲线后喊一声“我到了”,计数器减 1,教练一直在等计数器归零。
int threadCount = 3; CountDownLatch latch = new CountDownLatch(threadCount); ExecutorService executor = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { int taskId = i; executor.submit(() -> { try { // 模拟耗时操作 Thread.sleep(1000); System.out.println("task " + taskId + " completed"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } // 主线程等待所有任务完成 latch.await(); System.out.println("all tasks completed, start to merge result");关键点在于countDown()必须放在 finally 里。如果某个任务抛出异常导致 countDown() 没执行,count 永远不会归零,主线程就会一直 await() 卡死。这是我特别提醒自己的一点,也是实践中最容易忽略的健壮性问题。
await() 方法还有一个重载版本await(long timeout, TimeUnit unit),可以设置最长等待时间,避免因为某个任务卡住导致整个程序永远阻塞。在真实项目中,我几乎都会用带超时的版本。
3.2 CountDownLatch 和 Future.get() 的区别
写着写着我发现,如果用 ExecutorService 提交任务,完全可以用 Future.get() 来等待结果,那 CountDownLatch 是不是多余了?这两者的定位不一样:
CountDownLatch是“事件协作机制”,它关心的是任务是否完成,不关心任务的返回结果。Future.get()是“结果获取机制”,它既会等待任务完成,也能拿到任务返回值。
如果只需要等所有线程执行完,不需要返回结果,用 CountDownLatch 更合适,代码意图也更清晰。如果需要收集每个任务的返回值,用 Future 更自然。今天这个统计场景刚好两者都要用:每个任务返回一个统计数字,最终合并。所以我最终的写法是 submit 返回 Future 列表,再逐个 get():
List<Future<Integer>> futures = new ArrayList<>(); for (int i = 0; i < threadCount; i++) { futures.add(executor.submit(() -> { // 模拟数据拉取 return 100; })); } int total = 0; for (Future<Integer> future : futures) { total += future.get(); } System.out.println("total: " + total);这里要注意,future.get() 是阻塞的。多个 future 按顺序 get() 时,如果第一任务特别慢,后面已经完成的任务结果也只能干等着。优化做法是使用ExecutorCompletionService,谁先完成就先取谁的结果,不过那是后话了,今天先不在第 7 天的进度里塞太多。
另外,线程池用完要记得 shutdown(),不复用线程池的话,不执行 shutdown 会导致 JVM 进程迟迟不退出:
executor.shutdown();4. 排序算法写到背下来为止:冒泡排序与快速排序的对比
作为一个 Java 学习者,“算法题”是绕不过去的坎。今天热搜上看到一堆排序相关的词,我就把冒泡排序和快速排序又手写了一遍。写是真的能写出来,但“能写出来”和“理解透彻”是两回事。
4.1 冒泡排序:最简单也最容易写翻车的写法
冒泡排序的思路是每一轮把相邻的两个元素比较,如果顺序不对就交换位置,这样每轮结束后,最大的元素会“冒泡”到数组末尾。核心代码:
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)。
4.2 快速排序:核心是分区逻辑
快速排序的思路是选一个基准值 pivot,把数组分为小于 pivot 和大于 pivot 两部分,再递归地对两部分排序。实现方式有很多种,我习惯写 Lomuto 分区:
public static void quickSort(int[] arr, int low, int high) { if (low < high) { int pivotIndex = partition(arr, low, high); quickSort(arr, low, pivotIndex - 1); quickSort(arr, pivotIndex + 1, high); } } private static int partition(int[] arr, int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; swap(arr, i, j); } } swap(arr, i + 1, high); return i + 1; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }这里最需要理解的是 partition 函数的“不变量”:循环结束后,i + 1位置是 pivot 的最终位置,左侧都小于 pivot,右侧都大于等于 pivot。如果没理解这个不变量,递归的边界就很容易写错,要么死循环,要么漏排元素。
今天我用一个[5, 2, 9, 1, 5, 6]数组手动模拟了一遍执行过程,费了很大劲才彻底搞明白为什么 partition 里要先把 i 加 1,再交换。因为你是在“把小于 pivot 的元素往左移”,i 代表的是“已处理的小于 pivot 元素的最后一个位置”,每次遇到一个小于 pivot 的元素,就得把 i 向后挪一位来容纳它。
4.3 练手中的直观性能感受
我用随机生成的 10 万条整数分别跑了一下两个排序算法,冒泡排序的时间大概是秒级到十几秒,快速排序几乎瞬间完成。这个结果完全符合预期,也让我对时间复杂度从“书本上的概念”变成了“手上的体感”。
| 算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) |
快速排序最坏的情况是数组已经有序且每次都取到最大或最小元素作为 pivot,退化成 O(n²)。虽然概率不大,但知道它的存在很重要。主流工程里会用随机化挑选 pivot 或三数取中来规避。
5. 今日心得与一些小技巧:从多行字符串到 lambda 写法的实战体会
除了上面这些比较“硬核”的内容,今天还顺手接触了几个平时写代码经常用到的技巧,记录一下。
5.1 lambda 表达式在集合操作里的应用
以前总觉得 lambda 表达式就是“匿名内部类的简化写法”,今天在写 removeIf 和 stream 操作时才发现它远不止语法糖。比如我想从订单列表里取出金额大于 100 的所有订单 ID,用 stream + lambda 可以非常简洁地表达:
List<Integer> ids = orderList.stream() .filter(order -> order.getAmount() > 100) .map(Order::getId) .collect(Collectors.toList());不用 lambda 的时候,得写一大堆循环和临时变量。lambda 的核心价值不是少写几行字,而是让你把注意力集中在“要做什么”而不是“怎么做”,配合 stream 可以形成非常流畅的管道式表达。
不过今天也踩了一个小坑:lambda 表达式里引用的局部变量必须是 effectively final(实际不可变)。我一开始在图省事,对循环外面一个计数变量做count++,编译直接报错。查了一下原因是:lambda 捕获的局部变量如果有变化,Java 无法保证它在并发场景下可见性和一致性,干脆强制要求不可变。这也是为什么一些大循环里用循环变量做 lambda 体内部计算时会报错的原因。
5.2 Java 多行字符串写法的 3 种姿势
今天在拼接一段多行 SQL 时,被字符串的换行问题恶心到了。Java 8 里没有原生的多行字符串,常用的做法是字符串拼接加\n,可读性很差:
String sql = "SELECT id, name, amount\n" + "FROM orders\n" + "WHERE amount > 100\n" + "ORDER BY amount DESC";还有一种用 String.join():
String sql = String.join("\n", "SELECT id, name, amount", "FROM orders", "WHERE amount > 100");如果用的是 Java 13 以上的版本,直接支持文本块,用三个双引号包起来,保留原格式:
String sql = """ SELECT id, name, amount FROM orders WHERE amount > 100 ORDER BY amount DESC """;文本块会自动去除每行前面公共的缩进,这个特性对写 SQL、JSON、HTML 非常有帮助。我第一次用的时候差点被它“吃掉前导空格”的行为坑到,但搞清楚它是按最小缩进来计算的,就好理解了。
5.3 明日学习计划的调整建议
今天的过程让我意识到,光看教程的效率远远低于“带着问题去查资料”。如果明天让我继续规划,我会把重点放在几个方向上:一是把今天遇到的所有报错整理成一篇笔记,包括报错信息、触发场景、解决步骤;二是用集合框架去改写一个真实的小项目里的数据处理逻辑,加深对 API 的熟练度;三是开始接触 Java 动态代理和设计模式,因为这几个概念在面试里出现的频率很高。
另外,如果学完基础而不知道下一步该怎么走,我建议先别急着学框架。把 Java 基础(集合、线程、异常、IO、反射)扎扎实实过一遍,再进入 Spring 这条路,会顺很多。基础不牢,后面框架里的各种回调、代理、AOP 会让人一头雾水。
今天的最深体会是:学 Java 不是背 API 名录,而是要亲手把代码写出来、把报错踩一遍、把原理查明白。那些让人抓狂的报错,才是真正教会你东西的老师。