类的加载
当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载,连接,初始化三步来实现对这个类进行初始化,在java中这三步都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。
概括一下就是:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
-
类加载的触发条件
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。生成这4条指令的最常见的Java代码场景是:
- 使用new关键字实例化对象的时候
- 读取或设置一个类的静态字段的时候,被final修饰,已在编译期把结果放入常量池的静态字段除外(在字节码中,执行getstatic或者putstatic指令)
- 以及调用一个类的静态方法的时候(即在字节码中执行invokestatic指令)
- 使用java.lang.reflect包的方法对类进行反射调用的时候。
- 当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先触发父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发初始化。
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。生成这4条指令的最常见的Java代码场景是:
-
类加载的过程
在前文我们提到,如果该类还未被加载到内存中,则系统会通过加载,连接,初始化三步来实现对这个类进行初始化,那么这三步分别干了什么事呢?
-
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的的静态存储结构转换为方法区内运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
-
连接
- 验证
是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。包含四个阶段的校验动作- 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,即文件格式验证
- 对类的元数据信息进行语义校验,是否不存在不符合Java语言规范的元数据信息,即元数据验证
- 对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,即字节码验证
- 符号验证在连接的第三个阶段解析阶段中发生,目的是确保解析动作能正常进行。
- 准备
准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词:- 类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到java堆中。
- 这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。🌰:
在准备阶段后,value的值为0,而不是1,赋值为1的动作在初始化阶段。但如果这个变量同时被static和final修饰,那准备阶段之后它的值就是1了,我们可以简单理解为在编译为.class文件时,编译器就将结果放入调用它的类的常量池中了。public static int value = 1;
- 解析
连接的第三步,是虚拟机将常量池内的符号引用替换为直接引用的过程。“动态解析”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就进行解析。
- 验证
-
初始化
简单地说,初始化就是对类变量进行赋值及执行静态代码块,在这个阶段java代码才真正执行,也就是执行类构造器()方法的过程。
注意:加载、验证、准备、初始化这四个阶段发生的顺序是确定的,它们按顺序开始执行,而不是按顺序进行或完成,解析阶段在某些情况下可以在初始化以后开始,这是为了支持Java语言的运行时绑定
-
-
类加载器
java文件被编译后生成.class文件,类加载器负责将.class文件加载到内存中,并为之生成对应的Class对象。
-
Bootstrap ClassLoader 根类加载器
- 也被称为引导类加载器,负责Java核心类的加载;比如System,String等。在JDK中JRE的lib目录下rt.jar文件中
- 由C++语言实现(针对HotSpot),负责将存放在<JAVA_HOME>\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。
-
Extension ClassLoader 扩展类加载器
负责JRE的扩展目录中jar包的加载。在JDK中JRE的lib目录下ext目录 -
System ClassLoader 系统类加载器
负责在JVM启动时加载来自java命令的class文件,以及classpath环境变量所指定的jar包和类路径 -
Application ClassLoader
负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器,通过ClassLoader.getSystemClassLoader()方法直接获取。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
-
-
双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。这样做的好处是:- 可以避免重复加载,父类已经加载了,子类就不需要再次加载
- 更加安全,很好的解决了各个类加载器的基础类的统一问题,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。
举个🌰:加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。
-
延伸:java对象的创建
- 对象的创建条件
- 虚拟机遇到一个new指令时,首先将去检查这个指令的参数是否能再常量池中定位到一个类的符号引用。
- 检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那必须先执行响应的类加载过程。
- 在类加载检查功能通过后,为新生对象分配内存。对象所需大小在类加载完成后便可以完全确定。
- 对象的内存分布
- 对象头
- 第一部分:包括对象自身的运行时数据:如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称为"Mark Word"
- 第二部分:包括类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,如果对象是一个java数组,那在对象头中必须有一块用于记录数组长度的数据。
- 实例数据
是对象真正存储的有效信息,也是程序代码中锁定义的各种类型的字段内容。 - 对齐填充
对齐填充不是必然存在的。HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整倍数,也就是说对象的大小必须是8字节的整倍数。而对象头部分正好是8字节的整倍数。因此当尾箱实例数据部分没有对齐时,就需要通过对齐填充来补全。
- 对象头
- 对象的创建过程
- 类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 - 分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。- 内存分配的两种方式
分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(“标记-压缩”),值得注意的是,复制算法内存也是规整的 - 内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:- CAS+失败重试:
CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。 - TLAB:
为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
- CAS+失败重试:
- 内存分配的两种方式
- 初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。 - 设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。 - 执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
- 类加载检查
- 对象的创建条件