【Java SE】认识JVM:内存区域划分、类加载机制与GC

文章目录

一、JVM基础与内存划分

1.1 什么是JVM

JVM全称Java Virtual Machine,Java虚拟机,是一套软件模拟的虚拟计算机 ,隔离底层操作系统,实现Java 一次编译,到处运行

1.2 JVM完整执行流程

Java程序从.java到CPU执行分为四个模块协同工作:

  1. 类加载子系统 ClassLoader:读取class字节码文件,加载进内存;
  2. 运行时数据区 Runtime Data Area:存放程序运行期间所有内存数据;
  3. 执行引擎 Execution Engine:解析/编译字节码,转为操作系统指令;
  4. 本地接口 Native Interface:调用C/C++本地库,完成底层操作。

完整流程:

.java编译→.class字节码文件 → 类加载器加载至运行时数据区 → 执行引擎解析字节码 → 调用本地方法接口操作系统资源,CPU执行。

1.3 内存区域划分

在真实的操作系统中,对于进程的地址空间进行了功能区域划分,JVM仿照真实的机器和真实的操作系统进行设计。

JVM从操作系统中申请到了一些内存空间,供JVM进程的代码使用。

JVM内存空间的划分就是把JVM自身申请到的内存空间,再把内存按不同功能进行划分。类比把一栋大楼分成若干层,每一层细致划分。

核心区域

1. 程序计数器:记录当前线程执行字节码行号,执行Java方法存指令地址

这里的程序寄存器指内存上的空间,与计组中的程序寄存器不同,计组中的程序寄存器是在CPU上的

  • 唯一特性:JVM规范中唯一不会发生OOM的内存区域;
  • 线程私有:多线程切换时依靠PC寄存器恢复执行位置。

2. 元数据区 :保存当前类被加载好的数据

.java文件经过javac编译变成.class文件,想要运行这个文件,就需要把字节码文件加载到内存中,也就是元数据区。

元信息就是指属性,例如类名称,访问权限,继承自哪些类或实现了哪些接口;方法名称,参数,返回值等。

Java8之前元数据区也称为"方法区"

3. 栈 :保存方法调用关系

每次调用方法时都会进入方法内部,当方法调用完毕,返回调用位置,程序继续向后执行。这里的栈与数据结构中的栈逻辑类似,都具有后进先出的特性。

  • 与操作系统中的栈联系:
    JVM进程是C++代码编写的程序,本身就是存在一系列C++中的方法调用。维护这些C++方法的栈就是操作系统原生的栈。这些方法调用构成了JVM虚拟机程序。通过这些C++代码,解释执行.class文件中的字节码,这个过程又涉及到了Java方法调用,就构造出了Java的栈

栈区的两种异常:

  • StackOverflow:栈所占用的空间只有几十MB,如果递归函数出口逻辑错误,就有可能导致大量方法调用但是不返回,栈深度超出范围,造成栈溢出。
  • OOM:多线程大量创建线程,虚拟机无法分配栈内存。

4. 堆:保存对象实例,是JVM中最大的空间区域

假设创建一个对象实例:Test t = new Test()

  • 如果t是局部变量,t保存在栈中,函数调用完毕就会销毁
  • 如果t是成员变量,t保存在堆中
  • 如果t是静态成员变量,t保存在元数据区,随类加载创建
  • new Test()一定保存在堆上

如果堆上的对象不再使用就会被释放,也就是垃圾回收的工作。

元数据区和堆,整个Java进程共用一份,程序计数器和栈一个进程中可能有多份,每个线程各一份。

二、类加载机制

2.1 类完整生命周期

加载 → 连接(验证、准备、解析) → 初始化 → 使用 → 卸载

