一、简介
1.1 概述
JVM是Java Virtual Machine(Java虚拟机)的缩写,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域等组成。JVM屏蔽了与操作系统平台相关的信息,使得Java程序只需要生成在Java虚拟机上运行的目标代码(字节码),就可在多种平台上不加修改的运行,这也是Java能够“一次编译,到处运行的”原因。
所谓java能实现跨平台,是由在不同平台上运行不同的虚拟机决定的,因此java文件的执行不直接在操作系统上执行,而是通过jvm虚拟机执行,我们可以从这张图看到,JVM并没有直接与硬件打交道,而是与操作系统交互用以执行java程序。
1.2 JRE、JDK和JVM的关系
JRE(Java Runtime Environment, Java运行环境)是Java平台,所有的程序都要在JRE下才能够运行。包括JVM和Java核心类库和支持文件。
JDK(Java Development Kit,Java开发工具包)是用来编译、调试Java程序的开发工具包。包括Java工具(javac/java/jdb等)和Java基础的类库(java API )。
JVM(Java Virtual Machine, Java虚拟机)是JRE的一部分。JVM主要工作是解释自己的指令集(即字节码)并映射到本地的CPU指令集和OS的系统调用。Java语言是跨平台运行的,不同的操作系统会有不同的JVM映射规则,使之与操作系统无关,完成跨平台性。
使用JDK(调用JAVA API)开发JAVA程序后,通过JDK中的编译程序(javac)将Java程序编译为Java字节码,在JRE上运行这些字节码,JVM会解析并映射到真实操作系统的CPU指令集和OS的系统调用。
二、JVM架构
JVM的架构分为多个子系统,主要包括类加载子系统、运行时数据区、垃圾回收机制和执行引擎等。
2.1 类加载子系统
JVM 将 class 字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的 java.lang.Class 对象,作为方法区类数据的访问入口。
2.1.1 类加载器
把类加载阶段的 “ 通过一个类的全限定名来获取描述此类的二进制字节流 ” 这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。系统自带的类加载器分为三种:
- 启动类加载器 ===》 Bootstrap ClassLoader
- 扩展类加载器 ===》 Extension ClassLoader
- 应用程序类加载器 ===》Application ClassLoader
2.1.1.1 双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,每个层次的类加载器都是如此。因此所有的加载请求最终都会传送到Bootstrap类加载器(启动类加载器)中,只有父类加载反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
层级结构如下图所示:
双亲委派模型的优点:
java类随着它的加载器一起具备了一种带有优先级的层次关系。
例如类 java.lang.Object ,它存放在 rt.jar 之中,无论哪一个类加载器都要加载这个类,最终都是双亲委派模型最顶端的 Bootstrap 类加载器去加载。因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户编写了一个称为 “java.lang.Object” 的类,并存放在程序的ClassPath中,那系统中将会出现多个不同的 Object 类,java 类型体系中最基础的行为也就无法保证,应用程序也将会一片混乱。
2.1.1.2 自定义类加载器
继承 ClassLoader 类,重写 findClass() 方法:
2.1.2 类加载过程
类的加载过程如下:
JVM类加载机制分为五个部分:加载,校验,准备,解析,初始化,下面我们就分别来看一下这五个过程。其中加载、校验、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后。
加载(Loading):类加载器从字节流中读取类的二进制数据并将其加载到内存中。
验证(Verification):验证类的字节码是否符合JVM规范,防止恶意代码的侵入。
准备(Preparation):为类的静态变量分配内存并设置默认值。
解析(Resolution):将类中的符号引用解析为直接引用。
初始化(Initialization):执行类的构造方法,初始化类的静态变量和静态代码块。
2.1.2.1 加载
类加载器从字节流中读取类的二进制数据并将其加载到内存中。
加载过程主要完成三件事情:
- 通过类的全限定名来获取定义此类的二进制字节流
- 将这个类字节流代表的静态存储结构转为方法区的运行时数据结构
- 在堆中生成一个代表此类的 java.lang.Class 对象,作为访问方法区这些数据结构的入口。
这个过程主要由类加载器完成。
2.1.2.2 校验
验证类的字节码是否符合JVM规范,防止恶意代码的侵入。
- 文件格式验证:基于字节流验证。
- 是否以魔数0xCAFEBABE开头。
- 主、次版本号是否在当前虚拟机处理范围之内。
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
- Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
- 元数据验证:基于方法区的存储结构验证。
- 这个类是否有父类(除了java.lang.Object之外,所有类都应当有父类)。
- 这个类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中所要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等等)。
- 字节码验证:基于方法区的存储结构验证。主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。
- 符号引用验证:基于方法区的存储结构验证。可以看作是类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
- 符号引用中通过字符串描述的全限定名是否能够找到对应的类。
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
2.1.2.3 准备
为类变量分配内存(不包括实例变量),并将其初始化为默认值。(此时为默认值,在初始化的时候才会给变量赋值)即在方法区中分配这些变量所使用的内存空间。例如:
此时在准备阶段过后的初始值为0而不是123;将value赋值为123的 putstatic 指令是程序被编译后,存放于类构造器<clinit>方法之中。
特例:
此时value的值在准备阶段过后就是123。
2.1.2.4 解析
把类型中的符号引用转换为直接引用。
- 符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
- 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在
主要有以下四种:
- 类或接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
2.1.2.5 初始化
初始化阶段是执行类构造器<clinit>方法的过程。<clinit>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<clinit>方法。
java中,对于初始化阶段,有且只有以下五种情况才会对要求类立刻“初始化”(加载,验证,准备,自然需要在此之前开始):
- 使用 new 关键字实例化对象、访问或者设置一个类的静态字段(被final修饰、编译器优化时已经放入常量池的例外)、调用类方法,都会初始化该静态字段或者静态方法所在的类。
- 初始化类的时候,如果其父类没有被初始化过,则要先触发其父类初始化。
- 使用 java.lang.reflect 包的方法进行反射调用的时候,如果类没有被初始化,则要先初始化。
- 虚拟机启动时,用户会先初始化要执行的主类(含有main)。
- jdk 1.7后,如果 java.lang.invoke.MethodHandle 的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。
2.2 运行时数据区(HotSpot虚拟机)
JVM在程序执行时,管理着多个内存区域,称为运行时数据区。每个区域有不同的用途,主要包括以下几个区域:
- 方法区:存储类的相关信息(类信息、常量、静态变量、方法数据等)。
- 堆(Heap):存储Java对象,是垃圾回收器管理的区域。
- 虚拟机栈(JVM Stack):每个线程都有一个栈,用来存储方法的局部变量、操作数栈、返回地址等信息。
- 程序计数器(PC Register):存储当前线程正在执行的字节码的地址。
- 本地方法栈(Native Method Stack):为JVM调用本地方法(如C/C++编写的库)提供支持。
2.2.1 程序计数器
线程私有;是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,唯一不会造成 OutOfMemoryError 情况的区域。不过当线程执行的是 Native 方法的时候这个计数器中的值为 undefined 。
2.2.2 java虚拟机栈
线程私有;描述的是 java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧指执行一个方法所使用的那部分栈,线程的所有方法的栈帧串起来就组成了一个完整的 java 虚拟机栈。java 虚拟机栈针对线程而言,线程里的方法对应栈中的元素----栈帧;每执行一个方法,就为这个方法创建对应的栈帧,并 push 到 线程这个大容器-----java虚拟机栈 中,执行完成就 pop 出栈。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(用来指向内存中分配的类实例或者数组) 和 returnAddress 类型(指向字节码的指针)。注:long和double会占用2个局部变量空间(8字节),其余数据类型只占用一个(4字节)。对于局部变量,如果是基本类型,会把值直接存储在栈;如果是引用类型,比如String s = new String("william");会把其对象存储在堆,而把这个对象的引用(指针)存储在栈。
Java虚拟机规范中,对这个区域规定了两种异常状况:
- 线程请求栈的深度大于虚拟机所允许栈的深度,将抛出Stack Overflow Error
- 如果虚拟机栈可以动态扩展且扩展时无法申请到足够的内存,会抛出OutOfMemoryError
这里注意的是如果递归的方法递归的太深很容易抛出上面两种异常,所以递归虽然写起来方便,但是性能会有所下降,并且容易抛出异常。
-Xss128k:设置每个线程的堆栈大小为128k,若不设置则为JVM默认值,此时默认可动态扩展。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
2.2.3 本地方法栈
线程私有;作用和 java 虚拟机栈相似,区别是 java 虚拟机栈为虚拟机执行 java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native方法指java调用非java代码(c++)的接口(jni)。
sayHello() 方法加了一个关键字 native ,就代表是一个native接口。执行这个方法时,会根据 jni.h来找到真正的C++编写的sayHello() 的实际函数。jni.h 是存在于 %JAVA_HOME%\include 下面的一个文件,另外还有个 %JAVA_HOME%\include\win32 下的jni_md.h。
2.2.4 java堆
线程共享;是java虚拟机所管理的内存中最大的一块,在虚拟机启动时创建,用于存放对象实例。java堆是垃圾收集器管理的主要区域。由于现在的垃圾回收算法多是分代收集,所以Java堆里面又可分为:新生代和老年代;再细致一点年轻代还能分为Eden区、From Survivor空间、To Survivor空间。并且根据Java虚拟机规范的规定:Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。注意:有实例没有被分配,且堆无法再扩展的时候会抛出OutOfMemoryError异常,虚拟机调优其实也主要关注的是这个区域。成员变量作为对象的属性,当然是放在堆里了。对象在堆里,对象中的内容就是各种字段。
- -Xms:初始堆大小(最小堆)。
- -Xmx:最大堆大小。
-Xms 和 -Xmx 设置成一致的值可以避免堆自动扩展。Oracle官方推荐堆的初始化大小与堆可设置的最大值一般是相等的,即 Xms = Xmx,因为起始堆内存太小(Xms),会导致启动初期频繁 GC,起始堆内存较大(Xmx)有助于减少 GC 次数
- -Xmn:年轻代大小(Sun官方推荐配置为整个堆的1/3)。对 -XX:newSize、-XX:MaxnewSize两个参数的同时配置。
JVM内存大小 = 年轻代大小 + 老年代大小 + 永久代大小(perm)。永久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
- -XX:NewSize :指定新生代初始大小。
- -XX:MaxNewSize :指定新生代最大大小。
- -XX:NewRatio :是年老代 新生代相对的比例,比如NewRatio=2,表明年老代是新生代的2倍。老年代占了heap的2/3,新生代占了1/3。
- -XX:SurvivorRatio :配置的是在新生代里面Eden和一个Survivor比例。-XX:SurvivorRatio=8表示新生代的Eden占8/10,S1和S2各占1/10.
2.2.5 方法区
线程共享;方法区是JVM的一种规范,方法区在jdk1.7的实现是永久代,jdk1.8的实现是元空间Metaspace。可以选择不实现垃圾收集。方法区主要用于存储类字节码、静态变量、常量池、即时编译器编译后的代码等数据。
对于方法区的理解我们要注意以下几个方面:
- 方法区(Method Area)与堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它实际的物理内存空间和虚拟机堆区一样都可以是不连续的。
- 方法区在JVM启动的时候被创建,并且它实际的物理内存空间和虚拟机堆区一样都可以是不连续的。
- 方法区的大小跟堆空间一样,可以选择固定大小或者可扩展。方法区的大小决定了系统可以保存多少个类。如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误,如java.lang.OutOfMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace。
以下情况都可能导致方法区发生OOM异常:加载大量的第三方jar包、Tomcat部署的工程过多(30~50个)或者大量动态地生成反射类。
JVM常量池(详细见下一章):
- class文件常量池:诞生于编译时,存在于class文件中,存放符号引用和字面量。
- 运行时常量池:诞生于JVM运行时,jdk1.7永久代被移除后存在于元空间,存放class文件元信息描述、引用类型数据、编译后的代码数据、类文件常量池(综合了每个class文件常量池)
- 字符串常量池:jdk1.6处于永久代,jdk1.7后处于堆区;存放字符串对象的引用
- 基本类型包装类常量池:位于堆区
2.2.5.1 永久代
存储已被 java 虚拟机加载的类字节码、静态变量、常量池、即时编译器编译后的代码等数据。jdk1.7 的 HotSpot 虚拟机中,已经把原本放在永久代的 字符串常量池 和 静态变量 转移到 java 堆;符号引用(Symbols) 转移到了本地内存(Native Memory)。jdk1.8中,原有的永久代改为了元空间(Metaspace),直接使用机器物理内存,因为原来的永久代内存大小不易评估,同时调优效率较低。永久代的垃圾收集是和老年代捆绑在一起的。
配置参数:
-XX:PermSize=300M 设置永久代大小
-XX:MaxPermSize=300M 设置永久代最大大小
2.2.5.2 元空间(Metaspace)
- 1、字符串存在永久代中,容易出现性能问题和内存溢出。
- 2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。而对于元空间来说类的元数据可以在本地内存(native memory)分配,所以其最大可利用空间是整个系统内存的可用空间。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- Oracle 可能会将HotSpot 与 JRockit 合二为一。