JVM:JVM笔记

169 阅读14分钟

JVM笔记

内存结构

image-20230507225411346

1. 程序计数器

1.1 作用

  • 记住下一条JVM指令的执行地址

    image-20230507231547606

    源代码编译成JVM代码,解释器获取到JVM代码发送给CPU执行。而程序计数器则是==保存下一条执行JVM命令的地址==

  • 特点

    • ==线程私有==。在多线程情况下,记录每个线程自己的执行进度,互不干扰。
    • 不会存在内存溢出。

2. 虚拟机栈

2.1 定义

  • ==线程私有==

  • 虚拟机栈就是线程的运行空间,栈的长度就代表着运行需要的内存大小。

  • 每个栈由多个栈帧组成,每个栈帧都对应着一个方法。栈帧代表着对应方法运行时所占用的内存大小。

  • 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法。

  • 每个方法被调用,都会同步创建一个栈帧进行压栈。这个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕,都对应这一个栈帧的入栈和出战。

  • 局部变量表中包含了编译器期间可知的Java虚拟机的基本数据类型、对象引用(reference)和returnAddress。通常我们说的栈就是指虚拟机栈,更多情况下指的是虚拟机栈中局部变量表的部分。

    虚拟机栈

代码举例:

public class VmStack {
    public static void main(String[] args) {
        new VmStack().function01();
    }

    public void function01() {
        this.function02();
        System.out.println("function01 is using");
    }

    public void function02() {
        this.function03();
        System.out.println("function02 is using");
    }

    public void function03() {
        System.out.println("function03 is using");
    }
}

如图所示

  1. 我们可以看到这个方法有一个虚拟机栈和四个栈帧

    image-20230507234716540
  2. 当mian方法启动的时候,main方法被调用,所以压栈

    方法内的局部变量表所需要的内存空间在编译期间就已经完成分配了,所以当方法内局部变量表需要的空间已经完全确定,也就是说栈帧中保存局部变量内存空间的局部变量表大小已经完全确定,在方法运行期间不会修改。

    局部变量表保存了对应方法需要哪几种类型的、每种有多少个的局部变量。

    简单点,就是这个方法有多少个局部变量已经写死,不能修改。但是局部变量可能需要的空间会修改,比如你不断的向一个集合中添加数据直到挤满。

    image-20230507234851132
  3. 这时候main方法调用了function01方法,所以function01被压栈

  4. 以此类推,方法直接的调用到最后全部方法压栈

    image-20230507235254706

    此时执行VmStock这个方法的虚拟机栈所需内存大小就是这四个栈帧。

    此时的活动栈帧就是function03,也就是栈顶。==每个栈只有一个栈顶,也就只有一个活动栈帧==。

  5. 当function03执行完毕之后,就会弹栈,此时的虚拟机栈大小就由四变三了

    ==每一个方法被调用直至执行完毕,都对应这一个栈帧在虚拟机栈中入栈到出栈的过程。==

    image-20230507235724181
  6. 伴随着逐渐的弹出,栈帧逐渐减少,直至清空

2.2 问题解析

  1. 垃圾回收是否涉及栈内存?

    • Java的垃圾回收主要负责回收堆内存中的无用对象,栈内存中的对象则有JVM自行管理,不受垃圾回收影响。如果在递归方法中不断地回调没有设置出口,就会出现栈溢出异常StackOverflowError
  2. 占内存分配越大越好吗?

    • 物理内存的大小是固定的,每个栈内存越大,线程数越少。
  3. 方法内的局部变量是否线程安全?

    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全的

      public class Main {
          public void method01(){
              String str = "此字符串没有逃逸";
          }
      } 
      
    • 如果是局部变量引用了对应或者局部变量逃离方法的作用范围,则需要考虑线程安全

      public class Main {
          private List<Integer> list = new ArrayList<>();
      
          public void addToList(int n) {
              list.add(n);
          }
      
          public List<Integer> getList() {
              return list;
          }
      
          public static void main(String[] args) {
              Main obj = new Main();
              obj.addToList(1);
              obj.addToList(2);
              List<Integer> list = obj.getList();
              obj = null; // 对象的引用被设为 null,但对象本身仍然存在
              new Thread(() -> {
                  // 在另一个线程中访问 list
                  for (int i : list) {
                      System.out.println(i);
                  }
              }).start();
          }
      }
      
    • 什么是方法逃离作用范围

      当方法中的局部变量引用了一个对象,如果该对象在方法结束后仍然被其他线程所引用,就会出现线程安全问题。

