JVM 学习

发布时间 2023-10-21 22:47:59作者: Cheyaoyao

目录

1. 类加载器及类加载过程

1.1 基本流程


首先会把字节码文件加载到内存当中,加载由类加载子系统负责,在内存中(方法区)生成class对象,以及对一些必要的属性,比如静态属性初始化,当真正去执行字节码指令的时候,执行引擎按照字节码指令依次执行,这个过程需要从栈中的局部变量表中取数据、操作操作数栈、创建对象就要堆空间、指令依次往下走需要程序计数器、调用本地方法需要本地方法栈

1.2 类加载器子系统作用

1.3 类加载器角色

1.4 加载过程

(1) 加载 loading

在方法区存储的是这个类的描述信息(元数据),Class对象是存放在堆中,为程序访问这个类的类型信息提供了入口。
元数据包括:类的方法代码,变量名,方法名,访问权限,返回值等等。

  • 外部可以通过访问代表 Order 类的 Class 对象来获取 Order 的类数据结构

(2) 链接 linking

验证 verify

到这里之前,已经在内存中生成一个Class实例了

  • 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中
  • 格式验证之外的验证操作将会在方法区中进行
准备 prepare
  • 为类变量(静态变量)分配内存并且设置该类变量的默认初始值,即零值;在方法区进行
  • 这里不包括用final修饰的static,因为final在编译时就会分配了,准备阶段会显式赋值
  • 这里不会为实例变量初始化,因为实例还没创建,实例变量会跟着实例分配在堆中
  • 在这个阶段不会像初始化阶段中那样会有初始化或者代码被执行
解析 resolve

符号引用就是这种东西:

(3) 初始化 init

  • 简言之,为类的静态变量赋予正确的初始值
  • 如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕

static 与 final 的搭配问题

/**
 *
 * 哪些场景下,Java 编译器就不会生成<clinit>()方法
 */
public class InitializationTest1 {
  //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
  public int num = 1;
  //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
  public static int num1;
  //场景3:比如对于声明为 static final 的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
  public static final int num2 = 1;
}
/**
 *
 * 说明:使用 static + final 修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?
 * 情况1:在链接阶段的准备环节赋值
 * 情况2:在初始化阶段<clinit>()中赋值
 *
 * 结论:
 * 在链接阶段的准备环节赋值的情况:
 * 1. 对于基本数据类型的字段来说,如果使用 static final 修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行
 * 2. 对于 String 来说,如果使用字面量的方式赋值,使用 static final 修饰的话,则显式赋值通常是在链接阶段的准备环节进行
 *
 * 在初始化阶段<clinit>()中赋值的情况
 * 排除上述的在准备环节赋值的情况之外的情况
 *
 * 最终结论:使用 static + final 修饰,且显示赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行
 */
public class InitializationTest2 {
    public static int a = 1; //在初始化阶段<clinit>()中赋值
    public static final int INT_CONSTANT = 10;  //在链接阶段的准备环节赋值

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);   //在初始化阶段<clinit>()中赋值
    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000); //在初始化阶段<clinit>()中赋值

    public static final String s0 = "helloworld0"; //在链接阶段的准备环节赋值
    public static final String s1 = new String("helloworld1"); //在初始化阶段<clinit>()中赋值

}

1.5 类加载器

简要介绍

  • 加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader。
  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

简单来说:类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。

各加载器之间的关系


也可以分为两类:一类是启动类加载器,一类是继承了ClassLoader类的类加载器


启动类加载器 (引导类加载器,Bootstrap ClassLoader)

扩展类加载器 (Extension ClassLoader)

加载核心包之外的扩展包

应用程序类加载器 (系统类加载器,AppClassLoader)

自定义类加载器

  1. 什么时候需要自定义类加载器

    1. 想加载非classpath随意路径中的类文件
    2. 都是通过接口来使用实现,希望解耦时,常用在框架设计
    3. 这些类希望予以隔离,不同应用的同类名类都可以加载,不冲突,常见于tomcat容器
  2. 步骤

  3. 代码示例

  • 如果类的包名类名相同、类加载器也是同一个,才认为两个类一致

1.6 双亲委派机制

类加载器加载规则

  • Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。
  • 而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

工作原理


类加载器怎么找这个类的呢?
会根据包名类名查找,比如:这里就不会加载自定义的java.lang.String类,而是加载核心类库里的

优势

  • 避免类的重复加载;父加载器加载了子加载器就不会加载了
  • 保护程序安全,防止核心api被随意篡改
    • 自定义java.lang包里定义类,会报错

2. JVM内存结构

2.1. 程序计数器

  • 程序计数器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令
  • 每个线程都有属于自己的程序计数器,是线程私有的,生命周期和线程周期保持一致

两个作用:

  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • JVM的字节码解释器需要通过改变程序计数器的值来明确下一条要执行的字节码指令

2.2 java虚拟机栈

  • java的方法调用都是通过栈实现的,栈由一个个栈帧组成,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
  • 局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

