1

1. 类加载过程

虚拟机把描述类的class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的数据类型,这就是虚拟机的类加载机制。

当然在class文件的生成,则是由编译阶段完成。因此整个过程可以按照以下的流程:

编译 -> 加载 -> 验证(链接) -> 准备(链接) -> 解析(链接) -> 初始化 -> 执行

下面着重讲一下 类加载的过程:

  1. 加载

加载,是指Java虚拟机查找字符流(查找.class文件),并且根据字符流创建Java,lang.Class对象的过程,将类的.class文件的二进制数据读入内存,放在运行区域的方法区内,然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。

  1. 验证

验证阶段作用是保证Class文件的字节流包含的信息符合JVM规范,不会给JVM造成危害。如果验证失败就会抛出一个java.lang.VerifyError异常或其子类异常。

  1. 准备

准备阶段是正式为类变量设置分配内存,并设置初始值的阶段这些内存都在方法区中分配:
对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的只包括类变量(Class Variable,即静态变量,被static 关键字修饰的变量,只与类有关,因此被称为类变量),实例对象会在对象实例化时随着对象一块分配到Java 堆中。
  2. 从概念上讲,类变量所使用的内存都应该在方法区中进行分配。不过有一点注意的是,在 JDK 7 之前,Hotspot 使用 永久代来实现方法区时,实现是完全符合这种逻辑概念的。而在JDK 7 及之后,把原本放在永久代中的字符串常量池和静态变量等移到堆中。这个时候类变量会一并随着Class 对象一并放在 Java 堆中。
  3. 这里所设置的初始值,通常情况下是数据类型默认的 “零值”,如 (0,0L,null,false),比如我们定义了 public static int value = 11,那么value 变量在准备阶段赋的值是0,而不是11,(初始化阶段才会赋值),特殊情况:比如给 value 变量加上了 final 关键字public static final int value=11 ,那么准备阶段 value 的值就被赋值为 11。
  1. 解析

解析阶段是虚拟机将常量值中的符号引用替换为直接引用的过程,解析动作主要针对类和接口,字段,类方法,接口方法,方法类型,方法句柄,方法的限定符。

符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

  1. 初始化

初始化阶段是执行初始化方法 ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

说明:< clinit> () 方法是编译之后自动生成的。

对于< clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 < clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个进程阻塞,并且这种阻塞很难被发现。

对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  1. 当遇到 new 、 getstatic、putstatic 或 invokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    1.1. 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    1.2. 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    1.3. 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    1.4. 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("…"), newInstance() 等等。如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  6. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

2. 常量池

2.1. 静态/class文件常量池

class文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括静态常量池。 class文件中存在常量池(非运行时常量池),其在编译阶段就已经确定。

先简单写一个类举例:

class JavaBean{
    private int value = 1;
    public String s = "abc";
    public final static int f = 0x101;

    public void setValue(int v){
        final int temp = 3;
        this.value = temp + v;
    }

    public int getValue(){
        return value;
    }
}

通过javac命令编译之后,用javap -v 命令查看编译后的文件:

class JavaBasicKnowledge.JavaBean
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#29         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#30         // JavaBasicKnowledge/JavaBean.value:I
   #3 = String             #31            // abc
   #4 = Fieldref           #5.#32         // JavaBasicKnowledge/JavaBean.s:Ljava/lang/String;
   #5 = Class              #33            // JavaBasicKnowledge/JavaBean
   #6 = Class              #34            // java/lang/Object
   #7 = Utf8               value
   #8 = Utf8               I
   #9 = Utf8               s
  #10 = Utf8               Ljava/lang/String;
  #11 = Utf8               f
  #12 = Utf8               ConstantValue
  #13 = Integer            257
  #14 = Utf8               <init>
  #15 = Utf8               ()V
  #16 = Utf8               Code
  #17 = Utf8               LineNumberTable
  #18 = Utf8               LocalVariableTable
  #19 = Utf8               this
  #20 = Utf8               LJavaBasicKnowledge/JavaBean;
  #21 = Utf8               setValue
  #22 = Utf8               (I)V
  #23 = Utf8               v
  #24 = Utf8               temp
  #25 = Utf8               getValue
  #26 = Utf8               ()I
  #27 = Utf8               SourceFile
  #28 = Utf8               StringConstantPool.java
  #29 = NameAndType        #14:#15        // "<init>":()V
  #30 = NameAndType        #7:#8          // value:I
  #31 = Utf8               abc
  #32 = NameAndType        #9:#10         // s:Ljava/lang/String;
  #33 = Utf8               JavaBasicKnowledge/JavaBean
  #34 = Utf8               java/lang/Object

