运行时数据区
《Java虚拟机规范》 :“概念模型”这个词会经常被提及, 它代表了所有虚拟机的统一外观, 但各款具体的Java虚拟机并不 一定要完全照着概念模型的定义来进行设计, 可能会通过一些更高效率的等价方式去实现它。
程序计数器
当前线程所执行的字节码的行号。字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
Java虚拟机栈
描述的是Java方法执行的线程内存模型,每个方法被执行时,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用到执行完毕的过程,就对应这一个栈帧在虚拟机栈的入栈和出栈过程。
局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
Java 虚拟机栈会出现两种错误:StackOverFlowError
和 OutOfMemoryError
。
StackOverFlowError
: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。OutOfMemoryError
: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
HotSpot虚拟机的栈容量是不可以动态扩展的, 以前的Classic虚拟机倒是可以。 所以在HotSpot虚拟机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常——只要线程申请栈空间成功了就不会有OOM, 但是如果申请时就失败, 仍然是会出现OOM异常的。
本地方法栈
本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap) 。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
从回收内存的角度看, 由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词, 这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已, 而非某个Java虚拟机具体实现的固有内存布局, 更不是《Java虚拟机规范》 里对Java堆的进一步细致划分。 不少资料上经常写着类似于“Java虚拟机的堆内存分为新生代、 老年代、 永久代、 Eden、 Survivor……”这样的内容。 在十年之前(以G1收集器的出现为分界) , 作为业界绝对主流的HotSpot虚拟机, 它内部的垃圾收集器全部都基于“经典分代”[3]来设计, 需要新生代、 老年代收集器搭配才能工作, 但现在不可同日而语,也出现了不采用分代设计的垃圾回收器。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
: 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,-Xms
设置初始堆内存,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size (opens new window))
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆) ,目的应该是与 Java 堆区分开来。
说到方法区, 不得不提一下“永久代”这个概念, 尤其是在JDK 8以前, 许多Java程序员都习惯在HotSpot虚拟机上开发、 部署程序, 很多人都更愿意把方法区称呼为“永久代”(Permanent Generation) , 或将两者混为一谈。 本质上这两者并不是等价的, 因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区, 或者说使用永久代来实现方法区而已, 这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存, 省去专门为方法区编写内存管理代码的工作。 但是对于其他虚拟机实现, 譬如BEA JRockit、 IBM J9等来说, 是不存在永久代的概念的。 原则上如何实现方法区属于虚拟机实现细节, 不受《Java虚拟机规范》 管束, 并不要求统一。 但现在回头来看, 当年使用永久代来实现方法区的决定并不是一个好主意, 这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX: MaxPermSize的上限, 即使不设置也有默认大小, 而J9和JRockit只要没有触碰到进程可用内存的上限, 例如32位系统中的4GB限制, 就不会出问题) , 而且有极少数方法(例如String::intern()) 会因永久代的原因而导致不同虚拟机下有不同的表现。 当Oracle收购BEA获得了JRockit的所有权后, 准备把JRockit中的优秀功能, 譬如Java Mission Control管理工具, 移植到HotSpot虚拟机时, 但因为两者对方法区实现的差异而面临诸多困难。
考虑到HotSpot未来的发展, 在JDK 6的时候HotSpot开发团队就有放弃永久代, 逐步改为采用本地内存(Native Memory) 来实现方法区的计划了[1], 到了JDK 7的HotSpot, 已经把原本放在永久代的字符串常量池、 静态变量等移出, 而到了JDK 8, 终于完全废弃了永久代的概念, 改用与JRockit、 J9一样在本地内存中实现的元空间(Metaspace) 来代替, 把JDK 7中永久代还剩余的内容(主要是类型信息) 全部移到元空间中。
//JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
//一些常用参数:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
运行时常量池是方法区的一部分。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 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。