摘抄自这里

JVM内存结构

  • 线程私有:程序计数器、虚拟机栈、本地方法区
  • 线程共享:堆、方法区、堆外内存(JDK 7 的永久代或 JDK 8 的元空间、代码缓存)

一、程序计数器

程序计数寄存器(Program Counter Register),起名源于 CPU 的寄存器,寄存器存储指令相关线程信息,CPU只有把数据装载到寄存器才能够运行

JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码行号指示器

作用

PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码,由执行引擎读取指令

使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

PC寄存器为什么会被设定为线程私有的?

多线程在一个特定的时间段只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会中断或恢复。为了能准确记录各个线程正在执行的字节码指令地址,所以要私有,线程独立计算

总结

  • 一块很小的内存空间,运行速度最快的存储区域
  • 在 JVM 规范中,程序计数器是线程私有的,生命周期与线程的生命周期一致
  • 任何时间一个线程只有一个方法在执行,也就是所谓当前方法。如果执行Java方法则指向 JVM 字节码指令地址,如果执行 native 方法,则未指定值(undefined)
  • 是程序控制流的指示器
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域

二、虚拟机栈

主管 Java 程序的运行,保存方法的局部变量、部分结果,并参与方法的调用和返回

特点:

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈,方法执行结束,伴随着出栈
  • 栈不存在垃圾回收问题

栈可能出现的问题

Java 虚拟机规范允许 Java 虚拟机栈的大小是动态的或者是固定不变的

  • 如果采用固定大小,那么栈容量在线程创建时就确定好,超过最大容量则抛出 StackOverflowError
  • 如果可以动态扩展,并且在尝试扩展时无法申请足够的内存,或创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么会抛出 OutOfMemoryError

可以通过参数 -Xss 来设置线程的最大栈空间,栈的大小直接决定函数调用的最大可达深度

栈的存储单位

栈中存储什么?

  • 每隔线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame) 的格式存在
  • 在这个线程上正在执行的每个方法各自有对应的栈帧
  • 栈帧是一块内存区块,是一个数据集,维系着方法执行过程中的各种数据

栈运行原理

  • JVM 直接堆 Java 栈的操作只有两个,对栈帧的压栈出栈,遵循 FIFO 原则?(先进后出/后进先出原则)
  • 在一条活动线程中,一个时间点上,只有一个活动的栈帧,即 栈顶栈帧
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • Java 方法有两种返回函数,一种是正常的函数返回,使用 return ,另一种是抛异常,不管是哪种方式,都会导致栈帧被弹出

栈帧内部结构

栈帧存储着:

  • 局部变量表
  • 操作数栈
  • 动态链接 (指向运行时常量池的方法引用)
  • 方法返回值 (正常退出或异常退出的地址)
  • 附加信息
局部变量表
  • 局部变量表也称为本地变量表
  • 是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型对象引用returnAddress 类型 (指向了一条字节码指令的地址,已被异常表取代)
  • 局部变量表建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需要的容量大小是编译时期确定下来的,并保存在 Code 属性的 maximum local variables 数据项中
  • 栈越大,方法嵌套调用次数越多
  • 局部变量表的变量只在当前栈(当前方法)调用中有效
  • 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽 Slot
  • 局部变量表最基本的存储单元是 Slot(变量槽)
  • 32位以内的类型只占用一个Slot(包含returnAddress类型),64位的类型(long 和 double) 占用两个连续的 Slot
  • JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过索引即可成功访问局部变量表指定的局部变量表,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
  • 当一个实例方法被调用,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每个 Slot 上
  • 如果需要访问局部变量表中一个 64 bit 的局部变量表值,只需要使用前一个索引即可
  • 如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
  • 栈帧的局部变量表中的槽位可以复用,如果一个变量表过了其作用域,那么很有可能复用过期槽位,达到节省资源
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
  • 每个独立栈帧中除了包含局部变量表外,包含一个后进先出的操作数栈,也可以称为表达式栈
  • 操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈、出栈
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
概述
  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 每个操作数栈拥有明确栈深度用于存储数值,在编译期就定义好了,保存在方法的 Code 属性的 max_stack
  • 栈单位也是槽
  • 如果被调用的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈中
  • Java 虚拟机的解释引擎时基于操作数栈的执行引擎
栈顶缓存技术

将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

动态链接

  • 每一个栈帧内包含一个指向运行时常量池中该栈帧所属方法的引用

  • 在 Java 源文件被编译到字节码文件中,所有的变量和方法引用都作为 符号引用 保存在 Class 文件的常量池中。 动态链接的作用就是将这些符号引用转化为调用方法的直接引用
    动态链接

  • 静态链接:目标方法在编译期可知,则将调用方法的符号引用转换为直接引用的过程称之为静态链接

  • 动态链接:目标方法在运行期才能确定,则将调用方法的符号引用转换为直接引用的过程称之为动态链接

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时
  • 晚期绑定:晚期绑定就是指被调用的目标方法在运行期才能确定,且可能因为运行期状态而发生变化时
虚方法和非虚方法
  • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法

方法返回值

用来存放调用该方法的 PC 寄存器的值

本质上,方法的退出就是当前栈帧出栈的过程

通过异常完成出口退出的不会给它的上层调用者产生任何返回值

三、本地方法栈

一个 Native Method 就是一个 Java 调用非 Java 代码接口

  • Java 虚拟机用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
  • 本地方法栈也是私有的
  • 允许线程固定或者动态扩展内存大小(同虚拟机栈一样)
  • 本地方法用 C 语言实现的
  • 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

