JVM虚拟机和类加载器

被刻印的时光 ゝ 提交于 2019-12-04 04:47:06

一、JVM的介绍

Sun HotSpot VM

这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发, 而是由一家名为“Longview Technologies”的小公司设计的; 甚至这个虚拟机最初并非是为Java语言而开发的, 它来源于Strongtalk VM, 而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机, Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果, 在1997年收购了Longview Technologies公司, 从而获得了HotSpot VM。

BEA JRockit VM

JRockit VM曾经号称“世界上速度最快的JAVA虚拟机”, 它是BEA公司在2002年从Appeal Virtual Machines公司收购的虚拟机。BEA公司将其发展为一款专门为服务器硬件和服务器端应用场景高度优化的虚拟机, 由于专注于服务器端应用, 它可以不太关注程序启动速度, 因此JRockit内部不包含解析器实现, 全部代码都靠即时编译器译后执行。除此之外,JRockit的垃圾收集器和MissionControl服务套件等部分的实现, 在众多JAVA虚拟机中也一直处于领先水平。

IBM J9 VM

IBM J9 VM并不是IBM公司唯一的JAVA虚拟机, 不过是目前其主力发展的JAVA 虚拟机。IBM J9 VM原本是内部开发代号, 正式名称是“IBM Technology for Java Virtual Machine”, 它最早是由 IBM Ottawa实验室一个SmallTalk的虚拟机扩展来的。那时候, 这个虚拟机有一个bug是因为8K值定义错误引起, 工程师们花了很长时间终于发现并解决了这个错误, 此后这个版本的虚拟机就被称为K8了。于是, 后来出现的支持Java这个版本的虚拟机 就被称为J9 了。J9市场定位与HotSpot比较接近, 从服务器到桌面应用再到嵌入式都全面考虑的多用途虚拟机。

其他种类

二、JVM运行时数据区结构

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都各司其职, 以及创建和销毁的时间, 在《Java虚拟机规范(Java SE 8版)》的规定中, Java虚拟机所管理的内存包括五大部分区域: 分别是程序计数器、虚拟机栈、本地方法栈、堆和方法区。

具体如图所示

小结

我们把程序代码抽象一下, 可以理解为由三个部分组成,分别是数据、指令、控制流,所谓数据, 可以理解为定义的成员变量、静态变量、常量;指令可以理解为在方法 中执行的语句, 控制流理解为分支、循环、跳转、异常处理、线程恢复等。我们在编写代码的过程中, 可以理解为都是围绕着这三部分来展开组织代码的。

程序计数器

  • 程序计数器是一块较小的内存空间, 指的是当前线程所执行的字节码的行号指示器。这是比较官方的解释,通俗一点来说,首先程序计数器是与线程绑定的,也就是说每个线程在运行期都有独立的程序计数器,在上图中也可以看出,程序计数器是属于线程隔离的数据区。我们在学习Java多线程的时候讲过,多线程是通过线程间切换抢夺CPU分配的时间片来竞争执行的(多核CPU来说指的是一个内核),一个CPU处理器只会在同一时间执行一条线程指令。
  • 举个例子, 有两个线程A和B, 当A线程获取到运行时间并执行到一半时, CPU分配的时间片用完了,此时A线程就要被挂起,然后两个线程再次竞争下一次的CPU时间片,因此A线程就需要一个计数器来记录上次执行的位置, 好让下次再获取到CPU时间片时可以恢复到正确位置继续执行下去。各个线程之间的计数器是独立的, 互不影响,独立存储,如果线程在执行一个方法时,此线程记录的是正在执行的字节码指令的地址,如果执行的是本地(Native)方法,则计数器的值为空(undefined), 由于程序计数器的内存空间非常小,所以JVM规范中没有规定此区域的内存溢出的情况。

虚拟机栈

  • Java虚拟机栈也是线程独立的,多个线程有独立的Java虚拟机栈,栈的生命周期与线程相同,在线程启动时被创建,线程结束时被销毁,栈是用来存储Java方法运行时数据的,那栈中存储的数据是什么方式来组织的呢?
  • 其实在栈中存储的数据结构是一个数据单位来体现的,这个数据单位称为栈帧(Stack Frame), 当程序执行一个方法时, 会创建一个栈帧, 我们称为入栈, 当方法执行结束后, 栈帧就会被销毁, 我们称为出栈。在一个栈帧里, 用于存储局部变量表、操作数栈、动态链接、方法出口和一些额外的附加信息。
  • 栈帧是虚拟机在方法调用执行时存储在虚拟机栈的数据结构,也可以称为栈元素, 一个方法对应一个栈帧。

具体如图所示

本地方法栈

  • 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机机行Java方法(字节码)服务,而本地方法栈则为虚拟机便用到的Native方法服务。
  • 在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(Sun Hotspot)直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆

  • Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程和共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都 在这里分配内存。
  • 这一点在Java虚拟机规范中的描述是: 所有的对象实例以及数组都要在堆上分配。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟, 栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

方法区

方法区(Method Area)与Java堆一样, 是所有线程共享的内存区域, 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java虚拟机规范把方法区描述为堆的一个逻辑部分, 但方法区有一个别名叫做非堆(Non-Heap), 目的是与Java堆区分开来。

