JVM 是 Java Virtual Machine 的简称, 意为Java虚拟机.
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
也就是JVM的内存布局
保存程序中创建的对象
存放被JVM加载的类信息(类对象), 常量, 静态变量(static), 即时编译器编译后的代码等数据
存放方法的调用关系, 局部变量
记录了当前线程执行的下一条指令的内存地址
class Test {
public int n = 20;
public static int a = 10;
}
public class Main() {
public static void main(String[] arg) {
Test t = new Test();
}
}
n是普通的成员变量, 就包含在new的对象的内部, 存放在堆上
a是一个静态成员变量, 包含在类对象中, 存放在方法区中
t是一个局部变量, 存放在栈上
new Test()这个对象是保存在堆上的
栈上的t保存了堆上的new Test()的内存地址
Java程序一开始是一个.java文件, 通过javac编译成.class文件, 运行java程序, JVM就会读取.class文件, 把文件的内容加载到内存中, 并构造成一个.class对象
类加载就是: 把类从硬盘文件加载到内存中.
流程:
加载: 找到.class文件, 打开文件, 并读取文件内容, 并且尝试解析格式
验证: 检查当前.class文件是否符合标准格式
准备: 给类对象分配内存. 分配出来的内存空间, 内容就是全0的值.
解析: 将常量池内的符号引用替换为直接引用的过程, 也就是初始化常量的过程. 初始化类对象中涉及到的一些字符串常量, 这些字符串常量在.class文件中已经存在, 直接读到内存中就行.
初始化: 对类对象进行更具体的初始化操作. 初始化金泰城园, 执行静态代码块, 加载父类…
实则单亲.
JVM加载.class文件的时候, 需要用到"类加载器"模块.
JVM中自带了三个类加载器:
不是父类子类的继承关系, 而是对象里有一个parent引用指向 父 类加载器 实例
当接收到类加载请求时:
这样做的目的: 明确类的优先级(标准库的类最优先加载, 扩展库其次, 第三方库最低)
标准库中有一个类java.lang.String, 如果我们自己也写了一个java.lang.String类
JVM始终是加载标准库里的类, 而不会加载到我们写的类, 这样便可以避免程序员的代码对标准库的代码产生负面影响
类加载的时机?(类似于懒汉模式, 用到了才加载)
对于程序计数器, 栈而言, 它的生命周期与相关的线程有关, 随线程生, 随线程灭, 并且这两个区域的内存分配和回收具有确定性, 因为当方法或者线程结束了, 内存就自然跟着线程回收了. 所以垃圾回收主要是回收堆和方法区这两个区域.
缺点: 消耗额外的系统资源, 消耗一定的时间, 可能有STW问题
垃圾回收是以对象为单位进行回收
垃圾回收的流程分为两步: 1. 判定对象是否为垃圾. 2. 释放对象的内存
一个对象, 如果在后续代码中不会被继续使用到了, 就可以视作是垃圾了
不会被继续使用: 没有被任何引用指向
public void test() {
T t = new T();
t.func();
}
test();
调用方法test的时候, 局部变量t被创建, 当test方法执行完了之后, t自然销毁, 此时new T()
就没有被引用指向了, 这个对象也就是垃圾了.
引用计数
给这个对象里面安排一个计数器, 每次有引用指向它, 就把计数器+1, 每次引用被销毁, 计数器-1, 当计数器为0, 意味着该对象就是垃圾了
class Test {
//
}
Test t = new Test();//new Test()里的计数器为1
Test t2 = t;//new Test()里的计数器为2
Test t3 = t2;//new Test()里的计数器为3
t = null;//new Test()里的计数器为2
t2 = null;//new Test()里的计数器为1
t3 = null;//new Test()里的计数器为0, 此时该对象为垃圾了
该方法并非是JVM中使用的方案, Python和PHP的虚拟机GC采用的是此方案
缺陷:
可达性分析(JVM使用的方案)
JVM首先会从现有代码中的能直接访问到的对象出发, 尝试便利所有能访问的对象, 只要对象能访问到, 就会标记成"可达", 完成整个遍历之后, 不可达的对象, 就相当于是垃圾了.
这样的操作没有额外的空间开销, 但是消耗了更多的时间.
那些是能直接访问到的对象呢?
这些对象又被称为gc roots.
- 栈上的局部变量.
- 常量池里的引用
- 方法区中类静态属性引用的对象
- 本地方法栈中 JNI(Native方法)引用的对象
代码执行过程中, 一个对象是否是垃圾, 往往是动态变化的(之前不是垃圾, 现在是垃圾了), 所以可达性分析的扫描是持续的周期性的
标记清除: 被标记为垃圾的对象直接清除.
弊端: 申请内存的时候, 都是申请的连续的空间, 直接释放会导致内存的碎片化, 会破环原有的连续性, 可能会导致有内存, 但是申请不了.
复制算法: 通过冗余的内存空间, 把有效的对象复制到另一部分空间, 避免内存碎片.
把一个内存分成两份, 一份使用, 一份等待有效对象被复制过来.
弊端: 如果复制的对象多, 开销会很大, 而且内存利用率也不高, 相当于浪费了一般的内存.
标记整理: 类似于顺序表的删除元素操作.
弊端: 这样的方式虽然解决了复制算法内存利用率低的问题, 但是搬运对象的成本也比较高.
分代回收: 采用分治的思想, 进行代的划分, 把不同生命周期的对象放在不同代上, 不同代上采用最适合它的垃圾回收方式进行回收.
Java的对象大体分为两类: 1. 生命周期很长的; 2.生命周期很短的
不同的对象的生命周期是不一样的. 因此, 不同生命周期的对象可以采取不同的收集方式, 以便提高回收效率.
如何分代?
JVM根据对象存活的周期(GC周期性扫描)不同, 把对内存划分了2块, 为新生代和老年代.
新生代又分为伊甸区(Eden)和幸存区(Survivor).
伊甸区占大部分内存, 这里存储的对象生命周期都很短. 经过一次GC后, 存活下来的对象就会通过复制算法复制到幸存区.
幸存区是两块大小相等的内存区域, 每次只用一块, 如果这里的对象经过GC后存货, 会继续被复制到另一块幸存区, 如此往复.如果一个对象在幸存区里经过了很多轮GC还存活, 证明它的生命周期很长, 那么它就会被复制到老年代.
老年代的GC频率比新生代要低. 这里清理垃圾的策略是标记整理
如果一个对象的体积很大, 那么他会直接进入老年代, 因为这样的对象不适合进行复制