五、class 文件结构详解5.1 什么是 JVM 的 “无关性”?Java 具有平台无关性,也就是任何操作系统都能运行 Java 代码。之所以能实现这一点,是因为 Java 运行在虚拟机之上,不同的操作系统都拥有各自的 Java 虚拟机,因此 Java 能实现 “一次编写,处处运行”。 而 JVM 不仅具有平台无关性,还具有语言无关性。平台无关性是指不同操作系统都有各自的 JVM,而语言无关性是指 Java 虚拟机能运行除 Java 以外的代码! 这听起来非常惊人,但 JVM 对能运行的语言是有严格要求的。首先来了解下 Java 代码的运行过程。 Java 源代码首先需要使用 Javac 编译器编译成 class 文件,然后启动 JVM 执行 class 文件,从而程序开始运行。 也就是 JVM 只认识 class 文件,它并不管何种语言生成了 class 文件,只要 class 文件符合 JVM 的规范就能运行。 因此目前已经有 Scala、JRuby、Jython 等语言能够在 JVM 上运行。它们有各自的语法规则,不过它们的编译器都能将各自的源码编译成符合 JVM 规范的 class 文件,从而能够借助 JVM 运行它们。 5.2 纵观 Class 文件结构class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全是连续的 0/1。class 文件中的所有内容被分为两种类型:无符号数 和 表。
5.2.1 class 文件的组织结构
5.3 Class 文件的构成 1:魔数class 文件的头 4 个字节称为魔数,用来表示这个 class 文件的类型。 魔数的作用就相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 class 文件中标示文件类型比较合适。 class 文件的魔数是用 16 进制表示的 “CAFEBABE”,非常具有浪漫主义色彩,谁说程序员的情商都很低! 5.4 Class 文件的构成 2:版本信息紧接着魔数的 4 个字节是版本号。它表示本 class 中使用的是哪个版本的 JDK。 在高版本的 JVM 上能够运行低版本的 class 文件,但在低版本的 JVM 上无法运行高版本的 class 文件,即使该 class 文件中没有用到任何高版本 JDK 的特性也无法运行! 5.5 Class 文件的构成 3:常量池5.5.1 什么是常量池?紧接着版本号之后的就是常量池。常量池中存放两种类型的常量: 1)字面值常量,即我们在程序中定义的字符串、被 final 修饰的值。
5.5.2 常量池的特点
注:这个值是从 1 开始的,若为 5 表示池中有 4 个常量。
5.5.3 常量池中常量的类型刚才介绍了,常量池中的常量大体上分为:字面值常量 和 符号引用。在此基础上,根据常量的数据类型不同,又可以被细分为 14 种常量类型。 这 14 种常量类型都有各自的二维表示结构。每种常量类型的头 1 个字节都是 tag,用于表示当前常量属于 14 种类型中的哪一个。 以 CONSTANT_Class_info 常量为例,它的二维表示结构如下: CONSTANT_Class_info 表name_index 表示这个类或接口全限定名的位置。它的值表示指向常量池的第几个常量。它会指向一个 CONSTANT_Utf8_info 类型的常量。 它的二维表结构如下:tag 表示当前常量的类型 (当前常量为 CONSTANT_Class_info,因此 tag 的值应为 7,表示一个类或接口的全限定名); CONSTANT_Utf8_info 表为什么 Java 中定义的类、变量名字必须小于 64K?
类、接口、变量等名字都属于符号引用,它们都存储在常量池中。而不管哪种符号引用,它们的名字都由 CONSTANT_Utf8_info 类型的常量表示,这种类型的常量使用 u2 存储字符串的长度。 由于 2 字节最多能表示 65535 个数,因此这些名字的最大长度最多只能是 64K。 什么是 UTF-8 编码?什么是缩略 UTF-8 编码? 前者每个字符使用 3 个字节表示,而后者把 128 个 ASKII 码用 1 字节表示,某些字符用 2 字节表示,某些字符用 3 字节表示。 5.6 Class 文件的构成 4:访问标志在常量池之后是 2 字节的访问标志。访问标志是用来表示这个 class 文件是类还是接口、是否被 public 修饰、是否被 abstract 修饰、是否被 final 修饰等。 由于这些标志都由是 / 否表示,因此可以用 0/1 表示。访问标志为 2 字节,可以表示 16 位标志,但 JVM 目前只定义了 8 种,未定义的直接写 0. 5.7 Class 文件的构成 5:类索引、父类索引、接口索引集合类索引、父类索引、接口索引集合是用来表示当前 class 文件所表示类的名字、父类名字、接口们的名字。 它们按照顺序依次排列,类索引和父类索引各自使用一个 u2 类型的无符号常量,这个常量指向 CONSTANT_Class_info 类型的常量,该常量的 bytes 字段记录了本类、父类的全限定名。 由于一个类的接口可能有好多个,因此需要用一个集合来表示接口索引,它在类索引和父类索引之后。这个集合头两个字节表示接口索引集合的长度,接下来就是接口的名字索引。 5.8 Class 文件的构成 6:字段表的集合5.8.1 什么是字段表集合?接下来是字段表的集合。字段表集合用于存储本类所涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。 每一个字段表只表示一个成员变量,本类中所有的成员变量构成了字段表集合。 5.8.2 字段表结构的定义5.8.3 什么是描述符?
成员变量 (包括静态成员变量和实例变量) 和 方法都有各自的描述符。 对于字段而言,描述符用于描述字段的数据类型;对于方法而言,描述符用于描述字段的数据类型、参数列表、返回值。 在描述符中,基本数据类型用大写字母表示,对象类型用 “L 对象类型的全限定名” 表示,数组用 “[数组类型的全限定名” 表示。 描述方法时,将参数根据上述规则放在 () 中,() 右侧按照上述方法放置返回值。而且,参数之间无需任何符号。 5.8.4 字段表集合的注意点
5.9 Class 文件的构成 7:方法表的集合在 class 文件中,所有的方法以二维表的形式存储,每张表来表示一个函数,一个类中的所有方法构成方法表的集合。 方法表的结构和字段表的结构一致,只不过访问标志和属性表集合的可选项有所不同。
方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译过后的字节码指令。 如果本 class 没有重写父类的方法,那么本 class 文件的方法表集合中是不会出现父类 / 父接口的方法表; 本 class 的方法表集合可能出现程序猿没有定义的方法,编译器在编译时会在 class 文件的方法表集合中加入类构造器和实例构造器; 重载一个方法需要有相同的简单名称和不同的特征签名。JVM 的特征签名和 Java 的特征签名有所不同:
六、详解 Java 类的加载过程6.1 类的生命周期一个类从加载进内存到卸载出内存为止,一共经历 7 个阶段: 其中,类加载包括 5 个阶段: 在类加载的过程中,以下 3 个过程称为连接: 因此,JVM 的类加载过程也可以概括为 3 个过程: C/C++ 在运行前需要完成预处理、编译、汇编、链接;而在 Java 中,类加载 (加载、连接、初始化) 是在程序运行期间完成的。 在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处——提高程序的灵活性。 Java 语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载和动态连接。 6.2 类加载的时机6.2.1 类加载过程中每个步骤的顺序我们已经知道,类加载的过程包括:加载、连接、初始化,连接又分为:验证、准备、解析,所以说类加载一共分为 5 步:加载、验证、准备、解析、初始化。 其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。而解析过程会发生在初始化过程中。 6.2.2 类加载过程中 “初始化” 开始的时机JVM 规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始 (解析除外),这些过程具体在何时开始,JVM 规范并没有定义,不同的虚拟机可以根据具体的需求自定义。 初始化开始的时机:在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。 这四个指令对应的 Java 代码场景是:
使用 java.lang.reflect 进行反射调用的时候,如果类没有初始化,那就需要初始化。 当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;当虚拟机启动时,虚拟机会首先初始化带有 main 方法的类,即主类。 6.2.3 主动引用 与 被动引用JVM 规范中要求在程序运行过程中,“当且仅当” 出现上述 4 个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。 其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用。 那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。 6.2.4 被动引用的场景示例示例一public class Fu{ 输出结果:父类被初始化!柴毛毛 原因分析:本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。 但由于这个静态成员变量属于 Fu 类,Zi 类只是间接调用 Fu 类中的静态成员变量,因此 Zi 类调用 name 属性属于间接引用,而 Fu 类调用 name 属性属于直接引用,由于 JVM 只初始化直接引用的类,因此只有 Fu 类被初始化。 示例二public class A{ 输出结果:并没有输出 “父类被初始化!” 原因分析:这个过程看似满足初始化时机的第一条:遇到 new 创建对象时若类没被初始化,则初始化该类。 但现在通过 new 要创建的是一个数组对象,而非 Fu 类对象,因此也属于间接引用,不会初始化 Fu 类。 示例三public class Fu{ 输出结果:柴毛毛。 原因分析:本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。 但是,Fu 类的静态成员变量被 final 修饰,它已经是一个常量。被 final 修饰的常量在 Java 代码编译的过程中就会被放入它被引用的 class 文件的常量池中 (这里是 A 的常量池)。 所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。 6.2.5 接口的初始化接口和类都需要初始化,接口和类的初始化过程基本一样。 不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己; 但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到当父接口中的东西时才初始化父接口。 6.3 类加载的过程通过之前的介绍可知,类加载过程共有 5 个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。 下面详细介绍这 5 个过程 JVM 所做的工作。 6.3.1 加载
加载的过程在加载过程中,JVM 主要做 3 件事情:
从哪里加载?JVM 规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地 class 文件中读取,此外还可以从以下地方读取:
类和数组加载过程的区别?数组也有类型,称为 “数组类型”。如: String[] str = new String[10]; 这个数组的数组类型是 Ljava.lang.String,而 String 只是这个数组中元素的类型。 而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。 加载过程的注意点
6.3.2 验证验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none 参数关闭,以缩短类加载时间。 验证的目的是什么?验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。 为什么需要验证?虽然 Java 语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,如果出现这些情况,编译无法通过。 也就是说,Java 语言的安全性是通过编译器来保证的。 但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的。 当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。 通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证! 验证的过程
6.3.3 准备准备阶段完成两件事情:
示例 1:public static String name = ' 柴毛毛 '; 在准备阶段,JVM 会在方法区中为 name 分配内存空间,并赋上初始值 null。给 name 赋上 “ 柴毛毛 “ 是在初始化阶段完成的。 示例 2:public static final String name = ' 柴毛毛 '; 被 final 修饰的常量如果有初始值,那么在编译阶段就会将初始值存入 constantValue 属性中,在准备阶段就将 constantValue 的值赋给该字段。 6.3.3 解析解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。 6.3.4 初始化初始化阶段就是执行类构造器 clinit() 的过程。 clinit() 方法由编译器自动产生,收集类中 static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。 在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit() 方法对静态成员变量进行显示初始化。 初始化过程的注意点:
6.4 类加载器6.4.1 类与类加载器
6.4.2 类加载器种类JVM 提供如下三种类加载器:
6.4.3 双亲委派模型工作过程:如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。 作用:像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。 原理:双亲委派模型的代码在 java.lang.ClassLoader 类中的 loadClass 函数中实现,其逻辑如下:
七、Java 虚拟机的锁优化策略7.1 自旋锁背景:互斥同步对性能最大的影响是阻塞,挂起和恢复线程都需要转入内核态中完成;并且通常情况下,共享数据的锁定状态只持续很短的一段时间,为了这很短的一段时间进行上下文切换并不值得; 原理:当一条线程需要请求一把已经被占用的锁时,并不会进入阻塞状态,而是继续持有 CPU 执行权等待一段时间,该过程称为『自旋』; 优点:由于自旋等待锁的过程线程并不会引起上下文切换,因此比较高效; 缺点:自旋等待过程线程一直占用 CPU 执行权但不处理任何任务,因此若该过程过长,那就会造成 CPU 资源的浪费; 自适应自旋:自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。 7.2 锁清除编译器会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。 7.3 锁粗化若有一系列操作,反复地对同一把锁进行上锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作。 7.4 轻量级锁本质:使用 CAS 取代互斥同步。 背景:『轻量级锁』是相对于『重量级锁』而言的,而重量级锁就是传统的锁。 轻量级锁与重量级锁的比较:
实现原理:
前提:轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。 若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外,还额外发生了 CAS 操作,因此更慢! 7.5 偏向锁作用:偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。 与轻量级锁的区别:轻量级锁是在无竞争的情况下使用 CAS 操作来代替互斥量的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。 与轻量级锁的相同点:它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。 原理:当线程请求到锁对象后,将锁对象的状态标志位改为 01,即偏向模式。 然后使用 CAS 操作将线程的 ID 记录在锁对象的 Mark Word 中。以后该线程可以直接进入同步块,连 CAS 操作都不需要。 但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。 优点:偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。偏向锁可以通过虚拟机的参数来控制它是否开启。 |
|