深入理解JVM原理-第二节 JVM整体结构深度解析

深入理解JVM原理-第二节 JVM整体结构深度解析

一、JVM内存结构

JVM内存结构主要有三大块:堆内存、方法区和栈,如下图所示:

image

1.1 程序计数器(Program Counter Register)

线程私有,保证线程切换后恢复到执行位置。Java虚拟机的多线程是通过线程切换并获取时间片的方式来实现的。也就是说,在某一个时刻,一个处理器(多核处理器的一个内核)都只会执行一条线程中的指令。那么如何在线程切换后恢复到正确的执行位置呢?那我们就需要一个线程私有的程序计数器,来记录正在执行的虚拟机字节码的指令地址。而且程序计数器是唯一不会抛出OutOfMemoryError情况的区域。

一句话总结:记录线程的执行位置。

1.2 Java虚拟机栈(JVM Stacks)

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

一句话总结:创建栈帧执行方法,程序计数器会指向栈顶。

注:局部变量表存放的是编译期可知的各种基本数据结构、对象引用(不同于对象本身)和 retrunAddress类型。

1.3 本地方法栈(Native Method Stacks)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

1.4 Java堆(Heap)

Java堆:(Java Heap)是Java虚拟机所管理内存最大的一块,线程共享。在虚拟机启动时创建,此区域的唯一目的就是存放对象实例
Java堆是垃圾收集器管理的主要区域,因此很多时候被称为“GC堆”。由于现在收集器基本采用分代收集算法。所以Java堆还可以被分为:新生代(Eden空间、From Survivor空间、To Survivor空间)和老年代

Java虚拟机规范的规定,Java堆可以在物理不连续的内存空间上,只要逻辑上是连续的即可。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

1.5 方法区(Method Area)

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

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

运行时常量池(Runtime Constant Pool):是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。这部分内容在类加载后进入方法区的运行时常量池存放。
运行时常量池另一个重要特征就是具有动态性。java语言并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量放入池中,这种特性被开发人员利用的比较多的就是String类的intern()方法。

1.7 直接内存(Direct Memory)

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁的使用。而且也可能导致OutOfMemoryError异常。
在JDK1.4 中新加入的NIO类,引入了一种基于通道(Channel)与缓存区(Buffer)的I/O方式。它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中提高性能,因为避免了Java堆和Native堆中来回复制数据。

注:本机的直接内存的分配不会受到Java堆大小的限制,但是会受到本机总内存的限制。可能导致各个内存区域总和大于物理内存的限制,从而导致动态扩展时出现OutOfMemoryError。

二、堆内存结构及堆内存垃圾回收

2.1 堆内存结构

Java堆内存的区域划分,如下图所示:

image

Eden(伊甸园):该区域是最主要的刚创建的对象的内存分配区域,绝大多数对象都会被创建到这里(除了部分大对象通过内存担保机制创建到Old区域,默认大对象都是能够存活较长时间的),该区域的对象大部分都是短时间都会死亡的,垃圾回收器针对该部分主要采用标记整理算法回收该区域。

Surviver(幸存者):该区域也是属于新生代的区域,该区域是将在Eden中未被清理的对象存放到该区域中,该区域分为两块区域,采用的是复制算法,每次只使用一块,Eden与Surviver区域的比例是8:1,是根据大量的业务运行总结出来的规律。

Old:该区域是属于老年代,一般能够在Surviver中没有被清除出去的对象才会进入到这块区域,该区域主要是采用标记清除算法。

2.2 堆内存垃圾回收

简单介绍,后面会详细介绍GC。

新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0(即From)区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1(即To)区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC(默认15次)仍然存活的对象移动到老年代。

老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成(STW),所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。

注:Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互,这些现象多半是由于gc引起。

2.3 JVM内存参数如何设置

在JDK8版本废弃了永久代(Perm),替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。

下图为JDK7示例,对于JDK8只需要把永久代理解成元空间即可:

