深入理解JVM

315 阅读10分钟

高效学习模型

what-->why-->how 模型 是什么?-->为什么使用?-->如何使用?-->实现原理-->总结分享(学以致用)

一. JVM内存区域(下图牢记10遍以上, 随手可画出)

JVM内存分配结构图.png

  1. Java堆(Java Heap) => 线程共享

    JVM管理内存最大的区域, 对象实例都是在堆中分配内存的, 而在JVM栈中分配的只是引用,这些引用会指向堆中真正存储的对象。 此外,堆也是垃圾回收器(GC)所主要作用的区域, 并且,内存泄漏也都是发生在这个区域。

  2. Java栈(JVM栈) => 线程私有

    描述的是Java方法执行的内存模型: 每个方法执行的时候都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、动态链接、方法出口等信息。

  3. 本地方法栈 => 线程私有

    不同与虚拟机栈为 Java 方法服务、它是为 Native 方法服务的。

  4. 方法区 => 线程共享

    存储常量、静态变量、类信息、JIT编译后的代码等。

  5. 程序计数器 => 线程私有

    存储当前线程执行的字节码的行号指示器。

二. JVM堆内存分配策略

Java对象主要分配在堆中, 故对堆做深入分析

堆内存结构图如下图所示:

Java堆结构.png 年轻代:

  • Eden区:

    新生对象主要在Eden区分配内存。

  • Survivor区:

    当Eden区无足够的内存分配时, JVM执行引擎会触发一次MinorGC, 若对象仍然存活并且能被Survivor容纳的话, 则将对象移动到Survivor区; 当对象在From区及To区中循环熬过15次MinorGc后还存活, 则将对象移至老年代。

老年代:

老年代内存空间不足时会触发MajorGC(FullGC), 当老年代中无足够内存分配时,则触发OOM。

新生代GC(MinorGC)与老年代GC(MajorGC/ Full GC)区别:

MinorGC: 发生在新生代的垃圾回收动作,频繁,速度快。
MajorGC: 发生在老年代的垃圾回收动作,出现了 Major GC 经常会伴随至少一次 Minor GC(非绝对)。Major GC 的速度一般会比 Minor GC 慢十倍以上。

三. Java内存回收机制

1. 如何判断对象占用的内存是否可回收 ?

可达性分析法:

通过一系列的 ‘GC Roots’ 的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连的时候说明对象不可用。 image.png 可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

2. 对象的引用与内存回收的关系?

  • 强引用

    类似于 Object obj = new Object()这类的引用; 只要强引用在就不回收。

  • 软引用

    SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。

  • 弱引用

    WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

  • 虚引用

    PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

四. 内存回收算法

微信图片_20210505193333.png

  • 白色: 可用内存; 蓝色: 存活对象; 灰色: 可回收对象

  • 标记阶段: 搜索内存中的Java对象(GC Roots), 对能搜索到的对象进行标记

  • 清除阶段: 释放那些未被标记的对象所占用的内存

    特点:

    • 标记和清除效率不高
    • 产生大量不连续的内存片段,碎片太多可能会导致后续没有足够的连续内容分配给较大的对象,从而触发新的一次垃圾回收动作 微信图片_20210505201659.png
  • 将内存划分为大小相等的两块

  • 一块内存用完之后复制存活对象到另一块

  • 清理另一块内存

    特点:

    • 实现简单, 运行高效
    • 浪费一半空间,代价大 微信图片_20210505202058.png
  • 标记过程与"标记-清除"算法一样

  • 存活对象往一端进行移动

  • 清理其余内存

    特点:

    • 避免标记 - 清除 导致的内存碎片
    • 避免复制算法的空间浪费
  • 分代收集算法

    根据对象存活的周期不同将内存划分为新生代和老年代, 进而根据各年代的特点采用最适当的收集算法;

    • 新生代: GC时会有大批对象死去, 少量对象存活, 故选用复制算法, 只需付出少量存活对象的复制成本即可完成回收;--->复制算法
    • 老年代: 对象存活率较高、没有额外的空间分配对它进行担保。所以必须使用 标记 - 清除 或者 标记 - 整理 算法回收。--->标记整理算法

五. 垃圾回收器

收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。

