笛子的Java系列总结——JVM入门

写在前面

Java虚拟机(JVM)是个很复杂的东西,在初步学习网络上的资料时,我觉得它们都没有真正的涉及到原理,很多所谓的深入讲解JVM,都大同小异,甚至都没有将标准的JVM规范。
可稍微多了解一点,发现可能不是大佬们不往下讲,而是讲了我这种小白也看不懂,比如在JVM里的符号引用如何存储?中大佬说明“符号引用”和“直接应用”的区别时结合了一个Java的字节码文件,这个字节码文件看起来已经很吃力了,别提其他的原理了。而且针对JVM规范还有多种不同的实现
在这里插入图片描述

所以本文也就是对看过的JVM浅显的“八股文”根据自己的理解做个总结, 全部以Hotspot的实现为例,仍是大同小异之流,可能还有表述错误的地方,如果有大佬发现恳请指正!

补充、JVM整体组成

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

一、JVM的内存机制

在这里插入图片描述

图中橙色的部分是线程私有结构,线程私有数据区域生命与线程相同,依赖用户线程的启动/结束,而创建/销毁在VM内。
图中绿色的部分是线程共享结构,线程共享区域随虚拟机的启动/关闭而创建/销毁

Q: 既然是私有的,那多线程的话多有多个虚拟机栈、本地方法栈和程序计数器吗?
A: 是的。Java的多线程是用时间片轮转的方式实现的,每个线程都有自己的程序计数器,用于轮转到自己时从上次执行位置继续执行。每个线程执行时,都会创建一个栈,称为虚拟机栈,本地方法栈同理

1、程序计数器

  1. 简单来说就是记录程序执行到哪个位置了
  2. 当前线程所执行的字节码的行号指示器(有些类似于汇编语言的指令入口)
  3. 当执行Java方法的话,计数器记录的是虚拟机字节码指令执行到的地址,如果是Native方法,则为空

2、虚拟机栈

  1. 虚拟机栈用于存储Java方法的信息,以栈帧为基本结构,每个Java方法对应一个栈帧,一个Java方法开始执行时,对应的栈帧进入虚拟机栈,方法执行结束时,对应的帧栈出栈。所以说递归调用一个方法时,空间开销取决于递归栈的深度。
  2. 每个帧栈由四个部分组成

(1)局部变量表:用于存储局部变量

(2)操作数栈:用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。

比如整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

(3)动态链接:用于指向运行时常量池中该栈帧所属方法的引用。

可以理解为通过“方法名称”在内存中找到对应的方法。不如Math.max()调用时max()叫符号,需要通过max()这个符号去常量池中找到对应方法的符号引用,运行时将通过符号引用找到方法的字节码指令的内存地址。

(4)方法出口:用于存储方法返回时的信息来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出是,将调用者的程序计数器的值作为返回地址,栈帧中很可能会保存这个计数器值;而方法异常退出时,返回地址要通过异常处理器来确定,栈帧中一般不会保存这部分信息。

3、本地方法栈

  1. 虚拟机栈用于存储Java方法的信息,本地方法栈用于存储Native方法的信息
  2. Hotspot VM 直接把本地方法栈和虚拟机栈合二为一了

4、方法区

  1. 用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
  2. 运行时常量池是方法区的一部分,用于存在Class文件中除了类的版本、字段、方法、方法、接口描述等信息外的在编译期生成的各种字面量和符号引用。

(1)字面量可以理解为字面意思的常量,比如int a = 123,a是变量,123就是字面量

(2)符号引用:符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以知道相应的位置,比如说某个方法符号引用,如“
java/io/PrintStream.println:(Ljava/lang/String;)V”里面有类的信息,方法名,方法参数等信息。当第一次运行时,要根据字符串的内容,到该类的方法表中搜搜这个方法。运行一次后,符号引用会被替换为直接引用,下次就不用搜索了。

(3)直接引用:直接引用就是偏移量,可以直接指向地址值,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。

可以参考下别人描述的Java字节码指令执行分析,一条条跟下来,写的很少很清晰的 :https://blog.csdn.net/freelander_j/article/details/103510078

