☰
Java常用类深度解析:String、包装类与日期时间API的避坑指南
2026/10/12 3:59:06 网站建设 项目流程

做Java开发这几年,我面试过不少人,也带过不少新人,发现一个挺有意思的现象:问框架、问中间件,大家都能聊几句,一落到String、包装类、日期时间这些“常用类”,反而开始含糊。说白了就是平时天天在用,却从来没认真想过它们底层是什么、边界在哪、什么时候会出事。

这篇文章聚焦Java里最基础也最高频的那几类——String与字符串拼接三兄弟、包装类、Math/Arrays/System这几个静态工具类,以及日期时间API。内容不追求把API背全,重点放在“为什么这么设计”和“实际开发怎么用才不出事”上。刚学完语法的小白可以把它当系统梳理,工作一两年的同学也能查漏补缺,顺便看看有没有踩过我踩过的坑。

1. 先搞清楚:什么样的类才算“常用类”

1.1 常用类不是API手册,是编程思维的载体

很多同学学常用类就是背方法:String有哪些方法、Integer有哪些方法、Math有哪些方法,背完就忘。原因很简单,没抓住本质。Java把内置类分门别类放在不同包里,java.lang是语言核心,java.util是工具集合,java.time是时间处理,java.text是文本格式化。这些包里的类之所以被单独拎出来讲,不是因为方法多,而是因为承载了几个贯穿整个Java体系的编程概念。

  • 不可变性。String、包装类都是不可变对象,这个特性直接影响并发安全、缓存设计和性能优化思路。
  • 池化与缓存。字符串常量池、包装类的缓存区间,都是JVM层面的经典设计,理解了它们才能解释很多“反直觉”现象。
  • 自动装箱与拆箱。编译器帮我们做的语法糖,背后藏着性能代价和比较陷阱,线上NPE有一半跟它有关。
  • 工具类静态方法设计。Math、Arrays、System让你不new对象也能高效干活,省去重复造轮子。

说白了,常用类是你从“会写Java”到“理解Java”之间必经的一段路。这些概念不弄明白,后面读集合源码、调JVM参数、排查线上问题都会很吃力。我见过不少人拿着几年的开发经验,却连“String为什么不可变”都说不利索,往往就是基础阶段跳过了这一课。

1.2 它们在项目里到底有多常见

我粗略统计过自己维护的一个业务项目中import最多的Java包,排前面的永远是java.lang、java.util、java.time。再回头看看日常代码:日志用String拼、参数用Map装、时间用LocalDateTime记、唯一ID用UUID生成——几乎每行都离不开常用类。也正因如此,常用类出问题的影响面特别大。

一个String拼接没用对,可能拖慢一个接口;一个SimpleDateFormat共享给多个线程,可能直接让时间数据错乱;一个Integer用了==比较,可能在某次数据量变化后突然出现诡异bug。基础类的问题不是“少见”,而是“常见但隐蔽”。这也是我把它们单独拎出来讲的原因,越是基础的东西,越值得花时间夯实。

2. String:最熟悉的陌生人

2.1 不可变性不是限制,是保护

String是Java里最典型的不可变类:类被final修饰,内部字符数组也是final的,Java 9之后还改成了byte数组存储,配合压缩字符串特性节省内存。这意味着一个String对象一旦创建,内容就永远不变。很多初学者觉得这个设计“碍事”,改个字符串还得重新赋值,实际上这是三层保护。

第一是安全。字符串经常充当类名、文件路径、数据库连接地址、网络地址,如果String可变,恶意代码就能在运行时篡改这些关键标识,造成难以预估的问题。第二是线程安全。不可变对象天然适合多线程共享,多个线程拿着同一个String随便读,不需要加锁,也不会出现数据竞争。第三是性能复用。正因为不可变,JVM才敢放心搞字符串常量池:相同内容的字符串复用同一个对象,省内存、省比较时间。如果String可变,池子里一个对象被改了,所有引用它的地方全部中招,那这个池子根本不敢开。

我见过不少人在代码里用replace、substring、concat,误以为“改”了原字符串,其实每次都是返回新对象,原对象纹丝不动。明白这一点,很多“奇怪”的现象都能解释清楚。比如一段循环里反复replace,结果内存涨得飞快,就是因为每次操作都产生新对象,旧对象还没来得及回收。

2.2 equals和==:翻车率最高的比较方式

新手最容易踩的坑就是拿==比较字符串。Java里==比较的是引用地址,而不是内容。看这段代码:

String a = "hello"; String b = "hello"; System.out.println(a == b); // true,常量池复用 System.out.println(a.equals(b)); // true,内容相等 String c = new String("hello"); System.out.println(a == c); // false,new出来的新对象 System.out.println(a.equals(c)); // true

