jvm · 2021-07-22 0

JVM内存模型

一、JVM内存结构

jvm

JVM内存结构有五部分组成:堆、方法区、虚拟机栈、本地方法栈、程序计数器

堆、方法区是线程共享的

虚拟机栈、本地方法栈、程序计数器是线程私有的

  • 堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。回收器主要管理的对象。
  • 方法区(Method Area):线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
  • 虚拟机栈(JVM Stack):线程私有。存储局部变量表、操作栈、动态链接、方法出口,对象指针。
  • 本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的Native 方法服务。如Java使用c或者c++编写的接口服务时,代码在此区运行。
  • 程序计数器(Program Counter Register):线程私有。有些文章也翻译成PC寄存器(PC Register),同一个东西。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。

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

jvm-relation

二、堆(Heap)

heap

堆分为新生代和老年代。新生代分为Eden 空间、From Survivor 空间(s0)、To Survivor 空间(s1)。

对于jdk8,堆包含的有:对象实例、字符串常量池、静态变量、线程分配缓冲区

方法区的字符串常量池在堆中,字符串常量池存储的是string对象的直接引用,而不是直接存放的对象,是一张string table。

方法区的静态变量在堆中,静态变量是有static修饰的变量。

线程分配缓冲区(Thread Local Allocation Buffer)线程私有,但是不影响java堆的共性增加线程分配缓冲区是为了提升对象分配时的效率。

三、虚拟机栈(JVM Stack)

虚拟机栈(JVM Stack)也叫方法栈。

jvm-stacks

一个线程对应一个虚拟机栈,多个线程对应多个虚拟机栈。

一个虚拟机栈内,可以由多个栈帧组成。

每个方法被执行的时候,都会在虚拟机栈中同步创建一个栈帧(stack frame)。方法被执行时入栈,执行完后出栈。

每个栈帧的包含局部变量表局部变量表、操作数栈、动态连接、方法返回地址。

局部变量表中存储着方法里的java基本数据类型(byte/boolean/char/int/long/double/float/short)以及方法里的对象的引用。

如果线程请求的栈深度大于虚拟机所规定的栈深度,则会抛出StackOverFlowError即栈溢出。

四、本地方法栈(Native Method Stacks)

本地方法栈与虚拟机栈的作用是相似的,都是线程私有的,主要的区别在于:

虚拟机栈执行的是java方法

本地方法栈执行的是native方法

五、程序计数器(Program Counter Register)

它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。

六、方法区

方法区是JVM的一个规范。

《深入理解Java虚拟机》书中对方法区存储内容描述如下:它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

不同的JVM虚拟机实现不同,对于HotSpot虚拟机,jdk7以前是用“永久代”实现。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的

  1. 对于jdk7以前,方法区 = 永久代(PermGen)

  2. 对于jdk7,方法区 = 永久代(permGen)+ 堆(Java heap)

  3. 对于jdk8,方法区 = 元空间(metaspace)+ 堆(Java heap)

HotSpot虚拟机方法区演进:

1.jdk1.6及之前:有永久代(permanent generation) ,静态变量存放在永久代上

jdk6-method

2.jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中。

jdk7-method

3.jdk1.8及之后: 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍留在堆空间。

jdk8-method

元空间(metaspace):不是在虚拟机中,使用的是本地内。保存类的元数据,如方法、字段、类、包的描述信息。保存运行时常量池。

类的元数据:

jdk-method

1.类型信息

对每个加载的类型( 类class、接口interface、枚举enum、注解annotation),JVM必 须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于interface或是java. lang.Object,都没有父类)
  • 这个类型的修饰符(public, abstract, final的某个子集)
  • 这个类型直接接口的一个有序列表

2.域信息(成员变量)

  • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  • 域的相关信息包括:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)

3.方法信息(method)

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称。
  • 方法的返回类型(或void)。
  • 方法参数的数量和类型(按顺序)。
  • 方法的修饰符(public, private, protected, static, final, synchronized, native , abstract的一个子集)。
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外)。
  • 异常表( abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

4.非声明为final的static静态变量

  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
  • 类变量被类的所有实例所共享,即使没有类实例你也可以访问它。

5.全局常量(static final)

被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就被分配了。

我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);

每个class文件都有一个class常量池。

六、常量池

1.Class文件常量池

  • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

2.运行时常量池

  • 运行时常量池( Runtime Constant Pool)是方法区的一部分。

  • 运行时常量池是方法区的一部分,是一块内存区域。Class 文件常量池将在类加载后进入方法区的运行时常量池中存放。

  • 在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池;对于文本字符来说,它们会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的引用,而不是字符串本身。

3.字符串常量池

  • 方法区的一部分,保存在堆中
  • JVM规范中字符串常量池是在方法区上一个驻留字符串(Interned Strings)的位置,是为了优化而专门供字符串存储的一块区域,这个区域在整个虚拟机中是共享的
  • 字符串常量池存的不是字符串也不是String对象,而是一个个HashtableEntry,HashtableEntry里面的value指向的才是String对象

