本文已参与「新人创作礼」活动,一起开启掘金创作之路。
JVM 内存区域划分
Java 中的内存是通过 JVM 自动管理的. 了解 JVM 有利于进一步理解 Java.
JVM 内存结构如下图所示 :
在JVM 规范里, JVM 被分为 7 个内存区域. ( 注意 : 这只是规范并不是强制要求, 所以不同的虚拟机的实现可以不同, 同一虚拟机的不同 Java 版本的实现也可以不同 )
① 三个私有区
私有区是每个线程私有的,只有当前线程可以访问的区域. 线程私有的内存区域和线程具有相同的生命周期, 线程生则分配内存, 线程死则回收内存.
其分别是:
- 指令计数器 ( 程序计数器 ) :记录要执行的代码行数.
- 线程栈 ( 虚拟机栈 ) :局部变量, 执行 Java 方法.
- 本地方法栈:执行 Native 本地方法.
这几个区域在线程创建时创建, 在线程结束时被回收. 所以不存在垃圾回收问题.
② 四个共享区
四个共享区是所有线程共享的, 任何 Jvm 里的线程都可以读取操作, 共享区在 Jvm 启动时就会自动创建并分配.
其分别是:
- 堆内存:存储 Java 对象. 基本上所有 Java 对象都在这里.
- 方法区:存放 class 的结构.
- 常量池:方法区的一部分. 存放常量.
- 直接内存区:?
程序计数器
JVM中的程序计数器 Program Counter Register 也叫做 PC 寄存器, PC 计数器, 指令计数器等. JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟.
在 JVM 规范中, 每个线程都有它自己的程序计数器, 是线程私有的, 生命周期与线程的生命周期保持一致. 它是一块很小的内存空间, 几乎可以忽略不记. 所以是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 的区域.
作用:PC 寄存器存储了当前线程需要执行的下一条指令的地址, 也就是即将执行的指令代码. 由执行引擎来读取下一条指令. 它是程序控制流的指示器, 分支, 循环, 跳转, 异常处理, 线程恢复等基础功能都需要依赖这个计数器来完成.
程序计数器位于线程栈中, 它的的值由字节码执行引擎来维护.
为什么需要程序计数器
因为 Java 是支持多线程的, 对于多线程中的某一个线程 A 来说, 假如 A 正在执行一个方法, 执行了一半, 因为 CPU 的时间片轮转而被迫暂时停止运行, 让出 CPU, 让其他线程 B 去执行, 当线程 A 再次获取到 CPU 执行权时, 不能从头开始执行这个方法吧 ? 肯定是要接着执行的. 那么就需要有一个东西来记录它上次执行到哪里了, 这次应该从哪里开始执行. 所以, 这便是程序计数器的由来, 每个线程都有自己的程序计数器, 这样当 CPU 重新调度该线程的时候, 从这个线程的程序计数器中取出下一条要执行的字节码执行指令, 就可以继续执行了.
程序计数器为什么是线程私有
由于 CPU 的切换, 必然导致线程经常中断或恢复. 为了能够准确地记录各个线程正在执行的当前字节码指令地址, 最好的办法自然是为每一个线程都分配一个 PC 寄存器, 这样一来各个线程之间便可以进行独立计算, 从而不会出现相互干扰的情况.
再一个有多少线程要产生, 在编译时是不能确定的, 因此该区域也没有办法在编译时进行分配, 只能在创建线程时分配, 所以该区域是线程私有的.
程序计数器保存的值是什么
如果该线程正在执行的是一个 Java 方法, PC 值为正在执行的 JVM 字节码指令的地址.
如果该线程正在执行的是一个本地方法, PC 值是 undefined.
线程栈
线程栈又叫做虚拟机栈, Java 栈.
线程栈总是和线程联系在一起, 每当创建一个线程时, JVM 就会为这个线程创建一个对应的线程栈, 一个线程栈中又包含有多个栈帧.
栈帧是与每个 Java 方法关联起来的, 每运行一个方法就创建一个栈帧, 并将其压入线程栈中. 栈帧中会保存一个方法的局部变量, 操作数栈和方法返回值等信息. 方法中的局部变量都是在栈帧中分配的. 这样当该方法执行完毕, 栈帧弹出时, 相关的局部变量也就被回收掉了.
假如运行了一段代码如下 :
public class Test{
public int compute(){
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args){
Test test = new Test();
test.compute();
}
}
如下图, 是 main 线程栈运行后的情况 :
执行步骤:
1.先执行 main() 方法, 创建了 main 方法的栈帧, 并压入 main 线程栈.
2.接着调用了 compute() 方法, 创建了 compute 方法的栈帧, 并压入 main 线程栈.
3.当 compute() 方法执行完毕后, 弹出 compute 方法的栈帧.
4.当 main() 方法执行完毕后, 弹出 main方法的栈帧.
5.main 线程执行结束. 栈内存释放.
线程栈的出栈与入栈
每当一个方法执行完成时, 这个方法对应的栈帧就需要从线程栈中弹出, 栈帧会弹出栈帧的元素作为这个方法的返回值, 并清除这个栈帧.
线程栈栈顶的栈帧就是当前正在执行的活动栈帧, 也就是当前正在执行的方法, PC 寄存器也会指向这个地址. 只有这个活动的栈帧的本地变量可以被操作栈使用, 当在这个栈帧中调用另外一个方法时, 与之对应的一个新的栈帧又被创建, 这个新创建的栈帧又被放到线程栈的顶部, 变为当前的活动栈帧. 同样现在只有这个栈帧的本地变量才能被使用, 当在这个栈帧中所有指令执行完成时这个栈帧移出 线程栈, 刚才的那个栈帧又变为活动栈帧, 前面的栈帧的返回值又变为这个栈帧的操作栈中的一个操作数. 如果前面的栈帧没有返回值, 那么当前的栈帧的操作栈的操作数没有变化.
栈帧的结构
通过上面的图, 可以看到, 栈帧是和方法对应的, 一个栈帧就是一个方法的执行.
栈帧内部又由四个部分组成 :
① 局部变量表, 顾名思义, 存放的就是当前栈帧 (当前方法) 中的局部变量. 在上面代码中的 compute 栈帧中呢就是 this, 变量 a, 变量 b, 变量 c. 注意了 : this 也被当做局部变量, 且放在第一位. 如上图所示. 局部变量由于永远只存在一个线程中,不会被共享,所以永远是线程安全的.
② 操作数栈, 是一个用于计算的临时的数据中转区域. 一个变量不能直接放入局部变量表中, 而是先入操作数栈, 再从操作数栈出栈到局部变量表. 比如上面代码中, compute 方法中 int a = 1 这句代码, JVM 会先将数字 1 压入 操作数栈, 然后在局部变量表中创建局部变量 a, 然后从操作数栈中将数字 1 弹出来, 赋值给变量 a.
③ 动态链接, 则是当一个方法 a 中调用其他方法 b 时, 可以链接到方法区的该方法的位置. 完成调用.
④ 方法出口 : 当执行完子方法时, 需要回到原来的方法区,而方法出口帮你完成这件事.
调用本地方法
每个线程都有自己专属的本地方法内存区域, 存放运行的本地方法.
线程栈与 OOM
线程栈是线程私有的, 单一个线程里面可以存储的容量是有限的,所以线程栈会抛出 StackOverFlowError (当出现死循环递归调用时) 和 OOM.
如下是抛出 StackOverFlowError 的例子 :
public class Test {
public static void main(String[] args) {
sysHello();
}
private static void sysHello() {
sysHello();
}
}
线程栈的大小
线程栈的大小可以通过 -Xss 参数进行设置.
- 该参数限制的是每一个线程对应的线程栈的大小. 默认为
1M. 默认的大小已经够用了, 因为有入栈就会有出栈. - 对于栈帧的大小没有限制. 因为
每个栈帧的大小都是不固定的.
堆内存
JVM 中的堆是存储 Java 对象, 类的成员变量的地方, 它是 JVM 管理 Java 对象的核心存储区域.
Java 堆在虚拟机启动的时候创建, 是 JVM 所管理的内存中最大的一块. 此内存区域的唯一目的就是存放对象的实例. 几乎所有的对象实例都在这里分配内存, 例如对象实例和数组. 该区域被所有线程共享的. 因为堆内存的大小是有限制的, 所以这个区域会抛出 OutOfMemoryError 异常.
Java 堆也是垃圾收集器管理的主要区域, 由于现在的收集器基本都采用分代收集算法, 所以 Java 堆中还可以细分为新生代 ( Young ) 和 老年代 ( Old ). ( 堆大小 = 新生代 + 老年代 )
而新生代 ( Young ) 又被划分为三个区域:Eden, From Survivor, To Survivor. 这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象, 包括内存的分配以及回收.
堆的内存模型大致如下图所示:
默认情况下, 新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 , Eden : from : to = 8 : 1 : 1
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务, 所以无论什么时候, 总是有一块 Survivor 区域是空闲着的. 因此, 新生代实际可用的内存空间为 9/10 ( 即 90% ) 的新生代空间.
新创建的对象会在新生代的 Eden 区诞生, 当 Eden 区内存空间不足时, 发生 Minor GC 垃圾回收时, 幸存下来的对象将会移动到 From Survivor 区, 当 From Survivor 区内存不足时, 发生 Minor GC 垃圾回收, 幸存下来的对象将会移动到 To Survivor 区. 当 15 次垃圾回收依旧存活的对象, 会被移入老年代, 当老年代内存也不足时, 将发生 Full GC.
方法区
方法区是用来存放 JVM 装载的 class 的类结构信息的地方, 包括:类信息, 常量, 类的方法, 静态变量, 类型信息 ( 接口 / 父类 ) 等. 使用反射时, 所需的信息就是从方法区里获取的.
方法区也属于 Java 堆的一部分, 也就是通常说的 Java 堆中的永久区, 这个区域被所有线程共享. 在 Java8 中, HotSpot 虚拟机移除了永久区, 使用本地内存来存储 class 类信息, 并称之为元空间.
一般来说这个区域的内存回收目标是针对常量池的回收和对类型的卸载.
从 JVM 运行时区域内存模型来看, 堆和方法区是两块独立的内存块. ( Java7 时, 方法区移动到了堆中 ). 但从垃圾收集器来看, HotSpot 虚拟机选择把 GC 分代收集扩展至方法区, 或者说使用永久代来实现方法区, 所以很多人都更愿意把方法区称为 永久代.
运行时常量池
运行时常量池 Runtime Constant Pool 代表运行时每个 class 文件中的常量表. 包括 : 编译期的数字常量, 方法或者域的引用.
运行时常量池是方法区的一部分, 常量池在堆中,因为堆内存大小有限制, 所以常量池也会抛出 OutOfMemoryError.
变量用 final 修饰, 不能再修改它的值, 所以就成为常量, 而这个常量将会存放在常量区, 这些常量在编译时就知道占用空间的大小, 但并不是说明该区域编译就固定了, 运行期也可以修改常量池的大小, 典型的场景是在使用 String 时, 你可以调用 String 的 intern(), JVM 会判断当前所创建的 String 对象是否在常量池中, 若有, 则从常量区取, 否则把该字符放入常量池并返回, 这时就会修改常量池的大小(常量的大小是不可改变的).
本地方法栈
本地方法栈 Native Method Stack 与虚拟机栈的作用很相似, 唯一的区别就是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务, 而本地方法栈则为虚拟机使用到的 Native 方法服务.
既然是栈结构, 那么本地方法栈和线程栈也一样, 也会抛出 StackOverFlowError 和 OutOfMemoryError.
该区域主要是给调用本地方法的线程分配的, 该区域和线程栈 ( 虚拟机栈 ) 的最大区别就是, 在该线程的申请的内存不受 GC 管理, 需要调用者自己管理, JDK 中的 Math 类的大部分方法都是本地方法, 一个值得注意的问题是, 在执行本地方法时, 并不是运行字节码, 所以之前所说的指令计数器是没法记录下一条字节码指令的, 所以当执行本地方法时, 指令计数器为 undefined.
直接内存区
直接内存区 Direct Memory 既不是运行时数据区的一部分, 也不是 Java 虚拟机规范中定义的内存区域. 直接内存区也不由 JVM 管理. 他是利用本地方法库直接在 Java 堆之外直接向系统申请的内存空间. 比如 NIO 中的 DirectByteBuffer 就是操作直接内存的.
直接内存的好处就是避免了在 Java 堆和 Native 堆直接同步数据的步骤.
直接内存的分配不受到 Java 堆大小的限制, 但是它还是受到服务器总内存的影响.