Skip to content

Latest commit

 

History

History
501 lines (348 loc) · 15.5 KB

深入理解类加载机制.md

File metadata and controls

501 lines (348 loc) · 15.5 KB

类加载过程

首先来看下类的生命周期

其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,而解析阶段则不一样。

加载

在这个阶段,java虚拟机主要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流(没有指明必须从哪里获取)
  • 解析成运行时数据,即instanceKlass实例,存放在方法区
  • 堆内存中实例化一个java.lang.Class类的class对象,即instanceMirrorKlass实例。这个对象将作为程序访问方法区中的类型数据的外部接口。

对于数组类而言,情况则有所不同,数组类本身不通过类加载器创建,它是由java虚拟机直接在内存中动态构造出来的。当然,数组类去掉维度后的元素类型还是需要靠类加载器去创建。

这个阶段是开发人员可控性最强的阶段。

验证

这一阶段的目的是确保class文件的字节流中包含的信息符合《java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身安全。它包括:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

为静态变量分配内存并设置变量初始值的阶段,注意这里说的是静态变量,而不包括实例变量,实例变量将会在对象实例化的时候随对象一起分配在堆中。并且这里的初始值指的是数据类型的零值

特殊情况:

public static final int value=123;

如果字段被final修饰,那么在编译的时候就会给该字段添加ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。通过idea插件jclasslib可以查看字段。

解析

将常量池中的符号引用转换为直接引用

  • 符号引用:静态常量池的索引
  • 直接引用:内存地址

常量池:

  • 静态常量池(class文件中的常量池)
  • 运行时常量池(将class文件中的常量池载入虚拟机,并放入方法区,即class文件的常量池表构造而成)
  • 字符串常量池(1.8在堆中)

解析后的信息存储在ConstantPoolCache类实例中

1、类或接口的解析

2、字段解析

3、方法解析

4、接口方法解析

何时解析?

1、类加载以后马上解析

2、使用的时候(也就是初始化的时候,会判断是否解析过)

openjdk使用第二种方法,在执行特定指令码之前解析(见深入理解jvm虚拟机p273)

在某些情况下可以在初始化阶段之后再开始,这是为了支持java语言的晚绑定(运行时绑定、动态绑定)

如何避免重复解析

​ 借助缓存->ConstantPoolCache(运行时常量池的缓存),是一个Hashtable结构,

​ key:常量池的索引,value:ConstantPoolCacheEntry

​ 如下代码:

public class Test1 {
    public static void main(String[] args) {
        System.out.println(Sub.value);
        while (true);
    }
}

class Super{
    public static int value="123";;

    static {
        System.out.println("Super init...");
    }
}

class Sub extends Super{
    static {
        System.out.println("Sub init...");
    }
}

通过jclasslib查看:

 0 ldc #2 <123>
 2 putstatic #3 <com/cxylk/partone/Super.value>
 5 getstatic #4 <java/lang/System.out>
 8 ldc #5 <Super init...>
10 invokevirtual #6 <java/io/PrintStream.println>
13 return

其中字符串value的key就是常量池索引2,value就是将该String类型的字段包装成ConstantPoolCacheEntry

初始化

在该阶段,java虚拟机才真正开始执行类中编写的java代码,将主导权移交给应用程序。

初始化阶段就是执行类构造器<clinit>方法的过程

关于clinit方法和init方法

这里需要分清<clinit>方法和<init>方法:

  • <clinit>():由编译器自动收集类中的所有静态变量的赋值动作(注意是赋值动作,这也是为什么一个静态变量如果没有对它赋值,cliint方法中就没有该变量出现的原因)和静态语句块中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。例:

    public class Test{
        static{
            i=0;//赋值可以正常编译通过
            System.out.print(i)//报错“非法向前引用”
        }
        static int i=1;
    }

    java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。

  • <init>():实例构造器方法,就是构造函数,它会显示的调用父类构造器,这是和<clinit>方法所不同的。

总结如下:

1、如果没有静态属性、静态代码段,就不会生成clinit方法

2、final修饰,不会在clinit方法中,即使它被static修饰。当然,如果该变量所指向的对象,即使该变量的地址不会改变,但是所指向的对象是可能发生变化的,那么会出现在clinit方法中,被动引用中的第三点就是这种情况

3、一个字节码文件只有一个clinit方法,多线程环境下加锁同步,但会出现死锁的情况,并且不易查看。如下死锁代码,无法通过jconsole等工具查看

