JVM
JVM是java的虚拟机,是一个十分复杂的东西,所以掌握的要求比较高.本文主要是研究JVM的三大话题
- JVM内存划分
- JVM类加载
- JVM的垃圾回收
JVM内存划分
java程序要执行的时候,JVM会先申请一块空间,这里就涉及到JVM的内存划分
- 堆 : 放的是new 出来的对象
- 栈: 放的是方法之间的调用关系(栈中可以分为虚拟机栈和本地方法栈)
- 虚拟机栈: java中用来保存方法调用关系的内存空间
- 本地方法栈: jvm中C++写的代码,算是本地的方法的调用关系的内存空间
- 方法区: 放的是类对象(加载好的类)
- 程序计数器: 放的是下一个要执行的指令的地址
以上的内存划分都是在java1.7的时候
代码中的局部变量: 栈上
代码中的成员变量: 堆上
代码中的静态变量: 方法区
一个JVM进程中,堆和方法区都是只有一份的,栈和程序计数器每个线程都有自己的一份
常常会结合代码来判断内存划分
t是局部变量,所以在栈上
x是成员变量,在堆上
y是静态变量,是在类对象中,就是在方法区中
一个误区: t是一个引用类型,是不是在堆上?其实不是的!,变量在哪个部分,主要就是看变量是什么变量(局部 成员 静态)
这里的t2 是静态的,所以实在方法区中,new 后面的 Test2是在堆上的, xx是成员变量,所以是在堆上的
JVM的类加载
类加载是什么的
java程序在 运行之前需要先编译,也就是.java --> .class二进制字节码文件
在运行的时候,JVM会读取对应的.class文件,并解析内容,在内存在构造对象并进行初始化
简单来说,类加载是将类从文件中加载到内存中
类对象描述了这个类是什么样子的,有哪些属性(属性的名字 类型 访问权限)
有哪些方法(方法名字 参数个数 类型 返回值 访问权限)
继承自那个父类,实现了哪些接口
类对象是创建实例的具体依据
类加载的步骤
类加载大致能分成3个步骤
- 加载
- 验证
- 准备
- 解析
- 初始化
加载: 找到.class文件,读取文件内容,并按照.class规范来解析
验证: 检查当前的.class里的内容格式是否符合要求
准备: 给类里的静态变量分配内存空间
解析: 初始化字符串常量,将符号引用替换成直接引用
所谓的符号应用就是占位符,直接引用就是内存地址
在.class文件中,会包含字符串常量,但是在类加载之前,字符串常量没有分配内存空间,得类加载之后才会有内存空间,没有内存空间也就没有字符创常量的真实地址,所以只能先使用一个占位符,等分配好内存之后再替换之前的占位符
初始化: 针对类进行初始化,初始化静态成员,并加载父类
何时触发一个类加载
使用到一个类的时候就会触发类加载(并不是程序一启动就会进行类加载,而是在使用的时候才会进行类加载) [类似于懒汉模式]
具体什么情况是使用呢?
- 创建这个类的实例
- 使用了这个类的静态属性或者静态方法
- 使用了类的子类(加载子类就会触发加载父类)
双亲委派模型
JVM加载类是由 类加载器 (class loader)来负责的
JVM自带了多个 类加载器的
Bootstrap ClassLoader
Extension ClassLoader
Application ClassLoader
这个三个类加载器负责不同的模块
Bootstrap ClassLoader 负责加载标准库中的类
Extension ClaaLoader 负责加载JVM拓展的库的类
Application ClassLoader 负责加载我们自己项目中的自定义类
描述上述三个类加载器如何相互配合的工作工程,就是双亲委派模型
- 上述的三个类加载器存在父子关系,其中Application ClassLoader是最小的子类
- 进行类加载时,输入的内容要是全限定类名(写完整的类名),比如: java.lang.Thread
- 加载的时候先从最小的子类Application ClassLoader开始,但是类加载器不会立刻扫描自己负责的路径,而是将任务委派给父 "类加载器"来处理
垃圾回收机制GC
GC是什么
在学习C语言的时候,创建内存有两种方式
直接定义变量,变量对应着内存空间,一旦出了作用域,就会释放
还有一种是malloc申请内存(动态内存申请),务必需要通过free来释放资源,要是真的忘了就会导致内存泄漏,十分危险
所以手动释放内存是很容易出事的
GC(垃圾回收机制)是一种主流的解决方案,在Java Python JS Go PHP中都存在GC垃圾回收机制
所谓的GC: 程序员只要负责申请内存,释放内存的工作直接交给JVM完成就行了
虽然GC很好用,但是GC也有问题,其中最大的问题就是GC会引入额外的开销(时间 + 空间)
时间上会存在STW问题(Stop The World),会导致卡顿
空间上会消耗额外的CPU和内存资源
GC回收哪部分
JVM的几个部分:
方法区: 类对象, 加载之后不太会卸载
栈: 出了作用域就会释放,所以不用回收
程序计数器: 固定的内存空间,不必回收
堆: GC主要的回收对象
GC是怎么执行的
GC其实主要就是两步骤
- 先找出垃圾(看看谁是垃圾)
- 在回收垃圾(释放内存)
判定对象是不是垃圾
如果一个对象再也不会使用了,就算是垃圾了
在Java中,对象的使用,需要借助引用,要是一个对象,已经没有任何引用指向它了,说明了这个对象再也无法被使用了,就算是垃圾了
所以,判断是不是垃圾的最重要的就是,存在引用说明对象不是垃圾,要是引用不存在了就说明是垃圾
判断对象是否存在引用的两种方法是引用计数(不是JVM采用的方法,但是Python和PHP使用) 和 可达性分析(JVM中使用的方法)
所以要注意审题 : 确实问的是Java的垃圾回收机制还是"垃圾回收的机制",这里的两种方法是用来判断是不是垃圾
引用计数
给每个对象加上一个计数器,这个计数器表示当前的对象有几个引用
每当多一个引用指向该对象,计时器+1
每当少一个引用指向该对象,计数器-1(比如引用是一个局部变量,出了作用域 或者引用是一个成员变量,所在的对象被销毁了)
当计数器变成0的时候,说明此时已经没有引用指向这个对象了,所以就可以释放内存了
引用计数的优点: 简单 容易实现 执行效率也比较高
应用计数的缺点:
- 空间利用率地,尤其是对于小对象而言. 要是一个小对象中只有一个int的成员,结果还有拿出一个int的空间给计数器
- 可能会出现循环引用的情况
这两个对象实现了循环相互调用,这样子最后计数器就是1了,但是也没有引用能指向它们,所以就不能被释放
可行性分析[JVM采用的方法]
约定一些特定的变量来作为GC roots
每个一段时间,从GC roots出发,进行遍历,看看哪些变量能被访问到,能访问到的变量就算是"可达"
这里的GC roots可以是栈上的变量 / 常量池引用的对象 / 方法区,引用类型的静态变量
在找到 垃圾之后该怎么回收垃圾?
具体回收垃圾有4中方法
- 标记清除
- 复制算法
- 标记整理
- 分代回收
标记清除
在发现哪些是垃圾之后,键对象对应的内存空间释放
简单粗暴,但是有一个最大的问题,就是会导致产生内存碎片,加上上图中的深色的垃圾每个占1KB,清除完之后我想要申请一个2KB的的空间都申请不到,因为此时内存都是碎片的,没有连在一起的空间
复制算法
复制算法虽然能解决内存碎片的问题,但是缺点也是很明显的
复制算法的缺点:
- 空间利用率更低了(每次都是只用一半的内存)
- 一轮GC下来,万一大部分对象都是要保留的,只有少部分的对象要回收,这个时候复制的开销就会很大
标记整理
保证整理的方法类似于顺序表 删除/覆盖元素 主要是搬运操作
标记整理的空间利用率提高了,也能解决内存碎片的问题,但是搬运操作也是比较耗时的
分代回收
分代回收将上面的复制算法和标记整理综合了一下,根据 对象的不同特点来采取不同的回收方式,这里的对象特点主要是指对象的年龄
对象的年龄是根据GC的轮次来的
GC 就是一组线程,周期性扫描代码中的所有对象,要是一个对象经历了一次GC,没有被回收,它的年龄就要+1
一个基本的经验规律:
如果一个对象的寿命比较长,大概率就还会活的根据(要死早就死了,能活下来,说明生命力还比较旺盛)
针对以上的经验,将对象分成新生代(minor GC)(GC扫描的评率更高)和老年代(full GC / major GC)(GC扫描的频率更高)
新生代(minor GC): 刚刚被创建出来的新对象,往往很容易朝生夕死,很多对象都熬不过一轮GC
新对象会进入到伊甸区,要是新对象能坚持过一轮GC, 没挂,就会通过复制算法,复制到生存区
进入到生存区之后,每熬过一次GC,就会通过复制算法拷贝到另一个拷贝去,要是这个对象能一直不消亡 , 就会在两个生存区栈反复拷贝,每次GC都会筛选掉很多的对象
要是一个对象能在生存区中坚持了很多轮GC,还不挂,则进入到老年代(full GC / major GC )
当对象来到老年代,GC也还是会有的,只是频率低很多,这里每轮GC使用的是标记整理的方式来处理老年代对象
分代回收的过程,非常像找工作的情况
总结一下:
- 判断垃圾
- 引用计数
- 可达性分析
- 进行回收
- 标记清除–>内存碎片
- 复制算法–>浪费空间大
- 标记整理–>类似于顺序表搬运元素,时间比较长
- 分代回收–>因地制宜完成回收