美文网首页java学习之路
JavaGuide知识点整理——带你认识JVM

JavaGuide知识点整理——带你认识JVM

作者: 唯有努力不欺人丶 | 来源:发表于2022-08-29 22:32 被阅读0次

本篇文章是一个自己理解的展示,此文旨在提及而不深究。如果措辞或理解有问题欢迎指出。

JVM的基本介绍

JVM是java Virtual Machine的缩写,它是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现。
直白说:jvm类似于一台小电脑运行在windows或者linux操作系统环境下即可。它直接和操作系统进行交互,与硬件不直接交互,而操作系统可以帮助我们完成和硬件进行交互的工作。


image.png

java文件是如何被运行的

比如我们现在写了一个helloWorld.java,那么这个helloWorld.java抛开所有东西不谈,就类似与一个文件文件,只不过这个文件都是英文且有一定的缩进。
我们的jvm是不认识文本文件的,所以它需要一个编译。让其成为一个它会读二进制文件的helloWorld.class

  1. 类加载器
    如果JVM想要执行这个.class文件,我们需要将其装进一个类加载器中,它就像一个搬运工一样,会把所有的.class文件全部搬进JVM里面来。
    image.png
  2. 方法区
    方法区是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码等。
    类加载器将.class文件搬过来就是先丢到这一块上。

  3. 堆主要放了一些存储的数据,比如对象实例,数组等,它和方法区都同属于线程共享区域,也就是说它们都是线程不安全的。

  4. 栈是我们的代码运行空间,我们编写的每一个方法都会放到栈里面运行。我们会听说过本地方法栈或本地方法接口这两个名词,不过我们基本上不会涉及到这两块的内容,因为它俩底层是使用c来进行工作的,和java没多大的关系。
  5. 程序计数器
    主要就是完成一个加载工作,类似于一个指针一样。指向下一行我们需要执行的代码。和栈一样,都是线程独有的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。
    image.png
    小总结
    1. java文件经过编译后变成.class字节码文件
    2. 字节码文件通过类加载器被搬运到jvm虚拟机中
    3. 虚拟机主要有五大块: 方法区和堆都是线程共享区域,有线程安全问题。栈,本地方法栈和程序计数器都是独享区域,不存在线程安全问题。而jvm调优主要就是围绕堆,栈两大块进行。

简单的代码例子

下面是简单的测试代码:


测试代码

执行main方法的步骤如下:

  1. 编译好Main.java后得到Main.class,执行Main.class系统会启动一个JVM进程。从classpath路径中找到一个名为Main.class的二进制文件。将Main的类信息加载到运行时数据区的方法区内,这个过程叫做Main类的加载
  2. JVM找到Main的主程序入库,执行main方法。
  3. 这个main方法中的第一条语句是创建一个User对象,但是这个时候方法区中是没有User类的信息的,所以JVM马上加载User类。把User类的信息放到方法区中。
  4. 加载完User类后,JVM在堆中为一个新的User实例分配内存,然后调用构造函数初始化User实例,这个User实例持有指向方法区中的User类的类型信息的引用。
  5. 执行user.sayName()时,JVM根据user的引用找到user对象,然后根据user对象持有的引用定位到方法区中user类的类型信息的方法表,获得sayName的字节码地址。
  6. 执行sayName()

其实也不用管太多,只需要知道对象实例初始化时会去方法区中找类的信息,完成后再去栈那里运行方法,找方法就在方法表中找。

类加载器的介绍

之前也提到了它是负责加载.class文件的,它们在文件开头会有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构,并且ClassLoader只负责class文件的加载,而能否运行则由Execution Engine来决定。

类加载器的流程

从类被加载到虚拟机内存中开始,到释放内存总共有七个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为链接。

  1. 加载

    1. 将class文件加载到内存
    2. 将静态数据结构转化成方法区中运行时数据结构
    3. 在堆中生成一个代表这个类的java.lang.Class对象作为数据访问的入口
  2. 链接

    1. 验证:确保加载的类符合JVM规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查。
    2. 准备:为static变量在方法区中分配内存空间,设置变量的初始值,注意准备阶段只设置类中的静态变量,不包括实例变量。实例变量是对象初始化时赋值的。
    3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程
  3. 初始化
    初始化其实就是执行类构造器方法的<clinit>()的过程,而且要保证执行前父类的<clinit>()方法执行完毕,这个方法由编译器收集,顺序执行所有类变量显示初始化和静态代码块中语句。此时准备阶段时的默认值会变为实际值。由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了修改,会覆盖类变量的显示初始化,最终值会是静态代码块中的赋值。

  4. 卸载
    GC将无用对象从内存中卸载

类加载器的加载顺序

加载一个Class类的顺序也是有优先级的。类加载器从最底层开始网上的顺序是这样的:

  1. BootStrap ClassLoader:rt.jar
  2. Extension ClassLoader:加载扩展的jar包
  3. App ClassLoader:指定的classpath下面的jar包
  4. Custom ClassLoader:自定义的类加载器