静态常量池主要存放两大常量:字面量符号引用

1. 字面量

字面量接近java语言层面的常量概念,主要包括:

  1. 文本字符串,也就是我们经常申明的: public String s = "abc";中的"abc"。

     #9 = Utf8               s
     #3 = String             #31            // abc
     #31 = Utf8              abc
  2. 用final修饰的成员变量,包括静态变量、实例变量和局部变量。

     #11 = Utf8               f
     #12 = Utf8               ConstantValue
     #13 = Integer            257

    这里需要说明的一点,上面说的存在于常量池的字面量,指的是数据的值,也就是abc0x101(257),通过上面对常量池的观察可知这两个字面量是确实存在于常量池的。

而对于 基本类型数据(甚至是方法中的局部变量),也就是上面的private int value = 1;常量池中只保留了他的的字段描述符I和字段的名称value,他们的字面量不会存在于常量池

2. 符号引用

符号引用主要设涉及编译原理方面的概念,包括下面三类常量:

  1. 类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的"."替换为"/"得到的,主要用于在运行时解析得到类的直接引用,像上面

     #5 = Class              #33            // JavaBasicKnowledge/JavaBean
     #33 = Utf8               JavaBasicKnowledge/JavaBean
  2. 字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量。

     #4 = Fieldref           #5.#32         // JavaBasicKnowledge/JavaBean.value:I
     #5 = Class              #33            // JavaBasicKnowledge/JavaBean
     #32 = NameAndType       #7:#8          // value:I
    
     #7 = Utf8               value
     #8 = Utf8               I
    
     //这两个是局部变量,值保留字段名称
     #23 = Utf8               v
     #24 = Utf8               temp

    可以看到,对于方法中的局部变量名,class文件的常量池仅仅保存字段名。

  3. 方法中的名称和描述符,也即参数类型+返回值。

     #21 = Utf8               setValue
     #22 = Utf8               (I)V
    
     #25 = Utf8               getValue
     #26 = Utf8               ()I

2.2. 运行时常量池

运行时常量池是方法区的一部分,所以也是全局贡献的,我们知道,jvm在执行某个类的时候,必须经过 加载、链接(验证、准备、解析)、初始化,在第一步 加载的时候需要完成:

  • 通过一个类的全限定名来获取此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个类对象,代表加载的这个类,这个对象是java.lang.Class,它作为方法区这个类的各种数据访问的入口。

类对象普通对象是不同的,类对象是在类加载的时候完成的,是jvm创建的并且是单例的,作为这个类和外界交互的入口, 而普通的对象一般是在调用new之后创建。

1. 静态常量池 -> 运行时常量池

上面的第二条:将class字节流代表的静态存储结构转化为方法区的运行时数据结构,其中就包含了 静态常量池进入运行时常量池的过程。

2. 不同类共用一个运行时常量池,相同字符串优化

这里需要强调一下:不同的类共用一个运行时常量池,同时在进入运行时常量池的过程中,多个class文件中常量池相同的字符串,多个class文件中常量池中相同的字符串只会存在一份在运行时常量池,这也是一种优化。

运行时常量池的作用是存储java 静态常量池中的符号信息,运行时常量池中保存着一些class文件中描述的符号引用,同时在类的解析阶段还会将这些符号引用翻译出直接引用(直接指向实例对象的指针,内存地址),翻译出来的直接引用也是存储在运行时常量池中。

3. 运行时常量池比静态常量池多了动态性

运行时常量池相对于静态常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说 运行时常量池的内容并不全部来自静态常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。

2.3. 字符串常量池

