JVM的内存区域划分
JVM它其实也是一个进程,进程运行的过程中,会从操作系统中申请一些资源.内存就是其中的一种.这些内存就支撑了java程序的运行.JVM从系统中申请的一大块内存,会根据实际情况和使用用途来划分出不同的空间,这个就是区域划分.它一般分为 堆区, 栈区, 程序计数器, 元数据区4个区域.
堆区
我们代码中new出来的对象就是在堆当中,对象持有的非静态成员变量,也是在堆当中.
栈区
栈区分为本地方法栈和虚拟机栈. 本地方法栈是jvm通过c++写的代码的调用关系和局部变量.一般我们只关心虚拟机栈. 虚拟机栈是记录了java代码的调用关系和java代码的局部变量.
程序计数器
这是一块小区域,专门用来存储下一条要执行的java指令的地址,和x86cpu中的eip寄存器差不多.
元数据区
在之前也叫做 "方法区". 它里面记录的是类的信息,方法的信息, 一个程序有哪些类,每个类有哪些方法,方法里面都会包含哪些指令,都会记录在元数据区中.想我们的代码中的各种逻辑都会转化为java字节码,这些字节码就会在程序运行的时候被jvm加载到内存中,放到方法区当中.程序怎么执行就会按照元数据区中的java字节码来依次执行.
注意: 这里堆和元数据区只有一份,但是栈区和程序计数器由N份,和线程数目相关.
JVM的类加载机制
这里的类加载就是java进程来运行的时候,需要把.class文件从硬盘读取到内存,且进行一系列的校验解析的过程. 这里加载的过程可以分为5个步骤. 加载 - 验证 - 准备 - 解析 - 初始化.
加载
大概就是把硬盘中的.class文件找到,打开文件且读取到文件内容.
验证
把当前读到的文件内容需要确保是合法的.class文件格式.
准备
给类对象申请内存空间.这里的申请到的内存空间,里面的默认值都是0.类对象中的静态成员变量的值也就是0)
解析
这里就是对类中的字符串常量进行处理.将常量池中的符号引用转换为直接引用的过程.(符号引用可以理解为字符串和它的引用在文件中有偏移量,直接引用则就是地址).
初始化
针对类对象完成后续的初始化过程(这里还会执行静态代码块的逻辑,父类的加载)
双亲委派模型(加载环节)
这里描述了怎么样来查找.class文件的策略.在我们的JVM中进行类加载的操作,会有一个专门的模块 - "类加载器(ClassLoader)". JVM中有三个ClassLoader.这里类加载器的作用就是给它一个带有包名的类名,来找到对应的.class文件.
三个库的作用:
BootstrapClassLoader: 负责查找标准库中的目录.
ExtensionClassLoader: 负责查找扩展库中的目录.
ApplicationClassLoader: 负责查找当前项目的代码目录和第三方库.
这三个加载器的关系类似于父子关系.
工作过程
1. 先以ApplicationClassLoader为入口,开始工作.,但是它不会立刻开始查找自己负责的目录,而是将任务交给它的父亲.
2. 任务到ExtensionClassLoader,它也不会立刻开展工作,而是交给它的父亲.
3. 任务到BootsstrapClassLoader,它才会开始真正的搜索负责的目录.通过全限定类名,来尝试在标准库中找到符合的.class文件.找到了就进入打开文件,读文件的流程.没找到则交给它的孩子来处理.
4. ExtensionClassLoader拿到父亲给它的任务后,也会通过全限定类名,在扩展库中查找符合的.class文件.找到了就进入读文件的流程.没找到再交给它的孩子处理.
5. ApplicationClassLoader拿到夫妻给它的任务后,也会通过全限定类名,在项目中的目录和第三方库的目录中查找.找到了就进入读文件流程.没找到说明加载失败.则会抛出ClassNotFoundException异常.
这样设定的目的其实最主要的就是确保这几个类加载器的优先级. 按照这样的方式就算你自己写的类不小心和标准库中的类名字重复了,也可以避免导致标准库中的类失效.
JVM的垃圾回收机制(GC)
JVM的垃圾回收,说的就是将内存回收.在JVM的多个区中. 程序计数器不需要GC,栈不需要,因为它的局部变量在代码块执行结束后就会进行自动销毁. 元数据区也不需要,它一般都是涉及到类加载,很少涉及到类卸载. 这里最主要使用GC的就是堆区.因为它里面记录的是对象,而代码中可能会new出许许多多的对象,有的对象可能不使用了,就需要销毁.所以这里的内存回收,也可以看成是对象回收.
垃圾回收分为两步:
1. 识别出垃圾,哪些对象是垃圾,哪些对象不是垃圾.
2. 把标记为垃圾的对象的内存空间进行释放.
识别垃圾
这里识别一个对象是不是需要继续使用,就是判断还有没有引用在指向它.如果没有,它就可以视为垃圾.这里有两种方法: 1. 引用计数 2. 可达性分析
引用计数
这个方法在JVM中并没有使用,但是在其他的主流语言中还是在广泛应用中.
它的处理方式就是:
给每一个对象安排一个额外的空间,这个空间里就保存当前有几个引用. 而另一边会有专门的扫描线程,去获取到当前每个对象的引用计数的情况,一但发现这个对象的引用计数为0,就代表可以释放了.
但是它会存在一些问题:
1. 会消耗额外的内存空间.尤其是当对象比较小时,你的计数器消耗的空间可能就达到对象的一半了.
2.引用计数可能会产生循环引用的问题.
可达性分析(JVM使用的方法)
它的本质就是用时间来换空间. 它就不会产生额外的空间和循环引用的问题.
在代码中,会定义出很多变量,栈上的局部变量,堆上的成员变量,方法区的静态变量,常量池中引用的对象... 它就可以从这些变量作为起点,去进行遍历.这个遍历就是沿着这些变量持有的引用类型的成员,再进行下一步的访问. 可以遍历到的对象就不是垃圾,反之.
这里的遍历我们可以想象为二叉树的遍历.一但有一个节点为null,那这个结点后面的也都为null,也就是垃圾.
JVM中会存在扫描线程,对代码不断进行遍历.
清理垃圾
清理垃圾就是将被标记的内存空间进行释放.这里释放有三种方法.
标记 - 清除
这里就是直接将标记为垃圾的对象直接释放掉.但是一般不会使用这个方案.因为它会有内存碎片问题.直接释放,就会产生很多小的,离散的空闲内存空间,这就可能导致后续的内存申请失败.因为内存申请一般都是申请一块连续,大范围的空间.
复制
它的核心就不是直接释放内存,也是将不是垃圾的对象,复制到另一半的空间中(它会将可用内存空间分为两半).
这样子就规避了内存碎片化的问题,但是也会有一些缺点.
1. 总用的内存空间变少了.
2. 如果每次需要复制的对象比较多,复制开销就会很大了(它适合大部分对象都释放,少部分对象存活,这个时候使用复制)
标记 - 整理
这个方式就类似于顺序表中的删除中间元素,需要进行搬运.通过这个过程,也就解决了内存碎片的问题,但是它这里的搬运内存开销会很大.
JVM的分代回收
因为上面方法的优缺点,JVM就结合上面的思想,搞出来了一个综合方案.
JVM会给对象进行年龄记录,被JVM的扫描线程扫描过一次后就加一岁,起始岁数为0. 而还给堆内存划分了两个区域:分为为新生代和老年代.且新生代中还分了三个区:伊甸区和两个生存区.
处理流程:
1. 当代码中new出一个新的对象时,就是创建在伊甸区中的,伊甸区中会有很多对象.(放在里面是有一个经验规矩: 伊甸区的对象大多都是活不过第一轮GC的,"朝生夕死")
2. 当第一轮GC后,少部分存活下来的对象,就会通过复制算法拷贝到生存区中(GC后存活下来的对象不会很多,复制开销也不会很大).后续还会有GC扫描,不仅会扫描伊甸区,还会扫描生存区.生存区的大多数对象也会在扫描中被标记为垃圾.少部分存活的,又会继续复制算法拷贝到另一个生存区汇中.这样不断往复,且每过一次GC,生存下来的对象年龄就会+1.
3. 当一个对象在生存区中经历了若干次GC还没有消亡,则JVM就会认为它的生命周期特别长,就会将它从生存区拷贝到老年代.
4. 老年代的对象也会被GC扫描,但是扫描频率会大大降低.
5.当老年代的对象消亡时,JVM就会按照标记整理的方式,释放内存(老年代的对象很少,所以搬运开销不会很大)
到这里,上面的就是JVM中GC的核心思想.但是在一些实现细节上还是会有一些变数和优化~