Blog

Java 的值与引用:基本类型、对象和 Integer == 陷阱

Java 变量里存的要么是基本类型的值,要么是指向对象的引用。分清这一点,就能看懂静默溢出、按值传递、== 与 equals 的区别,以及两个存着 128 的 Integer 为什么不相等。

Java 变量里存的东西只有两种。基本类型的变量,比如 intdouble,存的就是值本身。其他类型的变量存的是一个引用,指向放在别处的对象。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,条件用 booleanchar 其实是一个 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。

字段有默认值,局部变量没有

类里的字段一开始就有默认值:数字是零,booleanfalse,引用是 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

后两行才是值得记住的陷阱。wronglong,空间足够大,却还是存了一个负数。右边的表达式先按 int 算术求值,在结果被拓宽成 long 之前就已经回绕了。把其中一个操作数写成 long,也就是 1L,整个加法就变成 long 算术。

如果溢出意味着 bug,比如金额或数量,就让它抛出异常。Math.addExactMath.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
  • 浮点数除以零不会抛异常。结果是 InfinityNaN。整数除以零则会抛出 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

longint 做强制转换时,只保留低位。所以 30 亿变成了负数,200 转成 byte 变成了 -56。从 double 转成 int 的行为不一样:它丢掉小数部分,而太大的值会停在 Integer.MAX_VALUE,不会回绕。

float 那几行让我们吃了一惊。把 int 赋给 float 不需要强制转换,因为 Java 把它算作拓宽转换。可是 16,777,217 回来时变成了 16,777,216。float 只有 24 位用来存数字的有效位,所以超过大约 1600 万以后,它无法表示每一个整数。longdouble 在超过大约 9 千万亿时也一样。拓宽转换不会丢掉数字的量级,但可能丢掉末尾的几位。

最后两行说明 char 就是数字。'A' + 1int 值 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]

countintpricedoublenameStringscoresArrayList<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); 完全没告诉读者 totalintlong 还是 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)

这两半代码长得差不多,行为却不同。对于 intsecond = first 复制的是数字 1,所以改 second 不影响 first。对于 Pointvar 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 代码,把两个变量都画成指向堆的箭头:

变量 a ref Point x = 1 x = 5 y = 2 b ref Point x = 9 y = 9 a.x 为 1 a.x 为 1,b.x 为 1 a.x 为 5,b.x 为 5 a.x 为 5,b.x 为 9 var a = new Point(1, 2):a 存着指向它的引用 var b = a:复制的是引用,不是对象 b.x = 5:顺着 b 的箭头修改对象 a.x 也是 5:a 指向同一个对象 b = new Point(9, 9):只有 b 的箭头移动 a 仍指向第一个对象,其中 x = 5

两个变量,一个对象。var b = a 复制的是引用,所以 b.x = 5 也改了 a 指向的对象。b = new Point(9, 9) 只移动 b 的箭头,a 仍然指向第一个对象。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. var a = new Point(1, 2) 在堆上创建一个 Point 对象,a 存着指向它的引用。a.x 是 1。
  2. var b = aa 里的引用复制到 b。没有创建新对象,所以两个箭头通向同一个 Point
  3. b.x = 5 顺着 b 的引用找到那个对象,把它的 x 设为 5。
  4. 现在 a.x 也是 5。a 本身什么都没变:它通向的还是同一个对象,变的是对象。
  5. b = new Point(9, 9) 创建第二个对象,把指向它的引用放进 b。只有 b 的箭头移动了。
  6. 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

两个字符串的字母相同,但它们是两个独立的对象,所以 ==falseequals 比较的是字符,结果是 true。比较字符串一定要用 equals。文本相同的两个字面量可能共用一个对象,所以 == 有时看起来能用,这比每次都失败更糟。

Money 展示了类没有定义 equals 时会怎样。它继承了 Objectequals,而那个方法做的事跟 == 一样。Price 重写了 equals 来比较 cents,所以两个 500 的价格是相等的。它还重写了 hashCode,因为这两个方法必须保持一致。讲 equalshashCode 和集合的那一篇会解释原因。record 会替你写好这两个方法。

装箱和 Integer == 陷阱

每种基本类型都有一个包装类,用对象的形式存放一个值:int 对应 Integerlong 对应 Longdouble 对应 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 语言规范要求,intshortbyte 装箱时缓存至少覆盖 -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 变量可以存 nullint 不行。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 返回 nullint 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 在编译时推断出一个固定的类型。它不是动态类型。
  • 类类型的变量存的是引用。赋值或传参时复制的是引用,所以对对象的修改通过每个副本都能看到,但给某个副本重新赋值不会影响其他变量。
  • 比较对象,包括 StringInteger,要用 equalsInteger 缓存了 -128 到 127,所以 == 在测试里能用,到了生产环境就出错。
  • 拆箱 null 会抛出 NullPointerExceptionfinal 阻止的是重新赋值,不是修改对象。

基本类型的变量存的是值,其他所有变量存的都是指向对象的引用。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。