java 中常量和变量

2021/01/08 Java基础知识 共 16615 字,约 48 分钟
闷骚的程序员

1. 常量和变量基本概念

1.1 什么是变量?

java中的变量是一个可以改变其值的标记符号,通过该符号可以定位到对应的值,从而作为操作数构成程序的一部分。
说白了变量就是一块可以反复修改内容的有名称的内存空间。
变量在使用前必须进行初始化赋值操作。

扩展:什么是同名变量?
在同一个块中,具有相同名称的两个或者多个变量,称为同名变量。
即便变量类型不同,但只要在同一个块中,其名称相同,就是同名变量。
JAVA编译器是不允许出现同名变量的,即在同一个块中,不允许出现

tips: java编译器为变量的初始化做了优化。即:“成员变量自动初始化、方法变量手动初始化”。
如果变量是成员变量的话,在使用前不用开发者显式初始化也可以,java编译器在编译成class文件时会自动为我们初始化成员变量,将它们初始化对应的默认值。
如果是方法内的局部变量的话,则需要开发者显式对其进行初始化,否则编译报错。

1.2 什么是常量?

java中的常量和变量比较类似,都是一块有名称内存空间地址,作为操作数构成程序的一部分。
只不过常量要求我们在使用前必须手动初始化赋值,意味着java编译器不会为我们优化常量的初始化操作。
而且,常量一旦手动初始化之后,其值在后续整个操作中不允许在修改,即不允许重置常量的值,如果尝试重置其值,编译器会报错。

要注意,不论是变量还是常量,都必须明确指定数据类型,因为java是强类型语言

下面看一个案例:

package zeh.test.demo.com.changliang;

public class TestConst {
    public static void main(String[] args) {
        int a = 1;
        a = 100;
        System.out.println(a);
        final int b = 10;
        // 常量一旦初始化之后,则不允许重置其内容;
        // 此处尝试重置,编译器禁止此行为,直接编译报错
        b = 101;
    }
}

1.3 java中常量和变量的分类?

基于我们的理解,java中的变量和常量往往具体划分为如下几种:

  1. 按照数据类型划分:
    按照变量和常量分配内存空间的大小及以何种形式存储,存储的内容是什么(值还是地址)来划分的话,分为:基本数据类型 + 引用数据类型。
    (1). 基本数据类型有八种,存储的是各种基本数据,即永远存储的是变量的具体值;比如int a = 10;则a中存储的是10。
    (2). 引用数据类型有四种(类,数组,接口,枚举),存储的是引用(即是指向堆内存空间的地址)。

  2. 按照存储的物理位置划分:
    先分析变量:
    (1). 方法变量:定义在方法或者某个代码块中,这种变量叫做方法(局部)变量,它的作用域只是当前这个方法或者块;方法变量保存在虚拟机栈中;线程私有,当前目标线程执行完毕,生命周期结束。
    (2). 实例成员变量:定义在类中的非static变量,这种变量叫做实例成员变量,它的作用域是当前类的某个实例对象;成员变量保存在堆内存中;多个线程共享,所有目标线程执行完毕,生命周期结束。
    (3). 静态成员变量:定义在类中的static变量,这种变量叫做类成员变量,也叫做静态成员变量,它的作用域是当前类的所有实例对象;类成员变量保存在方法区的静态区中;多个线程共享,所有目标线程执行完毕,生命周期结束。
    接下来分析常量,java中要想定义一个变量为常量的话,只需要对该变量使用final关键字进行修饰即可。因此,对应的常量也划分为如下几种:
    (1). 方法常量:也叫做局部常量,定义在方法或者某个块中,作用域是当前方法或者当前块;方法常量保存在虚拟机栈中;线程私有,当前目标线程执行完毕,生命周期结束。
    (2). 实例成员常量:作用域是当前类的某个实例化对象;实例成员常量保存在堆内存中;多个线程共享,所有目标线程执行完毕,生命周期结束。
    (3). 类成员常量:也叫做静态成员常量,作用域是当前类的所有实例化对象;静态成员常量保存在方法区的静态区中;多个线程共享,所有目标线程执行完毕,生命周期结束。
    备注:
    上面所有的保存指的都是基本值或者引用类型的引用地址,而不是对象实体,注意java中所有对象实体都保存在堆内存,这是毋庸置疑的。
    变量和常量的内存分配口诀:实例成员堆内存、静态成员方法区、局部变量栈内存。

