JVM深入浅出,图解易懂,赶紧收藏,入股不亏

大家好!我是未来村村长,就是那个“请你跟我这样做,我就跟你这样做!”的村长‍!

||Coding Again 2||

​ 未来村村长正推出一系列【Coding Again】文章,对之前学过的和没学过的知识重新进行整理,因为现在再回顾之前的文章,写得很乱,大概是因为自己当时也没有搞太明白相关的知识点,再出发即是一次对过去的知识扬弃的过程。该系列文章以java后端学习路线为轴进行推出,如果喜欢就一键三连吧!

文章目录

    • ||Coding Again 2||
    • 一、Java概述
    • 二、JVM内存结构
      • 1、JVM虚拟机内存区域
      • 2、HotSpot虚拟机对象探究
        • (1)对象的创建过程
        • (2)对象的内存布局
        • (3)对象的访问定位
          • ① 直接指针
          • ② 句柄访问
      • 3、溢出问题
        • (1)内存溢出——OutOfMemoryError
        • (2)堆溢出
        • (3)栈溢出
        • (4)方法区和运行时常量池溢出
    • 三、垃圾回收(GC)
      • 1、概述
      • 2、对象存活判定算法
        • (1)引用计算算法
        • (2)可达性分析算法
      • 3、垃圾收集算法
        • (1)分代收集理论
        • (2)标记-清除、复制、整理算法
          • ① 标记-清除
          • ② 标记-复制
          • ③ 标记-整理
      • 4、垃圾收集器
      • 5、引用类型
    • 四、类加载机制
      • 1、类加载机制
        • (1)类生命周期与加载过程
          • ① 加载
          • ② 验证
          • ③ 准备
          • ④ 解析
          • ⑤ 初始化
        • (2)类初始化时机
          • ① 主动引用
          • ② 被动引用
      • 2、类加载器
        • (1)加载器分类
        • (2)双亲委派机制

一、Java概述

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第1张图片

  • JDK:用于支持Java程序开发的最小环境,其中包含java程序语言、java虚拟机、java类库。JDK包含JRE。

  • JRE:用于支持Java程序运行的标准环境,Java类库API中的Java SE API子集和java虚拟机的统称。JRE大部分都是 C 和 C++ 语言编写的。JRE包含JVM。

  • JVM:Java虚拟机,本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。

二、JVM内存结构

1、JVM虚拟机内存区域

​ 我们在IDE中编写.java程序后,通过javac编译器编译成字节码文化(.class),类加载器把字节码文件加载到内存中,将其放在运行时数据区的方法区内。

​ 字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。

​ 下图中,方法区、堆、执行引擎、本地库接口属于线程共享的数据区,其余区域属于线程私有数据区。

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第2张图片

  • 运行时数据区:Java虚拟机所管理的内存区域。Java虚拟机在Java程序运行时,会将其所管理的内存划分未不同的数据区域,其中包含图中的方法区、堆、虚拟机栈、本地方法栈、程序计数器。
  • 程序计数器:程序控制流的指示器,当前线程所执行的字节码行号指示器,JVM工作时通过改变程序计数器的值,来选取下一条需要执行的字节码指令,从而实现循环、跳转、异常处理、线程恢复等基础功能。一个线程对应一个程序计数器,则程序计数器为线程私有。
  • 虚拟机栈:虚拟机栈描述的是Java方法执行的内存模型,线程私有,生命周期与线程相同。每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接返回方法地址等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 本地方法栈:与虚拟机栈发挥作用类似,虚拟机栈为虚拟机执行java方法服务,而本地方法栈为虚拟机使用到的本地方法服务。
  • 堆:此区域的唯一目的——存放所有实例对象或数组,线程共享,虚拟机所管理的内存最大区域。
  • 方法区:用于存储被虚拟机加载的类型信息常量静态变量、即时编译器编译后的代码缓存等数据,线程共享
    • 运行时常量池:方法区的一部分,用于存放常量,具有动态性。
  • 执行引擎:根据程序计数器中存储的指令地址执行classes中的指令。“虚拟机”是一个相对于“物理机”的概念,虚拟机的字节码是不能直接在物理机上运行的,需要JVM字节码执行引擎编译成机器码后才可在物理机上执行。
  • 本地接口:与本地方法库交互,是其它编程语言交互的接口。

2、HotSpot虚拟机对象探究

(1)对象的创建过程

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第3张图片

  • new:判断类是否加载、解析、初始化,若没有则执行类加载过程。

  • 内存分配:判断内存是否规整,不规整使用空闲列表方法,规整使用指针碰撞方法。对于内存分配的并发问题,有两种方式处理方式,一是CAS同步处理,二是本地线程分配缓冲。

  • 对象设置:将对象相关信息存入对象的对象头中

  • 对象初始化:new指令后执行,()方法,即构造函数。

