JVM

作者: emperorxiaomai | 来源:发表于2021-12-20 16:02 被阅读0次

    什么是Java虚拟机

    Java的目标是跨平台,就是所谓的“一处编译、处处运行”,但是显然不同的运行环境需要的二进制代码是不一样的;Java通过引入虚拟机(VM)的概念,让编译后的代码直接跑在一台虚拟的机器上,无论最终的目标平台是什么,都在上面构建出一个虚拟的一致的虚拟机出来,就可以达到一次编译到处执行的效果了。

    JVM内存模型

    JVM 内存模型共分为虚拟机栈,堆,方法区,程序计数器,本地方法栈五个部分。
    Java 虚拟机栈与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
    Java 堆对于大多数应用来说,Java 堆(Java Heap)是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
    方法区(Method Area)与Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看
    做是当前线程所执行的字节码的行号指示器。
    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。

    java虚拟机的基本结构

    一个Java虚拟机实例在运行过程中有三个子系统来保障它的正常运行,分别是类加载器子系统, 执行引擎子系统和垃圾收集子系统。


    image.png

    类加载过程

    类初始化的过程
    1.加载
    加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。
    类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。
    通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。

    从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。
    从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
    通过网络加载class文件。
    把一个Java源文件动态编译,并执行加载。
    类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。

    2.链接
    当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。

    1)验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。Java是相对C++语言是安全的语言,例如它有C++不具有的数组越界的检查。这本身就是对自身安全的一种保护。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
    
    四种验证做进一步说明:
    
    文件格式验证:主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
    
    元数据验证:对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
    
    字节码验证:最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
    
    符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
    

    2)准备:类准备阶段负责为类的静态变量分配内存,并设置默认初始值。

    3)解析:将类的二进制数据中的符号引用替换成直接引用。说明一下:符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。

    3.初始化
    初始化是为类的静态变量赋予正确的初始值,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。

    类加载的时机

    1. 创建类的实例,也就是new一个对象
    2. 访问某个类或接口的静态变量,或者对该静态变量赋值
    3. 调用类的静态方法
    4. 反射(Class.forName("com.lyj.load"))
    5. 初始化一个类的子类(会首先初始化子类的父类)
    6. JVM启动时标明的启动类,即文件名和类名相同的那个类

    类加载器

    1)根类加载器(bootstrap class loader):它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
    2)扩展类加载器(extensions class loader):它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。

    3)系统类加载器(system class loader):被称为系统(也称为应用)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。

    类加载机制

    • 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
    • 双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
    • 缓存机制。缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

    双亲委派机制

    双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。
    双亲委派机制的优势:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

    JVM内存结构模型

    image.png
    image.png

    (1) 线程私有
    程序计数器(字节码指令)、虚拟机栈(java方法)、本地方法栈(native方法)
    (2) 线程共享
    MetaSpace、Java堆

    • 线程私有:程序计数器(Program Counter Register)
      作用:
      (1)线程独立拥有计数器,是当前线程所执行的字节码行号指示器(逻辑)
      (2)改变计数器的值来选取当前线程需要执行的下一条字节码指令
      (3)线程独有,即一个线程拥有一个计数器
      (4)对Java方法计数,如果是Native方法则计数器值为Undefined
      (5)不会发生内存泄漏
    • 线程私有:Java虚拟机栈
      (1)作用:java方法执行的内存模型,包含多个栈帧
      (2)程序执行过程:对于当前线程,每个方法会产生1个栈帧,当方法执行结束后,该栈帧取消。而每个栈帧中包含:局部变量表、操作栈、动态链接、返回地址等等。
    • 线程私有:本地方法栈
      与虚拟机栈类似,主要用于标注native方法
    • 线程共享:元空间(MetaSpace)
      (1) 作用:存储class基本信息,包括java对象的method和field等
      (2) 元空间使用的是本地内存,而永久代是使用jvm内存
    • 线程共享:java堆
      (1)堆内存分类:根据对象存活的周期不同,把堆内存划分为:新生代、老年代和永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。
      (2)作用:堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中

    JVM内存分配策略

    JVM分配内存机制有三大原则和担保机制
    具体如下所示:
    优先分配到eden区
    大对象,直接进入到老年代
    长期存活的对象分配到老年代
    空间分配担保

    Java堆分代的原因

    给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。
    有了内存分代,新创建的对象会在新生代中分配内存;经过多次回收仍然存活下来的对象存放在老年代中;静态属性、类信息等存放在永久代中。新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC;老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收;永久代中回收效果太差,一般不进行垃圾回收。还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处

    逃逸分析(Escape Analysis)

    逃逸分析(Escape Analysis)本质上是JVM(以下的JVM均代表Hotspot虚拟机)即时编译器(JIT)的一种分析对象作用域的算法,或者可以称之为是一种用于优化JVM的分析技术。逃逸分析不是直接用来优化代码的技术,它为JVM编译器其他优化技术提供必要的分析依据。逃逸分析的基本行为 —— 分析对象作用域。
    逃逸分为两种:方法逃逸和线程逃逸。

    方法逃逸:一个对象在方法内被new后,被外部方法所引用,例如作为参数传递到外部方法,这样此对象发生了逃逸。
    线程逃逸:一个对象在方法内被new后,被外部线程访问,例如赋值给类变量或直接被其他线程访问,这样此对象发生了逃逸。
    既然有了逃逸分析作为依据,对这个对象就可以进行一些高效的优化:
    栈上分配(Stack Allocation):让对象分配在栈上,对象所占用的空间随着栈帧出栈而释放,这样能给GC减轻不少的压力。
    同步消除(Synchrogazation Elimination):又叫锁消除或同步省略。当确认一个对象不会发生线程逃逸,那么就可以消除耗时的线程同步过程。
    标量替换(Scalar Replace):指把一个对象替换为若干基本数据类型来访问。Java中标量指的是类似于int、long及reference等不能再进一步分解的数据类型。相对的,可以再分解的数据类型称为聚合量(Aggregate)也就是最常见的对象。有的对象可能不需要在堆上分配作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是被创建成一个个局部变量在存储在栈上(栈上分配),这样大概率会被分配在CPU高速寄存器中,也为之后的优化提供了条件。

    线上分配与TLAB

    GC

    垃圾收集器,它可以自动发现对象何时不再被使用,并继而销毁它。它减少了所必须考虑的议题和必须编写的代码,更重要的是,垃圾回收器提供了更高层的保障,可以避免暗藏的内存泄漏问题。

    未被使用的垃圾收集算法-引用计数法

    每个对象都含有一个引用计数器,当有引用连接对象时,引用计数加1.当引用离开使用域或被置为Null时,引用计数减1。当发现某个对象的引用计数为0时,就释放其占用的空间。
    缺点:

    1. 效率低,收集开销在整个程序生命周期中持续发生,需要在含有全部对象的列表上遍历。
    2. 无法解决循环引用的问题,可能应该被回收,但引用计数不为0。

    可达性分析法

    思想依据:对任何“活”的对象,一定能最终追溯到其存活在堆栈或静态存储区之中的引用。如果从堆栈和静态存储区开始,遍历所有的引用,就能找到所有“活”的对象。对于发现的每个引用,必须追踪它所引用的对象,然后是此对象包含的所有引用 ,如此反复,走到“根源于堆栈和静态存储区的引用”所形成的网络全部被访问为止。
    那么找到存活的对象之后,怎么处理,取决于不同的Java虚拟机实现。

    GC root对象

    Java中可以作为GC Roots的对象

    1、虚拟机栈(javaStack)(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
    2、方法区中的类静态属性引用的对象。
    3、方法区中常量引用的对象。
    4、本地方法栈中JNI(Native方法)引用的对象。

    回收算法

    停止-复制算法(stop-and-copy)

    先暂停程序,然后将所有存活的对象从当前堆复制到另一个堆,没有被复制的全部都是垃圾。
    缺点:效率低,两个方面,首先得有两个堆,然后得在这两个分离的堆之间来回倒腾,从而得维护比实际需要多一倍的空间。其次是复制,程序稳定后可能只有少量的垃圾,但仍然需要将所有内存自一处复制到另一处,这很浪费。

    标记-清扫(mark-and-sweep)

    所依据的思路同样是从堆栈和静态存储区出发,遍历所有的引用,进而找出所有存活的对象。每当它找到一个存活的对象,就会给对象设一个标记,这个过程不会回收任何对象。只有全部标记工作完成的时候,清理动作才会开始。在清理过程中,没有标记的对象将被释放,不会发生任何复制的动作。所以剩下的堆空间是不连续的,垃圾回收器要是希望得到连续空间的话,就得重新整理剩下的空间。

    代数(generation count)

    每个内存块都有相应的代数来记录它是否存活。通常如果块在某处被引用,其代数会增加;垃圾回收器会定期进行完整的清理动作。

    总结

    Java虚拟机中,小型对象的那些块被复制并整理,Java虚拟机会进行监视,如果所有对象都很稳定,垃圾回收器的效率降低的话,就切换到“标记-清扫”方式 ;同样,Java虚拟机会跟踪“标记-清扫”的效果,要是堆空间出现很多碎片,就会切换回“停止-复制”方式。这就是“自适应”技术,给它个啰嗦的称呼:自适用的、分代的、停止-复制、标记-清扫式垃圾回收器。

    GC收集器

    image.png

    G1

    GC收集器的三个考量指标:

    占用的内存(Capacity)
    延迟(Latency)
    吞吐量(Throughput)
    随着硬件的成本越来越低,机器的内存也越来越大,GC收集器占用的内存基本上可以容忍。而吞吐量可以通过集群(增加机器)来解决。

    随着JVM中内存的增大,STW的时间成为JVM急迫解决的问题,如果还是按照传统的分代模型,使用传统的垃圾收集器,那么STW的时间将会越来越长。

    在传统的垃圾收集器中,STW的时间是无法预测的,有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集内容呢?就像是领导在年初制定KPI一样,分配的任务多就多干些,分配的任务少就少干点。

    G1的思路说起来也类似,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。

    我们要求G1,在任意1秒的时间内,停顿不得超过10ms,这就是在给它制定KPI。G1会尽量达成这个目标,它能够反向推算出本次要收集的大体区域,以增量的方式完成收集。

    这也是使用G1垃圾回收器(-XX:+UseG1GC)不得不设置的一个参数:-XX:MaxGCPauseMillis=10。

    G1的堆内存划分

    G1将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

    G1的运行过程

    G1的运行过程

    G1的运行过程与CMS大体一致,分为以下四个步骤:
    初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
    并发标记( Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫
    描完成以后,并发时有引用变动的对象会产生漏标问题,G1中会使用SATB(snapshot-at-the-beginning)算法来解决,后面会详细介绍。
    最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发标记阶段仍遗留下来的最后那少量的SATB记录(漏标对象)。
    筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
    TAMS是什么?要达到GC与用户线程并发运行,必须要解决回收过程中新对象的分配,所以G1为每一个Region区域设计了两个名为TAMS(Top at Mark Start)的指针,从Region区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。

    三色标记

    在三色标记法之前有一个算法叫Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是0,如果发现对象是可达的就会置为1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成0方便下次清理。

    这个算法最大的问题是GC执行期间需要把整个程序完全暂停,不能实现用户线程和GC线程并发执行。因为在不同阶段标记清扫法的标志位0和1有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决GC运行时程序长时间挂起的问题,那就是三色标记法。

    三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个GC。

    三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。

    黑色:表示根对象,或者该对象与它引用的对象都已经被扫描过了。
    灰色:该对象本身已经被标记,但是它引用的对象还没有扫描完。
    白色:未被扫描的对象,如果扫描完所有对象之后,最终为白色的为不可达对象,也就是垃圾对象。

    相关文章

      网友评论

          本文标题:JVM

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