双亲委派机制

当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成。比如我现在要new 一个User,这个User是我们自定义的类,如果我们要加载它,就会先委派App ClassLoader.只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载。
这样做的好处是,加载位于rt.jar包中的类时,不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。
其实这个也是一个隔离的作用,避免我们的代码影响JDK代码,比如我们现在自己定义一个java.lang.String类:

package java.lang;
public class String {
    public static void main(String[] args) {
        System.out.println();
    }
}

尝试运行当前类的main函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实找到了rt.jar中的java.lang.String,然而发现这个里面并没有main方法。

运行时区域

本地方法栈和程序计数器

比如说我们现在点开Thread类的源码,会看到他的start()方法带有一个native关键字修饰,而且不存在方法体,这种用native修饰的方法就是本地方法,这是使用C来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。
程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的命令,它也是内存区域中唯一一个不会出现OOM的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
如果执行的是native方法,那么这个指针就不工作了。

方法区

方法区主要的作用是存放类的元数据信息,常量和静态变量等。当它存储的信息过大时,会在无法满足内存分配时报错(jdk8以后方法区的实现是元空间了,受物理内存大小的限制)

虚拟机栈和虚拟机堆

一句话:栈管运行,堆管存储。虚拟机栈负责运行代码,虚拟机堆负责存储数据。
虚拟机栈的概念
它是java方法执行的内存模型。里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程私有。同时如果我们听到局部变量表,那也是在说虚拟机栈。
虚拟机栈存在的异常
如果线程请求的栈深度大于虚拟机栈的最大深度,就会报StackOverflowError(死递归会导致),java虚拟机可以动态扩展,但是随着扩展会不断申请内存,当无法申请足够的内存时就会报错OutOfMemoryError。
虚拟机栈的生命周期
对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。
注意:八种基本类型的变量+对象的引用变量+实例方法都是在栈里面分配内存。
虚拟机栈的执行
我们经常说的栈帧数据,说白了在jvm中叫做栈帧,在java里其实就是方法,它也是存在栈中的。
栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集/比如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1会被压入栈中。同理方法b会有一个B1,方法c会有一个C1,等这个线程执行完毕后,会先弹出C1,然后B1,A1这种,它是一个先进后出的原则。
局部变量的复用
局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以slot为最小单位,一个slot可以存放32位以内的数据类型。
虚拟机通过索引定位的方式使用局部变量表。范围为0-slot的数量。方法中的参数就会按照一定顺序排列在这个局部变量表里,至于怎么排序的我们可以先不关心。
而为了节省栈帧空间,这些slot是可以复用的。当方法执行位置超过了某个变量,那么这个变量的slot可以被其它变量复用。当然如果需要复用那么我们的垃圾回收自然就不会动这些内存。
虚拟机堆的概念
JVM内存会划分为堆内存和非堆内存。堆内存中会划分为年轻代和老年代。而非堆内存则为永久代。年轻代又分为Eden和Survivor区。Survivor区分为Form和To。这两个总会有一个是空的。Eden,From,To默认比例8:1:1.当然这个比例可以调整。
堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给GC算法进行回收。非堆内存我们已经说过了,就是方法区。 1.7之前叫做永久代,1.8后叫做元空间。最大的区别就是元空间不存在与JVM中,使用的是本地内存。元空间有两个参数:

MetaspaceSize:初始化元空间大小,控制发生GC
MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。

Eden年轻代的介绍
我们new一个对象后,一般会先放在Eden划分出来的一块作为存储空间的内存。但是我们知道堆内存是线程共享的,所以有可能出现两个对象共用一个内存的情况。这里jvm的处理是每个线程都会预先申请好一块连续的内存空间并规定了对象的存放的位置,而如果空间不足则会再申请多块内存空间。这个操作称为TLAB。
当Eden空间满了以后,会触发Minor GC(就是在年轻代发现的GC)。存活下来的对象移动到s0区,s0区满了以后出发Minor GC,将存活的对象移动到s1,并且s0和s1指针交换。 总保证有一个s区是空的。
经过多次GC还存活的对象(默认15,因为hotspot记录年龄的空间只有4位,最多只能记录到15)会移动到老年代。老年代是存储长期存活的对象的,老年代满了会触发full GC,期间停止所有线程等待GC完成,所以对于响应要求高的应用应该尽量减少Full GC而避免响应超时。
当老年代执行了Full GC还是空间不足,会产生OOM,这个就是虚拟机中堆内存不足。堆内存可以通过-Xms,-Xmx调整。

如何判断一个对象需要被干掉

image.png

