JVM 系列 - 第一节:JVM 简介、运行时数据区、内存分代模型

用户头像
诸葛小猿
关注
发布于: 2020 年 10 月 27 日
JVM系列-第一节:JVM简介、运行时数据区、内存分代模型

一、什么是JVM?



JVM是Java Virtual Machine(Java虚拟机))的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。



JVM是一种规范,有很多种实现,比如Oracle/Sun JDK、OpenJDK等,用的都是相同的JVM:HotSpot VM;IBM开发的一个高度模块化的JVM:J9。除此之外,还有很多其他的JVM实现。通常大家说起“Java性能如何如何”、“Java有多少种GC”、“JVM如何调优”等问题,默认说的就是HotSpot VM,所以HotSpot VM是绝对的主流。下文提到的JVM都是指HotSpot。



Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在字节码文件中的指令。JVM有两个重要作用,1.机器码翻译。JVM保证“一次编译,多次运行”,原因是不同平台有不同的JVM,比如HotSpot有windows版和linux版本,不同的平台使用不同的版本,对于程序员来说,只需要关注些代码,不用考虑代码的移植性,因为不同平台的JVM已经屏蔽了系统的差异了。2.内存管理。程序员需要使用一个对象,只需要new出来,不用关心具体是如何new出来的,不用关心对象的生命周期是怎样的,也不用关心什么时候回收对象。





Java虚拟机主要分为五大模块:类加载器、运行时数据区、执行引擎、本地方法接口和垃圾收集模块。下面的内容主要讲其中两块,运行时数据区、垃圾收集器。



二、运行时数据区



当一个线程运行前,会把需要执行的代码的不同部分,放到运行时数据区的不同区域,当线程运行时会从不同的位置拿数据。



2.1程序计数器



程序计数器存储当前线程正在执行的字节码指令的地址和行号。为什么要记录一个线程正在执行的字节码指令的地址和行号呢?线程是java的最小执行单元,因为CUP同时执行多个线程的时候,会涉及到线程的切换,当CUP切换线程时,要记录这些信息,以便CUP再次切换到当前线程时,该线程知道从什么位置开始继续执行。每个线程都会有自己的程序计数器。



2.1栈



虚拟机栈存储当前线程运行的方法所需要的数据、指令、返回地址。



举一个简单的代码示例:



package com.wuxiaolong.jvm;
/**
* Description:
*
* @author 诸葛小猿
* @date 2020-09-06
*/
public class TestJVM {
public static final int AGE = 30;
public static void test () {
int a = 1;
int b = 2;
int c = a + b;
Object objc= new Object();
}
}



找到上面TestJVM.java编译后的TestJVM.class文件,通过javap命令查看字节码的每一条指令,将指令存入TestJVM.txt文件。



$ javap -c -v ./TestJVM.class > TestJVM.txt



指令文件TestJVM.txt:



Classfile /C:/Users/WuXiaoLong/Desktop/java-summary/target/classes/com/wuxiaolong/jvm/TestJVM.class
Last modified 2020-9-6; size 497 bytes
MD5 checksum e2bee1c0136645a123ea37b4c6aba4a2
Compiled from "TestJVM.java"
public class com.wuxiaolong.jvm.TestJVM
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
// 这里是常量池的描述
Constant pool:
#1 = Methodref #2.#23 // java/lang/Object."<init>":()V
#2 = Class #24 // java/lang/Object
#3 = Class #25 // com/wuxiaolong/jvm/TestJVM
#4 = Utf8 AGE
#5 = Utf8 I
#6 = Utf8 ConstantValue
#7 = Integer 30
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcom/wuxiaolong/jvm/TestJVM;
#15 = Utf8 test
#16 = Utf8 a
#17 = Utf8 b
#18 = Utf8 c
#19 = Utf8 objc
#20 = Utf8 Ljava/lang/Object;
#21 = Utf8 SourceFile
#22 = Utf8 TestJVM.java
#23 = NameAndType #8:#9 // "<init>":()V
#24 = Utf8 java/lang/Object
#25 = Utf8 com/wuxiaolong/jvm/TestJVM
{
// 静态常量AGE的描述
public static final int AGE;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 30
// 这里是TestJVM类的描述
public com.wuxiaolong.jvm.TestJVM();
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 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/wuxiaolong/jvm/TestJVM;
// 这里是test方法的描述
public static void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code: // test方法的指令
stack=2, locals=4, args_size=0
0: iconst_1
1: istore_0
2: iconst_2
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: istore_2
8: new #2 // class java/lang/Object //创建一个对象 在堆上分配了内存并在栈顶压入了指向这段内存的地址
11: dup
12: invokespecial #1 // Method java/lang/Object."<init>":()V //调用构造函数、实例化方法
15: astore_3
16: return
LineNumberTable: // test方法在java代码中的行号
line 14: 0
line 15: 2
line 16: 4
line 17: 8
line 18: 16
LocalVariableTable: // test方法的本地局部变量表
Start Length Slot Name Signature
2 15 0 a I
4 13 1 b I
8 9 2 c I
16 1 3 objc Ljava/lang/Object;
}
SourceFile: "TestJVM.java"