前3阶段合称类加载过程

  1. 加载 Loading
    1. 找到.class文件,根据类的全限定名(包名+类名,例如java.lang.String
    2. 打开文件,读取文件内容到内存中
  2. 验证 Verification:解析,校验.class文件合规性,防止恶意字节码破坏虚拟机,并把.class里的内容转换为结构化的数据(也就是符合虚拟机规范的格式);
  3. 准备 Preparation:给类对象申请内存空间,此时的空间相当于"全0"的状态;
  4. 解析 Resolution:初始化字符串常量。字符串常量本身就包含在.class中,解析阶段就要把解析出的字符串常量放到内存中(元数据区的常量池);
  5. 初始化 Initialization:针对类对象进行最后的初始化。为类对象各种属性进行填充,包括静态成员变量。如果这个类有父类,也会触发父类的加载。

一个进程中一个类的加载只会触发一次,并不是Java程序一启动就会加载所有的类,类加载也是懒汉模式(懒加载)的一种体现。

触发类加载的时机:

  1. 构造这个类的实例
  2. 调用类的静态属性或静态方法
  3. 使用某个类时,如果父类还没加载,也会触发父类的加载

2.2 双亲委派模型

类加载器

JVM中有专门的模块负责类加载,默认提供了三种类加载器:

  1. BootstrapClassLoader
  2. ExtensionClassLoader
  3. AplicationClassLoader

AplicationClassLoader中的parent引用指向ExtensionClassLoaderExtensionClassLoader中的parent引用指向BootstrapClassLoaderBootstrapClassLoader中的parent引用指向null,构成了类似链表关系。

这三个类加载器主要作用都是找.class文件,各自负责找的目录不同:

  • BootstrapClassLoader负责找Java标准库中的类
  • ExtensionClassLoader负责找Java扩展库中的类。扩展可以认为JVM厂商对于Java的库做的扩充
  • AplicationClassLoader负责找第三方库/当前项目。(当前学习过程中,通过Maven下载的类都属于第三方库)

双亲委派模型过程

  1. 进行类加载时,通过全限定名找.class文件时,就会从 AplicationClassLoader作为入口开始,把"加载类"这样的任务委托给它的父亲ExtensionClassLoader来执行
  2. ExtensionClassLoader也不会立即干活,先把任务委托给它的父亲AplicationClassLoader来执行
  3. AplicationClassLoader也想踢足球,但是它的parent为空,只能自己进行类加载,从标准库中找是否匹配的.class文件。如果没找到就把任务还给孩子ExtensionClassLoader
  4. ExtensionClassLoader在扩展库中继续找.class文件,找到就加载,没找到就继续还给孩子 AplicationClassLoader
  5. AplicationClassLoader继续负责找.class文件,找到就加载;没找到就抛出ClassNotFount异常

也可以自定义类加载器,参与到双亲委派模型中

  • 双亲委派模型两大优势
    • 避免类重复加载;
    • 安全防护:防止开发者自定义java.lang.Object篡改核心API。

三、垃圾回收GC原理

GC仅关注堆、方法区,虚拟机栈、本地方法栈、程序计数器生命周期随线程自动释放,无需回收。

3.1 GC工作流程

GC主要做两个工作:

  1. 找到垃圾(不再使用的对象)
  2. 释放垃圾(释放对应的内存)

1. 找到垃圾

(1)引用计数算法

每个对象在创建的的时候,就会分配一小块内存空间,保存一个整数,表示有多少个引用指向它,每次进行引用赋值的时候,都会触发引用计数的修改。

在Java中,使用对象一定要通过引用实现,当引用计数为0时,说明没有引用指向这个对象了,这样就可以判断这个对象就是垃圾

缺陷:

  1. 内存消耗更多,尤其对象本身较小时,引用计数消耗的占比就更大
  2. 可能出现循环引用

举个例子:

java 复制代码
class Test{
	Test t = null;
}

class Main{
 public static void main(String[] args){
 	Test a = new Test();//计数器+1
 	Test b = new Test();//计数器+1

	a.t = b;//计数器+1
	b.t = a;//计数器+1
 }
}

此时两个对象实例各有两个引用指向

如果把a和b都置为null

程序计数器各-1,不为0,但是都不能被调用。

PHP,Python都采用计数算法,搭配其他方案解决循环引用问题

(2)可达性分析

以代码中特定的对象作为遍历的起点,也称为GCRoots,进行遍历,来判定某个对象是否能被访问到到。每遍历一个对象,就标记为"可达",剩余没有被标记的就认为是不可达,就是接下来要回收的垃圾了。

可作为GC Roots的对象:

  1. 栈上的局部变量(引用类型)
  2. 常量池中引用指向的对象
  3. 静态成员(引用类型)

2. 释放垃圾

垃圾清除有四种算法:

  1. 标记-清除 :把垃圾对象的内存直接进行释放

    缺陷:会产生"内存碎片问题",很难找到大块的连续的内存空间

  2. 复制算法 :把内存分为两部分,起始都保存在A区域;当垃圾回收时,把不释放的数据复制到B区域,再把A区域整体释放。这样就确保空闲内存都是连续的

    缺陷:内存空间利用率低,直接砍半;如果不需要释放的数据很多,复制开销很大

  3. 标记-整理 :类似于顺序表删除,把不需要释放的数据重新搬运整理

    缺陷:内存搬运数据的操作开销也很大,只解决了空间成本问题。

  4. 分代收集:综合前三种方案。"代"指的是对象的年龄,单位是GC的轮次。

    1. 每个对象初始年龄是0,每经过一轮GC可达性分析,不是垃圾的对象年龄+1
    2. 根据年龄,把对象分为新生代和老年代。经过统计发现,如果一个对象是新生儿,大概率死的很快;如果一个对象是老油条,大概率能活很长时间。
    3. 对象在新生代反复被GC筛选,年龄增长,进入老年代。

堆区域划分

  • 新生代 Young:新建对象存放,98%对象朝生夕死。新生代进一步划分:
    • Eden 伊甸园:80%新生代,是所有新对象诞生地,由伊甸园到幸存区采用复制算法,因为绝大部分对象都活不过一轮GC,复制对象少,开销可控;
    • S0/S1 Survivor幸存者:各10%。幸存区的对象也要经历GC扫描,每一轮GC消灭一大批对象,然后通过复制算法把存活的对象复制到另一个幸存区;如果一个对象经过多次复制,都存活下来,就可以晋升到老年代了;
  • 老年代 Old:长期存活对象、大对象。GC频次比较低,采用标记-整理算法;

一个对象经历的区域:伊甸园 -> 幸存区->幸存区->...->幸存区->老年代 新生代统一采用复制算法;老年代采用标记整理

特殊情况:如果某个对象很大,复制成本很高,可以跳过新生代,进入老年代

3.2 垃圾收集器

在JVM中,由垃圾回收器来实现上述分代回收的策略。

分代回收只是最基本的策略,落实到垃圾收集器上,还有更特殊和进阶的策略。

收集器 分代 特点 使用场景
Serial 新生代 单线程串行,STW Client客户端、小内存程序
ParNew 新生代 Serial多线程版,唯一可配合CMS 老年代CMS搭配使用
Parallel Scavenge 新生代 吞吐量优先,自适应调参 后台计算、无交互服务
Serial Old 老年代 单线程标记整理 Client、CMS失败后备方案
Parallel Old 老年代 多线程吞吐量优先 高吞吐离线任务
CMS 老年代 并发低停顿,标记清除,碎片多 JDK8前互联网低延迟服务
G1 全堆分区Region 可预测停顿,自动压缩,替代CMS 大内存服务端,JDK9默认
相关推荐
小园子的小菜1 小时前
Java核心基础:反射与泛型底层原理及实战用法详解
java·开发语言
淘源码A1 小时前
多院区集团化云PACS系统源码,原生兼容国产软硬件环境
java·云计算·源码·saas·pacs·医学影像系统
何中应1 小时前
主流 OpenJDK 发行版下载安装
java·oracle·jdk·openjdk
daad7771 小时前
记录vscode连接matlab启动环境搭建
java·vscode·matlab
十五年专注C++开发1 小时前
C++之std::numeric_limits介绍
开发语言·c++·limits
2501_925963381 小时前
Hi3516 + WS73 SDIO Wi-Fi 适配笔记
java·开发语言
CoderYanger1 小时前
视频切割脚本(Python版)
linux·开发语言·windows·后端·python·程序人生·职场和发展
辞旧 lekkk1 小时前
CMake工程指南(一)
linux·运维·服务器·开发语言·qt·学习·萌新
不会就选b2 小时前
Linux之进程(七)--虚拟地址空间
jvm
Wang's Blog2 小时前
Java框架快速入门:深入MyBatis-Plus高级DML操作(ID策略·逻辑删除·乐观锁)
java·数据库·mybatis