tips: 常量虽然最终展示的保存位置是虚拟机栈、堆内存或者方法区的静态区,但实际上这是个复杂的过程。

  1. java编译器对于方法常量、实例成员常量和静态成员常量的值,会首先在*.class文件中进行保存,保存的区域叫做class常量池,而保存的东西属于java字面量的一部分。
  2. 当运行代码后,JVM装载程序,会将class常量池中的东西复制一份出来,复制到JVM方法区内存的一个地方,这个地方叫做运行时常量池。
  3. 随着程序执行
    如果调用的是方法常量,则将其对应的值从运行时常量池中复制一份出来,复制到当前作用域的虚拟机栈中;
    如果调用的是实例成员常量,则将其对应的值从运行时常量池中复制一份出来,复制到当前实例对象所在的堆内存中;
    如果是静态成员常量,同样从运行时常量池中复制一份出来,复制到方法区内存的静态区中。
    因此,常量虽然最终保存的位置和变量完全一样,但它的加载过程比变量复杂多了。

下面是案例:

package zeh.test.demo.com.changliang;

// 常量和变量的划分
public class MyConst {
    // 实例成员变量,最终保存在当前对象所在的堆内存中
    private int anInt1;
    // 静态成员常量,最终保存在方法区的静态区中
    private static int anInt2;
    // 实例成员常量
    // 1.   java编译器将字面量 100 保存在class常量池中;
    // 2.   运行后,JVM从class常量池中载入 100 的副本到方法区的运行时常量池中;
    // 3.   对象开辟后,将 100 从运行时常量池中复制一份副本到当前对象所在的堆内存中。
    private final int anInt3 = 100;
    // 静态成员常量
    // 1.   java编译器将字面量 200 保存在class常量池中;
    // 2.   运行后,JVM从class常量池中载入 200 的副本到方法区的运行时常量池中;
    // 3.   类装载完毕后,将 200 从运行时常量池中复制一份副本到方法区内存的静态区中。
    private final static int anInt4 = 200;
    public void testConst(){
        // 方法变量保存在虚拟机栈内存中;
        // 方法和块中不允许定义static变量
        int a1 = 100;
        // 方法常量保存在虚拟机栈内存中;
        // 1.   java编译器将字面量 200 保存在class常量池中(先这样理解,实际上JVM指令处理基本类型时不一定都保存到class常量池中的);
        // 2.   运行后,JVM从class常量池中载入 200 的副本到方法区的运行时常量池中;
        // 3.   当前方法入栈后,将 200 从运行时常量池中复制一份副本到当前方法的虚拟机栈中。
        final int a2 = 200;
    }
}

1.4 常量和变量的初始化方式有什么区别呢?

先说结论:

  1. 方法变量和方法常量在使用前必须手动初始化赋值,可以在声明时就赋值,也可以在后续使用前赋值;否则编译报错。
  2. 成员变量(静态和非静态)使用前可以不显式初始化,此时java编译器默认为我们初始化为对应的默认值。成员常量(静态和非静态)使用前必须手动初始化赋值。
  3. 成员常量的初始化赋值方式具体划分为如下:
    (1)在定义时就直接初始化赋值;
    (2)对于实例成员常量可以在构造方法或者构造块中初始化赋值;
    (3)对于静态成员常量可以在静态块中初始化赋值。

着重解释第3点! 可能我们对方法常量或者成员常量初始化赋值,最常见的操作就是定义时直接赋值,比如下面这种:

final int a = 100;  
private final int b = 200;  

