zoukankan      html  css  js  c++  java
  • HotSpot 虚拟机对象探秘

    对象的创建

    创建对象的方式

    1. 使用new关键字
    2. 使用Class类的newInstance方法
    3. 使用Constructor类的newInstance方法 
    4. 使用clone方法 
    5. 使用反序列化 

    使用new关键字

    通过这种方式,我们可以调用任意的构造函数(无参的和带参数的)。

    CreateInstance newInstance = new CreateInstance();
    //字节码
    0 new #2 <com/fhj/jvm/CreateInstance>
    3 dup
    4 invokespecial #3 <com/fhj/jvm/CreateInstance.<init> : ()V>

    使用Class类的newInstance方法

    我们可以通过这个newInstance方法调用无参且公开的构造函数创建对象。

    CreateInstance classInstance1 = (CreateInstance) Class.forName("com.fhj.jvm.CreateInstance").newInstance();
    //class.newInstance()新建对象
    CreateInstance classInstance2 = CreateInstance.class.newInstance();
    //字节码
    42 invokestatic #15 <java/lang/Class.forName : (Ljava/lang/String;)Ljava/lang/Class;>
    45 invokevirtual #16 <java/lang/Class.newInstance : ()Ljava/lang/Object;>

    86 invokevirtual #16 <java/lang/Class.newInstance : ()Ljava/lang/Object;>

    使用Constructor类的newInstance方法

    我们可以通过这个newInstance方法调用有参数的和私有的构造函数。事实上Class的newInstance方法内部调用Constructor的newInstance方法。

    Constructor<CreateInstance> constructor = CreateInstance.class.getConstructor();
    CreateInstance constructorInstance = constructor.newInstance();
    //字节码
    131 invokevirtual #18 <java/lang/Class.getConstructor : ([Ljava/lang/Class;)Ljava/lang/reflect/Constructor;>

    使用clone方法 

    用clone方法创建对象并不会调用任何构造函数。要使用clone方法,我们需要在需要clone的类中实现Cloneable接口,否则会出现java.lang.CloneNotSupportedException异常,由于Object类中clone方法是protected 修饰的,所以我们必须在需要克隆的类中重写克隆方法。

    CreateInstance cloneInstance = (CreateInstance) newInstance.clone();
    //字节码
    185 invokevirtual #21 <com/fhj/jvm/CreateInstance.clone : ()Ljava/lang/Object;>

    使用反序列化

    当我们使用反序列化一个对象的时候,JVM会给我们创建一个对象。但是,反序列化的时候JVM并不会去调用类的构造函数(前边的1,2,3方式都会去调用构造函数)来创建对象,而是通过之前序列化对象的字节序列来创建的。

    序列化对象必须实现Serializable这个接口,否则会出现java.io.NotSerializableException异常。把对象转为字节序列的过程称为对象的序列化;把字节序列恢复为对象的过程称为对象的反序列化。

    
    
    // 反序列新建对象
    // Serialization
    ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data.obj"));
    out.writeObject(cloneInstance);
    out.close();
    //Deserialization
    ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj"));
    CreateInstance serializableInstance = (CreateInstance) in.readObject();
    in.close();
    //字节码 
    277 invokevirtual #33 <java/io/ObjectInputStream.readObject : ()Ljava/lang/Object;>

    对象序列化通常有两种用途:

    1)将对象的字节序列永久的保存到硬盘上

    例如web服务器把某些对象保存到硬盘让他们离开内存空间,节约内存,当需要的时候再从硬盘上取回到内存中使用

    2)在网络上传递字节序列

    当两个进程进行远程通讯的时候,发送方将java对象转换成字节序列发送(序列化),接受方再把这些字节序列转换成java对象(反序列化)

    总结

    我们从上面的字节码片段可以看到,除了第1个方法,其他4个方法全都转变为invokevirtual(创建对象的直接方法),第一个方法转变为两个调用,new和invokespecial(构造函数调用)。

    demo:

    public class CreateInstanceTest {
    
        public static void main(String[] args) throws Exception {
            //new新建对象
            CreateInstance newInstance = new CreateInstance();
            System.out.println(newInstance + ", hashcode : " + newInstance.hashCode());
            //class.newInstance()新建对象
            CreateInstance classInstance1 = (CreateInstance) Class.forName("com.fhj.jvm.CreateInstance").newInstance();
            System.out.println(classInstance1 + ", hashcode : " + classInstance1.hashCode());
            //class.newInstance()新建对象
            CreateInstance classInstance2 = CreateInstance.class.newInstance();
            System.out.println(classInstance2 + ", hashcode : " + classInstance2.hashCode());
            //constructor.newInstance()新建对象
            Constructor<CreateInstance> constructor = CreateInstance.class.getConstructor();
            CreateInstance constructorInstance = constructor.newInstance();
            System.out.println(constructorInstance + ", hashcode : " + constructorInstance.hashCode());
            //clone()新建对象
            CreateInstance cloneInstance = (CreateInstance) newInstance.clone();
            System.out.println(cloneInstance + ", hashcode : " + cloneInstance.hashCode());
            // 反序列新建对象
            // Serialization
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data.obj"));
            out.writeObject(cloneInstance);
            out.close();
            //Deserialization
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj"));
            CreateInstance serializableInstance = (CreateInstance) in.readObject();
            in.close();
            System.out.println(serializableInstance + ", hashcode : " + serializableInstance.hashCode());
        }
    }

    CreateInstance类

    public class CreateInstance implements Cloneable, Serializable {
    
        private int age = 18;
    
        private String name;
    
        private CreateInstanceTest createInstanceTest;
    
        public CreateInstance() {
            this.name = "jack";
            this.createInstanceTest = new CreateInstanceTest();
        }
    
        @Override
        public Object clone() {
            Object obj = null;
            try {
                obj = super.clone();
            } catch (CloneNotSupportedException e) {
                e.printStackTrace();
            }
            return obj;
        }
    }

    创建对象的过程

    1. 当Java虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
    2. 在类加载检查通过后,虚拟机将为新生对象分配内存。内存大小在类加载完成后便可完全确定,为对象分配空间,实际上就是吧一块确定大小的内存块从堆中划分出来。
      1. 如果堆内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,所分配内存就仅仅是把指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
      2. 如果内存不是规整的,虚拟机就需要维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种方式称为“空闲列表”。
      3. 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存了。说理论上是因为在CMS的实现里面,为了能在多数情况下分配的更快,设计了一个叫做Linear Allocation Buffer 的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配。
    3.  除如何划分可用空间外,还需要考虑:对象创建在虚拟机中是非常频繁的行为,在并发情况下并不是线程安全的,可能出现以下情况,正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。针对以上情况,有两个方案可以处理:  
      1. 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
      2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在那个线程的本地缓冲区分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。 
    4. 内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

    5. 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是某个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用了偏向锁等,对象头会有不同的设置方式。
    6. 到这一步,从虚拟机的视角看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始,构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial 指令所决定,Java 编译器会在遇到new关键字的地方同时生成这两条字节码指令, 但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

    对象的内存布局

    在HotSpot虚拟机里,对象在堆内存中的存储布局划分为三个部分:

    • 对象头(Header)
    • 实例数据(Instance Data)
    • 对齐填充

    对象头(Header)

    包括以下两类信息

    • 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用来存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容自行了解。 
    • 类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是那个类的实例。但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身。此外,如果是数组对象,那在对象头中还会有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。 

    实例数据(Instance Data)

    是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle=mode, 默认mode是1)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),所以相同宽度的字段总是被分配到一起存放,在满足这个前提条件下,父类的变量出现在子类之前。如果+XX:CompactFields参数值为true(默认为true),那子类中较窄的变量也允许插入到父类变量的空隙中,以节省空间。

    对齐填充

    只是起了占位符的作用。因为HotSpot的自动内存管理要求对象起始地址必须为8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。对象头已经设计为8字节的倍数,所以如果对象实例数据没有对齐的话,需要通过对齐填充来补全。

    图示

    对象的访问定位

    Java程序通过栈上的reference数据来操作堆上的具体对象。由于reference 类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种

    • 使用句柄
    • 使用直接指针

    句柄访问:

    Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。使用句柄访问的好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不会修改。

     

     直接指针:

    Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。使用直接指针的好处是速度快,它节省了一次指针定位的时间开销,由于对象访问很频繁,所以能节省很多成本。

     HotSpot主要使用直接指针方式进行对象访问,但也有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也是十分常见的。

    执行引擎

    执行引擎

    JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。如果想让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的翻译者。

    Java代码编译和执行的过程

     大部分程序代码转换为物理机的目标代码或是虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

     解释器和JIT编译器

    解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码指令翻译为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着在根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

    JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

     

     解释器

    在Java发展历史中,一共有两套解释执行器,即古老的字节码解释器和现在普遍使用的模板解释器。

    字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率低下。

    模板解释器将每一条字节码和一个模板函数相关联。模板函数中直接产生这个字节码执行时的机器码,从而提高了解释器的性能。在HotSpot VM中,解释器主要有interpreter模块和Code模块构成。interpreter模块实现了解释器的核心功能,Code模块用于管理HotSpot VM在运行时生成的本地机器指令。

    JIT编译器

    基于解释器执行是比较低效的,所以JVM平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率提升。HotSpot VM采用解释器和即时编译器并存的架构。虽然解释器是低效的,但是当程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行。而即时编译器需要吧代码编译为本地代码,需要一定的执行时间。但是编译为本地代码后,执行效率高。像JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。它执行性能非常高效,但程序在启动时需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于看中启动时间的应用场景而言,就需要采用解释器和编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去很多时间。随着时间推移,编译器发挥作用,把越多的代码编译成本地代码,获得更高的执行效率。

    编译类型:

    • 前端编译:把.java文件转变为.class文件的过程。Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
    • JIT编译:把字节码转变为机器码的过程。HotSpot VM的c1 c2编译器。
    • 静态提前编译器(AOT编译器,Ahead Of Time Compiler):直接吧.java文件编译为本地机器代码的过程。GNU Compiler for the Java(GCJ)、Excelsior JET。

     是否启动JIT需要根据代码被调用执行的频率而定。那些被编译为本地代码的字节码被称为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以提升执行性能。

    热点探测

     一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以称为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换,或简称为OSR(On Stack Replacement)编译。

    一个方法要被调用多少次,或者一个循环体需要执行多少次才可以达到这个标准,必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。

    目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。

    采用基于计数器的热点探测,HotSpot VM 将会为每个方法都建立2个不同类型的计数器,分别为方法调用计数器和回边计数器。方法调用计数器用于统计方法的调用次数,回边计数器则用于统计循环体执行的循环次数。

     方法调用计数器

     方法调用计数器用于统计方法被调用的次数,默认阈值在Client模式下是1500次,server模式下是10000次。超过这个阈值,就会触发JIT编译。这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。

    当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

     

     热度衰减

    如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就被称为此方法统计的半衰周期。

    进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UserCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

    回边计数器

    统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器统计的目的就是为了触发OSR编译。

     

    设置程序执行方式

     默认情况下,HotSpot VM 采用解释器和编译器并存的架构,当然也可以通过命令显式地为Java虚拟机指定在运行时完全采用解释器执行,还是完全采用即时编译器执行。

    -Xint:完全采用解释器模式执行程序;

    -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。

    -Xmixed:采用解释器和即时编译器的混合模式执行程序。

     HotSpot VM中JIT分类

    在HotSpot VM中内嵌有两个JIT编译器,分别为Clien Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。我们可以通过如下命令显式指定Java虚拟机在运行时使用哪种即时编译器

    -client:指定Java虚拟机运行在Client模式下,并使用C1编译器。C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。

    -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

    分层编译策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控细腻系进行激进优化。不过在Java 7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1和C2相互协作共同来执行编译任务。

    C1和C2编译器不同的优化策略

    C1编译器主要有方法内联,去虚拟化、冗余消除。

    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:在运行期间吧一些不会执行的代码折叠掉

    C2编译器的优化主要是在全局层面,逃逸分析是优化基础。基于逃逸分析在C2上有如下几种优化:

    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配在栈而不是堆
    • 同步消除:清除同步操作,通常只synchronized

    总结

    一般来说,JIT编译出来的机器码执行性能比解释器高。C2编译器启动时长比C1编译器慢,系统稳定执行后,C2编译器执行速度远远快于C1编译器。

    自JDK10起,HotSpot又加入一个全新的即时编译器:Graal编译器。编译效果和C2编译器差不多。目前,带有“实验状态”标签,需要使用开关参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler去激活,才可以使用。

    JDK9引入AOT编译器(静态提前编译器,Ahead Of Time Compiler),JDK9 引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放在生成的动态共享库之中。所谓AOT编译,是与即时编译相对立的概念。即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。这样的好处是Java虚拟机加载已经预编译成二进制库,可以直接运行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验。缺点就是破坏了java“一次编译,处处运行”,必须为每个不同硬件、OS编译对应的发行包。降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。目前还在继续优化中,最初只支持Linux x64 java base。

  • 相关阅读:
    dhl:asp.net mvc 在View中获取Url参数的值
    Sql Server 中序号列的实现方法
    在LINQ中實踐多條件LEFT JOIN
    css div ul li 导航栏(横向):(纯CSS 多级菜单IE6能支持的)
    学习JQuery的$.Ready()与OnLoad $(window).load() 方法事件比较
    dhl:juery提示插件:jquery.Popup.js
    javascript中0级DOM和2级DOM事件模型浅析
    mvc:尽可能摆脱对HttpContext的依赖
    [备]ASP.net 中 OutputCache 指令各个参数的作用。
    dhl:我的jQuery代码:续...
  • 原文地址:https://www.cnblogs.com/xiaojiesir/p/15593092.html
Copyright © 2011-2022 走看看