
不少初学者把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 为什么需要包装类泛型、集合与nullJava是一门面向对象语言但基本类型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到127Boolean只有两个常量TRUE和FALSEDouble和Float完全没有缓存。你记不住那么多边界不如统一用equals一劳永逸。3.3 实际开发中的数值转换正确姿势包装类的另一个核心用途是字符串和数值之间的转换。最传统的方式是Integer.parseInt(123)返回基本类型intInteger.valueOf(123)返回Integer对象。如果字符串格式非法这两个方法都会抛出NumberFormatException。这是我在实际开发里见过最多的异常之一几乎都来自第三方传参没有校验就直接parse。后来Java 8引入了Optional和Stream之后这种转换有了一种更优雅的写法。比如从Map里取一个字符串类型的数量字段期望把它转成int但可能导致异常你可以这样写MapString, 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.0floor后是-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()会输出[I15db9742这种内存地址完全没用。正确做法是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()返回的是当前年份减去1900getMonth()是从0开始计数的也就是说月明明是一月返回值却是0。这种设计让大量开发者踩坑每次取出月份都要手动加1。Calendar虽然稍微好一点但月份依然是0到11而且它是可变的在多线程环境下需要自己额外加锁。另一个让人头疼的问题是Date和Calendar都没有办法精确到一个带时区的偏移量。Date表面上是“一个时间点”但它内部的毫秒数基于系统默认时区你不管是格式化输出还是和其他时区的时间比较都得手动处理时区偏移。更别提SimpleDateFormat它是出了名的线程不安全因为它的内部calendar字段在format和parse过程中是共享且可变的。多线程共用同一个SimpleDateFormat实例输出结果会随机错乱这是线上事故的重灾区。5.2 新API的三大核心LocalDate、LocalTime、LocalDateTimeJava 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.getMonthValueDate月份从0开始SimpleDateFormat定义成static共用用DateTimeFormatterSimpleDateFormat线程不安全数组打印直接调用数组toString用Arrays.toString数组继承的是Object的toStringnull判断用a.equals(b)用Objects.equals(a,b)a为null时直接NPE6.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这三组类彻底吃透它们是你日常编码中接触频率最高的对象。代码写多了你就会发现基础类的熟练度决定了你代码质量的起点不是写过多少框架而是这些底层细节掌握得够不够扎实。如果这篇文章能帮你少踩几个坑那这份分享就没白写。