但实际上对于成员常量来说,如果是静态成员常量的话可以在静态块中初始化赋值。如果是实例成员常量的话可以在构造块或者构造方法中初始化赋值。
背后原理实际上就是JVM对目标类的装载流程。
我们知道,JVM装载目标类的大致流程是:“加载”、“链接(验证、准备和解析)”、“初始化”、“使用”、“卸载”。
其中,重要的步骤体现在代码层面就是:“静静非构构,设置属性初始化”。即静态成员的初始化操作远早于非静态成员。
对于静态成员常量,其初始化过程只能保证在“静静”这两步骤执行完毕;要么是直接初始化赋值,要么是在静态块中进行初始化;因为静态成员的使用不依赖于构造对象,它早于构造对象的产生,初始化完可能就需要使用,所以不能放在后续的构造中进行初始化。
对于实例成员常量,其初始化过程只能保证在“非构构”这三步骤执行完毕;要么是直接初始化赋值,要么在构造方法中执行,要么在构造块中执行;因为静态块不能引用实例成员,因此实例成员不能在静态块中执行,只能在构造中执行。
java编译器遵守如上的规范,一旦检查到变量或者常量的初始化过程不符合该规范(比如方法变量使用前没有初始化、静态成员常量在构造方法中初始化、初始化常量后尝试重置它等),则java编译器会报错,不允许成功编译成class字节码。

下面我们来看下变量和常量初始化方式的案例:

package zeh.test.demo.com.changliang;

// 常量和变量的初始化方式
public class MyConst {
    // 实例成员变量,开发者可以不显式初始化,java编译器在编译为class文件后默认替我们初始化为对应的默认值0。
    private int anInt1;
    // 静态成员常量,开发者可以不显式初始化,java编译器在编译为class文件后默认替我们初始化为对应的默认值0。
    private static int anInt2;
    // 实例成员常量,开发者必须显式初始化赋值,否则编译器报错。
    private final int anInt3 = 100;
    // 静态成员常量,开发者必须显式初始化赋值,否则编译器报错。
    private final static int anInt4 = 200;
    // 实例成员常量,可以在构造方法中初始化赋值,否则编译器报错。
    private final int anInt5;
    // 实例成员常量,可以在构造块中初始化赋值,否则编译器报错。
    private final int anInt6;
    // 静态成员常量,可以在静态块中初始化赋值,否则编译器报错。
    private final static int anInt7;
    {
        // 构造块,为 anInt6 进行初始化操作
        anInt6 = 110;
    }
    public MyConst(){
        // 构造方法,为 anInt5 进行初始化操作
        anInt5 = 120;
    }
    static {
        // 静态块,为 anInt7 进行初始化操作
        anInt7 = 119;
    }
    public void testConst() {
        // 方法变量,使用前必须手动初始化赋值
        int a1;
        // 方法常量,使用前必须手动初始化赋值
        final int a2;
        a1 = 100;
        a2 = 200;
        System.out.println(a1);
        System.out.println(a2);
    }
}

1.5 常量的特性是java编译器限制的

我们务必要清楚一点,上面针对常量的所有特性限制,比如:
常量使用前必须初始化、常量初始化方式的规则、常量初始化后如果是基本类型则不允许重置它的值如果是引用类型则不允许重置它的引用地址等,这些限制完全是由java编译器做的校验。
java编译器检查到一个变量使用了final关键字修饰后,它便会根据这些规则去做检查,只有符合了final的检查规范才允许java源文件编译成class字节码,否则java编译器将报错。

2. 深入理解常量和变量:

2.1 变量的本质

  1. int a:告诉计算机我要申请一块4bytes的内存空间,对于编译器而言,a变量声明过了,那么class常量池中会保存a,此时a就是符号引用;在加载进JVM后为a分配了内存地址,后续操作a变量时直接将a这个符号引用换成直接引用,即换成为它分配的内存地址;
  2. a = 100;上一步声明了a变量为它分配了内存空间后,这一步初始化变量。很简单,将100这个值放在为a变量分配的内存空间中;
  3. a = 250;为a变量初始化后,可以重置a变量的值;即将250重新放在a变量所占的内存中;此时上面的100将被回收。(注意,基本类型的包装类和String都是不可变的,它们都是重新开辟堆内存保存值,而不是直接更改原有内存空间的值)
  4. 如下图所示:
    变量声明过程:

    变量内存分配过程:
  5. 参考一篇文章如下:
    Java中的变量遵循先定义后使用的原则。不像Visual Basic一样即使不定义变量也可以直接使用,Java中的变量必须先进行定义,之后才能使用。
    int a;是「从现在开始我们就可以使用整数a了」的意思。那么,当我们在计算机中运行这个语句时,会发生什么呢?
    我们把上图想像成计算机的内存。「int a;」的意思是,在计算机内存中给它分配4bytes的内存空间,那么在程序中,对编译器来说,变量「a」是已经经过定义的。那么,为什么是字节呢?
    Java中,对每一种数据都定义了一种类型。例如之前所说的字符串实际上是「String类型」的数据。所谓的数据类型,说到底就是指「存放数据所需要的内存大小」。「int a;」就是定义一个类型为int的变量a。Java中int型是用来定义「用4bytes来表示的整数」的,所以需要给它分配4bytes的内存空间。

