写业务代码时,你可能写过 int a = 10; 也写过 Integer b = 10;,但有没有想过:这两种写法有什么区别 ?为什么有时候 == 比较失灵 ?为什么 Integer 能用 equals 而 int 不能 ?明明赋值了却还是 NPE?
这篇帮你把基本类型和包装类彻底搞明白,后面写 Spring Boot 业务代码、接口参数校验、数据库映射时才不会踩坑。下面我按「基本类型 vs 包装类 → 装箱拆箱 → 缓存池 → equals 与 == → NPE 防御」的顺序往下聊。
1. 基本类型与包装类:天生一对 💑
1.1 为什么要有两套类型?
Java 是纯面向对象 的语言,但基本类型 (Primitive Types)的存在是为了性能。试想一下,如果所有数据都用对象表示:
java
// 假设没有基本类型,所有都是对象
Integer a = new Integer(10); // 每次都要 new 对象
int b = 10; // 基本类型,直接存值
- 基本类型 :直接存值,栈上分配,快
- 包装类 :对象,堆上分配,支持 null ,提供丰富方法
所以 Java 保留了 8 种基本类型,同时提供对应的包装类,兼顾性能 和灵活性。
1.2 8 种基本类型一览
Java 有 8 种基本类型,每种都有对应的包装类:
| 基本类型 | 包装类 | 字节数 | 取值范围 | 默认值 |
|---|---|---|---|---|
byte |
Byte |
1 | -128 ~ 127 | 0 |
short |
Short |
2 | -32768 ~ 32767 | 0 |
int |
Integer |
4 | -2^31 ~ 2^31-1(约 21 亿) | 0 |
long |
Long |
8 | -2^63 ~ 2^63-1 | 0L |
float |
Float |
4 | 约 ±3.4E38 | 0.0f |
double |
Double |
8 | 约 ±1.8E308 | 0.0d |
char |
Character |
2 | 0 ~ 65535(Unicode) | '\u0000' |
boolean |
Boolean |
1 | true / false | false |
1.3 基本类型 vs 包装类 核心区别
| 特性 | 基本类型 | 包装类 |
|---|---|---|
| 存储位置 | 栈 | 堆 |
| 默认值 | 0/false/'\u0000' | null |
| 是否为 null | ❌ 不能为 null | ✅ 可以为 null |
| 性能 | 高(直接存值) | 低(需要 new 对象) |
| 方法 | 无 | 有丰富的方法(如 Integer.parseInt()) |
| 泛型支持 | ❌ 不支持 | ✅ 支持 |
java
// 基本类型:直接存值
int a = 10; // 栈上存 10
// 包装类:对象
Integer b = 10; // 堆上存 Integer 对象,引用 b 存在栈上
// 基本类型不能为 null
// int c = null; // 编译错误!
// 包装类可以为 null
Integer d = null; // 合法
1.4 什么时候用基本类型?
- 数据库主键 ID :
long id(数据库主键通常非 null,但用 Long 更安全) - 业务状态码、计数 :
int status、int count - 性能敏感的核心循环:用基本类型(避免装箱拆箱开销)
- 方法返回值类型固定 :如
int calculate(),long timestamp - 数组 :
int[]、double[](数组元素是基本类型)
java
// 性能敏感的场景
public long calculateSum(List<Long> numbers) {
long sum = 0; // 基本类型,避免每次循环装箱
for (Long num : numbers) {
if (num != null) {
sum += num; // num 自动拆箱
}
}
return sum;
}
1.5 什么时候用包装类?
- 可能为 null 的字段:如表单提交的年龄、分数、金额
- 泛型 :集合
List<Integer>不能用List<int> - 数据库映射:MyBatis/JPA 的实体类字段(数据库字段可能为 null)
- DTO/VO 字段:前端传过来的参数可能为空
- Optional 容器 :与
Optional<Integer>配合
java
// 实体类:数据库字段可能为 null,用包装类
@Entity
public class User {
@Id
private Long id; // 主键非 null,但用 Long
private String name; // 名字可能为空
private Integer age; // 年龄可能为空
private Boolean active; // 状态可能未知
}
// DTO:前端参数可能为空
public class UserDTO {
private String name; // 可能为空
private Integer age; // 可能为空
}
2. 自动装箱与拆箱:编译器偷偷做的事 📦
2.1 什么是装箱和拆箱?
装箱 (Boxing):基本类型 → 包装类
拆箱(Unboxing):包装类 → 基本类型
java
// 装箱:编译器帮你 Integer.valueOf(10)
Integer a = 10; // 自动装箱
// 拆箱:编译器帮你 a.intValue()
int b = a; // 自动拆箱
这都是编译器帮你做的,写代码时和基本类型用法一样,但底层实现不同。
2.2 装箱的实现原理
Integer.valueOf() 的内部实现:
java
public static Integer valueOf(int i) {
// 如果在缓存范围内(-128 ~ 127),返回缓存对象
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
// 否则创建新对象
return new Integer(i);
}
关键点:
- 如果值在缓存范围内(-128 ~ 127),直接返回缓存对象
- 如果超出范围,创建新对象
2.3 拆箱的实现原理
java
// Integer 类的 intValue() 方法
public int intValue() {
return value; // 返回包装的值
}
所有包装类都有 xxxValue() 方法:
| 包装类 | 拆箱方法 |
|---|---|
Integer |
intValue() |
Long |
longValue() |
Double |
doubleValue() |
Boolean |
booleanValue() |
| ... | ... |
2.4 装箱的坑:缓存范围
java
// 缓存范围内:复用同一对象
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true(缓存范围内,复用同一对象)
// 缓存范围外:创建新对象
Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false(超出范围,创建新对象)
所以 :永远不要用 == 比较包装类 ,用 equals!
2.5 装箱的性能问题
java
// ❌ 性能问题:循环中频繁装箱
Long sum = 0L;
for (int i = 0; i < 1000000; i++) {
sum += i; // 每次 i 自动装箱成 Long
}
// ✅ 性能优化:使用基本类型
long sum = 0L;
for (int i = 0; i < 1000000; i++) {
sum += i; // 直接加法,无装箱
}
在高性能场景下,尽量避免循环中的自动装箱拆箱。
2.6 装箱的常见场景
java
// 场景 1:方法参数
void method(Integer num) { } // 接收包装类型
method(10); // 自动装箱
// 场景 2:集合操作
List<Integer> list = new ArrayList<>();
list.add(10); // 自动装箱
// 场景 3:三元运算符(容易出错!)
Integer a = null;
int b = a != null ? a : 0; // 编译错误!a 可能为 null,无法拆箱
// 正确写法
int b = a != null ? a.intValue() : 0; // 显式拆箱
int b = (a != null ? a : 0); // 三元运算符会自动拆箱
3. 缓存池:Integer 的小秘密 🎁
3.1 为什么要缓存?
为了性能。-128 ~ 127 是最常用的整数范围,每次都 new 一个新对象太浪费,直接复用缓存对���省内存又省时间。
java
// 不用缓存:每次 new 新对象
Integer a = new Integer(10);
Integer b = new Integer(10);
System.out.println(a == b); // false
// 用缓存:复用同一对象
Integer c = 10;
Integer d = 10;
System.out.println(c == d); // true
3.2 缓存范围
| 包装类 | 缓存范围 | 说明 |
|---|---|---|
Integer |
-128 ~ 127 | 默认,可通过 -XX:AutoBoxCacheMax 调整 |
Short |
-128 ~ 127 | 固定 |
Long |
-128 ~ 127 | 固定 |
Character |
0 ~ 127 | 固定 |
Byte |
-128 ~ 127 | 固定(全部) |
Boolean |
true / false | 两个常量 |
Float |
无 | 浮点数范围太大,缓存无意义 |
Double |
无 | 浮点数范围太大,缓存无意义 |
3.3 IntegerCache 实现
IntegerCache 是 Integer 的内部类:
java
private static class IntegerCache {
// 缓存下限:-128(固定)
static final int low = -128;
// 缓存上限:默认 127,可通过 -XX:AutoBoxCacheMax 调整
static final int high;
// 缓存数组
static final Integer cache[];
static {
// 默认 high = 127
high = 127;
// 创建缓存数组:256 个元素(-128 到 127)
cache = new Integer[(high - low) + 1];
// 初始化缓存
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
}
3.4 缓存配置
bash
# 修改 Integer 缓存上限(不推荐生产使用)
java -XX:AutoBoxCacheMax=200 MyApp
这样 -XX:AutoBoxCacheMax=200 设置后,缓存范围变成 -128 ~ 200。
3.5 缓存的实际应用场景
java
// 场景 1:数据库状态码
public enum OrderStatus {
PENDING(0), // 缓存
PAID(1), // 缓存
SHIPPED(2), // 缓存
COMPLETED(3), // 缓存
CANCELLED(4); // 缓存
private final int code;
OrderStatus(int code) {
this.code = code;
}
public static OrderStatus fromCode(int code) {
// 这里用 == 比较也可以,因为枚举是单例
for (OrderStatus status : values()) {
if (status.code == code) {
return status;
}
}
return null;
}
}
// 场景 2:缓存小整数(适合高频使用)
// 例如:分页页码、状态码、类型码等
4. equals 与 ==:到底该用哪个?⚖️
4.1 核心区别
| 比较方式 | 比较内容 | 适用场景 |
|---|---|---|
== |
引用地址(是不是同一个对象) | 基本类型、枚举(单例) |
equals() |
对象内容(值是否相等) | 包装类、字符串、自定义类 |
java
// == 比较的是引用地址
Integer a = new Integer(10);
Integer b = new Integer(10);
System.out.println(a == b); // false(两个不同的对象)
// equals 比较的是内容
System.out.println(a.equals(b)); // true(值相等)
4.2 基本规则
| 比较场景 | 推荐方式 | 原因 |
|---|---|---|
| 基本类型 vs 基本类型 | == |
值比较,性能最高 |
| 包装类 vs 包装类 | equals() |
值比较,避免缓存坑 |
| 字符串 | equals() |
String 重写了 equals |
| 枚举 | == |
枚举成员是单例 |
| 业务主键 ID 比较 | equals() |
安全可靠 |
4.3 常见错误
java
// ❌ 错误:包装类用 ==
Integer a = new Integer(10);
Integer b = new Integer(10);
System.out.println(a == b); // false(不同对象)
// ✅ 正确:包装类用 equals
System.out.println(a.equals(b)); // true
// ✅ 正确:基本类型用 ==
int x = 10;
int y = 10;
System.out.println(x == y); // true
4.4 字符串的特殊性
字符串是最常用的比较场景,有三种创建方式:
java
// 方式 1:字面量(常量池)
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true(常量池复用)
// 方式 2:new 对象(堆)
String s3 = new String("hello");
System.out.println(s1 == s3); // false(s3 在堆上)
// 方式 3:intern() 方法
String s4 = new String("hello");
s4.intern(); // 加入常量池
System.out.println(s1 == s4); // true(s4 指向常量池)
字符串比较永远用 equals:
java
// ❌ 错误
if (username == "admin") { // 可能失败
// ✅ 正确
if ("admin".equals(username)) { // 推荐
// ...
}
// ✅ 也正确(更安全,避免 NPE)
if (Objects.equals(username, "admin")) {
// ...
}
4.5 枚举的比较
枚举推荐用 == ,因为枚举成员是单例:
java
public enum OrderStatus {
PENDING,
PAID,
SHIPPED,
COMPLETED,
CANCELLED
}
// ✅ 推荐用 ==(枚举是单例)
if (status == OrderStatus.PENDING) {
// ...
}
// ✅ 也可以用 equals
if (status.equals(OrderStatus.PENDING)) {
// ...
}
为什么枚举可以用 ==:
- 枚举成员在 JVM 中是单例
==比较的是引用地址,单例的引用地址相同
4.6 业务代码示例
java
// 场景 1:订单状态比较
OrderStatus status = OrderStatus.PENDING;
// ❌ 错误:包装类用 ==
if (status == OrderStatus.PENDING) { // 可能出问题
// ✅ 正确:包装类用 equals
if (status.equals(OrderStatus.PENDING)) { // 推荐
// ✅ 正确:枚举用 ==
if (status == OrderStatus.PENDING) { // 枚举可以用 ==
// 场景 2:用户 ID 比较
Long userId1 = 100L;
Long userId2 = 100L;
// ❌ 错误
if (userId1 == userId2) { // 可能 false
// ✅ 正确
if (userId1.equals(userId2)) { // 推荐
// ✅ 也正确(Objects 工具类)
if (Objects.equals(userId1, userId2)) { // 更安全
4.7 自定义类的 equals
如果自定义类需要比较内容,需要重写 equals 方法:
java
public class User {
private Long id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id) &&
Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
注意 :重写 equals 时,一定要重写 hashCode(如果对象要存到 HashMap/HashSet 中)。
5. NPE 的根因与防御 🛡️
5.1 什么是 NPE?
NPE(NullPointerException,空指针异常) 是 Java 最常见的异常之一:
java
String str = null;
System.out.println(str.length()); // NullPointerException
5.2 为什么会有 NPE?
包装类可以为 null ,而基本类型不能。当包装类为 null 时,自动拆箱会触发 NPE:
java
Integer age = null;
int a = age; // 自动拆箱:age.intValue() → NPE
5.3 常见触发场景
java
// 场景 1:数据库查询结果为 null
User user = userMapper.selectById(100L);
String name = user.getName(); // user 为 null → NPE
// 场景 2:业务逻辑中 null 没有处理
Integer discount = getDiscount(); // 可能返回 null
int d = discount + 10; // 拆箱 → NPE
// 场景 3:自动拆箱
Integer count = null;
if (count > 0) { // 拆箱 → NPE
// 场景 4:方法链式调用
String city = user.getAddress().getCity(); // getAddress() 可能返回 null
// 场景 5:集合操作
List<String> list = null;
list.add("hello"); // NPE
// 场景 6:数组
String[] arr = null;
arr[0] = "hello"; // NPE
5.4 NPE 防御手段
5.4.1 优先使用基本类型
java
// 字段尽量用基本类型
public class User {
private long id; // 主键非 null,用 long
private int status; // 状态码用 int
private boolean active; // 布尔用 boolean
// 可能为 null 的字段用包装类
private String name; // 可能为空
private Integer age; // 可能为空
}
5.4.2 用 Optional 包装可能为 null 的值
java
// 之前
public User getUser(Long id) {
return userMapper.selectById(id); // 可能返回 null
}
// 之后:返回 Optional
public Optional<User> getUser(Long id) {
return Optional.ofNullable(userMapper.selectById(id));
}
// 调用方
getUser(1L).ifPresent(user -> System.out.println(user.getName()));
// 或者
User user = getUser(1L).orElse(null);
Optional 的常用方法:
java
Optional<String> opt = Optional.of("hello");
// 判断是否有值
if (opt.isPresent()) { }
// 获取值(推荐)
String value = opt.get(); // 可能抛异常
// 有值执行操作
opt.ifPresent(v -> System.out.println(v));
// 有值返回值,无值返回默认值
String result = opt.orElse("default");
// 有值返回值,无值返回计算结果
String result = opt.orElseGet(() -> computeDefault());
// 有值返回 Optional,无值返回空 Optional
Optional<String> filtered = opt.filter(s -> s.length() > 3);
// 转换值
Optional<Integer> len = opt.map(String::length);
5.4.3 用 Objects.requireNonNull 或 Assert
java
// 方法参数校验
public void setAge(Integer age) {
if (age == null) {
throw new IllegalArgumentException("年龄不能为空");
}
this.age = age;
}
// 用 Spring 的 Assert
public void setAge(Integer age) {
Assert.notNull(age, "年龄不能为空");
this.age = age;
}
// 用 Objects 工具类
public void setAge(Integer age) {
this.age = Objects.requireNonNull(age, "年龄不能为空");
}
5.4.4 用 @NotNull / @NotBlank 注解
java
// DTO 字段加注解
public class UserDTO {
@NotNull(message = "ID 不能为空")
private Long id;
@NotBlank(message = "用户名不能为空")
private String username;
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能为负数")
@Max(value = 150, message = "年龄超出范围")
private Integer age;
}
// Controller 层加 @Valid
@PostMapping("/user")
public Result<Void> addUser(@Valid @RequestBody UserDTO dto) {
// 参数为 null 或空字符串会直接返回 400 错误
userService.add(dto);
return Result.success();
}
常用校验注解:
| 注解 | 作用 |
|---|---|
@NotNull |
不能为 null |
@NotBlank |
不能为空字符串(仅 String) |
@NotEmpty |
不能为空(Collection/Map/Array) |
@Min / @Max |
数值范围 |
@Size |
字符串/集合大小 |
@Email |
邮箱格式 |
@Pattern |
正则匹配 |
5.4.5 用 null 安全的方法
java
// 之前:链式调用,每一步都可能 NPE
String city = user.getAddress().getCity();
// 之后:用 Optional
String city = Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElse("未知");
// 之后:用 Spring 的 ObjectUtils
String city = ObjectUtils.getIfPresent(
() -> user.getAddress().getCity(),
() -> "未知"
);
// 之后:用 Apache Commons Lang
String city = ObjectUtils.defaultIfNull(
user.getAddress() != null ? user.getAddress().getCity() : null,
"未知"
);
5.4.6 用空对象模式
java
// 定义一个空对象
public class EmptyAddress implements Address {
@Override
public String getCity() {
return "未知";
}
@Override
public String getDetail() {
return "未知";
}
}
// 返回空对象而不是 null
public Address getAddress() {
if (address == null) {
return new EmptyAddress();
}
return address;
}
5.5 NPE 排查命令
bash
# 查看异常堆栈
java -jar app.jar 2>&1 | grep -A 20 "NullPointerException"
# 用 Arthas 快速定位
$ watch com.example.User getName "{params, throwExp}" -e
# 用 jstack 查看线程堆栈
jstack <pid>
# 用 jdb 调试
jdb -attach <pid>
5.6 最佳实践总结
- 尽量避免返回 null:用空集合、空对象、Optional 代替
- 方法参数加校验 :用
@NotNull、Assert - 链式调用注意空指针:用 Optional 包装
- 集合初始化 :用
Collections.emptyList()等空集合 - 用工具类 :
Objects.requireNonNull()、Objects.equals()
6. 实战:Spring Boot 中的类型选择
6.1 实体类字段选择
java
// User 实体类
@Entity
@Table(name = "t_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 主键用 Long(可能为 null)
@Column(nullable = false)
private String username; // 用户名不能为空
private Integer age; // 年龄可能为空,用 Integer
private Boolean active; // 状态可能未知,用 Boolean
private LocalDateTime createTime; // 创建时间
}
6.2 DTO 字段选择
java
// 用户创建 DTO
public class CreateUserDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能为负数")
private Integer age; // 前端可能不传,用 Integer
}
6.3 Service 层处理
java
@Service
public class UserService {
public User getById(Long id) {
// 用 Optional 包装
return Optional.ofNullable(userMapper.selectById(id))
.orElseThrow(() -> new BusinessException("用户不存在"));
}
public void updateAge(Long id, Integer age) {
// 参数校验
Assert.notNull(age, "年龄不能为空");
Assert.isTrue(age >= 0 && age <= 150, "年龄超出范围");
// 业务逻辑
User user = userMapper.selectById(id);
if (user != null) {
user.setAge(age);
userMapper.updateById(user);
}
}
}
6.4 Controller 层处理
java
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/{id}")
public Result<User> getById(@PathVariable Long id) {
// 参数校验
if (id == null || id <= 0) {
return Result.error("ID 无效");
}
// 业务处理
User user = userService.getById(id);
return Result.success(user);
}
@PostMapping
public Result<Void> create(@Valid @RequestBody CreateUserDTO dto) {
// @Valid 自动校验,失败返回 400
userService.create(dto);
return Result.success();
}
}
小结
- 基本类型 存值、性能高、不能为 null ;包装类 是对象、可以为 null、提供丰富方法
- 自动装箱 用
valueOf(),自动拆箱 用xxxValue() - Integer 缓存池 范围是 -128 ~ 127,超出范围创建新对象
- 包装类比较用
equals,基本类型用==,字符串永远用equals - 枚举比较可以用
==,因为枚举成员是单例 - NPE 防御 :优先用基本类型、用 Optional 包装、用
@NotNull校验、用 null 安全的方法 - 数据库实体字段 用包装类(可能为 null),业务计算字段尽量用基本类型
下一篇(015)预告:String 与字符集:接口里乱码的根源------UTF-8 和 GBK 的区别、Java 内部编码、Spring Boot 接口怎么防乱码。