JVM体系结构(类加载机制、内存结构、垃圾回收)

        在上篇文章中我们稍微提了一下JVM,那在这篇文章中我们就来详细聊聊。在这里我推荐大家去阅读《Java Web技术内幕》这本书籍,这本书的内容还是不错的,有框架部分也有较为底层的部分,尤其是Java I/O、JVM以及中文编码这几大模块讲的很详细,大家有空可以去看一看。本篇文章也多处借用了书籍的内容。下面我们开始吧。

         JVM的全称是Java Virtual Machine (Java虚拟机),它通过模拟一个计算机来达到计算机所具有的计算功能。我们先来看看一个真实的计算机如何才能具备计算的功能。

以计算为中心来看计算机的体系结构可以分为如下几个部分:

1、指令集,这个计算机所能识别的机器语言的命令集合。
2、计算单元,即能够识别并且控制指令执行的功能模块。
3、寻址方式,地址的位数、最小地址和最大地址范围,以及地址的运行规则。
4、寄存器定义,包括操作数寄存器、变址寄存器、控制寄存器等的定义、数量和使用方式。
5、存储单元,能够存储操作数和保存操作结构的单元,如内核级缓存、内存和磁盘等。

接下来我们再来看看JVM的体系结构:

1、字节码指令集,能被JVM解析执行。
2、类加载器,在JVM启动时或者在类运行时将需要的class加载到JVM中。
3、执行引擎,执行引擎的任务是负责执行class文件中包含的字节码指令,相当于实际机器上的CPU。
4、内存区,将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者PC指针的记录器等。
5、本地方法调用,调用C或C++实现的本地方法的代码返回结果。

JVM体系结构(类加载机制、内存结构、垃圾回收)_第1张图片

 那么,JVM和实体机到底有何不同呢?大体有如下几点:

1、抽象规范,这个规范就约束了 JVM到底是什么,它有哪些组成部分,这些抽象的规范都在 The Java Virtiual Machine Specification(java虚拟机规范) 中详细描述了。
2、具体的实现,所谓具体的实现就是不同的厂商按照这个抽象的规范用软件或者软件和硬件结合的方式在相同或者不同的平台上的具体的实现。
3、运行中的实例,每个运行中的Java程序都是一个JVM实例。

接下来我们对JVM体系中的重点部分进行详细讲解。

JVM内存区域

PC寄存器:是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器,属于独占区域。由于Java程序是多线程执行的,所以当多个线程交叉执行时,被中断线程的程序当前执行到那条的内存地址必然要保存下来,以便于它被恢复时再按照被中断时的指令地址继续执行下去。如果线程执行的是java方法,那么,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果是native方法,那么值为undefined,这个区域也是唯一一个没有规定内存溢出情况的区域。

java栈:描述的是java方法执行的动态内存模型,每一个方法执行都会创建一个栈帧,用于存储局部变量表、操作数栈、方法的返回地址,指向当前方法所属的类的运行时常量池的引用。一个方法的开始和结束对应入栈和出栈。

局部变量表:局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。存放编译期可知的各种基本数据类型,引用类型,returnAddress类型,局部变量表的内存在编译期就完成了分配,运行时大小不会改变,所以,当一个方法进入时,这个方法需要在帧中分配多少内存是固定的。若方法太多大于栈深度的话,就会报栈溢出stackOverflowError。当我们给栈加大深度(内存)的话,就会不停的一直创建,直到占用内存大于虚拟机设定的内存或者机器本身的内存,那么就申请不到内存,就会报内存溢出outofmemoryError

本地方法栈:为虚拟机执行native方法服务

堆:存放对象实例、垃圾收集器管理的主要区域、新生代,老年代。java堆中一些设置都是为了垃圾回收器。当然也会抛出内存溢出outofmemoryError

方法区(可以理解为class文件在内存存放的位置):存储虚拟机加载的类信息(类的版本、字段、方法、接口),常量、静态变量、即时编译器编译后的的代码等数据。

