关注「Java视界」公众号,获取更多技术干货

【一】不要问我JVM !

一、什么是JVM?

JVM是Java的核心,因为所有的Java程序都运行在JVM上。

那么什么是JVM?

JVM即Java虚拟机,是一台执行Java字节码的虚拟机,拥有独立的运行机制(其运行的Java字节码也不一定由Java语言编译而成)。

JVM就是二进制字节码的运行环境,负责装载字节码到其内部,解释、编译为对应的机器指令执行。要具有这种能力,就要做到每一条Java指令,JVM中都有详细的定义,如怎么取操作数、怎么定义变量等。

JVM使得Java(也可以是其他语言)具有跨平台性、优秀的垃圾回收器以及可靠的即时编译器。

JVM的位置:
在这里插入图片描述
JVM直接在操作系统上运行,不与硬件直接交互。

JVM整体结构:
在这里插入图片描述

二、Java代码执行流程

在这里插入图片描述

三、JVM的架构模型

java编译器输入的指令流一种是基于栈的指令集架构,另外一种指令集架构基于寄存器的指令集架构。

1.基于栈式架构的特点:

  1. 设计和实现更简单,适用于资源受限的系统;
  2. 避开了寄存器的分配难题:使用零地址指令方式分配;
  3. 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
  4. 不需要硬件支持,可移植性更好,更好实现跨平台

2.基于寄存器架构的特点

  1. 典型的应用是X86的二进制指令集:比如传统的pc以及andriod的Davlik虚拟机;
  2. 指令集架构则完全依赖于硬件,可移植性差;
  3. 性能优秀和执行更高效。
  4. 花费更少的指令去完成一项操作

为了追求跨平台性,java的指令都是根据栈来设计的(不同平台的cpu架构不同,所以不能设计为基于寄存器的)。基于栈式架构优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

四、JVM的生命周期

4.1 启动

JVM的启动是通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class)开始的,至于这个类是什么类,是由具体的虚拟机的实现来指定的!

4.2 执行

程序执行时虚拟机执行,程序执行结束,它结束,执行一个java程序的时候,实际上是在执行一个java虚拟机进程!

4.3 退出

虚拟机的退出

  1. 程序正常执行结束
  2. 程序执行过程中遇到错误或者异常而终止
  3. 由于操作系统遇到问题而导致java虚拟机进程终止
  4. 程序调用了runtime或者system类的exit方法,并且java安全管理器也允许这次的操作

五、JVM的发展历程

六、JVM的结构

在这里插入图片描述
详细点的:
在这里插入图片描述
类的子系统负责从文件系统或者网络中加载.class文件,class文件在文件开头有特定的文件标识。
加载的类信息存放在方法区(方法区还保存运行时常量池信息,字符串常量、数字常量)
但是类的加载器只负责class文件的加载,至于它是否可以运行是执行引擎决定的。

七、类的加载过程

7.1 类加载器 classLoader角色

  1. .class文件是保存在本地磁盘上的一个模板,这个模板最终是要在执行的时候被加载到JVM中,然后JVM根据这个模板实例化一个一模一样的实例
  2. .class文件被加载到JVM的方法区,被称为DNA元数据模板
  3. 在.class文件–>JVM–>DNA元数据模板的过程中,就需要一个运输工具(类加载器 classLoader)来充当快递员的角色

7.2 类的加载过程

在这里插入图片描述
加载的时候会先判断下之前是否已经加载过,然后才开始链接:
在这里插入图片描述
① 通过一个类的权限定名去获取对应的.class文件,这个文件是二进制字节码文件
② 将这个字节码代表的静态存储结构转化成方法区的运行时数据结构
③ 在内存生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口

加载阶段结束后, Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,类型数据妥善安置在方法区之后,会在Java 堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

加载.class文件的方式
1.从本地加载.class文件
2.从网络加载.class文件
3.从本地加载.zip、.jar等归档文件
4.从专用数据库提取.class文件
5.从内存中加载.class文件(.java文件动态编译)
6.从加密文件中获取,典型的防class文件被篡改