public class InitDeadLock {
    public static void main(String[] args) {
        new Thread(A::test).start();
        new Thread(B::test).start();
    }
}

class A{
    static {
        System.out.println("ClassA init...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new B();
    }
    public static void test(){
        System.out.println("aaa");
    }
}

class B{
    static {
        System.out.println("ClassB init...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new A();
    }

    public static void test(){
        System.out.println();
    }
}

4、clinit方法块中生成的代码顺序与java代码的顺序是一致的,这个会影响程序的最终结果。

5、如果静态字段没有赋初值,那么一样不会出现在clinit方法中,因为clinit方法收集的是静态变量的赋值操作。其实很好理解,静态变量在准备阶段就已经赋了初值,如果在初始化阶段没有对它再赋值,那么就没有必要将它放入clinit。没有再赋值,说明我就用初始值那还放入clinit方法干嘛呢?

测试代码:

public class Hello {

    public static final int a=3;

    public static int b=5;

    public static String c="sdf";

    public static int d;
}

jclasslib结果如下

可以看到,a因为加了final修饰,所以会加上ConstantValue属性,并且不会出现在clinit方法中,d因为没有赋初值,所以也没有出现在clinit方法中。在clinit方法中只有b和c(对应的字节码为1和4这两条,后面有字段名,没显示),并且顺序和java代码的顺序一致。

有静态语句块的情况:

    static {
        System.out.println("A...");
    }

结果:

getstatic #2 <java/lang/System.out>
ldc #3 <A...>
invokevirtual #4 <java/io/PrintStream.println>
return

初始化时机

《深入理解jvm虚拟机》中分为主动引用和被动引用。

主动引用

1:new、getstatic、putstatic或invokestatic这四个字节码指令。对应的java代码场景

  • 使用new关键字实例化对象
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外,为什么是静态字段?不是静态字段,那么调用肯定会使用new)的时候
  • 调用一个类型的静态方法的时候

2:在调用类库中的某些反射方法时,例如,Class类或java.lang.reflect包中的反射方法。

3:初始化子类会去加载父类,并执行父类方法

4:启动类(main函数所在的类)

5:当使⽤jdk1.7动态语⾔⽀持时,如果⼀个java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic的⽅法句柄,并且这个⽅法句柄所对应的类没有进⾏初 始化,则需要先出触发其初始化

6:jdk8中接口的默认方法,如果有这个接口的实现类发生了初始化,那该接口要在其之前进行初始化。

看一个例子:

public class Test6 {
    public static void main(String[] args) {
        Test6_A instance = Test6_A.getInstance();
        System.out.println(instance.a);
        System.out.println(instance.b);
    }
}

class Test6_A{
    public static int a;
    public static Test6_A instance=new Test6_A();
    public static int b=0;
    Test6_A(){
        a++;
        b++;
    }

    public static Test6_A getInstance(){
        return instance;
    }
}

首先在准备阶段的时候,会将a和b赋初值成0,instance=null,初始化的时候执行new Test6_A(),将a和b的值都加1,接着执行int b=0,将b赋值为0,所以结果是

1 0,如果将int b=0放在int a下面,那结果就是1 1

被动引用

1:通过子类引用父类的静态字段,不会导致子类初始化

public class Test1 {
    public static void main(String[] args) {
        System.out.println(Sub.value);
    }
}

class Super{
    public static int value=123;

    static {
        System.out.println("Super init...");
    }
}

class Sub extends Super{

