JVM基础


Java内存区域

线程私有

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

程序计数器

作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 多线程情况下,程序计数器用于记录当前线程执行的位置,从而当线程被还回来的时候能够知道线程上次运行到哪。 注意:程序计数器是唯一一个不会出现OOM的内存区域,它的生命周期随着线程的创建而创建,随线程的结束而死亡。

虚拟机栈

与程序计数器一样,线程私有,生命周期与线程相同,描述的是java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。 局部变量主要存放了编译器可知的基本数据类型,对象引用。

会出现两种错误StackOverFlowError 和 OutOfMemoryError。

本地方法栈

虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

几乎所有对象都在堆中分配,如果某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接在栈上分配内存。

在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

上图所示的Eden区、两个Survivor区都属于新生代,中间一层属于老年代

大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden 区 转移到 Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 方法区也被称为永久代。方法区和永久代关系类似java接口和类的关系,类实现接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。

对象的创建过程

1. 类加载检查

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

2. 分配内存

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

内存分配两种方式:

由于创建对象是很频繁的事情,虚拟机必须要保证线程安全,一般使用两种方式:

  • CAS+失败重试
  • TLAB:首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

3. 初始化零值

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

4. 设置对象头

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

5. 执行init方法

上面工作完成后,一个新的对象以及产生,但从程序上看,对象的创建才刚开始,init方法还没有执行,所有字段都还为零。所以一般执行new指令后还会接着执行init方法。

对象分配的基本策略

  1. 优先在eden区分配
  2. 大对象直接进入老年代
  3. 长期存活对象直接进入老年代

判断对象是否存活的方法

堆中几乎放着所有的对象的实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡

引用计数法

给对象添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器减1;任何时候计数器为0就说嘛对象不可被再继续使用。但是可能会存在循环引用导致垃圾不可被回收。

可达性分析算法

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

GC Roots包括:

  1. 全局性引用,方法区的静态对象,常量对象的引用
  2. 执行上下文,对方法栈中的局部对象引用

强引用,软引用,弱引用,虚引用

强引用

垃圾回收器绝对不会回收它。当内存空间不足,虚拟机宁愿跑出OOM也不会回收

软引用

如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。

弱引用

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存

虚引用

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

垃圾回收算法

标记-清除算法

  • 原理
  1. 先根据可达性算法标记处相应的可回收对象
  2. 可回收的对象进行回收
    • 缺点 由于操作简单且不用做移动数据的操作,会导致回收对象后,存在不连续的内存碎片

复制算法

  • 原理
  1. 把堆等分成2块区域A、B,区域A负责非配对象,区域B不分配
  2. 对区域A使用标记清除算法把存活对象标记出来,然后把区域A中存活的对象都复制到区域B
  3. 最后把A区对象全部清理释放,解决了内存碎片问题
    • 缺点
  4. 使用空间变少
  5. 回收需要移动存活对象。效率低

标记-整理算法

  • 原理

前面两步骤与标记清除算法一样,不同的是它在标记清除算法的基础上添加了一个清理的过程,将所有存活的对象都往一端移动紧密排列,再清理另一段的所有区域

  • 缺点 每次垃圾清除都要频繁移动存活对象,效率较低

分代收集算法

  • 原理
    1. 对象在新生代的分配与回收,对象一般分配在Eden区,大部分对象在短时间都会被回收,所以Minor GC之后少部分存活的对象会被移动到S0,同时对象年龄+1,最后把Eden区对象清理释放空间
    2. 当触发下一次Minor GC,会把Eden区存活对象和S0中的存活对象一起移动到S1,并把Eden与S0的存活对象年龄+1,同时清空Edne与S0空间
    3. 若再触发Minor GC,则继续重复上一步,只不过是把Eden与S1存活堆上复制到S0,并把存活对象年龄+1。也就是Eden使用复制算法
  • 对象什么时候晋升老年代
    1. 对象的年龄达到了阈值,则会从S0/S1晋升为老年代
    2. 大对象,当某个对象分配需要大量的连续内存,此时对象的创建不会分配在Eden区,会直接分在老年代(如果分在Eden区,大对象的移动与复制会有较大的开销,且容易占满Survivor区)
    3. S0/S1区相同年龄对象的大小之和大于S0/S1空间一半,年龄大于该年龄对象也会晋升到老年代

常见的垃圾回收器

Serial收集器

新生代采用复制算法,老年代采用标记-整理算法

ParNew收集器

实际上就是Serial收集器多线程版本,除了使用多线程进行垃圾收集外,其余与Serial收集器一样。新生代采用复制算法,老年代采用标记-整理算法

CMS收集器

  • 原理 是一种以最短回收停顿时间为目的的收集器。即CMS收集器工作时,GC工作线程与用户线程可以并发执行,依次达到降低收集停顿时间的目的

  • 过程
    1. 初始标记:暂停所有其他线程,并记录下直接与root连接的对象,速度快;
    2. 并发标记:收集垃圾与用户线程一起执行,并发标记过程就是进行GC ROOts Tracing的过程
    3. 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
    4. 并发清除:开启用户线程,同时GC线程开始对未标记的区域做清扫
  • 缺点
    1. 对cpu敏感:比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
    2. 无法处理浮动垃圾:由于在清理并发阶段用户线程还在进行,所以清理的同时垃圾依旧在产生,这部分垃圾只能在下一次GC时再继续清理
    3. 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1收集器

  • 特点
    1. 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间
    2. 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念
    3. 空间整合:与 CMS 的“标记–清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
    4. 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
  • 过程
    1. 初始标记:仅仅标记以下GC Roots能直接关联到的对象
    2. 并发标记:从GC Roots开始堆中对象进行可达性分析,找出存活的对象
    3. 最终标记:是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
    4. 筛选回收:对各个区域的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划

CMS与G1区别

  1. G1降低了内存空间碎片
  2. 垃圾回收过程不一样
  3. SWT(stop the wrold),CMS以最小停顿时间为目标,G1可预测垃圾回收的停顿时间
  4. 使用范围不一样,CMS是老年代的收集器,需要配合新生代收集器一起使用;G1收集范围是老年代和新生代



Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • 2379. Minimum Recolors to Get K Consecutive Black Blocks
  • 2471. Minimum Number of Operations to Sort a Binary Tree by Level
  • 1387. Sort Integers by The Power Value
  • 2090. K Radius Subarray Averages
  • 2545. Sort the Students by Their Kth Score