当前位置: 首页 > 图灵资讯 > 技术篇> JVM

JVM

来源:图灵教育
时间:2023-05-29 14:01:24

5.1 JVM包含哪些部分?参考答案

JVM 主要由四部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地仓库接口),下图可大致描述 JVM 的结构。

JVM 是执行 Java 程序的虚拟计算机系统,让我们来看看执行过程:首先,我们需要准备编译 Java 字节码文件(即class文件),计算机需要以某种方式(类加载器)运行程序 class 文件加载到内存(运行时数据区),但字节码文件是JVM定义的指令集规范,它不能直接交给底层操作系统,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集 CPU 在执行过程中,需要调用一些不同的语言 Java 本地使用了提供的接口(如驱动、地图制作等。) Native 接口(本地库接口)。

ClassLoader:负责加载字节码文件,即加载字节码文件 class 文件,class 文件在文件开头有特定的文件标记, ClassLoader 只负责class 至于文件的加载是否可以运行,则由 Execution Engine 决定。

Runtime Data Area:存储数据分为五部分:Stack(虚拟机栈),Heap(堆),Method Area(方法区),PC Register(程序计数器),Native Method Stack(本地方法栈)。关于几乎所有的事情 Java 内存问题集中在这方面。

Execution Engine:执行引擎,也叫 Interpreter。Class 文件加载后,将指令和数据信息放入内存中,Execution Engine 负责向操作系统解释这些命令,即将到来 JVM 将指令集翻译成操作系统指令集。

Native Interface:负责调用本地接口。他的作用是调用不同语言的界面 JAVA 用,他会在 Native Method Stack 记录相应的本地方法,然后调用该方法 Execution Engine 加载相应的本地 lib。本来主要用于JAVA驱动、地图制作引擎等一些专业领域。现在,这种本地方法界面的调用已经被Socket通信、WebService等方式所取代。

5.2 JVM是如何工作的?参考答案

JVM的启动过程分为以下四个步骤:

JVM的装入环境和配置

java.Exe负责查找JRE,并按以下顺序选择JRE:

自己目录下的JRE;

JRE在父级目录下;

查看注册JRE。

装载JVM

在第一步找到JVM路径后,Java.JVM文件通过LoadJavaVM安装exe。LoadLibrary装载JVM动态连接库,然后JNI_CreateJavaVM和JVM中的到处函数JNI_GetDefaultJavaVMIntArgs Invocationfunction CreateJavaVM和GetDafaultJavaVMInitargs 函数指针变量。完成JVM装载工作。

初始化JVM获得本地调用接口

Invocationfunction -> CreateJavaVM,也就是JNI_CreateJavaVM方法获取JNIEnv结构的例子。

操作Java程序

Java程序运行Java程序有两种方式:Jar包 与 class。

