概述
JVM是 Java Virtual Machine(Java虚拟机)的缩写,也叫JVM内存模型。
引入JVM后,Java语言可以在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
常见的编译型语言如 C++,通常会把代码直接编译成 CPU 所能理解的机器码来运行。而 Java 为了实现 “ 一次编译,处处运行 ” 的特性,把编译的过程分成两部分,首先它会先由 javac 编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java 可能会干不过 C++ 这类编译型语言。
为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。
Java编译器只要面向JVM,生成JVM能理解的字节码文件。
JVM执行程序的过程:类加载器加载.class文件,将每条指令翻译成不同的机器码,通过特定平台运行。
JVM的体系架构
1、类加载器
类加载器:把class字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的类对象(Class)。
详见《Java类加载器-ClassLoader》
2、程序计数器(Program Counter Register)
所占的内存空间不大,很小一块,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器会在工作的时候改变这个计数器的值来选取下一条需要执行的字节码指令,像分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
在 JVM 中,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且不能互相干扰,否则就会影响到程序的正常执行次序。
3、栈
分为Java 虚拟机栈、本地方法栈。栈的生命周期和线程同步,线程结束,栈内存也就释放,因此不存在垃圾回收问题。
3.1、Java 虚拟机栈
Java 虚拟机栈中是一个个栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。栈遵循的是后进先出的原则,所以线程当前执行的方法对应的栈帧必定在 Java 虚拟机栈的顶部。
栈帧包含 5 个部分:
- 局部变量表(Local Variables):用来存储方法中的局部变量,包括方法的参数。对于基本数据类型的变量,直接存储变量的值;对于引用类型的变量,存储的是对象的引用。局部变量表的大小在编译期间就确定了,程序执行期间,它的大小是不会改变的。
- 操作数栈(Operand Stack):表达式的计算是在操作数栈中完成的。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈/出栈操作
- 指向运行时常量池的引用(Reference to runtime constant pool):当前方法所属的类的运行时常量池的引用,引用其他的常量类或者使用字符串常量池中的字符串。
- 方法返回地址(Return Address):方法执行完(不论是正常执行还是发生了异常)后需要返回到方法被调用的位置,程序才能继续执行,方法返回地址保存一些用来帮助恢复上层方法的执行状态的信息。
- 动态链接(Dynamic Linking):每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
3.2、本地方法栈
本地方法栈与 Java 虚拟机栈类似,区别是本地方法栈执行的是本地方法,也就是带有 native 关键字修饰的方法。
带native关键字的,说明Java的作用范围达不到了,会进入本地方法栈 (Native Method Stack)调用本地方法接口(JNI),调用底层的不同的编程语言的库。使用native关键字说明这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用。用于Java 和其他语言(如c++)进行协作时用的。
4、堆
堆是所有线程共享的一块内存区域,在 JVM 启动的时候创建,用来存储对象。
以前,Java 中“几乎”所有的对象都会在堆中分配,但随着 JIT(Just-In-Time)编译器的发展和逃逸技术的逐渐成熟,所有的对象都分配到堆上渐渐变得不那么“绝对”了。从 JDK 7 开始,JVM 已经默认开启逃逸分析了,意味着如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
逃逸技术:简单来讲就是,Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。
堆是 Java 垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度来看,由于垃圾收集器基本都采用了分代垃圾收集的算法,所以堆还可以细分为:新生代和老年代。新生代还可以细分为:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
5、元空间
方法区是 Java 虚拟机规范中的一个概念,就像是一个接口。元空间是 HotSpot 虚拟机中对方法区的一个实现,就像是接口的实现类。
5.1、方法区
方法区和堆一样,是线程共享的区域,它用来存储类信息、静态变量、常量,以及字节码等。
Java 8之前是永久代,永久代的方法区,和堆使用的物理内存是连续的。因为永久代空间配置有限,会出现java.lang.OutOfMemoryError: PermGen space错误,而元空间直接存在于本地内存中,理论上机器内存有多大,元空间就有多大。Java 8 移除了永久代,取而代之的是元空间。
方法区特点:
- 方法区是线程共享的;
- 方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。jvm也可以允许用户和程序指定方法区的初始大小,最小和最大限制;
- 方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展Java程序,这样可能会导致一些类,不再被使用,变为垃圾。这时候需要进行垃圾清理;
- 静态变量、常量、类信息(构造方法、接口定义)、运行时常量池存在方法区中
- 静态常量池:即Class文件中的常量池,当Class文件被加载完成后,java虚拟机会将静态常量池里的内容转移到运行时常量池里。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
- 字符串常量池存在运行时常量池之中(在JDK7之前存在运行时常量池之中,在JDK7已经将其转移到堆中)
6、GC垃圾回收器
6.1、作用区域
堆(新生区、老年区),大部分是新生区。
6.2、分类
- 普通GC、轻GC : 新生区垃圾回收
- 全局GC、重GC : 老年区垃圾回收
6.3、算法
引用计数算法
给每一个对象分配一个计数器,根据计数器数值来清除。
复制算法
标记清除算法
标记清除整理算法(标记压缩算法)
GC:分代收集算法
- 年轻代:存活率低,使用复制算法
- 老年代:存活率高,区域大,标记清除(内存碎片不太多就清除)+标记压缩混合实现
JVM内存模型
Java内存的访问方式
逻辑内存模型我们已经看到了,那当我们建立一个对象的时候是怎么进行访问的呢?
Object obj = new Object();
- Object obj:反映到Java 栈的本地变量表中,作为一个reference 类型数据出现。
- new Object():反映到Java 堆中,形成一块存储了Object 类型所有实例数据值的结构化内存。
- Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
由于reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有以下两种:
1、句柄访问方式:Java 堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
2、直接指针:使用直接指针的方式,引用中存储的就是对象的地址。这也是Hotspot虚拟机采用的方式。
具体实例示意图:
小结
- JVM内存模型包括 线程共享区域 和 线程私有区域 。
- 线程共享区域:包括堆和方法区,堆上存放对象和数组,方法区存放类的信息、静态变量和常量。
- 线程私有区域:包括Java虚拟机栈、本地方法栈和程序计数器。
- 虚拟机栈中是一个个栈帧,每个栈帧对应一个被调用的方法,本地方法栈与虚拟机栈类似,区别是本地方法栈执行的是本地方法。
- 程序计数器中保存的是当前需要执行的指令地址。
引申
Class实例在堆中还是方法区中?
知识点:
Classloader在加载class:
- 在JVM中,使用了OOP-KLASS模型来表示Java对象。
- JVM的Classloader在加载class时,创建instanceKlass,表示其元数据(类型信息),存放在方法区;instanceKlass是JVM中的数据结构;
- JVM的Classloader在加载class时,还会创建一个Class对象,存放在堆区,它为程序提供了访问类型信息的方法。
- 元数据(类型信息)包括类型的常量池、域(Field)信息、方法(Method)信息、所有静态(static)变量、classloader的引用。
new一个对象:
- 在new一个对象时,JVM创建instanceOopDesc,来表示这个对象,存放在堆区,其引用,存放在栈区;instanceOopDesc对应Java中的对象实例;
- HotSpot并不把instanceKlass暴露给Java,而会另外创建对应的instanceOopDesc来表示java.lang.Class对象,并将后者称为前者的“Java镜像”,klass持有指向oop引用(_java_mirror便是该instanceKlass对Class对象的引用);
- 注意,new操作返回的instanceOopDesc类型指针指向instanceKlass,而instanceKlass指向了对应的类型的Class实例的instanceOopDesc;有点绕,简单说,就是Person实例(堆)——>Person的instanceKlass(方法区)——>Person的Class(堆)。
instanceOopDesc,只包含数据信息,它包含4部分:
- 对象头
- Mark Word,用于存储对象自身运行时的数据,如哈希码(Hash Code),GC分代年龄,锁状态标志,偏向线程ID、偏向时间戳等信息,它会根据对象的状态复用自己的存储空间。它是实现轻量级锁和偏向锁的关键。
- 类型指针,对象会指向它的类的元数据的指针,即指向方法区的instanceKlass实例,虚拟机通过这个指针确定这个对象是哪个类的实例。
- Array length,如果对象是一个数组,还必须记录数组长度的数据。
- 实例数据:存放类的属性数据信息,包括父类的属性信息。
- 对齐填充(Padding):虚拟机要求对象起始地址必须是8字节的整数倍。仅仅是为了字节对齐。
结论:
- Class对象是存放在堆区的,不是方法区。
- 类的元数据(元数据并不是类的Class对象。Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。
面试题
Object o= new Object()在内存中占多少字节?
由引申知识点,对象在内存中存储的分为:对象头、类型指针、实例数据、和对齐填充。
其中,markWord 32位占4字节,64位占8字节,class pointer开启压缩占4字节,关闭压缩占8字节。也就是对象头默认占12字节(64虚拟机开启压缩)。Object o= new Object() 空对象,实例数据为0,而虚拟机默认占用字节要为8的整数倍,则对齐填充4,大小为12 + 4 =16字节。
Object o= new Object() 里有成员变量 String s= “123456”;
注意点的是"123456"在常量池中,所以Object 中存的是指针,而指针默认为8(未开启指针压缩),压缩后为4字节,则 12 +4 = 16字节,不用对齐填充。
Stu o= new Stu(); Stu{boolean ,String}
boolean byte short 都要转成 int类型也就是要补齐字节,都要转成4字节。则 12+4+4=20 对齐填充 +4 是24字节。
参考文档:
JVM与JMM 原创:禾下凉
JVM与JMM 原创:信仰