1.JVM内存区域的划分
一个Java写的程序跑起来,就得到了一个Java进程 = JVM + 上面运行的字节码指令;
进程:操作系统资源分配的基本单位;
内存区域的划分:
1.程序计数器
在内存空间里(比较小的空间),保存了下一个要执行的指令的内存地址(元数据区的地址);
这里的"下一条要执行的指令"是Java的字节码(不是CPU的二进制机器语言);
2.堆
JVM上最大的空间, new出来的对象都在堆上;
3.栈
函数中的局部变量,函数的形参 ,函数之间的调用关系;
分为Java虚拟机栈(JVM之上,运行的Java代码的函数调用关系) 和本地空间栈(JVM里头C++代码的函数调用关系);
4.元数据区(方法区)
Java程序中的指令.指令都是包含在类的方法中的;
保存了代码中涉及到的类的相关信息 ; 类的static属性也被包含在其中;
在一个Java进程中,元数据区和堆 是只有一份的.
程序计数器和栈 则可能有多份;
当一个Java进程中有多个线程的时候,每个线程都有自己的程序计数器和栈,线程就代表一个"执行流";
代码中的变量都处在上述的哪个区域呢?
一个变量处在哪个内存区域,和变量是不是"内置类型"无关,而是和变量的形态有关;
(1)局部变量 -> 栈;
(2)成员变量 -> 堆;
(3)静态成员变量 -> 元数据区(方法区);
2.JVM类加载的过程
Java程序 -> .java文件;
javac编译 -> .class文件;
运行Java进程的时候,JVM就要读取.class里面的内容,并且执行里面的命令;
读取.class内容的过程就是类加载的过程.
把类涉及到的字节码从硬盘读取到内存中(元数据区).
加载一个.class文件,就会用.class里的指令创建一个类对象.
类对象中就包含了.class文件中的各种信息,基于类对象就能创建该类的实例;
比如:类的名字;
类有哪些属性,属性名是什么,每个属性类型是什么:public/private;
类有哪些方法,每个方法名是什么,参数是什么,方法的类型:public/private;
继承的父类有哪些,实现的接口有哪些...
因为有了类对象,才能进行反射,反射的api都是从类对象中获取信息的;
**类加载的输入:.**class文件(类的全限定名);
**类加载得到的结果:**内存中对应的类对象;
类加载的具体步骤
1.加载
把.class文件找到; 在代码中先见到类的名字,然后进一步的找到对应的.class文件.
打开并读取文件内容;
2.验证
验证读到的.class文件中的数据是否正确,是否合法;
Java标准文档中,明确定义了.class文件的格式是怎么样的;
**magic:**计算机圈子约定俗成的做法.
二进制文件,会在开头的若干个字节,设置一个固定的常数进去;
通过这个常数,标识当前这个文件是啥样的文件.
minor_version/major_version:需要确保编译时使用的JDK和运行时使用的JDK版本一致;
JDK是可以向前兼容的(使用Java8的JDK编译出的.class文件放到Java17一般是可以运行的)
3.准备
分配内存空间;
最终需要得到类对象 -> 需要内存;
根据刚才读取到的内容,确定出类对象所需要的内存空间,申请这样的内存空间,
并且把内存空间中所有的内容,都初始化为0;
(Java创建一个内存空间,都会把这个内存空间全设为0,后续再进一步初始化;
C/C++则不会进行置零操作,此时内存上对应的数据是上次使用残留的)
4.解析
主要是针对类中的字符串常量进行处理;
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程.
有很多其他的指令使用"hello"字符串常量,在文件中,这些指令就会使用"偏移量"表示"hello"字符串;
5.初始化
针对类对象做最终的初始化操作;
执行静态成员的赋值语句;
执行类中的静态代码块;
针对父类也要进行加载(如果当前加载的类有父类,并且父类还没有被加载,这个环节也会触发对父类的加载);
双亲委派模型
更准确应该叫单亲委派模型/父亲委派模型;
类加载五个部分,第一个步骤中里面的一个环节;
给定类的全限定名,找到对应的class文件的位置;
**类加载器:**JVM中的功能模块,JVM中,已经内置了一些类加载器完成上述的"类加载"过程;
JVM默认有三个类加载器:
BootstrapClassLoader;(爷爷)
负责加载标准库的类;(标准库类,有一个专门的存放位置)
ExtensionClassLoader;(爸爸)
负责加载一些扩展类;(JVM厂商,希望对Java的功能做出一些扩展)
ApplicationClassLoader;(儿子)
负责加载第三方库中的类/自己写的代码的类;
注意:
这里的父子关系不是Java中父类子类这样的继承关系;
每个类加载器有个parent这样的引用指向父亲;
双亲委派模型的工作流程:
输入:类的全限定名(字符串),类似于java.lang.String
得到:找到对应的.class文件.
请求先会一直向上传递,若父亲没有找到,请求才会交给儿子;
若在某一加载器找到了,请求就结束了,就会执行类加载2345步骤;
如果都没有找到,就会抛出异常ClassNotFoundException;
这样模型的目的就是:防止用户自己写的类,把标准库的类覆盖掉.
保证标准库的类,被加载的优先级是最高的,标准库其次,第三方库优先级最低;
3.JVM的垃圾回收机制(GC)
C/C++中,malloc/new一个对象,都需要手动释放内存free/delete,如果不释放就会造成内存泄露;
垃圾回收机制就是为了让程序员可以放心大胆的new对象,防止内存泄漏问题;
GC也是有代价的,需要消耗一定的性能,进行GC的时候可能会触发**STW问题(Stop The World)**导致程序卡顿,故C/C++没有引入GC;
GC需要负责回收的区域有哪些:
1.程序计数器/栈:
都是跟随线程的,不需要GC.
2.堆:
GC回收的主战场,
3.元数据区:
类对象->类加载,一个程序中要加载的类都是有上线的.不会出现无限增长,内存泄漏的情况;
GC是如何进行回收的:
以对象为维度进行回收的.
1.先找出谁是垃圾.
需要针对每个对象分别判定.
方案一:引用计数(Java没有采纳)
在Java中使用对象一般都是通过"引用"来实现的;
如果一个对象如果没有引用指向了,就可以认为这个对象是垃圾了;
给每个对象分配一个计数器,衡量有多少个引用指向,
每次增加一个引用,计数器+1; 每次减少一个引用,计数器-1;
当计数器减为0,此时这个对象就是垃圾了;
在JVM中并没有采纳,Python/PHP使用这种方案;
存在以下两个问题:
(1)消耗额外的空间;
(2)引用计数可能会导致"循环引用"使得上述的判定出错;(需要引入"环路检测"机制来解决,代价更大了)
方案二:可达性分析(Java采用的方案)
用时间换空间;
在JVM中,专门搞了一波线程,周期性的扫描代码中的所有对象,判断某个对象是否"可达"(可以被访问到)
对应的,"不可达"的对象就是垃圾了;
可达性分析的起点叫做GC root;
一个程序中不是只有一个GC root, 而是有很多个;
哪些变量可以作为GC root 呢:
(1)栈上的局部变量(引用类型);
(2)方法区中,静态的成员(引用类型);
(3)常量池中引用指向的对象;
把所有的GC root都遍历一遍 ,针对每一个尽量往下延伸;
2.释放垃圾的内存空间.
方案一:标记-清除方法
针对内存中的对应对象进行释放:
这样的做法,会引入**"内存碎片问题"**:
很可能要释放的多个内存,不是连续的,
虽然把上述内存释放掉,但是整体上这些释放掉的空间并没有连在一起;
后续申请内存空间的时候就可能申请不了,因为申请的内存是连续的;
方案二:复制算法
同一时刻只使用内存的其中一半:
把不是垃圾的对象都拷贝到内存的另一侧,然后把存放垃圾的这一半内存全部释放掉;
缺点:
1.内存空间利用率低;
2.如果存活下来的对象比较多,复制成本也比较大;
方案三:标记-整理方法
非常类似于顺序表删除中间元素的过程;
**缺点:**搬运的开销也比较大;
方案四:分代算法(JVM采取的方法)
把上述几个方案综合一下,取长补短;
JVM根据对象的年龄(根据周期性的可达性分析来计算年龄),把对象进行区分:
**新生代:**一般创建的对象都会进入新生代;
**老年代:**大对象和经历了 N 次(⼀般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代
移动到老年代.
刚创建的对象就处在伊甸区(比较大);
根据经验规律,绝大部分对象是活不过第一轮GC的,
留存下来的对象,就会被拷贝到幸存区(比较小,内存浪费少);
幸存区是两个相等的空间,按照复制算法进行处理;
反复进行多次...
当对象年龄不断增长就会被放到老年代的区域;
根据经验规律,老年代的对象,生命周期比较长;
对于老年代,进行GC的频率就会降低,另外老年代是通过标记整理方法来回收(次数比较少,时间开销浪费也少);
垃圾回收器
"分代回收"是JVM的GC中的基本思想方法.
具体落实到JVM实现层面上,JVM还提供了多种的**"垃圾回收器";**
对上述的分代回收做进一步的拓展和实现;
关注CMS/G1即可
CMS
把整个GC过程拆成多个阶段,能和业务线程并发执行的就尽量并发,
尽可能减少STW的时间;
G1
把整个内存分成很多块,不同的颜色/字母表示这是新生代(伊甸区/幸存区)/老年代;
进行GC的时候,不要求一个GC就把所有的内存都回收一边,而是一轮GC只回收其中的一部分;
限制一轮GC花的时间/工作量,使STW的时间在可控范围内;