方法区存放内容:1.类的全限定名(类的全路径名)。2.类的直接超类的全限定名(如果这个类是Object,则它没有超类)。3.类的类型(类或接口)。4.类的访问修饰符,public,abstract,final等。5.类的直接接口全限定名的有序列表。6.常量池(字段,方法信息,静态变量,类型引用(class))等
hostspot使用永久代来实现方法区(方法区标准 -- 永久代实现),好处是hostspot可以像管理java堆一样管理这部分内存,可以省去专门为方法区编写管理内存的代码的工作,当然仅限于hostspot,其他虚拟机没有永久代的概念。垃圾回收在方法区只有少部分的操作,因为成本比较高,而垃圾回收器的的效率比较低,部分回收操作如:常量池、对象类型的卸载。当申请内存失败时同样也会抛出内存溢出
常量池:常量池数据编译期被确定,是Class文件中的一部分。存储了类、方法、接口等中的常量,当然也包括字符串常量。当加载一个方法时会为这个方法创建一个栈帧,而一些局部变量和引用都会放在局部变量表中,如果引用的是字符串则不会放在堆中,而会放在方法区的常量池中,而池中的有字符串表,它的数据结构为hashset来存放实例化的字符串对象,那么,引用相同的字符串则会相等。如果我们是手动new的则一定在堆中。
字符串池/字符串常量池:是常量池中的一部分,存储编译期类中的字产生符串类型数据。

JDK1.7中JVM把字符串常量池从方法区中移除了;JDK1.8中JVM把字符串常量池移入了堆中,同时取消了“永久代”,改用元空间代替

垃圾回收

1、如何判定对象为垃圾对象(标记算法)

引用计数法
        在对象中添加一个引用计数器,当有地方引用它时,计数器加一,引用失效时,计数器为0,但无法处理循环引用的问题。(-verbose:gc和-xx:PrintGCDetail打印垃圾回收器的回收日志  两个分别为简单和详细)

可达性分析法
       通过一系列的被称为 GC root的对象作为起点,依次往下寻找和GC root有关或间接有关的一些对象,这就会形成一条引用链,这条引用链上的对象都是可用的。当一个对象到 GC root 没有任何引用 链相连,则证明此对象是不可用。可被作为GC root对象的有:虚拟机栈(局部变量表)引用的对象、方法区中的类属性引用的对象、方法区中的常量引用的对象,本地方法栈中引用的对象 

引用
强引用:引用只要还在,就不会被GC回收;
软引用:在内存不足发生OOM之期,就回收掉该引用;
弱引用:在下一次GC前,就回收掉该引用;
虚引用:在任何时候可能被回收;

finalize
       若对象在进行可达性分析后发现没有与 GC roots 相连接的引用链,那么他将会被第一次标 记并进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法,当对象没有重写 finalize()方法或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没必要执 行。 若该对象被判定为有必要执行 finalize 方法,则这个对象会被放在一个 F-Queue 队列, finalize 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-queue 中的对象进行第二 次小规模的标记,若对象要在 finalize 中成功拯救自己—只要重新与引用链上的任何一个对 象建立关联即可,那么在第二次标记时他们将会被移出“即 将回收”集合。

2、如何回收

回收策略

1)、标记-清除算法:经过可达性分析法后,对需要清除的对象进行标记然后清除。缺点,产生大量不连续的内存碎片、标记和清除效率都不高 (效率问题和空间问题)

