JVM相关
1.JVM内存区域
一个运行起来的java进程就是一个Java虚拟机,就需要从操作系统中申请一大块内存。
内存中会根据作用的不同被划分成不同的区域:
(1)栈:存储的内容是代码在执行过程中,方法之间的调用关系(栈中每一个元素就是一个栈帧,代表一个方法的调用,包含方法的形参,返回值,局部变量)。
(2)堆:这里存储的内容是代码中new的对象
(3)方法区(1.8开始:元数据区):存储类对象(.class文件加载到内存就成为了类对象)
(4)程序计数器:存放每个线程,下一条指令执行的地址。空间较小主要就是用来存放一个“地址”,表示下一条要执行的指令,在内存的哪个地方(在方法区中去找下一条指令,指令是以二进制的形式存储在类对象中)。
程序计数器中的值会随着指令的执行,从而自动进行更新,去指向下一条指令。
如果是顺序执行的代码,那么下一条执行的指令地址就是把指令地址进行递增。
如果是条件循环执行的代码,那么下一条执行的指令地址就可能会跳到比较远的地址。
ps:栈和程序计数器每一个java线程一份,而堆和方法区是每一个java进程一份。(仅限java,放在c++中就不一定是这样了)
2.JVM类加载
(1)基本流程:
java代码经过编译后生成.class文件,java程序要想运行起来就需要将jvm读取这个.class文件,并且把里面的内容构造成类对象,保存到内存的方法区中。
官方文档中把类加载的过程分成了5个步骤:
1)加载:找到.class文件,并且打开文件,读取文件中的内容(通过全限定名称查找)
2)验证:检查当前二进制文件是否符合格式的要求,具体格式
3)准备:给类对象分配内存空间,并且为不被final修饰的static变量赋初始默认值(被final修饰的变量在此时会被赋值成程序员给定的值)
4)解析:针对类对象中字符串常量的处理,进行了一些初始化的操作。(符号引用到直接引用)
字符串常量在经过编译之后,eg:final String s = "hello",s本来是存储内存地址,但在.class文件中会重新针对该字符串进行创建出一个引用,此时这个引用就不是存储内存地址了,而是存储文件偏移量(字符串长度),通过这个变量就可以知道目前字符串处于哪个位置。到解析的阶段(类加载的时候),会重新将这个引用替换成内存地址。
5)初始化:对类对象中的各种属性进行初始化,还需执行静态代码块和加载一下父类(子类中调用构造方法,会先帮助父类进行构造)
(2)双亲委派模型:
是在加载过程中的第一个步骤。
首先需要了解一下类加载器:
是JVM中的一个模块,JVM内置了三个类加载器:
1)BootStrap ClassLoader
2)Extension ClassLoader
3)Application ClassLoader
这三个类加载器之间的关系就好似爷父子之间的关系,但不是靠继承构成的,而是由类加载中的一个属性(parent)来指向父类加载器。
加载的具体流程是:
①根据给定的全限定类名,找到对应的.class文件。
②从Application ClassLoader作为入口,开始执行查找的逻辑。
③Application ClassLoader不会立即扫描自己所负责的目录(负责搜索项目当前目录和第三方库的对应目录),而是会交给自己的父类加载器Extension ClassLoader。
④Extension ClassLoader不会立即扫描自己所负责的目录(负责搜索JDK中一些扩展库对应的目录,是JDK标准库之外的一些库,属于对JDK标准库的扩展),而是会交给自己的父类加载器BootStrap ClassLoader。
⑤BootStrap ClassLoader,也不会立即扫描自己所负责的目录(负责搜索JDK标准库对应目录),而是会交给自己的父类加载器,但是却发现没有父类加载器,因此只能区扫描自己负责的目录,一旦在标准库中查找到对应类的.class文件,此时加载的过程就完了,扫描结束过后如果没有扫描到,就会交给它的子类加载器(Extension ClassLoader)扫描。
⑥如果在Extension ClassLoader扫描到了就结束加载过程,没有扫描到就交给它的子类加载器(Application ClassLoader)扫描。
⑦如果在Application ClassLoader扫描到了就结束加载过程,没有扫描到此时应该交给它的子类加载器扫描,但是发现没有了,所以此时会抛出一个异常ClassNotFoundException
总结:所谓的双亲委派模型其实就是一个按照优先级查找.class文件的一个过程,之所以有这么一套流程,是为了确保JDK标准库扫描的优先级最高,其次是扩展库,最后才是项目当前目录和引入的第三方库对应目录。就好比:你在自己的代码中使用String(导入的包是java.lang.String),在类加载的时候加载的JDK标准库中的类,而不是自己写的这个类。
3.JVM垃圾回收机制(GC垃圾回收)
在java中new对象,是动态申请内存(运行时分配),如果一个资源申请了内存空间,长时间不使用但是不释放,就可能会造成内存泄漏。
在java中给出了一个方案,也就是垃圾回收机制,让JVM,自动判定某个内存是否不再使用了,
如果后面这个内存确实不用了,JVM就会自动回收把这个内存给回收掉了,此时就不需要手动回收了。
GC是垃圾回收,GC回收的目标是内存中的对象。对于java来说就是释放堆上的new出的对象,栈上的对象是随着栈帧的生命周期(方法执行结束,栈帧自然销毁,内存自然释放),静态变量,生命周期是整个程序,这个始终存在就意味着静态变量无需释放的,真正释放的就是堆上的对象。
GC回收垃圾的过程主要有两个步骤:
(1)找到垃圾
有两种主流的方案:
①引用计数
new出来的对象,会单独划分空间,来保存有一个计数器,计的是当前有多少引用指向该对象。
如果一个对象没有引用指向了(即引用计数为0),就可以将该对象视为垃圾了。
但是使用该种方式可能会存在两种问题:
·比较浪费内存:
计数器怎么说也得有两个字节,如果对象本身比较小,那么此时这个计数器所占空间比例就比较大了。
·存在循环引用问题:
②可达性分析:
有一组线程,周期性扫描代码中的所有对象,把所有可以访问到的对象,都给标记成“可达”,反之,如果经过扫描后,不可达的对象,就成垃圾了,需要被回收。
eg:
当然这里的遍历不一定是二叉搜索树,这里可达的实现大概率是靠N叉搜索树实现,这一步就是针对当前对象,看对象中有多少不同引用类型的成员,然后再对每一个类型的成员进行进一步的遍历。
通过上述过程,不难看出可达性分析是比较耗费资源的(开销较大)。
(2)回收垃圾
有三种基本的回收思路:
①标记删除:
扫描到一个不可达的对象,就直接进行释放,这个方案非常不好,那就是会产生很多的内存碎片。释放代码,是为了让其他代码能够申请一块连续的内存空间,随着时间的推移,内存碎片的情况就会愈演愈烈,就会导致后续申请连续内存空间变得困难。
②复制算法:
通过复制的方式,把有效的对象,归类到一起,在同一释放剩下的对象。
也就是把内存一分为二,一次只用其中一半。但这种方式有两个明显的问题:
a.内存利用率不高
b.如果有效的对象很多,那么复制的开销将会很大
③标记整理:
既能解决内存碎片的问题,又能够解决上述内存利用率不高的问题。
eg:
类似于顺序表删除元素的搬运操作,使用该种方式搬运开销仍然很大。
而JVM中GC垃圾回收主要思想是分代回收(具体实现可能有一些调整和优化),是对上述思路的集合,让不同的方案,扬长避短。
分代回收有一个很重要机制就是:对象能活过的GC扫描轮次越多,就是越老(代表当前对象是暂时不会被释放的)
eg:下图是整个分代回收的全部过程: