耄耄带你深入理解JVM虚拟机

耄耄带你深入理解JVM虚拟机

JVM(Java虚拟机)是运行Java字节码的虚拟计算机环境,它提供了一个平台无关的运行环境,使得Java程序能够在任何平台上运行而无需重新编写。JVM的核心功能包括加载代码、验证代码、执行代码、以及提供运行时环境。

本文将详细介绍JVM相关结构,主要包括JVM内存模型、类加载器、双亲委派机制、JMM、垃圾回收算法

[toc]

JVM内存模型

Java代码运行时,new出来的对象存在哪呢;方法执行的时候,方法存在哪;方法里面的参数、局部变量存在哪;一个类的元数据信息,也就是接口、父类、类的属性、函数等存在哪;代码种各种分支循环函数调用跳来跳去,JVM如何知道代码执行到哪一行;要理解这些东西,你就得了解JVM内存结构。

JVM内存结构主要划分为以下几个部分:堆、方法区、虚拟机栈、本地方法栈、程序计数器

image-20260305201555955

虚拟机栈

首先来说一下虚拟机栈,虚拟机栈本质上就是一个栈,代码执行需要调用方法,调用方法就需要将方法的信息封装成一个栈帧;然后将栈帧放入虚拟机栈,方法执行完后就出栈;这样对应的方法执行顺序就是后进先出

image-20260305201623761

所以我们就可以知道,虚拟机栈的本质就是用来管理方法执行的,那么问题来了,方法里面有很多东西啊,比如局部变量、入参、出参、参数传递,这些东西放在哪里呢?

这里就可以详细说一下栈帧的结构了,栈帧里面有局部变量表(保存局部变量)、操作数栈(算数运算和方法调用的一个参数传递),栈帧的其他东西不需要鸟姐太多,浅尝辄止即可。

那么问题又来了,虚拟机栈需要进行GC吗

显然不需要,因为虚拟机栈的结构是一个栈,方法执行完后就出栈了,纯自动的不需要GC来进行垃圾回收。

本地方法栈

image-20260304115750254

要理解本地方法栈,我们就要知道什么是本地方法,所谓本地方法,就是底层C或C++语言写的方法,Java代码执行的时候要调用底层C或C++的方法,那这些方法执行的时候,就需要放到本地方法栈

程序计数器

image-20260305201521886

因为代码中有分支循环、方法调用;执行的时候从这个类跳到那个类,从这一行跳到哪一行;跳来跳去,所以就需要用程序计数器来进行保存即将执行的字节码指令的位置,保存位置之后,我们就知道下一步要执行到什么代码了。


以上介绍的虚拟机栈、本地方法栈、程序计数器,这些都是线程私有的,那么问题来了,这些东西为什么要线程私有

不同线程是可以并发执行的,A线程调用A方法,B线程调用B方法,肯定需要不同的虚拟机栈来保存正在执行的方法

本地方法栈也是同理的;

那么程序计数器也可以参照以上来理解,不同线程执行的位置肯定是不一样的,方法执行到哪一行也是不一样的,所以需要不同的程序计数器去记录这个线程他执行到哪里了

image-20260305202341228

是用来存放对象的,几乎所有new出来的对象都存放到堆上。

对象放在堆里,那么基本类型放在栈帧的操作数栈或局部变量表里面。

问题来了:为什么要把对象放在堆里,基本类型放在栈里

首先,基本类型是比较小的,放在栈里方便创建和销毁,放在堆里会增加GC的一个压力;而且基本类型不太有可能会被线程共享,共享的话复制一份开销也不会太大;对象它比较大,需要去跨方法、跨线程甚至是跨GC周期去存活,对象放到堆里,只需要给一个引用地址就可以进行访问;假如对象放到栈里,就会失去共享能力,如果要共享就需要深拷贝一份;所以说对象放堆里,基本类型放栈里

问题又来了:所有的对象都会放到堆里吗?

JVM会对对象进行一个栈上分配,简单来说就是做逃逸分析,分析这个对象是不是只在方法内部使用,没有作为返回值或参数传递给其他对象使用,如果这个对象完全没有给到外边也就是完全没有逃逸,JVM就会将这个对象给拆散,根据对象内部属性拆成一个个基本类型然后在栈上进行分配。

方法区

image-20260305204257771

方法区里面放的不是方法,而是类的元数据信息,也就是类的全类名,父类字段信息、类的方法信息、类的静态变量等等。

