Skip to content

Java 数据类型与运算

数据类型决定一个值能表示什么、占多少空间、能做哪些运算。很多线上 bug 看起来是业务问题,根因其实是类型选择错误,比如金额用 double、订单数量用 short、用户 ID 用 int 导致溢出。

为什么要理解数据类型

Java 是强类型语言。变量声明后,编译器会根据类型限制它能保存的值和能参与的操作。

java
int count = 10;
// count = "10"; // 编译失败,int 不能接收字符串

强类型的好处是:很多错误可以在编译期提前发现,而不是等线上运行到某个分支才出问题。

基本类型和引用类型

mermaid
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()

基本类型一览

类型位数默认值常见用途
byte80文件、网络字节数据
short160较少直接使用
int320数量、状态、循环下标
long640LID、时间戳、较大数量
float320.0f对精度要求不高的小数
double640.0d科学计算、小数近似值
char16'\u0000'单个字符
boolean未明确规定false条件判断

注意:默认值是成员变量的默认值,局部变量必须手动初始化后才能读取。

基本类型和引用类型在内存中的区别

mermaid
flowchart TD
    A["main 方法栈帧"] --> B["int count = 10"]
    A --> C["User user 引用"]
    C --> D["堆中的 User 对象"]
    D --> E["name 字段"]
    D --> F["age 字段"]

示例:

java
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:

java
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 方法传参永远是值传递:

  1. 基本类型传的是值的副本。
  2. 引用类型传的是“引用值”的副本。

引用类型传进去以后,方法里能通过这个引用副本修改同一个对象的字段;但不能让外面的引用变量指向另一个对象。

mermaid
flowchart TD
    A["外部变量 user"] --> C["堆中 User 对象"]
    B["方法参数 param"] --> C
    D["param = new User()"] --> E["新的 User 对象"]
    A --> C

Demo:

java
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 只有值传递。对象参数传递的是引用值的副本,所以可以修改对象内部状态,但不能改变调用方变量本身指向哪个对象。

商业项目里的影响:

  1. Service 方法里修改传入 DTO 字段,会影响调用方看到的同一个对象。
  2. 方法内给参数重新赋值,不会改变外部变量。
  3. 可变对象在多层方法间传递时,要小心副作用。
  4. 需要保护对象不被改时,应使用不可变对象、拷贝对象或只暴露只读视图。

类型转换:自动提升和强制转换

Java 运算时会做类型提升。小范围类型可以自动转成大范围类型;大范围转小范围必须强制转换,并且可能丢失数据。

mermaid
flowchart TD
    A["byte"] --> B["short"]
    B --> C["int"]
    C --> D["long"]
    D --> E["float"]
    E --> F["double"]
    G["char"] --> C

自动类型提升:

java
int a = 10;
long b = a;       // int 自动转 long
double c = b;     // long 自动转 double

强制类型转换:

java
long big = 3_000_000_000L;
int small = (int) big;
System.out.println(small); // 可能变成负数或错误值

为什么会错?因为 int 只有 32 位,放不下 3_000_000_000L。强制转换不是“智能压缩”,而是截断高位,结果可能完全不符合业务含义。

表达式中的类型提升

byteshortchar 参与算术运算时,通常会先提升为 int

java
byte a = 1;
byte b = 2;
// byte c = a + b; // 编译失败,a + b 的结果是 int
int c = a + b;

这就是为什么很多初学者会疑惑“两个 byte 相加为什么不能赋给 byte”。Java 为了减少小整数运算溢出的隐式风险,会先提升到 int

复合赋值的隐式转换

复合赋值会隐含强制类型转换:

java
byte x = 1;
x += 1;      // 等价于 x = (byte) (x + 1)
// x = x + 1; // 编译失败,因为 x + 1 是 int

x += 1 看起来更方便,但如果数值超过范围,仍然会溢出。不要因为它能编译就忽略范围风险。

char、Unicode 和字符编码

char 是 16 位无符号字符单元,历史上常说它表示一个 Unicode 字符,但现代 Unicode 中有些字符超过 16 位,需要两个 char 组成代理对。

java
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 不是一回事:

概念说明
charJava 内部的 16 位字符单元
Unicode字符编号标准
UTF-8把 Unicode 编码成字节的方式
UTF-16Java String 内部常见编码表示相关

