02、JVM实战 - 内存与垃圾回收(一) -- 类加载子系统

此部分对应书中第七章内容。

1. 类加载机制概述:

1.1 定义:

JAVA虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程被称作虚拟机的类加载机制。
 

1.2 作用:

  • 类加载器子系统负责从文件系统或者网络中(正常是本地磁盘空间 )加载Class文件,class文件在文件开头有特定的文件标识(CA FE BA BE)
  • ClassLoader只负责class文件的加载,至于它是否可以运行,有Execution Engine决定
  • 加载后的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量。(这部分常量信息是Class文件中常量池部分的内存映射)

1.3 类加载器ClassLoader角色

相当于搬运工的角色:class文件 =》 JVM =》元数据模板。
 
比如图中过程:

1、 本机磁盘上的car.class字节码文件被我们的类加载器加载到了JVM中,称为DNA元数据模板(CarClass),放在方法区;
2、 这个元数据模板可以通过调用getClassLoader()方法来获取我们的类加载器,即获取是谁加载的这个类;
3、 这个元数据模板可以通过构造器生成各种各样的CAR对象;
4、 生成的CAR对象可以通过getClass方法可以获取到元数据类本身;

其他:
这些过程都是在程序运行期完成的。
java语言的可扩展性就是依赖运行期动态加载和动态链接这个特点实现的。

关于编译期和运行期,如下图:

 

2.类加载的时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期经历了:加载、验证、准备、解析、初始化、使用和卸载七个阶段。

2.1 必须对类进行初始化的六种情况

1、 遇到new、getstatic、putstatic或invokestatic四条字节码指令时,使用这几个指令的场景是:;

  • new实例化对象时
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
  • 调用一个类的静态方法时
    2、 反射:使用reflect包的方法对类型进行反射调用的时候;
    3、 当初始化类时,发现它的父类没有初始化,需要优先初始化父类;
    4、 虚拟机会优先初始化包含main()方法的类(启动类的类);
    5、 使用了JDK7新加入的动态语言支持时,如果一个MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄;
    6、 当一个接口中定义了JDK8新加入的默认方法(被default修饰的接口方法),并且该接口的实现类发生了初始化该接口要在该实现类之前初始化;

除了这6种情况,其他都不会触发初始化。

2.2一些不会初始化的例子

例1:通过子类引用父类的静态字段,不会导致子类初始化
// 父类
public class SuperClass {

    static {

        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}
// 子类
public class SubClass extends SuperClass{

    static {

        System.out.println("SubClass init!");
    }
}

public class NotInitClass {

    public static void main(String[] args) {

        // 通过子类引用父类的静态字段,不会导致子类初始化
        System.out.println(SubClass.value);
    }
}

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

例二:通过数组定义来引用类,不会触发此类的初始化
public class NotInitClass {

    public static void main(String[] args) {

        // 通过子类引用父类的静态字段,不会导致子类初始化
        // System.out.println(SubClass.value);
        // 通过数组定义来引用类,不会触发此类的初始化
        SuperClass[] sca = new SuperClass[10];
    }
}

输出:没有任何输出

例三:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
/**
 * 常量在编译阶段会存入调用类的常量池种,
 * 本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
 */
public class ConstClass {

    static {

        System.out.println("ConstClass init!");
    }

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

public class NotInitClass {

    public static void main(String[] args) {

        // System.out.println(SubClass.value);
        // SuperClass[] sca = new SuperClass[10];
        System.out.println(ConstClass.HELLOWORLD);
    }
}

运行结果:
 
原因:
虽然在Java源码种确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitClass类的常量池中,以后NotInitClass对常量ConstClss.HELLOWORLD的引用,实际都被转化为NotInitClass对自身常量池的引用了。也就是说,实际上NotInitClass的Class文件中并没有ConstClass类的符号引用人口,这2个类在编译成Class文件后就以不存在任何联系了。

3.类的加载过程

主要分为三部分:加载 - > 链接 - > 初始化。
 
如下代码的加载过程:

public class HelloLoader {

    public static void main(String[] args) {

        System.out.println("谢谢ClassLoader加载我....");
        System.out.println("你的大恩大德,我下辈子再报!");
    }
}

// 它的加载过程是怎么样的呢?
/*  执行 main( ) 方法(静态方法)就需要先加载承载类 HelloLoader
加载成功,则进行链接、初始化等操作,完成后调用 HelloLoader 类中的静态方法 main
加载失败则抛出异常*/

 

3.1 加载:

主要目的:生成class对象

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

其中第一点:我们可以从任何地方获取,可以从jar、war、网络、动态代理(Proxy)等。

3.2 链接:

分为三步:

1、 验证(Verify)

  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。

  • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
    2、 准备(Prepare)

  • 类变量分配内存并且设置该类变量的默认初始值,即零值。

  • 这里不包含用final修饰的 static,因为final在编译的时候就会分配了,准备阶段会显式

  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
    3、 解析(Resolve)

  • 将常量池内的符号引用转换为直接引用的过程

  • 事实上,解析操作住往会伴随着JVM在执行完初始化之后再执行

  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSITANT_Class_info、 CONSTANT_Fieldref_info、 CONSTANT_Methodref_into等

3.2.1 验证:验证class文件的字节流的合法性 。

包含四个阶段的检验动作:

  • 文件格式验证:是否以以CA FE BA BE开始、版本号是否在虚拟机接受范围之内。。。等
  • 元数据验证:对字节码进行语义分析如:是否有父类(除了Object类,所有类都有父类)、是否继承了final继承的类。。。等
  • 字节码验证:对类的方法体进行分析。
  • 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候如:符号引用中的类、字段方法的可访问性是否可以被当前类访问。。。等
3.2.2 准备:为类变量分配内存和初始值
  • 为类变量(static)分配内存并设值默认初始值。(这里的默认初始值是数据类型的初始值如int初始化为0;而不是设置的初始值,这个初始值的初始化需要到类的初始化阶段才会进行)
  • 这里不包括用final修饰的static,因为final、在编译的时候就会分配了,准备阶段会显式初始化。
  • 这里不会初始化实例变量,类变量会分配在方法区中,而实例变量会随着对象一起分配到java堆中。

举例:

public class HelloApp {

    // 解析:变量a在准备阶段会赋初始值,但不是1,而是0
    //在初始化阶段会被赋值为 1
    //prepare:a = 0 ---> initial : a = 1
    private static int a = 1;   

    public static void main(String[] args) {

        System.out.println(a);
    }
}

注意:在jdk7之前,hotspot使用永久代来实现方法区时,我们说类变量存放在方法区是没错的;但是在jdk8之后,类变量会随着Class对象一起存放在java堆中

3.2.3 解析:将常量池内的符号引用替换为直接引用的过程
  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标地句柄。

3.3 初始化

3.3.1 目的:初始化static修饰的内容。
  • 初始化阶段就是执行类构造器方法clinit()的过程
  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行
  • clinit()不同于类的构造器。(关联:构造器是虚拟机视角下的init()方法,任何一个类声明之后,内部至少存在一个类的构造器)
  • 若该类具有父类,JVM会保证子类的clinit()执行前,父类的clinit()已经执行完毕。
  • 虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁
3.3.2 初始化过程

如下代码:

public class ClassInitTest {

    private static int num = 1;

    static {

        num = 2;
        number = 20;
        // 只可以赋值,但是不能调用,报错:非法的前向引用。
        // System.out.println(number);
    }
    // 这么写是可以的,
    // 我们知道static定义的变量和代码块是和类一起加载的,并且按顺序执行的,
    // 但是前面我们说在准备阶段会为类变量初始化为默认值,所以这里初始化number为0了已经。
    // 即prepare:number -->0 ==> initial:20 --> 10
    private static int number = 10;

    public static void main(String[] args) {

        System.out.println(ClassInitTest.number);
    }
}

通过jclasslib插件查看class文件类初始化方法clinit的code:(注意,这里已经到了初始化步骤,所以看不到初始化为0的过程,clinit可以看下面的初始化部分内内容),可以看出num先赋值为1,再赋值为2,number先赋值为20再改为10。
 

3.3.5 初始化注意的一些事情:

1、 类构造器方法:clinit-->classinit,(不是类的构造器方法);
2、 一个类的clinit()方法是加锁的,保证只被调用一次(静态内部类实现单例);

/**
执行结果:
线程1开始
线程2开始
线程1初始化当前类

解析:程序卡死,分析原因:
两个线程同时去加载 DeadThread 类,而 DeadThread 类中静态代码块中有一处死循环
先加载 DeadThread 类的线程抢到了同步锁,然后在类的静态代码块中执行死循环,而另一个线程在等待同步锁的释放
所以无论哪个线程先执行 DeadThread 类的加载,另外一个类也不会继续执行
*/
public class DeadThreadTest {

    public static void main(String[] args) {

        Runnable r = () -> {

            System.out.println(Thread.currentThread().getName() + "开始");
            DeadThread dead = new DeadThread();
            System.out.println(Thread.currentThread().getName() + "结束");
        };

        Thread t1 = new Thread(r, "线程1");
        Thread t2 = new Thread(r, "线程2");

        t1.start();
        t2.start();
    }
}

class DeadThread {