运行jar 的时候,java.exe调用Getmainclasname函数,先获得JNIEnv实例,再调用JarfileJNIEnv类中的Getmanifest(),从其返回的Manifest对象中获取Getattrebutes(”Main-Class")的值,即jar 包中文件:META-INF/MANIFEST.MF指定的Main-Class主类名作为运行主类。之后,main函数会调用Java.LoadClass方法装载主类(FindClass使用JNIEnv实例)。

运行Class时,main函数直接调用Java.这类LoadClass方法装载在c中。

5.3 Java程序是如何运行的?参考答案

总而言之,写得好 Java 通过源代码文件 Java 编译器编译成字节码文件后,可以通过类加载器加载到内存中进行实例化,然后到达 Java 在虚拟机中解释执行,最后通过操作系统操作 CPU 执行获取结果如下图所示:

5.4 本地方法栈有什么用?参考答案

本地方法栈(Native Method Stacks)它与虚拟机栈的作用非常相似。区别在于虚拟机栈为虚拟机提供Java方法(即字节码)服务,而本地方法栈是为虚拟机使用的本地方法(Native)方法服务。

Java虚拟机规范对本地方法堆栈中使用的语言、使用方法和数据结构没有强制性规定。因此,具体的虚拟机可以根据需要自由实现,甚至一些Java虚拟机(如Hot-Spot虚拟机)也可以直接将本地方法堆栈与虚拟机堆栈相结合。和虚拟机栈一样,本地方法栈也会抛出Stackoverfloweror和outofmemoryeroro异常,当栈深度溢出或栈扩展失败时。

5.5 没有程序计数器会怎么样?参考答案

没有程序计数器,Java程序中的流程控制将无法正确控制,多线程也无法正确轮换。

扩展阅读

程序计数器(Program Counter Register)它是一个较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。在Java虚拟机的概念模型中,字节码解释器通过改变计数器的值来选择下一个需要执行的字节码指令。它是程序控制流的指示器,需要依靠计数器完成分支、循环、跳转、异常处理、线程恢复等基本功能。

由于Java虚拟机的多线程是通过轮流切换和分配处理器执行时间来实现的,处理器(对于多核处理器)只执行一个线程中的指令。因此,为了在线程切换后恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,不影响每个线程之间的计数器,并独立存储。我们称这种内存区域为“线程私有”内存。

如果线程正在执行Java方法,则该计数器记录虚拟机字节码指令的地址;如果是本地的(Native)该方法的计数器值应为空(Undefined)。这个内存区域是唯一一个在Java虚拟机规范中没有规定任何OutofmemoryEror情况的区域。

5.6 谈谈Java的内存分布参考答案

Java虚拟机在执行Java程序时,将其管理的内存划分为几个不同的数据区域。这些区域有自己的用途和创建和销毁的时间。随着虚拟机过程的启动,一些区域一直存在,一些区域依赖于用户线程的启动和结束。根据《Java虚拟机规范》,Java虚拟机管理的内存将包括以下运行数据区域。

程序计数器

程序计数器(Program Counter Register)它是一个较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。在Java虚拟机的概念模型中,字节码解释器通过改变计数器的值来选择下一个需要执行的字节码指令。它是程序控制流的指示器,需要依靠计数器完成分支、循环、跳转、异常处理、线程恢复等基本功能。

由于Java虚拟机的多线程是通过轮流切换和分配处理器执行时间来实现的,处理器(对于多核处理器)只执行一个线程中的指令。因此,为了在线程切换后恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,不影响每个线程之间的计数器,并独立存储。我们称这种内存区域为“线程私有”内存。

如果线程正在执行Java方法,则该计数器记录虚拟机字节码指令的地址;如果是本地的(Native)该方法的计数器值应为空(Undefined)。这个内存区域是唯一一个在Java虚拟机规范中没有规定任何OutofmemoryEror情况的区域。

Java虚拟机栈

Java虚拟机栈和程序计数器一样,(Java Virtual Machine Stack)线程也是私有的,其生命周期与线程相同。虚拟机栈描述了Java方法执行的线程内存模型:当每种方法执行时,Java虚拟机将同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作堆栈、动态连接、方法出口等信息。每一种方法都被调用到执行完成的过程中,对应着虚拟机栈中从入栈到出栈的栈帧过程。

在《Java虚拟机规范》中,该内存区域规定了两种异常情况:如果线程要求的栈深大于虚拟机允许的栈深,则抛出Stackoverflowerror异常;如果Java虚拟机栈的容量可以动态扩展,当栈扩展时不能申请足够的内存时,就会抛出outofmemoryeror异常。

本地方法栈

本地方法栈(Native Method Stacks)它与虚拟机栈的作用非常相似。区别在于虚拟机栈为虚拟机提供Java方法(即字节码)服务,而本地方法栈是为虚拟机使用的本地方法(Native)方法服务。

Java虚拟机规范对本地方法堆栈中使用的语言、使用方法和数据结构没有强制性规定。因此,具体的虚拟机可以根据需要自由实现,甚至一些Java虚拟机(如Hot-Spot虚拟机)也可以直接将本地方法堆栈与虚拟机堆栈相结合。和虚拟机栈一样,本地方法栈也会抛出Stackoverfloweror和outofmemoryeroro异常,当栈深度溢出或栈扩展失败时。

Java堆

对Java应用程序而言,Java堆(Java Heap)它是虚拟机管理的内存中最大的部分。在虚拟机启动时,Java堆是一个由所有线程共享的内存区域。这个内存区域的唯一目的是存储对象实例,Java世界中几乎所有的对象实例都在这里分配内存。Java虚拟机规范中对Java堆的描述是:“所有对象的例子和数组都应该分配在堆上”,作者在这里写的“几乎”是指从实现的角度来看,随着Java语言的发展,现在可以看到一些支持未来值类型的迹象,即使只考虑现在,由于即时编译技术的进步,特别是逃逸分析技术越来越强大,栈上分配和标量替换的优化手段导致了一些微妙的变化,所以Java对象的实例分配在堆上逐渐变得不那么绝对。

根据《Java虚拟机规范》,Java堆可以在物理不连续的内存空间中,但逻辑上应视为连续,就像我们使用磁盘空间存储文件一样,并不要求每个文件连续存储一样。然而,对于大对象(典型的数组对象),大多数虚拟机可能需要连续的内存空间,以实现简单和高效的存储。

Java堆可以实现固定大小或扩展,但目前主流的Java虚拟机它们都是根据可扩展性实现的(通过参数-Xmx和-Xms设置)。如果Java堆中没有内存完成实例分配,且堆不能再扩展,Java虚拟机将抛出OutofmemoryEror异常。

方法区

方法区(Method Area)与Java堆一样,它是每个线程共享的内存区域,用于存储类型信息、常量、静态变量、即时编译器编译的代码缓存等数据。虽然Java虚拟机规范将方法区描述为堆叠的逻辑部分,但它有一个别名叫“非堆叠”(Non-Heap),目的是区分Java堆。

根据《Java虚拟机规范》,如果方法区不能满足新的内存分配需求,则抛出outofmemoryerror异常。

常量池运行

常量池运行(Runtime Constant Pool)它是方法区的一部分。除了类似的版本、字段、方法、界面和其他描述信息外,Class文件中的另一个信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的常量池运行中。

由于常量池是方法区域的一部分,自然受到方法区域内存的限制,当常量池不能再申请内存时,会抛出Outofmemoryerror异常。

直接内存

直接内存(Direct Memory)它不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区。但这部分内存也被频繁使用,也可能导致OutofmemoryEror异常。

显然,直接内存的分配不会受到Java堆大小的限制,但由于它是内存,它肯定会受到总内存(包括物理内存、SWAP分区或页面文件)大小和处理器搜索空间的限制,一般服务器管理员根据实际内存设置-Xmx参数信息,但往往忽略直接内存,使每个内存区域的总和大于物理内存限制(包括物理和操作系统级的限制),导致动态扩展时OutofmemoryEror异常。

5.7 类存放在哪里?参考答案

方法区(Method Area)与Java堆一样,它是每个线程共享的内存区域,用于存储类型信息、常量、静态变量、即时编译器编译的代码缓存等数据。虽然Java虚拟机规范将方法区描述为堆叠的逻辑部分,但它有一个别名叫“非堆叠”(Non-Heap),目的是区分Java堆。

5.8 局部变量存储在哪里?参考答案

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

本地变量表存储了各种Java虚拟机的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是指向对象起始地址的引用指针,也可能指向代表对象的句柄或与对象相关的其他位置)和returnaddress类型(指向字节码指令的地址)。

