☰
Java数组与字符串核心易错点:从内存模型到面试必备技巧
2026/10/7 22:15:48 网站建设 项目流程

又到了Java基础系列的回炉时间。我带过的几个实习生,几乎都在数组和字符串上翻过车:有人被问"数组的长度是属性还是方法"直接愣住,有人把两个字符串用==比较然后调了半天BUG。这一篇是JAVA重点基础、进阶知识及易错点总结系列的第二篇,聚焦数组与字符串这两个Java里最基础、也最容易被面试官挖坑的知识点。数组是数据结构的地基,字符串是日常开发里操作最频繁的对象,两者组合起来几乎能覆盖一半的Java面试题。不管你是准备校招、跳槽的初中级工程师,还是写了两三年Java想查漏补缺,这篇文章都值得从头到尾过一遍。

我会从底层内存模型讲起,一直讲到API使用细节、常见转换场景、经典算法题,最后集中盘点一堆"当时没想到"的易错点。这些内容不查文档很难一次性记全,建议配合代码在IDE里亲手敲一遍。

1. 数组基础与进阶:先搞清楚内存里到底发生了什么

1.1 声明、初始化与"数组没有指针,但有引用"

很多Java新手是从C/C++转过来的,第一反应是拿"指针数组"那套概念去理解Java数组。这里必须先把观念掰过来:Java里的数组是一种引用类型,它本身的变量存的是堆内存中数组对象的地址,但这个地址你不能像C语言那样做加减运算,也没有指向数组元素的"指针"一说。

数组的声明和初始化语法并不多,但细节不少:

// 声明 int[] a; // 推荐风格,类型在前 int b[]; // 合法但易读性差,不推荐 // 创建 a = new int[5]; // 默认值填充:int为0,boolean为false,引用类型为null int[] arr1 = {1, 2, 3}; // 声明时直接初始化 int[] arr2 = new int[]{4, 5, 6}; // 匿名数组常见写法 String[] names = {"A", "B", "C"};

注意new int[5]是长度为5、下标从0到4的数组。数组一旦创建,长度不可变,arr.length是属性而不是方法,这是区分数组和字符串的第一道坎——String的length()是方法,数组的length是字段,面试时这个点真是百问不厌。

从内存模型看,执行int[] a = new int[5]时,栈上的引用变量a指向堆里的数组对象,数组对象头里有长度信息和数组元素。引用类型数组的元素就更有意思了,比如String[] s = new String[3],三个格子默认都是null,每个格子后面存的是指向字符串对象的引用,数组本身不存对象内容。这就是为什么很多人写String[] s = {"a"}; s[0].length()没问题,但new String[3]后用s[0].length()直接NPE——数组里根本没有初始化的字符串。

1.2 遍历、复制、比较、排序:基础API别用错

数组的基础操作说简单也简单,说坑也坑。我见过不少人在比较两个数组是否相等时直接写a.equals(b),然后稀里糊涂得到false。原因在于数组没有重写Object.equals,比较的是两个数组对象的引用,而不是内容。要比较内容得用Arrays.equals,而二维数组还得用Arrays.deepEquals:

String[] s1 = {"a", "b"}; String[] s2 = {"a", "b"}; System.out.println(s1.equals(s2)); // false,比较引用 System.out.println(Arrays.equals(s1, s2)); // true,比较内容 int[][] m1 = {{1, 2}, {3, 4}}; int[][] m2 = {{1, 2}, {3, 4}}; System.out.println(Arrays.deepEquals(m1, m2)); // true

数组复制是另一个高频场景。新手快速复制最容易想到arr.clone(),但要注意clone()是浅拷贝:基本类型数组复制的是值,引用类型数组复制的是引用地址,原数组和新数组的元素指向同一批对象。现代化建议是优先用Arrays.copyOf,底层调的还是System.arraycopy这个native方法,效率很高:

String[] src = {"a", "b", "c"}; String[] dest = Arrays.copyOf(src, src.length); dest[0] = "x"; // src不变,因为改的是dest槽位里的引用

