JAVA虚拟机(JVM)-- 万字解析

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: JAVA虚拟机(JVM)-- 万字解析

什么是JVM

       一个程序肯定要跟硬件打交道,但硬件肯定没办法直接和程序对接,那这是如何实现的?程序员首先编写java文件(文件后缀是 .java),然后这个java文件被编译成二进制字节码文件(文件后缀是 .class)才能跟JVM打交道,然后JVM再去跟操作系统说我要让某个硬件去干某件事,最后操作系统再去调动硬件


       所以这个过程中,JVM所要完成的工作就是执行二进制字节码文件的指令,根据这个指令去调用操作系统


JVM导致java的跨平台性

       知道上面这个之后,我们只需要再知道一个点,就能理解什么是java跨平台性,java为什么能跨平台了。操作系统有很多个(windows、linux、mac...)不同的操作系统对于同一个动作(比如将数据写入硬盘)的指令有可能是不同的,而一个java文件只能编译出一个二进制字节码文件,不过对于不同的操作系统,java提供了相对应的JVM版本,而一个二进制字节码文件就可以跟不同的JVM版本进行对接,保证了一个二进制字节码文件能够在不同的操作系统中都能兼容


JVM运行时数据区

       JVM要执行二进制字节码文件,包含着一个很重要的步骤,就是为程序(这里的程序其实就是这个二进制字节码文件),开辟出一块内存空间供程序使用,那这块内存空间到底要存些什么?要怎么开辟呢?


这块空间的名字叫运行时数据区


它有如下几个模块

下面简单(追求的就是一个快速)说明每个模块的作用


方法区

       主要用于存储类信息、静态变量、静态方法等


       存储几乎所有的对象实例


方法区和堆是所有线程共享的,接下来这三个模块都是线程各自占有的


虚拟机栈

       每个方法被执行的时候,虚拟机栈都会创建一个栈帧,这个栈帧会存储这个方法的必要信息,当方法执行结束之后,这个栈帧就会出栈


程序计数器

       这个自己要先去简单了解并发,程序计数器简单讲就是要让处理器知道上次执行到了该线程的哪个地方


本地方法栈

      本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务(什么是本地方法我也不懂,我这句话都是摘的)。

对象实例在JVM中的存储

       对象实例是一个程序最重要的部分之一,没有哪个正儿八经的程序是没有对象实例的,上面说到,堆区中存储的就是对象实例,那具体是这么存储呢?


堆为对象分配空间的两种方式

       假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”。所以选择哪种分配方式由Java堆是否规整决定


对象在内存中要存储那些信息

对象在堆内存中的存储信息可以划分为三个部分:对象头、实例数据和对齐填充


1、对象头部分包括两类信息。


第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分方称它为“Mark Word”


对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。


2、实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。


3、对象的第三部分是对齐填充,它没有特别的含义,仅仅起着占位符的作用。这是因为虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。


如何在堆中找到对象

       创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具 体对象。而reference数据访问对象的方式是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:


如果使用句柄访问的话, Java 堆中将可能会划分出一块内存来作为句柄池, reference 中存储的就

是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息

如果使用直接指针访问的话, Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关

信息, reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

JVM中的堆区分代

堆区分为新生代和老年代,形象化的理解:新生代主要存储刚刚创建出来的对象实例,如果新生代中的对象能经过多次回收(具体看下面讲的JVM垃圾回收机制)的洗礼,就让为它会比较经常被使用,从而进入老年代,顺便提一嘴,还有一个叫永久代的,其实就是方法区


JVM的垃圾回收机制(简称GC)

       JVM的垃圾回收机制非常强大,是JVM的一个很重要的功能,而且这也是跟对象实例息息相关的,上面我们讲到了对象实例是怎么储存的和使用的,那么如果对象实例不用了要怎么清除呢?


如何判断对象已经没用了

当JVM认为一个对像已经没用了,就会把这个对象判定为是垃圾,就会去回收它的空间,有两个方法判断一个对像是否已经没用了


