JVM内存模型

内存区域

  • 程序计数器:当前线程字节码行号指示器(线程私有)

  • Java虚拟机栈:存储局部变量表、操作数栈、方法出口等栈帧元素。(线程私有)

    局部变量表存放基本数据类型、对象引用等,其存储空间以局部变量槽表示。

  • 本地方法栈:为虚拟机提供本地方法服务。

  • Java堆:被所有线程共享的、最大的内存区域。存放对象实例,由垃圾收集器管理。

  • 方法区:线程共享,存储类型信息、静态变量等。运行时常量池是其中的一部分。

虚拟机对象探秘

对象的创建

  1. 类加载检查。检查常量池中是否已有对应的符号引用、加载。

  2. 分配内存。根据”空闲列表“,从堆中划分确定大小的内存块。

    此过程本身是线程不安全的,通过部分同步、划分线程空间等解决。

  3. 设置对象头。受虚拟机的具体配置影响。

  4. 执行构造函数。字段默认为零值。

对象内存布局

三个部分:对象头、实例数据、对齐填充。

  • 对象头:存储对象自身的运行时数据(GC分代年龄、锁状态等)和类型(元数据)指针。

  • 实例数据:记录父类继承下来的、子类定义的字段。

对象的访问定位

  • 句柄访问:稳定句柄地址,高效对象移动。

  • 指针访问:节省访问时间。

内存溢出异常

  • 堆溢出

建议用堆存储快照jmap进行分析,检查内存泄漏和内存溢出(对象是否必须存活)。

  • 栈溢出

原因是:栈帧过大或是虚拟机容量过小。

  • 方法区和运行时常量池溢出

情境:CGLib(一个直接操作字节码的开源项目)

  • 直接内存溢出

NIO就是一个间接使用直接内存的例子。

垃圾收集器

垃圾收集(GC),面向方法区、堆这两个不确定性区域。因为PC、虚拟机栈、本地方法栈随线程而生,随线程而灭,栈帧的内存分配在类结构确定时即确定,不需要过多考虑回收问题。

对象是否死亡?

引用计数算法

在对象中添加一个引用计数器,被引用则加一;引用失效后减一。计数器值为 0 的对象不再可用。这种方法难以回收循环引用的对象。

可达性分析算法

以“GC Roots“对象为起点,按照引用链向下搜索,不可达的对象被判为不可用。

可作为Roots的对象有 :虚拟机栈(本地变量表)引用的对象 (线程栈帧的局部变量)、方法区中类静态属性引用的对象 (静态变量)、常量引用的对象、同步锁持有对象。

即便如此,一个对象也不是一旦被判为不可达,就立即死去的,宣告一个的死亡需要经过两次标记过程。

  • 强引用:就是我们最常见的普通对象引用。如引用赋值,例如obj = new Obj()。该状态下的对象不会被收集。

  • 软引用:关联的对象(非必须),常用来实现内存敏感的缓存。在 OOM 前会列入回收范围中进行第二次回收

  • 弱引用:只能生存到下一次垃圾收集前,只具有弱引用的对象的生命周期短。同样是很多缓存实现的选择。

  • 虚引用:无法取得对象实例者,须和引用队列(ReferenceQueue)联合使用。唯一用途:回收时发送通知。

两次标记过程

  • 没有与GC Roots相连接的引用链(不可达)

  • 对象是否有必要执行finalize方法

finalize() 只会被执行一次,此方法若被重写覆盖,则将放置于一个低优先级队列。

不推荐使用finalize方法。建议使用try-finally方式。

回收方法区

主要回收废弃常量和无用的类。

  • 废弃:指没有任何引用指向之。

  • 无用:所有实例均已被回收/无实例;类加载器被回收;对应的Class对象未被引用。

垃圾收集算法

from Github TangBean

分代收集理论

三个假说:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。

  2. 强分代假说:熬过越多次垃圾收集过程的对象越是难以消亡。

  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

因此,垃圾收集器将堆根据年龄划分区域。其中新生代具有一个数据结构“记忆集”标识老年代中存在跨代引用的内存。

标记-清除算法

该算法是基础。先标记出所有需要回收的对象,标记完后,统一回收所有被标记对象。存在:碎片化、效率低的缺点。

其实JVM并不会直接回收内存空间,而是标记到空闲列表,需要时再分配。

标记-复制算法

根据弱分代假说,把内存划分为 1 块比较大的 Eden 区和 2 块较小的 Survivor 区。每次回收将处理 Eden 区和 1 块 Survivor 区,将以上两部分区中的存活对象复制到另一块 Survivor 区,再将以上两部分区域清空。如果目的区域的空间不足,可以依赖老生代的“分配担保区域”。

标记-整理算法

对于存活率较高的老生代区域,标记完后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。

移动存活对象的性能开销较大,但如此碎片化不可避免,影响程序的吞吐量。小部分收集器因此考虑使用“标记-清除方法”。

吞吐量:处理器处理用户代码的时间占总消耗时间的比值。

算法细节

  • 普通对象指针(OOP):快速完成GC Roots枚举。

  • 安全点:当垃圾收集发生,将所有线程在此点停留。实现机制是主动式中断。

    其思想为,线程执行时会不断地主动轮询标志位,若标记位为真则就近安全点挂起。

  • 安全区域:如果线程暂时无法响应中断请求,则安全区域保证该代码片段中,引用关系不发生变化,直到收到可以离开安全区域的信号。

  • “卡精度”(每个记录精确到一块内存区域)方式是实现记忆集的主流形式。其标识的内存区域块,称作“卡页”。

