JVM介绍(通俗易懂)

了解JVM之前,先了解一下JVM的主要组成部分和其各自的作用

JVM主要包括两个子系统和两个组件,两个子系统为Class loader(类装载)Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)Native Interface(本地库接口)

那么它们的作用分别是什么呢?

java文件经过javac编译成字节码文件,字节码文件经过Classloader(类装载)加载到内存当中,将其放在Runtime data area(运行时数据区),而字节码只是JVM的一套规范,底层的操作系统并不能直接执行字节码文件,因此需要特定的Execution engine(执行引擎),将字节码翻译成底层操作系统能识别的指令,然后再交给cpu执行,而这个过程中,需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

1. Class loader(类装载器)

JVM有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:

  1. BoostrapClassLoader,这里加载的是jre/lib/rt.jar里所有的class
  2. EtxCLassLoader,这里加载的是jre/lib/ext目录中所有jar包的class
  3. AppClassLoader,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系 统属性,或者CLASSPATH换将变量所指定的JAR包和类路径

其中,BoostrapClassLoader是EtxCLassLoader的父类,EtxCLassLoader又是AppClassLoader的父类
ps:这里的父类并非继承关系,而是EtxCLassLoader当中有个parent属性,这个属性是BoostrapClassLoader,其它的也是这样

类加载机制—双亲委派机制

JVM介绍(通俗易懂)_第1张图片

每个类加载器都有自己的缓存,集体的加载流程是:
先向上委派,查找缓存
再向下查找,查找加载路径

查找AppClassLoader缓存==>EtxCLassLoader缓存==>BoostrapClassLoader缓存
如果缓存当中有,说明这个类也就加载过了,直接加载返回 如果一直到BoostrapClassLoader缓存都没有,就会向下查找加载路径
查找BoostrapClassLoader加载路径==>EtxCLassLoader加载路径==>AppClassLoader加载路径
有则加载返回,没有就继续向下查找

2. Runtime data area(运行时数据区)

Java 虚拟机所管理的内存如下图所示

JVM介绍(通俗易懂)_第2张图片
2.1 VM Stack(虚拟机栈)

线程私有,Java虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时会创建一个栈帧。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。

注意1: 在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

注意2:很多人说:基本数据和对象引用存储在栈中。当然这种说法虽然是正确的,但是很不严谨,只能说这种说法针对的是局部变量。局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。但是,如果是成员变量,或者定义在方法外对象的引用,它们存储在堆中。

2.2 Native(本地方法栈)

Native:线程私有
1.被native修饰的方法就不再是java的作用范围了,而是调用了底层c的代码
2.native修饰的方法会被加载到JVM的本地方法栈当中
3.JNI:java native interface 本地方法接口,会被调用。它的作用,扩展了java的功能,融合不同的编程语言为java所调用
4.最终执行的时候,加载本地方法库当中的方法,然后执行

2.3 Method Area(方法区)

线程共享,静态变量,常量,类信息,运行时常量池,都放在方法区当中

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpotJVM 而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于Java堆的内存空间。方法区主要存放的是『Class』,而堆中主要存放的是『实例化的对象』

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space

  • 加载大量的第三方的jar包

  • Tomcat部署的工程过多(30 — 50个)

  • 大量动态的生成反射类

    关闭JVM就会释放这个区域的内存。

2.4 运行时常量池

属于方法区一部分,用于存放编译期生成的各种字面量和符号引用。编译器和运行期(String 的 intern())都可以将常量放入池中。内存有限,无法申请时抛出 OutOfMemoryError。

2.5 Heap(堆)

堆是JVM 所管理的内存中最大的一块。线程共享,主要是存放对象实例和数组

堆的内存分配情况

新生区,相对整个堆空间来说,内存分配比例 1。

  • 伊甸区(Eden),相对新生区来说,内存分配比例为 8
  • 幸存者from区,相对新生区来说,内存分配比例为 1
  • 幸存者to区,相对新生区来说,内存分配比例为 1,为空的永远是to区
    老年期,相对整个堆空间来说,内存分配比例为2。

永久区(1.8之前的叫法)、元空间(1.8之后的叫法)。

元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制

jdk1.8之后为什么将永久区放在本地内存?

比如Tomcat服务器启动时会把该服务器所有的项目(假如有50个)全部加载进内存,其中一些很多类的信息都保存在方法区,方法区只是一个概念,真正的实现是永久区(1.8之前)元空间(1.8之后),那这样会导致永久区内存被大量占用,可能会产生堆溢出

什么是堆内存泄露?

就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题。

2.6 程序计数器

内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成

如果有一个线程进行了sleep()操作,那程序计数器就会记录当前线程的字节码指令的地址,然后sleep()时间完成之后,如何在继续执行原来的线程呢,便是通过刚刚记录的地址。

3.HopSpot虚拟机对象的创建,访问定位、是否存活以及常见的垃圾回收算法

3.1 对象的创建