通过上面的指令描述文件,可以看出,TestJVM这个类的所有指令的描述。



JVM中的每一个线程拥有一个运行时栈。JVM会为每一个线程执行的方法在运行时栈中开辟一块空间,这块空间叫栈帧。每一个栈帧又分为几块,比如方法的局部变量表、操作数栈、动态链接、方法出口(返回地址)等:





在记录test方法的栈帧中,局部变量表中存在的是test方法中的四个本地变量:a/b/c/objc,对应TestJVM.txt指令描述文件的81-86行;



操作数栈中存的是局部变量对应的操作数,比如TestJVM.txt指令描述文件的62-74行指令中,第一个指令iconst_1:int型常量值1进栈,表示将int a = 1这一句代码中的操作数1放入(压栈)操作数栈里(这时栈里只有一个数1);第二个指令istore0:将栈顶int型数值存入第0个局部变量,第0个局部变量是谁?TestJVM.txt指令描述文件的83行,表明第0个局部变量a,操作数1出站存入局部变量a。其实iconst1和istore_0就是int a = 1这句代码执行的指令。其他的指令分析就不详细说了,具体每个指令什么意思可以自行网上查阅。



需要注意TestJVM.java的test方法第17行Object objc= new Object();,这是一个对象,和上面本地变量a/b/c不同,这一句涉及到的指令有new、dup、invokespecial、astore_3四个指令。其中new指的是创建一个对象,具体是在堆上分配内存,并在栈顶压入指向这段内存的地址。对象是存在堆里的,栈里只存对象在堆中的地址



动态链接指的是如果被调用的方法或对象在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。类似于TestJVM.txt的第70行和第72行中的#2和#1,对应TestJVM.txt的第12和11两行的Constant pool,动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。简单举一个例子,通常在Controller层调用Service层时,通过@Autowired注入一个Service,通常使用的时一个Service的接口而不是实现类,在Controller的一个方法中通过Service的接口中的方法调用具体的Service实现,如果Service接口有多个实现,程序在编译期并不知道具体使用哪个实现类,这个时候就会在字节码Constant pool部分生成动态链接(#),在程序的运行期最终转换为调用方法的直接引用。通过字节码指令文件可以看出Constant pool翻译成”常量池“并不准确,Constant pool中除了有常量,还有符号引用(包括类、方法、字段等的描述符)。



方法出口(返回地址)指的是,当一个方法执行完成后,要出栈,那么出栈后要去哪,正常执行的方法出栈和异常执行的方法的出栈也不一样。



注意,在线程中递归调用某个方法时,方法的每次调用都会有一个栈帧,所以线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。虽然栈的大小可以自动扩展,但动态扩展时无法申请到更大的空间时,任然会报出OutOfMemory。



栈帧大小确定的时间在编译期,不受运行期数据影响。所以局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。



2.3本地方法栈



本地方法栈和虚拟机栈类似,只是它描述的是本地方法在执行是的情况。什么是本地方法?本地方法是指方法被native关键字修饰的方法,在JDK中没有具体实现类的,具体的实现在JVM的代码中,这里就可以找到各种版本的Hotspot源码,源码是C或C++写的。之前文章中在分析CAS时就使用了Hotspot源码。



