JVM初舞台
jdk、jre、jvm的关系
jvm:java虚拟机,在java的运行环境中,jvm充当着翻译的角色,将java编译后的.class文件翻译成计算机所能识别的机器码。JVM不会自己生成代码,它只是负责翻译,jvm翻译时会用到许多依赖类库,这些类库存在于jre中。
jre:java运行时环境,它不仅包含了jvm还提供了很多类库(如读取文件操作,连接网络等),是由jvm标准加上实现的一堆基础类库组成的。
jdk:jdk包含了jre但是不仅仅有jre,jdk为开发者也就是程序员提供了许多实用的开发工具,如编译代码、打包代码、反编译等工具。图示如下:
jvm整体知识模块
JVM 能涉及非常庞大的一块知识体系,比如内存结构、垃圾回收、类加载、性能调优、JVM 自身优化技术、执行引擎、类文件结构、监控工具等。
但是在所有的知识体系中,都或多或少跟内存结构有一定的关系:
比如垃圾回收回收的就是内存、类加载加载到的地方也是内存、性能优化也涉及到内存优化、执行引擎与内存密不可分、类文件结构与内存的设计有关系,监控工具也会监控内存。所以内存结构处于 JVM 中核心位置。也是属于我们入门 JVM 学习的最好的选择。
运行时数据区
java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区
运行时数据区的图:
由图可知:
jvm的运行时数据区分为:方法区、堆、虚拟机栈、本地方法栈、程序计数器等
根据于线程的关系可以分为:
线程共享区:堆、方法区
线程私有区:虚拟机栈、本地方法栈、程序计数器
线程私有也即每个线程都拥有一份内存区域,有自己的虚拟机栈、本地方法栈和程序计数器。
线程共享区也即只有一块区域,所有线程共享这个这块内存有且仅有一份。
从线程私有区开始看:
线程私有区中,每一个线程都有虚拟机栈、本地方法栈和程序计数器,依次介绍每一块的功能
虚拟机栈
虚拟机栈的作用:在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。
大小限制 -Xss 默认1M 1024KB
虚拟机栈的基础:虚拟机栈是基于线程的,即使这个java程序只有一个main()方法,也是以线程的方法运行的。在线程的生命周期中参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
栈帧:在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。
我们可以以简单的java程序来模拟栈帧入栈出栈的操作:
public class MethodAndStack {
public static void main(String[] args) {
System.out.println("主程序方法");
A();
System.out.println("调用主程序方法完成");
}
public static void A(){
System.out.println("调用A方法");
B();
System.out.println("调用A方法完成");
}
public static void B(){
System.out.println("调用B方法");
C();
System.out.println("调用B方法完成");
}
public static void C(){
System.out.println("调用C方法");
System.out.println("调用C方法完成");
}
}
运行结果如下:
所以我们可以模拟程序运行时,栈帧入栈的操作:
主方法栈帧入栈:
A方法栈帧入栈:
B方法栈帧入栈:
C方法栈帧入栈:
C方法栈帧出栈:
B方法栈帧出栈:
A方法栈帧出栈:
主方法栈帧出栈:
整个过程简单来说也就是:
就是起一个 main 方法,在 main 方法运行中调用 A 方法,A 方法中调用 B 方法,B 方法中运行 C 方法。C 方法运行完了,C 方法出栈,接着 B 方法运行完了,B 方法出栈、接着 A 方法运行完了,A 方法出栈,最后 main 方法运行完了,main 方法这个栈帧就出栈了。
这个就是 Java 方法运行对虚拟机栈的一个影响。虚拟机栈就是用来存储线程运行方法中的数据的。而每一个方法对应一个栈帧。
我们来详细的说一下每一个栈帧里面的东西
栈帧
在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。
一个栈帧都包含以下四个区域:
局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的(方法中的变量)。首先它是一个32位的长度,主要存放我们的Java的八大基础数据类型,一般32位就可以存放下,如果是64位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的Object对象,我们只需要存放它的一个引用地址即可。
操作数栈:存放java方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的java数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的。
动态连接:Java 语言特性多态。
完成出口(返回地址):正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)
抽象出来的图示如下:
我们可以根据一段程序的.class文件来模拟虚拟机栈中的操作
public class Person {
public int work()throws Exception{
int x =1;
int y =2;
int z =(x+y)*10;
return z;
}
public static void main(String[] args) throws Exception{
Person person = new Person();//person 栈中--、 new Person 对象是在堆
person.work();
person.hashCode();
}
}
将此代码编译后的Person.class反编译后我们可以得到汇编代码如下:
public class ex1.Person {
public ex1.Person();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int work() throws java.lang.Exception;
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]) throws java.lang.Exception;
Code:
0: new #2 // class ex1/Person
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method work:()I
12: pop
13: aload_1
14: invokevirtual #5 // Method java/lang/Object.hashCode:()I
17: pop
18: return
}
我们可以根据相应的指令来分析程序运行时,虚拟机栈中的数据的变化,以work方法的栈帧为例:
public int work() throws java.lang.Exception;
Code:
0: iconst_1 // 将int型(1)推送至栈顶
1: istore_1 // 将栈顶元素从操作数栈存储到局部变量表1的位置
2: iconst_2 // 将int型(2)推送至栈顶
3: istore_2 // 将栈顶元素从操作数栈存储到局部变量表2的位置
4: iload_1 // 将局部变量位置为1的加载到操作数栈
5: iload_2 // 将局部变量位置为2的加载到操作数栈
6: iadd // 将栈顶和次栈顶(最上面的两个操作数)相加并将结果入栈
7: bipush 10 // 将常量10加载至操作数栈
9: imul // 将栈顶和次栈顶(最上面的两个操作数)相乘并将结果入栈
10: istore_3 // 将栈顶元素从操作数栈存储到局部变量表3的位置
11: iload_3 // 将局部变量位置为3的加载到操作数栈
12: ireturn // 返回栈顶元素
jvm指令查询地址(大佬的博客):
指令查询地址
虚拟机栈的大小并不是无限的,每一个线程的虚拟机栈大小默认为1M。当我们不断的在虚拟机栈中压入栈帧,从不出栈的话,虚拟机栈就会爆炸,抛出异常,最典型的就是死递归,如下程序:
public class StackError {
public static void main(String[] args) {
A();
}
public static void A(){
A();
}
}
程序执行结果:
所以在开发过程中一定要避免死递归的情况
本地方法栈
本地方法栈简单来说就是服务于jvm,用来调用native方法。
本地方法栈跟Java虚拟机栈的功能类似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用Java实现的,而是由C语言实现的(比如Object.hashcode方法)。
本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是native方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。
虚拟机规范无强制规定,各版本虚拟机自由实现,HotSpot直接把本地方法栈和虚拟机栈合二为一 。
程序计数器
较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
因为 JVM 是虚拟机,内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native 方法),这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个 会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。
另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域。
线程共享区域:
方法区
方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、静态变量、常量、运行时常量池、字符串常量池等。
方法区是JVM对内存的“逻辑划分”,在JDK1.7及之前很多开发者都习惯将方法区称为“永久代”,是因为在HotSpot虚拟机中,设计人员使用了永久代来实现了JVM规范的方法区。在JDK1.8及以后使用了元空间来实现方法区。
JVM 在执行某个类的时候,必须先加载。在加载类(加载、验证、准备、解析、初始化)的时候,JVM会先加载class文件,而在class文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。
字面量包括字符串(String a=“b”)、基本类型的常量(final 修饰的变量),符号引用则包括类和方法的全限定名(例如 String 这个类,它的全限定名就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。
符号引用
一个java类(假设为People类)被编译成一个class文件时,如果People类引用了Tool类,但是在编译时People类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以既将符号org.simple.Tool替换为Tool类的实际内存地址,
及直接引用地址。
总而言之也就是,在编译时用符号引用来代替引用类,在加载时再通过虚拟机获取该引用类的实际地址。以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定已经加载到内存中。
常量池和运行时常量池
常量池的概念很多,比如类常量池、运行时常量池、字符串常量池。
字符串常量池:在每个jvm中只有一份,存放字符串常量的引用值,(也称作全局常量池?)
类常量池:也称为静态常量池,存放字符串字面量、符号引用以及类和方法的信息。
运行时常量池:运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。
运行时常量池在JDK1.7版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。
元空间和永久代
方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。
元空间大小参数:
jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;
jdk1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize
jdk1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)
堆
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。
堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。
随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。
当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
堆参数设置
-Xms:堆的最小值;
-Xmx:堆的最大值;
-Xmn:新生代的大小;
-XX:NewSize;新生代最小值;
-XX:MaxNewSize:新生代最大值;
例如- Xmx256m
直接内存
直接内存有一种更加科学的叫法,堆外内存。
JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。
它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用directByteBuffer 对象直接引用并操作;
这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。
小结:
1、直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小。
2、其他堆外内存,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。