image

参数:

  • -Xms设置堆的最小空间大小。
  • -Xmx设置堆的最大空间大小。
  • -XX:NewSize设置新生代最小空间大小。
  • -XX:MaxNewSize设置新生代最大空间大小。
  • -XX:PermSize设置永久代最小空间大小(JDK7)。
  • -XX:MaxPermSize设置永久代最大空间大小(JDK7)。
  • -XX:MetaspaceSize设置元空间最小空间大小(JDK8)。
  • -XX:MaxMetaspaceSize设置元空间最大空间大小(JDK8)。
  • -Xss设置每个线程的堆栈大小。

没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制:

老年代空间大小=堆空间大小-年轻代大空间大小

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):

java -Xms2048M -Xmx2048M -Xmn1024M ‐Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar app.jar

三、实例讲解JVM内存分配流程

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MathTest {
public static int num = 10;

public int compute() { // 一个方法对应一块栈帧内存区域
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}

public static void main(String[] args) {
MathTest mathTest = new MathTest();
mathTest.compute();
}
}
3.1 整体流程

Step 1:当程序启动时,会在JVM栈内存区中分配一块该线程单独占有的内存区域。

image

Step 2:程序执行后,会在栈内存区域中分配方法对应的栈帧内存区域。

先运行main方法先分配main()栈帧区域,在运行compute()方法时分配compute()栈帧区域。

image

Step 3:先执行完compute()方法先出栈,最后执行main()方法最后才出栈。

栈(FILO-Last in first out),先进后出机制。

3.2 通过反汇编指令分析

命令 javap -c MathTest.class > MathTest.txt

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
Compiled from "MathTest.java"
public class com.example.demo.MathTest {
public static int num;

public com.example.demo.MathTest();
Code:
0: aload_0 //取this对应的对应引用值,压入操作数栈
1: invokespecial #1 //根据编译时类型来调用实例方法 Method java/lang/Object."<init>":()V
4: return //从方法中返回,返回值为void

public int compute();
Code:
0: iconst_1 //将int类型常量1压入栈
1: istore_1 //将int类型值存入局部变量1
2: iconst_2 //将int类型常量2压入栈
3: istore_2 //将int类型值存入局部变量2
4: iload_1 //从局部变量1中装载int类型值
5: iload_2 //从局部变量2中装载int类型值
6: iadd //执行int类型的加法
7: bipush 10 //将一个8位带符号整数压入栈
9: imul //执行int类型的乘法
10: istore_3 //将int类型值存入局部变量3
11: iload_3 //从局部变量3中装载int类型值
12: ireturn //从方法中返回int类型的数据

public static void main(java.lang.String[]);
Code:
0: new #2 //创建一个新对象 class com/example/demo/MathTest
3: dup //操作数栈管理
4: invokespecial #3 //根据编译时类型来调用实例方法 Method "<init>":()V
7: astore_1 //将引用类型或returnAddress类型值存入局部变量1
8: aload_1 //从局部变量1中装载引用类型值
9: invokevirtual #4 //调度对象的实便方法 Method compute:()I
12: pop // 操作数栈管理
13: return //从方法中返回,返回值为void

static {};
Code:
0: bipush 10 //将一个8位带符号整数压入栈
2: putstatic #5 //设置类中静态字段的值 Field num:I
5: return //从方法中返回,返回值为void
}

四、实例讲解JVM内存溢出异常

4.1 堆溢出

堆是用来存储对象本身的以及数组(数组引用是存放在Java栈中的),堆是被所有线程共享的。

-Xms10m:表示初始堆10M;
-Xmx10m : 表示最大可用10M;
+HeapDumpOnOutOfMemoryError: 将溢出转存dump快照;
-XX:HeapDumpPath :转存的dump快照通常都需要指定一个路径,然后分析结果。

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
public class HeapOOM {
//vm options 设置
// -Xms10m -Xmx10m
//-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:/jvmdump/HeapOOM.dump -Xms10M -Xmx10M -XX:+PrintGCDetails
static class OOMObject {
}

public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}

