JVM 笔记之内存区域介绍 - 1

196 阅读11分钟

运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,通常被称为运行时数据区。相比于 C、C++ 的手动内存管理,Java 引以为豪的地方就是它的自动内存管理机制。

在 JVM 中,JVM 内存主要分为堆、方法区、程序计数器、虚拟机栈和本地方法栈。

同时,按照与线程的关系也可以这么划分区域:

1)线程私有区域:一个线程拥有单独的一份内存区域。

2)线程共享区域:被所有线程共享,并且只有一份。

这里还有一个直接内存(堆外内存),虽然它不是运行时数据区的一部分,但是会被频繁地使用。可以理解成没有被虚拟机化的操作系统上的其他内存。例如,操作系统上有 8G 内存,被 JVM 虚拟化了 3G,那么还剩余 5G, JVM 是借助一些工具使用这 5G 内存的,这个内存部分称之为直接内存。

JVM的内存区域

虚拟机栈

作用:存储当前线程运行 Java 方法所需的数据,指令和返回地址。

虚拟机栈是基于线程的,和线程同生命周期,哪怕只有一个 main 方法,也是以线程的方式运行的。

通常,虚拟机栈的大小缺省为 1M。用参数 -Xss 调整大小,例如 -Xss256k。

参考官方文档(JDK1.8):docs.oracle.com/javase/8/do…

栈帧:在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。

栈帧大体都包含以下四个区域:

1)局部变量表:用于存放局部变量(方法中的变量)。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。

2)操作数据栈:用于存放 Java 方法执行的操作数,顾名思义就是用来操作的,操作的元素可以是任意的 Java 数据类型,所以一个方法刚开始的时候,这个方法的操作数栈就是空的。本质上,操作数栈是 JVM 执行引擎的一个工作区,就是说方法在执行,才会对操作数栈进行操作,如果不执行,操作数栈其实就是空的。

3)动态链接:Java 语言特性多态(后续章节细讲,需要结合 class 与执行引擎一起来讲)。

4)返回地址:正常返回(调用程序计数器中的地址作为返回),异常的话(通过异常处理器表<非栈帧中的>来确定)。

程序计数器

作用:指向当前线程正在执行的字节码指令的地址。

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。

因为 JVM 是虚拟机,内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native 方法),这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。

注意:程序计数器也是 JVM 中唯一不会 OOM ( OutOfMemory ) 的内存区域。

本地方法栈

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。甚至可以认为,虚拟机栈和本地方法栈是同一个区域。虚拟机规范无强制规定,各版本虚拟机自由实现,HotSpot 直接把本地方法栈和虚拟机栈合二为一。

方法区

方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、静态变量、常量、运行时常量池、字符串常量池等。

方法区是 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 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。

例如,类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中;在 JVM 加载完类之后,JVM 会将字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池多个相同的字符串在运行时常量池只会存在一份。

常量池有很多概念,包括运行时常量池、class 常量池、字符串常量池。

严格来说是静态常量池和运行时常量池,静态常量池是存放字符串字面量、符号引用以及类和方法的信息,而运行时常量池存放的是运行时一些直接引用。运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。

运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

元空间

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。

在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。

方法区大小参数:

  • JDK1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize
  • JDK1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize

JDK1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?

官方解释:移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。

堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。

随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。

那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?

这和两个方面有关:对象的类型和在 Java 类中存在的位置。

Java 的对象可以分为**基本数据类型和普通对象。**对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

堆大小参数:

  • -Xms:堆的最小值
  • -Xmx:堆的最大值
  • -Xmn:新生代的大小
  • -XX:NewSize:新生代最小值
  • -XX:MaxNewSize:新生代最大值

直接内存(堆外内存)

JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。

它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用 directByteBuffer 对象直接引用并操作;这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。

小结:

1、直接内存主要是通过 DirectByteBuffer 申请的内存,可以用参数“MaxDirectMemorySize”来限制其大小。

2、其他堆外内存,主要是指使用了 Unsafe 或者其他 JNI 手段直接申请的内存。

堆外内存的泄漏是非常严重的,它的排查难度高、影响大,甚至会造成主机的死亡。同时,要注意 Oracle 之前计划在 Java9 中去掉 sun.misc.Unsafe API 。这里删除 sun.misc.Unsafe 的原因之一是使 Java 更加安全,并且有替代方案。