5.9 介绍Java代码编译过程参考答案

从Javac代码的整体结构来看,编译过程大致可以分为一个准备过程和三个处理过程,如下所示。

准备过程:初始插入式注解处理器。

分析和填充符号表的过程包括:

词法和语法分析将源代码的字符流转换为标记集合,构建抽象语法树。

填写符号表,生成符号地址和符号信息。

插入式注解处理器的注解过程:

在Javac源码中,插入式注解处理器的初始化过程是在initporcesanotations()方法中完成的,其执行过程是在procesanotations()方法中完成的。这种方法将判断是否有新的注解处理器需要执行。如果是这样,通过Javacprocesing-Environmentdoprocesing()生成一个新的Javacompiler对象来处理编译的后续步骤。

分析和字节码生成过程包括:

标记检查,检查语法的静态信息。

分析数据流和控制流,检查程序的动态运行过程。

解语法糖,将简化代码编写的语法糖还原为原始形式。

生成字节码,将前一步生成的信息转化为字节码。

在上述三个处理过程中,插入式注释可能会产生新的符号。如果产生新的符号,则必须返回到以前的分析和填写符号表。总的来说,三者之间的关系和交互顺序如图所示。

5.10 介绍类加载过程的参考答案

从加载到虚拟机内存,到卸载内存,它的整个生命周期都会被加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,验证、准备和分析统称为连接(Linking)。这七个阶段的发生顺序如下图所示。

上述七个阶段包括类加载的全过程,即加载、验证、准备、分析和初始化。

一、加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)Java虚拟机在加载阶段需要完成以下三件事:

定义此类二进制字节流是通过一个类的全限定名来获得的。

将字节流所代表的静态存储结构转换为方法区运行时的数据结构。

在内存中生成代表这一类的java.lang.Class对象作为方法区各种数据的访问入口。

加载阶段结束后,Java虚拟机外的二进制字节流按照虚拟机设定的格式存储在方法区。方法区的数据存储格式完全由虚拟机定义,Java虚拟机规范没有规定该区域的具体数据结构。在方法区妥善安置类型数据后,Java堆内存将实例化为Java.lang.Class类对象将作为程序访问方法区类型数据的外部接口。

二、验证

验证是连接阶段的第一步。本阶段的目的是确保Class文件字节流中包含的信息符合Java虚拟机规范的所有约束要求,并确保该信息作为代码运行后不会危及虚拟机本身的安全。验证阶段大致完成以下四个阶段:文件格式验证、元数据验证、字节码验证和符号引用验证。

文件格式验证:

在第一阶段,需要验证字节流是否符合Class文件格式的规范,并且可以由当前版本的虚拟机处理。

元数据验证:

第二阶段是语义分析字节码描述的信息,以确保其描述的信息符合Java语言规范的要求。

字节码验证:

第三阶段是通过数据流分析和控制流分析来确定程序语义是合法和合乎逻辑的。

符号引用验证:

符号引用验证可视为对类别本身以外的各种信息(常量池中的各种符号引用)的匹配验证。一般来说,这类资源是否缺乏或禁止访问它所依赖的一些外部类别、方法、字段和其他资源。

三、准备

准备阶段是将内存分配并设置类变量初始值的阶段,正式定义为类中的变量(即静态变量,由static修改的变量)。从概念上讲,这些变量使用的内存应该分配在方法区域,但必须注意的是,方法区本身是一个逻辑区域。在JDK7和之前,HotSpot使用永久代来实现方法区域时,它完全符合这一逻辑概念。而在JDK 8.之后,类变量将与Class对象一起存储在Java堆中。此时,“方法区域的类变量”完全是逻辑概念的表达。

四、解析

分析阶段是Java虚拟机将常量池中的符号引用替换为直接引用的过程,在Class文件中以CONSTANT引用符号_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,分析阶段中提到的直接引用与符号引用有什么关系?

符号引用(Symbolic References):符号引用用一组符号来描述引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义地定位到目标。符号引用与虚拟机实现的内存布局无关,引用的目标不一定是已经加载到虚拟机内存中的内容。各种虚拟机可以实现不同的内存布局,但可接受的符号引用必须相同,因为符号引用的字面量形式在Java虚拟机规范的Class文件格式中明确定义。

直接引用(Direct References):直接引用是指可以直接指向目标的指针、相对偏移或可以间接定位到目标的句柄。直接引用与虚拟机实现的内存布局直接相关。不同虚拟机实例中翻译的直接引用相同的符号通常不相同。如果有直接引用,则引用的目标必须存在于虚拟机的内存中。

五、初始化

类的初始化阶段是类加载过程的最后一步。在之前介绍的类加载动作中,除了用户应用程序在加载阶段可以部分参与自定义类加载器外,其他动作完全由Java虚拟机控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,并将主导地位移交给应用程序。

在准备阶段,变量给出了系统要求的初始零值,在初始化阶段,初始化变量和其他资源将根据程序员通过程序编码制定的主观计划进行初始化。我们也可以从另一种更直接的形式来表达:初始化阶段是执行类结构()方法的过程。()不是程序员在Java代码中直接编写的方法,而是Javac编译器的自动生成物。

5.11 介绍对象的实例化过程参考答案

对象实例化过程是由非静态变量、非静态代码块和相应结构器组成的执行类结构函数对应于字节码文件中的()方法(实例结构器)。

()方法可以重载多个,类有几个结构器就有几个()方法;

()方法中的代码执行顺序为:父类变量初始化、父类代码块、父类构造器、子类变量初始化、子类代码块和子类构造器。

静态变量、静态代码块、普通变量、普通代码块、构造器的执行顺序如下:

