一、预备知识
1. JVM运行时数据区域
1.1 方法区
方法区存放了什么?
方法区主要存放的内容有:
- 常量池
- 被虚拟机加载的类的信息,比如方法名字,类的名字,父类、接口以及一些静态变量,静态方法等。
- 一些即时编译器编译的代码数据、常量。
方法区的实现
-
jdk1.6及之前,方法区是完全由永久代实现的;
-
在jdk1.7时,将方法区的常量池放到了堆中进行实现;
-
在jdk1.8时,引入了元空间(MetaSpace)进行实现;运行时常量池和静态变量都存储到了堆中,MetaSpace存储类的元数据,MetaSpace直接申请在本地内存中(Native memory),这样类的元数据分配只受本地内存大小的限制,OOM问题就不存在了。元空间的出现就是为了解决突出的类和类加载器元数据过多导致的OOM问题,而从jdk7中开始永久代经过对方法区的分裂后已经几乎只存储类和类加载器的元数据信息了,到了jdk8,元空间中也是存储这些信息
1.2 堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:
- 新生代(Young Generation)
- 老年代(Old Generation)
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
java -Xms1M -Xmx2M HackTheJava
1.3 虚拟机栈
虚拟机栈是线程私有的区域。它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。 其主要由栈桢构成,栈桢中主要包括:
- 局部变量表
- 操作数栈
- 动态链接
- 出口信息等
如果栈桢的深度过深的话,会抛出StackOverFlowError
在调用Java方法时,会压入一个栈祯,当方法结束时,会弹出一个栈祯;方法结束的形式:
- return
- throw异常
1.4 本地方法栈
本地方法栈也是线程私有的区域。功能和虚拟机栈类似,不过本地方法栈主要用于服务native
方法。本地方法栈也有栈祯,存储的内容与虚拟机栈的栈祯类似。
1.5 程序计数器
记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。
2. 对象实例在内存中的存在形式
在堆中的对象实例主要由三部分构成:
第一部分:对象头:
- Mark Word:
- 指向对象的引用指针
- 如果是数组类型的话,还有一个数组长度字段
第二部分:对象实例数据
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
第三部分:对齐填充
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
二、new一个对象的过程
第一步: 类加载
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。下面为一个类的加载过程。
1. 加载
- 找字节流
- 通过类的完全限定名称获取定义该类的二进制字节流。
- 创建类
- 将该字节流表示的静态存储结构 转换为 方法区的运行时存储结构。
- 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。注意,这个对象是在堆区的!
2. 链接
-
验证
- 用于确保加载类能够满足java虚拟机的约束条件
-
准备
- 为类中的static变量分配内存,并清空这块内存(即尚未尽心给初始化),使用的是方法区的内存。
-
解析
- 符号引用转换成实际引用,如果符号引用指向的类未被加载,则加载这个类( 但未必进行类的链接及初始化 )
- 其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定
-
静态绑定:
- 在类加载的解析阶段,会把符号引用转换成直接引用.
- 类A中调用讲台方法B.f()的转换过程为:
-
1.invokestatic #index
index是一个数字, 指在类的A的常量池中第index个索引的常量表( 这个常量表表述了B.f()的信息 ),得到符号引用。
-
2.然后根据这个常量表找到对应类(这里是类B), 如果该类未被加载,则加载, 链接, 初始化该类
-
3.找到目标方法后,将这个直接地址(B.f()的直接地址)记录到类A的常量池索引为index的常量表中。这个过程叫常量池解析 ,以后再次调用B.f()时,将直接找到 f()方法的字节码。
-
-
动态绑定:
-
对于非静态非私有非final的方法, 则采用动态绑定.
-
如果说静态绑定是一劳永逸,那么动态绑定则是在运行时,每一次的方法调用都动态的去寻找目标方法位置.
-
在JVM加载类的同时,会在方法区中为这个类存放很多信息。其中就有一个数据结构叫方法表。它以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址 。
下面来根据例子来讲一下过程,假设 Father是个接口,Son实现了Father,
Father father = new Son();
然后再调用father.f()
的时候, 这一调用发生在类C中.-
动态绑定的过程:
invokevirtual #index
- 这个index是
f()
在当前调用类(这里是类C) 的常量池中对应的第index个常量表(这个常量表描述了调用方法),然后根据起描述内容,就能找到Father类,然后去Father类里找到f()
在方法表里的偏移量#offset, 得到偏移量之后,再去Son
的方法表里用相同的#offset去找,就能够调用方法f()
了, 原因是:- 子类方法表中继承了父类的方法
- 相同的方法(相同的方法签名:方法名和参数列表)在所有类的方法表中的索引相同。比如Father方法表中的f1()和Son方法表中的f1()都位于各自方法表的第11项中。(Son继承了Father)
-
然后就完成了动态绑定.
-
这里小结一下, #index找到类符号引用,然后找到了
Father
类,然后去Father
类里找到了对应方法在方法表里的偏移量,然后在这个对象真正的类的方法表里去用这个偏移量来查方法! -
动态绑定-> 相同的方法偏移量,不同的方法表
-
动态绑定: 保存方法偏移量, 然后去方法表里找. 静态绑定: 保存方法地址,直接访问到方法, 不需要查表.
-
往往在invokevirtual #index之前,都会有预先根据这个对象在堆里的信息,来得到这个对象的真正类!然后就得到了这个真正的类的方法表!
-
正是因为上面两条,再加上运行时动态翻译符号引用,就实现了多态.(目前的理解)
-
-
3. 初始化
注意,此时还是类加载阶段,并不涉及到对象,因此这里进行的初始化的,都是类里的static变量! 都是在方法区里的!
在静态字段中,如果被final修饰的字段被赋值,并且它的类型是基本类型或Stiring类型时,那么该字段会被Java编译器标记成常量值,其初始化直接由Java虚拟机完成!
所有非常量初始化的赋值操作,会被统一放到<clinit>
方法的方法体中,然后统一执行。。Java虚拟机会通过加锁来保证<clinit>()
只执行一次.
-
初始化时机
-
主动引用
- 虚拟机启动时,初始化用户指定的主类(Main)
- 遇到new
- 遇到调用静态方法 或 访问静态字段, 则初始化对应静态方法/字段所在的类
- 子类的初始化触发父类的初始化
- 假设一个接口A定义了
default
方法,如果类B实现了该接口,且类B初始化了,那么会触发接口A的初始化 - 使用反射API对某个类进行反射调用,则初始化这个类
- 初次调用
MethodHandle
实例时,初始化该MethodHandle
指向的方法所在类.
-
被动引用
-
被动引用不会触发初始化!
-
通过子类引用父类的静态字段,不会导致子类初始化。
System.out.println(SubClass.value); // value 字段在 SuperClass 中定义
- 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
SuperClass[] sca = new SuperClass[10];
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
System.out.println(ConstClass.HELLOWORLD);
-
-
第二步: 为对象分配内存空间
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
第三步:初始化0值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
第四步:设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
第五步:执行<init>
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。