2.3 栈内存溢出

  • 什么是栈内存溢出?

    • 当线程请求的栈深度大于虚拟机允许的深度,就会抛出StackOverflowError异常。

      可以通过递归、死循环等方式可以实现,就是不断的向虚拟机栈中压栈指导栈空间不足。

    • 如果Java虚拟机栈可以动态扩容,当栈扩容时无法获取足够的内存就会抛出OutOfMemoryError异常

      在HotSpot虚拟机中栈容量是无法动态扩容的,所以不会因为动态扩容导致OutOfMemoryError异常,只要线程申请栈空间成功则不会出现OOM。但是如果申请失败了就会出现OOM异常。

3. 本地方法栈

3.1 定义

  • ==线程私有==
  • 本地方法栈和虚拟机栈的作用类似,区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈为虚拟机执行本地方法服务。

4. 堆

4.1 笔记

  • ==线程公有==
  • 此内存区域的唯一目的就是存放对象实例。
  • Java堆是垃圾回收器管理的区域
  • 从内存分配的角度上看,堆中可以划分出多个线程私有的分配缓冲区(TLAB),以提升对象分配时的效率。
  • 如果Java堆中没有内存完成实例的分配,并且堆也无法再扩展时,Java虚拟机就会抛出OutOfMemoryError异常。
  • 一般来说Java规范对堆的约束时,物理上存储可以不连续,但是逻辑上需要连续。如果在面对像数组这类的大对象的时候,为了方便保存也会要求保存该对象时候物理存储上连续。

5. 方法区

5.1 定义

  • ==线程公有==
  • 用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译后的代码缓存等数据。

5.2 永久代和元空间

  • 方法区在不同的时期有着不同的表现,接下来我们根据jdk1.7和jdk1.8分开讨论
    • jdk1.7之前,当时的设计团队选择将Java堆一样设计模式实现方法区,此时就叫做永久代。永久代受JVM管理,固定大小不会自动扩展,GC受JVM调配有的时候清除缓慢。它的大小是固定的,不会自动扩展,因此在某些情况下可能会导致内存溢出的问题。
    • jdk1.8之后,设计团队使用了元空间的方式来实现方法区。元空间不再使用固定大小内存,而是使用本地内存,因此它可以动态分配和释放内存。元空间中存储了类的元数据,和永久代相比,元空间具备更好的内存管理和回收机制。在jdk1.8之后的字符串常量池被放在了Java堆的新生代中,而静态变量则放在了老年代当中。

5.3 运行时常量池

  • 无论是在永久代或者元空间的时期,运行时常量池都是方法区的一部分。
  • Class文件中包含了一项信息叫做常量池表。该表用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • Java虚拟机对Class文件的每一部分都有要求,但是对于运行时常量池没有任何要求,任何供应商实现的虚拟机都可以按照自己的要求实现对应的运行时常量池。
  • 运行时常量池和Class常量池不同,它具有动态性。不要求常量在编译期产生,可以在运行时将新的常量放入常量池当中。
  • 运行时常量受到JVM的影响,如果无法申请空间也会出现OOM

虚拟机对象探秘

1. 对象的创建

1.1 类加载检查

当Java虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。

1.2 对象分配内存

我们根据内存是否规整有两种方法去分配

  1. 内存规整的情况下,所有使用过的内存空间都被放在一边,空闲的内存被放在另一边,中间有一个指针作为分界点的指示器。当需要内存分配的时候只需要将指针向空间内存挪动一段大小与对象相等的距离即可。这种分配方式称为==指针碰撞==。通常在复制清除算法、标记整理法都可以让内存规整。
  2. 内存不规整的情况下,Java堆会维护一个列表,上面记录哪些内存快可用和大小。当需要分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。这种分配方法称为==空闲列表==。标记清除法就会出现内存碎片,让内存不规整。

在内存分配的时候,除了是否规整之外的,在高并发的情况下,可能会出现线程安全问题。

解决这个问题由两个可选方案

  1. ==CAS+失败重试==:失败则重试,一直到成功为止的方式保证更新操作的原子性
  2. 本地线程分配缓冲:为每个线程在Java堆上预分配一小块内存。每个线程使用内存的时候优先使用预分配内存。如果预分配内存不足则使用==CAS+失败重试==的方法重新获取一块预分配内存。

1.3 对象属性初始化零值