1、引用计数法:记录指向该对象的引用数,当该数值为零时就将该对象判定为垃圾


这个方法实现简单,判定效率也高,不过它有个致命的问题,它无法解决相互对象之间相互循环引用的问题,看下面这个例子

此时对象1和对象2除了对方指向自己的引用外,没有其他的引用了,这个时候,无论是对象1还是对象2,我们认为都已经没用了,因为程序是找不到它俩的,但是引用计数法无法将它们判定为垃圾,因为它们的被引用数不是为零

public class Test {
  public Object object = null;
  public static void main(String[] args) {
    Test a = new Test();//对象1
    Test b = new Test();//对象2
    a.object = b;
    b.object = a;
    a = null;
    b = null;
  }
}

正是因为这个缺点,主流的java虚拟机都不会使用该判定方法


2、可达性分析:


选定一些满足特定条件的对象作为根对象(GC Roots),那些与跟对象存在直接或间接引用关系的就是有用的对象,而与根对象没有任何关联的对象,就是垃圾对象(如下图)


这是当今主流的判定机制

GC的分类

Minor GC是新生代GC,指的是发生在新生代的垃圾收集动作。由于java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。


Major GC是老年代GC,指的是发生在老年代的GC,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多。


Full GC是清理整个堆空间,包括年轻代和老年代(Minor GC和Major GC一起执行就是Full GC)


GC和分代的关系

那么现在我们就知道了为什么要分代了:


对象实例一般会首先分配到新生代当中,当新生代当中的空间不够用的时候,就会触发Minor GC,这个时候就会有一些没用的对象实例被清除掉,而有些就会留下来,那些能够挺过一定次数Minor GC的对象,最后就会进入到老年代当中,如果老年代中的空间也不够用了,那么就会进行Major GC


回收算法

我们上面说到GC会对垃圾进行回收,那具体要这么回收呢?这个就是回收算法,目前有三种回收算法,分别是:标记-清除、标记-复制、标记-整理


标记-清除

看下面的示意图,这个代表堆中的某块空间(可以是年轻代或老年代),每个紫色方块就是一个对象,上面我们说,JVM的对象是否存活的判定方法是可达性分析,所有那些没被GC Root引用的就要给标记成垃圾对象,标记完后再统一进行回收,这会造成内存空间碎片化的问题,另外还有执行效率不稳定的问题

标记-复制

将堆区分为两块区域,先只在其中一块区域创建对象,垃圾回收的时候,先标记出那些不要被回收的对象,然后将其复制到另外一块区域中,然后清空原本那块区域,新生代使用的就是标记-复制算法,新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。这里存在一个问题,当存活的对象的总大小大于那块Survivor空间,那就会造成溢出,而那些溢出的对象,会直接进入老年代,这叫分配担保

标记-整理

标记-复制算法存在需要额外空间进行分配担保的问题,新生代有老年代做分配担保,那老年代没人做分配担保就没办法使用标记-整理算法,要使用标记-整理算法,同样是先进行标记,不过不马上进行回收,而是让所有的存活对象都向内存空间一端移动,然后直接清理掉边界以外的堆空间


标记-整理存在一个弊端,在整理的过程中,必须全程暂停用户应用程序,这个被形象地称为“Stop The World”,实际上只要对象的存储地址发生了改变,就会“Stop The World”,所以标记-复制算法也会“Stop The World”

垃圾收集器

上面说的回收算法是理论层面的,接下来讲这些理论的实现者--垃圾收集器


垃圾回收器大概可以分为三类:


串行

吞吐量优先

响应时间优先

串行


使用单线程回收,因此就适用于堆内存较小,CPU数量少的(因为多了也没用)个人电脑


吞吐量优先


使用多线程回收,适用于堆内存较大,CPU较多的场景(如果是CPU个数较少,比如单核CPU,会导致回收线程之间相互争抢CPU的时间片,导致线程上下文切换的时间浪费,效率反而会比串行的垃圾回收器低),适合工作在服务器上


