序言
联系我上次写的关于Java内存的文章,对象访问在 Java 语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及 Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:
Object obj = new Object();
1、假设这句代码出现在方法体中,那"Object obj"这部分的语义将会反映到Java 栈的本地变量表中,作为一个 reference 类型数据出现。
2、而"new Object()"这部分的语义将会反映到Java 堆中,形成一块存储了 Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。
3、另外,在 Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
- 那在new对象的过程中,究竟发生了什么?JVM做了哪些工作?
实质上,Java在new一个对象的时候,会先查看对象所属的类有没有被加载到内存,如果没有的话,就会先通过类的
全限定名(包名+类名)
来加载。加载并初始化类完成后,再进行对象的创建工作。
我们下面的讨论是假设是第一次使用该类,这样的话new一个对象就可以分为两个过程:加载并初始化类和创建对象。
类加载过程
类加载概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最中形成可以被虚拟机直接使用的Java类型,这就是虚拟机的 类加载机制。
与那些在编译时需要注意连接工作的语言不同,在Java语言里面,类型的加载和连接过程都是在程序运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能为语言本身提供高度的灵活性,Java中天生可以动态扩展的语言特性就是依赖于运行期动态加载和动态连接这个特点实现的。
- 类的生命周期:
双亲委托模型
Java是使用双亲委派模型来进行类的加载的,所以在描述类加载过程前,我们先看一下 双亲委托模型的工作过程:
如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。
类加载器双亲委派模型:
1、启动类加载器:加载系统环境变量下JAVA_HOME/lib目录下的类库;
2、扩展类加载器:加载JAVA_HOME/lib/ext目录下的类库;
3、应用程序类加载器(系统类加载器):加载用户类路径Class_Path指定的类库。(我们可以在使用第三方插件时,把jar包添加到ClassPath后就是使用了这个加载器);
4、自定义加载器:如果需要自定义加载时的规则(比如:指定类的字节流来源、动态加载时性能优化等),可以自己实现类加载器;
使用双亲委托机制的好处是:
能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。
加载
什么情况下需要开始类加载过程的第一个阶段:加载。
【了解】:
虚拟机规范并没有进行强制约束,这点可以交给虚拟机的具体实现来*把握。但是对于初始化阶段,虚拟机规范则是有严格规定了有且只有4种情况必须立即对类进行“初始化”(而加载、验证、准备自然要在此之前开始):
1、遇到new 、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有初始化,则需要先触发其初始化;
2、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化;
3、当初始化一个类的时候,如果父类没有初始化,需要先初始化父类;
4、当虚拟机启动时,用户需要指定一个主类(包含main()那个类),虚拟机会先初始化这个类。
加载与类加载的区别:
“加载”(Loading)阶段是“类加载(Class Loading)”过程的一个阶段,希望不要混淆。
加载阶段需要做的3件事情:
[ ] 通过一个类的全限定名来获取定义此类的二进制字节流;
-
[ ] 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
Java代码在进行javac编译的时候,并不像C/C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法和字段的最终布局信息,因为这些字段和方法的符号引用不经过转换的话是无法直接被虚拟机使用的。当虚拟机运行的时候,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址中。
- [ ] 在Java堆中生成一个代表这个类的java.lang.class对象,作为方法区这些数据的访问入口;
验证
验证时连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
为什么要进行验证?
【了解】:
Java语言本身是相对安全的语言(相对C/C++),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为他并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但是Class文件并不一定要求使用Java源码编译而来,可以使用任何途径。在字节码的语义层面上,上述Java代码无法做到的事都是可以实现的,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有毒的字节流而导致系统崩溃,所以验证是虚拟机对自身保护是十分重要的一项工作。
如何进行验证?
【了解】:
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段有两个容易混淆的概念:
这个是时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
解析
- 解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程;
符号引用
:用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可;
直接引用
:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标已经存在于内存中。
- 解析动作主要针对于
类或接口
、字段
、类方法
、接口方法
四类符号引用进行;
以上2、3、4三个阶段又合称为链接阶段,链接阶段要做的是将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中。
初始化
是所有类加载过程的最后一步,前面的过程除了加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码);
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源;
主要分为:
1、为静态变量赋值;
2、执行static代码块(注意:static代码块只有JVM能够调用);
如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。
因为子类存在对父类的依赖,所以类的加载顺序是先加载父类后加载子类,初始化也一样。不过, 父类初始化时,子类静态变量的值也是有的,是默认值。
最终,方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句和静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。
创建对象
- 在堆区分配对象需要的内存
分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量;
- 对所有实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值 ;
- 执行实例初始化代码
初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法;(代码块先于构造方法执行)
- 在栈区开辟空间
如果有类似于Object obj = new Object()形式的obj引用的话,在栈区定义Object 类型引用变量obj,然后将堆区对象的地址赋值给它;
补充
通过实例引用调用实例方法的时候,先从方法区中对象的实际类型信息找,找不到的话再去父类类型信息中找。
如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要经过很多次查找。这时候大多系统会采用一种称为虚方法表的方法来优化调用的效率。
所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。Java中new对象的过程