梳理Java虚拟机相关的面试题,主要参考《深入理解Java虚拟机 JVM高级特性与最佳实践》(第2版, 周志明 著)一书,其余部分整合网络相关内容。注意,关于Java并发编程的面试题因为内容较多,单独整理。Java基础相关的面试题可以参考Java基础常见面试题总结一文。
Java程序从编译到运行的整个过程
学习Java语言,需要搞清楚的第一个问题就是Java程序是如何运行起来的。
与C语言先编译成二进制文件然后直接在机器上执行不同,Java语言会先编译成字节码文件(.class后缀),然后将字节码文件解释成二进制代码,最后在机器上执行。
Java编译器:将Java源文件(.java文件)编译成字节码文件(.class文件,是特殊的二进制文件,二进制字节码文件),这种字节码就是JVM的“机器语言”。javac.exe可以简单看成是Java编译器。
Java解释器:是JVM的一部分。Java解释器用来解释执行Java编译器编译后的程序。java.exe可以简单看成是Java解释器。
说一下无关性
这里主要指Java语言的平台无关性和语言无关性。
Java语言在诞生之初,就考虑到"平台无关性"。这里的"平台"是指操作系统。Java在诞生之初,就提出过一个宣传口号"一次编写,到处运行(Write Once,Run Anywhere)"。在各种不同的硬件体系结构和不同的操作系统长期并存发展的背景下,"平台无关性"的地位日渐重要。Java虚拟机在实现平台性时,使用一种平台无关的字节码,从而实现了程序的一次编写,到处运行。
实现语言无关性的基础是虚拟机和字节码存储格式。Java虚拟机不与包括Java在内的任何语言绑定,它只与"Class文件"这种特定的二进制文件格式所关联。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。**Class文件实现了Java虚拟机与Java语言的解耦。**Java虚拟机提供的语言无关性示例图如下:
JDK和JRE
JDK(Java Development Kit)是用于支持Java程序开发的最小环境。由Java语言、Java虚拟机、Java API类库三个部分组成。
JRE(Java Runtime Environment)是支持Java程序运行的标准环境。由Java SE API和Java虚拟机两部分组成。
JDK中包含JRE。 简单来说:如果你需要运行 java 程序,只需安装 JRE 就可以了,如果你需要编写 java 程序,需要安装 JDK。
JVM 体系结构
JVM 体系结构分为四部分:
(1) 类加载器(ClassLoader):用于装载 .class 文件
(2) 执行引擎:用于执行字节码,或者执行本地方法
(3) 运行时数据区:包括方法区、堆、Java 栈、PC 寄存器、本地方法栈
(4) 垃圾收集器:用于回收废弃垃圾
类文件结构
类文件也就是Class文件,是一组以8位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑地排列在Class文件中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据。各数据项按照“高位在前”(大端模式,Big-Endian)的方式进行存储。
根据Java虚拟机规范,Class文件格式采用一种类似于C语言结构体的伪结构体来存储,这种伪结构中只有两种数据类型:无符号数和表。无符号数属于基本数据类型,以u1、u2、u4、u8来分别代表1、2、4、8个字节的无符号数。表是由多个无符号数或其他表作为数据项构成的符合数据类型,所有的表都习惯性地以“_info”结尾。整个Class文件本质上就是一张表,它由如下所示的数据项构成。
魔数(Magic Number)
每个class文件开头的四个字节, 称为魔数。这个魔数的唯一作用是身份识别,标志这个文件是否为一个能被虚拟机接受的Class文件。该魔数是一个固定值: 0XCAFEBABE。
之所以没使用扩展名进行识别,主要是基于安全方面的考虑(文件扩展名可以随意地改动)。
Class文件版本号
魔数后面的四个字节是Class文件的次版本号(Minor Version,占用两个字节)和主版本号(Major Version,占用两个字节)。版本号标志Class文件对应虚拟机版本。Java的版本号从45开始(1.0和1.1),之后的主版本向上加 1。如47表示1.3版本。
虚拟机仅Class文件做向下兼容,而不支持向上兼容。也就是说,高版本的JVM能识别低版本的javac编译器编译的class文件,而低版本的JVM不能识别高版本的javac编译器编译的class文件。
常量池(Constant Pool)
版本号后面的是常量池。常量池容量不固定,在常量池的入口使用u2类型数据记录常量池容量,这个计数器被称为常量池容量计数器(Constant Pool Counter)。注意,该值从1开始计数。保留0来表示“不引用任何一个常量池数据项”。各个数据项通过索引值访问,时间复杂度为O(1)。
Class文件中常量池与运行时常量池是两个不同的概念。运行时常量池在内存中,而Class文件中常量池在文件中;两者的相似之处是都主要存储字面量(Literal)和符号引用(Symbolic Reference)。其中,字面量主要包括字符串,声明为final常量的值或者某个属性的初始值等;而符号引用主要存储类和接口的全限定名称(Fully Qualified Name),字段的名称和描述符,方法的名称和描述符。作为动态语言,虚拟机在运行期会将符号引用转化成真正的内存入口地址。而JVM内存模型方法区中的运行时常量池,除了存放存放编译期的字面量以及符号引用外,还可以存储运行时常量。最具代表性的就是String的intern方法。
常量池支持的数据项类型如下:
类型访问标志(Access Flag)
常量池后是占有两个字节的访问标志(Access Flag),用来识别类或接口的访问信息,比如这个Class是类还是接口,是public还是private,是否被声明为final等。具体标志位及含义如下:
两个字节的访问标志共16个标志位,使用逻辑或运算对可重叠标志进行合并。
类索引(This Class)、父类索引(Super Class)和接口索引集合(Interfaces)
访问标志的后面是类索引、父类索引和接口索引集合。Java中是单继承多实现,除Object类外,每个类都有父类,所以父类唯一,而一个类可以实现多个接口,因此接口不唯一。类索引和父类索引都是用一个u2类型数据表示,而接口索引集合则是一组u2类型数据表示。
类索引和父类索引各指向一个类型为CONSTANT_Class_info的类描述符常量,用来描述具体的类。接口索引第一项u2则为接口索引计数器,用来记录实现了多少个接口,如果为0则后面不再占用任何字节。
字段表集合(Fields)
类索引、父类索引和接口索引集合后面是字段数量(fields_count) 和字段表(field_info)集合,其中 fields_count表示类中field_info表的数量,field _info描述接口或类中声明的变量。字段(field)包括类的实例变量和类变量,但不包含从父类继承过来的字段。字段表中包含如下信息:字段的作用域(public、privte、protected),是实例变量还是类变量(static),是否可变(final),并发可见性(volatile,是否强制从主内存中读写),是否可被序列化(transient)等,字段数据类型(基本数据类型、对象、数组),字段名称。字段表结构如下:
因修饰符都是布尔值(要么有,要么没有),所以可用标志位表示。字段表的access_flag与类的access_flag作用类似。
紧跟access_flag的是两项索引值:name_index和descriptor_index。其中name_index表示的是字段的简单名称,descriptor_index 表示字段和方法的描述符。
简单名称是指没有类型和参数修饰的方法或字段名称。如类中的inc()方法和m字段的简单名称分别是“inc”和“m”。
全限定名是将类全名中的“.”替换成“/”,并使用“;”作为最后一个类(表示全限定名结束)。如“org/sun/class/string”是一个全限定名。
描述符是用来描述字段的数据类型、方法的参数列表(数量、类型及顺序)和返回值。根据描述符的规则:基本数据类型以及代表无返回值的void类型都用一个大写的字符来表示,而对象类型则用字符L加对象的全限定名来描述,对于数组类型,每一个维度用一个前置的 “[” 字符来描述,如定义个int[][]类型的二维数组,记录为:“[[I”。对于方法,按照先参数列表后返回值的顺序描述。参数列表按照参数顺序放在“()”内,如方法void login()描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava.lang.String”。
在descriptor_index是attribute_count和attribute_info集合,存储额外信息。(指向属性表集合)
方法表集合(Methods)
方法表集合后是方法数量(methods_count) 和 方法表(method_info)集合。Class文件存储格式中对方法和字段的描述完全一致,方法表的字段结构和字段表一样,包括访问标志、名称索引、描述符索引、属性表集合四项。这些数据的含义非常类似,在访问标志和属性表集合有所区别。
属性表集合(Attributes)
字段表和方法表中均使用到属性表(attribute_info)集合,用于描述某些特定场景的专有信息。虚拟机(Java SE 7)中预定义属性已达21项。本文仅对一些常用属性做介绍。
(1) Code 属性
Code属性存储方法体中代码Java代码编译成的字节码指令。Code 是Class文件中最重要的一个属性。如果将Java程序中的信息分为代码和元数据,那么Code 属性则来记录代码,其他数据项用来记录元数据(类、字段、方法定义等)。
异常表也是Java代码的一部分。编译器使用异常表,而不是简单的跳转命令来实现Java异常及finally处理机制。
(2) Exception属性
记录方法描述时,在throws关键字后面列举的异常。
(3) StackMapTable属性
该属性在JDK 1.6发布,是一个复杂的属性,位于Code属性的属性表中,用于辅助实现“字节码验证器”。
(4) Signature属性
Signature属性在JDK 1.5发布,用来记录泛型类型,以弥补Java语言的伪泛型带来的不足(反射时无法获取泛型信息等)。
类加载机制
虚拟机的类加载机制就是将描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可直接使用的Java类型的过程。
类从被加载到虚拟机内存中开始,直到从内存中卸载为止,它的整个生命周期分为七个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸御(Unloading)。其中验证、准备、解析三个部分统称为连接。七个阶段发生的顺序如下:
类加载时机
虚拟机规范中没有强行约束类加载的时机。也就是说,具体的虚拟机可以自定义类加载时机。但是,虚拟机严格规范了“类初始化”时机。有且只有下述五种情况会对类进行初始化:
(1) 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,会尝试进行初始化。上述四条指令对应的Java代码场景是:使用new关键字实例化对象时、读取或者设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)时、调用一个类的静态方法。
(2) 使用java.lang.reflect包的方法对类进行反射调用(如Class.forName(“com.xx.Main”)),如果类没有进行过初始化,则需要先触发其初始化。
(3) 当初始化一个类时,如果发现其父类还未初始化,则先触发父类的初始化。
(4) 虚拟机启动时,用户指定的需要执行的主类(包含main()方法的类)先初始化。
(5) 在使用JDK 1.7版本及更高版本时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄时,会尝试对这个方法句柄对应的类进行初始化。
对于以上五种场景,虚拟机规范将其行为称为对一个类进行“主动引用”。其他引用类的方式,均不会触发类的初始化,统称为“被动引用”。被动引用常见场景如下:
(1) 子类调用父类的静态字段,子类不会被初始化,只有父类会初始化。也就是说,对于静态字段,只有直接定义这个字段的类才会被初始化。
(2) 通过数组定义来引用类,不会触发该类的初始化。
(3) 访问类常量(static final修饰字段),不会触发该类的初始化。
类和接口在加载阶段的区别
接口的加载与类的加载稍有不同:
(1) 接口中不能使用static{}块。
(2) 当一个接口在初始化时,并不要求其父接口全部都完成初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化(延迟加载)。
类加载过程简介
虚拟机中类加载的全过程包括加载、验证、准备、解析、初始化、使用、卸载等七个阶段。
在这七个阶段中,加载、验证、准备、初始化等阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下会在初始化阶段之后开始,这是为了支持Java语言的运行时绑定。另外,上述几个阶段是按顺序开始,这些阶段是互相交叉进行,通常在一个阶段执行的过程中会调用或激活另一个阶段。
加载(Loading)
加载就是将类的二进制流加载到内存中。详细来说,在加载阶段,虚拟机需要完成以下三件事情:
(1)通过一个类的全限定名来获取其定义的二进制字节流;
(2)将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构;
(3)在 Java 堆中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的数据的访问入口。
注意,虚拟机没有严格限制二进制字节流的获取来源。除了从Class文件中获取,还可以从ZIP包中获取(Jar包、EAR包、VWAR包)、从网络中获取(最典型的应用是Applet)、运行时计算生成(使用动态代理技术)、其他文件生成(JSP应用)、从数据库中读取,等。
加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是开发人员可控性最强的阶段,开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。加载阶段的核心是“类加载器”。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,并在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。
验证(Verification)
验证的目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。
虚拟机对类验证的实现,大致都会包含以下四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。
(1)文件格式验证:验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。后面三个验证都是基于方法区的存储结构进行的。
(2)元数据验证:对类的元数据信息进行语义分析(对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
(3)字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,确保程序语义是合法、符合逻辑的。对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。字节码校验从类型推导转变为类型检查(StackMapTable属性记录是否合法),节省验证所需时间。
(4)符号引用验证:就是对对类自身以外的信息进行匹配性验证。该验证发生在虚拟机将符号引用转化为直接引用的时候,该验证的主要目的是确保解析动作能正常执行。
准备(Preparation)
准备阶段是为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下两点需要注意:
(1)分配内存的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
(2)这里的初始值,仅是数据类型默认的零值(如0、0L、null、false等),而不是在Java代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 3;
那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
(3) 字段属性表存在ConstantValue属性(类常量,static final)时,其值会初始化为ConstantValue指定的值。
解析(Resolution)
解析是虚拟机将常量池中的符号引用转化为直接引用的过程。
符号引用和直接引用
Class文件中不会保存类或接口、字段、方法的内存布局信息,虚拟机会在加载Class文件时进行动态连接,将符号引用转换为直接引用,也就是解析的过程。符号引用和直接引用的区别与关联:
(1)符号引用:一组符号来描述所引用的目标。符号引用与虚拟机实现的内存布局无关,引用的目标无需加载到内存。
(2)直接引用:直接指向目标的指针、相对偏移量或间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上解析的直接引用一般不会相同。直接引用引用的目标,必定已经存在于内存中。
解析时机
解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
解析目标
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info七种常量类型。
其中方法类型、方法句柄和调用点限定符位是JDK 1.7新增,与动态语言特性相关。
(1)类或接口的解析:将非数组类型或数组类型,调用不同的类加载器,解析成直接引用。在解析完成前,还会确认访问权限。
(2)字段解析:对字段解析前,会获取字段所属类或接口的符号引用,如果没有,则触发该类或接口的解析。获得字段所属类或接口的符号引用后,先在该类或接口中查找匹配的字段。如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和其父接口。如果还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。在解析完成前,同样也会确认访问权限。
(3)类方法解析:对类方法的解析与对字段解析的搜索步骤相似,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索是先搜索父类,再搜索接口。在解析完成前,同样也会确认访问权限。
(4)接口方法解析:与类方法解析步骤类似,接口不会有父类,因此,只会递归向上搜索父接口。由于接口方法默认是public,所以不存在访问权限问题。
初始化(Initialization)
特指类的初始化。初始化是执行类构造器()方法的过程。相比准备阶段,变量设置系统要求的初始值,初始化阶段会根据程序员指定的主观计划去初始化类变量和其他资源。
()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。
()方法不同于构造方法(类或实例的()方法)。在子类的调用前保证父类的()方法已被调用。
()方法是线程安全的,执行的线程需先获取锁才能进行初始化操作,保证只有一个线程能执行(利用此特性可以实现线程安全的懒汉单例模式)。
使用
当类完成初始化后,就可以使用该类了。
卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。注意,这里销毁的是类对象,不是实例对象,与垃圾回收无关。
类加载器
类加载器是根据一个类的全限定名来获取描述此类的二进制字节流。该功能模块被放到Java虚拟机外部实现,实现了应用程序自定义所需类的获取方式。
类和类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其唯一性,每个类加载器都有一个独立的类名称空间。
也就是说,同一个类,在多个类加载器加载后,不再相等。
双亲委派模型(Parents Delegation Model)
Java 中类加载器根据是否继承自java.lang.ClassLoader类可分成两类:一类是启动类加载器(Bootstrap ClassLoader),另一其他类加载器(Other ClassLoader)。启动类加载器使用C++实现,由虚拟机提供。其他类加载器由Java语言实现,独立于虚拟机外部。是由 Java 应用开发人员编写的。
从Java开发人员的角度,类加载器可细为四类:引导类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)、自定义类加载器(User ClassLoader)。
(1) 引导类加载器(Bootstrap ClassLoader): 用来加载 Java 的核心库(放在<JAVA_HOME>\lib目录或-Xbootclasspath参数指定目录,且虚拟机指定的文件名)。该类加载器不继承 java.lang.ClassLoader,如需将加载请求委派给引导类加载,直接使用null即可。
(2) 扩展类加载器(Extension ClassLoader): 用来加载 Java 的扩展库(放在<JAVA_HOME>\lib\ext目录或java.ext.dirs 系统变量指定的路径)。该类加载器继承自 java.lang.ClassLoader,如需将加载请求委派给引导类加载,可直接调用sun.misc.Launcher E x t C l a s s L o a d e r 。 ( 3 ) 应用程序类加载器( A p p l i c a t i o n C l a s s L o a d e r ):用来加载用户类库(放在用户类路径( C L A S S P A T H ))。由于这个类加载器是 C l a s s L o a d e r 中的 g e t S y s t e m C l a s s L o a d e r ( ) 方法的返回值,所以与将其称为“系统类加载器( S y s t e m C l a s s L o a d e r )”。该类加载器继承自 j a v a . l a n g . C l a s s L o a d e r ,如需将加载请求委派给引导类加载,可直接调用 s u n . m i s c . L a u n c h e r ExtClassLoader。 (3) 应用程序类加载器(Application ClassLoader): 用来加载用户类库(放在用户类路径(CLASSPATH))。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以与将其称为“系统类加载器(System ClassLoader)”。该类加载器继承自 java.lang.ClassLoader,如需将加载请求委派给引导类加载,可直接调用sun.misc.Launcher ExtClassLoader。(3)应用程序类加载器(ApplicationClassLoader):用来加载用户类库(放在用户类路径(CLASSPATH))。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以与将其称为“系统类加载器(SystemClassLoader)”。该类加载器继承自java.lang.ClassLoader,如需将加载请求委派给引导类加载,可直接调用sun.misc.LauncherApplicationClassLoader。
(4) 用户类加载器(User ClassLoader)。JVM建议用户将应用程序类加载器作为自定义类加载器的父类加载器。
双亲委派模型简介
类加载器之间的层次关系称为双亲委派模型。
双亲委派模型在JDK 1.2被引入。这里的“双亲”不是指父母,而更倾向于父类,指代当前类加载器的基类加载器。Java Doc 对其过程描述是:
The Java platform uses a delegation model for loading classes. The basic idea is that every class loader has a "parent" class loader. When loading a class, a class loader first "delegates" the search for the class to its parent class loader before attempting to find the class itself.
双亲委派模型工作过程是: 如果一个类加载器收到了类加载请求,它并不会自己先去加载这个类,而是把这个请求委托给父类的加载器去执行,每一层的类加载器都进行该操作,因此,请求最终将到达顶层的启动类加载器。只有当父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。
双亲委派模型优势
双亲委派模型的优势是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。这种机制的好处是:
(1)首先,这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要在子ClassLoader再加载一次。
(2)其次,是考虑到安全因素。Java 核心 API 中定义类型不应被随意替换。假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。同时,java.lang作为核心API包,需要访问权限,强制定义一个启动类加载器无法解析的类后,在自定义类加载中不会被加载。
双亲委派模型的实现如下:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {Class currentClass = findLoadedClass(name); // 首先,判断是否已经加载过该类if(currentClass == null) {try {if(parent != null) {currentClass = parent.loadClass(name, resolve);} else {currentClass = findBoostrapClassOrNull(name);}}catch(ClassNotFoundException exception) {// 父类无法完成加载请求}if(currentClass == null) {c = findClass(name);// 调用自身findClass方法加载类}}if(resolve) {resolveClass(currentClass);}
}
破坏双亲委派模型
任何事物都具有两面性,双亲委派模型也不例外。在双亲委派模型的发展中,有三次较大的“被破坏”情况。
向下兼容
双亲委派模型的第一次“被破坏”发生在双亲委派模型出现之前。
双亲委派模型发布于JDK 1.2,所以JDK 1.2之前的版本(JDK 1.0 和 JDK 1.1)实现的类加载器不支持双亲委派模型。在JDK 1.2之前,用户通过继承java.lang.ClassLoader,并重写loadClass()方法,实现自定义类加载器。比如,web容器(JBoss、Tomcat、Weblogic)实现了自定义类加载器。
为支持向后兼容,JDK1.2之后的java.lang.ClassLoader添加一个新的proceted方法findClass()。JDK1.2之后已不再提倡用户去覆盖loadClass()方法,而是把自己的类加载逻辑写到findClass()方法中。而对于基于重写loadClass()方法的方式,如果父类加载器加载失败,则可调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。 实现双亲委派模型的两种方式:
方法一:
重写父类java.lang.ClassLoader的findClass()方法。该方法仅适用于JDK 1.2及之后版本的类加载器。
方法二:
重写父类java.lang.ClassLoader的loadClass()方法和findClass()方法,并在调用loadClass()方法尝试使用父类加载器加载失败后,调用自己的findClass()方法。
支持SPI调用
双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的。
双亲委派模型解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),但是却无法解决基础类调用用户代码的场景。一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar中),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置:如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。
这样,JNDI服务就可通过线程上下文类加载器去加载所需SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背双亲委派模型。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。
支持热部署
双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的。
用户希望应用程序能像计算机外设一样,不用重启机器就能立即使用。对一些生产系统,“热部署”(代码热替换、模块热部署),尤其是企业级软件开发者具有极大的吸引力。
模块化规范化之争,主要有Sun主导的Jigsaw项目和已经成为业界“事实上”的Java模块化标准的OSGi。
OSGi的原理是:每个程序模块(OSGi称之为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,将Bundle连同类加载器一起换掉(程序模块和类加载器绑定在一起),以实现代码的热替换。
在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。
字节码指令简介
字节码指令存储在Code属性中。每个字节码指令是一个字节长度(0~255)的操作码(Opcode)和零到多个操作数(Operands),也就是说指令集的操作码个数不能超过256个。
指令集中,大多数的指令都包含其操作所对应的数据类型信息。如 iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中。
但由于虚拟机操作码长度只有一个字节,所以包含数据类型的操作码为指令集的设计带来压力:如果每一种数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那指令集的数据就会超过256个了。因此虚拟机只提供有限的指令集来支持所有的数据类型。
如load 操作, 只有iload、lload、fload、dload、aload用来支持int、long、float、double、reference 类型的入栈,而对于boolean 、byte、short 和char 则没有专门的指令来进行运算。编译器会在编译期或运行期将byte 和 short 类型的数据带符号扩展为int类型的数据,将boolean 和 char 类型的数据零位扩展为相应的int 类型数据。
加载和存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传输。
1)将一个局部变量加载到操作数栈的指令包括:iload,iload_,lload、lload_、float、 fload_、dload、dload_,aload、aload_。
2)将一个数值从操作数栈存储到局部变量表的指令:istore,istore_,lstore,lstore_,fstore,fstore_,dstore,dstore_,astore,astore_
3)将常量加载到操作数栈的指令:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_,lconst_,fconst_,dconst_
4)局部变量表的访问索引指令:wide
一部分以尖括号结尾的指令代表了一组指令,如iload_,代表了iload_0,iload_1等,这几组指令都是带有一个操作数的通用指令。
运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
1)加法指令:iadd,ladd,fadd,dadd
2)减法指令:isub,lsub,fsub,dsub
3)乘法指令:imul,lmul,fmul,dmul
4)除法指令:idiv,ldiv,fdiv,ddiv
5)求余指令:irem,lrem,frem,drem
6)取反指令:ineg,leng,fneg,dneg
7)位移指令:ishl,ishr,iushr,lshl,lshr,lushr
8)按位或指令:ior,lor
9)按位与指令:iand,land
10)按位异或指令:ixor,lxor
11)局部变量自增指令:iinc
12)比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp
Java虚拟机没有明确规定整型数据溢出的情况,但规定了处理整型数据时,只有除法和求余指令出现除数为0时会导致虚拟机抛出异常。
Java虚拟机要求在浮点数运算的时候,所有结果否必须舍入到适当的精度,如果有两种可表示的形式与该值一样,会优先选择最低有效位为零的。称之为最接近数舍入模式。
浮点数向整数转换的时候,Java虚拟机使用IEEE 754标准中的向零舍入模式,这种模式舍入的结果会导致数字被截断,所有小数部分的有效字节会被丢掉。
类型转换指令
类型转换指令将两种Java虚拟机数值类型相互转换,这些操作一般用于实现用户代码的显式类型转换操作。
JVM直接就支持宽化类型转换(小范围类型向大范围类型转换):
1)int类型到long,float,double类型
2)long类型到float,double类型
3)float到double类型
但在处理窄化类型转换时,必须显式使用转换指令来完成,这些指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和 d2f。
将int 或 long 窄化为整型T的时候,仅仅简单的把除了低位的N个字节以外的内容丢弃,N是T的长度。这有可能导致转换结果与输入值有不同的正负号。
在将一个浮点值窄化为整数类型T(仅限于 int 和 long 类型),将遵循以下转换规则:
1)如果浮点值是NaN , 呐转换结果就是int 或 long 类型的0
2)如果浮点值不是无穷大,浮点值使用IEEE 754 的向零舍入模式取整,获得整数v, 如果v在T表示范围之内,那就是v
3)否则,根据v的符号, 转换为T 所能表示的最大或者最小正数
对象创建与访问指令
虽然类实例和数组都是对象,Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。
1)创建实例的指令:new
2)创建数组的指令:newarray,anewarray,multianewarray
3)访问字段指令:getfield,putfield,getstatic,putstatic
4)把数组元素加载到操作数栈指令:baload,caload,saload,iaload,laload,faload,daload,aaload
5)将操作数栈的数值存储到数组元素中执行:bastore,castore,castore,sastore,iastore,fastore,dastore,aastore
6)取数组长度指令:arraylength JVM支持方法级同步和方法内部一段指令序列同步,这两种都是通过moniter实现的。
7)检查实例类型指令:instanceof,checkcast
操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:
1)将操作数栈的栈顶一个或两个元素出栈:pop、pop2
2)复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
3)将栈最顶端的两个数值互换:swap
控制转移指令
让JVM有条件或无条件从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括:
- 条件分支:ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnotnull,if_cmpeq,if_icmpne,if_icmlt,if_icmpgt等
- 复合条件分支:tableswitch,lookupswitch
- 无条件分支:goto,goto_w,jsr,jsr_w,ret
JVM中有专门的指令集处理int和reference类型的条件分支比较操作,为了可以无明显标示一个实体值是否是null,有专门的指令检测null 值。boolean类型和byte类型,char类型和short类型的条件分支比较操作,都使用int类型的比较指令完成,而 long,float,double条件分支比较操作,由相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件比较操作完成整个分支跳转。各种类型的比较都最终会转化为int类型的比较操作。
方法调用和返回指令
invokevirtual指令:调用对象的实例方法,根据对象的实际类型进行分派(虚拟机分派)。
invokeinterface指令:调用接口方法,在运行时搜索一个实现这个接口方法的对象,找出合适的方法进行调用。
invokespecial:调用需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法
invokestatic:调用类方法(static)
方法返回指令是根据返回值的类型区分的,包括ireturn(返回值是boolean,byte,char,short和 int),lreturn,freturn,drturn和areturn,另外一个return供void方法,实例初始化方法,类和接口的类初始化i方法使用。
异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都有athrow 指令来实现,除了用throw 语句显示抛出异常情况外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。
在Java虚拟机中,处理异常不是由字节码指令来实现的,而是采用异常表来完成的。
同步指令
方法级的同步是隐式的,无需通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机从方法常量池中的方法标结构中的 ACC_SYNCHRONIZED标志区分是否是同步方法。方法调用时,调用指令会检查该标志是否被设置,若设置,执行线程持有moniter,然后执行方法,最后完成方法时释放moniter。
同步一段指令集序列,通常由synchronized块标示,JVM指令集中有monitorenter和monitorexit来支持synchronized语义。
结构化锁定是指方法调用期间每一个monitor退出都与前面monitor进入相匹配的情形。JVM通过以下两条规则来保证结结构化锁成立(T代表一线程,M代表一个monitor):
(1) T在方法执行时持有M的次数必须与T在方法完成时释放的M次数相等
(2) 任何时刻都不会出现T释放M的次数比T持有M的次数多的情况
字节码执行引擎
JVM实现了可执行代码与操作系统的隔离。开发者在JVM上执行的代码从操作系统的机器码转变为虚拟机的字节码。而且,虚拟机的执行引擎,可定制指令集与执行引擎的结构体系,丰富了指令集。
字节码执行引擎的概念模型统一实现如下功能:将输入的字节码文件进行字节码解析后,输出执行结果。
运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈帧运行时所需内存固定,仅因具体虚拟机的实现而不同。栈帧运行时需要使用的内存已在编译代码阶段,对局部变量表的size,操作数栈的深度进行统计,并写入到方法表的Code属性中。
对于执行引擎来讲,在活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。
概念模型中,典型的栈帧结构如下图:
局部变量表(Local Variable Table)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。Class文件中方法的Code属性的max_locals数据项中存储该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范没有明确指明一个Slot应占用的内存空间大小,只是约束每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。也就是说,Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。如果在64位虚拟机中使用64位的物理内存空间去实现一个Slot,那么虚拟机需要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。
一个Slot可以存放一个32位以内(不是32位,随处理器、操作系统或虚拟机不同而变化)的数据类型。 Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress 8种类型。
reference类型表示对一个对象实例的引用。虚拟机规范既没有规定reference类型的长度,也没有明确指出这种引用的结构。一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束。(C++语言,默认情况下(不开启RTTI支持的情况),就只能满足第一点,而不满足第二点。这也是为何C++中提供反射的根本原因。)
returnAddress类型很少用,是为字节码指令jsr、jsr_w和ret服务的。该类型指向一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现已经由异常表代替。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确规定(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。值得一提的是,这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题(不存在线程安全问题)。
虚拟机通过索引的方式使用局部变量表(时间复杂度是O(1)),索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。虚拟机规定,对于存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,否则将抛出异常。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
Slot复用策略。为尽可能节省栈帧空间,局部变量表中的Slot支持重用。方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。
Slot复用策略设计尽管可节省栈帧空间,但也会伴随一些额外的副作用,例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为。示例如下:
public static void main(String[]args)(){byte[] placeholder = new byte[64*1024*1024];System.gc();
}
设置作用域后,仍不会回收:
public static void main(String[]args)(){{byte[] placeholder = new byte[64*1024*1024];}System.gc();
}
按照正常的理解,placeholder在执行GC时,已不再访问,该部分内存理应被回收,但是由于支持Slot重用策略,且placeholder占用的Slot没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍保存对它的关联。
所以,在Java编码规范中,常有不使用的对象应手动赋值为null编码规则。但是,这个操作真的很有必要吗?《深入理解Java虚拟机》一书给出两点理由:
(1)变量设置为null会被编译器优化掉。在使用编译器编译时,会进行编译优化。对于在变量使用完毕后,将其设置为null的代码,会因编译器的优化策略不同,可能会被优化掉。
(2)通过局部变量作用域来控制局部变量的回收时间时最优雅的解决方案。仅仅通过代码规范来规避Slot复用策略的缺点不是一种有效的解决方法。示例如下:
public static void main(String[]args)(){{byte[] placeholder = new byte[64*1024*1024];}int a = 0;System.gc();
}
未设置初值的局部变量,不能被使用。(未分配内存空间)
示例代码:
public static void main(String[]args)(){int a;System.out.println(a);// 抛出异常
}
操作数栈(Operand Stack)
操作数栈也常称为操作栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double,其中32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
栈帧重叠优化。在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。这主要参照局部性原理。图示如下:
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
动态连接(Dynamic Linking)
动态连接就是将方法的符号引用替换为运行时常量池中该栈帧所属方法的直接引用的过程。每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用(符号引用),字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态解析。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:正常完成出口(Normal Method Invocation Completion)和异常完成出口(Abrupt Method Invocation Completion)。
正常完成出口是通过方法返回的字节码指令实现。执行引擎在遇到return(无返回值)、ireturn(返回值是boolean、byte、char、short和int)、lreturn(返回值是long)、freturn(返回值是float)、dreturn(返回值是double)、areturn(返回值是reference类型)。
异常完成出口在方法执行过程中遇到异常,且该异常没有在方法体内得到处理的场景。新的异常处理使用方法表实现。当Java虚拟机内部产生异常,或代码中使用athrow字节码指令产生的异常后,如果在该方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。古老的虚拟机使用jsr、jsr_w和ret字节码指令,配合returnAddress类型(存储一条字节码指令的地址),实现异常处理。方法异常退出后,不会产生任何返回值。
无论是正常完成,还是异常完成,方法在退出后,都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用
在本文中,方法调用不等同于方法执行,方法调用阶段唯一的任务是确定被调用方法的版本(调用哪一个方法),还不涉及方法内部的具体运作过程。
方法调用的过程就是Class文件存储的符号引用替换为目标方法的直接引用。
解析调用(Resolution Invoke)
在类加载的解析阶段,会将一部分符号引用转为直接引用,也就是在编译阶段就能够确定唯一的目标方法,这类方法的调用称为解析调用。
此类方法的特点是“编译期可知,运行期不可变”。主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问,因此他们都不可能通过继承或者别的方式重写该方法。符合这两类的方法主要有以下四种:静态方法、私有方法、实例构造器、父类方法。
虚拟机中提供以下五条方法调用指令:
invokestatic:调用静态方法,解析阶段确定唯一方法版本
invokespecial:调用实例构造器init方法、私有及父类方法,解析阶段确定唯一方法版本
invokevirtual:调用所有虚方法和final方法
invokeinterface:调用接口方法
invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法(静态方法、私有方法、实例构造器、父类方法)称为非虚方法,其余的(final修饰的除外)称为虚方法。
对于final修饰的方法,尽管使用invokeVirtual指令调用,但是这这种方法无法被覆盖,所以无须对方法接收者进行多态选择,又或者说,多态选择的结果唯一。Java语言规范明确规定,final方法是一种非虚方法。
分派调用(Dispatch Invoke)
解析调用和分派调用是描述方法调用的不同角度,本质上还是将符号引用替换成直接引用。
解析调用是一个静态过程,在编译期可完全确定需要执行的方法,不会在运行期处理。
分派与多态特性有关,能揭示“重载”和“重写”的实现原理。
分派调用根据在编译期完成,还是运行期完成,可分为静态分派和动态分派。另外,根据分派依据的宗量数,可分为单分派和多分派。两两组合后,分派可分为四类:静态单分派、静态多分派、动态单分派、动态多分派。
静态分派
在学习静态分派前,需要了解两个概念:静态类型(Static Type,也称外观类型)和实际类型(Actual Type,不是动态类型)。
静态类型可以理解为变量声明的类型,而实际类型就是创建这个对象的类型。如下面的man这个变量,它的静态类型就是Human,man这个变量的实际类型就是Man。
Human man = new Man();
静态类型和实际类型的区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会发生变化,并且最终的静态类型是编译期间可知的。而实际类型的变化结果在运行期才可以确定,编译器在编译程序时并不知道一个对象的实际类型是什么。比如下面的代码:
// 实际类型变化
Human man = new Man();
man = new Woman();
// 静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);
所有依赖静态类型来定位方法版本的分派动作叫做静态分派,静态分派的典型应用是方法重载。静态分派发生在编译期间,因此确定静态分派的动作实际上不是由虚拟机来执行的。
另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只是一个相对来说更加合适的版本。这主要是因为字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去推断。而现有推断规则,是一种模糊规则(类型转换规则)。
注意,解析调用和分派调用不是二选一的关系。比如静态方法的重载是分派调用,但也是解析调用。
动态分派
如果说,静态分派和重载有关,那么动态分派则和重写有关。
在运行期根据实际类型确定方法执行版本的分派过程叫做动态分派。动态分派的典型应用场景就是方法重写。
invokevirtual指令在多态查找时,其运行时解析过程大致分为以下几个步骤:
(1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C;
(2)如果在类型C中找到与常量中的描述符和简单名称一样的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,返回java.lang.IllegalAccessError异常;
(3)否则,按照继承关系从下到上依次对C的各个父类进行搜索和验证;
(4)如果还没有找到合适的方法,抛出java.lang.AbstractMethodError异常。
invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,然后将常量池中的类方法符号引用解析到直接引用上,这个过程就是Java语言中方法重写的本质。
动态分派示例代码如下:
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
单分派和多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于宗量的个数,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是基于多个宗量。
示例代码如下:
public class Dispatch {static class Pepsi {}static class Coca {}public static class Father {public void like(Pepsi p) {System.out.println("Father likes pepsi");}public void like(Coca c) {System.out.println("Father likes coca");}}public static class Son extends Father {public void like(Pepsi p){System.out.println("Son likes pepsi");}public void like(Coca c) {System.out.println("Son likes coca");}}public static void main(String[] args) {Father father = new Father();Father son = new Son();father.like(new Coca()); // 静态多分派son.like(new Pepsi()); // 动态单分派}
}
在编译期,father实例是静态类型,是静态分派。在选择目标方法时,依据两点:(1)静态类型是Father还是Son; (2)方法参数是Pepsi还是Coca。这次选择产生了两个invokevirtual指令,两条指令的参数分别为常量池中指向Father.like(Coca)和Father.like(Pepsi)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
在运行时,son实例需要明确实际类型,是动态分派过程。在执行son.like(new Pepsi())时,由于编译期已经明确目标方法的签名必须是like(Pepsi),所以虚拟机此时不关心传递的参数是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成影响,唯一有影响的就是方法接收者的实际类型是Father还是Son。因为只有一个宗量,所以Java的动态分派属于单分派。
目前Java语言是一门静态多分派、动态单分派的语言。而Java虚拟机已经提供对动态语言的支持,即JDK 1.7中新增invokedynamic指令。
动态分派的实现简介
动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,基于性能的考虑,大部分虚拟机都会对其进行优化。常用的“稳定优化”手段是使用“虚方法表”。也就是在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能。
虚方法表存放各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类和子类的虚方法表中都应该具有一样的索引号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,完成类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
动态类型语言
动态类型语言和静态类型语言
静态类型语言是在编译期进行类型检查过程的语言(如C、C++、Java)。强类型语言。
动态类型语言是在运行期进行类型检查过程的语言(如JavaScript、Lua、PHP、Ruby等)。弱类型语言。
Java虚拟机的愿景是可支持多种语言运行在虚拟机上,因此,在虚拟机层面提供动态类型的支持很有必要。这也是JDK 1.7中invokedynamic指令及java.lang.invoke包出现的技术背景。
本文介绍的动态类型支持概念,重点介绍函数指针场景。
java.lang.invoke
java.lang.invoke包的重要目的除了依靠符号引用来确定调用目标方法以外,还提供一种新的动态确定目标方法的机制,叫做MethodHandle。
java语言无法将一个函数作为参数进行传递,普遍的做法是设计一个接口,然后以实现了这个接口的对象作为参数。引入java.lang.invoke后,可以基于MethoedHandle实现类似功能。
public class Ser {interface IPrintable{println();}pulic static void println(IPrintable printer) {printer.println();}static class ClassA implements IPrintable{public void println(String s) {System.out.println(s);}}public static void main(String... strings) throws Throwable {println(new ClassA()); // 使用接口,实现钩子Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();getPrintlnMH(obj).invokeExact("cadasdasda"); // 使用MethodHandle实现类似函数指针功能}private static MethodHandle getPrintlnMH( Object obj) throws Throwable {// MethodType,也称为方法模板,用于记录方法类型,第一个参数是方法返回值,其余参数是方法参数MethodType mt = MethodType.methodType(void.class, String.class);// lookup()方法是在指定类中查找符合方法名称、方法类型、调用权限的方法的句柄// findVirtual()方法用于查找特定类中指定方法简单名称和模板信息的方法// bindTo()方法关联方法和方法接收者return MethodHandles.lookup().findVirtual(obj.getClass(), "println",mt).bindTo(obj);}
}
Reflection和MethodHandle
除了使用MethodHandle模拟方法调用,还可以使用反射实现,两者侧重定不同。
(1)尽管Relection和MethodHandle都是模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.lookup中的3个方法-findStaitc、findVirtual、findSpecial对应invokestatic、invokevirtual和invokespecial这几条字节码指令,而这些底层细节在使用Reflection API时是不需要关心的。
(2)Reflection中的java.lang.reflect.Method对象远比MethodHandle机制信息含量多,换句话说,Reflection是重量级,而MethodHandle是轻量级。
(3)MethodHandle性能可在虚拟机层面进行优化,而Reflection则不能。
(4)Reflection API仅面向Java语言服务,而MethodHandle则设计成面向运行在Java虚拟机之上的语言。(在其他语言中,引用MethodHandle对应的包)
invokedynamic指令
invokedynamic指令需要与MethodHandle机制一样,都是为了解决原有四条“invoke”指令方法分派规则固化在虚拟机之中后,无法实现类似函数指针功能的问题,从而将查找目标方法的决定权从虚拟机转移到用户代码。
每个出现的invokedynamic指令的位置都称为一个动态调用点(dynamic call site)。每个动态调用点在初始化时,都处于未链接的状态(不包含要调用方法)。invokedynamic指令的第一个参数不再是代表方法符号引用等CONSTANT_Methodref_info常量,而是JDK 1.7新增的CONSTANT_InvokedDynamic_info常量。这个常量包含三部分信息:引导方法(bootstrap method)、方法类型(MethodType)、方法名称。
虚拟机执行invokedynamic指令时,首先需要链接其对应的动态调用点。在链接的时候,Java虚拟机会先调用对应的引导方法(bootstrap method)。这个启动方法的返回值是java.lang.invoke.CallSite类的对象。这个CallSite对象的getTarget方法可以获取到实际要调用的目标方法句柄。有了方法句柄之后,对这个动态调用点的调用,实际上是代理给方法句柄来完成的。也就是说,对invokedynamic指令的调用实际上就等价于对方法句柄的调用,具体来说是被转换成对方法句柄的invoke方法的调用。Java 7中提供了三种类型的动态调用点CallSite的实现,分别是java.lang.invoke.ConstantCallSite、java.lang. invoke.MutableCallSite和java.lang.invoke.VolatileCallSite。这些CallSite实现的不同之处在于所对应的目标方法句柄的特性不同。ConstantCallSite所表示的调用点绑定的是一个固定的方法句柄,一旦链接之后,就无法修改;MutableCallSite所表示的调用点则允许在运行时动态修改其目标方法句柄,即可以重新链接到新的方法句柄上;而VolatileCallSite的作用与MutableCallSite类似,不同的是它适用于多线程情况,用来保证对于目标方法句柄所做的修改能够被其他线程看到。这也是名称中volatile的含义所在,类似于Java中的volatile关键词的作用。
invokedynamic指令面向字节码。由于传统的Java编译器并不会生成invokedynamic指令,所以为利用invokedynamic指令,还需要开发人员自己生成包含这个指令的字节代码。一种推荐的方式是使用第三方工具实现这个需求,而不是手动修改生成的字节码,如INDY工具。
方法执行
方法执行就是虚拟机执行方法中的字节码指令的过程。
执行引擎在执行Java代码时,有两种模式选择:解释执行(通过解释器执行)和编译执行(通过即使编译器产生本地代码执行)。
解释执行
解释执行,就是执行程序时,再将中间码(例如Java的字节码通过JVM解释成机器码)一行行的解释成机器码进行执行。这个运行过程是解释一行,执行一行。
Java语言经常被人们定位为“解释执行”的语言,在JDK 1.0时代,这种定义还算比较准确,但当主流的虚拟机都包含了即时编译器后,Class文件到底是被解释执行还是编译执行,就成了只有虚拟机才能准确判断的事情了。所以,在当前阶段笼统地说“解释执行”,对Java语言来说是没有任何意义的。只有确定了Java实现版本和执行引擎运行模式时,谈论解释执行还是编译执行才会比较确切。
编译执行
编译执行,就是将一段程序直接翻译成机器码(对于C/C++这种非跨平台的语言)。编译执行是直接将所有语句都编译成了机器语言,并且保存成可执行的机器码。执行的时候,是直接进行执行机器语言,不需要再进行解释/编译。
内存管理
C、 C++语言依赖开发人员管理内存,提高灵活性,但也带来了内存泄漏(OutOfMemeory,内存泄漏指是指由于疏忽或错误造成程序未能释放已经不再使用的内存。主要指指针跑飞)和内存溢出(StackOverflow,内存溢出是指系统在为某段执行指令(程序)分配内存时,发现剩余内存不足并抛出错误。主要指栈溢出)的隐患。严格依赖于程序员的对内存的认知水平。
为1.屏蔽开发人员对内存的直接操作,2.保证内存的安全使用,JVM承担了这部分职责。
尽管JVM自动内存管理机制已经很完善,但是仍可能存在内存泄漏或内存溢出的问题。为增加在这类问题出现后的处理能力,有必要熟悉JVM的内存管理机制。
内存管理概述
Java 虚拟机运行时数据区划分为:堆、方法区、程序计数器、虚拟机栈、本地方法栈。其概述图如下:
对于C语言内存区,可以发现JVM在C语言的基础上进行了调整,以配合Java语言面向对象、支持原生调用、支持多线程等特性。
Java 堆(Java Heap)
Java堆是供绝大多数类实例和数组对象分配内存的区域。(神域的小部分类实例分配在方法区–常量引用的对象)
Java堆是各个线程共享的运行时内存区域。
Java堆是垃圾收集器(GC)管理的主要对象,因此也被称为“GC堆”(Garbage Collected Heap,注意,这里未翻译成“垃圾堆”)。
JVM规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续即可。(物理可不连续,逻辑必须连续)
方法区(Method Area)
方法区用于存储已被虚拟机加载的类的结构信息、运行时常量池、编译后的代码等。
方法区是所有线程共享的内存区域,与堆类似。
方法区存储类数据,Java堆存储对象数据。类加载时主要和方法区交互,对象分配时主要和Java堆交互。
运行时常量池
运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到运行时常量池中。
同时,运行时的常量也会放到这里。如String类型的intern()方法。
运行时常量池与C语言的“数据区”类似。
程序计数器(Program Counter Register)
程序计数器可看作当前线程所执行的字节码的行号指示器。所谓字节码指示器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
每个线程都有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,这类内存区域为“线程私有”内存。
Java 栈(Java Stack)
Java 栈描述Java方法执行的内存模型,每个方法执行的时候都会创建一个栈帧(Stack Frame)。栈帧用于存放局部变量表、操作数栈、动态链接、方法出口等方法执行相关信息。方法的调用和完成过程,对应一个栈帧在JVM栈中从入栈到出栈的过程。
Java 栈与程序计数器一样,也是线程私有,它的生命周期与线程相同。
本地方法栈(Native Method Stack)
JVM根据方法的实现语言,将方法分为两类:Java方法和Native方法。其中Native方法特指C/C++方法。Java方法在Java 栈中存储、调用。Native方法则在本地方法栈中存储、调用。
本地方法栈与Java 栈一样,也是线程私有,它的生命周期与线程相同。
直接内存
直接内存不是虚拟机运行时数据区的一部分。在JDK 1.4 新加入NIO(New Input/Output)类时,引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式。其使用Native函数库直接分配内存,然后通过一个存储在Java对立面的DirectByteBuffer对象作为这块内存的引用进行操作。
直接内存不受Java 堆的大小影响,但是会受到本机总内存大大小及处理器寻址空间的限制。
HotSpot对象内存处理
作为最流行的虚拟机,研究HotSpot对象在内存的处理过程具有实用价值。虚拟机中对象的处理主要三个部分:对象创建、对象布局、对象访问。
1. 对象创建
Java中对象创建,在Java语言层面,仅仅是一个new关键字。而当虚拟机遇到一条new指令时,会进行一序列对象创建的操作。HotSpot虚拟机中对象创建主要分为五步:(1)加载类;(2)分配内存;(3)初始化零值;(4)设置对象头;(5)执行Init方法。
(1)加载类
当虚拟机遇到一条new指令时,首先会去检查该指令的参数能否在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化过,如果没有,则必须先执行相应的类加载过程。
(2)分配内存
类加载检查完成后,虚拟机将为新对象分配内存空间。该过程就是在堆中划分一小部分确定大小的空间,用于存储对象信息。其中分配方式有以下两种:指针碰撞和空闲列表。指针碰撞是指在内存绝对规整的前提下,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器。而分配内存就是将指针向空闲空间挪动一段与对象大小相等的距离。空闲列表则假定内存不规整,无法简单地进行指针碰撞,此时必须维护一个列表,记录可用内存块。内存是否规整,与垃圾收集器是否带有压缩整理功能决定。
另一个需要考虑的问题就是并发场景下内存分配。在并发场景下,可能存在给对象A分配内存,指针未及时修改,对象B又同时使用原来的指针来分配内存的情况。解决这个问题的方案有两种:一种是“CAS+失败重试”保证更新操作的原子性;另一种是优先使用TLAB(Thread Local Allocation Buffer,线程本地分配缓存),TLAB是指每个线程在Java堆中预先分配的一小块内存。
(3)初始化零值
内存分配完毕后,虚拟机将该对象分配的内存空间全部设置初始值零(不包含对象头部分),该操作可以保证对象的实例字段在代码中即使不赋予初始值也可以直接使用。程序能访问到这些字段的数据类型所对应的零值(不包括对象头)。
(4)设置对象头
初始化零值完成后,虚拟机将对象的一些必要信息存放在对象头中,这些信息包括:例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
(5)执行Init方法
虚拟机完成一个对象的创建后,对于java程序而言,对于该对象的一些定制的内容还未进行,方法中包含了程序员的定制需求和意愿,执行完init方法后,对象完成了初始化,此时才是一个可用对象。
2. 对象布局
虚拟机中,对象在内存中的存储包括三个部分:对象头、实例数据和对齐填充。
对象头
对象头包括两部分信息,第一部分用于存储对象自身运行时数据(哈希码、GC分代年龄、锁状态标志等等);另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充
该部分不是必然存在的,仅起占位作用,可类比C语言的结构体的对齐特性。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3. 对象访问
Java程序通过java栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有两种:(1)使用句柄;(2)使用直接指针。
两种访问方式各有优势:使用句柄访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时(垃圾回收时,移动对象是非常普遍的行为),只需改变句柄中的实例数据的指针,而reference本身不需要调整。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
HotSpot使用第二种方式进行对象访问,但从整个软件开发的范围开看,使用句柄访问的方式也很常见。
垃圾回收
如何判断对象存活
在研究对象的回收之前,我们需要先看一下如何进行判断对象是否还有存活价值,即要先判断对象是否还有被引用。
堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆回收之前,第一件事情就是要确定这些对象哪些还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象)。在主流的商用程序语言中(Java和C#等),都是使用可达性分析算法(Reachability Analysis)来判断对象是否存活的,但是又有很多人认为是用引用计数算法(Reference Counting)来判断。接下来将 分别介绍这两种算法。
引用计数(Reference Counting)算法
引用计数算法实现思想如下:给对象中添加一个引用计数器,每当有一个地方引用该对象时,其计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
引用计数算法实现简单,判定高效,在大部分情况下她都是一个不错的算法,也有一些比较著名的应用案例,如微软公司的COM(Component Object Model)技术、Python语言和在游戏脚本领域被广泛应用的Squirrel都使用引用计数算法进行内存管理。但是,引用计数算法无法解决对象之间循环引用的问题,也即ABBA问题。
之所以在COM技术、Python等使用该技术,是因为其已找到对应的解决策略或定位循环引用的方法。能想到的一种简单的方法是通过分层的概念,来避免循环引用。强制约束同层之间不能相互引用。也可以通过判断算法判断是否存在ABBA这种场景。还有一种方法就是规定一些根结点,这个根结点只能被外部引用,不存在引用他人的场景(这里将其成为引用原子性),这种算法思想也是“可达性分析算法”的基础。
可达性分析(Reachability Analysis)算法
在主流的商用程序语言中,都是通过可达性分析来判断对象是否存活的。这个算法的思想是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始往下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连接时(用图论的话来说,就是从GC Roots到这个对象不可达),就证明此对象是不可用的。示例如下:
在Java语言中,可以作为GC Roots对象的有:
(1)虚拟机栈(栈帧的本地变量表)中引用的对象;
(2)本地方法栈中JNI(Native方法)引用的对象;
(3)方法区中类静态属性(static修饰)引用的对象;
(4)方法区中常量(final修饰)引用的对象。
引用级别划分
引用计数算法和可达性分析算法通过管理引用来判断该对象是否存活。为了更好的进行内存管理,丰富对象的引用状态,更好的刻画现实世界,在JDK 1.2后,Java对引用的概念进行了扩充。根据引用对应垃圾回收的力度,引用可分为四种。从强到弱依次是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
(1)强引用
如果一个对象具有强引用,那垃圾回收器绝不会回收它。使用方式如下:
Object o = new Object(); // 强引用
如果不使用,尽量通过如下方式来弱化引用:
o = null; // 帮助垃圾收集器回收此对象
显式地设置o为null,或超出对象的生命周期范围,则gc认为该对象不存在引用,就可回收对象。
在方法内部的强引用,会在方法运行完成后退出虚拟机栈或本地方法栈,引用消失。此后这个Object可被回收。如果这个对象是全局变量,就需在不用这个对象时赋值为null,因为强引用不会被垃圾回收。
(2)软引用
如果一个对象只具有软引用,当内存空间不足时,该引用对应的对象的内存将被回收。软引用可用来实现内存敏感的高速缓存。
String str=new String("abc");
SoftReference<String> softRef=new SoftReference<String>(str); // 软引用
当内存不足时,等价于:
String str=new String("abc");
if(JVM.内存不足()) {str = null; // 转换为软引用System.gc(); // 垃圾回收器进行回收
}
软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了:1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建;2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。这时就可以使用软引用。实例如下:
Browser prev = new Browser(); // 获取页面进行浏览
SoftReference sr = new SoftReference(prev); // 浏览完毕后置为软引用
if(sr.get()!=null){ rev = (Browser) sr.get(); // 还没有被回收器回收,直接获取
}else{prev = new Browser(); // 由于内存吃紧,所以对软引用的对象回收了sr = new SoftReference(prev); // 重新构建
}
(3)弱引用
如果一个对象只具有弱引用,不管内存空间足够与否,都会在执行GC时,回收对应内存。使用方式如下:
String str=new String("abc");
WeakReference<String> abcWeakRef = new WeakReference<String>(str);
执行GC时,等价于:
String str=new String("abc");
str = null;
System.gc();
如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那可用 Weak Reference 来引用此对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候你就是用弱引用。
这个引用不会在对象的垃圾回收判断中产生任何附加的影响。
(4)虚引用
如果一个对象只具有虚引用,那么在任何时候,都有可能被GC。与其他几种引用都不同,虚引用并不会决定对象的生命周期。虚引用主要用来跟踪对象被垃圾回收器回收的活动,从而在对象被垃圾回收器回收时收到一个系统通知。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
垃圾收集算法
不同垃圾回收器,可能采用不同的垃圾回收算法。这里,重点介绍几种常用的垃圾回收算法。
标记-清除(Mark-Sweep)算法
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。该算法的思想是:首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。
标记-清除算法存在两点不足:1.标记和清除过程效率不高;2.会产生空间碎片问题。
1.标记和清除过程效率低下问题
使用“对象存活检测算法”对需要回收的对象进行标记的过程,以及从Java堆中遍历需要被清除的对象,并进行清除。因需要进行遍历,所以效率不高。
2.空间碎片问题
清除可回收对象仅仅是将该对象占用的内存回收,所以会产生不连续空间碎片。当需要分配较大对象时,无法找到足够的内存而不得不提前触发另一次垃圾回收。
标记-清除算法使用的方法是最简单的。但标记阶段完成后,未被访问到的对象需要使用一个空闲列表来记录所有的空闲区域以及大小。对空闲列表的管理会增加分配对象时的性能。
标记-清除算法执行过程实例如下:
复制算法
复制算法是为了解决标记-清除算法的效率问题。其思想如下:将可用内存的容量分为大小相等的两块,每次只使用其中的一块,当其中一块内存用完或不足以分配给下一个对象时,就将该内存块中存活的对象复制到另一个内存块上面=,然后再把该内存块的空间清除。
优点:
内存分配时,顺序分配内存,无需考虑内存碎片问题。实现简单,运行高效。
缺点:
该算法的代价是将内存缩小为原来的一半,内存利用率过低。
复制算法执行实例如下:
现代商业虚拟机都采用该算法来回收新生代。由于新生代中绝大部分对象存活时间较短,所以无需按照1:1的比例来划分内存空间。而是将内存分为较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。HotSpot虚拟机中默认Eden和Survivor的大小比例是8:1。
Eden 区
IBM 公司的专业研究表明,98%的对象是朝生夕死。所以,大多数情况下,对象会在Eden分配,当Eden没有足够空间时,虚拟机会发起一次Minor GC。在Minor GC中,Eden 会被清空,对于无需回收的对象将会进入到Survivor的From区。
Survivor 区
Survivor 区相当于Eden 和 Old 区的缓冲,暂存存活时间不是很多的对象。
1.为什么需要 Survivor 区?
Eden 区回收后存活对象如果直接进入Old区,会导致Old 区很快沾满,且存活对象在第二次或三次回收时,就可被清除。
Survior 区进行预筛选,只有经历16次Minor GC的对象才能被送到老年代。
一个例外:如果Survivor 区不足以存放Eden区和另外一个Survivor区的存活对象,那么这些对象将直接进入Old区。
2.为什么需要两个Survivor区?
如果只有一个Survivor区,在执行Minor GC时,既要考虑将Eden 区存活对象放置到该Survivor区,还需要考虑Survivor区中剩余存活对象。因为这种场景下,只能使用标记清除算法,会带来内存碎片问题。
如果有两个Survivor区,则可将Eden 区存活对象和其中一个Survivor区的剩余存活对象(上一次Minor GC存活对象的再次筛选或存活的对象)复制到另一个Survivor区。
3.为什么不是多个Survivor区?
Survivor区数量越多,每个Survivor区的Size就越小,越容易导致Survivor区满。两个Survivor区是权衡后的最佳方案。
(4) 无需回收的对象的Size大于Survivor怎么办?
如果无需回收对象的Size大于Survivor(Survivor无法完全存储无需回收的对象),部分无法安置的对象会直接进入老年代。(内存担保机制)
标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的内存空间,就需要额外的空间进行分配担保,以应对内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
标记-整理算法与标记-清除算法类似,只是在标记后,不是对未标记的内存区域进行清理,而是让所有的存活对象都向一端移动,然后清理掉边界外的内存。
标记-整理算法实例如下:
分代收集算法
分代收集算法就是根据对象存活周期的不同,将内存划分几块。一般是将Java堆分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,经常选用复制算法。
在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清除”或“标记-整理”算法来进行回收。
分代收集算法实例如下:
Java堆主要分为两个区域-新生代和老年代,其中新生代占据1/3的内存空间,老年代占据2/3的内存空间。
HotSpot内存回收简介
商用虚拟机在实现GC时,必须对算法的执行效率进行考量,以保证虚拟机高效运行。在发起内存回收时,HotSpot采用以下优化策略:枚举根节点、定义安全点、定义安全区域。
可达性分析算法虽然能定位可回收对象,但是存在以下问题:
1.明确GC Roots耗时
可达性分析的基础是明确GC Roots节点。可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
2.GC停顿不可避免
可达性分析对执行时间的敏感还体现在GC停顿上。因为这项分析工作必须在一个能确保一致性的快照中进行。这里的“一致性”是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有
Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
枚举根节点
由于目前的主流Java虚拟机使用的都是准确式GC(参考Exact VM对Classic VM的改进),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。
在HotSpot中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
这样,算法的时间复杂度就从O(n)优化到O(1),且不会占用过多的额外内存。
定义安全点(Safepoint)
OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot并没有为每条指令都生成OopMap,而是在“特定的位置”记录这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。安全点的选定是以“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
对于Sefepoint,还需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
主动式中断是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
定义安全区域(Safe Region)
Safepoint机制保证在程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”(没有分配CPU时间,也即线程处于Sleep状态或者Blocked状态),这时线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。
在线程执行到Safe Region代码时,首先标识自己已经进入了Safe Region。当在这段时间里JVM要发起GC时,就不用管标识为Safe Region状态的线程。在线程要离开Safe Region时,要检查系统是否已经完成根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
常用垃圾收集器简介
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范对垃圾收集器应该如何实现并没有任何规定。基于JDK1.7 Update 14 之后的HotSpot虚拟机,所包含的收集器如下图所示:
可见,HotSpot采用了七种种垃圾收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。Hotspot之所以实现如此多的收集器,是因为目前并无完美的收集器出现,只能选择对具体应用最适合的收集器。
Serial收集器
Serial(串行)收集器是最基本、发展历史最悠久,基于复制算法的新生代收集器,是JDK 1.3.1之前新生代收集器的唯一选择。它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接受的。Serial收集器的运行示意图如下:
尽管“Stop The World”会导致服务不可用,且HotSpot开发团队为消除或减少停顿而不断努力(从Parallel收集器到Concurrent Mark Sweep收集器,再到Garbage first收集器),在桌面级别应用场景,待收集内存不会太大,停顿时间完全可控制在几十毫秒,最多一百多毫秒。而且,作为单线程收集器,Serial收集器可以获得最高的单线程收集效率。
ParNew收集器
ParNew同样用于新生代,是Serial的多线程版本,并且在参数、算法(同样是复制算法)上也完全和Serial相同。
Par是Parallel的缩写,但它的并行仅仅指的是收集多线程并行,并不是收集和原程序可以并行进行。ParNew也是需要暂停程序一切的工作,然后多线程执行垃圾回收。ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):
ParNew收集器相比Serial收集器,仅实现基于多线程的GC,但它却是Server模式下的首选新生代收集器。其中一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器配合工作。
ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。在多CPU环境下,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。
Parallel Scavenge 收集器
Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
支持参数控制,以及自适应调节策略是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
Serial Old收集器
Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。
此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:
(1)在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
(2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
它的工作流程与Serial收集器相同。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。该收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作流程与Parallel Scavenge相同,这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:
CMS(Concurrent Mark Sweep)收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的。CMS收集器工作的整个流程分为以下4个步骤:
(1)初始标记(Initial mark):仅标记GC Roots能直接关联到的对象,速度很快(准确式内存),需要“Stop The World”。
(2)并发标记(Concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长,并发执行。
(3)重新标记(Remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
(4)并发清除(Concurrent sweep):对已标记的垃圾进行GC,并发执行。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。CMS收集器的运作步骤中并发和需要停顿的时间:
CMS是一款优秀的收集器,起主要优点体现在:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。 但是CMS收集器存在以下缺点:
(1)对CPU资源非常敏感。因面向并发设计程序,所以对CPU资源比较敏感。在多核时代,这个缺点已转换成优点。但在单核处理器场景下,则要慎重考虑。
(2)无法处理浮动垃圾(Floating Garbage)。 “浮动垃圾”是指在CMS并发清理阶段,用户线程运行产生的新垃圾。这一部分垃圾出现在标记之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。“浮动垃圾”会导致出现“Concurrent Mode Failure”,进而引发另一次Full GC。同时,由于垃圾收集阶段用户线程还需运行,所以必须预留足够的内存空间给用户线程使用。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
(3)标记-清除算法导致的空间碎片。CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来不便。可以通过设置参数,决定执行合并整理的时机。
G1收集器
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的,可预测停顿时间的垃圾收集器。G1具备如下特点:
(1)并行与并发。G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间。
(2)分代收集。分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
(3)空间整合。G1从整体来看是基于“标记-整理”算法的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
(4)可预测的停顿。这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)垃圾收集器的特征。
横跨整个堆内存。G1将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离,而都是一部分Region(不需要连续)的集合。
建立可预测的时间模型。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
避免全堆扫描——Remembered Set。G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
G1收集器的运作大致可划分为以下几个步骤:
(1)初始标记(Initial Mark)。仅标记 GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的Region中创建对象,此阶段需要停顿线程(Stop the World),但耗时很短。
(2)并发标记(Concurrent Mark)。从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。
(3)最终标记(Final Mark)。为修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程(Stop the World),但可并行执行。
(4)筛选回收(Live Data Count and Evacuation)。 对各个Region中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
G1 收集器的运作步骤如下:
Java动态编译
动态编译(运行时编译)是指程序在运行时加载类并进行编译。
动态编译使用场景
动态编译的应用场景很多,典型的场景就是一个需要外部输入Java代码的场景,如Leetcode等在线算法测试平台。
动态编译实现
早期实现动态编译,主要使用反射实现。JDK 1.6后,也可以基于动态编译API实现。
动态编译的实现:
(1) 通过Runtime调用javac命令,启动新的进程去编译Java代码。示例代码如下:
Runtime run = Runtime.getRuntime();
Process process = run.exec("javac -cp d:/myjava/Helloworld.java")
(2) 通过JavaCompiler动态编译。 使用JDK 1.6新增的JavaCompiler API。示例代码如下:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int result = compiler.run(null, null, null, "f:/HelloWorld.java");
System.out.println(result==0?"编译成功":"编译失败");
完成动态编译后,接下来就可以运行该类中的接口。方式一是使用另外一个进程执行;方式二是使用反射实现。方式一示例代码如下:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int result = compiler.run(null, null, null, "f:/HelloWorld.java");
System.out.println(result==0?"编译成功":"编译失败");
Runtime run = Runtime.getRuntime();
Process process = run.exec("java -cp f: HelloWorld");BufferedReader w = new BufferedReader(new InputStreamReader(process.getInputStream()));
System.out.println(w.readLine());
方式二示例代码如下:
public class DynamicCompile {public static void main(String[] args) throws IOException {JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();int result = compiler.run(null, null, null, "f:/HelloWorld.java");System.out.println(result==0?"编译成功":"编译失败");try {URL[] urls = new URL[]{new URL("file:/"+"f:/")};URLClassLoader loader = new URLClassLoader(urls);Class<?> c = loader.loadClass("HelloWorld");Method m = c.getMethod("main", String[].class);m.invoke(null, (Object)new String[]{});//静态方法不用谢调用的对象//加Object强制转换的原因//由于可变参数是JDK5.0之后才有 m.invoke(null, new String[]{"23","34"});//编译器会把它编译成m.invoke(null,"23","34");的格式,会发生参数不匹配的问题//带数组的参数都这样做} catch (Exception e) {e.printStackTrace();}}
}
outofmemory和stackoverflow
内存泄漏,OutOfMemeory,是指由于疏忽或错误造成程序未能释放已经不再使用的内存。主要指指针跑飞。如数组越界。
内存溢出,StackOverflow,是指系统在为某段执行指令(程序)分配内存时,发现剩余内存不足并抛出错误。主要指栈溢出。
参考
《深入理解Java虚拟机 JVM高级特性与最佳实践 第2版》 周志明 著
https://segmentfault.com/a/1190000016842546 Java动态性(1) - 动态编译(DynamicCompile)
https://javabeat.net/the-java-6-0-compiler-api/ The Java 6.0 Compiler API
https://www.cnblogs.com/jxrichar/p/4883465.html 动态生成java、动态编译、动态加载
https://www.cnblogs.com/hbuwdx/p/9489177.html Java动态编译技术原理
https://blog.csdn.net/ShuSheng0007/article/details/81269295 秒懂Java动态编程(Javassist研究)
https://blog.csdn.net/wangjian530/article/details/83449067 Java动态编译JavaCompiler
https://www.throwx.cn/2020/06/06/java-dynamic-compile/ 深入理解Java的动态编译
https://blog.csdn.net/m0_37556444/article/details/81912283 如何破坏双亲委派模型
https://www.jianshu.com/p/bfa495467014 破坏双亲委派机制的那些事
https://blog.51cto.com/westsky/1579033 OSGI(面向Java的动态模型系统)和它的实现Equinox
https://segmentfault.com/a/1190000008722128 JVM 虚拟机字节码指令表
https://www.cnblogs.com/wade-luffy/p/6058067.html 运行时栈帧结构
https://cloud.tencent.com/developer/article/1894284 什么是编译执行和解释执行
https://segmentfault.com/a/1190000016640854 JVM系列2:HotSpot虚拟机对象
https://blog.csdn.net/u011080472/article/details/51324103 【深入理解JVM】:垃圾收集算法
https://www.jianshu.com/p/50d5c88b272d 深入理解JVM(5) : Java垃圾收集器
https://www.cnblogs.com/1024Community/p/honery.html 扒一扒JVM的垃圾回收机制
https://mp.weixin.qq.com/s/feJKRqYJTVEIxl6jvjevAg 咱们从头到尾说一次 Java 的垃圾回收