基本介绍
JVM:Java虚拟机,用于解释执行Java字节码
jdk:Java开发工具包
jre:Java运行时环境
C++语言将写入的程序直接编译成二进制的机器语言,而java不想重新编译,希望能直接执行。Java先通过javac把.java文件转成.class文件,.class文件是字节码文件,包含Java字节码,然后Java把这个字节码文件在某个具体的平台上执行。此时再通过jvm把上述的字节码转成对应的CPU能识别的机器指令
当前主流的JVM:HotSpot VM
JVM中的内存区域划分
JVM其实也是一个进程,就是在任务管理器中能看到的Java进程
进程运行的过程中,要从操作系统申请一些资源(典型资源:内存),申请到的内存空间可以支撑后续Java程序的执行。
比如:在Java中定义变量,就会申请内存,这里的申请就是由jvm完成的
而jvm申请的这一块内存还会根据实际的用途划分出不同的空间(区域划分)
1.堆:代码中new出来的对象,对象中持有的非静态成员变量都是在堆里(只有一份)
2.栈:本地方法栈包含了方法调用关系和局部变量,虚拟机栈记录了Java代码的调用关系,Java代码的局部变量(一般提到的栈指的是虚拟机栈)(可以有n份,n与线程相关)
这里的堆和栈和数据结构里的不一样
这里的堆和栈都是内存区域,而数据结构的堆是一颗二叉树,栈是后进先出的数据结构
3.程序计数器:空间比较小,存储下一条要执行的Java指令的地址(有n份)
x86的CPU也有一个类似的寄存器:eip
4.元数据区:保存类的信息和方法的信息 (只有1份)
类的信息:类的名称,继承哪个类,实现的接口;有什么属性,属性名字,属性类型,权限;有什么方法,方法名字,方法参数,权限等等
“元数据(meta data)”:往往指一些描述性质或者辅助性质的属性
笔试题
class Test{
private int n;
private static int m;
}
main(){
Test t = new Test();
}
上述代码里的t, n, m各自处于jvm哪个区域
t是一个局部变量(引用类型),这个变量在栈上
n是Test的成员变量,处于堆上
m是static修饰的变量,称为类属性,存在类对象中,也属于元数据区
JVM的类加载机制
类加载:Java进程运行的时候需要把.class文件从硬盘读取到内存并进行一系列的校验解析的过程
过程(5个步骤)
1.加载:把硬盘上的.class文件找到,打开文件,读取文件内容(读到的是二进制数据)
如何查找对应的文件?双亲委派模型(一种查找策略)
2.验证:确保读到的文件是合法的.class文件(验证依据:Java的虚拟机规范)
3.准备:给类对象申请内存空间,申请到的内存空间里面的默认值是0
4.解析:针对类中字符串常量进行处理,将常量池中的符号替换成直接引用的过程
class Test{
private String s = "hello";
}
在代码中我们知道s相当于包含了"hello"字符串常量的地址,但是在文件中是不存在"地址"这样的概念的。地址是内存的地址,硬盘里没有地址。
虽然没有地址,但是我们可以存储一个类似于地址的偏移量
把hello字符串的开头到文件开头就是一个偏移量
此处文件中填充给s的hello的偏移量就认为是符号引用,接下来把.class文件加载到内存中,先把"hello"这个字符串加载到内存中,此时“hello”就有地址了,s里面的值就可以替换成当前“hello”真实的地址了,可以直接引用这个地址了
5.初始化:针对类对象完成后续的初始化(要执行代码块的逻辑,甚至会触发父类的加载)
双亲委派模型(重点)
JVM中的类加载操作有一个专门的模块:类加载器
作用:给定全限定类名,也就是带有包名的类名,比如java.lang.String就是一个全限定类名,能找到.class文件
默认有三个
BootstrapClassLoader:负责查找标准库的目录
ExtensionClassLoader:负责查找扩展库的目录
ApplicationClassLoader:负责查找当前项目的代码目录以及第三方库的目录
上述三个类加载器存在父子关系,这个父子关系类似于二叉树,有一个指针parent,指向自己的父类加载器
双亲委派模型的工作过程:
1.从ApplicationClassLoader作为入口,先开始工作
2.ApplicationClassLoader不会立即搜索自己负责的目录,会把搜索的任务交给自己的父亲
3.代码进入到ExtensionClassLoader的范畴,ExtensionClassLoader也不会立即搜索自己负责的目录,会把搜索任务交给父亲
4.BoostrapClassLoader没有父亲,没办法推脱搜索任务了,才会真正搜索自己负责的标准库目录。通过全限定类名,尝试在标准库目录中找到符合要求的.class文件
找到了就直接进入打开文件和读文件的流程;如果没到找,返回给孩子类加载器,继续尝试加载
5.ExtensionClassLoader收到交回的任务后,在自己负责的扩展库目录搜索,找到了进入后续流程,没找到再丢给自己孩子
6.ApplicationClassLoader收到交回的任务后,自己进行搜索负责的目录。再找不到就抛出ClassNotFoundException 异常
上述执行顺序的好处:
1)确保几个类加载器之间的优先级
2)用户自定义的类不会被jvm加载,可以防止自定义类不小心和标准库中的类名字重复
JVM的垃圾回收机制(GC机制)
基本情况
这个机制不需要程序员手动释放内存。程序回自动判断某个内存是否会继续使用,如果内存后续不用了,就会自动释放掉。
垃圾回收中一个重要问题:STW(stop the world)问题——触发垃圾回收的时候,可能会使当前程序的其他业务逻辑被暂停
垃圾回收内存的话,那内存里面几个区域里面情况如何?
1)程序计数器 -- 不需要GC
2)栈 -- 不需要GC,因为局部变量在代码块执行结束之后自动销毁
3)元数据区/方法区 -- 不需要GC,因为一般都是涉及类加载而不是类卸载
4)堆 -- GC的主要工作区域
所以,垃圾回收回收内存,不如说是回收对象(对象也是回收的单位)
工作机制
1.识别出垃圾
判定哪些对象不再使用,哪些对象还在使用
在Java中的对象一定要通过引用的方式来使用,除非匿名对象。如果一个对象没有任何引用指向它,就可以认为无法被代码引用,就可以作为垃圾了
void func(){
Test t = new Test();
t.do();
}
这里通过new Test在堆上创建了对象,与此同时在栈上也存储下这个局部变量
当执行到 “}” 的时候,t这个局部变量在栈中自动销毁。上面的new Test()对象就没有引用指向它了。此时这个对象就成为了垃圾
如果代码复杂一点呢?
Test t1 = new Test();
Test t2 = t1;
t3 = t2;
t4 = t3;
此时会有很多引用指向new Test同一个对象,需要确保所有的引用都销毁了才能把Test对象视为垃圾。如果代码再复杂,每个引用的生命周期各不相同,那怎么办呢?
解决办法:
1)引用计数:给每个对象安排一个额外的空间,空间里要保存当前这个对象有几个引用
每次有一个引用指向这个对象,引用计数就+1,制空或者删除一个引用,引用计数就-1
此时有专门的扫描线程去获取当前每个对象的引用计数情况,如果发现对象的引用计数为0,说明这个对象可以被释放了
这个方法虽然没有在JVM中使用,但是广泛应用于其他语言的垃圾回收机制中,比如python和PHP
问题一:每个对象分配到的计数器消耗了额外的内存空间,对象数目一多空间资源容易不足
问题二:引用计数可能会产生“循环引用的问题”
此时两个对象,引用计数都不是0,不能被GC回收掉,但是这两个对象又无法使用 -- 类似于死锁
2)可达性分析(JVM用的是这个)
本质上用时间换空间。在写代码的时候会定义很多变量,就可以从这些变量作为起点,尝试进行遍历(沿着这些变量中持有的引用类型的成员,再进一步地往下进行访问),所有能被访问的对象就不是垃圾了,剩下的遍历一圈也访问不到的对象就是垃圾
虽然这个代码中只有一个root的引用,但是7个结点的对象都是可达的。JVM中存在扫描线程,会尽可能多的去遍历访问对象
如果root.right = null的话,a跟c之间就会断开,那么按照上述方法遍历的操作就无法访问到c和f了,此时c和f节点对象就不可达,不可达就变成垃圾了
2.把标记为垃圾的对象的内存空间进行释放
释放方式
1)标记 - 清除
把标记成垃圾的对象直接释放掉(一般不使用)
产生的问题:内存碎片 -- 小的但是离散的空闲内存空间
会导致后续的内存申请失败。因为我们的内存申请时一次性申请一个连续的空间,比如我们申请1M的内存空间,此时的1M字节都是连续的
如果有很多内存碎片就可能导致空闲空间总和超过1MB,但是没有比1MB大的连续空间,申请就会失败
2)复制算法
核心是不直接释放内存,而是把内存划分成为两半
把不是垃圾的对象复制到内存的另一半里,接下来就把左侧的空间(原来垃圾存在的空间)整体释放掉
比如我们要删掉对象2和4,我们会把不需要删除的1和3复制一份到右半边内存
然后把左半边全删掉
优点:规避内存碎片问题
缺点:1)总的可用内存变少;2)如果每次要复制的对象很多,复制的开销很大(所以这个算法适用情况:当前这轮GC中要删掉的对象很多,存活的对象很少)
3)标记 - 整理
类似顺序表删除中间的元素(搬运思想)
比如下面要删除1,3,6
接着把2,4,5,7,8往前搬运
优点:解决内存碎片问题,不需要过多浪费内存空间
缺点:复制开销很大
4)JVM采用的综合方案:分代回收(重点)
引入概念:对象的年龄(初始年龄为0)
JVM中有专门的线程负责周期性扫描或释放。一个对象如果被线程扫描了一次,发现是可达了,该对象的年龄+1
JVM会根据对象年龄的差异,把整个堆内存分成两个大的部分,分别为新生代(年龄小的对象)和老年代(年龄大的对象)
具体流程:复制算法+标记 - 整理
a)当代码中new处理一个新的对象,这个对象就被放在伊甸区
一个经验规律:大部分伊甸区的对象是朝生夕死的,活不过第一轮GC
b)第一轮GC扫描之后,少数幸存的对象就会通过复制算法拷贝到生存区,后续GC扫描线程继续扫描,生存区中大部分对象也会在扫描中被标记为垃圾,少数存活的会被拷贝到另一半的生存区中每经历一轮扫描,生存的对象年龄+1
c)如果这个对象在生存区中经过若干轮GC之后还存活着,JVM会认为这个对象的生命周期很长,就会将其从生存区拷贝到老年代
d)老年代的对象也要参与扫描,但是被扫描的频率大大降低
e)对象在老年代寄掉了,JVM就将其释放了
常使用的垃圾收集器:GMS, G1和ZGC