同一个“hello”,a和b因为字面量相同,在常量池里复用了同一个对象,所以==是true;c用new创建,强制在堆上造了一个新对象,==就变成false。但equals比较的是内容,始终是true。这个例子我面试必问,能一次答对的人真不多。

所以规规矩矩一条:比较字符串内容一律用equals,绝不用==。如果只是判断一个字符串是不是null,那可以用==,因为null比较恰恰要比较引用。顺带提一句,equals也有讲究。判断一个字符串是否等于某个常量时,把常量放前面:

if ("success".equals(status)) { ... }

这样即使status是null,也不会抛空指针,最多返回false。反过来写status.equals("success")就很容易中招。

2.3 字符串常量池与intern

字符串常量池是JVM专门存放字符串字面量的地方。用双引号写的字符串会去池里找,有就直接用,没有就放进去,这就是“复用”的底层机制。但有个方法很多人听过却用不明白:intern()。它的作用是把一个运行期创建的字符串“塞回”常量池,并返回池中的引用。

String a = new String("abc"); String b = a.intern(); System.out.println(a == b); // false,a还在堆上,b指向常量池 String c = "abc"; System.out.println(b == c); // true

现实中我不建议在业务代码里滥用intern,因为常量池不是无限大的,乱塞字符串反而增加负担,甚至影响GC。知道有这回事、面试能说清楚就行,实际写代码用equals最稳。真要省内存,先想想自己是不是存了太多重复字符串,从源头控制比事后intern靠谱得多。

3. 字符串拼接三兄弟:+号、StringBuilder、StringBuffer

3.1 +号底层到底发生了什么

很多人写拼接特别喜欢用+,好看又省事:

String msg = "用户:" + name + ",登录成功";

这条语句看着简单,实际上编译器会把它优化成StringBuilder的append调用链。如果你用的IDE有反编译功能,可以亲眼看一下。Java 8之后,在大多数情况下,用+拼接少量字符串并不会明显拖慢性能,编译器已经帮我们兜底了。

但注意,这只是“单条语句”的优化。一旦拼接发生在循环里,编译器没法把new StringBuilder提到循环外,于是每一轮循环都会new一个StringBuilder,再toString生成新字符串,老字符串变成垃圾,性能直接崩给你看。我排查过一个慢接口,最后定位到的就是一段几百次循环的+=拼接,当时就一个感觉:经典。

3.2 StringBuilder和StringBuffer的真实差异

StringBuilder和StringBuffer的方法几乎一模一样,都是append、insert、delete、reverse这些,唯一的关键差异是线程安全,我用表格整理了一下。

特性StringBuilderStringBuffer
线程安全否是,方法加了synchronized
性能更高,无锁开销每次调用都有同步成本
适用场景局部变量拼接,绝大多数业务场景极少见的多线程共享可变字符串

实际开发里,绝大多数拼接场景都是方法内部的局部变量,根本不存在多线程竞争。我见过有老项目为了“稳妥”全程用StringBuffer,接口一压测就发现性能比预想差不少。其实局部变量拼接,无脑StringBuilder就行,别给自己加戏。

那什么时候用StringBuffer?只有当同一个字符串构建对象真的会被多个线程同时操作时才需要。但说实话,这种场景非常少见,更好的做法是让每个线程持有自己的局部变量,根本不需要共享一个可变构建器。

3.3 循环拼接的正确姿势与容量问题

循环拼接的正确写法很简单,把构建器提到循环外面:

StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i).append(","); } String result = sb.toString();

另外一个容易忽略的细节是初始容量。如果预先知道最终长度大概是多少,new StringBuilder(1024)这种写法能减少扩容次数。StringBuilder底层是字符数组,扩容逻辑和ArrayList类似,默认容量16,不够了会扩容并复制原内容,频繁扩容也是成本。提前给容量是个好习惯,量级小的时候无所谓,但配合循环拼接大数据量时差距非常明显。

我曾经给一个报表导出接口做过优化,就是把几十处字符串拼接统一改成带初始容量的StringBuilder,接口耗时直接降了将近一半。别小看这些细节,在高频调用下,每一个对象的创建和复制都会被放大。

4. 包装类:自动装箱没你想的那么简单

4.1 为什么需要包装类,以及它的代价

Java是面向对象语言,可基本类型int、double、boolean这些并不是对象,没法放进集合,也没法泛型化。为了解决这个矛盾,Java给每个基本类型配了一个包装类:int对应Integer、double对应Double、boolean对应Boolean,等等。有了包装类,基本类型和对象之间还能自动转换,这叫自动装箱和拆箱。

Integer num = 100; // 自动装箱:int转Integer int value = num; // 自动拆箱:Integer转int

写起来很舒服,但代价是每次装箱都会创建对象(有缓存的情况另说),而且拆箱可能抛空指针。开发里最典型的翻车现场是这种:

