深入理解Java虚拟机 JVM 内存结构

 

前言

JVM是Java中比较难理解和掌握的一部分,也是面试中被问的比较多的,掌握好JVM底层原理有助于我们在开发中写出效率更高的代码,可以让我们面对OutOfMemoryError时不再一脸懵逼,可以用掌握的JVM知识去查找分析问题、去进行JVM的调优、去让我们的应用程序可以支持更高的并发量等。。。。。。总之一句话,学好JVM很重要!

 

JVM是什么

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的,注意JVM是基于软件的,不是基于硬件的。

Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用模式Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。

比如下图:我们编译后产生的.class文件是二进制的字节码,字节码是不能被机器直接运行的,通过JVM把编译好的字节码转换成对应操作系统平台可以直接识别运行的机器码指令,JVM充当了一个中间转换的桥梁,这样我们编写的Java文件就可以做到 "一次编译,到处运行" 。

 

JVM内存结构概览

JVM虚拟机规范官方文档地址:https://docs.oracle.com/javase/specs/,JDK8虚拟机参考手册地址:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html,JDK8官方文档地址为https://docs.oracle.com/javase/8/docs/,JDK8中内存结构文档https://docs.oracle.com/javase/specs/jvms/se8/html/index.html。

我们先看下下面这张图(这张图非常重要!非常重要!非常重要!),一个Java文件的执行过程为:Hello.java文件通过javac被编译为Hello.class文件,然后类装载子系统将class文件加载到运行时数据区,通过执行引擎去执行生成的机器指令。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这个数据区域就叫运行时数据区。运行时数据区主要包含了PC寄存器(程序计数器)、Java虚拟机栈、本地方法栈、Java堆、方法区以及运行时常量池,这其中Java堆、方法区跟Java虚拟机栈是学习的重点。

但是,需要注意的是,上面的区域划分只是逻辑区域,对于有些区域的限制是比较松的,所以不同的虚拟机厂商在实现上,甚至是同一款虚拟机的不同版本也是不尽相同的。

 

运行时数据区

程序计数器

程序计数器(Program counter Register,也叫PC寄存器)是一块较小的内存空间,是线程私有的,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模到,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需耍依赖这个计数器来完成。因为Java是可以多线程执行的,一个线程执行到一半可能因为CPU时间片轮转切换到了另外一个线程,在切换回之前线程的时候,需要回到线程上次的执行位置,所以要线程私有。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区城是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情祝的区域。

比如在下面代码中test1()中调用了test2(),test2()执行完成后退出,这时候需要回到test1()方法中继续执行,程序计数器记录了下一个需要执行的指令的行号。

public void test1(){
  test2();
  System.out.println("test1");
}

public void test2(){
  System.out.println("test2");
}

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同到都会创建一个栈帧(Stack Frame)用于存储局部量表、操作数栈、动态链接、方法出口等信息。栈帧是Java方法运行时的基础数据结构,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟栈中从入栈到出栈的过程(说人话就是要执行一个方法,将该方法的栈帧压入栈顶,方法执行完成其栈帧出栈)。在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法称为当前线程方法,而该方法的栈帧就称为当前帧。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象始地址的引用指针,也可能是指向一个代表对象的句柄或其地与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

在Java虚拟机规范中,对这个区域定了两种异状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常;一般的虚拟机栈都是可扩展的,如果扩展时无法丰请到足够的内存,就会抛出OutOfMemoryError异常,可以通过-Xss设置每个线程的堆栈大小。

Java虚拟机栈的结构如下图所示:Java虚拟机栈的生命周期与线程一致,一个方法对应一块栈帧内存区域,栈帧中包含局部变量表、操作数栈、动态链接、方法出口等信息。拿下面代码举例,程序执行main(),main()先压入栈顶,然后main()方法中new了一个Math对象,math变量是指向堆中Math对象的引用,math变量就属于局部变量表,创建Math对象之后,调用了其compute(),然后compute()压入栈顶,compute方法执行完成后其栈帧出栈,然后根据程序计数器记录程序执行的行号,继续回到main方法执行,main方法中已经没有其他执行指令了,则main方法退出,main方法对应的栈帧出栈,虚拟机栈中已经没有其他栈帧,main线程生命周期结束。

注意:关于Java虚拟机栈中的栈帧,还有栈帧中的组成部分,这里只是做个简单的概述,后续会单独进行详细讲解,希望继续关注。

public class Math {

	private static final Integer CONSTANT=666;
	
	private int compute() {//一个方法对应一块栈帧内存区域
		int a=3;
		int b=5;
		int c=(a+b)*10;
		return c;
	}
	
	public static void main(String[] args) {
		Math math=new Math();
		math.compute();
	}
}

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈非常相似,也是线程私有的,它们的区别不过是虚拟机栈执行的是Java方法(也就是字节码),而本地方法栈用到的是Native方法。与虚拟机战一样。本地方法栈区域也会出现StackOverFlowError和OutOfMemoryError异常。

方法区

