💕"Echo"💕
作者:Mylvzi
文章主要内容:JVM内部世界(内存划分,类加载,垃圾回收)
关于JVM的学习主要掌握三方面:
- JVM内存区的划分
- 类加载
- 垃圾回收
一.JVM内存区的划分
当一个Java进程开始执行时,JVM会首先向操作系统申请一块较大的内存
来提供进程在执行过程中所需的空间,而JVM为了更加高效,规范化的管理数据,将这块内存划分为5个区域
- 方法区/元数据区
- 栈区
- 堆区
- 程序计数器
- 本地方法区
1.方法区/元数据区
主要存放与
类相关
的信息,如静态变量,方法等
Java中的一个(.class)文件在运行时就会被加载为一个类对象
,类对象中包含与类相关的数据和方法,这些信息都被存储到元数据区(方法区)中
2.堆区
存放实例化的对象(new)
存储实例化出的对象,包括对象中包含的实例变量(成员变量)
注:实例方法是存储在方法区之中的,属于类对象的信息
3.程序计数器
存放进程在执行过程中的
字节码指令
在方法区的地址
或者当前正在执行的方法的地址
程序计数器是JVM内存中占用内存比较小的一部分区域,当一个Java进程运行时,文件中包含的代码就都被转化为字节码指令
,字节码指令是JVM可以识别和执行的最小单位,通过字节码指令来完成代码中的逻辑
程序计数器的一个很大的用途是用在多线程
之中,一个进程包含多个线程,每一个线程都有自己的私有的程序计数器
,用于存储当前线程的执行指令,当由一个线程跳转到另一个线程时,需要保存当前线程的执行的指令的位置,以便跳转回该线程时能够继续执行代码,程序计数器就起到了这样的作用,由此也可以看出,程序计数器也是线程安全的
4.栈区
用于存放局部变量和方法的调用关系,每调用一次方法,就会在栈区中创建出栈帧
,来表示一个方法调用,随着方法的执行完毕,栈帧又会从栈区之中脱离(出栈)
5.本地方法区
存储本地方法
在Java中,有很多方法的底层是通过C++进行编写的,在源码中我们无法看到背后的具体执行逻辑,但是在开发中也会使用,所以就划分出本地方法区专门存储这些方法
一个经典面试题:
分别说出一下三个变量在内存中的位置
class Test {
public int a;
public static int b;
}
Test t = new Test();
- a:通过new Test()创建,和new出来的对象一样位于堆区之中(成员变量)
- b:静态变量,位于方法区
- t:引用变量,位于栈区之中
注意:
Test t = new Test();
t
并不是我们实例化出的对象,而是一个引用
,真正的对象是在堆区中存储的,t
就类似于C语言中的指针,用于指向实例化的对象,但t本身并不是对象,仅仅是一个引用类型的变量
总结:
每个线程
都有自己私有的栈空间
和程序计数器,同一个进程里的所有线程共用方法区和堆区
找到垃圾的方式有两种:
- 引用计数
- 可达性分析
可达性分析的核心是通过一组线程周期性的扫描所有的对象
,在一次扫描过程中,如果扫描到了对应的对象,就标记为可达,表示该对象仍然存在,如果没有扫描到,JVM就会执行回收
内部是通过一个N叉树
的方式来组织各种对象的,通过扫描这棵树的方式来进行可达性的分析
二.类加载
类加载部分主要掌握两部分:
- 类加载的过程
- 双亲委派模型
1.类加载过程
类加载就是
.class文件被JVM转换为类对象
的过程
在完成源代码的编写之后,源代码会被转换为字节码文件,这些文件通常以.class作为后缀,JVM需要读取到这个.class文件并将其转换为类对象,并保存到方法区中才能运行程序
所谓程序运行,执行代码本质上就是要执行方法
,要执行方法首先要知道这些方法的指令(字节码),而这些指令是和创建的类紧密相连的
类加载的过程分为5步:
1.加载
分为三步:
- 找到.class文件
- 打开.class文件
- 读取.class文件
2.验证
对于生成的.class文件,JVM是有着严格的格式规范的,JVM在读取.class文件之后,首先会对格式进行验证,具体的格式在Java的标准文档上有介绍
3.准备
为类对象分配内存,注意这里仅仅只是分配内存,并没有进行初始化
4.解析
符号引用 --> 直接引用
文件偏移量 --> 内存地址
Java源代码中的字符串引用也会被保存到.class文件之中:
类似:
String s = "hello";
引用s
在代码中实际上是存储的字符串的地址
,引用s被加载到.class文件
之中,在文件里面是没有地址
这个概念的,但是初始化s就必须要指明其所指向的对象的地址,在文件系统里,我们通过引用和指向对象之间的距离文件偏移量
来替代地址
这个概念
比如当引用s被保存到.class文件之中,和其指向的字符串"hello"在文件中的存储位置相差1000,则文件偏移量就是1000
5.初始化
类对象初始化 把各个属性初始化好 还需要初始化static成员,静态代码块,加载父类
2.双亲委派模型
在上面类加载的第一步中,第一步找到.class
文件是一个比较繁琐的过程,在Java中,通过类加载器来完成寻找.class文件的过程,类加载器是JVM的一个模块,内置了三个类加载器帮助我们完成找.class文件的过程,分别是:
注:上面的三个类加载器并不是继承
关系,之所以叫爷父子是因为每个类加载器中都有一个属性parent
,这个属性指向上一级的加载器
完整过程:
- 给一个
全限定类名
,作为寻找的依据(比如java.lang.String) - 以Application ClassLoader为入口,但是先不从自己的库中寻找(负责当前项目的库和第三方库),而是先交给Extension ClassLoader加载器
- Extension ClassLoader加载器也不会直接在自己的库中寻找(负责JDK的扩展库),而是先交给BootStrap ClassLoader类加载器
- 同样的BootStrap ClassLoader也不会直接在自己的库中寻找,而是交给自己的父加载器,但是并没有父加载器,就只能在自己的库中寻找,如果找到了,就执行加载操作的剩余步骤,如果没找到就交给子加载器(Extension ClassLoader)
- Extension ClassLoader此时就会从自己的库中寻找对应的.class文件,如果找到了,执行加载操作的剩余步骤,没找到,交给子加载器(Application ClassLoader)
- Application ClassLoader从自己的库中寻找,如果找到了,执行加载操作的剩余步骤,没找到,就会抛出
ClassNotFound
异常
以上就是查找.class文件的完整过程,上述寻找的过程就被称为双亲委派模型
,这里的双亲其实是翻译问题,英文是parent
,而不是parents
,应该翻译为双亲之一,实际上在上述类加载器中,也只有父子这种关系
双亲委派模型实际上就是一个优先级问题
,是为了保证标准库中的类先被加载,其次是扩展库,最后才是当前项目和第三方库
比如你自己写了一个形如java.lang.String的类,在加载时会首先从标准库中寻找,而不是你自己的项目库
实际上,双亲委派模型也不是不能打破的,比如tomcat服务器,在进行类加载时只会在webapp目录中寻找,如果没找到,也不会从其他地方寻找
3.垃圾回收机制(GC)
在C语言中我们学习过动态内存管理这一章节,通过malloc/realloc函数申请动态的内存,通过free来释放申请的动态内存,对于动态内存来说,最需要注意的一点是要及时通过free来释放申请的内存,如果不及时释放,就有可能造成内存泄露问题
在C++里面也是,都是需要通过手动的释放申请的内存(C++中是delete方法),这种手动释放内存
的方式对于程序员来说是一个致命杀手,会常常突然出现,而且难以发现(往往是因为长时间的大量不释放内存所导致的),为了解决这种问题,最好的方法就是把释放内存这个操作交给计算机去执行
在Java中就引入了**垃圾回收机制(Garbage Collection)**来自动的完成内存的释放,可有这样的一个比喻说明C++和Java的垃圾回收机制的不同–“C++是手动挡,Java是自动挡”
GC的工作过程主要有以下两步:
- 找到垃圾
- 释放垃圾
1.找到垃圾
释放垃圾的第一步首先需要找到"垃圾",这里的垃圾就是不再使用的内存.具体找的方式大体上相同,都是需要有一组线程去不断的扫描的当前所有的对象,判断对象是否仍被引用,如果没有引用就认为是"垃圾"
不同的语言实现的方式有所差异,大概分为以下两种:
- 引用计数
- 可达性分析
1.引用计数
为new出来的出现单独创建一块内存空间,当做计数器
,描述这个对象有多少引用指向
如果引用计数为0,就代表没有引用指向,也就代表此对象成为"垃圾",可以被释放
引用计数的问题
1.需要额外占用内存空间
引用计数需要额外的内存当做计数器,计数器少说也得2个字节,如果对象本身很小,那么计数器的内存占总体的内存的比例就会很大,而且随着对象数目的增多,这种额外的内存开销就不容忽视
2.存在循环引用问题
如果两个对象分别引用,就会形成环形引用,就有可能出现永远无法释放的问题
class Test {
public Test t;
}
Test a = new Test();
Test b = new Test();
// 在内部分别引用
a.t = b;
b.t = a;
// 置空
a = null;
b = null;
在上述代码中,每置空之前,创建出的两个对象的引用计数都是2,分别给a,b置空,但是内部t对象仍在引用,所以创建的两个Test对象的引用变为1
此时a和b被销毁了,在代码中不可能再访问到这两个对象,但是此时这两个对象的引用计数不为0,要想释放对象1,需要先释放对象2,要想释放对象2,需要先释放对象1,构成了逻辑上的死环,这两个对象就永远无法进行释放了
2.可达性分析
Java的GC机制采用的是可达性分析,通过扫描的方式,从特定对象出发(GC Root),对扫描到的对象标记为可达
,没有扫描到的对象就认为是不可达的,需要当做垃圾进行释放
可达性分析本质上是一种使用时间换空间的方式,通过一组扫描线程,不断的对所有的对象进行扫描,且这种扫描是周期性的,遍历方式类似于树的遍历(底层很可能是N叉树)
GC Root是扫描过程的起点,通常包括以下几种类型:
- 活动线程的本地变量和输入参数
- 静态对象的引用
- 活动线程的所有类对象
2.释放垃圾
释放垃圾的方法主要有三种:
- 标记清楚
- 复制算法
- 标记整理
1.标记清除
对于标记的对象,直接释放
标记清除是一种简单粗暴的方式,垃圾在哪里,就直接释放
演示:
缺陷:
- 会导致大量内存碎片的出现.申请内存是直接申请一个连续的空间,内存碎片的出现会导致可申请的连续空间变小,比如如果上述区域2的内存空间较小,新的对象所需的内存空间大于2,那么2区域的内存就永远无法使用了,随着内存碎片的增多,这种情况会更加明显
2.复制算法
将内存
一分为2
,一半用于对象的存储,一般用于复制
上述标记清楚的方式最大的缺陷就在于连续空间的减少,通过复制算法就能解决上述问题
将区域2和4删除之后,剩余的区域1和3一起复制到内存的另一半,这样当有新的对象尝试申请内存时,就可以利用到左侧的连续的内存空间
但是复制算法的方式的缺点也很明显:
- 内存利用率不高,整个内存一分为2
- 如果有效的数据很多,挪动一次需要移动的数据很多,开销不容忽视
3.标记整理
上述两种方法都有着各自的缺陷,通过标记整理的方式能够进一步的提高效率和内存利用率
标记整理处理垃圾的方式类似于顺序表删除任意位置元素的实现
,在删除之后,需要从后往前挪动数据,来保证顺序表的连续性
标记整理也是这样,当有垃圾被回收之后,就把有效数据从后往前挪动,保证内存利用的连续性
但其实这种方式的开销也很大,也需要大量的挪动数据
JVM采用的实际上是一种更加高效的方式,利用一些经验规律
,达到内存利用和垃圾回收效率的最大化
,JVM内部采用的方式叫做分代回收
JVM把内存分为几个部分
- 伊甸区
- 幸存区
- 老年区
新new出来的对象会首先被存储到伊甸区(新生代)之中,经验表明,new出来的对象的生命周期是很短的,往往短时间内就会随着方法的结束而销毁,在一次扫描过程中就能被释放,没有被释放的对象就存储到幸存区之中
由于对象的销毁很快,大部分的对象在伊甸区中就被销毁了,所以在幸存去之中存储的对象很少,就比较适合使用复制算法,幸存区 的内存被一分为二.
幸存区也会被扫描线程扫描,不过扫描的频率比伊甸区之中要低很多,每扫描一次就利用复制算法对垃圾进行回收,往往在幸存区之中要进行多轮扫描
经过多轮扫描之后,如果仍有对象存储到幸存区之中,这些对象就会被转移到老年区之中,老年区的扫描频率更低
为什么扫描的频率越来越低呢?这其实也是一种经验规律,如果对象在第一次(伊甸区)之中没有被释放,那么其生存时间就比较长,证明该对象在短时间内不会被清除,如果在幸存区之中经过多轮扫描还是存活,就更加证明该对象在短时间之内不会被清除,不需要频繁的去扫描该对象
分代回收的这种机制就像是找工作,新生代就是笔试,对象多,淘汰率高,通过笔试就是进入了面试(幸存区),还要经过多轮的面试(在幸存去反复的被扫描),都通过了就进入老年代(拿到offer了,此时检查的频率就降低了,但是如果被标记为垃圾,就会被淘汰
以上就是JVM