JVM之内存结构

2022/11/22 JVM

# 1 程序计数器

1669015637679

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

  • 作用:是记住下一条jvm指令的执行地址
  • 特点
    • 是线程私有的
    • 不会存在内存溢出

# 2 虚拟机栈

1669015814320

# 2.1 定义

Java Virtual Machine Stacks (Java 虚拟机栈)

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

问题辨析

  1. 垃圾回收是否涉及栈内存?
    • 每一个栈帧使用完后会自动释放该栈帧所对应的内存,垃圾回收只涉及到堆内存
  2. 栈内存分配越大越好吗?
    • 每个线程都会对应一个栈内存,如果栈内存分配越大则所能使用的线程数则减小,也对程序的执行起不到加快的作用
  3. 方法内的局部变量是否线程安全?
    • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

# 2.2 栈内存溢出

  • 栈帧过多导致栈内存溢出

    • 递归没有给到合适的出口
    • 第三方组件使用不当导致循环引用(ObjectMapper:对象 -> Json)
  • 栈帧过大导致栈内存溢出(局部变量过多导致,但较难触发)

Tip:

  • 可以使用 -Xss 来改变栈内存的大小

  • 栈内存溢出的错误信息为:java.lang.StackOverflowError

# 2.3 线程运行诊断

eg1:CPU占用过多

定位问题步骤(Linux)

  • top 命令定位哪个进程对CPU的占用过高
  • ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定义是哪个线程引起的cpu占用过高)
  • jstack 进程id (可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号)

eg2:程序运行很长时间没有结果(线程死锁)

# 3 本地方法栈

1669019510517

本地方法栈的作用类似于JVM虚拟机栈,就是调用本地方法Native时会开辟一个本地方法的执行内存,也就是本地方法栈,其中有调用库函数的执行栈帧。

# 4 堆

1669021362263

# 4.1 定义

Heap 堆

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

特点

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

# 4.2 堆内存溢出

当创建的对象足够多并且这些对象都正在被使用,不会被当成垃圾进行处理时,就会产生堆内存溢出的现象

Tip:

  • 可以使用 -Xmx 来改变堆内存的最大大小
  • 堆内存溢出的错误信息为:java.lang.OutOfMemoryError: Java heap space

# 4.3 堆内存诊断

有如下代码进行测试