栈是运行时的单位,而堆是存储单位
栈解决程序运行问题,如程序如何执行;而堆是解决数据存储问题,数据怎么放、放在哪

四、堆内存

Java 堆是虚拟机管理的内存中最大的一块,被所有线程共享,此内存区域的唯一目的是存放对象实例,几乎所有对象实例在这里分配内存

为了提高垃圾回收效率,虚拟机把堆内存逻辑上划分为三块区域(分代的唯一理由是优化 GC 性能)

  • 新生代:新对象和没到达一定年龄的对象
  • 老年代:被长时间使用的对象
  • 元空间(1.8 之前叫永久代):像一些方法的操作临时对象等, JDK 1.8 之前是占用 JVM 内存,之后直接使用物理内存
    Java堆内存

Java 堆可以是处于物理上不连续的内存空间,只要逻辑上连续即可,像磁盘空间一样;实现时,可以是固定大小,也可以是可扩展,主流虚拟机都是可以扩展的(通过 -XmxXms ),如果堆中没有完成实例分配,并且堆再无内存扩展时,就会抛出 OutOfMemoryError

新生代(Young Generation)

所有新对象创建的地方,当填充年轻代时,执行垃圾收集。这种收集叫 Minor GC

年轻代被划分为三个部分,伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是 8:1:1

  • 大多数新创建对象位于 Eden 区
  • 当 Eden 空间被对象填充时,执行 Minor GC,并将所有幸存者对象移动到一个幸存者空间中
  • Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
  • 经过多次 GC 循环后存活的对象被移动到老年代。通过,这是通过设置年轻一代对象的年龄阈值来实现

老年代(Old Generation)

存放旧的一代内存经过多轮 Minor GC 存活的对象。通常,垃圾收集是在老年代内存满时执行。老年代垃圾收集称为 主 GC(Major GC),通常需要更长时间

大对象直接进入老年代。这样做避免在 Eden 区和两个 Survivor 区之间发生大量内存拷贝

元空间

不管是永久代和元空间,都可以看着做是 Java 虚拟机规范中方法区的实现

设置堆内存大小和 OOM

  • -Xms:设置 Java 堆内存的起始内存
  • -Xmx:设置 Java 堆内存的最大内存

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常

我们通常会将 -Xmx-Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能

查看 JVM 堆内存分配

  1. 默认情况下新生代和老年代的比例是 1:2,可以通过 -XX:NewRatio 配置
  2. 新生代中 Eden:Survivor0:Survivor1 的比例可以通过 -XX:SurvivorRatio 配置
  3. JDK 8 是默认开启 -XX:+UseAdaptiveSizePolicy ,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄;此时 –XX:NewRatio -XX:SurvivorRatio 将会失效

每次 GC 后都会重新计算 Eden、 From Survivor、 To Survivor 的大小

计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量

对象在堆中的生命周期

  1. 当创建一个对象时,对象会优先分配到新生代的 Eden 区
    · 此时 JVM 会给对象定义一个对象年轻计数器-XX:MaxTenuringThreshold
  2. 当 Eden 空间不足时, JVM 将执行新生代垃圾回收(Minor GC)
    · JVM 会把存活对象转移到 Survivor 中,并且对象年龄 + 1
    · 对象在 Survivor 中同样会经历 Minor GC,每经历一次,对象年龄 + 1
  3. 如果分配的对象超过了 -XX:PetenureSizeThreshold ,对象会直接被分配到老年代

TLAB

  • 从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
TLAB 好处
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

堆是分配内存的唯一选择吗?

逃逸分析

逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则发生逃逸。如作为调用参数传递到其他地方,称为方法逃逸

使用逃逸分析,编译器会对代码做优化:

  • 栈上分配:将堆分配转化为栈分配
  • 同步省略:如果只能从一个线程访问,不做同步考虑
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递

同步省略(消除)

同步省略也叫锁消除

1
2
3
4
5
6
public void keep() {
Object keeper = new Object();
synchronized(keeper) {
System.out.println(keeper);
}
}

如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:

1
2
3
4
public void keep() {
Object keeper = new Object();
System.out.println(keeper);
}
标量替换

标量 指一个无法再分解成更小数据的数据。Java 中的原始数据类型是标量

相对的,可以分解的数据叫做 聚合量

在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。

通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations 查看标量替换情况。

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
alloc();
}

private static void alloc() {
Point point = new Point1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}

以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

1
2
3
4
5
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
栈上分配

为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

但无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

五、方法区

方法区是 Java 虚拟机定义的一种概念,是一种规范;元空间和永久代是其实现

  • 方法区(Method Area)和 Java 堆一样,是所有线程共享的内存区域
  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern() 方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
  • 方法区的大小和堆空间一样,可以固定也可以扩展
  • JVM 关闭后方法区会被释放
    默认值依赖于平台。Windows 下,-XX:MetaspaceSize21M-XX:MaxMetaspacaSize 的值是 -1,即没有限制

方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等

类型信息

对每个加载的类型, JVM 必须在方法区中存储以下类型信息

  • 完整有效名称
  • 直接父类的完整有效名(对于 interface 或是 java.lang.Object,都没有父类)
  • 类型的修饰符(public, abstract, final等)
  • 这个类型直接接口的一个有序列表

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)

  • 在加载类和结构到虚拟机后,就会创建对应的运行时常量池
  • 常量池表是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。

Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。

jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中

jdk1.8 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中

方法区的垃圾回收

方法区的垃圾回收主要内回收两部分内容:常量池中废弃的常量和不再使用的类型