类加载机制与对象的创建
类的生命周期
加载
查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口
注:jvm中classloader类加载器加载class发送在此阶段,这个阶段也是可控性很强的一个阶段,开发人员可以自定义classloader来完成加载
连接
1) 验证: 确保被加载类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
- 文件格式校验:验证字节流是否符合Class文件格式,例如是否以0XCAFEBABYE开头(class文件的开头校验字节, 称为魔数数)、常量池是否有常量的类型不被支持
- 元数据验证: 对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求
- 字节码校验: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的(语义分析)
- 符号引用验证: 保证解析动作能正确执行
注:验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
2) 准备: 为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这个时候分配的只有static变量,实例变量会在对象实例化的时候分配在堆中
- 分配的初始值是指的类型默认的零值(0、0L、null、false等)
- 如果类字段中存在被static和final同时修饰的(ConstantValue),那么在准备阶段变量value就会被初始化为显示的赋值
3) 解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量
- 符号引用:目标的描述符,与虚拟机内存无关的表述(jvm内存结构中有描述)
- 直接引用:直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
1) 类初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
2) 类初始化时机
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如
Class.forName(“com.shengsiyuan.Test”)
) - 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类
结束生命周期
在如下几种情况下,Java虚拟机将结束生命周期:
- 执行了
System.exit()
方法 - 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类加载器
1) 加载器类型
启动类加载器:
BootstrapClassLoader
,负责加载存放在JDK\jre\lib
下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.开头的类均被BootstrapClassLoader加载)。启动类加载器是无法被Java程序直接引用的(由C++实现)。扩展类加载器:
ExtensionClassLoader
,该加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext
目录中,或者由 java.ext.dirs系统变量指定的路径中的所有类库(如javax.开头的类),开发者可以直接使用扩展类加载器。应用程序类加载器:
ApplicationClassLoader
,该类加载器由sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
2) JVM类加载机制
全盘负责: 当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
父类委托(双亲委派模型): 先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
缓存机制: 缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class
对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
3) 类的加载
类加载有三种方式:
- 命令行启动应用时候由JVM初始化加载
- 通过Class.forName()方法动态加载
- 通过ClassLoader.loadClass()方法动态加载
Class.forName和ClassLoader.loadClass的区别:
- Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块
- ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块
4) 如何破坏双亲委派机制
有时候我们需要自定义ClassLoader
来加载我们自己写的类文件,只需要继承ClassLoader
类重写findClass
方法,如下:
1 |
|
然后,通过调用MyClassLoader
的loadClass
就可以加载类了
但这个类是遵循双亲委托机制(BootStrapClassLoader->ExtClassLoader->AppClassLoader->MyClassLoader),如下:
如何破坏双亲委托机制了,看一下ClassLoader
的loadClass
方法:
1 |
|
我们可以看到,调用父加载器去加载类的逻辑是在loadClass
里面实现的,所以我们要破坏双亲委托模型,只需要重写loadClass
方法就可以
常量的本质和数组创建
这样一个例子:
1 |
|
Test2
如果用final修饰,则只会打印hello world
, 如果去掉final则会将静态代码块的内容也打印出来。
这说明如果使用final时并没有初始化Test2
,这里说一个原理:
常量在编译阶段会存入调用这个常量的方法所在的常量池当中,本质上调用类并没有直接引用到定义常量所在的类,因此并不会触发定义产量的类的初始化。
编译期常量和运行期常量
1 |
|
上述例子运行,确可以输出Test3的静态代码块的内容,这跟上一个例子有些不同,关键在于Test1所定义的常量str在编译期间无法确定,只有在运行期间才能确定,这样就导致了目标类的初始化:
当编译期无法确定具体值的常量,那么其值不会放到调用类的常量池,就会导致主动使用这个常量所在的类,就会导致这个类的初始化
数组创建的区别
1 |
|
运行上述代码,并没有输出Test4的静态代码块。这与之前说的new会触发类初始化相违背,这是因为:数组不通过类加载器创建,它是由java虚拟机动态生成一种类型,如上述例子生成的就是[Test4]
这种形式,数组要去掉一个维度才是具体的元素类型。
对象的创建与内存布局
虚拟机遇到一条new指令时,大体分为三个部分,如下:
- 分配内存空间
- 初始化对象
- 将内存空间的地址赋值给对应的引用
但是由于重排序的缘故,步骤2、3可能会发生重排序。所以写单例模式的时候要注意(DCL双重锁)。
对象创建详细过程
1).检验
当虚拟机执行到new时,会先去常量池中查找这个类的符号引用。如果能找到符号引用,说明此类已经被加载到方法区(方法区存储虚拟机已经加载的类的信息),可以继续执行;如果找不到符号引用,就会使用类加载器执行类的加载过程,类加载完成后继续执行。
2).为对象分配内存
对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务具体便等同于一块确定大小的内存从Java堆中划分出来, 有两种划分方法:
指针碰撞: 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可
空闲列表: 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为空闲列表
分配的时候也要考虑并发情况下线程安全问题。比如在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有两个方案:
- 同步的方法: 虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
- 另一种是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),分配内存的时候再TLAB上分配,互不干扰。
3).内存空间初始化为0
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)
4).对象头的设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
6). 执行init()方法
在上面工作都完成之后,在虚拟机的视角来看,一个新的对象已经产生了。但是在Java程序的视角看来,对象创建才刚刚开始——< init>方法还没有执行,所有的字段都为零呢。所以一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行< init>方法,把对象按照程序员的意愿进行初始化。
对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:
- 对象头(Header): 对象头又包含
Mark Word
和Class 对象指针
- 实例数据(Instance Data): 对象实际数据
- 对齐填充(Padding): 按 8 个字节对齐
对象头
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。(字宽也称虚拟机的字,32位虚拟机为32bit, 64位则为 64 bit)
HotSpot虚拟机的对象头包括两部分信息:
- 第一部分叫
Mark Word
,存储如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,长度为 32 bit(一个字宽,64位虚拟机则为64bit)
在 32 位虚拟机中,对象不同状态时,mark world 如下图所示:
类型指针(
kclass pointer
),该指针指向它的类元数据。这部分也占一个字宽(32位系统就是32bit)如果是数组类型,还有一个字宽来表示数组长度
Klass Word 指针压缩
Klass Word 这里其实是虚拟机设计的一个oop-klass model模型,这里的OOP是指Ordinary Object Pointer(普通对象指针),看起来像个指针实际上是藏在指针里的对象。而 klass 则包含 元数据和方法信息,用来描述 Java 类。它在64位虚拟机开启压缩指针的环境下占用 32bits 空间。
这样存储对象指针的时候,这样 jvm 可以将一个 35 位指针压缩成 32 位,意味着使用32位引用的情况下最多可以使用2^(32+3)=32 GB
空间。当要去 jvm内存中找到一个对象的时候,只需要左移三位就可以找到真正的对象,如图所示:
-XX:+UseCompressedOops
允许对象指针压缩。
实例数据
这部分数据存储对象真正的有效信息,包括:
- longs/doubles
- ints
- shorts/chars
- bytes/booleans
- oops(Ordinary Object Pointers)
对齐填充
由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头部分正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
也可以通过 jvm 参数来修改对齐填充的字节数,比如设置为16:
1 |
|
再计算对象头大小的时候,因为有对其填充的存在,就不能直接是MarkWord+KlassWord了。需要按着操作系统的位数进行填充,比如32位就是4个字节。对象的大小应该是4个字节的倍数,64位也是同样道理,对象的大小是8
字节的倍数(想想对象指针压缩)
对象的逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。
逃逸分析的JVM参数:
- 开启逃逸分析:-XX:+DoEscapeAnalysis(默认是开启的)
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
对象逃逸状态
- 全局逃逸
一个对象的作用范围超出了当前方法或当前线程, 如以下几种情况:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
- 参数逃逸
即一个对象被作为方法参数传递或者被参数引用,作为调用参数传递到其他地方
- 无逃逸
即对象没有发生逃逸
逃逸分析优化
当分析出一个对象没有发生逃逸的时候,可以有几种优化:
- 锁消除
当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁(synchronize)。
锁消除的 JVM 参数:
开启锁消除:-XX:+EliminateLocks(JDK8 默认是开启的)
关闭锁消除:-XX:-EliminateLocks
- 标量替换
标量替换(scalar replacement)。Java中的原始类型无法再分解,可以看作标量(scalar);指向对象的引用也是标量;而对象本身则是聚合量(aggregate),可以包含任意个数的标量。如果把一个Java
对象拆散,将其成员变量恢复为分散的变量,这就叫做标量替换。拆散后的变量便可以被单独分析与优化,可以各自分别在活动记录(栈帧或寄存器)上分配空间;原本的对象就无需整体分配空间了。
这样一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。
- 栈上分配
并非所有的对象都是分配在上的。当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能。