jvm的类加载机制
jvm加载机制对于大多数人来说都是比较陌生的,但是当你作为一个拥有几年工作经验的开发来说,了解内部机制的是极其重要的,下面先看下代码!
public class SSClass
{
static
{
System.out.println("SSClass");
}
}
public class SuperClass extends SSClass
{
static
{
System.out.println("SuperClass init!");
}
public static int value = 123;
public SuperClass()
{
System.out.println("init SuperClass");
}
}
public class SubClass extends SuperClass
{
static
{
System.out.println("SubClass init");
}
static int a;
public SubClass()
{
System.out.println("init SubClass");
}
}
public class NotInitialization
{
public static void main(String[] args)
{
System.out.println(SubClass.value);
}
}
运行结果
SSClass
SuperClass init!
123
类加载过程
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连接(Linking)。如图所示。
image.png加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。以下陈述的内容都已HotSpot为基准。
一、 加载
在加载阶段(可以参考java.lang.ClassLoader的loadClass()方法),虚拟机需要完成以下3件事情:
image.png- 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口; - 加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
其实通俗来讲就是讲类的.class文件中二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个
java.lang.Class对象,用来封装类在方法区内的数据结构。
注:这一块原先说了在加载过程中会执行静态代码块,后来经沐小晨曦私信我,该地方与后面再初始化过程执行静态代码块说法自相矛盾,后来查了一番,确实如此,多谢提醒。可以确定的说:
静态代码块是在初始化的时候执行的
二、连接
类加载完成后就进入了类的连接阶段,连接阶段主要分为三个过程分别是:验证,准备和解析。在连接阶段,主要是将已经读到内存的类的二进制数据合并到虚拟机的运行时环境中去。
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个阶段的检验动作:
这个阶段主要目的是保证Class流的格式是正确的。主要验证的内容包括:
- 文件格式的验证
是否以0xCAFEBABE开头
版本号是否合理
- 元数据的验证
是否有父类
是否继承了final类
非抽象类实现了所有抽象方法
- 字节码验证
运行检查
栈数据类型和操作码数据参数吻合
跳转指令指定到合理的位置
- 符号引用验证
常量池中描述类是否存在
访问的方法或字段是否存在且有足够的权限
准备
这个阶段主要是为对象和变量分配内存,并为类设置初始值(方法区中)
对于static类型变量在这个阶段会为其赋值为默认值,比如
public static int v=5,
在这个阶段会为其赋值为v=0,
public static final int v=5,
而对于static final类型的变量,在准备阶段就会被赋值为正确的值
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
初始化
在这个阶段主要执行类的构造方法。并且为静态变量赋值为初始值,执行静态块代码。
所有的Java类只有在对类的首次主动使用时才会被初始化。主动使用的情况有六中,其他情况都属于被动使用:
1、 创建类的实例
2、访问某个类或接口的静态变量,或者对该静态变量赋值
3、调用类的静态方法
4、反射(Class.fotName)
5、初始化一个类的子类
6、Java虚拟机启动时被标明为启动类的类(面方法所在的类)
注意:
1、当Java虚拟机初始化一个类时,要求他的所有父类都已经被初始化,但是这条规则并不适合接口。在初始化一个类或接口时,并不会先初始化它所实现的接口。
2、只有当程序访问的静态变量或静态方法确实在当前类或当前接口中定义时,才可以认为是对类或接口的主动使用。如果静态方法或变量在parent中定义,从子类进行调用,则不会初始化子类。
public class Test
{
static
{
i=0;
System.out.println(i);//这句编译器会报错:Cannot reference a field before it is defined(非法向前应用)
}
static int i=1;
}
那么去掉报错的那句,改成下面:
public class Test
{
static
{
i=0;
// System.out.println(i);
}
static int i=1;
public static void main(String args[])
{
System.out.println(i);
}
}
输出结果是什么呢?当然是1啦~在准备阶段我们知道i=0,然后类初始化阶段按照顺序执行,首先执行static块中的i=0,接着执行static赋值操作i=1,最后在main方法中获取i的值为1。
<clinit>()
:虚拟机在装载一个类初始化的时候调用的
<init>()
:虚拟机类实例类化的时候调用的
<clinit>()方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到
new,getstatic,putstatic,invokestatic
这失调字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
案例分析
package jvm.classload;
public class StaticTest
{
public static void main(String[] args)
{
staticFunction();
}
static StaticTest st = new StaticTest();
static
{
System.out.println("1");
}
{
System.out.println("2");
}
StaticTest()
{
System.out.println("3");
System.out.println("a="+a+",b="+b);
}
public static void staticFunction(){
System.out.println("4");
}
int a=110;
static int b =112;
}
结果为什么呢???
如果按照下面的正常逻辑:
java中赋值顺序:
- 父类的静态变量赋值
- 自身的静态变量赋值
- 父类成员变量赋值和父类块赋值
- 父类构造函数赋值
- 自身成员变量赋值和自身块赋值
- 自身构造函数赋值
最终得出的结果是
1,4
但是这肯定是错的,正确的打印的结果是
2
3
a=110,b=0
1
4
是不是有点不可思议,那么听我慢慢分析!!
首先类的生命周期:
加载->验证->准备->解析->初始化->使用->卸载
在这些过程中,只有在准备阶段和初始化阶段会涉及到类和变量的赋值和初始化;
准备阶段
该阶段需要做的就是类变量的内存分配和设置默认值
static int b =112;
这里对b进行初始化,但是初始化值b为0,如果添加了 final的话,则b=112
初始化阶段
初始化阶段需要做的是执行类构造器,
所以首先执行的是:
类的初始化阶段需要做是执行类构造器(类构造器是编译器收集所有静态语句块和类变量的赋值语句按语句在源码中的顺序合并生成类构造器,对象的构造方法是<init>()
,类的构造方法是<clinit>()
,在代码中我们可以看到第一个静态变量的是:static StaticTest st = new StaticTest();
因此先执行第一条静态变量的赋值语句即st = new StaticTest (),
然后此时会进行对象的初始化:-->初始化成员变量-->执行构造方法因此设置a为110->打印2->执行构造方法(打印3,此时a已经赋值为110,但是b只是设置了默认值0,并未完成赋值动作),等对象的初始化完成后继续执行之前的类构造器的语句,接下来就不详细说了,按照语句在源码中的顺序执行即可。
这里面还牵涉到一个冷知识,就是在嵌套初始化时有一个特别的逻辑。特别是内嵌的这个变量恰好是个静态成员,而且是本类的实例。
这会导致一个有趣的现象:“实例初始化竟然出现在静态初始化之前”。
其实并没有提前,你要知道java记录初始化与否的时机。
将上诉案例代码简化一下
public class Test {
public static void main(String[] args) {
func();
}
static Test st = new Test();
static void func(){}
}
- 首先在执行此段代码时,首先由main方法的调用触发静态初始化。
- 在初始化Test 类的静态部分时,遇到st这个成员。
- 但凑巧这个变量引用的是本类的实例。
- 那么问题来了,此时静态初始化过程还没完成就要初始化实例部分了。是这样么?
- 从人的角度是的。但从java的角度,一旦开始初始化静态部分,无论是否完成,后续都不会再重新触发静态初始化流程了。
- 因此在实例化st变量时,实际上是把实例初始化嵌入到了静态初始化流程中,并且在楼主的问题中,嵌入到了静态初始化的起始位置。这就导致了实例初始化完全至于静态初始化之前。这也是导致a有值b没值的原因。
最后再考虑到文本顺序,结果就显而易见了。
网友评论