一些问题:

  • 举例栈溢出的情况?
    • StackOverflowError
  • 调整栈大小,就能保证不出现溢出吗?
    • 不能保证,比如,如果递归是死循环,总会出现超出内存的情况
  • 垃圾回收是否会涉及到虚拟机栈?
    • 不会的!通过出栈就相当于把垃圾回收了(栈帧会被销毁)
  • 方法中定义的局部变量是否存在安全问题?
    • 如果只有一个线程才可以操作此数据,则必是线程安全的,因为栈是线程私有的
    • 如果有多个线程可以操作此数据,则此数据是共享数据,不考虑同步机制的话,是会有线程安全问题的。
      • 什么情况下会是共享数据呢?
        • 方法中的形参,由方法外传进来的
        • 会return出去
          • 注意return s.toString()这种s在方法内,并没有传出去,是String new了一个字符串对象出去,方法结束后,s的引用就没了
        • 总结:如果变量是在内部产生、内部消亡的,就不是共享数据

2.3 本地方法栈

  • 与虚拟机栈类似,区别在于本地方法栈是用来管理本地方法的调用
  • 也是线程私有的
  • 内存也可以是固定也可以动态扩展

2.4 堆

概述


为什么堆是GC的重点区域呢

  • 在大内存、频繁GC的环境中,GC会是性能的瓶颈
  • 因为频繁GC会影响到用户线程的执行

堆的内存结构

  • 注:1.8版本后移除了永久代

堆空间大小的设置

  • 最大内存设置和起始内存一样是为了:如果堆空间不够会不断扩容,gc回收内存后,还要对堆空间释放,频繁扩容释放会造成不必要的系统压力

年轻代与老年代

内存分配



伊甸园满的的时候会触发YGC/Minor GC
然后将没被垃圾回收的对象复制到to区,并将它们的寿命加1
然后将伊甸园的所有对象都回收
然后交换from区和to区的位置,则to区又为空了
下一次伊甸园满时会将伊甸园去和from区没被回收的对象复制到to区(也就是说幸存区的垃圾回收是被动的)
...
当对象的寿命达到阈值时,就会被放到老年代

Minor GC、Major GC与Full GC


GC的执行、检索、判断哪些是垃圾的时候,就会导致用户线程暂停(STW),用户线程暂停会导致程序执行效率变差

年轻代GC触发机制:

  • 虽然频率高,但是速度快,所以不怎么影响用户线程执行效率

老年代GC触发机制:

  • OOM(Out of Memory),超出了堆区的内存限制

整堆收集

堆空间分代思想

为什么需要把Java堆分代?不分代就不能正常工作了吗?

TLAB



  • 至于为什么内存分配需要加锁,因为多线程可能竞争一块内存空间,而那一块内存只能分配给一个对象,加锁是乐观锁,CAS+失败重试,提高性能,由此可见TLAB对性能提高更大
  • TLAB中的对象并不是私有的,这个技术只是为了解决在对给对象分配内存的时候,不需要加锁,普通的EDEN区分配是需要加锁的(这里面的数据是共享的)
  • TLAB在EDEN中,是可以被其它所有线程访问的,GC的时候也会被回收

逃逸分析



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

  • 如何快速判断?
    • 看new的【对象实体】是否有可能在方法外被调用
    • 注意,这里说的是对象本身,而不是变量,与局部变量线程安全问题的区别就在于这

2.5 方法区

栈、堆、方法区的交互关系


内部结构

  • 存储内容

    • 存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存
  • 类型信息

  • 域(Field)信息

  • 方法信息

  • 字节码展示

non-final的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它。
补充说明:全局常量:static final

被声明为final的类变量的处理方法则不同,每个全局变量在编译的时候就会被分配了
从字节码文件查看区别:

静态变量存储位置

  • 这里所有的实例对象都是存储在堆中
  • instanceObj随着Test的对象实例存放在java堆中
  • localObject则是存放在foo()方法栈帧的局部变量中
  • jdk7后,staticObj静态变量是存放在java.lang.Class的实例中,所以这个静态变量是跟着这个实例变量存储在堆中

方法区中的垃圾回收

方法区中的垃圾回收主要包括两部分:常量池中废弃的常量和不再使用的类型

2.6 运行时常量池

在加载类和接口到虚拟机后,就会根据class文件中的常量池 在方法区里创建对应的运行时常量池

关于常量池:
常量池:可以看做是一张表,它包括各种【字面量】和对类型域、方法的【符号引用】,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等类型

为什么需要常量池?
在常量池中,相同的常量只会被存储一次,字节码存储指向常量的引用,从而节省空间

关于字节码文件:
包含类的版本信息、字段、方法以及接口等描述信息外,还包含常量池

2.7 字符串常量池

StringTable为什么要调整?

2.8 直接内存

不受java虚拟机管理,属于操作系统内存,相当于是java与磁盘之间的数据缓冲区

2.9 大厂面试题


3. 对象创建过程

3.1 大厂面试题

3.2 创建对象的方式

  • new
  • Class的newInstance()
  • Constructor的newInstance(Xxx)
  • 使用clone()
  • 使用反序列
  • 第三方库Objenesis

3.3 创建对象步骤

