JVM内存结构

本文详细解析了Java虚拟机中的关键内存区域,包括程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池、StringTable以及直接内存,讨论了内存分配、回收、溢出及性能优化的相关概念和案例。
摘要由CSDN通过智能技术生成
Java全栈

1、程序计数器

1)定义

Program Counter Register 程序计数器(寄存器)

  • 作用,是记住下一条jvm指令的执行地址
  • 特点
    • 是线程私有的
    • 不会存在内存溢出
2)作用
0: getstatic #20 		// PrintStream out = System.out; 
3: astore_1 			// -- 
4: aload_1 				// out.println(1); 
5: iconst_1 			// -- 
6: invokevirtual #26 	// -- 
9: aload_1 				// out.println(2); 
10: iconst_2 			// -- 
11: invokevirtual #26 	// -- 
14: aload_1 			// out.println(3); 
15: iconst_3 			// -- 
16: invokevirtual #26 	// -- 
19: aload_1 			// out.println(4); 
20: iconst_4 			// -- 
21: invokevirtual #26 	// -- 
24: aload_1 			// out.println(5); 
25: iconst_5 			// -- 
26: invokevirtual #26 	// -- 
29: return
  • 解释器会解释指令为机器码交给 cpu 执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行。
  • 多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录线程下一行指令的地址行号,以便于接着往下执行。

2、虚拟机栈

1)定义
  • 每个线程运行需要的内存空间,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
2)问题辨析
  1. 垃圾回收是否涉及栈内存?
  • 不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。
  1. 栈内存分配越大越好吗?
  • 不是。因为物理内存是一定的,栈内存越大,可支持的递归调用越多,但是可执行的线程数就会越少。
  1. 方法里的局部变量是否线程安全?
  • 如果方法内部的变量没有逃离方法的作用访问,它是线程安全的。(局部变量线程私有)
  • 如果是局部变量引用了对象,并逃离了方法的访问,那就要考虑线程安全问题。
public static void main(String[] args) {
    StringBuilder builder = new StringBuilder();
    builder.append(1);
    builder.append(2);
    new Thread(()->{
        m2(builder);
    }).start();
}
static void m1(){
    // 独立变量
    StringBuilder builder = new StringBuilder();
    builder.append(1);
    builder.append(2);
    System.out.println(builder.toString());
}

static void m2(StringBuilder builder){
    // 共享变量
    builder.append(1);
    builder.append(2);
    System.out.println(builder.toString());
}

static StringBuilder m3(){
    // 返回后变成了共享变量
    StringBuilder builder = new StringBuilder();
    builder.append(1);
    builder.append(2);
    retfurn builder;
}
3)栈内存溢出

栈的内存是固定的,栈帧过大、过多、或者第三方类库操作,都有可能造成栈内存溢出 java.lang.stackOverflowError ,使用 -Xss256k 可指定栈内存大小!

  • 无限调用递归方法时
  • 循环引用时
4)线程运行诊断

案例一:cpu 占用过多

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

  • top 命令:查看是哪个进程占用 CPU 过高
  • ps H -eo pid, tid, %cpu | grep 刚才通过 top 查到的进程id:通过 ps 命令进一步查看是哪个线程占用 CPU 过高
  • jstack 进程 id:通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

案例二:程序运行很长时间都没有结果(线程死锁问题)

  • 运行Java程序:会产生进程id

  • jstack 进程 id

3、本地方法栈

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

public final native Class<?> getClass();

4、堆

1)定义

Heap 堆:通过new关键字创建的对象都会被放在堆内存

特点:

  • 它是线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制
2)堆内存溢出

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

可以使用 -Xmx8m 来指定堆内存大小。

3)堆内存诊断(Terminal 中输入)
  1. jps 工具
    1. 查看当前系统中有哪些 java 进程
  1. jmap 工具
    1. 查看堆内存占用情况 jmap - heap 进程id(只能查看某一时刻)
  1. jconsole 工具
    1. 图形界面的,多功能的监测工具,可以连续监测
  1. jvisualvm 工具
    1. 图形界面的,多功能的监测工具,可以连续监测

5、方法区

1)定义

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。

方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。

它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。

尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!

2)组成

Hotspot 虚拟机 jdk1.6 - 1.8 内存逻辑结构图

3)方法区内存溢出
  • 1.8 之前会导致永久代内存溢出 java.lang.OutofMenoryError:PermGen space
    • 使用 -XX:MaxPermSize=8m 指定永久代内存大小
  • 1.8 之后会导致元空间内存溢出 java.lang.OutofMenoryError:Metaspace
    • 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

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

场景:

  • spring使用cglib动态代理
  • mybatis使用cglib动态代理
4)运行时常量池

二进制字节码包含(类的基本信息,常量池,类方法定义,包含了虚拟机的指令)

首先看看常量池是什么,编译如下代码:

public class Test {
    public static void main(String[] args) {
    	System.out.println("Hello World!");
    }
}

然后使用 javap -v Test.class 命令反编译查看结果。

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 Word
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/Str
ing;)V
   #5 = Class              #26            // demo/HelloWord
   #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               Ldemo/HelloWord;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWord.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello Word
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               demo/HelloWord
  #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

每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。

{
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2     // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3     // String Hello Word
         5: invokevirtual #4     // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 10: 0
        line 11: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

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

运行时常量池:常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址(加载到内存中)

5)StringTable(串池)
  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象(懒惰加载)
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder
  • 字符串常量拼接的原理是编译器优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
