一、JVM 结构图

  • Java 类的 .java 文件被编译为 .class 文件后,先通过类加载器 ClassLoader 加载到 JVM 中
  • 被加载后的类一般放在方法区 Method Area 中,类创建的实例对象会放在 Heap
  • 当堆中的实例对象调用方法时,会用到虚拟机栈程序计数器本地方法栈
  • 方法执行时,每行代码由解释器逐行执行,频繁调用的代码会使用即时编译器进行优化处理,此时垃圾回收会对程序中的垃圾对象进行回收
  • 一些方法需要调用本地方法接口来完成

image-20210519222925617

内存结构 –> GC 垃圾回收 –> 字节码文件 –> 类加载机制 –> 执行引擎优化

二、JVM 内存结构

JVM 内存结构大概可以分为:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈
  • 方法区

2.1、程序计数器

1、定义

Program Counter Register 程序计数器(寄存器),是用于存放下一条指令所在单元的地址的地方

当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为取指令。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。

2、特点

  • 程序计数器是线程私有

程序必须记住所属线程下一条指令存放的地址,假设现在有两条线程,且程序计数器不是线程私有的。

线程一争取到了 CPU 时间片,然后运行到第九条指令,此时时间片用完,线程二就会开始运行第十条指令,这个时候可能会发生错误,所以程序计数器必须是线程私有的。

  • 程序计数器不存在内存溢出

2.2、虚拟机栈

1、定义

Java Virtual Machine Stacks (Java 虚拟机栈),线程运行需要的内存空间,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同,虚拟机栈中存放的数据单元称为栈帧,每一个栈帧对应一次方法的调用。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;

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

2、栈帧

​ 栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的java虚拟机栈的栈元素,是每个方法运行时需要的内存。

  栈帧存储了方法的局部变量表(包括局部变量和方法参数)操作数栈动态连接方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。

​ 在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

​ 每一个线程在某一时刻只能拥有一个当前栈帧,对应那个正在执行的方法。

image-20210519232003586

3、局部变量表

  • 局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。

  • 局部变量表存放了编译期可知的各种基本数据类型( boolean、byte、char、short、int、float、long、double)「String是引用类型」,对象引用(reference类型) 和 returnAddress 类型(它指向了一条字节码指令的地址)
  • 对于局部变量来说,基本数据和对象引用存储在栈中。

局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。

如果是成员变量,或者定义在方法外对象的引用,它们存储在中。

4、栈内存溢出

  • 栈帧过多导致栈内存溢出(常用)

在递归中,如果没有设置一个正确的退出出口,那么就会因为栈帧过多而导致栈内存溢出。

image-20210520104437342

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

image-20210520104550423

5、举例说明

对于以下代码,在 main 方法中 method1(); 处下一个断点,以 DEBUG 启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class JVMStack {
public static void main(String[] args) {
method1();
}

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

public static int method2(int a, int b) {
int c = a + b;
return c;
}
}
  • 由于 main 函数也是一个方法,所以启动后可以看到虚拟机栈中出现了一个 main 栈帧

image-20210519234840897

  • 点击 【步入】 ,可以看到此时 method1 栈帧被压入虚拟机栈中,说明此时 JVM 为 method1 方法分配了一块内存空间

image-20210519234940823

  • 再次点击 【步入】 ,可以看到 method2 栈帧被压入虚拟机栈中,此时此时由于方法中还存在参数,所以局部变量表中有变量存在

image-20210519235203494

  • 点击下一步,可以看到局部变量表多了一个局部变量 c

image-20210519235251349

  • 此时再次点击下一步,由于此时 method2 方法已经执行完毕,所以可以看到 method2 栈帧占用的空间随着出栈而被释放,method1 和 main 也是同理

image-20210519235351443

6、问题辨析

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

不需要;因为栈帧内存无非涉及到一次次的方法调用,等到方法结束后,这个栈帧所占用的内存会随着栈帧出栈而释放,所以根本不需要垃圾回收管理。

  • 栈内存是否越大越好?

栈内存划分越大反而会使你的线程数越少,因为物理内存的大小是一定的,每个线程的栈内存多,会使得线程数目变少,不建议过大。

  • 方法内的局部变量是否线程安全?
  1. 如果没有逃离线程的作用范围(没有返回变量,也不和外面变量有一个联系等)或者是基本类型变量,则线程安全,每个线程都会是线程私有的局部变量,互不干扰;

对于以下方法,StringBuilder 对象是线程安全的,因为对于每个线程而言,StringBuilder 是私有变量

1
2
3
4
5
6
7
public static void m1() {
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}

对于以下方法而言,由于其他线程可以访问 StringBuilder 对象,所以以下的 StringBuilder 对象不是线程安全的