5、堆

  1. 数据的主要存放区域,即创建的对象和数组的存放位置,也是垃圾收集器进行垃圾收集的最重要的内存区域。
  2. 现代VM采用分代收集算法,因此Java堆从GC的角度还可以细分为:新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代
    在这里插入图片描述

二、JVM的垃圾回收

这里的垃圾回收主要针对内存中的堆结构而言

1、确定垃圾的算法

  1. 引用计数法
  • 统计一个对象的引用,如果一个对象没有任何与之关联的引用,说明当前对象可以回收
  • 缺陷:可能存在互相引用的两个对象,导致无法回收,造成内存泄漏
  1. 可达性分析
  • 为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”,对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
  • 要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,就将被回收。

2、收集垃圾的算法

  1. 标记-清除法(Mark-Sweep)

在这里插入图片描述

  • 最简单直接的方法,把确定为垃圾的对象标记并清除,
  • 缺陷是后出现内存碎片,可能导致内存存在空余却无法存放大对象的情况出现
  1. 复制算法
    在这里插入图片描述
  • 用来解决标记清除法会产生碎片的缺陷,但是它的缺陷是只能利用原本内存空间的一半
  • 按内存容量将内存划分为等大小的两块,每次只用其中一块,当一块内存满之后,将还存活的对象复制到另一块上,把已使用的内存清除。
  1. 标记-整理法(Mark-Compact)
    在这里插入图片描述
  • 结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
  1. 分代收集算法
  • 分代收集法是目前大部分 JVM 所采用的方法
    其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
  • 新生代使用复制算法
    目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。
    一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。
    在这里插入图片描述
  • 老年代使用标记整理算法
    因为老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
  • 分代收集算法执行流程
    ① 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
    ② 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
    ③ 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
    ④ 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
    ⑤ 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。

3、GC垃圾收集器

这部分也不用想着深入理解,搞清底层原理了,目前知道每个垃圾收集器使用的是什么垃圾收集算法,是否为多线程,新生代和年老代直接的组合关系就行了,
这里列出来的也只是最基本和原始的垃圾收集器,现在用的都是更加高效的版本
在这里插入图片描述

  1. Serial 垃圾收集器
  • 单线程、复制算法、新生代
  • 在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束(STW : Stop The World)
  1. ParNew 垃圾收集器
  • Serial的多线程版本、新生代
  • 在垃圾收集过程中同样也要暂停所有其他的工作线程
  1. Parallel Scavenge收集器
  • 多线程,复制算法、新生代
  • 它和ParNew收集器的一个重要区别,是它存在自适应调节策略,可以尽量提高程序的吞吐量,在更合适的时机进行垃圾回收
    吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
  1. Serial Old 收集器
  • 单线程、标记整理算法、年老代
  • 新生代Serial与年老代Seiral Old搭配垃圾收集过程举例(并不是只能和Serial搭配)
    在这里插入图片描述
  1. Parallel Old 收集器
  • 多线程标记整理算法
  • Parallel Old 正是为了在年老代同样提供吞
    吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。
  1. CMS收集器
  • 多线程、标记清除算法
  • CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
    ① 初始标记:只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程
    ② 并发标记:进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
    ③ 重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程
    ④ 并发清除:清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并
    发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
    在这里插入图片描述

三、Java中的四种引用类型

这部分主要知道四种类型的引用被回收的时机就行,可以根据它们被回收的时机决定用途

1、强引用

在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

2、软引用

  1. 软引用需要用 SoftReference 类来实现
  2. 对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。

3、弱引用

  1. 弱引用需要用 WeakReference 类来实现
  2. 它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。

4、虚引用

  1. 虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
  2. 它随时都可能被回收

5、其他说明

  1. 软引用和弱引用两者都可以实现缓存功能,但软引用实现的缓存通常用在服务端,而在移动设备中的内存更为紧缺,对垃圾回收更为敏感,因此android中的缓存通常是用弱引用来实现(比如LruCache)
  2. 虚引用 为了被虚引用关联的对象在被垃圾回收器回收时,能够收到一个系统通知
  3. 使用SoftReference,WeakReference,PhantomReference 的时候,可以关联一个ReferenceQueue。那么当垃圾回收器准备回收一个被引用包装的对象时,该引用会被加入到关联的ReferenceQueue。程序可以通过判断引用队列中是否已经加入引用,来了解被引用的对象是否被GC回收。