public static void main(String[] args) {
    String s1 = "a";
    String s2 = "b";
    // 在串池中
    String s3 = "ab";
    // new StringBuilder().append(s1).append(s2).toString() new String("ab")
    // 在堆内存中
    String s4 = s1 + s2;
    // 会去串池中直接找"ab"(javac 在编译期间的优化:结果已经在编译期间确定为ab)
    String s5 = "a" + "b";
    // false
    System.out.println(s3 == s4);
    // true
    System.out.println(s3 == s5);
}

intern方法(JDK 1.8 )

调用字符串对象的 intern 方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败
  • 无论放入是否成功,都会返回串池中的字符串对象

注意:如果调用 intern 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

JDK 1.6:如果串池中没有该字符串对象,将其对象复制一份后将复制的对象放入串池,其它一致

例1:

public static void main(String[] args) {
    // "a" "b" 被放入串池中,str 则存在于堆内存之中
    String str = new String("a") + new String("b");
    // 调用 str 的 intern 方法,这时串池中没有 "ab" ,则会将该字符串对象放入到串池中,此时堆内存与串池中的 "ab" 是同一个对象
    String st2 = str.intern();
    // 给 str3 赋值,因为此时串池中已有 "ab" ,则直接将串池中的内容返回
    String str3 = "ab";
    // 因为堆内存与串池中的 "ab" 是同一个对象,所以以下两条语句打印的都为 true
    System.out.println(str == st2);
    System.out.println(str == str3);
}

例2:

public static void main(String[] args) {
    // 此处创建字符串对象 "ab" ,因为串池中还没有 "ab" ,所以将其放入串池中
    String str3 = "ab";
    // "a" "b" 被放入串池中,str 则存在于堆内存之中
    String str = new String("a") + new String("b");
    // 此时因为在创建 str3 时,"ab" 已存在与串池中,所以放入失败,但是会返回串池中的 "ab" 
    String str2 = str.intern();
    // false
    System.out.println(str == str2);
    // false
    System.out.println(str == str3);
    // true
    System.out.println(str2 == str3);
}
6)StringTable 的位置

jdk1.6 StringTable 位置是在永久代中,1.8 StringTable 位置是在堆中。

7)StringTable 垃圾回收
  • -Xmx10m 指定堆内存大小
  • -XX:+PrintStringTableStatistics 打印字符串常量池信息
  • -XX:+PrintGCDetails
  • -verbose:gc 打印 gc 的次数,耗费时间等信息
/**
 * 演示 StringTable 垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public static void main(String[] args) {
    int i = 0;
    try {
        for(int j = 0; j < 10000; j++) { // j = 100, j = 10000
            String.valueOf(j).intern();
            i++;
        }
    }catch (Exception e) {
        e.printStackTrace();
    }finally {
        System.out.println(i);
    }
}
8)StringTable 性能调优
  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间
-XX:StringTableSize=桶个数(最少设置为 1009 以上)
  • 考虑是否需要将字符串对象入池
  • 可以通过 intern 方法减少重复入池

6、直接内存

1)定义

Direct Memory

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理
2)使用直接内存的好处
  • 文件读写流程

因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java 堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。

  • 使用了 DirectBuffer 的文件读取流程

直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。

3)直接内存回收原理
public class Code_06_DirectMemoryTest {
    public static int _1GB = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
        // method();
        method1();
    }

    // 演示 直接内存被 unsafe 创建与回收
    private static void method1() throws IOException, NoSuchFieldException, IllegalAccessException {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe)field.get(Unsafe.class);

        // 分配内存
        long base = unsafe.allocateMemory(_1GB);
        unsafe.setMemory(base,_1GB, (byte)0);
        System.in.read();

        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }

    // 演示 直接内存被释放
    private static void method() throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
        System.out.println("分配完毕");
        System.in.read();
        System.out.println("开始释放");
        byteBuffer = null;
        System.gc(); // 手动 gc
        System.in.read();
    }
}

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

  • 第一步:allocateDirect 的实现
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

底层是创建了一个 DirectByteBuffer 对象。

  • 第二步:DirectByteBuffer 类
DirectByteBuffer(int cap) {   // package-private
   
    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为虚引用的实际对象, 第二个参数是一个回调,实现了 runnable 接口,run 方法中通过 unsafe 释放内存。
    att = null;
}

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

 public void clean() {
    if (remove(this)) {
        try {
        // 都用函数的 run 方法, 释放内存
            this.thunk.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;
                }
            });
        }
    }
}

可以看到关键的一行代码, this.thunk.run(),thunk 是 Runnable 对象。run 方法就是回调 Deallocator 中的 run 方法,

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    // 释放内存
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}
4)直接内存的回收机制总结
  • 使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法
  • ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存

注意:

/**
 * -XX:+DisableExplicitGC 显示的
 */
private static void method() throws IOException {
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
    System.out.println("分配完毕");
    System.in.read();
    System.out.println("开始释放");
    byteBuffer = null;
    System.gc(); // 手动 gc 失效
    System.in.read();
}

一般用 jvm 调优时,会加上下面的参数:

-XX:+DisableExplicitGC  // 静止显示的 GC

意思就是禁止我们手动的 GC,比如手动 System.gc() 无效,它是一种 full gc,会回收新生代、老年代,会造成程序执行的时间比较长。所以我们就通过 unsafe 对象调用 freeMemory 的方式释放内存。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值