vcjmhg 的个人博客

自律者自由

  menu
93 文章
13419 浏览
1 当前访客
ღゝ◡╹)ノ❤️

Java运行时内存区域如何划分? 置顶!

概述

对 Java 来说其一大特色便是其方便的自动内存管理机制,而这一机制实现的基础依赖于两点:

  1. "垃圾回收算法"
  2. 内存区域划分

其中针对第一点,垃圾回收算法其具体原理前边已经写过一篇文章来对常用的算法以及原理进行了总结(具体参考"垃圾回收算法总结" ),此处不再详述。我们这里着重学习第二点 Java 内存区域的划分。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分成若干不同的数据区域,这些区域有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

具体哪些区域会一直存在?哪些区域会“昙花一现”呢?

我们首先看下 JDK 内存区域划分图(JDK1.6 及其之前):

image.png

JDK1.8 以后,内存布局发生了一些变化:

image.png

从内存布局图中不难看出,线程私有的区域主要有:

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

所有线程共享的空间主要有:

  • 方法区
  • 直接内存(非运行时数据区的一部分)

简单来说,共享的区域会一直存在(区域会一直存在,但区域内的数据会有变化),伴随 Java 程序运行的整个生命周期,而线程私有的空间则会线程的创建而创建,随着线程的结束而消逝。

那某个区域是线程私有还是共有又是依据什么来划分的?

具体要回答好这个问题,我们必须要深入理解 Java 每个区域的功能以及其在程序运行过程中所扮演的角色。

程序计数器

首先,我们先了解一下 程序计数器(Program Counter Register),如果之前有学过《计算机组成原理》那对这个 程序计数器 一定不会陌生,它永远指向下一条指令。在 Java 中程序计数器的含义与之类似但稍有不同,在 Java 虚拟中,程序计数器 里边存放的也是下一条需要执行的字节码指令字节码解释器 在工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

但由于 Java 虚拟机的多线程技术是通过线程轮流切换、分配处理器的执行时间的方式来实现的,在任何确定时刻,一个处理器(单核)只能执行一条线程中的指令,且在时间片结束之后当前线程必然要发生切换,因而为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的 程序计数器,各个线程之间的计数器独立存储,互不干扰。

从上边的描述中,我们可以总结出程序计数器的两个功能:

  1. 字节码计数器通过改变程序计数器来实现程序的流程控制,比如顺序执行、选择、循环和异常处理等。
  2. 在多线程环境下,程序计数器用来记录当前线程的执行位置,以便于进程被切换回来时,可以知道程序的执行位置。

同时,该片内存区域有一个特点,它是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域

这是为什么呢?

因为程序计数器本质上保存的就是下一条需要执行的字节码指令的偏移地址。当执行到下一条指令的时候,改变的只是程序计数器中的地址,并不会申请新的空间,因而不会出现 OutOfMemoryError 的问题。换句话说,由于虚拟机的地址长度是固定的,因而存储偏移地址的程序计数器的空间也是固定的,因而也就不会出现 OutOfMemoryError 的问题。

Java 虚拟机栈

大部分小伙伴在刚开始接触 JVM 内存划分时,总会听到有人说:“Java 内存笼统分为堆内存和栈内存”,虽然实际上 Java 内存的实际划分远比这复杂,但也在一定程度上说明了这两部分中两部分空间的重要性。

其中 后续会专门进行讲述(具体可参考"堆" ),而 通常指的就是 Java虚拟机栈,或者说更多情况下指的是虚拟机栈中 局部变量表 部分(实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)。

说了这么多,那这个 Java虚拟机栈 到底是干嘛用的呢?

简单来说,Java虚拟机栈 描述的是 Java 方法执行的线程内存模型:每个方法被执行时,每次方法调用的数据都是通过栈传递的。虚拟机栈 可用类比数据结构中栈,虚拟机栈 中保存的主要内容是栈帧,每一次函数调用 都会有一个对应的栈帧被压入虚拟机栈 ,每一个函数调用结束后,都会有一个栈帧被弹出。方法的具体执行过程可参考另一篇文章"方法在 JVM 虚拟机中的执行过程"。

此时,可能有伙伴会问了为什么 Java 虚拟机栈也是线程私有的呢?

我们考虑这样一种场景:在多线程场景下,线程 A 在执行方法 a(),线程 B 执行方法 b(),由于 方法a() 执行时间较长,在执行一半的情况下发生了线程切换,因而方法 a() 此时还在虚拟机栈底,方法 b() 开始执行,此时会造成一个问题,a() 方法中的数据此处对线程 B 来说是可见的,甚至是可被篡改的。

因而为了保证各个线程自己私有数据(如私有变量)的数据安全,虚拟机栈必然也是线程私有的

Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

  • StackOverFlowError 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常异常。

本地方法栈

本地方法栈与 Java 虚拟机所发挥的作用非常相似,不同点在于虚拟机栈为虚拟机执行 Java 方法提供服务,而本地方法栈则为虚拟机所使用到的 本地方法(native)提供服务。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

为什么说是几乎呢?

这是因为随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存

Java 堆是垃圾回收器管理的主要区域因而又被称之为 GC堆。从垃圾回收的角度来看,堆内存被分成三部分:新生代老年代永久代。其中新生代详细划分又可以分成 Eden区From区To区,具体可参考"Appel 式回收"。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:

  • 新生代内存
  • 老年代内存
  • 永久代内存

image.png

在 JDK8 之后,方法区被彻底移除了,取而代之的是元空间,元空间使用的是直接内存。

image.png

堆空间在使用时情况如下:

  1. 大部分情况下对象会直接在 Eden 区分配。
  2. 在经过一次新生代垃圾回收之后,如果如果对象还存活会进入 s0 或者 s1 (其实就是 From 区),并且对象的年龄还会加 1
  3. 当年龄增加到到一定程度之后,就会被晋升到老年代中。对象晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
  3. ......

方法区

方法区和 Java 堆一样是各个线程共享的内存区域,它用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然说《Java 虚拟机规范》把方法区描述为堆的一个逻辑部分,但实际上它实际上还有一个别名叫做非堆,目的主要是和 Java 堆区分开来。

方法区有时也被称之为永久代,但两者实际上还是有差别的:

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

前边我们也说了 JDK8 之后已经没有永久代这一概念了,取而代之的是元空间,为什么要这样做呢?

  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

运行时常量池

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

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

在具体应用方法 JDK1.4 新加入了 NIO 类,引入了一种基于通道与缓冲区的 I/O 方法,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

总结

本文主要讲了 Java 运行时内存的区域划分。按照线程私有还是共有分成两类,其中私有部分包含了程序计数器、Java 虚拟机栈、本地方法栈这三部分区域,其他区域为线程共有,并且每个区域的具体作用,文中也有阐述。

参考

  1. Java 内存区域
  2. 《深入理解 JVM 虚拟机》

标题:Java运行时内存区域如何划分?
作者:vcjmhg
地址:https://vcjmhg.top/how-to-devide-java-memory