Java 数据类型与运算
数据类型决定一个值能表示什么、占多少空间、能做哪些运算。很多线上 bug 看起来是业务问题,根因其实是类型选择错误,比如金额用 double、订单数量用 short、用户 ID 用 int 导致溢出。
为什么要理解数据类型
Java 是强类型语言。变量声明后,编译器会根据类型限制它能保存的值和能参与的操作。
int count = 10;
// count = "10"; // 编译失败,int 不能接收字符串强类型的好处是:很多错误可以在编译期提前发现,而不是等线上运行到某个分支才出问题。
基本类型和引用类型
flowchart TD
A["Java 类型"] --> B["基本类型"]
A --> C["引用类型"]
B --> D["byte short int long"]
B --> E["float double"]
B --> F["char boolean"]
C --> G["类"]
C --> H["接口"]
C --> I["数组"]
C --> J["枚举和注解"]| 类型 | 说明 | 示例 |
|---|---|---|
| 基本类型 | 变量里直接保存值 | int age = 18 |
| 引用类型 | 变量里保存对象地址的引用 | User user = new User() |
基本类型一览
| 类型 | 位数 | 默认值 | 常见用途 |
|---|---|---|---|
byte | 8 | 0 | 文件、网络字节数据 |
short | 16 | 0 | 较少直接使用 |
int | 32 | 0 | 数量、状态、循环下标 |
long | 64 | 0L | ID、时间戳、较大数量 |
float | 32 | 0.0f | 对精度要求不高的小数 |
double | 64 | 0.0d | 科学计算、小数近似值 |
char | 16 | '\u0000' | 单个字符 |
boolean | 未明确规定 | false | 条件判断 |
注意:默认值是成员变量的默认值,局部变量必须手动初始化后才能读取。
基本类型和引用类型在内存中的区别
flowchart TD
A["main 方法栈帧"] --> B["int count = 10"]
A --> C["User user 引用"]
C --> D["堆中的 User 对象"]
D --> E["name 字段"]
D --> F["age 字段"]示例:
public class TypeMemoryDemo {
public static void main(String[] args) {
int count = 10;
User user = new User("Tom", 18);
}
}
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}count 是基本类型值;user 是引用,真正的 User 对象在堆里。
默认值、局部变量和成员变量
Java 里“变量有没有默认值”要看变量在哪里。
| 变量位置 | 是否有默认值 | 原因 |
|---|---|---|
| 成员变量 | 有 | 对象创建时 JVM 会给字段初始化零值 |
| 静态变量 | 有 | 类初始化前会在准备阶段给静态字段零值 |
| 局部变量 | 没有,必须显式赋值 | 编译器要求局部变量使用前明确初始化 |
| 数组元素 | 有 | 数组创建时元素会初始化为类型默认值 |
Demo:
public class DefaultValueDemo {
private int memberCount; // 默认 0
private Boolean enabled; // 默认 null
public static void main(String[] args) {
int localCount;
// System.out.println(localCount); // 编译失败,局部变量未初始化
int[] numbers = new int[3];
System.out.println(numbers[0]); // 0
}
}为什么局部变量不自动给默认值?因为局部变量通常在方法里表达临时业务步骤,如果允许未赋值就读取,很容易掩盖逻辑错误。编译期直接报错,能把问题提前暴露。
Java 是值传递,不是引用传递
这是 Java 基础里非常容易误解的点。
Java 方法传参永远是值传递:
- 基本类型传的是值的副本。
- 引用类型传的是“引用值”的副本。
引用类型传进去以后,方法里能通过这个引用副本修改同一个对象的字段;但不能让外面的引用变量指向另一个对象。
flowchart TD
A["外部变量 user"] --> C["堆中 User 对象"]
B["方法参数 param"] --> C
D["param = new User()"] --> E["新的 User 对象"]
A --> CDemo:
class User {
String name;
User(String name) {
this.name = name;
}
}
public class PassByValueDemo {
public static void main(String[] args) {
int count = 10;
changeInt(count);
System.out.println(count); // 10
User user = new User("Tom");
changeName(user);
System.out.println(user.name); // Jerry
changeReference(user);
System.out.println(user.name); // Jerry,不会变成 Lucy
}
static void changeInt(int value) {
value = 20;
}
static void changeName(User param) {
param.name = "Jerry";
}
static void changeReference(User param) {
param = new User("Lucy");
}
}面试标准说法:Java 只有值传递。对象参数传递的是引用值的副本,所以可以修改对象内部状态,但不能改变调用方变量本身指向哪个对象。
商业项目里的影响:
- Service 方法里修改传入 DTO 字段,会影响调用方看到的同一个对象。
- 方法内给参数重新赋值,不会改变外部变量。
- 可变对象在多层方法间传递时,要小心副作用。
- 需要保护对象不被改时,应使用不可变对象、拷贝对象或只暴露只读视图。
类型转换:自动提升和强制转换
Java 运算时会做类型提升。小范围类型可以自动转成大范围类型;大范围转小范围必须强制转换,并且可能丢失数据。
flowchart TD
A["byte"] --> B["short"]
B --> C["int"]
C --> D["long"]
D --> E["float"]
E --> F["double"]
G["char"] --> C自动类型提升:
int a = 10;
long b = a; // int 自动转 long
double c = b; // long 自动转 double强制类型转换:
long big = 3_000_000_000L;
int small = (int) big;
System.out.println(small); // 可能变成负数或错误值为什么会错?因为 int 只有 32 位,放不下 3_000_000_000L。强制转换不是“智能压缩”,而是截断高位,结果可能完全不符合业务含义。
表达式中的类型提升
byte、short、char 参与算术运算时,通常会先提升为 int。
byte a = 1;
byte b = 2;
// byte c = a + b; // 编译失败,a + b 的结果是 int
int c = a + b;这就是为什么很多初学者会疑惑“两个 byte 相加为什么不能赋给 byte”。Java 为了减少小整数运算溢出的隐式风险,会先提升到 int。
复合赋值的隐式转换
复合赋值会隐含强制类型转换:
byte x = 1;
x += 1; // 等价于 x = (byte) (x + 1)
// x = x + 1; // 编译失败,因为 x + 1 是 intx += 1 看起来更方便,但如果数值超过范围,仍然会溢出。不要因为它能编译就忽略范围风险。
char、Unicode 和字符编码
char 是 16 位无符号字符单元,历史上常说它表示一个 Unicode 字符,但现代 Unicode 中有些字符超过 16 位,需要两个 char 组成代理对。
public class CharDemo {
public static void main(String[] args) {
char c = '中';
System.out.println(c);
String emoji = "😀";
System.out.println(emoji.length()); // 2,不是 1
System.out.println(emoji.codePointCount(0, emoji.length())); // 1
}
}这说明 String.length() 返回的是 UTF-16 code unit 数量,不一定等于人眼看到的字符数量。处理昵称、表情、国际化文本时要特别小心。
字符编码和 char 不是一回事:
| 概念 | 说明 |
|---|---|
char | Java 内部的 16 位字符单元 |
| Unicode | 字符编号标准 |
| UTF-8 | 把 Unicode 编码成字节的方式 |
| UTF-16 | Java String 内部常见编码表示相关 |
文件、网络传输的是字节,不是 char。读写文件必须明确编码,否则不同机器默认字符集不同,可能出现乱码。
boolean 不是数字
Java 的 boolean 只能是 true 或 false,不能像 C 语言那样用 0、1 直接表示。
boolean success = true;
// if (1) {} // Java 编译失败这让条件表达更清晰,避免把整数误当布尔。数据库或接口中如果用 0/1 表示开关,进入 Java 后建议转换为 Boolean 或枚举,让业务含义更明确。
== 和 equals 到底比什么
这道题不能只背“== 比地址,equals 比内容”。更准确地说:
`==` 是 Java 语言层面的比较运算符;`equals` 是对象的方法,默认比较地址,但可以被类重写成业务相等。基本类型使用 ==
基本类型变量里直接放的是值,所以 == 比较的就是值本身。
int a = 100;
int b = 100;
System.out.println(a == b); // true如果是 char、boolean、long 等基本类型,本质也一样。
引用类型使用 ==
引用类型变量里保存的是“指向堆中对象的引用”。所以 == 比的是两个引用是否指向同一个对象。
String a = new String("abc");
String b = new String("abc");
System.out.println(a == b); // false,两个不同对象
System.out.println(a.equals(b)); // true,String 重写了 equals,比较字符内容你可以把它理解成:
flowchart TD
A["变量 a"] --> C["堆对象 String('abc') #1"]
B["变量 b"] --> D["堆对象 String('abc') #2"]
C -. "内容相同" .- Da == b 问的是“是不是同一个堆对象”;a.equals(b) 问的是“逻辑内容是不是相等”,前提是这个类正确重写了 equals。
equals 默认也比较地址
equals 来自 Object,默认实现大致等价于:
public boolean equals(Object obj) {
return this == obj;
}所以如果自定义类不重写 equals,它的 equals 也只是比较地址。
class Patient {
private final String patientNo;
Patient(String patientNo) {
this.patientNo = patientNo;
}
}
public class EqualsDemo {
public static void main(String[] args) {
Patient p1 = new Patient("P1001");
Patient p2 = new Patient("P1001");
System.out.println(p1 == p2); // false
System.out.println(p1.equals(p2)); // false,没有重写 equals
}
}如果业务上认为“患者号相同就是同一个患者”,就必须重写 equals。
import java.util.Objects;
class Patient {
private final String patientNo;
Patient(String patientNo) {
this.patientNo = patientNo;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof Patient other)) {
return false;
}
return Objects.equals(this.patientNo, other.patientNo);
}
@Override
public int hashCode() {
return Objects.hash(patientNo);
}
}为什么重写 equals 必须重写 hashCode
哈希集合先用 hashCode 定位大概位置,再用 equals 判断是否同一个元素。它们有一个硬约束:
只要 a.equals(b) 为 true,a.hashCode() 就必须等于 b.hashCode()。反过来不要求成立。hashCode 相同只是说明可能在同一个桶里,仍然要靠 equals 做最终判断。
如果只重写 equals,不重写 hashCode,会出现非常隐蔽的问题:两个业务上相同的对象,可能因为 hash 不同进入不同桶,导致 HashSet 去重失败、HashMap 查不到。
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class BadPatient {
private final String patientNo;
BadPatient(String patientNo) {
this.patientNo = patientNo;
}
@Override
public boolean equals(Object obj) {
return obj instanceof BadPatient other
&& Objects.equals(patientNo, other.patientNo);
}
}
public class HashCodeProblemDemo {
public static void main(String[] args) {
Set<BadPatient> set = new HashSet<>();
set.add(new BadPatient("P1001"));
set.add(new BadPatient("P1001"));
System.out.println(set.size()); // 可能是 2,业务去重失败
}
}商业系统里这类错误很危险。比如医疗采集平台用“患者号 + 就诊号 + 报告号”做业务去重,如果 equals/hashCode 写错,轻则重复采集,重则覆盖或漏处理数据。
包装类型比较为什么不能乱用 ==
包装类型是对象,== 比较的是引用。但 Integer 等包装类有缓存机制,常见范围 -128 到 127 可能复用对象。
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); // true,命中缓存
System.out.println(c == d); // false,通常不是同一对象这就导致 == 的结果看起来“有时对,有时不对”。业务代码比较包装类型值,一律优先用:
Objects.equals(a, b)这样还能避免空指针。
整数溢出
整数类型不是无限大的,超过范围会溢出。
public class OverflowDemo {
public static void main(String[] args) {
int max = Integer.MAX_VALUE;
System.out.println(max);
System.out.println(max + 1);
}
}Integer.MAX_VALUE + 1 不会变成更大的数,而是变成负数。这在订单号、库存、积分、计数器里都可能造成严重问题。
溢出的本质是固定二进制位数装不下结果。Java 整数采用补码表示,超过最大值后会回绕到最小值。
flowchart TD
A["Integer.MAX_VALUE"] --> B["加 1"]
B --> C["二进制高位溢出"]
C --> D["结果变成 Integer.MIN_VALUE"]如果是库存、积分、金额分、全局计数器,溢出不是小问题,可能导致负库存、负金额、排序错乱、分页异常。
业务建议:
| 场景 | 推荐类型 |
|---|---|
| 数据库主键 ID | Long 或 long |
| 金额分单位保存 | Long |
| 金额元单位计算 | BigDecimal |
| 普通页码、数量 | Integer 或 int |
JDK 8 提供了安全运算方法,溢出时抛异常:
public class ExactMathDemo {
public static void main(String[] args) {
int max = Integer.MAX_VALUE;
System.out.println(Math.addExact(max, 1)); // ArithmeticException
}
}生产中如果你明确“不允许溢出”,可以使用 Math.addExact、Math.multiplyExact 这类方法,让错误尽早暴露。
为什么金额不能用 double
float 和 double 使用二进制浮点表示,很多十进制小数无法被精确表示。
public class DoubleMoneyDemo {
public static void main(String[] args) {
System.out.println(0.1 + 0.2);
}
}输出通常不是精确的 0.3。
金额计算应使用 BigDecimal:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class MoneyDemo {
public static void main(String[] args) {
BigDecimal price = new BigDecimal("19.90");
BigDecimal count = new BigDecimal("3");
BigDecimal discount = new BigDecimal("0.95");
BigDecimal total = price.multiply(count)
.multiply(discount)
.setScale(2, RoundingMode.HALF_UP);
System.out.println(total);
}
}重点:创建 BigDecimal 时优先用字符串,不要用 new BigDecimal(0.1)。
BigDecimal 的 equals 和 compareTo
BigDecimal 还有一个常见坑:equals 会比较值和精度,compareTo 只比较数值大小。
import java.math.BigDecimal;
public class BigDecimalCompareDemo {
public static void main(String[] args) {
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b)); // false,scale 不同
System.out.println(a.compareTo(b) == 0); // true,数值相等
}
}金额大小比较建议用 compareTo。如果金额要入库、展示或作为统一结果,应明确 scale 和舍入规则,例如统一保留两位小数。
商业建议:
| 场景 | 推荐 |
|---|---|
| 数据库金额字段 | DECIMAL(18,2) 或以分为单位的 BIGINT |
| Java 业务计算 | BigDecimal,明确 RoundingMode |
| 金额比较 | compareTo |
| 金额对象相等判断 | 先统一 scale,再判断 |
| JSON 接口传金额 | 字符串或明确精度的数字,避免前端浮点误差 |
包装类型和自动装箱
基本类型有对应包装类型:
| 基本类型 | 包装类型 |
|---|---|
int | Integer |
long | Long |
double | Double |
boolean | Boolean |
自动装箱和拆箱:
Integer a = 10; // 自动装箱:int -> Integer
int b = a; // 自动拆箱:Integer -> int常见坑:
Integer count = null;
// int value = count; // NullPointerException,拆箱时空指针业务 DTO 和数据库字段经常使用包装类型,因为它可以表达 null,表示“没有传值”或“数据库为空”。但参与计算前必须判空。
基本类型和包装类型怎么选
| 场景 | 推荐 | 原因 |
|---|---|---|
| 局部计数、循环变量 | 基本类型 | 无需表达 null,性能直接 |
| DTO 请求参数 | 包装类型 | 能区分“没传”和“传了 0/false” |
| 数据库可空字段 | 包装类型 | 能表达数据库 NULL |
| 实体必填字段 | 视业务而定 | 创建时校验,避免运行期 null |
| 高并发计数 | AtomicLong、LongAdder | 保证并发安全 |
尤其是接口更新场景:
class UpdateUserRequest {
private Integer age; // null 表示不更新年龄,0 表示更新为 0
private Boolean enabled; // null 表示不更新开关,false 表示关闭
}如果这里用 int 和 boolean,就无法区分“不传”和“传了默认值”,很容易误更新数据。
Integer 缓存
public class IntegerCacheDemo {
public static void main(String[] args) {
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b);
System.out.println(c == d);
}
}常见情况下,-128 到 127 的 Integer 会被缓存,所以 a == b 可能是 true,但 c == d 可能是 false。
结论:包装类型比较值时用 equals,不要用 ==。
运算符原理
| 运算符 | 用途 | 注意点 |
|---|---|---|
+ - * / % | 算术运算 | 整数除法会丢弃小数 |
== != > < | 比较运算 | 引用类型 == 比较引用地址 |
| `&& | !` | |
| `& | ^ ~ << >> >>>` | 位运算 |
= += -= | 赋值运算 | 复合赋值可能包含隐式类型转换 |
短路 Demo:
public class ShortCircuitDemo {
public static void main(String[] args) {
String name = null;
if (name != null && name.length() > 0) {
System.out.println(name);
}
}
}如果没有短路,name.length() 会空指针。&& 左边为 false 时,右边不会执行。
& 和 &&、| 和 || 的区别
| 运算符 | 是否短路 | 常见用途 |
|---|---|---|
&& | 是 | 业务条件判断 |
| ` | ` | |
& | 否 | boolean 非短路判断、位运算 |
| ` | ` | 否 |
String name = null;
if (name != null && name.length() > 0) {
System.out.println(name);
}
// if (name != null & name.length() > 0) {
// System.out.println(name); // 会空指针,因为右边仍会执行
// }业务判断优先使用 && 和 ||。&、| 更多用于位运算,或者极少数需要两边表达式都执行的场景。
位运算业务 Demo:权限标记
位运算适合存储多个开关状态。
public class PermissionDemo {
private static final int READ = 1; // 0001
private static final int WRITE = 1 << 1; // 0010
private static final int DELETE = 1 << 2; // 0100
public static void main(String[] args) {
int permission = READ | WRITE;
System.out.println(hasPermission(permission, READ));
System.out.println(hasPermission(permission, DELETE));
}
private static boolean hasPermission(int permission, int target) {
return (permission & target) != 0;
}
}原理:每一位表示一个权限,多个权限可以用 | 合并,用 & 判断。
商业开发常见选择
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 金额 | BigDecimal 或分单位 long | 避免浮点精度问题 |
| ID | Long | 防止数据量增长后超过 int |
| 状态码 | Integer 或枚举 | 状态清晰,可维护 |
| 开关字段 | Boolean | 可表达 true、false、null |
| 高性能计数 | long、LongAdder | 并发下更适合 |
商业 Demo:订单金额和状态更新
这个 Demo 把类型选择、金额、包装类型、枚举和空值语义放在一起。
import java.math.BigDecimal;
import java.math.RoundingMode;
enum OrderStatus {
CREATED,
PAID,
CANCELLED
}
class UpdateOrderRequest {
private Long orderId;
private Integer buyCount;
private Boolean useCoupon;
public Long getOrderId() {
return orderId;
}
public Integer getBuyCount() {
return buyCount;
}
public Boolean getUseCoupon() {
return useCoupon;
}
}
public class OrderTypeDemo {
public static BigDecimal calculatePayAmount(BigDecimal unitPrice, int count, boolean useCoupon) {
if (unitPrice == null) {
throw new IllegalArgumentException("单价不能为空");
}
if (count <= 0) {
throw new IllegalArgumentException("购买数量必须大于 0");
}
BigDecimal total = unitPrice.multiply(BigDecimal.valueOf(count));
if (useCoupon) {
total = total.multiply(new BigDecimal("0.90"));
}
return total.setScale(2, RoundingMode.HALF_UP);
}
public static void main(String[] args) {
BigDecimal amount = calculatePayAmount(new BigDecimal("19.90"), 3, true);
System.out.println(amount);
}
}这个例子里:
orderId用Long,避免未来超过int。- 请求字段用包装类型,能表达“未传”。
- 金额用
BigDecimal,避免浮点误差。 - 订单状态用枚举,避免魔法数字。
- 计算前做参数校验,避免 null 拆箱和非法数量。
线上问题排查
金额对不上
flowchart TD
A["金额对不上"] --> B{"是否使用 double/float"}
B -- "是" --> C["改 BigDecimal 或分单位 long"]
B -- "否" --> D{"BigDecimal 构造是否用字符串"}
D -- "否" --> E["避免 new BigDecimal(0.1)"]
D -- "是" --> F{"舍入规则是否一致"}
F -- "否" --> G["统一 scale 和 RoundingMode"]
F -- "是" --> H["检查数据库 DECIMAL 和前端精度"]空指针来自自动拆箱
排查点:
- 异常栈是否指向
int value = wrapper或if (wrapper > 0)。 - DTO、数据库字段、Map 取值是否可能为 null。
- 是否用
Boolean enabled直接写if (enabled)。 - 是否接口未传字段导致包装类型为 null。
安全写法:
Boolean enabled = request.getEnabled();
if (Boolean.TRUE.equals(enabled)) {
System.out.println("启用");
}ID 或计数异常变负数
如果 ID、库存、积分、计数器突然变负,要检查:
- 是否用
int保存超过范围的数据。 - 是否做了乘法、加法溢出。
- 数据库字段和 Java 字段类型是否不一致。
- JSON 反序列化是否把大整数转成了错误类型。
- 是否需要
long、BigInteger或Math.addExact。
面试标准回答
基本类型和引用类型区别是什么?
基本类型变量直接保存值,例如 int、long、boolean;引用类型变量保存对象引用,真正对象通常在堆里。基本类型没有 null,包装类型和普通对象可以为 null。理解这个区别才能解释 ==、空指针、值传递和对象修改。
Java 是值传递还是引用传递?
Java 只有值传递。基本类型传值的副本;引用类型传引用值的副本。方法里可以通过引用副本修改同一个对象的字段,但不能改变调用方变量指向哪个对象。
为什么金额不能用 double?double 是二进制浮点,很多十进制小数无法精确表示,例如 0.1 + 0.2 不等于精确的 0.3。金额应使用 BigDecimal 并明确舍入规则,或者用分为单位的 long 保存。
包装类型有什么坑?
包装类型可以为 null,自动拆箱时如果是 null 会抛 NullPointerException;包装类型用 == 比较的是引用,还受 Integer 缓存影响。业务比较应使用 Objects.equals,布尔判断用 Boolean.TRUE.equals(value)。
整数溢出怎么理解?
整数类型位数固定,超过范围不会自动变成更大类型,而是按补码回绕,可能从最大正数变成负数。ID、金额分、库存、计数器要根据范围选择 long,必要时用 Math.addExact 暴露溢出。
常见问题
为什么 int / int 得不到小数
两个整数相除,结果仍然是整数,小数部分会被截断。
System.out.println(5 / 2); // 2
System.out.println(5 / 2.0); // 2.5为什么包装类型可能空指针
包装类型是对象,可以为 null。当它自动拆箱成基本类型时,如果值为 null,就会抛出 NullPointerException。
为什么业务代码不建议滥用位运算
位运算很高效,但可读性不如枚举和布尔字段。只有权限位、状态位、底层框架、高性能场景才适合使用。
本章小结
数据类型影响程序正确性、性能和可维护性。Java 基础里必须掌握基本类型、引用类型、包装类型、自动装箱、整数溢出、浮点精度和运算符短路。业务开发里尤其要注意金额、ID、状态、权限这些高风险类型。
