类加载

在 Java 代码中,类型(class, interface, enum 等 )、连接与初始化过程都是在程序运行期间完成的。

static int a = 1

  • 加载:查找并加载类的二进制数据,最常用的一种,将在磁盘上编写的类型加载到内存中
  • 连接:
    • 验证:确保被加载类的正确性
    • 准备:为类的静态变量分配内存,并初始化为默认值,这一步的时候 a 是 int 类型的,默认值 为 0 , a = 0
    • 解析:将类与类之间的关系确立(列如将一个类型的符号引用转换为直接引用
  • 初始化:对类中的静态变量赋予正确的初始值,这一步正确的初始值 1 赋值给 a, a = 1

类加载器 class loader :将每个 Java 类型加载到内存中

在以下情况下,Java 虚拟机结束生命周期

  • 执行了 System.exit() 方法
  • 程序正常执行结束
  • 程序正在执行过程中遇到了异常或者错误而异常终止
  • 由于操作系统出现错误而导致 Java 虚拟机进程终止

初始化

所有的 Java 虚拟机实现必须在每个类或者接口被 Java 程序 首次主动使用 时才初始化

主动使用:

  • 创建类的实例
  • 访问某个类或者接口的静态变量,或者是对这个静态变量赋值
  • 调用类的静态方法
  • 反射 (Class.forName("java.lang.String"))
  • 初始化一个类的子类
  • 虚拟机启动时被标记为启动类的类 Main方法

除了上述的方法以外,其他使用类的方法都视为对类的 被动使用 不会对类进行 初始化

下面看一个例子代码演示:

定义一个父类,含有一个静态代码块,一个静态字段

1
2
3
4
5
6
7
class Parent{
public static String str = "hello";

static{
System.out.println("Parent static block");
}
}

再定义一个子类,同样含有一个静态代码快,一个静态字段

1
2
3
4
5
6
7
class Child{
public static String str2 = "world";

static{
System.out.println("Child static block");
}
}

对于下面的 main 方法执行结果是什么呢?

1
2
3
public static void main(String[] args){
System.out.println(Child.str);
}

执行的结果是:

1
2
Parent static block
hello

那么,再看看对于下面的 main 方法执行结果又是什么呢?

1
2
3
public static void main(String[] args){
System.out,println(Child.str2);
}

执行结果是:

1
2
3
Parent static block
Child static block
world

原来,对于静态字段来说,只有直接定义了该字段的类才会使用,所以对于第一次执行结果,只有父类的静态代码块得到了执行。

对于第二次执行结果,在初始化一个子类的时候,对于他的所有父类也视作主动使用,也会进行初始化。所以父类和子类的静态代码块都会得到执行。

但是修改一下 Parent 类的代码,将 str 设置为一个常量:

1
2
3
4
5
6
7
class Parent{
public static final String str = "hello";

static {
System.out.println("Parent static block");
}
}

这个时候执行下列 main 方法执行结果是什么呢?

1
2
3
4
5
public class test{
public static void main (String[] args){
System.out.println(Parent.str);
}
}

结果是只会打印 hello,并不会打印出 Parent 类的 static 块。

主要原因是:常量在编译阶段会存入到调用这个常量的方法所在的类的常量池中,本质上,调用类并没有直接引用到定义常量的类,因此定义常量的类并不会进行初始化

那么对于所有的常量都会被放到调用常量的类的常量池中吗?

修改 Parent 类的代码,添加一个 String 类型的常量,只不过这个常量在编译期间是不可确定的。

1
2
3
4
5
6
7
8
9
class Parent {
public static final String str = "hello";

public static final String ID = UUID.randomUUID().toString();

static {
System.out.println("parent static block");
}
}

再在 main 方法中执行,结果是输出了随机 32 位的 ID 的同时也输出了 static 代码块的内容。

所以当一个常量的值并非编译期间可以确定的时候,这个常量并不会放到调用类的常量池中,当程序运行时,会导致主动使用这个常量所在的类,所以常量所在的类会进行初始化

对于一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Test {
public static void main(String[] args) {
Singleton singleton = Singleton.getSingleton();

System.out.println("num1 - " + singleton.num1);
System.out.println("num2 - " + singleton.num2);
}
}
class Singleton{
public static int num1;
public static Singleton singleton = new Singleton();

private Singleton(){
num1++;
num2++;
System.out.println(num1);
System.out.println(num2);
}

public static int num2 = 0;

public static Singleton getSingleton(){
return singleton;
}
}

执行结果是:

1
2
3
4
1
1
num1 - 1
num2 - 0

为什么会产生这样的结果呢?

是因为在 main 方法中是通过 getSingleton() 这个静态方法来获得 Singleton 的实例对象的。而首次调用一个类的静态方法会经过连接阶段的准备阶段,这个时候 num1 和 num2 是 int 类型的,所以都被初始化为 0。然后是通过 new 关键字获得一个实例对象,也就是初始化阶段。由类中定义的语句从上向下初始化,在构造方法中,num1 和 num2 都加一被改写为 1。而初始化阶段会把静态变量赋予正确的初始值,这个例子中 num2 正确的初始值是 0。所以最后结果 num1为 1,num2 为 0。但是构造方法内 num1 和 num2 都为 1。


类的加载

类的加载是指将类的 .class 文件中的二进制数据读入到内存中,将其放在运行是数据区 方法区 内,然后在内存中创建一个 Java.lang.class 对象,用来封装类在方法区内的数据结构

加载 .class 文件的方式:

  • 从本地系统中直接加载
  • 通过网络下载 .class 文件
  • 从 zip, jar 等归档文件中加载 .class 文件
  • 从专有数据库中提取 .class 文件
  • 将 Java 源文件动态编译为 .class 文件

虚拟机参数

  • -XX:+<option> ,表示开启 option 选项
  • -XX:-<option>, 表示关闭 option 选项
  • -XX:<option> = <value> , 表示将 option 选项的值设置为 value

通过在 jvm 虚拟机配置中添加参数:

  • -XX:+TraceClassLoading 可以打印出程序加载类的顺序

助记符

  • ldc 表示将 int,float,String 类型的常量值从常量池中推送至栈顶
  • bipush 表示将单字节 (-128 ~ 127) 的常量值推送至栈顶
  • sipush 表示将一个短整型常量值 (-32768 ~ 32767) 推送至栈顶
  • iconst_1 表示将 int 类型的 1 推送至栈顶,同样的还有 iconst_m1 ~ iconst_5
  • anewarray 表示创建一个引用类型(如类,接口 等)的数组,并将其引用值压入栈顶
  • newarray 表示创建一个指定的原始类型(如 int、float、char 等)的数组,并将其引用值压入栈顶

类的生命周期

  1. 加载:把二进制形式的 Java 类型读入 Java 虚拟机
  2. 验证:验证二进制形式的 Java 文件是否有错误
  3. 准备:为类变量分配内存,设置默认值。在初始化完成以前,类变量都没有初始化为正确的初始值
  4. 解析:在类常量池中寻找类,接口,字符和方法的符号引用,把这些符号引用替换为直接引用
  5. 初始化:为类变量赋予正确的初始值
  6. 类实例化:
    • 为新的对象分配内存
    • 为实例变量赋予默认值
    • 为实例变量赋予正确的初始值
    • Java 编译器为每个编译的类至少生成一个实例初始化方法,对应类的构造方法。在 class 文件对于每个构造方法,编译器都生成一个 <init>方法
  7. 垃圾回收和对象终结

类加载器

Java 虚拟机提供了以下 3 种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录的,或者是通过 -Xborclasspath参数指定路径中的,被虚拟机认可的(以文件名识别,如 jar 结尾的)类。
  • 扩展类加载器(Extension ClassLoader): 负责加载 JAVA_HOME\lib\ext 目录中,或者是通过 Java.ext.dirs 系统变量指定路径中类库。
  • 应用程序类加载器(Application Class Loader): 负责加载用户路径 ClassPath 上的类库

Java 虚拟机通过双亲委托模型来进行类的加载,当然也可以自定义类继承 java.lang.ClassLoader 实现自定义类加载器。

双亲委托:一个类加载器收到了类加载的请求,它首先自身不会尝试去加载这个类,而是把这个请求委托给父类去完成。只有当父类加载器无法完成这个加载请求的时候,它自身才会尝试加载。这样做的好处是,对于一个类保证了使用不同的类加载器都尽量获得同一个 Object 对象。