虚拟机遇到一条new指令,首先检查该类是否加载进内存,如果没有,类加载器就会先把该类加载进内存,如果已经加载进内存,就会创建堆的内存空间是否规整,如果规整,就采用指针碰撞的方式为对象分配内存,否则就采用空闲列表的方式为对象分配内存

什么叫内存空间规整?

规整就是在堆当中用过的内存放在一边,空闲内存放在另一边,可以简单理解为空闲内存是否连续

指针碰撞:针对规整的内存空间,分配内存时,将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离
空闲列表:JVM虚拟机维护了一个列表,记录了堆当中那些内存是可用的,这样在给对象分配内存的时候,就会从列表当中查询一块足够大的内存分配给对象,分配之后再更新列表记录。

选择哪种分配方式是由Java堆是否规整决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3.2 对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄直接指针 两种方式。

句柄访问

Java堆当中划出来一块内存作为句柄池,引用当中存储句柄地址,而句柄又包含了对象实例数据地址和对象类型数据地址。

注意,句柄当中存储的都是地址,可以简单理解为一级索引,而对象实例数据地址指向了Java堆当中的实例数据,对象类型数据地址指向了方法区当中的类型数据。
JVM介绍(通俗易懂)_第3张图片

优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

直接指针:

如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

JVM介绍(通俗易懂)_第4张图片

优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

3.3 对象是否存活

在进行内存回收之前要做的事情就是判断那些对象是‘死’的,哪些是‘活’的。

引用计数法:

每个对象都有一个引用计数器,当对象被引用时+1,当引用失效时-1,如果为0,代表这个对象可以被回收

public class GCDemo1 {
    public static void main(String[] args) {
        A a1 = new A();//这个假如为实例1,实例1的引用计数+1,最终结果=1
        A a2 = new A();//这个假如为实例2,实例2的引用计数+1,最终结果=2
        a1.object = a2;//实例2的引用计数+1,最终结果=2
        a2.object = a1;//实例1的引用计数+1,最终结果=2
        a1 = null;//实例1的引用计数-1,最终结果=1,不会被回收
        a2 = null;//实例1的引用计数-1,最终结果=1,不会被回收
    }
}
class A{
    public Object object = null;
}

由上面代码可以看出,这里会产生一个循环引用的问题,由于实例1和实例2互相引用,引用计数器记录的值不为零,所以导致不再使用的对象引用一直存在,就不会被回收,造成了内存泄露。使用Java虚拟机当中采用是另一种方式,可达性分析。

可达性分析:

通过一系列的 ‘GC Roots’ 的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots,没有任何引用链相连的时候说明对象不可用。

可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象和常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象

这样说可能不太直观,看图。
JVM介绍(通俗易懂)_第5张图片
从上图可以看出,reference1、reference2、reference3都是GC Roots

  • reference1->对象实例1
  • reference2->对象实例2
  • reference3->对象实例4->对象实例6

可以看出,对象实例3和对象实例5是不可达的,所以会被GC回收

3.4 常见的垃圾回收算法

1.Mark-Sweep(标记-清除)算法
这是最基础也是最简单的垃圾回收算法,顾名思义,这个算法分为两个阶段:标记阶段和清除阶段

标记阶段就是为可回收的对象打上标记。 清除阶段就是将标记的对象进行回收。

JVM介绍(通俗易懂)_第6张图片
优点:相比复制算法不需要额外的内存空间

缺点:容易产生内存碎片(导致内存空间不连续)

2.Copying(复制)算法

为了解决标记清除算法的缺陷,复制算法就被提出来了

复制算法把堆当中的新生代内存分为2块,分别是伊甸区、幸存者区。
幸存者区又分为幸存者from区、幸存者to区,三者占据的内存比例为8:1:1

首先,新创建的对象都会被放在伊甸区。
当伊甸区满了之后,会触发第一次YGC,把伊甸区存活下来的对象复制到幸存者from区,然后清空伊甸区。
当伊甸区再次满了之后,会触发第二次YGC,把伊甸区和幸存者from区存活的对象复制到幸存者to区,清空伊甸区和幸存者from区,然后幸存者from区和幸存者to区交换位置,区别幸存者from区和幸存者to区最简单的方法,谁空谁是to区。之后的YGC以此类推。

ps:当一个对象经历了15次(默认值,可以通过-XX:MaxTenuringThreshold设置)轻GC还没有被回收,它会被移动到老年区

优点

在存活对象不多的情况下,性能高,能解决内存碎片和Java垃圾回收之标记清除算法详解 中导致的引用更新问题。

缺点

会造成一部分的内存浪费。不过可以根据实际情况,将内存块大小比例适当调整;
如果存活对象的数量比较大,coping的性能会变得很差。

3.Mark-Compact(标记-整理)算法(压缩法)
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
JVM介绍(通俗易懂)_第7张图片
优点:不会产生内存碎片
缺点:多了一个移动成本

以上这是关于JVM一些个人的总结。

你可能感兴趣的:(JVM,java,jar,开发语言,jvm)