对 Java 来说其一大特色便是其方便的自动内存管理机制,而这一机制实现的基础依赖于两点:
我们这里着重学习第二点 Java 内存区域的划分。我之前的博客有说垃圾回收算法的文章,有兴趣的朋友可以去我的主页看看!
运行时数据区通常包括这5个部分:程序计数器(Program Counter Register)、Java栈(VM Stack)、本地方法栈(Native Method Stack)、方法区(Method Area)、堆(Heap)。
它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址,一旦该指令被执行,pc寄存器会被更新至下条指令的地址)
,当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息
。存储方法中的局部变量(包括在方法中声明的非静态变量)、函数形参。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用
。Java中的堆是用来存储对象本身,它们相应的成员变量,以及数组(当然,数组引用是存放在Java栈中的)
。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆。-Xms
:初始堆大小即最小内存值-Xmx
:堆最大可用大小-Xmn
:新生代空间的大小-XXSurvivorRatio
:定义了新生代中Eden区域和Survivor区域(From幸存区或To幸存区)的比例,默认为8,也就是说Eden占新生代的8/10,From幸存区和To幸存区各占新生代的1/10。计算公式: Eden = (R*Y)/(R+1+1) From = Y/(R+1+1) To = Y/(R+1+1) R:SurvivorRatio比例 Y:新生代空间大小 复制代码
存储了所有类级别数据(包括类的名称等)、静态变量、静态方法、常量、编译器编译后的代码、运行时常量池等
。-XX:PermSize
:永久代的初始大小。-XX:MaxPermSize
:永久代的最大空间。-XX:MetaspaceSize
:初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。-XX:MaxMetaspaceSize
:最大空间,默认是没有限制的。-XX:MinMetaspaceFreeRatio
:在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。-XX:MaxMetaspaceFreeRatio
:在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。作者:longyuan5
链接:https://juejin.cn/post/6945370778657521701
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分成若干不同的数据区域,这些区域有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则依赖用户线程的启动和结束而建立和销毁。【领取资料】
我们首先看下 JDK 内存区域划分图(JDK1.6 及其之前):
JDK1.8 以后,内存布局发生了一些变化:
从内存布局图中不难看出,线程私有的区域主要有:
简单来说,共享的区域会一直存在(区域会一直存在,但区域内的数据会有变化),伴随 Java 程序运行的整个生命周期,而线程私有的空间则会线程的创建而创建,随着线程的结束而消逝。【领取资料】
具体要回答好这个问题,我们必须要深入理解 Java 每个区域的功能以及其在程序运行过程中所扮演的角色。
首先,我们先了解一下 程序计数器 (Program Counter Register),如果之前有学过《计算机组成原理》那对这个 程序计数器 一定不会陌生,它永远指向下一条指令。在 Java 中程序计数器的含义与之类似但稍有不同,在 Java 虚拟中, 程序计数器 里边存放的也是下一条需要执行的 字节码指令 , 字节码解释器 在工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
但由于 Java 虚拟机的多线程技术是通过线程轮流切换、分配处理器的执行时间的方式来实现的,在 任何确定时刻 ,一个处理器(单核)只能执行一条线程中的指令,且在时间片结束之后当前线程必然要发生切换,因而 为了线程切换后能够恢复到正确的执行位置 ,每条线程都需要一个独立的 程序计数器 ,各个线程之间的计数器独立存储,互不干扰。
从上边的描述中,我们可以总结出程序计数器的两个功能:
同时,该片内存区域有一个特点, 它是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError
情况的区域 。
因为程序计数器本质上保存的就是下一条需要执行的字节码指令的 偏移地址 。当执行到下一条指令的时候,改变的只是程序计数器中的地址,并不会 申请新的空间 ,因而不会出现 OutOfMemoryError
的问题。换句话说,由于虚拟机的地址长度是固定的,因而存储偏移地址的程序计数器的空间也是固定的,因而也就不会出现 OutOfMemoryError
的问题。
大部分小伙伴在刚开始接触 JVM 内存划分时,总会听到有人说:“Java 内存笼统分为堆内存和栈内存”,虽然实际上 Java 内存的实际划分远比这复杂,但也在一定程度上说明了 堆 和 栈 这两部分中两部分空间的重要性。
其中 堆 后续会专门进行讲述(具体可参考"堆" ),而 栈 通常指的就是 Java虚拟机栈 ,或者说更多情况下指的是虚拟机栈中 局部变量表 部分(实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)。
简单来说, Java虚拟机栈 描述的是 Java 方法执行的 线程内存模型 :每个方法被执行时,每次方法调用的数据都是通过栈传递的。 虚拟机栈 可用类比数据结构中栈, 虚拟机栈 中保存的主要内容是栈帧,每一次 函数调用 都会有一个对应的栈帧被压入 虚拟机栈 ,每一个函数调用结束后,都会有一个栈帧被弹出。方法的具体执行过程可参考另一篇文章"方法在 JVM 虚拟机中的执行过程"。
此时,可能有伙伴会问了为什么 Java 虚拟机栈也是线程私有的呢?
我们考虑这样一种场景:在多线程场景下,线程 A 在执行方法 a() ,线程 B 执行方法 b() ,由于 方法a() 执行时间较长,在执行一半的情况下发生了线程切换,因而方法 a() 此时还在虚拟机栈底,方法 b() 开始执行,此时会造成一个问题, a() 方法中的数据此处对线程 B 来说是可见的,甚至是可被篡改的。
因而为了保证各个线程自己私有数据(如私有变量)的数据安全,虚 拟机栈必然也是线程私有的 。
Java 虚拟机栈会出现两种错误: StackOverFlowError
和 OutOfMemoryError
。
本地方法栈与 Java 虚拟机所发挥的作用非常相似,不同点在于虚拟机栈为虚拟机执行 Java 方法提供服务,而本地方法栈则为虚拟机所使用到的 本地方法 (native)提供服务。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
这是因为随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在 栈上分配内存 。
Java 堆是垃圾回收器管理的主要区域因而又被称之为 GC堆 。从垃圾回收的角度来看,堆内存被分成三部分: 新生代 、 老年代 、 永久代 。其中新生代详细划分又可以分成 Eden区 、 From区 和 To区 ,具体可参考"Appel 式回收"。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:
在 JDK8之后,方法区被彻底移除了,取而代之的是元空间,元空间使用的是直接内存。
堆空间在使用时情况如下:
大部分情况下对象会直接在 Eden 区分配。
在经过 一次新生代垃圾回收 之后,如果如果对象还存活会进入 s0 或者 s1 (其实就是 From 区),并且对象的年龄还会加 1
当年龄增加到到一定程度之后,就会被晋升到老年代中。对象晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
…
方法区和 Java 堆一样是各个线程共享的内存区域,它用于存储被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存 等数据。虽然说《Java 虚拟机规范》把方法区描述为 堆的一个逻辑部分 ,但实际上它实际上还有一个别名叫做非堆,目的主要是和 Java 堆区分开来。
方法区有时也被称之为 永久代 ,但两者实际上还是有差别的:
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说, 永久代是 HotSpot 的概念 ,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西 , 合并之后就没有必要额外的设置这么一个永久代的地方了。
运行时常量池
运行时常量池 是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有 常量池表(用于存放编译期生成的各种字面量和符号引用) ,这部分内容在类加载之后存放到方法区的运行时常量池中。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
在具体应用方法 JDK1.4 新加入了 NIO 类,引入了一种基于通道与缓冲区的 I/O 方法,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为 避免了在 Java 堆和 Native 堆之间来回复制数据 。
本文主要讲了 Java 运行时内存的区域划分。按照线程私有还是共有分成两类,其中私有部分包含了程序计数器、Java 虚拟机栈、本地方法栈这三部分区域,其他区域为线程共有,并且每个区域的具体作用,文中也有阐述。