/*
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at com.example.demo.HeapOOM.main(HeapOOM.java:16)
*/
}
4.2 栈溢出和本地方法栈溢出

栈溢出

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StackOverflowTest {
//vm options 设置
//-Xss128k 默认-Xss1M
static int count = 0;

static void redo() {
count++;
redo();
}

public static void main(String[] args) {
try {
redo();
} finally {
System.out.println(count);
}
}

/*
18170
Exception in thread "main" java.lang.StackOverflowError
at com.example.demo.StackOverflowTest.redo(StackOverflowTest.java:11)
*/
}
4.3 方法区和运行时常量池溢出

Java方法区是用来存放类名、访问修饰符、常量池、字段描述、方法描述等,因运行时常量池是方法区的一部分,所以这里也包含运行时常量池。我们可以通过jvm参数-XX:PermSize=2M -XX:MaxPermSize=2M来指定该区域的内存大小。

在JDK8上运行下面的代码将不会出现异常,因为JDK8已结去掉了永久代,当然-XX:PermSize=2m -XX:MaxPermSize=2m也将被忽略,因此我们可以指定Metaspace的大小, -XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RuntimeConstantPoolOom {
public static void main(String[] args) {
//vm options 设置
//-XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m
List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
/**
String.intern()方法是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,
则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
*/
}
}

/*
Error occurred during initialization of VM
OutOfMemoryError: Metaspace
*/
}
4.4 直接内存溢出

DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DirectMemoryOOM {
//vm options 设置
//-Xmx20M -XX:MaxDirectMemorySize=10M
private static final int _1MB = 1024 * 1024;

public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}

/*
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at com.example.demo.DirectMemoryOOM.main(DirectMemoryOOM.java:22)
*/
}

五、逃逸分析

5.1 JVM的运行模式有三种
  • 解释模式(Interpreted Mode):只使用解释器(-Xint 强制JVM使用解释模式),执行一行JVM字节码就编译一行为机器码
  • 编译模式(Compiled Mode):只使用编译器(-Xcomp JVM使用编译模式),先将所有JVM字节码一次编译为机器码,然后一次性执行所有机器码
  • 混合模式(Mixed Mode):依然使用解释模式执行代码,但是对于一些 “热点” 代码采用编译模式执行,JVM一般采用混合模式执行代码

解释模式启动快,对于只需要执行部分代码,并且大多数代码只会执行一次的情况比较适合;编译模式启动慢,但是后期执行速度快,而 且比较占用内存,因为机器码的数量至少是JVM字节码的十倍以上,这种模式适合代码可能会被反复执行的场景;混合模式是JVM默认采用的执行代码方式,一开始还是解释执行,但是对于少部分 “热点 ”代码会采用编译模式执行,这些热点代码对应的机器码会被缓存起 来,下次再执行无需再编译,这就是我们常见的JIT(Just In Time Compiler)即时编译技术。 在即时编译过程中JVM可能会对我们的代码最一些优化,比如对象逃逸分析、cpu指令重排、无效变量删除等。

5.2 对象逃逸分析

分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如:作为调用参数传递到其他方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public User test1() { 
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
return user;
}

public void test2() {
User user = new User();
user.setId(1);
user.setName("zhuge");
//TODO 保存到数据库
}

很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配的栈内存里,让其在方法结束时跟随栈内存一起被回收掉。 JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

-------------已经触及底线 感谢您的阅读-------------

本文标题:深入理解JVM原理-第二节 JVM整体结构深度解析

文章作者:趙小傑~~

发布时间:2019年10月04日 - 19:53:55

最后更新:2019年11月04日 - 20:08:27

原始链接:https://cnsyear.com/posts/91dc9261.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

0%