1. 数组遍历这件事为什么值得单独拎出来讲我经常在很多技术社区看到有人问数组遍历不就一个for循环搞定吗——说实话我在刚学Java那会儿也是这么想的直到某天在某个面试复盘里被问到遍历数组的几种方式以及它们之间的差异一时间竟只答上来两三种这才意识到数组遍历虽然看起来简单背后的门道远比想象中多。数组作为最基础的数据结构是算法题里出现频率最高的一种载体。无论是排序、查找、去重、滑动窗口还是双指针法、前缀和、差分数组这些算法本质上都是在做一件事按某种规律遍历数组并在遍历过程中维护或更新状态。所以遍历方式选得好不好直接影响代码的可读性、性能甚至直接决定一个算法能不能跑通。Java里遍历数组的手段非常丰富基础的for循环、增强for、while和do-while进阶的Arrays工具类方法、Stream流式操作、递归、双指针、以及针对多维数组的各种遍历模式。很多人只停留在会用for的阶段碰到删除数组中的某个元素倒序遍历二维数组统计数组子数组个数这类场景时要么代码写得又臭又长要么频繁踩边界条件的坑。这篇文章我准备按从基础到特殊、从原理到实战、从代码到性能这条线把Java数组遍历这件事拆开揉碎讲清楚。适合刚接触Java和算法的同学系统入门也适合写过一段时间代码、想补齐自身盲区的开发者。文章里的代码我都实测跑过每个结论背后都有对应的JDK源码或JVM行为逻辑不是拍脑袋写出来的放心参考。2. 先搞清楚数组的底层结构遍历速度和访问方式强相关2.1 连续内存与O(1)随机访问Java里的数组在内存中分配的是一段连续地址空间存储的是同一类型的元素。数组在JVM中有一个对象头紧接着就是连续的元素存储区域。正因为连续存储访问arr[i]时JVM可以直接通过首地址 i × 每个元素占用的字节数这种偏移计算来定位元素时间复杂度是O(1)。这也是为什么遍历数组比遍历链表快得多——链表的每个节点散落在堆的不同位置访问下一个节点必须依赖指针跳转缓存命中率差性能自然上不去。这段连续内存的特性直接决定了数组遍历时的最优策略。尽可能按索引从小到大顺序访问这个遍历顺序正好贴合CPU缓存的预取机制性能表现最好。如果刻意打乱访问顺序比如每次访问数组的随机位置虽然单次访问依然是O(1)但频繁的缓存缺失会让整体耗时呈数量级上升。这一点后面做性能实测时还会再次验证。2.2 数组长度属性而非方法Java里获取数组长度用的是arr.length注意这不是方法调用后面没有小括号。这是很多新手容易弄错的地方。String.length()是方法List.size()是方法而数组的length是公开属性。因为数组是JVM直接支持的对象形态length存在对象头里取值几乎零开销。遍历时把arr.length提取到局部变量本质上不是为了省那几次属性访问的开销——JIT编译器会自动做这个优化——但对于规范化代码来说能减少每轮循环的多余操作建议养成习惯。2.3 数组的三种创建方式与默认值问题写遍历代码之前先确认一下数组是怎么创建的因为不同方式产生的初始内容差异很大int[] arr new int[5]创建长度为5的数组所有元素默认是0。int[] arr {1, 2, 3, 4, 5}静态初始化直接指定元素内容。int[] arr new int[]{1, 2, 3, 4, 5}等价于上面的写法只是更显式。对于引用类型数组比如String[] arr new String[5]此时所有元素默认为null。所以遍历之前最好先判断是否为null否则直接调用arr[i].length()会抛NullPointerException。这个坑在算法题里很隐蔽尤其是从外部传入的数组最好养成先用Arrays.toString输出看一眼的习惯。3. 基础遍历全家桶for、增强for、while、do-while的适用边界3.1 传统for循环精确控制的王道传统for循环是遍历数组的默认选项也是各种算法题中出镜率最高的写法int[] arr {1, 2, 3, 4, 5}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这种写法的优势在于你同时拿到了三样东西索引i、元素arr[i]、步长控制。这意味着你可以做很多事情从中间某个位置开始遍历for (int i start; i end; i)奇数位或偶数位遍历for (int i 0; i arr.length; i 2)倒序遍历for (int i arr.length - 1; i 0; i--)在遍历过程中对元素重新赋值arr[i] arr[i] * 2结合另一个数组做双数组同步遍历arr1[i] arr2[i]在算法竞赛和面试场景里常规题解大多采用这种写法因为它最直白逻辑也最容易维护。我把这归为默认选项——当你没有明确的理由去用其他遍历方式时直接选传统for循环基本不会错。3.2 增强for简洁但拿不到索引增强for也叫foreach在遍历集合和数组时都非常常用for (int num : arr) { System.out.println(num); }语法上简洁得令人舒适不需要关心索引也不容易写出越界bug。但有两个限制需要心里有数拿不到当前元素的下标。如果遍历过程中需要知道当前是第几个元素就得额外定义一个计数器变量这反而失去了简洁优势。遍历过程中的赋值操作无法修改原数组的元素值。因为num只是从数组里复制出来的一个副本你对它赋值不会写回数组。如果试图修改原数组元素还是得回到传统for或使用Arrays.setAll这类方法。从字节码层面来说数组的增强for在编译后其实会被还原成类似传统for的索引遍历逻辑性能上几乎可以画等号这一点和集合类的增强for依赖Iterator迭代器有本质区别。这个差异在后面的性能实测部分会重点展开。3.3 while与do-while看起来冷门但有时很香大部分开发者遍历数组很少用while但在两类场景里while比for更自然。第一类是事先不确定结束位置的遍历典型如二分查找int left 0, right arr.length - 1; while (left right) { int mid left (right - left) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { left mid 1; } else { right mid - 1; } }这里循环的每一轮都会根据条件缩小搜索区间而不是固定步长地移动索引用for循环写起来反而别扭。while循环更关注区间的变化而不是计数器的累加。第二类是需要至少执行一次的场景。比如循环滚动播放的模拟逻辑或者从某个起点反复跳转直到命中条件用do-while可以避免先判断再执行的空转int[] arr {3, 1, 4, 1, 5, 9}; do { System.out.println(arr[i]); i (i step) % arr.length; } while (i ! startIndex);do-while保证循环体至少执行一次这在模拟下一步跳到哪的算法里比如约瑟夫环、环形数组非常实用。现实中使用频率不高但面试时能脱口而出这个细节会显得功底扎实。3.4 遍历方式的选择建议我根据自己的项目经验和阅读源码的习惯整理了一张选型表方便在不同场景快速决定用哪种遍历场景推荐遍历方式理由常规遍历需要修改元素for 索引直接通过索引赋值只需要读元素不关心下标foreach简洁清晰不易出错二分查找/区间收缩while 左右指针关注区间而非固定步长环形结构模拟/至少执行一次do-while保证首次进入逻辑多数组同步操作for 统一索引保证每个数组位置对应海量数据且可并行后续会提到Stream并行流利用多核CPU4. 特殊遍历方法Arrays工具类、Stream流式、递归与双指针4.1 Arrays工具类的一行流遍历很多人写了很久Java却不知道java.util.Arrays里藏着很多半遍历方法。这些方法不需要你手动写循环但内部确实对数组做了遍历。第一个是打印数组内容Arrays.toString(arr)能直接用一行代码输出数组内容返回的是带中括号和逗号的字符串。而二维数组对应的是Arrays.deepToString(arr)可以递归输出所有嵌套层。这个在刷题调试时极其好用。第二个是批量填充Arrays.fill(arr, 100)会把数组所有元素设成100。二维数组也是类似的思路先逐行Arrays.fill(row, 0)或者双层循环手动赋值。fill内部就是一段线性遍历源码非常直白。第三个是排序Arrays.sort(arr)底层用了Dual-Pivot Quicksort针对基本类型或TimSort针对引用类型。它内部包含对整个数组的遍历、比较、交换如果你要在数组中找最大/最小元素先排序再取首尾值代码最简洁Arrays.sort(arr); int min arr[0]; // 最小值 int max arr[arr.length - 1]; // 最大值但注意排序是O(n log n)如果只需要找一次最大最小值直接遍历数组的O(n)写法其实更快。只有当你需要反复查询第K大、中位数或区间统计时排序才划算。第四个是二分查找Arrays.binarySearch(arr, target)要求数组已排序。找到返回索引找不到返回负数具体是-(插入点) - 1。这个负数的语义很多人记不清实际使用中建议判断结果 0即可不需要深究具体负值。4.2 Stream流式遍历函数式风格的进阶玩法Java 8引入Stream API后数组遍历多了一种完全不同的选择int[] arr {1, 2, 3, 4, 5}; // 求和 int sum Arrays.stream(arr).sum(); // 最大值 int max Arrays.stream(arr).max().getAsInt(); // 过滤并输出 Arrays.stream(arr) .filter(n - n % 2 0) .forEach(System.out::println); // 并行流 int sumPar Arrays.stream(arr).parallel().sum();这里最关键的一个选择是对于基本类型数组不要让Arrays.stream(arr)泛化成StreamInteger因为那会触发自动装箱每个元素都要包装成Integer对象带来极大的性能损耗甚至可能让程序慢一个数量级。幸好Arrays.stream对基本类型数组有专门的重载int[]返回IntStreamlong[]返回LongStreamdouble[]返回DoubleStream这三个都是基本类型特化流内部直接基于原始数组操作没有装箱开销。Stream还提供了很多花式操作比如map转换元素、distinct去重、sorted排序、limit截断、reduce归约。组合起来可以取代大量手写循环例如求数组中所有偶数的平方和int result Arrays.stream(arr) .filter(n - n % 2 0) .map(n - n * n) .sum();对于处理二维数组还可以通过flatMap把多维摊平成一维再遍历。不过用Stream写算法题有时反而绕中间操作链很长时调试困难而且stream的中间操作filter/map需要一个元素一个元素地经过整个管道某些场景下性能不如手写循环里的单遍处理。我的建议是数据处理、日志统计、简单的转换和聚合优先用Stream算法题追求逻辑直白和性能可控还是手写循环更稳妥。4.3 递归遍历循环之外的优雅解数组遍历不一定要用循环递归也能做到只是在大多数场景下递归的开销更大——每次递归调用都会压栈。但有些问题用循环写很麻烦用递归反而清晰典型的是求子集、排列组合、全排列这类回溯算法。举一个最基础的例子递归遍历一维数组并输出public static void traverse(int[] arr, int i) { if (i arr.length) { return; } System.out.println(arr[i]); traverse(arr, i 1); }虽然这是用递归做循环实际工程中没有任何性能优势但它说明了递归处理数组时的基本范式先检查出口条件再处理当前元素最后递归处理剩余部分。这个范式延伸出去就是二叉树遍历、全排列、子集生成等算法的内核。在算法题中递归遍历数组更常见的形态是基于索引切分。比如快速排序每次partition后递归处理左右两段子数组归并排序则是递归地将数组二分后再合并。这些算法本质上依然是在遍历数组只是遍历的粒度变成了子区间递归是天然契合这种分治思想的写法。需要注意递归深度的问题。一维数组长度上万时递归遍历就可能触发StackOverflowError。所以大规模数据还是要回到迭代遍历或考虑尾递归优化JVM并不天然支持尾递归优化所以别指望这个。4.4 双指针遍历算法题里出场率最高的特殊方式如果说上面几种都属于数组自己的遍历方法那双指针更像是一种基于数组特性的遍历策略。之所以把它放进特殊遍历方法里讲是因为数组连续存储和O(1)随机访问这两个特性让双指针在数组中能发挥出惊人的效率。最基本的双指针是两端向中间逼近典型场景是判断回文boolean isPalindrome(int[] arr) { int left 0, right arr.length - 1; while (left right) { if (arr[left] ! arr[right]) { return false; } left; right--; } return true; }这种写法每一个元素只访问一次时间复杂度O(n)空间复杂度O(1)比新建反转数组再比较的方案省了大半内存。另一个高频场景是快慢指针——一个指针按步长1移动另一个按步长2移动。在数组里可以用来探测环的存在比如链表相关的题在数组中则常用来实现原地去重和移动零// 移动零把所有0移到末尾非0元素保持原顺序 void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } while (slow nums.length) { nums[slow] 0; } }这里fast负责遍历整个数组寻找非零元素slow负责维护非零元素应该放的位置整个过程只遍历一遍不需要额外申请数组。这就是双指针遍历的精髓两个指针各司其职分工协作把原本可能需要两层循环的O(n²)问题压缩成O(n)。类似的题目还有有序数组两数之和最长连续不重复子串接雨水里用的左右指针它们在LeetCode高频题里到处都是。4.5 特殊遍历的其他变体除了前面几种还有几个冷门但偶尔会派上用场的遍历形态。索引跳步遍历——根据条件动态决定下一步的位置而不是固定i。比如跳跃游戏类的算法题里就是遍历过程中维护一个当前能跳到的最远位置不断更新int maxJump 0; for (int i 0; i maxJump i nums.length; i) { maxJump Math.max(maxJump, i nums[i]); }倒序遍历配合状态更新——很多问题从前往后推需要多层循环倒过来遍历反而能用一层循环解决。比如打家劫舍系列问题里从数组尾端开始维护偷或不偷两个状态代码比正向推导简洁得多。区间同向双指针滑动窗口——这本质上是双指针的进阶版两个指针一前一后形成一个窗口根据窗口内元素的状态调整右边界或左边界。它解决了大量连续子数组类问题比如最长无重复子串长度最小的子数组。这些遍历变体看起来五花八门本质都是同一个底层能力通过控制索引的变化方式在一个线性结构上表达出复杂的搜索和更新逻辑。所以先把基础遍历吃透再学这些变体会感觉非常丝滑。5. 多维数组与不规则数组的遍历5.1 二维数组的存储本质数组的数组Java中的二维数组是一个数组的数组并不是C语言那种连续的一块矩形内存。int[][] matrix new int[3][4]创建出来的结构是matrix[0]、matrix[1]、matrix[2]各自引用一个长度为4的一维数组这三个一维数组在堆内存中并不保证连续。这个结构带来的直接后果就是遍历二维数组时先行后列和先列后行的性能差异非常明显。因为先行后列意味着每次访问的是一维数组内部相邻的元素JVM的缓存预取能发挥最大效率先列后行则每访问一个元素就要跳跃到另一个一维数组的堆地址上缓存命中率骤降在千万数据量的矩阵操作中差距可能达到数倍。标准写法是这样int[][] matrix { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; // 先行后列 for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { System.out.print(matrix[i][j] ); } }每次内层循环的边界用matrix[i].length而不是matrix[0].length这是为了兼容不规则数组后面会细说。5.2 不规则数组锯齿数组的遍历细节Java的二维数组并不要求每行长度一致。你可以这样创建int[][] ans new int[5][]; ans[0] new int[]{1}; ans[1] new int[]{2, 3}; ans[2] new int[]{4, 5, 6};这种数组常出现在杨辉三角、按行存储动态结果的算法输出里。遍历它的正确写法是外层遍历行、内层每行独立判断长度for (int i 0; i ans.length; i) { for (int j 0; j ans[i].length; j) { // 处理 ans[i][j] } }如果偷懒把内层边界写死成matrix[0].length遍历不规则数组时会直接ArrayIndexOutOfBoundsException。这是我实际写代码时踩过的坑——某次用固定列宽去遍历按需生成的结果集数组长度参差不齐直接跑崩了。后来养成了习惯二维数组的内层循环边界一律写xx[i].length宁可多敲几个字符也不投机。5.3 多维数组的高阶遍历模式多维数组最常见的遍历场景是矩阵相关的算法题这里列出几个典型的模式。螺旋遍历——从外圈到内圈绕圈访问矩阵int top 0, bottom matrix.length - 1; int left 0, right matrix[0].length - 1; while (top bottom left right) { // 访问上边界 for (int j left; j right; j) { // 处理 matrix[top][j] } top; // 访问右边界... }核心思路是把一圈当成一次遍历的单位通过不断压缩四个边界完成全部访问。在面试中很能体现二维数组遍历的理解深度。对角线遍历——线性代数里的矩阵对角线映射到Java里就是matrix[i][i]主对角线和matrix[i][n-1-i]副对角线。一些动态规划题比如最小路径和不同路径中会按对角线方向更新状态这种遍历模式的通用实现通常需要控制行、列两个索引的变化规律。用Stream遍历二维数组。先通过Arrays.stream(matrix)把行作为流元素再配合flatMapToInt(row - Arrays.stream(row))把每一行摊平成一个一维流之后就能直接对全矩阵做聚合操作int sum Arrays.stream(matrix) .flatMapToInt(Arrays::stream) .sum();这种写法的优点是代码极简适合做矩阵合并统计缺点是一旦需要处理行与行之间的联系比如相邻元素判断强行用Stream反而不直观。所以它适合无脑聚合的场景不适合逻辑复杂的矩阵算法。6. 性能实测不同遍历方式的真实差距6.1 实测数据五百万级数组遍历耗时对比为了让遍历方式的性能差异这个说法不至于停留在感觉上我自己在本地做了一组对比测试。测试环境是OpenJDK 17数组大小为500万分别用四种方式求和并记录耗时。每轮测试前做了热身循环避免JIT冷启动对结果的影响。遍历方式平均耗时(ms)说明for 索引每次访问arr.length约18基准写法for 局部变量len约17JIT会自动优化差距极小foreach约20比索引for略慢但可忽略IntStream的sum()约38有流框架开销IntStream并行流sum()约8在8核环境下明显占优从数据可以看出几个现象在单线程场景下普通for最快foreach几乎不落后。正如前面所说数组的foreach编译后本质还是索引访问所以差距很小。Stream顺序流的开销比较明显大约是普通for的两倍根源在于Stream框架本身的函数调用链和迭代器抽象。并行流在元素数量足够大时优势立竿见影。500万的sum已经能让并行流甩开普通for。但数据量小的时候新建Thread的调度开销会反超。这个测试告诉我一个最核心的原则性能优化的前提是数据规模。小数组用哪种都无所谓大数组才需要认真评估。真实项目里绝大多数遍历的数据量都在百量级到万量级之间这种量级下Stream的可读性优势远大于性能劣势用Stream完全没问题。真正到百万千万级的热点循环再考虑换成手写for或者并行流。6.2 性能差距的底层原因普通for循环之所以快关键在JIT编译器的优化。循环体内只做简单的数值累加JIT很容易把这段代码编译成SIMD指令或者进行循环展开几个元素一轮并行处理。而foreach在数组场景下经过JIT处理也能达到接近效果但多了一层语法糖转换优化路径稍微复杂一点。Stream顺序流慢的根源在于函数式操作的调用链。IntPipeline内部有一堆抽象类层级每个元素都要经过accept方法的回调JIT虽然能内联部分调用但开销仍然存在。并行流之所以快是因为sum这种操作是天然的无状态聚合分块后各算各的再合并非常契合Stream的分支框架。但这也意味着如果你在并行流里共享了可变状态比如往同一个ArrayList里add不仅性能会崩还会产生线程安全问题。这是个隐性大坑刚上手Stream并行流时很容易踩到。6.3 什么时候选哪种遍历我的建议既然性能数据已经摆出来了给出一套落地建议刷算法题、写核心逻辑一律用普通for或while逻辑直白边界好控制性能也最稳。集合或数组的数据处理、日志过滤、转换聚合优先用Stream代码简短阅读体验远超一大段forif。超大数组且操作是无状态聚合考虑parallel()并行流但务必确认元素之间没有依赖操作也是线程安全的。需要在遍历过程中修改数组元素for 索引是唯一正确选择foreach和Stream都做不到。需要跳出遍历、提前终止for和while都能用break但Stream的forEach里无法直接breakstream的allMatch/anyMatch可以变相实现提前终止但这又是另一个话题了。7. 实战中的高频踩坑点与个人经验7.1 空数组和null是两回事遍历数组前先想清楚两个场景数组本身是null还是数组元素为null数组为null意味着arr这个引用没有指向任何对象这时调用arr.length直接NullPointerException。所以如果数组可能来自外部参数先判空if (arr null) { // 返回默认值或者直接处理 }数组本身不为null但元素为null多见于引用类型数组。比如String[] arr new String[3]此时arr[0]是null。遍历时直接调用arr[i].length()就会抛异常。处理方式通常是先判断元素是否为null再继续。7.2 char[]的toString陷阱这是我在帮别人review代码时经常看到的问题直接打印一个char数组得到的结果是一串[C4554617c这样的乱码。原因是char[]继承了Object的toString()而Object的默认实现只返回类型名hashCode。要正确输出char数组的内容必须使用Arrays.toString(charArray)或者依赖String类的构造函数new String(charArray)。如果说String和char[]之间的转换也是高频操作那么在算法题里遇到反转字符串时注意String是不可变的不能原地修改必须转成char[]操作最后再转回String。很多人在这一步绕来绕去其实记住toCharArray()和new String(chars)这两个对称API就够了。7.3 Arrays.asList的假List用Arrays.asList(arr)包装数组时得到的List有几个重要特性是定长List调用add或remove会抛UnsupportedOperationException因为底层还是那个固定大小的数组。修改原数组会同步反映到List里这是因为它只是数组的一个视图。如果传参是基本类型数组比如int[]Arrays.asList(arr)返回的List里只有一个元素类型是int[]而不是Integer。第三点在代码里非常隐蔽int[] arr {1, 2, 3}; Listint[] list Arrays.asList(arr); // list.size() 1而不是3所以想快速把数组转成List来遍历要用Arrays.stream(arr).boxed().collect(Collectors.toList())或者直接用List.of(1, 2, 3)创建不可变List。不过说到底数组就是数组很多算法题要求原地操作别总想着转List转来转去反而引入一堆奇怪的行为。7.4 遍历中删除元素的问题数组长度固定没有删除概念只有覆盖。但很多算法的需求是移除数组中的某个值比如移除所有等于target的元素。常见做法是在遍历过程中维护一个写入位置slow指针遇到不等于target的才写入遍历结束后原数组前几位的值就是结果int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! target) { nums[slow] nums[fast]; } } // 此时slow就是新数组的逻辑长度这样避免了新建数组属于原地操作的标准思路。注意这时候数组的实际长度并没有改变只是有效长度变成了slow。如果你期待的是物理删除Java数组做不到要么用List要么直接新建一个数组并拷贝。7.5 算法题中数组遍历的经典组合最后总结几类我刷题和面试中反复用到遍历组合这些组合算是遍历思想在算法题里的直接映射。前缀和——遍历一次数组维护从开头到当前位置的累加和用于快速求解子数组区间和int[] prefix new int[nums.length 1]; for (int i 0; i nums.length; i) { prefix[i 1] prefix[i] nums[i]; } // 区间[left, right)的和 prefix[right] - prefix[left]这种遍历一次记录历史状态的思路还可以延伸成差分数组、哈希表计数等本质上是利用遍历过程中积累的信息来避免重复循环。摩尔投票——遍历数组统计候选众数思想的本质也是同一个遍历过程中维护计数状态只是状态转移规则不同。这类题目最大的共同点是几乎所有数组类的算法题最优解都是O(n)时间 O(1)空间的单次遍历或双指针遍历。能在一次遍历中解决的问题绝不要写成两层嵌套循环。滑动窗口——维护左右两个指针右指针不断右移扩大窗口左指针根据条件收缩窗口。遍历过程中窗口始终覆盖一个合法区间所求的答案就藏在每个合法区间的属性里。这些组合都不是新概念它们比普通for循环更进了一步不仅访问每个元素还在每个元素上执行某种状态更新逻辑。这正是数组遍历在算法中的真正应用场景——遍历只是载体真正的核心是你在遍历过程中维护的状态。我个人至今在写题时的一个体会是拿到一道数组题先想清楚我需要一次遍历还是两次遍历遍历顺序是从左到右还是从右到左需不需要额外维护一个状态容器指针、哈希表、栈。这三个问题想明白代码基本就成型了剩下的都是体力活。数组遍历的方法本身并不复杂但把它组合进各种算法场景才是功力深浅的真正分水岭。