2.4方法区



方法区中存储类字节码的类信息、常量、静态变量、JIT等信息。



TestJVM.txt指令文件的第10-35行是常量池,这部分的内容就放在方法区的常量池中。





这里可以思考一下,静态变量和常量为什么不放在堆中?我感觉是因为常量和静态变量一般都是不变的,只要存储一份就可以了,放在堆中那么每次new对象都会存在相同的数据,造成空间浪费。



2.5堆



对于大多数应用来说,堆是java虚拟机管理内存最大的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。因此需要重点了解下。



堆里存储的都是new出来的对象,栈里存储该对象的引用会指向堆里该对象的内存地址。所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟(浅谈HotSpot逃逸分析),这个说法也不是那么绝对,但是大多数情况都是这样的。



三、JVM内存分代模型



在JDK1.8之前,JVM的内存分三大块,新生代、老年代、永久代。其中前两块在堆中,后一块在方法区中。在JDK1.8及以后,移除了永久代使用了Meta Space。





为什么要分代?因为不同的对象的生命周期是不一样的,将不同生命周期的对象放在不同的代,使用不同的垃圾回收算法进行回收。



3.1新生代



一般来说,对象刚new出来会放在新生代,新生代中的对象一般是生命周期比较短,一次回收能回收(Minor GC)98%以上的对象。



新生代内存分为三块Eden区、s0区、s1区,为什么是分三块



新生代分三块,主要的原因是新生代使用的垃圾回收算法使用的是复制算法。





S0和S1是两块大小相同、功能相同的区域,但是一次GC只能有一块区域起作用。当eden区第一次满了时,会触发第一次minor gc,回收eden区的对象,gc后,还有一个对象是可达的,那么就属于存活的对象,这个对象会被放到s0区域。当eden区第二次满了时,会触发第二次minor gc,这时会回收eden区和s0区域,如果这时对象b依然可达,并且对象j也可达,那么这两个对象就会进入s1区域。可以看出,每次gc时存活的对象会在s0和s1区域来回复制,这就是复制算法。每次gc后存活的对象,年龄都会加1,多次gc后年龄达到固定的阈值(默认15)后,对象会进入老年代。



新生代内存分为三块Eden区、s0区、s1区,比例是:8:1:1。为什么比例是8:1:1?因为复制算法中s0和s1只能一块区域起作用,另一块是空的,所以并不是所有的新生代都是有效的存储空间,s0和s1过大会导致可用内存变小并且eden区过小,minor gc会变得更频繁;s0和s1过小,导致较少的gc次数时,s0和s1就会满了,从而导致年龄较小的对象进入老年代。8:1:1可以看成是二八原则。



3.2老年代



新生代和老年代的内存比例是1:2。



新生代中多次回收后依然存在的对象会进入老年代。



3.3永久代和Meta Space



为了避免永久代的溢出,在JDK1.8及之后,去掉了永久代,使用了Meta Space。Meta Space这块内存属于代外分配的内存,使用的是机器的直接内存。Meta Space可以自动扩容,虽然可以自动扩容,但Meta Space也并不是越大越好,因为机器的总内存是固定的,Meta Space变大会挤压其他的内存空间的使用。



后续将继续介绍java内存模型、四种引用、GC回收算法、GC回收器、JVM优化等内容。



关注公众号,输入“java-summary”即可获得源码。



完成,收工!





传播知识,共享价值】,感谢小伙伴们的关注和支持,我是【诸葛小猿】,一个彷徨中奋斗的互联网民工。





发布于: 2020 年 10 月 27 日 阅读数: 739
用户头像

诸葛小猿

关注

我是诸葛小猿,一个彷徨中奋斗的互联网民工 2020.07.08 加入

公众号:foolish_man_xl

评论 (2 条评论)

发布
用户头像
讲的真好
14 小时前
回复
感谢支持
8 小时前
回复
没有更多了
JVM系列-第一节:JVM简介、运行时数据区、内存分代模型