Java中各种类型的大小表示如下:

public class DeclareParams {
    public static void main( String args[] ) {
        byte b;
        short s;
        int i;
        long l;
        float f;
        double d;
        char c;
    }
}

上面的程序中,所有变量合计起来总共需要1+2+4+8+4+8+2=29bytes的内存分配空间。
怎样来给变量初始化?
声明变量是给这个变量分配一定的内存空间。但是光声明的话,是没法知道到底内存里有什么内容的。因此,在读取变量的值之前,有必要给这个变量赋上一定的值。如果你使用一个没有经过初始化的变量的话,编译程序就会报错。「a = 1;」把整数1写入之前分配的内存区域中往内存中写入的值并非「1」,而是「00000001」。那么为什么会是「00000001」呢?Java中,不仅变量有类型,包括数值在内的所有的数据也都有一种类型。因为给int型的变量a赋的值「1」同样也是int型的,所以就会往内存中写入「00000001」。那么,如果变量的类型是byte的话,那结果又会怎么样呢?因为Byte型用1byte的内在来表示一个整数,所以同样是「a = 1;」,写入到内存里的却是1byte大小的「01」。基本型以外的「数据类型」像int和byte之类的数据类型,我们称为基本型(英语里primitive是「原始的」的意思)这些类型,是拿来做那些“CPU直接从内存中读写内容,然后进行计算的最原始的操作”用的。基本型以外的类型的用途,如String,System.out.println(…);中的System和out也不是基本型变量。其中基本型以外的类型有「类」和「引用」。

2.2 常量的本质

  1. final int a;表示声明一个整型常量;常量是使用final修饰的变量,可以是成员常量,也可以是方法常量,一旦是常量,无论如何都得在编译阶段手动初始化。
  2. a = 100;表示为常量a初始化为100,这个初始化操作在编译后就确定了,即此时100这个值保存在class文件的class常量池中。
  3. a = 235;a在前面已经被初始化为100,编译期间确定;当尝试为a重新赋值,编译器将报错。编译器会检查所有经过final修饰的变量,检查它的字面量或者符号引用在后续是否被修改,如果有地方尝试这么做,编译器将直接报错。

3. 深挖到final关键字的祖坟上去

3.1 什么是class常量池?

