JVM

经过一段时间的期末冲刺预习,现在终于有时间进行jvm的学习了😥
更新日志:
    2022.6.17 根据狂神的视频进行初步了解掌握

1. JVM的探究

  1. 请你谈谈对于JVM的理解?Java8虚拟机和之前的变化更新
  2. 什么是OOM,什么是栈溢出StackOverFlowError? 怎么分析
  3. JVM的常用调优参数有哪些
  4. 内存快照如何抓取,怎么分析Dump文件
  5. 谈谈JVM中,你对于类加载器的认识
三种jvm
  • SUN公司 hotpot

  • BEA JRockit

  • IBM J9VM

2. JVM 结构

image-20220619152717928

image-20220617184022048

1. 类加载器

类加载器分为四类

  • 虚拟机自带的加载器

  • 启动类(根)加载器(使用C++)

  • 扩展类加载器

  • 应用程序加载器

2. 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里 ,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

3. Native和方法区

3.1 Native

有时候我们会在Java的源码里面看见如下代码

1
private native void start0();

凡是带了native关键字的方法,说明Java的作用范围达不到了 需要去调用底层C语言的库,会进入本地方法栈,然后调用本地方法接口 JNI

本地方法接口就是为了去调本地方法库 扩展Java的使用 融合不同的编程语言为Java所用

3.2 方法区

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单的来说,所有定义的方法的信息都保存在该区域,此区域书于共享区间;方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。静态变量、常量、类信息(构造方法,接口定义),运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关

4. 栈

“栈”通常就是指上面讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。栈内存,主管程序的运行、生命周期和线程同步。线程结束,栈内存也释放。对于栈来说,不存在垃圾回收问题, 一旦线程结束栈就结束

