JVM 类加载机制

一、Java 虚拟机

虚拟机可分为:系统虚拟机和程序虚拟机

系统虚拟机:系统虚拟机是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。例如:Visual Box、VMware 就属于系统虚拟机。

程序虚拟机:程序虚拟机是专门执行单个计算机程序的虚拟机。例如:Java 虚拟机。

Java 虚拟机是一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。意思是任何语言经过编译后,只要生成的是 .class 文件,就会被 JVM 解析执行

Java 虚拟机的特点:一次编译,到处运行,自动内存管理,自动垃圾回收

一次编译,到处运行:.java 文件被编译成 .class 文件后,既可以在 Windows 操作系统上运行,也可以在 Linux 操作系统上运行,还可以在 Mac 操作系统上运行,只要它们的系统上安装了对应版本的虚拟机。体现了 JVM 的跨平台性。

JVM 类加载机制_第1张图片

二、类加载过程

类的加载过程就是把 .java 文件编译后生成的 .class 文件加载到 JVM 的过程。
JVM 类加载机制_第2张图片

x.java 通过 javac 命令编译后生成 x.class 文件,x.class 文件通过类加载器(ClassLoader)加载到 Java 虚拟机。加载的过程分为 加载(Loading)、链接(Linking)、初始化(Initialization) 的过程,其中链接(Linking)又分为 验证(Verification)、准备(Preparation)、解析(Resolution) 三个部分。
JVM 类加载机制_第3张图片

加载(Loading)

将 .class 文件从磁盘读取到内存,并在内存中构建出 Java 类的原型——类模板对象。

在加载类时,Java 虚拟机必须完成以下 3 件事情:

  • 通过类的全限定名,获取类的二进制数据流。
  • 解析类的二进制数据流为方法区内的运行时数据结构(Java 类模型)
  • 在堆中创建 java.lang.Class 类的实例,表示该类型,作为方法区这个类的各种数据的访问入口。

数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的,但数组类的元素类型还是要靠类加载器来完成加载:

  • 如果数组的元素类型是引用数据类型,那么就遵循定义的加载过程递归加载和创建数组的元素类型
  • 如果数组的元素类型是基本数据类型(例如 int[] 数组),那么 Java 虚拟机将会把数组标记为与引导类加载器关联

链接(Linking)

  • 验证(Verification)对 .class 文件的格式进行校验,确保加载进来的 .class 文件的安全性。验证主要分为格式验证、语义验证、字节码验证、符号引用验证。格式验证会和加载阶段一起执行,格式验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中,接下来在方法区中进行语义验证、字节码验证、符号引用验证。
  • 准备(Preparation)给类的静态变量分配内存,并赋默认值。(不包含 final 修饰的静态变量,因为 final 修饰的变量在编译的时候就被会分配)
  • 解析(Resolution)将常量池内的符号引用转换为直接引用。为了支持 Java 语言的运行时绑定特性,在某些情况下解析阶段可以在初始化阶段之后再开始。(运行时绑定,也可以称为动态绑定或晚期绑定,意思是如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称之为运行时绑定。多态就是运行时绑定。

初始化(Initialization)

为静态变量赋初始值。初始化阶段就是执行类构造器方法 () 的过程(() 类构造器方法() 构造函数并不是同一个方法)。() 不是程序员在 Java 代码中直接编写的方法,是 Javac 编译器的自动生成物。() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的。

1)由父及子,静态先行:Java 虚拟机会保证在子类的 () 方法执行前,父类的 () 方法已经执行完毕(父类的 () 方法先执行,但并不是执行子类 () 方法会调用父类的 () 方法,区别于构造函数)。由于父类的 () 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的赋值变量操作

static class Parent {
	public static int A = 1;
	static {
		A = 2;
	}
}
static class Sub extends Parent {
	public static int B = A;
}

public static void main(String[] args) {
	System.out.println(Sub.B);  // 2
}

2)() 方法对于类或接口来说并不是必需的。不会生成 () 方法的情况:

  • 一个类中并没有声明任何的类变量,也没有静态代码块时;
  • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时;
  • 一个类中包含 static final 修饰的基本数据类型的字段。这些类字段初始化语采用编译时常量表达式。
public class InitializationTest1 {
    // 场景1:对应非静态的字段,不管是否进行了显示赋值,都不回生成 () 方法
    public int num = 1;
    // 场景2:静态的字段,没有显示的赋值,不会生成 () 方法
    public static int num1;
    // 场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显示赋值,都不会生成 () 方法
    public static final int num2 = 1}

3)() 方法的线程安全性:虚拟机会保证一个类的 () 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 () 方法。其他线程都需要阻塞等待,直到活动线程执行 () 方法完毕。正是因为函数 () 带锁线程安全的,因此如果在一个类的 () 方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁,并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。() 方法是 static 修饰的,但此方法并没有被 synchronized 修饰,所以出现死锁很难被发现

三、类加载器

Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己去决定如何获取所需的类。实现这个动作的代码被称为类加载器。意思是 Java 虚拟机中并不包含类加载器。

3.1 类与类加载器

JVM 判断两个 class 对象是否为同一个类的两个必要条件:

  • 类的全限定类名必须一致
  • 加载这个类的 ClassLoader (ClassLoader 的实例对象)必须相同