方法区(Method Area),是各个线程共享的内存区域,,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non一Heap(非堆),目的就是要和堆分开。这部分存储的是运行时必须的类相关信息,装载进此区域的数据是不会被垃圾收集器回收的,只有关闭Jvm才会释放这块区域占用的内存。

对于Hotspot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)",但严格本质上说两者不同,或者说使用永久代来实现方法区而己,永久代是方法区(相当于是一个接口interface)的一个实现,idkl.7的版本中,己经将原本放在永久代的字符串常量池移走。Jdk1.7中方法区是用永久代实现的,到1.8中是用元空间(MetaSpace)实现的,而元空间使用的是直接内存。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时会抛出OutOfMemoryError异常。可以通过-XX:PermSize和 -XX:MaxPermSize来分别设置永久区最小、最大空间。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生产的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存人口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

Java语言不要求常量一定只有编译器才能产生,运行时也可能将新的常量放入池中,该特性用的比较多的就是String类的intern()方法。运行时常量池是方法区的一部分,在内存不够时,也会抛出OutOfMemoryError异常。

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是线程共享的,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是着JIT编译器的发展与逸分析技术逐渐成熟,栈上分配、标量替换优化技术会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么"绝对"了。

Java堆是被收集管理的主要区域,因此很多时候也被称做"GC堆"(Garbage Collected Heap)。从内存回收角度来看,由于现在收集器基本都采用分代算法(为什么要采用分代算法,常用的垃圾收集算法有哪些后面会进行介绍),所以堆中还以细分:新生代(Young/New)和老年代(Old/Tenure),新生代又可以划分为Eden(伊甸园)空间、survivor(幸存区,其又可以分为from survivor和to survivor,也就是S0和S1)空间等。从内存分配的角度来看,线程共享的Java堆中可划分出多个程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的是为了更好地回收内存,或更快地分配内存。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只逻辑上是连续的即可。.Java虚拟机中可以对堆进行扩展,可以通过-Xms 设置起始堆大小、通过-Xmx设置最大堆大小、通过-XX:NewSize设置新生代最小空间大小、通过 -XX:MaxNewSize设置新生代最大空间大小。如果在堆中没有完成实例分配,并且地也无法再扩展时,将会抛OutOfMemoryError异常。

下图是Java7中的Jvm内存划分:

  • 堆(Heap)、永久代(PermGen)
  • 堆(Heap)又分为新生代(NewGen)或者叫年轻代(YoungGen)、老年代(OldGen)
  • 年轻代(YoungGen)又可分为Eden区(伊甸园区)、Survivor区(幸存区)
  • Survivor区(幸存区)又可分为FromSpace(S0)和ToSpace(S1),整个年轻代中默认比例Eden:S0:S1=8:1:1,同一时间内S0跟S1只会有一个区域被占用
  • 年轻代(New):年轻代用来存放JVM刚分配的Java对象
  • 年老代(Old):年轻代中经过垃圾回收没有回收掉的对象将被Copy到年老代
  • 永久代(Perm):永久代存放Class、Method元信息,其大小跟项目的规模、类、方法的量有关
  • 年轻代发生的GC叫Minor GC,老年代发生的GC叫Major GC
  • 另外还有一个Full GC,是清理整个堆空间―包括年轻代和永久代

关于堆的分代、还有对象是如何从年轻代进入老年代等都会在后面的章节中介绍。

我们看下面这张图,在JDK1.8中将永久代去掉了,改由元空间(MetaSpace)去实现方法区,而元空间跟永久代的最大区别就是其不在JVM内存中,而是使用的直接内存。

关于方法区、常量池、永久代在JDK6、7、8中的变动还是挺大的:

  • Jdk1.6及之前:有永久代,常量池1.6在方法区
  • Jdk1.7:有永久代,但己经逐步“去永久代”,常量池1.7在堆
  • Jdk1.8及之后:无永久代,常量池1.8在元空间

 

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NlO(New Inpu/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。当各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),会导致动态扩展时出现OutOfMemoryError异常。

关于JVM的内存结构本节先做了一个大概的介绍,其中还有很多细节没有介绍:栈帧中的各个组成部分分别是干什么用的,堆内存的划分,对象是如果从新生代到老年代的,为什么要分代收集,垃圾收集算法有哪些,垃圾收集器有哪些。。。。。这些在后面的章节中会慢慢一一介绍,希望继续关注。

文章内容参考了周志明老师的《深入理解Java虚拟机第二版》以及他翻译的《Java虚拟机规范 JavaSE8版》,想学习JVM的话强烈推荐这本《深入理解Java虚拟机第二版》。

关于深入理解Java虚拟机 JVM 内存结构的文章就介绍至此,更多相关Java 虚拟机 JVM 内存结构内容请搜索编程宝库以前的文章,希望大家多多支持编程宝库

 工厂模式介绍工厂模式也是非常常见的设计模式之一,其属于创建型模式。工厂模式分类:简单工厂(Simple Factory)、工厂方法(Factory Method)、抽象工厂 ...