step1. 判断对象对应的类是否加载、链接、初始化

  1. 虚拟机先在常量池中检查,哪个常量池?运行时常量池不是加载类才会有的吗?这还没加载类怎么去运行时常量池查找?运行时常量池里的不是直接引用吗?

step2. 为对象分配内存

首先计算对象占用空间大小,接着在堆中划分出一块内存给新对象。
如果实例成员是引用变量,仅分配引用变量空间即可,即4个字节大小

如果内存规整


如果内存不规整

step3. 处理并发安全问题

采用CAS配上失败重试保证更新的原子性
每个线程预先分配一块TLAB

step4. 初始化分配到的空间

所有属性设置默认初始化值,保证对象实例字段在不赋值时可以直接使用

step5. 设置对象的对象头

step6. 执行init方法进行初始化

步骤总结

  • 加载类元信息
  • 为对象分配内存
  • 处理并发问题
  • 属性的默认初始化
  • 设置对象头信息
  • 属性的显示初始化、代码块中初始化、构造器中初始化

3.3 内存布局

对象头

实例数据

对齐填充

无特别含义,仅仅起占位符作用

绘图展示



3.4 对象访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

  • 通过栈帧上的reference引用

对象访问方式主要有两种

  • 句柄访问
  • 直接指针

4. 垃圾回收

垃圾是指运行程序中没有任何指针指向的对象

4.1 垃圾标记算法

引用计数算法


缺点第三点是指两个对象互相引用的情况,导致引用计数器不会为0,产生内存泄漏
内存泄露:对象应该被回收,但是没有回收

可达性分析算法






对象的finalization机制


虚拟机的对象有三种状态

具体过程:

4.2 垃圾清除阶段

标记-清除算法


复制算法


类似于幸存区的from区和to区


注:这个“特别的”写反了,应该是:如果垃圾对象很少,那要复制的数量会很多,效率会差

标记-压缩算法




移动是说移动对象到另一个区域,移动对象需要修改原指针的指向,移动后,该指向新区域的对象

对比三种算法

分代收集算法


增量收集算法

  • 目的是为了减小延迟

分区算法

主要目的也是为了减小延迟

4.3 引用

  • 强引用:永远不会回收
    • 造成java内存泄露的主要原因
  • 软引用:内存不足即回收
    • 当内存足够时,不会回收软引用的可达对象
    • 当内存不够时,会回收
  • 弱引用:发现即回收
    • WeakHashMap底层使用了弱引用
  • 虚引用:用来对象回收跟踪

4.4 垃圾回收器

评估GC的性能指标

各经典垃圾回收器与垃圾分代之间的关系



查看默认垃圾回收器

Serial回收器:串行回收



ParNew回收器:并行回收



Parallel回收器:吞吐量优先


自适应调节内存分配策略


CMS回收器:低延迟







在回收的时候,用户线程还在执行,会继续生产对象、垃圾等,消耗内存,所以不能等到空间被填满后才开始回收

为什么不用标记-压缩呢

  • 因为用户线程还在执行,不能对对象地址修改;一定要用的话,得STW

  • 产生内存碎片的问题相当于随时给jvm埋了颗定时炸弹
  • 当突然来了次业务高峰,可能会触发full gc,然后会启动后备方案 serial old,这个串行,性能很差,可能会导致业务停滞几秒中,导致用户体验很差

G1回收器:区域化分代式







  • G1回收器的使用场景

  • 分区region

  • 垃圾回收过程

      • 解释“解决方法”第四点:就是检查要写入的引用是否在自己这个region
  • 优化建议

总结

  • 怎么选择垃圾回收器

  • 面试题

5. String

5.1 基本特性




举个例子:改变s1,但s2不会变,因为字符串常量池中的字符串是不可变的,修改只会重新创建一个字符串

5.2 内存分配位置

使用双引号声明出来的String会直接存储在字符串常量池中

为什么要改呢?

  • 因为永久代空间比较小
  • 永久代垃圾回收频率很低

5.3 字符串拼接操作




有变量才会使用StringBuild,如果都被final修饰,那就是常量,还是会存储在常量池中

StringBuilder使用的是toString(),而toString()使用的是new String()
注意:使用s.intern()方法,返回的是常量池中的对象地址,但是s还是在堆中

  • append与直接拼接的效率

5.4 intern()的使用

问 new String()会创建几个对象?
toString()中的new String()使用的构造方法是:

public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        ...
    }

new String("s")使用的构造方法是:

public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }
  • new String()会创建两个对象,一个在堆中,一个在字符串常量表中,但返回的是堆中对象的指针
  • toString()会创建一个对象,在堆中
  • s.intern()在使用
    • 在jdk7及其以后的版本里,如果使用这个方法前,堆中已有了s这个字符串,但常量池中还没有,那常量池就会把堆中的字符串对象的地址复制过来,即常量池中存储的是堆中的地址;因为常量池也在堆中
    • 在jdk7之前,如果堆中已有了这个字符串,但常量池还没有,那就把堆中的字符串对象复制过去,这个地址和堆中字符串的地址不一样,因为这时常量池在永久代中