Integer count = null; if (count > 0) { // 这里直接NPE ... }

count是null,拆箱成int的时候直接抛NullPointerException。我排查过不少线上NPE,有一半就是这种“包装类参与计算”导致的问题。原则就一条:包装类参与运算前,必须先判空。尤其从数据库或接口拿到包装类字段时,别想当然认为它一定有值。

4.2 Integer缓存:-128到127的秘密

自动装箱不是每次都会new对象。Integer有一个缓存机制:-128到127之间的整数,装箱时直接复用预先创建的对象。看代码:

Integer x = 100; Integer y = 100; System.out.println(x == y); // true,都在缓存里 Integer m = 200; Integer n = 200; System.out.println(m == n); // false,超出缓存范围,各自new

这个例子我面试必问,回答正确的人不超过一半。原因很简单,-128到127是JVM预先创建好的一批Integer对象,装箱时直接拿现成的;超出这个范围就老实new一个。缓存上限其实可以通过JVM参数调整,但一般没人动它。

这个坑的教训非常直接:比较包装类,永远用equals,别用==。别人写的代码里用==比较Integer,如果值落在缓存范围内可能碰巧正确,一旦超过127,结果就和你预期完全相反。这种bug特别恶心,因为不是必现,跟具体数据强相关。

4.3 比较包装类的正确姿势

包装类比较,一套正确姿势走天下:

Integer a = 200; Integer b = 200; System.out.println(a.equals(b)); // true,比较的是int值 Long id1 = 10000000000L; Long id2 = 10000000000L; System.out.println(id1.longValue() == id2.longValue()); // true,转基本类型再比较

开发里封装返回Long类型的id是常态,比较两个Long时我建议用equals,或者先转longValue再==,直接==几乎必坑。还有一个细节:两个不同类型的包装类比较,比如Integer和Long,equals会直接返回false,因为类型不同。这时候先把它们转成同一种基本类型再比。别嫌麻烦,这些规则记牢了,能帮你省掉大量排查时间。

5. Math、Arrays、System:三个被低估的工具类

5.1 Math的边界情况与精度陷阱

Math类提供一堆静态数学方法:abs、ceil、floor、round、max、min、pow、sqrt、random。日常用起来很简单,但边界情况坑人。最经典的是abs,绝对值有个著名特例:

System.out.println(Math.abs(Integer.MIN_VALUE)); // 还是负数 -2147483648

因为int能表示的范围是-2147483648到2147483647,MIN_VALUE取绝对值后在int范围内没有对应正数,结果就是它自己。这就是溢出问题。同理Math.abs(Long.MIN_VALUE)也一样。所以我在代码里取绝对值前会先判断一下,或者改用long承接,避免边界数据触发溢出。

还有round和floor的差别。Math.round(1.5)结果是2,Math.round(-1.5)结果是-1,因为round对负数不是简单的四舍五入,很多人在这里算错过。想要稳定的向下取整就老老实实用floor,想要向上取整就用ceil,别靠round硬凑。

5.2 Arrays的常用套路和坑

Arrays是操作数组的神器:sort排序、binarySearch二分查找、copyOf复制扩容、fill填充、equals比较数组内容、toString打印数组。我代码里最常用的是转List:

List<String> list = Arrays.asList("a", "b", "c");

但这个asList有个大坑,很多人不知道:它返回的不是java.util.ArrayList,而是Arrays内部的一个固定长度List,不能add也不能remove,一调就抛UnsupportedOperationException。而且它内部直接引用了原数组,改数组元素,List跟着变。

正确的创建方式是这样:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

Java 9之后还有List.of(),直接返回不可变列表,也不能改,适合那种“确认不变”的场景。记住一点:凡是返回不可变集合的工厂方法,都不要试图往里塞元素,否则运行时才知道错。

5.3 System类里藏着的实用方法

System类最出名的是System.out和System.err,但它还有几个业务开发里的宝贝方法。System.currentTimeMillis()返回当前时间的毫秒时间戳,是最底层的时间来源,几乎所有时间操作最后都追溯到它。System.nanoTime()则是高精度计时器,适合做性能基准测试,测量的是从某个固定点开始的时间差,不是墙上时钟,不能拿它当时间戳用。

System.arraycopy是高效的数组复制方法,底层是native实现,复制大数组时性能比手写for循环高一个量级。还有System.gc(),我的建议是永远不要主动调用。你以为在优化内存,实际是在干扰JVM的GC决策,可能引发更频繁的Full GC。JVM自己知道什么时候该回收,别替它操心。

6. 日期时间:用对API,少踩一半坑

6.1 Date和Calendar为什么让人头疼

老Java程序员都经历过Date和Calendar的时代。Date类本身是可变的,setTime随手一改,其他持有这个对象的地方全被带跑偏。Calendar更别提了,月份从0开始算,一月是0不是1,不踩个三五次坑根本记不住。

所以Java 8提供全新的java.time包之后,我的态度很明确:新代码一律用LocalDateTime、LocalDate、LocalTime、Instant这套新API,老代码遇到再顺手迁移。别再抱着旧工具不放了,它不是“经典”,它是“历史包袱”。

6.2 SimpleDateFormat的线程安全陷阱

讲一个真实翻车例子:某项目里把SimpleDateFormat定义成static全局变量,方便到处用。结果上线之后偶发性出现时间格式错乱,有些日志的时间莫名其妙多了几分钟。排查了很久,最后定位到就是多线程并发调用同一个SimpleDateFormat导致的。原因在于SimpleDateFormat内部维护了Calendar状态,parse和format过程会读写共享状态,不是线程安全的。

解决办法有两种。第一种,每次使用时new一个,简单但浪费。第二种,用ThreadLocal包装,每个线程一份:

private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

新代码我更推荐直接上DateTimeFormatter,它本身就是不可变且线程安全的,定义成static毫无压力,设计上就把这个问题消灭了。能用新API解决的事,就别再给旧API打补丁。

6.3 LocalDateTime的正确打开方式

新API的核心用法其实很直观:

LocalDateTime now = LocalDateTime.now(); LocalDateTime time = LocalDateTime.of(2024, 5, 1, 10, 30, 0); LocalDate date = LocalDate.parse("2024-05-01"); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String text = now.format(formatter); LocalDateTime parsed = LocalDateTime.parse("2024-05-01 10:30:00", formatter);

加一天、减一小时、比较先后,都是链式调用,语义清晰不会搞混。数据库字段类型和Java类型的对应关系,我建议记这张表:

数据库字段类型Java类型建议
datetimeLocalDateTime
dateLocalDate
timestampInstant 或 OffsetDateTime

这套映射在主流ORM框架和JDBC驱动里都支持得很好,不用再纠结类型转换。另外,跨时区的项目一定要用带时区的类型,别用LocalDateTime硬扛。LocalDateTime本身就“没有时区概念”,你把它理解成“墙上挂钟显示的时间”就行,换时区场景必须上ZonedDateTime或Instant。

7. 实际项目里我没少为这些坑买单

7.1 集合类里也有“常用类”的坑

虽然集合类是另一个大话题,但有几个和常用类交叉的坑必须说。ArrayList和HashMap都建议给初始容量。HashMap的默认容量16,负载因子0.75,意味着到12个元素就开始扩容,扩容要重新计算哈希、重新放桶,很贵。如果你知道大概要存多少条,直接new HashMap<>(expectedSize)能省很多事。

还有HashMap的key,用可变对象当key是灾难:对象内容变了,hashCode变了,存进去之后取不出来,等于数据“丢了”。所以key尽量用String、Integer这种不可变类,这也是常用类“不可变设计”在集合里的呼应。你会发现,理解了不可变性,很多看似无关的知识点其实是连在一起的。

7.2 split和replaceAll的正则陷阱

String的split和replaceAll都接收正则表达式,这是个高频坑点。想按点号分割字符串,直接写"a.b.c".split(".")是错的,因为"."在正则里是任意字符,结果是个空数组。得写成"\."。同理,replaceAll的第一个参数也是正则,如果字符串里包含$和\这样的符号,替换会出问题。

我遇到过一个场景,要把用户输入的模板里的${var}替换成实际值,$在正则替换里是特殊字符,直接replaceAll("${var}", value)根本不对,得用Matcher.quoteReplacement处理。这类问题排查起来特别费劲,因为结果偶尔才不对。建议团队里约定:按固定字符串分割用split,按固定字符串替换优先用replace(不带All那个),它不接正则,纯字面量替换,能少很多幺蛾子。

7.3 一个基础类体检清单

最后分享一个我自己总结的基础类代码体检清单,新代码Review和旧代码重构时对照着看,能拦住大部分问题:

  • 字符串内容比较是否用了equals,常量是否放在前面。
  • 循环里有没有+=拼接字符串,StringBuilder有没有建在循环里。
  • 包装类参与运算前是否判空,包装类比较是否用了equals。
  • SimpleDateFormat是不是static共享的,新代码是否换成了DateTimeFormatter。
  • 日期字符串解析是否用了正确的时区。
  • split和replaceAll的参数是否需要转义。
  • HashMap、ArrayList创建时是否能预估容量。

我这些年排查过的线上问题,很大一部分都能落到这个清单上。这套清单是我自己踩坑踩出来的,不是从文档里抄来的。基础类看着不起眼,但正是这些不起眼的地方,决定了代码在高并发、大数据量下能不能扛得住。我自己的习惯是,每次写完涉及这些基础类的代码,都主动过一遍这份清单,别嫌麻烦,一次排查的功夫顶得上十次加班。

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

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

立即咨询