0%

一. 工厂模式概念

工厂模式是一种创建型模式,他的意义是专门创建一个类,我们在这个类中去完成我们需要创建的对象,这样既可以方便管理,又可以提高代码的可扩展性和灵活性。工厂模式包括简单工厂模式、工厂方法模式、抽象工厂模式,其中工厂方法模式就是我们常说的工厂模式。

我们在开发初期通常是直接通过new来创建一个对象的,这样的方式无疑是最简单的,但是这样创建对象有很多不足的地方,我们可以通过一个例子来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

class LoginAction {
private UserDAO udao;

public LoginAction() {
udao = new JDBCUserDAO(); //创建对象
}

public void execute() {
//其他代码
udao.findUserById(); //使用对象
//其他代码
}

我们在LoginAction类中创建了一哦个UserDao对象udao,并在execute()方法中调用了udao对象的findUserById()方法。这样LoginAction对udao既有创建职责又有使用职责,在同一个类中将两种职责耦合在了一起,当我们有修改需求的时候,比如我们要创建的是UserDAO的子类的时候,我们就需要修改LoginAction()中的代码,违背了开闭原则。

而工厂模式是通常解决这个问题好方法,我们可以创建一个UserDaoFactory由它来专门负责UserDao类及其子类对象的创建。当我们需要增加UserDao子类或者修改构造函数的时候,我们只需要维护UserDaoFactory而不影响其他相关类的使用。工厂模式一只强调的是当两个类A和B发生关联的时候,两者只会发生A使用B或者A创建B这两种关系,而不是同时发生。 并且工厂类帮我们封装了创建对象的细节,我们只要传递相应的参数,就可以帮助我们创建具体的对象。下面我们来介绍依次介绍3个工厂工行模式。

二. 简单工厂模式

简单工厂模式又叫做静态工厂模式,他并不属于23种经典模式之中,我们从一个例子中先体验简单工厂模式。

比如说我们需要生产pizza,一共有奶酪pizza和希腊pizza两种不同口味,我们创建一个工厂来负责生产这两种口味的pizza,代码如下

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
26
27
28
29
30
31
32
33

public abstract class Pizza {


private String name;
abstract public void prepare();
public void eat(){
System.out.println("开动啦!吃:" + name);
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public class CheesePizza extends Pizza {
@Override
public void prepare() {
System.out.println("准备一个奶酪披萨");
}
}

public class GreekPizza extends Pizza{
@Override
public void prepare() {
System.out.println("准备一个希腊披萨");
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

public class SimpleFactory {

static public Pizza createPizza(String name){
Pizza pizza =null;

if(name.equals("cheese")){
pizza = new CheesePizza();
pizza.setName("奶酪披萨");
pizza.prepare();
}else if (name.equals("greek")){
pizza = new GreekPizza();
pizza.setName("希腊披萨");
pizza.prepare();
}
return pizza;
}

}


1
2
3
4
5
6
7
8
9
10
11
public class OrderPizza {
public static void main(String[] args) {
Pizza pizza = SimpleFactory.createPizza("greek");
if(pizza!=null){
pizza.eat();
}else {
System.out.println("没有pizza");
}
}
}

当我们在订单中需要创建一个pizza的时候,我们会调用SimpleFactory中的方法createPizza来创建一个对象,SimpleFactory会根据参数来自主判断创建对象的类型。

这种方法使用起来比较简单,表面上解决了OrderPizza中耦合的问题,但是他只是将逻辑判断中的代码转移到了工厂中,这会使得工厂类不满足ocp原则,在需要修改代码的时候我们需要维护工厂类的逻辑判断。

二. 工厂方法模式

为了解决简单工厂模式的缺点,使得工厂类更加符合开闭原则。我们先使用一个抽象类去定义一个抽象的创建对象的方法,再由子类去继承这个抽象类,去专门创建具体的对象。

还是上一个例子,但是我们分别创建了奶酪pizza工厂和希腊pizza工厂去继承一个抽象的工厂类。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class Factory {

public abstract Pizza createPizza();

}

public class GreekPizzaFactory extends Factory{

@Override
public Pizza createPizza() {
GreekPizza greekPizza = new GreekPizza();
greekPizza.setName("希腊披萨");
return greekPizza;
}
}

public class CheesePizzaFactory extends Factory{

@Override
public Pizza createPizza() {
return new CheesePizza();
}
}

这样写的好处就是,我们在增加或者删除不同口味pizza的时候,我们只需要创建不同的工厂去生产不同的pizza就可以了,而不用去修改factory中的代码。但是工厂方法模式也有一定的缺陷,就是当我们的产品增多时我们需要创建大量的具体工厂去生产不同的对象,导致系统类的个数成对增加,在一定程度上增加了系统的复杂性。

二. 抽象工厂模式

在工厂方法模式中具体工厂负责生产具体的产品,每一个具体工厂对应一种具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或者一组重载的工厂方法。但是有时候我们需要一个工厂可以提供多个产品对象,而不是单一的产品对象。

为了更清晰地理解工厂方法模式,需要先引入两个概念:

  • 产品等级结构 :产品等级结构即产品的继承结构,如一个抽象类是电视机,其子类有海尔电视机、海信电视机、TCL电视机,则抽象电视机与具体品牌的电视机之间构成了一个产品等级结构,抽象电视机是父类,而具体品牌的电视机是其子类。
  • 产品族 :在抽象工厂模式中,产品族是指由同一个工厂生产的,位于不同产品等级结构中的一组产品,如海尔电器工厂生产的海尔电视机、海尔电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。

当系统所提供的工厂所需生产的具体产品并不是一个简单的对象,而是多个位于不同产品等级结构中属于不同类型的具体产品时需要使用抽象工厂模式。

抽象工厂模式是所有形式的工厂模式中最为抽象和最具一般性的一种形态。

抽象工厂模式与工厂方法模式最大的区别在于,工厂方法模式针对的是一个产品等级结构,而抽象工厂模式则需要面对多个产品等级结构,一个工厂等级结构可以负责多个不同产品等级结构中的产品对象的创建 。当一个工厂等级结构可以创建出分属于不同产品等级结构的一个产品族中的所有对象时,抽象工厂模式比工厂方法模式更为简单、有效率。
这里我们看一个uml图理解一下

upload successful

当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用同一个产品族中的对象。这对一些需要根据当前环境来决定其行为的软件系统来说,是一种非常实用的设计模式。

增加新的具体工厂和产品族很方便,无须修改已有系统,符合“开闭原则”。

参考文献

视频:
尚硅谷Java设计模式,韩顺平图解java设计模式

文献:
3. 抽象工厂模式(Abstract Factory)

深入理解工厂模式

创建对象与使用对象——谈谈工厂的作用

单例模式顾名思义就是保证某个类只有一个实例的设计方式,他负责自己创建实例,且保证实例的唯一性。这种实例的唯一性,可以避免某些高频率使用的对象被频繁的创建和销毁,提高了效率,节省了系统资源。单例模式向外界提供了创建该类实例的方法,并私有化构造方法,使得外界不能直接通过new构造实例。

单例模式使用的场景:需要频繁的进行创建和销毁的对象、创建对象时耗时过多或耗费资源过多(即:重量级
对象),但又经常用到的对象、工具类对象、频繁访问数据库或文件的对象(比如数据源、session 工厂等)。

单例模式分为饿汉式和饱汉式,下面我们来详细介绍一下这两种设计模式。

二. 饿汉式

饿汉式是通过直接声明一个该类静态成员变量并在加载的时候就直接创建实例。我们可以用以下的方法进行构造,代码如下:

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
26
27
28
29
30
31
package com.mjj.singleton;

public class Singleton01 {

public static void main(String[] args) {
Singleton instance = Singleton.getInstance();
Singleton instance1 = Singleton.getInstance();
boolean equals = instance.equals(instance1);
System.out.println(instance == instance1);
System.out.println(instance.hashCode());
System.out.println(instance1.hashCode());


}

}

//饿汉式
class Singleton{

private Singleton(){

}

private static final Singleton instance = new Singleton();

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

饿汉式直接在内部声明对象实例,并提供了一个返回对象实例的方法,这样类在加载的时候就会自动创建一个该类的实例。这种方法实现起来简单易懂,并且自动支持线程安全(由jvm保证)。但是饿汉式的方法又一个缺点就是类在加载的时候就会被创建该实例,不管有没有被使用到,这就导致如果这个实例没有被用到的化,就会造成资源浪费。

三. 懒汉式

如果说饿汉式是一种空间换时间的方法,那么懒汉式就是就是一种时间换空间的方法。懒汉式提供一种懒加载的方式,只有在需要的时候才会创建该类的实例对象。他有很多种方法,下面我们来具体看一下。

1. 线程不安全

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
26
27
28
29
30
package com.mjj.singleton;


//懒汉式 只有在获取getInstance的时候才会去加载,并且只会加载一次
// 缺点 线程不安全
public class SingletonTest03 {
public static void main(String[] args) {
Singleton03 instance = Singleton03.getInstance();
Singleton03 instance1 = Singleton03.getInstance();
System.out.println(instance == instance1);
System.out.println(instance.hashCode());
System.out.println(instance1.hashCode());
}
}

class Singleton03{
private static Singleton03 singleton;

private Singleton03(){

}

public static Singleton03 getInstance(){
if(singleton == null){
singleton = new Singleton03();
}
return singleton;

}
}

这种懒汉式将实例化对象封装在方法中,这样可以实现懒加载,也就是只有在用到getInstance()的时候才会加载实例,但是这种方法不是线程安全的,比如有两个线程A和B同时调用getInstance()方法,当A已经进入if(singleton == null)语句之后,还没有跳出if判断语句,B线程也进入到if判断语句,这就会导致创建多个兑现,违背单例模式对象唯一性。为了解决这一问题,我们有3种方法进行改进。

2. 双重检查

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
26
27
28
29
30
31
32
33
package com.mjj.singleton;



public class SingletonTest05 {
public static void main(String[] args) {
Singleton05 instance = Singleton05.getInstance();
Singleton05 instance1 = Singleton05.getInstance();
System.out.println(instance == instance1);
System.out.println(instance.hashCode());
System.out.println(instance1.hashCode());
}
}

class Singleton05{
private static volatile Singleton05 singleton;

private Singleton05(){

}

// 双重检测的方法 不仅线程安全 并且能够解决效率底的问题 建议使用
public static Singleton05 getInstance(){
if(singleton == null){
synchronized (Singleton05.class) {
if (singleton == null)
singleton = new Singleton05();
}
}
return singleton;
}
}

这种方法通过两次判断类的实例对象是否被创建,其中第一次判断语句是为了防止线程每一次调用getIntance()都会使用synchronized进行同步,使代码执行效率变高,第二次判断语句是为了防止多线程情况下重复创建对象。这里使用volatile有两个目的,第一是使多线程之间共享资源可见,当一个线程在修改公共资源的时候其他线程能够立刻从内存中读取修改值,第二个好处就是使得指令有序,防止空指针引用。举个例子,当线程A运行到singleton = new Singleton05()时,其实会有3个指令操作

    1. 获取对象地址;
    1. 在对象地址上初始化一个Singleton05对象;
    1. 将singleton引用指向对象地址;
      但是JVM会自动进行指令的优化,他可能会按照1->2->3的方式进行也可能会按照1->3->2的方式进行,当线程A按照第二种方式操作的时候,且已经将singlton引用指向了对象地址了,这时正好线程B运行到第一个判断语句就会判断成非空,然后返回singleton引用,此时引用并没有初始化对象,就是一个空指针,这样就会造成空指针异常。

3. 静态内部类

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
26
27
28
29
30
31
32
package com.mjj.singleton;



public class SingletonTest06 {
public static void main(String[] args) {
Singleton06 instance = Singleton06.getInstance();
Singleton06 instance1 = Singleton06.getInstance();
System.out.println(instance == instance1);
System.out.println(instance.hashCode());
System.out.println(instance1.hashCode());

}
}

class Singleton06{

private Singleton06(){

}

private static class SingletonInstance{
private static Singleton06 singleton = new Singleton06();
}
// 使用静态内部类 不仅线程安全 并且能够解决效率底的问题 建议使用
public static Singleton06 getInstance(){

return SingletonInstance.singleton;
}
}


静态内部类的方法是创建一个内部类,并在内部类里面创建静态实例对象,内部类必须是私有的只能由外部类调用,这种方法相当于将多线程安全的问题交给JVM去处理,也是推荐使用的方法。

4. 枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.mjj.singleton;

public class SingletonTest07 {
public static void main(String[] args) {
Sinleton07 instance1 = Sinleton07.Instance;
Sinleton07 instance2 = Sinleton07.Instance;
System.out.println(instance1 == instance2);

}
}

enum Sinleton07{
Instance;
public void methof(){
System.out.println("枚举方法 单例模式");
}
}


使用枚举也是一种比较好的方法,这种方法使用简单,不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,也是推荐的方法。

参考文献

视频:
尚硅谷Java设计模式,韩顺平图解java设计模式

文献:
双重检查单例为什么要加volatile
单例模式懒汉式和饿汉式区别

一. 类加载机制概念

我们前面讲到了JVM的工作原理说到,对JAVA源代码编译出来的.class字节码文件,需要通过JVM将字节码转化为底层可识别的机器码,而第一步就是将class文件中的类描述数据加载到虚拟机中。并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,我们称这样的步骤叫做JVM的类加载机制。

二. 类加载过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备、解析3个阶段统称为连接,它们开始的顺序如下图所示:

upload successful

这里加载、连接、初始化是在JVM装载器中进行的。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:

  • 静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
  • 动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。

下面我们具体介绍加载、连接、初始化这三个过程。

1. 加载

加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取其定义的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

这样说起来有些难以理解,我们可以举一个简单的例子,

1
2
3
4
5
6
7
8
9
package ClassLoaderTest;

public class Example1 {
static int a = 1;

public static void main(String[] args) {
System.out.println(Example1.a);
}
}

我们在类中初始化了一个static int类型的元素a,在加载这个类时,JVM首先会从classpath路径下找到一个叫Example1.class的字节流文件,然后将此字节流文件中的静态数据和静态代码块(这里指的就是我们的static int a)放入到方法区内,并在内存中生成一个Example1对象,这个对象是java.lang.Class类型的(由加载器提取并放入堆中),可以用来访问方法区内属于这个类的各种数据。这里我们Example1.a就是访问可存在方法区中的static int类型元素。

值得注意的是class对象也是存放在堆中,方法区中存放的只是类型数据

类的加载方式有很多种,可以从JAR包中、网络获取、JSP文件生成Class文件、从数据库中获取等。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。这里就需要对类加载器有充分的了解,我会在后续详细介绍不同的类加载器。

我们需要注意数组类和非数组类的加载是有一定区别的。数组本身不是通过类加载器创建的,它是通过JAVA虚拟机直接创建的,但是数组中的元素类型最终是靠类加载器创建,一个类加载器遵循一下规则:

  • 如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间被标识
  • 如果数组的组件类型不是引用类型(例如int[]数组),java虚拟机将会吧数组C标记为于引导类加载器相关联
  • 数组类的可见性与他的组件类型相一致,如果组件不是引用类型,那数组类型的可见性默认为public

我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
package ClassLoaderTest;

public class Example2 {
static int a =1;

public static void main(String[] args) {
Example2 [] a = new Example2[5];
Example2 b = new Example2();
System.out.println(a.getClass());
System.out.println(b.getClass());
}
}

getClass()表示的是获取实例的类型类,也就是我们在内存空间生成的java.lang.Class类型结果,结果显示为

1
2
class [LClassLoaderTest.Example2;
class ClassLoaderTest.Example2

这里可以看到数组也是一个类,但是数组类型和他的组件类型是不一样的,会有[L来标识数组类型。具体内容可以参考一下这篇内容:如何理解数组在Java中作为一个类?

2. 验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。主要有以下几种验证。

文件格式验证:
主要验证字节流是否符合Class文件格式的规范,如果符合则把字节流加载到方法区中进行存储。如主次版本号是否在当前虚拟机处理范围内, 开头是否有0xCAFEBABE等。

元数据验证:
元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。

字节码验证:
该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。

符号引用验证:
这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

3. 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分。这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

4. 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

  • 类或方法解析: 判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
  • 字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上下往上递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从下往上递归搜索其父类,直至查找结束,查找流程如下图所示:
    upload successful

最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。

  • 类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

  • 接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

4. 初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器 <clinit>()方法的过程。
这里简单说明下 <clinit>()方法的执行规则:

1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。

2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

我们看一段代码来更深入的理解上面的规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package ClassLoaderTest;


class Father1{
public static int a = 1;
static{
a = 2;
}
}

class Child1 extends Father1{
public static int b = a;
}

public class ClinitTest {
public static void main(String[] args) {
System.out.println(Child1.b);
}
}

此时的结果是 2

我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的()方法,根据规则2,在此之前,要先执行完其父类Father的()方法,又根据规则1,在执行()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的()方法,这样便会将b的赋值为2.

如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。

另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。

Java虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:

    1. 使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。
    1. 通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。
    1. 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。
    1. 当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类。
    1. 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

注意,虚拟机规范使用了“有且只有”这个词描述,这五种情况被称为“主动引用”,除了这五种情况,所有其他的类引用方式都不会触发类初始化,被称为“被动引用”。

被动引用的例子一:
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SuperClass {
//静态变量value
public static int value = 666;
//静态块,父类初始化时会调用
static{
System.out.println("父类初始化!");
}
}

//子类
public class SubClass extends SuperClass{
//静态块,子类初始化时会调用
static{
System.out.println("子类初始化!");
}
}

//主类、测试类
public class NotInit {
public static void main(String[] args){
System.out.println(SubClass.value);
}
}

输出结果为:

父类初始化
33

被动引用的例子二:
通过数组定义来引用类,不会触发子类的初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

//父类
public class SuperClass {
//静态变量value
public static int value = 666;
//静态块,父类初始化时会调用
static{
System.out.println("父类初始化!");
}
}

//主类、测试类
public class NotInit {
public static void main(String[] args){
SuperClass[] test = new SuperClass[10];
}
}

结果为空白,这里没有触发类SuperClass的初始化阶段,但是会触发[lSuperClass的类初始化,它是由虚拟机自动生成的,这我们在前面的加载这一阶段中已经提到了。

被动引用例子三:
刚刚讲解时也提到,静态常量在编译阶段就会被存入调用类的常量池中,不会引用到定义常量的类,这是一个特例,需要特别记忆,不会触发类的初始化!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

//常量类
public class ConstClass {
static{
System.out.println("常量类初始化!");
}

public static final String HELLOWORLD = "hello world!";
}

//主类、测试类
public class NotInit {
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD);
}
}

结果只会输出hell world!不会输出常量类初始化,这是因为final是修饰的HELLOWORLD在编译阶段通过常量传播优化,已经将常量的值”hello world!”存储在NotInit类的敞亮池中,以后NotInit对常量ConstClass.HELLOWORLD的引用实际都会被转化为NotInit类对自身常量池的引用。也就是说NotInit类的Class文件实际上并没有ConstClass类的符号引用入口,这两个类在编译成Class文件之后就没有关系了。

三. 类加载器

JVM设计者把类加载阶段中的“通过’类全名’来获取定义此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

1.类与类加载器
对于任何一个类,都需要由加载它的类加载器和这个类来确立其在JVM中的唯一性。也就是说,两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。

2. 类加载器分类
启动类加载器:
主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 /lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,在工作中它负责加载扩展类加载器和系统类加载器,并作为这些加载器的父加载器,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。

扩展类加载器:
它负责加载扩展目录(%JAVA_HOME%/jre/lib/ext)下的jar包,派生与ClassLoader类,父类加载器为启动类加载器,用户可以把自己开发的类打包成jar包放在这个目录下即可扩展核心类以外的新功能。

系统类加载器或称为应用程序类加载器:
是指Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,下面我们进一步了解它。

双亲委派模型

双亲委派模型原理:
双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,请注意双亲委派模式中的父子关系并非通常所说的类继承关系,而是采用组合关系来复用父类加载器的相关代码,类加载器间的关系如下:

upload successful
其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派模型优势
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。可能你会想,如果我们在classpath路径下自定义一个名为java.lang.SingleInterge类(该类是胡编的)呢?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出如下异常:

1
java.lang.SecurityException: Prohibited package name: java.lang

双亲委派破坏

在Java应用中存在着很多服务提供者接口(Service Provider Interface,SPI),这些接口允许第三方为它们提供实现,如常见的 SPI 有 JDBC、JNDI等,这些 SPI 的接口属于 Java 核心库,一般存在rt.jar包中,由Bootstrap类加载器加载,而 SPI 的第三方实现代码则是作为Java应用所依赖的 jar 包被存放在classpath路径下,由于SPI接口中的代码经常需要加载具体的第三方实现类并调用其相关方法,但SPI的核心接口类是由引导类加载器来加载的,而Bootstrap类加载器无法直接加载SPI的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器SPI的实现类。在这种情况下,我们就需要一种特殊的类加载器来加载第三方的类库,而线程上下文类加载器就是很好的选择。
线程上下文类加载器(contextClassLoader)是从 JDK 1.2 开始引入的,我们可以通过java.lang.Thread类中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)方法来获取和设置线程的上下文类加载器。如果没有手动设置上下文类加载器,线程将继承其父线程的上下文类加载器,初始线程的上下文类加载器是系统类加载器(AppClassLoader),在线程中运行的代码可以通过此类加载器来加载类和资源,如下图所示,以jdbc.jar加载为例

upload successful
从图可知rt.jar核心包是有Bootstrap类加载器加载的,其内包含SPI核心接口类,由于SPI中的类经常需要调用外部实现类的方法,而jdbc.jar包含外部实现类(jdbc.jar存在于classpath路径)无法通过Bootstrap类加载器加载,因此只能委派线程上下文类加载器把jdbc.jar中的实现类加载到内存以便SPI相关类使用。显然这种线程上下文类加载器的加载方式破坏了“双亲委派模型”,它在执行过程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器,当然这也使得Java类加载器变得更加灵活。为了进一步证实这种场景,不妨看看DriverManager类的源码,DriverManager是Java核心rt.jar包中的类,该类用来管理不同数据库的实现驱动即Driver,它们都实现了Java核心包中的java.sql.Driver接口,如mysql驱动包中的com.mysql.jdbc.Driver,这里主要看看如何加载外部实现类,在DriverManager初始化时会执行如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

//DriverManager是Java核心包rt.jar的类
public class DriverManager {
//省略不必要的代码
static {
loadInitialDrivers();//执行该方法
println("JDBC DriverManager initialized");
}

//loadInitialDrivers方法
private static void loadInitialDrivers() {
sun.misc.Providers()
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//加载外部的Driver的实现类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
//省略不必要的代码......
}
});
}

在DriverManager类初始化时执行了loadInitialDrivers()方法,在该方法中通过ServiceLoader.load(Driver.class);去加载外部实现的驱动类,ServiceLoader类会去读取mysql的jdbc.jar下META-INF文件的内容,如下所示

upload successful

而com.mysql.jdbc.Driver继承类如下:

1
2
3
4
5
6
7
8
9
10
11

public class Driver extends com.mysql.cj.jdbc.Driver {
public Driver() throws SQLException {
super();
}

static {
System.err.println("Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. "
+ "The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.");
}
}

从注释可以看出平常我们使用com.mysql.jdbc.Driver已被丢弃了,取而代之的是com.mysql.cj.jdbc.Driver,也就是说官方不再建议我们使用如下代码注册mysql驱动

1
2
3
4
5
6
7

//不建议使用该方式注册驱动类
Class.forName("com.mysql.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通过java库获取数据库连接
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");

而是直接去掉注册步骤,如下即可

1
2
3
String url = "jdbc:mysql://localhost:3306/cm-storylocker?characterEncoding=UTF-8";
// 通过java库获取数据库连接
Connection conn = java.sql.DriverManager.getConnection(url, "root", "root@555");

这样ServiceLoader会帮助我们处理一切,并最终通过load()方法加载,看看load()方法实现

1
2
3
4
5
public static <S> ServiceLoader<S> load(Class<S> service) {
//通过线程上下文类加载器加载
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

很明显了确实通过线程上下文类加载器加载的,实际上核心包的SPI类对外部实现类的加载都是基于线程上下文类加载器执行的,通过这种方式实现了Java核心代码内部去调用外部实现类。我们知道线程上下文类加载器默认情况下就是AppClassLoader,那为什么不直接通过getSystemClassLoader()获取类加载器来加载classpath路径下的类的呢?其实是可行的,但这种直接使用getSystemClassLoader()方法获取AppClassLoader加载类有一个缺点,那就是代码部署到不同服务时会出现问题,如把代码部署到Java Web应用服务或者EJB之类的服务将会出问题,因为这些服务使用的线程上下文类加载器并非AppClassLoader,而是Java Web应用服自家的类加载器,类加载器不同。,所以我们应用该少用getSystemClassLoader()。总之不同的服务使用的可能默认ClassLoader是不同的,但使用线程上下文类加载器总能获取到与当前程序执行相同的ClassLoader,从而避免不必要的问题。ok~.关于线程上下文类加载器暂且聊到这,前面阐述的DriverManager类,大家可以自行看看源码,相信会有更多的体会,另外关于ServiceLoader本篇并没有过多的阐述,毕竟我们主题是类加载器,但ServiceLoader是个很不错的解耦机制,大家可以自行查阅其相关用法。

面试题、

下面我们看一些面试来回顾一下学习的内容,这也是为以后面试做的一些准备吧。

  • 看你简历写得熟悉JVM,那你说说类的加载过程吧?
  • 我们可以自定义一个String类来使用吗?(判断类的唯一性)
  • 什么是类加载器,类加载器有哪些?
  • 多线程的情况下,类的加载为什么不会出现重复加载的情况?
  • 什么是双亲委派机制?它有啥优势?可以打破这种机制吗?

参考文章

视频:

尚硅谷宋红康JVM教程(java虚拟机详解,jvm从入门到精通)

文章:

Java类加载机制(全套)
Java 类加载机制(阿里)-何时初始化类
【深入Java虚拟机】之四:类加载机制
【深入Java虚拟机】之三:类初始化
JVM类加载机制详解(二)类加载器与双亲委派模型
深入理解Java类加载器(ClassLoader)

一. 什么是JVM

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。

它是一种能够运行Java bytecode的虚拟机,以堆栈结构机器来进行实做。最早由太阳微系统所研发并实现第一个实现版本,是Java平台的一部分,能够运行以Java语言写作的软件程序。

Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。通过对中央处理器(CPU)所执行的软件实现,实现能执行编译过的Java程序码(Applet与应用程序)。

作为一种编程语言的虚拟机,实际上不只是专用于Java语言,只要生成的编译文件符合JVM对加载编译文件格式要求,任何语言都可以由JVM编译运行。此外,除了甲骨文,也有其他开源或闭源的实现。

upload successful

总之高级语言只要是能够通过编译器生成符合JVM规范的字节码,就可以在JVM上运行。JVM的这种可扩展性也解释了java的 “一次编译,到处运行的” 的原因。


二. JVM的相关结构

1. JDK、JRE、JVM的关系结构

upload successful

如上图所示:JDK包含了JAVA程序设计语言、JVM、JAVA API类库这三个部分,用于支持JAVA程序开发的最小环境,是JAVA程序开发的必需品;JRE则只包含了JVM以及JAVA API类库中的JAVA SE API子集,JRE不能够用于JAVA程序开发,只是为JAVA程序运行提供一个标准环境。

2. JVM工作原理

upload successful

如图所示,java源代码通过编译器转化为字节码,JVM将字节码读取、校验,并逐条解释成操作系统可读取的机器码。对于一些热点代码(即多次重复出现的代码),JVM将会通过JIT代码生成器对这些代码进行二次编译直接生成机器码,避免以后重复解释,且提升JVM工作效率。

这里解释一下为什么不先将字节码全部编译成机器码,再执行机器码,按道理来说这样运行效率会更高呀?原因是前期的编译操作是需要一定时间的,特别对于大型的JAVA文件,若前期编译操作浪费大量时间就会影响用户使用体验;而逐条解释虽然效率相对不高,但是能够从快速加载文件,通过牺牲效率换取用户使用体验,从这点来说还是必要的。

3. JVM内存结构

upload successful

  • 程序技术器
    程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

  • JAVA虚拟栈
    与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 本地方法栈
    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

  • JAVA堆
    对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。

  • 方法区
    方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
    对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。

三. JVM的生命周期

1. JVM的启动

java虚拟机的启动是通过引导类加载器创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。初始类加载完成后,再通过main方法将其他的类加载进来。当java程序启动时,JVM进程就被创建和运行。

2. JVM的执行

  • 一个运行中的JVM有着一个清晰的任务: 执行JAVA程序。
  • 程序开始执行时他才运行,程序结束时他就停止了。
  • 执行一个所谓的JAVA程序的时候,真真正正在执行的是一个叫JAVA虚拟机的进程。
    举个例子,我们先执行一段如下代码。
1
2
3
4
5
6
7
8
9
10
public class Lifetime_JVM {
public static void main(String[] args) throws InterruptedException {
int a=2;
int b=3;
int c = a+b;
Thread.sleep(6000);

System.out.println("good");
}
}

在程序执行未结束时,我们在当前文件夹下使用terminal输入jps,这是一个可以查看java程序运行进程的工具,我们可以看到下图
upload successful
这里会出现一个Lifetime_JVM的进程,说明jvm已经启动,等程序执行完毕,我们在输入jps时,这个Lifetime_JVM的进程就会消失,如图所示
upload successful

3. JVM的退出

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止。
  • 由于操作系统出现错误而导致JAVA虚拟机进程终止。
  • 某线程调用runtime类或者system类的exit访达,或者Runtime类的halt访达,并且JAVA安全管理器也允许这次exit或halt操作。

四. 参考文章

视频:

尚硅谷宋红康JVM教程(java虚拟机详解,jvm从入门到精通)

文章:

JVM是什么?深入解析JVM原理!

Java中JVM虚拟机详解

jvm系列(二):JVM内存结构

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment