《Java 虚拟机》 基本概念与内存结构

简介: 《Java 虚拟机》 基本概念与内存结构

1. JVM 基本概念

定义:Java Virtual Machine,Java 程序的运行环境(Java 二进制字节码的运行环境)。

优点:

  • 一次编写,到处运行
  • 自动管理内存,具有垃圾回收的功能
  • 数组下标越界检查
  • 多态

JVM、JRE、JDK、JavaSE 和 JavaEE 之间比较:

image.png

2. JVM 内存结构

整体架构

image.png

2.1 程序计数器

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

作用:保存 JVM 中下一条要执行字节码的指令的地址,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。


特点:


线程私有的,不会被其他线程共享

唯一一个不会存在内存溢出的区域

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值为空(Undefined)。《深入理解 Java 虚拟机》


解释:每个 Java 线程都会直接映射到一个操作系统线程上执行,而 Native 方法是由原生平台直接执行,不需要理会 JVM 平台层面的程序计数器。

2.2 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)属于线程私有区域。

定义:


描述的是 Java 方法执行的内存模型,每个方法对应一个栈帧。

每个线程运行时所需要的内存,称为虚拟机栈,生命周期与线程相同。

每个栈由多个栈帧(Frame)组成,每个栈帧对应着每个方法执行时所占用的内存空间,用于存储 局部变量表(存放各种基本类型以及引用类型的变量)、操作数栈、动态链接和方法出口等信息。每个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

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

/**
* 演示栈帧
*/
public class Demo1_1 {
    public static void main(String[] args) throws InterruptedException {
        method1();
    }
    private static void method1() {
        method2(1, 2);
    }
    private static int method2(int a, int b) {
        int c = a + b;
        return c;
    }
}

image.png

从上图中可以看到,每个栈帧在栈中的位置符合栈先进先出的特性。


问题辨析:


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

答:垃圾回收不涉及栈内存。栈内存就是一次次的方法调用所产生的栈帧内存,而栈帧内存在每一次方法调用结束后都会被弹出栈,也就是自动地被回收掉,所以根本不需要垃圾回收管理栈内存。

栈内存的分配越大越好嘛?

答:不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

方法内的局部变量是否线程安全?

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

如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全。

/**
 - 局部变量的线程安全问题
 */
public class Demo1_17 {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(4);
        sb.append(5);
        sb.append(6);
        new Thread(()->{
            m2(sb);
        }).start();
    }
    // 线程安全
    public static void m1() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    // 线程不安全,参数是从外部传递进来,说明该对象也能作为其他方法的参数,进行操作
    public static void m2(StringBuilder sb) {
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    // 线程不安全,有返回值,其他地方可能会调用返回的对象
    public static StringBuilder m3() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;
    }
}

这个区域规定了两种异常情况:

  • 线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常。也就是栈帧过多导致栈内存溢出,例如无限递归。
/**
- 演示栈内存溢出
- -Xss 参数设置栈内存容量
- 设置: -Xss256k
- 默认为1024k
*/
public class Demo1_2 {
   private static int count;
   public static void main(String[] args) {
       try {
           method();
       } catch (Throwable e) {
           e.printStackTrace();
           System.out.println(count);
       }
   }
   public static void method() {
       count++;
       method();
   }
}

经过 3301 次递归调用后,栈内存溢出。


虚拟机栈在进行动态扩展时,如果无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

线程运行诊断:

CPU 占用过高:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程:


top 命令,查看是哪个进程占用 CPU 过高。

ps H -eo pid, tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用 CPU 过高。

jstack 进程 id 通过查看进程中的线程的 tid,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

2.2.1 局部变量表

定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括八种基本数据类型、对象引用和 returnAddress 地址(指向了一条字节码指令的地址)

是线程的私有数据,不存在数据安全问题

局部变量表所需的内存空间在编译期间完成分配,并保存在方法的 Code 属性的 maximum local varibales 数据项中,在方法运行期间是不会改变局部变量表的大小的

局部变量表中最基本的单位是 Slot(变量槽),32 位以内的类型只占用一个 Slot(包括 returnAddress 类型),64 位的类型(long 和 double) 占用两个 Slot

