JVM内存模型、垃圾回收、类加载机制

一、内存模型

jvm内存模型在 java7 和 java8 有了一些变化,java7中,方法区实际还是存储在虚拟机堆区中,但在java8开始,方法区存储在了元空间中位于操作系统内存中,但是串表还是在堆区的。

JVM内存模型、垃圾回收、类加载机制_第1张图片

1.1 非线程私有

非线程私有内存区,允许被所有线程共享访问。

Java 堆区在在 JVM 启动的时候被创建,用于存储对象实例的内存区,堆区又分为新生代和老年代。堆的大小在 JVM 启动的时候就已经设定好了,可以通过选项 “-Xmx”“-Xms” 来设置 ,"-Xmx" 表示堆区的起始内存,"-Xms" 表示堆区的最大内存,一旦超过 “-Xmx” 所指定的最大内存时,将会抛出 “OutOfMemoryError” 异常。

方法区:存储了每一个 Java类的结构信息,如:运行时常量池、字段、方法数据、构造函数和普通方法的字节码内容以及类、实例、接口初始化时需要用到的特殊方法等数据。在 HotSpot 中,方法区只是物理上的独立,实际上还包含在 Java 堆区内。很多人把方法区称为永久代,是因为方法区除了可以通过 “-XX:MaxPermSize” 设置内存大小进行动态扩展外,不会像堆区那样频繁的被 GC 执行回收。

运行时常量池:也是方法区中的一部分,一个有效的二进制字节码文件中除了包括类的版本信息、字段、方法以及接口描述等信息外,还包含一项信息就是 常量池表,运行时常量池就是字节码文件中常量池表的表现形式。当类装载器成功将一个类或接口装载进 JVM 后,就会创建与之对应的运行时常量池。若所需内存超过堆区设定的最大内存时,也会抛出 “OutOfMemoryError” 异常。

// 二进制字节码文件内容:
Last modified:最后修改时间
MD5:MD5校验码
minor version: 0,major version: 52:版本号,确认它是否是有效的字节码文件
Constant pool: 常量池
#1:符号引用,在运行时常量池中,将符号引用改为引用地址
#22.#23:方法信息

Classfile /H:/workspace/study/out/production/study/com/yuchen/Main.class
  Last modified 2020-8-12; size 528 bytes
  MD5 checksum fb47093303d5fc965c05b41525aaa724
  Compiled from "Main.java"
