玩转Java:整数1+1居然可以不等于2?


来点神奇现象

一开始你该不会以为是浮点数精度问题吧,No No No,就是整数的1+1运算结果不为2,先来看段代码的执行演示:

完整代码揭秘

如果你复制了上面GIF中的代码到自己的环境下运行,你会发现结果就是2,这时候读者肯定就会骂我,说上面的动图是假的,是录屏的特技,根本就没有这种情况。

没错,确实加了duang~\~\~的特技,不过没有在录屏或GIF上做手脚,而是我没有录下完整的代码。下面就展示一下完整的代码:

public class TestIntegerMagic {

    public static void main(String[] args) {
        Integer a = 1;
        System.out.println(a + 1);
    }

    // 刻意没有展示的代码
    static {
        Integer a = 1;
        try {
            Field value = a.getClass().getDeclaredField("value");
            value.setAccessible(true);
            value.set(a, 114513);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

如果你了解下面的机制,看到这段代码的时候估计就会心一笑,秒懂了:

  • 基本类型包装类的自动装箱/拆箱
  • Integer 缓存机制
  • Java反射机制
  • JVM类加载机制

下面我就结合这个一加一的例子,逐个讲解。

原理讲解

自动装箱与拆箱

如果了解并且使用过Java进行项目开发,99%会有意或无意中利用到这个机制。关于这个机制,网上的参考资料非常非常多,我这里就我的理解来讲解一下:

上面的样例代码中,出现了这种代码:

Integer a = 1;

可实际上在被编译成class文件后,会被先处理成Integer a = Integer.valueOf(1)再进行编译,我们可以观察源代码编译后的字节码来验证这个过程。
以下是使用IDEA查看的字节码信息:

可以看到确实是使用了Integer对象的静态方法valueOf来获取Integer类实例对象。

而拆箱呢,则是调用了Integer实例方法intValue方法,上面的字节码图片中也有出现。

所以,本篇文章例子中最终编译的Java代码应该是这样的

// 其他声明省略
Integer a = Integer.valueOf(1);
System.out.println(a.intValue() + 1);

为什么要提到这个自动装箱的机制呢?因为,使用自动装箱或valueOf方法,往往会涉及到另一个机制,Integer的缓存机制

Integer缓存机制

Integer缓存机制是指Integer在通过valueOf方法获取实例时,直接获取到先前早就实例化好的对象,而不需要重新实例化创建对象,从而实现减轻GC压力和提高程序运行速度

想要理解这个过程到底发生了什么,最好的方法就是阅读Integer.valueOf(int i)的源码,这个没有涉及到什么高深的设计模式和算法,仔细阅读的话还是很好理解的。

这里我直接贴出这段代码,只有3行:

public Integer {
    // ...其他声明略...
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
    // ...其他声明略...
}

当传入的int值在[IntegerCache.low, IntegerCache.high]这个闭区间内,就直接返回IntegerCache.cache这个数组中对应下标中的元素(即Integer对象)

这时候会继续抛出两个问题:

  1. IntegerCache.lowIntegerCache.high分别是多少?如何指定?
  2. IntegerCache.cache是怎么初始化的?

答案就在Integer的内部类IntegerCache中可以找到答案,代码只有而三十行,不多,也很容易理解

这点代码很容易一眼就看出,low默认是-128,high默认是127。
low被固定死了,但high是可以配置的,方法就是虚拟机添加参数java.lang.Integer.IntegerCache.high设置。

后面就是一个for循环来逐个逐个实例化并加入到数组中了。

以上初始化过程是在Integer内部类IntegerCache的static代码块(静态代码块)中执行,而这段的代码执行时机和JVM类加载机制有关,继续阅读,下面我们再单独讨论。

Java反射机制

在了解Java反射机制之前,我们先探讨下int的包装类Integer是如何包装int的。

Integer包装原理

我们可以从获取或创建Integer的方法中下手,简单翻阅源代码,最简单的例子,就是用new关键字调用构造方法实例化。以下是Integer的构造方法和intValue()源码:

public Integer {
    private final int value;
    public Integer(int value) {
        this.value = value;
    }

    public int intValue() {
        return value;
    }
}

利用Java非基本类型传值为引用的特性,从而实现了使用对象来包装基本数据类型。
emmm,平平无奇的构造方法和intValue(),像极了我们无脑生成的构造方法和getter方法。

反射登场

Integer的字段value使用了private final修饰,看起来好像很稳很安全的感觉,因为外部是无法直接获取和修改这个属性的值的,而且即使是内部也不允许运行时再次修改这个值。

那么有什么办法打破这限制呢?当然有,那就是Java强大又恐怖的反射机制。
Java的反射机制很灵活,我就不详细介绍了,只讲解下反射机制是如何影响这篇文章主题中效果的实现的。

什么是Java反射机制?

这个机制就是Java允许程序在运行期间获取类的各种属性,包括声明的方法,成员变量,以及使用了什么修饰符,方法有哪些参数,分别是什么类型等。除了对这些属性进行读取操作,还能对成员变量进行设置值操作,对可访问性进行修改(可突破private和部分final限制),对方法进行调用,实例化对象等等操作。

所以Java反射机制是非常强大而又危险的,利用反射机制可以实现各种功能灵活的工具和框架(比如大名鼎鼎的Spring),但也可以破坏面向对象设计上的封装性,甚至是篡改程序运行期间的数据(比如著名沙盒游戏Minecraft的Mod实现就大量依赖了这个)

使用反射修改Integer对象的value属性

这个就是核心部分了,利用Integer的自动拆装箱与缓存机制,我们通过反射去强行修改Integer缓存中某个对象的value。
代码:

// 先拿到数字1的Integer对象(当然,这个是Integer缓存数组中拿到的对象,前面讲缓存机制的时候有讲到)
Integer a = 1;
try {
    // 获取对象Integer中名为value的字段信息(反射机制)
    // 注:a.getClass()相当于Integer.class,获取Integer的class对象,class对象是反射调用一个关键对象,里面包含了类的各种信息,感兴趣的可另外找文章阅读,网上资料也很多
    Field value = a.getClass().getDeclaredField("value");

    // 将value字段的反射可访问性设置为true,即允许反射访问,这样就能突破private的限制
    value.setAccessible(true);

    // 将Integer对象的value字段值设置为114513
    // 因为value是对象的实例属性,所以需要挑一个被这个作用影响的实例对象
    // 这里就挑了从Integer缓存里拿到的实例对象,强行设置这个对象的value值
    value.set(a, 114513);
} catch (NoSuchFieldException | IllegalAccessException e) {

    // 尝试获取不存在的字段
    // 或
    // 读写被保护约束的对象属性字段(private,protect等约束)时抛出(如果你没改变访问性的话)
    e.printStackTrace();
}

上面的代码一旦执行,任何通过valueOf或自动装箱机制获取到的“值为1”的Integer对象时,实际上它的value已经是114513了。

但是文章的示例代码中并未在main函数中对这些代码主动进行调用,而是被static关键字包起来,那么这些代码是怎么执行的呢?执行的时机又是什么?

下面就需要简单了解下JVM的类加载机制了

JVM类加载机制

首先,Java类的加载遵循的是懒加载原则。什么意思呢?就是你调用到的时候才会去加载,你还没调用到就不加载,就像一个懒汉(拓展延伸:懒汉式单例模式),干活的时候踢一脚才动一下。

类加载流程

一个类在Java虚拟机中从被加载到卸载,会按顺序涉及到下面的流程

  1. 加载 - 将class字节码文件加载到内存中,并将这些数据转换成方法区中的运行时数据(静态变量、静态代码块、常量池等),在堆中生成一个Class类对象代表这个类(反射原理),作为方法区类数据的访问入口。

  2. 链接
    总体上就是将Java类的二进制代码合并到JVM的运行状态之中。

    1. 验证 - 验证Java类的二进制数据合不合法,格式对不对
    2. 准备 - 正式为类变量(static变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。注意此时的设置初始值为默认值,具体赋值在初始化阶段完成。
    3. 解析 - 虚拟机常量池内的符号引用替换为直接引用(地址引用)的过程。
  3. 初始化
    初始化阶段是执行类构造器()方法的过程。类构造器()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的。

    • 当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先初始化其父类。
    • 虚拟机会保证一个类的()方法在多线程环境中被正确加锁和同步。
  4. 使用

  5. 卸载
    卸载条件较为严格,需要满足以下条件:

    1. 这个类的类加载器(ClassLoader)已经被回收
    2. 类的所有实例被回收
    3. 类的class对象没有被引用

静态代码块的执行

了解了类的加载流程后可以知道,静态代码块会在触发类加载时自动执行,以下条件会触发类加载:

  1. 被直接调用
    1. new
    2. 被访问静态变量或方法
  2. 作为父类或内部静态类被带着一起加载
  3. 被反射机制Class.forName触发
  4. 主类(启动类)

buff已叠满,流程解析

在了解了上面的知识点后,那么就有足够的知识点和能力去理解整个示例程序的代码运行原理了

0x0 启动类TestIntegerMagic被加载

这个main方法就在这个类中被声明了,作为启动入口,这个类自然是一开始最先被加载,就会在执行main方法之前,先执行静态代码块(static块)的代码。

0x1 Integer及其内部静态类IntegerCache被加载,初始化Integer缓存

TestIntegerMagic类的静态代码块中,调用了Integer

static {
    // 略
    Integer a = Integer.valueOf(1); // 自动装箱后的代码
    // 略
}

触发Integer类的加载,内部静态类IntegerCache被带着一起加载,执行其中的静态代码块初始化了Integer缓存数组。
这里已取得缓存数组中引用的Integer实例对象

0x2 反射介入,破坏封装性,修改Integer缓存实例的value

反射介绍那部分已详细讲解,现在任何通过valueOf或自动装箱机制获取到的“值为1”的Integer对象时,实际上它的value已经是114513了。

0x3 main函数第一行代码开始执行

由于此时Integer缓存值为1的实例已经被修改成值为114513,所以这里通过自动装箱机制(实际代码:Integer a = Integer.valueOf(1))获取到的Integer实例是缓存数组中引用的实例,值就是114513了。

0x4 Integer值运算(a + 1)

经过拆箱,实际代码为:

a.intValue() + 1

已知a的value是114513,所以实际上是114513 + 1,最终值当然就是114514啦~

0x5 数值打印,你被骗了

引用资料