(2)对象的内存布局

​ 在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充。

  • 对象头:包含两类信息,第一类是类型指针,对象指向它的类型元数据指针,Java虚拟机通过这个指针确定该对象是哪个类的实例。第二类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。
  • 实例数据:真正存储对象的有效信息,即在程序代码中定义的各类型字段内容。
  • 对其填充:用来补充实例数据未满8子节倍数的部分,因为HotSpot虚拟机内存股那里系统要求对象起始地址必须为8子节。

(3)对象的访问定位

① 直接指针

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第4张图片

  • 过程:Java堆中划分出一块内存放置访问类型数据的相关信息,reference中存储对象地址,这样访问对象就不需要间接访问。

  • 优点:速度更快,节省了一次指针定位的时间开销,HotSpot主要以直接指针的方式进行对象访问。

② 句柄访问

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第5张图片

  • 过程:Java堆中划分出一块内存作为句柄池,reference中存储对象的句柄地址,句柄包含了对象实例数据与类型数据各自的地址信息。

  • 优点:reference中存储的是稳定句柄地址,在对象被移动时,只需要改变句柄中的实例数据指针,reference本身不用改变。

3、溢出问题

(1)内存溢出——OutOfMemoryError

​ 根据内存溢出的区域可分为栈溢出、方法区和运行时常量池溢出、直接内存溢出。

(2)堆溢出

​ 溢出原因:只要我们不断地创建对象并且垃圾回收机制未清理或不能及时收集清理总容量触及最大堆的容量限制后,就会产生内存溢出异常。

​ 解决:

  • 内存泄露:查询GC Roots的应用链,找到泄露对象的引用路径和哪些GC Roots相关联,导致垃圾收集机制无法回收他们。根据对象的类型信息和GC Roots应用链信息准确定位泄露对象的创建位置,定位到具体代码位置
  • 对象生命周期过长:检查是存在某些对象生命周期过长持有状态时间过长,内存结构不合理。

(3)栈溢出

​ 溢出原因:HotSpot虚拟机不区分虚拟机栈和本地方法栈,当线程请求的栈深度大与虚拟机所允许的最大深度,将抛出SatckOverflowError异常。如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够内存时,将抛出OutOfMemoryError异常。

​ 解决:

  • 减少线程数量
  • 更换虚拟机
  • 减少内存(堆容量和栈容量)换取更多线程

(4)方法区和运行时常量池溢出

​ 溢出原因:运行时常量池是方法区的一部分,运行时产生的类太多,如动态产生类(动态代理、反射等),会发生溢出

​ 解决:JDK8以后永久代退出了历史舞台,可以通过调整元空间的大小或垃圾收集的频率来避免方法区溢出

三、垃圾回收(GC)

1、概述

​ 程序计数器、虚拟机栈、本地方法栈三个区域线程私有,随线程的生命周期开始而开始,结束而结束。栈中的栈帧随着方法的进入和推出执行出栈和入栈操作,每一个栈帧分配的内存在编译期就已知,

​ 堆和方法区中,在程序运行期间才能知道会创建哪些对象和创建多少对象,这部分内存的分配和回收是动态的。

​ java的GC:Java程序在运行期间,JVM会创建一个优先级较低的线程来作为垃圾回收的线程,该线程通过算法对对象的存活进行判定,并对已死的对象进行垃圾收集处理。

2、对象存活判定算法

(1)引用计算算法

​ 主流的JVM都不使用该算法进行垃圾回收,因为单纯使用该方法不能解决像对象之间相互循环引用等问题,需要对该算法进行额外的扩展。该方法十分简单,算法过程如下:

  • 在对象中添加一个引用计数器,每当其被引用,计数器加1
  • 每当其引用失效,计数器减1
  • 计数器为0的对象则判定为死亡

(2)可达性分析算法

​ 该算法为比较主流的判定对象存活算法。这个算法的基本思路为:

  • 从GC Roots开始向下搜索,搜索所走过的路径为引用链
  • 当一个对象到GC Roots没用任何引用链时,则证明此对象是不可用的,表示可以回收

​ 在Java中,GC Roots对象包括以下几种:(一般为栈、方法区中引用的对象,以及JVM内部的引用)

  • 在虚拟机栈中引用的对象
  • 在方法区中类静态属性引用的对象或常量引用的对象
  • 本地方法栈中的Native方法引用的对象
  • Java虚拟机内部的引用
  • 被同步(synchronized)锁持有的对象等

3、垃圾收集算法

(1)分代收集理论

