关于 Java虚拟机:内存处理与执行引擎
一.Java技术体系简介:
Java技术体系包括以下几个组成部分:
- java程序设计语言
- 各种硬件平台上的java虚拟机
- Class文件格式
- Java API 类库
- 来自商业机构和开源社区的第三方类库
JDK(java Development Kit):包括java程序设计语言,java虚拟机,java API类库。JDK是用于支持java程序开发的最小环境。
JRE(java Runtime Environment) 包括java API类库中的java SE API子集,java虚拟机。JRE是支持java程序运行的标准环境。
下图展示了Java技术体系所包含的内容,以及JDK和JRE所涵盖的范围:
按照技术所服务的领域来分,Java技术体系可以分为四个平台,分别是:
- java Card:支持一些java小程序(Applet)运行在小内存设备上的平台
- Java ME(Micro Edition):支持java程序运行在移动终端上的平台,对java APi 有所精简,并加入了针对移动终端的支持。
- Java SE(Standard Edition):支持面向桌面级应用的java平台,提供了完整的java核心API。
- Java EE(Enterprise Edition):支持使用多层架构的企业应用的java平台,除了提供java SE API之外,还对其做了大量的扩充并提供了相关的部署支持。
二,java内存管理机制
- 运行时数据区域:
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖于用户线程的启动和结束而建立和销毁。
Java虚拟机所管理的内存包括以下几个运行时数据区域:
1.1 程序计数器:
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里,字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖于程序计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个核心)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。
如果线程正在执行的是一个Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,则程序计数器的值为空(Undefined)。
程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
1.2 Java虚拟机栈
与程序计数器一样,java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,栈帧是方法运行时的基础数据结构)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
对于C/C++等程序来说,其内存管理常常分为栈、堆等。对于Java,栈即指代虚拟机栈,或者说是虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用地址,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
可以通过 -Xss 这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小:
java -Xss=512M HackTheJava
该区域可能抛出以下异常:
- 当线程请求的栈深度超过最大值,会抛出StackOverflowError 异常;
- 栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError 异常。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
1.3本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError异常和OutOfMemoryError异常。
1.4 Java堆(Heap)
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。在JVM中,几乎所有的对象实例都在这里分配内存。Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展和逃逸技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也变得不是那么绝对了。
Java堆是垃圾收集器管理的主要区域,因此,Java堆也被称为“GC堆”(Garbage Collected Heap)。
现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对象采取不同的垃圾回收算法,因此虚拟机把Java堆分成以下三块:
- 新生代(Young Generation)
- 老年代(Old Generation)
- 永久代(Permanent Generation)
当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。为了更高效的进行垃圾回收,把新生代继续划分为以下三个空间:
- Eden
- From Survivor
- To Survivor
-
从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。在实现时,既可以实现成固定大小的,也可以是可扩展的,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
可以通过 -Xms 和 -Xmx 两个虚拟机参数来指定一个程序的 Java 堆内存大小,第一个参数设置最小值,第二个参数设置最大值。
java -Xms=1M -XmX=2M HackTheJava
1.5方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该与Java堆区分开来。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如同永久代名字一样永久存在。该区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池(Runtime Costant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性。Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,如String类的intern()方法。
既然运行时常量区是方法区的一部分,当常量池无法申请到内存时会抛出OutOfMemoryError异常。
Class类文件解析
java:一次编写,到处运行。Write Once,Run Anywhere。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石。
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。
Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。
- 1. Class类文件的结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
Class文件是一组以8位字节为基础单元的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。
Class文件中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是有多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。
Class文件中的数据项,无论是顺序还是数量,甚至于数据存储的字节序,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
Class文件格式如下:
- 常量池(constant_pool)
-
常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
常量池中主要存放两大类常量:字面量和符号引用。
字面量比较接近于Java层面的常量概念,如文本字符串、被声明为final的常量值等。
符号引用总结起来则包括了下面三类常量:
- 类和接口的全限定名(即带有包名的Class名,如:org.lxh.test.TestClass)
- 字段的名称和描述符(private、static等描述符)
- 方法的名称和描述符(private、static等描述符)
虚拟机在加载Class文件时才会进行动态连接,也就是说,Class文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
- 访问标志(access_flags)
访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息。包括:
- 这个Class是类还是接口;
- 是否定义为public类型;
- 是否定义为abstract类型;
- 如果是类的话,是否被声明为final等。
访问标志包括public/protected/private/abstract/final等等。
虚拟机类加载机制:
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类加载过程
在Java语言里面,类型的加载、连接、初始化过程都是在程序运行期间完成的。
特点:灵活性、动态扩展(运行期动态加载和动态连接)
-
类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括:
- 加载(Loading)
- 验证(Verification)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
那么,什么情况下需要开始类加载过程的第一个阶段加载呢?!!有且只有五种情况!!
- 遇到new/getstatic/putstatic/invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化(分别对应于:使用new实例化对象、读取或设置类的静态字段、调用一个类的静态方法)。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
被动引用:
- 通过子类引用父类的静态字段,不会导致子类初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 常量在编译阶段会调入类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。(常量传播优化)
对于接口的加载过程,我们需要注意的是:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时才会初始化。
类加载过程主要包括加载、验证、准备、解析和初始化5个阶段。
1.1 加载
在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
1.2 验证
验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全。验证是虚拟机对自身保护的一项重要工作。
从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:
- 文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个Java类型信息的要求。
- 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。该验证阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
- 字节码验证
第三阶段是对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。该验证阶段的主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证
第四阶段是对类自身以外的信息进行匹配性校验。该验证阶段的主要目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要的阶段。
1.3 准备
准备阶段是为类变量分配内存并设置类变量初始值的阶段。
注意:此时进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量。
考虑下面一个问题:
试比较下面两种情况下在准备阶段后value对应的值是多少。
// 情形一
public static int value = 123;
// 情形二
public static final int value = 123;
答案是:对于情形一,准备阶段后value的值为0;对于情形二,准备阶段后value的值为123。
原因在于,情形一下value的赋值操作是在<init>部分完成的,而在情形二下,value对应为ConstantValue属性。
1.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。虚拟机规范中并未规定解析阶段发生的具体时间。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
1.5 初始化
类初始化阶段是类加载的最后一步。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。也就是说,初始化阶段是执行类构造器
<clinit>
方法的过程。<clinit>
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。例如:public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 非法前向引用!!!
}
static int i = 1;
}
- <clinit>方法与类的构造函数(实例构造器<init>)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。
- 由于父类的<clinit>方法先执行,也就有,父类中定义的静态语句块要优先于子类的变量赋值操作。
- <clinit>方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法。
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作。但是接口与类不同的是,执行接口的<clinit>方法不需要先执行父接口的<clinit>方法,只有当父接口中定义的变量使用时,父接口才会初始化。
- 虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>方法完毕。同时,需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>方法的那条线程退出<clinit>方法后,其他线程唤醒之后不会再次进入<clinit>方法。同一个类加载器下,一个类型只会初始化一次。
2. 类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流 ( 即字节码 )”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
2.1 类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。通俗而言:比较两个类是否“相等”(这里所指的“相等”,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof() 关键字做对象所属关系判定等情况),只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
2.2 类加载器分类
从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader),这个类加载器用 C++ 实现,是虚拟机自身的一部分;
- 所有其他类的加载器,这些类由 Java 实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
从 Java 开发人员的角度看,类加载器可以划分得更细致一些:
- 启动类加载器(Bootstrap ClassLoader) 此类加载器负责将存放在
<JAVA_HOME>\lib
目录中的,或者被-Xbootclasspath
参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。 - 扩展类加载器(Extension ClassLoader) 这个类加载器是由 ExtClassLoader实现的。它负责将
<JAVA_HOME>/lib/ext
或者被java.ext.dir
系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(Application ClassLoader) 这个类加载器是由AppClassLoader实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。