在工作中,String类是我们使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,这就是我们今天要讨论的核心,即字符串常量池(String Pool)。字符串常量池由String类私有的维护。

字符串是常量,字符串池中的每个字符串对象只有唯一的一份,可以被多个引用所指向,避免了重复创建内容相同的字符串。

1. Java中创建字符串对象的两种方式

一般有如下两种:

String s0 = "hellow";
String s1 = new String("hellow");

它们的区别在于:

  1. 通过字面值赋值创建的字符串对象存放在字符串池中。第一种方式声明的字面量hellow是在编译期就已经确定的,它会直接进入静态常量池中;当运行期间在全局字符串常量池中会保存它的一个引用.实际上最终还是要在堆上创建一个”hellow”对象,这个后面会讲。
  2. 通过关键字new出来的字符串对象存放在堆中。第二种方式方式使用了new String(),也就是调用了String类的构造函数,我们知道new指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。因此就算是final 修饰的new出来的字符串变量,依然不进入常量池

因此此时调用System.out.println(s0 == s1);返回的肯定是flase,因此==符号比较的是两边元素的地址,s1和s0都存在于堆上,但是地址肯定不相同。我们来看几个常见的题目:

String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hel" + "lo";
String s4 = "Hel" + new String("lo");
String s5 = new String("Hello");
String s7 = "H";
String s8 = "ello";
String s9 = s7 + s8;

System.out.println(s1 == s2);  // true
System.out.println(s1 == s3);  // true
System.out.println(s1 == s4);  // false
System.out.println(s1 == s9);  // false

class文件里常量池里大部分数据会被加载到“运行时常量池”,包括String的字面量;但同时“Hello”字符串的一个引用会被存到同样在“非堆”区域的“字符串常量池”中,而"Hello"本体还是和所有对象一样,创建在Java堆中。

当主线程开始创建s1时,虚拟机会先去字符串池中找是否有equals(“Hello”)的String,如果相等就把在字符串池中“Hello”的引用复制给s1;如果找不到相等的字符串,就会在堆中新建一个对象,同时把引用驻留在字符串池,再把引用赋给str。

当用字面量赋值的方法创建字符串时,无论创建多少次,只要字符串的值相同,它们所指向的都是堆中的同一个对象。

2. 优缺点

字符串池的优点就是避免了相同内容的字符串的创建,节省了内存,省去了创建相同字符串的时间,同时提升了性能;

另一方面,字符串池的缺点就是增加了部分时间成本,即JVM在常量池中遍历对象所需要的时间,不过其时间成本相比而言比较低。

如果对空间要求高于时间要求,且存在大量重复字符串时,可以考虑使用常量池存储。
如果对查询速度要求很高,且存储字符串数量很大,重复率很低的情况下,不建议存储在常量池中。

3. String.intern()

一个初始为空的字符串池,它由类String独自维护。当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,将此String对象添加到池中,并返回此String对象的引用。 对于任意两个字符串s和t,当且仅当s.equals(t)为true时,s.instan() == t.instan才为true。所有字面值字符串和字符串赋值常量表达式都使用 intern方法进行操作。

4. 题目

(1)用final来修饰字符串
先看一段代码:

String a = "123";  
String b = "456";
String c = a+b;
System.out.println(c=="123456");
//输出:false
final String a = "123";  
final String b = "456";
String c = a+b;
System.out.println(c=="123456");
//输出:true

对于字面常量"123456"被拼接后,是否进入常量池的。

  • 对于第一段代码a,b是变量所以在编译期间是不能确定其值的,所以c的值"123456"并没有加载进常量池;
  • 而对于第二段代码,因为a、b是常量,所以在编译期间就计算出了字面量"123456",并加载进常量池。结果就很明了了。

(2)动态加载到常量池

String a = "123";  
String b = "456";
String c = a+b;
c.intern();    //在运行时加载到常量池
System.out.println(c=="123456");
//输出:true

通过 c.intern()方法动态加载到常量池之后输出true ,进一步论证了上文的结果。

(3)通过new在字符串常量池中