​ 若每次都要对每一个对象进行是否回收判定,效率是比较低的。我们可以对java堆的内存空间再进行一个划分,划分为新生代、老年代、永久代(JDK8之前以永久代作为实现,而JDK8以后由元空间实现,且元空间占用的是本地内存)。而新生代又划分为Eden(伊甸园)和Survior(生存区,包含From和To)。

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第6张图片

  • 伊甸园:新创建的对象会进入新生代的伊甸园

  • Survior:新生的对象经过一次GC操作以后,依然存活的进入Survior中的From区

  • From区:当有对象第一次进入From区时,To区中的对象会进入到From区,随后进入To区

  • To:在To区进行年龄判定,继续进行GC操作,当GC操作判定对象年龄大于15(默认,经历一次GC年龄+1)时,该对象会进入老年代

  • 空间担保机制:如果一次GC以后,新生代的依旧存在大量对象,Survior无法承载,则JVM会将Survior无法承载的部分送到老年代中,若老年代还是装不下,则会进行一次Full GC,若还是装不下,则发生内存溢出。

  • 元空间:元空间不在java堆中,而是JVM申请的本地内存区域,这样就不会出现永久代存在的内存溢出问题,而且永久代的内存大小申请是难以确定的。

(2)标记-清除、复制、整理算法

① 标记-清除

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第7张图片

​ 根据对象存活判定算法(如可达性分析或引用计算),对内存区当中需要被清理的对象进行标记,然后对其进行直接删除操作。

​ 缺点:会时内存空间利用碎片化,空间利用效率不高

② 标记-复制

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第8张图片

​ 将内存分为两部分,一部分存放创建的对象,一部分为空。根据对象存活判定算法(如可达性分析或引用计算),对内存区当中需要被清理的对象进行标记,将标记的算法清除,然后将存活的对象剪切到内存为空的那部分区域。Survior区域(分为From和To)使用的就是这个算法。

​ 缺点:不适用与老年代区域,因为老年代区域执行GC操作的对象较少,若每删除一个对象就要重新进行一次复制排序,效率较低。

③ 标记-整理

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第9张图片

​ 不划分内存区域,根据对象存活判定算法(如可达性分析或引用计算),对内存区当中需要被清理的对象进行标记,然后对内存中的对象进行重新排序,将被标记的对象放在最后,然后清除。

​ 缺点:效率较低。

4、垃圾收集器

​ 经过jDK二十多年的更新迭代,出现了较多的垃圾收集器,从最开始的Serial收集器(复制算法)到CMS收集器(标记-清除算法),到JDK7和8的Garbage First收集器(标记-整理算法),实现了“全功能的垃圾收集器”,因其内存消耗问题,后续出现了第三方公司开发的Oracle不支持的shennandoah收集器,Oracle随后又发布了与其目标相似的ZGC收集器。

​ 这里我们详细说明一下G1垃圾收集器的运行机制。在G1出现以前,像Serial收集器的范围是新生代、serial Old的范围是老年代。而G1收集器的目标范围包含了整个java堆。

​ G1将Java堆分为2048个大小相同的Region块,Region根据需要来扮演新生代或老年代角色,收集器对不同的角色进行不同的回收策略,其运作过程主要为以下四个步骤:

  • 初始标记:标记GC Roots能直接关联的对象,修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用Region中分配新对象。
  • 并发标记:从GC Roots开始对堆中的对象进行可达性分析,递归扫描整个堆的对象图,找到要回收的对象。重新处理SATB记录下的并发时有引用变动的对象。
  • 最终标记:对用户线程进行暂停,用于处理并发阶段结束遗留的少量的SATB记录。
  • 筛选回收:更新Region中的统计数据,根据多个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。将要清理的Region中存活的对象复制到一个空Region,将该Region清空。

5、引用类型

  • 强引用:new方法进行类的对象实例化为强引用,只要存在强引用的对象,垃圾收集器就永远不会收掉被引用的对象。
  • 软引用:描述还有用,但是非必须的对象。在系统将要发生内存溢出时,会将软引用对象收集清理。
  • 弱引用:描述非必要对象,比软引用更弱。若引用对象在下一次GC操作会被回收,无论内存占用情况如何。
  • 虚引用:为一个对象设置虚引用关联的唯一目的只是为了在这个对象被收集器收集时收到一个系统通知。

四、类加载机制

1、类加载机制

(1)类生命周期与加载过程

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第10张图片

① 加载
  • 通过类的完全限定名称获取定义该类的⼆进制字节流。

  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。

  • 在内存中生成⼀个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

② 验证

​ 确保 Class ⽂件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机⾃身的安全。

③ 准备

