JVM:01.内存结构

参考资料:

  • 黑马 JVM 视频
  • 《深入理解Java虚拟机》

一、什么是JVM

1. 定义

Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

JVM很多公司都有发行过,目前主流的是HotSpot JVM

2. 好处

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收机制
  • 数组下标越界检查
  • JVM内部使用虚方法调用的机制实现了多态

3. 比较

JVM JRE JDK的区别

JVM JRE JDK的区别

4. Java文件执行过程

java文件(编译为)–>二进制字节码(其中包含JVM指令)–> 机器码(字节码通过解释器转为机器码)–>CPU

二、内存结构

整体架构

JVM内存整体架构

1. 程序计数器

1.1 作用

用于保存JVM中下一条所要执行的指令的地址

1.2 特点

  • 线程私有
    • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
    • 程序计数器是每个线程私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不会存在内存溢出

2. 栈

2.1 定义

  • 每个线程运行需要的内存空间,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
method1();
}

private static void method1() {
method2(1, 2);
}

private static int method2(int a, int b) {
int c = a + b;
return c;
}
}

在控制台中可以看到,主类中的方法在进入虚拟机栈的时候,符合栈的特点

虚拟机栈

2.2 问题辨析

  • 垃圾回收是否涉及栈内存?
    • 不涉及。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
  • 栈内存的分配越大越好吗?
    • 不是。栈内存越大,虽然可以支持更多的递归调用,但因为物理内存是一定的,导致可执行的总线程数就会越少。
  • 方法内的局部变量是否是线程安全的?
    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果局部变量引用了对象(基本数据类型不会有线程安全),并逃离了方法的作用范围,则需要考虑线程安全问题。例如形参、返回值等,其他线程能够访问到该线程的局部变量,则容易引起线程安全问题

2.3 内存溢出

Java.lang.stackOverflowError 栈内存溢出

发生原因

  • 虚拟机栈中,栈帧过多(无限递归)

​ 也可能是第三方库问题导致无限递归,比如员工类中有部门信息,部门类中有员工集合,导致相互引用无限递归(详见视频P13)

​ 如果使用-Xss命令将栈内存设置减少,能递归的次数会变少,即栈帧减少

  • 每个栈帧所占用过大 较少见

2.4 线程运行诊断

