前言
本文是介绍 JVM(Java Virtual Machine)。
目录
一、JVM
JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。
JVM工作
Java 程序不一样,使用 javac 编译成 .class 文件之后,还需要使用 Java 命令去主动执行它,操作系统并不认识这些 .class 文件。所以JVM就是一个翻译,将 .class 文件字节码翻译成机器识别的二进制机器码。
从跨平台到跨语言
从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了。JVM 只需要保证能够正确执行 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了。
为了实现跨平台型,不同操作系统有不同的JDK的版本。
JVM只识别字节码,所以JVM其实跟语言是解耦的,也就是没有直接关联,并不是它翻译Java文件,而是识别class文件,这个一般称之为字节码。还有像Groovy 、Kotlin、Jruby等等语言,它们其实也是编译成字节码,所以也可以在JVM上面跑,这个就是JVM的跨语言特征。
JVM、JRE、JDK的关系
JVM只是一个翻译,把Class翻译成机器识别的代码,但是需要注意,JVM 不会自己生成代码,需要大家编写代码,同时需要很多依赖类库,这个时候就需要用到JRE。
JRE是什么,它除了包含JVM之外,提供了很多的类库(就是我们说的jar包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络,使用I/O等等之类的)这些东西就是JRE提供的基础类库。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE(Java Runtime Environment)。
但对于程序员来说,JRE还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用JDK,因为JDK还提供了一些非常好用的小工具,比如 javac(编译代码)、java、jar (打包代码)、javap(反编译<反汇编>)等。这个就是JDK。具体可以文档可以通过官网去下载。
二、JVM整体
一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的翻译存在,输入字节码,调用操作系统函数。过程如下:Java 文件->编译器>字节码->JVM->机器码。

我们所说的 JVM,狭义上指的就 HotSpot(因为JVM有很多版本,但是使用最多的是HotSpot)。如非特殊说明,我们都以 HotSpot 为准。Java 之所以成为跨平台,就是由于 JVM 的存在。Java 的字节码,是沟通 Java 语言与 JVM 的桥梁,同时也是沟通 JVM 与操作系统的桥梁。
运行时数据区域
JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。主要分为
程序计数器、虚拟机栈、本地方法栈、堆和方法区。它们可分为两类:
线程私有:程序计数器、虚拟机栈、本地方法栈。
所有线程共享:堆和方法区。
Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。
程序计数器
指向当前线程正在执行的字节码指令的地址。是一块很小的内存空间。用于线程恢复执行。
例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
程序计数器也是JVM中唯一不会OOM(OutOfMemory)的内存区域。
虚拟机栈
也称Java栈,存储当前线程运行方法所需的数据,指令,返回地址。(存放Java方法调用所创建的栈帧)
Java 虚拟机栈是基于线程的。哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。
每个栈帧都包含四个区域:局部变量表、操作数栈、动态连接、返回地址。
栈的大小缺省一般为1M(与平台有关),可用参数-Xss调整大小,例如-Xss256k。
局部变量表:存放局部变量和对象的引用。首先它是一个32位的长度,主要存放我们的Java的八大基础数据类型,一般32位就可以存放下,如果是64位的就使用高低位占用两个也可以存放下。
操作数栈:存放方法执行的操作数。它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的元素可以是任意的java数据类型,一个方法刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法就是JVM一直运行入栈/出栈的操作。
动态连接:Java语言特性多态,需要类运行时才能确定具体的方法。
返回地址:正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)。
栈帧执行对内存区域的影响:
在JVM中,基于解释执行的这种方式是基于栈的引擎,这个说的栈,就是操作数栈。
注:Java解释执行的基于操作数栈,兼容性好,效率偏低;C/C++执行是基于寄存器,运算快,移植性差。
本地方法栈
保存native方法的信息。当线程调用native方法后,JVM不会为其在虚拟机栈中创建栈帧,而是动态链接并直接调用native方法。
本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的。本地方法栈它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot直接把本地方法栈和虚拟机栈合二为一 。
方法区
用来存放已被虚拟机加载的类信息,常量,静态变量,即时编译期编译后的代码。
JVM 在执行某个类的时候,必须先加载。在加载类(加载、验证、准备、解析、初始化)的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。
方法区是线程共享的,假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
在jdk不同版本的实现:
jdk1.7及以前使用了永久代来实现,初始和最大值:-XX:PermSize;-XX:MaxPermSize。
由于永久代内存经常不够用或发生内存溢出,回收率都偏低,分配多大空间难以确定,在jdk1.8以后使用了元空间来实现,方便扩展。初始和最大值:-XX:MetaspaceSize; -XX:MaxMetaspaceSize;jdk1.8以后大小就只受本机总内存的限制(如果不设置参数的话)
注:Java8 使用元空间替代永久代,官方的解释:移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
堆
用来存放对象实例和数组。堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。
随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
堆大小参数:
-Xms:堆的初始内存分配大小,最小值;-Xmx:堆的分配内存最大值,例如- Xmx256m。
-Xmn:新生代的大小;-XX:NewSize:新生代最小值;-XX:MaxNewSize:新生代最大值。
注:线程共享的区域划分为方法区和堆,因为方法区存放偏静态的数据,堆中存放频繁创建回收的动态数据,便于垃圾回收高效。
直接内存
也称为堆外内存,不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域;如果使用了NIO,这块区域会被频繁使用,在java堆内可以用directByteBuffer对象直接引用并操作;
这块内存不受java堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常。
从底层深入理解运行时数据区
开启HSDB工具,Jdk1.8启动JHSDB的时候必须将sawindbg.dll复制到对应目录的jre下。

当我们通过 Java 运行以上代码时,JVM 的整个处理过程如下:
JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间。
JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
完成上一个步骤后, JVM 首先会执行构造器,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,静态变量和常量放入方法区
执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher 对象,对象引用 student 就存放在栈中。
深入辨析堆和栈
功能
以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;
而堆内存用来存储Java中的对象。无论是对象的成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;
线程独享还是共享
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
空间大小
栈的内存要远远小于堆内存,栈的深度(大小)是有-Xss限制的,可能发生StackOverFlowError问题(无限递归调用)。
三、内存溢出
Out Of Memory:可用内存不足,无法完成内存分配。
栈溢出
StackOverFlowError:线程栈的大小超出了分配的内存限制(-Xss)。一般的方法调用是很难出现的,如果出现了可能会是无限递归,导致线程栈空间耗尽。
虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。
OutOfMemoryError:栈空间不足以创建额外的线程。不断建立线程,JVM申请栈内存,机器没有足够的内存。
堆溢出
申请内存空间,超出最大堆内存空间。
如果是内存溢出,则通过调大 -Xms,-Xmx参数。
如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么就应该检查JVM的堆参数设置,与机器的内存对比,看是否还有可以调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。
方法区溢出
- 运行时常量池溢出
- 方法区中保存的Class对象没有被及时回收掉或者Class信息占用的内存超过了配置。
Class要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数-Xnoclassgc可以进行控制):
该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

直接内存溢出
也称堆外溢出,直接内存的容量可以通过-XX:MaxDirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常;由直接内存导致的内存溢出,一个比较明显的特征是在HeapDump文件中不会看见有什么明显的异常情况,如果发生了OOM,同时Dump文件很小,可以考虑重点排查下直接内存方面的原因。
四、虚拟机优化技术
编译优化技术——方法内联
方法内联的优化行为,就是把目标方法的代码原封不动的“复制”到调用的方法中,避免真实的方法调用而已。
栈的优化技术——栈帧之间数据的共享
在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的JVM在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。