一 基本概念
1.1 什么是jvm
虚拟机指用软件的方式模拟完整硬件系统功能,运行在一个安全隔离环境中的完整计算机系统,是物理机的软件实现。常用的虚拟机软件有VMWare,Virtual Box,Java Virtual Machine
常见的java虚拟机:Sun HotSpot VM、BEA JRockit VM、IBM J9 VM、Google Dalvik VM。java8基于(Sun HotSpot VM、BEA JRockit VM)整合
1.2 jvm构成
jvm由三个主要的子系统组成:
- 类装载器子系统(将字节码.class文件装载到运行时数据区中去)
- 运行时数据区(java虚拟机对应的内存区域叫运行时数据区)
- 执行引擎(执行java程序,输出结果,包含垃圾收集器模块,用于在程序运行的时候不断清理内存区域中垃圾)
下面图是整个jvm的结构
- 程序计数器:线程私有的,是一块很小的内存空间,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址;
- 虚拟机栈:线程私有的,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数、动态链接和方法返回等信息,当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError;
https://blog.csdn.net/gentlezuo/article/details/90580116 - 本地方法栈:线程私有的,保存的是native方法的信息,当一个jvm创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;
- 堆:java堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作;
- 方法区:存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据
- 方法区是JVM规范的一个概念定义,并不是一个具体的实现,每一个JVM的实现都可以有各自的实现;
- 在Java官方的HotSpot 虚拟机中,Java8版本以后,是用元空间来实现的方法区;在Java8之前的版本,则是用永久代实现的方法区;
- 也就是说,“元空间” 和 “方法区”,一个是HotSpot 的具体实现技术,一个是JVM规范的抽象定义;
元空间的存储位置是在计算机的内存当中,而永久代的存储位置是在JVM的堆(Heap)中
之所以移除permGen永久代在java8中因为
- This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
这是JRockit和Hotspot融合工作的一部分。JRockit的客户不需要配置永久代(因为JRockit没有永久代),并且习惯于不配置永久代。- 随着Java在Web领域的发展,Java程序变得越来越大,需要加载的内容也越来越多,如果使用永久代实现方法区,那么需要手动扩大堆的大小,而使用元空间之后,就可以直接存储在内存当中,不用手动去修改堆的大小。
以上两部分引用转自:https://www.zhihu.com/question/358312524
二 运行时数据区(JVM内存结构)
java程序运行的时候在一个进程中运行,进程中有很多线程,这些线程是真正去执行我们代码的最小单元,线程运行的时候会使用到一些数据因此是会跟内存进行交互的,内存有可能是所有线程共享的,也有可能是每个线程独自占有的。
每个线程独占/私有的内存区域(每个线程自己开辟的内存空间)有
- 栈内存
- 本地方法栈
- 程序计数器
所有线程共享的内存区域有
- 堆内存
- 方法区
线程私有的内存区域
2.1 栈内存
- 栈是每个线程独有的,不被其他线程共享
- 栈是先进后出的数据结构
- 方法进栈以后称之为栈帧,一个栈帧包括四个部分:局部变量表,操作数栈,动态链接,方法出口
案例
public class Math {
private int compute(){
int a=1;
int b=2;
int c=(a+b)*10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("finished");
}
}
main方法先进栈,compute()后进栈,各自维护了四部分,当compute方法执行完毕后,compute栈帧弹出,然后main方法弹出栈
执行上面的java代码,生成字节码class文件Math.class,然后我们采用jdk提供的方便阅读字节码文件的javap命令来查看生成的易读的字节码文件到txt文件中
javap -c Math.class > math.txt
Compiled from "Math.java"
public class org.radient.jvm.Math {
public org.radient.jvm.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int compute();
Code:
0: iconst_1 # 将int类型常量1压入栈(操作数栈),即1
1: istore_1 # 将int类型值存入局部变量1,即a
2: iconst_2 # 将int类型常量2压入栈
3: istore_2 # 将int类型值存入局部变量2
4: iload_1 # 从局部变量1中装载int类型值1(注意:装载的是值1,非变量引用a!!或a=1)
5: iload_2 # 从局部变量2中装载int类型值2
6: iadd # 执行int类型的加法,即a+b
7: bipush 10 # 将a+b的结果存回操作数栈,
9: imul # 执行int类型的乘法,即a+b的结果*10
10: istore_3 # 将int类型值存入局部变量3,即变量c
11: iload_3 # 从局部变量3中装载int类型值
12: ireturn # 返回结果
public static void main(java.lang.String[]);
Code:
0: new #2 // class org/radient/jvm/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: pop
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: ldc #6 // String finished
18: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
}
根据上面可读的字节码文件来分析一下整个的执行过程,上面每行序号后面对应的是JVM指令,具体每个指令的意思,参阅:https://www.cnblogs.com/lsy131479/p/11201241.html
根据上面的指令, 可以分析一下compute()执行过程,具体的解析指令已经在上面代码中标注出来了
案例代码执行过程:(操作数栈用于临时存放操作中的值,局部变量表存放变量和变量的值)
(1)先为局部变量例如a开辟内存空间,入局部变量表a,变量的值1进入操作数栈中进行运算,运算完将结果更新到局部变量表a,有了a=1
(2)同上,处理变量b=2
(3)将局部变量表中要运算的两个变量的值1和2放入操作数栈,然后因为操作数栈也是栈结构,遵循后进先出,因此看到iadd(int类型加法)的时候,先将2弹出操作数栈,然后将1弹出操作数栈,然后对这两个元素进行加法运算,获得结果3
(4)将上一步获得的结果3写回操作数栈,并执行bipush 10,将10压如操作数栈,然后弹出10和3,执行乘法运算,将结果30写回操作数栈
(5)istore_3(将int类型值存入局部变量3)将30写入局部变量表给c,c=30
(6)iload_3(将int类型值放到操作数栈)将30放入操作数栈,然后执行ireturn,从方法中返回int类型的30,如果要打印的话,就返回给system.out方法所属的栈祯,栈祯顺序=>>>main->system.out->compute
2.1.1 局部变量表
main方法中上来就创建了个math对象,即main()的局部变量是一个对象类型,不是基本类型,根据我们的常识,对象类型new出来的对象是放在堆内存的,那么相比于compute()中的基本变量,main中创建的math对象在局部变量中怎么保存呢?
math()的局部变量表保存math对象的引用,math对象的引用指向的是堆内存中的math对象实体
2.1.2 方法出口
上面图中我们刚才看了局部变量表和操作数栈,那么方法出口是什么呢?
方法出口就是compute方法执行完以后返回main方法,怎么知道要执行下面的打印syso语句呢?就是根据这个方法出口,类似于导游的作用
2.1.3 动态链接
动态链接就是存储当前线程很多不同方法的指令码,只在程序运行的时候创建
我们可以通过下述代码助于理解
public static void main(String[] args) {
Math math = new Math();
math.compute();
Math math2 = new Math();
math2.compute();
System.out.println("finished");
}
如上创建了两个对象,math和math2,都是来源于模板类Math,每new出来一个对象,该对象头里就有个指针指向对象所属的的那个类(math.class),为什么要指向呢?为什么就知道执行的compute()的代码就是上面那几行呢?是math类的呢?
这并不是理所当然!是因为创建对象的时候,对象里面存储了类元信息(比如:这个类有哪些方法都是属于这个类的类元信息)
一旦有了这个指针以后,再去调用这个对象的compute()的时候,底层做的就是根据math对象的头指针找到对应的Math类的那块(指令码)
所以对象1和对象2都找到了Math类对应的compute()的代码,math.compute()
是符号引用
更深层次理解什么叫动态链接:
我们生成更加复杂的可读的指令码文件,采用命令:
javap -v Math.class > math.txt
Classfile /H:/package/�����¼/java-study/target/classes/org/radient/jvm/Math.class
Last modified 2019-10-14; size 809 bytes
MD5 checksum 5ce39fe60ec15465be0bf023228c71f6
Compiled from "Math.java"
public class org.radient.jvm.Math
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #8.#31 // java/lang/Object."<init>":()V
#2 = Class #32 // org/radient/jvm/Math
#3 = Methodref #2.#31 // org/radient/jvm/Math."<init>":()V
#4 = Methodref #2.#33 // org/radient/jvm/Math.compute:()I
#5 = Fieldref #34.#35 // java/lang/System.out:Ljava/io/PrintStream;
#6 = String #36 // finished
#7 = Methodref #37.#38 // java/io/PrintStream.println:(Ljava/lang/String;)V
#8 = Class #39 // java/lang/Object
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lorg/radient/jvm/Math;
#16 = Utf8 compute
#17 = Utf8 ()I
#18 = Utf8 a
#19 = Utf8 I
#20 = Utf8 b
#21 = Utf8 c
#22 = Utf8 main
#23 = Utf8 ([Ljava/lang/String;)V
#24 = Utf8 args
#25 = Utf8 [Ljava/lang/String;
#26 = Utf8 math
#27 = Utf8 math2
#28 = Utf8 MethodParameters
#29 = Utf8 SourceFile
#30 = Utf8 Math.java
#31 = NameAndType #9:#10 // "<init>":()V
#32 = Utf8 org/radient/jvm/Math
#33 = NameAndType #16:#17 // compute:()I
#34 = Class #40 // java/lang/System
#35 = NameAndType #41:#42 // out:Ljava/io/PrintStream;
#36 = Utf8 finished
#37 = Class #43 // java/io/PrintStream
#38 = NameAndType #44:#45 // println:(Ljava/lang/String;)V
#39 = Utf8 java/lang/Object
#40 = Utf8 java/lang/System
#41 = Utf8 out
#42 = Utf8 Ljava/io/PrintStream;
#43 = Utf8 java/io/PrintStream
#44 = Utf8 println
#45 = Utf8 (Ljava/lang/String;)V
{
public org.radient.jvm.Math();
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 Lorg/radient/jvm/Math;
public int compute();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
LineNumberTable:
line 10: 0
line 11: 2
line 12: 4
line 13: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lorg/radient/jvm/Math;
2 11 1 a I
4 9 2 b I
11 2 3 c I
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class org/radient/jvm/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: pop
13: new #2 // class org/radient/jvm/Math
16: dup
17: invokespecial #3 // Method "<init>":()V
20: astore_2
21: aload_2
22: invokevirtual #4 // Method compute:()I
25: pop
26: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
29: ldc #6 // String finished
31: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
34: return
LineNumberTable:
line 17: 0
line 18: 8
line 19: 13
line 20: 21
line 21: 26
line 22: 34
LocalVariableTable:
Start Length Slot Name Signature
0 35 0 args [Ljava/lang/String;
8 27 1 math Lorg/radient/jvm/Math;
21 14 2 math2 Lorg/radient/jvm/Math;
MethodParameters:
Name Flags
args
}
SourceFile: "Math.java"
这个#4是一个引用,指向常量池中的
而#2,#32又是引用,指向Math的Class和
这样我们就将静态的compute方法转化为实际的指令码存放位置
根据堆中对象的头指针,找到方法区中加载的Math.class类的元信息(指令码的内存地址),将这个内存地址放到栈中的动态链接内存中
再执行compute方法的时候,会根据动态链接,返回一条线在方法区中找到放到方法区这块内存中的指令码,这块指令码是程序运行过程中生成的
2.2 程序计数器
(1)程序计数器就是用来存储将要执行那一行JVM指令码的行号(内存地址)
(2)程序计数器同栈结构一样是每个线程自己的,不被其他线程共享
(3)执行第一行代码的时候,程序计数器就有值了,而且每执行完一行,jvm的执行引擎,会将当前线程的程序计数器的值改为下一行的行号,根据这个程序计数器,知道我们将要执行的下一行代码
如上图,可读字节码文件每一行指令前的行号就是程序计数器中记录的东西
2.3 本地方法栈
带native的方法,不是java实现,是c语言实现的,Java执行到这一行的时候,会去java底层的c库里面,找xx.dll结尾的文件(类似于java中的jar包),在这个dll文件中有start0方法的实现
执行引擎会利用本地方法接口来真正调用底层c语言的接口
线程共享的内存区域
2.4 方法区
方法区的基本介绍在上面的前言部分已经阐述,下面来详细讲讲方法区
首先是方法区的一些概念
2.4.1 方法区的演变史
- jdk1.6以及1.6以前
有永久代,静态变量存放在永久代
- jdk1.7
有永久代,但是已经开始着手移除永久代,首当其冲将静态变量和字符串常量池移动到堆中
- jdk1.8
随着JDK8的到来,JVM不再有PermGen。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中。
2.4.2 class文件常量池、运行时常量池、字符串常量池
- class文件常量池
已加载的每个class文件中,都维护着一个常量池,里面存放着编译时期生成的各种字面值和符号引用。,
class文件常量池在类被加载的时候,会被复制到方法区中的运行时常量池,池中的数据项类似数组项一样,是通过索引访问的
类的加载过程中的链接部分的解析步骤就是
以下内容节选自https://blog.csdn.net/luanlouis/article/details/39960815
- 运行时常量池
jvm会将各个class文件中的常量池载入到运行时常量池中,即编译期间产生的字面量、符号引用。类的加载过程中的链接部分的解析步骤就是把符号引用替换为直接引用,即把那些描述符(名字)替换为能直接定位到字段、方法的引用或句柄(地址)。即除了保存class文件中的符号引用,还会把翻译出来的直接引用也存储在运行时常量池
同时,运行时常量池允许在运行期间将新的变量放入常量池中。最主要的运用便是String类的intern()方法:检查字符串常量池中是否存在String并返回池里的字符串引用;若池中不存在,则将其加入池中,并返回其引用。这样做主要是为了避免在堆中不断地创建新的字符串对象 - 字符串常量池
https://zhuanlan.zhihu.com/p/160770086
https://www.cnblogs.com/tiancai/p/9321338.html
2.5 堆
虚拟机启动时创建,用于存放实例对象,几乎所有的对象都在堆上分配内存,对象无法在堆中申请到内存的时候抛出oom异常,同时也是GC管理的主要区域,可通过 -Xmx -Xms参数来分别制定最大堆和最小堆
由上图可以看到,堆由两部分组成,年轻代和老年代,年轻代又由eden区和survivor区组成,年轻代占了1/3的堆内存空间,老年代占据2/3的堆内存空间,eden区又占据年轻代8/10的内存空间
new出来的对象都在eden区(亚当和夏娃在伊甸园造人)
eden区内存占用满了以后,就会触发轻量级GC:minor GC。minor GC会将内存中没有引用指向他的无用对象回收,释放掉这部分内存空间,避免浪费内存空间(因为还有新的对象需要放进去)
为什么年轻代有2个survivor
为了保证任何时候总有一个survivor是空的。
因为将eden区的存活对象复制到survivor区时,必须保证survivor区是空的,如果survivor区中已有上次复制的存活对象时,这次再复制的对象肯定和上次的内存地址是不连续的,会产生内存碎片,浪费survivor空间。
如果只有一个survivor区,第一次GC后,survivor区非空,eden区空,为了保证第二次能复制到一个空的区域,新的对象必须在survivor区中出生,而survivor区是很小的,很容易就会再次引发GC。
而如果有两个survivor区,第一次GC后,把eden区和survivor0区一起复制到survivor1区,然后清空survivor0和eden区,此时survivor1非空,survivor0和eden区为空,下一次GC时把survivor0和survivor1交换,这样就能保证向survivor区复制时始终都有一个survivor区是空的,也就能保证新对象能始终在eden区出生了。
关于年轻代的GC流程
(1)创建的新对象都放到eden区,当eden区满以后,执行一次minor gc,将尚存活的对象放入s0即from中去
(2)后面new出来的对象继续往eden区放,当eden区再次放满,将再进行一次minor gc,又有一些对象放入from,from区最终也有放满的时候
(3)当from区被持续放对象,放满以后,也会进行一次minor gc,这次gc不仅仅会回收eden区,也会回收from区,from区回收的时候,同样回收的是没有引用的对象,from中仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置,默认是16次)的对象会被移动到老年代中,没有达到阈值的对象会被复制到“To”区域,而Eden区中所有存活的对象都会被复制到“To”,
(4)经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”
(5)不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,在进行一次GC的时候,会将所有对象移动到年老代中。总有一天老年代也会放满,到时候就会触发老年代GC-full gc
网友评论