1、CPU占用过高

  • Linux环境下运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
    • top命令,查看是哪个进程占用CPU过高

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

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

      (注意jstack查找出的线程id是16进制的需要转换

2、线程死锁问题

  • 同样可以使用jstack 进程id来检查线程死锁问题

3. 本地方法栈

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

本地方法栈为这些本地方法提供栈内存

4. 堆

4.1 定义

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

4.2 特点

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制(new的对象没有指向引用了之后会被垃圾回收)

4.3 堆内存溢出

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

虽然有垃圾回收机制,但new的对象可能会一直在使用,一直不释放,并且越来越多,就会导致堆内存溢出

有些时候写的程序运行时间短,不会造成堆内存溢出,因为默认的堆内存上限比较大, 因此可以将堆内存设置的小一点再测试代码,看是否会有堆内存溢出问题,可以使用 -Xmx命令

4.4 堆内存诊断

下述命令在Terminal输入即可

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

jmap:查看堆内存占用情况(只能查看某一个时刻), jmap - heap 进程id

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

jvisualvm:同上,性能更强大。详见视频21

5. 方法区

5.1 定义

方法区(method area)只是JVM规范中定义的一个概念,用于存储类信息、常量池、静态变量、常量、编译器编译后的代码等数据

5.2 演变过程

java6

方法区jdk1.6实现的方式叫永久代,其将方法区放置于JVM的内存结构中,由JVM管理

image-20220325162821527

从图中可以看出,在7以及之前堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存,下面的图可能可以帮助我们更好的理解。

image-20220325163030693

很多人都愿意将方法区称作永久代。本质上来讲两者并不等价,仅因为Hotspot将GC分代扩展至方法区,或者说使用永久代来实现方法区。在他虚拟机上是没有永久代的概念的,永久代是Hotspot针对该规范进行的实现。

什么是HotSpot:我们通常使用的Java SE都是由Sun JDK和OpenJDK所提供,这也是应用最广泛的版本。 而该版本使用的VM就是HotSpot VM。简单来说,我们所讲的java虚拟机指的就是HotSpot的版本。

再重复一遍就是,Java7及以前版本的方法区实现方式为永久代

同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。这也导致了永久代的垃圾收集是和老年代捆绑在一起的,无论谁满了,都会触发永久代和老年代的垃圾收集。

Java8

对于Java8,HotSpots实现方法区的方式叫元空间,如下图所示,其将方法区放置于本地内存中,交给本地内存管理,不由JVM管理。

元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中的“java.lang.OutOfMemoryError: PermGenspace”

同时它也将字符串常量池StringTable放到了堆中,而不是放在以前的常量池中

image-20220302164134185

演变的原因

为什么Java8改用元空间实现?

  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。当使用元空间时,可以加载多少类的元数据由系统的实际可用空间来控制
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • 字符串池存在永久代中,容易出现性能问题和内存溢出。
  • Oracle 可能会将HotSpot 与 JRockit 合二为一(暂不了解)

5.3 方法区内存溢出

1.8 以前内存溢出称为永久代内存溢出

可以手动设置JVM参数-XX:MaxPermSize=8m,便于检测方法区内存溢出

1
java.lang.OutOfMemoryError: PermGen space

1.8 之后内存溢出称为元空间内存溢出

可以手动设置JVM参数-XX:MaxMetaspaceSize=8m,便于检测方法区内存溢出

1
java.lang.OutOfMemoryError: Metaspace

方法区内存溢出场景:

  • Spring
  • MyBatis

这些框架经常动态生成代理类、实现类等,所以方法区内存溢出的情况也非常常见,尤其是1.8之前,导致永久代内存溢出很常见,1.8之后元空间使用了系统内存,充裕了很多,垃圾回收机制由元空间自行管理,比永久代的垃圾回收效率提高很多

5.4 运行时常量池

java编译后的二进制字节码包含三个部分:

  • 类的基本信息
  • 常量池
  • 类方法定义(这部分包含JVM指令)

想要得到字节码文件需要对java文件进行反编译,使用命令:javap -v Helloworld.class

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

(在这之前需要先生成class文件,可以用javac Helloworld.java在当前目录生成,或者terminal定位到out文件夹

image-20220302212513574

image-20220302212719809

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

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

5.5 字符串常量池 StringTable

5.5.1 定义

字符串作为最常用也是最基础的引用数据类型,JVM为String提供了字符串常量池(简称“串池”)来提高性能

字符串常量池在数据结构上是一个hashtable,长度一开始是固定的,并且不能扩容。这里用StringTable[ ]来表示

5.5.2 串池的位置

jdk1.6是在永久代的常量池中

image-20220303091314426

jdk1.6以后,即1.7/1.8,主要说法是1.8,jdk1.8放在了堆中,注意此时串池不在常量池中了,常量池在元空间中并且一起放到了本地内存里。

1.8做出修改的原因是:永久代的内存回收效率很低,永久代是需要Full GC的时候才触发永久代的垃圾回收。Full GC得等到老年代的空间不足才会触发,这个触发的时机有点晚,间接导致串池的回收效率不高,进而导致永久代的内存不足

image-20220303091447563

具体证明案例详见视频36

5.5.3 字符串的实例化方式

方式一:通过字面量定义的方式,将字符串常量"abc"存储在字符串常量池,目的是共享。

方式二:通过new + 构造器的方式,创建了一个字符串对象存储在堆中,堆中的对象又有一个value的字段,其指向常量池中的字符串(如果串池中没有的话,就新建一个)

见 JavaSE–Java进阶:08.Java常用类–一、String类–3 String实例化的区别

5.5.4 字符串的延迟加载

1
2
3
4
5
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}

JVM执行String s1 = "a"; 执行到这一句时,才会把 a 符号变为 “a” 字符串对象,然后在串池里找,有没有”a”,没有就在StringTable[ ]里放入“a”,即StringTable["a"]。如果有,就使用串池中的对象

JVM执行String s2 = "b"; 执行到这一句时,才会把 b 符号变为 “b” 字符串对象,然后在串池里找,有没有”b”,没有就在StringTable[ ]里放入“b”,即StringTable["a","b"]。如果有,就使用串池中的对象

JVM执行String s3 = "ab"; 执行到这一句时,才会把 ab 符号变为 “ab” 字符串对象,然后在串池里找,有没有”ab”,没有就在StringTable[ ]里放入“ab”,即StringTable["a","b","ab"]。如果有,就使用串池中的对象

这里也体现了串池的特点:常量池中的字符串仅是符号,第一次用到时才变为对象,被称为字符串的延迟加载,这是一种懒惰式行为

5.5.5 字符串的唯一性

串池另一个特点是,避免重复创建字符串对象,取值不同的字符串对象在串池中是唯一的,即向串池中添加字符串对象时,会先找有没有,没有才添加,有就使用串池中的对象

5.5.6 字符串的拼接

常量相加
1
2
3
4
5
6
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a" + "b";
}