吞吐量优先的目标是:在单位时间内,让Stop The  World(以下简称STW)的时间最短


响应时间优先


同样也是多线程回收,同样适用于堆内存较大,CPU较多的场景,同样适合工作在服务器上


响应时间优先的目标是:让单次的STW的时间最短


一个例子区分 吞吐量优先 和 响应时间优先:


在一个单位时间内,吞吐量优先追求 0.5 + 0.5 = 1,吞吐量不在乎 0.5 很大,只在乎 1 最小,而响应时间优先追求 0.3 + 0.3 + 0.3 +0.3 +0.3 =1.5 ,响应时间优先不在乎1.5很大,只在乎 0.3 最小


三种垃圾收集器的使用方法和工作流程


串行

开启串行垃圾回收器的VM参数是:-XX:+UseSerialGC = Serial + SerialOld


Serial 垃圾回收器工作在新生代,采用 标记-复制 算法


SerialOld 垃圾回收器工作在老年代,采用 标记-整理 算法


工作流程如下:

为什么需要STW?


       因为在垃圾回收的工程中,有些对象的地址是会发生改变的,如果垃圾回收的过程中用户线程还在工作,那用户线程就有可能找不到对象, 从而产生错误,因此在垃圾回收器进行回收的过程中,其他用户线程都要阻塞


吞吐量优先

开启吞吐量优先的垃圾回收器的参数是:-XX:+UseParallelGC ~ -XX:+UseParallelOldGC


ParallelGC 垃圾回收器工作在新生代中,采用 标记-复制 算法


ParallelOldGC 垃圾回收工作在老年代中,采用 标记-整理 算法


在 jdk 1.8 中默认使用的就是 ParallelGC,而这两个开关是一同开启的,也就是开启 ParallelGC会连同开启ParallelOldGC


Parallel 就是并行的意思,说明这些垃圾回收线程是并行执行的


工作流程如下:

通过参数 -XX:ParallelGCThreads=n  可以设置垃圾回收线程的数量


除此之外还有三个比较重要的参数:


-XX:+UseAdaptiveSizePolicy :采用自适应的大小调整策略,会动态的调整  Eden 和 Survivor 的比例,还会调整堆的大小、老年代的晋升阈值等

-XX:GCTimeRatio=ratio : 用于调整垃圾回收的时间在总工作时间中的占比,计算公式为 1/(1+ratio),如果达不到设置的时间占比,ParallelGC就会尝试调大堆的大小,因为堆的容量较大,GC的次数就会比较少,ratio的值默认为99,但是 1/100 的占比时间很难达到,因此我们正常将 ratio 设置为19

- XX:MaxGCPauseMillis=ms : 单次垃圾回收的最大时间,默认值200ms,于  -XX:GCTimeRatio 参数存在冲突,因为 -XX:GCTimeRatio 可能为了达到目标,而调大堆的大小,而且堆越大,单次垃圾回收的时间就越长

响应时间优先

开启响应时间优先的参数:-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld


ConcMarkSweepGC 垃圾回收器工作在老年代中,采用 标记-清除 算法


其中的 Conc 值 concurrent 并行的意思,因为该GC在进行垃圾回收的过程中, 某些时刻存在垃圾回收线程和用户线程并发的现象


ConcMarkSweepGC 可能会存在并发失败的问题,如果出现并发失败,那 ConcMarkSweepGC 就会退化成 SerialOldGC


ParNewGC 垃圾回收器工作在新生代中,采用 标记-复制 算法


工作流程如下:

从上面我们可以看到,只有 初始标记 和 重新标记 的阶段需要STW,而且初始标记的时间又非常短,因此能达到更好的响应时间


G1 垃圾回收器

G1(Garbage First)是 JDK 9 默认的垃圾回收器,同时注重吞吐量和低延迟,默认的暂停目标是 200 ms