在 JVM 中,即使这两个类对象来源于同一个 Class 文件,被同一个虚拟机所加载,但是加载他们的 ClassLoader 不同,这两个类对象就不相等。比如我自己写一个String,包名为 java.lang.String,我自己写这个 String 是通过 User ClassLoader 加载的,而官方的 String 是通过 Bootstrap ClassLoader 加载器加载的,虽然他们的包名和类名一致,但他们也不是同一个类对象。

3.2 类加载器分类

启动类加载器(Bootstrap Class Loader,也叫引导类加载器):负责加载 JRE 的核心类库,如 JRE 目标下的 jre/lib/rt.jar、chsrsets.jar、resources. jar等。启动类加载器是由 C/C++ 语言实现的。所以调用它的 getClassLoader() 方法返回 null。

扩展类加载器(Extension Class Loader):负责加载 JRE 扩展目录 ext 中的 jar 类包。

应用程序加载器(Application Class Loader,也叫系统类加载器):负责加载 ClassPath 路径下的类包。

自定义类加载器(User Class Loader):我们理解的用户自定义类加载器,是用户自己定义的,实际上官方对自定义类加载器的定义是:所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。也就是说:所有直接或间接继承 ClassLoader 的类都叫自定义类加载器,Ext ClassLoader 和 App ClassLoader 也是间接继承了 ClassLoader,他们也算是自定义类加载器。

3.3 双亲委派模型

如果一个类加载器收到了类加载请求,它不会自己先去加载,而是向上委托,让父类加载器去加载,一直委托到 Bootstrap ClassLoader,如果 Bootstrap ClassLoader 能加载则加载这个类,如果不能加载则让子类加载器去加载,如果所有的子类加载器都不能加载这个类,则会抛出 ClassNotFound 异常。
JVM 类加载机制_第4张图片

为什么要搞双亲委派机制?

  • 避免类的重复加载,确保一个类的全局唯一性
  • 保护程序安全,防止核心 API 被修改

如果没有双亲委派机制,我可以自己写一个 java.lang.String 类覆盖 Oracle 的 String,由于用户输入的密码大部分都是 String 类型,所以我就可以通过自己写的 String 来获取用户的密码。

3.4 如何破坏双亲委派模型

1)继承 ClassLoader 接口,Tomcat 中的 WebappClassLoader 继承 ClassLoader 的子类 URLClassLoader。
2)重写 loadClass 方法,实现自己的逻辑,不要每次都先委托给父类加载,例如可以先在本地加载,这样就破坏了双亲委派模型了。

3.5 实现自定义类加载器

Java 提供了抽象类 java.lang.ClassLoader,所有用户自定义的类加载器都应该继承 ClassLoader 类。

在自定义ClassLoader 的子类时候,我们常见的会有两种做法:
方式一:重写 loadClass() 方法
方式二:重写 findClass() 方法

这两种方法本质上差不多,毕竟 loadClass() 也会调用 findClass(),但是从逻辑上讲我们最好不要直接修改 loadClass(),建议的做法是只在 findClass() 里重写自定义类的加载方法,根据参数指定类的名字,返回对应的 Class 对象的引用。。

loadClass() 这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass() 方法的过程中必须写双亲委派的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。

public class MyClassLoader extends ClassLoader{
    private String byteCodePath;

    public MyClassLoader(String byteCodePath){
        this.byteCodePath=byteCodePath;
    }

    public MyClassLoader(String byteCodePath, ClassLoader parent){
        super(parent);
        this.byteCodePath=byteCodePath;
    }

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

        String fileName = byteCodePath + className + ".class";
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        BufferedInputStream bis= null;
        try {
            bis = new BufferedInputStream(new FileInputStream(fileName));

            int len;
            byte[] data=new byte[1024];
            
            // 通过循环操作,把对应的文件(fileName)数据写到内存对应的 ByteArrayOutputStream 对应里面的字节数组中
            while ((len=bis.read(data))!=-1){
                baos.write(data,0,len);
            }

            // 获取内存中的完整自己数组数据
            byte[] byteCodes = baos.toByteArray();
            
            // 调用defineClass,将字节数组的数据转化为class的实例
            Class clazz = defineClass(null, byteCodes, 0, byteCodes.length);
            
            return clazz;
            
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if (baos!=null)
                baos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (bis!=null)
                bis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return null;
    }
}

测试类:

public class MyClassLoaderTest {
    public static void main(String []args){

        MyClassLoader loader=new MyClassLoader("d://");

        Class clazz= null;
        
        try {
        
            clazz = loader.loadClass("Demo1");
            System.out.println("加载此类的类加载器为:" + clazz.getClassLoader().getClass().getName());
            System.out.println("加载当前 Demo1 类的类加载器的父类加载器为:" + clazz.getClassLoader().getParent().getClass().getName());

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

结果:

加载此类的类加载器为:sun.misc.Launcher$AppClassLoader
加载当前 Demo1 类的类加载器的父类加载器为:sun.misc.Launcher$ExtClassLoader

3.5 沙箱安全机制

Java 安全模型的核心就是 Java 沙箱(sandbox)。什么是沙箱?沙箱是一个限制程序运行的环境。沙箱安全机制的作用:

  • 限制程序的运行环境,它将代码归入保护域,不同的保护域有不一样的权限,这样就对代码进行了有效隔离。
  • 限制代码对本地系统资源进行访问,防止对本地系统造成破环。
  • 保护 Java 原生的 JDK 代码。

沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的 Java 程序运行都可以指定沙箱,可以定制安全策略。JDK1.6时期,引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域。系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(ProtectedDomain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限。

你可能感兴趣的:(JVM,java,jvm,后端,开发语言)