7.3 链接

加载阶段与链接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
在这里插入图片描述

7.3.1 验证

验证的目的是确保 Class 文件的字节流中包含的信息符合《 Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java 语言本身是相对安全的编程语言(起码对于 C/C++ 来说是相对安全的),使用纯粹的 Java 代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译。但前面也曾说过, Class文件并不一定只能由 Java 源码编译而来,它可以使用包括靠键盘 0 和 1 直接在二进制编辑器中敲出 Class文件在内的任何途径产生。上述 Java 代码无法做到的事情在字节码层面上都是可以实现的,至少 语义上是可以表达出来的。Java 虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java 虚拟机保护自身的一项必要措施。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify : none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

① 文件格式验证

要验证字节流是否符合 Class文件格式的规范,比如:

  • 主、次版本号是否在当前Java虚拟机接受范围之内
  • 常量池的常量中是否有不被支持的常量类型
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量

只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

② 元数据验证

元数据验证是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

③ 字节码验证

字节码验证主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

④ 符号引用验证

符号引用验证发生在虚拟机将符号引用转化为直接引用 的时候,这个转化动作将在连接的第三阶段—— 解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。例如:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

7.3.2 准备

准备阶段是正式为类中定义的静态变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段。

7.3.3 解析

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

符号引用:直接引用
在这里插入图片描述
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在 Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

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

7.4 初始化

在这里插入图片描述
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。

八、类的加载器ClassLoader

8.1 ClassLoader分类