其中,s4涉及到字符串的拼接,是两个常量相加,s4会直接去串池中找到"ab",从字节码中也能够看出

image-20220302222025841

因此,如果判定s3 和 s4是否相等,会输出 ture

1
System.out.println(s3==s4);  // true
变量相加
1
2
3
4
5
6
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s5 = s1 + s2;
}

其中,s5涉及到字符串的拼接,是两个变量相加,其本质是:

1
new StringBuilder().append("a").append("b").toString()

最后的这个toString()方法,又是创建了一个String,所以有又相当于

1
new String("ab")

从字节码中也能够看出这些本质:

image-20220302223036952

所以s5虽然值是”ab”,但它是创建在里的一个对象,而s3是字符串常量池中的一个对象,因此如果执行下面代码,会输出false

1
System.out.println(s3==s5);  //false
本质对比

字符串常量拼接的本质其实是 javac在编译期间的优化,因为都是常量,相加的结果不会变了,结果在编译期就已经确定为”ab”,运行时不会更改。

而变量相加在运行时,其引用的值可能会修改,因此不能使用同样的方法。原理是使用StringBuilder()

5.5.7 字符串的intern方法

intern 方法,尝试将串池中还没有的字符串对象放入串池

jdk8

1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 同时会把串池中的对象返回

详见代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void test1() {
// StringTable[]
String s = new String("a") + new String("b");
// StringTable["a"] -> 堆 new String("a")
// StringTable["a","b"] -> 堆 new String("b"),
// 堆 s = new String("ab")

// 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,并会把串池中的对象返回
String s2 = s.intern();
// 把s这个字符串对象尝试放入串池,发现没有,所以就把这个对象放入串池。此时,StringTable["a","b","ab"]
// 出现了符号"ab",会去串池里找,发现已经有了,就用串池中的"ab"
// 所以s和"ab"相同
System.out.println( s == "ab" ); // true
// s2 是串池中的 "ab" 返回,所以也和"ab"相同
System.out.println( s2 == "ab"); // true
System.out.println( s == s2 ); // true
}

如果”ab“先创建,就不一样了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void test2() {
// StringTable[]
String x = "ab";
// StringTable["ab"]

String s = new String("a") + new String("b");
// StringTable["ab","a"] -> 堆 new String("a")
// StringTable["ab","a","b"] -> 堆 new String("b"),
// 堆 s = new String("ab")

// 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,并会把串池中的对象返回
String s2 = s.intern();
// 把s这个字符串对象尝试放入串池,发现有,不会放入。所以,s和"ab"不同
System.out.println( s == x ); // false
// s2 是串池中的 x 即 "ab" 返回,所以和x相同
System.out.println( s2 == x); // true
System.out.println( s == s2 ); // false
}
jdk6

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