父类子类的实例化顺序如下:

扩展阅读

Java是一种面向对象的编程语言,在Java程序运行过程中始终创建对象。在语言层面上,创建对象通常只是一个new关键字(例外:复制和反序列化),而在虚拟机中,创建对象(本文讨论的对象仅限于普通Java对象,不包括数组和Class对象等。)是一个怎样的过程?

当Java虚拟机遇到字节码new指令时,首先检查该指令的参数是否可以在常量池中定位为一个类的符号引用,并检查该符号引用所代表的类是否已加载、分析和初始化。如果没有,则必须首先执行相应的类加载过程。

类加载检查通过后,虚拟机将为新对象分配内存。在类加载完成后,可以完全确定对象所需内存的大小。为对象分配空间的任务实际上相当于从Java堆中划分一个确定大小的内存块。假设Java堆中的内存是绝对规则的,所有使用的内存都被放置在一边,空闲内存放在另一边,中间有一个指针作为边界点的指示器。分配的内存只是将指针移动到空闲空间的方向,与物体大小相等。这种分配方法被称为“指针碰撞”(Bump The Pointer)。但是,如果Java堆中的内存不规则,使用的内存与空闲内存交错,则无法简单地进行指针碰撞。虚拟机必须维护列表,记录哪些内存块是可用的。在分配过程中,从列表中找到足够大的空间,将其划分给对象实例,并更新列表上的记录。这种分配方法被称为“空闲列表”(Free List)。选择哪种分配方式取决于Java堆是否规则,Java堆是否规则取决于使用的垃圾收集器是否有空间压缩(Compact)能力决定。因此,使用Serial时、系统采用的分配算法是指针碰撞,简单高效;当使用基于清除的CMS时(Sweep)在算法收集器中,理论上只能使用更复杂的空闲列表来分配内存。

除了如何划分可用空间外,还有另一个问题需要考虑:对象创建在虚拟机中是一种非常频繁的行为。即使只修改一个指针指向的位置,在并发条件下也不是线程安全。可能有内存分配给对象A,指针没有时间修改,对象B使用原指针同时分配内存。解决这个问题有两种可选方案:一种是同步处理分配内存空间的动作——事实上,虚拟机采用CAS与失败重试相匹配,以确保更新操作的原子性;另一种是根据线程将内存分配的动作划分为不同的空间,即每个线程在Java堆中提前分配一小块内存,本地线程分配缓冲称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),在本地缓冲区分配内存的线程中,只有当本地缓冲区耗尽时,才需要同时锁定新的缓冲区。可以通过虚拟机使用TLAB吗?-XX:+/-Usetlab参数设置。

内存分配完成后,虚拟机必须将分配到的内存空间(但不包括对象头)初始化为零。如果使用TLAB,也可以提前到TLAB分配。此操作确保对象的实例字段可以直接在Java代码中使用,而无需赋予初始值,使程序能够访问这些字段的数据类型对应的零值。

接下来,Java虚拟机还需要设置对象,比如对象是什么样的例子,如何找到元数据信息,对象的哈希码(实际上,对象的哈希码会延迟到真正调用Object::hashCode()方法时计算)、对象GC分代年龄等信息。这些信息存储在对象的对象头上(Object Header)之中。根据虚拟机目前的运行状态,如是否使用偏向锁等,对象头会有不同的设置方式。

上述工作完成后,从虚拟机的角度来看,已经产生了一个新的对象。但从Java程序的角度来看,对象创建才刚刚开始——构建函数,即Class文件中的()方法尚未实施,所有字段均为默认零值,对象所需的其他资源和状态信息尚未按照预定意图构建。一般来说,Java编译器会在遇到new关键字的地方同时生成这两个字节码指令(由字节码流中new指令后面是否跟随invokespecial指令决定,但如果直接通过其他方式生成,则不一定如此),new指令后会执行()方法,根据程序员的意愿初始化对象。这样一个真正可用的对象是完全构造出来的。

5.12 元空间在栈内还是栈外?参考答案

在栈外,元空间占用本地内存。

扩展阅读

很多Java程序员习惯于在HotSpot虚拟机上开发和部署程序,很多人更愿意称方法区为“永久代”,或者混淆两者。本质上,这两者并不相等,因为只有当时的Hotspot虚拟机设计团队选择将收集器的分代设计扩展到方法区,或者使用永久代来实现方法区,使Hotspot垃圾收集器能够像管理Java堆一样管理这部分内存,省去了专门为方法区编写内存管理代码的工作。但BEAJrockittt等其他虚拟机的实现,、IBM 就J9而言,没有永久代的概念。如何实现方法区原则上属于虚拟机实现细节,不受Java虚拟机规范的约束,不需要统一。

现在回想起来,使用永久代来实现方法区的决定并不是一个好主意。这种设计导致Java应用程序更容易遇到内存溢出问题(永久代)-XX:MaxPermsize的上限,即使没有设置,也有默认尺寸,而J9和Jrockit只要没有触及过程中可用内存的上限,比如32位系统中的4GB限制,就不会有问题)。而且有很少的方法(比如String)::intern()由于永久代的原因,不同的虚拟机会有不同的性能。

当Oracle收购BEA并获得Jrockit的所有权时,准备将Jrockit中的优秀功能,如Java Mission 当Control管理工具移植到HotSpot虚拟机时,由于方法区域的差异,面临着许多困难。考虑到HotSpot的未来发展,JDK 6.Hotspot开发团队已经放弃了永久代,并逐渐改为使用本地内存(Native Memory)实现方法区规划,JDK 7.hotspot已经将原本放在永久代的字符串常量池、静态变量等移出,到达JDK 8.永久代的概念终于完全废弃了,JRockitt与JRockitt、在本地内存中实现J9的元空间(Meta-space)用JDK代替 7中永久代剩余的内容(主要是类型信息)全部移动到元空间。