数组转字符串打印也有标准化姿势。直接System.out.println(arr)打印的是[I@1b6d3586这种"类名@哈希值",正确做法是Arrays.toString(arr),二维数组要用Arrays.deepToString(arr),这样才输出[1, 2, 3]这样的可读结果。

排序这块基本需求是升序,Arrays.sort(int[])对基本类型和引用类型都支持。但降序有个经典坑:基本类型数组不能直接传Comparator.reverseOrder(),因为泛型不适用于基本类型。你得先转成Integer[],或者自己手写。说到手写,冒泡排序几乎是算法入门第一课,也是面试手撕题常客,我给出一个带优化标志位的版本:

public static void bubbleSort(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) break; // 没发生交换,数组已有序 } }

优化点在于"某一趟没有任何元素交换说明已经有序",及时跳出循环能省不少时间,面试时写出来是加分项。

2. 二维数组与动态扩容:面试爱问的"结构"陷阱

2.1 二维数组的内存模型与不规则数组

二维数组不算独立的数据结构,本质是"数组的数组"。int[][] matrix = new int[3][4]做的事情是:先创建一个长度为3的外层数组,每个元素又是一个长度为4的int数组。访问matrix[1][2]时,先定位到外层下标为1的数组,再取那个数组下标为2的元素。

Java有个C语言里没有的玩法——不规则二维数组。你甚至可以只声明第一维,第二维每行长度不一样:

int[][] ragged = new int[3][]; ragged[0] = new int[2]; ragged[1] = new int[5]; ragged[2] = new int[1];

这种结构在处理稀疏数据时很实用,但遍历时不能想当然三层嵌套,必须先检查内层是否为空。我在实际开发中遇到过一种踩坑场景:从接口拿到的JSON解析成int[][]后,内层某些行是null,直接用ragged[1][0]取值就炸了。稳妥写法是遍历前判断:

if (ragged[i] != null && ragged[i].length > 0) { // 取 ragged[i][0] }

二维数组遍历还有个性能细节需要提一下:循环顺序建议"外层循环走行,内层循环走列",也就是行优先。因为JVM底层按连续内存访问更友好,虽然Java数组是对象不能完全类比C语言的连续内存,但保持习惯性的行优先遍历能让代码可读性更好,避免matrix[j][i]这种跳跃式写法。

2.2 Arrays.asList与"数组增加"的正确姿势

先说说Arrays.asList这个高频工具的坑,它几乎是Java面试题里数组部分的常驻嘉宾。

int[] arr = {1, 2, 3}; List<int[]> list = Arrays.asList(arr); System.out.println(list.size()); // 1,不是3

看到问题了吗?基本类型的int[]会被当成一个整体对象,放进List后成了一个只有一个元素的List<int[]>。想把int数组转成List,要么循环添加,要么先用Stream装箱:

List<Integer> list2 = Arrays.stream(arr).boxed().collect(Collectors.toList());

还有一个坑是Arrays.asList返回的List是定长的,它内部直接引用原数组,不支持add和remove,调用就抛UnsupportedOperationException。同时,因为共享底层数组,你改List里的某个元素,原数组对应位置也会变。如果想真正得到一个独立的ArrayList,要再包一层:

List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));

数组一旦创建长度就固定,那"数组增加"怎么做?手写扩容的思路是创建一个更长的新数组,再把旧数据搬过去。System.arraycopy就是干这个的:

public static int[] addElement(int[] old, int value) { int[] newArr = Arrays.copyOf(old, old.length + 1); newArr[old.length] = value; return newArr; }

实际业务里频繁增删基本都会直接用ArrayList而不是裸数组,它的扩容机制本质上就是"创建一个新数组并复制旧元素"。Java的ArrayList默认初始容量10,扩容时新容量约等于旧容量的1.5倍,这也是我建议日常开发优先用集合类、面试才研究裸数组的原因——理解了底层扩容逻辑,才能解释为什么频繁add会带来额外的复制开销,以及为什么预估容量大的集合建议用new ArrayList<>(expectedSize)提前指定。

3. 字符串进阶:不可变性、常量池与三件套怎么选

3.1 从不可变设计到"==""和equals

字符串如果要列一个"Java面试题出现次数排行榜",前十名里至少有三个位置属于String相关:不可变性、常量池、==与equals的区别。这几点全都串在同一个底层设计上:String是不可变的,底层value数组是final的(JDK 8里是final char[],JDK 9之后是final byte[])。

为什么要设计成不可变?背后有几个非常实际的考虑。第一是字符串常量池能安全复用字面量,节省内存;第二是哈希值可以缓存,String作为HashMap的key才足够安全高效;第三是安全性,类名、URL、文件路径这些关键参数如果可变,很容易被中间篡改;第四是天然线程安全,不需要额外同步。

==比较的是引用地址,equals比较的是内容。但字符串字面量有常量池这一层,所以:

String s1 = "abc"; String s2 = "abc"; s1 == s2; // true,常量池直接复用 String s3 = new String("abc"); s1 == s3; // false,s3指向堆里新建的对象 s1.equals(s3); // true,内容相同 s3.intern() == s1; // true,intern返回常量池引用

new String("abc")这个过程其实产生了两个对象:一个在常量池里(如果之前没有),一个在堆上。这种题目几乎是送分题,但也是最容易让新手蒙圈的承重墙。判断字符串内容是否相等,一律用equals,只有你确定两个引用指向同一个常量池对象时才能用==,比如String s1 = "a"; String s2 = "a";这种字面量比较。遇到== null这种检查空指针的写法不冲突,那是判断引用是否为null,不是比较字符串内容。

