内存区域划分
像办公楼一样,有办公区休息区吃饭区啥的,JVM 这个应用程序就会在启动的时候就会向操作系统申请一块内存区域,然后把这个区域分成几个部分,每个部分有不同的功能作用。一个 Java 进程就对应一个 JVM。
(虽然说这里的栈也是"先进后出"的,但数据结构的栈是个更大的概念,这里的特指 JVM 的一块内存空间)
**本地方法栈(线程私有):**native 表示 JVM 内部的 C++ 代码,这里就是给调用 native 方法(JVM 内部的方法)准备的栈空间。这里存储的是 native 方法之间的调用关系。
程序计数器(线程私有): 记录当前线程执行到哪个指令了。每个线程一份,很小一块存一个地址的。
虚拟机栈(线程私有): 给 Java 代码使用的栈,这里面拥有很多个栈,每个线程就对应一个。这里存储的是 方法 之间的调用关系。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。调用一个方法就创建一个栈帧。
**堆区(线程共享):**整个 JVM 空间最大的区域,new 出来的对象(类的成员变量)都在堆上。一个进程只有一个堆。
元数据区 / 方法区(线程共享): 用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。运行时常量池是方法区的一部分,存放字面量与符号引用。字面量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值。 符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
小结:1. 局部变量在 栈 上、2. 普通成员变量在 堆 上、3. 静态成员变量在 方法区。
类加载
类加载就是将 .class 文件 从文件(硬盘)被加载到内存中(元数据区)的过程。
**加载:**就是把 .class文件先找到(找的过程)然后打开文件读文件,再把文件内容读到内存中。最终加载完成得到了一个类对象。
**验证:**根据 JVM 虚拟机规范来检查 .class 文件格式(是一个二进制文件)是否正确。
**准备:**给类对象分配内存空间(先在元数据区占个位置),同时也会使静态成员被设置成 0 值。
解析: 初始化字符串,把符号引用转为直接引用。字符串常量得有一块内存空间来存,这块内存空间还得有一个引用来保存内存空间的起始地址,当类加载的时候,字符串常量处在 .class文件 中,此时这个引用记录的并非字符串常量真实的地址(占位符),此时就是符号引用;当类加载之后,字符串常量真正被放到内存中了,此时才有内存地址,这个引用才能被真正赋值成内存地址,此时就是直接引用了。
**初始化:**调用构造方法,进行成员初始化,执行代码块,静态代码块,加载父类......
一个类,什么时候会被加载?要用到的时候才加载,加载过一次之后就不用重复加载了。
-
构造 类 的实例大的时候
-
调用这个类的 静态方法/使用静态属性
-
加载子类的时候会先加载其父类
双亲委派模型
双亲委派模型描述的是在加载中,找 .class 文件的基本过程。
**类加载器工作流程:**首先加载一个类的时候,是从 ApplicationClassLoader开始的,但是它会把加载人物交给父亲去加载,到了ExtensionClassLoader这里它也会交给父亲去加载,到了BootstrapClassLoader之后,再想交给父亲发现没有上级了,就只能自己加载了,搜索自己负责的标准库目录相关的类,找到就加载,没找到就继续传回去由子类来加载。如果最下面的还没找到这个类那么就会抛异常了。
为啥有这顺序: 主要是出自于 JVM 实现代码的逻辑,因为 JVM 的代码是按照类似于递归的方式来实现的。同时,这个顺序也保证了 Bootstrap 能先加载,Application 能后加载,这就能避免程序猿定义了一个类似于 java.lang.String 这样的类,就会先加载的是标准库的类而不会加载到自己写的这个类了。再者,也可以自定义类加载器,自定义的能随便加入上述流程的任意位置。
垃圾回收机制
所谓的"垃圾",指的就是不再使用的内存,垃圾回收就是把不用的内存帮我们自动释放了。(C++是要自己手动释放的,否则可能会出现"内存泄漏问题")
GC
GC 是当下最主流的一种垃圾回收机制。上面已经知道,JVM 里是有很多的内存区域的,GC 主要就是针对 堆 进行释放的,因为大,多。同时,GC 是以"对象"为基本单位进行回收的,为不是字节,比如一个对象有很多属性,其中7个不用了,3个还在用,那么此时就不会回收那不用的7个,而是都不用了才把这个对象一起回收。
GC 实际工作过程
1. 找到垃圾 / 判定垃圾
关键思路,就是看这个对象有没有"引用"在指向它。这里有两种方式判断:
a)引用计数【python、php......】
会给每个对象分配一个计数器,每当创建一个引用的时候计数器就+1,当这个引用被销毁了计数器就-1。虽然这个方法简单有效,但是存在两个问题:第一个内存空间浪费多,因为计数器也是需要一定内存来存数据的,当代码中存在多个对象的时候就会额外产生很多内存占用。第二个就是存在循环引用问题:
b)可达性分析【Java的做法】
Java 的对象都是通过引用来指向并访问的,大多数情况下,一个引用指向一个对象,这个对象里面的成员又指向别的对象,比如二叉树。然后可达性分析,就把所有的这些对象组织的结构视为是树,然后就从树结点出发,遍历这棵树,把可以访问的视为"可达",不能访问的视为"不可达"。JVM 就会将这些不可达的视为垃圾进行回收。很明显,这种遍历相比上述会比较慢,因此就会隔一段时间遍历一遍。
2. 再以对象为单位进行释放
垃圾回收算法
a)标记清除
b)复制算法(解决了内存碎片问题)
c)标记整理(解决了复制算法空间利用率低的缺点)
这种类似于顺序表有搬运元素的操作,效率也不高,特别是当搬运空间比较大的时候开销也会更大。
分代回收
基于上述这些基本策略,JVM 就采用了复合策略,也就是把垃圾回收分成不同的场景,根据场景的不同决定使用哪些算法。
这里就会给对象引入一个 "年龄" 这样的概念,其中的 "一岁" 就代表经历了一轮可达性分析之后,发现这个对象不是垃圾(也可以理解成是 熬过GC的轮次)。