Java 的值属于基本类型或引用类型。基本类型直接表示布尔值、整数、浮点数或 UTF-16 代码单元;引用类型的值指向对象或数组,也可以是 null。
扩大转换也可能丢失精度,缩小转换可能截断或回绕;包装类型还会带来 null 拆箱和引用相等性问题。
按值域和缺失值语义选择类型,在边界处显式校验转换,并用 BigDecimal、Math.*Exact 与值相等比较表达业务约束。
是什么,为什么存在
数据类型规定一个值可以表示什么,以及哪些操作对它有效。Java 是静态类型语言:编译器在代码运行前检查赋值、方法调用和表达式能否按语言规则组合。变量的类型不会因为它后来保存了另一个值而改变,var 也只是让编译器从初始化表达式推断一个固定的局部变量类型。
Java 的类型分为 基本类型(primitive type) 和 引用类型(reference type) 。八种基本类型是语言内建的值类别;类、接口、数组和类型变量都属于引用类型。基本类型变量不能保存 null,引用变量则保存对象或数组的引用,也可能保存空引用 null。
这一区分会出现在几乎每个 API 边界。算术表达式会执行数值提升,泛型集合需要包装类型,数据库或反序列化数据可能产生 null,文本处理还必须区分 char 和完整 Unicode 码点。理解类型规则可以让你在编译器允许代码通过时,继续判断转换是否符合业务含义。
八种基本类型
Java 规定了整数类型的位宽和取值范围,因此这些语义不随处理器平台改变。boolean 只有 true 和 false 两个值;Java 语言规范没有规定它在对象布局中占多少字节。
| 类型 | 类别 | 位宽 | 取值或含义 |
|---|---|---|---|
byte | 有符号整数 | 8 | −128 到 127 |
short | 有符号整数 | 16 | −32,768 到 32,767 |
int | 有符号整数 | 32 | −2³¹ 到 2³¹−1 |
long | 有符号整数 | 64 | −2⁶³ 到 2⁶³−1 |
char | 无符号整数 | 16 | 一个 UTF-16 代码单元,0 到 65,535 |
float | IEEE 754 二进制浮点数 | 32 | 有限值、正负零、正负无穷和 NaN |
double | IEEE 754 二进制浮点数 | 64 | 有限值、正负零、正负无穷和 NaN |
boolean | 布尔值 | 未规定 | true 或 false |
int 通常是整数运算的默认选择,整数文字也默认具有 int 类型;超过其范围的整数字面量通常需要 L 后缀成为 long。浮点文字默认是 double,赋给 float 时需要 F 后缀或显式转换。后缀解决的是字面量类型,不会阻止运行时运算溢出或舍入。
引用类型与对象
引用值不是对象内容本身。把一个数组、字符串或普通对象的引用赋给另一个变量,两个变量会指向同一个对象;通过其中一个引用修改可变对象,另一个引用可以看到变化。Java 只按值传递参数,对引用类型而言,被复制的值正是引用。
数组本身是对象,即使它的元素是基本类型。int[] 保存连续的 int 元素语义,而 Integer[] 保存引用,每个元素还可能是 null。泛型类型参数不能写成基本类型,所以 List<int> 非法,存放整数的通用集合要使用 List<Integer>。
默认值只属于特定存储位置
实例字段、静态字段和新建数组的元素会得到默认值。数值基本类型默认为零,char 默认为 \u0000,boolean 默认为 false,引用类型默认为 null。这些默认值让对象和数组在构造后拥有确定状态。
局部变量没有默认值。编译器执行明确赋值分析;在某条路径上读取尚未确定赋值的局部变量会导致编译错误。这条规则阻止方法把未初始化的栈上状态误当成合法业务值。
按契约选择类型
选择类型要从允许的值集合出发,而不是只看当前样本能放进哪个最小表示。普通的有界计数可使用 int,文档规定的范围更大时使用 long。如果溢出属于错误而不是模运算,契约还需要校验或精确运算;单纯换成更宽的类型只是推远边界。
缺失是独立于数值范围的另一项决定。基本类型表示每个实例都有值;包装类型可以用 null 编码缺失,却要求每个消费者处理这一状态。只有领域排除 -1,而且 API 记录了其含义时,它才适合作为哨兵值。
有些看似数字的数据并不是数量。账号、邮政编码和外部分配的标识符可能需要保留前导零、格式或超出算术类型位数的内容。除非契约要求算术运算,否则应把它们表示为文本或领域类型。
编码字节使用 byte[],UTF-16 文本使用 char 或 String,但不能靠强制转换在两者之间转换。文本转字节需要明确 UTF-8 等字符编码。这个边界应放在编码器或解码器 API 中,以便有意选择畸形输入的处理方式。
工作原理
声明、赋值与方法调用
变量声明建立一个静态类型,赋值表达式必须能通过赋值转换产生该类型的值。方法调用则使用方法调用转换选择可适用的重载。强制转换表达式提供额外的转换能力,但它只是请求语言按规定改变表示或检查引用类型,并不证明结果符合业务范围。
引用类型赋值可以向上转换,例如把 ArrayList<String> 引用赋给 List<String> 变量。向下转换需要显式强制转换,并在运行时检查对象是否属于目标类型;检查失败会抛出 ClassCastException。模式匹配的 instanceof 可以把测试与安全的局部绑定合在一起。
重载调用的转换阶段
重载解析会分阶段考虑转换。只需恒等转换、扩大基本类型转换或扩大引用转换的固定参数候选,会先于需要装箱或拆箱的候选处理。只有固定参数阶段都失败后,才会考虑可变参数候选。
这种顺序意味着新增一个包装类型重载可能改变生成代码选择的调用目标,即使每个方法单独看都能编译。基本类型扩大重载可能先于装箱重载胜出,而已有的包装引用可能在较早阶段扩大为 Object,而不是拆箱后传给基本类型参数。检查调用时既要看实参的声明类型,也要看它的运行时值。
空字面量可以传给引用参数,不能传给基本类型参数。如果互不相关的引用重载同样具体,直接传入 null 可能在编译期产生歧义。应避免只靠不相关包装类型区分的重载族;具有领域含义的方法名通常能更清楚地表达契约。
扩大与缩小基本类型转换
扩大基本类型转换把值送入语言规定的目标类型。byte、short 和 char 可以扩大为 int,整数类型还可以沿规则转换为更宽的整数或浮点类型。扩大整数到更宽整数不会丢失值,但 int 到 float、long 到 float 或 long 到 double 可能因有效位不足而丢失精度。
缩小转换(narrowing conversion) 需要显式强制转换,除非编译期常量满足少数赋值规则。整数缩小时会丢弃高位,因此结果可能变号;浮点数转整数时先向零舍入,并对 NaN 与超范围值应用规范定义的结果。强制转换不会因普通的数值越界自动抛出异常。
常见的扩大路径如下。箭头表示存在扩大转换,不表示每一步都保持数值精度。
| 来源 | 可扩大的目标 |
|---|---|
byte | short、int、long、float、double |
short | int、long、float、double |
char | int、long、float、double |
int | long、float、double |
long | float、double |
float | double |
boolean 不参与数值转换。Java 不允许用 0 和 1 代替布尔值,也不允许在布尔值与整数之间强制转换。这让条件表达式的意图保持明确。
数值提升与中间结果
一元和二元数值运算会执行 数值提升(numeric promotion) 。byte、short 和 char 在大多数算术中先提升为 int,所以两个 byte 相加的表达式类型也是 int。混合类型运算会按照二元数值提升规则选择共同类型。
结果变量更宽,并不能挽救已经溢出的中间结果。表达式 long total = intPrice * intQuantity 先以 int 完成乘法,再把已经得到的结果扩大为 long。把一个操作数提前转换为 long,或者使用 Math.multiplyExact,才能改变乘法本身的范围或把溢出变成异常。
复合赋值还有隐式缩小语义。count += delta 大致等价于 count = (T) (count + delta),其中 T 是左侧类型,但左侧只求值一次。因此,byte 的 += 可以编译,同时也可能静默回绕。
装箱、拆箱与 null
自动装箱(autoboxing) 把基本类型值转换成对应包装类引用,例如从 int 到 Integer。 拆箱(unboxing) 执行反向转换。编译器会在泛型集合、赋值、方法调用和表达式需要时插入这些转换。
包装对象拥有引用身份,但多数业务代码关心的是它包装的值。对两个 Integer 使用 == 比较的是引用,除非表达式先触发拆箱;某些小型常量的装箱身份受规范保证,这反而会让错误代码在小测试数据上看似正确。应使用 equals 或 Objects.equals 比较包装值。
把 null 包装引用拆箱会抛出 NullPointerException。这种拆箱可能隐藏在算术、关系比较、条件表达式、方法实参或增强 for 循环里。API 必须明确缺失值是否允许,并在拆箱前验证、提供有业务含义的默认值,或继续使用引用形式表达缺失。
浮点数、十进制数与文本
float 和 double 使用二进制浮点表示。许多十进制小数没有有限的二进制表示,因此 0.1 + 0.2 的结果不等于 0.3 的 double 表示。它们适合测量、科学计算和允许舍入误差的数值,但通常不适合要求十进制精确与固定舍入规则的金额。
BigDecimal 用任意精度整数和小数位数表示十进制值。由外部十进制文本构造时使用字符串构造器;new BigDecimal(0.1) 会忠实捕获那个不精确的 double 值。除法可能需要显式给出精度或舍入模式,equals 还会比较小数位数,而 compareTo 比较数值顺序。
char 表示一个 UTF-16 代码单元,不保证是完整用户可见字符。基本多文种平面之外的 Unicode 码点使用一对代理代码单元,所以字符串的 length() 与代码点数量可能不同。需要遍历完整码点时,使用 codePoints()、codePointAt 或按代码点偏移的 API。
数组保留运行时元素类型
数组对象会在运行时记住元素类型。Java 数组具有协变性,因此 String[] 可以赋给 Object[] 变量,但底层对象仍然是字符串数组。通过这个较宽的引用存入非字符串值可以通过编译,却会抛出 ArrayStoreException。
泛型集合选择了另一种取舍。List<String> 不是 List<Object> 的子类型,因此类似的不安全写入会在编译期被拒绝。普通 Java 泛型的类型参数会被擦除,而数组的元素类型检查仍在运行时保留。
基本类型数组也没有逐元素装箱语义。int[] 元素始终具有 int 值并初始化为零;Integer[] 元素是引用并初始化为 null。应根据 API 是否需要泛型互操作和缺失值来选择,只在性能确实重要时再测量。
示例
基本类型与引用
public class PrimitiveValues {
public static void main(String[] args) {
byte warehouseZone = 12;
int unitsInStock = 2_400;
long orderId = 3_000_000_000L;
double packageWeightKg = 1.75;
char currencySymbol = '€';
boolean paid = true;
int[] dailyOrders = {18, 21, 16};
int[] sameOrders = dailyOrders; // 复制引用,而不是数组元素
sameOrders[0] = 20;
System.out.println("zone=" + warehouseZone + ", stock=" + unitsInStock);
System.out.println("order=" + orderId + ", weight=" + packageWeightKg);
System.out.println("currencyUnit=" + (int) currencySymbol + ", paid=" + paid);
System.out.println("firstDay=" + dailyOrders[0]);
}
}zone=12, stock=2400
order=3000000000, weight=1.75
currencyUnit=8364, paid=true
firstDay=20字面量后缀让 orderId 与 packageWeightKg 获得需要的类型。最后一行证明数组变量保存引用:修改 sameOrders 指向的元素,也会通过 dailyOrders 观察到同一个变化。
转换与溢出检查
public class NumericConversions {
public static void main(String[] args) {
int exactInteger = 16_777_217;
float roundedFloat = exactInteger; // 扩大类型,但丢失一个单位的精度
int shipmentCode = 130;
byte narrowedCode = (byte) shipmentCode; // 只保留低 8 位
byte packedCount = 12;
packedCount += 120; // 隐含缩小转换
System.out.printf("int=%d float=%.0f%n", exactInteger, roundedFloat);
System.out.println("narrowed=" + narrowedCode + ", compound=" + packedCount);
try {
Math.toIntExact(3_000_000_000L);
} catch (ArithmeticException error) {
System.out.println("overflow detected");
}
}
}int=16777217 float=16777216
narrowed=-126, compound=-124
overflow detected第一行展示了扩大转换不等于精确转换。后两种 byte 结果来自低位保留,而不是异常;Math.toIntExact 则适合需要拒绝越界值的边界。
包装类型与缺失值
import java.util.List;
import java.util.Objects;
public class BoxingValues {
public static void main(String[] args) {
List<Integer> quantities = List.of(2, 3, 4); // 添加元素时发生装箱
int total = 0;
for (int quantity : quantities) { // 读取每个元素时发生拆箱
total = Math.addExact(total, quantity);
}
Integer expected = 9;
Integer actual = Integer.valueOf(total);
Integer missing = null;
System.out.println("total=" + total);
System.out.println("matches=" + Objects.equals(expected, actual));
System.out.println("fallback=" + Objects.requireNonNullElse(missing, 0));
}
}total=9
matches=true
fallback=0集合的元素类型必须是 Integer,但累加器可以继续使用 int。Objects.equals 同时处理值比较和空引用;默认值只有在业务确实把缺失解释为零时才合适。
精确十进制与 Unicode 码点
import java.math.BigDecimal;
import java.math.RoundingMode;
public class DecimalAndText {
public static void main(String[] args) {
BigDecimal unitPrice = new BigDecimal("19.95");
BigDecimal quantity = BigDecimal.valueOf(3);
BigDecimal taxRate = new BigDecimal("0.075");
BigDecimal subtotal = unitPrice.multiply(quantity);
BigDecimal tax = subtotal.multiply(taxRate)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal oneDecimal = new BigDecimal("1.0");
BigDecimal twoDecimals = new BigDecimal("1.00");
String vehicle = "🚗";
System.out.println("subtotal=" + subtotal + ", tax=" + tax);
System.out.println("numericEqual=" + (oneDecimal.compareTo(twoDecimals) == 0));
System.out.println("equals=" + oneDecimal.equals(twoDecimals));
System.out.println("units=" + vehicle.length()
+ ", codePoints=" + vehicle.codePointCount(0, vehicle.length()));
}
}subtotal=59.85, tax=4.49
numericEqual=true
equals=false
units=2, codePoints=1金额从十进制字符串进入 BigDecimal,舍入规则只在税额需要两位小数的边界出现。另两行分别揭示了 BigDecimal 的小数位数语义,以及 UTF-16 代码单元数量和 Unicode 码点数量的差别。
陷阱
深入理解转换边界
常量表达式的窄化赋值
整数字面量 127 的类型是 int,但 byte limit = 127 可以编译。赋值转换允许把可在编译期求值的 int 常量缩小到 byte、short 或 char,前提是值能由目标类型表示。这是编译器证明安全后的例外,不是运行时整数变量之间的通用转换。
如果先写 int limitValue = 127,再把 limitValue 赋给 byte,就需要显式转换,因为它不再是规范所需的常量表达式。final int limitValue = 127 在满足常量变量规则时可以继续参与常量表达式。审查生成代码时,要区分编译期证明与运行时数据。
浮点数转整数的确定结果
浮点数缩小为整数时,Java 不使用调用者选择的舍入模式。有限值先向零舍入;结果超出目标整数范围时会落到相应的最小值或最大值,NaN 转换为零。再缩小到 byte 或 short 时,还会继续丢弃高位。
这种确定性不等于业务安全。把未验证的 double 强制转换为订单数量,NaN 可能悄悄成为 0,正无穷可能成为最大整数。需要拒绝这些输入时,应先用 Double.isFinite 与显式范围检查,再执行转换。
装箱身份的最低保证
对某些编译期常量进行装箱时,规范要求相同的小型值产生 == 相同的引用,包括布尔值、char 的 \u0000 到 \u007f,以及 byte、short、int、long 的 −128 到 127。实现可以缓存更多值,所以缓存范围外的身份不能作为可移植假设。
这解释了为什么包装对象的 == 缺陷容易漏过单元测试。测试值若都在最低缓存范围内,引用比较会偶然符合值比较预期。值语义应通过 equals、Objects.equals 或拆箱后的基本类型比较明确表达。
BigDecimal 的值与表示
BigDecimal 的值由无标度整数和小数位数组成,因此 1.0 与 1.00 数值顺序相同,却有不同表示。compareTo 返回零表示数值相等;equals 只有在值和小数位数都相同时才为真。把 BigDecimal 用作 HashMap 键时,这个差别尤其重要,因为哈希相等遵循 equals。
计算策略还需要规定除法和舍入边界。无法精确终止的除法在没有舍入上下文时会抛出 ArithmeticException。不要让生成代码任意选择 HALF_UP 或固定两位小数;币种、税务规则或领域协议才决定小数位数与舍入模式。
引用转换的运行时检查
引用强制转换不会把对象变成另一个类的实例。它只会在运行时兼容性检查通过后改变静态视图。把实际引用 Long 的 Object 转成 Integer 会失败,即使两个包装对象保存的数值可以比较。
如果输入契约允许多个运行时变体,应使用 instanceof。如果这些变体属于领域模型,则使用多态或密封层次结构。先猜测性地转换,再捕获 ClassCastException,通常会掩盖本应显式检查或建模的边界。
null 的行为不同:把 null 转成引用类型会成功,结果仍然是 null。因此,一次成功转换只能证明非空对象的类型兼容性,不能证明值存在。后续操作要解引用结果时,仍需让空值检查保持可见。
反射与反序列化 API 常把值暴露为 Object,容易诱使生成代码根据样本载荷直接转换。转换前应核对生产者声明的模式,以及支持的所有数值包装类。通过 Number API 转换仍可能缩小或舍入,所以同样需要范围策略。
正负零、无穷与 NaN
浮点类型包含正零、负零、正负无穷和 NaN。普通 == 会把两个零视为相等,但倒数可以揭示它们的符号。NaN 与任何值都不相等,包括它自身,所以要使用 Float.isNaN 或 Double.isNaN 检查。
浮点数除以零会按 IEEE 754 语义产生无穷或 NaN,不会抛出整数除零使用的 ArithmeticException。只捕获 ArithmeticException 的生成校验无法拒绝非有限浮点结果。API 只接受普通有限测量值时,应使用 isFinite。
存在 NaN 时,关系运算符无法提供有用的全序。排序和有序容器应采用 Double.compare 或 Double.compareTo 等库比较契约,而且应用要先决定是否允许非有限值。不要用相减后强制转换的方式临时拼出排序规则。
BigDecimal 没有 NaN 或无穷值。这会让无效十进制输入失败,而不是作为特殊数值继续传播,但仍不能取代范围、小数位数和舍入规则。精确表示与合法业务输入是两项不同保证。
类型规则与运行时布局
语言规范规定了值范围和转换语义,但不承诺某个局部变量或包装对象在特定 JVM 上占用多少内存。对象头、引用宽度、对齐和 JIT 优化都属于实现层面。没有针对目标 JVM 的测量时,不应从基本类型位宽直接推导完整对象大小或性能倍数。
同理,自动装箱的源代码语义不保证每次都分配一个可观察的新对象。编译器和 JVM 可以在不改变可观察行为的前提下消除分配。性能敏感代码应先用合适的基准工具测量,但正确性不能依赖优化是否发生。
线协议或文件布局要求确切位宽时,应采用格式的字节级契约,而不是假设 JVM 对象布局。ByteBuffer 和显式编码器可以让字节序、位宽与失败处理保持可见。
API 只需要值语义时,应暴露基本类型或不可变领域值,而不是包装对象身份。这样能把实现的缓存与分配决策留在契约之外。
4个问题 · 1 道输出预测题 · 1 道找错题