不少初学者把Java基础学完之后,反而陷入一种奇怪的迷茫:语法书翻了好几遍,switch能背、for循环能写、继承多态也弄明白了,但真到了要写点实际功能的时候,突然不知道该用哪个类、哪个方法。比如要处理一段文本,到底是先new一个String还是直接拼接?要算个随机数,是Math.random()还是Random类?要格式个日期,SimpleDateFormat被线上告警打脸好几次。这些让人纠结的问题,其实都指向同一个知识点——Java基础里那个最容易被低估的“常用类”。
这篇内容我打算把日常开发里出场率最高的一批常用类拉出来过一遍:字符串三兄弟(String/StringBuilder/StringBuffer)、包装类与自动装箱、Math/Arrays/Objects等工具类,还有新旧日期API的演进。每个部分都结合真实业务里的使用场景来讲,尽量讲透“为什么”,而不是只列方法清单。适合刚学完Java语法、准备系统过一遍基础知识的初学者,也适合正在刷Java面试题、想查缺补漏的开发者。看完你会发现,常用类不只是API的堆砌,它背后是一套很讲究的设计哲学。
1. 先把常用类放在全局看:为什么“会用”不等于“懂用”
1.1 从JVM角度看常用类的存在价值
任何一个Java程序跑起来,背后都有一个JVM在管理内存、加载类、执行字节码。常用类看起来只是一个个封装好的工具,实际上它们从设计之初就要考虑JVM的执行效率。拿最常见的String来说,它内部用一个被final修饰的byte数组存数据,这个设计直接决定了String不可变。为什么不可变?因为在JVM里,String对象经常被放进字符串常量池,如果String可变,那常量池里共享同一个字面量的多个变量就可能互相污染。你想想,两个变量引用同一个字符串,一个把内容改了,另一个也跟着变,这程序基本没法写。
再比如包装类Integer,它在JVM里并不是每次new都会创建新对象。JVM规范允许对-128到127范围内的整数进行缓存,这个范围内的Integer对象在JVM启动时就有现成的,你直接拿来用就行。这就解释了为什么Integer a = 127; Integer b = 127; a == b返回true,而Integer c = 128; Integer d = 128; c == d返回false。这不是Bug,是设计者在性能和语义之间做的一个微妙平衡。
理解这层关系很重要。你只有站在JVM的角度去看常用类,才能真正想明白很多面试题和踩坑案例的底层原因。否则你只是记住了结论,换个场景照样该错还是错。
1.2 常用类之间如何配合完成一次真实业务调用
单独聊每个类容易显得零散,我们先看一个真实场景:一个用户注册接口,前端传过来一个字符串类型的手机号,后端需要校验它是不是11位、是不是全是数字、去除首尾空格,最后转成long类型存进数据库,再在日志里输出格式化后的时间戳。
这个场景里已经用到了好几个常用类:String的trim()和matches()方法处理格式、Integer或Long的parseLong解析、Math或BigDecimal处理数值,还有日志里日期格式化要用到的DateTimeFormatter。把这些类串起来看,你会发现它们之间存在清晰的协作关系:有的负责清洗数据,有的负责类型转换,有的负责计算,有的负责包装成对象方便集合存储。这正是学习常用类的正确姿势——不是孤立地背每个类有哪些方法,而是知道一条数据流经过哪些类、每一步在做什么转换。
2. 字符串家族:String、StringBuilder、StringBuffer到底怎么选
2.1 String不可变的真相:从字节数组到常量池
Java面试里有一个经久不衰的问题:String为什么设计成不可变?要回答这个问题,得先知道String在JDK 8之前用char数组存储,JDK 9开始用byte数组存储,目的是节省空间——很多英文字符根本用不到两个字节,用byte数组配合编码标记可以省一半内存。数组被final修饰意味着引用不可变,但真正让String不可变的是它没有提供任何修改内部数组的方法,所有看似修改的操作,比如replace()、substring(),底层都是新建一个String对象。
不可变带来的好处是实打实的。第一,线程安全,多个线程同时读同一个字符串没有任何问题,不需要加锁。第二,适合作为HashMap的key,因为它的hashCode可以缓存,而且保证不会变。第三,字符串常量池可以放心复用同一个字面量对象。坏处也很明确——每次修改都产生新对象,尤其在循环里做字符串拼接,会产生大量中间垃圾对象,触发频繁GC。
我举个例子说明这个问题的严重性。你在一个for循环里写str += item;,循环1000次,就会产生1000个中间String对象,旧的都被丢掉等GC回收。如果这个循环还在一个高频接口里,系统性能会被明显拖垮。这就是为什么性能敏感的代码里,字符串拼接要用StringBuilder。
2.2 拼接字符串的陷阱与StringBuilder的正确打开方式
日常写代码,字符串拼接是避不开的操作。java里拼字符串有几种方式:直接加号、String.concat()、StringBuilder.append()、StringBuffer.append()。这四者在性能和语义上差异巨大。
直接加号看起来最方便,编译期其实也做了优化。如果是在一个表达式里常量拼接,比如"a" + "b" + "c",编译器直接帮你算成"abc"。但如果是变量拼接,比如str + var,编译器会生成一个StringBuilder对象然后调用append方法。听起来好像编译器已经帮你转成StringBuilder了,但实际上每次循环迭代都会new一个新的StringBuilder,所以循环内拼接依然会产生大量无用对象,只是从大量String对象变成了大量StringBuilder对象而已。
StringBuilder和StringBuffer的差别只有一个:StringBuffer的方法加了synchronized修饰,线程安全但性能略差。在单线程环境下,没有任何理由用StringBuffer。所以我个人写业务代码,除了少量拼接直接用加号,循环或批量拼接一律用StringBuilder。这里有个小技巧:如果你大概知道最终字符串的长度,可以用new StringBuilder(initialCapacity)指定初始容量,减少扩容次数。在JDK里,StringBuilder扩容规则是旧的capacity乘2再加2,扩容需要复制数组,是很浪费的操作。
2.3 面试高频:equals与intern的细节
字符串比较是另一个经典坑。在Java里,用==比较String比较的是引用,用equals比较的是内容。这个大多数人知道,但很多人掉进过另一个坑:String s1 = "hello"; String s2 = new String("hello");这两个对象并不相同,s1 == s2是false。原因很简单,s1指向常量池里的对象,s2是堆里手动new出来的对象,地址不同。
还有个intern()方法,它能把堆里的字符串放入常量池并返回常量池里的引用。在JDK 7之前,这个操作有可能把String对象复制到永久代,性能较差。JDK 7之后常量池移到了堆里,intern实现也简化了,就是往一个map里查一下,存在就返回已有引用,不存在就放进去。说实话,日常业务开发里intern的使用场景不多,但面试经常问,尤其会跟==混在一起出题。你只要记住一句话:==比较的是内存地址,equals比较的是字符序列本身,intern可以主动把对象“注册”进常量池换取复用,就够了。
3. 包装类与自动装箱:当基本类型“变成”对象之后
3.1 为什么需要包装类:泛型、集合与null
Java是一门面向对象语言,但基本类型int、double、boolean并不是对象。这导致很多场景下基本类型没法直接用。比如泛型容器List< Integer >,泛型参数必须是引用类型,你不能写List ,因为泛型在编译时会做类型擦除,基本类型不具备被Object引用的能力。再比如HashMap的value可以是任何对象,null作为合法的“空值”语义,基本类型int根本不可能表示“没有赋值”这种状态,默认值0和null含义完全不同。
这套设计的原因在于,基本类型追求的是性能和简单,对象追求的是能力和结构。包装类Integer、Double、Boolean就是把基本类型包一层,让它能参与到面向对象的体系里。Java提供了8个包装类和基本类型一一对应。有了它们,int可以放进集合、可以为null、可以调用方法,这就是自动装箱存在的意义。
自动装箱和拆箱是Java 5引入的语法糖。你写Integer num = 100;,编译器在背后帮你调用Integer.valueOf(100)。你写int x = num;,编译器调用num.intValue()。看起来很方便,但如果不了解底层实现,容易在性能上吃亏。比如在一个循环里频繁做装箱拆箱操作,会产生大量中间对象。如果是写算法题或者高性能模块,建议直接用基本类型。
3.2 缓存范围与比较陷阱:128这个数字的教训
包装类里最典型的坑就是Integer的缓存机制。前面提到JVM会缓存-128到127范围内的Integer对象,这个范围还允许通过JVM参数调整上限。在这个范围内,Integer.valueOf(127)返回的都是同一个缓存对象,而超过这个范围,valueOf会new一个新对象。这个设计的本意是好的,因为小整数在业务里出现频率极高,缓存可以减少对象创建。
问题来了,你在代码里写Integer a = 127; Integer b = 127;然后拿a == b比较,结果true,你心想没问题。后来数据变成Integer a = 128; Integer b = 128;同样写a == b,结果false。你可能会一脸懵,觉得自己没有改变写法,为什么结果变了。实际上你没变,变的是数据的大小越过了缓存边界。这个坑在开发中极其常见,尤其是从数据库或第三方接口取值,数值不可控的时候。
正确的做法很明确:包装类之间一律用equals比较,不要用==。不要觉得自己能记住缓存范围就可以侥幸用==,因为Integer、Long、Short、Byte都有缓存,但Character的缓存范围是0到127,Boolean只有两个常量TRUE和FALSE,Double和Float完全没有缓存。你记不住那么多边界,不如统一用equals,一劳永逸。
3.3 实际开发中的数值转换正确姿势
包装类的另一个核心用途是字符串和数值之间的转换。最传统的方式是Integer.parseInt("123"),返回基本类型int;Integer.valueOf("123"),返回Integer对象。如果字符串格式非法,这两个方法都会抛出NumberFormatException。这是我在实际开发里见过最多的异常之一,几乎都来自第三方传参没有校验就直接parse。
后来Java 8引入了Optional和Stream之后,这种转换有了一种更优雅的写法。比如从Map里取一个字符串类型的数量字段,期望把它转成int,但可能导致异常,你可以这样写:
Map<String, Object> param = new HashMap<>(); String countStr = String.valueOf(param.getOrDefault("count", "0")); int count = Optional.ofNullable(countStr) .filter(s -> s.matches("\\d+")) .map(Integer::parseInt) .orElse(0);这段代码先保证字符串非空,再用正则确认是数字,最后才parseInt,解析失败就用默认值0。虽然比三行if判断长一点,但语义更清晰,也避免了异常处理里的样板代码。这个写法在我的项目里已经成了标配。顺便提醒一下,使用Double.parseDouble处理带小数点的字符串时,要注意"1.0"这种格式是可以的,但"1,0"不行,逗号分隔符是NumberFormat不可接受的。
4. 高频工具类:Math、Arrays、Objects、Collections的妙用
4.1 Math类:不只是算个平方根
Math类里的方法全是静态的,日常开发几乎天天碰到。面试里经常问的Math.round()和Math.floor()区别,这里也顺带说清楚。Math.round()是四舍五入取整,内部实现实际上是(long)Math.floor(a + 0.5d),所以对正数来说就是四舍五入,对负数要特别注意。比如Math.round(-1.5)结果是-1,而不是你想象的四舍五入到-2。原因是-1.5加0.5等于-1.0,floor后是-1。这个细节很多人踩坑。
Math.random()返回[0.0, 1.0)之间的double,要生成某个范围的随机整数,标准写法是int num = (int)(Math.random() * (max - min + 1)) + min。但说实话,现在业务里很少直接用Math.random了,更推荐用java.util.Random或者ThreadLocalRandom,后者在多线程环境里性能更好、线程隔离,没有竞争冲突。
Math里还有一个容易被忽略的方法:Math.multiplyExact()。它能在乘法溢出时抛出ArithmeticException,而不是悄悄返回一个错误结果。如果你处理的是金额、数量这类不允许出错的数值,这个方法比a * b安全得多。类似的还有addExact、subtractExact和toIntExact,都是带溢出检测的。我个人在做数值校验逻辑时比较喜欢用这套方法,能把本来要写的溢出判断代码省掉。
4.2 Arrays工具类:排序、填充、拷贝三板斧
Arrays是所有数组操作的集合地。它提供的方法很杂,但核心场景就几个:排序、查找、拷贝、填充和转集合。
排序最常用的是Arrays.sort(),它对基本类型数组使用双轴快速排序,对对象数组使用TimSort,两种都是时间复杂度O(n log n)的稳定高性能算法。如果你想给数组按自定义规则排序,可以传一个Comparator,比如Arrays.sort(arr, (a, b) -> b - a)实现降序。但注意,比较器里返回负数表示a在前,正数表示b在前,写反了排序结果就反了。
拷贝数组有几种方式:Arrays.copyOf()可以指定新数组长度,System.arraycopy()是native方法,性能更高,但需要手动管理源位置、目标位置和长度。实际开发中如果你只是要复制整个数组,直接Arrays.copyOf就行。如果要在原数组中间插入一段数据,用System.arraycopy来移动区域更合适,这也是ArrayList内部扩容时用的方法,说明它的性能在工业界是经过验证的。
还有一个高频操作是把数组打印成易读的字符串。直接用数组的toString()会输出[I@15db9742这种内存地址,完全没用。正确做法是Arrays.toString(arr),它会输出[1, 2, 3]这种风格。如果是二维数组,要用Arrays.deepToString()才能看到内层内容。这个知识点在排错时很实用,能少走很多弯路。
4.3 Objects工具类:从根源上预防空指针
Java 7开始提供Objects工具类,专门处理对象层面的通用操作。最出名的就是Objects.equals(a, b),它的完整版本是a == b || (a != null && a.equals(b))。它可以防止两个参数都是null时返回null,也可以防止调用a.equals(b)时a为null导致的NPE。
另一个高频方法是Objects.requireNonNull()。这个方法的用途是强制要求参数不为空,如果为空就直接抛出NullPointerException,并输出你设置的message。它在写构造器和公共接口时特别有用,可以提前暴露问题,而不是让异常从50层调用栈之后才冒出来。很多框架源码里都在用,比如Spring的Assert.notNull本质上也是这套思路。
还有Objects.isNull()和Objects.nonNull()。这两个方法配合Optional和Stream使用效果极佳。比如:
list.stream().filter(Objects::nonNull).map(String::trim)只用一行代码就把null元素全部过滤掉了。这在处理从外部接口获取的数据时能省掉大量if判断。几年写下来,我真心觉得Objects类虽然低调,但它在代码健壮性上的贡献远超很多花哨的功能。
4.4 Collections:集合的百宝箱
Collections和Arrays一样,是一个服务于集合的工具类。它最有名的方法包括排序sort、反转reverse、打乱shuffle、求最大值max/min,以及不可变集合unmodifiableList。
排序在集合上使用很频繁。Collections.sort(list)要求集合里的元素实现Comparable接口,或者你在外部传Comparator。这里要提醒一个点:排序方法会修改集合本身,而不是返回新集合。如果你不希望原集合被打乱,一定要先拷贝一份再排序。我见过不少同事在拿到一个list之后顺手sort,结果发现后续还要保持原顺序,最终只能重新查库。这种错误完全没有必要,拷贝的成本很低。
不可变集合是另一个容易被忽略的安全设计。当一个集合被作为公共配置传给多个模块时,如果不希望任何模块往里添加或删除元素,应该用Collections.unmodifiableList(list)包裹一层,任何修改尝试都会抛出UnsupportedOperationException。这个操作就像是给集合加了一把只读锁,能从设计层面防止潜在的数据污染。
5. 时间与日期:从Date到LocalDateTime的演进
5.1 老API的痛点:Date和Calendar为什么遭人嫌弃
在Java 8之前,处理日期时间主要靠java.util.Date和java.util.Calendar,这两个类可以说是Java历史上有名的设计失败案例。Date的很多方法,比如getYear(),返回的是当前年份减去1900,getMonth()是从0开始计数的,也就是说月明明是一月,返回值却是0。这种设计让大量开发者踩坑,每次取出月份都要手动加1。Calendar虽然稍微好一点,但月份依然是0到11,而且它是可变的,在多线程环境下需要自己额外加锁。
另一个让人头疼的问题是Date和Calendar都没有办法精确到一个带时区的偏移量。Date表面上是“一个时间点”,但它内部的毫秒数基于系统默认时区,你不管是格式化输出还是和其他时区的时间比较,都得手动处理时区偏移。更别提SimpleDateFormat,它是出了名的线程不安全,因为它的内部calendar字段在format和parse过程中是共享且可变的。多线程共用同一个SimpleDateFormat实例,输出结果会随机错乱,这是线上事故的重灾区。
5.2 新API的三大核心:LocalDate、LocalTime、LocalDateTime
Java 8引入的java.time包彻底解决了老API的这些问题。它的设计思路是:用不可变对象表示时间,所有操作都返回新对象,天然线程安全。核心类有三个:LocalDate只表示年月日,LocalTime只表示时分秒,LocalDateTime是它们的组合。
这些类的方法命名非常统一,几乎都是动词开头的明白词。获取当前时间用now(),构造指定时间用of(),调整时间用plusDays()、minusWeeks()、withYear()等。调整方式有两种,一种是plus/minus系列做加减,一种是with系列做替换,比如withHour(10)就把小时改成10点。这套API的优势在于,你很难用错,因为方法名已经把语义说得清清楚楚。
在实际业务代码里,最常见的操作是计算两个日期相差多少天。用老API处理这个需求通常要先把两个Date转成毫秒数再相减除以一天的毫秒数,代码又长又容易出错。用新API可以这样做:
LocalDate start = LocalDate.of(2024, 6, 1); LocalDate end = LocalDate.now(); long days = ChronoUnit.DAYS.between(start, end);一行调用就完事,而且语义明确,可读性极强。如果要计算两个LocalDateTime相差多少小时,就把ChronoUnit换成HOURS。整个过程不需要任何格式转换,不会踩时区的坑。这套API的底层实现还考虑了性能,要比重复使用SimpleDateFormat快得多。
5.3 格式化与解析:DateTimeFormatter的正确用法
新API的格式化工具是DateTimeFormatter,它是不可变且线程安全的,可以放心地用一个静态实例供全项目共用。比如我要统一日志里的时间格式,可以这样定义:
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");然后格式化输出就是LocalDateTime.now().format(FMT),不需要每次调用都new,也不会出现多线程错乱。反过来解析字符串,用LocalDateTime.parse("2024-07-11 15:30:00", FMT)就能得到对象。
还有一个容易被忽略的设计:LocalDateTime本身是不携带时区信息的,它只是年月日时分秒的“本地展示”。如果你要从一个带时区的字符串解析出具体时间,要用ZonedDateTime或者OffsetDateTime。比如一个跨境电商网站要显示海外用户的订单时间,存数据库用UTC时间,展示给用户时转换成用户所在时区。LocalDateTime就无法直接做到这一点,需要用ZonedDateTime.withZoneSameInstant(ZoneId.of("Asia/Shanghai"))。在分布式系统里,时刻要牢记:存储用UTC,展示按用户时区转换,这是时间和日期处理的基本原则。
6. 面试与开发中的高频疑难点:我的排错经验速查
6.1 一张表看清常用类的高频坑位
我把这些年实际开发里遇到过的、以及面试中经常考察的常见问题整理成一张速查表,方便你查阅和自查。
| 场景 | 错误示范 | 正确姿势 | 底层原因 |
|---|---|---|---|
| 字符串拼接 | 循环内使用+= | 用StringBuilder.append | 每次拼接产生新对象,影响GC性能 |
| 字符串比较 | 用==比较两个内容相同的字符串 | 用equals比较内容 | ==比较引用地址 |
| Integer比较 | 用==比较两个Integer对象 | 用equals或intValue | 缓存范围只有-128到127 |
| 日期月份 | 直接用Date.getMonth() | 用LocalDateTime.getMonthValue | Date月份从0开始 |
| SimpleDateFormat | 定义成static共用 | 用DateTimeFormatter | SimpleDateFormat线程不安全 |
| 数组打印 | 直接调用数组toString | 用Arrays.toString | 数组继承的是Object的toString |
| null判断 | 用a.equals(b) | 用Objects.equals(a,b) | a为null时直接NPE |
6.2 排查指南:从异常信息反推常用类用法
如果你在开发中遇到问题,不要去怀疑JVM“神经质”,先看看Stack Trace的第一行,通常会直接告诉你哪个类、哪个方法出了什么类型异常。比如NumberFormatException大概率是parse方法接收了一个非数字字符串,NPE大概率是某个返回null的对象被直接调用了方法,UnsupportedOperationException多半是对unmodifiable集合做了写操作。
我调试String相关的问题时,有一个屡试不爽的方法:先打印传入的字符串前后有没有隐藏字符,比如换行符、制表符或者全角空格。用Arrays.toString(str.toCharArray())或者str.codePoints()查看每个字符的码点,问题往往能一眼看出来。有一次排查一个用户提交手机号总是校验失败的问题,结果发现前端拼接时在手机号后面带了个零宽空格,肉眼看不到,但matches("\d{11}")就是匹配不上。这种问题不从底层字符码点入手,排查一个星期都不一定找到根源。
6.3 学习建议与个人心得
最后说点我在学习和带新人过程中的体会。常用类这个知识板块特别容易出现“看过就忘”的情况,原因在于方法实在太多,光靠背记根本记不住。我的经验是:从业务需求出发去驱动学习。你想实现“把字符串里的数字提取成List< Integer >”,就会主动去查Pattern是不是可以配合Matcher做全局匹配,查Integer.valueOf和parseInt的差异。带着问题学,记忆牢固得多。
而且我强烈建议你读一遍JDK源码,至少把String、Integer、Arrays这几个最核心的类源码过一遍。先不要觉得源码高深,直接打开IDE里的反编译文件,你就能看到String内部是怎么用byte数组的、Integer的缓存数组长什么样、Arrays.sort底层调用的排序算法是什么。源码读得多了,你会发现很多所谓的面试题根本不用背,你看到问题就能猜到它在考哪个实现细节。
结尾
写到这里,该聊的几个常用类都已经过了一遍。回想这些年带过的项目,百分之八九十的线上小问题都出自这些基础类的错误用法。尤其Integer的缓存陷阱、SimpleDateFormat的多线程污染、字符串循环拼接,这三个坑我几乎每年都要在代码评审里推翻重来好几遍。我个人的建议是,重点把String、Integer、LocalDateTime这三组类彻底吃透,它们是你日常编码中接触频率最高的对象。代码写多了你就会发现,基础类的熟练度决定了你代码质量的起点,不是写过多少框架,而是这些底层细节掌握得够不够扎实。如果这篇文章能帮你少踩几个坑,那这份分享就没白写。