四、Java中的类加载机制

1、加载过程的五个阶段

加载阶段 → 验证阶段 → 准备阶段 → 解析阶段 → 初始化阶段

1.加载:把class字节码文件从各个来源通过类加载器装载入内存中,这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口
在这里插入图片描述

  • 字节码来源。一般的加载来源包括从本地路径下编译生成的.class文件,从jar包中的.class文件,从远程网络,以及动态代理实时编译。
  • 类加载器。一般包括启动类加载器,扩展类加载器,应用类加载器以及用户的自定义类加载器。
  1. 验证:这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
  • 包括对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息?
  • 对于元数据的验证,比如该类是否继承了被final修饰的类?类中的字段,方法是否与父类冲突?是否出现了不合理的重载?
  • 对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。+ 对于符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类?校验符号引用中的访问性(private,public等)是否可被当前类访问?
  1. 准备:主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值。
  • 特别需要注意,初值,不是代码中具体写的初始化的值,而是 Java 虚拟机根据不同变量类型的默认初始值。
  • 比如 8 种基本类型的初值,默认为 0;引用类型的初值则为null;常量的初值即为代码中设置的值,例如final static tmp = 456, 那么该阶段 456 就是tmp的初值。
  1. 解析:将常量池内的符号引用替换为直接引用的过程
    这个前面其实已经解释过
  • 举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
  1. 初始化:这个阶段主要是对类变量初始化,是执行类构造器的过程。
  • 换句话说,只对static修饰的变量或语句进行初始化。
  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

2、类加载器

  1. 按照等级高低从上到下可以分为:启动类加载器、扩展类加载器、应用类加载器、自定义类加载器
    在这里插入图片描述

  2. 双亲委派模型

  • 什么是双亲委派模型:当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
  • 双亲委派的优点是什么:比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。
  1. 示例过程
    在这里插入图片描述

五、一些疑问总结

  1. 方法区、永久代、元空间有什么关系?
    (1)方法区是《Java虚拟机规范》中要求的规范,可以理解为接口;而永久代和元空间不同的Hotspot版本对方法区的两种不同实现
    (2)永久代和空间的区别

    • 永久代(Java 7及以前)
      在这里插入图片描述

    堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存。也就是说,方法区和堆的Eden和老年代是连续的。
    在这里插入图片描述

永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。
+ 元空间
在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。
在这里插入图片描述
本地内存(Native memory),也称为C-Heap,是供JVM自身进程使用的。当Java Heap空间不足时会触发GC,但Native memory空间不够却不会触发GC。
在这里插入图片描述

元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中“java.lang.OutOfMemoryError: PermGen space”这种错误。看上图中的方法区,是不是“膨胀”了。默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM同样提供了参数来限制它使用的使用。

在这里插入图片描述

  1. 类的哪些信息存在了其他结构中?
    在Java7中永久代中存储的部分数据已经开始转移到Java Heap或Native Memory中了。比如,符号引用(Symbols)转移到了Native Memory;字符串常量池(interned strings)转移到了Java Heap类的静态变量(class statics)转移到了Java Heap。

六、引用资料总结

下面这些链接都是引用在文中或者个人觉得写的很棒的回答和总结
1、JVM里的符号引用如何存储?
https://www.zhihu.com/question/30300585/answer/51335493
2、面试官:请你谈谈 Java 的类加载过程
https://cloud.tencent.com/developer/article/1628085
3、【JVM学习】将java文件编译成字节码文件,再到反编译,字节码指令剖析
https://blog.csdn.net/seesun2012/article/details/84729598?utm_medium=distribute.pc_relevant.none-task-blog-baidujs_title-0&spm=1001.2101.3001.4242
4、Java字节码指令分析(按步分析字节码指令执行过程,含内存变化图)
https://blog.csdn.net/freelander_j/article/details/103510078
5、Java中的四种引用和引用队列的概念
https://www.jianshu.com/p/6ae4f53a4752
6、面试官 | JVM 为什么使用元空间替换了永久代?
https://zhuanlan.zhihu.com/p/111809384
7、经典面试题 - 讲一讲JVM的组成
https://zhuanlan.zhihu.com/p/112020069

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值