变量的分类:


成员变量:在使用前都经历过默认初始化赋值:(1)类变量: 连接的准备阶段:给类变量默认赋值 —>初始化阶段:给类变量显式赋值即静态代码块赋值(2)实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值

局部变量:在使用前必须要进行显式赋值!否则,编译不通过

注意:


在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,在方法执行时,虚拟机使用局部变量表完成方法的传递

局部变量表中的变量也是重要的垃圾回收器根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

2.2.2 操作数栈

主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈

操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈来完成一次数据访问

2.2.3 动态链接

2.3 本地方法栈

定义:本地方法栈和虚拟机栈非常类似,最大的区别就是虚拟机栈是为虚拟机执行 Java 方法(即字节码)服务,而本地方法栈是为虚拟机用到的 Native 方法服务。《深入理解 Java 虚拟机》


一些带有 native 关键字的方法就是需要 Java 去调用本地的 C 或者 C++ 方法,因为 Java 有时候无法直接和操作系统底层交互,所以需要用到本地方法。


两种异常情况:


StackOverflowError

OutOfMemoryError

异常测试参考虚拟机栈。

2.4 Java 堆

定义:Java 堆是 Java 虚拟机所管理的内存中最大的一块区域,在虚拟机启动时创建,几乎所有的对象实例都在 Java 堆分配内存,是所有线程共享的一块区域。此外,Java 堆也是垃圾收集器管理的主要区域,通常也被称为 “GC 堆(Garbage Collected Heap)”


特点:


存放对象实例,由所有线程共享,堆内存中的对象都需要考虑线程安全问题。

有垃圾回收机制。

Java 堆内存溢出 :java.lang.OutofMemoryError :java heap space. Java 堆内存溢出

/**
- 演示堆内存溢出
- -Xms 参数表示堆的最小值    -Xmx 参数表示堆的最大值;设置成一样可以避免自动扩展
- VM Args:-Xms20m -Xmx20m
*/
public class Demo1_5 {
  public static void main(String[] args) {
    int i = 0;
    try {
        List<String> list = new ArrayList<>();
        String a = "hello";
        while (true) {
            list.add(a);
            a = a + a;
            i++;
        }
    } catch (Throwable e) {
        e.printStackTrace();
        System.out.println(i);
    }
  }
}

image.png

堆内存诊断


jps 工具

在终端输入 jps 命令

查看当前系统中有哪些 java 进程

jmap 工具

查看堆内存占用情况 jmap -heap 进程 id

jconsole 工具

在终端输入 jconsole 命令

图形界面的,多功能的监测工具,可以连续监测

jvisualvm 工具

在终端输入命令 jvisualvm

2.5 方法区

定义: 方法区(Method Area)与 Java 堆一样,被 Java 虚拟机中所有线程共享。它类似于存储区域,用来存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


内存溢出:

1、jdk 1.8 以前会导致永久代溢出

* 演示永久代内存溢出  java.lang.OutMemoryError: PerGen space
* -XX:MaxPermSize = 8m

2、jdk 1.8 以后会导致元空间溢出

/**
 * 演示元空间内存溢出
 * -XX:MaxMetaspaceSize=8m
 */
public class Demo1_8 extends ClassLoader{  // 可以加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test= new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length);
            }
        } finally {
            System.out.println(j);
        }
    }
}

实际发生的场景:


spring

mybatis

运行时常量池

在介绍运行时常量池之前,先来了解一下常量池。


常量池(Constant Pool Table):


二进制字节码组成:


类基本信息

常量池信息

类方法定义

虚拟机指令

在终端命令行中输入二进制字节码查看的命令:

javap -v *.class 
// 二进制字节码(类基本信息、常量池、类方法定义、包含了虚拟机指令)
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

类的基本信息

Classfile /D:/java学习/深入理解java虚拟机/out/production/深入理解java虚拟机/com/xxx/t1/HelloWorld.class
  Last modified 2022-3-29; size 555 bytes
  MD5 checksum 66459c9d86936e88b74e59657176cfcc
  Compiled from "HelloWorld.java"