详细代码暂时略过。如有需要详见黑马32集

5.5.8 串池相关面试题

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
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; // ab
String s4 = s1 + s2; // new String("ab")
String s5 = "ab";
String s6 = s4.intern();

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

String x2 = new String("c") + new String("d"); // new String("cd")
String x1 = "cd";
x2.intern();
System.out.println(x1 == x2); // false

/** 问,如果调换了x1和x2.intern()的位置呢,则为true。即:
* String x2 = new String("c") + new String("d");
* x2.intern();
* String x1 = "cd";
* System.out.println(x1 == x2); // true
*/
}

5.5.9 串池的垃圾回收

串池中的内存不够,且很多字符串没有被引用,就会触发垃圾回收机制

首先需要借助虚拟机命令-XX:+PrintStringTableStatistics,让程序能够打印串池信息

对于下面一段代码,其运行结果为打印0。其中,串池信息如图所示

1
2
3
4
5
6
7
8
9
10
11
@Test
public void test4(){
int i = 0;
try {

} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}

image-20220303094936474

现在创建10000个新的字符串入池,观察变化,发现多了10000个左右的字符串对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void test4(){
int i = 0;
try {
for (int j = 0; j < 10000; j++) {
String.valueOf(j).intern();
i++;
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}

image-20220303095038896

为了演示垃圾回收,手动将堆内存调小-Xmx10m,使其触发垃圾回收。并打印垃圾回收信息-XX:+PrintGCDetails -verbose:gc

1
[GC (Allocation Failure) [PSYoungGen: 2048K->504K(2560K)] 2048K->780K(9728K), 0.0068930 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

image-20220303095341176

5.5.10 串池的性能调优

串池的底层结构是哈希表,如果哈希表桶(buckets)的个数比较多,链表比较短,哈希碰撞的几率就会减小,查找速度变快。反之则会很慢

考虑将字符串对象是否入池

如果代码中有较多重复的字符串,可以考虑将字符串入池。因为intern会返回串池的对象,能够减少字符串的对象

演示案例:(代码略过,详见视频38)

重复10次读取一个48万个单词的文本文档,并不让其触发GC(比如放到一个List里)。借助 Jvisualvm工具观察内存

不入池,字符串会占据300M左右的内存

入池,字符串会占据30-40M左右的内存

调整-XX:StringTableSize=桶个数

演示案例:(代码略过,详见视频38)

需要读取一个48万个单词的文本文档,并让其入池,默认的堆内存大小很容易将其读到内存中,手动设置桶的个数命令为:

-XX:StringTableSize=200000

当buckets个数为60013(默认)时,其耗费的时间为0.6s

当buckets个数为200000时,其耗费的时间为0.4s

当buckets个数为1009(最小)时,其耗费的时间为12s

因此,如果代码中的字符串常量个数非常多时,可以将StringTableSize调大,减少查找时间

6. 直接内存

直接内存不受 JVM 内存回收管理,而是操作系统管理的内存。它常见于 NIO (同步非阻塞IO) 操作时,数据读写时用于数据缓冲区

由于它属于操作系统管理,Java使用的时候,虽然其分配回收成本较高,但大文件的读写性能非常高

正常文件的读写过程需要将磁盘文件先放入到系统缓存区,Java不能访问系统缓存区,因此文件全部读到系统缓存区后,再放入Java缓冲区

image-20220303103225571

而使用直接内存的话,Java可以访问到操作系统里的这块直接内存区域,少了一次缓冲区的复制操作,速度得到成倍的提升

image-20220303103625212

直接内存也会引发直接内存的内存溢出java.lang.OutOfMemoryError: Direct buffer memory

因为jvm垃圾回收不会回收掉直接内存这部分的内存,所以可能原因是直接或间接使用了ByteBuffer中的 allocateDirect方法的时候,而没有做clear

视频剩下的没有记录,用到再补充


JVM:01.内存结构
http://jswanyu.github.io/2022/04/04/JVM/01-内存结构/
作者
万宇
发布于
2022年4月4日
许可协议