理解JVM

JVM简介

JVM 是 Java Virtual Machine 的简称,意为 Java 虚拟机
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统
常见的虚拟机: JVM 、 VMwave 、 Virtual Box
JVM 和其他两个虚拟机的区别:

  1. VMwave 与 VirtualBox 是通过软件模拟物理 CPU 的指令集,物理系统中会有很多的寄存器
  2. JVM 则是通过软件模拟 Java 字节码的指令集, JVM 中只是主要保留了 PC寄存器,其他的寄存器都进行了裁剪
    JVM 是一台被定制过的现实当中不存在的计算机

1)内存区域划分

执行流程

程序在执行之前先要把 java 代码转换成字节码( class 文件), JVM 首先需要把字节码通过一定的方式类加载器( ClassLoader ) 把文件加载到内存中 运行时数据区( Runtime Data Area ),而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执 行引擎( Execution Engine ) 将字节码翻译成底层系统指令再交由 CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口( Native Interface ) 来实现整个程序的功能,这就是这 4个主要组成部分的职责与功能
总结来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:

  1. 类加载器( ClassLoader )
  2. 运行时数据区( Runtime Data Area )
  3. 执行引擎( Execution Engine )
  4. 本地库接口(Native Interface)

运行时数据区

一个运行起来的Java进程,就是一个JVM虚拟机,需要从内存中申请一块内存
JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型( (Java Memory Model,简称JMM )完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:

方法区(1.7及以前)/元数据区(1.8开始)

存储类对象(类信息、常量、静态变量,及、即编译器编译后的数据)

.class文件加载到内存后

运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用
字面量 : 字符串 (JDK 8 移动到堆中 ) 、 final 常量、基本数据类型的值
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述
堆
存储代码中 new 的对象(占据内存最大的区域)
栈
存储代码执行过程中,方法之间的调用关系

每个元素,称为一个"栈帧"
每个栈帧,代表了一个方法调用
栈帧里包含了方法的入口,方法返回的位置,方法的形参,方法的返回值,局部变量...
程序计数器

一块比较小的空间,主要用来存放一个"地址",表示下一条要执行的指令在内存中的哪个地方(方法区里,每个方法里面的指令,都是以二进制的形式保存到对应类对象里)

class Test{

public void a(){}

public void b(){}

}

方法a 和方法b会被编译成二进制指令,放到 .class 文件中

执行类加载的时候,就把 .class 文件里的内容,加载起来,放到类对象中

刚开始调用方法,程序计数器,记录的就是方法的入口的地址

随着一条一条的执行指令,每执行一条,程序计数器的值都会自动更新去指向下一条指令

如果当前线程正在执行的是一个 Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是一个 Native 方法,这个计数器值为空
本地方法栈

指的是使用 native 关键字修饰的方法

这个方法不用Java实现,而是在JVM内部通过C++代码实现的

JVM内部的C++代码的调用关系

总结:

1)虚拟机栈,程序计数器,本地方法栈,都是每个线程都有一份

2)堆区,元数据区,在JVM进程中只有一份

一个JVM进程,可能有多个线程

每个线程,有自己的 程序计数器 和 栈空间,这些线程共用一份 堆 和 方法区

可以说是,每个线程都有自己的私有空间
1)堆 存放new出来的对象

2)方法区/元数据区 存放类对象(类加载后存放的位置)

3)栈 存放方法之间的调用关系

4)程序计数器 存放每个线程,下一条要执行的指令的地址

1)2)整个Java进程共用一份 3)4)每个线程都有自己的一份

常见问题

class Test{

public int n = 100;

public static int a = 10;

}

void main(){

Test t = new Test();

} n,a,t ,new Test() 处于哪个区域

Test t = new Test();

t这个变量是一个引用类型的变量,存储的是一个对象的地址,不是对象本身

new出来的对象在堆上,同时有创建了一个局部变量 Test t (引用类型的变量),把地址存到 t 里

一个变量处于哪个区域,和变量的形态密切相关

局部变量 处于 栈 上

成员变量 处于 堆 上

静态变量(类属性) 处于 元数据区/方法区 里

2)类加载的过程

基本流程

java代码会被编译成.class文件(包含一些字节码),java程序要想运行起来,需要让JVM读取到这些.class文件,并把里面的内容构造成类对象,保存到内存的方法去中

"执行代码"就是调用方法,需要先知道每个方法,编译后生成的指令都是什么

官方文档把类加载的过程,主要分成了五个步骤(三个步骤):

  1. 加载
  2. 连接 (1.验证 2.准备 3. 解析)
  3. 初始化

