目录
一、JVM 内存区域划分
JVM 也就是启动的时候,会申请到一整个很大的内存区域
JVM 是一个应用程序,要从操作系统这里申请内存(相当于租了个写字楼)
JVM 就根据需要,把整个空间,分成几个部分,每个部分各自有不同的功能作用
1、栈
2、堆
堆,是整个 JVM 空间最大的区域
new 出来的对象,都是在 堆 上,类的成员变量,也是在堆上面
堆是一个进程只有一份的,栈是每个线程有一份,一个进程有多个
堆,多个线程用的都是同一个堆,栈,每个线程用自己的栈
每个 jvm ,就是一个 java 进程,如果弄两个 java 进程,就是两个 jvm 了
3、元数据区(方法区)
类对象,常量池,静态成员等都存放在这里
如何判断某个变量在哪个区域上?
1、局部变量:在 栈 上
2、普通成员变量:在 堆 上
3、静态成员变量:在 方法区 / 元数据区
二、JVM 类加载机制
1、类加载机制介绍
准确的来说,类加载就是 .class 文件,从文件(硬盘)被加载到内存中(元数据区)这样的一个过程
加载:把 .class 文件找到(找的过程),打开文件,读文件,把文件内容读到内存中
验证:根据 jvm 虚拟机规范,检查下 .class 文件格式是否正确
准备:给类对象分配内存空间(现在元数据区占个位),此时内存初始化成全为0,也会使静态成员设置成 0 值
解析:初始化字符串常量,把符号引用转为直接引用
初始化:调用构造方法,进行成员的初始化,执行代码块,静态代码块,加载父类 .....
把符号引用转为直接引用:字符串常量,得有一块内存空间,来存这个字符的实际内容,还得有一个引用,来保存这个内存空间的起始地址
在类加载之前,字符串常量,此时是处在 .class 文件中的,此时 “引用” 记录的并非字符串常量的真正的地址,而是它在文件中的 “偏移量” 这个东西(或者是个占位符)
类加载之后,才真正把这个字符串常量给放到内存中,此时才有 “内存地址”,这个引用才能真正被赋值成指定内存地址(直接引用)
类加载到底什么时候会触发?
不是 java 程序一运行,就把所有的类都加载的,而是真正用到才加载(懒汉模式)
1、构造 类 的实例
2、调用这个类的静态方法 / 使用静态属性
3、加载子类,就会先加载其父类
一旦加载过之后,后续再使用,就不必重复加载了
2、双亲委派模型(经典问题)
双亲委派模型,描述的是这个加载,找 .class 文件的基本过程
JVM 默认提供了三个类加载器,他们三个各有分工
1、BootstrapClassLoader ,负责加载标准库中的类(Java 规范,要求提供哪些类,无论是哪种 JVM 的实现,都会提供一样的类)
2、ExtensionClassLoader ,负责加载 JVM 扩展库中的内容(规范之外,由实现 JVM 的厂商 / 组织,提供的额外的功能)
3、ApplicationClassLoader ,负责加载用户提供的第三方库 / 用户项目代码中的类
上述三个类,存在 “父子关系”,(不是父类子类,相当于每个 classLoader 有一个 parent 属性,指向自己的父 类加载器)
上述三个类加载器如何配合工作?
首先,加载一个类的时候,是先从 ApplicationClassLoader 开始,但是 ApplicationClassLoader 会把加载任务交给父亲,让父亲去进行
于是 ExtensionClassLoader 要去加载了,但是也不是真加载,而是再委托自己的父亲
于是 BootstrapClassLoader 要去加载了,也是想委托自己的父亲,结果发现自己的父亲是 null,没有父亲 / 父亲加载完了,没找着类,才由自己进行加载
此时,BootstrapClassLoader 就会搜索自己负责的标准库目录相关的类,如果找到就加载,如果没找到,就继续由子 类加载器 进行加载,于是 ExtensionClassLoader 真正搜索扩展库相关的墓库,如果找到就加载,如果没找到就还是由子 类加载器 进行加载
ApplicationClassLoader 真正搜索用户项目相关的目录,没找到就由子类加载器进行加载(由于当前没有 子 了,就只能抛出 类找不到 这样的异常)
为什么要有上述这个顺序?
上述这套顺序,其实出自于 jvm 实现代码的逻辑,这段代码大概是类似于 “递归” 的方式写的
这个顺序,最主要的目的就是为了保证 BootstrapClassLoader 能够先加载,ApplicationClassLoader 能够后加载,这就可以避免用户创建了一些奇怪的类,引起不必要的 bug
假设用户在自己的代码中,写了个 java.lang.String 这个类,按照上述加载流程, jvm 加载的还是标准库的类,不会加载到用户自己写的类
这样就能保证,即使出现上述问题,也不会让 jvm 已有代码混乱,最多是用户自己写的类不生效罢了
再另一方面,类加载器其实是可以用户自定义的,上述三个类加载器是 jvm 自带的,用户自定义的类加载器,也可以加入到上述流程,就可以和先有的加载器配合使用
站在 jvm 的角度,类加载的核心应该是:解析 .class 文件,解析每个字节是干什么的
破坏双亲委托模型:
自己写的类加载器,可以去遵守也可以不遵守,是否遵守,主要是看需求
tomcat ,,去加载 webapp 这里就是单独的类加载器,不遵守双亲委派模型
三、JVM 垃圾回收机制 GC
1、了解 GC
什么是垃圾?
垃圾指的是不再使用的内存
垃圾回收就是把不用的内存帮我们自动释放了
C 语言中有 malloc ,C++ 有 new ,这些属于动态申请内存(在堆上申请一块内存空间)
上述内存空间需要手动方式进行释放:free,delete
如果不手动释放,这块内存的空间就会一直存在,一直存在到进程结束(堆上的内存生命周期比较长,不像 栈,栈的空间会随着方法的执行结束,栈帧自动销毁而自动释放,堆则默认不能自动释放)
这可能会导致一个很严重的问题:内存泄露
如果内存一直占着,又不释放,就会导致剩余空间越来越少,进一步导致后续的内存申请操作申请失败
因为内存泄露是一个很严重的问题,因此大佬们就想了一些办法来解决这个问题,垃圾回收(GC)是其中最主流的一种方式
GC 的好处:非常省心,让程序员写代码简单点,不容易出错
GC 的坏处:需要消耗额外的系统资源,也有额外的性能开销
另外,GC 这里还有一个比较关键的问题:STW 问题(stop the world)
1、如果有时候,内存中的垃圾已经很多了,此时触发一次 GC 操作,开销可能非常大,大到可能就把系统资源吃了很多
2、另一方面,GC 回收垃圾的时候,可能会涉及一些 锁 操作,导致业务代码无法正常执行
JVM 里面有很多块内存区域,GC 主要是针对其中的 堆 进行释放
GC 是以 “对象” 为基本单位进行回收的!!!(而不是字节)
GC 回收的是整个对象都不在使用的情况,而一部分使用,一部分不使用的对象,暂时先不回收
要回收,就是回收整个对象,而不会回收半个对象,因此我们说 GC 是以对象为基本单位进行回收的
这样设定,目的就是 “简单”
2、GC 实际工作过程:
(1)找到垃圾 / 判定垃圾
判定哪个对象以后一定不用了,哪个对象后面还可能使用
关键思路:抓住这个对象,看看到底有没有 “引用” 指向它
在 java 中,只有一条路:通过引用来使用!!!
如果一个对象,有引用指向它,就可能被使用到,如果一个对象没有引用指向,就一定不会再被使用了
具体如何知道对象是否有引用指向?
两种典型实现:
(1)引用计数 [不是 java 的做法]
给每个对象,都分配了一个计数器(整数)
每次创建引用指向该对象,计数器就 +1,每次该引用被销毁了,计数器就 -1
缺点:
1、内存空间浪费的多(利用率低)
每个对象都得分配一个计数器,如果对象特别多,占用的额外空间就会很多(尤其是对象特别小的情况)
2、循环引用的问题
接下来,如果 a 和 b 引用销毁,此时 1 号对象和 2 号对象引用计数都 -1,但是结果都还是 1,不是 0
虽然不是 0,不能释放内存,但是实际上这两个对象已经没有办法被访问到了
(2)可达性分析 [ java 的做法]
java 中的对象,都是通过引用来指向并访问的
经常,是一个引用指向一个对象,这个对象里的成员,又指向别的对象
整个 java 中所有的对象,就通过类似于上述的关系,通过这种 链式 / 树形 结构,整体给串起来
可达性分析,就是把所有这些对象被组织的结构,视为是 树,从树根节点出发,遍历树,所有能被访问到的对象,标记为 “可达”,不能被访问到的,就是不可达
每次你 new 一个对象,jvm 都会记录下来,jvm 会知道一共哪些对象,每个对象的地址...
相当于 jvm 自己捏着一个所有对象的名单,通过上述遍历,把可达的标记出来,剩下的不可达的就可以作为垃圾进行回收了
可达性分析,需要进行类似于 “树遍历” 这个操作,相比于引用计数来说,肯定要更慢一些,但是空间利用率提高了,同时解决了循环引用的问题
但是速度慢没关系,上述可达性分析遍历操作,并不需要一直执行,只需要每隔一段时间,分析一遍就可以了(虽迟,但到)
进行可达性分析遍历的起点,称为:GCroots,主要有以下几种:
1、栈上的局部变量
2、常量池中的对象
3、静态成员变量
一个代码中有很多这样的起点,把每个这样的起点都往下遍历一遍即可,就完成了一次扫描过程
(2)对象的释放
如何清理垃圾?,主要是三种基本方法
1、标记清除
简单粗暴,但是存在内存碎片问题
被释放的内存空间是零散的,不是连续的,而申请空间要求的是连续空间
这就可能会导致总的空闲空间很大,但是每一个具体空间都很小,此时申请大一点的空间就会失败了
2、复制算法
复制算法,就是把不是垃圾的对象,复制到另外一半,然后把整个空间删除掉
每次触发算法,都是向另一侧复制,内存中的数据拷贝过去
缺点:空间利用率低,如果垃圾少,有效对象多,复制成本就比较大了
3、标记整理
类似于顺序表删除中间元素,会有元素搬运的操作,保证了空间利用率,同时也解决了内存碎片的问题
缺点:效率不高,如果要搬运的空间比较大,开销也很大
上述做法,并不完美,基于上述这些基本策略,我们又有了一个复合策略:“分代回收”
把垃圾回收,分成不同的常见,有的常见使用这个算法,有的场景使用那个算法,各展所长
分代是怎么分的?
基于一个经验规律:如果一个东西存在的时间比较长了,大概率会继续长时间持续存在下去
java 对象,要么就是生命周期特别短,要么就是特别长,我们就根据生命周期的长短,分别使用不同的算法
我们给对象引入一个概念:年龄(熬过 GC 的轮次)
我们可以认为:年龄越大,对象存在的时间就越久
伊甸区:刚 new 出来的,年龄是 0 的对象
熬过一轮 GC,对象就要被放到幸存区了
虽然看起来幸存区很小,伊甸区很大,但是一般够放(根据经验规律,大部分的 java 对象都是 “朝生夕死”,生命周期特别短)
伊甸区 => 幸存区 使用的是 “复制算法”
到了幸存区域之后,也要周期性的接受 GC 的考验,如果变成垃圾,就要被释放,如果不是垃圾,就拷贝到另一个幸存区(这两个幸存区同一时刻只存在一个,两个之间使用复制算法来回拷贝)
由于幸存区体积不大,此处的空间浪费,也能接受
如果这个对象,已经在两个幸存区中,来回拷贝很多次,就要进入老年代
老年代,都是年纪大的对象,生命周期普遍更长
针对老年代,也要周期性GC 扫描,但是频率更低了,如果老年代的对象是垃圾了,使用标记整理的方式进行释放
上述是 GC 中典型的垃圾回收算法
实际上,JVM 在实现的时候,会有一些差异,事实上,JVM 有很多的 “垃圾回收实现”,称为 垃圾回收器
回收期具体的实现做法,会按照上述算法思想展开,但是会有一些 变化 / 改进,不同的垃圾回收器,侧重点不同,这里就不再介绍了