1
2
3
4
5
6
public static void m1(StringBuilder sb) {
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}
  1. 如果是静态变量则会影响,所以如果是静态变量的话就要考虑线程安全的一个问题

  2. 如果不是基本变量并且逃离了线程的作用范围(有返回变量等),返回了局部变量被其他线程拿到了修改了也要注意线程安全的一个问题

对于以下代码,由于返回了局部变量,这个局部变量可以被其他线程拿到修改,所以也需要注意线程安全

1
2
3
4
5
6
7
public static StringBuilder m3() {
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
return sb;
}

7、线程运行诊断

  • 案例一:CPU 占用过多
  1. linux 中,可以使用 top 命令来查看 CPU 的使用情况,定位到占用过多 CPU 的进程

image-20210520105933390

  1. 使用 ps 命令,查看占用 CPU 过多的线程

输出进程 pid,线程 tid 和 CPU占用率,此时会将 linux 系统中所有进程线程和对应 CPU 占用情况输出

1
ps H -eo pid,tid,%cpu

image-20210520110429525

  1. 使用 JDK 自带的工具 jstack 进行分析,此时可以列出进程中所有的线程
1
jstack 进程ID

image-20210520111642795

  1. 在上面我们已经直到出现问题的线程 ID 为 32655 ,此时我们将这个数转换为 16 进制,得到的结果为 7F99

image-20210520112048579

  1. 此时 thread1 的线程编号为 7f99,表示出问题的线程就是 thread1

image-20210520112153266

  1. 再上图中可以看到 thread1 处于 RUNNABLE 状态,出现我呢提的代码为 Demo1_16 类的第 8 行代码

  2. 打开源代码,可以看到问题出现在这个死循环中

image-20210520112420379

  • 案例二:程序运行很长时间没有结果

可能是发生死锁

  1. 使用 jstack 进行分析
1
jstack 进程id
  1. 在结果最后可以查看是否发生死锁,以及死锁产生的线程和代码行数

image-20210520113532173

  1. 查看源代码

image-20210520113750526

2.3、本地方法栈

1、定义

本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowErrorOutOfMemoryError 异常。

不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法

2、本地(native)方法

Java 中有两种方法:Java 本地方法和本地方法。

JAVA 方法是由 JAVA 编写的,编译成字节码,存储在 class 文件中。

本地方法是由其它语言编写的,编译成和处理器相关的机器代码。

  • 什么是本地方法?

一个本地方法就是一个java调用非java代码的接口。该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。

Object 类中的 hashCode()notifynotifyAll 和 `` 方法就是一个本地方法

1
public native int hashCode();
  • 为什么要使用本地方法

java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  1. 与java环境外交互:

有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。

  1. 与操作系统交互:

JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了 jre 的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

2.4、堆(Heap)

1、定义

jvm 中堆主要是用来存放对象(通过 new 关键字创建的对象都会使用到堆内存),并且作为 jvm 中最大的一块,因此垃圾回收时主要作用于堆,因此堆也被称作GC堆。

堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

java虚拟机规范:所有对象实例以及数据都应该分配到堆上

2、特点

  • 堆是线程共享的,堆中对象都需要考虑线程安全的问题
  • 垃圾回收主要作用于堆,堆中没有引用指向的对象会被垃圾回收机制回收。
  • 堆越大可以允许更少的GC
  • 堆越小则依赖频繁GC,如果GC频率低则容易溢出。

3、堆内存溢出

  • 什么是内存溢出?

内存溢出,是指程序在申请内存时,没有足够的内存空间供其使用。

内存泄漏,是指无法释放已申请的内存。

内存泄漏会导致内存溢出

  • 堆内存溢出时通常会出现:java.lang.OutofMemoryError 错误

2.5、方法区

