学习java虚拟机——(1)内存结构

本文最后更新于:5 个月前

学习java虚拟机——(1)内存结构

1. 程序计数器

1.1定义

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

1.2作用

记住下一条jvm指令的执行地址

1.3 特点

  • 是线程私有的
  • 不会存在内存溢出

2. 虚拟机栈

2.1 定义

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

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

问题辨析

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

不需要==。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。垃圾回收==只会回收堆内存,不会回收栈内存。

  1. 栈内存分配越大越好吗?

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

  1. 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
    如果方法内局部变量没有逃离方法的作用访问,它是线程安全的

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

2.2 内存溢出

  • 栈帧过多导致栈内存溢出(无限递归)
  • 栈帧过大导致栈内存溢出

2.3 线程运行诊断

  1. CPU占用过高
  • ==Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程==
    • top命令,查看是哪个进程占用CPU过高
    • ps H -eo pid, tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用CPU过高
    • jstack 进程id 通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的,需要转换
  1. 运行很长时间没有结果

可能由于多个线程发生死锁,jstack 进程id 通过查看进程中的线程的nid

3. 本地方法栈

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

4. 堆

4.1 定义

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

特点

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

4.2 堆内存溢出

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

堆内存诊断

  1. jps : 查看当前系统中有哪些 java 进程*(获得进程id)* jps
  2. jmap (某时刻) 查看堆内存占用情况 jmap -heap 进程id
  3. **jconsole **图形化界面的,多功能的监测工具,可以连续监测 jconsole
  4. **jvisualvm **jvisualvm

5. 方法区

5.1 定义

方法区是在所有 Java 虚拟机线程之间共享。它存储每个类的结构,如运行时常量池字段方法数据,以及方法构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法 。

==方法区域是在虚拟机启动时创建的。==尽管方法区域在==逻辑上是堆的一部分==,但简单的实现可能会选择不进行垃圾回收或压缩它。此规范不规定方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小的,也可以根据计算的要求进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的。

如果方法区域中的内存无法用于满足分配请求,则 Java 虚拟机将引发 .OutOfMemoryError

5.2 结构

StringTable后来放在堆里的原因是永久栈的垃圾回收效率不高, 在堆中只需触发minGc就可进行回收

5.3 方法区内存溢出

  • 1.8以前会导致永久代内存溢出
  • 1.8以后会导致元空间内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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); // Class 对象
}
} finally {
System.out.println(j);
}
}
}

5.4 常量池

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

通过反编译来查看类的信息

  • 获得对应类的.class文件

    • 在JDK对应的bin目录下运行cmd,也可以在IDEA控制台输入
    • 在控制台输入 javap -v 类的绝对路径
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    E:\jvm\out\production\jvm\cn\itcast\jvm\t5>javap -v HelloWorld.class
    Classfile /E:/jvm/out/production/jvm/cn/itcast/jvm/t5/HelloWorld.class
    //类基本信息
    Last modified 2022-9-5; size 567 bytes
    MD5 checksum 8efebdac91aa496515fa1c161184e354
    Compiled from "HelloWorld.java"
    public class cn.itcast.jvm.t5.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 // cn/itcast/jvm/t5/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 Lcn/itcast/jvm/t5/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 cn/itcast/jvm/t5/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

    //类的方法定义
    {
    public cn.itcast.jvm.t5.HelloWorld(); //构造方法
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    LineNumberTable:
    line 4: 0
    LocalVariableTable:
    Start Length Slot Name Signature
    0 5 0 this Lcn/itcast/jvm/t5/HelloWorld;

    public static void main(java.lang.String[]); //main方法
    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 world
    5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    8: return
    LineNumberTable:
    line 6: 0
    line 7: 8
    LocalVariableTable:
    Start Length Slot Name Signature
    0 9 0 args [Ljava/lang/String;
    }
    SourceFile: "HelloWorld.java"

5.4 运行时常量池

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

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

5.5 StringTable 的特性

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象

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

  • 字符串变量拼接的原理是StringBuilder

  • 字符串常量拼接的原理是编译器优化

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

    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入!如果没有则放入串池,会把串池中的对象返回
  • 注意:无论是串池还是堆里面的字符串,都是对象

5.5.1 用来放字符串对象且里面的元素不重复

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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";
}
}

常量池中的信息,都会被加载到运行时常量池中,但这是a b ab 仅是常量池中的符号,还没有成为java字符串

0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return

当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)

当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中

当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中

最终StringTable [“a”, “b”, “ab”]

注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

5.5.2 使用拼接字符串变量对象创建字符串的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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")
System.out.println(s3 == s4); //false

}
}

反编译后的结果

Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder.”“:()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing;
27: astore 4
29: return

通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()

最后的toString方法的返回值是一个新的字符串,但字符串的值和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中.

5.5.3 使用拼接字符串常量对象的方法创建字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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
}
}

反编译后的结果

==Code:==
==stack=2, locals=6, args_size=1==
==0: ldc #2 // String a==
==2: astore_1==
==3: ldc #3 // String b==
==5: astore_2==
==6: ldc #4 // String ab==
==8: astore_3==
==9: new #5 // class java/lang/StringBuilder==
==12: dup==
==13: invokespecial #6 // Method java/lang/StringBuilder.”“:()V==
==16: aload_1==
==17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String==
==;)Ljava/lang/StringBuilder;==
==20: aload_2==
==21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String==
==;)Ljava/lang/StringBuilder;==
==24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str==
==ing;==
==27: astore 4==
==//ab3初始化时直接从串池中获取字符串==
==29: ldc #4 // String ab==
==31: astore 5==
==33: return==

  • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了“ab”,所以ab3直接从串池中获取值,所以进行的操作和 ab = “ab” 一致。
  • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值所以需要使用StringBuilder来创建

5.5.4 intern方法 1.8

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

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败

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

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

  • 成功:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
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);
}
}
  • 失败:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
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);
}
}

5.5.5 intern方法 1.6

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

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
  • 如果有该字符串对象,则放入失败

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

注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

5.6 StringTable 垃圾回收

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

5.7 StringTable 调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间

-XX:StringTableSize=xxxx

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

6. 直接内存

  • 属于操作系统,常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理

文件读写流程

使用了DirectBuffer

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

释放原理

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

通过

1
2
//通过ByteBuffer申请1M的直接内存 
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);

申请直接内存,但JVM并不能回收直接内存中的内容,它是如何实现回收的呢?

allocateDirect的实现

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

DirectByteBuffer类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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为虚引用的实际对象
att = null;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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方法

1
2
3
4
5
6
7
8
9
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address); //释放直接内存中占用的内存
address = 0;
Bits.unreserveMemory(size, capacity);
}

直接内存的回收机制总结

  • 使用了Unsafe类来完成直接内存的分配回收,回收需要主动调用freeMemory方法
  • ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer。一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存
1
-XX:+DisableExplicitGC 显式的

禁用显式的垃圾回收(代码中的System.gc()),可能会影响字节内存的释放回收


学习java虚拟机——(1)内存结构
https://changzer.gitee.io/2022/09/08/学习java虚拟机——1内存结构/
作者
长泽
发布于
2022年9月8日
许可协议