上图中程序计数器,虚拟机栈,本地方法栈三个区域随着线程的生存而生存。内存分配和回收都是确定的,随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而java堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。
在进行回收前要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法:

  1. 引用计数器:给对象添加一个引用计数器,每次引用这个对象时计数器加1,引用失效时减1,计数器为0的就是不会再次使用。不过这些情况无法解决循环依赖。
  2. 可达性分析:这是一种类似于二叉树的实现,将一系列GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。当一个对象到GC ROOT没有任何引用链时,说明对象是不可用的。
    这种方法的优点是能够解决循环依赖的问题,缺点是耗费大量资源和时间。它的分析过程引用关系不能发生变化,所以需要停止所有进程。

如何宣告一个对象真正死亡
首先必须要提到一个finalize()的方法。
这个方法是Object类的一个方法,一个对象的finalize()方法只会被系统自动调用一次。经过finalize()方法逃脱死亡的对象,第二次不会再调用。
判断一个对象的死亡至少需要经过两次标记

  1. 如果对象进行可达性分析后没有与GC ROOT相连的引用链,那么它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行finalize()方法。如果对象有必要执行的话则放入F-Queue队列中。
  2. GC对F-Queue队列中的对象进行二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出已将回收的集合。如果此时对象还没成功逃脱,那么只能被回收了。

垃圾回收算法

标记清除算法
标记清除算法就是分为标记和清除两个阶段,标记出所有需要回收的对象,标记结束后统一回收。这个套路很简单,但是问题会出现内存碎片。后续的算法都是根据这个基础加以改进的。
其实它就是把已死亡的对象标记为空闲内存,然后记录在列表里,当我们需要new一个对象的时候,内存管理模块会从空闲列表中寻找空闲的内存来分给新的对象。
不足的方面就是标记和清除的效率比较低下,而且内存碎片会很多。这就导致我们需要大内存块时,无法获取到足够的内存。

标记清除算法
复制算法
为了解决效率问题,复制算法出现了。它将可用内存划分成两等份,每次只使用其中一块。和survivor一样也是from ,to两个指针这样玩法。这样就解决了碎片问题。
但是这样其实只能使用一半内存,堆内存的使用效率十分低下。
复制算法
标记整理算法
复制算法在对象存活率高的时候会有效率问题,标记过程中仍然是标记-清除算法一样,但是后续步骤不是直接堆可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
标记整理算法
分代收集算法
这种算法就是根据对象存货周期不同把内存划分为几块,一般是把java堆分为新生代和老年代。这样就可以根据年代特点采用合适的收集算法了。
新生代中死亡率很高,采用复制算法。老年代中存活率高,就用标记-清除或者标记-整理算法。
各种各样的垃圾回收器

jdk8默认的垃圾收集器是Parallel Scavenge 和Parallel Old
从JDK9开始,G1变成默认的垃圾收集器了。

关于JVM调优

根据上面的一些知识点,我们可以尝试对jvm进行调优,主要就是堆内存这块。
所有线程共享数据区大小 = 新生代大小+老年代大小+持久代大小(元空间使用物理内存)。所以java堆中增大年轻代就会缩小老年代。因为老年代使用full GC,对性能影响比较大,所以推荐年轻代/老年代 是 3/8

调整最大堆内存和最小堆内存

-Xmx -Xms:指定java堆最大值(默认物理内存四分之一)和初始化java堆最小值(默认物理内存的六十四分之一)。
在使用的时候内存占用超过百分之60会动态申请内存,直到最大值位置。使用内存不足百分之30则会自动缩小内存,直到最小值。

调整新生代和老年代的比值

-XX:NewRatio 新生代和老年代的比值
比如-XX:NewRatio = 4.表示新生代:老年代 = 1:4.即新生代占用整个堆的1/5.

调整Survivor和Eden区的比值

-XX:SurvivorRatio 设置两个Survivor和eden的比值
比如-XX:SurvivorRatio=8.表示两个Survivor:eden = 2:8。也就是一个Survivor占用年轻代的十分之一。

设置年轻代和老年代的大小

-XX:NewSize --- 设置年轻代大小
-XX:MaxNewSize --- 设置年轻代最大值

永久区的设置

-XX:PermSize -XX:MaxPermSize
初始空间默认物理内存的六十四分之一,最大空间默认物理内存的四分之一。

调整每个线程的栈空间的大小

可以通过-Xss:调整每个线程栈空间的大小
jdk5以后每个线程堆栈大小为1m,以前每个线程堆栈大小为256k。相同物理内存下,减小这个值能生成更多的线程,但是操作系统对一个进程内的线程数还是有限制的,不能无限生成。界限值大概3000-5000左右。

本篇笔记就记到这里,很多知识点都是一带而过,提及而不深究,感兴趣的可以自己去找资料,因为jvm这块细节很多,但是常用的并不多。总而言之手打不易,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,身体健健康康!

相关文章

网友评论

    本文标题:JavaGuide知识点整理——带你认识JVM

    本文链接:https://www.haomeiwen.com/subject/wdscnrtx.html