JVM基础(内存管理,GC机制,类加载)

896 阅读13分钟

一、内存管理机制

1.1运行时数据区域

1. 程序计数器

作用:PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

2. Java虚拟机栈

作用:主管Java程序的运行,它保存方法的局部变量,部分结果,并参与方法的调用与返回。

Q:栈中存储什么?

  • 每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种信息。

Q:栈帧的内部结构是什么?

栈帧中存储着:

  • 局部变量表 (定义一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量)
  • 操作数栈 (在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈)
  • 动态链接(指向运行时常量池的方法引用)
  • 方法返回地址
  • 一些附加信息

3.本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

4. Java 堆

堆空间细分

对象分配过程
  1. new 的对象先放在Eden区,此区大小有限制。
  2. 当Eden区填满时,程序又需要创建对象,JVM 的垃圾回收器会对Eden进行垃圾回收(Minor GC),将Eden中不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区。
  3. 将在Eden中存活的对象移动到Survivor 0区。
  4. 如果再触发垃圾回收,上次在Survivor 0区的对象还没有的被回收的话, 就会从 Survivor 0区放到 Survivor 1 区。
  5. 如果再经历一次垃圾回收,会重新由1->0 ,如此反复。
  6. 默认对象在0->1复制的此次数为15时可以放到养老区。
  7. 当养老区内存不足时,会触发Major GC。
  8. 若Major GC后仍内存不足,就会产生OOM异常。

对象分配

内存分配策略

  • 优先分配到Eden
  • 大对象直接分配到老年代
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
  • 空间分配担保

补充: JVM 为每个线程分配了一个私有缓存区域(TLAB),包含在Eden空间中,避免多个线程操作同一个地址,使用TLAB可以避免一系列非线程安全问题。

对象分配过程TLAB

Q:堆是分配对象的唯一选择吗?

没有发生逃逸的对象,可以分配到栈上,随着方法执行的结束,栈空间就被移除。

5.方法区

概念:方法区看作是一块独立于Java堆的内存空间。

内部结构

  • 类型信息
  • 运行时常量池(常量池表用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中)
  • 静态变量
  • JIT代码缓存
  • 域信息
  • 方法信息

方法区的演进细节

Q:为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  • 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  • 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

二、GC (垃圾回收)机制

Q1:什么是垃圾?

垃圾是指在运行程序中没有被任何指针指向的对象,这个对象就是需要被回收的垃圾。

Q2:为什么要进行垃圾回收?

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出

Q3:垃圾回收的区域?

  • 方法区
  • 堆(工作重点)
    • 频繁收集年轻代
    • 较少收集养老代
    • 基本不动元空间

2.1垃圾回收相关算法

1.标记阶段(判断对象存活)

a. 引用计数算法

原理:对每个对象保存一个整形的引用计数器属性。用于记录对象被引用的情况。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,增加了时间开销。
  • 无法处理循环引用问题

b.可达性分析算法

原理:

  • 以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称为引用链
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为对象。
  • 在可达性分析算法中,只有被根对象集合直接或者间接连接的对象才是存活对象。

Q: GC Roots 是什么元素组成的?

  • 虚拟机栈中引用的对象
  • 本地方法栈内JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象,如Java类的引用类型静态变量
  • 方法区中常量引用的对象,如字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等。

补充:除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

2. 清除阶段(区分内存存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放无用对象所占用的内存空间)

a.标记-清除算法

过程:

  • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。
  • 清除:Collector对堆内存从头到尾进行线性的遍历,回收没有被标记为可达的对象。

标记-清除算法

缺点:

  • 效率不算高
  • 进行GC时,需要停止整个应用程序(STW),导致用户体验差
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表

b.复制算法

思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

复制算法

优点:

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题

缺点:

  • 需要两倍的内存空间
  • 复制意味着GC需要维护region之间对象引用关系,内存占用和时间开销大

补充:年轻代适合复制算法,因为年轻代一般存活对象少,复制的数量少。

c.标记压缩算法

标记:Collector从引用根节点开始遍历,标记所有被引用的对象。

压缩:将所有存活的对象压缩到内存的一端,按顺序排放,之后清理边界外所有的空间。

