网上看到一道题,竟然做错了,于是深挖了一下类的初始化过程,并把这道题做了点改动,感兴趣的童鞋可以先尝试做下这道题,如果做对了,说明你是真的牛,不需要看后面的内容了:
class SingleTon {
private static SingleTon singleTon = new SingleTon();
public static int count1;
public static int count2 = 0;
public SingleTon() {
count3=1;
count1++;
count2++;
}
{
count1 +=1;
}
public int count3 = count1+count2;
{
count3 +=3;
}
public static SingleTon getInstance() {
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon singleTon = SingleTon.getInstance();
System.out.println("count1=" + singleTon.count1);
System.out.println("count2=" + singleTon.count2);
System.out.println("count3=" + singleTon.count3);
}
}
正确答案是:
count1=2
count2=0
count3=1
如果你的答案不是这个,那就开始学习后面内容吧:
<clinit>与<init>
开始之前先普及一下两个方法<clinit>与<init>方法,如果你用过btrace追踪类或对象的创建,那就一定不会对它们陌生。
在我们用javac编译生成class文件时,编译器会生成这两个方法添加到class文件中:
- <clinit> 类构造器,类的初始化方法,类初始化过程只执行一次,所以这个方法只会被调用一次,初始化的类会被封装为klass对象存放在永久带(jdk1.8之前版本)或元数据区(jdk1.8、jdk1.9);
- <init> 实例构造器,实例的初始化方法,类被实例化的时候调用,所以可以被多次调用;
<clinit>
在类加载的第五个阶段“类初始化"阶段执行的就是<clinit>方法,类只会初始化一次,所以<clinit>只会,也只能执行一次,这个特殊初始化方法是不能被Java代码调用的,它只能作为类加载过程的一部分由JVM直接调用。
<clinit>具体做哪些事情呢?
jvm在生成<clinit>方法时,会收集类里面的“静态语句块”和“静态变量初始化操作”放到这里来执行,收集顺序按照代码书写顺序,先收集父类的,再收集子类的:
- 父类静态变量初始化和父类的静态语句块;
- 子类的静态变量初始化和子类的静态语句块;
什么时候会执行类的初始化?
初始化阶段,jvm虚拟机规范严格规定了以下几种情况,如果类未初始化会对类进行初始化操作:
- 创建类的实例; (先调用<clinit>,再调用<init>)
- 访问类的静态变量;
- 访问类的静态方法;
- Class.forName("com.zqz.Test") ; (initialize为true时,默认就是true)
- 当初始化一个类时,发现其父类还未初始化,则先对父类初始化;
- 虚拟机启动时,定义了main()方法的类先初始化;
以上情况称为称对一个类进行“主动引用”,除此以上情况之外,均不会触发类的初始化;
接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。
不会触发类初始化的操作,称为“被动引用”,被动引用有以下几种情况:
- 子类调用父类的静态变量,子类不会被初始化。只有父类被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化;
- 通过数组定义来引用类,不会触发类的初始化;
- 访问类的常量,不会初始化类; (常量在类加载第三阶段准备阶段就被初始化了,会存入调用类的常量池中,本质上并没有直接引用定义常量的类)
- 通过类名获取Class对象,不会触发类的初始化; (在类加载第一阶段已经生成Class对象在内存里面了)
- 通过Class.forName加载指定类时,指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化;
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作;
<clinit>与NoClassDefFoundError
<clinit>与这个异常有什么关系呢?
class被加载到jvm里后,都会被分装成instanceKlass对象,每个instanceKlass都有以下五种状态用来标识类的加载进度:
// See "The Java Virtual Machine Specification" section 2.16.2-5 for a detailed description
// of the class loading & initialization procedure, and the use of the states.
enum ClassState {
allocated, // allocated (but not yet linked)
loaded, // loaded and inserted in class hierarchy (but not linked yet)
linked, // successfully linked/verified (but not initialized yet)
being_initialized, // currently running class initializer
fully_initialized, // initialized (successfull final state)
initialization_error // error happened during initialization
};
以上代码来自hotspot\src\share\vm\oops\instanceKlass.hpp文件;
值为3~5的最后三个状态和<clinit>执行有关,当开始执行<clinit>方法的时候,调用InstanceKlass的_init_state将类加载状态标记为being_initialized,如果执行<clinit>成功,类加载状态就被标为fully_initialized,如果执行<clinit>失败,类的加载状态就被标记为initialization_error,代表类加载失败,同时可能会抛出异常java.lang.ExceptionIninitializerError ,<clinit>只能被执行一次, 以后再尝试载类时,检查到类的状态为initialization_error, 就会直接抛出异常java.lang.NoClassDefFoundError: Could not initialize class ,如果再看到这个异常,是执行哪些代码出了问题?看过了上面的内容你应该就明白了吧。
所以一旦一个类加载失败了,那么要想加载这个类唯一的方式就是重启应用;
<init>
<init>在对类进行实例化创建的时候调用,在对类进行实例化之前,类肯定已经先被初始化了;所以<init>一定是在<clinit>之后执行的;
<init>具体做哪些事情呢?
jvm在生成<init>方法时,会收集类里面的普通语句块,普通变量初始化、构造函数放到这个方法里面来执行,收集顺序按照代码书写顺序,先收集父类的,再收集子类的:
- 父类变量初始化和父类语句块 ;
- 父类构造函数 ;
- 子类变量初始化和子类语句块 ;
- 子类构造函数 ;
什么时候会执行类的初始化?
- new操作符实例化对象;
- 调用Class或java.lang.reflect.Constructor对象的newInstance()方法;
- 调用已经实例化对象的clone()方法;
- 通过java.io.ObjectInputStream类的getObject()方法反序列化;
知道了上面两个方法做什么,什么时候会调用了,下面我们看下类的加载过程,把他们串联起来:
一、加载阶段
- 通过一个类的全限定名来获取定义此类的的二进制字节流;
- 将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构;
- 在内存中生成一个java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
需要注意第三条,这解释了为什么,通过类名获取Class对象,不会触发类的初始化,因为Class对象是在这个阶段生成的;
加载有两种方式,一种是启动的时候会将rt.jar中的类都加载进来,一种是用到的时候才会加载;
二、验证阶段
确保Class文件中的字节流中的包含信息符合jvm的要求,并且不会虚拟机自身的安全,这阶段和我们这道题没有关系,不详说了;
三、准备阶段
这个阶段就和我们的题目有关系了。
这个阶段会在方法区或元数据区:
- 给静态的类变量分配内存,并设置默认值(注意是默认值不是初始值);
- 给常量分配内存并设置值(注意不是默认值,也不是初始值,常量只有一个值,设置后不能改);
这里解释下什么是默认值什么是初始值,如果静态变量是一个标量,也就是基础类型,比如int、long默认值就是0,boolean默认值就是false,如果是引用类型默认值就是null,举个栗子,类里面有以下三个静态变量和一个常量:
static int num = 1;
static boolean bl = true;
staitc String str = new String("123");
final static int NUM2 = 1;
执行完这个阶段后, 静态常量num = 0 , bl= flase ,str = null,静态变量 NUM2=1;
各种类型的初始值如下:
int =0
long = 0L
float=0f
double=0d
short =(short)0
boolean=false
chart = '\u000'
byte=(byte)0
reference=null
四、解析:将符号引用转成直接引用
这阶段和我们这道题没有关系,简单说一下我的理解,在编译阶段引用是没有分配内存,只有载入jvm运行时才会分配内存,我们不知道应该将地址指向哪里,所以就用符号来代替,在解析阶段,需要载入内存的都载入内存了,地址都确定了,就可以将符号引用替换为真正的引用了;
符号引用是对类、变量、方法的描述,包括三类常量:
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
五 、类初始化
这个阶段就是我们上面讲到的<clinit>方法了,调用<clinit>来执行类的初始化;
六、使用
这个阶段就是调用<init>方法实例化出对象来使用了;
七、卸载
当类使用完了,再也用不到的时候就可以将类卸载了,类的卸载非常严格:
- 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例;
- 加载该类的ClassLoader已经被GC;
- 该类的java.lang.Class 对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法;
java 有三个内置的类加载器:
1. BootstrapClassLoader ,默认加载 JAVA_HOME/jre/lib 目录下的jar包,-Xbootclasspath: 来指定加载的jar包路径;
2. ExtensionClassLoader,默认加载 JAVA_HOME/jre/lib/ext目录下的jar包,可以在运行时使用-Djava.ext.dirs指定加载类的路径,也可以将你想要加载的类丢到JAVA_HOME/jre/lib/ext这个目录下,让ExtensionClassLoader帮你去加载;
3. ApplicationClassLoader, 加载我们自己应用classpath指定的类和jar包;
类加载器内部,有一个classes变量,来存储当前classloader加载的类:
private final Vector<Class<?>> classes = new Vector<>();
所以Classloader会对其加载的class有一个强引用,class要能够被卸载,必须要Classloader能够被卸载,但是这三个内置的类加载器是永远都不会被卸载掉的,能够被卸载掉的只有自定义的类加载器,所以只有被自定义加载器加载的类才有可能被卸载;
答题
知道了以上知识,这道题就好分析了:
1. 在类载入的第三阶段准备阶段,给静态变量sigleTon、count1、count2分配内存,并设置默认值:
private static SingleTon singleTon = null
public static int count1 =0;
public static int count2 =0;
2. 在Test类的main方法中执行SingleTon.getInstance()方法,因为是调用类的静态方法,触发类的初始化,开始执行类载入的第五阶段类的初始化<clinit>方法初始化类,clinit方法会给静态变量赋值;
2.1 按照书写顺序,先给singleTon 变量赋值,执行:
private static SingleTon singleTon = new SingleTon()
因为调用了 new SigleTon() 实例化类,触发了SigleTon的<init>方法,<init>方法会按顺序执行以下代码:
{
count1 +=1;//count1=0+1=1
}
public int count3 = count1+count2;//count3=1+0=1
{
count3 +=3;//count3=1+3=4
}
private SingleTon() {
count3=1;//count3=1
count1++;//count1=2
count2++;//count2=1
}
执行过后 count1 = 2,count2=1,count3=1;
2.2 按照书写顺序执行public static int count1; 这一步没有赋值操作,所以直接跳过;
2.3 按照书写顺序执行public static int count2 = 0; 执行过后count2=0
所以count1=2 ,count2=0,coun3 = 1;
最后执行getInstance()方法返回;
最后需要说明的是:
一个类被加载的时候,它的内部类在没有被用到的时候不会被Classloader加载进来,例如单例模式中一种写法;
public class Singleton {
private Singleton() {
System.out.println("被调用");
}
private static class SingletonInstance {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonInstance.INSTANCE;
}
public static void main(String[] args)throws Exception{
Thread.sleep(10000);
}
}
在jvm启动参数中添加-verbose:class ,当执行这个类的时候,会发现,控制台打印了Singleton类,但是内部类SingetonInstance没有被加载,当然"被调用"也是不会被打印出来的;