前言
前面介绍过
JVM被分为三个主要的子系统:
- 类加载器子系统
- 运行时数据区(也就是内存相关)
- 执行引擎
因为内存相关知识贯穿JVM的整个执行流程, 所以我们先说说内存的相关知识.
关于JVM内存, 有两个比较重要的概念,
这两个概念, 经常会有人搞混, 所以, 顺带来做个梳理.
内存模型
内存结构
什么是内存模型(JMM)?
Java Memory Model, 就是我们常说的JMM;
JMM和JVM内存结构不同, 它只是一个抽象的概念, 描述了一组规则或规范, 这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。
我们知道, Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型.
JMM定义了一些语法集, 这些语法集映射到Java语言中就是volatile、synchronized等关键字.
简而言之, JMM就是为了解决Java多线程对共享数据的读写一致性问题而产生的一种模型!
PS: 关于Java多线程的读写一致性问题的前世今生可以阅读我的另一篇文章你不得不知道的线程安全问题
JMM内存模型可以归纳为下图
什么是JVM内存结构?
JVM的内存结构也叫运行时数据区;
JVM中内存通常划分为两个部分, 分别为堆内存与栈内存,栈内存主要用运行线程方法存放本地暂时变量与线程中方法运行时候须要的引用对象地址;
堆内存则存放全部的对象信息.
相比栈内存, 堆内存能够所大的多, 所以JVM一直通过对堆内存划分不同的功能区块, 实现对堆内存中对象管理.
堆内存不够最常见的错误就是OOM(OutOfMemoryError)
栈内存溢出最常见的错误就是StackOverflowError
此外, 也有较为细致的划分;
根据JVM 规范, 定义了五种运行时数据区, 分别是:
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- Java堆
- 方法区
这里注意, JVM规范只是一种规范, 而不是具体的实现!
JVM规范只是规定了这五种数据区的作用, 并没有规定如何去实现它,所以在不同的JVM中对这五种数据区实现是不同的;
举个例子:
接下来,我们就以HotSpot虚拟机为例来理清JVM的内存结构
程序计数器(线程私有)
程序计数器可以看作是JVM对CPU程序计数器的一种模拟;
它是一块较小的内存空间, 用来存储当前线程的所执行的字节码的行号;
我们知道, Java的多线程是通过线程轮流切换、分配处理器时间片的方式来实现的,
所以在任何一个时刻, 一个CPU的内核只会执行一个线程中的命令;
一旦当前线程的时间片结束然后被挂起, 当又轮到这个被挂起的线程执行的时候, 如何去恢复被挂起前的状态?
这个就是依靠程序计数器,保存当前执行的字节码的位置.
简而言之, 就是个“书签”的功能.
注意以下几点:
- 程序计数器是线程私有的, 每个线程都有一个自己的程序计数器.
- 如果当前线程执行的是native方法, 则其值为null
- 在这块内存空间中不存在任何OutOfMemoryError情况
Java虚拟机栈(线程私有)
Java虚拟机栈描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息.
每一个方法从调用直至执行完成 的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception).
栈帧随着方法调用而创建,随着方法结束而销毁, 无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
Java虚拟机栈特点如下:
- Java虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)
- 栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息
- 每一个方法被调用直至执行完毕的过程, 就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程
栈帧结构如下图
- 局部变量表
- 操作数栈
- 动态链接(或运行时常量池的方法引用)
- 方法返回地址
- 附加信息
本地方法栈(线程私有)
本地方法栈与虚拟机栈的区别是:
虚拟机栈执行的是 Java 方法, 本地方法栈执行的是本地方法(Native Method),其他基本上一致;
在 HotSpot 中直接把本地方法栈和虚拟机栈合二为一, 这里暂时不做过多叙述.
堆(线程共有)
堆内存和元数据区都被所有线程共享, 在虚拟机启动时创建.
Java 堆是内存空间占据的最大一块区域了, 用来存放对象实例及数组,也就是说我们 new 出来的对象都存放在这里。
这里也是垃圾回收器的主要活动营地了, 于是它就有了一个别名叫做 GC 堆, 并且单个 JVM 进程有且仅有一个 Java 堆.
现代JVM 采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden 区和From Survivor 区和 To Survivor 区)和老年代.
新生代(占1/3的堆空间,通常使用MinorGC)
新生代几乎是所有 Java 对象出生的地方, 用来存放新生的对象.
由于频繁创建对象, 所以会频繁触发 MinorGC 进行垃圾回收.
新生代又细分为三个区
- Eden
- From Servivor (也叫S0)
- To Servivor (也叫S1)
Eden : S0 : S1 占比为 : 8:1:1 (可以通过参数 –XX:SurvivorRatio 来设定)
MinorGC 的过程(复制->清空->互换)
一般情况下, 新对象都会在新生代 ( Eden 和 一个 Survivor 区域, 假设是 From 区域 ) 出生;
对于大对象 ( 即: 需要分配一块较大的连续内存空间 ) , 新生代放不下时, 则直接进入到老年代;
一次完整的MinorGC 的过程如下:
复制: Eden、From复制到 To,年龄+1
在初始阶段, 新创建的对象被分配到Eden区, 此时From 和 To 都为空;
当Eden满的时候会触发第一次GC, 此时会把还活着的对象复制到 From;
当Eden再次触发GC的时候会扫描Eden和From, 对这两个区域进行垃圾回收;
经过这次回收后, 还存活的对象会复制到 To清空: 清空Eden、From
上述操作完成后, 清空 Eden 和 From 中的对象互换: To 和 From 互换(谁空谁是To区)
最后,To 和 From 互换,原 To 成为下一次 GC 时的 From区;
PS: 对象会在From和To区域中复制来复制去, 如此交换15次(JVM默认为15,最大也是15,因为只留了4个字节; 可以通过参数 -XX:MaxTenuringThreshold 来设定),
最终如果对象还是存活, 就存入老年代.
老年代(占2/3的堆空间,通常使用MajorGC)
老年代主要存放应用程序中生命周期长的内存对象, 这些对象比较稳定, 所以 MajorGC 不会频繁执行.
在进行 MajorGC 前一般都先进行 了一次 MinorGC, 使得有新生代的对象晋身入老年代, 导致老年代空间不够用时才触发.
当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间.
MajorGC(标记->清除)
MajorGC 采用标记清除算法: 首先扫描一次所有老年代,标记出存活的对象,然后回收没 有标记的对象.
MajorGC 的耗时比较长,因为要扫描再回收.
MajorGC 会产生内存碎片,为了减 少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配.
当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常
方法区(线程共有)
方法区是线程共享的, 主要存储类信息、常量池、静态变量、JIT编译后的代码等数据, 理论上来说方法区是堆的逻辑组成部分;
前面简单提过, 方法区只是JVM规范定义的一个数据区, 不同的JVM对其实现方式不同, 而我们常用的HotSpot对方法区的实现, 随着JDK版本的升级, 也是经历了多次调整.
JDK1.6及之前(永久代): 方法区存放类信息、字符串常量池、静态变量、即时编译器编译后的代码等数据
JDK1.7及以后(永久代): 将静态变量、字符串常量池从方法区中移了出来,放在了JVM堆中
JDK1.8及以后(元空间): 类的元信息(类信息、字段、方法、常量等)被存储在元空间中; 常量池和静态变量被放在了JVM堆中
为什么要用元空间来替代永久代呢?
简介: 永久代主要存放 Class 和 Meta(元数据)的信息, Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理;
所以这 也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常.