也叫 标记-清除-压缩 算法。

优点:

  • 消除了标记-清除算法中内存区域分散的缺点。
  • 消除了复制算法中,内存减半的高额代价。

缺点:

  • 效率低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全程暂停用户应用程序(STW)。

算法对比

d.分代收集算法

目前几乎所有的GC都是采用分代收集算法执行垃圾回收的。

在HotSpot中

  • 年轻代(复制算法)
  • 老年代(标记清除/标记清除与标记压缩混合实现)

e.增量收集算法

思想:垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直至垃圾收集完成。

缺点:因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

f.分区算法

思想:将整个堆空间划分成连续的不同小区间,每个小区间都能独立使用,独立回收。可以控制一次回收多少个小区间。

分区算法

2.2 内存溢出(OOM)和内存泄漏

Q1:内存溢出的原因?

  • Java虚拟机的堆内存设置不够
  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

Q2:内存泄漏是什么?

严格来说,只有对象不会再被程序用到,但是GC又不能回收他们的情况,才叫内存泄漏。

但实际情况很多时候一些不太好的实践会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

举例:

  1. 单例模式

单例的生命周期和应用程序一样长,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

2.3 再谈引用

四种引用

补充:

  • 强引用可以直接访问目标对象

  • 强引用所指向的对象在任何时候都不会被系统回收(就算出现OOM也不会)

  • 强引用可能会导致内存泄漏

2.4 垃圾回收器

垃圾收集器

  1. Serial回收器:串行回收

特点:采用复制算法、串行回收和STW机制,用于年轻代垃圾回收。

Serial Old 用于老年代回收,采用串行回收和STW机制,标记压缩算法。

优势:简单高效,适用于单cpu环境。

工作流程

  1. ParNew回收器:并行回收

特点:与Serial回收器的区别是,一个是并行,一个是串行,也是采用复制算法,STW机制

老年代回收使用Serial Old 或者CMS配合。

工作流程

  1. Parallel回收器:吞吐量优先

特点:采用复制算法、并行回收和STW机制。可以控制吞吐量大小,这是与ParNew的重要区别。

Parallel Old采用 标记-压缩算法,并行回收和STW机制。

工作流程

  1. CMS回收器:低延迟

特点:采用标记-清除算法,STW机制,是老年代的收集器,不能与Parallel配合工作,年轻代只能选择ParNew或者Serial收集器中的一个。

工作流程

  • 初始标记阶段:STW,任务仅仅是标记出GC Roots能直接关联到的对象,速度非常快。
  • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时长,但不需要停顿用户线程,垃圾收集线程与用户线程并发运行。
  • 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,停顿事件比初始标记稍长,但远比并发标记阶段时间短。
  • 并发清除:清除除掉标记阶段判断的已经死亡的对象,释放内存空间,与用户线程并发。

Q:为什么CMS不把清除算法换成清除压缩算法以消除内存碎片的影响?

并发运行的环境下,标记压缩算法会更改对象的引用地址,用户线程会受到影响。

CMS缺点:

  • 会产生内存碎片
  • CMS收集器对CPU资源非常敏感
  • CMS收集器无法处理浮动垃圾
  1. G1回收器:区域化分代式

G1跟踪各个Region里面堆积的价值大小(回收所获得的控件大小以及回收所需要的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

G1使用了全新的分区算法,有以下特点:

  • 并行与并发
  • 分代收集

G1工作主要包括以下三个环节

  • 年轻代GC
  • 老年代并发标记过程
  • 混合回收

G1回收过程一:年轻代GC

G1回收过程二:并发标记过程

G1回收过程三:混合回收

G1回收可选的过程四:Full GC

三、类加载机制

类加载的生命周期

类的加载过程

1. 加载

  • 通过类的完全限定名称获取定义该类的二进制字节流。(可以有各种来源)
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
  • 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

2. 验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

3. 准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。

4. 解析

将常量池的符号引用替换为直接引用的过程。

其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

5. 初始化

初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit\>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。


参考:

尚硅谷JVM全套教程,百万播放,全网巅峰(宋红康详解java虚拟机)

《深入理解Java虚拟机》