文件、网络传输的是字节,不是 char。读写文件必须明确编码,否则不同机器默认字符集不同,可能出现乱码。

boolean 不是数字

Java 的 boolean 只能是 truefalse,不能像 C 语言那样用 01 直接表示。

java
boolean success = true;
// if (1) {} // Java 编译失败

这让条件表达更清晰,避免把整数误当布尔。数据库或接口中如果用 0/1 表示开关,进入 Java 后建议转换为 Boolean 或枚举,让业务含义更明确。

==equals 到底比什么

这道题不能只背“== 比地址,equals 比内容”。更准确地说:

text
`==` 是 Java 语言层面的比较运算符;`equals` 是对象的方法,默认比较地址,但可以被类重写成业务相等。

基本类型使用 ==

基本类型变量里直接放的是值,所以 == 比较的就是值本身。

java
int a = 100;
int b = 100;
System.out.println(a == b); // true

如果是 charbooleanlong 等基本类型,本质也一样。

引用类型使用 ==

引用类型变量里保存的是“指向堆中对象的引用”。所以 == 比的是两个引用是否指向同一个对象。

java
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,比较字符内容

你可以把它理解成:

mermaid
flowchart TD
    A["变量 a"] --> C["堆对象 String('abc') #1"]
    B["变量 b"] --> D["堆对象 String('abc') #2"]
    C -. "内容相同" .- D

a == b 问的是“是不是同一个堆对象”;a.equals(b) 问的是“逻辑内容是不是相等”,前提是这个类正确重写了 equals

equals 默认也比较地址

equals 来自 Object,默认实现大致等价于:

java
public boolean equals(Object obj) {
    return this == obj;
}

所以如果自定义类不重写 equals,它的 equals 也只是比较地址。

java
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

java
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 判断是否同一个元素。它们有一个硬约束:

text
只要 a.equals(b) 为 true,a.hashCode() 就必须等于 b.hashCode()。

反过来不要求成立。hashCode 相同只是说明可能在同一个桶里,仍然要靠 equals 做最终判断。

如果只重写 equals,不重写 hashCode,会出现非常隐蔽的问题:两个业务上相同的对象,可能因为 hash 不同进入不同桶,导致 HashSet 去重失败、HashMap 查不到。

java
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 等包装类有缓存机制,常见范围 -128127 可能复用对象。

java
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;

System.out.println(a == b); // true,命中缓存
System.out.println(c == d); // false,通常不是同一对象

这就导致 == 的结果看起来“有时对,有时不对”。业务代码比较包装类型值,一律优先用:

java
Objects.equals(a, b)

这样还能避免空指针。

整数溢出

整数类型不是无限大的,超过范围会溢出。

java
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 整数采用补码表示,超过最大值后会回绕到最小值。

mermaid
flowchart TD
    A["Integer.MAX_VALUE"] --> B["加 1"]
    B --> C["二进制高位溢出"]
    C --> D["结果变成 Integer.MIN_VALUE"]

如果是库存、积分、金额分、全局计数器,溢出不是小问题,可能导致负库存、负金额、排序错乱、分页异常。

业务建议:

场景推荐类型
数据库主键 IDLonglong
金额分单位保存Long
金额元单位计算BigDecimal
普通页码、数量Integerint

JDK 8 提供了安全运算方法,溢出时抛异常:

java
public class ExactMathDemo {
    public static void main(String[] args) {
        int max = Integer.MAX_VALUE;
        System.out.println(Math.addExact(max, 1)); // ArithmeticException
    }
}

生产中如果你明确“不允许溢出”,可以使用 Math.addExactMath.multiplyExact 这类方法,让错误尽早暴露。

为什么金额不能用 double

floatdouble 使用二进制浮点表示,很多十进制小数无法被精确表示。

java
public class DoubleMoneyDemo {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);
    }
}

输出通常不是精确的 0.3

金额计算应使用 BigDecimal

java
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 只比较数值大小。

java
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 接口传金额字符串或明确精度的数字,避免前端浮点误差

包装类型和自动装箱

基本类型有对应包装类型:

基本类型包装类型
intInteger
longLong
doubleDouble
booleanBoolean

自动装箱和拆箱:

java
Integer a = 10; // 自动装箱:int -> Integer
int b = a;      // 自动拆箱:Integer -> int

常见坑:

java
Integer count = null;
// int value = count; // NullPointerException,拆箱时空指针

