JVM虚拟机
Java运行时数据区域
Java 8 的JVM内存结构,GC 主要工作在 Heap 区和 MetaSpace 区(图中蓝色部分)
-
堆区(线程共享)
- 所有的对象实例以及数组都应当在堆上分配
- 将堆的最小值-Xms参数与最大值-Xmx参数设置为一样可避免堆自动扩展
- Xmn2g :设置年轻代大小为2G。整个堆大小=年轻代大小 + 年老代大小 + 持久代(永久代)大小
-
栈区(线程私有)
- 分为Java虚拟机栈和本地方法栈,Hot-Spot虚拟机直接把本地方法栈和虚拟机栈合二为一。-Xss512k设置栈大小
- 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接等信息。
- 局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个(reference类型没有具体说明)。方法入参(包括实例方法中的隐藏参数“this”)、方法局部变量、方法返回地址、显式异常处理程序的参数(就是try-catch语句中catch块中所定义的异常)都需要依赖局部变量表来存放。
- Java虚拟机执行字节码是基于操作数栈(32位数据类型所占的栈容量为1,64位所占的栈容量为2)的,大多数字节码指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。字节码的算术指令只能操作 操作数栈 中的数据,并将结果保存在操作数栈中,不能直接操作堆或局部变量表中的数据,因此需要先将堆中的数据读入操作数栈,再从操作数栈读出结果。
- 一个方法的操作数栈和局部变量表所需的内存空间在编译期间完全确定,当进入一个方法时,这个方法的栈帧大小是完全确定的,在方法运行期间不会改变。
-
程序计数器(线程私有)
- 是当前线程所执行的字节码的行号指示器。
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。
-
方法区(线程共享)
- 用于存储已被虚拟机加载的Class类型信息、运行时常量池(包括Class文件常量池表中的常量、静态变量,字符串常量),即时编译后的代码缓存等数据。
- Class对象(堆中的对象都有一个指针指向Class对象)是存放在堆区的,不是方法区。类的元数据才是存在方法区的。元数据并不是类的Class对象!Class对象是加载的最终产品,是访问方法区中的类型数据的接口。类的元数据包括:类的方法代码,变量名,方法名,访问权限,返回值等。
- JDK8以前常用永久代(在堆里面)来实现方法区,CMS可以通过-XX:MaxPermSize设置永久代的上限,即使不设置也有默认大小,这容易受到永久代溢出导致的错误。JDK8以后已经采用在本地内存(不在堆中)中实现的元空间(Metaspace, 存放Class Metadata)来代替永久代,把永久代中的Class类型信息放到了元空间,利用元空间来实现方法区。G1有 -XX:MaxMetaspaceSize="512m"设置最大元空间大小,InitialCodeCacheSize 设置即时编译的代码缓存大小
- 自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,String.intern()会把首次遇到的字符串添加到字符串常量池中。
- 方法区甚至可以选择不实现垃圾收集,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载
https://segmentfault.com/a/1190000038807051
对象的创建(new)
-
检查类是否已完成加载,否则执行类的加载。对象所需内存的大小在类加载完成 后便可完全确定
-
在堆中分配一块连续内存。
- 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用 的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩 整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
- 为了加快分配速度,可以采用 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),每个线程在Java堆中预先分配一小块内存,线程分配内存时优先在自己的本地私有缓冲区中分配,等本地缓冲用完了,需要分配新的本地缓冲时才需要同步锁定系统的全局内存分配指针。
-
将分配的内存初始化为零值
-
设置对象头中的信息
-
执行构造函数
对象的内存布局
一个对象的内存包括3部分:对象头(Header)、实例数据、对齐填充
-
其中对象头又包括3部分:1、Mark Word(32位或64位) 2、Class Pointer(指向方法区中类信息的指针(其实指向堆),32位或64位(开启指针压缩是32位))3、数组长度(如果对象是一个Java数组,会额外记录数组长度,32位(与虚拟机是32/64位无关))
-
Mark Word包括对象的哈希码、对象的GC分代年龄信息、synchronized锁信息。
- 哈希码会延后到真正调用Object::hashCode()方法时才计算,计算出来后会缓存到对象头中,后面不用再计算,OpenJDK8的默认实现是返回随机数,不过可以修改计算方法返回内存地址;如果对象重写了Object::hashCode方法,那就不会缓存到对象头中,也不会从对象头读取,有些不可变类型(比如String)会有个private int hash的成员用来缓存第一次计算出来的hash值,这样后续调用hashCode方法时不用再重新计算。
- 当一个对象的Mark Word中已经缓存了hash code后,它就无法进入偏向锁状态;参考 https://www.cnblogs.com/zoo-keeper/articles/16783930.html
-
实例数据(Instance Data)
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。 -
对齐填充(Padding)
- HotSpot虚拟机要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
- 指针压缩:Java中的引用指向的都是对象的起始地址,由于对象起始地址都是8字节整数倍,那引用的低3位都是0,这样可以将指针右移3位,这样32位指针可以寻址4GB * 2^3 = 32GB 的内存,只要堆小于32GB,那就自动开启指针压缩
参考 https://www.cnblogs.com/rickiyang/p/14206724.html (指针压缩原理)
Java中基础数据类型是在栈上分配还是在堆上分配?
- 如果在方法体内定义的,这时候就是在栈上分配的
- 如果是类的成员变量,这时候就是在堆上分配的
- 如果是类的静态成员变量,在方法区上分配的
Java基本数据类型
char是2字节,与虚拟机是32/64位无关。引用可能是32或64位。char到byte或short的转型是不安全的。
Java虚拟机内部是将boolean类型映射成int类型来处理的(true为1,false为0),因此,通过字节码修改工具,将原本声明为 boolean 类型的局部变量 赋值为除了 0、1 之外的整数值,在 Java 虚拟机看来是“合法”的。
内存对齐的原因
CPU访问内存不能读取任意地址,只能读取特定地址,比如要求起始地址必须是2或4字节的倍数,不能从第3字节开始读。这样如果一个int的起始地址不是2的倍数,那读取这个int需要读两次。
除了对象的首地址(其实是第一个成员的起始地址)需要对其,对象的每个成员最好也对齐。
C语言提供 #pragma pack(4) 来设置编译时的内存对其值为4
Class文件
Class文件的常量池表(constant pool)
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于Java语言层面的常量概念,包括:
- 文本字符串
整个类中(包括成员和方法)用双引号括起来的文本,如"北京"。 - 被声明为final的常量值等
- 方法中定义的局部变量,如 int b = 3; 这里的b和3都会进入常量池,与是否为final无关
- 被声明为final的成员的初始值会进入常量池,如 private final int c = 3; 这里3会进入常量池
- static但不是final的成员的值不会进入常量池,如 private static int d = 4; 这里4不会进入常量池(见下面的ConstantValue属性,因为非final的static成员是通过类构造器<clinit>() 函数来初始化的,其初始值在类构造器的方法中)
字段表
字段表只包含本类或接口中直接定义的成员,字段表不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。
Java语言不允许出现两个同名的成员,即便其数据类型、修饰符不相同。但Class文件中,只要描述符(类型)不同,可以出现同名的成员。
方法表
方法里的Java代码,经过Javac编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面。
如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器“<clinit>()”方法和实例构造器“<init>()”方法
在Java语言中不允许出现这样的两个方法:方法名称和方法入参相同,仅仅是返回值不同,但Class文件却允许出现这样的两个方法,因为Class文件方法的描述符包括入参和出参
ConstantValue属性
ConstantValue的作用是让虚拟机在准备阶段为类的静态变量赋值。
对非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>()方法中进行的;而对于类变量(static成员),则有两种方式可以选择:在类构造器<clinit>()方法中或者使用ConstantValue属性。目前Oracle公司实现的Javac编译器的选择是,如果同时使用final和static来修饰一个变量,并且这个变量的数据类 型是基本类型或者java.lang.String的话,就将会生成ConstantValue属性来进行初始化,此时是通过引用Class文件常量池中的常量来表示初始值;如果这个变量没 有被final修饰,或者并非基本类型及字符串,则将会选择在<clinit>()方法中进行初始化。
字节码指令集
java字节码的算术指令集没有针对byte、char、short、boolean类型的,因此这几个类型(以及数组元素)的算术运算都是扩展为int类型然后再计算。两个char相加、char和int相加、short和short相加 得到的是int类型。另一个原因是Java的算术指令是基于操作数栈的,只能操作操作数栈上面的变量,而char、short在操作数栈中与int一样,都是占用一个变量槽,因为char、short需要先扩展成int再参与运算,得到结果也是int。
类生命周期
简记为LLIUU,其中 L又可分为VPR。
类加载的时机
《Java虚拟机规范》没有强制约束什么情况下需要进行第一个阶段“加载”,但是《Java虚拟机规范》严格规定了“有且只有”以下几种情况必须立即对类进行“初始化”(而前面的加载、验证、准备、解析需要在此之前开始):
- 遇到下面3种情况时,如果类型没有进行过初始化,则需要先触发其初始化阶段:
- 使用new关键字实例化对象;
- 读写一个类直接定义的非final的static静态字段(如果这个静态字段是子类继承得到的,通过子类读写这个静态字段只会触发父类的初始化);
- 调用一个类直接定义的静态方法。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化,但是这个类实现的接口不用先初始化。但如果该接口含有默认方法(default修饰的接口方法)则需要先初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_p utStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
这几种场景称为对一个类型进行主动引用。除此之外,其他的引用方式都不会触发初始化,这些方式称为被动引用
注意:
1、Student s = new Student[10],不会触发Student类的初始化,只会触发一个名为 “[Lcom.wys.corejava.Student”的类的初始化阶段,这个类是由虚拟机自动生成的、直接继承于java.lang.Object类的数组类。这个类封装了对数组元素的访问。
2、System.out.println(Son.value); 或者 Son.f(); 如果静态变量value和静态方法f()没有定义在Son中,而是定义在Son继承的Father中,那这里只会触发Father类的初始化,不会触发Son类的初始化。
3、在类B中执行 System.out.println(ClassA.value); 如果value是final static 的,则不会触发ClassA的静态初始化。因为final static常量的值在编译阶段会通过常量传播存入调用类的常量池中,以后类B对ClassA.value的引用,实际都被转化为类B对自身常量池的引用,类B的Class文件中没有直接引用到定义常量的类A,因此不会触发定义常量的类A的初始化。
接口也有初始化过程,接口中不能使用“static{}”语句块,但编译器仍然会为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。接口与类的区别是:1、当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。2、在初始化一个类时,会先初始化它的父类,但不会先初始化它所实现的接口。
1、加载阶段
通过类加载器(引导类加载器,用户自定义类加载器)把Class字节码文件加载到JVM方法区,加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中,然后在Java堆内存中实例化一个java.lang.Class类的对象(jdk1.8中,java.lang.Class 对象和 static 成员变量在运行时内存的位置都位于堆(Heap),且static 成员变量位于 Class对象内。jdk1.7之前Class对象和static变量是在永久代,说“类变量在方法区”也没有问题,是参考JVM规范的逻辑表述),作为程序访问方法区中的类型数据的接口。一个类被哪个类加载器加载,这个类会被标识在该类加载器的类名称空间上,一个类型必须与其所属的类加载器一起确定唯一性。
数组类本身不通过类加载器创建,它是由Java虚拟机在内存中动态构造出来的。但数组类的元素类型还是要靠类加载器来完成加载。数组类有后续的连接和初始化过程。如果数组的元素类型是引用类型(如C[] = new C[2]),数组将被标识在加载该元素类型(类C)的类加载器的类名称空间上;如果数组的元素类型是基本数据类型(如int[]),Java虚拟机将会把数组标记为与引导类加载器关联。(ClassLoader的源码注解 Every Class object contains a Class reference to the ClassLoader (Class#getClassLoader()) that defined it. If this object represents a primitive type or void, null(null是引导类加载器) is returned. Class objects for array classes are not created by class loaders, but are created automatically as required by the Java runtime. The class loader for an array class, as returned by Class#getClassLoader() is the same as the class loader for its element type; if the element type is a primitive type, then the array class has no class loader (null是引导类加载器) )
JVM并不是在启动时就把所有的“.class”文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。
类加载器
启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中方能执行类加载(ExtClassloader 与 AppClassLoader位于rt.jar包中,都是由Bootstrap classloader加载的)
双亲委派模型:每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器,因此所有的加载请求最终会转发到最顶层的启动类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。双亲委派模型的实现在抽象类ClassLoader的loadClass方法中,ExtClassloader 与 AppClassLoader都 extends ClassLoader,都使用的是ClassLoader的loadClass方法。参见 https://www.cnblogs.com/zoo-keeper/articles/15602260.html
在 Java 9 之前,启动类加载器(Bootstrap Classloader)负责加载最为基础、最为重要的类,比如存放在 <JAVA_HOME>\lib (rt.jar, resources.jar)目录 和虚拟机参数 -Xbootclasspath 指定路径中的类。扩展类加载器(ExtClassloader)负责加载相对次要、但又通用的类,比如 <JAVA_HOME>\lib\ext 目录、系统变量 java.ext.dirs 目录中的类。应用程序类加载器(又叫系统类加载器, AppClassLoader(System Classloader))负责加载应用程序路径下(虚拟机参数 -cp/-classpath、系统变量 java.class.path 、环境变量 CLASSPATH 指定的路径)的类。如果应用程序没有自定义过自己的类加载器,应用程序中的类默认是由应用程序类加载器加载的。
除了加载功能之外,类加载器还提供了命名空间的作用。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流(同一个class字节码),经由不同的类加载器加载,也会得到两个不同的类。这里的“不同”是指:类的Class对象的equals()、isAssignableFrom()、isInstance()、 instanceof关键字、a1.getClass() == a2.getClass() 会返回false。

上图的父子关系是通过组合实现的
2、验证阶段
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
3、准备阶段
准备阶段是正式为类中定义的静态变量分配内存并设置初始值。如果Class文件有ConstantValue属性(final static变量才会进入ConstantValue),则按照ConstantValue中的初始值为静态变量赋值,否则初始值为0。准备阶段还会构造类的虚方法表
4、解析阶段
Java 源代码的编译之后,方法的调用是使用符号引用来表示的,解析阶段是Java虚拟机将符号引用替换为直接引用的过程。Class文件中的引用是符号引用(通过符号在常量池的下标来引用该符号),直接引用就是指针(方法区的内存地址),直接引用要求被引用对象已经在内存中。
方法调用和返回指令
- invokevirtual指令: 调用对象的实例方法,需要在运行时根据对象的实际类型进行分派(虚方法分派),实现多态。
- invokeinterface指令: 调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokespecial指令: 调用实例初始化方法(构造函数)、私有方法和父类方法(通过super调用)。
- invokestatic指令: 调用类静态方法(static方法)。
- invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。
前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
在类加载的解析阶段会把满足「编译期可知,运行期不可变」的方法的符号引用替换为指向方法区的直接引用(返回这个变量的声明类型的方法地址),不会延迟到运行时再去完成,没有性能损失。满足编译期可知,运行期不可变的方法(非虚方法)有:静态方法、私有方法、构造函数、父类方法(通过super调用)、final修饰的方法(《Java语言规范》中明确定义了被final修饰的方法是一种非虚方法,尽管它使用invokevirtual指令调用)。不满足上述条件的方法(虚方法)的符号引用替换为直接引用的过程发生在方法调用期间。
静态分派
根据入参的「静态类型」和方法调用者的「静态类型」来决定方法执行版本的分派动作称为静态分派。静态分派最典型的应用场景为方法重载。静态分派发生在编译阶段,是由编译器来执行的。
动态分派
在运行期根据参数实际类型确定方法执行版本的分派过程称为动态分派。
根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:
(1)找到操作数栈顶的第一个元素所指向的对象(this)的实际类型,记作C。
(2)如果在类型C中找到描述符相符(方法名称和入参完全一样)的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
(3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
注意,被final修饰的方法不是虚方法,但使用invokevirtual指令调用。对于final方法,第一步之前会先看符号引用中的方法是不是final的,如果是final的就不会进行后面的步骤,直接返回这个变量的声明类型中的final方法的地址
Java 重写条件
(1)重写方法不能缩小访问权限;
(2)参数列表必须与被重写方法相同(包括显式形式);
(3)返回类型必须与被重写方法的相同或是其子类;
(4)重写方法不能抛出新的异常,或者超过了父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。
动态分派优化手段--虚方法表
上述动态分派的过程比较耗时,一种常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能,虚方法表中不会记录非虚方法的信息,虚方法表一般在类加载的准备阶段进行初始化。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址会被替换为指向子类实现版本的入口地址。在上图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
方法表本质上是一个数组,每个数组元素指向一个当前类或其祖先类中非私有的实例方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
当使用invokeinterface来调用方法时,由于不同的类可以实现同一interface,我们无法确定在某个类中的interface中的方法处在哪个位置。必须每次都执行一次在methodtable中的搜索了。所以,在这种实现中,通过invokeinterface访问方法比通过invokevirtual访问明显慢很多。
动态分派优化手段--方法内联
方法内联就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用,消除方法调用的成本,为其他优化手段建立良好的基础。
C和C++默认的方法是非虚方法,如果需要用到多态,就用virtual关键字来修饰。Java对象的实例方法默认(不使用final修饰)是虚方法,上述的虚方法表是动态分派调用的一种优化手段。但如果Java虚拟机真的遇到虚方法就去查虚表而不做内联的话,Java技术可能就已经因性能问题而被淘汰很多年了。实际上虚拟机会通过类型继承关系分析的结果对虚方法进行方法内联优化。
invokespecial、invokestatic指令调用的方法以及被final修饰的方法的调用版本在编译期是可知的,这些方法可以使用直接进行方法内联。但是invokevirtual和invokeinterface调用的虚方法由于多态,可能存在多于一个版本的方法,很难确定应该使用哪个方法版本,无法直接进行方法内联。
为了解决虚方法的内联问题,Java虚拟机引入了类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序 的全貌就是现在运行的这个样子”来进行方法内联,这种内联被称为守护内联(Guarded Inlining)。不过由于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论(方法内联用在后端的即时编译器,不是在前端编译器javac中用的),因此这种内联必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃 已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。这种状态下方法调用 是真正发生了的,但是比起直接查虚方法表还是要快一些。内联缓存是一个建立在目标方法正常入口 之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生 后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进 来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存,其开销相当于真正查找虚方法表来进行方法分派。
5、初始化阶段
初始化阶段就是执行类构造器<clinit >()方法。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生。<clinit>()方法与类的构造函数不同,不需要在子类的<clinit>()方法中显式地调用父类的<clinit>()方法,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。<clinit >()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit >()方法。
初始化优先级
相同优先级按语句先后顺序,子类没有显式用super则默认调用父类无参构造函数
父(1) -> 子(2) -> 父(3 4) -> 子(5 6) 两次从上到下
1、父类静态代码块和static成员 (其实final static变量的赋值在这一步之前,见ConstantValue属性)
2、子类静态代码块和static成员
3、父类构造代码块和一般成员变量
4、父类构造函数
5、子类构造代码块和一般成员变量
6、子类构造函数
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit >()方法。但接口与类不同的是,执行接口的<clinit >()方法不需要先执行父接口的<clinit >()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit >()方法。
Java虚拟机必须保证一个类的<clinit >()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit >()方法。其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程被唤醒后不会再次进入<clinit>()方法执行。同一个类加载器下,一个类型只会被初始化一次。
编译
如果我们把字节码看作是程序语言的一种中间表示形式的话,那编译可以分为前端编译:把*.java文件转变成*.class文件;和 后端编译:把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码;后端编译包括即时编译(Just In Time, JIT)和提前编译(Ahead Of Time,AOT)
前端编译(javac)
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示:
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程,包括:
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。 如果用户代码中没有提供任何构造函数,那编译器将会自动添加一个无参构造函数
- 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
- 分析与字节码生成过程,包括:
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它会检查诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理,final变量是否被再次赋值和修改。 - 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现。 javac在编译时会解语法糖,将简化代码编写的语法糖还原为原有的形式,因为Java虚拟机运行时并不直接支持这些语法。
- 语法糖包括:
- 变长参数
变长参数会被转换为一个新的数组类型的参数,会创建新的数组对象。 new Integer[](1, 2) - 自动装箱拆箱
转换为调用Integer.intValue()方法 与 Integer.valueOf() ,有性能开销 - 遍历循环
遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。 - 泛型
JVM并不支持范型,范型是编译器模拟的。编译时,泛型会通过类型擦除替换为原来的裸类型(Raw Type),并且在元素访问、修改时自动插入一些强制类型转换和检查指令,因此对于运行期的Java语言来说,ArrayList<Integer>与ArrayList<String>是同一个类型)
- 变长参数
- 字节码生成
- 字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令 写到磁盘中,编译器还进行了少量的代码添加和转换工作。实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段被添加到语法树之中的。请注意这里的实例构造器并不等同于添加无参默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个无参默认构造函数,这个工作在填充符号表阶段中就已经完成。<init>()和<clinit>()这两个构造器的产生实际上是一种代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造器,<clinit>()方法中无须调用父类的<clinit>()方法)等操作收敛到<init>()和 <clinit>()方法之中。
- 我的理解:<init>() 不是一个单独的方法。<init>:(I)V 指入参类型为int的构造函数 <init>:()V 指无参构造函数。在字节码生成阶段,会将所有构造语句块和实例变量初始化语句按照代码先后顺序往所有构造函数中copy一份,生成最终的构造函数。如果一个类只有一个方法,没有构造函数,编译出来的class文件中会有两个方法。如果一个类定义了两个构造函数和一个方法,编译出来的class文件中会有三个方法。
即时编译
为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?程序何时使用解释器执行?何时使用编译器执行?哪些程序代码会被编译为本地代码?
即时编译是在运行期把字节码转变成本地机器码的过程;Java程序最初都是通过解释器 (Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),然后在运行时,虚拟机将会把这些代码编译成本地机器码。
解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。但编译出来的代码放在方法区,会占用更多内存
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机加入了“分层编译”功能。HotSpot虚拟机中内置了两个即时编译器,分别被称 为“客户端编译器”(又叫C1编译器)和“服务端编译器”(C2编译器,代码优化更好,但更耗时),可以先用快速但低质量的C1编译器为高质量的C2编译器争取出更多编译时间。解释器、客户端编译器和服务端编译器会同时工作,为了编译出优化程度更高的代码,解释器需要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响,热点代码都可能会被多次编译,得到优化程度不同的本地代码。
编译对象
对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体:
- 被多次调用的方法。
当一个方法触发即时编译后,本次执行还是解释执行,需要等到下次再调用这个方法时才会使用编译后的代码。 - 被多次执行的循环体。
对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是代码执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号。这种编译方式因为编译发生在方法执行的过程中,称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。对于这种情况,最开始是解释执行进入这个方法的循环体,等代码编译完成后,如果这个方法还没退出,还在循环内,会直接栈上替换,然后执行编译后的代码
触发条件:
- 基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。方法的优点是是实现简单高效,缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。 - 基于计数器的热点探测
虚拟机会为 每个方法或代码块建立两个计数器(方法调用计数器 和 回边计数器,“回边”是指在循环边界往回跳转)统计方法的执行次数。如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现复杂,需要为每个方法建立并维护计数器,但是它的统计结果相对来说更加精确严谨。
提前编译
指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。提前编译出来的代码是平台相关的,与Java的“一次编译,到处运行”口号相冲突
提前编译的代码输出质量,不一定会比即时编译更高。解释器或者客户端编译器运行过程中,会不断收集性能监控信息,这些数据一般在静态分析时是无法得到的,利用这些数据可以做更好的优化。
后端编译器的优化技术
方法内联
见虚方法表
逃逸分析
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
基于逃逸分析的优化手段:
- 栈上分配:如果一个对象不会逃逸出线程之外(方法逃逸也可以使用栈上分配),可以让这个对象在栈上分配内存。对象所占用的内存空间就可以随方法结束栈帧出栈而自动销毁,垃圾收集子系统的压力将会下降很多。
- 标量替换:如果一个对象不会逃逸出方法之外,在使用栈上分配的基础上,可以把一个Java对象拆散为若干个基本数据类型,程序真正执行的时候不去创建这个对象,而改为直接在栈上创建它的若干个被这个方法使用的成员变量来代替(类似构造函数的内联),减少一次对象访问开销,甚至进一步去掉不使用的成员变量。
- 锁消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步(锁)措施可以安全地消除掉。