public class Demo1_4 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1..");
        Thread.sleep(30000);
        // 分配 10M内存
        byte[] bytes = new byte[1024 * 1024 * 10];
        System.out.println("2..");
        Thread.sleep(30000);
        bytes = null;
        // 进行一次垃圾回收
        System.gc();
        System.out.println("3..");
        Thread.sleep(1000000L);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  1. jps 工具:查看当前系统中有哪些 Java 进程

    PS JVM> jps
    12128 Demo1_4
    12116 RemoteMavenServer
    14968
    7720 Jps
    7800 Launcher
    
    1
    2
    3
    4
    5
    6
  2. jmap 工具:查看堆内存占用情况 jmap -heap 进程id

    • 在输出1时的堆内存情况

      Heap Usage:										# 堆内存占用情况
      PS Young Generation
      Eden Space:										# 新创建对象使用的分区
         capacity = 34078720 (32.5MB)					# 堆内存容量
         used     = 4771568 (4.5505218505859375MB)	# 堆内存已使用情况
         free     = 29307152 (27.949478149414062MB)	# 可分配
         14.001605694110577% used
      
      1
      2
      3
      4
      5
      6
      7
    • 在输出2时的堆内存情况,因为创建了一个 10M 的字节数组,所以此时堆内存使用量比上次多了 10M 左右的使用

      Heap Usage:										# 堆内存占用情况
      PS Young Generation
      Eden Space:										# 新创建对象使用的分区
         capacity = 34078720 (32.5MB)					# 堆内存容量
         used     = 15257344 (14.550537109375MB)		# 堆内存已使用情况
         free     = 18821376 (17.949462890625MB)		# 可分配
         44.77088341346154% used
      
      1
      2
      3
      4
      5
      6
      7
    • 在输出3时的堆内存情况,进行了垃圾回收,将分配的 10M 字节数组进行回收,使得堆内存使用量大大减少

      Heap Usage:										# 堆内存占用情况
      PS Young Generation
      Eden Space:										# 新创建对象使用的分区
         capacity = 34078720 (32.5MB)					# 堆内存容量
         used     = 681592 (0.6500167846679688MB)		# 堆内存已使用情况
         free     = 33397128 (31.84998321533203MB)	# 可分配
         2.0000516451322117% used
      
      1
      2
      3
      4
      5
      6
      7
  3. jconsole 工具:图形界面,多功能的监测工具,可以连续监测

    分为三个阶段的使用情况,阶段一为初始阶段。阶段二时分配了一段内存,阶段三进行了垃圾回收

    1669024208924

  4. jvisualvm 工具:与 jconsole 类似的图形化界面,但是该用具可以 dump堆内存快照,可以查看堆中存活的对象信息

    • 案例/代码:垃圾回收后,内存占用仍然很高

      public class Demo1_6 {
          public static void main(String[] args) throws InterruptedException {
              List<Student> list = new ArrayList<>(200);
              for (int i = 0; i < 200; i++) {
                  list.add(new Student());
              }
              // 为了调试而休眠
              Thread.sleep(1000000000L);
          }
      }
      class Student {
          private byte[] big = new byte[1024 * 1024];
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
    • 使用 jps 命令获取到该程序的进程id,使用 jmap -heap 进程id 查看堆内存使用

      可以发现堆内存总共占用了 (16+187)M,然后使用 jconsole 工具进行该进程的 gc 回收

      Heap Usage:
      PS Young Generation
      Eden Space:
         capacity = 116916224 (111.5MB)
         used     = 17285928 (16.485145568847656MB)
         free     = 99630296 (95.01485443115234MB)
         14.784883918249019% used
      PS Old Generation					# 老年代堆内存使用情况
         capacity = 334495744 (319.0MB)
         used     = 196736312 (187.62236785888672MB)
         free     = 137759432 (131.37763214111328MB)
         58.815789297456654% used
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12

      可以发现,虽然进行了 gc 垃圾回收,但是堆内存占用还是居高不下,使用 jconsole 工具已经没办法进一步查询到具体问题了

      1669043972054

    • 使用 jvisualvm 进行堆内存 dump

      使用命令 jvisualvm 打开可视化界面,选择当前执行的进程,查看详细信息,然后进行堆内存 dump

      1669044220427

      可以发现当前的 ArrayList 对象占用的内存高达约 200M 左右的内存,点击进去查看该对象的详细信息

      1669044331372

      在 ArrayList 对象中的每一个元素都占用了约 1M 的内存大小,一共有200个元素,则总占用就有了大约 200M 的堆内存空间,由此快速定位到代码中存在的问题

      1669044475571

# 5 方法区

1669044952813

# 5.1 定义

Java 虚拟机规范 (opens new window)

以下是官方对方法区的定义

Java 虚拟机具有 在所有 Java 虚拟机之间共享的方法区域 线程。方法区域类似于已编译的存储区域传统语言的代码或类似于“文本”段的代码 操作系统进程。它存储每个类结构,如运行时常量池、字段和方法数据以及方法和构造函数,包括特殊方法 (§2.9 (opens new window)) 用于类和实例初始化 和接口初始化。

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

Java 虚拟机实现可以为程序员或用户控制方法区域的初始大小,以及,对于不同尺寸的方法区域,控制最大值和最小方法区域大小。

以下特殊条件与方法区域相关联:

  • 如果方法区域中的内存无法用于满足分配请求,Java 虚拟机抛出一个。OutOfMemoryError

# 5.2 组成

JDK6 和 JDK8 的内存结构发生了变化

注意:图中的 常量池 指的是 运行时常量池

1669045525089

# 5.3 方法区内存溢出

  • JDK1.8 以前会导致永久代内存溢出:java.lang.OutOfMemoryError: PermGen space
  • JDK1.8 以后会导致元空间内存溢出:java.lang.OutOfMemoryError: Metaspace

演示 JDK1.8 以后元空间内存溢出(JDK1.6 代码几乎相同,永久代使用 -XX:MaxPermSize=8m

public class Demo1_7 extends ClassLoader {
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_7 test = new Demo1_7();
            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);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

在 1.8 以后元空间使用的是系统没存,没有限制大小,不会发生溢出的情况,需要设置 -XX:MaxMetaspaceSize=8m

5411
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
	at com.zhuhjay.Demo1_7.main(Demo1_7.java:23)
1
2
3
4
5
6

方法区内存溢出场景:

  • Spring
  • Mybatis

这些场景都会使用 CGLIB 等代理技术动态的生成字节码,使用不当可能会导致方法区内存溢出的情况。

# 5.4 运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

Tip: 常量池的概念解析

编译运行以下代码

public class Demo1_8 {
     public static void main(String[] args) {
         System.out.println("hello world");
     }
}
1
2
3
4
5

将编译后的 class 文件进行反编译 javap -v Demo1_8.class

  • 源码 1-8 行是文件的 类的基本信息
  • 源码 9-42 行是 常量池信息
  • 源码 44-72 行是 类方法定义信息
  • 当 class 文件被运行之后,所有的 #.. 都会被赋予真实的物理地址信息
Classfile /G:/Idea_workspace/JVM/target/classes/com/zhuhjay/Demo1_8.class
  Last modified 2022-11-22; size 548 bytes
  MD5 checksum 551e83a2ede53badac918978a8846964
  Compiled from "Demo1_8.java"
public class com.zhuhjay.Demo1_8
  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/zhuhjay/Demo1_8
   #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/zhuhjay/Demo1_8;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Demo1_8.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/zhuhjay/Demo1_8
  #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 com.zhuhjay.Demo1_8();
    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 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/zhuhjay/Demo1_8;

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

进行main方法信息进行分析 62-66 行代码截取,在CPU中是不会读取到 // 后面的注释信息的,这些注释信息是为了让程序员快速明白的注释信息。

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
1
2
3
4
5
  • 0: getstatic #2

    • getstatic:获取类的成员变量

    • #2:去常量池 Constant pool 中查找对应的信息

      1. #2 = Fieldref #21.#22:需要找到地址 #21.#22,该组成为 成员变量的引用

      2. #21 = Class #28:需要找到地址 #28,该地址信息为 Class

      3. #28 = Utf8 java/lang/System:找到 System 类名

      4. #22 = NameAndType #29:#30:需要找到 方法名和其类型

      5. #29 = Utf8 out:找到调用的静态方法

      6. #30 = Utf8 Ljava/io/PrintStream;:找到类型

    • 即该条命令就是为了找到该结果 Field java/lang/System.out:Ljava/io/PrintStream;,这也就是源码的 System.out

  • 3: ldc #3

    • ldc:找到一个地址,并作为下一次指令的参数
    • #3:
      1. #3 = String #23:需要找到地址 #23
      2. #23 = Utf8 hello world:找到类型为 Utf8 的字符串
    • 即该条指令就是为了找到结果 hello world
  • 5: invokevirtual #4

    • invokevirtual:执行一次虚方法调用(方法调用)
    • #4:
      1. #4 = Methodref #24.#25:需要找到地址 #24.#25,该组为 方法引用
      2. #24 = Class #31:需要找到地址 #31,是一个 Class 对象
      3. #31 = Utf8 java/io/PrintStream:一个输出流的类名
      4. #25 = NameAndType #32:#33:需要找到地址 #32:#33,方法名及其类型
      5. #32 = Utf8 println:找到需要调用的方法名称
      6. #33 = Utf8 (Ljava/lang/String;)V:参数类型为 String,无返回值
    • 即该条指令就是为了找到结果 Method java/io/PrintStream.println:(Ljava/lang/String;)V,也就是源码中的 println("hello world"),需要将上一个指令的数据作为方法参数

# 5.5 StringTable

用来存储字符串的常量池,使用硬编码的字符串都会被存储到字符串常量池中

eg:

  • new String("a") 会被放入字符串常量池和堆中,返回值是堆中的引用
  • "a" 会被直接放到字符串常量池中,返回值是串池中的引用
# 5.5.1 解析引入
  • 现有以下代码,将其进行编译后,使用 javap 对其进行反编译,查看常量池的信息

    public class Demo1_9 {
        public static void main(String[] args) {
            String s1 = "a";
            String s2 = "b";
            String s3 = "ab";
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
  • 编译结果如下(只展示部分)

    • 常量池中的信息,都会被加载到运行时常量池中,这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
    • ldc #2 会把 a 符号变为 "a" 字符串对象,然后到 StringTable 中去找是否存在该值,不存在则存入 StringTable 中
      • StringTable 是 hashtable 结构,不能扩容,一创建就固定了大小
    • ldc #3 ldc #4 同上
    • astore_1 即是将上一条指令的内容存入到方法栈桢的变量表的 1 位置上
    Constant pool:
       #2 = String             #25            // a
       #3 = String             #26            // b
       #4 = String             #27            // ab
    
    public static void main(java.lang.String[]);
        Code: 
            stack=1, locals=4, 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: return
            LocalVariableTable:			# main方法栈帧的变量表
                Start  Length  Slot  Name   Signature
                    0      10     0  args   [Ljava/lang/String;
                    3       7     1    s1   Ljava/lang/String;
                    6       4     2    s2   Ljava/lang/String;
                    9       1     3    s3   Ljava/lang/String;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
# 5.5.2 变量拼接
  • 现对源代码进行改变如下,需要了解字符串变量拼接的原理,以及 s3 == s4 是否成立

    public class Demo1_9 {
        public static void main(String[] args) {
            String s1 = "a";
            String s2 = "b";
            String s3 = "ab";
            String s4 = s1 + s2;
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
  • 进行反编译(只展示部分),根据如下执行的指令,可以发现

    • String s4 = s1 + s2; 是通过 new StringBuilder().append("a").append("b").toString() 来进行组成的字符串
    • StringBuilder 中的 toString 方法就是通过 new String("ab") 在堆空间中创建一个对象
    • 以上即可得出 s3 == s4 不成立,s3 变量指向串池,s4 变量指向堆中的对象
    Constant pool:
       #2 = String             #30            // a
       #3 = String             #31            // b
       #4 = String             #32            // ab
       #5 = Class              #33            // java/lang/StringBuilder
       #6 = Methodref          #5.#29         // java/lang/StringBuilder."<init>":()V
       #7 = Methodref          #5.#34         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
       #8 = Methodref          #5.#35         // java/lang/StringBuilder.toString:()Ljava/lang/String;
    
    public static void main(java.lang.String[]);
        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."<init>":()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/String;
        27: astore        4
        29: return
    
    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
# 5.5.3 常量拼接
  • 现对源代码进行改变如下,需要了解字符串常量拼接的原理,以及 s3 == s5 是否成立

    public class Demo1_9 {
        public static void main(String[] args) {
            String s1 = "a";
            String s2 = "b";
            String s3 = "ab";
            String s4 = s1 + s2;
            String s5 = "a" + "b";
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
  • 进行反编译(只展示部分),根据如下执行的指令,可以发现

    • String s5 = "a" + "b"; 直接往 StringTable 串池中查找 "ab"
    • 发生以上的原因:javac 在编译期间的优化,结果已经在编译器确定为 "ab"
    • 即得出 s3 == s5 成立,s3 和 s5 都是来自串池中的 "ab",属于同一个对象
    public static void main(java.lang.String[]);
        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."<init>":()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/String;
        27: astore        4
        29: ldc           #4                  // String ab
        31: astore        5
        33: return
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
# 5.5.4 intern (JDK1.8)
  • 现对源代码进行改变如下,需要了解 String.intern() 方法的原理,以及 s6 == s7 是否成立

    public class Demo1_9 {
        public static void main(String[] args) {
            String s6 = new String("c") + new String("d");
            String s7 = "cd";
            String intern = s6.intern();
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
  • 进行反编译(只展示部分),根据如下执行的指令,可以发现

    • 通过 new String("c") 方法,会在堆内存和常量池中各创建一份数据,然后通过 变量拼接 的方式在堆内存中生成一份 cd,但此数据没有入串池中
    • String s7 = "cd"; 此时串池中没有存在该数据,所以入串池操作
    • s6.intern(); 将该字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
    • 程序执行完后,会发现 s6 属于堆内存中的对象,而 s7 属于串池中的对象,所以 s6 == s7 不成立
    public static void main(java.lang.String[]);
        0: new           #2                  // class java/lang/StringBuilder
        3: dup
        4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
        7: new           #4                  // class java/lang/String
        10: dup
        11: ldc           #5                  // String c
        13: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: new           #4                  // class java/lang/String
        22: dup
        23: ldc           #8                  // String d
        25: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: astore_1
        35: ldc           #10                 // String cd
        37: astore_2
        38: aload_1
        39: invokevirtual #11                 // Method java/lang/String.intern:()Ljava/lang/String;
        42: astore_3
        43: return
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

思考:

​ 将以上代码修改为以下代码,s6 == s7 又会是怎样的结果?

public class Demo1_9 {
    public static void main(String[] args) {
        String s6 = new String("c") + new String("d");
        String intern = s6.intern();
        String s7 = "cd";
    }
}
1
2
3
4
5
6
7

分析:

  1. 执行完 s6 变量赋值过后,常量池和堆的数据如下
    • StringTable ["c", "d"]
    • Heap ['new String("c")', 'new String("d")', 'new String("cd")']
  2. 执行完 s6.intern 后,会判断常量池中是否存在 "cd"
    • StringTable ["c", "d", "cd"] (实际上这里的 "cd" 是堆中 'new String("cd")' 的一个引用,是用一个对象
    • Heap ['new String("c")', 'new String("d")', 'new String("cd")']
  3. 执行到 s7 时,发现常量池中存在了 "cd",所以直接引用

综上所述,当前情况下的 s6 == s7 是成立的

# 5.5.5 intern (JDK1.6)

在 JDK1.6 中,以下执行的结果与 JDK1.8 的相同: s6 == s7 不成立

public class Demo1_9 {
    public static void main(String[] args) {
        String s6 = new String("c") + new String("d");
        String s7 = "cd";
        String intern = s6.intern();
    }
}
1
2
3
4
5
6
7

但是当改为以下代码时,结果却不尽相同

  1. 执行完 s6 变量赋值过后,常量池和堆的数据如下
    • StringTable ["c", "d"]
    • Heap ['new String("c")', 'new String("d")', 'new String("cd")']
  2. 执行完 s6.intern 后,会判断常量池中是否存在 "cd",如果不存在,那么将 s6 拷贝一份放入常量池中,并返回
    • StringTable ["c", "d", "cd"] (在此时,"cd" 和 'new String("cd")' 不是同一个对象,因为是通过拷贝的方式
    • Heap ['new String("c")', 'new String("d")', 'new String("cd")']
  3. 执行到 s7 时,发现常量池中存在了 "cd",所以直接引用

综上所述,当前情况下的 s6 == s7 是不成立的

public class Demo1_9 {
    public static void main(String[] args) {
        String s6 = new String("c") + new String("d");
        String intern = s6.intern();
        String s7 = "cd";
    }
}
1
2
3
4
5
6
7
# 5.5.6 特性总结
  • 常量池中的字符串仅是符号,第一次用到时才变为对象

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

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

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

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

    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回

# 5.6 StringTable 面试题

学习完 StringTable 字符串常量池后,能正确的理解回答下面的题目,那么恭喜你掌握了一个新的知识!

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢?如果是jdk1.6呢?
System.out.println(x1 == x2);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

答案:

false true true 
jdk1.8: false	true
jdk1.6: false 	false
1
2
3

# 5.7 StringTable 位置

在 JDK1.6 时,StringTable 是常量池的一部分,常量池存储在永久代中(在永久代中需要进行 Full GC 才会进行垃圾回收,回收效率低)

在 JDK1.8 时,StringTable 是堆中的一部分,垃圾回收跟随堆进行,回收效率高

怎么证明以上两个版本存储的位置不相同?

  • 在 JDK1.6 中,如果常量池中发生了内存溢出,那么就会报错 java.lang.OutOfMemoryError: PermGen space,永久代内存溢出
  • 在 JDK1.8 中,如果常量池中发生了内存溢出,那么就会报错 java.lang.OutOfMemoryError: Java heap space,堆内存溢出

通过以上思路,来证明 JDK1.6 和 JDK1.8 StringTable 常量池的位置不同

案例代码

public class Demo1_10 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
        try {
            for (int j = 0; j < 260000; j++) {
                list.add(String.valueOf(j).intern());
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

JDK1.8 验证:需要设置堆内存的大小 -Xmx10m,会发现有以下结果,报错信息和预想的结果不一样?

  • 原来,在 JVM规范中 当GC垃圾回收花了 98% 的时间只释放了 2% 的堆空间时,JVM 就会认为该程序没救了,就直接抛出该异常
  • 那么要跳过这个限制,需要再多配置一个参数 -XX:-UseGCOverheadLimit
java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.Integer.toString(Integer.java:403)
	at java.lang.String.valueOf(String.java:3099)
	at com.zhuhjay.Demo1_10.main(Demo1_10.java:16)
145367
1
2
3
4
5

最终结果就是预想的结果了!

*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message can't create byte arrau at JPLISAgent.c line: 813
java.lang.OutOfMemoryError: Java heap space
	at java.lang.Integer.toString(Integer.java:401)
	at java.lang.String.valueOf(String.java:3099)
	at com.zhuhjay.Demo1_10.main(Demo1_10.java:16)
146837
1
2
3
4
5
6

JDK1.6 验证:需要设置永久代内存的大小 -XX:MaxPermSize=10m

# 5.8 StringTable 垃圾回收

查看字符串常量池 StringTable 的垃圾回收是否会在空间不足时进行字符串常量池的垃圾回收

示例代码,为了更进一步查看堆内存和字符串常量池的空间,需要添加以下参数

  • -Xmx10m:设置堆的大小,为了方便测试GC垃圾回收
  • -XX:+PrintStringTableStatistics:打印字符串表的信息
  • -XX:+PrintGCDetails -verbose:gc:统计垃圾回收的信息
public class Demo1_11 {
    public static void main(String[] args) {
        int i = 0;
        try {
			// TODO 待插入代码
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

查看输出结果,认识一下下面所代表的含义

Heap		# 堆内存的占用情况
 PSYoungGen      total 2560K, used 1676K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 81% used [0x00000000ffd00000,0x00000000ffea3198,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
 Metaspace       used 3167K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 345K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:		# 符号表的详细信息(也是常量池的一部分,但是并不关心这部分内容)
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13260 =    318240 bytes, avg  24.000
Number of literals      :     13260 =    568232 bytes, avg  42.853
Total footprint         :           =   1046560 bytes
Average bucket size     :     0.663
Variance of bucket size :     0.666
Std. dev. of bucket size:     0.816
Maximum bucket size     :         6
StringTable statistics:		# StringTable 统计信息,使用 hashtable 结构
Number of buckets       :     60013 =    480104 bytes, avg   8.000	# 桶个数 = 字节数
Number of entries       :      1739 =     41736 bytes, avg  24.000	# 键值对个数 = 字节数
Number of literals      :      1739 =    156592 bytes, avg  90.047	# 字符串常量个数 = 字节数
Total footprint         :           =    678432 bytes				# 总的空间
Average bucket size     :     0.029
Variance of bucket size :     0.029
Std. dev. of bucket size:     0.171
Maximum bucket size     :         3
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

现在在以上代码中插入以下代码:将10000个字符串添加到字符串常量池中,此时字符串常量池中理应会在原来的 1739 基础上多 10000

for (int j = 0; j < 10000; j++) {
    String.valueOf(j).intern();
    i++;
}
1
2
3
4

可以看到此时运行就有了 GC垃圾回收 的信息,此时字符串常量池没有达到预期的效果,说明当常量池中有不被使用的数据会被垃圾回收

[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->704K(9728K), 0.0031150 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
10000
Heap
...省略展示
SymbolTable statistics:
...省略展示
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      4026 =     96624 bytes, avg  24.000
Number of literals      :      4026 =    266888 bytes, avg  66.291
Total footprint         :           =    843616 bytes
Average bucket size     :     0.067
Variance of bucket size :     0.065
Std. dev. of bucket size:     0.256
Maximum bucket size     :         3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5.9 StringTable 性能调优

  • 字符串常量池的性能随着分配桶的个数越多,效率越高,参数配置 -XX:StringTableSize=1009,配置最小值为 1009

  • String.intern 调优了一大部分性能,防止滋生很多的重复数据

# 6 直接内存

直接内存是系统内存

# 6.1 定义

Direct Memory

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

# 6.2 使用直接内存

# 6.2.1 测试IO与Direct Memory读写效率

使用差不多 1G 的文件来进行读写对比

public class Demo1_12 {
    private static final String FROM = "G:\\maven\\maven_repository_0.zip";
    private static final String TO = "G:\\IDM_DownLoad\\Video\\Temp\\maven.zip";
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        io();
        directBuffer();
    }

    private static void directBuffer() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
            FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1MB);
            while (true) {
                int length = from.read(bb);
                if (length == -1) {
                    break;
                }
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
    }

    private static void io() {
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1MB];
            while (true) {
                int length = from.read(buf);
                if (length == -1) {
                    break;
                }
                to.write(buf, 0, length);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("io 用时:" + (end - start) / 1000_000.0);
    }
}
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

对比结果如下,单位ms,可以发现使用直接内存读写效率更高

io 用时:19611.177
directBuffer 用时:5490.16
1
2

1669128719697

  • Java的 io 操作涉及到 CPU 的用户态和内核态
    • io 操作需要操作系统从磁盘中进行磁盘文件的读取,将磁盘文件读取到系统内存中的系统缓存区
    • 此时 Java 不能直接操作该缓存区,需要在 Java 堆内存中开辟一块 java缓冲区byte[]
    • 然后将 系统缓冲区 的数据写到 Java缓冲区 中才能进一步操作

1669128690245

  • 调用 ByteBuffer bb = ByteBuffer.allocateDirect(_1MB); 时,会在 系统内存 和 Java堆内存 中开辟一块 direct Memory 内存来进行使用,这就是直接内存,使得 Java 可以直接操作

# 6.2.2 直接内存溢出

每次分配 100M 内存,直到 Direct buffer memory 溢出

public class Demo1_13 {
    private static final 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);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

可以看到直接内存分配到大约 1.7G 时就溢出了

17
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:695)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at com.zhuhjay.Demo1_13.main(Demo1_13.java:19)
1
2
3
4
5
6

# 6.2.3 直接内存释放

查看以下案例,分配 1GB 的内存,然后使用 GC 进行回收

public class Demo1_14 {
    private static final 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();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

结果如图所示,这样一来感觉好像可以被 GC垃圾回收 进行 直接内存 的回收,但其实并不然。

1669130149269

接下来 Unsafe 脱离 JVM 的内存管理来进行 直接内存 分配

public class Demo1_15 {
    private static final int _1GB = 1024 * 1024 * 1024;
    public static void main(String[] args) throws Exception {
        Unsafe unsafe = getUnsafe();
        // 分配内存,并返回分配到的内存首地址
        long base = unsafe.allocateMemory(_1GB);
        unsafe.setMemory(base, _1GB, (byte) 0);
        System.in.read();

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

    private static Unsafe getUnsafe() throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        return (Unsafe) field.get(null);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

可以发现使用 直接内存 是通过 Unsafe 进行管理的,GC垃圾回收 只能对 JVM内存有效

1669130788224

查看 ByteBuffer.allocateDirect() 方法的源码

  • 使用了 Unsafe 进行 直接内存的分配
  • 使用了 Cleaner 特殊对象,虚引用 PhantomReference<Object> 对象
  • 虚引用对象 Cleaner 所关联的实际对象 ByteBuffer 被 GC垃圾回收之后,就会主动调用回调对象 Deallocator 主动的释放 直接内存

1669131731576

# 6.3 分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调 用 freeMemory 来释放直接内存

禁用显示回收对直接内存的影响(就是禁用代码层面的 System.gc(),使其无效)

  • System.gc() 触发的是 Full GC,比较影响性能的,不光回收新生代,还会回收老年代,会使得程序暂停时间较长
  • 使用虚拟机参数 -XX:+DisableExplicitGC
  • 代码层面的 GC 失效后,ByteBuffer 得不到内存的释放,使得 直接内存 也无法被释放,需要等到下一次 GC 时才会触发
  • 问题解决:手动调用 Unsafe 释放直接内存

1669132766803