兄弟们,做Java开发的有句话叫“根基不牢,地动山摇”。很多工作两三年的朋友,写业务代码溜得飞起,但一问到Java常用类的底层设计、经典坑点,反而支支吾吾。尤其是面试时候,面试官最爱从“你平时用过哪些常用类”切入,一路追问到String、包装类、集合的底层原理,这些都是拉开差距的地方。这篇东西,我结合自己这些年的项目经验和带新人的心得,把Java里最核心、最高频的常用类系统梳理一遍。不讲虚的,直接上干货,适合正在学Java基础的朋友,也适合准备跳槽想复盘一轮的老手。
1. 先从老祖宗Object说起
1.1 所有类的根,equals和hashCode的约定
Object是Java所有类的父类,这个大家都知道,但真正理解它设计意图的人不多。它里面有十几个方法,日常开发中最常打交道的就是equals、hashCode、toString这三个。
先说equals。默认的Object.equals比较的是内存地址,也就是“是不是同一个对象”。但在业务开发里,我们关心的往往是“两个对象的内容是否相等”。比如一个User对象,只要userId相同,我们就认为是同一个用户。这时候就需要重写equals。
但重写equals有个铁律:重写equals必须同时重写hashCode。为什么?因为Java里很多集合类,比如HashMap、HashSet,都依赖hashCode先定位存储位置,再用equals确认是否相等。如果你只重写equals不重写hashCode,会导致两个内容相同的对象hashCode不同,HashMap就可能把一个逻辑上相同的对象存进两个不同的桶里,出现重复数据,甚至get的时候找不到。这个坑我在实际项目里见过不止一次。
hashCode的约定有三个:
- 两个对象equals相等,hashCode必须相等;
- 两个对象hashCode相等,equals不一定相等(哈希碰撞);
- 同一个对象多次调用hashCode,结果必须一致。
实操建议:用IDE自动生成的equals和hashCode(基于关键业务字段)就好,比手写靠谱,千万别自己拍脑袋写hash算法,容易引入性能问题。
1.2 toString和finalize的实际使用
toString应该是Object里最“好用”的方法了。默认实现是“类名@十六进制哈希值”,这玩意儿在排查问题时候基本没法看。所以我习惯在实体类里重写toString,把核心字段打出来。这样在日志里看到的不再是com.demo.User@1a2b3c4,而是User{id=1, name='张三', age=18},排查线上问题效率直接翻倍。
finalize这方法现在基本属于“遗弃”状态了。它是在对象被垃圾回收前调用的钩子,但JVM不保证它一定会执行,也不保证执行顺序,用它来做资源释放完全是给自己挖坑。Java 9开始已经标记为废弃了。我的观点就一句话:别碰finalize,资源释放请用try-with-resources或者finally块。
另外还有两个经常被忽略的方法:getClass和clone。getClass配合反射用的场景多,比如动态判断实例类型。clone是浅拷贝,只有实现了Cloneable接口才能调用,但它“违反直觉”的地方在于:浅拷贝出来的对象,引用类型的成员变量还是指向同一个对象。所以实际开发里我更推荐用构造函数拷贝、或者直接转JSON再转回来这种方案,比clone可控得多。
2. String类,Java里最特殊的“基本类型”
2.1 不可变性的底层逻辑
Java程序员几乎每天都在用String,但很多人没想过一个问题:为什么String要被设计成不可变的?这是面试高频题,同时也是理解JVM内存模型的一个非常好的切入点。
String的不可变体现在:它内部用final修饰的char数组(JDK9开始改成byte数组)存储字符,类本身也是final的,不允许被继承。所有看似修改字符串的操作,比如concat、replace、substring,实际上都是返回了一个新对象,原对象纹丝不动。
这个设计的价值:
- 线程安全:不可变对象天然线程安全,可以被多个线程共享,不需要加锁;
- 字符串常量池:正因为不可变,JVM才能放心地把相同的字符串字面量放在常量池里共享,避免重复创建对象浪费内存;
- 哈希缓存:String的hashCode在第一次计算后会被缓存起来,因为它不可变,所以这个缓存永远有效,HashMap用String做key效率很高。
我遇到过有不少人背了“String不可变”这句话,却不知道它对应的场景意义。面试时如果能从线程安全和常量池这两个点回答,就比单纯背结论高一个档次。
2.2 常量池与new String("abc")的区别
这是Java基础面试必考的一道经典题。String a = "abc"和String b = new String("abc")有什么区别?
答案分两层。第一层,“abc”这个字面量在类加载阶段就会被放进字符串常量池;a直接引用常量池里的对象,不会在堆里额外创建对象。而new String("abc")强制在堆里创建一个新对象,然后这个新对象的value又指向常量池里的字面量。
画个简单的内存逻辑:常量池里有一个"abc",堆里有一个独立的String对象,b引用的是堆对象,a引用的是常量池对象。所以a == b返回false,因为用==比较的是引用地址,不是内容。
那a == "abc"呢?返回true,因为a引用的就是常量池里的那个"abc"。b.equals("abc")返回true,因为equals比较内容。
还有个容易踩坑的:字符串的+拼接,如果两个都是字面量,比如"a" + "b",编译器会直接在编译期就优化成"ab",也就是说它也指向常量池。但如果有一个是变量,比如String c = "a"; String d = c + "b";,这个拼接在运行期是通过StringBuilder完成的,d指向堆里新建的对象。所以d == "ab"是false,d.intern() == "ab"才是true。
2.3 StringBuilder和StringBuffer怎么选
既然String不可变,那可变的字符串类就有用武之地了。StringBuilder和StringBuffer的API几乎一模一样,唯一区别是StringBuffer的方法加了synchronized关键字,线程安全。
线程安全听着“高大上”,但同步是有性能开销的。在绝大多数业务场景下,字符串拼接发生在方法内部,不会跨线程共享变量,这时候用StringBuffer完全没必要。所以我的建议很简单:默认用StringBuilder;只有明确要作为成员变量被多线程共用时,才用StringBuffer。
这里还有个性能大坑要提醒:不要在循环里用+拼接字符串。比如往一个List里的数据拼SQL的IN语句:
String sql = "SELECT * FROM user WHERE id IN ("; for (Long id : idList) { sql += id + ","; }每次+=都会创建一个新的String对象,循环1000次就是创建1000个对象,还会伴随多次数组拷贝,性能惨不忍睹。正确姿势是先StringBuilder sb = new StringBuilder(),循环里sb.append(id).append(","),最后一次性sb.toString()。
JDK9以后,单个的“字面量+变量”表达式编译器会自动转换成StringBuilder操作,这是编译器的优化,但循环体内是多次独立的拼接表达式,编译器没法跨迭代优化,坑仍然在。
3. 包装类,自动装箱拆箱的甜蜜陷阱
3.1 Integer缓存的经典面试题
Java的类型体系里,基本类型和对象之间有个桥梁——包装类。int对应Integer,long对应Long,boolean对应Boolean。有了自动装箱拆箱,代码里可以Integer i = 100; int j = i;,编译器自动完成转换。
但这个转换背后有不少隐藏逻辑。看一个经典题目:
Integer a = 100; Integer b = 100; System.out.println(a == b); // true Integer c = 200; Integer d = 200; System.out.println(c == d); // false答案是第一个true,第二个false。原因是Integer内部有个缓存机制:valueOf方法在取值-128到127之间的整数时,会直接返回缓存数组里的同一个对象。所以a和b指向的是缓存里的同一个对象,==比较引用当然相等。而200超出了缓存范围,valueOf会new一个新的Integer对象,c和 d是不同的引用,所以==返回false。
这个缓存范围在Java 6之前是写死的-128到127,Java 6开始可以通过JVM参数调整上限。实践中我不建议大家去调这个参数,知道就行。
避免这套坑的办法:包装类型比较一律用equals,别用==。这也是阿里Java开发手册里明确要求的。同理,所有包装类的比较都要遵循这个原则。
3.2 包装类在泛型和集合里的必要性
基本类型不能用在泛型里,这是包装类存在的一个重要原因。比如List<Integer>,你不能写成List<int>。因为泛型在编译期会被类型擦除,底层需要的是Object类型,基本类型无法完成这个转换。
集合框架存储的元素必须是对象——LinkedList的节点、HashMap的键值这些内部结构只能引用对象。所以往集合里放数字时,int会自动装箱成Integer;从集合里取出来时,Integer自动拆箱成int。这个过程表层代码看不到,但字节码里清清楚楚写着Integer.valueOf和intValue的调用。
由此引发的空指针是最常见的包装类坑。比如:
Map<String, Integer> map = new HashMap<>(); int num = map.get("key"); // 如果key不存在,get返回null,拆箱瞬间抛NPE这里map.get返回的是null,赋值给int的时候要拆箱,拆箱调用intValue,对null调用方法——空指针。我在代码审查里见这类问题太多了。养成习惯:从集合或对象里取包装类,赋值给基本类型之前,一定要判空。
3.3 数值比较的“感觉差不多”不等于“相等”
还有一个很容易被忽略的点:包装类作为JavaBean属性时,默认值是null,不是0。比如一个Integer类型的年龄字段,用户没填时它是null,如果直接参与算术运算就会NPE。
而基本类型int的默认值是0,两者语义完全不同。数据库字段允许为NULL时,对应Java实体类字段选包装类;不允许为空时,可以用基本类型,不要混淆。这个选择不光是“省不省内存”的问题,而是直接关系到数据语义的准确性。
4. 日期时间类,从Date一路到LocalDateTime
4.1 SimpleDateFormat的线程安全问题
早期的Java日期API是java.util.Date和java.util.Calendar,配合java.text.SimpleDateFormat做格式化。这玩意儿三个字总结:不好用。月份从0开始计(0代表1月),年份偏移1900,很多刚入行的朋友第一次用new Date(2024, 0, 1)建日期时,看到输出2024年1月1日松了口气,却不知道这构造函数已经废弃了,内部还帮你把年减了1900。
而SimpleDateFormat更坑:它内部维护了一个Calendar对象,format和parse方法不是线程安全的。当多个线程共享同一个SimpleDateFormat实例时,会出现解析结果错乱,甚至JVM崩溃。
我早年在一家金融公司,线上系统有个定时任务,每天晚上用同一个SimpleDateFormat格式化时间戳后写文件,某天突然有几个数据时间错了,排查了半天才发现是并发调用导致Calendar内部状态被污染。后来统一用ThreadLocal包了一层,或者干脆用LocalDateTime替代,问题根除了。
4.2 新时间API的正确使用方式
Java 8开始引入了java.time包,这个设计确实优秀。核心类是LocalDate(日期)、LocalTime(时间)、LocalDateTime(日期+时间)、ZonedDateTime(带时区)和Instant(时间戳)。
这套API的设计理念是“不可变+线程安全”,所有方法都返回新对象,不会修改旧对象。命名也非常规范:of用来创建实例,plus做加法,minus做减法,with做调整,format用DateTimeFormatter格式化,parse从字符串解析。
我自己写代码的经验是:
- 数据库存储时间推荐用
TIMESTAMP,Java侧对应LocalDateTime(MyBatis-Plus等框架都能自动转换); - 跨时区系统用
Instant或ZonedDateTime,与UTC对比时才不会有偏差; - 前端传字符串时间,用
LocalDateTime.parse时注意默认格式是2024-01-01T10:00:00,带了字母T,和常用格式不同,一般要配合DateTimeFormatter自定义格式,比如:
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); LocalDateTime time = LocalDateTime.parse("2024-06-01 12:30:00", fmt);4.3 日期计算和格式化的实操细节
做日期计算时,新API的链式写法非常舒服。比如计算30天前的日期:
LocalDate today = LocalDate.now(); LocalDate thirtyDaysAgo = today.minusDays(30);计算两个日期差多少天:
long days = ChronoUnit.DAYS.between(startDate, endDate);需要把日期格式化成字符串:
String str = today.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日"));这里有几个习惯要养成:判断日期间隔时,两个时间点统一用Instant做比较,不要在LocalDateTime之间直接做大小比较,因为LocalDateTime不带时区信息,跨时区场景会出错;每个月最后一天用YearMonth来算,不要自己判断月份是大是小,更稳妥:
YearMonth month = YearMonth.of(2024, 2); LocalDate lastDay = month.atEndOfMonth();5. 集合框架里的高频常用类
5.1 ArrayList和LinkedList的真实差异
聊到集合框架,ArrayList和LinkedList是出场率最高的两个。很多教科书上说“ArrayList查询快、增删慢,LinkedList增删快、查询慢”,这话有误导性。
真相是:
- ArrayList基于动态数组,随机访问是O(1),但指定位置插入或删除需要移动后续元素,尾部插入最省事;
- LinkedList基于双向链表,头部插入删除是O(1),但随机访问是O(n),哪怕get(500)也要从链头开始找过去;
- 链表每个节点还要多存前后指针,内存开销比ArrayList一个连续数组大得多。
更反直觉的是,如果做中间插入,比如在列表正中间插一个元素,LinkedList虽然定位到了那个位置,但插入本身还是需要改动前后节点的引用,而ArrayList要搬运后半段元素——测试下来,数据量上万时,ArrayList反而常常比LinkedList更快,因为连续内存搬运的效率实在太高了,而链表节点的新建和指针操作开销不小。
所以现代实践基本形成了共识:绝大多数场景用ArrayList就好。LinkedList的真正优势场景是“队列两端的频繁插入删除”,比如实现了Deque接口做双端队列用,但这种情况直接用ArrayDeque更合适。
5.2 HashMap的底层机制与扩容策略
HashMap可能是Java里最常用的键值对容器,也是面试重灾区。它底层是“数组+链表+红黑树”结构:根据key的hashCode计算桶下标,哈希碰撞后用链表(或树)拖链解决。
几个关键参数:
- 默认初始容量16;
- 默认负载因子0.75;
- 链表长度超过8且数组长度超过64时,链表升级为红黑树;
- 树退化回链表的阈值是6(留了缓冲,防止元素在边界频繁增删导致反复转换)。
为什么负载因子是0.75?这是时间与空间的折中。负载因子越大,空间利用率越高,但哈希碰撞概率增大,查询效率下降;负载因子越小,空间浪费越多,但查询效率高。0.75这个值在数学统计上(泊松分布模型)能把链表长度超过8的概率压得非常低,所以这个参数设计是有统计学依据的。
扩容机制:当size超过容量×负载因子(16×0.75=12)时,容量扩大为原来的两倍,然后重新计算每个元素的位置,这个过程叫rehash。JDK8对扩容做了优化,元素的位置要么在原下标,要么在原下标+旧容量,判断依据是key的hash值新增的那一位是0还是1,省去了从零重新计算hash的过程。
日常开发要注意的:千万别用可变对象做HashMap的key。如果key的hashCode依赖的字段被修改了,HashMap再get这个key时,计算出来的新桶位和原来存放的位置对不上,就找不到数据了。如果非要用,后果自负——这是Google Java规范里也点名提示过的。
5.3 HashSet和TreeSet的选型思路
HashSet底层就是个HashMap(value是固定的PRESENT常量),去重靠的就是HashMap的key唯一性。所以重写equals和hashCode的正确性在HashSet里格外重要——去重判断先走hashCode定位,再走equals确认。
TreeSet底层是红黑树,元素有序,要么实现Comparable接口,要么传入Comparator比较器。它适合需要有序遍历的场景,比如按分数从低到高输出玩家排名。但插入和删除的时间复杂度是O(logn),比HashSet的O(1)要慢。所以纯去重用HashSet,需要排序才选TreeSet。
顺便说一句,很多面试题里喜欢问“HashSet怎么实现去重”。标准回答就是结合HashMap key不可重复的机制来论述,这要求你理解HashMap的put过程:先算hash找桶,桶里没元素直接放;桶里有元素再逐个equals比较,有相等的,新值覆盖旧值,返回旧值。HashSet往HashMap里put(k, PRESENT)时,如果返回的旧值不是null,说明key已经存在,就“去重失败”,set里不添加。
6. 工具类,写业务代码的加速器
6.1 Arrays和Collections这对黄金搭档
Arrays是数组的操作工具类,排序用Arrays.sort(),二分查找用Arrays.binarySearch(),数组转列表用Arrays.asList(),数组拷贝用Arrays.copyOf()和System.arraycopy()(这是底层方法)。
这些方法里有一个反直觉的坑:Arrays.asList()返回的列表不能增删元素。它返回的是一个Arrays内部类ArrayList(注意不是java.util.ArrayList),这个内部类的数组是定长的,调add或remove会直接抛UnsupportedOperationException。这个设计是为了保留数组定长特性,但很多人第一次用都会踩这个坑。如果需要真正可以增删的List,new ArrayList<>(Arrays.asList(arr))套一层就好。
Collections工具类针对集合操作:Collections.sort()排序(传List和Comparator),Collections.reverse()反转,Collections.shuffle()打乱,Collections.unmodifiableList()把一个可变集合包装成不可变集合,Collections.synchronizedList()包装成线程安全集合。这里有个经验:unmodifiableXxx包装出来的集合,只禁止通过包装之后的引用修改集合,如果原集合引用还在可变,仍然可以改。要保证绝对不可变,要么彻底不暴露原引用,要么用Java 9之后的List.of()、Set.of()创建真正的不可变集合。
6.2 Objects工具类避免空指针折磨
Objects类是Java 7引入的,专治各种空指针。写代码时最常用的:
Objects.equals(a, b):做了null安全的比较,a和b都可以是null,不会NPE;Objects.requireNonNull(obj):校验obj不为null,为null就抛NPE,常用于方法入参校验;Objects.hashCode(obj):null安全的hashCode,是null返回0;Objects.toString(obj):null安全的toString。
我记得有个老项目里,大量代码写的是if (user != null && user.getName() != null && user.getName().equals("admin")),三层判空。如果改成Objects.equals(user.getName(), "admin"),并且在入口用requireNonNull卡掉不可能的null,代码会干净很多。每少一个if嵌套,可读性就好一分。
6.3 Math类和其他零散工具类
Math类提供了一堆静态方法:Math.max/min、Math.abs、Math.pow、Math.sqrt、Math.random()(0.0到1.0的随机数,注意左闭右开)。还有Math.floor和Math.ceil。需要注意Math.abs(Integer.MIN_VALUE)返回的仍然是负数,因为整数溢出了,这种边界条件测试时容易忽略。
按需使用的还有:UUID生成随机唯一ID(UUID.randomUUID().toString(),做分布式系统里的数据库主键、traceId特别好用);BigDecimal做金钱运算(浮点数用double直接算加减乘除会出现精度问题,金钱场景必须用BigDecimal,初始化最好用字符串构造器new BigDecimal("0.1"),不要用new BigDecimal(0.1))。
7. 面试高频题与日常避坑速查
7.1 高频面试题整理
| 问题 | 关键考点 | 一句话解法 |
|---|---|---|
| String为什么不可变 | 线程安全、常量池共享、hash缓存 | 内部value是final byte数组,类本身final |
| String、StringBuilder、StringBuffer区别 | 不可变/可变、线程安全 | 默认StringBuilder,共享场景用StringBuffer |
| ==和equals区别 | 引用比较/内容比较 | Object.equals比较引用,重写后比较内容 |
| 重写equals必须重写hashCode? | 集合去重依赖 | HashMap/HashSet先比hashCode再比equals |
| Integer缓存范围 | 自动装箱的valueOf缓存 | -128到127之间返回缓存对象 |
| HashMap底层结构 | 数组+链表+红黑树 | 负载因子0.75,链表超8转树 |
| ArrayList和LinkedList怎么选 | 随机访问/两端操作 | 无特殊理由选ArrayList |
| SimpleDateFormat线程安全 | Calendar状态污染 | 用LocalDateTime或ThreadLocal包一层 |
| BigDecimal精度 | double无法精确表达 | 用String构造BigDecimal,不直接用double |
| Arrays.asList能否增删 | 定长数组包装 | 返回内部类ArrayList,增删抛异常 |
7.2 开发中遇到的经典Bug复盘
复盘我经历的几次事故,都是因为“对常用类只知API不知原理”。
第一起是并发环境下时间格式化错乱。线上服务多个线程共用一个SimpleDateFormat实例,某天批量任务解析用户生日时,部分记录变成了前一天甚至差了好几年。原因前面说过了,SimpleDateFormat里的Calendar是共享可变状态。后来不只换了LocalDateTime,还顺手排查了一遍代码库里所有SimpleDateFormat的使用点。
第二起是HashMap并发扩容死循环。JDK7时代HashMap并发put会导致扩容时形成环形链表,get时死循环CPU飙满。JDK8修了这个问题,但并发下的数据丢失问题仍然存在,多线程put会导致size统计不准确。现在规范一律要求:并发场景用ConcurrentHashMap,别再拿HashMap硬扛多线程了。
第三起是整数溢出。有一次计算一批商品的折扣总额,逻辑是discount * price / 100,其中discount和price都是int。某次算大额订单时,discount * price超过了int的最大值2147483647,变成了负数,总价计算直接崩了。这种用int做乘法运算的操作,哪怕结果看起来不会超范围,中间过程也可能溢出。钱相关计算,一律BigDecimal,或者至少保证中间结果是long。
7.3 我的日常编码检查清单
这些坑踩多了,我现在写代码有一套自己的检查清单:
- 包装类比较,一律equals,绝不用
==; - 集合、Map里取出来的包装类赋值给基本类型,先判空;
- 循环拼接字符串,用StringBuilder,不在循环体内用
+; - 日期格式化,不用SimpleDateFormat,用DateTimeFormatter;
- 键值对存储,优先考虑HashMap,并发才用ConcurrentHashMap;
- 数组转List要能增删,用
new ArrayList<>(Arrays.asList(arr)); - 金钱计算,从字符串构造BigDecimal,不用double直接做运算;
- 实体类里覆写toString,生产环境排查问题省事。
8. 学习路径建议和资料推荐
8.1 怎么把这些类学到能用的程度
很多初学者有个误区:把Java常用类的API文档从头到尾背一遍,然后发现写代码还是啥都不会。正确思路是以问题为驱动去学。比如,你先写一个小工具——解析一份CSV文件,按某列排序,去重,统计各分类数量,导出到Excel。这个过程中你自然要用到BufferedReader、ArrayList、HashMap、TreeSet、Comparator、LocalDateTime这些类和接口,用起来了,API自然就记住了。
另一个好办法是“读源码”。不用把JDK源码全读一遍,但ArrayList的扩容逻辑、StringBuilder的append流程、HashMap的put方法、Integer的缓存实现,这四段源码值得精读。读源码不是为了背代码,而是理解设计者面临什么问题、为什么这么解决。比如Integer缓存,读valueOf方法的那一刻,你就明白了为什么Integer i = 127和i = 128行为不一样。这些理解,面试官一问就能感受出来是“真懂”还是“背过”。
8.2 推荐几个沉淀方向
Java常用类背后牵涉的知识面很广:内存模型、集合源码、并发机制、设计模式。我建议按“广度了解-深度精通”两步走。第一轮先把上面讲的类和API都用熟,能独立写出可运行的程序;第二轮针对集合和并发,细读源码,搞明白底层实现。到那个时候,你已经不是“会用”Java,而是“懂”Java了。
如果时间紧张,至少把String、包装类、集合框架、日期时间、BigDecimal这五块吃透。它们覆盖了线上Java服务90%以上的日常开发需求,也是面试官最爱的提问领域。把这篇里的原理和坑点消化掉,基础这一关就问题不大了。