
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 排序是合理的。但我在插入时没有预估容量导致数组多次扩容一定程度上拖慢了性能。正确做法是能估算大小时就直接指定初始容量ListOrder 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()IteratorOrder 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 处理不会报错直接返回 12.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()ListFutureInteger futures new ArrayList(); for (int i 0; i threadCount; i) { futures.add(executor.submit(() - { // 模拟数据拉取 return 100; })); } int total 0; for (FutureInteger 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 可以非常简洁地表达ListInteger 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 名录而是要亲手把代码写出来、把报错踩一遍、把原理查明白。那些让人抓狂的报错才是真正教会你东西的老师。