运行时数据区
- 程序计数器(线程私有)
- 虚拟机栈(线程私有)
- 本地方法栈(线程私有)
- 堆(线程共享)
- 方法区(线程共享)
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本略有不同:
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
Java虚拟机的多线程通过CPU不停切换各个线程,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
虚拟机栈
与程序计数器一样, Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候, Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。(Java 虚拟机栈是由一个个栈帧组成)
内存区域划分中的“栈”通常指的是虚拟机栈,或者更多情况下是指虚拟机栈中的局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
Java 虚拟机栈会出现两种异常:StackOverflowError
和 OutOfMemoryError
。
StackOverflowError
: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度大于当前 虚拟机所允许的深度的时候,将抛出 StackOverflowError 异常。OutOfMemoryError
: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
本地方法栈
与虚拟机栈所发挥的作用非常相似,其区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。 在 HotSpot 虚拟机中直接把本地方法栈和虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
堆可以处于物理上不连续的内存空间中(物理内存),但在逻辑上它应该被视为连续的(虚拟内存)。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的内存区域,因此也被称作GC 堆(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。当然,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局更不是对Java堆的进一步细致划分(近年来也出现了不采用“经典分代”设计的新垃圾收集器,将Java堆内存分为新生代、老年代、永久代等未必适合)。将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
JDK 7 与 8 的区别
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
- 堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象,且堆也无法再扩展时,就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,若没有特别配置,将会使用默认值)
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的是与 Java 堆区分开来。
方法区也被称为永久代。以下是方法区和永久代的关系:
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
JDK 8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK 7 就已经开始了),使用在本地内存中实现的元空间来代替。
JDK 7 时,将原本放在永久代的字符串常量池、静态变量等移至 Java 堆中;到了 JDK 8 时,将 JDK 7 中永久代剩余的内容(类型信息)全部移到元空间。
方法区和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
为什么要将永久代替换为元空间呢?
-
整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当元空间溢出时会得到如下错误:
java.lang.OutOfMemoryError: MetaSpace
你可以使用
-XX:MaxMetaspaceSize
标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize
调整标志定义元空间的初始大小,如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。 -
元空间里面存放的是类的元数据,这样加载多少类的元数据就不由
MaxPermSize
控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。 -
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
- JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
- JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是 hotspot 中的永久代 。
- JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace)
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
内存存储总结
- 栈:
- 栈包含虚拟机栈、本地方法栈,线程私有,且内存连续
- 每个方法被调用都会创建一个栈帧,用于存储局部变量表、操作数栈、方法出口等,其中局部变量表用于存放方法参数和方法内部定义的局部变量(基本数据类型、对象引用)。
- 方法里再调用一个方法,则再开辟一个栈帧。先进后出,方法由里向外执行完关闭
- 分配内存大小和生命周期是可以确定的,当没有引用指向数据时,即方法结束或线程结束,内存就跟着回收了(注意:栈没有GC,而是因为出栈)
- 堆:
- JVM只有一个堆区(heap)被所有线程共享,不连续的内存空间
- 堆中不存放基本类型和对象引用,只存放对象本身(数组也是对象)
- 方法区:
- JVM只有一个方法区,线程共享
- 用来存放程序中永远不变或唯一的内容:class类信息、静态变量、常量等
参考资料
《深入理解Java虚拟机 第3版》——周志明