2)、停止-复制算法:在堆中分为两块区域新生代和老年代,老年代并不是特别关注,主要是新生代。新生代(Young Gen)又可以划分为Eden(只要是新创建的对象都会被扔到这里,也是垃圾回收器最常光顾),Survivor存活区(当 Eden 区中没有足够的 内存空间进行分配时,虚拟机将发起一次minorGC{minor gc:发生在新生代的垃圾收集动作,非常频繁,一般回收速度比较快 full gc:发生在老年代的gc},若对象在 eden 出生并经过第一次 minor gc 后仍然存活,并且能被 survivor 容纳的话,将被移到 survivor 空间中,并且对象年龄设为 1.)。老年代(TenuredGen):对象在 survivor 中每熬过一次 minor gc,年龄就+1,当他年龄达到一定程度(默认为 15), 就会晋升到老年代,垃圾回收也相对没有那么频繁。
复制算法就是将可用内存按照容量划分为大小相等的两块,每次只使用其中一块。 当这一块的内存用完了,则就将还存活的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉,那么下一次的垃圾回收就发生在另一块内存空间上。但这样每次只使用一块内存会造成内存浪费。HotSpot解决:将内存分为一块较大的 eden 空间和两块较小的 survivor 空间,默认比例是 8:1:1,即每次新生代中可用内存空间为整个新生代容量的 90%,每次使用eden和其中一个survivour。当回收时,将 eden 和 survivor 中还存活的对象一次性复制到 另外一块 survivor 上,最后清理掉 eden 和刚才用过的 survivor,若另外一块 survivor 空间没 有足够内存空间存放上次新生代收集下来 的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3)、标记-清理算法:此方法主要是针对老年代,复制算法不适合老年代,效率很低。标记过程和“标记-清除”算法一样,但后续步骤不是直接对可回收对 象进行清除,而是让存活对象都向一端移动,然后直接清理掉另一端的内存

4)、分代收集算法:根据内存的分代,选择不同的垃圾回收算法。新生代:停止-复制算法     老年代:标记-清理算法

空间分配担保机制:在发生 minor gc 前,虚拟机会检测老年代最大可用的连续空间是否>新生代 all 对象总空 间,若这个条件成立,那么 minor gc 可以确保是安全的。若不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。若允许,那么会继续检测老年代最大可 用的连续空间是否>历次晋升到老年代对象的平均大小。若大于,则将尝试进行一次 minor gc,尽管这次 minor gc 是有风险的。若小于或 HandlePromotionFailure 设置不允许冒险,则 这时要改为进行一次 full gc。

垃圾回收器           

第一阶段,Serial(串行)收集器
这个收集器是一个单线程收集器,只会使用一个CPU或者一条收集线程进行垃圾收集工作。其余的工作线程必须暂停,直到收集结束,回收慢。PS:开启Serial收集器的方式:-XX:+UseSerialGC
第二阶段,Parallel(并行)收集器
Serial收集器的多线程版本。垃圾收集器线程和工作线程同时工作。效率高,但是当Heap过大时,应用程序暂停时间较长。PS:开启Parallel收集器的方式:-XX:+UseParallelGC -XX:+UseParallelO ldGC
第三阶段,CMS(并发)收集器
CMS收集器在Minor GC时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。在Full GC时不再暂停应用线程,而是使用若干个后台线程定期的对老年代空间进行扫描,及时回收其中不再使用的对象。老年代回收时暂停时间短。产生内存碎片    PS:开启CMS收集器的方式:-XX:+UseParNewGC -  XX:+UseConcMarkSweepGC
第四阶段,G1(并发)收集器
G1是JDK1.7中正式投入使用的用于取代CMS压缩回收器。它虽然没有在物理上隔断新生代老生代,但是仍然属于分代垃圾回收器。G1仍然会区分年轻代与老年代,年轻代依然分有Eden区与Survivor区。
G1首先将堆分为大小相等的Region避免全区域的垃圾回收。然后追踪每个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的回收时间优先回收价值最大的 Region。同时G1采用Remembered Set来存放Region之间的对象引用 ,其他回收器中的新生代与老年代之间的对象引用,从而避免全堆扫描。这种使用Region划分内存空间以及有优先级的区域回收方式,保证 G1 回收器在有限的时间内可以获得尽可能 高的回收效率。
G1收集器的设计初衷是为了尽量缩短处理超大堆(大于4GB)时产生的停顿。相对于CMS的优势而言是内存碎片的产生率大大降低。    PS:开启G1收集器的方式:-XX:+UseG1GC 

 JVM对类的回收条件如下: 1. Java堆中不存在该类的所有实例; 2. 加载该类的ClassLoader已经被回收; 3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类。