通常将堆空间划分为一系列2次幂大小的卡页(Card Page)。 卡表(Card Table),用于标记卡页的状态每个标记项为1个字节,每个卡表项对应一个卡页。实现机制:写屏障。

  • “卡表”需要进行维护。当其他分代区域对象引用本区域对象,则对应的卡表元素应标记脏。写屏障相当于对“引用类型字段赋值”切面 产生环形通知,供程序执行额外的工作。

为解决并发环境下对象引用的“引用链修改导致对象消失”问题,增量分析和原始快照是两种解决方案。前者是记录插入的新引用,后者是记录需删除的引用关系。

经典垃圾收集器

Serial

Serial收集器是简单而高效的传统收集器,缺点是进行垃圾收集工作时必须暂停所有的用户线程。

ParNew

ParNew收集器的改进仅是使用多线程并行进行垃圾收集。

此时的并行,表多条垃圾收集线程在协同工作,用户线程仍然处于等待状态

并发:多个执行流同时(不同步)执行同样的指令序列。表示收集器和用户线程均在运行。

Parallel Scavenge 收集器机制几乎和 ParNew 一样。其改进在于可以自定最大收集停顿时间、吞吐量大小。JDK 1.8 默认使用的是 Parallel Scavenge + Parallel Old。

Serial Old收集器是Serial收集器的老年代版本。

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,在注重吞吐量以及 CPU 资源稀缺的场合,可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

image-20220225214050776

CMS收集器

目标是最短回收停顿时间,在B/S架构下的web应用中广泛应用,是HotSpot 虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。其运作过程包含四个步骤:

  1. 初始标记。需要暂停用户线程。

  2. 并发标记。从GC Roots遍历对象图的过程。使用增量更新方案。

  3. 重新标记。需要暂停用户线程。

  4. 并发清除。因为不需要移动存活对象,因此与用户线程并发。

主要优点:并发收集、用户线程低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;

  • 无法处理浮动垃圾;

  • 它使用的回收算法(“标记-清除”算法)会导致收集结束时产生大量空间碎片。

为什么不使用 标记 - 整理 算法代替?为了保证线程安全,在整理时要对那个分隔指针加锁,保证同一时刻只有一个线程能修改它,加锁的这一过程相当于将并行的清理过程变成了串行的,也就失去了并行清理的意义了。

G1 收集器

开创了基于region的内存布局形式,主要面向服务端应用。其思想是:面向堆内存的任何部分组成回收集(CSet),以垃圾数量划分内存,把region作为回收的基本单位,各自独立并发垃圾回收。

虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。

from: https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.htmlG1 收集器由于其卡表的占用空间较大,因此维护的空间开销较大,根据弱生代假说,这是一个尚待优化的领域。

低延迟垃圾收集器

随着时代的进步,开发人员更关注于低延时。

Shenandoah

作为非Oracle开发的工具,独具一格地提供枷锁下的服务。

黄色的区域表CSet,绿色表存活,蓝色表用于分配的region

ZGC

ZGC的region是可变的,分为大型(4 MB)、中型(256 KB)、小型。

img

核心阶段是重定位阶段。其并发整理算法的实现应用了染色指针技术:在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,来自染色指针的信息。

分代分配策略

GC操作

  • 部分收集 (Partial GC):

    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集,发生频率高、速度快。

    • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集,经常伴随着至少一次的 Minor GC,速度一般比 Minor GC 慢上 10 倍以上。

    • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

  • 整堆收集 (Full GC):全量回收整个 Java 堆和方法区。当老年代空间满,或者说无法存下新生代依旧存活的对象时。

  • 关于前面提到的内存担保区域

    • 在发生 Minor GC 前,虚拟机检查老年代最大可用的连续空间是否大于新生代所有对象总空间,否则不安全,检查配置是否允许失败,是则尝试着进行一次 Minor GC,否则进行一次 Full GC。

    • 默认配置:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

分配策略

  • 对象优先在新生代(Eden)区分配。

    当 Eden 区没有足够空间进行分配时,将触发一次 Minor GC

  • 大对象直接进入老年代。

    • 大对象:需要大量连续空间(如3 MB)的对象。

    • 避免在 Eden 区和 Survivor 区发生内存的来回复制。

  • 长期存活的对象进入老年代

    • 固定对象年龄判定 虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,指导达到设定值

    • 动态对象年龄判定:Survivor 中有相同年龄的对象的空间总和占比过半,则年龄大于或等于该年龄的对象直接晋升到老年代。

Metaspace 元空间与 PermGen 永久代

Java 8 彻底将永久代 (PermGen) 移除出了 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Metaspace。

  • PermGen 内存经常会溢出

  • 方法区:移至 Metaspace(位于本地堆内存);

    默认的类的元数据分配只受本地内存大小的限制,也就是说本地内存剩余多少,理论上 Metaspace 就可以有多大

  • 字符串常量:移至 Java Heap。

虚拟机工具

基础故障处理工具

JDK命令行工具

在没有 GUI,只提供了纯文本控制台环境的服务器上,jstat将是运行期间定位虚拟机性能问题的首选工具,以处理延迟、卡顿的状况。上一章讨论的垃圾收集信息,即可通过jstat -gcutil vmid 进行显示。

实例:内存异常处理

  • OOM:堆空间不足,可使用jmap查看对象是否存在内存泄漏,调整-Xmx参数

  • SOE:栈空间不足,可调整-Xss参数增加栈空间

JDK可视化故障处理工具

  • JHSDB

  • JConsole

  • Visual VM

线上故障排查全套路,拿走不谢-51CTO.COM


参考:

Github TangBean的思维导图 《深入理解JAVA虚拟机》 https://www.baeldung.com/jvm-parameters

最后更新于

这有帮助吗?