文章目录
- 一. JVM内存区域划分
- 二. JVM类加载机制
- 2.1 类加载整体流程
- 2.2 类加载的时机
- 2.3 双亲委派模型(经典)
- 三. JVM垃圾回收机制(GC)
- 3.1 GC实际工作过程
- 3.1.1 找到垃圾/判定垃圾
- 1. 引用计数(不是java的做法,Python/PHP)
- 2. 可达性分析(Java的做法)
- 3.1.2 清理垃圾
- 1. 标记清除
- 2. 复制算法
- 3. 标记整理
- 4. 分代回收
一. JVM内存区域划分
JVM启动的时候,会申请到一整个很大的内存区域.JVM是一个应用程序,要从操作系统里申请内存.JVM就根据需要,把空间分为几个部分,每个部分各自有不同的功能.具体划分如下:
-
Native Method Stacks(本地方法栈)
:native表示是JVM内部的C++代码.就是给调用native方法(JVM内部的方法)准备的栈空间. -
Program Counter Register(程序计数器)
:记录当前线程执行到那个命令.(很小的一块存一个地址)每个线程有一份. -
JVM Stack(虚拟机栈)
:虚拟机栈是给Java代码使用的栈. 此栈是JVM中一个特定的空间,对于 JVM虚拟机栈
,这里存储的是方法之间的调用关系.整个栈空间内部,可以认为是包含很多个元素(每个元素表示一个方法),把这里的每个元素称为**“栈帧”,这一个栈帧里,会包含这个方法的入口地址,方法的参数,返回地址,局部变量等.
而对于 本地方法栈 , 存储的是native方法之间的调用关系**.
虚拟机栈,不是只有一个,而是有很多个,每个线程都有一个.
由于函数调用,是有先进后出特点的.此处的栈,也是先进后出的.
栈空间整体一般都不会很大,但是每个栈帧其实占得空间比较小,一般代码无限递归才会出现栈溢出情况. -
Heap(堆)
:整个JVM空间最大的区域.new出来的对象,类的成员变量,都在堆上.**堆是一个进程只有一份,栈是每个线程有一份.**一个进程里有多个线程.所以一个进程有多个栈.每个jvm就是一个java进程.
注意这里的常见说法:栈是线程私有的.(此说法不完全对).私有的意思是我的你用不了.实际上,一个线程栈上的内容,可以被另一个线程使用.比如通过变量捕获,一个线程可以访问到另一个线程的局部变量.
-
Metaspace(元数据区)
:即方法区.一个进程里只有一块,多个线程共用一块. -
小结: -
局部变量 在 栈
-
普通成员变量 在 堆
-
静态成员变量 在 方法区/元数据区
二. JVM类加载机制
2.1 类加载整体流程
类加载,准确的说,类加载就是.class文件,从文件(硬盘)被加载到内存中(元数据区)这样的过程.这个过程是非常复杂的.
.class文件可以有多个类对象
.class文件是编译后的Java源代码,它包含了编译后的字节码指令.
类加载主要分为以下几个过程:
-
加载
:把.class文件找到(找的过程),打开文件,地文件,把文件内容读到内存中. -
验证
:检查.class文件格式是否正确..class
文件是一个二进制文件,这里的格式是有严格说明的,官方提供了JVM虚拟机规范.规范文档上描述了.class的格式:
java代码中写的类的所有信息,都会包含在上述.class文件中.使用二进制的方式重新组织. -
准备
:给类对象分配内存空间(此时内存初始化为全0) -
解析
:针对字符串常量进行初始化,把符号引用转为直接引用.
字符串常量,得有一块内存空间,存这个字符的实际内容,还得有一个引用,用来保存这个内存空间的起始地址.
在类加载之前,字符串常量,此时是处在.class文件中的,此时这个"引用"记录的并非是字符串常量的真正地址,而是它在文件中的"偏移量".(或者是占位符)(符号引用)
类加载之后,才真正在把这个字符串常量放到内存中,此时才有"内存地址",这个引用才能被真正赋予成指定内存地址.(直接引用)举个🌰: 小学的时候,学校组织以班为单位大家看电影,
但是到电影院之前我不知道自己的真实地址(真实座位),但是我知道我前面是A,后面是B,此时到了电影院,我们仨也是挨着的.(符号引用)
此时我只知道自己的相对位置. 到了电影院之后,老师组织同学们坐下.我坐下之后才知道我们的真正位置.(直接引用) -
初始化
:调用构造方法,进行成员初始化,执行代码块,静态代码块,加载父类.
2.2 类加载的时机
一个类,不是java程序已运行,就把所有的类都加载了.而是真正使用的时候才加载(懒汉模式)
在以下场景中会触发类加载:
- 构造类的实例
- 调用这个类的静态方法/使用静态属性
- 加载子类,就会先加载其父类
以上都是用到了再加载,一旦加载过后,后续使用就不用再加载.
2.3 双亲委派模型(经典)
此过程发生在加载阶段.
双亲委派模型:描述的是加载 找.class文件的基本过程.
JVM默认提供了三个类加载器:
启动类加载器(BootStrap ClassLoader)
:负责加载Java标准库中的类(无论是哪种JVM的实现,都会提供这些一样的类)扩展类加载器(Extension ClassLoader)
:负责加载JVM扩展库中的类.(由实现JVM的厂商/组织提供的额外功能)应用程序类加载器(Application ClassLoader)
:负责加载项目中自己写的类以及第三方库中的.
上述三个类,存在"父子关系"(不是父类子类,相当于每个class loader有一个parent属性,指向自己的父 类加载器).
那么上述类加载器如何配合工作呢?
首先加载一个类的时候,实现从ApplicationClassLoader
开始.但是ApplicationClassLoader
会把家在任务交给父亲,让父亲去进行.于是ExtensionClassLoader
要去加载了,但是也不是真的加载,而是再委托给自己的父亲.于是BootstrapClassLoader
要去加载了,也是想委托给自己的父亲,结果发现,自己的父亲是null. 没有父亲/父亲加载完了,没找着类,才由自己进行加载,此时BootstrapClassLoader
就会搜索自己负责的标准库目录的相关的类,如果找到就加载,如果没找到,就继续由子类加载器进行加载.然后由ExtensionClassLoader
真正搜索扩展库相关的目录,如果找到了就加载,如果没找到就由子类加载器进行加载.然后由ApplicationClassLoader
真正搜索用户项目相关的目录,如果找到就加载,如果没找到就由子类加载器进行加载(由于当前没有子了,只能抛出 类找不到这样的异常)
那么为什么要有上述顺序呢?
上述这套顺序其实是出自于JVM实现代码的逻辑,这段代码大概是使用递归的方式写的.但这个顺序,最主要的目的就是为了保证Bootstrap
能够先加载,Application
能够后加载.这样就可以避免说因为用户创建了一些奇怪的类,引起不必要的bug.
假设用户在自己的代码中写了个java.lang.String
按照上述加载流程,此时JVM加载的还是标准库的类,不会加载到用户自己写的这个类. 这样就能保证,即使出现上述问题,也不会让jvm已有代码混乱,最多是用户自己写的类不生效.
另一方面,类加载器,其实是可以用户自定义的.上述三个类加载器是jvm自带的.用户自定义的类加载器,也可以加入到上述流程中,就可以和现有的加载器配合使用,
三. JVM垃圾回收机制(GC)
垃圾:指的是不再使用的内存空间.垃圾回收,就是把不用的内存帮我们自动释放.
在执行程序时,要在堆上申请一块内存空间.在C/C++中,上述内存空间需要手动方式进行释放.如果不手动释放的话,这块内存的空间就会持续存在,一直存在到进程结束(堆上的内存生命周期比较长.不像栈,栈的空间会随着方法执行结束,栈帧销毁而自动释放,堆则默认不能自动释放.)那么这就可能会导致一个严重的问题—内存泄漏.如果内存一直占着不用,又不释放,就会导致剩余空间越来越少.进一步导致后续的内存申请操作失败.因此,大佬们想了一些办法,来解决内存泄漏的问题.
GC是其中最为主流的方式.(Java Go Python PHP JS大部分的主流语言都是使用GC解决内存泄漏的问题的)
但GC中有一个比较关键的问题:STW问题(stop the world)
如果有时候,内存中的垃圾已经非常多了,此时触发一次GC操作,开销可能非常大,大到可能把系统资源吃了很多.另一方面GC回收垃圾的时候可能会涉及到一些锁操作.导致业务代码无法正常执行.这样的卡顿,极端情况下,可能是出现即使毫秒甚至上百毫秒.
JVM中有很多内存区域:
- 堆
- 栈
- 程序计数器
- 元数据区
- …
GC主要是针对堆进行释放的.
GC是以"对象"为基本单位,进行回收的.而不是字节!
GC回收的是整个对象都不再使用的情况.而一部分使用一部分不使用的对象,暂且不回收.
3.1 GC实际工作过程
- 找到垃圾/判定垃圾
- 再进行对象的释放
3.1.1 找到垃圾/判定垃圾
找到垃圾/判定垃圾的关键就是看到底有没有"引用"指向它.如果一个对象,有引用指向它,就可能被使用到;如果一个对象,没有引用指向了,就不会再被使用了.
具体如何知道对象是否有引用指向呢?
1. 引用计数(不是java的做法,Python/PHP)
给每个对象分配了一个计数器(整数).每次创建一个引用指向该对象,计数器就+1,每次该引用被销毁,计数器就-1.
{Test t = new Test();//Test对象的引用计数1Test t2 = t;//t2也指向了t,引用计数2Test t3 = t;//引用计数就是3
}
//大括号结束,上述三个引用超出作用域失效.此时引用计数就是0了,此时new Test()对象就是垃圾了.
这个办法简单有效,但是java没有采用.但是这个办法也有一定的缺点:
1. 内存空间浪费的多(利用率低)
每个对象都要分配一个计数器,如果按四个字节计算.代码中的对象非常少就无所谓.但是如果对象特别多,占用的额外空间就会很多,尤其是每个对象都比较小的情 况.例如:一个对象体积1k,此时多4个字节,无所谓.一个对象体积事4字节,此时多4个字节,相当于体积扩大一倍.
2. 存在循环引用的问题
class Test{Test t = null;
}
Test a = new Test();//1号对象,引用计数是1
Test b = new Test();//2号对象,引用计数是1
a.t = b;//a.t也指向2号对象了,2号对象的引用是2
b.t = a;//b.t也指向1号对象了,1号对象的引用是2
接下来,a和b引用销毁,此时a和b计数-1,但引用计数结果还都是1,不能释放资源,但实际这两个对象已经无法访问了。python/php使用引用计数,需要搭配其他机制来避免循环引用.
举个🌰:
2. 可达性分析(Java的做法)
把对象之间的引用关系理解成了一个树形结构,从一些特殊的起点出发,进行遍历,只要能遍历访问到的对象,就是"可达的",再把"不可达的"当作垃圾即可.
就像上述的二叉树,root指向根节点a.
如果root.right.right = null
,此时就表示f不可达
如果root.right = null
此时就表示c不可达,f也不可达了。
可达性分析的关键要点,进行上述遍历,需要有"起点"被称为gcroots
.以下常做为根起点:
- 栈上的局部变量(每个栈的每个局部变量,都是起点)
- 常量池中引用的对象
- 方法区中,静态成员引用的对象
可达性分析,总的来所,就是从所有的gcroots的起点出发,看看该对象里又通过引用能访问那些对象,依次遍历,把所有可以访问的对象都给遍历一遍(遍历的同时把对象标记成"可达"),剩下的遍历不到的对象就是"不可达".
可达性分析的特点:可达性分析克服了引用计数的两个缺点,但是也有自己的缺点:
- 消耗的时间更多,因此某个对象成了垃圾,也不一定能第一时间发现,因为扫描的过程,需要消耗时间
- 在进行可达性分析的时候,依次遍历,一旦这个过程中,当前代码中的对象引用关系发生了变化,这就会使情况变得更加复杂。比如,当一个对象指向下一个对象,刚遍历完这个对象,这个对象的引用变了。因此,我们为了更准确的遍历,需要让其他的业务线程暂停工作(STW问题)。
3.1.2 清理垃圾
主要有三种基本做法:
1. 标记清除
这种策略,就是直接把垃圾对象的内存释放,但是这个方式的缺点就是会产生内存碎片.
我们从内存中申请空间的时候,都是整块的连续的空间,现在这里空闲的空间是离散的,独立的空间,总的空间可能很大.假如总的空闲的空间可能超过了1G,但是你想申请500MB可能都不一定申请到。
2. 复制算法
为了解决内存碎片的问题,又引入了复制算法.复制算法,是把整个内存空间,分成两半,一次只用一半.
现在将2和4标记为垃圾,要释放垃圾,复制算法会将左边不需要释放内存的空间复制到右边的空间中,然后整体释放左边空间的内存.
复制算法,就是把"不是垃圾"的对象复制到另外一半,然后把整个空间删除掉.
每次触发复制算法,都是向另外─侧进行复制,内存中的数据拷贝过去.
缺点:
- 空间利用率低
- 如果要是垃圾少,有效对象多,复制成本就比较大了~~
3. 标记整理
这种方法,保证了空间利用率,同时也解决了内存碎片问题
但是这种做法的缺点:
- 效率不高如果要搬运的空间比较大,此时开销也很大
4. 分代回收
基于上述这些基本策略,搞了一个复合策略"分代回收"
把垃圾回收,分成不同的场景,有的场景有这个算法,有的场景有那个,各展所长.
分代是怎么分的?
基于一个经验规律:如果一个东西,存在的时间比较长了,那么大概率还会继续的长时间持续存在下去.(要没早就没了,既然存在,肯定有点用)
规律不等于"定律",允许例外,针对大部分情况有效的.
上述规律,对于Java的对象也是有效的.(是有一系列的实验和论证过程)
java的对象要么就是生命周期特别短,要么就是特别长.根据生命周期的长短,分别使用不同的算法.
给对象引入一个概念,年龄.(单位不是年,而是熬过GC的轮次)(经过了这一轮可达性分析的遍历,发现这个对象还不是垃圾.这就是"熬过一轮GC") 年龄越大,这个对象存在的时间就越久.
刚new 出来的,年龄是0的对象,放到伊甸区.(出自圣经,上帝在伊甸园造小人)
熬过一轮GC,对象就要被放到幸存区了.虽然看起来幸存区很小,伊甸区很大,一般够放.
伊甸区到幸存区,采用的是复制算法.
幸存区之后,也要周期性的接受GC的考验.
如果变成垃圾,就要被释放.如果不是垃圾,拷贝到另外一个幸存区(这俩幸存区同一时刻只用一个),在两者之间来回拷贝(复制算法),由于幸存区体积不大, 此处的空间浪费也能接受.如果这个对象已经再两个幸存区中来回拷贝很多次了这个时候就要进入老年代了·
老年代都是年纪大的对象.生命周期普遍更长.针对老年代,也要周期性GC扫描,但是频率更低了
如果老年代的对象是垃圾了,使用标记整理的方式进行释放.