方法区是JVM的一个规范,不同JVM对方法区的实现是不同的;

  • JDK1.8之前,方法区这个实现叫做永久代,永久代是JVM内存里面的,是运行时数据区的一部分
  • JDK1.8之后,方法区就变成了元空间,放在本地内存里面,也就是操作系统内存

为什么要把永久代换成元空间,放到本地内存里面?

因为永久代占用JVM内存,JVM内存比较小,加载得过多就容易OOM;元空间依赖操作系统内存,加载多少类的元数据信息就由实际可用的空间来配置,能加载的类就更多了,不容易OOM


堆是存对象的,不同的线程需要去使用,所以堆肯定是线程共享的。

方法区放的是类的元数据信息,类的元数据信息就是用来new对象的,一个元数据信息多个地方要用来new对象,那么肯定是线程共享的。


类加载器

类加载器是JVM用来把.class字节码文件加载到内存、转换成Clss对象的组件。它最核心的两个职责:

  • 一是动态加载,程序运行时按需加载类,不用编译期就把所有类都塞进来;
  • 二是隔离命名空间,不同类加载器加载的同名类互不干扰,Tomcat能同时跑多个版本相同依赖的Web应用就靠这个。
image-20260306163223301

JDK8的三种类加载器

JDK8有三层类加载器:

  1. 启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,主要负责加载lib目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件,它是所有类加载器的父亲。
  2. 扩展类加载器(Extension ClassLoader)),它是Java实现的,独立于虚拟机,主要负责加载lib\ext目录中或被java.ext.dirs系统变量所指定的路径的类库。
  3. 应用程序类加载器(Application ClassLoader),它是Java实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这个加载器就是我们程序中的默认加载器。

JDK9模块化后的变化

JDK9引入了模块化系统Jigsaw,原来的rt,jar、tool,jar被拆成了几十个jmod文件。既然已经满足可扩展需求,就没必要保留<JAVA_HOME>/1ib/ext这个目录了,所以扩展类加载器被重命名为平台类加载器(PlatformClassLoader),主要加载被module-info,java中定义的类。

Java的类加载过程

类加载就是把.class文件的二进制数据读进内存,经过校验、转换,最终变成VM能用的Class对象。 二进制流不一定非得来自.class文件,也可以是字节码工具动态生成的、或者从网络传过来的,只要格式对,JVM都认。 整个类加载流程分为三大阶段:加载、连接、初始化。连接又能拆成验证、准备、解析三步,所以细分下来是5个阶段:

  1. 加载:把二进制流读进内存,在方法区生成类的运行时数据结构,同时在堆里创建一个Clss对象作为访问入口。
  2. 验证:校验二进制流是否符合Clss文件规范,包括魔数检查、版本号校验、元数据验证、字节码验证、符号引用验证。这一步是为了防止恶意代码搞崩JVM。
  3. 准备:给类变量(static修饰的变量)分配内存并设置初始零值。注意这里只是零值,比如static int a-123在准备阶段a的值是0,不是123。但如果是static final inta = 123,编译期就确定了,准备阶段直接赋值123。
  4. 解析:把常量池里的符号引l用替换成直接引l用。符号引用就是一个字符串形式的标识,比如java/lang/Object;直接引用是真正的内存地址或偏移量,能直接定位到目标。
  5. 初始化:执行类构造器()方法,这时候才真正执行static int a=123这种赋值操作,静态代码块也是在这个阶段跑的。

双亲委派机制

前面说了Java类加载器,那么现在就能详细聊一聊双亲委派机制

双亲委派其实可以理解为类逐级加载的一个规则,就是先看看上层的类加载器能不能加载;简单来说:儿子在做类加载的时候,先去看看它爹能不能加载,它爹再去看它爷爷能不能加载;它爷爷能加载就让爷爷加载,它爷爷不能加载就让它父亲进行加载,它父亲如果不能加载就自己加载;这个过程就叫做委派,也就是逐级加载。

这样做有什么好处呢?

  1. 避免重复加载:逐级加载能避免两个类加载器加载同一个类。
  2. 避免Java的核心API被篡改:在逐级加载的模式下,核心API肯定能被顶层的类加载器所加载。

打破双亲委派模型

那么设想一个场景,如果我有一个目录,需要这个目录下的类有先被加载,不想去走逐级加载,应该怎么做呢?这就是所谓的打破双亲委派模型

打破双亲委派的核心就是重写loadClass()方法,在里面区优先加载某个目录的类:

