JVM整体讲解

168 阅读15分钟

1.类加载

1.1 先来搞清楚几个概念

  • 编译

    我们写好".java"文件之后,要打包成".jar"或者".war"文件放到服务器去部署。

    这里非常关键的一步就是编译,就是把我们的".java"文件编译成".class"的字节码文件,这样才可以被运行起来。

  • 类加载器

    编译好的".class"字节码文件在哪里可以运行呢?

    java -jar xxx.jar的时候其实启动了JVM进程,它来负责运行我们编译好的".class"文件。

    此时,类加载器就会把编译好的".class"字节码文件加载到JVM中,以供后续代码的运行。

  • 字节码执行引擎

    JVM基于自己的字节码执行引擎,来执行加载到内存里的字节码文件。

    比如:代码中的"main()"方法,JVM就会从这个"main()"开始执行里面的代码,需要哪个类的时候就会用类加载器加载对应的类。

    所以springboot启动的时候会把main方法中的SpringApplication类加载进来,也就是把所有被spring容器管理的类全部加载进来,至于其他没有被spring管理的类,此时不会被加载,在用到的时候才会被加载。

1.2 JVM加载类的过程

在这里插入图片描述

  • 加载

    上面提到类在被使用的时候会被字节码执行引擎加载到JVM中,这是第一阶段

  • 连接--验证

    简单来说,这一步是根据java虚拟机规范,来校验加载进来的".class"文件中的内容,是否符合规范。

  • 连接--准备

    这个准备阶段,其实就是给加载进来的类对象以及类变量分配一定的空间,然后再给一个默认的初始值

  • 连接--解析

    这个阶段就是把符号引用替换为直接引用(比如说方法的符号引用,是有方法名和相关描述符组成,在解析阶段,JVM把符号引用替换成一个指针,这个指针就是直接引用,它指向该类的该方法在方法区中的内存位置)。

  • 初始化

    为类的静态变量赋予正确的初始值。当静态变量的等号右边的值是一个常量表达式时,不会调用static代码块进行初始化。只有等号右边的值是一个运行时运算出来的值,才会调用static初始化。

  • 使用

    主动使用

    JVM必须在每个类“首次 主动使用”的时候,才会初始化这些类。 (1) 创建类的实例 (2) 读写某个类或者接口的静态变量 (3) 调用类的静态方法 (4) 同过反射的API(Class.forName())获取类 (5) 初始化一个类的子类 (6) JVM启动的时候,被标明启动类的类(包含Main方法的类) 只有当程序使用的静态变量或者静态方法确实在该类中定义时,该可以认为是对该类或者接口的主动使用。

    **被动使用:**除了主动使用的6种情况,其他情况都是被动使用,都不会导致类的初始化。

    JVM规范允许类加载器在预料某个类将要被使用的时候,就预先加载它。如果该class文件缺失或者存在错误,则在程序“首次 主动使用”的时候,才报告这个错误。(Linkage Error错误)。如果这个类一直没有被程序“主动使用”,就不会报错。

    类加载机制与接口: (1) 当Java虚拟机初始化一个类时,不会初始化该类实现的接口。 (2) 在初始化一个接口时,不会初始化这个接口父接口。 (3) 只有当程序首次使用该接口的静态变量时,才导致该接口的初始化。

  • 卸载

    (1)有JVM自带的三种类加载器(根、扩展、系统)加载的类始终不会卸载。因为JVM始终引用这些类加载 器,这些类加载器使用引用他们所加载的类,因此这些Class类对象始终是可到达的。 (2) 由用户自定义类加载器加载的类,是可以被卸载的。

1.3 类加载机制

  • 双亲委派机制

    当一个类加载器收到类加载请求的时候,它首先不会自己去加载这个类的信息,而是把该请求转发给父类加载器,依次向上

    所以所有的类加载请求都会被传递到父类加载器中,只有当父类加载器中无法加载到所需的类,子类加载器才会自己尝试去加载该类。

    当当前类加载器和所有父类加载器都无法加载该类时,抛出ClassNotFindException异常。

    (1) 当一个类加载器加载一个类时,该类所依赖的其他类也会被这个类加载器加载到内存中。

    (2) 所有的Class对象都会被缓存,当程序需要使用某个Class时,类加载器先从缓存中查找,找不到,才从class文件中读取数据,转化成Class对象,存入缓存中。

  • 类加载器

    JVM自带的类加载器(3种)

    (1)根类加载器(Bootstrap): a、C++编写的,程序员无法在程序中获取该类 b、负责加载虚拟机的核心库,lib目录下,比如java.lang.Object c、没有继承ClassLoader类

    (2)扩展类加载器(Extension): a、Java编写的,从指定目录中加载类库 b、父加载器是根类加载器 c、是ClassLoader的子类 d、如果用户把创建的jar文件放到指定目录中lib/ext,也会被扩展加载器加载。 (3)系统加载器(System)或者应用加载器(App): a、Java编写的 b、父加载器是扩展类加载器 c、从环境变量或者class.path中加载类 d、是用户自定义类加载的默认父加载器 e、是ClassLoader的子类

    用户自定义的类加载器 (1)Java.lang.ClassLoader类的子类 (2)用户可以定制类的加载方式 (3)父类加载器是系统加载器 (4)编写步骤: a、继承ClassLoader b、重写findClass方法。从特定位置加载class文件,得到字节数组,然后利用defineClass把字节数组转化 为Class对象

2.JVM运行时数据区

Java虚拟机在执行Java程序的过程中会将其管理的内存划分为若干个不同的数据区域,这些区域有各自的用途、创建和销毁的时间。有些区域随虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束来建立和销毁。Java虚拟机所管理的内存包括以下几个运行时数据区域,如图:

在这里插入图片描述

  • 程序计数器:

    指向当前线程正在执行的字节码指令。线程私有

  • 虚拟机栈:

    虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压人栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。 (1)栈帧:栈帧存储方法的相关信息,包含局部变量数表、返回值、操作数栈、动态链接 a、局部变量表:包含了方法执行过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。 b、返回值:如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向 方法调用指令 后面的一条指令地址。 c、操作数栈:操作变量的内存模型。操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)。操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。 d、动态链接:每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。 (2)线程私有

  • 本地方法栈:

    调用本地native的内存模型,线程私有

  • 方法区:

    用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据,线程共享

    运行时常量池:

    a、是方法区的一部分 b、存放编译期生成的各种字面量和符号引用 c、Class文件中除了存有类的版本、字段、方法、接口等描述信息,还有一项是常量池,存有这个类的 编译期生成的各种字面量和符号引用,这部分内容将在类加载后,存放到方法区的运行时常量池中。

  • 堆(Heap):

    (1)Java堆是虚拟机管理的内存中最大的一块 (2)Java堆是所有线程共享的区域 (3)在虚拟机启动时创建 (4)此内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。存放new生成的对象和数组 (5)Java堆是垃圾收集器管理的内存区域,因此很多时候称为“GC堆”

在这里插入图片描述

3.JMM java内存模型

1、 Java的并发采用“共享内存”模型,线程之间通过读写内存的公共状态进行通讯。多个线程之间是不能通过直接传递数据交互的,它们之间交互只能通过共享变量实现。 2、 主要目的是定义程序中各个变量的访问规则。 3、 Java内存模型规定所有变量都存储在主内存中,每个线程还有自己的工作内存。 (1) 线程的工作内存中保存了被该线程使用到的变量的拷贝(从主内存中拷贝过来),线程对变量的所有操作都必须在工作内存中执行,而不能直接访问主内存中的变量。 (2) 不同线程之间无法直接访问对方工作内存的变量,线程间变量值的传递都要通过主内存来完成。 (3) 主内存主要对应Java堆中实例数据部分。工作内存对应于虚拟机栈中部分区域。

在这里插入图片描述

