作者:~小明学编程
文章专栏:JavaEE
格言:热爱编程的,终将被编程所厚爱。
目录
内存区域的划分
程序计数器(线程私有)
Java虚拟机栈 (线程私有)
本地方法栈
堆(线程共享)
方法区(线程共享)
类加载的过程
Loading
Linking
验证
准备
解析
Initializing
双亲委派模型
垃圾回收机制
如何判断垃圾
基于引用计数
基于可达性分析
回收垃圾
标记-清除
复制算法
标记整理
分代回收
垃圾回收的实现
内存区域的划分
我们JVM的内存区域主要分为4个区域:
- 程序计数区
- 栈区
- 堆区
- 方法去
程序计数器(线程私有)
程序计器所占的区域是内存中最小的一块其主要的作用就是保存我们我们的下一条指令所在的位置,因为操作系统是以线程为单位调度执行的,每个线程都得记录自己的执行位置,所以每个线程都会有一个程序计数器,指令就是字节码(就编译产生的字节码文件【后缀 .class】),程序要想运行,JVM 就得把 字节码文件 加载起来,放到内存中。然后程序就会把一条条指令,从内存中取出来,放到 CPU 上执行。
Java虚拟机栈 (线程私有)
我们的栈区放的是我们的局部变量和方法调用的信息。
我们在调用一个方法的时候将会创建栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息相当于入栈,当我们执行完该方法之后就会执行出栈操作。
另外我们的栈区域也比较小一般情况下只有几兆或者几十兆,当我们在使用递归的时候如果递归的程度比较深的话或者死循环这样的情况就会导致我们的栈溢出。
本地方法栈
本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的。
堆(线程共享)
程序中创建的所有对象都在保存在堆中,每一个进程都会分配一个堆区,我们一个进程中的多个线程也就共享一个堆区。
值得注意的是当我们在一个方法中new了一个对象那么该对象是放在堆中的但是该对象的引用是局部变量所以就放在栈区之中。
方法区(线程共享)
方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。
我们的.java代码会被解析成.class的二进制字节码,然后.class就会被jvm构成类对象,这个类对象就被放在了方法区,对象就描述了 这个类 “长什么样”,描述类名字是啥,里面有哪些成员,有哪些方法,每个成员的名字和类型等等,同时方法区里面还放着 static 修饰的成员(类属性)。
类加载的过程
类加载主要就是把 .class 文件加载到内存中,构建成类对象。主要有三个部分:loading、linking、initializing。
Loading
Loading阶段我们主要完成三件事:
- 先找到对应的 .class 文件,然后打开并通过二进制的字节流读取 .class 文件,同时初步生成一个 类对象。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构:我们看到第一行u4 magic就代表着我们首先会读取前4个字节,其中magic代表着我们文件的格式,然后一一的读取出来然后就按照这个格式将读取到的数据填写到我们初步生成的类对象中。
- 内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
Linking
在linking阶段主要分成三部,分别是验证,准备和解析。
验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。
准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
Initializing
初始化阶段,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程。
双亲委派模型
双亲委派模型是 类加载 中的一个环节,描述的是 JVM 中的 类加载器,如何根据类的全限定名(如java.lang.String)找到 .class 文件的过程。
我们的类加载器主要分为下面三个部分:
- BootStrapClassLoader:负责加载标准库中的类(String,ArrayList,Random,Scanner)。
- ExtensionClassLoader:负责加载 JDK 扩展的类(现在很少用)。
- ApplicationClassLoader:负责加载当前项目目录中的类。
查找标准库中的类:
1.当我们查找标准库中的类的时候我们首先会区看看我们的父类也就是ExtensionClassLoader类加载器有没有加载当前的要查找的类
2.如果没有那就去父类查找到了父类我们要看看我们当前类的父类有没有加载所在查找的类。
3.如果没有再去父类BootStrapClassLoader去查到,然后同样再去父类查找此时没有父类就查到当前自己的类。
查找自己的类:
1.当我们查找标准库中的类的时候我们首先会区看看我们的父类也就是ExtensionClassLoader类加载器有没有加载当前的要查找的类
2.如果没有那就去父类查找到了父类我们要看看我们当前类的父类有没有加载所在查找的类。
3.如果没有再去父类BootStrapClassLoader去查到,然后同样再去父类查找此时没有父类就查到当前自己的类。
4.如果当前类查不到就查到子类也就是ExtensionClassLoader所在的jvm扩展类。
5.如果ExtensionClassLoader所在类查不到就去ApplicationClassLoader中去查找。
这种设计使得一旦我们设计的类与标准库中的类重复了我们还是能找到标准库中的类。
垃圾回收机制
我们在写代码的时候经常的会要申请空间,就像我们上面介绍的那几种内存布局我们针对不同的情况将会申请各自的空间,但是我们一味的申请空间如果不释放的话我们的空间总有被占用完毕的时候,这就是我们的内存泄漏。
对于我们上述的四个内存区域其中Java虚拟机栈和程序计数器和本地方法栈是不需要考虑内存回收问题的,并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此我们主要关注的为Java堆与方法区这两个区域。
如何判断垃圾
基于引用计数
所谓的引用计数就是给我们的对象额外的增加一块区域用于计算这个对象被引用的次数每有一个引用引用该对象这个计数器就会加1,当有一个引用指向null,这个计数器就减1,如果计数器等于0了,那就释放该对象。
缺点:
1.空间利用率低:当我们对象本身就很小的时候此时加上一个计数器就会显得很浪费空间。
2.循环问题:
class Test {
Test t = null;
}
Test t1 = new Test();
Test t2 = new Test();
t1.t = t2;
t2.t = t1;
这个时候我们将会发现问题如果我们对t1和t2进行null,那么将会出现下面的情况。
此时两个对象的计数器都是1了,但是这个1不可能变成0了,因为此时指向这两个对象的引用都在这两个对象里面。
基于可达性分析
通过额外的线程,定期的针对整个内存空间的对象进行扫描。有一些起始位置(GCRoots),会类似于 深度优先遍历一样,把可以访问到的对象进行标记,能标记的就是可达对象,如果没标记就是不可达,就是垃圾。
例如下图:我们通过root节点可以访问到所有的节点,这个时候就不存在垃圾。
但是对于下面这种情况root的右节点被切断了,此时c和f都访问不到了,那么他们就都是垃圾。
GCRoots:
a)栈上的局部变量
b)常量池中的引用指向的对象
c)方法区中的家庭成员指向的对象
回收垃圾
回收垃圾主要有三种方法。
标记-清除
标记就是可达性标记,清除就是直接释放内存,释放之后的内存可能是不连续的,就是内存碎片,所以当内存碎片多了就会影响我们开辟大一点的内存,明明内存是够的但是因为内存碎片的存在就导致了开辟不了那么大的内存。
复制算法
为了解决内存碎片,引入的复制算法。就是把申请的内存一分为二,然后不是垃圾的,拷贝到内存的另一边,然后把原来的一半内存空间整体都释放。
接着我们将需要的内存空间给拷贝到另外一边。
此时就解决了内存碎片的问题,但是又引入了一个问题,那就是
- 内存空间利用率低
- 当保留的对象多,要释放的对象少,此时复制开销就很大。
标记整理
针对复制算法,再做出改进。类似于顺序表删除中间元素,有一个搬运操作。
将未标记的元素都给拷贝到前面
但是这种方法依然不能解决复制搬运的开销问题,复制和搬运的开销依然很大。
分代回收
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。
1.对象刚创建出来的时候,就放在伊甸区。
2.如果伊甸区的对象熬过一轮GC扫描,就会被拷贝到 幸存区(应用了复制算法)。
3.在后续的几轮 GC 中,幸存区的对象在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰掉一波幸存者。
4.在持续若干次之后,对象就进入老年代,一个对象越老,继续存活的可能性就越大,所以老年代的扫描频率大大低于新生代,老年代中使用标记整理的方式进行回收。
分代回收中,还有一个特殊情况: 有一类对象可以直接进入老年代(大对象,占有内存多的对象),大对象拷贝开销比较大,不适合使用复制算法,所以直接进入老年代。
1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
垃圾回收的实现
常用的垃圾回收器如下:
- Serial 收集器(新生代)/ Serial Old 收集器(老年代):这俩都是串行收集,在进行垃圾扫描和释放的时候,业务线程要停止工作。就是这边停止工作,他先扫描完,再去进行释放,然后业务线再继续工作。这种方式扫描的慢,释放的也慢,也会产生严重的 STW 问题。
- ParNew 收集器(新生代)/Parallel Scavenge 收集器(新生代,是并行清除)/ Parallel Old 收集器(老年代):这些回收器都是并发收集的,引入了多线程,Parallel Scavenge 比 ParNew 多出了一些参数,可以用来控制 STW 的时间。
下面是新的垃圾回收器,核心思想就是:化整为零,:
GMS 收集器:设计的比较巧妙,设计初衷是为了尽可能的让 STW 时间短。
a)初始标记,速度很快,会引起短暂的 STW(只是找到 GCRoots)
b)并发标记,虽然速度很慢,但是可以和业务线程并发执行,不会产生 STW
c)重新标记,在 b 的业务代码可能会影响并发标记的结果,针对 b 的结果进行微调,虽然会引起 STW,但是只是微调,速度快。
d)回收内存,也是和业务线程并发的,所以就没有 STW。
G1 收集器:是唯一一款全区域的垃圾回收器。
a)把整个内存,分成了很多小的区域。
b)给这些区域进行了不同的标记。
c)有的区域放新生代对象,有的放老年代对象。
d)然后再扫描的时候,一次扫描若干个区域(不追求一轮 GC 就扫描完,分舵从来扫)对于业务代码影响是更小的。
e)在当下可以优化带让 STW 停顿时间小于 1ms。