likes
comments
collection
share

JVM知识总结

作者站长头像
站长
· 阅读数 64

JVM运行时数据区:

简介

JVM在执行Java程序过程中会将内存分为若干个不同的区域,这些区域被称为JVM运行时数据区。JDK1.8之前和之后略有不同。

JDK1.8之前: JVM知识总结 JDK1.8之后: JVM知识总结

不同之处:JDK1.8之前,方法区是由永久代实现的,位于JVM内存中;JDK1.8之后,方法区是由元空间实现的,位于直接内存中。

详述

线程私有的:程序计数器、虚拟机栈、本地方法栈

线程共享的:堆、方法区、直接内存 (非运行时数据区的一部分)

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

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

虚拟机栈: Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。

Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。 StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。 OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种错误。

: Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。 (注:为什么说几乎?因为随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了。从JDK 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。)

堆的分区: Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代。新生代又分为Eden空间、From Survivor、To Survivor空间等。 JVM知识总结

大部分情况(超大对象或者连续的数组会直接分配到老年代),对象会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1);之后每一次新生代垃圾回收,存活的对象会在s0 和 s1之间来回复制,且年龄增加1;当它的年龄增加到一定程度(对象晋升到老年代的年龄阈值,取决于老年代动态晋升阈值的计算),就会晋升到老年代中。 注:老年代动态晋升阈值的计算逻辑,Hotspot遍历当前Survivor 区所有对象,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold参数中更小的一个值,作为新的老年代晋升年龄阈值。

如何设置堆各区域的大小:

  • -Xms512m: 这个参数设置了JVM的初始堆大小为512MB。-Xms用于指定JVM启动时堆的初始大小。
  • -Xmx2g: 这个参数设置了JVM的最大堆大小为2GB。-Xmx用于指定JVM堆的最大可用内存大小。
  • -Xmn256m: 这个参数设置了年轻代的大小为256MB。-Xmn用于指定年轻代的大小,即新生代内存的总大小。
  • -XX:NewRatio参数用于设置年轻代与老年代的初始大小比例。它的值是一个整数,表示老年代大小相对于年轻代大小的比例。例如,如果设置为1,表示年轻代与老年代的大小相等;如果设置为2,表示老年代的大小是年轻代的两倍。默认值为2。
  • -XX:SurvivorRatio参数用于设置年轻代中Eden区和Survivor区的大小比例。它的值是一个整数,表示Eden区与Survivor区的大小比例。例如,如果设置为2,表示Eden区的大小是Survivor区的两倍。默认值为8。

方法区: 方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non—Heap(非堆),目的应该是与Java堆区分开来。 设置方法区大小的方式:

JDK1.8之前 -XX:PermSize=N //设置PermGen (永久代) 初始大小 -XX:MaxPermSize=N //设置PermGen (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK1.8之后 -XX:MetaspaceSize=N //设置 Metaspace(元空间)的初始大小 -XX:MaxMetaspaceSize=N //设置 Metaspace (元空间)的最大大小

运行时常量池: 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译器生成的各种字面量和符号引用)。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。 运行时常量池的位置变化: 1.JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区,此时hotspot虚拟机对方法区的实现为永久代。 2.JDK1.7字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代。 3.JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之,这时候字符串常量池还在堆运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace)。

类的加载和对象的创建过程

类的加载过程

类加载过程:加载 -> 连接(验证、准备、解析) -> 初始化

类加载器的类型以及它们之间的关系: 系统类(应用类)加载器 -> 扩展类加载器 -> 引导类加载器

双亲委派机制: 加载一个类会逐层向父级委托,直到引导类加载器,如果不属于引导类加载器加载范围(jvm核心类库),则逐级向下传递,交给正确的类加载器加载; 作用:保护核心类库代码不被破坏;

类加载的依赖传递原则: 一个classloader加载一个class后,这个class所引用或者依赖的类也由这个classloader载入,除非显示的用另一个classloader载入;

对象的创建过程

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。 JVM知识总结

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突

而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, init 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

垃圾收集

垃圾收集目标

无用的对象是垃圾收集的目标,怎么判断一个对象是否有用呢?有两种算法可以判断:

1. 引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

2. 可达性分析法

是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

垃圾收集算法有哪些

1. 标记-清除算法

该算法分为“标记”和“清除”阶段:首先比较出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:1. 效率问题;2. 空间问题(标记清除后会产生大量不连续的碎片)。 JVM知识总结 2. 标记-整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。解决了“标记-清除”算法的空间碎片问题。 JVM知识总结 3. 复制算法

以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。解决了“标记-清除”算法的效率问题。 JVM知识总结

4. 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。 比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

垃圾收集器有哪些

JVM知识总结 1. Serial 收集器

Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World"),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。 JVM知识总结 虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

2. ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。新生代采用复制算法,老年代采用标记-整理算法。 JVM知识总结

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器 (真正意义上的并发收集器,后面会介绍到)配合工作。 并行和并发概念补充: 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

3. Parallel Scavenge 收集器

Parallel Scavenge 收集器也是多线程收集器。新生代采用复制算法,老年代采用标记-整理算法。

JVM知识总结

它看上去几乎和ParNew都一样。 那么它有什么特别之处呢? Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在困难的话可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资 源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

4. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常适合在注重用户体验的应用上使用。CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  1. 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;

  2. 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。

  3. 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。

  4. 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

JVM知识总结

CMS 是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点1. 对 CPU 资源敏感;2. 无法处理浮动垃圾;3. 它使用的“标记-清除”算法会导致收集结束时会有大量空间碎片产生。 注:浮动垃圾:在 CMS 的并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。

5. G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化征。它具备以下特点:

  1. 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。

  2. 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。

  3. 空间整合:与 CMS 的“标记--清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

  4. 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收。

JVM知识总结

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1垃圾收集器采用了不同的并发标记机制,具体如下:

  • 原始快照(Snapshot-at-the-Beginning, SATB) : 在并发标记开始时获取所有活动对象的一个快照,并通过Remembered Set记录对象引用的变化。即使在并发标记过程中,用户线程改变了对象引用关系,只要引用关系变化发生在并发标记阶段之前,那么对应的对象仍会被正确地标记为存活或垃圾。
  • 增量更新(Incremental Update)的替代方案:不像CMS使用增量更新可能导致浮动垃圾,G1采用了一种机制,当并发标记期间发现灰色对象指向白色对象的新引用时,会把这些引用记录下来,确保在标记结束时,即使这些引用被断开,白色对象也能被正确标记为存活。

综上所述,虽然理论上G1垃圾收集器在设计上尽量避免了浮动垃圾的产生,但在实际运行中并不能完全杜绝浮动垃圾的存在,尤其是在并发标记阶段结束至下一次STW(Stop-The-World)之前的很短时间内,如果应用线程快速创建了大量的短生命周期对象,仍有可能生成一些浮动垃圾。不过,G1通过更加智能和灵活的垃圾收集策略以及更频繁的局部收集,通常能够更快地处理掉这些浮动垃圾,从而保持较高的收集效率和较短的停顿时间。

转载自:https://juejin.cn/post/7350924978680070194
评论
请登录