5.13 谈谈JVM的类加载器,以及父母指定的模型参考答案

一、类加载器

Java虚拟机设计团队故意将Java虚拟机外部实现“通过一个类的全限定名获取描述该类的二进制字节流”的动作,以便应用程序决定如何获得所需的类。实现此动作的代码称为“类加载器”(Class Loader)。

虽然类加载器只用于实现类加载动作,但它在Java程序中的作用远远超过类加载阶段。对于任何类,加载它的类加载器都必须与Java虚拟机本身一起确立其独特性。每个类加载器都有一个独立的类名空间。这句话可以表达得更流行:比较两类是否“相等”,只有在这两类是由同一类加载器加载的前提下才有意义。否则,即使这两类来自同一Class文件,并由同一Java虚拟机加载,只要加载的类加载器不同,这两类必须不相等。

二、双亲委派模型

Java自JDK1.2以来一直保持着三层加载器和父母指定的加载架构。对于这一时期的Java应用程序,绝大多数Java程序将使用以下三个系统提供的类加载器进行加载。

启动式加载器(Bootstrap Class Loader):这种类型的加载器负责加载和存储在\lib目录中,或存储在-Xbotclasspath参数指定的路径中,Java虚拟机可以识别(根据文件名识别,如rt.jar、tools.jar,即使在lib目录中,名称不一致的类库也不会加载)类库加载到虚拟机的内存中。Java程序不能直接引用启动式加载器。用户在编写自定义式加载器时,如果需要将加载请求委托给指导式加载器,则可以直接用null代替。

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

应用程序类加载器(Application Class Loader):这种类型的加载器是由sunn这种类型的.misc.Launcher$实现Appclasloader。因为应用程序类加载器是Classloader类中的getsysteme-ClassLoader()方法的返回值,所以在某些情况下也被称为“系统加载器”。负责加载用户类路径(ClassPath)对于上述所有类库,开发人员也可以直接在代码中使用此类加载器。如果应用程序中没有定制自己的类加载器,这通常是程序中默认的类加载器。

这些类型加载器之间的合作关系“通常”如下图所示,图中显示的各种类型加载器之间的层次关系被称为类型加载器的“父母指定模型”(Parents Delegation Model)”。除顶层启动式加载器外,双亲委派模型要求其他类加载器都有自己的父式加载器。然而,这里加载器之间的父子关系通常不是继承的(Inheritance)通常使用组合来实现的关系(Composition)关系重用父加载器的代码。

父母指定模型的工作过程是:如果类加载器收到类加载请求,它不会尝试加载类,而是委托父加载器完成请求,每个层次的类加载器,所以所有加载请求最终应传输到顶部启动加载器,只有当父加载器反馈不能完成加载请求(其搜索范围未找到所需类),子加载器试图自己完成加载。

使用父母指定的模型来组织类加载器之间的关系,一个明显的好处是Java中的类与其类加载器有优先级关系。例如类java.lang.Object,它存放在rt中.在jar中,无论哪种类型的加载器需要加载这个类别,它最终都被指定为模型顶部的启动式加载器。因此,object类可以确保在程序的各种类型的加载器环境中是相同的类别。相反,如果不使用父母指定的模型,由各种加载器加载,如果用户自己写了一个名为java.lang.Object类,并且放在程序的Classpath中,那么系统中就会出现许多不同的Object类,Java类型系统中最基本的行为也无法保证,应用程序也会变得一片混乱。

扩展阅读

确保Java程序的稳定运行是非常重要的,但它的实现非常简单,只有十多行代码可以实现,都集中在Java.lang.ClassloaderloadClass()方法中。

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats PerfCounter.getParentDelegationTime().addTime(t1 - t0); PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }该代码的逻辑清晰易懂:首先检查请求加载的类型是否已加载,如果没有,则调用父加载器的loadClass()方法,如果父加载器为空,则默认使用启动式加载器作为父加载器。如果父类加载器加载失败,抛出Classnotfoundexception异常,则调用自己的findClass()方法试图加载。

5.14 父母的分配机制会被破坏吗?参考答案

父母指定的模型不是一个强制约束的模型,而是Java设计师向开发者推荐的类加载器实现方式。Java世界中的大多数类加载器都遵循这个模型,但也有例外。直到Java模块化出现,父母指定的模型主要出现了三次大规模的“损坏”。

双亲委派模型的第一次“破坏”实际上发生在双亲委派模型出现之前——JDK 以前的“古代”时代出现了1.2。因为双亲委派的模型在JDK 1.2后才引入,但类加载器的概念和抽象类java的概念.lang.Clasloader已经存在于Java的第一个版本中。面对现有用户定制的加载器代码,Java设计师在引入父母指定的模型时不得不做出一些妥协,以适应这些现有代码,loadClass()被子类覆盖的可能性不能用技术手段避免,只能在JDK 1.2之后的java.lang.在Classloader中添加一种新的protected方法findClass(),并在引导用户编写类加载逻辑时尽可能重写该方法,而不是在loadClass()中编写代码。根据loadClass()方法的逻辑,如果父亲的加载失败,他们将自动调用自己的findClass()方法来完成加载,这不会影响用户根据自己的意愿加载类,也可以确保新写的类加载器符合父母的规则。

父母指定模型的第二次“损坏”是由模型本身的缺陷造成的,父母指定很好地解决了基本类型的一致性问题(基本类型越高),基本类型被称为“基础”,因为它们总是作为用户代码继承,呼叫API,但程序设计往往没有绝对不变的完美规则,如果有基本类型需要调用回用户的代码,该怎么办?

