JVM内存模型
虚拟机栈
虚拟机栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用。
虚拟机栈的作用:主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。每个方法被执行的时候都会创建一个栈帧,用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈帧(Stack Frame):是用于虚拟机执行时方法调用和方法执行时的数据结构,它是虚拟栈的基本元素,栈帧由局部变量区、操作数栈等组成每一个方法从调用到方法返回都对应着一个栈帧入栈出栈的过程。最顶部的栈帧称为当前栈帧,栈帧所关联的方法称为当前方法,定义这个方法的类称为当前类,该线程中,虚拟机有且也只会对当前栈帧进行操作。
栈帧的作用:有存储数据,部分过程结果,处理动态链接,方法返回值和异常分派。
每一个栈帧包含的内容有局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译代码时,栈帧需要多大的局部变量表,多深的操作数栈都可以完全确定的,并写入到方法表的code属性中。
堆(Heap)
堆是 java 虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,该内存区域存放了对象实例及数组(但不是所有的对象实例都在堆中), 在 Java 中,堆被划分成两个不同的区域:新生代、老年代,新生代 ( Young ) 又被划分为三个区域:Eden、S0、S1(SurvivorFrom、SurvivorTo)。三个区域的比例为 8 : 1 : 1。
堆中不存放基本类型和对象引用,只存放对象本身(也就是值),类的成员变量在不同对象中各不相同,都有自己的存储空间(成员变量在堆中的对象中),基本类型和引用类型的成员变量都在这个对象的空间中,作为一个整体存储在堆。
新生代
主要是用来存放新生的对象。一般占据堆内存的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC
进行垃圾回收。
-
Eden区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代),当Eden区内存不够的时候就会触发
MinorGC
,对新生代区进行一次垃圾回收。 -
SurvivorFrom区:上一次GC的幸存者,作为这一次GC的被扫描者。
-
SurvivorTo区:保留了一次
MinorGC
过程中的幸存者。
S0,S1 区复制之后发生交换,谁是空的,谁就是 SurvivorTo
区。
JVM 每次只会使用Eden区和其中一块Survivor
来为对象服务,所以无论什么时候,都会有一块Survivor
是空的,因此新生代实际可用空间只有90%,当 JVM 无法为新建对象分配内存空间的时候 (Eden 满了),Minor GC 被触发。因此新生代空间占用率越高,Minor GC
越频繁。
MinorGC的过程(采用复制算法):
首先,把
Eden
和ServivorFrom
区域中存活的对象复制到ServicorTo
区域(如果有对象的年龄以及达到了老年的标准,一般是 15,则赋值到老年代区)同时把这些对象的年龄 + 1(如果ServicorTo
不够位置了就放到老年区)然后清空Eden
和ServicorFrom
中的对象;最后,ServicorTo
和ServicorFrom
互换,原ServicorTo
成为下一次 GC 时的ServicorFrom
区。Minor GC 触发机制:
当年轻代满(指的是
Eden
满,Survivor
满不会引发 GC)时就会触发Minor GC
(通过复制算法回收垃圾)。对象年龄(Age)计数器:
虚拟机给每个对象定义了一个对象年龄(Age)计数器。
如果对象在
Eden
出生并经过第一次Minor GC
后仍然存活,并且能被Survivor
容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在
Survivor
区中每熬过一次Minor GC
,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
(阈值) 来设置。
老年代
老年代的对象比较稳定,所以MajorGC
不会频繁执行。在进 MajorGC
前一般都先进行了一次MinorGC
,使得有新生代的对象晋身入老年代,导致空间不够用时才触发,当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC
进行垃圾回收腾出空间。
MajorGC 采用标记清除算法:
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。
MajorGC
的耗时比较长(速度一般会比Minor GC
慢10倍以上,STW 的时间更长),因为要扫描再回收。MajorGC
会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出
OOM(Out of Memory)
异常。
方法区(Method Area)
方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息
、域信息
、常量池
(字符串常量池以及所有基本类型都有其相应的常量池)、运行时常量池
。这其中,类信息又包括了类的版本、字段、方法、接口和父类等信息。
类信息
JVM 在执行某个类的时候,必须经过加载、连接、初始化,而连接
又包括验证、准备、解析三个阶段。在加载类的时候,JVM 会先加载 Class 文件,而在 Class 文件中便有类的版本、字段、方法和接口等描述信息,这就是类信息
。
类信息包含类 Class、接口Interface、枚举Enum、注解Annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)。
- 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.0bject,都没有父类)。
- 这个类型的修饰符( public, abstract , final 的某个子集)。
- 这个类型直接接口的一个有序列表。
域信息(Field)成员变量
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称。
- 方法的返回类型(或void),方法参数的数量和类型(按顺序)。
- 方法的修饰符(public, private,protected,static, final,synchronized,native,abstract...)。
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)。
常量池
在 Class 文件中,除了类信息
,还有一项信息是常量池,用于存放编译期间生成的各种字面量
和符号引用
。
字面量
:包括字符串(String a=“b”)、基本类型的常量(final 修饰的变量),符号引用
:则包括类和方法的全限定名(例如 String 这个类,它的全限定名就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。
运行时常量池
当类加载到内存后,JVM 就会将 Class 文件常量池
中的内容存放到运行时常量池
中,在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。
例如:
类中的一个字符串常量在 Class 文件中时,存放在 Class 文件常量池中的,在 JVM 加载完类之后,JVM 会将这个>字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,因此,Class 文件中常量池多个相同的字符串在运行时常量池只会存在一份。
比如:
public static void main(String[] args) {
String str = "Hello";
System.out.println((str == ("Hel" + "lo")));
String loStr = "lo";
System.out.println((str == ("Hel" + loStr)));
System.out.println(str == ("Hel" + loStr).intern());
}
其运行结果为:
true
false
true
第一个为true,是因为在编译成 Class 文件时,能够识别为同一字符串的, JVM 会将其自动优化成字符串常量,引用自同一个String 对象。
第二个为false,是因为在运行时创建的字符串具有独立的内存地址,所以不引用自同一个String对象。
最后一个为true,是因为String的intern()方法会查找在常量池中是否存在一个相等(调用equals()方法结果相等)的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。
注意:方法区无法满足内存分配需求的时候,比如一直往常量池中加入数据,运行时常量池就会溢出,从而报错OutOfMemoryError;
本地方法栈(Native Stack)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native
方法服务。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError
和 OutOfMemoryError
异常。
对于局部变量,如果是基本类型,会把值直接存储在栈;如果是引用类型,比如String s = new String("william");会把其对象存储在堆,而把这个对象的引用(指针)存储在栈。
程序计数器(PC Register)
在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了各条线程之间的切换后计数器能恢复到正确的执行位置,所以每条线程都会有一个独立的程序计数器。
当线程正在执行一个Java方法,程序计数器记录的是正在执行的JVM字节码指令的地址;如果正在执行的是一个Natvie
(本地方法),那么这个计数器的值则为Underfined
。
程序计数器占用的内存空间很少,也是唯一一个在JVM规范中没有规定任何OutOfMemoryError
(内存不足错误)的区域。