JVM 类加载机制
面试必问的 JVM 类加载机制,你懂了吗?
转载:面试必问的 JVM 类加载机制,你懂了吗?_程序员囧辉的博客-CSDN博客
1、类加载的过程。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
1)加载
“类加载”过程的一个阶段,在加载阶段,虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
2)验证
连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
3)准备
该阶段是正式为类变量(static修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里所说的初始值“通常情况”下是数据类型的零值,下表列出了Java中所有基本数据类型的零值。
4)解析
该阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
5)初始化
到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序制定的主观计划去初始化类变量和其他资源。
我们也可以从另外一种更直接的形式来表达:初始化阶段是执行类构造器
我之前还写过一篇关于初始化的面试题: 一道有意思的“初始化”面试题,有兴趣的同学可以看一看。
2、Java 虚拟机中有哪些类加载器?
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度来看,绝大部分Java程序都会使用到以下3种系统提供的类加载器。
1)启动类加载器(Bootstrap ClassLoader):
这个类加载器负责将存放在
2)扩展类加载器(Extension ClassLoader):
这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载
3)应用程序类加载器(Application ClassLoader):
这个类加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如图所示。
3、什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
类加载的源码如下:
1 |
|
4、为什么使用双亲委派模式?
1)使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。
2)如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
5、有哪些场景破坏了双亲委派模型?
目前比较常见的场景主要有:
1)线程上下文类加载器,典型的:JDBC 使用线程上下文类加载器加载 Driver 实现类
2)Tomcat 的多 Web 应用程序
3)OSGI 实现模块化热部署
6、为什么要破坏双亲委派模型?
原因其实很简单,就是使用双亲委派模型无法满足需求了,因此只能破坏它,这边以面试常问的 Tomcat 为例。
我们知道 Tomcat 容器可以同时部署多个 Web 应用程序,多个 Web 应用程序很容易存在依赖同一个 jar 包,但是版本不一样的情况。例如应用1和应用2都依赖了 spring ,应用1使用的 3.2.* 版本,而应用2使用的是 4.3.* 版本。
如果遵循双亲委派模型,这个时候使用哪个版本了?
其实使用哪个版本都不行,很容易出现兼容性问题。因此,Tomcat 只能选择破坏双亲委派模型。
7、如何破坏双亲委派模型?
破坏双亲委派模型的思路都比较类似,这边以面试中常问到的 Tomcat 为例。
其实原理非常简单,我们可以看到上面的类加载方法源码(loadClass)的方法修饰符是 protected,因此我们只需以下几步就能破坏双亲委派模型。
1)继承 ClassLoader,Tomcat 中的 WebappClassLoader 继承 ClassLoader 的子类 URLClassLoader。
2)重写 loadClass 方法,实现自己的逻辑,不要每次都先委托给父类加载,例如可以先在本地加载,这样就破坏了双亲委派模型了。
8、Tomcat 的类加载器?
Tomcat 的类加载器如下图所示:
1)Bootstrap ClassLoader:可以看到上图中缺少了 Extension ClassLoader,在 Tomcat 中 Extension ClassLoader 被集成到了 Bootstrap ClassLoader 里面。
2)System ClassLoader 就是 Application ClassLoader:Tomcat 中的系统类加载器不会加载 CLASSPATH 环境变量的内容,而是从以下资源库构建 System 类加载器。
- $CATALINA_HOME/bin/bootstrap.jar,包含用于初始化Tomcat服务器的 main() 方法,以及它所依赖的类加载器实现类。
- $CATALINA_BASE/bin/tomcat-juli.jar 或 $CATALINA_HOME/bin/tomcat-juli.jar,日志实现类。
- 如果 $CATALINA_BASE/bin 中存在 tomcat-juli.jar,则使用它来代替 $CATALINA_HOME/bin中的那个。
- $CATALINA_HOME/bin/commons-daemon.jar
3)Common ClassLoader:从名字也看出来来了,主要包含一些通用的类,这些类对 Tomcat 内部类和所有 Web 应用程序都可见。
该类加载器搜索的位置由 $CATALINA_BASE/conf/catalina.properties 中的 common.loader 属性定义,默认设置将按照顺序搜索以下位置。
- $CATALINA_BASE/lib 中未打包的类和资源
- $CATALINA_BASE/lib 目录下的JAR 文件
- $CATALINA_HOME/lib 中未打包的类和资源
- $CATALINA_HOME/lib 目录下的JAR文件
4)WebappX ClassLoader:Tomcat 为每个部署的 Web 应用程序创建一个单独的类加载器,这样保证了不同应用之间是隔离的,类和资源对其他 Web 应用是不可见的。加载的路径如下:
- Web应用的 /WEB-INF/classes 目录下的所有未打包的类和资源
- Web应用的 /WEB-INF/lib 目录下的 JAR 文件中的类和资源
9、Tomcat 的类加载过程?
Tomcat 的类加载过程,也就是 WebappClassLoaderBase#loadClass 的逻辑如下。
1)首先本地缓存 resourceEntries,如果已经被加载过则直接返回缓存中的数据。
2)检查 JVM 是否已经加载过该类,如果是则直接返回。
3) 检查要加载的类是否是 Java SE 的类,如果是则使用 BootStrap 类加载器加载该类,以防止 webapp 的类覆盖了 Java SE 的类。
例如你写了一个 java.lang.String 类,放在当前应用的 /WEB-INF/classes 中,如果没有此步骤的保证,那么之后项目中使用的 String 类都是你自己定义的,而不是 rt.jar 下面的,可能会导致很多隐患。
4)针对委托属性 delegate 显示设置为 true、或者一些特殊的类(javax、org 包下的部分类),使用双亲委派模式加载,只有很少部分使用双亲委派模型来加载。
5)尝试从本地加载类,如果步骤4中加载失败也会走到本步骤,这边打破了双亲委派模型,优先从本地进行加载。
6)走到这,代表步骤5加载失败,如果之前不是使用双亲委派模式,则在这边会委托给父类加载器来尝试加载。
7)走到这边代表所有的尝试都加载失败,抛出 ClassNotFoundException。
10、JDBC 使用线程上下文类加载器的原理
JDBC 功能相关的基础类是由 Java 统一定义的,在 rt.jar 里面,例如 DriverManager,也就是由 Bootstrap ClassLoader 来加载,而 JDBC 的实现类是在各厂商的实现 jar 包里,例如 MySQL 是在 mysql-connector-java 里,oracle、sqlserver 也会有各自的实现 jar。
此时需要 JDBC 的基础类调用其他厂商实现并部署在应用程序的 ClassPath 下的 JDBC 服务提供接口(SPI,Service Provider Interface)的代码。当类A调用类B时,此时类B是由类A的类加载器来负责加载,而 JDBC 的基础类都是由 Bootstrap ClassLoader 来加载,但是 Bootstrap ClassLoader 是不认识也不会去加载这些厂商实现的代码的。
因此,Java 提供了线程上下文类加载器,允许通过 Thread#setContextClassLoader/Thread#getContextClassLoader() 来设置和获取当前线程的上下文类加载器。如果创建线程时没有设置,则会继承父线程的,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。
综上,JDBC 可以通过线程上下文类加载器,来实现父类加载器“委托”子类加载器完成类加载的行为,这个就明显不遵守双亲委派模型了,不过这也是双亲委派模型自身的缺陷导致的。
面试必问的 JVM 运行时数据区,你懂了吗?
Java 虚拟机的运行时数据区经常在面试中被拿来提问,很多概念在市面上有各种各样的说法,搞的不少同学应该是懵逼的。
当我们陷入不知道哪个说法是正确的情况时,最好的参考就是源码和规范。
在面试中,当面试官反问你:为什么某某是这样?的时候,如果你回答:因为规范是这么写的、因为源码是这么写的。
这个回答是非常有说服力的。
因此,本文在描述一些有争议的问题上,优先以《Java 虚拟机规范》的说法为准。
1、运行时数据区(Run-Time Data Areas)
Java 虚拟机定义了若干种在程序执行期间会使用到的运行时数据区域。
其中一些数据区域在 Java 虚拟机启动时被创建,随着虚拟机退出而销毁。也就是线程间共享的区域:堆、方法区、运行时常量池。
另外一些数据区域是按线程划分的,这些数据区域在线程创建时创建,在线程退出时销毁。也就是线程间隔离的区域:程序计数器、Java虚拟机栈、本地方法栈。
1)程序计数器(Program Counter Register)
Java 虚拟机可以支持多个线程同时执行,每个线程都有自己的程序计数器。在任何时刻,每个线程都只会执行一个方法的代码,这个方法称为该线程的当前方法(current method)。
如果线程正在执行的是 Java 方法(不是 native 的),则程序计数器记录的是正在执行的 Java 虚拟机字节码指令的地址。如果正在执行的是本地(native)方法,那么计数器的值是空的(undefined)。
2)Java虚拟机栈(Java Virtual Machine Stacks)
每个 Java 虚拟机线程都有自己私有的 Java 虚拟机栈,它与线程同时创建,用于存储栈帧。
Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
3)本地方法栈(Native Method Stacks)
本地方法栈与 Java 虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是 Java 虚拟机栈为虚拟机执行 Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地(Native)方法服务。
4)堆(Heap)
堆是被各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。
堆在虚拟机启动时创建,堆存储的对象不会被显示释放,而是由垃圾收集器进行统一管理和回收。
5)方法区(Method Area)
方法区是被各个线程共享的运行时内存区域。方法区类似于传统语言的编译代码的存储区。它存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据,构造函数和普通方法的字节码内容,还包括一些用于类、实例、接口初始化用到的特殊方法。
6)运行时常量池(Run-Time Constant Pool)
运行时常量池是 class 文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式。
它包含了若干种常量,从编译时已知的数值字面量到必须在运行时解析后才能获得的方法和字段引用。运行时常量池的功能类似于传统编程语言的符号表(symbol table),不过它包含的数据范围比通常意义上的符号表要更为广泛。
2、Java 中有哪几种常量池?
现在我们经常提到的常量池主要有三种:class 文件常量池、运行时常量池、字符串常量池。
3、class 文件常量池
class 文件常量池(class constant pool)属于 class 文件的其中一项,class 类文件包含:魔数、类的版本、常量池、访问标志、字段表集合、方发表等信息。
常量池用于存放编译期间生成的各种字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
符号引用则属于编译原理方面的概念。符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。符号引用主要包括下面几类常量:
- 被模块导出或开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
常量池中每一项常量都是一个表,截至JDK 13,常量表中分别有17种不同类型的常量。17种常量类型所代表的具体含义如图所示。
关于 class 文件常量池的更多内容可以阅读周志明的《深入理解Java虚拟机》6.3.2 章节。
4、运行时常量池
class 文件常量池是在类被编译成 class 文件时生成的。而当类被加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时常量池中。
Java 虚拟机规范中对运行时常量池的定义如下:
A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a class file.
运行时常量池是 class 文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式。
因此,根据规范定义,可以说运行时常量池是 class 文件常量池的运行时表示,每个类在运行时都有自己的一个独立的运行时常量池。
5、字符串常量池
简单来说,HotSpot VM 里的字符串常量池(StringTable)是个哈希表,全局只有一份,被所有的类共享。
StringTable 具体存储的是 String 对象的引用,而不是 String 对象实例自身。String 对象实例在 JDK 6 及之前是在永久代里,从JDK 7 开始放在堆里。
根据 Java 虚拟机规范的定义,堆是存储 Java 对象的地方,其他地方是不会有 Java 对象实体的,如果有的话,根据规范定义,这些地方也要算堆的一部分。
6、字符串常量池是否属于方法区?
我认为是不属于的。
在读本文之前,我相信很多同学会有如下观点:因为运行时常量池属于方法区,所以很多同学认为字符串常量池也应该属于方法区。
但是相信看了上面的内容后,会开始意识到,运行时常量池和字符串常量池其实是不同的两个东西,当然它们在字符串解析时会有关联。
Java 虚拟机规范中对方法区的定义如下:
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization
在 Java 虚拟机中,方法区是被各个线程共享的运行时内存区域。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的文本段。它存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据,构造函数和普通方法的字节码内容,还包括一些用于类、实例、接口初始化用到的特殊方法。
这边的关键在于 “它存储了每一个类的结构信息”,而字符串常量池并不属于某个类,字符串常量是全局共享的,因此,根据规范定义,我们可以说字符串常量池不属于方法区。
那字符串常量池(StringTable)究竟存在哪里了?
- 问题:
1
2
3
4
5
6
7
8
9
10那么这两句话谁对谁错:
StringTable 本体是存储在 native memory(本地内存)里,不是在永久代里,不是在方法区里,当然,更不是在堆里
自 JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆之中
这两句话是相互矛盾的。
第一句话说StringTable本体存储在本地内存中,不在永久代、方法区或堆中。这是正确的,因为StringTable是存储在本地内存中的数据结构。
第二句话说自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆中。这也是正确的,因为在JDK 7及以后的版本中,字符串常量池被移动到了Java堆中。
所以,这两句话描述了不同版本的情况,第一句话适用于JDK 7之前的版本,第二句话适用于JDK 7及以后的版本。它们描述了不同的存储位置。
希望这个解答能够帮助你理解。如果你有进一步的问题,请随时提问。
StringTable 本体是存储在 native memory(本地内存)里,不是在永久代里,不是在方法区里,当然,更不是在堆里。
7、运行时常量池和字符串常量池的关联?
上面说了,运行时常量池和字符串常量池在字符串解析时会有关联,具体如下。
类的运行时常量池中有 CONSTANT_String_info(见题3表格)类型的常量,CONSTANT_String_info 类型的常量的解析(resolve)过程如下:
首先到字符串常量池(StringTable)中查找是否已经有了该字符串的引用,如果有,则直接返回字符串常量池的引用;如果没有,则在堆中创建 String 对象,并在字符串常量池驻留其引用,然后返回该引用。
也就说,运行时常量池里的 CONSTANT_String_info 类型的常量,经过解析(resolve)之后,同样存的是字符串的引用,并且和 StringTable 驻留的引用的是一致的。
8、String#intern 方法
在 JDK 7 及之后的版本中,该方法的作用如下:如果字符串常量池中已经有这个字符串,则直接返回常量池中的引用;如果没有,则将这个字符串的引用保存一份到字符串常量池,然后返回这个引用。
下面的例子可以进行简单的验证:
1 |
|
9、永久代(PermGen)
永久代在 Java 8 被移除。根据官方提案的描述,移除的主要动机是:要将 JRockit 和 Hotspot 进行融合,而 JRockit 并没有永久代。
而据我们所了解的,还有另外一个重要原因是永久代本身也存在较多的问题,经常出现OOM,还出过不少bug。
根据官方提案的描述,永久代主要存储了三种数据:
1)Class metadata(类元数据),也就是方法区中包含的数据,除了编译生成的字节码被放在 native memory(本地内存)。
2)interned Strings,也就是字符串常量池中驻留引用的字符串对象,字符串常量池只驻留引用,而实际对象是在永久代中。
3)class static variables,类静态变量。
移除永久代后,interned Strings 和 class static variables 被移动了堆中,Class metadata 被移动到了后来的元空间。
10、永久代和方法区的关系?
方法区是 Java 虚拟机规范中定义的一种逻辑概念,而永久代是对方法区的实现。但是永久代并不等同于方法区,方法区也不等同于永久代。
永久代中的 interned Strings 并不属于方法区,按规范:堆是存储 Java 对象的地方 ,这部分应该属于堆,因此永久代并不是只用于实现方法区。
方法区中 JIT 编译生成的代码并不是存放在永久代,而是在 native memory 中,因此可以说方法区也并不只是由永久代来实现。
11、元空间(metaspace)
元空间在 Java 8 移除永久代后被引入,用来代替永久代,本质和永久代类似,都是对方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存(native memory)。
元空间主要用于存储 Class metadata(类元数据),根据其命名其实也看得出来。
可以通过 -XX:MaxMetaspaceSize 参数来限制元空间的大小,如果没有设置该参数,则元空间默认限制为机器内存。
12、为什么引入元空间?
在 Java 8 之前,Java 虚拟机使用永久代来存放类元信息,通过-XX:PermSize、-XX:MaxPermSize 来控制这块内存的大小,随着动态类加载的情况越来越多,这块内存变得不太可控,到底设置多大合适是每个开发者要考虑的问题。
如果设置小了,容易出现内存溢出;如果设置大了,又有点浪费,尽管不会实质分配这么大的物理内存。
而元空间可以较好的解决内存设置多大的问题:当我们没有指定 -XX:MaxMetaspaceSize 时,元空间可以动态的调整使用的内存大小,以容纳不断增加的类。
13、元空间能彻底解决内存溢出(Out Of Memory)问题吗?
很遗憾,答案是不行的。
元空间无法彻底解决内存溢出的问题,只能说是有所缓解。当内存使用完毕后,元空间一样会出现内存溢出的情况,最典型的场景就是出现了内存泄漏时。