    static {
        System.out.println("Super init...");
    }
}

Sub没有初始化,但是它已经被加载了,通过VM参数-XX:+TraceClassLoading可以看到:

[Loaded com.cxylk.partone.Super from file:/D:/workspace/learn-jvm/out/production/learn-jvm/]
[Loaded com.cxylk.partone.Sub from file:/D:/workspace/learn-jvm/out/production/learn-jvm/]

结果:

Super init... 123

2:通过数组定义来引用类,并不会触发此类的初始化

public class Test2 {
    public static void main(String[] args) {
        TestA[] testAS=new TestA[1];//只是将TestA当做数组类型来用,没有使用它,并不会初始化
        System.out.println("end");
    }
}

class TestA{
    static {
        System.out.println("testa");
    }
}

结果:

end

如果改成这样

TestA testA=new TestA();

那么就会触发初始化

3:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化(主动引用第一点的第二条特例)

public class Test3 {
    public static void main(String[] args) {
        System.out.println(Const.HELLOWORD);
        while (true);
    }
}

class Const{
    public static final String HELLOWORD="hello word";
    static {
        System.out.println("A...");
    }
}

Test3的字节码如下:

0 getstatic #2 <java/lang/System.out>
3 ldc #4 <hello word>
5 invokevirtual #5 <java/io/PrintStream.println>
8 return

Const的字节码如下:

0 getstatic #2 <java/lang/System.out>
3 ldc #3 <A...>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return

这里有getstatic指令,这是因为System.out是静态方法,而不是初始化当前类。

并且值得注意的是,当查看当前所加载的类时,并没有找到Const,但Const确实已经被编译成了class文件,而且使用HSDB查看的时候,也没有发现Const类。

现在将上面的例子换成下面这样:

public class Test4 {
    public static void main(String[] args) {
        System.out.println(NotConst.RANDOM);
        while (true);
    }
}

class NotConst{
    public static final String RANDOM= UUID.randomUUID().toString();//地址是常量,但是引用不是
    static {
        System.out.println("A...");
    }
}

那么就会触发NotConst的初始化,虽然RANDOM被final修饰,但它的引用是个变化的。

clinit方法的字节码如下:

 0 invokestatic #2 <java/util/UUID.randomUUID>
 3 invokevirtual #3 <java/util/UUID.toString>
 6 putstatic #4 <com/cxylk/partone/NotConst.RANDOM>
 9 getstatic #5 <java/lang/System.out>
12 ldc #6 <A...>
14 invokevirtual #7 <java/io/PrintStream.println>
17 return

注意:0和9这条指令不是当前类的初始化指令,6这条指令才是,调用putstatic给当前静态变量赋值。可以看到,此时RANDOM虽然被final修饰,但是它还是会出现在clinit方法中

静态字段的存储:

以前面第一个被动引用的代码为例,查看HSDB结果:

value是Super类的静态属性,不会存储在子类Sub的镜像类中

常量池缓存

所以,当通过子类去访问父类的静态字段有2种方式:

1、先去父类的镜像类中去取,如果有就直接返回,没有会沿着继承链将请求网上抛。算法的性能会随着继承链的变长而上升,时间复杂度为O(N)

2、借助另外的数据结构,使用K-V的格式存储,查询时间为O(1)

Hotspot就是使⽤的第⼆种⽅式,借助另外的数据结构ConstantPoolCache,常量池类ConstantPool中 有个属性_cache指向了这个结构。

 ConstantPoolCache*   _cache;       // the cache holding interpreter runtime information

每⼀条数据对应⼀个类ConstantPoolCacheEntry。 ConstantPoolCacheEntry在哪呢?在ConstantPoolCache对象后⾯,看代码 \openjdk\hotspot\src\share\vm\oops\cpCache.hpp

ConstantPoolCacheEntry* base() const           { return (ConstantPoolCacheEntry*)((address)this + in_bytes(base_offset())); }

这个公式的意思是ConstantPoolCache对象的地址加上ConstantPoolCache对象的内存⼤⼩

ConstantPoolCache

常量池缓存是为常量池预留的运⾏时数据结构。保存所有字段访问和调⽤字节码的解释器运⾏时信息。在类被主动(即初始化)使⽤之前创建和初始化的。每个缓存项在解析时被填充

以上面被动引用第一个例子为例,我们看看子类和父类中的ConstantPool

左边的是子类的instanceKlass中的常量池信息,缓存显示的是null。右边是父类的instanceKlass中的常量池信息,_cache项指向的缓存是有值的。

如何读取

\openjdk\hotspot\src\share\vm\interpreter\bytecodeInterpreter.cpp

      CASE(_getstatic):
        {
          u2 index;
          ConstantPoolCacheEntry* cache;
          index = Bytes::get_native_u2(pc+1);

          // QQQ Need to make this as inlined as possible. Probably need to
          // split all the bytecode cases out so c++ compiler has a chance
          // for constant prop to fold everything possible away.

          cache = cp->entry_at(index);
          //判断是否解析过
          if (!cache->is_resolved((Bytecodes::Code)opcode)) {
            CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
                    handle_exception);
            cache = cp->entry_at(index);
          }
....

可以看到,是直接去获取ConstantPoolCacheEntry

虚拟机退出

java虚拟机的退出条件是,某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且java安全管理器也允许这次exit或halt操作。