1内存区域
1.1运行时区域
- 程序计数器:存放程序程序当前执行的指令。
- java虚拟机栈:存储局部变量,操作数栈,动态链接,方法出口等信息。如果请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常,如果栈可以动态扩展,当栈无法申请足够内存会抛出OutOfMemoryError。
- 本地方法栈:与虚拟机栈类似,不过是为Native方法服务。
- Java堆:存放对象实例。当无法扩展时,抛出抛出OutOfMemoryError。
方法区:存放用于被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存数据等。如果方法区无法满足新内存的分配,将抛出OutOfMemoryError。
- 运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池表,用于存放编译期间生成的各种字面量与符号引用。
永久代:永久区只是HotSpot虚拟机对虚拟机的实现才有的叫法,并不是所有虚拟机的方法区都等价于永久代。HotSpot将收集器的分代设计扩展到方法区,希望垃圾处理器能像管理堆一样管理这片内存。但这种设计导致java应用更容易溢出。JDK7把永久代中的字符串常量池、静态变量移出。JDK8完全废弃永久代的概念,使用元空间来代替。
- 运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池表,用于存放编译期间生成的各种字面量与符号引用。
直接内存:并不是虚拟机运行时数据区的一部分。在JDK14中加入了NIO类,引入了一种基于通道于缓冲区的IO方式,可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆里的DirectByteBuffer对象作为这对内存的引用进行操作。
1.2对象的创建
1. 2.1类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
1.2.2.分配内存
虚拟机分配内存有指针碰撞和空闲列表两种方法。
- 指针碰撞:已分配的和未分配的都是连续的,指针指向交界处,当要分配新的内存时,指针向未分配内存偏移相应的内存大小
- 空闲列表:已分配的和未分配的是交错的,虚拟机维护一个表来记录未分配的内存
以前在《深入理解计算机系统》中看到用空闲链表来记录:用指针指向下一块未分配的地址
当使用Serial,ParNew等带压缩处理过程的收集器时,系统采用的分配算法是指针碰撞。当使用CMS这种基于清除的算法的收集器时,理论上采用空闲列表来分配内存。
解决分配内存时的线程安全问题:
- CAS+失败重试
- 本地线程分配缓冲(TLAB):为每一个线程预先在堆中分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
1.2.3初始化
将分配到的内存空间(不包括对象头)都初始化为0
1.2.4设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
1.2.5执行构造函数
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
1.3对象的访问定位
句柄
直接指针
2 类文件结构
- 概念:class文件时一组以8个字节为基础单位的二进制流。class文件采用一种类似于c语言结构体的伪结构来存储数据。这种伪结构只有无符号数和表两种数据类型。
- 无符号数:以u1、u2、u3、u4分别代表一、二、三、四个字节。无符号数可以用来描述数字,索引引用,数量值,或者按UTF-8编码构成的字符串值。
- 表是由多个无符号数或者其他表构成的复合数据类型,所有的表习惯以”_info”结尾。
- 结构
- Magic Number(u4):确定文件是否是一个能被虚拟机接收的class文件。值为0xCAFEBABE
- 次版本号(u2) 主版本号(u2)
- 常量池:入口为一个计数值(u2),以1开始计数。然后接着是以不同结构类型保存的常量。
- 访问标志(u2):标识这个文件是否位public,是否为final,是类还是接口,注解,枚举等信息。
- 类索引(u2) :确定这个类的全限定名 父类类索引(u2):确定父类的全限定名
- 接口索引集合:入口是一个(u2)的接口计数器,以0开始计数,后面紧跟者(u2)的接口索引的集合。
- 字段表集合
- 方法表集合
- 属性表集合
3类加载机制
- 一个类型被加载到虚拟机到卸载出内存所需经过的生命周期。
其中:加载、验证、准备、初始化、卸载这五个阶段顺序是确定的。解析某些情况下会在初始化阶段之后再开始。
3.1类的初始化时机
《java虚拟机规范》规定了有且只有六种情况如果没有对类进行初始化,则必须对类进行初始化,而加载,验证,准备自然需要在此之前开始。
- 遇到new,getstatic,putstatic,invokestatic这四条字节码指令时。通常有以下场景
- 使用new关键字
- 读取或设置一个类型的静态字段(被final修饰的除外)
- 调用一个类型的静态方法。
- 使用reflect包的方法对类型进行反射调用
- 初始化类时,要先对父类进行初始化
- 当虚拟机启动,用户需指定一个要执行的主类(包含main方法的类),这个类会先初始化
- 当一个MethodHandle实例最后解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄。
- 当接口定义了默认方法(被default关键字修饰的方法),如果这个接口的实现类初始化,这个接口要先初始化
3.2主动引用,被动引用
主动引用:会对类型进行初始化
被动引用:不会对类型进行初始化
被动引用的三个例子
class SuperClass{
public static int value = 100;
public static final String HELLOWORLD = "hello world";
static {
System.out.println("superclass被初始化。。。。。");
}
}
class SubClass extends SuperClass{
static {
System.out.println("subclass被初始化。。。。。");
}
}
通过子类引用父类的静态字段,不会导致子类的初始化
//对于静态字段,只有直接定义的类才会被初始化
public class Demo {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
通过数组定义来引用类,不会触发此类的初始化
//虚拟机会创建一个类来包装数组的使用,会初始化包装类
public class Demo {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
3.引用类的常量不会触发类的初始化
//常量在编译阶段会将常量直接存在Demo类的常量池,所以不会初始化定义常量的类。
public class Demo {
public static void main(String[] args) {
System.out.println(SuperClass.HELLOWORLD);
}
}
3.3类的加载过程
3.3.1加载
在加载阶段,java虚拟机需要完成以下三件事
- 通过一个类的全限定名来获取定义此类的二进制流
- 将这个字节流所代表的静态存储结构转化为方法区运行时的数据结构
- 在内存中生成一个代表这类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
3.3.2验证
目的:确保Class文件的字节流包含的信息复合《java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机的自身安全。
分为四个阶段:文件格式验证,元数据验证,字节码验证,符号引用验证。
3.3.3准备
为静态变量分配内存并设置初始值,一般设为0,在初始化阶段才给静态变量进行赋值操作。如果用final关键字修饰,则初始值会设置为所赋的值。
3.3.4解析
将符号引用替换为直接引用的过程。也就是得到类或者字段、方法在内存中的指针或者偏移量。
3.3.5初始化
初始化阶段就是执行类构造器
创建一个对象时调用顺序的总结
父类静态代码和静态属性初始化->子类静态代码和静态属性初始化->父类普通代码块和普通属性初始化->父类构造方法->子类的普通代码块和普通属性初始化->子类的构造方法。
参考文献
- 《深入理解java虚拟机第三版》周志明
- https://javaguide.cn/