文章目录
- JVM内存划分
- JVM类加载
- 为什么需要类加载?
- 类加载的过程
- 何时触发类加载?
- 双亲委派模型
- JVM的垃圾回收机制(GC)
- 什么是垃圾回收?
- GC回收哪部分内存?
- 回收机制
- 怎么找出垃圾?
- 引用计数
- 可达性分析(JVM采用)
- 怎么清理垃圾?
- 标记清除
- 复制算法
- 标记整理
- 分代回收
JVM是Java程序运行的基础,整个JVM的体系结构非常复杂,但是幸运的是经常使用到的只有三个部分:内存划分、类加载和垃圾回收机制。我们接下来就向大家详细的介绍JVM里对于这三部分的具体实现:
JVM内存划分
Java程序是一个名字为Java的进程,这个进程就是所说的“JVM”。在Java程序运行前,JVM就会先向操作系统申请一大块内存空间,这个内存空间内部又划分为几个不同的部分:
注:
- 栈:存放的是方法之间的调用关系
- 堆:存放程序中创建的所有对象(编译后未加载好的对象)
- 方法区:存放类对象(编译后加载好的对象)
- 程序计数器:存放下一个要执行的指令的地址
- 在java1.7之后,将方法区换成了元数据区;即在原本的方法区内部又划分了不同的区域
- 在一个JVM进程中,堆区和方法区只有一份,所有线程共用;栈区和程序计数器,每个线程都有自己的一份
- 同时,栈:存放局部变量;堆:存放成员变量;方法区:存放静态变量。
JVM类加载
为什么需要类加载?
Java程序在运行之前需要先编译,由 .jaca 文件编译为 .class 文件(二进制字节码文件),运行的时候 JVM 就会读取相应的 .class 文件并解析其中的内容,在内存中构造出类对象并进行初始化。
类加载的过程
-
加载
找到 .class 文件,读取文件内容并按照 .class 规范的格式来解析
-
连接
-
验证
检查当前 .class 文件里的内容格式是否符合要求
-
准备
给类里的静态变量分配内存空间
-
解析
初始化字符串常量:把符号引用(占位符)替换成直接引用(内存地址)
比如:代码中有一行 String s = “hello”;在类加载之前 “hello” 这个字符串常量没有分配内存空间,没有内存空间 s 也就无法保存 “hello” 的真正的地址,只能用占位符标记一下;等给 “hello” 分配内存空间后,再用真正的地址来替换之前的占位符。
-
-
初始化
针对类进行初始化,初始化静态成员、执行静态代码块、加载父类。
何时触发类加载?
使用到一个类的时候就要触发类加载(类并不是程序一启动就加载了,而是在第一次被使用的时候才会加载)
比如:
- 创建了这个类的实例
- 使用了类的静态方法、静态属性
- 使用了类的子类(加载子类会触发加载父类)
双亲委派模型
双亲委派模型:决定了按照啥样的规则到哪些目录下去找 .class 文件
JVM加载类是由类加载器(class loader)这样的模块来负责的,JVM自带了多个类加载器:
- Bootstrap ClassLoader:负责加载标准库中的类
- Extension ClassLoader:负责加载JVM扩展的库的类
- Application ClassLoader:负责加载项目里自定义的类
注:
- 上述三个类加载器具有父子关系
- 进行类加载的时候,输入的内容是 全限定类名 形如:java.lang.Thread
- 加载的时候从 Application ClassLoader 开始
- 某个类加载器加载的时候,不会立即扫描自己负责的路径,而是先把任务委派给父“类加载器”来先进行处理
- 找到最上面的 Bootstrap ClassLoader 再往上就没有父类加载器了,只能自己动手加载
- 如果父类没有找到类,就交给自己的儿子继续加载
- 如果一直找到最下面的 Application ClassLoader 也没有找到类,就会抛出一个“类没有找到”的异常,类加载也就失败了。
按照这个顺序加载最大的好处就是:如果我们自己写了个类,正好全限定类名和标准库中的类冲突了(类名一样),此时进行类加载就能保证加载到标准库中的类,防止代码加载错了带来问题。
JVM的垃圾回收机制(GC)
什么是垃圾回收?
我们的内存空间是有限的,如果只申请内存空间但是不释放就会造成很严重的问题。而垃圾回收机制呢就是:让JVM自动判定你申请的内存啥时候需要释放,即当JVM认为这块内存不再被使用了就会释放。
这样的机制可以减轻程序员的负担也能更好的避免忘记释放内存空间的问题,毕竟程序员只负责申请内存空间即可,释放内存空间的工作交给了JVM来完成。
GC回收哪部分内存?
栈:存放方法间的调用关系----------释放时机确定,不必回收
堆:存放程序中所有的对象----------GC进行回收
方法区:存放加载好的类 ----------加载好后不需要回收
程序计数器:存放下一条指令的地址---------是一块固定的内存空间,不必回收
回收机制
怎么找出垃圾?
如果一个对象再也不被使用了,那么它就是垃圾。
在Java中,对象的使用需要凭借引用。如果一个对象已经没有任何一个引用可以指向它,那么它就无法被使用,就变成了垃圾。
所以:我们要通过引用来判断当前对象是否还能被使用,没有引用就代表无法使用。我们下面介绍两种判断引用是否存在的方法:
引用计数
引用计数:给每个对象都加一个计数器,通过这个计数器来表示“当前对象具有几个引用”
每多一个引用指向该对象,计数器就+1;每少一个引用指向该对象,计数器就-1;
当计数器数值为0的时候,就证明当前对象没有引用了、不能被使用了、可以被释放了。
引用计数:
优点: 简单、容易实现、执行效率高
缺点:
- 空间利用率比较低。(如果对象里只存一个int的数值,此时还需要再引入一个int的计数器,计数器所占的比重就太大了)
- 可能会出现循环引用的情况。(虽然当前有两个对象的计数器都为1,但是是这两个对象在相互引用,并没有别的引用指向他俩。此时他俩本应该被释放的,但是现在不能释放)
可达性分析(JVM采用)
约定一些特定的对象作为(GC roots),每隔一段时间,从 GC roots 出发进行遍历,看看当前哪些对象是能被访问到的。能被访问到的对象就称为“可达”;访问不到的对象就称为“不可达”。包含“可达”对象的类不做处理,包含“不可达”对象的类要被确定为垃圾。
注: 可以设为 GC roots 的对象有:
- 栈中引用的对象
- 方法区中常量引用的对象
- 方法区中静态属性引用的对象
怎么清理垃圾?
标记清除
灰色的部分被标记为“垃圾”。标记出垃圾之后,直接把对象对应的内存空间释放。
缺点:内存碎片化。会导致整个内存“支离破碎”,假设总内存空间为9k(全部被使用),每块被释放的空间(灰色区域)为1k。清理垃圾后释放出4k的空闲空间,但是如果想要申请2k的内存空间会申请失败。因为现在空闲的空间都是分离的,申请空间时要得到连续的内存空间
复制算法
在申请内存空间的时候直接申请想要空间大小的2倍,并把这块内存空间分为俩部分(左侧和右侧),使用左侧空间时,右侧不用;使用右侧空间时,左侧不用。
在回收垃圾时,不再是原地释放了,而是把“非垃圾”(可以继续使用的对象)拷贝到另一侧,然后再把之前使用的这一半空间整个释放掉。
优点:解决了内存碎片化问题
缺点:
- 空间利用率更低了(使用一半空间一半)
- 如果这一轮GC,大部分对象都要保留,只有小部分对象需要回收,复制的开销非常大
标记整理
像顺序表的删除元素一样,进行搬运操作。如果前面的一小块内存空间被释放掉,则把它后面的数据依次向前搬运,占用掉已经释放了的这块内存空间。
优点:
- 解决了内存碎片化问题
- 提高了空间利用率
缺点:搬运操作的消耗也很大
分代回收
分代回收,是对上面三种算法的综合应用:
- 将对象按照不同的年龄放入到不同的区域,针对不同的区域采用不同的回收方式。其中,对象的年龄是指该对象经历的GC的轮次,经历过一次GC对象的年龄就+1。
- 根据对象的年龄,把对象分为新生代(年龄小的对象)和老年代(年龄大的对象)
- 新生代GC的扫描频率较高;老年代GC的扫描频率较低。(根据一个基本规律:如果一个对象的寿命比较长,那么它大概率还要活的更久。换句话说,要死早死了)
- 刚创建出来的新对象进入伊甸区。如果新对象熬过一轮GC,就通过复制算法,复制到生存区中。(绝大多数新对象都熬不过一轮GC)
- 生存区中的对象也要继续经历GC的考验。每熬过一轮GC就通过复制算法拷贝到另一个生存区中,只要这个对象不消亡就会在两个生存区之间来回拷贝。(每一轮GC都会杀死一大波对象)
- 如果一个对象在生存区中反复坚持了很多轮都没有消亡,就把它放到老年代中
- 在老年代中的对象通过标记整理的方式来杀死(老年代中GC扫描的频率比较低)
特殊情况: 如果对象是一个非常大的对象,则直接放入老年代。
因为:
- 大的对象进行复制算法的时候开销太大了
- 大的对象创建出来相对较难,好不容易创建出来后不会让它轻易的销毁。