image.png 说明:如果两个收集器之间存在连线说明他们之间可以搭配使用。

  • Serial 收集器

    这是一个单线程收集器。意味着它只会使用一个 CPU 或一条收集线程去完成收集工作,并且在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。 image.png

  • ParNew 收集器

    可以认为是 Serial 收集器的多线程版本。

    image.png

    并行:Parallel

    指多条垃圾收集线程并行工作,此时用户线程处于等待状态

    并发:Concurrent

    指用户线程和垃圾回收线程同时执行(不一定是并行,有可能是交叉执行),用户进程在运行,而垃圾回收线程在另一个 CPU 上运行。

  • Parallel Scavenge 收集器

    这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。

    CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。

    作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是 GC 的自适应调整策略(GC Ergonomics)。

  • Serial Old 收集器

    收集器的老年代版本,单线程,使用 标记 —— 整理。

    image.png

  • Parallel Old 收集器

    Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,使用 标记 — 整理 image.png

  • CMS 收集器

    CMS (Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。基于 标记 — 清除 算法实现。

    运作步骤:

    1. 初始标记(CMS initial mark):标记 GC Roots 能直接关联到的对象
    2. 并发标记(CMS concurrent mark):进行 GC Roots Tracing
    3. 重新标记(CMS remark):修正并发标记期间的变动部分
    4. 并发清除(CMS concurrent sweep) image.png 缺点:对 CPU 资源敏感、无法收集浮动垃圾、标记 —— 清除 算法带来的空间碎片
  • G1 收集器

    面向服务端的垃圾回收器。

    优点:并行与并发、分代收集、空间整合、可预测停顿。

    运作步骤:

    1. 初始标记(Initial Marking)
    2. 并发标记(Concurrent Marking)
    3. 最终标记(Final Marking)
    4. 筛选回收(Live Data Counting and Evacuation) image.png

六. 什么是Java内存模型(Java Memory Model, JMM) ?

JMM是Java虚拟机定义的一种让Java程序在各种平台下都能达到一致的内存访问效果的机制及规范;

JMM对一些关键字提供特殊访问规则。如:synchronized、volatile以及并发包等;

七. 为什么使用JMM ?

目的是为了解决由于多线程通过共享内存进行通信时,存在的原子性、可见性、有序性问题。

  • 原子性(Atomicity)

    基本类型变量的访问读写是具备原子性的;

    方法和代码块类的原子性操作使用synchronized关键字。

  • 可见性(Visibility)

    可见性是指当一个线程修改了共享变量的值 , 其他线程能够立即得知这个修改;

    Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的;

    Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新;

    使用volatile来保证多线程操作时变量的可见性;

    synchronized和final也可实现可见性。

  • 有序性(Ordering):

    使用synchronized和volatile来保证多线程之间操作的有序性;

    volatile关键字会禁止指令重排;

    synchronized关键字保证同一时刻只允许一条线程操作。

八. JMM实现原理

  • CPU多核并发缓存架构图解:

    微信图片_20210523154951.png

  • JMM跟CPU缓存模型类似,是基于CPU缓存模型来建立的;

    图解如下:

    微信图片_20210523155006.png

    微信图片_20210523155006.png

  • JMM数据原子操作(8种):

    • read(读取): 从主内存读取数据
    • load(载入): 将主内存读取到的数据写入工作内存
    • use(使用): 从工作内存读取数据来计算
    • assign(赋值): 将计算好的值重新赋值到工作内存中
    • store(存储): 将工作内存数据写入主内存
    • write(写入): 将store过去的变量值赋值给主内存中的变量
    • lock(锁定): 将主内存变量加锁
    • unlock(解锁): 将主内存变量解锁, 解锁后其他线程可以锁定该变量
  • JMM缓存不一致问题(解决方案):

    总线加锁(性能太低):

    CPU从主内存读取数据到高速缓存, 会在总线对这个数据加锁, 这样其它CPU没法去读或写这个数据, 直到这个CPU使用完数据释放锁后其它CPU才能读取该数据.

    MESI缓存一致性协议:

    多个CPU从主内存读取同一个数据到各自的高速缓存, 当其中某个CPU修改了缓存中的数据, 该数据会马上同步回到主内存, 其它CPU通过总线嗅探机制可以感知到数据的变化从而将自己缓存中的数据失效.

  • volatile的理解及缓存可见性实现原理

    volatile是JVM提供的最轻量级的同步机制;

    被volatile修饰的变量具备两种特性: a.可见性: 当一条线程修改一个变量的值后, 新值对其它线程来说是可以立即感知的; b.禁止指令重排序

    底层实现主要通过汇编lock前缀指令, 它会锁定这块内存区域的缓存并回写到主内存, 此操作被称为"缓存锁定", MESI缓存一致性协议机制会阻止同时修改两个以上处理器缓存的内存区域数据;

    一个处理器的缓存值通过总线回写到内存会导致其他处理器相应的缓存失效.

     public class VolatileVisibilityTest {
      private static volatile boolean initFlag = false;
      public static void main() throws InterruptedException{
       new Thread(new Runnable(){
        @Override
        public void run(){
         System.out.printIn("准备数据...")
         while(!initFlag){
        }
         System.out.printIn("准备数据完毕...,执行程序")
        }
     }).start();
     Thread.sleep(2000);
     new Thread(new Runnable(){
       @Override
       public void run(){
        prepareData();
       }
     }).start();
     }
     public static void prepareData(){
      System.out.printIn("准备数据中...")
      initFlag =true;
      System.out.printIn("准备数据完毕!")
     }
    

上述代码可分别验证变量是否被volatile修饰的结果

代码执行流程如下图分析所示: image.png