适用于超大的堆内存,其工作原理是将堆划分为多个大小相等的 Region ,每个 Region 都可以单独作为 Eden、Survivor、老年代


G1 整体上使用 标记-整理 算法,两个 Region之间采用 复制 算法


如果在 JDK 1.8 中使用G1,需要设置VM参数:-XX:+UseG1GC


未完待续 ...  


目录
相关文章
|
13天前
|
设计模式 安全 Java
Java 编程中的设计模式:单例模式的深度解析
【9月更文挑战第22天】在Java的世界里,单例模式就像是一位老练的舞者,轻盈地穿梭在对象创建的舞台上。它确保了一个类仅有一个实例,并提供全局访问点。这不仅仅是代码优雅的体现,更是资源管理的高手。我们将一起探索单例模式的奥秘,从基础实现到高级应用,再到它与现代Java版本的舞蹈,让我们揭开单例模式的面纱,一探究竟。
23 11
|
6天前
|
存储 算法 Java
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
本文介绍了 JVM 的内存区域划分、类加载过程及垃圾回收机制。内存区域包括程序计数器、堆、栈和元数据区,每个区域存储不同类型的数据。类加载过程涉及加载、验证、准备、解析和初始化五个步骤。垃圾回收机制主要在堆内存进行,通过可达性分析识别垃圾对象,并采用标记-清除、复制和标记-整理等算法进行回收。此外,还介绍了 CMS 和 G1 等垃圾回收器的特点。
19 0
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
|
13天前
|
缓存 负载均衡 Dubbo
Dubbo技术深度解析及其在Java中的实战应用
Dubbo是一款由阿里巴巴开源的高性能、轻量级的Java分布式服务框架,它致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案。
39 6
|
9天前
|
监控 算法 Java
深入解析Java中的垃圾回收机制
本文旨在全面解析Java的垃圾回收机制,探讨其工作原理、常见算法以及在实际开发中的应用。通过对这一重要主题的深入分析,希望帮助读者更好地理解Java虚拟机(JVM)如何管理内存,从而编写出更高效、稳定的Java应用程序。
|
9天前
|
Java 开发者
Java中的异常处理机制深度解析
在Java编程中,异常处理是保证程序稳定性和健壮性的重要手段。本文将深入探讨Java的异常处理机制,包括异常的分类、捕获与处理、自定义异常以及一些最佳实践。通过详细讲解和代码示例,帮助读者更好地理解和应用这一机制,提升代码质量。
12 1
|
11天前
|
分布式计算 Java API
深入解析Java中的Lambda表达式及其应用
本文将深入探讨Java中Lambda表达式的定义、优势及其在实际编程中的应用。通过具体示例,帮助读者更好地理解和使用这一强大的编程工具。
|
2月前
|
监控 网络协议 Java
Tomcat源码解析】整体架构组成及核心组件
Tomcat,原名Catalina,是一款优雅轻盈的Web服务器,自4.x版本起扩展了JSP、EL等功能,超越了单纯的Servlet容器范畴。Servlet是Sun公司为Java编程Web应用制定的规范,Tomcat作为Servlet容器,负责构建Request与Response对象,并执行业务逻辑。
Tomcat源码解析】整体架构组成及核心组件
|
2月前
|
存储 NoSQL Redis
redis 6源码解析之 object
redis 6源码解析之 object
58 6
|
21天前
|
存储 缓存 Java
什么是线程池?从底层源码入手,深度解析线程池的工作原理
本文从底层源码入手,深度解析ThreadPoolExecutor底层源码,包括其核心字段、内部类和重要方法,另外对Executors工具类下的四种自带线程池源码进行解释。 阅读本文后,可以对线程池的工作原理、七大参数、生命周期、拒绝策略等内容拥有更深入的认识。
什么是线程池?从底层源码入手,深度解析线程池的工作原理
|
25天前
|
开发工具
Flutter-AnimatedWidget组件源码解析
Flutter-AnimatedWidget组件源码解析

推荐镜像

更多
下一篇
无影云桌面