image-20210520155205815

  • 类型信息:
    • 类的完整名称
    • 类的直接父类的完整名称
    • 类的直接实现接口的有序列表
    • 类型标志(类类型还是接口类型)
    • 类的修饰符(public private defautl abstract final static
  • 类型的常量池

存放该类型所用到的常量的有序集合,包括直接常量(字符串、整数、浮点数)和对其他类型、字段、方法的符号引用。

  • 字段信息(该类声明的所有字段)

    • 字段修饰符(public、peotect、private、default
    • 字段的类型
    • 字段名称
  • 方法信息

    • 方法信息中包含类的所有方法。
    • 方法修饰符
    • 方法返回类型
    • 方法名
    • 方法参数个数、类型、顺序等
    • 方法字节码
    • 操作数栈和该方法在栈帧中的局部变量区大小
    • 异常表
  • 类变量(静态变量)

  • 指向类加载器的引用

  • 指向Class实例的引用

  • 方法表

  • 运行时常量池(Runtime Constant Pool)

1、定义

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

方法区在 JVM 启动时被创建,它逻辑上是堆的一部分(可能不是)。

如果方法区无法申请到满足其需求的内存,那么它也会抛出 OutofMemoryError 错误

2、组成

以 Oracle 的 HotSpot 为例

  • 在 JDK 1.8 之前,方法区被称为永久代PermGen

在 JDK 1.6 前,字符串常量池处于方法区中

image-20210520150400499

  • 在 JDK 1.8 后,方法区被称为元空间Metaspace

在 JDK 1.8 后,字符串常量池被放入堆中。

image-20210520150529933

3、方法区内存溢出

  • JDK 8 前会导致永久代内存溢出

  • JDK 8 后会导致元空间内存溢出

在 JDK 8 后,元空间使用的内存是操作系统的内存

4、运行时常量池

  • 常量池

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

  • 运行时常量池

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

5、StringTable (串池)

  • 先看一道面试题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);

由于 s3 由两个字符串常量拼接而成,所以编译器会优化为 “ab”

由于 s4 由两个变量使用 “+” 拼接,所以会使用 StringBuilder 进行拼接

在 JDK 8 中, intern 方法会将不在常量池的对象放入常量池中,所以 s6 为 “ab”

故答案为:false、true、true

对于 x2 ,同样会使用 StringBuilder 进行拼接,此时在堆中会创建一个新对象

调用 x2 的 intern 方法时,由于池中已经有 “cd” ,所以不会入池成功,此时 x1 == x2 返回 false

  • 先看一个简单的例子,在编译后我们使用反编译工具进行查看
1
2
3
      String s1 = "a";
String s2 = "b";
String s3 = "ab";
1
javap -v JVMStack.class

image-20210520182546517

常量池中的信息,都会被加载到运行时常量池中,此时 abab 都是常量池中的符号,还没有变为 Java 字符串对象

执行 ldc #2 的时候,此时会将符号 a 变为字符串对象,然后放入串池

串池是一个 HashTable 结构

image-20210520182623731

  • 使用反编译工具反编译以下代码
1
2
3
4
      String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;

20210520183650

在最后 StringBuildertoString() 方法中, StringBuilder 又创建了了一个新的 String 对象,然后指向 s4;

所以 s3 == s4 可以得出结果为 false ,这是因为 s3 处于串池,而 s4 是一个被 new 出来的对象,处于 堆中。

  • 使用反编译工具反编译以下代码
1
2
3
4
5
      String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";

2021-05-20_185510

直接在常量池中找到 “ab” ,然后赋给 s5!

所以代码 s5 == s3 的执行结果为 true,这是 javac 在编译器时的优化,s5 的结果已经在编译期间确定为 ab,而由于 s4 的两个拼接元素都是变量,所以不能在编译期间确定。

串池中的字符串是懒加载的,不是直接创建所有的字符串对象,而是遇到串池没有的字符串对象时才往串池中新添加一个

6、StringTable 特性

  • 常量池中的字符串仅仅是符号,只有在第一次用到时才会变为字符串对象。
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串对象之间使用 “+” 号拼接的原理是 StringBuilder (JDK 8)
  • 字符串常量拼接的原理是编译器优化
  • 可以使用 intern 方法,主动将串池还没有的字符串放入串池中

7、intern 1.8

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

8、intern 1.6

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

9、StringTable 位置

  • JDK 6

在 JDK 6中,StringTable 的位置处于永久代中

image-20210520225352004

  • JDK 8

在 JDK 8中,StringTable 的位置处于堆中。

image-20210520225402863

2.6、直接内存

1、定义

直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

直接内存是Java堆外的、直接向系统申请的内存区间,是操作系统内存

通过存在堆中的 DirectByteBuffer 操作Native内存。访问直接内存的速度会优于Java堆。即读写性能高。

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

2、不使用直接内存,用普通 IO 读写文件

需要先将文件读入系统缓存区,由于 Java 不能直接操作系统内存,所以还需要在 Java 堆内存中开辟一块缓冲区,然后使用 Java 对堆缓冲区中的数据进行操作

2021-05-21_111618

3、使用直接内存

使用直接内存后,直接在操作系统中开辟一块直接内存,Java 可以对这块直接内存进行操作,提高了读写效率。

2021-05-21_111634

4、直接内存溢出

由于直接内存在Java堆外,因此它的大小不会直接受限于 一Xmx 指定的最大 堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

5、释放原理

直接内存的分配使用了 Unsafe 对象的 allocateMemory 方法

直接内存的释放底层调用了 Unsafe 对象的 freeMemory 方法