java
复制代码
@Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先检查该类是否已经被加载过 Class<?> c = findLoadedClass(name); if (c == null) { // 对指定包优先自己加载,其他仍走双亲委派 if (name.startsWith("com.example.plugin")) { try { // 自定义加载逻辑,查找并定义类 c = findClass(name); } catch (ClassNotFoundException ignore) { // 兜底给父加载器,捕获异常不做处理 } } // 如果自定义加载未成功,委托父类加载器(双亲委派) if (c == null) { c = super.loadClass(name, false); // 委托父加载器 } } // 如果需要解析,对类进行解析处理 if (resolve) { resolveClass(c); } return c; } }

为什么需要打破双亲委派模型呢?肯定不是为了打破而打破,实际项目中可能会出现父类的类加载器需要调用子类的加载器才能加载实现类这个需求。举个例子:

在JDBC场景中:

  • DriverManager由Bootstrap ClassLoader加载(上层);
  • 驱动实现在classpath,由Application ClassLoader可见(下层);
  • 若严格按双亲委派,上层代码无法“向下"看到下层类

JDBC的解决方式:

  • 在DriverManager初始化时,通过ServiceLoader.load(Driver..class)使用当前线程的上下文类加载器(Thread Context ClassLoader)去加载实现类;

Java内存模型(JMM)

Java内存模型是JVM定义的一套规范,规定了多线程程序中变量如何在内存中存储和传递,约定了线程何时从主内存读取数据、何时把数据写回主内存。 JMM的核心目标是确保多线程环境下的可见性、有序性和原子性,屏蔽掉硬件和编译器优化带来的不一致问题:

  1. 可见性:一个线程对变量的修改能及时被其他线程看到。volatile关键字就是用来保证可见性的,强制线程每次读写都直接跟主内存交互
  2. 有序性:线程执行操作的顺序。JMM允许指令重排序来提高性能,但通过happens-before关系保证跨线程的有序性
  3. 原子性:操作不可分割,执行过程中不会被打断。synchronized关键字能保证代码块的原子性

JMM的抽象内存模型:

  • 主内存存放共享变量,所有线程都能访问
  • 每个线程有自己的本地内存,存放共享变量的副本
  • 线程对变量的操作必须在本地内存中进行,不能直接操作主内存
  • 线程间变量传递必须通过主内存完成
image-20260306183309036

主内存和工作内存

  • 主内存:主内存是java堆内存的一部分,所有的实例变量、静态变量和数组元素都存储在主内存中。
  • 工作内存:每个线程都有自己的工作内存。工作内存存储了主内存中变量的副本,线程对变量的所有操作都在工作内存中进行,而不是直接在主内存中。

线程之间不能直接访问对方的工作内存中的变量,线程间变量的传递必须通过主内存来完成。

内存间的交互操作(8种操作必须原子性)

Java内存模型定义了八种操作,用于控制主内存和工作内存之间的交互,这些操作都是原子的:

  1. lock(锁定):把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):把一个变量从独占状态中释放出来,释放后的变量才能被其他线程锁
  3. read(读取):从主内存中读取一个变量到工作内存中。
  4. load(载入):把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):把工作内存中的一个变量值传递给执行引擎。
  6. assign(赋值):把一个从执行引擎接收到的值赋给工作内存中的变量。
  7. store(存储):把工作内存中的一个变量的值传送到主内存中。
  8. write(写入):把store操作从工作内存中得到的变量值放入主内存的变量中。

对于volatile型变量的特殊规则

  • 可见性:对一个volatile变量的写操作会立即刷新到主内存中,任何线程对这个volati1e变量的读操作都能立即看到最新的值。
  • 禁止指令重排序:在对volatile变量进行读/写操作时,会插入内存屏障,禁止指令重排序。具体来说:
    • volatile变量的写操作不能与之前的读/写操作重排序。
    • volatile变量的读操作不能与之后的读/写操作重排序。

针对long和double型变量的特殊规则(long和double的非原子协定)

  • 非原子性:在一些平台上,对64位的log和double类型的变量的读/写操作是非原子的。这意味着读取一个64位变量时,可能只读取了其中的32位数据,从而导致读取到的值是不完整的。
  • 解决方案:可以通过使用volatile关键字或者使用锁来保证对long和double类型变量的操作是原子的。

原子性、可见性、有序性

  • 原子性:指一个操作是不可分割的,即使在多线程环境下,一个操作一旦开始,就不会被其他线程中断。
  • 可见性:指一个线程对共享变量的修改,能够及时地被其他线程看到。volatile关键字、锁和内存屏障可以保证可见性。
  • 有序性:指程序按照代码顺序执行。ava内存模型允许编译器和处理器对指令进行重排序,但通过volatile和锁可以保证必要的有序性。

Happens-Before原则

Happens-Before原则是JMM中定义的操作间的顺序规侧,确保操作的有序性和可见性。具体包括以下八个规侧:

  1. 程序次序规则:一个线程中的每个操作,按照程序代码的顺序发生。
  2. 监视器锁规侧:一个解锁操作发生在同一个锁的随后的加锁操作之前。
  3. volatile变量规侧:对一个volatile变量的写操作发生在对该变量的随后的读操作之前。
  4. 线程启动规则:在一个线程中对另一个线程的Thread.start()调用发生在这个新线程的每一个操作之前。
  5. 线程终止规侧:一个线程中的所有操作都发生在另一个线程检测到这个线程已经终止(通过Thread.join()返回)之前。
  6. 线程中断规则:对线程的中断操作(Thread.interrupt())发生在被中断线程检测到中断事件(通过Thread,interrupted()或Thread.isInterrupted())之前。
  7. 对象终结规则:一个对象的构造函数执行结束发生在这个对象的finalize()方法之前。
  8. 传递性:如果操作A Happens-Before操作B,操作B Happens-Before操作C,那么操作A Happens-Before操作C。

垃圾回收

你要回收一个垃圾,就得知道怎么看一个对象是不是垃圾,主要有两种算法:

  • 引用计数法:如果对象被引用了,计数器+1;计数器为0代表是垃圾;但是这种方法没有办法处理循环引用。
  • 可达性分析:对象之间的引用关系可以构成一个有向图,可达性分析就是从根对象触发,去遍历这个有向图;遍历不到,就是不可达对象,也就是垃圾。

可达性分析的具体过程

三色标记法

三色标记是一种增量标记算法,让GC可以和应用线程并发执行,不用一口气停下来把所有对象扫完。CMS和G1都用了这套算法。 核心思路是给对象打三种颜色的标签:

  1. 白色:还没被GC访问过,可能是垃圾
  2. 灰色:已经被访问,但它引用的对象还没处理完
  3. 黑色:自己和它引用的对象都改处理完了,肯定不是垃圾

标记过程从GC Roots开始,先把根对象染成灰色。然后不断从灰色集合里拿对象出来,把它引用的白色对象染成灰色,自己染成黑色。重复这个过程直到没有灰色对象为止。最后剩下的白色对象就是垃圾。

但是这会出现两个问题:漏标和多标

####漏标

image-20260306185853438

假设GC刚扫完A,A变成黑色,B还是灰色等着被扫。这时候mutator把A到C的引用加上了,又把B到C的引用删了。等GC去扫B的时候,B没有引用了,GC认为C是白色的垃圾对象。但实际上C还被A引用着,不该被回收。

这就是漏标,把活着的对象当垃圾清了,这是致命错误,GC绝对不能容忍。宁可放过,不能杀错。

解决方案

漏标发生需要同时满足两个条件:

  1. mutator给黑色对象加了一条到白色对象的引用
  2. mutator把灰色对象到用那个白色对象的引用删了

打破任意一个条件就不会漏标。两种经典方案:

  • 增量更新:用写屏障拦截第一个条件。黑色对象要引用白色对象时,把白色对象变成灰色,或者把黑色对象退回灰色重新扫一遍。CMS用的就是这个方案。
  • STB:用写屏障拦截第二个条件。灰色对象删除对白色对象的引用时,把这条旧引用记下来,相当于保存了标记开始时刻的引用快照。G1用的是这个方案,SATB全称是Snapshot At The Beginning.

两种方案各有优劣。增量更新更精确,但需要在标记结束后再扫一遍被修改的引用。SATB可能会多标一些对象,但不需要重新扫描,整体停顿时间更短。

多标

image-20260306185853438

还有多标的情况:A变黑之后,mutator把根到A的引用删了,A其实已经是垃圾了,但已经被标成黑色不会被回收,只能等下次GC。这个问题不严重,最多浪费一点内存,下次GC就清理掉了。

垃圾回收算法

垃圾回收算法本质上就是处理内存碎片的几种不同策略。,主要有三种:标记-清除算法、复制算法、标记-整理算法

1. 标记-清除算法