业务 DTO 和数据库字段经常使用包装类型,因为它可以表达 null,表示“没有传值”或“数据库为空”。但参与计算前必须判空。

基本类型和包装类型怎么选

场景推荐原因
局部计数、循环变量基本类型无需表达 null,性能直接
DTO 请求参数包装类型能区分“没传”和“传了 0/false”
数据库可空字段包装类型能表达数据库 NULL
实体必填字段视业务而定创建时校验,避免运行期 null
高并发计数AtomicLongLongAdder保证并发安全

尤其是接口更新场景:

java
class UpdateUserRequest {
    private Integer age;      // null 表示不更新年龄,0 表示更新为 0
    private Boolean enabled;  // null 表示不更新开关,false 表示关闭
}

如果这里用 intboolean,就无法区分“不传”和“传了默认值”,很容易误更新数据。

Integer 缓存

java
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);
    }
}

常见情况下,-128127Integer 会被缓存,所以 a == b 可能是 true,但 c == d 可能是 false

结论:包装类型比较值时用 equals,不要用 ==

运算符原理

运算符用途注意点
+ - * / %算术运算整数除法会丢弃小数
== != > <比较运算引用类型 == 比较引用地址
`&&!`
`&^ ~ << >> >>>`位运算
= += -=赋值运算复合赋值可能包含隐式类型转换

短路 Demo:

java
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 非短路判断、位运算
``
java
String name = null;

if (name != null && name.length() > 0) {
    System.out.println(name);
}

// if (name != null & name.length() > 0) {
//     System.out.println(name); // 会空指针,因为右边仍会执行
// }

业务判断优先使用 &&||&| 更多用于位运算,或者极少数需要两边表达式都执行的场景。

位运算业务 Demo:权限标记

位运算适合存储多个开关状态。

java
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避免浮点精度问题
IDLong防止数据量增长后超过 int
状态码Integer 或枚举状态清晰,可维护
开关字段Boolean可表达 true、false、null
高性能计数longLongAdder并发下更适合

商业 Demo:订单金额和状态更新

这个 Demo 把类型选择、金额、包装类型、枚举和空值语义放在一起。

java
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);
    }
}

这个例子里:

  1. orderIdLong,避免未来超过 int
  2. 请求字段用包装类型,能表达“未传”。
  3. 金额用 BigDecimal,避免浮点误差。
  4. 订单状态用枚举,避免魔法数字。
  5. 计算前做参数校验,避免 null 拆箱和非法数量。

线上问题排查

金额对不上

mermaid
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 和前端精度"]

空指针来自自动拆箱

排查点:

  1. 异常栈是否指向 int value = wrapperif (wrapper > 0)
  2. DTO、数据库字段、Map 取值是否可能为 null。
  3. 是否用 Boolean enabled 直接写 if (enabled)
  4. 是否接口未传字段导致包装类型为 null。

安全写法:

java
Boolean enabled = request.getEnabled();
if (Boolean.TRUE.equals(enabled)) {
    System.out.println("启用");
}

ID 或计数异常变负数

如果 ID、库存、积分、计数器突然变负,要检查:

  1. 是否用 int 保存超过范围的数据。
  2. 是否做了乘法、加法溢出。
  3. 数据库字段和 Java 字段类型是否不一致。
  4. JSON 反序列化是否把大整数转成了错误类型。
  5. 是否需要 longBigIntegerMath.addExact

面试标准回答

基本类型和引用类型区别是什么?
基本类型变量直接保存值,例如 intlongboolean;引用类型变量保存对象引用,真正对象通常在堆里。基本类型没有 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 得不到小数

两个整数相除,结果仍然是整数,小数部分会被截断。

java
System.out.println(5 / 2);      // 2
System.out.println(5 / 2.0);    // 2.5

为什么包装类型可能空指针

包装类型是对象,可以为 null。当它自动拆箱成基本类型时,如果值为 null,就会抛出 NullPointerException

为什么业务代码不建议滥用位运算

位运算很高效,但可读性不如枚举和布尔字段。只有权限位、状态位、底层框架、高性能场景才适合使用。

本章小结

数据类型影响程序正确性、性能和可维护性。Java 基础里必须掌握基本类型、引用类型、包装类型、自动装箱、整数溢出、浮点精度和运算符短路。业务开发里尤其要注意金额、ID、状态、权限这些高风险类型。