【1】类的生命周期
一个类从加载进内存到卸载出内存为止,一共经历7个阶段:
加载—>验证—>准备—>解析—>初始化—>使用—>卸载
其中,类加载包括5个阶段:
加载—>验证—>准备—>解析—>初始化
在类加载的过程中,以下3个过程称为连接:
验证—>准备—>解析
因此,JVM的类加载过程也可以概括为3个过程:
加载—>连接—>初始化
C/C++在运行前需要完成预处理、编译、汇编、连接。而在Java中,类加载(加载、连接、初始化)是在程序运行期间完成的。
在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处-----提高程序的灵活性。
Java语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载和动态连接。
【2】类加载的时机
① 类加载过程中每个步骤的顺序
我们已经知道,类加载的过程包括:加载、连接、初始化。连接又分为:验证、准备、解析。所以说类加载的过程一共分为5步:加载、验证、准备、解析、初始化。
其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。
而解析过程会发生在初始化过程中。
② 类加载过程中“初始化”开始的时机
JVM规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始(解析除外)。这些过程具体在何时开始,JVM规范并没有定义,不同的虚拟机可以根据具体的需求自定义。
初始化开始的时机
在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic等。这四个指定对应的Java代码场景是:
通过new创建对象;
读取、设置一个类的静态成员变量(不包括final修饰的静态变量);
调用一个类的静态成员函数,即静态方法;
使用java.lang.reflect进行反射调用的时候,如果类没有初始化,那就需要初始化。
当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类。
当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类。
③ 主动引用与被动引用
JVM规范中要求在程序运行过程中,“当且仅当”出现上述4个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。
其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用。
那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。
④ 被动引用的场景示例示例一:
public class Fu{ public static String name="柴毛毛"; static{ System.out.println("父类被初始化!"); } } public class Zi extends Fu{ static{ System.out.println("子类被初始化!"); } } public TestClass{ public void static main(String[] args){ System.out.println(Zi.name); } }
测试结果:
父类被初始化! 柴毛毛
原因分析:
本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。
但由于这个静态成员变量属于Fu类,Zi类只是间接调用Fu类中的静态成员变量,因此Zi类调用name属性属于间接引用。而Fu类调用name属性属于直接引用,由于JVM只初始化直接引用的类,因此只有Fu类被初始化。
这里需要注意的是,main方法在其他类中,如果在Zi类中执行main方法,JVM同样会初始化Zi类:
public class Zi extends Fu { static{ System.out.println("子类被初始化!"); } public static void main(String[] args){ System.out.println(Zi.name); } }
此时输出结果:
父类被初始化! 子类被初始化! 柴毛毛
示例二:
public class A { public static void main(String[] args){ Fu[] arr = new Fu[10]; } }
输出结果:为空,并没有输出“父类被初始化!”。
原因分析:
这个过程看似满足初始化时机的第一条:遇到new创建对象时若类没被初始化,则初始化该类。
但现在通过new要创建的是一个数组对象,而非Fu类对象,因此也属于间接引用,不会初始化Fu类。
示例三:
public class Fu { public static final String name="柴毛毛"; static{ System.out.println("父类被初始化!"); } } public class A { public static void main(String[] args){ System.out.println(Fu.name); } }
输出结果:
柴毛毛
原因分析:
本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。
但是Fu类的静态成员变量被final修饰,它已经是一个常量。
被final修饰的常量在Java代码编译(java源文件编译成class字节码)的过程中就会被放入引用它的class文件的常量池中(这里是A的常量池)。所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。
有说法称之为“常量传播优化”。
示例四:访问以下final修饰的static常量会触发类的初始化。
public class LFim { /* * 在复杂类型中只有字符串不会触发初始化过程!! */ public static final String STRING="LangShen"; //不会触发初始化过程!!,没有经历自动装箱, //字符串是编译期直接保存在常量池中的!!! //获取值时处理方法和它们不一样,它是一个常量表, //一个字符序列对应一个对象来获取!!! /* * 以下都会触发初始化过程!! * * 这些初始化指的是类的初始化!!! */ public static final ClassA CLASSA=null; //自定义类,会触发初始化过程!,当然你就更别说new 一个对象了,new 一个也会触发初始化过程!! public static final Integer INTEGER=45; //会触发初始化过程!!,因为经历了自动装箱 public static final Character CHARACTER='X'; //会触发初始化过程!!因为经历了自动装箱 public static final String STRING2=new String("456"); //会触发初始化过程!!,是new 出来的!!! static{ System.out.println("初始化静态代码块!!"); } }
⑤ 接口的初始化
接口和类都需要初始化,接口和类的初始化过程基本一样。
不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到父接口中的东西时,才初始化父接口。
【3】类加载的过程1—加载
通过之前的介绍可知,类加载过程共有5个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。下面详细介绍这5个过程JVM所做的工作。
“加载”是类加载过程的第一步。
① 加载的过程
在加载过程中,JVM主要做3件事:
通过一个类的全限定名来获取这个类的二进制字节流,即class文件:
在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。
将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;
在内存中创建一个java.lang.Class类型的对象:
接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个class类型的类对象是提供给外界访问该类的接口。
② 从哪里加载?
JVM规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取:
从压缩包中读取
如:jar、War、Ear等
从其他文件中动态生成
如:从JSP文件中生成Class类
从数据库中读取
将二进制字节流存储进数据库中,然后在加载的时候从数据库中读取。有些中间件会这么做,用来实现代码在集群间分发。
从网络中获取
从网络中获取二进制字节流,典型的就是Applet。
③ 类和数组加载过程的区别数组也有类型,称为“数组类型”。如:
String[] str = new String[10];
这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。
当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类。再由类加载器创建数组中的元素类。
而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。
④ 加载过程的注意点
JVM规范并未给出类在方法区中存放的数据结构
类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM规范并没有指定。
JVM规范并没有指定Class对象存放的位置
在二进制字节流以特定格式存储在方法区以后,JVM会创建一个java.lang.Class类型的对象,作为本类的外部接口。既然是对象就应该存放在堆内存中,不过JVM规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot将Class对象存放在方法区。
加载阶段和连接阶段是交叉的
通过之前的介绍可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照以下顺序开始:加载、验证、准备、解析、初始化,但结束顺序无所谓。因此由于每个步骤处理时间的长短不一,就会导致有些步骤会出现交叉。
【4】类加载的过程2—验证
验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none
参数关闭,以缩短类加载时间。
① 验证的目的
验证是为了确保二进制字节流中信息符合虚拟机规范,并没有安全问题。
② 为什么需要验证
虽然Java语言是一门安全语言,它能确保程序员无法访问数组边界以外的内存、避免让一个对象转换成任意类型,避免跳转到不存在的代码行;如果出现这些情况,编译无法通过。也就是说,Java语言的安全性是通过编译器来保证的。
但是我们知道,编译器(如javac)和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的。当然,如果是编译器给它的,那就相对安全。但如果是从其他途径获得的,那么无法确保该二进制字节流是安全的。
通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证。
③ 验证的过程
文件格式验证
这个阶段主要验证输入的二进制字节流是否符合class文件的结构规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。
本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。
通过上文可知,加载开始前,二进制字节流还没有进入方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。
也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储在方法区中,继而开始下阶段的验证和创建Class对象等操作。这个过程印证了:加载和验证是交叉进行的。
元数据验证
本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。
字节码验证
本阶段是验证过程的最复杂的一个阶段。本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。
符号引用验证
本阶段验证发生在解析阶段,确保解析能正常执行。
【5】类加载的过程3—准备和解析
① 准备
准备阶段完成两件事情:
- 为已经在方法区中的类中的静态成员变量分配内存
类的静态成员变量也存储在方法区中(如果是常量,则编译时存放进引用其的class的常量池中,加载时存入方法区的运行时常量池)。 - 为静态成员变量设置初始值
初始值为0、false、null等。
示例一:
public static String name="柴毛毛";
在准备阶段,JVM会在方法区中为name分配内存空间,并赋上初始值null。给name赋上“柴毛毛”是在初始化阶段完成的。
示例二:
public static final String name="柴毛毛";
final 用于声明属性、方法和类,分别表示属性一旦被分配内存空间就必须初始化并且以后不可变;方法一旦定义必须有实现代码并且子类里不可被覆盖;类一旦定义不能被定义为抽象类或是接口,因为不可被继承。
被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantValue属性(class文件的常量池)中,在准备阶段就将constantValue的值赋给该字段。如果没有初始值而又没有被static修饰的变量,如private final String seasonName;将在类构造函数中初始化。
当这个属性被修饰为final,而非static的时候,它属于类的实例对象的资源,当类被加载进内存的时候这个属性并没有给其分配内存空间,而只是定义了一个变量a,只有当类被实例化的时候这个属性才被分配内存空间,而实例化的时候同时执行了构造函数,所以属性被初始化了,也就符合了当它被分配内存空间的时候就需要初始化,以后不再改变的条件。
如果是同时被static和final修饰的变量,则只有两个地方初始化:
定义的时候赋值;
静态代码块里面赋值
② 解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。如果不执行,符号解析要等到字节码指令使用这个引用时才会进行。
【6】类加载的过程4—初始化
初始化阶段就是执行类构造器clinit()的过程(不是init())。把类中的变量初始化成合适的值。执行静态初始化程序,把静态变量初始化成指定的值
clinit()方法由编译器自动产生,收集类中static{}代码块中类变量赋值语句和类中静态成员变量的赋值语句。此时将会执行静态代码块和静态方法。
在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。
初始化过程的注意点
- clinit()方法中静态成员变量的赋值顺序是根据Java代码中静态成员变量的出现的顺序决定的。
- 静态代码块能访问出现在静态代码块之前的静态成员变量,无法访问出现在静态代码块之后的成员变量。
- 静态代码块能给出现在静态代码块之后的静态成员变量赋值。
因为在准备阶段已经给静态成员变量进行了默认初始化。
public class Test{ static{ i=0;//给变量赋值可以正常编译通过 System.out.print(i);//这句编译器会提示"非法向前引用" } static int i=1; }
构造函数init()需要显示调用父类构造函数,而类的构造函数clinit()不需要调用父类的类构造函数,因为虚拟机会确保子类的clinit()方法执行前已经执行了父类的clinit()方法。
因此在虚拟机中第一个被执行的clinit()方法的类肯定是java.lang.Object。由于父类的clinit()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会生成clinit()方法。
接口也需要通过clinit()方法为接口中定义的静态成员变量显示初始化。
接口中不能使用静态代码块。
接口在执行clinit()方法前,虚拟机不会确保其父接口的clinit()方法被执行,只有当父接口中的静态成员变量被使用到时才会执行父接口的clinit()方法。
虚拟机会给clinit()方法加锁,因此当多条线程同时执行某一个类的clinit()方法时,只有一个方法会被执行,其他的方法都被阻塞。并且,只要有一个clinit()方法执行完,其它的clinit()方法就不会再被执行。因此,在同一个类加载器下,同一个类只会被初始化一次。
【7】类加载器
① 类与类加载器
- 类加载器的作用
- 将class文件加载进JVM的方法区,并在方法区中创建一个java.lang.Class对象作为外界访问这个类的接口。
- java.lang.Class对象存放在哪里,JVM规范并未严格研究,具体看厂商实现。HotSpot将java.lang.Class对象存放在方法区。
类与加载器的联系
比较两个类是否相等,只有当这两个类由同一个加载器加载才有意义。否则,即使同一个class文件被不同的类加载器加载,那这两个类必定不同,即通过类的Class对象的equals执行的结果必定为false。
② 类加载器种类
JVM 提供如下三种类加载器:
启动类加载器(BootStrap ClassLoader,又称根加载器)
每次执行 java 命令时都会使用该加载器为虚拟机加载核心类。该加载器是由 native code 实现,而不是 Java 代码,加载类的路径为 <JAVA_HOME>/jre/lib。特别的 <JAVA_HOME>/jre/lib/rt.jar 中包含了 sun.misc.Launcher 类, 而 sun.misc.Launcher$ExtClassLoader 和 sun.misc.Launcher$AppClassLoader 都是 sun.misc.Launcher的内部类,所以拓展类加载器和系统类加载器都是由启动类加载器加载的。
扩展类加载器(Extension ClassLoader)
用于加载拓展库中的类。拓展库路径为<JAVA_HOME>/jre/lib/ext/。实现类为 sun.misc.Launcher$ExtClassLoader。
应用程序类加载器(System ClassLoader)
负责加载用户classpath下的class文件,又叫系统加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。实现类为 sun.misc.Launcher$AppClassLoader
此外还有用户自定义类加载器,继承自System ClassLoader。
类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载—双亲委派模型。
③ 类加载图示
连接过程包括验证、准备和解析。
④ 双亲委派模型
工作过程
如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。
作用
像java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的Object类都是同一个。
原理
双亲委派模型的代码在java.lang.ClassLoader类中的loadClass函数中实现,其逻辑如下:
首先检查类是否被加载;
若未加载,则调用父类加载器的loadClass方法;
若该方法抛出ClassNotFoundException异常,表示父类加载器无法加载,则当前类加载器调用findClass加载类;
若父类加载器可以加载,则直接返回Class对象。
源码示例如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
【8】类/对象的静态成员变量与非静态成员变量
① 静态成员变量
通过上面可知,类的静态成员变量在准备阶段进行内存分配并默认初始化,在初始化阶段进行显示初始化。
静态变量在以后的创建对象的时候不再初始化,但是对象可以再次对静态变量进行赋值,该赋值操作将会反映到方法区中类的数据结构中。即,方法区中存储静态变量的值也会随之发生改变。
同样,修改类中静态变量的值会反映到已经创建好的对象上面。
示例如下:
public class ClassA { private int a =10; private static int b=20; public int getA() { return a; } public void setA(int a) { this.a = a; } public static int getB() { return b; } public static void setB(int b) { ClassA.b = b; } }
测试类如下
public class TestClassA { public static void main(String[] args) throws InterruptedException { // 先实例化一个对象 ClassA classA = new ClassA(); // 另起一个县城 Thread thread = new Thread(new Runnable() { @Override public void run() { //获取实例化对象的静态变量值 System.out.println("thread:"+classA.getB()); //重新设置实例化对象的静态变量值并获取 classA.setB(30); System.out.println("thread:"+classA.getB()); // 直接通过类设置类的静态变量值 ClassA.setB(50); // 这里,获取实例化对象的静态变量值 System.out.println("thread:"+classA.getB()); } }); thread.start(); //再来一个对象 ClassA classA2 = new ClassA(); Thread thread2 = new Thread(new Runnable() { @Override public void run() { // 首先打印对象的静态变量值 System.out.println("thread2:"+classA2.getB()); // 设置头一个对象的静态变量值 classA.setB(40); // 打印类中的静态变量值 System.out.println("thread2:"+ClassA.getB()); } }); thread2.start(); } }
输出结果:
thread:20 thread:30 thread:50 thread2:50 thread2:40
② 非静态成员变量
非静态成员变量只有在实例化对象的时候才会分配内存并赋值,非静态成员变量随对象一起保存在堆中。
每个实例化对象的非静态成员变量只属于自己,值修改也只 针对自己,并不会影响到其他对象或者反映到方法区中类的数据结构上面。
每次实例化对象的时候,非静态变量的值都为最初存放在方法区中的值,如这里private int a =10;。每次新建对象,对象的a都为10。
测试类如下:
public class TestClassA { public static void main(String[] args) throws InterruptedException { ClassA classA = new ClassA(); Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("thread:"+classA.getA()); classA.setA(30); System.out.println("thread:"+classA.getA()); } }); thread.start(); ClassA classA2 = new ClassA(); Thread thread2 = new Thread(new Runnable() { @Override public void run() { System.out.println("thread2:"+classA2.getA()); classA2.setA(40); System.out.println("thread2:"+classA2.getA()); } }); thread2.start(); } }
输出如下:
thread:10 thread:30 thread2:10 thread2:40
另外,需要注意的是类的非静态成员变量中,其中基本类型(非包装类型)变量的值,即字面量是保存在class文件的常量池中的,在加载的时候会被存放在方法区的运行时常量池。
public class A{ private int a=10; private int b=1000; private Integer c=20; private Integer d=2000; //... }
其中,10,1000都会被放在常量池中;而包装类型20和2000,其中20也放在常量池中,而且是一个Integer(20)对象。2000不在-128-127范围内,是一个新建对象!!!