JVM内存划分
JVM也就是Java进程,这个进程一旦跑起来之后机会向操作系统这里申请一大片空间,JVM会进一步对这个空间进行划分,划分成不同的区域,从而每个区域都有不同的功能作用。
1.堆(heap)
整个内存区域中,最大的区域,放的是代码中new出来的对象(成员变量)。
2.栈(stack)
JVM虚拟机栈:保存了方法的调用关系。
本地方法栈:方法的调用关系,使用栈来维护。
3.元数据区
以前叫方法区,Java8改名。
存放类对象:代码中写的每个类,在JVM上运行的时候,都会有对于类对象。
方法相关信息:类有一些方法,每个方法都代表了一系列指令合集。
常量池:字面常量值。
4.程序计数器
内存区域中最小的区域,保存了当前要执行的下一条指令(JVM字节码)的地址,这个地址就是元数据区里的一个地址。
基本原则:
一个对象在那个区域,取决于对应变量的形态:
局部变量: 堆上
成员变量: 堆上
静态成员变量:方法区/元数据区
上述四个区域中,堆和元数据区,是整个进程只有一份,栈和程序计数器,是每个线程都有一份的。
类加载过程
编写java代码后硬盘上存储的是.java文件,一个java进程要运行起来(执行cpu指令,都是通过字节码让JVM翻译出来),就需要把.java文件变成.class文件,加载到内存中,得到类对象。
类加载的几个环节:
1.加载:在硬盘上,找到对应的.class文件,读取文件内容。
2.验证:检查.class里的内容,是否符合要求(.class文件格式在java的官方文档中有明确定义)。
3.准备:给类对象分配内存空间(类加载最终得到的就是类对象),会把元数据区中的这块空间全部填充成0。
4.解析:针对字符串常量来初始化,把刚才.class文件中的常量内容取出来,放到元数据区。
5.初始化:针对类对象进行初始化,给静态成员进行初始化,执行静态代码块。
后续代码就可以使用这个类对象,创建实例,或者使用里面的静态成员了。
双亲委派模型
描述了JVM加载.class文件过程中寻找 文件的过程。双亲委派模型出现在类加载的第一步------加载,根据代码中给出的全限定类名找到对应的.class文件。
类加载器:在JVM中包含的一个特定的模块/类,这个类负责完成后续的加载工作。
JVM中内置了三个类加载器,负责加载不同的类。
BootstrapClassLoader:负责加载标准库的类。(爷爷)
ExtentionClassLoader:负责加载JVM拓展库的类。(父亲)
ApplicationClassLoader:负责加载第三方库的类和程序员自己写的类。(儿子)
此处的"父子关系"不是通过继承表示的,而是通过类加载器中存在一个"parent"这样的字段,指向自己的父亲。类似于二叉树的三叉实现形式。
加载过程
加载在全限定类名ava.Test中,自己写的一个类
***1.工作从ApplicationClassLoader开始进行,*它不会立即去搜索第三方库的相关目录,而是把任务交给自己的 父亲进行处理。
***2.工作来到了ExtentionClassLoader,*它也不会立即去搜索自己负责的拓展库的目录,也是把任务交给自己的父亲去处理。
***3.工作最后到了BootstrapClassLoader,*BootstrapClassLoader的parent指向null,只能自己去处理,BootstrapClassLoader尝试在标准库的路径中搜索上述类,如果这个类在标准库中找到了,于是搜索过程就完成了,类加载器负责打开文件,读取文件等后续操作就行了。如果没找到,任务就丢给自己的儿子去处理。
***4.工作回到了ExtentionClassLoader,*此时搜索拓展库对应目录,找到该目录 ,就由当前类加载器负责打开文件,读取文件等后续操作,如果没找到,继续还给儿子去处理。
***5.工作目录回到ApplicationClassLoader,*此时搜索第三方库/用户项目代码目录,找到了,也是由当前类加载器负责处理。没找到则会抛出异常ClassNotFoundException。
双亲委派模型,拿到任务后先交给父亲处理,处理不了再自己处理。
GC垃圾回收机制
GC回收的是"内存",更准确的来说是堆上的内存。每次回收都是一个完整的对象,不能回收帮对象(一个 对象有几个成员就要全部回收,不能只回收部分)。
找到需要回收的垃圾(不被使用的对象)。
一个对象的创建时机是明确的,但什么时候不再使用,往往是模糊的,在编程中一定要确保代码中使用的每个对象都得是有效的,不能出现"提前释放 "的情况。因此判断一个对象是否是垃圾,判定的方式是比较保守的,判定某个对象,是否存在引用指向它。
使用对象都是通过引用的方式来使用的,如果没有引用指向这个对象,意味着这个对象注定无法在代码中被使用。如果没有引用指向的对象就可以视为垃圾了。
对于上述代码来说,修改了m的指向,此时new Main()的对象就没有引用指向了,这个对象就可以被认为是垃圾。
判定某个对象是否有引用指向,这里介绍两种方法:
1.引用计数(PHP/PPython的方案)
在对象本体上引入计数器,给引用赋值之前,先根据引用找到对象,并更新计数器,判断计数器是否是0。
引入计数为0,此时对象就是垃圾。
缺陷:
1.额外消耗存储空间。如果对象比较大,浪费一些空间可以忽略,如果对象比较小,或者对象数目比较多,空间浪费的就多了。
2.存在循环引用的问题。
java
class Test{
Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
**此时两个引用互相赋值,这两个对象相互指向对方,导致两个对象的引用计数都为1,你外部的代码无法访问这两个对象,**这便是循环引用问题。
2.可达性分析(JVM采取的方案)
解决了空间的问题,也解决了循环引用的问题,但是付出了时间上的代价。
遍历
JVM把对象之间的引用理解为一个"树形结构",JVM就会不停的遍历这样的结构,把所有能够遍历访问到的对象标记成可达,剩下的就是不可达。这个树形结构在写代码的时候,把类定义好,对象实例化好后,这样的树形结构就已经存在了。在java的代码中:
栈上的局部变量,引用类型的,就都是GC Root,包括常量池中引用的对象,方法中的静态成员。会有很多的树形结构,JVM会周期性的对所有的树进行遍历,不停的标记可达,也不停的把不可达的对象回收掉。由于可达性分析需要一定的时间,java的垃圾回收没办法做到"实时性",JVM提供了一组专门的负责GC的线程,不停的扫描工作。
释放对应的内存。
1.标记清除
直接把标记为垃圾的对象对应的内存释放掉,被释放掉的内存其他代码可以反复利用。
这样的做法有会存在"内存碎片"问题,空闲的内存被分成一个个的碎片,后续很难申请到大的内存。申请的内存都是要申请连续内存空间的。
2.复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,通过拷贝将不需要释放的内存单独存放到另一片空间中,解决了内存碎片的问题,带来了浪费更多空间的问题,如果保存的对象比较多,复制的时间开销也比较大。
3.标记-整理
标记需要回收的垃圾,将标记对象删除,然后将存活对象移动到一起。解决了内存碎片化的问题和空间利用率的问题。但是整个算法时间开销比较大。
4.分代回收(重要)
上面3个方案只是铺垫,JVM中实际的方案是综合上面的方案,提出的更复杂的机制。这个机制是通过对象的年龄讨论的,GC会有一组线程进行周期性扫描,某个对象在经历一轮GC后还存在没有被标记为垃圾年龄就+1:
现在再来看堆区里面分为了很多区域:
Eden(伊甸区):新创建的对象都放在这个区,大部分对象的生命周期都是比较短的,第一轮GC到达的时候就会成为垃圾,只有少数对象能活过第一轮GC。
S0(生存区):活下来的对象通过复制算法,进入生存区。由于活下来的对象很少,生存区空间不需要很大。
s1(生存区):S0-S1,每经历一轮GC扫描,生存区就会淘汰一批对象,剩下的对象通过复制算法进入另一个生存区。
Old区(老年代):某些经历了很多了GC没有成为垃圾,就会进入老年代(复制算法)。
老年代的对象也是要进行GC扫描的,但老年代生命周期比较长,就可以降低GC扫描频率。