看了很多 java 内存管理的文章或者博客, 写的要么笼统, 要么划分的不正确, 且很多文章都千篇一律. 例如部分地方将 jvm 笼统的分为堆, 栈, 程序计数器, 这么分太过于笼统, 无法清晰的阐述 java 的内存管理模型; 部分地方将 jvm 分为堆, 栈, 程序计数器, 常量池, 方法区, 这么分, 很全面, 但是过于混乱, 因为这些区域之间存在并列和包含关系, 而最近再次刷《Java Thinking》这本书的时候, 从新学习了关于内存模型的内容. 基于上述原因, 我决定来谈谈 jvm 虚拟机的内存划分.
至于垃圾回收机制, 个人觉得应该和内存管理一同讨论, 所以在此, 我也将内存回收机制拿出来进行一起讨论.
本片博客的大致结构: 1. 内存区域; 2. 内存回收机制; 3. 垃圾回收器
1. 内存区域
首先看看官方的内存模型图片: 图片来自《Java 虚拟机规范(第 2 版)》
1.1. 程序计数器:
程序计数器是一个比较小的内存区域, 用于指示当前线程所执行的字节码执行到了第几行, 可以理解为是当前线程的行号指示器. 字节码解释器在工作时, 会通过改变这个计数器的值来取下一条语句指令. 由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的, 在任何一个确定的时刻, 一个处理器 (对于多核处理器来说是一个内核) 只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 各条线程之间的计数器互不影响, 独立存储, 我们称这类内存区域为 "线程私有" 的内存. 如果线程正在执行的是一个 Java 方法, 这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是 Natvie 方法, 这个计数器值则为空(Undefined), 由于程序计数器只是记录当前指令地址, 所以不存在内存溢出的情况, 因此, 程序计数器也是所有 JVM 内存区域中唯一一个没有定义 OutOfMemoryError 的区域.
1.2. 栈
栈分为虚拟机栈和本地方法栈, 既然都是栈, 那么就具有相同的特性: 私有的, 线程安全, 栈中存储了基本数据类型和对象的引用
1.2.1.java 虚拟机栈
一个线程的每个方法在执行的同时, 都会创建一个栈帧(Statck Frame), 栈帧中存储的有局部变量表, 操作站, 动态链接, 方法出口等, 当方法被调用时, 栈帧在 JVM 栈中入栈, 当方法执行完成时, 栈帧出栈. 实际上每个方法的调用相当于是栈帧的入栈已经出栈操作. 局部变量表中存储着方法的相关局部变量, 包括各种基本数据类型, 对象的引用, 返回地址等. 在局部变量表中, 只有 long 和 double 类型会占用 2 个局部变量空间(Slot, 对于 32 位机器, 一个 Slot 就是 32 个 bit), 其它都是 1 个 Slot. 需要注意的是, 局部变量表是在编译时就已经确定好的, 方法运行所需要分配的空间在栈帧中是完全确定的, 在方法的生命周期内都不会改变. 虚拟机栈中定义了两种异常, 如果线程调用的栈深度大于虚拟机允许的最大深度, 则抛出 StatckOverFlowError(栈溢出); 不过多数 Java 虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的), 所以线程可以一直申请栈, 直到内存不足, 此时, 会抛出 OutOfMemoryError(内存溢出).
1.2.2. 本地方法栈
本地方法栈在作用, 运行机制, 异常类型等方面都与虚拟机栈相同, 唯一的区别是: 虚拟机栈是执行 Java 方法的, 而本地方法栈是用来执行 native 方法的, 如调用 C++,C# 编写的方法. 目前在很多虚拟机中(如 Sun 的 JDK 默认的 HotSpot 虚拟机), 会将本地方法栈与虚拟机栈放在一起使用.
1.3. 堆
堆是线程共享的, 存储的是对象的实例, 有的地方写存储的是对象的实例和数组, 实际上数组是特殊的类, 那么数组也属于对象的实例. 在 JVM 所管理的内存中, 堆区是最大的一块, 堆区也是 Java GC 发生的主要场所, 在虚拟机启动时创建, 所以堆也成为 GC 堆, 按照 java 垃圾回收的概念, 堆又可以分为新生代和老年代, 永久代(只有部分虚拟机中有永久代的概念, sun 公司的 HotSpot 虚拟机就有, 其它的一般没有, 而 hotSpot 应用的比较广泛), 其中新生代又可以分为 Eden,From Survivor,To Survivor, 其中每一块具体的作用在垃圾回收模块会详细介绍. 原则上讲, 所有的对象都在堆区上分配内存, 但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟, 栈上分配, 标量替换优化技术将会导致一些微妙的变化发生, 所有的对象都分配在堆上也渐渐变得不是那么 "绝对" 了. 一般的, 根据 Java 虚拟机规范规定, 堆内存需要在逻辑上是连续的(在物理上不需要), 在实现时, 可以是固定大小的, 也可以是可扩展的, 目前主流的虚拟机都是可扩展的(通过 - Xmx 和 - Xms 控制). 如果在执行垃圾回收之后, 仍没有足够的内存分配, 也不能再扩展, 将会抛出 OutOfMemoryError:Java heap space 异.
1.4. 方法区
方法区 (Method Area) 与 Java 堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据. 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分, 但是它却有一个别名叫做 Non-Heap(非堆), 目的应该是与 Java 堆区分开来. 对于习惯在 HotSpot 虚拟机上开发和部署程序的开发者来说, 很多人愿意把方法区称为 "永久代"(Permanent Generation), 本质上两者并不等价, 仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区, 或者说使用永久代来实现方法区而已. 对于其他虚拟机 (如 BEA JRockit,IBM J9 等) 来说是不存在永久代的概念的. 即使是 HotSpot 虚拟机本身, 根据官方发布的路线图信息, 现在也有放弃永久代并 "搬家" 至 Native Memory 来实现方法区的规划了. Java 虚拟机规范对这个区域的限制非常宽松, 除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外(通过设置 permsize 和 MaxPermsize 设置方法区的初始化大小和最大内存), 还可以选择不实现垃圾收集. 相对而言, 垃圾收集行为在这个区域是比较少出现的, 但并非数据进入了方法区就如永久代的名字一样 "永久" 存在了. 这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载, 一般来说这个区域的回收 "成绩" 比较难以令人满意, 尤其是类型的卸载, 条件相当苛刻, 但是这部分区域的回收确实是有必要的. 在 Sun 公司的 BUG 列表中, 曾出现过的若干个严重的 BUG 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏. 根据 Java 虚拟机规范的规定, 当方法区无法满足内存分配需求时, 将抛出 OutOfMemoryError 异常.
1.5. 常量池
运行时常量池 (Runtime Constant Pool) 是方法区的一部分. Class 文件中除了有类的版本, 字段, 方法, 接口等描述等信息外, 还有一项信息是常量池 (Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中. Java 虚拟机对 Class 文件的每一部分(自然也包括常量池) 的格式都有严格的规定, 每一个字节用于存储哪种数据都必须符合规范上的要求, 这样才会被虚拟机认可, 装载和执行. 但对于运行时常量池, Java 虚拟机规范没有做任何细节的要求, 不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域. 不过, 一般来说, 除了保存 Class 文件中描述的符号引用外, 还会把翻译出来的直接引用也存储在运行时常量池中. 运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性, Java 语言并不要求常量一定只能在编译期产生, 也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 这种特性被开发人员利用得比较多的便是 String 类的 intern()方法. 既然运行时常量池是方法区的一部分, 自然会受到方法区内存的限制, 当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常.
2. 垃圾回收算法
java 语言中一个显著的特点就是引入了 java 回收机制, 是 c++ 程序员最头疼的内存管理的问题迎刃而解, 它使得 java 程序员在编写程序的时候不在考虑内存管理. 由于有个垃圾回收机制, java 中的额对象不在有 "作用域" 的概念, 只有对象的引用才有 "作用域". 垃圾回收可以有效的防止内存泄露, 有效的使用空闲的内存; java 语言规范没有明确的说明 JVM 使用哪种垃圾回收算法, 但是任何一种垃圾回收算法一般要做两件基本事情:(1)发现无用的信息对象;(2)回收将无用对象占用的内存空间. 使该空间可被程序再次使用.
2.1. 引用计数法(Reference Counting Collector)
引用计数是垃圾收集器中的早期策略. 在这种方法中, 堆中每个对象实例都有一个引用计数. 当一个对象被创建时, 且将该对象实例分配给一个变量, 该变量计数设置为 1. 当任何其它变量被赋值为这个对象的引用时, 计数加 1(a = b, 则 b 引用的对象实例的计数器 + 1), 但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时, 对象实例的引用计数器减 1. 任何引用计数器为 0 的对象实例可以被当作垃圾收集. 当一个对象实例被垃圾收集时, 它引用的任何对象实例的引用计数器减 1.
2.1.2 优缺点
优点: 引用计数收集器可以很快的执行, 交织在程序运行中. 对程序需要不被长时间打断的实时环境比较有利.
缺点: 无法检测出循环引用. 如父对象有一个对子对象的引用, 子对象反过来引用父对象. 这样, 他们的引用计数永远不可能为 0.
2.2.tracing 算法(Tracing Collector) 或 标记 - 清除算法(mark and sweep)
该算法是从离散数学中的图论引入的, 程序把所有的引用关系看作一张图, 从一个节点 GC ROOT 开始, 寻找对应的引用节点, 找到这个节点以后, 继续寻找这个节点的引用节点, 当所有的引用节点寻找完毕之后, 剩余的节点则被认为是没有被引用到的节点, 即无用的节点.
java 中可作为 GC Root 的对象有
1. 虚拟机栈中引用的对象(本地变量表)
2. 方法区中静态属性引用的对象
方法区中常量引用的对象
4. 本地方法栈中引用的对象(Native 对象)
标记 - 清除算法采用从根集合进行扫描, 对存活的对象对象标记, 标记完毕后, 再扫描整个空间中未被标记的对象, 进行回收. 分为两个阶段: 标记阶段和清除阶段. 标记阶段的任务是标记出所有需要被回收的对象, 清除阶段就是回收被标记的对象所占用的空间. 具体过程如下图所示. 标记 - 清除算法不需要进行对象的移动, 并且仅对不存活的对象进行处理, 在存活对象比较多的情况下极为高效, 但由于标记 - 清除算法直接回收不存活的对象, 因此会造成内存碎片.
2.3.compacting 算法 或 标记 - 整理算法
标记 - 整理算法采用标记 - 清除算法一样的方式进行对象的标记, 但在清除时不同, 在回收不存活的对象占用的空间后, 会将所有的存活对象往左端空闲空间移动, 并更新对应的指针. 标记 - 整理算法是在标记 - 清除算法的基础上, 又进行了对象的移动, 因此成本更高, 但是却解决了内存碎片的问题. 在基于 Compacting 算法的收集器的实现中, 一般增加句柄和句柄表.
2.4.copying 算法(Compacting Collector)
该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收. 它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间, 当对象满了, 基于 copying 算法的垃圾 收集就从根集中扫描活动对象, 并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞), 这样空闲面变成了对象面, 原来的对象面变成了空闲面, 程序会在新的对象面中分配内存. 一种典型的基于 coping 算法的垃圾回收是 stop-and-copy 算法, 它将堆分成对象面和空闲区域面, 在对象面与空闲区域面的切换过程中, 程序暂停执行. 这种算法虽然实现简单, 运行高效且不容易产生内存碎片, 但是却对内存空间的使用做出了高昂的代价, 因为能够使用的内存缩减到原来的一半. 很显然, Copying 算法的效率跟存活对象的数目多少有很大的关系, 如果存活对象很多, 那么 Copying 算法的效率将会大大降低.
2.5.generation 算法(Generational Collector)
分代的垃圾回收策略, 是基于这样一个事实: 不同的对象的生命周期是不一样的. 因此, 不同生命周期的对象可以采取不同的回收算法, 以便提高回收效率. 分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法. 它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域. 一般情况下将堆区划分为老年代 (Tenured Generation) 和新生代 (Young Generation) 和持久代, 老年代的特点是每次垃圾收集时只有少量对象需要被回收, 而新生代的特点是每次垃圾回收时都有大量的对象需要被回收, 那么就可以根据不同代的特点采取最适合的收集算法.
2.5.1. 年轻代(Young Generation)
1. 所有新生成的对象首先都是放在年轻代的. 年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象.
2. 新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0,survivor1)区. 一个 Eden 区, 两个 Survivor 区(一般而言). 大部分对象在 Eden 区中生成. 回收时先将 eden 区存活对象复制到一个 survivor0 区, 然后清空 eden 区, 当这个 survivor0 区也存放满了时, 则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区, 然后清空 eden 和这个 survivor0 区, 此时 survivor0 区是空的, 然后将 survivor0 区和 survivor1 区交换, 即保持 survivor1 区为空, 如此往复.
3. 当 survivor1 区不足以存放 eden 和 survivor0 的存活对象时, 就将存活对象直接存放到老年代. 若是老年代也满了就会触发一次 Full GC, 也就是新生代, 老年代都进行回收
4. 新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)
2.5.2. 年老代(Old Generation)
1. 在年轻代中经历了 N 次垃圾回收后仍然存活的对象, 就会被放到年老代中. 因此, 可以认为年老代中存放的都是一些生命周期较长的对象.
2. 内存比新生代也大很多(大概比例是 1:2), 当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低, 老年代对象存活时间比较长, 存活率标记高.
2.5.3. 持久代(Permanent Generation)
用于存放静态文件, 如 Java 类, 方法等. 持久代对垃圾回收没有显著影响, 但是有些应用可能动态生成或者调用一些 class, 例如 Hibernate 等, 在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类.
目前大部分垃圾收集器对于新生代都采取 Copying 算法, 因为新生代中每次垃圾回收都要回收大部分对象, 也就是说需要复制的操作次数较少, 但是实际中并不是按照 1:1 的比例来划分新生代的空间的, 一般来说是将新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间, 每次使用 Eden 空间和其中的一块 Survivor 空间, 当进行回收时, 将 Eden 和 Survivor 中还存活的对象复制到另一块 Survivor 空间中, 然后清理掉 Eden 和刚才使用过的 Survivor 空间.
而由于老年代的特点是每次回收都只回收少量对象, 一般使用的是 Mark-Compact 算法.
当 Eden 区满的时候, 会触发第一次 Minor gc, 把还活着的对象拷贝到 Survivor From 区; 当 Eden 区再次出发 Minor gc 的时候, 会扫描 Eden 区和 From 区, 对两个区域进行垃圾回收, 经过这次回收后还存活的对象, 则直接复制到 To 区域, 并将 Eden 区和 From 区清空. 当后续 Eden 区又发生 Minor gc 的时候, 会对 Eden 区和 To 区进行垃圾回收, 存活的对象复制到 From 区, 并将 Eden 区和 To 区清空 部分对象会在 From 区域和 To 区域中复制来复制去, 如此交换 15 次(由 JVM 参数 MaxTenuringThreshold 决定, 这个参数默认是 15), 最终如果还存活, 就存入老年代.
3. 垃圾回收(了解)
新生代收集器使用的收集器: Serial,PraNew,Parallel Scavenge
老年代收集器使用的收集器: Serial Old,Parallel Old,CMS
Serial 收集器(复制算法)
新生代单线程收集器, 标记和清理都是单线程, 优点是简单高效.
Serial Old 收集器(标记 - 整理算法)
老年代单线程收集器, Serial 收集器的老年代版本.
ParNew 收集器(停止 - 复制算法)
新生代收集器, 可以认为是 Serial 收集器的多线程版本, 在多核 CPU 环境下有着比 Serial 更好的表现.
Parallel Scavenge 收集器(停止 - 复制算法)
并行收集器, 追求高吞吐量, 高效利用 CPU. 吞吐量一般为 99%, 吞吐量 = 用户线程时间 /(用户线程时间 + GC 线程时间). 适合后台应用等对交互相应要求不高的场景.
Parallel Old 收集器(停止 - 复制算法)
Parallel Scavenge 收集器的老年代版本, 并行收集器, 吞吐量优先
CMS(Concurrent Mark Sweep)收集器(标记 - 清理算法)
高并发, 低停顿, 追求最短 GC 回收停顿时间, CPU 占用比较高, 响应时间快, 停顿时间短, 多核 CPU 追求高响应时间的选择
由于对象进行了分代处理, 因此垃圾回收区域, 时间也不一样. GC 有两种类型: Scavenge GC 和 Full GC.
Scavenge GC
一般情况下, 当新对象生成, 并且在 Eden 申请空间失败时, 就会触发 Scavenge GC, 对 Eden 区域进行 GC, 清除非存活对象, 并且把尚且存活的对象移动到 Survivor 区. 然后整理 Survivor 的两个区. 这种方式的 GC 是对年轻代的 Eden 区进行, 不会影响到年老代. 因为大部分对象都是从 Eden 区开始的, 同时 Eden 区不会分配的很大, 所以 Eden 区的 GC 会频繁进行. 因而, 一般在这里需要使用速度快, 效率高的算法, 使 Eden 去能尽快空闲出来.
Full GC
对整个堆进行整理, 包括 Young,Tenured 和 Perm.Full GC 因为需要对整个堆进行回收, 所以比 Scavenge GC 要慢, 因此应该尽可能减少 Full GC 的次数. 在对 JVM 调优的过程中, 很大一部分工作就是对于 FullGC 的调节. 有如下原因可能导致 Full GC:
1. 年老代 (Tenured) 被写满
2. 持久代 (Perm) 被写满
3.System.gc()被显示调用
4. 上一次 GC 之后 Heap 的各域分配策略动态变化
1. 静态集合类像 HashMap,Vector 等的使用最容易出现内存泄露, 这些静态变量的生命周期和应用程序一致, 所有的对象 Object 也不能被释放, 因为他们也将一直被 Vector 等应用着.
2. 各种连接, 数据库连接, 网络连接, IO 连接等没有显示调用 close 关闭, 不被 GC 回收导致内存泄露.
3. 监听器的使用, 在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露.
- https://www.cnblogs.com/andy-zcx/p/5522836.html
- https://www.cnblogs.com/dz-boss/p/10219503.html
来源: https://juejin.im/post/5c73a3966fb9a04a0605821b