1)加载
找到.class文件,打开文件,读取文件内容
往往代码中,会给定某个类的"全限定类名",例如
java.lang.String java.util.ArrayList ...
JVM会根据这个类名,在一些指定的目录范围内查找
" 加载 " ( Loading )阶段是整个 " 类加载 " ( Class Loading )过程中的一个阶段,它和类加载 Class Loading 是不同的,一个是加载 Loading 另一个是类加载 Class Loading ,所以不要把二者搞混了
在加载 Loading 阶段, Java 虚拟机需要完成以下三件事情:
1 )通过一个类的全限定名来获取定义此类的二进制字节流
2 )将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3 )在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
2)验证
验证是连接阶段的第一步

.class文件是一个二进制的格式(某个字节具有特定的含义),需要验证当前读取的这个格式是否符合要求
3)准备

给类对象分配内存空间(最终目标是构造出类对象)

此处只是分配内存空间,并没有进行初始化,这个空间上的内存的值全都是0值

(此时类的 static 成员全是0值)
4)解析

针对类对象中包含的字符串常量进行处理,进行一些初始化操作

java代码中用到的字符串常量,在编译之后,也会进入到.class文件中
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程

final String s = "hello";

此时,.class文件的二进制指令中,会有一个 s 这样的引用被创建出来

由于引用本质上保存的是一个变量的地址,在.class文件中(文件中不涉及到内存地址)

因此,在.class文件中,s 的初始化语句,就会先被设置成一个"文件的偏移量",通过偏移量,就能找到"hello"这个字符串所在的位置

当这个类真正被加载到内存中的时候,再把这个偏移量,替换回真正的内存地址

在.class文件中会有一条指令,这条指令就描述了 String s = @100(偏移量)

"hello"字符串距离文件开头的长度是100字节

把字符串的真实地址,替换成文件偏移量的过程,就是"解析阶段"的主要工作

也叫做,把"符号引用"(文件偏移量)替换成"直接引用"(内存地址)
5)初始化
初始化阶段, Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程
针对类对象进行初始化,把类对象中需要的属性设置好------>
初始化static成员,执行静态代码块,加载父类...

双亲委派模型

站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器( Bootstrap
ClassLoader ),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader

属于类加载中,第一个步骤 "加载"过程中的一个环节
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
此处 "父子" 关系,不是继承关系
这几个 ClassLoader 里面有一个 parent 属性,指向了一个 "父加载器"
类加载器,JVM内置了三个类加载器:

1) BootStrap ClassLoader

负责的是标准库的目录

2) Extension ClassLoader

负责的是JDK中一些扩展的库

3) Application ClassLoader

负责的是搜索项目当前目录和第三方库对应目录

程序员也可以自己手动创造新的类加载器
类加载的过程(找.class文件的过程):

1)给定一个类的全限定类名,例如 java.lang.String

2)从 Application ClassLoader 作为入口,开始执行查找的逻辑

3)Application ClassLoader ,不会立即去扫描自己负责的目录,而是把查找的任务,交给它的父加载器 Extension ClassLoader

4)Extension ClassLoader,也不会立即扫描自己负责的目录,而是把查找的任务,交给它的父加载器 BootStrap ClassLoader

5)BootStrap ClassLoader,也不会立即扫描自己负责的目录,也想把它交给自己的父加载器,结果发现它自己没有父加载器。因此,BootStrap ClassLoader只能扫描自己负责的目录

6)没有扫描到,就会回到 Extension ClassLoader,Extension ClassLoader就会扫描自己负责的目录。如果找到,就执行后续的类加载操作,此时查找过程结束;如果没找到,把任务交给 Application ClassLoader 执行

7)没有扫描到,就会返回到 Application ClassLoader,Application ClassLoader 会扫描自己负责的目录。如果找到,就执行后续的类加载操作;如果没找到,就会抛出一个 ClassNotFoundException
双亲委派模型,就是一个 查找优先级 问题------>

确保 标准库的类,被加载的优先级最高,其次是 扩展库,其次是 自己写的库和第三方库

(若自己实现了一个 java.lang.String,JVM加载的是标准库的类)
双亲委派模型的打破:

如果自己编写一个类加载器,就不一定要遵循上述的流程

例如Tomcat里,加载 webapp 时用的就是自定义的类加载器

只能在 webapp 指定的目录中查找,找不到就直接抛异常(不会去标准库中查找)

优点

  1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了
  2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模
    型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
    类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户
    自己提供的因此安全性就不能得到保证了

3)垃圾回收机制

对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此此处所讲的有关内存分配和回收关注的为 Java 堆 与 方法区 这两个区域
在 Java 中,所有的对象都是要存在内存中的(也可以说内存中存储的是一个个对象),因此我们将内存回收,也可以叫做死亡对象的回收