    static {

        if (true) {

            System.out.println(Thread.currentThread().getName() + "初始化当前类");
            while (true) {

            }
        }
    }
}

//====================
//修改上述静态方法:
static {

    if (true) {

        System.out.println(Thread.currentThread().getName() + "初始化当前类");
    }
}
/**
执行结果:
线程1开始
线程2开始
线程2初始化当前类
线程2结束
线程1结束
也就是静态代码块只会被执行一次
*/

1、 类变量的初始化是顺序执行的;
2、 子类的静态初始化晚于父类的;

/**
首先,执行 main( ) 方法需要加载 ClinitTest1 类
获取 Son.B 静态变量,需要加载 Son 类
Son 类的父类是 Father 类,所以需要先执行 Father 类的加载,再执行 Son 类的加载
*/
public class ClinitTest1 {

    static class Father{

        public static int A = 1;
        static{

            A = 2;
        }
    }

    static class Son extends Father{

        public static int B = A;
    }

    public static void main(String[] args) {

        //加载Father类,其次加载Son类。
        System.out.println(Son.B); //2
    }
}

1、 针对的是加上static的类变量、静态代码块等如果类没有静态代码块,也没有对变量的赋值操作,那么编译器不会为这个类生成clinit()方法;

public class ClinitTest {

    private int a = 1;

    public static void main(String[] args) {

        int b = 2;
    }
}

 

4.类加载器

JVM支持2种类型的类加载器:引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)

其中,所有派生于(继承或间接继承)ClassLoader的类加载器都划分为自定义类加载器

注意:对于任何一个类,都必由加载它的类加载器和这个类本身一起共同确立其在Java虚拟器中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
翻译一下上面这句话就是:

1、 比较2个类是否相等,需要判断他们是否由同一个类加载器加载的否则,哪怕他们是同一个class文件被用一个虚拟机加载,只要他们的类加载器不同,那他们一定不同;
2、 JVM会将类加载器的一个引用作为类型信息的一部分保存在方法区中;

 
其中Bootstrap类加载器是用C/C++写的,下面的都是用java编写的

4.1常见类加载器(虚拟机自带的加载器):

4.1.1 Bootstrap ClassLoader:启动类加载器
  • 即是引导类加载器
  • 由C++实现,嵌套在jvm内部。所以无法被java程序直接引用
  • 只负责加载核心库:存放在<JAVA\_HOME>\lib目录或者被-Xbootclasspath参数所指定的路径中存放,而且是JVM虚拟机能够识别(如rt.jar,tools.jar,不是所有的都能识别)的类库加载到虚拟机内存中。
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 并不继承自java.lang.ClassLoader,没有父加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
4.1.2 Extension ClassLoader:扩展类加载器
  • 负责加载<JAVA\_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径下的所有类库。如果用户创建的jar放在此目录下,也会自动由扩展类加载器加载
  • 是java类库的扩展机制,在JDK9之后被模块化带来的天然的扩展能力所取代。
  • jdk9之前由ExtClassLoader类实现(JDK9及之后,已经没有这个类了)。
  • 父类加载器为启动类加载器

ExtClassLoader 继承树:
&nbsp;

4.1.3 Application ClassLoader: 应用程序类加载器
  • 也叫系统类加载器。
  • 由AppClassLoader类实现。
  • 父类加载器为扩展类加载器
  • 加载用户自定义类路径上所有的类库。
  • 该类是程序中默认的类加载器,一般来说,java应用的类都是由他来完成加载
  • 它负责加载环境变量 classpath 或 系统属性java.class.path指定路径下的类库
    &nbsp;
4.1.4 通过代码看看,哪些类用了哪些加载器:
public class ClassLoaderTest {

    public static void main(String[] args) {

        // 获取类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        // 获取其上层:扩展类加载器
        // 注意:我用的是jdk13,由于加入了模块化,所以这里获取到的是PlatformClassLoader
        // jdk8及之前是extClassLoader
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

        // 获取其上层:启动类加载器 ---> c++写的,获取不到
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader); // null

        // 获取用户自定义类加载器:默认使用系统类加载器加载
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);

        // String类是rt.jar下面的,是启动类(引导类)加载器加载的
        // java核心类库都是启动类加载器加载的
        ClassLoader stringLoader = String.class.getClassLoader();
        System.out.println(stringLoader);
    }
}

打印结果:
&nbsp;

4.2 自定义类加载器(不重要)

4.2.1 为什么要自定义类加载器:
  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载源
  • 防止源码泄露
4.2.2 实现简单步骤:

1、 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求;
2、 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中;
3、 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁;

4.2.3 代码编写:
public class CustomClassLoader extends ClassLoader {

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        try {

            byte[] result = getClassFromCustomPath(name);
            if (result == null) {

                throw new FileNotFoundException();
            } else {

                return defineClass(name, result, 0, result.length);
            }
        } catch (FileNotFoundException e) {

            e.printStackTrace();
        }

