Java虚拟机-JVM
1 简介
1.1 概念
JVM是Java Virtual Machine (java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
1.2 作用
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
1.3 常见的几款java虚拟机
- SUN Classic VM : 第一款商用java虚拟机,1996年1月jdk1.0中带的java虚拟机,只能使用纯解释器的方式来执行java代码
- Exact VM:准确式内存管理,编译器和解释器混合工作以及两级即时编译 ,只在Solaris平台发布
- HotSport VM:即时编译,节约了时间和存储,称霸武林
- KVM:简单,轻量,高可以执行,主要在手机平台使用
- JRockit:BEA,世界上最快的java虚拟机,专注服务端应用,oracle收购BEA后,JRockit基本上已经消失了
- dalvik:不能直接指向class文件,寄存器架构,执行dex文件,由class文件转化而来
- MicrosoftJvm:只能在windows平台运行
1.4 JVM内存模型
- 堆(Heap)
- 方法区(Method Area)
- 程序计数器(Program Counter Register)
- 虚拟机栈(VM Stack)
- 本地方法栈(Native Method Stack)
1.5 JVM结构图
注1:通过javac会把.java文件(源文件)编译成class files (.class字节码文件)
注2:灰色部分(Java栈,本地方法栈和程序计数器)是线程私有,不存在线程安全问题,橙色部分(方法区和堆)为线程共享区。
2 类加载器
类加载器将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。
注1:ClassLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎Execution Engine决定
注2:需要加载的内容:如类的声明,方法的声明,静态的量(static)等等
2.1 类加载器分类
启动类加载器(BootstrapClassLoader):
也称为根加载器,是虚拟机自带的加载器,用于加载$JAVA_HOME/jre/lib/rt.jar包内的class文件。rt.jar是Java基础类库,包含Java运行环境所需的基础类。
拓展类加载器 (ExtClassLoader):
虚拟机自带的加载器,由Java语言实现,用于加载$JAVA_HOME/jre/lib/ext/.jar目录下的class文件
应用程序类加载器(AppClassLoader):
虚拟机自带的加载器,用于加载当前应用的classpath的所有类,也就是我们自己写的那些Java代码
用户自定义加载器
某个类通过继承Java.lang.ClassLoader抽象类自定义一个类加载器。
2.2 加载器的关系图
他们的关系是一种父子关系
2.3 类加载步骤
2.3.1 加载(Loading)
通过一个类的全类名获取其二进制字节流,将这个二进制流代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。
2.3.2 链接(Linking)
该过程又可以分为三个阶段:验证Verfication,准备Preparation和解析Resolution
- 验证阶段 用于确保加载的Class文件的字节流包含的信息是否符合虚拟机要求,保证其正确性合法性;
- 准备阶段 为类变量(static修饰的变量)分配内存并根据对象类型设置相应的默认初始值(比如int类型为0,Integer类型为null)
这里不包含常量,因为常量在编译时(.java文件编译成.class文件的时候)分配,准备阶段会显示初始化。类的实例变量不会在这个阶段准备初始化。
- 解析阶段 用于将符号引用转换为直接引用。
例:
public class Test {
public static void main(String[] args) {
String str = "hello";
System.out.println(str);
}
}
使用 javap -v Test.class 命令查看其字节码
可以看到常量池中有许多符号引用(比如#2),解析阶段就是将其解析为直接引用(比如#2表示字符串常量hello)的过程。
2.3.3 初始化Initialization
- 该阶段就是执行类的构造器方法< clinit >()的过程 。
- clinit不是类的构造器,不需要我们自己定义,是javac编译器自动搜集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
- 如果一个类不包含类变量(静态变量)和静态代码块,那么它的字节码中就不会有构造器方法< clinit >()。
例:
public class Test {
private static int aaa = 1;
static {
aaa = 200;
System.out.println(Test.bbb);
bbb = 300;
}
private static int bbb = 2;
public static void main(String[] args) {
System.out.println(Test.bbb);
}
}
输出结果是:0,2
问:第一个打印语句时bbb还没有定义,为什么也能打印出结果?
解释:链接阶段中准备阶段所做的事情,就是为类变量(static修饰的变量)分配内存并根据对象类型设置相应的默认初始值。这个时候bbb已经被分配并赋予默认初始值(0),所以static块中可以使用该变量(这也说明,如果是实例变量则不能)
2.4 变量初始化顺序
- 常量 在编译的时候就会被分配具体值:
- 静态变量 (类变量)在类加载过程的初始化阶段被赋值
- 成员变量 在对象初始化时赋值
示例:
public class Test {
private static String hello = "aa";
private static final String HELLO = "bb";
private String world = "cc";
public static void main(String[] args) {
System.out.println(Test.hello);
}
}
查看:javap -v -p XXX.class
2.5 双亲委派机制
所谓的 双亲委派机制 就是:当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类(上面已经说过启动类加载器、拓展类加载器、应用程序类加载器、用户自定义加载器之间的关系是一种父子关系)去完成,每一个层次类加载器都是如此。只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
示例:
在src/main/java目录下新建java.lang包,然后在该包下新建一个String类:
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("helo");
}
}
所以上面的例子中,应用程序类加载器(AppClassLoader)委派给它的父类拓展类加载器(ExtClassLoader)去加载,ExtClassLoader又委托给它的父类启动类加载器(BootstrapClassLoader)去加载。BootstrapClassLoader从它的加载路径$JAVA_HOME/jre/lib/rt.jar 下找到了 java.lang.String 类,即rt.jar包下的String类,而该类里并没有main方法,所以便抛出了如上异常。
双亲委派机制的优点
采用双亲委派的一个好处是:不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个String对象,所以我们自定义的Java类并不会污染JDK自带的那些类(即使全类名一样),这种保护机制也叫沙箱安全机制。
3 程序计数器
程序计数器 (Program Counter Register)又叫PC寄存器。每个线程都有一个程序计数器,是线程私有的,是一块较小的内存空间。
它是一个指针,指向方法区中的方法字节码,用来存储指向下一条指令的地址,也即将要执行的指令代码,由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
如果执行的是一个Native方法,那这个计数器的值为undefied
程序计数器的作用
- 由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。
- CPU需要不停地切换各个线程,有了程序计数器后,当CPU切换回来后,我们就可以知道接着从哪开始继续执行程序
4 虚拟机栈(Java栈)
虚拟机栈 也称为 Java栈 ,每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个 栈帧(Stack Frame)。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)。
- Java虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)。
- 栈帧包括局部变量表(8种基本数据类型,对象引用地址)、操作数栈、动态链接、方法返回地址和一些附加信息。(一个栈针对应一个方法)
- 每一个方法被调用直至执行完毕的过程,就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程。
JVM对虚拟机栈的操作只有压栈(入栈)和出栈操作,遵循后进先出(FILO原则);在一个活动线程中,一个时间点只会有一个活动的栈帧,即当前正在执行方法对应的栈帧(当前栈帧);如果一个方法调用了另一个方法,那么对应的新的栈帧将会被创建出来,放在栈顶,成为新的当前栈帧
Java方法执行结束正常退出和抛出异常这两种情况会导致栈帧被弹出(退出)。
4.1 栈针内部结构
每个栈帧包含5个组成部分:局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和一些附加信息
4.1.1 局部变量表
局部变量表是一个数字数组,用于存储方法参数和方法体内的局部变量。
示例:
public class Test {
public static void hello(String name) {
Date date = new Date();
int count = 1;
}
}
非静态方法的局部变量表和静态方法相比,多了个this对象(即当前类)
4.1.2 操作数栈
每一个独立的栈帧中除了包含局部变量表外,还包含一个FILO的操作数栈,用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。
public class Test {
public void test() {
int a = 1;
}
}
4.1.3 动态链接
在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
public class Test {
public void hello() {
System.out.println("hello");
}
}
4.1.4 方法返回地址
存放调用该方法的pc寄存器的值,一个方法的结束分为以下两种方式:
- 正常执行结束;
- 出现未处理异常,非正常退出。
无论是哪种方式退出,在方法退出后都返回到该方法被调用的位置。
- 方法正常退出时,调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令地址;
- 异常退出时,返回地址需要通过异常表来确定
4.1.5 一些附加信息
比如对程序调式提供的支持信息
4.2 虚拟机栈大小调整
Java虚拟机规范允许虚拟机栈的大小固定不变或者动态扩展。
- 固定情况下:如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,则抛出StackOverflowError 异常;
- 可动态扩展情况下:尝试扩展的时候无法申请到足够的内存;或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,则会抛出 OutOfMemoryError 异常(OOM)
不同平台的虚拟机栈默认大小不同:
- Linux/x64 (64-bit): 1024 KB
- macOS (64-bit): 1024 KB
- Windows: 默认值取决于虚拟内存
通过-Xss(-XX:ThreadStackSize简写)设置虚拟机栈大小,默认单位为字节:
- -Xss1m
- -Xss1024k
- -Xss1048576
虚拟机栈越大,方法调用深度越深
5 本地方法接口
本地方法接口 (Native Interface)的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。于是就在内存中专门开辟了一块区域处理标记为native的代码.
native方法没有方法体(因为不是Java实现),所以看上去像是“接口”一样,故得名本地方法接口。
6 本地方法栈
虚拟机栈用于管理Java方法的调用,而本地方法栈则是用于管理本地方法接口的调用
7 堆(Heap)
**堆(Heap)**一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。堆中保存着所有引用类型的真实信息(即 对象 ),以方便执行器执行。
7.1 堆的分区
java7
java8
Java8后的堆物理上只分为新生代和养老代(老年代),Metaspace(元空间)不占用堆内存,而是直接使用物理内存。
7.2 为什么要把堆分区
分区的唯一理由就是为了优化GC性能
Java程序中不同对象的生命周期不同,70%~99%对象都是临时对象,这类对象在新生区“朝生夕死”。如果没有分区,GC时搜集垃圾需要对整个堆内存进行扫描;分区后,回收这些“朝生夕死”的对象,只需要在小范围的区域中(新生区)搜集垃圾。
7.3 堆参数
以JDK1.8+HotSpot为例,常用的两个可调整的堆参数:
- -Xms,等价于XX:InitialHeapSize (设置堆的初始内存大小,默认为物理内存的1/64)
- -Xmx,等价于XX:MaxHeapSize (设置堆的最大内存大小,默认为物理内存的1/4)
8 方法区
方法区并不是所谓的存储方法的区域,而是供各线程共享的运行时内存区域。它存储了已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
8.1 方法区、堆、栈关系
8.2 方法区内部结构
8.2.1 类型信息
对每个加载的类型(类class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(包名.类名);
- 这个类型直接父类的完整有效名(interface和java.lang.Object没有父类);
- 这个类的修饰符(public,abstract,final);
- 这个类型直接接口的一个有序列表(一个类可以实现多个接口)。
8.2.2 方法信息
方法信息包含了这个类的所有方法信息(包括构造器),这些信息和其声明顺序一致:
- 方法名称;
- 方法的返回值类型(没有返回值则是void);
- 方法参数的数量和类型(有序);(参数的变量存放在栈)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract);
- 方法的字节码、操作数栈、局部变量表及其大小(abstract和native方法除外);
- 异常表(abstract和native方法除外)。
8.2.3 域信息
8.2.4 JIT代码缓存
8.2.5 运行时常量池
类字节码反编译后,会有一个Constant pool的结构,俗称为常量池,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。虚拟机栈的动态链接就是将符号引用(这些符号引用的集合就是常量池)转换为直接引用(符号引用对应的具体信息,这些具体信息的集合就是运行时常量池,存在方法区中)的过程。
Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。这里所说的常量包括:基本类型包装类(包装类不管理浮点型,整形只会管理-128到127)和String(也可以通过String.intern()方法可以强制将String放入常量池)
8.2.6 静态变量
静态变量就是使用static修饰的域信息。静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。静态变量也称为类变量,类变量被类的所有实例共享,即使没有类实例时你也可以访问它
8.2.7 方法区的演进
9 Java对象的内存结构
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:
9.1 Mark Word(标记字段)
- 每个对象里有个标记字段。
- 其内容是一系列的标记位,用于存储对象自身运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等
9.2 Klass Pointer(类型指针)
- 类型指针指向对象的类元数据的内存地址
- 虚拟机通过这个指针确定该对象是哪个类的实例
9.3 对象实例数据
这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:
- byte和boolean是1个字节
- short和char是2个字节
- int和float是4个字节
- long和double是8个字节
- 引用类型在32位系统上每个占用4bytes, 在64位系统上每个占用8bytes
9.4 对齐填充
HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍
示例:
new int[0]:对象头(24字节)+实例数据(0字节)+对齐填充(前面计算结果为24,是8的整数倍,所以对齐填充字节为0)=24字节
new int[1]:对象头(24字节)+实例数据(基本类型4字节)+对齐填充(前面计算结果为28,需要加4才是8的整数倍,所以对齐填充字节为4)=32字节
Integer a:对象头(16字节)+实例数据(引用类型8字节)+对齐填充(前面计算结果为24,是8的整数倍,所以对齐填充字节为0)=24字节