GC => 垃圾回收
GC回收的目标,是内存中的对象
对于Java来说,就是new出来的对象
栈里的局部变量,是跟着栈帧的生命周期走的(方法执行结束,栈帧销毁,内存释放)
静态变量,生命周期就是整个程序,始终存在,意味着静态变量是无需释放的
真正要需要GC释放的 堆 上的对象

垃圾的判断算法

在GC中,有如下两个主流的方案:

  1. 引用计数(Python,PHP)

new出来的对象,单独安排一块空间,来保存一个计数器

{

Test t = new Test();

Test t2 = t1;

}

出{}之后,t1和t2就销毁了,引用计数就为0了

保存引用计数,描述有这个对象有几个引用指向它

在Java中,使用对象,必须依靠引用

如果一个对象,没有使用引用指向,就可以视为 垃圾 了(引用计数为0)

缺点:

1)比较浪费内存

如果对象很小,计数器占据的空间就难以忽视了

2)存在"循环引用"问题

class Test{

public Test t;

}

Test a = new Test();

Test b = new Test();

a.t = b;

b.t = a;

a = null;

b = null;

此时,a 和 b两个引用已经销毁了

new出来的两个对象,无法被其他的代码访问到,但是它们的引用计数不是0,所以不能被回收

此时,第一个对象,引用了第二个对象;第二个对象,引用了第一个对象------>类似死锁

  1. 可达性分析

本质上是时间换空间

有一个/一组线程,周期性的扫描代码中的对象

从一些特定的对象出发,尽可能的进行访问的遍历,把所有能访问到的对象,都标记成"可达";反之,经扫描后,未被标记到的对象,就是 垃圾

此处的遍历大概率是N叉树,看访问的某个对象,里面有多少个引用类型的成员,针对每个引用类型的成员都需要进行进一步的便利

此处的可达性分析都是周期性的------>可达性分析比较消耗系统资源,开销比较大

垃圾回收算法

三种基本思路

1)标记清除

总的空闲空间是 2MB,但是申请空间时,只能申请 <= 1MB的空间

把对应的对象,直接释放掉------>会产生很多"碎片"

释放内存,目的是让别的代码能够申请

申请内存,都是申请到"连续"的内存

2)复制算法

把内存分成两份,一次只用其中的一半

通过复制的方式,把有效对象归类到一起,再统一释放剩下的空间

这个方案可以解决内存碎片的问题,但缺点依然很明显:

  1. 内存要浪费一半,利用率不高

  2. 如果有效对象很多,拷贝开销很大

3)标记整理

既能解决内存碎片的问题,又能处理重复算法中利用率问题

类似于顺序表删除元素的搬运操作------>搬运的开销依然很大

具体实现

伊甸区 存放刚 new 出来的对象

经验规律:从对象诞生开始,到第一轮可达性分析的过程中,虽然时间不长(往往就是毫秒---秒),但是,在这个时间段内,大部分的对象都会变成垃圾

1)伊甸区--->幸存区 复制算法

每一轮GC扫描之后,都会把有效对象复制到幸存区中,伊甸区就可以整个释放了

由于经验规律,真正需要复制的对象不多,非常适合复制算法

幸存区 分成大小相等的两块,每次只用一块(复制算法的体现)

2)GC扫描线程也会扫描幸存区,会把活过GC扫描的对象(扫描过程中可达)拷贝到幸存区的另一个部分

幸存区之间的拷贝,每一轮会拷贝多个对象,每一轮也会淘汰多个对象(有些对象随着时间的推移,就成垃圾了)

3)当这个对象已经在幸存区存活过很多轮GC扫描后,JVM就认为这个对象,短时间内应该是释放不掉了,就会把这个对象拷贝到 老年代

4)进入老年代的对象,也会被GC扫描,但是频率会比新生代低得多------>减少GC扫描的开销

经验规律,新生代的对象更容易成为垃圾,老年代的对象更容易存活

分代回收------> 对象活过的GC扫描越多,就越老
新生代,主要使用 复制算法
老年代,主要使用 标记整理
分代回收,是JVM中主要的回收的思想方法
但是在垃圾回收具体实现的时候,可能会有一些调整和优化

相关推荐
小羊没烦恼!5 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
俊昭喜喜里5 天前
java中的继承和多态的区别
java
小羊没烦恼!5 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
譕痕5 天前
JSONObject与JSONArray封装数据格式区别
java·json
胡写代码5 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖5 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
此时不提桶,更待何时5 天前
01-06-A-JVM排查实战详解
java·jvm
vipxieliang5 天前
ValidX 在 DDD 领域驱动设计中的实践
java·spring boot
ba_pi5 天前
mysql 查询所有表名并授权
java
ProcessOn官方账号5 天前
java函数式编程--入门基础
java·编程·函数式编程