public class com.czh.t1.HelloWorld
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER

常量池信息:

Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/xxx/t1/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/xxx/t1/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/xxx/t1/HelloWorld
  #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               (Ljava/lang/String;)V

运行时常量池


常量池是方法区的一部分,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量和符号引用等信息。

运行时常量池也是方法区的一部分,常量池是 *.class 文件中的,当该类被加载后,它的常量池信息就会被放入方法区的运行时常量池中。

注意:运行时常量池相对于 Class 文件常量池的一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,例如可以使用 String 类的 intern() 方法。


String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。《深入理解 Java 虚拟机》


异常情况:


当常量池无法再申请内存时会抛出 OutOfMemoryError 异常。

串池 StringTable 特性


常量池中的字符串仅是符号,第一次用到时才变为对象

利用串池的机制,来避免重复创建字符串对象

字符串变量拼接的原理是 StringBuilder(1.8),属于编译器优化

可以使用 intern() 方法,主动将串池中还没有的字符串对象放入串池:

1.8 将这个字符串对象尝试放入串池,如果有则不会放入,放入失败;如果没有则会放入串池,放入成功,会把串池中的对象返回。

1.6 将这个字符串对象尝试放入串池,如果有则不会放入,放入失败;如果没有把此对象复制一份,放入串池,放入成功,会把串池中的对象返回。

注意:在 1.8 中,如果调用 intern() 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象。而在 1.6 中,无论调用 intern() 方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象。

此外,无论放入是否成功,都会返回串池中的字符串对象。

public class Demo1_23 {
    // ["a", "b"]
    public static void main(String[] args) {
        String x = "ab";
        String s = new String("a") + new String("b");  // new String("ab")
        // 堆 new String("a") new String("b") new String("ab")
        String s2 = s.intern();   //将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
        System.out.println(s2 == "ab");  // true
        System.out.println(s2 == x); // true
        System.out.println(s == x); // false
    }
}

StringTable 位置


jdk 1.6 在永久代中

jdk 1.8 在堆内存中

StringTable 垃圾回收:StringTable 在内存紧张时,会发生垃圾回收。


StringTable 调优


调整 -XX:StringTableSize = 桶个数。由于 StringTable 是由 HashTable 实现的,所以可以适当增加 HashTable 桶的个数,来减少字符串放入串池所需要的时间。

考虑是否需要将字符串对象入池。可以通过intern方法减少重复入池。

2.6 直接内存

Direct Memory 直接内存:


属于操作系统,在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式

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

不受 JVM 内存回收管理

文件读写流程:image.png

使用了 DirectBuffer

image.png

使用 Native 函数库可以直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,在一些场合中能够显著提高性能,避免了在 Java 堆和 Native 堆中来回复制数据。

直接内存溢出演示

/**
 * 演示直接内存溢出
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class Demo1_10 {
    static int _100Mb = 1024 * 1024 * 100;
    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);
        }
        // 方法区是jvm规范,jdk6 中对方法区的实现称为永久代,jdk8 对方法区的实现称为元空间
    }
}

image.png

直接内存释放原理

直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过 unsafe.freeMemory 来手动释放。

/**
 * 演示自动分配和回收直接内存
 */
public class Demo1_26 {
    static int _1GB = 1024 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
        System.out.println("分配完毕......");
        System.in.read();
        System.out.println("开始释放......");
        byteBuffer = null;
        System.gc();
        System.in.read();
    }
}

allocateDirect 方法实现:

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

DirectByteBuffer 类:

DirectByteBuffer(int cap) {   
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);
    long base = 0;
    try {
        base = unsafe.allocateMemory(size); //主动分配直接内存
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
    att = null;
}

这里调用了一个 Cleaner 的 create 方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer)被回收以后,就会调用 Cleaner 的 clean 方法,来清除直接内存中占用的内存。