3.2 String、StringBuilder、StringBuffer怎么选

这个三件套对比题面试官也爱问,解题思路很清晰:String不可变所以拼接产生新对象;StringBuffer线程安全但有同步开销;StringBuilder非线程安全但性能最好。日常开发里99%的单线程场景直接选StringBuilder。

很多人写代码习惯直接用+拼接字符串,其实Java编译器在遇到String s = "a" + "b" + "c"时会自动优化,但如果拼接的是变量,比如str += x,编译器会隐式创建StringBuilder,循环里拼接尤其明显——每次迭代都在创建新的StringBuilder对象,性能白丢。我用JMH做过一个小基准测试,循环10万次拼接,用StringBuilder的实现比裸+快一个数量级还多。

我给出一个简化对比表,方便面试前快速过一遍:

类可变性线程安全性能适用场景
String不可变安全拼接效率低字符串常量、少量拼接
StringBuilder可变非安全最高单线程大量拼接、循环拼接
StringBuffer可变安全(方法级synchronized)较低多线程共享拼接场景

有一点需要补充:StringBuilder的默认容量是16,追加超过后会自动扩容,原理和ArrayList类似。所以如果你预先知道要拼接很多内容,可以new StringBuilder(initialCapacity)减少扩容次数。

字符串排序也是个常见需求。字符串数组直接用Arrays.sort就是字典序排序;如果是按字符串长度排,或者要求倒序,就得传入Comparator:

String[] words = {"banana", "apple", "cherry"}; Arrays.sort(words); // 字典序升序 Arrays.sort(words, Comparator.reverseOrder()); // 字典序降序 Arrays.sort(words, Comparator.comparingInt(String::length)); // 按长度升序

4. 字符串与数组的实战转换:高频需求一碗端

4.1 数字与字符串互转的注意事项

字符串和数字互转太常用了,但翻车的细节也多。字符串转数字核心方法:

int n = Integer.parseInt("123"); double d = Double.parseDouble("3.14"); long l = Long.parseLong("10000000000000"); // 包装类方式 Integer num = Integer.valueOf("123");

Integer.valueOf("123")和Integer.parseInt("123")的区别在于前者返回Integer对象,后者返回int基本类型。类似的,parseInt遇到空字符串、含空格、非数字字符、超出范围的值都会抛NumberFormatException。实际开发里你很可能遇到用户输入" 123 "这种带空格的值,需要先trim()再转换。我自己踩过的坑是解析手机号时忘记判断空串,导致接口偶发500。

数字转字符串的方式很多:

String s1 = String.valueOf(123); String s2 = Integer.toString(123); String s3 = "" + 123;

"" + 123在底层其实会编译成StringBuilder的append调用,代码写起来顺手,但如果是循环里的大量拼接,还是直接用StringBuilder更稳。String.valueOf有一个非常隐蔽的坑,传null时要特别小心:

String.valueOf((Object) null); // 返回 "null" String.valueOf((char[]) null); // 抛NullPointerException

因为String.valueOf有char[]的重载版本,传入null时编译器会匹配到char[]重载,方法内部解引用后直接NPE。这种问题用Objects.toString(obj, "default")可以规避,这也是我在代码审查时必提的一个点。

4.2 char数组、字符串逆序与经典题"同构字符串"

字符串和char[]的互转也是高频操作:

char[] chars = s.toCharArray(); // String -> char数组 String str = new String(chars); // char数组 -> String

为什么会有这个需求?一方面char[]可以直接修改任意下标元素,实现String做不到的"原地修改";另一方面,字符串逆序、字符统计、同构判断等算法题,用char[]操作比一堆substring直观得多。

字符串逆序,最简单的是用StringBuilder:

String reversed = new StringBuilder(s).reverse().toString();

手写逆序也不难,核心是双指针交换:

