Java 变量里存的要么是基本类型的值,要么是指向对象的引用。分清这一点,就能看懂静默溢出、按值传递、== 与 equals 的区别,以及两个存着 128 的 Integer 为什么不相等。
Java 变量里存的东西只有两种。基本类型的变量,比如 int 或 double,存的就是值本身。其他类型的变量存的是一个引用,指向放在别处的对象。Java 新手遇到的意外,大多来自把这两种搞混。
本文会讲八种基本类型、溢出与算术、类型转换、var、引用、按值传递、== 与 equals、装箱和 Integer == 陷阱,以及 final 到底固定了什么。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存为 Main.java,然后执行 java Main.java。
Java 的八种基本类型
Java 正好有八种基本类型:六种表示数字,一种表示字符,一种表示真或假。它们的大小由语言规定,所以 int 在任何机器上都是 32 位。取值范围不用背,每个类型的包装类都把它们存成了常量:
void main() {
IO.println("byte " + Byte.MIN_VALUE + " to " + Byte.MAX_VALUE);
IO.println("short " + Short.MIN_VALUE + " to " + Short.MAX_VALUE);
IO.println("int " + Integer.MIN_VALUE + " to " + Integer.MAX_VALUE);
IO.println("long " + Long.MIN_VALUE + " to " + Long.MAX_VALUE);
IO.println("float largest " + Float.MAX_VALUE);
IO.println("double largest " + Double.MAX_VALUE);
IO.println("char " + (int) Character.MIN_VALUE + " to " + (int) Character.MAX_VALUE);
IO.println("bits " + Byte.SIZE + " " + Short.SIZE + " " + Integer.SIZE + " " + Long.SIZE
+ " " + Float.SIZE + " " + Double.SIZE + " " + Character.SIZE);
boolean done = true;
IO.println("boolean " + done + " or " + !done);
}
输出:
byte -128 to 127
short -32768 to 32767
int -2147483648 to 2147483647
long -9223372036854775808 to 9223372036854775807
float largest 3.4028235E38
double largest 1.7976931348623157E308
char 0 to 65535
bits 8 16 32 64 32 64 16
boolean true or false
大多数代码用 int 表示整数,二十亿不够用时换成 long,测量值用 double,条件用 boolean。char 其实是一个 16 位的数字,所以它的范围打印出来是 0 到 65535。
数字字面量的写法
数字字面量里可以加下划线、表示类型的后缀,以及表示十六进制或二进制的前缀。下划线只是给人看的,编译器会忽略它们:
void main() {
int million = 1_000_000;
long worldPopulation = 8_100_000_000L;
float ratio = 1.5f;
double price = 19.99;
int mask = 0xFF;
int flags = 0b1010;
char grade = 'A';
IO.println(million + " " + worldPopulation + " " + ratio + " " + price);
IO.println(mask + " " + flags + " " + grade);
}
输出:
1000000 8100000000 1.5 19.99
255 10 A
0xFF 是十六进制的 255,0b1010 是二进制的 10。真正要记住的是后缀。整数字面量默认是 int,除非以 L 结尾;小数字面量默认是 double,除非以 f 结尾。如果一个数字超出了 int 的范围,你却没写 L,程序就编译不过:
void main() {
long worldPopulation = 8100000000;
IO.println(worldPopulation);
}
构建失败,报错:
Main.java:2: error: integer number too large
long worldPopulation = 8100000000;
^
变量是 long 也没用。编译器先把字面量当作 int 来读,而它放不下。要用大写的 L,因为小写的 l 看起来像数字 1。
字段有默认值,局部变量没有
类里的字段一开始就有默认值:数字是零,boolean 是 false,引用是 null。方法里的局部变量则完全没有默认值。
class Account {
int balance;
double rate;
boolean frozen;
String owner;
}
void main() {
var account = new Account();
IO.println(account.balance + " " + account.rate + " " + account.frozen + " " + account.owner);
}
输出:
0 0.0 false null
换成局部变量试试,在你赋值之前,编译器不让你读取它:
void main() {
int count;
IO.println(count);
}
构建失败,报错:
Main.java:3: error: variable count might not have been initialized
IO.println(count);
^
Java 在运行之前会检查方法里的每一条路径。只要有一条路径可能在没赋值的情况下读到这个变量,构建就会失败。
整数溢出会悄无声息地回绕
当 int 的计算结果超过 int 的最大值时,Java 不会停下来,也不会警告你。值会回绕到 int 的最小负数,然后从那里继续算。
void main() {
int max = Integer.MAX_VALUE;
IO.println(max + 1);
IO.println(max * 2);
long wrong = Integer.MAX_VALUE + 1;
long right = Integer.MAX_VALUE + 1L;
IO.println(wrong);
IO.println(right);
}
输出:
-2147483648
-2
-2147483648
2147483648
后两行才是值得记住的陷阱。wrong 是 long,空间足够大,却还是存了一个负数。右边的表达式先按 int 算术求值,在结果被拓宽成 long 之前就已经回绕了。把其中一个操作数写成 long,也就是 1L,整个加法就变成 long 算术。
如果溢出意味着 bug,比如金额或数量,就让它抛出异常。Math.addExact、Math.multiplyExact 这类方法在结果放不下时会抛异常:
void main() {
int stock = 2_000_000_000;
IO.println("before: " + stock);
stock = Math.addExact(stock, 500_000_000);
IO.println("after: " + stock);
}
输出后停止:
before: 2000000000
Exception in thread "main" java.lang.ArithmeticException: integer overflow
程序带着清楚的错误信息崩溃,总好过库存变成负 18 亿。
除法、取余和浮点数
两个整数相除,结果还是整数,小数点后面的部分直接丢掉。它不做四舍五入,而是向零截断。一旦遇到负数,这一点就很重要。
void main() {
IO.println(7 / 2);
IO.println(-7 / 2);
IO.println(7 / 2.0);
IO.println(-7 % 3);
IO.println(7 % -3);
IO.println(Math.floorMod(-7, 3));
IO.println(0.1 + 0.2);
IO.println(1.0 / 0);
IO.println(0.0 / 0);
}
输出:
3
-3
3.5
-1
1
2
0.30000000000000004
Infinity
NaN
逐行来看:
7 / 2是 3,-7 / 2是 -3,而不是 -4。除法向零截断。7 / 2.0是 3.5,因为有一个操作数是double,所以这次除法也是double除法。%的结果跟左操作数同号。-7 % 3是 -1。如果你用%让下标循环,或者算星期几,负数输入会得到负数结果。Math.floorMod的结果总是跟除数同号,这通常才是你想要的。0.1 + 0.2不等于 0.3。double存的是二进制小数,而 0.1 没有精确的二进制表示,就像 1/3 没有精确的十进制表示一样。处理金额时,用long存分,或者用BigDecimal。- 浮点数除以零不会抛异常。结果是
Infinity或NaN。整数除以零则会抛出ArithmeticException。
类型之间的转换
Java 会自动把小类型转换成大类型,这叫拓宽转换。反过来叫窄化转换,需要强制类型转换,而且可能在没有任何报错的情况下丢失数据。
void main() {
int small = 42;
long wide = small;
double wider = wide;
IO.println(wide + " " + wider);
long tooBig = 3_000_000_000L;
IO.println((int) tooBig);
IO.println((byte) 200);
IO.println((int) 3.99);
IO.println((int) -3.99);
IO.println((int) 1e20);
int exact = 16_777_217;
float approx = exact;
IO.println(approx);
IO.println((int) approx);
char letter = 'A';
IO.println(letter + 1);
IO.println((char) (letter + 1));
}
输出:
42 42.0
-1294967296
-56
3
-3
2147483647
1.6777216E7
16777216
66
B
从 long 和 int 做强制转换时,只保留低位。所以 30 亿变成了负数,200 转成 byte 变成了 -56。从 double 转成 int 的行为不一样:它丢掉小数部分,而太大的值会停在 Integer.MAX_VALUE,不会回绕。
float 那几行让我们吃了一惊。把 int 赋给 float 不需要强制转换,因为 Java 把它算作拓宽转换。可是 16,777,217 回来时变成了 16,777,216。float 只有 24 位用来存数字的有效位,所以超过大约 1600 万以后,它无法表示每一个整数。long 转 double 在超过大约 9 千万亿时也一样。拓宽转换不会丢掉数字的量级,但可能丢掉末尾的几位。
最后两行说明 char 就是数字。'A' + 1 是 int 值 66,也就是 A 的编码加一,再强制转回 char 就得到 B。
还有一点是我们测试时注意到的。short s = 1; s += 70000; 能编译通过,因为 += 这样的复合赋值里藏着一次强制转换。不过加上 javac -Xlint:all 时,Java 25 会警告 implicit cast from int to short in compound assignment is possibly lossy。这项检查默认关闭,需要你主动打开,而它值得打开。
var:让编译器替你写类型
var 用来声明局部变量,由编译器根据你赋的值推断它的类型。它是 Java 10 引入的。
void main() {
var count = 10;
var price = 19.99;
var name = "Ana";
var scores = new ArrayList<Integer>();
scores.add(90);
IO.println(count + " " + price + " " + name + " " + scores);
}
输出:
10 19.99 Ana [90]
count 是 int,price 是 double,name 是 String,scores 是 ArrayList<Integer>。这些类型在编译时就固定了,跟你亲手写出来完全一样。var 不是动态类型。之后试着往 count 里存一个字符串:
void main() {
var count = 10;
count = "ten";
IO.println(count);
}
构建失败,报错:
Main.java:3: error: incompatible types: String cannot be converted to int
count = "ten";
^
var 还需要一个值来推断。var total; 会报 cannot use 'var' on variable without initializer,而 var nothing = null; 也会失败,因为 null 看不出你想要哪个类型。
类型已经写在同一行时就用 var,比如 var scores = new ArrayList<Integer>()。类型本身就是关键信息时,别用它。var total = calculate(order); 完全没告诉读者 total 是 int、long 还是 BigDecimal,而处理金额时,这个区别很重要。
类类型的变量存的是引用
类型是类的变量并不存放对象。它存的是一个引用,也就是 Java 找到那个对象所需的信息。把一个变量赋给另一个变量,复制的是引用,所以两个变量通向同一个对象。
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
void main() {
int first = 1;
int second = first;
second = 5;
IO.println("ints: " + first + " " + second);
var a = new Point(1, 2);
var b = a;
b.x = 5;
IO.println("points: " + a + " " + b);
b = new Point(9, 9);
IO.println("points: " + a + " " + b);
}
输出:
ints: 1 5
points: Point(5, 2) Point(5, 2)
points: Point(5, 2) Point(9, 9)
这两半代码长得差不多,行为却不同。对于 int,second = first 复制的是数字 1,所以改 second 不影响 first。对于 Point,var b = a 复制的是引用。Point 对象始终只有一个,所以 b.x = 5 也改了 a 通向的那个对象。
最后一次赋值又不一样。b = new Point(9, 9) 没有修改任何对象。它让 b 指向一个新对象,而 a 仍然指向原来那个。
用十岁孩子能懂的话说
想象学校里的一排储物柜。存基本类型的变量是一个盒子,数字就放在盒子里。复制这个盒子,你会得到第二个装着同样数字的盒子,在其中一个上面乱涂,另一个不受影响。
存引用的变量是一张纸条,上面写着一个柜子号码。玩具在柜子里,不在纸条上。复制纸条后,你手里有两张都写着“14 号柜”的纸条。如果你的朋友用他的纸条打开 14 号柜,把玩具涂成红色,那你用自己的纸条打开 14 号柜时,看到的也是一个红色玩具。
现在你的朋友把他的纸条擦掉,改写成“30 号柜”。这既不会挪动玩具,也不会改变你的纸条。你的纸条上还是 14。
准确的说法
Java 的值分两种。基本类型值就是数字、字符或 boolean 本身。引用值指向堆上的一个对象,或者是 null。基本类型的变量存基本类型值。任何类、接口、record、枚举或数组类型的变量都存引用值。
赋值总是复制变量里的值。对基本类型来说,复制的是数字。对引用来说,复制的是引用,所以赋值之后两个变量指向同一个对象。在引用上用 .,比如 b.x = 5,会顺着引用找到对象,然后操作这个对象。给变量本身赋值,比如 b = new Point(9, 9),只是替换 b 里的引用,不会动任何对象。
这个比喻的局限:柜子号码是你能读出来、还能拿来做加减的东西。Java 引用不行。你不能打印它,不能给它加数,也不能自己决定它指向哪个对象。而且垃圾回收器可以随意在堆里移动对象,你的引用却不会因此改变。另外,当没有任何纸条再提到某个柜子时,垃圾回收器就会把它清空。
看两个变量共享一个对象
下面的动画一步步演示同一段 Point 代码,把两个变量都画成指向堆的箭头:
两个变量,一个对象。var b = a 复制的是引用,所以 b.x = 5 也改了 a 指向的对象。b = new Point(9, 9) 只移动 b 的箭头,a 仍然指向第一个对象。
如果动画没有播放,下面用文字把这几步再说一遍:
var a = new Point(1, 2)在堆上创建一个Point对象,a存着指向它的引用。a.x是 1。var b = a把a里的引用复制到b。没有创建新对象,所以两个箭头通向同一个Point。b.x = 5顺着b的引用找到那个对象,把它的x设为 5。- 现在
a.x也是 5。a本身什么都没变:它通向的还是同一个对象,变的是对象。 b = new Point(9, 9)创建第二个对象,把指向它的引用放进b。只有b的箭头移动了。a仍然指向第一个对象,这个对象的x仍然等于 5。
Java 总是按值传递参数
调用方法时,Java 把每个参数的值复制到方法的形参里。对于对象,复制的值就是引用。这一条规则就能解释,为什么方法里的有些修改会反映到调用方,有些却不会。
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
void reassign(Point p) {
p = new Point(0, 0);
IO.println("inside reassign: " + p);
}
void mutate(Point p) {
p.x = 100;
IO.println("inside mutate: " + p);
}
void main() {
var point = new Point(1, 2);
reassign(point);
IO.println("after reassign: " + point);
mutate(point);
IO.println("after mutate: " + point);
}
输出:
inside reassign: Point(0, 0)
after reassign: Point(1, 2)
inside mutate: Point(100, 2)
after mutate: Point(100, 2)
两次调用里,p 一开始都是 point 中引用的副本。在 reassign 里,p = new Point(0, 0) 替换了这个副本,调用方的 point 对此一无所知。在 mutate 里,p.x = 100 顺着副本找到那个共享的对象并修改它,所以调用方能看到变化。
有人会说“Java 按引用传递对象”。其实不是。如果真是那样,reassign 就会改掉调用方的变量。方法可以修改你交给它的对象,但永远没法让你的变量指向另一个对象。
== 比较引用,equals 比较内容
对两个基本类型值,== 比较的是值。对两个引用,== 问的是它们是否指向同一个对象,而 equals 问的是两个对象算不算相等。
class Money {
final long cents;
Money(long cents) {
this.cents = cents;
}
}
class Price {
final long cents;
Price(long cents) {
this.cents = cents;
}
@Override
public boolean equals(Object other) {
return other instanceof Price p && p.cents == cents;
}
@Override
public int hashCode() {
return Long.hashCode(cents);
}
}
void main() {
String typed = "hello";
String built = new StringBuilder("hel").append("lo").toString();
IO.println(typed == built);
IO.println(typed.equals(built));
IO.println(new Money(500) == new Money(500));
IO.println(new Money(500).equals(new Money(500)));
IO.println(new Price(500) == new Price(500));
IO.println(new Price(500).equals(new Price(500)));
}
输出:
false
true
false
false
false
true
两个字符串的字母相同,但它们是两个独立的对象,所以 == 是 false。equals 比较的是字符,结果是 true。比较字符串一定要用 equals。文本相同的两个字面量可能共用一个对象,所以 == 有时看起来能用,这比每次都失败更糟。
Money 展示了类没有定义 equals 时会怎样。它继承了 Object 的 equals,而那个方法做的事跟 == 一样。Price 重写了 equals 来比较 cents,所以两个 500 的价格是相等的。它还重写了 hashCode,因为这两个方法必须保持一致。讲 equals、hashCode 和集合的那一篇会解释原因。record 会替你写好这两个方法。
装箱和 Integer == 陷阱
每种基本类型都有一个包装类,用对象的形式存放一个值:int 对应 Integer,long 对应 Long,double 对应 Double,以此类推。Java 会在它们之间自动转换。把 int 变成 Integer 叫装箱,反过来叫拆箱。集合必须用包装类,因为不允许写 List<int>。
Integer 是对象,所以对两个 Integer 用 == 比较的是引用。这就引出了 Java 里最有名的意外:
void main() {
Integer a = 127, b = 127;
IO.println("127 == 127: " + (a == b));
Integer c = 128, d = 128;
IO.println("128 == 128: " + (c == d));
IO.println("128 equals: " + c.equals(d));
int plain = 128;
IO.println("Integer == int: " + (c == plain));
Long big1 = 127L, big2 = 127L;
Long big3 = 128L, big4 = 128L;
IO.println("Long 127: " + (big1 == big2) + ", Long 128: " + (big3 == big4));
}
输出:
127 == 127: true
128 == 128: false
128 equals: true
Integer == int: true
Long 127: true, Long 128: false
同样的代码,数字只差一,答案却不同。装箱会调用 Integer.valueOf,而 valueOf 为较小的值缓存了一批 Integer 对象。把 127 装箱两次,拿到的是同一个缓存对象,所以 == 是 true。把 128 装箱两次,得到的是两个新对象,所以 == 是 false。
Java 语言规范要求,int、short 和 byte 装箱时缓存至少覆盖 -128 到 127,char 则覆盖到 127。从最后一行可以看出,Long 实际上也用同样的范围。具体实现可以缓存更多。在 HotSpot 上,你可以用一个 JVM 参数调高 Integer 的上限,这样 128 比较的结果就是 true:
java -XX:AutoBoxCacheMax=1000 Main.java
所以这个 bug 很难发现。用小数字测试时都能通过,等真实的 ID 超过 127 就出问题了。
Integer == int 那一行是 true,因为有一边是基本类型。Java 会把 Integer 拆箱,然后比较数字。包装类的规则很简单:用 equals 比较,或者先拆箱成基本类型再比较。
拆箱 null 会抛异常
Integer 变量可以存 null,int 不行。Java 把 null 拆箱成 int 时,拿不到任何数字,于是抛出 NullPointerException。抛异常的那行代码看起来甚至不像在碰对象:
Map<String, Integer> stock = new TreeMap<>();
Integer lookup(String item) {
return stock.get(item);
}
void main() {
stock.put("apples", 3);
int apples = lookup("apples");
IO.println("apples: " + apples);
int pears = lookup("pears");
IO.println("pears: " + pears);
}
输出后停止:
apples: 3
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "Main.lookup(String)" is null
键不存在时,Map.get 返回 null。int pears = lookup("pears") 这一行里藏着一次 intValue() 调用,错误信息里点名的正是这次调用。现代 JVM 默认会打印这样有帮助的信息,这里它准确地告诉了你发生了什么。如果你的意思是“找不到就当作零”,就明确写出来:stock.getOrDefault(item, 0)。
final 固定的是引用,不是对象
final 变量只能赋值一次。当变量存的是引用时,这意味着它始终指向同一个对象。但它不会阻止任何人修改那个对象。
void main() {
final List<String> names = new ArrayList<>();
names.add("Ana");
names.add("Ben");
names.remove("Ana");
IO.println(names);
}
输出:
[Ben]
通过一个 final 变量,列表改了三次,编译器毫无意见。final 禁止的是让变量指向别处:
void main() {
final List<String> names = new ArrayList<>();
names.add("Ana");
names = new ArrayList<>();
IO.println(names);
}
构建失败,报错:
Main.java:4: error: cannot assign a value to final variable names
names = new ArrayList<>();
^
如果你想要一个谁都改不了的列表,需要的是不可修改的对象,而不是 final 变量。List.of("Ana", "Ben") 就能创建一个,对它调用 add 会抛出 UnsupportedOperationException。
要点
- Java 有八种大小固定的基本类型。它们的上下限从
Integer.MAX_VALUE这样的常量里读,别靠记忆。 int算术溢出时会悄无声息地回绕,即使你把结果存进long也一样。溢出意味着 bug 时,用Math.addExact这类方法。- 整数除法向零截断,
%的结果跟左操作数同号,double无法精确表示 0.1。 var在编译时推断出一个固定的类型。它不是动态类型。- 类类型的变量存的是引用。赋值或传参时复制的是引用,所以对对象的修改通过每个副本都能看到,但给某个副本重新赋值不会影响其他变量。
- 比较对象,包括
String和Integer,要用equals。Integer缓存了 -128 到 127,所以==在测试里能用,到了生产环境就出错。 - 拆箱
null会抛出NullPointerException;final阻止的是重新赋值,不是修改对象。
基本类型的变量存的是值,其他所有变量存的都是指向对象的引用。