public void clean() {
       if (remove(this)) {
           try {
               this.thunk.run(); //调用run方法
           } catch (final Throwable var2) {
               AccessController.doPrivileged(new PrivilegedAction<Void>() {
                   public Void run() {
                       if (System.err != null) {
                           (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                       }
                       System.exit(1);
                       return null;
                   }
               });
}

对应对象的 run 方法:

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address); //释放直接内存中占用的内存
    address = 0;
    Bits.unreserveMemory(size, capacity);
}
/**
 * 演示主动分配和主动回收直接内存
 */
public class Demo1_27 {
    static int _1GB = 1024 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();
        // 分配内存,base表示分配的内存的地址
        long base = unsafe.allocateMemory(_1GB);
        unsafe.setMemory(base, _1GB, (byte) 0);
        System.in.read();
        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }
    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e){
            throw new RuntimeException();
        }
    }
}

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

BuyteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存。


相关文章
|
14天前
|
存储 Java 编译器
Java内存区域详解
Java内存区域详解
29 0
Java内存区域详解
|
24天前
|
缓存 算法 Java
Java内存管理与调优:释放应用潜能的关键
【4月更文挑战第2天】Java内存管理关乎性能与稳定性。理解JVM内存结构,如堆和栈,是优化基础。内存泄漏是常见问题,需谨慎管理对象生命周期,并使用工具如VisualVM检测。有效字符串处理、选择合适数据结构和算法能提升效率。垃圾回收自动回收内存,但策略调整影响性能,如选择不同类型的垃圾回收器。其他优化包括调整堆大小、使用对象池和缓存。掌握这些技巧,开发者能优化应用,提升系统性能。
|
20天前
|
缓存 安全 Java
Java并发编程进阶:深入理解Java内存模型
【4月更文挑战第6天】Java内存模型(JMM)是多线程编程的关键,定义了线程间共享变量读写的规则,确保数据一致性和可见性。主要包括原子性、可见性和有序性三大特性。Happens-Before原则规定操作顺序,内存屏障和锁则保障这些原则的实施。理解JMM和相关机制对于编写线程安全、高性能的Java并发程序至关重要。
|
28天前
|
缓存 Java C#
【JVM故障问题排查心得】「Java技术体系方向」Java虚拟机内存优化之虚拟机参数调优原理介绍(一)
【JVM故障问题排查心得】「Java技术体系方向」Java虚拟机内存优化之虚拟机参数调优原理介绍
79 0
|
2天前
|
Java 程序员 数据库连接
Java从入门到精通:3.3.2性能优化与调优——内存管理篇
Java从入门到精通:3.3.2性能优化与调优——内存管理篇
Java从入门到精通:3.3.2性能优化与调优——内存管理篇
|
2天前
|
Dubbo Java 应用服务中间件
Java从入门到精通:3.2.2分布式与并发编程——了解分布式系统的基本概念,学习使用Dubbo、Spring Cloud等分布式框架
Java从入门到精通:3.2.2分布式与并发编程——了解分布式系统的基本概念,学习使用Dubbo、Spring Cloud等分布式框架
|
3天前
|
存储 安全 Java
滚雪球学Java(19):JavaSE中的内存管理:你所不知道的秘密
【4月更文挑战第8天】🏆本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
30 4
滚雪球学Java(19):JavaSE中的内存管理:你所不知道的秘密
|
10天前
|
存储 缓存 监控
Java内存管理:垃圾回收与内存泄漏
【4月更文挑战第16天】本文探讨了Java的内存管理机制,重点在于垃圾回收和内存泄漏。垃圾回收通过标记-清除过程回收无用对象,Java提供了多种GC类型,如Serial、Parallel、CMS和G1。内存泄漏导致内存无法释放,常见原因包括静态集合、监听器、内部类、未关闭资源和缓存。内存泄漏影响性能,可能导致应用崩溃。避免内存泄漏的策略包括代码审查、使用分析工具、合理设计和及时释放资源。理解这些原理对开发高性能Java应用至关重要。
|
18天前
|
存储 缓存 安全
【企业级理解】高效并发之Java内存模型
【企业级理解】高效并发之Java内存模型
|
25天前
|
Java
java中jar启动设置内存大小java -jar 设置堆栈内存大小
java中jar启动设置内存大小java -jar 设置堆栈内存大小
12 1