字节码被加载到JVM中后,Class文件常量池里面的内容会被加载到运行时常量池,但是字符串会被创建到堆中,并且字符串常量池会存放一个相应的引用。直到运行时,会去字符串常量池中查找是否有相同内容对象的引用,否则就在堆中创建一个新的字符串对象,字符串常量池中创建该对象的引用。

七、String和字符串常量池

1.

对于:

String s = new String("xyz");

首先去找字符串常量池找,看能不能找到“xyz”字符串对应对象的引用,如果字符串常量池中找不到:

  • 创建一个String对象和char数组对象
  • 将创建的String对象封装成HashtableEntry,作为StringTable的value进行存储
  • new String("xyz")会在堆区又创建一个String对象,char数组直接指向创建好的char数组对象

s3

2.

对于:

String s = "xyz";

首先去找字符串常量池找,看能不能找到“xyz”字符串的引用,如果字符串常量池中能找不到:

  • 创建一个String对象和char数组对象
  • 将创建的String对象封装成HashtableEntry,作为StringTable的value进行存储
  • 返回创建的String对象

s1

3.

对于:

String s1 = new String("xyz");
String s2 = "xyz";

s2

通过修改String的char[],判定s1和s2,底层是同个char[]。

    @Test
    public void testString3() throws Exception {
        String s1 = new String("xyz");
        String s2 = "xyz";

        Field valueField = String.class.getDeclaredField("value");
        valueField.setAccessible(true);

        char[] cs = (char[])valueField.get(s1);
        cs[1] = 't';

        System.out.println(s1);         // xtz
        System.out.println(s2);         // xtz

        System.out.println(s1 == s2);   // false
    }

4.

对于:

String str1 = "aa" + "bb";

String str1 = "aa" + "bb";会被编译成String str1 = "aabb"

5.

对于:

String s1 = "aa";
String s2 = "bb";
String str1 = s1 + s2;

String str1 = s1 + s2;会被编译成StringBuilder.append(s1).append(s2).toString()

StringBuilder里面的toString方法调用的是String类里面的String(char value[], int offset, int count)构造方法,其根据参数复制一份char数组对象。创建一个String对象,String对象的value指向复制的char数组对象。

并没有驻留到字符串常量池里面去

调用intern方法可以实现驻留

    @Test
    public void testString5() throws Exception {
        String s1 = "aa";
        String s2 = "bb";
        String str1 = s1 + s2;
        // String str1 = new StringBuilder().append(s1).append(s2).toString();

        // str1.intern();
        String str2 = "aabb";

        Field valueField = String.class.getDeclaredField("value");
        valueField.setAccessible(true);

        char[] cs = (char[])valueField.get(str1);
        cs[1] = 'e';

        System.out.println(str1);           // aebb
        System.out.println(str2);           // aabb

        System.out.println(str1 == str2);   // false
    }

6.

intern方法

intern方法就是创建了一个HashtableEntry对象,并把value指向String对象,然后把HashtableEntry通过hash定位存到对应的字符串成常量池中。当然,前提是字符串常量池中原来没有对应的HashtableEntry

在执行str.intern()之前;

no-intern

在执行str.intern()之后:

intern

八、垃圾回收

1.gc root

哪些可以作为GC ROOT

  1. Java虚拟机栈(局部变量表 也就是方法中参数和方法局部变量)引用的对象。
  2. 方法区中(类信息)静态引用(也就是当前类静态引用)的对象。
  3. 处于存活状态的线程对象(注意是线程对象 而不是线程的对象)。
  4. 本地native方法jni引用的对象。

一个对象GCRoot不可达,java虚拟机就认为是垃圾对象,就会进行垃圾回收

九、gc 常用算法

GC 常用的算法有:

  1. 标记-清除算法
  2. 复制算法
  3. 标记-压缩算法
  4. 分代收集算法

目前主流的 JVM(HotSpot)采用的是分代收集算法。

1.标记 - 清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:

  1. 效率问题,标记和清除过程的效率都不高。
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当前程序在以后的运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

2. 复制算法

"复制"(Copying)的收集算法,它将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块就行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低,

3. 标记-整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保。以应对被使用的内存中所有对象都 100 % 存活的极端情况,所以在老年代一般直接选用这种算法。

根据老年代的特点,有人提出了另外一种"标记-整理"(Mark-Compact)算法,标记过程仍然与"标记-清除"算法一样,但后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉一端边界以外的内存。

4. 分代收集算法

(新生代的GC+老年代的GC)

GC 分代的基本假设:

绝大部分对象的生命周期都非常短暂,存活时间短。

"分代收集"(Generational Collection)算法,把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集时都会发现有大批的对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

而在老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-整理"算法来进行回收。