- 🎥 个人主页:Dikz12
- 📕格言:吾愚多不敏,而愿加学
- 欢迎大家👍点赞✍评论⭐收藏
目录
JVM 运⾏流程图
JVM 中内存区域划分
方法区 / 元数据区
堆
栈
程序计数器
本地方法栈
内存区域总结
JVM 中类加载过程
双亲委派模型
JVM 中的垃圾回收机制
找到垃圾
引用计数 (Python,PHP)
可达性分析(Java)
回收垃圾
标记清除
复制算法
标记整理
堆的新生代 和 老生代
JVM 运⾏流程图
JVM 中内存区域划分
一个运行起来的Java进程,就是一个JVM虚拟机,就需要从操作系统申请一块内存,然后把这个内存,划分成不同区域,每个区域都有不同的作用.
比如:学校这么大场地,就相当于申请了一大块内存空间.
方法区 / 元数据区
jdk1.7及其以前叫做 方法区;从jdk1.8开始叫做元数据区.
元数据区存储内容:用来存储被虚拟机加载的类信息、常量、静态变量. (这里存储的是类对象, .class文件,加载到内存之后,就成了类对象)
堆
堆存储的内容:程序创建的所有对象都保存在堆里.(就是代码new的对象)
堆⾥⾯分为两个区域:新⽣代和⽼⽣代.
具体在垃圾回收机制里说明!!!
栈
栈存储的内容:代码执行过程中,方法之间的调用关系.(a->b->c)
每个栈帧里就包含了方法的入口, 方法的返回的位置,方法的形参,方法的返回值,局部变量......等
程序计数器
程序计数器是一块比较小的空间,主要就是存放一个“地址”,下一条要执行的指令,在内存中的哪个地方.
刚开始调用方法,程序计数器,记录的就是方法入口的地址;随着一条一条的执行指令,每执行一条,程序计数器的值就会自动更新,去指向下一条指令.
class Test{
public void a(){...}
public void b(){...}
}
这里的方法a 和 方法b都会被编译成二进制的指令,就会被放到.class文件中.
本地方法栈
本地方法栈,指的是使用nativa关键字修饰的方法;这个方法不是使用Java实现的,而是在JVM内部通过C++代码实现的. (JVM内部的C++代码调关系)
内存区域总结
JVM,是一个运行Java进程,一个进程可以包含多个线程,内存区域有如何划分?
虚拟机栈及程序计数器,都是每个线程都有一份.
堆和元数据区在jvm进程中只有一份,线程共享.
JVM 有10个线程,就会有10个虚拟机栈,也会有10个程序计数器.
JVM 中类加载过程
对于一个类来说,它的生命周期如下:
课本上 和 官方文档 把这个类加载的过程,主要是分成了五个部分.
1.加载. 找到.class文件,打开文件,读取文件内容. 代码中,会给定某个类的“全限定类名”,jvm就会根据这个类名,在一些指定的目录范围内查找. (全限定类名:java.lang.Sring 、java.util.ArrayList)
2.验证. 检验 .class 文件是一个二进制的格式.(某个字节,都是有特定的含义)
java8规范要求:Chapter 4. The class File Format
3. 准备. 给类对象分配内存空间,这里只是分配内存空间,还没有初始化呢,此时这个空间上的内存数值,全是0.
4.解析. 针对类对象中包含的字符串常量进行处理,进行一些初始化操作. java代码中用到的 字符串常量,在编译之后也会进入到.class文件中.
比如: final String str = "test".
在.class文件的二进制指令中,也会有一个 str 这样的引用被创建出来;由于应用本质上保存的是一个变量的地址,在 .class文件是一个文件,不涉及到内存地址,就会先设置成一个“文件的偏移量”通过偏移量,就能找到“test”这个字符串所在的位置.(真正被加载到内存的时候,再把这个偏移量替换成真正的内存地址)
5. 初始化. 针对类对象进行初始化,就是把类对象中需要的各个属性都设置好.
双亲委派模型
1.BootStrap :启动类加载器.
2. Ext ClassLoader :扩展类加载器。加载 lib/ext ⽬录下的类.
3. App ClassLoader:应⽤程序类加载器.
4. ⾃定义加载器:根据⾃⼰的需求定制类加载器.
JVM中,内置了,三个类加载器:
过程:
1. ⼀个类加载器收到了类加载的请求,它⾸先不会⾃⼰去尝试加载这个类.
2. ⽽是把这个请求委派给⽗类加载器去完成,每⼀个层次的类加载器都是如此.
3. 所有的加载请求最终都应该传送到最顶层的启动类加载器中
4. 只有当⽗加载器反馈⾃⼰⽆ 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,⼦加载器才会尝试⾃⼰去完成加载.
5.当子类加载器AppClassLoader,也没找到就会抛出一个ClassNotFoundException.
JVM 中的垃圾回收机制
GC垃圾 回收的目标,其实就是 内存中的对象. 对于Java来说就是 new出来的对象.
栈里放的是局部变量,是跟随栈帧的生命周期走的.(方法执行结束,栈帧销毁,内存自然释放)
静态变量,生命周期就是整个程序,始终在,就意味着,静态变量时无需释放的.
GC可以理解两大步骤: 1. 找到垃圾. 2.释放垃圾
找到垃圾
引用计数 (Python,PHP)
为什么Java不使用引用计数???
1.比较浪费内存.
计数器,也要有两个字节.如果对象本身就很小,这个计数器占据的空间比例就很大了.
比如对象本身就两个字节,计数器占据的空间就是50%. (如果对象小并且多,计数器占据的空间就难以忽视了)
2.引用计数机制,存在“循环引用”问题.
可达性分析(Java)
有一个 或者 一组线程,周期性的扫描我们代码中的所有对象.
从一些特定的对象出发,尽可能的进行访问的遍历,把所有都能够访问到的队象,都标记成"可达"
反之,经过扫描之后,未被标记的对象,就是“垃圾”了.
可达性分析,是周期性进行的,所以是比较消耗系统资源,开销比较大.是时间换空间的手段
回收垃圾
标记清除
简单粗暴的方式,比如,申请了一块内存空间,上面有一些对象,通过可达性分析发现 2 和 4是垃圾, 就直接把对应的对象的内存,直接释放掉,就是标记清除方案.
这个方案其实非常不好,会产生很多的内存碎片;释放内存,目的是为了让别的代码能够申请. 申请内促,都是申请到“连续”的内存空间 ; 随着时间的推移,内存碎片的情况,就会越来越严重,最会导致后续内存申请举步维艰.
复制算法
通过复制的方式,把有效的对象,归类到一起,在统一释放剩下的空间.
比如,一块内存空间先一分为二,一次只用一其中一半, 里面有一些对象,假设1,3,5垃圾,通过复制算法,把2 和 4 复制到另外一边,就可以把左侧的整体释放掉.
这个方案可以有效的解决内存碎片问题,缺点也是很明显的;
1.内存要浪费一半,利用率不高.
2.如果有效对象非常多,复制的开销就很大了.
标记整理
既能够解决内存碎片的问题,又能处理复制算法中的利用率.
过程就类似于顺序表删除元素的搬运操作.
比如,一块内存空间,通过可达性分析,1,3,5是垃圾, 把2搬运到1的位置....
搬运开销仍然很大!!
实际上,JVM 采取的释放思路,是上述基础思路结合体.(让不同的方案,扬长避短)
堆的新生代 和 老生代
垃圾回收只是针对堆进行的, 堆的内存空间会分成两部分,不是等分的,具体怎么分不一定,左边称为“新生代”,右边称为“老年代”.
新生代:又进一步的划分,分为 幸存区 和 伊甸区.
幸存区:等分的两部分,每次只用一块. 正好就是复制算法的体现.
伊甸区:放的是刚 new出来的对象.(还没有经过可达性扫描)
经验规律: 从对象诞生,到第一轮可达性分析扫描,这个过程中虽然时间不长,基本就是 毫秒 - 秒(这个时间维度,对于程序的眼中也挺长了),在这个时间里大部分的对象都会成为垃圾.
1.伊甸区 -> 幸存区 (复制算法)
每一轮GC扫描之后,都会把有效的对象,复制到幸存区中,伊甸区就可以整个释放了. 由于经验规律,真正需要复制的对象不多,非常适合复制算法.
2. GC 扫描线程也会扫描 幸存区. 就会把活过GC扫描的可达对象,复制到幸存区的另一部分. 幸存之前的复制,每一轮会复制多个对象,每一轮也可以淘汰掉一些对象.
3. 当这个对象已经在幸存区存活很多轮GC扫描之后,JVM 就认为这个对象,短时间内是应该释放不掉,就会把这个对象复制到老年代.
4. 进入老年代的对象,虽然也会被GC 扫描,扫描频率就会比新生代,低很多.
(也是为了减少GC扫描的开销,要挂早就挂了!!!)