栈的运行原理 : 栈帧(栈帧也叫过程活动记录,是编译器用来实现过程函数调用的一种数据结构”。实际上,可以简单理解为:栈帧就是存储在用户栈上的(当然内核栈同样适用)每一次函数调用涉及的相关信息的记录单元。

5. 堆

heap一个jvm只有一个堆内存 堆内存的大小是可以调节的

堆内存中还要细分三个区域

  • 新生区(伊甸园区 eden space 幸存0区 幸存1区)

  • 养老区

  • 永久区(1.8后称为元空间)

堆内存

  1. 新生区

类诞生和成长的地方 甚至死亡,伊甸园区:所有的对象都是在伊甸园区new出来的

  1. 永久区(元空间)

这个区域常驻内存 用来存放自身携带的class对象 interface元数据 存储的是Java运行的一些环境或类信息 这个区域不存在垃圾回收 关闭vm虚拟机就会释放这个区域的内存

元空间逻辑上存在 物理上不存在

5.1 堆内存错误

假设内存满了 OOM 堆内存错误

java.lang.OutOfMemoryError:Java heap space

当出现这种错误的时候我们该怎么办呢?

答案就是JProfiler,可以通过JProfiler进行调优

  1. 下载该插件

image-20220617192619772

  1. 下载应用,然后在idea中配置

image-20220617192638260

  1. 通过命令生成dump文件,然后进行分析

image-20220617192656192

常用的一些命令

1
2
3
4
5
6
//- Xms 设置初始化内存分配大小 1/64
//- Xmx 谈置最大分配’内存,默认1/4
//- XX :+ PrintGCDetails // 打印GC垃圾回收信息
//- XX :+HeapDumpOn0utOfMemoryError// oom DUMP

//-Xms1m-Xmx8m- XX :+ HeapDumpOnOutOfMemoryError

3. Hotpot虚拟机对象探秘

3.1 对象的创建

当Java遇到一个新的new指令的时候,它首先会去检查指令的参数能否在常量池中定位到 一个类的符号引用,并且检查这个符号所引用的类是否已经被加载,解析和初始化过,如果没有,则会先进行加载,接下来就会为这个新的对象分配内存,如果Java堆中的内存是规整的,那么分配内存的方法就是把指针向空闲空间的方向挪动一段距离,这种分配方式被称为:“指针碰撞”。如果内存不是规整的,那么就需要建立一个“空闲列表”,用来记录那些内存块分给了哪些对象。

​ 在分配内存的过程中,又会遇到一个问题,就是如果正在给对象A分配内存,但是对象B又同时使用了原来的指针来分配内存的情况。这种时候有两种可选择的方案–一种是堆分配内存的操作进行同步处理,第二种就是建立本地线程分配缓冲

​ 接下来,Java虚拟机还需要对对象进行一些必要的设置,比如这个对象是哪个类的实例,如何找回类的元数据信息,对象的哈希码,对象的GC年龄分代信息。最后执行构造函数等方法,一个完整的对象才被创建出来。

一个对象在内存中实例化的过程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class People{
String name; // 定义一个成员变量 name
int age; // 成员变量 age
Double height; // 成员变量 height
public void print(){
System.out.println("人的姓名:"+name);
System.out.println("人的年龄:"+age);
System.out.println("人的身高:"+height);
}

public static void main(String[] args) {
String name; // 定义一个局部变量 name
int age; // 局部变量 age
Double height; // 局部变量 height

People people = new People() ; //实例化对象people
people.name = "张三" ; //赋值
people.age = 18; //赋值
people.height = 180.0 ; //赋值
people.print(); //调用方法sing
}
}
  1. 在还没开始时

image-20220619102309453

  1. 开始执行,类中的成员变量和方法会先进入到方法区

image-20220619102608309

  1. main方法被压入栈中

image-20220619102825310

  1. 执行到Person person = new Person();会在栈中创建Person类的引用,在堆中存放实例化的对象,然后将成员变量和成员方法放在实例中(都是取得成员变量和成员方法的地址值)

    image-20220619103527954

    1. 接下来对 person 对象进行赋值, person.name = “张三” ; person.age = 13; person.height= 180.0;先在栈区找到 person,然后根据地址值找到 new Person() 进行赋值操作。

    image-20220619104355652

    1. 在方法体void ()被调用完成后,就会立刻马上从栈内弹出(出栈)最后,在main()函数完成后,main()函数也会出栈 如图:

      image-20220619104854834

3.2 对象的内存布局

Hotpot虚拟机对象的头部包含两类信息,第一类是存储对象本身运行时的数据,比如哈希码,GC年龄分代,锁状态标志,偏向线程ID,偏向时间戳等,这部分数据被官方称为"Mark word".如下图所示image-20220622112951852

还有一类就是类型指针,Java虚拟机通过这个指针来判断该对象是哪个类的实例,如果对象是数组,还会有一个数据来记录数组的长度。

3.3 对象的访问定位

简单的来说,访问主要有句柄访问和指针访问,直接使用指针访问的话,可以节省一次指针定位的时间,累积下来,也是比较可观的,在hotpot中,主要使用指针访问,而在各种语言和框架中,使用句柄访问也很常见

句柄访问

直接指针访问

4. 垃圾回收

4.1 什么是垃圾回收

GC(Garbage Collection )垃圾回收,分为轻量级垃圾回收和重量级垃圾回收 主要是在伊甸园区和年老区

jvm 在进行GC时,并不是对这三个区域统一回收 回收的都是年轻代

将对象分为三种状态:年轻代、年老代、持久代。JVM将堆内存划分为 Eden、Survivor 和 Tenured/Old 空间。

年轻代:所有新生成的对象首先都是放在Eden区。

年老代:在年轻代中经历了N(默认15)次垃圾回收后仍然存活的对象,就会被放到年老代中。

持久代:用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响。

Minor GC:

用于清理年轻代区域。Eden区满了就会触发一次Minor GC。清理无用对象,将有用对象复制到 Survivor1、Survivor2 区中(这两个区,大小空间也相同,同一时刻Survivor1和Survivor2 只有一个在用,一个为空)

Major GC:

用于清理老年代区域。

Full GC:

用于清理年轻代、年老代区域。 成本较高,会对系统性能产生影响。

垃圾回收过程(不是所有的收集器)

  • 新创建的对象,绝大多数都会存储在Eden中
  • 当Eden满了,不能创建新对象,则触发Minor GC,将无用对象清理掉,将剩余对象复制到某个Survivor(S1)中,同时清空Eden区
  • 当Eden再次满了,会将S1中不能清空的对象存到另一个Survivor(S2),同时将Eden中不能清空的复制到S1,保证Eden、S1均被清空
  • 重复15次Survivor中未被清理的对象,则复制到老年代区
  • 当年老代区满了,则会触发一个一次完整的垃圾回收(Full GC)
对象已死?
  1. 引用计数法

​ 在对象中添加一个引用计数器,在一个地方引用它的时候,计数器就加一,当引用失效的时候,计数器就减一,任何时候当计数器为零的时候,这个对象就是不可能再使用的,就坐等被回收吧

  1. 可达性分析

    这个算法的基本思路就是通过一系列称为“GC Roots” 的根对象作为起始节点,向下搜索,所搜走过的路径称为“引用链”,如果某个对象与Roots之间没有任何的链相连接,那么就称这个对象不可达,即对象已死。

4.2 GC 的算法

分代收集理论

这个理论建立在弱分代学说和强分代学说的基础之上,认为收集器应该将内存区域分为几个区域,然后依据其年龄分开进行存储,分别进行回收

标记清除法(Mark-Sweep)

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间

优点: 不需要额外的空间

缺点:两次扫描 严重浪费时间 会产生大量的内存碎片

image-20220704101326795

标记-复制算法(Mark-Copying)

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题,复制算法最佳的使用场景 对象存活度较低的时候 ,现在的商用Java虚拟机大多都优先采用了这个算法去收集新生代。

优缺点:没有内存碎片,浪费了一半的内存空间

image-20220704101610542

标记-整理算法(Mark-Compact)

也称标记-压缩法为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存. 压缩防止内存碎片产生 再次扫描 向一段一段存活的对象多了一次移动成本

image-20220704101709355

总结

内存效率 :复制算法 > 标记清除算法 > 标记压缩算法

内存整齐度 复制算法 = 标记压缩算法 > 标记清除算法

内存利用率 标记压缩算法 = 标记清除算法 > 复制算法

没有最好的算法

年轻代存活率低所以用复制算法

老年代存活率高用标记清除算法+标记压缩混合实现

4.3 垃圾回收的作用

垃圾回收是在内存中中存在没有引用的对象或超过作用域的对象时进行垃圾回收,垃圾回收的目的是识别并且丢弃不再使用的对象来释放和重用资源。查找和回收(清理)无用的对象。以便让JVM更有效的使用内存。

4.3 经典的垃圾收集器

Serial收集器

采用标记-复制算法。这个收集器是最基础,历史最悠久的收集器,看名字就知道,这是一个单线程的收集器,因为是单线程,他在进行垃圾收集的时候必须暂停其他所有的工作,直到它收集完毕,这个过程被称为"stop the world"(砸瓦鲁多!🤔),但是它现在仍然是hotpot虚拟机在客户端模式下的默认新生代收集器。

ParNew收集器

采用标记-复制算法。这个收集器是Serial收集器的多线程并行版本,它是不少运行在服务端的虚拟机首选新生代收集器(JDK7之前),很大的原因就是只有他才能跟CMS配合工作(老年代收集器),从JDK9开始,ParNew+CMS收集器就不再是官方最推荐的服务器端收集器组合了,因为G1出现了。

Parallel Scavenge收集器

同样是基于标记-复制算法实现的新生代多线程并发收集器,这个收集器主要关注的是达到一个可控制的吞吐量

image-20220704103520606

它也提供了一些参数用来控制吞吐量,这里不再详述,因为和吞吐量密切相关,因此也被称为“吞吐量优先收集器”

Serial Old 收集器

Serial收集器的老年代版本,一个采用标记-整理算法的单线程收集器。主要也是提供给客户端模式下的hotpot虚拟机使用。它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old 收集器

Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。用来和Parallel Scavenge收集器搭配,一起专注于吞吐量的组合。适合注重吞吐量和处理器资源稀缺的场景

CMS 收集器

全称Concurrent Mark Sweep收集器,基于标记-清除算法的老年代收集器,是以获取最短回收停顿时间为目标的收集器,但它不是简单的标记-清除。主要分为了 初识标记并发标记重新标记并发清除 四个步骤 ,这个收集器主要特点就是并发收集,低停顿。由于标记-清除算法很容易产生内存碎片,所以CMS收集器予以解决(JDK9后被废除)

Garbage First 收集器

G1是一款主要面向服务端应用的垃圾收集器。JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器。它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

4.4 低延迟垃圾收集器

5. 类文件结构

Class 文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据结构:“无符号数”和“表”。

5.1 魔数和Java版本

每个class版本的头四个字节被称为魔数,Class文件的魔数被早期的开发者取为“0xCAFEBABY”,紧接的四个字节存储的Java的版本信息,高版本的JDK能够兼容低版本的版本。

5.2 常量池

紧接着版本号之后,就是Java的常量池入口,由于常量池的数量大多是不固定的,因此,在常量池的入口需要放置一个u2类型的数据,代表常量池的容器计数器。常量池主要存放两大类常量,字面量和符号引用。常量池中每一项常量都是一个表,常量池中一共有17种数据类型的结构。

5.3 访问标志

在常量池结束后紧接的两个字节代表着访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括,这个class是类还是接口,是否是public,是否是abstract类,

5.4 类索引,父类索引与接口索引集合

Class文件中由这三项数据来确定该类型的继承关系,,类索引,父类索引和接口索引集合都按照顺序排列在访问标志之后。

5.5 字段表集合

字段表用于描述接口或者类声明中的变量,Java语言中的字段包括类级变量,以及实例级变量,但是不包括方法内部声明的局部变量,字段可以包括修饰符有字段的作用域,是实例变量还是类变量等一系列信息。值得注意的是,字段表中不会列出从父类或者父接口中继承而来的字段。

5.6 方法表集合

方法表中的结构如同字段表一样,依次包括访问标志,名称索引,描述符索引,属性表集合几项,而因为有一些关键字不能修饰方法,所以方法表里的访问标志少了一些标志。在Java语言中,要重载方法,除了需要与原方法具有相同的简单名称之外,还需要一个与原方法不同的特征签名,特征签名是值一个方法中各个参数在常量池中的字段符号引用的集合,正是因为返回值不包含在特征签名中,因此Java中的方法无法通过返回值的不同来进行重载。

5.7 属性表集合

为了正确解析Class文件,在早期的规范中,规定了虚拟机9种应当识别的属性,在Java SE 12的规范中,这个数目已经增加到了29种。

如下图:

image-20220908180421975

image-20220908180450216

6. 类加载机制

6.1 类加载的流程

一个类从被加载进虚拟机开始,到卸载出内存为止需要经历 加载 验证 准备 解析 初始化 使用 加载 几个阶段,其中详细的环节这里就不展开介绍了。

6.2 类加载器

前面已经说过类加载器一共有四类

  • 自定义加载器
  • 启动类(根)加载器(使用C++)
  • 扩展类加载器
  • 应用程序加载器

6.3 双亲委派机制

主要的作用就是保证安全,一般来说一个类启动的时候会按照这个顺序(APP-- EXC --BOOT)寻找加载器,而最终执行的加载器则是从上到下的,BOOT-> EXC->APP,只有没有找到上面一层的类加载器的时候,才会执行下一层的加载器。.从最内层JVM自带的类加载器开始加载,外层恶意同名类得不到加载从而无法调用

  1. 类加载器收到类的加载的请求
  2. 将这个请求向上委托给父类加载器去完成,一直向上委托,直到启动类加载器
  3. 启动加载器检查是否能够加载当前这个类,能加载就结束,使用当前的加载器,否则,抛出异常,通知自家在其进行加载
  4. 重复步骤3

如下图所示:

image-20220909102404711

6.4 沙箱安全机制

沙箱机制就是讲Java代码限定在虚拟机JVM特定的运行范围中,并且严格限制代码对本地资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。通俗来说就是虚拟机把代码加载到拥有不同权限的域里,然后代码就拥有了该域的所有权限。这样就能控制不同代码拥有不同调用操作系统和本地资源的权限

组成沙箱的基本组件:
1.字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范。可以帮助Java程序实现内存保护 。核心类不经过字节码校验
2.类装载器:其中类装载器在3个方面对Java沙箱起作用

防止恶意代码干涉善意代码(双亲委派机制)
守护被信任的类库边界
它将代码归入保护域,确定了代码可以进行哪些操作

7. JMM

Java Mermory Model

(待完善)


JVM
http://example.com/2022/06/16/JVM/
作者
Mercury
发布于
2022年6月16日
许可协议