Java程序中堆和堆栈内存结构示例

代码如下

public class Memory {
    public static void main(String[] args) { // line 1
        int i = 1; // line 2
        Object obj = new Object(); // line 3
        Memory mem = new Memory(); // line 4
        mem.foo(obj); // line 5
    } // line 9
    private void foo(Object param){ // line 6
        String str = param.toString(); // line 7
        System.out.println(str);
    }// line 8
}

分析

具体如图所示

Java堆内部结构

  • Young(年轻代): 大多数情况下JAVA程序中创建的对象是从新生代分配内存, 新生代有两部分组成: Eden Space和两块大小相等的Survivor Space(S0和S1)。可以通过参数-Xmn来指定新生代的大小, 通过-XX: SurivorRatio来指定Eden Space和Survivor Space的大小。
  • Tenured(年老代): 年老代存放从年轻代存活的对象。一般来说年老代存放的都是生命期较长的对象。
  • **Perm(持久代)**用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时 候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过- XX: MaxPermSize=进行设置。

具体如图所示

新生区

  • 新生区是类的诞生、成长、消亡的区域, 一个类在这里产生、应用, 最后被垃圾回收器收集, 结束生命。新生区又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace), 所有的类都是在伊甸区被new出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时, 程序又需要创建对象, JVM的垃圾回收器将对伊甸园进行垃圾回收(Minor GC),将伊甸园中的剩余对象移动到幸存0区。若幸存0区也满了,再对该区进行垃圾回收, 然后移动到1区。那如果1区也满了, 再移动到养老区, 若养老区也满了, 那么这个时候将产生Major GC(FullGC), 进行养老区的内存清理。若养老区执行Full GC之后发现依然无法进行对象的保存, 就会产生OOM异常“OutOfMemoryError”。
  • 如果出现java.lang.OutOfMemoryError: Java heap space异常, 说明Java虚拟机的堆内存不够。原因如下:
  1. Java虚拟机的堆内存设置不够, 可以通过参数-Xms、-Xmx来调整。
  2. 代码中创建了大量大对象, 并且长时间不能被垃圾收集器收集(存在被引用)。

养老区

养老区用于保存从新生区筛选出来的JAVA对象, 一般池对象都在这个区域活跃。

永久区

永久存储区是一个常驻内存区域, 用于存放JDK自身所携带的Class,Interface的元数据, 也就是说它存储的是运行环境必须的类信息, 被装载进此区域的数据是不会被垃圾回收器回收掉的, 关闭JVM才会释放此区域所占用的内存。如果出现java.lang.OutOfMemoryError: PermGen space, 说明是Java虚拟机对永久代Perm内存设置不够。

可能是以下原因:

程序启动需要加载大量的第三方jar包。例如: 在一个Tomcat下部署了太多的应用。 大量动态反射生成的类不断被加载, 最终导致Perm区被占满。

说明

  • Jdk1.6及之前: 常量池分配在永久代 。
  • Jdk1.7:常量池仍然存在, 但已经逐步“去永久代” 。
  • Jdk1.8及之后: 常量池不存在了(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中)。

永久代(PermGen)和元空间的区别(Metaspace)

类的元数据、字符串池、类的静态变量将会从永久代移除,放入Java heap或者native memory。其中建议JVM的实现中将类的元数据放入native memory, 将字符串池和类的静态变量放入Java堆中。这样加载多少类的元数据就不在由MaxPermSize控制, 而由系统的实际可用空间来控制。为什么这样做呢? 减少OOM只是表因, 更深层的原因还是要合并HotSpot和JRockit的代码, JRockit从来没有一个叫永久代的东西, 但是运行良好, 也不需要开发运维人员设置这么一个永久代的大小。

总结

了解虚拟机运行时数据区往往是大厂面试比问考题, 在现代程序复杂度、重量级、效率化的需求下, 程序不单单是要求能运行, 运行性能、效率的需求尤为重要。程序员不光要按照需求实现功能, 还要考虑程序的性能、健壮性和可扩展性。

如图

三、类加载器

类加载器的分类

  • 启动类加载器(Bootstrap ClassLoader 引导类加载器): 这个类负责将放在< JAVA_HOME>\lib目录下, 并且是虚拟机识别的(仅仅按照文件名识别, 名字不符合的类库即使在lib目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用, 如果要把加载请求委派给启动类加载器, 那就直接用null代替即可。
  • 扩展类加载器(Extension ClassLoader): 负责加载< JAVA_HOME>\lib\ext目录下的类库。
  • 应用程序类加载器(Application ClassLoader), 也称为系统类加载器: 负责加载用户类路径上所指定的类库, 如果应用程序中没有自定义过自己的类加载器, 一般情况下这个就是程序中默认的类加载器。通俗的来讲: 一般情况下, 我们自己写的类是由这个加载器加载。

如图

类加载的过程

JVM类加载工作原理: 就是把类的class文件加载到内存中, 并对数据进行校验、转换解析和初始化, 最终形成被虚拟机使用的java类型。 类加载的生命周期包括以下几个部分: 加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading), 其中验证、准备、解析三个部分统称链接。

如图

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!