        throw new ClassNotFoundException(name);
    }

    private byte[] getClassFromCustomPath(String name) {

        //从自定义路径中加载指定类:细节略
        //如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
        return null;
    }

    public static void main(String[] args) {

        CustomClassLoader customClassLoader = new CustomClassLoader();
        try {

            Class<?> clazz = Class.forName("One", true, customClassLoader);
            Object obj = clazz.newInstance();
            System.out.println(obj.getClass().getClassLoader());
        } catch (Exception e) {

            e.printStackTrace();
        }
    }
}

4.3 关于ClassLoader类

ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)
&nbsp;

4.3.1 源码:
protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

    synchronized (getClassLoadingLock(name)) {

        // 首先检查这个classsh是否已经加载过了
        Class<?> c = findLoadedClass(name);
        //如果没有加载过
        if (c == null) {

            long t0 = System.nanoTime();
            try {

                // c==null表示没有加载,如果有父类的加载器则让父类加载器加载
                if (parent != null) {

                    c = parent.loadClass(name, false);
                } else {

                    //如果父类的加载器为空 则说明递归到bootStrapClassloader了
                    //bootStrapClassloader比较特殊无法通过get获取
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {

     }
            if (c == null) {

                //如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
                long t1 = System.nanoTime();
                c = findClass(name);
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {

            resolveClass(c);
        }
        return c;
    }
}

protected Class<?> findClass(String name){

   //1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
      ...

   //2. 调用defineClass将字节数组转成Class对象
   return defineClass(buf, off, len);
}

// 将字节码数组解析成一个Class对象,用native方法实现
protected final Class<?> defineClass(byte[] b, int off, int len){

   ...
}

从源码我们可以看出:

  • JVM 的类加载器是分层次的,它们有父子关系,而这个关系不是继承维护,而是组合,每个类加载器都持有一个 parent字段,指向父加载器。
  • defineClass方法的职责是调用 native 方法把 Java 类的字节码解析成一个 Class 对象。
  • findClass方法的主要职责就是找到.class文件并把.class文件读到内存得到字节码数组,然后调用 defineClass方法得到 Class 对象。子类必须实现findClass。
  • loadClass方法的主要职责就是实现双亲委派机制:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。

4.4 双亲委派模型(重要)

Java虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存中生成 class 对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

工作过程:

1、 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成;
2、 每一个层次的类加载器都是如此(向上递归),因此所有的加载请求最终都应该传送到最顶层的启动类加载器中;
3、 如果父类可以完成类加载,就成功返回;只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去完成加载;

&nbsp;
其中CustomClasLoader为自定义类加载器。

4.4.1 一个例子:

1、 我们定义一个测试方法,里面new一个String类,;
2、 同时我们在项目下,新建java.lang包,在包中新建一个自定义的String类,里面包含一个static静态块:;
3、 运行StringTest的main方法;

public class StringTest {

    public static void main(String[] args) {

        String str = new String();
        System.out.println("双亲委派模型");

        StringTest test = new StringTest();
        System.out.println(test.getClass().getClassLoader());
    }
}
package java.lang;

public class String {

    static {

        System.out.println("我是自定义的String类");
    }
}

运行结果:
&nbsp;
可以看出静态块并没有被执行,所以我们自定义的String类并没有被加载,加载的还是核心类库中的String类。
假如我们在刚刚自定义的String类中添加一个main方法,启动:

package java.lang;

public class String {

    static {

        System.out.println("我是自定义的String类");
    }

    public static void main(String[] args) {

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

结果:
&nbsp;
这是因为加载的是核心类库中的String类,而此类中没有main方法。

4.4.2 双亲委派模型优势:
  • 避免类的重复加载。因为一个类加载的时候,会委托上级加载器加载,如果上级加载器已经加载了,就不会让下级加载器加载了

  • 保护程序安全,防止核心API被随意篡改。即使被篡改也不会被加载,即使被加载也不会是同一个class对象,因为不同的加载器加载同一个.class也不是同一个Class对象。这样则保证了Class的执行安全

  • 自定义类:java.lang.String

  • 自定义类:java.lang.yhxStart

这里我们看个例子:在我们刚刚定义的java.lang包下定义一个启动类,然后执行:

package java.lang;

public class yhxStart {

    public static void main(String[] args) {

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

结果:保证了java的核心类库不能被随意修改
&nbsp;

4.4.3 如何打破双亲委派模型(重要)

参考上一篇文章

4.5 沙箱安全机制

自定义String类时:在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java.lang.String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制

4.6 对类加载器的引用

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的