String a= new String("123")+new String("456");
//String b= new String("123456");
System.out.println(a.intern()==a);
//输出:true ;
//放开注释输出:false
  • 没放开注释时在字符常量池中没有"123456"的字面常量,a.intern()方法赋值一份a的引用到常量池并返回,所以a.intern()==a。
  • 放开注释后,字符常量池中已经通过new String(“123456”)在常量池中新建了一个字符串对象(但是其本身是在堆上的对象),固a.intern()返回的不是a的引用,而是new String(“123456”)的同时在字符常量池中新创建的字符串对象。

2.4. java 基本数据类型包装类及常量池

java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

public class StringConstantPool{

    public static void main(String[] args){
        //5种整形的包装类Byte,Short,Integer,Long,Character的对象,
        //在值小于127时可以使用常量池
        Integer i1=127;
        Integer i2=127;
        System.out.println(i1==i2);//输出true

        //值大于127时,不会从常量池中取对象
        Integer i3=128;
        Integer i4=128;
        System.out.println(i3==i4);//输出false
        //Boolean类也实现了常量池技术

        Boolean bool1=true;
        Boolean bool2=true;
        System.out.println(bool1==bool2);//输出true

        //浮点类型的包装类没有实现常量池技术
        Double d1=1.0;
        Double d2=1.0;
        System.out.println(d1==d2); //输出false

    }
}

在JDK5.0之前是不允许直接将基本数据类型的数据直接赋值给其对应地包装类的,如:Integer i = 5; 但是在JDK5.0中支持这种写法,因为编译器会自动将上面的代码转换成如下代码:Integer i=Integer.valueOf(5);这就是Java的装箱.JDK5.0也提供了自动拆箱:Integer i =5; int j = i;

2.5. 常量池和静态变量

上述提到了 静态/class文件常量池、运行时常量池、字符串常量池,在刚接触这些概率时,很容易对这三者混淆。
但实际上,三者并非是同一维度上可以横向对比的概念,只不过在名词上都带有常量池。

1. 静态常量池和运行时常量池

二者不应该横向对比,而是有时间上的顺序,因为静态常量池在jvm加载后,才生成了运行时常量池,算是常量池的不同形态。

但运行时常量池的内容并不全部来自静态常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。

在存储方式上,静态常量池存在.class字节码文件中,即由编译器编译阶段生成。而运行时常量池是在加载阶段后,存储在内存中的。

2. 运行时常量池和字符串常量池

字符串常量池算是运行时常量池的一部分,只不过由于字符串在java开发中应用频繁,就单独拎了出来。

但运行时常量池和静态常量池一样,都是每个类class各有一份。但字符串常量池又称“全局字符串常量池”,jvm中只有一个。

字符串常量池的来源大致有两种:

  • 静态常量池中字符串类型的字面量,当加载成运行时常量池时就继承了。
  • 动态生成的字符串常量,如果String.intern()的应用。

二者在存储方式上有争议,已大众化的理解如下:

  • 在 JDK6.0 及之前版本,字符串常量池是放在 Perm Gen 区(也就是方法区)中,此时常量池中存储的是对象。
  • 在 JDK7.0 版本,只提到字符串常量池被移到了堆中了,默认就是其他运行时常量池还在方法区,此时运行时常量池存储的字符串值就是引用了。(争议:文档只提到字符串常量池移到堆,其实并提到其他运行时常量池是否也移到堆)
  • 在 JDK8.0 中,永久代(方法区)被元空间取代了。

3. 静态变量

3.1. 静态变量和字符串常量池

静态变量在jvm存储位置上,和字符串常量池一直很像。

  • jdk1.6及以前:有永久代,静态变量存储在永久代上。
  • jdk1.7 : 有永久代,但已经逐步在去“永久代”,类型信息、字段、方法、常量保存在永久代中,但字符串常量池、静态变量保存在堆中。
  • jdk1.8 : 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,但字符串常量池、静态变量保存在堆中。

jdk 1.7 中,将 字符串常量池 放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代空间不足、永久代空间不足时才会触发,导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。而放到堆中,就能更及时的进行回收。所以,当字符串常量池和静态变量进入堆后,是可以像普通对象一样经过minor gc、full gc。