先遍历一遍,把有用的对象打个标记,然后把没标记的垃圾直接清掉。问题是清完之后空出来的地方东一块西一块的,像蜂窝煤一样。下次想分配个大对象,明明总空间够,但就是找不到一块连续的地儿放。

image-20260306190440429

2. 复制算法

把内存一分为二,平时只用一半。回收的时候把活着的对象全部复制到另一半,整整齐齐排好,然后把原来那一半直接清空。好处是快,绝对没有碎片。坏处是得空着一半地盘不能用,太浪费。

image-20260306190520122

3. 标记整理算法

老年代常用。老年代对象活得久,用复制算法得复制一大堆太慢,用标记-清除又有碎片。标记-整理的做法是先标记,然后把所有活着的对象往一端推,像整理书架一样排紧凑,最后把剩下的空间清空。既没碎片,又不用浪费一半空间,代价是移动对象比较耗时。

image-20260306190544970

新生代与老年代

依据以上垃圾回收算法的特点,JVM将堆区划分为两块区域,一个是新生代一个是老年代。

  • 新生代:新生代区域基本上是一些新出生的对象,大多数对象就是朝生夕死的,例如:从数据库查一批数据放到对象里面,返回给前端,对象立马就不用了,就应该被回收。
  • 老年代:项目里面各个Service类、Controller类的、Spring IOC容器等,这些对象活周期长,项目启动就存在,项目停止才进行回收,这样的对象就放到老年代里面。

新生代,因为朝生夕死,垃圾对象多,存活对象少,就适合使用复制算法

老年代,因为对象存活周期长,就适合使用标记-清除或标记整理算法

针对不同的内存区域,使用不同的回收算法,这个就叫分代收集算法

垃圾回收过程

新生代分为三个区域,分别是EDEN、Survivor0、Survivor1,这两个Survivor大多被称为from survivor 和 to survivor

一般来说,对象会在EDEN区出生,当EDEN区空间不足的时候,就会触发一次young gc,这个时候就会使用复制算法,把EDEN区存活对象和from区对象复制到 to survivor区;然后呢存活对象寿命+1,EDEN清除垃圾,from和to进行交换,from 存放存活对象。

如果to区空间满了,就会把to区对象移动到老年代。

存活对象寿命达到阈值(15)的时候,这种情况下也会把对象从新生代移动到老年代

如果一次晋升的对象多了,老年代装不下了怎么办?这个时候会尝试触发young gc去清理新生代对象,减少晋升的数量。如果还是不够,那么就会触发full gc,把新生代和老年代都清理一遍还会清理一下元空间,老年代垃圾回收过程实际上是使用标记清除或标记整理的一个过程,具体使用什么算法,那得看使用了什么样的垃圾回收器。

垃圾回收器

HotSpot虚拟机的垃圾收集器按作用区域分成两类:新生代收集器和老年代收集器,它们需要搭配使用。

image-20260306193942486

新生代收集器

  1. Seial:单线程,用标记-复制算法。GC时所有应用线程停下来等着,简单粗暴。在客户端模式下是默认收集器,几十MB的新生代几毫秒就能收完。
  2. ParNew:Serial的多线程版本,除了能并行收集,别的都一样。它存在的意义是能跟CMS配合,JDK9之后跟CMS绑定了,单独用不了了。
  3. Parallel Scavenge:也叫吞吐量收集器,多线程并行收集。它的目标不是缩短单次停顿,而是最大化CPU用在业务代码上的时间占比。适合后台跑批、大数据计算这种不在乎偶尔卡一下的场景。

老年代收集器

  1. Serial Old:Serial的老年代版本,单线程,用标记-整理算法。
  2. Parallel Old:Parallel Scavenge的老年代搭档,多线程并行标记-整理。要发挥吞吐量优先的效果,新生代老年代得配套用。
  3. CMS:全称Concurrent Mark Sweep,追求低停顿。大部分工作跟应用线程并发执行,只有初始标记和重新标记需要短暂停顿。缺点是用标记-清除算法会产生碎片,还有并发失败的风险。JDK9标记为废弃,JDK14正式移除。
  4. G1:JDK9之后的默认收集器,把堆切成2048个左右的Region,不再严格区分新生代老年代。能设定目标停顿时间,让GC变得可预测。
  5. ZGC:DK11引入的低延迟收集器,停顿时间控制在10ms以内,跟堆大小无关。支持TB级别的堆内存。JDK15转正。
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP