JVM内存结构

194 阅读11分钟

JVM

先说一下,本文参考了大量黑马程序员与《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》上的内容,对于图片,有的是截屏的大佬博客里面的图片,有的是用的模板库,有的来源于网络,有的来源于自己画的......

若读者发现错误,请告知,作者会尽快修改

一、内存结构

1.1 前言

​ 先放一张简洁的图,本章会围绕以下内容进行讲解

image-20220420154655348

1.2 程序计数器

概念:

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

​ 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

为什么需要程序计数器呢?

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,所以,在任何时刻,处理器只会执行一条指令。当我们执行了另一个线程后,又回到刚刚的某个线程,这时为了能恢复到正确的执行位置,我们的程序计数器就起作用了。

特点

  • 是线程私有的

    每条线程都有独立的程序计数器,各条线程之间计数器互不影响,独立存储

  • 不会存在内存溢出

    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

1.3 Java虚拟机栈

概念:

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

​ 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

虚拟机栈 :每个线程运行时所需要的内存

栈帧: 栈是由多个栈帧组成的,栈帧对应着每次方法调用时的内存

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

两类异常状况

  • StackOverflowError

    栈帧过多导致栈内存溢出

  • OutOfMemoryError

    栈帧过大导致栈内存溢出

1.4 本地方法栈

概念:

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

两类异常状况

  • StackOverflowError

  • OutOfMemoryError

线程运行诊断

  • 用top定位哪个进程对cpu的占用过高

  • ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)jstack 进程id

  • 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号

1.5 堆

1.5.1 概念:

对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。

​ 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

通过 new 关键字,创建对象都会使用堆内存

1.5.2 特点

  • 是线程共享的,所以堆中的对象都要考虑线程安全问题
  • 有垃圾回收机制

垃圾回收

从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现

新生代” “老年代“永久代” “**Eden空间 **” “From Survivor空间” “To Survivor空间”等名词,具体的后面说。

1.5.3 堆内存诊断

  • jps工具
    • 查看当前系统中的java进程
  • jmap
    • jmap -heap 进程id 可以查看堆内存使用情况
  • jconsole
  • 图形化界面,多功能的监测工具,可以连续监测,可检测死锁
  • jvisualvm
  • 功能更强的图形化界面

1.6 方法区

1.6.1 概念:

我们先来看下JDK8 中JVM的官方文档

官方文档

image-20220420173524565

1.6.2 特点

  • 和堆一样,是各个线程共享的内存区域

  • 有垃圾回收机制

1.6.3 作用

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

1.6.4 内存溢出

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError异常。

  • JDK 1.8前会导致永久代内存溢出

    演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space

    -XX:MaxPermSize=8m

  • 1.8 之后会导致元空间内存溢出

    演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace

    -XX:MaxMetaspaceSize=8m

场景:spring,mybatis

1.6.5 永久代与元空间

到了JDK 8,完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta- space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

image-20220421072450209

1.6.6 关于垃圾回收

  • 可以选择不实现垃圾回收

  • 相对而言,垃圾收集行为在这个区域较少出现,但并非数据进入了方法区就如永久代的名字一样“永久”存在了

  • 该区域的内存回收目标主要是:常量池的回收和对类型的卸载,但回收效果通常令人不是很满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

1.7 运行时常量池

1.7.1 概念

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

​ 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

  • 常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量

    等信息

  • 运行时常量池:常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量

    池,并把里面的符号地址变为真实地址

咋们在Idea中可以通过插件Jclasslib来查看到如下信息

image-20220421083834067

1.7.2 特点

  • 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性

    并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也以将新的常量放入池中,

    用得比较多的便是String类的intern()方法

1.7.3 内存溢出

  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常

1.8 StringTable

1.8.1 概念

StringTable叫做字符串常量池,用于存放字符串常量

1.8.2 String的一些特性

在说StringTable的特性前,我们先来看看String的一些特性

String的不可变性

String str = "tian";

这种情况,"tian"是被存入了 StringTable中的

str 指向了StringTable中的 "tian"

image-20220426083156491

我们稍微修改下代码

String str = "tian";
str = "cx";

image-20220426083600582

我们接下来在电脑上跑一下,来验证不可变性

        String str = "tian";
        System.out.println(System.identityHashCode(str));
        str = "cx";
        System.out.println(System.identityHashCode(str));
        str = "tian";
        System.out.println(System.identityHashCode(str));

运行结果

image-20220426084024056

通过图可以看出: 第一次和第三次的hash值一样,第二次指向了另一个字符串,所以hash值不一样,同时,我们也可以得出结论: str是指向了一个新的地址,并没有改变最开始指向地址的内容

那么为什么要设计为不可变性呢?

1、常量池

Java 中的字符串常量池的存在就是为了性能优化,当创建一个新字符串对象时,若常量池中已有,就不会创建新对象了,就直接引用常量池中的对象,这样减少了 JVM 的内存开销,提高效率。

2、hashcode 缓存

因为字符串不可变,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就使得字符串很适合作为 HashMap 中的 key,效率大大提高。

3、多线程安全

多线程中,可变对象的值很可能被其他线程改变,造成不可预期的结果。而不可变的 String 可以自由在多个线程之间共享,不需要同步处理

总的来说就两点

  • 安全性

  • 性能

通过阅读String类的源码,总结出String是怎么实现不可变性

  • String是由final 修饰的,final修饰的类是最终类,不能被修改

    image-20220426090743797

  • 成员变量由privatefinal修饰且没有set方法

  • 通过构造对象时,成员变量是使用深拷贝来初始化的

在顺便看一下字符串拼接

代码来自黑马程序员,写得非常详细

// StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容
public class Demo1_22 {
    // 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
    // ldc #2 会把 a 符号变为 "a" 字符串对象
    // ldc #3 会把 b 符号变为 "b" 字符串对象
    // ldc #4 会把 ab 符号变为 "ab" 字符串对象

    public static void main(String[] args) {
        String s1 = "a"; // 懒惰的
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()  new String("ab")
        String s5 = "a" + "b";  // javac 在编译期间的优化,结果已经在编译期确定为ab

        System.out.println(s3 == s5); //true

    }
}

1.8.3 StringTable的一些特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder (1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池

intern方法

在JDK 1.8中,intern方法的作用就是尝试将一个字符串放入StringTable中,如果不存在就放入StringTable并返回StringTable中的地址,如果存在的话就直接返回StringTable中的地址。

本来打算在后面放题目,想了下,这里还是有必要放道题

public class Demo1_23 {

    //  ["ab", "a", "b"]
    public static void main(String[] args) {

        String x = "ab";
        String s = new String("a") + new String("b");

        // 堆  new String("a")   new String("b") new String("ab")
        String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回

        System.out.println( s2 == x ); //true
        System.out.println(  s == x ); // false
    }

}

1.8.4 StringTable的位置

先说结论: StringTable是放在堆中的

然后验证:

public static void main(String[] args) throws InterruptedException {
    ArrayList list = new ArrayList();
    String str = "hello";
    for(int i = 0;i < Integer.MAX_VALUE;i++) {
        String s = str + i;
        str = s;
        list.add(s.intern());
    }
}

image-20220426094840311

1.8.5 StringTable的垃圾回收机制

StringTable是有垃圾回收机制的

/**
 * 演示 StringTable 垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class Demo1_7 {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        try {
            for (int j = 0; j < 10000; j++) { // j=100, j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }

    }
}

当我们注释掉9~11行的代码时,我们可以看到字符串对象数量:如图

image-20220428104531421当我们取消注释9~11行的代码时,我们可以看到字符串对象数量:如图

image-20220428104618396

本来应该是超过1万的,但这里确 一万 都没达到,这是为什么呢?

答案:

StringTable有垃圾回收机制,超过了设置的大小,就会进行垃圾回收

通过下面这个图也能看出来,触发了垃圾回收机制

image-20220428105809002

1.8.6 StringTable性能调优

  • 调整 -XX:StringTableSize=桶个数
  • 考虑将字符串对象是否入池

1.9 直接内存

1.9.1 概念

​ 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现

​ 摘自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明》

Direct Memory

  • 常见于 NIO 操作时,用于数据缓冲区

  • 分配回收成本较高,但读写性能高

  • 不受 JVM 内存回收管理

1.9.2 分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法

  • ByteBuffffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffffer 对象,一旦

ByteBuffffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调

用 freeMemory 来释放直接内存

总结导图

image-20220428193207040

问题