此步操作保证了对象的实例字段在Java代码中可以不赋值直接使用,使程序能访问这些字段的数据类型所对应的零值。

1.4 对象头设置

对象头中包含了:该对象是哪个类的实例、类的元数据、对象的哈希码、对象的GC分代年龄。还会根据是否使用偏向锁、轻量锁、重量锁对对象头有不同的设置方式。

1.5 调用构造方法

从虚拟机视角而言,对象已经创建了。对于Java程序而言,对象创建才刚刚开始。这一步骤会调用程序员设置的构造方法,对对象进行初始化,这样一个真正的对象才算被完全构造出来。

对象创建过程

2. 对象的内存布局

对象在Java堆中的布局可以划分为三个部分:对象头、实例数据、对齐填充

2.1 对象头

对象头包含了两部分信息。

  • 第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态表示、线程持有做、偏向线程ID、偏行时间戳等。这部分信息官方称为Mark Word,是一个有着动态定义的数据结构。会根据锁状态的不同,Mark Word也会改变,但是包含的信息量不变。
  • 第二部分是类型指针,即对象指向它的类型元数据的指针。Java虚拟机会通过这个指针来确定该对象是哪个类的实例。
  • (不一定存在)第三部分是记录数组长度的数据。因为虚拟机需要通过普通Java对象的元数据信息确认Java对象的大小,如果数组的长度不确定的,将无法通过元数据中的信息推断出数组的大小。(Java对象的元数据就是指对象头)

2.2 实例数据

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字 段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来

2.3 对齐填充

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作 用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是 任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

3. 对象的访问定位

创建对象是为了使用对象,虚拟机栈会通栈上的reference数据来访问堆上的具体对象。==主流的访问方式有使用句柄和直接指针两种==。

3.1 使用句柄

  • 如何定位

    Java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象示例数据与类型数据各自的具体地址。这种访问方式的优势是稳定句柄地址,在对象被移动的时候只用改变句柄中的示例数据指针,而reference本身不需要修改。

    使用句柄

3.2 直接指针

  • 如何定位

    直接指针访问,reference中存储的直接就是对象的地址,对象的元数据则通过[对象头](#2.1 对象头)的类型指针来指向。这种方式的优势就是速度更快,不需要多一次简介访问的开销。

    直接指针

垃圾收集器与内存分配策略

1. 判断对象是否存活

1.1 引用计数法

  • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器加一;当引用失效的时候,计数器减一;任何时刻引用计数器为零的对象就是不可能在被使用的对象。此时的对象就可以被清除。

1.2 可达性分析

  • 可达性分析算法

    • 通过一系列称为“GC Roots”的根节点作为起始节点,从这些节点根据引用关系向下深度遍历。可到达的对象就是可用对象,不可达的对象就代表这个对象不可能再被使用。
  • GC Roots有哪些

    1. 在虚拟机栈中引用的对象
    2. 方法区中静态属性引用的对象
    3. 方法区中常量引用的对象
    4. 本地方法栈中JNI引用的对象
    5. Java虚拟机内部引用,如NullPointException、OutOfMemoryError等,还有系统类加载器
    6. 所有被同步锁(synchronized关键字)持有的对象
    7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

    除了上述对象可以作为GC Roots,还会根据用户选择的垃圾收集器来选定某一些对象作为GC Roots。两者结合起来就是完整的GC Roots集合。

2. 引用

2.1 强引用

  • 强引用就是传统的引用,通过Object obj = new Object()来创建的引用关系。==无论什么情况下,只要强引用关系还在,垃圾收集器就永远不会回收被引用的对象==。

2.2 软引用

  • 用于描述一些有用,但是非必须的对象。==被软引用关联的对象,在系统即将发生内存溢出之前就会进行回收==。如果本次回收过后依然没有足够的内存,才会抛出内存溢出异常。

2.3 弱引用

  • 用于表述非必须的对象,但是非必须程度比软引用关联的对象更弱。这些对象只能存活到下一垃圾收集器工作之前。==一旦发生垃圾回收,无论当前内存是否足够,都会被回收掉==。

2.4 虚引用

  • 为一个对象设置虚引用关联的唯一目的就是能够在这个对象被收集器回收时收到一个系统通知。

3. 垃圾回收

3.1 二次标记

即使在可达性分析中不可达的对象也不是马上清除的,这时候是处于一个缓刑阶段。要宣布一个对象真正死亡,至少经历两次标记过程:通过可达性分析对不可达对象进行标记,

  • 什么是二次标记

    当第一次GC