前面讲过,对于常量的值,比如private final int a = 100;中的 100 ,java编译器首先会将其保存到编译好的class文件中,保存的地方称为class常量池。

  1. 那什么是class常量池呢?
    class常量池也称为静态常量池,是class字节码文件中的一块物理存储区域,它主要保存两类常量:字面量符号引用量
    字面量:文本字符串、被声明为final的常量值、基本数据类型的值、其他。
    符号引用量:类和结构的权限定名、字段的名称和描述符、方法的名称和描述符等。
  2. 为什么需要常量池?
    常量池引入的目的是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。这是一种享元模式的实现。
  3. 通过案例观察class常量池中保存了啥东西:
    看下class常量池中都保存了啥
    package zeh.test.demo.com.changliang;
    // 常量和变量的初始化方式
    public class MyConst {
    // 定义基本类型的成员变量
    private byte baseByte = 101;
    private short baseShort = 102;
    private int baseInt = 103;
    private long baseLong = 104;
    private float baseFloat = 105;
    private double baseDouble = 106;
    private char baseChar = 107;
    private boolean baseBoolean = true;
    private String baseString = "成员变量";
    // 定义基本类型的成员常量
    private final byte baseConstByte = 111;
    private final short baseConstShort = 112;
    private final int baseConstInt = 113;
    private final long baseConstLong = 114;
    private final float baseConstFloat = 115;
    private final double baseConstDouble = 116;
    private final char baseConstChar = 117;
    private final boolean baseConstBoolean = true;
    private final String baseConstString = "成员常量";
    public void testConst() {
        // 定义方法变量
        byte baseLocalByte = 11;
        short baseLocalShort = 12;
        int baseLocalInt = 13;
        long baseLocalLong = 14;
        float baseLocalFloat = 15;
        double baseLocalDouble = 16;
        char baseLocalChar = 17;
        boolean baseLocalBoolean = true;
        String baseLocalString = "方法变量";
        // 定义方法常量
        final byte baseLocalConstByte = 21;
        final short baseLocalConstShort = 22;
        final int baseLocalConstInt = 23;
        final long baseLocalConstLong = 24;
        final float baseLocalConstFloat = 25;
        final double baseLocalConstDouble = 26;
        final char baseLocalConstChar = 27;
        final boolean baseLocalConstBoolean = true;
        final String baseLocalConstString = "方法常量";
    }
    }
    

    上述代码通过一个main方法随便运行后,生成class文件,我们通过javap -v命令对class文件进行反汇编,可以看到汇编文件中的class常量池信息。
    由于常量池内容很多,我这里直接给出结论:
    class常量池中保存了两部分内容:
    (1). 字面量:文本字符串(代码中的成员变量名、成员常量名、方法变量名、方法常量名、字符串常量等)、被声明为final的常量值、基本数据类型的值、其他。
    (2). 符号引用量:引用的目标类的权限定名和描述符、方法的名称和描述符、字段的名称和描述符。
    然而第1点中明确看了下,class常量池中对于基本数据类型的值,是没有 byte 、 short 、 int 、 long 、 char 、 boolean的。
    这些类型在基本数据类型中都是属于整数类型的,这说明java编译器对整数类型的处理是有优化的。
    我这里给出结论:java编译器在处理整数类型的值时,根据不同的取值范围会应用不同的JVM指令,对于范围较小的取值,不会放在class常量池中而是直接存储在JVM指令中,只有较大范围的取值才会加入到class常量池中去。

具体请参考:
《Java常量池 关于int类型数值的一些疑问的探究》
《java中bipush与sipush的区别》

  1. 深入了解class常量池请参考如下大神的博客:
    《class文件基本组织结构》
    《Class文件中的常量池详解(上)》
    《Class文件中的常量池详解(下)》

3.2 宏替换和符号引用替换

  1. 几个基础知识:
    字符串链接符“+”,java编译器底层是如何处理的呢?
    我们知道,任何数据类型通过字符串链接符“+”与一个字符串进行运算,都会自动转换为String类型,那它底层原理是啥呢?
    先看几个案例:
    (1). 验证字符串直接赋值
    package zeh.test.demo.com.changliang;
    public class TestMyBase {
    public static void main(String[] args) {
        String name = "zhaoeh";
        String name1 = getName1();
        String name2 = getName2();
        System.out.println("name1 == name2 ? " + (name1 == name2));
        System.out.println("name == name1 ? " + (name == name1));
    }
    private static String getName1() {
        return "zhaoeh";
    }
    private static String getName2() {
        return "zhaoeh";
    }
    }
    

    上面案例结果都是true;说明对于相同的字符串而言,只要是直接赋值的操作,则它们的内存地址都是相同的。
    JVM对于字符串的设计实际上使用了字符串常量池来设计,关于这块,我们后续再深入了解。
    此处只需要知道:凡是直接赋值的相同字符串,它们都来源于字符串常量池中的同一个字符串常量。

(2). 验证字符串链接符“+”底层使用了StringBuilder

package zeh.test.demo.com.changliang;

public class TestMyBase {
    public static void main(String[] args) {
        String name = "zhaoeh1";
        String name1 = getName1() + 1;
        String name2 = getName2() + 1;
        // false
        System.out.println("name1 == name2 ? " + (name1 == name2));
        // false
        System.out.println("name == name1 ? " + (name == name1));
    }
    private static String getName1() {
        return "zhaoeh";
    }
    private static String getName2() {
        return "zhaoeh";
    }
}

改造了下案例,通过字符串链接符“+”进行了字符串的链接操作。
尽管链接后的字符串内容是一样的,然而直接比较地址都返回了false。
我们使用javap -v进行反汇编发现了“+”链接符的底层竟然使用了StringBuilder来拼接。因此StringBuilder拼接后再转换为String,其对应的内存地址当然是新的。

(3). 验证StringBuilder每次返回的字符串都是重新开辟的堆内存地址

package zeh.test.demo.com.changliang;

