目录
在JVM中,我们主要去讨论以下的问题:
1. JVM中的内存区域划分
2. JVM的类加载机制
3. JVM中的垃圾回收策略
一、JVM简介
JVM 是 Java Virtual Machine 的简称,意为 Java 虚拟机
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统
JVM的执行流程:
程序在执行之前先要把 java 代码转换成字节码( class 文件), JVM 首先需要把字节码通过一定的方式 类加载器( ClassLoader ) 把文件加载到内存中 运行时数据区( Runtime Data Area ) ,而字节码 文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执 行引擎( Execution Engine ) 将字节码翻译成底层系统指令再交由 CPU 去执行,而这个过程中需要调 用其他语言的接口 本地库接口( Native Interface ) 来实现整个程序的功能,这就是这 4 个主要组成部分的职责与功能。
![](https://img-blog.csdnimg.cn/154a64ceb36d4809ad5f673454e47f10.png)
二、JVM中的内存区域划分
JVM其实是一个 Java 进程,Java进程会从操作系统申请一大块区域,给java代码使用。
这一大块区域进一步划分出不同的用途:
堆 | new 出来的对象(成员变量) |
栈 | 维护方法之间的调用关系(局部变量) |
方法区(旧)/ 元数据区(新) | 放的是类加载之后的类对象(静态变量) |
🙈经典考点:给一段代码,问某个变量处于内存的哪个区域
🙉看变量的形态:局部变量、成员变量、静态变量
具体内存区域划分:
🔺🔺总结:
1.堆的作用:程序中创建的所有对象都在保存在堆中
2.Java 虚拟机栈的作用:Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈
3.虚拟机栈:给 java 代码使用
4.本地方法栈:给 jvm 内部的本地方法使用的(JVM 内部通过 C++代码实现的方法)
5.程序计数器:记录当前程序指定到哪个指令(相当于一个简单的 long 类型的变量存了一个内存地址,内存地址就是下一个要执行的 字节码 所在的地址)
6.方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。
7.堆和元数据区在JVM进程中只有一份,栈(本地方法栈)和程序计数器则是存在多份,每个线程都有一份
三、JVM加载
1.类加载
简单来说就是:把 .class 文件加载到内存,得到类对象的过程
对于一个类来说,可以总结成五个词(要记住):
1.1 加载
即找到 .class 文件,并且读文件内容
1.2 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
1.3 准备
给类对象分配内存空间(类加载最终就是为了得到类对象),即未初始化的空间,内存空间中的数据全是0(类对象中的静态成员也是全0的)
1.4 解析
针对 字符串常量 进行初始化,也就是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程
字符串常量在 .class 文件中就存在了,但是他们只是知道彼此之间的相对位置(偏移量),不知道自己在内存中的实际地址,这个时候的字符串常量就是符号引用;
真正加载到内存中,就会把字符串常量填充到内存中的特定地址上,字符串常量之间的相对位置还是一样的但是这些字符串有了自己真正的内存地址,此时的字符串就是直接引用(Java中的普通引用)
例如:一个班级组织学生去看电影,学生需要在操场集合,站队的时候谁在前谁在后位置是固定的,这个时候张三和李四在一排站着,但是在路上走的时候,可能走着走着队伍变形了,这个时候张三想和李四一起走就需要进行调整,保持一个走位和李四走在一起,最后仍然可以保证进场之后张三和李四坐在一起
1.5 初始
针对类对象进行初始化(初始化静态成员,执行静态代码块,类要是有父类还需加载父类...)
1.6 总结
类加载什么时候触发?不是 jvm 一启动,就会把所有的 .class 都加载了!!整体是一个“懒加载”的策略(懒汉模式),即非必要,不加载
什么叫做“必要”:
1.创建了类的实例
2.使用了这个类的静态方法/静态属性
3.使用子类会触发父类的加载
2.双亲委派模型
类加载中最关键的一个考点——双亲委派模型(在第一个步骤中,找 .class 文件 这个过程)
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
在JVM中内置了三个 类加载器:
BootStrap ClassLoader | 负责加载 Java标准库中的类 |
Extension ClassLoader | 负责加载一些非标准的 Sun/ Oracle 扩展库的类 |
Application ClassLoader | 负责加载项目中自己写的类以及第三方库中的类 |
具体加载一个类的时候,过程如下:需要先给定一个类的全限定类名“java.lang.String”(字符串)
以上历程在日常工作中也经常存在:把 BootStrap 想象成公司老板,Extension 想象成主管,Application 想象成基层员工
双亲委派模型优点:
1.避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
2.安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。
四、JVM 垃圾回收(GC)
帮助程序猿自动释放内存的
C语言中,malloc 的内存必须手动 free,否则就容易出现内存泄露(光申请内存不释放,内存逐渐用完了,导致程序崩溃;内存泄漏是C++程序猿职业生涯中的头号杀手);Java等后续的编程语言引入了 GC 来解决上述问题,能有效的减少内存泄漏的出现概率。
内存的释放是一件比较纠结的事情,申请的时机是明确的(使用了必须要申请),释放的时机是模糊的(彻底不使用才能释放)。C/C++做法是完全让程序猿来决定,比较不靠谱的,特别依赖程序猿的水平;而 Java 通过 JVM 自动判定,基于一系列策略就可以让这个准确性比较高,但是也会付出一些代价
JVM 中的内存有好几个区域,是释放哪个部分的空间?堆!!!(new 出来的对象)
程序计数器就是一个单纯存地址的整数,不需要,是随着线程一起销毁;栈也是随着线程一起销毁,方法调用完毕,方法的局部变量自然随着栈操作就销毁了;元数据区/方法区,存的类对象很少会“卸载”。
堆!!!就成了GC的主要目标 :GC也就是以对象为单位进行释放的(说是释放内存,其实是释放对象)
GC中主要分成两个阶段:
1.找——确认垃圾
2.释放——把垃圾对象的内存给释放掉
这个时候就涉及到了垃圾回收算法
1.确认垃圾
一个对象如果后续再也没用了,就可以认为是垃圾:Java中使用一个对象,只能通过引用,如果一个对象没有引用指向它,此时这个对象一定是无法被使用的(托托的是垃圾);如果一个对象已经不想用了,但是这个引用可能还指向着呢这种情况下JVM是没法考虑到的;因此 Java 中只是单纯通过引用没有指向这个操作来判定垃圾的
java 怎样知道一个对象是否有引用指向呢
1.1 引用计数
给对象里安排一个额外的空间,保存一个整数表示对象有几个引用指向(Java 实际上没使用这个方案,Python、PHP采取的)
两个缺陷:
1️⃣浪费内存空间
2️⃣存在循环引用的情况:会导致引用计数的判定逻辑出错
1.2 可达性分析(Java 采用的方案)
把对象之间的引用关系理解成一个树形结构,从一些特殊的七点触发进行遍历,只要能遍历访问到的对象就是“可达”,再把“不可达的”当做垃圾即可
可达性分析关键要点:进行上述遍历,需要有“起点”(GC Roots):
1️⃣栈上的局部变量(每个栈的每个局部变量,都是起点)
2️⃣常量池中引用的对象
3️⃣方法区中,静态成员引用的对象
可达性分析:从所有的 GC Roots 的起点出发,看看该对象里又通过引用能访问哪些对象,顺藤摸瓜,把所有可以访问的对象都给遍历一遍(遍历的同时把对象标记成“可达”)
剩下的自然就是“不可达”
- 可达性分析克服了引用计数器的两个缺点,但是也有自己的问题:
1️⃣消耗更多的时间(某个对象成了垃圾也不一定第一时间发现,因为扫描的过程需要消耗时间的)
2️⃣在进行可达性分析的时候,要顺藤摸瓜,一旦这个过程中,当前代码中对象的引用关系发生变化了,就麻烦了
因此为了更准确的完成这个“摸瓜”的过程,需要让其他的业务线程暂停工作!!(STW 问题)
2.释放“垃圾”对象
三种典型的策略:1️⃣标记清除2️⃣复制算法3️⃣标记整理
2.1 标记清除
2.2 复制算法
把整个内存空间分成两段,一次只用一半
复制算法解决了内存碎片问题,但是也有缺点:
1️⃣内存利用率比较低
2️⃣如果当前的对象大部分都要保留的,垃圾很少,此时复制成本就比较高
2.3 标记整理
类似于顺序表删除中间元素,有一个搬运过程
1️⃣解决内存碎片问题
2️⃣搬运开销比较大,不适合频繁进行
以上三种策略不能直接解决问题,因此实际上,JVM 的实现思路是结合上述几种思想方法——分代回收
2.4 分代回收
分代回收思想:给对象设定了“年龄”这样的概念,即描述了这个对象存在多久了;如果一个对象刚诞生,认为是0岁,每次经过一轮扫描(可达性分析),如果没被标记成垃圾,这个时候对象就涨一岁。通过年龄来区分对象的存活时间(经验规律:如果一个对象存活时间很长了,他将继续存在更长的时间)
分代回收:针对不同的情况使用不同的策略(取长补短)即针对不同年龄的对象采取不同的回收策略
把Java堆分为新生代和老年代
1️⃣新创建的对象放在伊甸区:当垃圾回收扫描到伊甸区之后,绝大部分对象都会在第一轮 GC 中就会被干掉,即大部分对象活不过一岁
2️⃣如果伊甸区的对象熬过第一轮 GC,就会通过复制算法拷贝到生存区:生存区分成两半(大小均等),一次只使用其中一半(垃圾回收扫描伊甸区的对象,也是发现垃圾就淘汰,对于不是垃圾,通过 复制算法 复制到生存区的另一半)
3️⃣当这个对象在生存区熬过若干轮 GC 之后,年龄增长到一定程度了,就会通过 复制算法 拷贝到老年代
4️⃣进入老年代的对象,年龄都挺大,再消亡的概率比前面新生代中的对象小不少,针对老年代的 GC 的扫描频次就会降低很多;如果老年代中发现某个对象是垃圾了,使用标记整理的方式清除
5️⃣特殊情况:如果对象非常大,直接进入老年代(大对象进行复制算法,成本比较高,而且大对象也不会很多)
3.垃圾收集器
垃圾收集器是垃圾回收算法(引用计数法、标记清楚法、标记整理法、复制算法)的具体实现,不同垃圾收集器、不同版本的JVM所提供的垃圾收集器可能会有很在差别。
垃圾回收器是具体的实现方式:具体实现的时候往往基于上述思想方法做出一些改进和优化,包含更多复杂的细节(只作为了解即可),java版本的变更,垃圾回收器也在不断变化
3.1 CMS收集器(老年代收集器,并发GC)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
特点:
- 针对老年代,采用标记-清楚法清除垃圾;
- 基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
- 以获取最短回收停顿时间为目标;
- 并发收集、低停顿;
- CMS收集器有3个明显的缺点:1.对CPU资源非常敏感、2.无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败、3.产生大量内存碎片
- 垃圾收集线程与用户线程(基本上)可以同时工作
3.2 G1收集器(唯一一款全区域的垃圾回收器)
G1(Garbage First)垃圾回收器是用在heap memory很大的情况下,把heap划分为很多很多的region块,然后并行的对其进行垃圾回收;G1垃圾回收器在清除实例所占用的内存空间后,还会做内存压缩。G1垃圾回收器回收region的时候基本不会STW,而是基于 most garbage优先回收(整体来看是基于"标记-整理"算法,从局部(两个region之间)基于"复制"算法) 的策略来对region进行垃圾回收的
- 能充分利用多CPU、多核环境下的硬件优势;
- 可以并行来缩短(Stop The World)停顿时间;
- 也可以并发让垃圾收集与用户程序同时进行;
- 分代收集,收集范围包括新生代和老年代
- 能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
- 能够采用不同方式处理不同时期的对象;
- 应用场景可以面向服务端应用,针对具有大内存、多处理器的机器;
- 采用标记-整理 + 复制算法来回收垃圾