简单记录一些《深入理解Java虚拟机》的笔记(多数图文都摘自《深入理解Java虚拟机》),供自己查阅和交流学习。
第四章、第五章
这两章主要讲了JDK自带的虚拟机性能监控工具以及调优实战,很有价值,但不便于记录。主要思想是根据垃圾收集日志来分析虚拟机运行状况,并调节虚拟机参数。
第六章
一、Class文件结构
个人理解:Java文件编译后的Class文件是一个描述性的文件,将原本的Java文件中的信息统计并描述出来,虚拟机加载的时候根据这个描述性文件生成Class对象存放在方法区。所以Class文件的结构是虚拟机规定好的,严格并且紧凑。Class文件是一个以8位的字节为单位的二进制流,即最小单位为字节,若遇见需要占用超过8位的数据,则根据高位在前,低位在后的规则每隔8位进行分割开来。根据书中讲解,使用十六进制工具打开class文件,部分内容如下图:
稍微解析几个字节:Class文件开头4个字节总是16进制的CAFEBABE(据说和某咖啡品牌有关),这一文件开头的4个字节的魔术数字(magic),其唯一作用就是使得该文件被识别为Class文件。然后后面跟着4个字节的version,是Class文件的版本。比如此处00 00 00 33,十进制则是51,代表需要JDK1.7以上的环境。后面两个字节则是常量的数量(constant_pool_count),下标从1开始,此处为02AB,其对应的十进制-1就是这个Class文件中常量的个数,下图是Class文件的详细结构:
其中,u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数。属于基本数据类型。其他的类似cp_info则是一个复杂数据类型,这个复杂类型内部是一个表,类似于整个Class的结构这样,用于描述cp_info的各项特征。
1、常量池: 常量池对应上图中的constant_pool,它的容量是前一项constant_pool_count决定的。常量池中有两大类数据:字面量和符号引用。字面量就是文本字符串、常量值等等,而符号引用包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。也就是说,Class文件中,只是存储的字段、方法的符号引用并非真正的内存地址。只有在类加载过程的“解析”阶段,才会将符号引用替换为直接引用。
个人理解(欢迎批评指正):比如说现在在某个类中有一个字段为Z,这个字段Z的类型为类A,那么当解析这个字段Z的时候,会首先解析Z对应的类A。在其常量池中会有一个类型为CONSTANT_Class_info的常量,这个常量是这个类A的符号引用,而这个CONSTANT_Class_info类型当中包括tag和name_index两项。其中name_index会指向一个CONSTANT_Utf8_info类型的常量,这个CONSTANT_Utf8_info类型的常量会存有代表这个类A的符号引用的字符串。这个符号引用在类创建或运行时被解析为真正的内存地址。
2、字段表集合: 字段表——fields用于描述接口或者类中声明的变量(成员变量)。每一个字段需要一个字段表来进行描述:作用域、可变性、是否可被序列化、实例变量还是类变量等等。这些字段中,可用基本数据类型描述的,就用二进制数表示。而类似于字段名、字段的数据类型这类无法固定的,则引用常量池中的常量。
3、方法表集合: 方法表——methods与字段表类似,也会有多个标志进行描述。同时还有相应的属性表,方法中的代码就存在于方法属性表集合中一个名为Code的属性里面。
前面简单记录了Class文件的结构,使用16进制文件查看解析自然不便,现在可以使用字节码查看工具或者JDK的javap命令,查看解析好的Class文件(下图与上文的16进制图片并非同一个Class文件)。
二、字节码指令
通过javap命令能看见Class文件的方法表的Code属性,这个Code属性包含了可执行的字节码指令,通过阅读这些字节码指令,可以了解方法中的代码执行过程。为了查看字节码指令组成的执行过程,需要熟悉常见的指令,在此记录一些主要的字节码指令。
一般来说,每个字节码指令有数据类型作为前缀,比如:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。为了控制字节码指令的数量,并非每个字节码指令都支持所有的数据类型。
比如:大部分的指令都没有支持整数类型byte、 char和short,甚至没有任何指令支持boolean类型。 编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(ZeroExtend)为相应的int类型数据。 与之类似,在处理boolean、 byte、 short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。 因此,大多数对于boolean、 byte、 short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
T代表数据类型的简写:
加载和存储指令:
Tload:将一个局部变量加载到操作栈(局部变量表和操作栈均在虚拟机栈的每个栈帧中)
Tstore:将一个数值从操作数栈存储到局部变量表
Tipush:将一个常量加载到操作数栈
Taload:将一个数组元素加载到操作数栈
Tastore:将一个操作数栈的值存储到数组元素中运算指令:
Tadd:加法
Tsub:减法
Tmul:乘法
Tdiv:除法
Trem:求余
Tneg:取反
Tshl、Tshr:位移
Tor:按位或
Tand:按位与
Txor:按位异或
iinc:局部变量自增
Tcmpg、Tcmpl:比较类型转换指令(窄化类型转换):
i2b、 i2c、 i2s、 l2i、 f2i、 f2l、 d2i、 d2l和d2f对象创建与访问指令:
创建类实例的指令:new。
创建数组的指令:newarray、 anewarray、 multianewarray。
访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、 putfield、 getstatic、 putstatic。
取数组长度的指令:arraylength。
检查类实例类型的指令:instanceof、 checkcas操作数栈管理指令:
将操作数栈的栈顶一个或两个元素出栈:pop、 pop2。
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、 dup2、dup_x1、 dup2_x1、 dup_x2、 dup2_x2。
将栈最顶端的两个数值互换:swap。控制转移指令:
条件分支:ifeq、 iflt、 ifle、 ifne、 ifgt、 ifge、 ifnull、 ifnonnull、if_icmpeq、 if_icmpne、if_icmplt、 if_icmpgt、 if_icmple、if_icmpge、 if_acmpeq和if_acmpne。
复合条件分支:tableswitch、 lookupswitch。
无条件分支:goto、 goto_w、 jsr、 jsr_w、 ret。方法调用和返回指令:
invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)
invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、 私有方法和父类方法。
invokestatic指令用于调用类方法(static方法)。invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而>invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。同步指令(synchronized):
monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持
第七章
这一章主要讲了类加载过程,详细的过程大致分为加载、验证、准备、初始化、使用、卸载几步。
一、类加载过程
1、加载
类加载过程主要是三件事:
1)通过类的全限定名来获取定义此类的二进制字节流(比如从Class文件读取)
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。在Hotspot虚拟机中,这个Class对象是存放在方法区的。
2、验证
为了虚拟机的安全,这一步主要是验证读取的字节流是否符合虚拟机的约束。包括文件格式、语义分析、字节码指令验证、符号引用验证等等,在这个阶段不通过,通常就会抛出异常。比如如果某个符号引用对应的类的方法不存在,就会抛出java.lang.NoSuchMethodError异常
3、准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。 其次,这里所说的初始值“通常情况”下是数据类型的零值。
4、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、 字段、 类方法、 接口方法、 方法类型、 方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型
5、类的初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。 到了初始化阶段,才真正开始执行类中定义的Java程序代码。
有且只有以下五种情况会触发类初始化:
1)遇到new、 getstatic、 putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。 生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、 读取或设置一个类的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、 REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
特别需要注意的:
1)通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。比如父类A中有字段a,B继承了A,而B中没有a字段。调用B.a,不会触发B的初始化。
2)通过数组定义来引用类,不会触发此类的初始化。比如:A[] simple = new A[10],不会触发A的初始化。
3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
初始化阶段是执行类构造器<clinit>()方法的过程,<clinit>()方法是有编译器自动收集类中所有类变量的赋值动作和static{}块中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。 因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。 但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步。如果有多个线程同时初始化一个类,那么只会有一个线程去执行<clinit>()方法。
二、类加载器
1、类与类加载器的关系
在虚拟机中,要区分两个类是否相等,需要“类Class文件”+“加载这个类的类加载器”都相等。即这两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。这里所指的“相等”,包括代表类的Class对象的equals()方法、 isAssignableFrom()方法、 isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
2、双亲委托机制
对于虚拟机来说,类加载器只有两种:虚拟机自带的启动类加载器和其他用Java语言实现的类加载器。
对于开发人员来说,可以更详细一点,分为三类:
1)启动类加载器(Bootstrap ClassLoader):这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。 启动类加载器无法被Java程序直接引用。但是可以通过委托机制将加载请求委派给引导类加载器(实现自己的类加载器在本文不详细讨论)。
2)扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
3)应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。 由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
Java设计者推荐开发者在实现类加载器的时候遵从双亲委托机制,即除了启动类加载器以外,其他的类加载器都应该有自己的父类加载器。但是这里的父子关系一般不会以继承来实现,而是使用组合。示意图如下图所示:
双亲委托机制的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
为什么要使用双亲委托机制呢?使用这种模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。 例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。 相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。 如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
3、线程上下文类加载器
因为有了双亲委托机制,那么使得类带有了层次关系。但是也有不便的地方。如果一个接口A使用了启动类加载器去加载,而类B实现了接口A并且使用了自定义的加载器,那么在向上造型的时候必然会失败,因为A和B使用了不同的类加载器,导致类型不一致。如果启动类加载器能够加载类B,那直接使得A和B使用同一个类加载器也没有问题。但是事实是现有很多组件,是由JDK定义一套接口(这套接口由启动类加载器加载),而具体的实现是由第三方书写的。如此一来,启动类加载器只认识JDK中虚拟机识别的路径下的类,第三方的类是不能被启动类加载器所加载的,便出现了类型转换的问题。
因此Java设计团队引入了线程上下文加载器(Thread Context ClassLoader),这个类加载器可以通过java.lang.Thread类的
setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,便可以解决上述类型问题。上文中这套由JDK定义的接口,不再使用启动类加载器加载,而是由启动类加载器请求线程上下文加载器去加载。一般的,线程上下文加载器是应用程序类加载器,那么使得这套JDK定义的接口可以使用应用程序类加载器加载。而第三方的实现默认也是应用程序类加载器加载,这样一来,接口和实现类的类加载器就统一了,解决了类型转换问题。