站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:

  • 引导类加载器( Bootstrap ClassLoader),使用 C++ 语言实现 ,是虚拟机自身的一部分;
  • 自定义类加载器,由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
    在这里插入图片描述
  • 启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在
    <JAVA_HOME>\lib目录,或被-Xbootclasspath参数所指定的路径中。而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

  • 应用程序类加载器(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

代码中获取下类加载器:

public class GetClassLoader {
    public static void main(String[] args) {
        ClassLoader classLoader = GetClassLoader.class.getClassLoader();
        Console.log("[classLoader]:{}", classLoader);
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        Console.log("[systemClassLoader]:{}", systemClassLoader);
        ClassLoader parent = systemClassLoader.getParent();
        Console.log("[parent]:{}", parent);
        ClassLoader parentParent = parent.getParent();
        Console.log("[parentParent]:{}", parentParent);
        
        ClassLoader stringClassLoader = String.class.getClassLoader();
        Console.log("[stringClassLoader]:{}", stringClassLoader);
    }
}
[classLoader]:sun.misc.Launcher$AppClassLoader@18b4aac2
[systemClassLoader]:sun.misc.Launcher$AppClassLoader@18b4aac2
[parent]:sun.misc.Launcher$ExtClassLoader@51565ec2
[parentParent]:null
[stringClassLoader]:null

可以看出:

  1. 对于用户自定义类来说,默认使用系统加载器AppClassLoader进行加载
  2. 系统类加载器的上层加载器是扩展类加载器ExtClassLoader,加载java.ext.dirs系统指定的目录的类库
  3. 扩展类加载器的上层加载器是引导类加载器,C++编写,所以获取不到,是null
  4. String类型默认使用的是 引导类加载器,获取不到,是null。它只加载java、javax、sun开头的类

8.2 为什么要自定义类加载器?怎么实现自定义类加载器?

为什么要自定义类加载器?

  1. 隔离加载类
  2. 修改类加载的方式
  3. 扩展加载源
  4. 防止源码泄漏

怎么实现自定义类加载器?

  1. 开发人员通过继承java.lang.ClassLoader类的方式,实现自己的类加载器能满足上面的需求
  2. JDK1.2时,继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类。JDK1.2后,可以在findClass()方法中去编写自定义的类加载逻辑
  3. 在编写自定义类加载器时,若没有太过复杂的需求,可以直接继承URLClassLoader类,可以避免自己去编写findClass()方法中的逻辑以及获取字节码流的方式。

8.3 ClassLoader的获取

ClassLoader是一个抽象类,除了C++实现的引导类加载器之外,其他的自定义类加载器都要继承于它。

它的方法有:

方法名称作用
getParent()返回其上层加载器
loadClass(String name)返回名称为name的类
findClass(String name)返回名称为name的类
findLoadedClass(String name)返回已经被加载过的名称为name的类
defineClass()把数组内容转换为Java类
resolveClass()链接指定的java类
  • 获取当前类的加载器
    aClass.getClassLoader()
  • 获取当前线程上下文的加载器
    Thread.currentThread().getContextClassLoader()
public class ClassLoaderTest {
    public static void main(String[] args) {
        try {
            Class<?> aClass = Class.forName("JVM.GetClassLoader");
            ClassLoader classLoader = aClass.getClassLoader();
            Console.log("【1】{}", classLoader);
            ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
            Console.log("【2】{}", contextClassLoader);
            ClassLoader parent = ClassLoader.getSystemClassLoader().getParent();
            Console.log("【3】{}", parent);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
【1】sun.misc.Launcher$AppClassLoader@18b4aac2
【2】sun.misc.Launcher$AppClassLoader@18b4aac2
【3】sun.misc.Launcher$ExtClassLoader@1593948d

九、双亲委派机制

9.1 类的唯一性

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这个结论很重要!这是设计“双亲委派”模型的原因。

9.2 双亲委派

“ 双亲委派模型(Parents Delegation Model)”就是各种类加载器之间的层次关系。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。

双亲委派模型的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的引导类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

双亲委派的好处就是所有类的加载最终都会汇聚到启动类加载器来加载,根据前面类和类加载器的关系介绍, 类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,这样就使得这个类在程序的各种类加载器环境中都能够保证是同一个类。如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath 中,那系统中就会出现多个不同的 Object 类, Java 类型体系中最基础的行为也就无从保证,应用程序将会一片混乱。

另外,双亲委派也避免了一个类的重复加载。

9.3 沙箱安全机制

前面的代码演示了 String 类型默认使用的是 引导类加载器。
引导类加载器会先加载JDK自带的java.lang.String,而不是去加载别的路径下的String类,这就是沙箱安全机制。这样的方式可以保证Java源码的安全。

十、类的主动使用和被动使用

对类的使用方式分为:主动使用、被动使用。

主动使用:

  • 1:通过new关键字创建实例
  • 2:访问类的静态变量,包括读取和更新
  • 3:访问类的静态方法
  • 4:反射操作,会导致类的初始化
  • 5:初始化子类会导致父类的的初始化
  • 6:执行该类的main函数

其他方式就是被动使用

  • 1:构造某个类的数组时不会导致该类的初始化
  • 2:引用该类的静态常量,注意是常量,不会导致初始化

9.4 打破双亲委派机制

为何要破坏双亲委派模型?

打破的原因是双亲委派一个类只会加载一次,而热部署需要重新加载类,就只能打破了,不然类修改了不会实时生效。

按照双亲委派模型,加载类需要委托父类加载器进行加载,父类加载器加载不了再逐层向下委派。使用线程上下文类加载器以后,可以根据类限定名直接加载,不需要委托父类加载器了,也就打破了“双亲委派模型”。

以Tomcat为例,为什么Tomcat要破坏双亲委派?

Tomcat是web容器,那么一个web容器可能需要部署多个应用程序。不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。

如多个应用都要依赖hollis.jar,但是A应用需要依赖1.0.0版本,但是B应用需要依赖1.0.1版本。这两个版本中都有一个类是com.hollis.Test.class。

如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。

所以,Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器。

Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。

如何打破双亲委派机制?
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。了有线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

posted @ 2022-06-25 14:01  沙滩de流沙  阅读(22)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货