public static String reverse(String s) { char[] arr = s.toCharArray(); for (int i = 0, j = arr.length - 1; i < j; i++, j--) { char tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return new String(arr); }

这里考你的是对不可变String的理解:中间过程完全可以在char[]上操作,最后再一次性生成新String。

跟数组、字符串都沾边的经典题,我提一题LeetCode 205同构字符串。题目描述是:有两个等长的字符串s和t,如果存在一种字符映射关系,让s中的每个字符可以唯一映射到t中的对应字符,则称它们同构。这个题用数组做映射比用HashMap更快,因为字符范围有限:

public boolean isIsomorphic(String s, String t) { int[] mapS = new int[256]; int[] mapT = new int[256]; for (int i = 0; i < s.length(); i++) { char sc = s.charAt(i); char tc = t.charAt(i); if (mapS[sc] != mapT[tc]) { return false; } mapS[sc] = i + 1; mapT[tc] = i + 1; } return true; }

这个思路的精髓在于双向校验:不仅要看s里的字符能不能映射到t,还要看t里的字符是不是被重复映射。用i + 1而不用i,是为了区分"没有被映射过"和"映射位置在0"两种情况。这个题的数组下标设计能很好体现"用数组替代Map"的思维方式——面试官会因此高看你一眼。

还有一个高频考题是判断字符串里是否包含"非字母和数字"的字符,可以用Character.isLetterOrDigit()逐字符判断,也可以用正则matches(".*[^a-zA-Z0-9].*")。我建议在性能敏感场景用前者,正则表达式虽然写起来快但回溯开销大。

5. 易错点集中盘点与排查实录

5.1 数组类易错点快查

这一节是全文的浓缩精华,我把实际开发和面试里反复出现的坑整理成速查表,建议直接收藏:

易错场景错误示范正确做法原因
比较数组内容相等arr1.equals(arr2)Arrays.equals(arr1, arr2)数组没有重写equals
打印数组内容System.out.println(arr)Arrays.toString(arr)默认输出类名@哈希值
比较二维数组Arrays.equals(m1, m2)Arrays.deepEquals(m1, m2)元素是数组,需要深比较
用Arrays.asList包装基本类型数组Arrays.asList(intArr)用Stream或循环装箱整个数组被当成一个元素
对Arrays.asList结果执行addlist.add(x)外面再包一层new ArrayList<>返回内部定长List
声明了数组不初始化就访问元素new String[3]后arr[0].length()先逐个初始化再使用引用类型数组默认null
访问new int[0]的下标0int[] e = new int[0]; e[0] = 1;访问前判断length没有任何元素,必然越界

数组越界有个很容易忽视的场景:for循环里边界用了<=而不是<,最后一遍肯定抛ArrayIndexOutOfBoundsException。排查这类问题不要只盯着逻辑看,先把数组的实际长度打印出来,很多时候是上游传进来的数组比预想短。

5.2 字符串类易错点快查

字符串的坑分布得更隐蔽,我把它们集中列出来:

易错场景错误示范正确做法原因
判断字符串相等s1 == s2s1.equals(s2)==比较引用地址
split后丢失末尾空串"a,b,".split(",")"a,b,".split(",", -1)默认丢弃末尾空字符串
混淆replace和replaceAllstr.replaceAll(".", "-")只想替换字面量,用replacereplaceAll按正则解析,.匹配任何字符
忽略空字符串直接parseInteger.parseInt("")先trim()和判空空串无法转数字,抛NumberFormatException
StringBuilder在循环里重复创建循环内new StringBuilder()循环外创建,复用一个实例频繁创建对象浪费内存
把字符串当C语言处理找'\0'结束符使用charAt和length()Java字符串没有结束符

关于split丢空串,我详细解释一下。"a,b,".split(",")返回的是["a", "b"],不是["a", "b", ""],因为Java的split默认会移除末尾空字符串。如果需要保留,用负数limit,split(",", -1)就返回["a", "b", ""]。这个细节在处理CSV行、日志分隔符时是真的会踩。

replace和replaceAll的区别也很容易翻车。replace(CharSequence, CharSequence)两个参数都是字面量;replaceAll(String, String)和replaceFirst(String, String)的第一个参数是正则。所以当你写str.replaceAll(".", "-")想替换点号时,.被当成正则通配符匹配所有字符,整个串可能全变成-。正确写法是str.replace(".", "-")或str.replaceAll("\\\\.", "-")。我见过不止一次因为这种低级错误导致生产日志错乱。

再补充一个跟C/C++转Java的人有关的知识点:C语言的字符串用'\0'作为结束符,所以字符串长度需要遍历到结束符才能算出来。而Java的String内部维护了length变量,获取长度是O(1)操作,不需要也没办法靠结束符判断。如果你在JNI、协议解析里看到类似字节数组末尾的0,那是业务自定义的结束标志,跟Java String本身没关系——这里的核心区别是"C字符串是字符数组的语法糖,Java字符串是带长度信息的对象"。

最后一个小技巧,排查字符串问题时,尽量多利用debugger查看String内部的value数组。很多人遇到诡异的字符串内容,要么是编码问题,要么是隐藏了不可见字符(比如换行、零宽空格)。在IDE的Debugger里直接展开String对象,看value数组对应的ASCII码,比肉眼盯着控制台判断靠谱得多。

我个人在实际操作中的体会是:数组和字符串知识点不难,难在细节太多、太碎,而且每一个细节在面试或线上问题里都可能变成大坑。建议你看完这篇文章后,不要只记结论,自己动手把每个易错场景写成一个小的测试类,在IDE里跑一遍看看报什么错、输出什么结果。把这些坑亲手踩一遍,印象比任何文章都深。后续如果这块还有想深入的内容,比如Stream API对数组和集合的流式操作、IO里字节数组和String的编码转换,我可以再挑一个主题继续写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询