​ 准备阶段为类变量分配内存并设置初始值,使⽤的是⽅法区的内存。

​ 实例变量不会在这阶段分配内存,它会在对象实例化时随着对象⼀起被分配在堆中。

​ 实例化不是类加载的⼀个过程,类加载发⽣在所有实例化操作之前,并且类加载只进⾏⼀次,实例化可以进⾏多次。

④ 解析

​ 将常量池的符号引⽤替换为直接引⽤的过程。

​ 其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了⽀持 Java 的动态绑定。

⑤ 初始化

​ 初始化阶段才真正开始执⾏类中定义的 Java 程序代码。初始化阶段是虚拟机执⾏类构造器 () ⽅ 法的过程。

​ 在准备阶段,类变量已经赋过⼀次系统要求的初始值,⽽在初始化阶段,根据程序员通过程 序制定的主观计划去初始化类变量和其它资源。

(2)类初始化时机

① 主动引用

有下列五种情况必须对类进⾏初始化(加载、验证、准备都会随之发⽣):

  • 使⽤ new 关键字实例化对象的时候; 读取或设置⼀个类的静态字段(被 final 修饰、已在编译期把结果放⼊常量池的静态字段除外)的时 候;以及调⽤⼀个类的静态⽅法的时候。(到new、getstatic、putstatic、invokestatic 这四条字节码指令)
  • 使⽤ java.lang.reflect 包的⽅法对类进⾏反射调⽤的时候,如果类没有进⾏初始化,则需要先触发 其初始化。
  • 当初始化⼀个类的时候,如果发现其⽗类还没有进⾏过初始化,则需要先触发其⽗类的初始化
  • 当虚拟机启动时,⽤户需要指定⼀个要执⾏的主类(包含 main() ⽅法的那个类),虚拟机会先初始 化这个主类;
  • 如果⼀个 java.lang.invoke.MethodHandle 实例最后的解析结 果为⽅法句柄,并且这个⽅法句柄所对应的类 没有进⾏过初始化,则需要先触发其初始化
② 被动引用

所有引⽤类的⽅式都不会触发初始化, 称为被动引⽤:

  • 通过⼦类引⽤⽗类的静态字段,不会导致⼦类初始化。
  • 通过数组定义来引⽤类,不会触发此类的初始化。该过程会对数组类进⾏初始化,数组类是⼀个由 虚拟机⾃动⽣成的、直接继承⾃ Object 的⼦类,其中包含了数组的属性和⽅法。
  • 常量在编译阶段会存⼊调⽤类的常量池中,本质上并没有直接引⽤到定义常量的类,因此不会触发 定义常量的类的初始化。

2、类加载器

(1)加载器分类

虚拟机角度

  • 启动类加载器(Bootstrap ClassLoader),使⽤ C++ 实现,是虚拟机⾃身的⼀部分;

  • 所有其它类的加载器,使⽤ Java 实现,独⽴于虚拟机,继承⾃抽象类 java.lang.ClassLoader。

当然从开发人员的角度来看,还可以划分出:

  • 扩展类加载器(Extension ClassLoader):JDK开发团队允许用户将具有通用属性的类库放在ext目录以扩展javaSE功能,该扩展类使用扩展类加载器加载。
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径上的所有类库,在没有用户定义的类加载器时,该加载器就是程序默认的类加载器。
  • 启动类加载器(Bootstrap ClassLoader):负责加载\lib目录下或者被-Xbootclasspath参数指定路径存放名字符合类库的jar文件。

(2)双亲委派机制

​ 应⽤程序是由三种类加载器互相配合从⽽实现类加载,除此之外还可以加⼊⾃⼰定义的类加载器。

​ 下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有⾃⼰的⽗类加载器。这⾥的⽗⼦关系⼀般通过组合 关系(Composition)来实现,⽽不是继承关系(Inheritance)。

JVM深入浅出,图解易懂,赶紧收藏,入股不亏_第11张图片

​ 其工作过程为:

  • 如果一个类加载器收到了加载类的请求,它首先不会自己去尝试加载这个类,而是把该请求交给其父类加载器,每一层级的加载器皆如此
  • 因此所有加载请求最终都会传送到最顶层的启动类加载器中
  • 只有当父类无法完成该加载的请求,子加载器才会尝试自己加载

​ 简而言之,双亲委派模型就是,每个人要有个父亲,但是最开始的那个没有,而且父子关系不是通过继承来的,是通过认的,我们称为干爹。遇到事请了,儿子请教父亲、父亲请教他的父亲,一直请教到祖宗。如果父亲干不了,那他的儿子就只能自己想办法干。

参考:
【1】周志明——《深入理解Java虚拟机》

你可能感兴趣的:(Coding,Again)