public class TestMyBase {
    public static void main(String[] args) {
        StringBuilder stringBuilder = new StringBuilder();
        String builderStr = stringBuilder.append("zhangsan").toString();
        String str = "zhangsan";
        // true
        System.out.println(builderStr.equals(str));
        // false
        System.out.println(builderStr == str);
    }
}

使用StringBuilder、StringBuffer或者String构造每次都创建新的堆内存地址。

  1. 宏替换和符号引用替换
    上面提到,class常量池基本保存两类内容:字面量和符号引用。
    我们先看java编译器在处理字面量时,对于变量和常量有什么区别呢?
    请看下面案例:
    (1). java虚拟机处理字面量:宏替换
    package zeh.test.demo.com.changliang;
    public class TestConst {
     public static void main(String[] args) {
         // 相关变量和相关常量定义
         String name1 = "eric";
         final String name2 = "eric";
         final String name3 = getName();
         String target = "eric100";
         String target1 = name1 + 100;
         String target2 = name2 + 100;
         // false
         // 尽管name1的值eric已经存入class常量池
         // 但因为name1不是final的,因此编译器无法确定该值后续是否被修改
         // 所以在使用name1的地方不会把name1替换成对应的字面量"eric",而是在运行时通过字段链接进行确定
         System.out.println(target1 == target);
         // true
         // name2的eric已经存入class常量池,name2是final,且编译阶段明确可以确定name2的值就是字面量eric
         // 因此后续所有使用name2的地方,编译器实际上都直接将name2替换成了字面量eric
         // 所以class字节码文件中,name2 + 100实际上就是字面量"eric100",因此两者的地址是相同的
         System.out.println(target2 == target);
     }
    }
    

    第一个为false,第二个为true。这就是java编译器在处理变量和常量的区别。
    即:当final变量是基本数据类型以及String类型时,如果在编译期间能知道它的确切值,则编译器会把它当做编译期常量使用。
    也就是说在用到该final变量的地方,相当于直接访问的这个常量,不需要在运行时确定。
    需要注意:只有在编译期间能确切知道final变量值的情况下,编译器才会进行这样的优化。而对于变量和那些无法在编译期间确定值的final常量,编译器是不会进行这样的优化的。
    这种优化称作“宏替换”。

(2). java虚拟机处理符号引用:符号引用替换 java虚拟机如何处理变量和编译期间无法确定值的常量呢?

package zeh.test.demo.com.changliang;
public class TestConst {
    public static void main(String[] args) {
        final String name3 = getName();
        String target = "eric100";
        String target3 = name3 + 100;
        // false,虽然name3是final的常量,但是取值不明确,无法在编译阶段确定下来
        // 只有在运行阶段才能确定name3的取值
        // 所以此时name3实际上是符号引用,该符号引用能够明确定位到getName()方法,当JVM运行后,才会将该符号引用替换为直接引用
        // 而JVM对于字符串的链接“+”底层是使用StringBuilder进行的,因此getName()方法返回的直接引用实际上是个新的字符串地址。
        System.out.println(target3 == target);
    }
    public static String getName() {
        return "eric";
    }
}

name3在编译期间无法确定其值,尽管它是final的,但实际上对于它的处理是和普通变量一样的,因为无法确定它的明确值,因此在编译阶段,name3此时实际上保存的是符号引用。
该符号引用明确指向getName()方法。
当JVM启动后,加载完当前类,才会将name3保存的符号引用替换为直接引用。
也就是java编译器对于变量和无法明确确定值的常量,采用的是符号引用替换的策略。

我们再深入看下面案例:

package zeh.test.demo.com.changliang;

public class TestConst {
    public static void main(String[] args) {
        final Person person = new Person();
        System.out.println(person.hashCode());
    }
}
class Person{
}

person对象是final的,但它不是基本类型和String,而是一个明确的javabean。
因此,编译阶段肯定无法明确知道它的地址,因为它的地址只有等到JVM运行后开辟堆内存才知道。
所以,编译器无法对其使用宏替换,此时使用的就是符号引用替换。
即:class字节码文件中person常量保存的是符号引用,等JVM加载完Person类后,再将其替换为对应的直接引用(person对象的对内存地址)。

