文章目录
1. 定义
JDK - Java开发工具包
JRE - Java运行时环境
JVM - Java虚拟机
C++这种语言可以直接编译成二进制的机器指令,不同的CPU上面支持的指令不一样,如果是换了操作系统,可能就会需要重新编码。
Java只使用一套编码,在各个操作系统上都是使用一套编码。
先通过 javac 把 .java文件 转换成 .class文件 (字节码文件,包含的就是Java字节码,字节码就是Java自己搞的一套CPU指令)。然后在某个具体的系统上执行,此时通过 JVM 将 Java的字节码文件(二进制指令) 转换成对应的CPU能识别的机器指令。
jvm像是翻译官,负责将Java字节码文件转换成对应的系统的CPU可执行的指令。
主流的JVM:HotSpot VM
三个主要的话题
1.JVM 中的内存区域划分
2.JVM 的类加载机制
3.JVM 中的垃圾回收算法
2. 内存区域划分
JVM 其实也是一个 Java进程 ,进程在运行的过程中需要从操作系统申请资源。
如果定义一个变量,就会申请,变量申请到的内存就是 JVM 从 操作系统 中申请的内存。
JVM 申请到空间后会划分出几个区域,每个区域都有不同的作用。
1.堆区:代码中new出的对象,对象中持有的非静态成员变量都会存在堆区中,一个JVM中只有一份。
2.栈帧:本地方法栈/虚拟机栈,包含了方法调用关系和局部变量,一个JVM中可能有多份。
3.程序计数器:这个区域比较小的空间,专门用来存储下一条要执行的 java 指令的地址,一个JVM中只有多份。
4.元数据区:一个程序中有哪些类,每个类中有哪些方法,每个方法包含哪些指令,都会记录在元数据区中,一个JVM中只有一份。
3. 类加载机制
类加载,指的是java进程运行的时候,需要把 .class文件 从硬盘读取到内存,并进行一系列的校验解析的过程。
类加载过程可以分成5个步骤
- 加载,把硬盘上的 .class文件 找到,打开文件,读取到文件内容(认为读到的是二进制的数据)。
- 验证,当前需要确保读到的文件的内容是合法的 .class 文件(字节码文件) 格式。
- 准备,给类对象申请内存空间,此时申请到的内存空间里面的默认值都是全 0 的(该阶段类对象里的静态成员变量值也就相当于是0)。
- 解析,主要是针对类中的字符串常量进行处理。该阶段是JVM将常量池内的符号引用替换为直接引用的过程,即初始化常量的过程。
- 初始化,针对类对象完成后续的初始化,还要执行静态代码块的逻辑,还可能会触发父类的加载。
补充
- 加载–双亲委派模型
JVM中进行类加载的操作,是有一个专门的模块,称为"类加载器"(ClassLoader),类加载器默认是有三个的。
BootstrapClassLoader:负责查找标准库的目录(Java语法规范里面描述了标准库中应该有哪些功能)
ExtensionClassLoader:负责查找扩展库的目录(实现JV 的厂商/组织,会在标准库的基础上扩充一些额外的功能)
ApplicationClassLoader:负责查找当前项目的代码目录,以及第三方库的目录
上述的三个类加载器,存在"父子关系"(不是面向对象中的,父/子类继承关系),而是类似于"二叉树”,有一个指针(引用) parent,指向自己的"父"类加载器。
工作过程:
- ApplicationClassLoader 作为入口,先开始工作
- ApplicationClassLoader 不会立即搜索自己负责的目录,会把搜索的任务交给自己的父亲 ExtensionClassLoader
- 代码就进入到 ExtensionClassLoader 范畴了,ExtensionClassLoader 也不会立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲代码 BootstrapClassLoader
- BootstrapClassLoader 也不想立即搜索自己负责的目录,也要把搜索的任务交给自己的父亲
- BootstrapClassLoader 发现自己没有父亲才会真正搜索负责的目录 (标准库目录)通过全限定类名,尝试在标准库目录中找到符合要求的 .class 文件,如果找到了,接下来就直接进入到打开文件/读文件等流程中,如果没找到,回到孩子这一辈的类加载器中,继续尝试加载。
- ExtensionClassLoader 收到父亲交回给他的任务之后自己进行搜索负责目录(扩展库的目录),如果找到了,接下来进入到后续流程,如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载
- ApplicationClassLoader 收到父亲交回给他的任务之后,自己进行搜索负责的目录(当前项目目录/第三方库目录),如果找到了,接下来进入后续流程,如果没找到,也是回到孩子这一辈的类加载器中继续尝试加载,默认情况下ApplicationClassLoader 没有孩子了,此时说明类加载过程失败了,就会抛出 ClassNotFoundException 异常
按照上述的顺序,假定在代码中自己定义了一个java.lang.String 这样的类,最终程序的执行效果,自定义的类不会被JVM加载。这样的设定,有效避免自己写的类,不小心和标准库的类名字重复,导致标准库的类功能失效。
- 验证阶段
在验证阶段中,具体的验证依据,在java的虚拟机规范中,有明确的格式说明。 - 解析阶段
在解析阶段中,JVM中将常量池的符号引用替换为直接引用的过程,也就是初始化常量的过程。
符号引用:在文件中不存在地址这样的概念,此处文件中填充给s的"hello"的偏移量就是"符号引用"。
接下来把文件加载到内存中,此时"hello"就有地址了,此时s将偏移量转换为真正的地址。
4. 垃圾回收算法(GC)
垃圾回收算法的主要目的是释放内存。
引入垃圾回收算法后就不再需要手动释放内存,程序会判定某个内存是否会继续使用,如果不用就会释放掉。
垃圾回收算法的问题:STW(stop the world)问题,触发垃圾回收机制可能会使代码的其他业务逻辑被迫暂停。
Java 发展这么多年,GC 这块的技术积累也越来越强大,有办法把 STW 的时间控制到 1ms 之内。
4.1 认识垃圾
垃圾回收,是回收内存
JVM 中的内存有好几块
- 程序计数器 (不需要 GC)
- 栈(不需要 GC) ,局部变量都是在代码块执行结束之后自动销毁,生命周期都非常明确
- 元数据区/方法区(一般不需要 GC) ,一般都是涉及到"类加载"很少涉及到"类卸载”
- 堆是GC主要的战场
4.2 识别垃圾
判定你这个对象后续是否要继续使用
在 Java 中,使用对象,一定需要通过引用的方式来使用(当然,有一个例外,匿名对象new MyThread().start()–这种情况下,这行代码执行完,对应的MyThread对象就会被当做垃圾)
如果一个对象没有任何引用指向他,就视为是无法被代码中使用,就可以作为垃圾了。
void func() {
Test t = new Test();
t.conv();
}
通过 new Test 就是在堆上创建了对象。执行到这个}之后,此时局部变量t就直接被释放了,堆此时再进一步,上述 new Test() 对象,也就没有引用指向他了,此时,这个代码就无法访问使用这个对象,这个对象就是垃圾了。
如果有更多的引用指向同一个对象,情况不好办了。
此时就会有很多引用指向 new Test 同一个对象(此时有很多引用, 都保存了 Test 对象的地址),此时通过任意的引用都能访问 Test 对象,需要确保所有的指向 Test 对象的引用都销毁了,才能把 Test 对象视为垃圾,上述这些引用的生命周期各不相同的,此时情况就不好办了。
Test t1 = new Test();
Test t2 = t1;
t3 = t2;
t4 = t3;
4.3 标记垃圾
4.3.1 引用计数
这种思想方法,并没有在 JVM 中使用,但是广泛应用于其他主流语言的垃圾回收机制中(Python,PHP)
给每个对象安排一个额外的空间,空间里要保存当前这个对象有几个引用。
有专门的扫描线程,去获取到当前每个对象的引用计数的情况,发现对象的引用计数为 0,说明这个对象就可以释放了(就是垃圾了)。
存在问题:
- 消耗额外的内存空间
要给每个对象都安排一个计数器(如果计数器按照 2 个字节算),如果整个程序中对象数目很多,总的消耗的空间也会非常多,尤其是如果每个对象体积比较小(假设每个对象 4 个字节计数器消耗的空间,已经达到对象的空间的一半)- 引用计数可能会产生“循环引用”的问题,此时,引用计数就无法正确工作了
class Test {
Test t;
Test a = new Test();
Test b = new Test();
a.t = b:
b.t = a;
a = null;
b = null;
通过引用计数的方式回收内存,会导致对象a与b计数再释放空间的时候,漏掉一个,导致该位置的内存一直被引用。
4.3.2 可达性分析
本质上是用“时间换空间”,相比于引用计数,需要消耗更多的额外的时间,但是总体来说,还是可控的,不会产生类似于"循环引用"这样的问题。
在写代码的过程中,会定义很多的变量,比如,栈上的局部变量/方法区中的静态类型的变量/常量池中引用的对象…
就可以从这些变量作为起点,去进行“遍历",沿着这些变量中持有的引用类型的成员,再进一步的往下进行访问,所有能被访问到的对象,自然就不是垃圾了,剩下的遍历一圈也访问不到的对象,自然就是垃圾。
4.4 清除垃圾
该阶段就是把标记为垃圾的对象的内存空间进行释放。
4.4.1 标记-清除
把标记为垃圾的对象,直接释放掉(最朴素的做法)
此时就是把标记为垃圾的对象对应的内存空间直接释放,会产生很多的小的,但是离散的空闲内存空间,会导致后续申请内存失败。
4.4.2 复制算法
复制算法,核心就是不直接释放内存,而是把不是垃圾的对象,复制到内存的另一半里。
将不是垃圾的对象复制到内存的另一半,然后把左侧空间整体释放掉
确实能够规避内存碎片问题,但是也有缺点
1.总的可用内存,变少了(买两个煎饼果子,吃一个丢一个)
2.如果每次要复制的对象比较多,此时复制开销也就很大了
4.4.3 标记-整理
类似于顺序表删除中间元素 (搬运)。
通过这个过程,能有效解决内存碎片问题,并且这个过程也不像复制算法一样,需要浪费过多的内存空间,但是搬运内存开销很大。
4.4.4 *分代回收
衣据不同种类的对象,采取不同的方式
引入概念,对象的年龄。
JVM中有专门的线程负责周期性扫描/释放。 一个对象,如果被线程扫描了一次,可达了(不是垃圾),年龄就 +1(初始年龄相当于是 0)
JVM中就会根据对象年龄的差异,把整个堆内存分成两个大的部分新生代(年龄小的对象)/ 老年代(年龄大的对象)
- 当代码中 new 出一个新的对象,这个对象就是被创建在伊甸区的。
经验规律:伊甸区中的对象,大部分是活不过第一轮GC,这些对象都是"朝生夕死"生命周期非常短!- 第一轮 GC 扫描完成之后,少数伊甸区中幸存的对象,就会通过复制算法,拷贝到 生存区,后续 GC 的扫描线程还会持续进行扫描,不仅要扫描伊甸区,也要扫描生存区的对象生存区中的大部分对象也会在扫描中被标记为垃圾,少数存活的,就会继续使用复制算法,拷贝到另外一个生存区中,只要这个对象能够在生存区中继续存活,就会被复制算法继续拷贝到另一半的生存区中。
每次经历一轮 GC 的扫描,对象的年龄都会 +1- 如果这个对象在生存区中,经过了若干轮 GC 仍然健在,JVM 就会认为这个对象生命周期大概率很长,就把这个对象从生存区,拷贝到老年代
- 老年代的对象,当然也要被 GC 扫描,但是扫描的频次就会大大降低了,老年代的对象,要 G 早 G 了,既然没 G 说明生命周期应该是很长的。
频繁 GC 扫描意义也不大,白白浪费时间,不如放到老年代,降低扫描频率