这并非不可能。一个典型的例子是JNDI服务。JNDI现在是Java的标准服务。其代码由启动式加载器加载(在JDK) 1.3加入rt.jar),必须属于Java中非常基本的类型。然而,JNDI存在的目的是搜索和集中管理资源。它需要在应用程序的Classpath下调用JNDI服务提供商界面,由其他制造商实现和部署(Service Provider Interface,SPI)代码,现在问题来了,启动式加载器是不可能理解和加载这些代码的,那该怎么办呢?

为了解决这一困境,Java的设计团队不得不引入一个不优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这种加载器可以通过java.lang.Threadsetcontextextexteread类-ClassLoader()设置方法。如果在创建线程时没有设置,它将从父线程中继承一个。如果在应用程序的整体范围内没有设置,则该类加载器默认为应用程序类加载器。

有了线程上下文类加载器,程序可以做一些“欺诈”的事情。JNDI服务使用上下文类加载器加载所需的SPI服务代码,这是一种父加载器要求子加载器完成类加载行为,这种行为实际上是通过父母指定模型水平结构逆向使用加载器,违反了父母指定模型的一般原则,但也无助。Java中涉及SPI的加载基本都是这样完成的,比如JNDI、JDBC、JCE、JAXB、JBI等。然而,当SPI服务提供商超过一个时,代码只能根据特定提供商的类型进行硬编码判断。为了消除这种非常不优雅的实现方法,JDK 6点,JDK提供Java.util.Serviceloader类,META-INF/services中的配置信息,辅以责任链模式,这是SPI加载的一个相对合理的解决方案。

父母指定模型的第三次“破坏”是由用户对程序动态的追求造成的。这里的“动态”指的是一些非常“流行”的术语:代码热替换(HotSwap)、模块热部署(Hot Deployment)等。说白了,希望Java应用程序能像我们的电脑外设一样连接鼠标和U盘,不用重启机器就能立即使用。如果鼠标有问题或需要升级,请更改鼠标,无需关闭或重启。对于个人电脑来说,重启一次没什么大不了的,但对于一些生产系统来说,关机重启一次可能会被列为生产事故。在这种情况下,热部署对软件开发者,尤其是大型系统或企业软件开发者非常有吸引力。

早在2008年,Sun/Oracle公司就提出了JSR-294,Java社区关于模块化规范的第一场战役、JSR-277标准提案被IBM公司主导的JSR-291击败(即OSGi R4.2)提案。虽然Sun/Oracle不愿意失去Java模块化的主导地位,然后拿出Jigsaw项目迎战,但OSGi已经站稳了脚跟,成为业内“实际”的Java模块化标准。长期以来,IBM凭借OSGI的广泛应用基础,让Jigsaw吃尽苦头,其影响一直持续到Jigsaw和JDK 9出现才算结束。即使Jigsaw现在是Java的标准功能,它仍然需要小心避免动态热部署的优势,仅限于静态解决模块间包装隔离和访问控制的问题,现在让我们简要看看OSGi是如何通过类加载器实现热部署的。

OSGi实现模块化热部署的关键是实现其自定义的类加载器机制。每个程序模块(OSGi称为bundle)都有自己的类加载器。当需要更换bundle时,用类似的加载器更换bundle以实现代码的热替换。在OSGI环境下,类加载器不再是父母推荐的树结构,而是进一步发展成更复杂的网结构。当收到类加载请求时,OSGI将按以下顺序进行类搜索:

以java为例.*第一类,委派给父类加载器加载。

否则,将指定列表中的类别分配给父加载器。

否则,将Import列表中的类委托给ExportBundle类加载器加载。

否则,用自己的类加载器查找当前Bundle的Classpath。

否则,找出类别是否在自己的Fragment中 在bundle中,如果是,将被任命为Fragmentt Bundle类加载器加载。

否则,找Dynamicc 将Import列表的Bundle委派给相应Bundle的类加载器加载。

否则,类别搜索失败。

在上述搜索顺序中,只有前两点仍然符合父母指定模型的原则,其他类别的搜索是在平面类加载器中进行的。

5.15 介绍Java垃圾回收机制参考答案

1.哪些内存需要回收?

在Java内存运行区域的各个部分中,堆叠和方法区域具有明显的不确定性:多个实现类的一个接口可能不同,不同条件的一种方法分支可能不同,只有在运行期间,我们才能知道程序将创建什么对象,创建多少对象,这部分内存的分配和回收是动态的。垃圾收集器关注的是如何管理这部分内存。我们通常所说的内存分配和回收只指这部分内存。

二、如何定义垃圾

引用计数算法:

在对象中添加一个引用计数器,每当有一个地方引用时,增加一个计数器值;当引用失败时,计数器值减少一个;任何时候都不可能使用计数器为零的对象。

然而,在Java领域,至少主流Java虚拟机没有选择引用计数算法来管理内存,主要原因是这个看似简单的算法有很多例外,必须配合大量的额外处理来确保正确的工作,如简单的引用计数很难解决对象之间的循环引用问题。

举个简单的例子:对象obja和objb都有字段instance,赋值obja.instance=objB及objB.instance=objA,此外,这两个对象没有引用。事实上,这两个对象不再可能被访问,但它们的引用计数不是零,因为它们相互引用,引用计数算法不能回收它们。

可达性分析算法:

通过可达性分析,目前主流商业程序语言的内存管理子系统(Reachability Analysis)判断对象是否存活的算法。该算法的基本思路是通过一系列称为“GC Roots根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为“引用链”(Reference Chain),若某个对象到GC Roots之间没有引用链连接,也没有图论 当Roots无法到达这个对象时,证明这个对象是不可能再使用的。