类加载机制

类加载过程

类从加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括: 加载-验证-准备-解析-初始化-使用-卸载,其中验证-准备-解析称为链接。

加载阶段,虚拟机需要完成下面 3 件事: 
1)通过一个类的全限定名获取定义此类的二进制字节流; (可从文件{class、jsp、jar文件}、网络、计算生成一个二进制流{$Proxy}、数据库)
2)将这个字节流所表示的静态存储结构转化为方法区运行时数据结构 
3)在方法区生成一个java.lang.Class对象,作为方法区数据的访问入口。
验证阶段的目的是为了确保 clsss 文件的字节流中包含的信息符合当前虚拟机的要求,且 不会危害虚拟机自身的安全。验证阶段大致会完成下面 4 个阶段的检验动作:
1)文件格式验证  2)元数据验证  3)字节码验证  4)符号引用验证{字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过 字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明它一定安全}。 
准备阶段是正式为类变量分配内存并设置变量的初始化值得阶段,这些变量所使用的 内存都将在方法区中进行分配。(不是实例变量,且是初始值,若 public static int a=123;准 备阶段后 a 的值为 0,而不是 123,要在初始化之后才变为 123,但若被 final 修饰,public static final int a=123;在准备阶段后就变为了 123) 
解析阶段是虚拟机将常量池中的符号引用变为直接引用的过程。

符号引用:在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替
直接引用:直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

初始化阶段即为执行类中的静态初始化代码、构造器代码以及静态属性的初始化,在四种情况下初始化过程会被触发执行:调用了new;反射调用了类中的方法;子类调用了初始化;JVM启动过程中指定的初始化类

类加载器

从 Java 虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器,这 个类加载器使用 c++实现,是虚拟机自身的一部分。另一种就是所有其他的类加载器,这些 类加载器都由 Java 实现,且全部继承自 java.lang.ClassLoader。
从 JAVA 开发人员角度,类加载器分为: 
1)启动类加载器(Bootstrap ClassLoader),它是用C++实现的,JVM启动时初始化此ClassLoader,并由此ClassLoader完成$JAVA_HOME中jre/lib/rt.jar(Sun JDK的实现)中所有class文件的加载,这个jar中包含了java规范定义的所有接口以及实现。启动类加载器无法被 Java 程序直接引用。 
2)扩展类加载器(Extension ClassLoader),负责加载\lib\ext 下或者 java.ext.dirs 系统变量指定 路径下 all 类库,开发者可以直接使用扩展类加载器。
3)应用程序类加载器(AppClassLoader),负责加载用户路径 classpath 上指定的类库,开发者可以直接 使用这个类加载器,若应用程序中没有定义过自己的类加载器,一般情况下,这个就是程序 中默认的类加载器。
4) 自定义加载器(User-Defined ClassLoader),User-DefinedClassLoader是Java开发人员继承ClassLoader抽象类自行实现的ClassLoader,基于自定义的ClassLoader可用于加载非Classpath中的jar以及目录。

双亲委派模型:若一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个 类,而是把所这个请求委派给父类加载器去完成,每一层的加载器都是如此,因此 all 加载 请求最终都应该传送到顶级的启动类加载器。只有当父类加载器反馈自己无法加载时(他的 搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型好处:eg,object 类。它存放在 rt.jar 中,无论哪个类加载器要加载这 个类,最终都是委派给处于模型顶端的启动类加载器加载,因此 object 类在程序的各种加载 环境中都是同一个类。

好了,以上就是本次的内容。当然,整个JVM体系内容非常多,我只是将一些重点内容给提取出来而已,如果想详细了解的话,还是推荐大家去看《深入理解Java虚拟机:JVM 高级特性与最佳 实践》这本书籍。另外,本人才疏学浅,如有错误部分请及时指出,本人感激不尽。

参考:

《Java Web技术内幕》

https://blog.csdn.net/baidu_22254181/article/details/81981430

你可能感兴趣的:(JVM)