JVM之运行时数据区 Runtime Data Areas
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM之运行时数据区 Runtime Data Areas相关的知识,希望对你有一定的参考价值。
Runtime Data Areas
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5
概述
官方解释
The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits.
在程序的执行过程中,Java虚拟机定义了各种运行时数据区。其中一些运行时数据区创建于Java虚拟机启动,并且仅当虚拟机退出时销毁。而其他则是每个线程独有的,线程独有的数据区在线程创建时创建。线程退出时销毁。
注:
线程共享数据区:堆区Heap、方法区Method Area[堆、堆外内存(永久代或元空间、代码缓存)]
线程独占数据区:Java虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)、程序计数器/PC寄存器(Program Counter Register).
图示
详细图示
PC寄存器
The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own
pc
(program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6) for that thread. If that method is not native
, the pc
register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native
, the value of the Java Virtual Machine’s pc
register is undefined. The Java Virtual Machine’s pc
register is wide enough to hold a returnAddress
or a native pointer on the specific platform.Java虚拟机能够支持多个线程同时运行。每一个Java虚拟机线程都有它自己的PC寄存器。任何时候,一个Java虚拟机线程只会在一个方法代码中执行。这个方法称之为这个线程的当前方法。如果这个方法不是本地方法,则PC寄存器中存储的是当前执行的Java虚拟机指令的地址。如果正在执行的方法时一个本地方法,则在PC寄存器中的地址为undefined。Java虚拟机的PC寄存器有足够大的容量去容纳一个返回地址或者在特殊平台的一个本地指针。
总结:
- PC寄存器是每个线程独有的。
- PC寄存器记录的是当前线程正在执行的Java虚拟机指令的地址(非本地方法)。或者是undefined(本地方法)。
- PC寄存器的生命周期和线程的生命周期一致。
- 任何时间一个线程只有一个方法在执行,称之为
当前方法
- 字节码解释器工作时就是通过改变这计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复基础功能都需要依赖这个计数器来完成。
- 它是《Java虚拟机规范》中唯一一个没有规定任何OutOfMemoryError情况的区域。
PC寄存器为什么要设置为私有的?
-> 我们都知道所谓的多线程在一个特定的时间段内只会执行某一个线程的方法,CPU会不停的切换任务,这样必然导致经常中断和恢复,如何保证分毫不差呢?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的方法就是每个线程都有自己的PC寄存器。这样,各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于 CPU 时间片轮限制,多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU 时间片
CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:俄们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
虚拟机栈
Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames (§2.6). A Java Virtual Machine stack is analogous to the stack of a conventional language such as C: it holds local variables and partial results, and plays a part in method invocation and return. Because the Java Virtual Machine stack is never manipulated directly except to push and pop frames, frames may be heap allocated. The memory for a Java Virtual Machine stack does not need to be contiguous.
每一个虚拟机线程都有一个私有的虚拟机栈。当线程创建的时候而创建。一个Java虚拟机栈存储着多个Java虚拟机栈帧。Java虚拟机栈类似于C中的栈。它有本地变量表(Local variables)和部分结果。是方法调用和返回的一部分。因为Java虚拟机栈出了入栈和出栈以外,不能进行其他操作。Java虚拟机栈内存空间可以是不连续的。
In the First Edition of The Java® Virtual Machine Specification, the Java Virtual Machine stack was known as the Java stack.
This specification permits Java Virtual Machine stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the Java Virtual Machine stacks are of a fixed size, the size of each Java Virtual Machine stack may be chosen independently when that stack is created.
Java虚拟机规范中运行Java虚拟机栈可以是一个固定内存大小,也可以是动态扩展和收缩的。如果Java虚拟机栈是一个固定大小的内存,这个大小是可以在Java虚拟机栈创建时进行选择的。
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of Java Virtual Machine stacks, as well as, in the case of dynamically expanding or contracting Java Virtual Machine stacks, control over the maximum and minimum sizes.
Java虚拟机提供给程序员或用户去控制虚拟机栈的初始化大小,同时,如果是动态扩容或收缩的场景下,对最大最小容量的控制。
The following exceptional conditions are associated with Java Virtual Machine stacks:异常:
- If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a
StackOverflowError
.如果虚拟机栈申请的内存超过了被允许的大小,虚拟机会抛出一个StackOverflowError;
- If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an
OutOfMemoryError
.如果java虚拟机可以动态扩容,并且尝试了扩容但是还是没有足够的内存可以扩容,或者在创建线程没有足够的空间进行栈的初始化,此时Java虚拟机会抛出OutOfMemoryError
总结:
- Java虚拟机栈是每个线程独有的。
- Java虚拟机栈的生命周期和线程一样。
- Java虚拟机栈只能进行栈帧的入栈和出栈,不能进行其他操作。
- Java虚拟机可以是固定大小或者是动态扩展和收缩的。如果是固定大小,则可以进行指定的。
- 固定大小。如果虚拟机栈申请的内存超过了被允许的大小,虚拟机会抛出一个StackOverflowError;
- 动态扩容。在扩容时无法申请到足够内存,或者新创建线程没有足够空间初始化栈,则会抛出OutOfMemoryError。
基本内容
Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack), 每一个线程都会创建一个自己的Java虚拟机栈,其内部是由一个一个的栈帧组成的(Stack Frame),对应着方法的一次一次调用,线程私有的。
一个栈帧的创建意味着一个方法的调用开始,而一个栈帧的销毁意味着一个方法的结束(包括正常结束&异常退出)。
生命周期
和线程的生命周期一样。
作用
Java虚拟机栈主管程序的运行,保存着局部变量和部分返回结果,是方法调用和返回的一部分。
栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器。
JVM直接对Java栈的操作只有两个:
- 每一个方法的执行,伴随着栈帧的入栈
- 执行结束后栈帧的出栈
对于栈来说不存在垃圾回收的情况。(栈存在异常的情况 StackOverflowError)
栈中可能出现的异常
Java 虚拟机规范允许Java 栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个StackOverflowError 异常。
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。
**注意:**但是HotSpot虚拟机的栈内存是不可以动态扩展的,所以在Hotspot虚拟机中不会出现栈的OOM,只会出现StackOverflowError。
public void test()
System.out.println("AAA");
test();
// 上述代码就会造成栈的StackOverflowError
Exception in thread "main" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
我们可以通过-Xss10m
来设置栈的大小为10m;
栈的存储单位
栈中存储的是什么?
每一个线程都有自己的栈,栈中的数据是以栈帧的形式存在的。在这个线程上正在执行的每个方法都有对应自己的栈帧(Stack Frame)。
栈帧(Stack Frame)是一个内存块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈运行原理
JVM直接对java虚拟机栈的操作有两个,一个是入栈,一个是出栈,遵循栈的特点先进后出,后进先出
原则。
在一条活动线程上中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame)
, 与当前栈帧对应的方法称为当前方法(Current Method)
. 定义这个方法的类称为当前类(Current Class)
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果当前方法中调用了其他方法,对应的新的栈帧会被创建出来,然后压入栈顶,称为新的当前栈帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另一个栈帧。
如果当前方法调用了其他方法,在方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机栈会丢弃当前栈帧,使得前一个栈帧称为新的当前栈帧。
每一个方法从调用开始到执行结束的过程,都对应着一个栈帧的入栈和出栈的过程。
Java方法返回方式
Java方法有两种返回函数的方式,一种是正常的方法执行结束返回,使用return指令,另一种就是出现异常。不管是哪种方式,都会导致栈帧被弹出。
栈帧的内部结构
每个栈帧都存储着下列信息:
- 局部变量表(Local Variables [Table])
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 附加信息
图示:
并行情况下,每一个线程的栈都是私有的,一次每一线程都有各自的栈,并且每个栈中都有很多栈帧。
在编译Java源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来了,并且写入到方法表的code属性之中,换言之,一个栈帧需要分配多少内存,并不会收到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
局部变量表(LV)
局部变量表【Local Variables】也称为局部变量数组或本地变量表。
- 定义为一个数字数组。主要用于存储方法参数和定义在方法体内的局部变量.这些数据类型包括各种基本数据类型、对象引用(reference),以及returnAddress类型。
- 由于局部变量表是建立在线程的虚拟机栈上,是线程私有的,所以不存在数据安全的问题。
- 局部变量表所需的容量在编译期间就确定下来了。并保存在方法表的code属性的maximum local variables数据项中。在方法运行期间不会改变局部变量表的大小。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更大的栈空间,使得嵌套调用的次数减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递。当方法调用结束后,随着方法的栈帧的销毁,局部变量表也会随之销毁。
关于slot的理解
- 局部变量表,最基础的存储单元Slot(变量槽)
- 参数值的存放总是在局部变量数组index0开始,到数组长度-1的索引结束。
- 局部变量表中存放编译期可知的各种基础数据(8种),引用数据(reference),returnAddress类型的变量。
- 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占两个slot。
- byte,short,char在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
- JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表的每一个slot上。
- 如果需要访问局部变量表中的一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long和double类型的变量)。
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象的this引用将会存放在局部变量表下标为0的位置上。其余参数按照顺序依次存放。
Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量的作用域已经结束,那么在其作用域外之后声明的变量就很有可能会重复利用其过期的槽位。从而达到节省资源的目的。
public class SlotReuseTest
public static void main(String[] args)
int a = 1;
int b = 2;
上面代码int类型的b会重用a的槽位,因为在b声明的地方,a的作用域已经结束了。
静态变量和局部变量的对比
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次在“初始化阶段”,为对象设置显示初始化值。
和类变量不同的是,局部变量表不存在系统初始化过程,这意味着一旦定义了局部变量则必须手动初始化,否则无法使用。
上述图片可以看到,编译期即出错。
补充说明
在栈帧中。与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点(GC Roots),只要被局部变量表中直接或间接引用的对象都不会被回收。
==>【可达性分析,以GC Roots根节点,GC Roots直接可达或间接可达的对象都不是垃圾】。
操作数栈(OS)
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last In First Out)的操作数栈,也称之为表达式栈(Expression Stack)。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(PUSH)或出栈(POP);
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后将其结果压入栈。
- 比如:执行复制,交换,求和等操作。
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是 JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之创建出来,这个方法的操作数栈是空的。
每一个操作数栈都有一个明确的栈深度由于存储数值,其所需的最大深度在编译期间就已经确定好了,保存在放的code的属性中,为 maximum stack size的值。
栈中的任何一个元素都是可以任意的Java数据类型。
- 32bit的类型占用一个栈单位的深度。
- 64bit的类型占用两个栈单位的深度。
操作数栈并非采用索引的方式进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会压入到当前栈帧的操作数栈顶中,并更新PC寄存器下一条需要执行的指令。
操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,这里的栈指的就是操作数栈。
动态链接(DL)
动态链接(Dynamic Linking),方法返回地址,附加信息:有些地方称之为帧数据区。
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking). 比如:invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。如果:描述一个方法调用了另外的其他方法时,就是通过常量池中执行方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
为什么需要运行时常量池呢?
常量池的作用:就是为了提供一些符号和常量,便于指令识别。
方法的调用:解析与分配
在JVM中,将符号引用转换为调用方的直接引用与方法的绑定机制相关。
静态链接
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转为直接引用。这种情况下将调用方法的符号引用转为直接引用的过程称之为静态链接
动态链接
如果被调用的方法在编译器无法被确定下来,只能够在程序运行期将调用的方法的符号引用转为直接引用,由于这种引用转换过程具有动态性,因此称之为动态链接。
静态链接和动态链接不是名词,而是动词,这是理解的关键。
对应的方法的绑定机制为:早期绑定(Early Binding) 和 晚期绑定(Late Binding). 绑定是一个字段、方法和类在符号引用被替换为直接引用的过程。这仅发生一次。
早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转为直接引用。
晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法和非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称之为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称之为虚方法。
普通调用指令
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
动态调用指令
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而 invokedynamic 指令则支持由用户确定方法版本。其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(fina1 修饰的除外)称为虚方法。
关于 invokedynamic 指令
- JVM 字节码指令集一直比较稳定,一直到 Java7 中才增加了一个 invokedynamic 指令,这是Java 为了实现「动态类型语言」支持而做的一种改进。
- 但是在 Java7 中并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令。直到 Java8 的 Lambda 表达式的出现,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式。
- Java7 中增加的动态语言类型支持的本质是对 Java 虚拟机规范的修改,而不是对 Java 语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在 Java 平台的动态语言的编译器。
动态类型语言和静态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
方法重写的本质
Java 语言中方法重写的本质:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.1ang.AbstractMethodsrror 异常。
IllegalAccessError 介绍
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
方法返回地址RA
存放调用该方法的PC寄存器的值。一个方法的结束,有两种情况:
- 正常执行退出。即return指令。
- 出现未处理的异常,非正常退出。
无论是哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令地址。而通过异常退出时,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令(return).会有返回值传递给上层的方法调用者。简称正常调用完成。
- 一个方法在正常调用完成之后,究竟需要使用哪一个退出指令,还需要根据方法的返回值的实际类型而定。
- 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short,int类型时使用)。lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个return指令表名为void的方法,实例初始化方法,类和接口的初始化方法使用。
- 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有找到对应的异常处理器,就会导致方法的退出,简称异常调用完成。
代码:
public static void main(String[] args)
try
int i = 0;
int a = 2 / i;
catch (Exception e)
e.printStackTrace();
方法执行过程中,抛出异常的异常处理,存储在一个异常处理表中,方便在发生异常的时候找到对应处理异常的代码。
本质上,方法的退出就是当前栈帧的出栈的过程。此时,需要恢复上层方法的局部变量表,操作数栈,将返回值压入到调用者栈帧的操作数栈、设置PC寄存器值等,让方法调用者能正常执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不是给他的上层调用者产生任何的返回值。
附加信息
《java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧中。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。而本地方法栈则是为虚拟机是用到的本地方法服务。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryErrory异常。
在Hotspot虚拟机中,直接将本地方法栈和虚拟机栈合二为一。
堆
概述
The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads.
The heap is the run-time data area from which memory for all class instances and arrays is allocated.
Java虚拟机的堆区被所有Java线程所共享。堆是运行时数据区用来分配所有的类实例和数组的。
The heap is created on virtual machine start-up. Heap storage for objects is reclaimed by an automatic storage management system (known as a garbage collector); objects are never explicitly deallocated.
堆在虚拟机启动时创建,堆存储的对象通过自动的存储管理系统来进行释放内存。(如垃圾收集器)。对象不需要明确的回收,即不需要手动回收。
The Java Virtual Machine assumes no particular type of automatic storage management system, and the storage management technique may be chosen according to the implementor’s system requirements. The heap may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger heap becomes unnecessary. The memory for the heap does not need to be contiguous.
Java虚拟机不采用特定的某种自动存储管理系统,而取决于其实现系统选择的存储技术。堆可以是一个固定大小的,也可以是一个可以动态扩容和收缩的。堆内存不需要是连续的一块空间。
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the heap, as well as, if the heap can be dynamically expanded or contracted, control over the maximum and minimum heap size.
一个java虚拟机实现可以提供给程序员或者用户自己控制堆区的初始化大小。同时,如果堆区支持动态扩容和压缩,也可以进行最大和最小的堆区设置。
The following exceptional condition is associated with the heap:
- If a computation requires more heap than can be made available by the automatic storage management system, the Java Virtual Machine throws an
OutOfMemoryError
.堆内存相关的异常:
当计算所需要的堆区大小比自动内存管理系统能够提供的要大,Java虚拟机则会抛出OOM。
总结:
- 堆是线程共享的区域。用来分配所有的对象实例和数组。
The heap is the run-time data area from which memory for all class instances and arrays is allocated.
- 堆在虚拟机启动时创建。无需手动进行内存释放。方法结束后,堆中的对象不会马上被移除,需要垃圾收集的时候才会回收移除。
- 如果是固定大小的堆,则可以通过设置其初始化大小,如果可动态扩展和压缩的,则可以设置其最大和最小的大小。
- 通过
-Xms20m
来设置堆区的最小内存为20m,-Xmx20m
设置堆最大内存为20m,-Xmn10m
设置新生代内存大小为10m。 - 通过
-XX:SurvivorRatio=8
来设置Eden区:s0:s1=8:1:1; - 通过
-XX:NewRatio=2
来设置新生代和老年代的比例。新生代为1,老年代占2;
- 当对象所需堆区不够时,虚拟机会抛出OOM。
- 堆区可以是不连续的区域。【堆可以在物理上不是连续的,但是逻辑上应该视为其连续的】。
- 数组和对象不会存储在栈中,因为栈帧保存引用。这个引用指向对象或数组在堆中的位置。【Hotspot虚拟机启用了逃逸分析,但是不会进行栈上分配,只会进行标量替换】
- 堆是GC(Garbage Collection 垃圾收集器)执行垃圾收集回收的重点区域。
- 如果从分配内存的角度看,所有的线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer),以提高对象分配的效率。
栈堆方法区的关系图
堆内存细分
Java7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代
- Young Generation Space 新生代。新生代又分为 Eden区和Survivor区。
- Tenure Generation Space 老年代。Old/Tenure.
- Permanent Space 永久代。
Java8及之后的堆内存逻辑上分为三部分:新生代+老年代+元空间
- Young Generation Space 新生代。同样分为Eden区和Survivor区。
- Tenure Generation Space 老年代。Old/Tenure.
- Meta Space. 元空间
设置堆内存大小与OOM
堆空间大小设置
Java堆区用于存储对象实例和数组。堆的大小在JVM启动的时候就设置好了。可以通过-Xms
和-Xmx
来设置。
-
-Xms
:用于设置堆的初始内存。等价于-XX:InitialHeapSize
; -
-Xmx
:用于设置堆的最大内存。等价于-XX:MaxHeapSize
;
一旦堆区中的内存大小超过-Xmx
所指定的内存,将会抛出OOM[OutOfMemoryError]异常。
通常会将-Xms
和-Xmx
两个参数设置一样的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小。从而提高性能。
默认情况下
- 初始内存大小:电脑物理内存大小/64;
- 最大内存大小:电脑物理内存大小/4;
long totalMemory = Runtime.getRuntime().totalMemory(); //初始内存
long maxMemory = Runtime.getRuntime().maxMemory(); //最大内存
System.out.println(totalMemory / 1024.0 / 1024 / 1024 + "G"); // 0.23779296875G
System.out.println(maxMemory / 1024.0 / 1024 / 1024 + "G");// 3.51220703125G
OutOfMemoryError 举例
-Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
-Xms20m -Xmx20m 设置堆的初始化内存和最大内存都为10M
-XX:SurvivorRatio=8 设置Eden:Survivor0:Survivor1=8:1:1
public class HeapOutOfMemory
// -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
public static void main(String[] args) throws InterruptedException
byte[] bytes = new byte[1024 * 1024 * 10];//10M
TimeUnit.SECONDS.sleep(1000000);
运行上述代码:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.example.demo.jvm.heap.HeapOutOfMemory.main(HeapOutOfMemory.java:8)
新生代与老年代
存储在JVM的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常的迅速。
- 另一类对象的生命周期却非常长,在某些极端情况下还能够与JVM生命周期保持一致。
Java堆区进一步划分的话,可以分为新生代(YoungGen)和老年代(OldGen);
其中新生代又分为 Eden区、Survivor0 和 Survivor1区。有时也称之为 From区 和 To区。
配置新生代和老年代占比
- 默认
-XX:NewRatio=2
,表示新生代占1,老年代占2,新生代占堆区的1/3; - 可以修改
-XX:NewRatio=4
,表示新生代占1,老年代占4,新生代占堆区的1/5;
在HotSpot中,Eden空间和两个Survivor区的比例是 8:1:1; 可以使用-XX:SurvivorRatio=5
来进行手动设置。
==几乎所有的Java对象都是在Eden区被new出来的。==绝大部分的Java对象的销毁都在新生代进行了。
IBM公司的专门研究表明:新生代的80%的对象都是朝生夕死的。
可以使用-Xmn
来设置新生代最大内存大小,这个参数一般是使用默认值即可。
图解对象分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题。并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new的对象先放在Eden区。此区有大小限制。
- 当Eden区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对新生代进行垃圾回收(MinorGC/YoungGC).将伊甸园区Eden的不再被其他对象所引用的对象进行销毁。再加载新的对象到伊甸园区。
- 然后将伊甸园区的剩余对象移动到幸存者0区。对象年龄计数器加1。【存储在对象头中】
- 如果再次触发垃圾回收,此时上次幸存下来的放到了幸存者0区,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回到幸存者0区,接着再去幸存者1区。
- 啥时候能去老年区呢?可以设置次数。默认是15次。
- 可以通过参数:
-XX:MaxTenuringThreshold=N
来进行设置。
- 在老年区相对安全。当老年代区域内存不足时,再次触发GC[MajorGC],对养老区进行内存清理。【FullGC。对整堆进行清理】
- 若养老区执行了MajorGC之后,发现依然无法进行对象内存分配,则会抛出OOM异常。【java.lang.OutOfMemoryError: Java Heap Space】
对象分配和晋升流程图
总结:
- 针对幸存者s0.s1区的总结:复制之后有交换,谁空谁是To。
- 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不在永久代和方法区收集。
Minor GC,MajorGC、Full GC
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。
很对Hotspot虚拟机的实现,他里面的GC按照回收区域又分为两种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC).
- 部分收集:不是完整的收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(MinorGC/ YoungGC):只是新生代的垃圾收集。
- 老年代收集(MajorGC/OldGC):只是老年代的垃圾收集。
- 目前,只有CMS会有单独的老年代收集行为。
- 注意,很多时候,MajorGC和FullGC都是混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合回收(MixedGC):收集整个新生代部分老年代的垃圾收集。
- 目前,只有G1会有这种行为。【region】
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
最简单的分代式GC策略的触发条件
年轻代GC(MinorGC)的触发机制
- 当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden区满,Survivor区满不会触发GC。(每次MinorGC会清理年轻代的内存)。
- 因为Java对象大多都具备朝生夕死的特点,所以MinorGC非常频繁,一般回收速度也比较快。
- MinorGC会引发STW,暂停其他用户线程。等垃圾回收结束,用户线程才会恢复运行。
老年代GC(MajorGC/FullGC)触发机制
- 指发生在老年代的GC,对象从老年代消失时,我们说“MajorGC”或”FullGC“发生了。
- 出现了MajorGC,经常会伴随着至少一次“MinorGC”(但非绝对,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发MinorGC,如果之后空间还不足,则会触发MajorGC。
- MajorGC的速度一般比MinorGC慢10倍以上,STW的时间更长。
- 如果MajorGC后,内存还是不足,则会报OOM。
FullGC触发机制
触发Full GC执行的情况有如下五种:
- 调用了System.gc(); 系统建议执行FullGC,但不是必然执行。
- 老年代空间不足
- 方法区空间不足
- 通过MinorGC后进入老年代的平均大小大于老年代的可用内存。
- 由Eden区、S0区向S1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代可用内存小于该对象大小。
说明:FullGC是开发或调优中尽量避免的。这样STW世家你会短一些。
堆空间分代思想
为什么要把Java堆分代?不分代就不能正常工作吗?
经研究:不同对象的生命周期是不同的。70%-99%的对象是临时对象。
- 新生代:由Eden,两个大小相同的survivor(又称From/To,S0/S1)构成,to总是空。
- 老年代:存放新生代中经历多次GC仍然存活的对象。
其实不分代完全可以,分代的唯一理由就是优化GC的性能。如果没有分代,那么所有的对象都在一块。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块对象的“朝生夕死”对象的区域进行回收。这样就会腾出比较大的空间。
内存分配策略
如果对象在Eden出生并经过第一次MinorGC存活下来,并且能被Survivor容纳的话,将被移动到Survivor区,并将对象的年龄设置为1. 对象在Survivor区中没熬过一次MinorGC,年龄就会增加1,当他的年代到达一定程度(默认为15岁,其实每个JVM,每个GC有所不同)时。就会被晋升到老年代。
对象的晋升老年代年龄阈值。可以通过-XX:MaxTenuringThreshlod
来设置。
针对不同年龄段的对象分配原则如下:
- 优先分配到Eden。
- 大对象直接分配到老年代。(尽量避免程序中出现过多的大对象)
- 长期存活的对象分配到老年代。
- 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和超过了Survivor区的一半。年龄大于或等于该年龄的对象将会直接晋升到老年代。无需等到
-XX:MaxTenuringThreshlod
要求达到的年龄值。 - 空间分配担保:
-XX:HandlePromotionFailure
.
为对象分配内存TLAB
为什么有TLAB(Thread Local Allocation Buffer)?
- 堆区是线程共享区域,任何线程都可以访问到堆区的共享数据。
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB?
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间中。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全的问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
TLAB 的再说明
- 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM 确实是将 TLAB 作为内存分配的首选。
- 在程序中,开发人员可以通过选项“
-XX:UseTLAB
”设置是否开启 TLAB 空间。 - 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “
-XX:TLABWasteTargetPercent
” 设置 TLAB 空间所占用 Eden 空间的百分比大小。 - 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
堆空间的参数设置
官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html
-XX:+PrintFlagsInitial 查看所有的参数的默认初始值。
-XX:+PrintFlagsFinal 查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms: 堆区的初始化值。(默认为物理内存的1/64)
-Xmx: 堆区的最大值。(默认为物理内存的1/4)
-Xmn: 新生代大小。(初始值及最大值)
-XX:NewRatio=2 设置新生代和老年代的比例。2表示新生代:老年代=1:2;新生代占堆区的1/3;
-XX:SurvivorRatio=8 设置Eden区与Survivor区的比例。8表示Eden:S0:S1=8:1:1;
-XX:MaxTenuringThreshold=15 设置对象晋升成老年代的年龄阈值。默认是15;
-XX:+PrintGCDetails 打印GC的详细信息。 打印GC简要信息:1. -XX:+PrintGC. 2.、-verbose:gc
-XX:HandlePromotionFailure 是否开启空间担保策略。
-XX:HandlePromotionFailure
在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
- 如果大于,则此次MinorGC是安全的。
- 如果小于,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败。
- 如果
HandlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次MinorGC,但这次MinorGC依然是有风险的。
- 如果小于,则改为进行一次FullGC。
- 如果
HandlePromotionFailure=false
,则改为进行一次FullGC;
在 JDK6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 openJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。
JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 FullGC。
堆是分配对象的唯一选择么?
在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:
<以上是关于JVM之运行时数据区 Runtime Data Areas的主要内容,如果未能解决你的问题,请参考以下文章
JVM系列之运行时数据区(Run-Time Data Areas)