回顾下前面讲的,比如此处,因为person是常量,所以java编译器不允许person再指向其他的地址。
那java编译器在编译阶段并不知道person的实际内存地址,它是如何进行校验的呢?
它校验的其实就是person目前的符号引用,看符号引用在后续是否被修改为其他的符号引用了。
因为符号引用和直接引用实际上是一一对应的,你可以理解符号引用就是直接引用的一个预置虚拟引用,等有了直接引用就直接把符号引用替换了。

关于宏替换和符号引用替换,请参考:
《浅析Java中的final关键字》
《java基础(八) 深入解析常量池与装拆箱机制》
《JAVA的符号引用和直接引用》
《JVM中的直接引用和符号引用》

4. 常量和变量案例

package zeh.myjavase.code09init.demo03;

class TestVarAndConstants {
    
    // 定义普通类中4种类型的成员。
    // 注意:常量即便作为成员,也必须显式进行初始化,编译器不会默认为我们初始化成员常量,否则报错。
    private static final String STATIC_FINAL_FIELD_TEST = "静态常量";
    private final String NOT_STATIC_FINAL_FIELD_TEST = "非静态常量";
    private static String staticFieldTest = "静态成员";
    private String notStaticFiledTest = "非静态成员";

    public void fun() {
        // 普通类方法内部只有两种,因为static不能修饰方法内部的常量或者变量。
        // 定义同名的方法常量和变量
        final String NOT_STATIC_FINAL_FIELD = "常量";
        String notStaticFiled = "变量";

        // 定义不同名的方法常量和变量
        final String STATIC_FINAL_FIELD1 = "常量";
        String staticField1 = "变量";

        System.out.println("输出成员");
        // 不指定前缀,采取就近原则访问同名常量
        System.out.println(TestVarAndConstants.STATIC_FINAL_FIELD_TEST);
        // 对于常量,尽管保存在方法区的运行时常量池中,但它的引用是在堆或者栈中开辟的。
        System.out.println(new TestVarAndConstants().NOT_STATIC_FINAL_FIELD_TEST);
        // 不指定前缀,采取就近原则访问同名常量
        System.out.println(TestVarAndConstants.staticFieldTest);
        // 对于常量,尽管保存在方法区的运行时常量池中,但它的引用是在堆或者栈中开辟的。
        System.out.println(new TestVarAndConstants().notStaticFiledTest);

        System.out.println("输出方法变量");
        System.out.println(NOT_STATIC_FINAL_FIELD);
        System.out.println(notStaticFiled);
        System.out.println(STATIC_FINAL_FIELD1);
        System.out.println(staticField1);
    }
}

public class Demo03VarAndConstantRun {

    // 定义主类中4种类型的成员。
    private static final String STATIC_FINAL_FIELD = "静态常量";
    private final String NOT_STATIC_FINAL_FIELD = "非静态常量";
    private static String staticField = "静态成员";
    private String notStaticFiled = "非静态成员";

    public static void main(String[] args) {
        System.out.println("断点");

        // 主类方法内部只有两种,因为static不能修饰方法内部的常量或者变量,根本原因是JVM内存模型规定方法内的变量和常量只能在栈内存上动态开辟空间以保存值或者引用地址(对于常量即便是基本类型栈中保存的仅仅是指向运行时常量池的引用地址而已)。
        // 定义同名的方法常量和变量
        final String NOT_STATIC_FINAL_FIELD = "常量";
        String notStaticFiled = "变量";

        // 定义不同名的方法常量和变量
        final String FINAL_FIELD = "常量";
        String field = "变量";

        System.out.println("输出成员");
        // 不指定前缀,采取就近原则访问同名常量
        System.out.println(Demo03VarAndConstantRun.STATIC_FINAL_FIELD);
        // 对于常量,尽管保存在方法区的运行时常量池中,但它的引用是在堆或者栈中开辟的。
        System.out.println(new Demo03VarAndConstantRun().NOT_STATIC_FINAL_FIELD);
        // 不指定前缀,采取就近原则访问同名常量
        System.out.println(Demo03VarAndConstantRun.staticField);
        // 对于常量,尽管保存在方法区的运行时常量池中,但它的引用是在堆或者栈中开辟的。
        System.out.println(new Demo03VarAndConstantRun().notStaticFiled);

        System.out.println("输出方法变量");
        System.out.println(NOT_STATIC_FINAL_FIELD);
        System.out.println(notStaticFiled);
        System.out.println(FINAL_FIELD);
        System.out.println(field);

        // 实例化普通类,并调用方法
        TestVarAndConstants test = new TestVarAndConstants();
        test.fun();
    }
}

