JVM 内存划分
JVM 内存划分为 四个区域,分别为 程序计数器、元数据区、栈、堆
程序计数器是记录当前指令执行到哪个地址
元数据区存储存储的是当前类加载好的数据,包括常量池和类对象的信息,.java 编译之后产生 .class 文件,运行代码的时候会把了类对象的信息保存在内存里,其中类元信息包括:类名、类的父类,实现哪些接口,类的权限public/private…
方法元信息:方法名,参数类型,返回值…
类元信息和方法元信息都是类对象里面包含的。
栈:分为本地方法栈和虚拟机栈,本质上都是存储方法的栈帧,每次调用方法的时候,会进行压栈,方法调用完会执行出栈操作,本地方法栈执行的是C++方法,虚拟机栈执行的是 Java方法。
堆:存放 new 出来的对象的实例。
Test t = new Test();
new Test() 这个对象的实例一定是存储在堆上的。
t 需要分类讨论,如果 t 是一个局部变量,t 就是在栈上的;如果 t 是 成员变量,t 就是在堆上的;如果 t 是 静态成员变量,t 就是在元数据区里的。
元数据区 和 堆,整个Java进程共用一份
程序计数器和栈,每个线程有一份
在面试时,如果遇到谈一下栈和堆的话, 要注意是数据结构里的栈和堆, 还是JVM的栈和堆
类加载
1)加载:根据类的全限定名(包名+类名)找到对应的 .class 文件,然后打开文件,将文件的内容读取到内存中里。
2)验证:解析和校验 .class 文件的内容是否合法,如果合法就把这里的内容转化成结构化的数据
3)准备:给类对象申请 全0 的内存空间
4)解析:针对字符串常量进行初始化,将 .class 文件中的字符串常量放入元数据区的常量池中
5)初始化:将类对象进行最终的初始化,包括对类对象各种属性的填充,类中的静态成员,父类,如果父类没有被加载过,会触发父类的类加载。
类加载触发的时机为懒加载,当Java代码需要用到哪个类才会触发哪个类的加载
双亲委派模型
双亲委派模型的根据JVM实现类加载的源码提出的,也就是说是先有JVM的类加载源码,才有后面的双亲委派模型。
JVM 默认提供了三种类加载器,分别为 BootstrapClassLoader、ExtensionClassLoader、ApplicationClassLoader
你可以认为这三个类加载器的关系如下:
它们之间的联系,你可以认为是这三个类加载器都有一个引用指向上一个类加载器,ApplationClassLoader 的父亲的 ExtensionClassLoader,ExtensionClassLoader 的父亲是 BootstrapClassLoader,BootstrapClassLoader 的父亲为 null。
BootstrapClassLoader 负责加载Java 标准库的 .class 文件
ExtensionClassLoader 负责加载 Java 扩展库的 .class 文件
ApplicationClassLoader 负责加载 Java第三方库的.class 文件
随着时代的发展,Java的扩展库我们很少使用,取而代之的是Java第三方库,例如使用maven 加载的库就是第三方库
双亲委派模型的执行流程:
进行类加载,通过全限定类名寻找 .class 文件,先从 ApplicationClassLoader 最为入口开始,然后将类加载任务委托上一级 ExtensionClassLoader ,再将类加载任务委托给上一级 BootstrapClassLoader,由于 BootstrapClsssLoader 没有上一级,开始执行类加载任务,将需要的Java标准库里的 .class 文件加载到内存里,然后将任务下放给下一级 ExtensionClassLoader ,ExtensionClassLoader 将所需要的Java扩展库的 .class 文件加载到内存里,最后将任务交给下一级 ApplicationClassLoader,ApplicationClassLoader 负责加载Java第三方库的 .class 文件。
如果没有找到对应的 .class 文件,就会抛出异常 ClassNotFoundException
垃圾回收 GC
在Java中你不需要手动释放内存,这是因为JVM 内部实现了 GC,也就是垃圾回收机制,这个机制可以自动回收内存空间,这使得Java程序员不需要考虑内存泄漏的问题。
垃圾回收有两个步骤:首先就是找到垃圾,然后再回收垃圾
如何找到垃圾???
找到垃圾这里介绍两个算法,一个是引用计数,另一个是可达性分析
引用计数
在创建的对象旁边开辟一个空间,用来存放该对象被多少个引用引用着,当被引用的数量为 0 的时候就会进行垃圾回收:
但这也有缺点:
1)内存消耗多,尤其是对象本身比较小的时候,引用计数占据的空间的比例就会很大,例如:假设对象自身占8个字节,引用计数可能占 4 个字节
2)可能出现 “ 循环引用” 的问题
假设一个类里面还定义了一个引用,用这个类创建的两个对象的引用相互指向对方,那这两个对象是不可能被垃圾回收掉的:
当我们将 a 和 b 两个引用置为 null 的时候,我们创建的两个对象的实例是不会被回收掉的,因为此时两个对象都被对方引用着,双方的引用计数均为 1,这就是 “循环引用”
可达性分析(JVM 使用)
为了解决上述引用计数产生的两个弊端(空间浪费和循环引用),这里提出了可达性分析算法。
可达性分析算法采用时间换空间的策略,这也是 JVM 寻找垃圾使用的算法
首先对代码的一些特定对象作为遍历的 “起点”:这些特定的对象包括 栈上的局部变量(引用变量)、常量池引用指向的对象、静态成员(引用类型的)【这些对象在程序运行到任何时刻都容易被捕获到的】
【因为不会扫描堆上的对象,所以不会存在因为 “循环引用” 而影响垃圾判断的现象】
然后进行遍历:判断某个对象是否能被访问到,在能访问到的对象标记为 “可达”。
当完成遍历后,把未标记为 “可达” 的对象标记为 “不可达”
JVM 通过上述操作,就知道哪些对象是 可达的,哪些是 不可达的,接下来就是回收垃圾的操作了。
举个例子:
class Node {
Node left;
Node right;
}
public class Test2 {
public Node build() {
Node a = new Node();
Node b = new Node();
Node c = new Node();
Node d = new Node();
Node e = new Node();
Node f = new Node();
Node g = new Node();
a.left = b;
a.right = c;
b.left = d;
b.right = e;
f.right = g;
return a;
}
}
由于方法最后只返回 a 这个节点,这时候 f 和 g 节点就会被回收掉。
如何处理找到的垃圾???
这里介绍四种处理方式:标记–清除 算法,复制算法,标记整理 算法,分代算法,其中最后一个算法 分代算法是 JVM 真正使用的。
标记–清除 算法
在需要回收的内存上做上标记,然后进行回收清除操作,这是最直接的算法,但是存在一个缺点就是会产生内存碎片。
举个例子:假设一共有 7 块内存块,经过可行性分析算法得出 2、4、6 这三块内存为需要回收的内存,先做上标记,然后进行回收释放,但是空出来的内存碎片可能难以得到利用。
标记整理算法
由于标记清除算法会产生大量的内存碎片标记整理算法直接将垃圾回收设计为类似顺序表删除的形式, 将非垃圾的对象排到一起, 这样就可以空出大块的空闲内存块了.
但是缺点也是显而易见的, 就是系统开销比较大, 尤其是移动较大的对象的时候.
复制算法
将内存空间一分为二,当需要进行垃圾回收的时候,将非垃圾的内存块依次紧凑地复制到另一半内存中。
但是也存在两个缺点
1)内存消耗大,内存的空间利用率低
2)一旦非垃圾的内存块较多的时候,复制的成本就会较高,尤其是复制包含较大的对象的时候。
举个例子:要回收 1 和 4 号内存块。
分代算法 (JVM 使用)
分代算法是结合了上面两种算法的优点,取长补短的综合算法
JVM 将对象划分为 新生代和老年代,新生代又划分了三个区域分别是伊甸区、两个幸存区【空间占比为8:1:1】
分代算法的代指的是对象的年龄大小, 年龄的大小和 GC 轮次相关
命名的来源于圣经,人类发源地伊甸园,在这里指新生成的对象的存放位置,由于灭世洪水的降临,人类打造了诺亚方舟,成功躲避天灾的人门就是幸存者,这里的幸存区是指对象经过GC之后还能存留下来的存储位置
我们认为对象一开始生成的时候,是最容易被 GC 掉的,所以新生成的对象保存在 伊甸区里,经过 一轮GC 之后剩下的对象不会很多,将这些幸存的对象放到幸存区里,两个幸存区就是为了执行复制算法,经过多轮 GC 之后,还能在幸存区里存活的对象将放入到老年代。
一般来说,如果内存占用空间比较大的对象我们一般是直接放入老年代, 减少系统开销
新生代的 GC 频次较高
老年代的 GC 频次较低
因为我们认为要 GC 掉的早就 GC 掉了,之所以能存活在老年代,说明这个对象经常被使用,也就是说老年代的对象不需要高频次的 GC 次数,GC 的周期可以长一点,减少系统开销。