ARTICLE DETAIL

资讯详情

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

Java数组筛选偶数并变换:从for循环到Stream API的完整实践

Java数组筛选偶数并变换:从for循环到Stream API的完整实践 最近在带一个刚入门的同事写Java练习遇到一道特别经典的题目从一个整数数组里筛出所有偶数并把每个偶数乘以2。题目本身不难但顺着这道题往下聊我发现数组筛选与数值变换这件事几乎串起了Java日常开发里一半的基础知识点遍历、取模、动态扩容、Stream的懒执行、装箱拆箱的性能损耗甚至连面试里最爱问的数组和集合怎么选都能从这里引出。这篇文章我就以这道题为主线把偶数提取与数值变换的完整链路掰开揉碎讲一遍包括几种写法的取舍、实测下来的性能差异以及我在工程代码里踩过的坑。1. 筛偶数这道题的背后数组遍历与边界意识1.1 需求拆解一次看似简单的遍历题目很直白给一个int[]数组把里面的偶数提取出来再对提取到的每个偶数做一次数值变换。不同版本的题目变换规则不同常见的有乘2、平方、加偏移量但内核完全一致——先过滤再映射。很多新手拿到题第一反应是这不简单吗然后啪地写一个循环加if判断就完了。确实从功能实现的角度这题五步之内能写完。但我带人的时候从来不让同事直接写代码先让他在纸上回答三个问题筛出来的结果放哪里是新建数组还是用集合如果原数组里有0、负数甚至null引用对象数组场景算不算偶数要不要保留原数组筛完之后还要不要用如果要用原来的下标对应关系怎么办这三个问题问完十个人里大概有七个会开始犹豫。犹豫是好事说明开始思考了。这道题真正的考点从来不是会不会写for循环而是你有没有养成处理边界条件的习惯。数组这个数据结构最朴素的特性就是定长、连续、按下标访问所有花活都是围绕这三个特性展开的而边界意识恰恰是使用定长结构时最核心的能力。1.2 取模运算的细节偶数的定义与负数的坑判断偶数最直接的写法是i % 2 0这在正整数场景下完全正确但一旦出现负数事情就没那么简单了。Java里取模运算的结果符号与被除数左边操作数一致。所以-4 % 2结果是0没问题-3 % 2结果是-1也不是0。从这个角度看负数场景下用% 2 0依然能正确判断偶数。真正容易翻车的是下面这种写法// 错误示范用位运算判断时会忽略符号位 if ((num 1) 0) { ... }这段代码在正整数下等价于% 2 0但对负数就会出问题因为负数的二进制补码最低位可能是1也可能是0直接用num 1判断得到的结果和数学定义并不一致。如果真想用位运算得写成(num 1) 0配合对负数取绝对值或者干脆老老实实num % 2 0。另一个容易被忽略的点是0。0除以任何非零数余数都是0所以0会被正确筛为偶数。但在业务语义上0有时候并不想被当成一个有效偶数处理。我见过不少同事在金融类代码里筛偶数结果把0也算进去导致后续金额计算出现诡异的结果。所以动手前先确认业务定义到底是能被2整除的整数还是大于0的偶数这两者在代码里就差一个条件但产生的数据结果完全不同。注意凡涉及金额、数量、序号这类有业务含义的数值筛选前务必先确认业务规则别拿数学定义硬套业务场景。2. 三条实现路线for循环、增强for与Stream API2.1 传统for循环可控性最强的写法先说最基础的写法也是我认为覆盖场景最广的一种。核心思路分两步先遍历一遍原数组数出偶数个数再创建目标数组并二次遍历填入数据。public static int[] extractEvens(int[] source) { if (source null) { return new int[0]; } // 第一遍遍历统计偶数个数确定目标数组长度 int count 0; for (int num : source) { if (num % 2 0) { count; } } // 创建精确长度的目标数组 int[] result new int[count]; int index 0; // 第二遍遍历填入偶数 for (int num : source) { if (num % 2 0) { result[index] num * 2; // 数值变换直接做在这里 } } return result; }这段代码看起来有点啰嗦两遍遍历在数据量小的时候完全无所谓但它有个非常实在的好处目标数组长度精确不多占一个格子也不需要任何动态扩容机制。数组是定长的既然结果个数在筛选前不可预知那就先数一遍。这种先统计、后分配的思路在整个Java领域都很常见比如StringBuilder的扩容策略、ArrayList的ensureCapacity本质上都是尽可能减少不必要的空间浪费和重复拷贝。如果你觉得两遍遍历太麻烦也有折中方案先创建一个和原数组等长的临时数组填完之后用Arrays.copyOf截断。这在代码简洁性上更优代价是可能会创建一个很大的临时数组。public static int[] extractEvensWithCopy(int[] source) { int[] temp new int[source.length]; int index 0; for (int num : source) { if (num % 2 0) { temp[index] num * 2; } } return Arrays.copyOf(temp, index); }Arrays.copyOf底层调用的是System.arraycopy这个native方法在截断场景下效率很高。这个方案最大的优点是不需要提前统计个数适合那种原数组很大但偶数很少的场景——提前统计的一遍遍历成本可能反而比多分配一个等长数组更高。当然这只是微观层面的优化真实项目里两者差异通常可以忽略选哪种取决于你更在乎代码可读性还是极致的内存占用。2.2 增强for与ArrayList动态扩容的代价初学者最喜欢用ArrayList来筛因为不需要提前知道结果个数list.add往里塞就行public static ListInteger extractEvensToList(int[] source) { ListInteger result new ArrayList(); for (int num : source) { if (num % 2 0) { result.add(num * 2); } } return result; }代码很清爽但代价藏在ArrayList的内部机制里。ArrayList底层是一个Object[]数组初始容量是10当元素个数超过容量时会触发扩容新容量大约是旧容量的1.5倍并且需要把旧数组的元素整体拷贝到新数组。如果你的原数组有10万个元素且全是偶数ArrayList会经历多次扩容和拷贝虽然均摊下来时间复杂度依然是O(n)但实际的数组拷贝次数和内存分配次数明显多于直接创建精确长度的数组。有人会说那我初始化时给个容量不就行了确实可以比如new ArrayList(source.length / 2 1)但这就等于又回到了提前估算个数的老路上而且估算永远可能不准。所以我会跟同事说如果后续操作离不开List比如要频繁增删、要用Collections工具类那就放心用ArrayList别纠结那几次扩容如果最终结果就是要一个数组那直接走数组操作更干脆。选型不是比谁更高级而是看整条数据处理链路里谁的总成本最低。2.3 Stream API一行代码背后的执行逻辑Java 8之后这个问题有了更优雅的解法public static int[] extractEvensByStream(int[] source) { return Arrays.stream(source) .filter(num - num % 2 0) .map(num - num * 2) .toArray(); }这一串链式调用每个方法名都直白到不需要注释。但用stream的时候必须理解它背后是惰性求值的filter和map被调用时并不会立刻遍历数组它们只是被装配到一条操作流水线上直到遇到终止操作toArray整条流水线才会真正启动。这就带来一个很有意思的特性筛选和变换是交错执行的而不是先完整筛完再统一变换。比如第一个元素是奇数filter就会直接放行map根本不会被触发第一个元素是偶数filter放行后map立刻执行乘2。这种逐元素流水线的好处是内存占用很小不需要暂存中间结果。坏处是如果你在filter或者map里写了打印日志、副作用操作调试时看到的执行顺序可能和直觉不一样——它真的是边筛边变的。另外要注意Arrays.stream(int[])得到的是IntStream这是专门为原始类型int设计的流避免了装箱。但如果你的数据源是Integer[]那Arrays.stream得到的就是StreamInteger后续的map、filter操作全部在包装类型上执行性能和下面要说的装箱问题就都来了。3. 数值变换的完整链路提取、映射与统计3.1 变换场景一提取偶数并放大/平方数值变换最常见的是对筛选结果做数学运算。上面的例子里用了乘2实际业务中还有平方、加固定偏移、取绝对值、归一化等。一个典型场景是把提取的偶数按从小到大排序后平方返回public static int[] transformEvenSquares(int[] source) { return Arrays.stream(source) .filter(num - num % 2 0) .sorted() .map(num - num * num) .toArray(); }这里的sorted()对IntStream来说是一个有状态的中转操作它会等到所有元素流过filter之后在内部完成排序再开始输出。也就是说链路上filter和sorted之间其实存在一个隐性的缓冲区这个缓冲区的大小取决于通过filter的元素数量。如果你误以为这种流水线永远不占额外内存那遇到sorted这类有状态操作时就会对内存使用产生误判。还有一个我自己犯过的错误拿map做类型转换。比如从int变成了long如果继续用toArray()返回的还是int[]超出int范围的数值会直接溢出。正确做法是mapToLong后用toArray返回long[]或者用collect收集到ListLong。很多看起来神秘的数值错误追根溯源都是类型转换环节没做对。3.2 变换场景二筛选后的聚合统计筛选加变换之后往往跟着聚合统计汇总所有偶数的和、求平均值、找最大值最小值。手写循环做这些当然没问题但Stream提供了一组现成的终止操作// 提取所有偶数并计算总和 int sum Arrays.stream(source) .filter(num - num % 2 0) .sum(); // 提取所有偶数并封装统计结果 IntSummaryStatistics stats Arrays.stream(source) .filter(num - num % 2 0) .summaryStatistics(); System.out.println(count stats.getCount()); System.out.println(sum stats.getSum()); System.out.println(max stats.getMax()); System.out.println(min stats.getMin()); System.out.println(avg stats.getAverage());IntSummaryStatistics这个类值得一提它一次性给出计数、总和、最大值、最小值、平均值五个统计量底层就是维护了几个变量遍历一遍数组就全部算出来了。比你自己写循环里分别维护五个变量要清晰太多。我在做报表数据预处理时经常用到它一段话就能把一组数的基本画像拉出来。但要注意getAverage()的返回值是double而且当count为0时返回的是0.0不是NaN也不是异常。这个行为容易让人误以为没有元素时平均值就是0在业务上可能产生误导。如果你需要区分没有数据和平均值确实为0得先判断getCount() 0。3.3 变换场景三回写原数组与下标映射有些场景不是要返回一个新数组而是要把原数组里的偶数原地改掉或者把变化后的值写回原数组的对应下标。比如把数组里所有偶数翻倍奇数保留原值public static void doubleEvensInPlace(int[] source) { for (int i 0; i source.length; i) { if (source[i] % 2 0) { source[i] source[i] * 2; } } }原地修改的优势是零额外内存劣势是破坏了原始数据。如果后续逻辑还需要用到原始数组就得先拷贝一份。这里推荐用source.clone()或者Arrays.copyOf(source, source.length)两者都可以但clone对一维数组来说语义最直接。要注意的是二维数组的clone是浅拷贝只复制了外层引用内层数组仍然是共享的——这也是Java面试高频考点了。还有一类需求是按原下标取数比如筛出偶数的同时记录它们在原数组中的位置。这时候我会用一个简单的小类或者两个平行数组来保存下标值Listint[] evensWithIndex new ArrayList(); for (int i 0; i source.length; i) { if (source[i] % 2 0) { evensWithIndex.add(new int[] {i, source[i]}); } }这个模式在处理稀疏数据、做映射表、做坐标定位时特别有用。虽然用一个int[]塞两个值不算特别优雅但胜在实现简单、性能好。如果你追求可读性也可以定义个record EvenItem(int index, int value)Java 16之后这写法干净得多。4. 性能实测与内存陷阱为什么Stream不是万能药4.1 基准测试大数据量下的三种方案对比我在一台普通开发机上做过一个粗糙的基准测试生成一个50万元素的int数组分别用两次遍历精确数组临时数组加copyOfArrayList收集Stream toArray四种方式提取偶数并乘2循环跑10次取平均值。结果大致如下实现方式平均耗时毫秒额外内存峰值代码可读性两次遍历精确数组约18极低一个结果数组清晰但啰嗦临时数组copyOf约20较高一个等长临时数组简洁ArrayList收集约35中等动态扩容多次拷贝最直观Stream toArray约25较低流水线逐元素处理最优雅这个结果和我预期基本一致传统双重遍历性能最好因为内存分配最精准Stream比想象中好得益于IntStream避免了装箱ArrayList因为多次扩容拷贝排最后。但这个差距在50万数据量级只有十几毫秒在绝大多数业务系统里完全不构成性能瓶颈。所以我跟同事强调不要因为Stream慢这个印象就拒绝它现代JIT编译器对stream的优化相当到位。真正的性能取舍应该看的是数据量级、执行频率和内存敏感度。如果是一个被高频调用、单次处理百万级数据的核心路径用精确数组没问题如果是一个每天跑几次的报表任务用Stream省下的开发时间远大于那十几毫秒的机器时间。提示不要在没有基准测试的情况下凭感觉优化。先写清楚、写正确再考虑性能真要优化用JMH或者至少用足够大的数据量做对比测试。4.2 装箱与拆箱Integer vs int 的真实代价上面表格里的Stream方案用的是Arrays.stream(int[])拿到的IntStream全程原始类型没有装箱。但如果你写的是// 错误示范先转成包装类型数组再走对象流 Integer[] boxedArray ...; Arrays.stream(boxedArray) // StreamInteger .filter(...) .map(...) .collect(...);那每处理一个元素就涉及Integer对象的创建和拆箱50万元素就是50万个Integer对象。每个Integer对象有对象头、实例字段、对齐填充24字节起步int只有4字节。同样是那组数据换成包装类型数组后内存占用翻了数倍GC压力也会明显上升。这个道理对泛型集合同样成立ListInteger里的每一个元素都是对象不是原始int。很多老项目喜欢用ListInteger存海量数值跑批任务时GC时间居高不下换成int[]或者IntList如Eclipse Collections里的实现之后内存立刻降下来。做数组筛选与数值变换时如果数据量上去了第一反应应该是能不能用原始类型数组/流的版本而不是纠结循环方式。4.3 并行流的使用边界再聊一个看起来很美、用起来容易翻车的点parallelStream。把筛选变成并行确实能利用多核但有三个前提数据量足够大、元素处理是纯计算不涉及共享状态、结果收集开销可控。拿偶数筛选这个场景举例int[] evens Arrays.stream(source) .parallel() .filter(num - num % 2 0) .map(num - num * 2) .toArray();小数据量下并行流的线程调度和结果合并开销比省下的计算时间还多跑得反而更慢。而且并行流默认使用ForkJoinPool公共线程池如果你在Web应用里多处使用并行流它们会共享线程池一旦某个任务阻塞可能拖垮整个应用的并行任务。我在生产环境踩过一次一个报表模块用并行流处理较大的数据集结果和另一个也在用并行流的模块互相抢占线程接口超时率飙升。从那以后我对并行流的态度变成了默认不用除非有明确证据显示单线程是瓶颈。5. 面试官常追问的边界场景与底层原理5.1 空数组与null的处理数组处理的第一步永远是判空。空数组new int[0]和null是两回事空数组可以安全遍历结果是一个好元素都没有null直接遍历会抛NullPointerException。我的习惯是入口处统一处理public static int[] extractEvens(int[] source) { if (source null || source.length 0) { return new int[0]; } // 正常处理逻辑... }这种防御性返回空数组的约定在工程上很实用。调用方拿到空数组后可以直接安全地遍历、打印、再加工不需要额外判断null。如果返回null调用方就不得不在每个使用点都做判空漏一处就是线上事故。这条经验不限于筛偶数所有返回集合、数组的公共方法都应该遵守返回空对象而非null的约定。5.2 大数组与Integer溢出数值变换里最隐蔽的坑是溢出。筛出偶数后乘2如果原数组里的元素很大比如接近Integer.MAX_VALUE约21.47亿乘2直接溢出变成负数。Java的int溢出是静默发生的不会抛异常也不会给你任何警告数据就错了。做这类数值变换时要不要提前考虑溢出我的建议是先看数据的业务边界。如果数据来源是用户输入、外部接口、文件导入取值范围不可控就应该用long承接变换结果// 用long承接乘法结果避免int溢出 long[] result Arrays.stream(source) .filter(num - num % 2 0) .mapToLong(num - (long) num * 2) .toArray();有些同事觉得用long是过度设计但在金融、数据分析领域吃过亏的人都知道溢出bug是那种测试测不出、上线炸一片的典型。宁可类型宽一点也不要在变换链路上省这个心思。5.3 从数组到集合再到Stream的转换链路数组、List、Stream三者之间的转换是Java里高频出现的操作我干脆在这里列个对照表方便随手查阅转换方向写法备注int[] - IntStreamArrays.stream(intArr)原始类型流性能好int[] - ListIntegerArrays.stream(intArr).boxed().collect(toList())需要boxed装箱Integer[] - ListIntegerArrays.asList(integerArr)返回的是固定size的视图ListInteger - int[]list.stream().mapToInt(Integer::intValue).toArray()拆箱转原始数组Integer[] - int[]Arrays.stream(integerArr).mapToInt(Integer::intValue).toArray()同上int[] - Integer[]Arrays.stream(intArr).boxed().toArray(Integer[]::new)装箱转包装数组注意Arrays.asList这个经典陷阱它返回的List是固定大小的不能add也不能remove想改结构得new ArrayList(Arrays.asList(...))。同时Arrays.asList(primitiveArray)如果把int[]整个传进去得到的其实是Listint[]里面只有一个元素——那个数组本身而不是各个int元素。这题也是面试里的高频失分点写错往往是因为不知道泛型不支持原始类型。6. 工程化落地从一次性筛选到通用处理组件6.1 封装通用筛选器把筛选逻辑从业务代码里抽出来是我带团队时比较坚持的一点。最初版本的通用筛选器长这样public class ArrayFilter { public static int[] filter(int[] source, IntPredicate predicate) { if (source null) { return new int[0]; } int[] temp new int[source.length]; int index 0; for (int num : source) { if (predicate.test(num)) { temp[index] num; } } return Arrays.copyOf(temp, index); } } // 调用方 int[] evens ArrayFilter.filter(source, num - num % 2 0);用IntPredicate替代写死的判断条件后这个工具方法就不只是筛偶数了。素数、负数、大于某个阈值的数都能通过传入不同的Lambda复用同一套筛选逻辑。一两年后回头看这种两遍遍历或临时数组截断加函数式条件的封装是我在项目里复用率最高的代码段之一。6.2 结合Lambda表达式的策略模式再往前走一步把筛选变换合并成一个整体操作就形成了一个小型的策略管道public static int[] process(int[] source, IntPredicate filter, IntUnaryOperator mapper) { return Arrays.stream(source) .filter(filter) .map(mapper) .toArray(); } // 筛偶数并乘2 int[] result ArrayProcessor.process(source, n - n % 2 0, n - n * 2); // 筛大于10的数并平方 int[] result2 ArrayProcessor.process(source, n - n 10, n - n * n);这种设计的妙处在于每个调用方只关注自己的业务规则通用的遍历、筛选、映射机制被平台化了。后续如果想加日志、加统计、加并行开关只需要在process方法内部调整所有调用方自动受益。我在一个数据清洗项目里就是这么干的把十几处重复的for循环全部收敛到一个方法里代码量少了将近三分之一排查问题的时候也只需要盯着一处逻辑看。6.3 我的实战建议与踩坑记录最后分享几条这些年做数组处理攒下的实在经验每条都是付过学费的。第一不要在流操作里改外部状态。比如在map里对一个外部计数器做count这在单线程下勉强能用一旦切换并行流就会出并发问题。需要计数就用IntStream自带的count、sum或者收集到统计对象里。第二谨慎处理空结果和全量结果。筛偶数可能筛出空数组也可能一个都筛不掉原数组全奇数。这两种结果在业务上含义完全不同空数组表示有输入但无命中全量结果表示所有数据都被处理了。如果你的下游逻辑对这两种情况有不同处理方式一定要在代码里显式区分而不是只靠一个length 0的判断敷衍过去。第三数组筛选和数值变换最后一定要做数据校验。我在生产环境遇到过好多次筛选条件写反、变换公式抄错、溢出没考虑结果下游报表数据异常。现在我的习惯是处理完数组后如果数据量不大直接打印或断言几个关键值验证一下数据量大就抽几个样本点核对。这一步多花两分钟能省掉后面两小时的排查时间。第四多用现有的工具少重复造轮子。除了Streamjava.util.Arrays里的copyOf、fill、sort、binarySearch以及java.util.Collections的各种方法都已经把数组和集合的常见操作优化得很好了。自己手写的循环和拷贝逻辑大概率不如JDK里经过多年打磨的实现可靠。数组筛选与数值变换看着是入门级的题目但往深里走边界处理、性能选型、可复用设计、类型安全每一层都有值得打磨的细节。把这道题吃透等于把Java数据处理的基础骨架搭起来了。后面遇到更复杂的集合转换、流式处理、批量计算思路都是一脉相承的。
返回列表