以局部窥全局,这个问题其实很复杂,要弄清楚这个问题,首先要对JVM运行时数据区域划分以及各个数据区域的作用了和指掌。
JVM在执行Java程序的过程中(简称运行时)会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
从图中可以看到,线程共享的区域是方法区和堆,线程隔离(线程私有)的区域是虚拟机栈、本地方法栈和程序计数器。
简单解释下线程共享和线程私有是啥意思:
下面我们来分别解释下这几个数据区域。
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间的计数器互不影响。
虚拟机栈其实是由一个一个的栈帧(Stack Frame)组成的,一个栈帧描述的就是一个 Java方法执行的内存模型。也就是说每个方法在执行的同时都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
其中,局部变量表存放了以下三种类型的数据:
这些数据类型在局部变量表中的存储空间以 局部变量槽 (Slot) 来表示,或者说局部变量表的基本存储单元是Slot,JVM 为每一个Slot 都分配了一个访问索引,通过这个索引就可以成功访问到局部变量表中存储的某个值。
在《Java虚拟机规范》中,对虚拟机栈这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(栈溢出)
- 如果使用的JVM支持动态扩展虚拟机栈容量的话,当栈扩展时无法申请到足够的内存就会抛出OutOfMemoryError 异常(内存溢出)
本地方法栈和上面我们所说的虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的Native方法服务,而虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。
这里解释一下Native方法的概念,其实不仅Java,很多语言中都有这个概念。
“A native method is a Java method whose implementation is provided by non-java code.”
就是说一个Native方法其实就是一个接口,但是它的具体实现是在外部由非Java语言比如C或C++等来写的。Java通过JNI来调用本地方法, 而本地方法是以库文件的形式存放的(在Windows平台上是动态链接库——DLL文件形式存在)。
所以同一个Native方法,如果用不同的虚拟机去调用它,那么得到的结果和运行效率可能是不一样的,因为不同的虚拟机对于某个Native方法都有自己的实现,比如Object类的hashcode方法。
为什么需要 Native 方法呢?
其主要原因就是Java虽然使用起来很方便,但是有些层次的任务用Java实现起来不容易,或者对程序的效率有比较高的要求时,Java语言可能并不是最好的选择。所以Native方法使得Java程序能够超越Java运行时的界限,有效地扩充了JVM。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常
Java 堆是虚拟机所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作GC堆(Garbage Collected Heap)。
对于堆这个概念小伙伴们肯定还听说过各种诸如新生代、老年代、永久代、Eden空间、From Survivor空间、To Survivor空间等名词,需要注意的是,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,只是为了通过这种分代设计来更好地回收内存,或者更快地分配内存,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对 Java堆的进一步细致划分
根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java堆既可以被实现成固定大小的,也可以是可扩展的,当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx
和-Xms
设定)
如果在堆中没有内存来完成对象实例的分配,并且堆也无法再扩展时,JVM就会抛出OutOfMemoryError异常
方法区通俗点理解就是,在虚拟机完成类加载之后,存储这个类相关的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and instance initialization and interface initialization. 它存储每个类的结构,如运行时的常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法。
方法区其实本身很好理解,但是《深入理解Java虚拟机》提到的一句话:方法区是堆的一个逻辑部分,真的是让我困惑了很长时间。
下面我来结合我的理解给大家解释下,我觉得这个 “方法区是堆的一个逻辑部分” 应该适用于 JDK 8以前,而不适用JDK 8以及之后。
先来看JDK 8之前:
可以看到,JDK 8之前,堆和方法区其实是连在一起的,或者说,方法区就是堆的一部分。
但是呢,方法区存储的东西又有些特别,在过去自定义类加载器使用不普遍的时候,类几乎是“静态的” 并且很少被卸载和回收,因此类也可以被看成 “永久的”(这也就是永久代的含义),另外由于类作为JVM实现的一部分,它们不由程序来创建,所以为了和堆区分开来呢,就给了 “方法区” 这样一个名字用来存储类的信息。在JDK 8之前,方法区的具体实现方法是永久代,永久代是HotSpot虚拟机给出的实现,但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。
永久代是一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize
的值来控制永久代的大小,32位机器默认的永久代的大小为64M,64位的机器则为85M。
永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收。
显然这种设计并不是一个好的主意,由于我们可以通过‑XX:MaxPermSize
设置永久代的大小,一旦类的元数据超过了设定的大小,程序就会耗尽内存,并出现内存溢出错误 (java.lang.OutOfMemoryError: PermGen space
)。
而且有极少数的方法(例如String
的intern()
方法可以在运行过程中手动的将字符串添加到 字符串常量池中,在JDK1.7之前的HotSpot虚拟机中,字符串常量池被存储在永久代中)会因永久代的原因而导致不同虚拟机下有不同的表现
所以我们总结下HotSpots在JDK 8抛弃永久代,转而用元空间来实现方法区的两大原因:
String
的intern()
方法会因永久代的原因而导致不同虚拟机下有不同的表现,不利于代码迁移那么元空间到底是个啥,和方法区有啥区别?
元空间与永久代之间最大的区别在于:元空间不再与堆连续,并且是存在于本地内存(Native memory)中的。
运行时数据区域的对比如下图:
- 元空间存在于本地内存,意味着只要本地内存足够,它就不会OOM,不会出现像永久代中的java.lang.OutOfMemoryError: PermGenspace,理论上元空间的大小取决于32位或64位操作系统的可用虚拟内存大小
- 堆外内存不受GC控制,无法通过GC释放内存,那该以什么样的形式释放呢,可以参见这篇文章:堆外内存的回收机制分析。