public class com.yuchen.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#21         // java/lang/Object."":()V
   #2 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Methodref          #24.#25        // java/io/PrintStream.println:(I)V
   #4 = Class              #26            // com/yuchen/Main
   #5 = Class              #27            // java/lang/Object
   #6 = Utf8               
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Lcom/yuchen/Main;
  #13 = Utf8               main
  #14 = Utf8               ([Ljava/lang/String;)V
  #15 = Utf8               args
  #16 = Utf8               [Ljava/lang/String;
  #17 = Utf8               a
  #18 = Utf8               I
  #19 = Utf8               SourceFile
  #20 = Utf8               Main.java
  #21 = NameAndType        #6:#7          // "":()V
  #22 = Class              #28            // java/lang/System
  #23 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(I)V
  #26 = Utf8               com/yuchen/Main
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (I)V
{
  public com.yuchen.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/yuchen/Main;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: iconst_2
         1: istore_1
         2: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         5: iload_1
         6: iconst_2
         7: ishl
         8: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        11: return
      LineNumberTable:
        line 6: 0
        line 7: 2
        line 8: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  args   [Ljava/lang/String;
            2      10     1     a   I
}
SourceFile: "Main.java"

1.2 线程私有

非线程私有内存区,不允许被所有线程共享访问。线程私有内存区是只允许被所属的独立线程进行访问的一类内存区。

PC寄存器:如果当前线程所执行的方法是一个 Java 方法,那么PC寄存器就会存储正在执行的字节码指令地址。如果是native方法,PC寄存器的值就是 null。PC寄存器被设定为线程私有的原因是:多线程在一个特定的时间段内只会执行其中一个线程的方法,CPU会不停的切换任务,为了准确的记录各个线程正在执行的当前字节码指令地址,可以为每一个线程都分配一个 PC寄存器 ,JVM 的字节码解释器通过改变 PC寄存器的值来明确下一条应该执行什么样的字节码命令。PC寄存器是 JVM 的内存区中唯一一个没有明确规定需要抛出 OutOfMemoryError异常 的运行时内存区。

虚拟机栈:虚拟机栈用于存储线程运行时的内存、栈帧,栈帧中所存储的是局部变量表、操作数栈、方法出口等信息。局部变量表用于存储各类原始数据类型、对象引用等。

本地方法栈:用于支持本地方法的执行。

// debug启动后,Main方法入栈,调用了change()方法,之后change方法入栈,调用完毕后change方法出栈,main方法亦是。
public class Main {
    public static void main(String[] args) {
        int a = change(2);
        System.out.println(a);
    }
    static int change(int a){
        int b = 2;
        System.out.println(b);
        return a<<2;
    }
}

二、内存分配

如果一个类在分配内存之前已经成功完成类装载步骤之后,JVM 就会优先选择在 TLAB(Thread Local Allocation 本地线程分配缓冲区) 为对象分配内存空间,TLAB 在 Java堆区中是一块线程私有区域,包含在 Eden 空间内,不是所有的对象实例都可以在 TLAB 中成功分配内存,但是 JVM 会将 TLAB 作为分配内存的首选,可以通过 “-XX:UseTLAB” 设置是否开启 TLABTLAB 空间内存很小,只占了 Eden 空间的1%,可以通过 “-XX:TLABWasteTargetPercent” 设置 TLAB 所占 Eden 空间的百分比大小。当对象在 TLAB 中分配内存地址失败时,直接在 Eden 空间中分配内存,如果在 Eden 空间中也无法分配内存时,JVM 就会执行 MinorGC((清除掉垃圾对象,同时将Eden和From空间存活的对象放进To Survivor,每个对象的对象头都包含一个Age(4bit),在Surivivor每熬过一次GC,Age都会 +1 ,当达到一定年龄时(默认是15)JVM就会将其放入老年区。),直到分配成功,如果是大对象则直接分配到老年代中。

GC执行完后,若内存空间以规整和有序的方式分布,即已用的和未使用的内存都各自一边,彼此间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过 指针碰撞 即通过修改指针的偏移量将新对象分配在第一个空闲内存上。反之只能使用 空闲列表 执行内存分配。

当为对象成功分配好所需的内存空间后,JVM 就会对分配后的内存空间进行零值初始化,程序能够访问到这些字段的数据类型所对应的零值。

JVM内存模型、垃圾回收、类加载机制_第2张图片

三、垃圾回收

3.1 垃圾标记

在GC执行垃圾回收之前,首先需要区分出存活对象和死亡对象,只有被标记为已经死亡的对象,GC在执行垃圾回收的时才会释放掉其所占用的内存空间。死亡对象:当一个对象已经不再被任何存活对象所继续引用时,就可以判定为死亡对象。

3.1.1 垃圾标记:引用计数法

引用计数法会为每一个对象都创建要给私有的引用计数器,当目标对象被其他存活的对象引用时,引用计数器的值会 +1,不再引用时就 -1,当引用计数器的值为 0 时,就意味着该对象不再被任何存活对象所引用,可以标记为垃圾对象。但比如两个死亡对象互相引用时,它们的计数器的值还是不为 0 ,导致GC在执行垃圾回收时永远无法释放掉无用对象所占用的空间,会引起 内存泄漏

3.1.2 垃圾标记:可达性分析

可达性分析也叫根搜索算法,它是以根对象集合作为起始点(GC Root),按照从上到下的方式搜索被根对象所连接的目标对象是否可达,如果目标对象不可达时,就可以标记为垃圾对象。

可以作为根对象集合的内容有:

  1. 栈(栈帧中的局部变量区域,局部变量表)所引用的对象
  2. 方法区中类静态属性的对象引用
  3. 运行时常量池中的对象引用
  4. 本地方法栈中的对象引用

3.2 垃圾回收

3.2.1 标记-清除

当对象已经被标记为垃圾对象后就直接释放掉其所占用的内存空间。由于被执行垃圾回收的垃圾对象所占用的内存空间可能是一些不连续的内存块,会产生一些内存碎片,导致后续没有内存分配给较大的对象。

3.2.2 复制

Java 堆区细分可以划分为 新生代老年代新生代 又可以划分为 Eden 空间、From Survivor 空间、To Survivor 空间,它们所占用的比例是 8:1:1 ,可以通过 “-XX:SurvivorRatio” 来调整空间比例。当执行一次 Minor GCEden 空间中的存活对象会被复制到 To 空间中,并且之前已经经历过一次 Minor GC 并在 From 空间存活下来的对象如果还年轻的话也会被复制到 To 空间中。若存活对象的分代年龄超过 “-XX:MaxTenuringThreshold” 所指定的**阈值(默认是15)**时,会直接晋升到老年代中;若 To 空间的容量达到阈值时,存活对象也会晋升到老年代中。EdenFrom 空间中的对象就都是垃圾对象了,直接释放掉其所占用的内存空间。执行完 Minor Gc 后,EdenFrom 空间将会被清空,同时 FromTo 空间互换位置。

JVM内存模型、垃圾回收、类加载机制_第3张图片

3.2.3 标记-压缩

当成功标记出内存中的垃圾对象后,该算法会将所有的存活对象都移动到一个规整且连续的内存空间中,然后执行 Major GC(或Full GC) 回收垃圾对象所占用的内存空间。当被成功执行压缩后,彼此间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过 指针碰撞 即通过修改指针的偏移量将新对象分配在第一个空闲内存上。

四、类加载机制

4.1 Java中从类到执行的过程

  1. 编译器将 Xxx.java 源文件编译为 Xxx.class 字节码文件
  2. XxxClassLoader 将字节码文件转换为 JVM 中的 Class 对象
  3. JVM 利用 Class 来实例化 Xxx 对象

4.2 类的生命周期

加载(loading)----连接(验证verification 准备preparation 解析resolution)----初始化(initialization)----使用(using)----下载(unloading)

4.3 类的加载过程

ClassLoader的加载步骤:

**首先是装载:**查找和导入 class 文件;接下来是连接: 检查载入的 class 文件数据的正确性,为类的 静态变量 分配存储空间,然后解析,也就是将 符号引用 转换成 直接引用 ;最后初始化静态变量、静态代码块。

这个过程在程序调用类的静态成员时开始,所以静态方法 main() 才会成为一般程序的入口方法。

4.3.1 加载

  1. 从 class 文件获取二进制字节流
  2. 将字节流中的静态结构转化为方法区的运行时动态结构
  3. 在内存中生成代表该 class 的 java.lang.Class 对象,作为方法区该类的访问入口

4.3.2 连接

验证:验证 class 文件的字节流中包含的信息是否符合 JVM 的要求,并确保不会危害 JVM 自身的安全

准备:为静态变量分配内存并赋初始值

解析:将常量池内的符号引用转换成直接引用

4.3 初始化

调用类的 clinit() 方法,为静态变量赋予实际的值,执行静态代码块。

4.4 类加载器

  1. **BootStrapClassLoader:启动类加载器也叫根类加载器,**负责加载java的核心类库,主要加载 “JAVA_HOME/lib” 目录下的所有类型(包含System,String这样的核心类),根类加载器非常特殊,它不是java.lang.ClassLoader的子类,它是JVM自身内部由C/C++实现的,并不是java实现的。
  2. **Ext ClassLoader:扩展类加载器,**主要加载扩展目录 “JAVA_HOME/lib/ext” 下的所有类型,用户可以把自己开发的类打包成jar包放在这个目录下即可扩展核心类以外的功能。
  3. **AppClassLoader:系统应用类加载器,**加载程序所在目录即 classpath,
  4. **自定义ClassLoader:**Java编写,定制化加载,在程序运行期间, 通过java.lang.ClassLoader的子类动态加载class文件, 体现java动态实时类装入特性。

自定义ClassLoader的实例: 在工程目录外创建一个Java类并编译为字节码文件,创建一个读取本地.class的类并重写findClass方法

public class MyClassLoader extends ClassLoader {
    private String path;
    private String classLoaderName;

    public MyClassLoader(String name, String classLoaderName) {
        this.path = path;
        this.classLoaderName = classLoaderName;
    }


    /***
     * 用于寻找类文件
     */
    @Override
    public Class findClass(String name) {
        byte[] b = loadClassData(name);
        return defineClass(name, b, 0, b.length);
    }

    /***
     * 用于加载类文件
     * @param name
     * @return
     */
    private byte[] loadClassData(String name) {
        name = path + name + ".class";
        InputStream in = null;
        ByteArrayOutputStream out = null;
        try {
            in = new FileInputStream(new File(name));
            out = new ByteArrayOutputStream();
            int i = 0;
            while ((i = in.read()) != -1) {
                out.write(i);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                out.close();
                in.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return out.toByteArray();
    }
}

4.5 双亲委派机制

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载都是如此,因此所有的加载请求都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径里找不到这个所需要加载的类),子类加载器才会尝试自己去加载,而由于 BootStrapClassLoader是由C++编写的,所以到 ExtClassLoader加载器时,它的上级就为null,此时,执行 c = findBootstrapClassOrNull(name); 方法,故名思意,去执行 BootStrapClassLoader查找有没有加载过对应的类,如果没有就会尝试在 BootStrap 加载路径下查找有没有此.class文件,如果没有交给 ExtClassLoader 去查找 ExtClassLoader 加载路径查找,一级一级向上后再一级一级向下,**若都未找到,则抛出 ClassNotFoundException 异常,**因此称为ClassLoader 的双亲委派机制。

JVM内存模型、垃圾回收、类加载机制_第4张图片

4.5.1 双亲委派机制的好处

好处:保证了加载的类是同一个,保证内库更加安全。Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。
缺点:由于需要一级一级去判断,所以效率低

你可能感兴趣的:(JVM,java,jvm,内存泄漏,java)