如下图所示,对象object 5、object 6、object 虽然7是相互关联的,但它们去GC Roots是无法实现的,因此它们将被判定为可回收对象。

在Java技术系统中,固定可用作GC Roots的对象包括以下几种:

在虚拟机栈(栈帧中的本地变量表)中引用的对象,如参数、局部变量、临时变量等。

Java类引用类型的静态变量是方法区中类静态属性引用的对象。

常量引用在方法区的对象,如字符串常量池(String Table)里的引用。

JNI(通常称为Native方法)在本地方法栈中引用的对象。

Java虚拟机内部的引用,如基本数据类型对应的Class对象,以及一些常驻异常对象(如Nullpointexcepiton)、OutOfMemoryError)等等,还有系统加载器。

所有被同步锁(synchronized关键字)持有的对象。

JMXBeann反映了Java虚拟机的内部情况、JVMTI注册回调、本地代码缓存等。

回收方法区:

方法区垃圾收集主要回收两部分:废弃常量和不再使用类型。回收的常量与回收Java堆中的物体非常相似。以常量池中字面量回收的例子为例,如果字符串“java“曾经进入常量池,但目前系统中没有字符串对象的值。”java换句话说,常量池中没有引用任何字符串对象的“java“常量,虚拟机中没有其他地方引用这个字面量。如果此时发生内存回收,确实有必要判断垃圾收集器,“java常量池将被系统清理干净。常量池中的其他类别(接口)、符号引用的方法和字段也相似。

判断一个常量是“废弃”还是比较简单,要判断一个类型是否属于“不再使用”“使用类”的条件比较苛刻。需要同时满足以下三个条件:

所有这样的例子都被回收了,也就是说,Java堆中没有这样的例子及其任何派生子类。

加载此类加载器已被回收利用,除非是精心设计的可替代加载器场景,如OSGi、JSP重加载等,否则通常很难实现。

这类对应的java.lang.Class对象在任何地方都没有被引用,也不能通过反射访问任何地方。

三、如何回收垃圾

分代收集理论:

目前,大多数商业虚拟机的垃圾收集器都遵循“分代收集”(GenerationalCollection)设计理论,分代收集称为理论,本质上是一套符合大多数程序运行实际情况的经验法则,它基于两个分代假说:

弱分代假说(Weak Generational Hypothesis):绝大多数对象朝生夕灭。

强分代假说(Strong Generational Hypothesis):垃圾收集过程越多,死亡的对象就越困难。

这两个分代假设共同奠定了许多常用垃圾收集器的一致设计原则:收集器应将Java堆分为不同的区域,然后根据其年龄(年龄是垃圾收集过程的数量)将回收对象分配到不同的区域。显然,如果一个地区的大多数对象日夜灭亡,很难通过垃圾收集过程,那么把它们放在一起,只注意如何保持少量的生存,而不是标记大量的回收对象,可以以更低的价格回收大量的空间;如果其余的是不可消灭的对象,那么虚拟机可以以更低的频率回收该区域,这也考虑了垃圾收集的时间成本和内存空间的有效利用。

标记-清除算法:

最早和最基本的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如其名称,分为“标记”和“删除”两个阶段:首先标记所有需要回收的对象,在标记完成后,统一回收所有标记的对象,也可以反过来标记生存的对象,统一回收所有未标记的对象。

它的主要缺点有两个:一是执行效率不稳定。如果Java堆中包含大量物体,且大部分物体需要回收,则必须进行大量标记和清除,导致标记和清除过程的执行效率随着物体数量的增加而降低;二是内存空间的碎片化。标记和清除后会产生大量不连续的内存碎片,太多的空间碎片可能会导致另一个垃圾收集动作,当未来在程序运行过程中需要分配大对象时,无法找到足够的连续内存。标记-清除算法的执行过程如下图所示。

标记-复制算法:

1969年,Fenichel提出了一种叫做“半区复制”的方法,以解决标记-清除算法面临大量可回收对象时执行效率低的问题(Semispace Copying)垃圾收集算法将可用内存按容量分为两部分,每次只使用其中一部分。当这一块的内存用完时,将活着的对象复制到另一块,然后一次清理使用过的内存空间。如果大多数内存对象存活,该算法将产生大量的内存复制成本,但对于大多数对象是可回收的,该算法需要复制占少数存活对象,每次整个半区域内存回收,分配内存不需要考虑复杂的空间碎片,只要移动顶部指针,按顺序分配。这样,操作简单高效,但其缺陷也很明显。这种复制回收算法的成本是将可用内存缩小到原来的一半,浪费了太多的空间。标记-复制算法的执行过程如下图所示。

在1989年,Andrew 针对具有“朝生夕灭”特点的对象,Appel提出了一种更优化的半区复制分代策略,现称为“Appel回收”。Appel回收的具体方法是将新一代分为大Eden空间和两个小Survivor空间,每次只使用Eden和其中一个Survivor分配内存。垃圾收集发生时,将Eden和Survivor中仍存活的对象一次性复制到另一个Survivor空间,然后直接清理Eden和已使用的Survivor空间。默认情况下,HotSpot虚拟机Eden与Survivor的比例为8∶1.也就是说,每一代新一代可用的内存空间是整个新一代容量的90%(Eden的80%加上Survivor的10%),只有一个Survivor空间,也就是说,10%的新一代将被“浪费”。当然,98%的对象可以回收,只是在“普通场景”下测量的数据。没有人能100%保证每次回收只有不到10%的对象存活。因此,Appel回收还有一个安全设计,充当罕见的“逃生门”,当Survivor空间不足以容纳一次Minor时 当GC之后存活的对象时,需要依靠其他内存区(其实大部分都是老年人)来分配担保(Handle Promotion)。