4、Java线程之间的通信由内存模型JMM(Java Memory Model)控制。 (1)JMM决定一个线程对变量的写入何时对另一个线程可见。 (2)线程之间共享变量存储在主内存中 (3)每个线程有一个私有的本地内存,里面存储了读/写共享变量的副本。 (4)JMM通过控制每个线程的本地内存之间的交互,来为程序员提供内存可见性保证。 5、可见性、有序性: (1)当一个共享变量在多个本地内存中有副本时,如果一个本地内存修改了该变量的副本,其他变量应该能够看到修改后的值,此为可见性。 (2)保证线程的有序执行,这个为有序性。(保证线程安全) 6、内存间交互操作: (1)lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。 (2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 (3)read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。 (4)load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中 (5)use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。 (6)assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。 (7)store(存储):把工作内存的变量的值传递给主内存 (8)write(写入):把store操作的值入到主内存的变量中

4.堆内存划分

在这里插入图片描述

Java堆的内存划分如图所示,分别为年轻代、Old Memory(老年代)、Perm(永久代)。其中在Jdk1.8中,永久代被移除,使用MetaSpace代替。 1、新生代: (1)使用复制清除算法(Copinng算法),原因是年轻代每次GC都要回收大部分对象。新生代里面分成一份较大的Eden空间和两份较小的Survivor空间。每次只使用Eden和其中一块Survivor空间,然后垃圾回收的时候,把存活对象放到未使用的Survivor(划分出from、to)空间中,清空Eden和刚才使用过的Survivor空间。 (2)分为Eden、Survivor From、Survivor To,比例默认为8:1:1 (3)内存不足时发生Minor GC 2、老年代: (1)采用标记-整理算法(mark-compact),原因是老年代每次GC只会回收少部分对象。 **3、Perm:**用来存储类的元数据,也就是方法区。 (1)Perm的废除:在jdk1.8中,Perm被替换成MetaSpace,MetaSpace存放在本地内存中。原因是永久代进场内存不够用,或者发生内存泄漏。 (2)MetaSpace(元空间):元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 4、堆内存的划分在JVM里面的示意图:

在这里插入图片描述

5.GC回收

  • 判定对象是否要回收:

**1、可达性分析法:**通过一系列“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象不一定会成为可回收对象。进入DEAD状态的线程还可以恢复,GC不会回收它的内存。(把一些对象当做root对象,JVM认为root对象是不可回收的,并且root对象引用的对象也是不可回收的) 2、 以下对象会被认为是root对象: (1) 虚拟机栈(栈帧中本地变量表)中引用的对象 (2) 方法区中静态属性引用的对象 (3) 方法区中常量引用的对象 (4) 本地方法栈中Native方法引用的对象 3、 对象被判定可被回收,需要经历两个阶段: (1) 第一个阶段是可达性分析,分析该对象是否可达 (2) 第二个阶段是当对象没有重写finalize()方法或者finalize()方法已经被调用过,虚拟机认为该对象不可以被救活,因此回收该对象。(finalize()方法在垃圾回收中的作用是,给该对象一次救活的机会) 4、 方法区中的垃圾回收: (1) 常量池中一些常量、符号引用没有被引用,则会被清理出常量池 (2) 无用的类:被判定为无用的类,会被清理出方法区。判定方法如下: A、 该类的所有实例被回收 B、 加载该类的ClassLoader被回收 C、 该类的Class对象没有被引用

  • 常见的垃圾回收算法:

1、Mark-Sweep(标记-清除算法):

(1)思想:标记清除算法分为两个阶段,标记阶段和清除阶段。标记阶段任务是标记出所有需要回收的对象,清除阶段就是清除被标记对象的空间。 (2)优缺点:实现简单,容易产生内存碎片

2、Copying(复制清除算法):

(1)思想:将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。 (2)优缺点:不容易产生内存碎片;可用内存空间少;存活对象多的话,效率低下。

3、Mark-Compact(标记-整理算法):

(1)思想:先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。 (2)优缺点:不容易产生内存碎片;内存利用率高;存活对象多并且分散的时候,移动次数多,效率低下

4、分代收集算法:(目前大部分JVM的垃圾收集器所采用的算法):

思想:把堆分成新生代和老年代。(永久代指的是方法区)

(1) 因为新生代每次垃圾回收都要回收大部分对象,所以新生代采用Copying算法。新生代里面分成一份较大的Eden空间和两份较小的Survivor空间。每次只使用Eden和其中一块Survivor空间,然后垃圾回收的时候,把存活对象放到未使用的Survivor(划分出from、to)空间中,清空Eden和刚才使用过的Survivor空间。 (2) 由于老年代每次只回收少量的对象,因此采用mark-compact算法。 (3) 在堆区外有一个永久代。对永久代的回收主要是无效的类和常量。