3.2. static 和 final

1. ConstantValue属性

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。非static类型的变量的赋值是在实例构造器方法中进行的;static类型变量赋值分两种,在类构造器中赋值,或使用ConstantValue属性赋值。

在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化。

2. 为什么ConstantValue的属性值只限于基本类型和String

因为从常量池中只能引用到基本类型和String类型的字面量

3. final、static、static final修饰的字段赋值的区别
  • static修饰的字段在加载过程中准备阶段被初始化,但是这个阶段只会赋值一个默认的值(0或者null而并非定义变量设置的值)初始化阶段在类构造器中才会赋值为变量定义的值。(关于static的初始化请看“init”与”clinit”的区别)。
  • final修饰的字段在运行时被初始化,可以直接赋值,也可以在实例构造器中赋值,赋值后不可修改。final关键字来修饰变量表明了该变量一旦赋值就无法更改。同时编译器必须保证该变量在使用前被初始化赋值。
    例如你的static final int c1这个变量,是一个静态变量,静态变量的初始化可以在静态块中进行。而非static变量,可以初始化块中和构造方法中进行。
    如果你在这几个地方没有对final变量进行赋值,编译器便会报错。
  • static final修饰的字段在javac编译时生成comstantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。可以理解为在编译期即把结果放入了常量池中。

在一个类中定义字段时,可以声明为成员变量(如final),也可以声明为类变量(静态变量),静态变量在装载类时被初始化,而成员变量每次创建实例时都会被初始化一次。一个字段被声明为static final,表示这个字段在初始化完成后就不可再改变了,final,类的初始化完成后,在类的实例化进行赋值,每次实例化的值不一定相同。加上了static 的 final,在类只装载一次的情况下,可以是真正意义的“常量”。例如:

private static final int random = new Random().nestInt();//每次发生类装载时都会赋值一次,且赋的值都不一样 
private final int random = new Random().nestInt();//每次生成这个类的实例时都会赋值一次,且赋的值都不一样

3.3. 静态代码块

1. 静态代码块 vs 实例代码块

在讲静态代码块时,就先对比的讲一讲实例代码块,看一下例子:

public class Person {
    // 静态代码块
    static {
        System.out.println("Person.static initializer");
    }
    // 实例初始化块
    {
        System.out.println("Person.instance initializer");
    }
    //构造方法
    public Person() {
        System.out.println("Person.Person");
    }
}

public void test1() {
    Person person1 = new Person();
    Person person2 = new Person();
}

最终打印的结果为:

Person.static initializer
Person.instance initializer
Person.Person
Person.instance initializer
Person.Person
  • 静态代码块:在类加载时候执行静态代码块,只会执行一次。
  • 实例代码块:每次调用构造方法之前首先调用实例初始化块。

静态代码块和实例代码块的区别,主要体现在执行顺序和执行次数上:静态代码块在类加载的时候执行,因为类只加载一次,所以静态代码块只执行一次,实例初始化块在每次new对象的时候调用构造方法之前被执行,而且每当new一个对象就要执行一次。

静态代码块、实例初始化块、构造方法的执行顺序依次为:静态代码块 -> 实例初始化块 -> 构造方法

2. 静态代码块和静态变量

还是先看2个例子:

// 例1:
public class Test{
    static int cnt = 6;
    static{
        cnt += 9;
    }
    public static void main(String[] args){
        System.out.println(“cnt =” + cnt);
    }
    static{
        cnt /=3;
    };
}

// 例2:
public class Test {  
   static {
      cnt = 6;
   }
   static int cnt = 100;
   public static void main(String[] args) {
      System.out.println("cnt = " + cnt);
   }
   static {
      cnt /= 2;
   }
}

输出结果是,例1结果是5;例2结果是50。

首先,静态代码块、静态变量这两个是属于同一级别的,是按代码写得顺序执行的,而不是先执行静态变量后执行静态初始化块

其次,执行静态代码块、静态变量之前,首先扫描类里面所有的静态变量,赋值为默认值(即cnt=0)。


KerryWu
641 声望159 粉丝

保持饥饿