标记-整理算法:

当对象存活率较高时,标记-复制算法应进行更多的复制操作,效率将降低。更重要的是,如果你不想浪费50%的空间,你需要有额外的空间来分配担保,以应对被使用内存中所有对象100%存活的极端情况。因此,这种算法在老年人不能直接选择。

1974年,Edward针对老年对象的生存特征 Lueders提出了另一个有针对性的“标记-整理”(Mark-Compact)算法中的标记过程仍然与“标记-清除”算法相同,但后续步骤不是直接清理可回收对象,而是将所有生存对象移动到内存空间的一端,然后直接清理边界以外的内存,“标记-整理”算法的示意图如下图所示。

5.16 请介绍分代回收机制的参考答案

目前,大多数商业虚拟机的垃圾收集器都遵循“分代收集”(GenerationalCollection)[插图]理论设计,分代收集被称为理论,本质上是一套符合大多数程序运行实际情况的经验法则,它基于两个分代假说:

弱分代假说(Weak Generational Hypothesis):绝大多数对象朝生夕灭。

强分代假说(Strong Generational Hypothesis):垃圾收集过程越多,死亡的对象就越困难。

这两个分代假设共同奠定了许多常用垃圾收集器的一致设计原则:收集器应将Java堆分为不同的区域,然后根据其年龄(年龄是垃圾收集过程的数量)将回收对象分配到不同的区域。在目前的商用Java虚拟机中,设计师通常至少将Java堆划分为新一代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新一代,每次收集垃圾时,都会发现大量物体死亡,而每次回收后存活的少量物体将逐渐升级为老年人。

分代收集不仅仅是简单地划分内存区域,至少有一个明显的困难:对象不是孤立的,对象之间会有跨代引用。如果你现在想收集仅限于新一代地区,但新一代的对象完全有可能被老年人引用,为了找到该地区的生存对象,你必须在固定的GC中 除了Roots,整个老年时代的所有对象都是额外的,以确保可达性分析结果的正确性,反过来也是如此。虽然整个老年人的所有对象方案理论上都是可行的,但无疑会给内存回收带来很大的性能负担。为了解决这一问题,有必要在分代收集理论中添加第三条经验规则:

跨代引用假说(Intergenerational Reference Hypothesis):与同代引用相比,跨代引用只占少数。

根据这个假设,我们不应该扫描整个老年人的少量跨代引用,也不必浪费空间来记录每个对象是否存在以及哪些跨代引用。我们只需要在新一代建立一个全球数据结构(称为“记忆集”,RememberedSet),这种结构将老年人划分为几个小块,标志着老年人的哪个内存会被跨代引用。从那时起,Minor就发生了 GC时,只有包含跨代引用的小块内存中的对象才会添加到GC中 扫描Roots。虽然这种方法需要在对象改变引用关系(如赋予自己或属性)时保持记录数据的正确性,这将增加运行过程中的一些费用,但与收集时扫描整个老年人相比仍然具有成本效益。

5.17 JVM中完整的GC流程是什么?参考答案

新创造的对象一般分布在新一代,新一代常用的垃圾回收器是 ParNew 它遵循垃圾回收器 8:1:1 将新一代分成 Eden 区,两个 Survivor 区。在某个时刻,我们创建的对象将是 Eden 所有区域都挤满了,这个对象是挤满新一代的最后一个对象。这时,Minor GC 就触发了。

在正式 Minor GC 前,JVM 会先检查新一代的对象,是比老一代的剩余空间大还是小。为什么要做这样的检查?原因很简单,如果 Minor GC 之后 Survivor 剩余对象不能放在区内,这些对象会进入老年人,所以要提前检查老年人是否足够。有两种情况:

老年人的剩余空间大于新生代的对象大小,所以直接Minoror GC,GC完survivor不够放,老年人绝对够放;

老年人的剩余空间小于新一代对象的大小。此时,有必要检查“老年人的空间分配担保规则”是否已经启用。具体来说, -XX:-HandlePromotionFailure 是否设置了参数。

老年人的空间分配担保规则是这样的。如果老年人的剩余空间大小大于以前的空间大小 Minor GC 然后允许剩余对象的大小进行 Minor GC。因为就概率而言,之前的放下,这次也应该放下。有两种情况:

老年人的剩余空间大于以前的Minoror GC后剩余对象的大小,进行 Minor GC;

老年人的剩余空间比以前的Minoror小 GC后剩余对象的大小,Fulll GC,把老年人空出来再检查。

老年空间分配担保规则的开放只能说是高概率的,Minor GC 剩下的对象足以放在老年,所以当然也会有万一,Minor GC 以后会有三种情况:

Minor GC 之后的对象就足够放了 Survivor 区,大家都开心,GC 结束;

Minor GC 之后的对象还不够放 Survivor 区,然后进入老年,老年可以放下,也可以,GC 结束;

Minor GC 之后的对象还不够放 Survivor 地区,老年人也放不下,那只能 Full GC。

成功就在前面 GC 例子,还有 3 会导致中等情况 GC 失败,报 OOM:

紧接上一节 Full GC 之后,老年人还是放不下剩下的对象,只能放下剩下的对象 OOM;

老年人分配担保机制没有一次开放 Full GC 之后,老年人还是放不下剩下的对象,只能 OOM;

开启老年人分配担保机制,但担保不合格,一次 Full GC 之后老年人还是放不下剩下的对象,也是可以的 OOM。

5.18 Full GC会导致什么?参考答案

Full GC会“Stop The World也就是说,用户的应用程序在GC期间被暂停。