JavaEE初阶34、35
一、JVM内存区域划分
1、java进程是资源分配的基本单位
一个用java写的程序跑起来,就得到了一个java进程,java进程=JVM+上面运行的字节码指令。
java的进程是资源分配的基本单位。让我们举一个生活中的例子,一个办公团队,想要组建一个小公司,因此老板租了一块区域。接下来,老板就需要将这块区域划分为几个区域,使其能进行正常的业务工作,于是,这块区域被划分为:老板办公室、员工工作区、前台、茶水区……java进程就可以类比为拥有这么一块区域,进程将内存区域划分为不同的部分,以保证程序可以正常运行!
2、四个不同的内存区域
java进程将内存区域划分为四个部分,分别是程序计数器、堆、栈、元数据区。
1)程序计数器
程序计数器占比较小的空间,它的作用是保存下一条要执行指令的地址。这里的“下一条要执行的指令”是java的字节码。
2)堆
堆是JVM上最大的空间,我们new的对象都在堆上。
3)栈
栈保存了函数中的局部变量、函数的形参、函数之间的调用关系等信息。
4)元数据区
元数据区以前也称为“方法区”,它主要保存java程序中的指令(指令都是包含在类的方法中)、代码中涉及到的关于类的信息、还有类的static属性
一个java进程中,元数据区和堆是只有一份的。程序计数器和栈则可能有多份(当一个java进程有多个线程的时候,每个线程都有自己的程序计数器和栈)
3、一道题目
有以下代码,请问代码中的a、b、c、d、e、f分别存储在内存中的哪块区域。
class Test{
private int a;
private Test b=new Test();
private static int c;
private static Test d=new Test();
public staic void main(String[] args){
int e=10;
Test f=new Test();
}
}
一个变量存储在哪个内存区域,和变量的形态有关。
1)局部变量-->栈
2)类成员变量-->堆
3)静态成员变量-->元数据区
a:变量a是类的成员变量,存储在堆区
b:变量b是一个引用类型的类成员变量,存储在堆区,它存储的是Test()的地址,new 的Test()对象则存储在堆区。
c:变量c是全局静态变量,存储在元数据区
d:变量d是一个引用类型的全局静态变量,存储在元数据区,new 的Test()对象则存储在堆区
e:变量e是局部变量,存储在栈中
f: 变量f是一个引用类型的局部变量,存储在栈中,new 的Test()对象则存储在堆区
二、JVM类加载过程
1、什么是类对象
我们写一个java程序,得到一个.java文件;然后经过javac编译,得到.class文件(存在硬盘中)。
当运行Java进程的时候,JVM就需要“读取” .class 中的内容,并且执行里面的指令。其中,读取.class 的操作,就是“类加载”,类加载是把类涉及到的字节码,从硬盘读取到内存中的元数据区。也就是把这个.class中的指令转变为一个类对象。
加载一个.class 文件,也就会创建对应的类对象,类对象里面包含了.class文件中的各种信息。比如:
类的名字是啥
类里有哪些属性,每个属性的名字是啥、每个属性的类型是啥、是用publice修饰还是private修饰
类里有哪些方法、每个方法的名字是啥、参数是啥、是用publice修饰还是private修饰
继承的父类是啥
实现的接口有哪些
2、类加载的具体步骤
1)加载:
把.class 文件找到,代码中先见到类的名字,然后进一步找到.class文件,打开并读取文件的内容
2)验证:
验证我们读到的.class 文件的数据,是否正确、是否合法?
3)准备:
准备过程也就是分配内存空间,根据类对象读取到的内容,确定出类对象需要的内存空间,申请这样的内存空间,并且把内存空间的所有内容,都初始化为0。
比如下面的代码,此时将这个代码进行类加载,到达准备阶段时,此时的a被初始化为0,还并不会被设置为10。
class Test{
public static int a=10;
}
4)解析:
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
或许你会很疑惑,什么是符号引用?什么是直接引用?让我来详细介绍一下。
字符串常量池里面的字符串其实是存储在.class文件中(存在硬盘中),它是没有地址的,因为我们通常谈到的地址,是属于内存的概念。但是在类加载之前,数据还没有加载到内存中,所有的字符串是存储在硬盘上的,因此没有地址!
直接引用,这个引用是用来保存一个地址,通过这个地址,我们就可以找到这个地址存储的值!
总而言之:存在内存中的数据,才有地址;存在硬盘中的数据,没有地址。
那么,字符串常量池中的字符串存储在硬盘中,如何表示究竟在硬盘中的哪个方位呢?
我们可以使用“偏移量"的概念来描述字符串在文件中所处的位置,这种方式叫做”符号引用”。
等到进行类加载的时候,就会把hello这个字符串加载到内存中,此时hello这个字符串就有了地址,此时,就可以使用直接引用的方式找到hello这个字符串。
在没有进行类加载之前,.class 文件有很多指令使用到hello字符串,此时这些指令找到hello使用的是符号引用。我们的解析阶段就是,在进行类加载后,hello这个字符串加载到内存中,有了地址,此时的hello可以进行直接引用查找之后,Java虚拟机将常量池内的符号引用替换为直接引用的过程。
5)初始化:
初始化阶段一般会进行下面几个操作:
1、针对类对象做最终的初始化操作,比如在准备阶段中,a被设置为0,此时到了初始化阶段,才真正把a初始化为10。
class Test{
public static int a=10;
}
2、执行类中的静态代码块
3、针对父类也进行加载,如果当前加载的类还有父类,且这个父类还未被加载过,此时在这个环节也会触发针对父类的加载。
3、双亲委派模型
双亲委派模型是类加载的阶段1涉及到的一个环节,也就是找文件环节。
找文件操作依靠的是JVM自带是三个类加载器:
1)BootstrapClassLoader:负责加载标准库的类
2) ExtensionClassLoader:负责加载扩展库的类
3) ApplicationClassLoader:负责加载第三方库的类(也就是我们自己写的类)
这三个类加载器逻辑上是有父子级关系的,我们可以这样类比:BootstrapClassLoader是爷爷,ExtensionClassLoader是爸爸,ApplicationClassLoader是儿子。
当执行找目录的过程中,这三个类加载器会分别获得一个目录,用于它们来查找文件,爷爷目录可以查找到标志库的类、爸爸目录可以查找到扩展库的类、儿子目录可以查找到第三方库的类。
首先,JVM进行找文件操作,此时将任务发送到儿子加载器,儿子加载器第一时间不是立即执行找文件操作,而是先将这个任务发送到父亲加载器,让父亲加载器先找一下这个文件;
父亲加载器接收到任务,也不是第一时间立即执行找文件操作,而是先将这个任务发送到爷爷加载器,让爷爷加载器先找一下这个文件;
爷爷加载器接收到任务,根据其目录,开始执行找文件操作!分为两种情况:找到该文件,进行类加载操作;未找到该文件,将任务再派送回父亲加载器
如果父亲加载器收到爷爷加载器派发回的找文件任务,父亲加载器再执行找文件操作,也分为两种情况,找到则进行类加载;未找到则将任务派回给儿子加载器
如果儿子加载器收到父亲加载器派发回的找文件任务,儿子加载器再执行找文件操作,找到则进行类加载,未找到则报错
上述逻辑的目的,是为了确保标志库中的类是优先级最高的,扩展库其次,第三方库最低。也就是说,我们平时写代码,可能会出现类名和标准库或者扩展库的类名一致,此时JVM要保证标志库的类优先加载,扩展库的类其次,我们自己写的类最低。
三、垃圾回收机制(GC)
1、内存泄漏
在C/C++中,malloc/new一个对象,都需要手动释放内存free/delete,如果不释放内存,就会造成内存泄漏。针对这一情况,各大语言的解决策略是不同的:
1)C语言针对内存泄漏,采取摆烂态度,对内存泄漏这种问题并没有提出什么有效的解决方案。
2)C++针对内存泄漏,给出了一个“智能指针”,但其实效果也一般。
3)Java针对内存泄漏,则给出来更系统更完整的解决方案,这就是我们要介绍的“垃圾回收机制”,也就是GC。程序员写代码,可以放心大胆地new对象,JVM会自动识别,哪些new完的对象再也不用了,就会把这样的对象自动释放掉。
2、回收机制的回收主战场
JVM中有好几个内存区域,那么GC主要负责回收哪里呢?
1)程序计数器和栈是跟随线程的,因此不需要GC,线程结束,内存就释放了
2)元数据区主要存储类对象数据,一个程序里面要加载的类,都是有上限的,不会出现无限增长的情况,因此也不需要GC
3)堆主要存储new的对象,是GC的主战场
GC回收是以对象为维度的,以下是GC回收的逻辑:
3、GC具体是怎样回收的
GC回收分为两个步骤:
1)先找出来谁是垃圾(垃圾==不再使用的对象,也就是说,不再有引用指向这个对象)
2)释放垃圾的内存空间
识别垃圾方案1:引用计数
给每个对象分配一个计数器,衡量有多少个引用指向这个对象。
每次增加一个引用,计数器+1;
每次减少一个引用,计数器-1;
当计数器减少为0,此时对象就是垃圾了;
结合代码举个例子:
a)Test a=new Test() :此时new Test()这个对象存储在堆中,这个对象里面有一个计数器,此时只有a一个引用指向Test(),因此计数器=1;
b)Test b=a :增加一个引用指向Test(),因此计数器=2;
c)a=null:少了一个引用指向Test(),此时计数器=1;
d)b=null:少了一个引用指向Test(),此时计数器=0,GC回收掉Test();
上述方案其实存在两个问题,因此JVM并没有采纳这个方案。
问题1:计数器的引用增加了内存开销,因为我们计数器是分配到对象中的,因此每个对象因为引入计数器而需要分配更多空间。
问题2:这种方案可能会产生“循环引用”,导致无法GC回收
class Test{
Test t=null;
}
Test a=new Test();
Test b=new Test();
a.t=b;
b.t=a;
a=null;
b=null;
让我来具体介绍一下“循环引用”为什么会导致无法GC回收。
a)我们创建一个Test类,这个类里面有一个属性,即Test t。
b)实例化两个Test类,引用分别是a,b
c)分别设置Test对象t的值,此时各自的计数器都为2
d)将a,b置为null,此时就出现了“循环引用”,由于各自的计数器都为1,因此无法进行GC操作,但是实际上,两个Test对象都无法再获取到了,可以被视为垃圾,但是却无法GC。
识别垃圾方案2:可达性分析 (JVM采用)
在JVM中,专门搞了一波线程,它会周期性地扫描代码中的所有对象,判定某个对象是否“可达,也就是可以被访问到,对应的,不可达的对象,就是垃圾了。
类比生活在的例子,老师拥有学生的名单,当老师根据名单进行点名时,喊到的同学说明来教室上课了,那么没喊到的同学,说明没来上课
JVM拥有所有对象的名单,周期性扫描时,可以被访问到的对象,说明还有引用指向它,因此还正在使用中,不用回收。无法访问到的对象,说明已经没有引用指向它,因此可以判定为垃圾,将它回收!
可达性分析的起点,称为GC root,一个程序中,GC root不是只有一个,而是有很多个,它可以是栈上的局部变量(引用类型)、方法区中静态的成员(引用类型)、常量池引用指向的对象……把所有的GC root都遍历一遍,针对每一个尽可能往下延伸,因此特别消耗性能。
知道了谁是垃圾,需要回收,那么该如何回收呢?
回收垃圾方案1:标记清除法
标记清除也就是将被标记为垃圾的对象进行回收,使其空出内存空间!可是这可能会造成”内存碎片问题“。
让我们看看下面这张图片,蓝色区域为原本是垃圾的区域,此时进行“标记清除”,将其空间进行回收之后,可是空闲出来的空间是一段不连续的内存,当后面JVM需要申请一个3M的连续空间时,是无法实现的!这就是”内存碎片问题“
回收垃圾方案2:复制算法
复制算法的逻辑是将内存空间一分为二,同一时刻,只使用其中一半。
如图,JVM将内存一分为二,每次只使用其中一半,将垃圾对象识别出来后,JVM将非垃圾对象复制到另一半的内存空间中,接下来另一半的内存空间全部释放!
但是复制算法缺点也很明显:
1)内存空间利用率低,每次只使用了一半
2)如果存活下来的对象比较多,那么复制成本 也比较大
垃圾回收方案3:标记整理法
标记整理法的思想标记难解释,我们以上述例子来讲解一下它的具体逻辑:
a)有三个垃圾对象1、3、5,需要回收,其他对象均为不需要回收的
b)将对象2复制到对象1的内存空间中
c)将对象4复制到原对象2的内存空间中
d)将对象6复制到对象3的内存空间中
e)将对象7复制到原对象4的内存空间中
f)将对象8移动复制到对象5的内存空间中
g)释放掉后面的内存空间
但是标记整理法搬运的开销也不小
垃圾回收方案 4:分代回收法(JVM采用)
以上3个垃圾回收方案因为有各自的缺陷,因此都不是JVM采纳的垃圾回收方案,实际上,JVM采用的垃圾回收方案是”分代回收法“,这个方案是将上述几个方案综合在一起,取长补短而成的。
JVM根据对象的年龄,把对象进行区分,年轻的是新生代,年老的是老年代。那么JVM是如何计算对象的年龄的呢,其实是通过前面的可达性分析计算的,由于可达性分析会周期性扫描每个对象,每经过一轮扫描并且存活的对象,年龄+1。
分代回收将内存整个堆内存划分为几个区域:
a)伊甸区:存储新生代,采用复制算法。根据经验规律,绝大部分新对象,活不过第一轮GC,留存下来的拷贝到幸存区
由于绝大部分新对象活不过第一轮GC,因此复制算法真正需要拷贝的对象其实不多,开销会比较小
b)幸存区:是两个相等的空间,采用复制算法,反复进行多次
c)老年区:存储老年代,采用标记整理法,如果一个对象在幸存区已经反复被拷贝多次,也没有被判定为垃圾,它的年龄不断增长,此时它就可以拷贝到老年区
d)根据经验规律,老年代中的对象,生命周期会比较长,GC的频率会比较低,因此需要整理的次数也不多