目录
1. JVM 内存区域划分
2. JVM 中类加载的过程
1) 类加载的基本流程
2) 双亲委派模型
3. JVM 中垃圾回收机制
1) 找到垃圾
a) 引用计数
b) 可达性分析
2) 释放垃圾
1. JVM 内存区域划分
一个运行起来的 Java 进程,其实就是一个 JVM 虚拟机。
而进程是资源分配的基本单位,所以 JVM 就首先会申请一大块内存,然后把这个内存划分成不同的区域,每个区域都有不同的作用。
JVM 内存区域划分成以下四个部分:
1. 方法区 (1.7 及之前) / 元数据区 (1.8 开始)
方法区存储的内容,就是类对象。( .class 文件加载到内存之后,就成了类对象)
2. 堆
这里存储的内容,就是代码中 new 的对象。
堆是占据空间最大的区域。
3. 虚拟机栈(就是平常我们所说的栈)
这里存储的内容,就是代码执行过程中,方法之间的调用关系。
4. 程序计数器
是个比较小的空间,主要用来存放一个 "地址",这个地址,就表示了下一条要执行的指令,在内存中的哪个地方(方法区里)。
每个方法,里面的指令,都是以二进制的形式, 保存到类对象中的。
刚开始调用方法的时候,程序计数器记录的就是方法的入口的地址。
随着一条一条的执行指令,每执行一条指令,程序计数器的值都会自动更新,去指向下一条指令。
程序计数器和虚拟机栈是每个线程都有一份,而堆和方法区在 JVM 进程中是只有一份的。
在 Java 里,每个线程都有自己私有的栈空间。
2. JVM 中类加载的过程
1) 类加载的基本流程
java 代码会被编译成 .class 文件(包含了一些字节码),java 程序想要运行起来,就需要让 JVM 读取到这些 .class 文件,并把里面的内容,构造成类对象,保存到内存的方法区中。
官方文档把类加载的过程主要分成了 5 个步骤。
1. 加载:找到 .class 文件,打开文件,读取文件内容。
往往代码中,会给定某个类的 "全限定类名"(比如 java.lang.String,java.util.ArrayList) ,JVM 就会根据这个类名,在一些指定的目录范围内查找。
2. 验证: .class 文件是一个二进制的格式。(某个字节,都是有某些特定含义的),就需要验证你当前读到的这个格式是否符合要求。
3. 准备:给类对象分配内存空间(最终的目标,是要构造出类对象)
这里只是分配空间,还没有初始化,此时这个空间上的内存的数值,就是全 0 的,此时如果尝试打印类的 static 成员,就是全 0 的。
4. 解析:针对类对象中包含的字符串常量进行处理,进行一些初始化操作。
java 代码中用到的字符串常量,在编译之后,也会进入到 .class 文件中。
5. 初始化:针对类对象进行初始化。
把类对象中需要的各个属性都设置好。
还需要初始化号 static 成员
还需要执行静态代码块
以及可能还需要加载一下父类。
总结类加载的基本流程:
1. 加载:找到 .class 文件,打开 .class 文件,读取 .class 文件
2. 验证:验证当前 .class 文件格式是否正确
3. 准备:给类对象分配内存空间
4. 解析:将符号引用替换成直接引用
5. 初始化:初始化类对象
2) 双亲委派模型
属于类加载中第一个步骤 "加载" 中的一个环节,是负责根据全限定类名,来找到 .class 文件的。
类加载器,是 JVM 中的一个模块(专门负责类加载的操作)。
JVM 中,内置了三个类加载器:
1. BootStrap ClassLoader 爷
2. Extension ClassLoader 父
3. Application ClassLoader 子
这个父子关系,不是继承构成的,而是这几个 ClassLoader 里有一个 parent 这样的属性,指向了一个 父 "类加载器"。
程序员也可以手动创建出新的类加载器。
所以说,双亲委派模型,就是一个查找优先级的问题,先找标准库,再找扩展库,最后找第三方库。
3. JVM 中垃圾回收机制
在 Java 中,new 一个对象,就是 "动态内存申请",在 C 语言中,使用 malloc 这种 "动态内存申请" 的函数,使用完之后,就需要手动调用 free 释放内存,如果不释放,就会出现内存泄露这样的问题,而在 Java 中就不用手动释放内存,因为 JVM 自动判定,是否某个对象已经不再使用了,并帮我们进行释放不再使用的对象的内存了。像这种不再使用的对象,就称之为 "垃圾",这种机制,也就叫做 GC 垃圾回收机制。
GC 也有缺陷:
1. 系统开销,需要有一个/一些特定的线程,不停的扫描你内存中的所有的对象,看是否能够回收,此时是需要额外的内存和 CPU 资源的。
2. 效率问题,这样的扫描线程,不一定能够及时的释放内存 (扫描总是有一定周期的),一旦同一时刻,出现大量的对象都需要被回收,GC 产生的负担就会很大,甚至引起整个程序都卡顿 (STW 问题 stop the world)
但是 GC 属于大势所趋,Python,PHP,Go.... 都是具有 GC 机制的。
GC 是垃圾回收,GC 回收的目标,其实是 内存中的 对象。
对于 Java 来说,就是 new 出来的这些对象。
栈里的局部变量,是跟随着栈帧的生命周期走的。(方法执行结束,栈帧销毁,内存自然释放)
静态变量,生命周期就是整个程序,这就意味着 静态变量 是无需释放的。
因此真正需要 gc 释放的对象就是 堆 上的对象。
gc 可以理解成两个大的步骤:
1. 找到垃圾
2. 释放垃圾
1) 找到垃圾
在 GC 的圈子中,有两种主流的方案:1. 引用计数 2. 可达性分析 (Java 采用的是这种)
a) 引用计数
new 出来的对象,单独安排一块空间,来保存一个计数器。
b) 可达性分析
可达性分析,本质上是一个时间换空间这样的手段。
有一个/一组线程,周期性的扫描代码中的所有对象。
从一些特定的对象出发,尽可能的进行访问的遍历,把所有能够访问到的对象,都标记成 "可达",反之,经过扫描之后,未被标记成 "可达" 的对象,就是垃圾了。
就跟二叉树的遍历差不多,只不过不是二叉树,而是 N 叉树。
2) 释放垃圾
有三种基本的思路:
1. 标记清除
是一种比较简单粗暴的方式。
2. 复制算法
第二种思路,就是解决,刚刚标记清除出现的内存碎片的办法。
通过复制的方式,把有效的对象,归类到一起,再统一释放剩下的空间。
3. 标记整理
既能够解决内存碎片的问题,又能够处理复制算法中利用率。
类似于顺序表删除元素的搬运操作。
实际上,JVM 采取的释放思路,是上述基础思路的结合体。
分代回收:
分代回收,对象能活过的 GC 扫描轮次越多,就是越老。