4.1 第一阶段:编译阶段

javac工具预编译.java文件,产出物.class文件。即生成VarAndConstant.class文件和Test.class文件。一个class对应一个.class字节码文件。.class文件中存放着class常量池和其他附加信息。Class常量池保存着方法的符号引用,和上述源文件中所有的字面量,也保存着final定义的常量。

4.2 第二阶段:JVM装载阶段(加载、链接<验证,准备和解析>、初始化)

  1. 加载:JVM找到main方法,加载main方法所在的class信息和其通过主动引用层层引用的其他class信息。注意,JVM本身对main方法所在的类就是主动引用。
    加载类Demo03VarAndConstant和TestVarAndConstants;
    因为main方法中有TestVarAndConstants test = new TestVarAndConstants();
    调用了TestVarAndConstants类的构造方法(注意构造方法也是一个类的静态成员),所以加载完Demo03VarAndConstant类当执行到TestVarAndConstants test = new TestVarAndConstants();语句时再加载TestVarAndConstants类。
    由此可见,加载目标类的开始点是主动引用目标类(包括构造方法)。
    注意:每一个主动引用都触发目标类的装载,每一个目标类的装载都遵循”静静非构构,设置属性初始化”的装载流程。
  2. 链接:主要说准备阶段(此处没有考虑String常量池的优化)。
    ①. 一个.class文件在编译完成经过验证阶段后就对应一个唯一的权限定名的Class对象,这个对象由JVM创建,因为.class文件加载进JVM中全局唯一,所以对应的Class对象也全局唯一。Class对象的引用地址保存在方法区内存的静态区,只会实例化一次。所以VarAndConstant类和Test类在这个阶段的方法区中已经存在了对应的Class对象了。
    注意:Class对象地址被目标类的*.class字节码引用,Class对象地址指向Class对象,同时Class对象中又聚合了目标class文件的各种结构信息,比如实现的接口、目标class的名称、包名、构造方法、成员变量等信息,即Class对象的地址指向每一个对应的字节码文件的Class内存实体。Class对象是JVM提供的用于反射机制的基础。
    ②. 准备阶段将class常量池中的final常量地址复制一份到方法区内存的运行时常量池中;即将STATIC_FINAL_FIELD成员、NOT_STATIC_FINAL_FIELD成员、FINAL_FIELD常量地址复制到运行时常量池中。
    ③. 随后将静态成员地址复制一份到方法区内存的静态区中。如果此时成员既是final又是static,则先复制到运行时常量池,再从运行时常量池复制到静态区。复制完成后,静态区的静态成员就实例化完成了。即STATIC_FINAL_FIELD成员和staticField成员在此阶段已经初始化完成(有具体值就初始化为具体值,没有就初始化为默认值),此时前面由JVM创建的VarAndConstant类的Class对象指向静态区中的静态成员。由此可见,准备阶段只为静态成员实例化和初始化。
  3. 初始化并使用:
    ①. main方法一步步顺序执行。
    ②. 遇到方法变量就初始化,遇到输出就输出。
    01) 当遇到方法变量和常量时,在栈中开辟内存保存方法变量,从运行时常量池中复制方法常量保存到栈内存中;
    02) 当遇到调用的非静态成员时,在堆中开辟空间保存,若是非静态常量,从运行时常量池中复制到对象的堆内存中, 03) 当执行到TestVarAndConstants test = new TestVarAndConstants();语句时,调用了TestVarAndConstants的构造方法,开始返回到加载阶段加载TestVarAndConstants.class。具体流程和VarAndConstant一样。
  4. 卸载:
    当一个类执行完毕后,所有实例化的对象空间都等待被回收,加载进JVM的类的信息和Class对象也等待被回收。注意此处的类执行完毕是指JVM进程结束。
    注意,目标类需要满足如下3个条件才会被gc回收卸载:
    1) 该类的所有实例已经被gc。
    2) 加载该类的ClassLoader实例被gc(启动类加载器永远不会被回收)。
    3) 该类的class实例没有在其他地方被引用。

文档信息

Search

    Table of Contents