a)JVM中的内存布局是怎么样的,它们分别的作用是什么?
b)JVM中的类加载的过程是怎么样的,具体步骤?
c)JVM中的垃圾回收机制(GC)有什么优缺点,涉及到的回收机制和算法有什么?
下面我们就来一一探讨这三个问题。
JVM,说是叫“虚拟机”,但是对比VMware和Virtual Box 虚拟机来说,并不是同一回事。JVM只是对硬件设备进行了简单的抽象封装,能够达到跨平台的效果。而VMware和Virtual Box 是100% 使用软件来模拟出真实的硬件。
还有HotSpot VM,是Oracle官方和开源OpenJDK 都是用这个虚拟机的。
JVM划分的区域:
1.堆
2.栈
3.方法区
4.程序计数器
JVM实际上是一个Java 进程,进程就是用来管理硬件资源的,比如内存。JVM启动之后就会从操作系统这里申请到一大块内存。
具体的内存布局:
对于堆区和方法区,在整个JVM中只存在一份,而程序计数器和栈区是跟进程绑定在一起的,每个不同的线程都有独立的一份程序计数器和栈区。
不同的区域放不同的东西:
1.堆中放入的是 new 的对象。(不要忘了在JDK 1.8中,字符串常量池在堆中)
2.方法区放入的是 类对象。
.java->.class->JVM就会把.class文件进行加载,加载到内存中,最后变为类对象。
类的static 成员,作为类属性。同样也是在类对象当中的,就放到方法区里。
类对象里有什么?
a)包含这个类的各种属性的名字,类型,访问权限。
b)包含这个类的各种方法的名字,返回值,访问权限,参数类型,以及方法的实现的二进制代码。
c)包含这个类的static 成员。
方法区内部有个运行时常量池,存放字面量和符号引用:
字面量 : final常量、基本数据类型的值。
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
3.栈区放入的是 局部变量。
对于什么变量放在栈区,什么变量放在堆区,跟它是引用类型还是内置类型无关,只要取决于它是在内存中的哪个区域,是全局变量(成员变量),还是局部变量,还是静态变量?
Java虚拟机栈:给上层的Java代码来使用的。
本地方法栈:本地方法栈是给本地方法使用的。
4.程序计数器放入的是 内存地址。
这个内存地址的含义是,接下来要去执行的指令地址。
我们写的.java 文件 -> .class 文件 ->读到内存当中 -> 每个指令都有自己的地址 -> CPU要执行指令就需要从内存中去取地址,然后再在CPU上执行。
1.堆溢出,代码中出现堆溢出的话就会抛出“java.lang.OutOfMemoryError”,典型的情况就是不断地去new 对象而不去释放内存。
2.栈溢出,代码中出现栈溢出的话就会抛出“java.lang.StackOverflowError”,典型的场景就是不断去递归不带有终止的if条件。栈里面除了要放局部变量外,还要放方法的调用关系。
堆和栈的空间大小,都可以通过JVM(Java进程的命令行参数)来进行配置。
a)下面代码doGet方法中的test是在哪个内存区域?
class Test {
public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Test test = new Test();
}
}
答:Test test = new Test();
中的test是一个局部变量,因此是存放在栈区中的,而new 出的对象就是放在堆区中。
b)下面代码Test test = new Test();
是在哪个内存区域?
class Test {
public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
Test test = new Test();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
}
}
答:因为HelloServlet这个类是需要new出来的,对应地对象里面就有test引用及其对象。t是一个全局变量,因此是在堆中的,后面new 出来的对象,也是在堆中的。
c)下面代码中static Test test = new Test();
存放于内存的哪个区域?
class Test {
public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
static Test test = new Test();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
}
}
答:因为Test test = new Test();
是被static关键字修饰了,因此t变成了静态成员变量,此时t就是在类对象中,即方法区当中。而new 出的对象不变,仍然存放在堆中。
我们可以把引用类型当作一个“低配指针”,但从更严谨的角度去看,引用并不是一个指针。Java的引用相当于堆C语言的指针功能进行了裁剪,Java中的引用只能用来解引用(如:使用 . 就是默认地解引用)和比较(==或!=) 。
类加载其实是JVM 中的一个非常核心的流程,做的事情,就是把.class 文件,转成JVM 中的类对象。
要想完成 类加载,必须要明确的知道,.class文件中都有啥,按照.class文件中的规则进行解析。因此,编译器和类加载器(JVM)必须要商量好.class文件的格式。而.class 文件的格式,在JVM虚拟机规范文档 里面已经约定好了的,则编程语言的语法,也可以理解为一种“协议”。
发明/定义一个编程语言,就一定要让该编程语言跑起来,就需要把源码编译成可执行程序,再进行执行。过程:编译->.class->加载 。
在JVM虚拟机规范文档中有:
上面的是Java语法规范,是约束编译器和程序员之间的。下面的是Java虚拟机规范,是约束编译器和JVM 之间的。
我们可以选择HTML 的文档格式去查看,第二章的第一节就是有关.class文件格式的规范。而点进去又会提示你在第四章中才会有…
u4就是一个无符号四个字节的整数,u2就是两个字节的无符号整数。而带有info的都是结构体。可以看到,它把java代码中,定义的一个类的核心信息都体现进去了,只不过这个文件的格式是二进制的。
因此,根据上述的格式,我们可以自己开发一个编程语言,然后编译就根据.class文件的格式一样,就可以直接在JVM中去解析执行了。
这样就大大地降低了语言开发的成本,如Kotlin,Scala,Groovy等语言都是基于JVM体系的语言。因此,Kotlin就能够和Java无缝对接,非常方便地去使用Java现有的生态,对比Java,含有的语法糖更多一些。它是有更多现代一些编程语言的特点。
类的生命周期:都离不开.class文件的格式
加载:目的是把.class 文件给找到。如果代码中需要加载某个类,就需要去特定的目录下去查找该.class文件,找到之后,就需要打开这个文件,并且读取这个文件。此时这些数据就已经读到内存里了。
验证:目的是验证后缀为.class 的文件是否是编译器编译生成的,如果是人为地去改后缀变为.class 的文件,那么就不是一个合法的.class 文件。除了验证.class 文件的格式外,还需要验证文件里面的字节码指令是否正确。(方法里面具体要执行的指令)
准备:目的是为类对象中的一些成员变量分配内存空间(静态变量…),并且进行一个初步的初始化(初始空间大小为0).
解析:主要是针对字符串常量进行的处理。.class文件涉及到一些字符串常量,在解析的过程中,就把这些字符串常量替换成当前JVM中的字符串常量。
注:不是程序一启动,就把所有的类都加载完毕的,而是用到哪个类就加载哪个类,而字符串常量是最初启动JVM的时候就有的。
初始化:主要针对在“准备”环节中,对初步初始化的静态变量进行真正地初始化。同时也会执行static 的代码块。
前面两个过程是重要理解的。
针对上述JVM类加载过程,有个代码需要注意一下:
我们发现结果是:(由父及子,静态最先)
原因:当new B() 的时候,就会先尝试去加载 B 这个类,然后加载B 的时候,因为是B 继承于A ,于是又得先加载A 。等到两个类都加载完了,再进行实例化的操作。
双亲委派模型,是类加载中的加载环节里面的很小的一部分细节。更准确地说,应该叫“父亲委派模型”。
在进行类加载的过程中,其中一个非常重要的环节,就是根据这个类的名字(如:java.lang.String) 找到对应的.class 文件。
在JVM中,有三个类加载器(三个特殊的对象)来负责找文件的操作。这三个类加载器对象都有各自找的区域。
图示如下:
这三个类加载器之间存在父子关系(但并不是继承中的父子关系,而是类似于链表一样,每个类里面都有个 parent 字段,指向了父类加载器)。
双亲委派模型的流程:
当代码中使用到某个类的时候,就会触发类加载。首先是从AppClassLoader 开始的,但是AppClassLoader 并不会直接开始去扫描自己负责的目录,而是先找它的爸爸。找到了ExtClassLoader 之后,它也一样,不会立刻去扫描自己负责的目录,而是又去找它的爸爸。
找到BootStarp 之后,它也不会立刻去扫描自己负责的目录,而去找它的爸爸。但是它并没有爸爸,因此就只能自己先去扫描自己负责的目录。如果在自己的目录中,找到了复合的类,就没有其它类加载器的事情了。但是如果没有找到匹配的类,就告诉儿子(ExtClassLoader)。
ExtClassLoader再来找自己负责的目录,如果找到,就加载,找不到就告诉儿子(AppClassLoader)去查找。
AppClassLoader就在自己负责的目录去查找,如果找到就加载,找不到就抛出ClassNotFound异常。
这里有这么一套规则,其实就是在约定上述被扫描的目录的优先级。这个优先级在正常情况下没有什么作用,假设如果是我们自己创建了一个java.lang.String 的类(只有一个类),同时有标准库中的String 类。那么有优先级后,就会先去加载标准库中的String类,因为我们创建的类是一个复合类,因此就没有其它加载器的事情了。
1.避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
2.安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。
我们学了Servlet后,知道里面根本就是没有main方法的,而且很少会涉及到标准库中的类,一般涉及的类都有Servlet相关。因此Tomcat 的webapps 中就有很多的类,是Tomcat 内部自己实现的类加载器来完成的(目的是告诉程序去额外的目录去找.class)。则Tomcat就没有去遵守 双亲委派模型。
垃圾回收,回收的是内存。JVM 其实是一个进程(java),一个进程会持有很多的硬件资源,如(CPU,内存,硬盘,带宽),而系统的内存总量,是一定的。因此对内存的合理使用是非常重要的。
内存要经过:申请->使用->释放 过程。内存是有限的,并且要给很多的进程去使用。从代码编写的角度看,内存申请的时机是很明确的,但是内存的释放时机很模糊。对于C语言来说还好,内存的释放是靠程序员自己去手动释放的,如malloc、free等。但是一旦忘了释放内存,就会造成内存泄漏,直到内存耗尽为止。
对于内存泄漏问题,不同的语言有了不同的解决方法:在C++中引用了智能指针,在合适的时机去自动释放内存,(一般是通过引用计数的方式来衡量这个内存被引用了多少次,当引用计数为0时就真正释放内存)。在Rust中,采取的方案是基于语法上的强校验,Rust引入了很多对内存操作相关的语法规则,在编译器编译期间就会对进行严格的检查和校验,一旦发现有代码存在内存泄漏的风险,就编译报错。但是也有不好的地方,它的语法非常丑陋,同时也限制了很多功能的实现。以至于在实现一些特殊功能的时候,要使用个’unsafe’操作,引入这个操作,之前的校验也就部分的失效了。
而Java中采用垃圾回收的方式,对于该机制来说,哪一个代码申请都可以,哪里申请都可以,都是由JVM统一去进行垃圾回收(内存释放),具体来说,就是由JVM 内部的一组专门负责垃圾回收的线程来进行这样的工作。
优点:能够非常好地保证不出现内存泄漏的情况(不是100%保证),并且是自动去进行内存释放。
缺点:
1.需要消耗额外的系统资源。
2.内存释放可能存在延时(不是内存不用了就马上回收,可能过段时间才会回收)
3.可能会出现STW 问题(stop the world),比如说有一大段内存需要去释放,那么可能系统的资源都用来去释放该内存了,而其它的代码就不能够继续执行,没法去做别的事情了。但是现在大佬们能够将STW 问题限制在了1ms 之内。
JVM中有四个区域:堆区、方法区、栈区、程序计数器。堆区里面的内存就主要是JVM需要释放的内存对象。而方法区里面的是类对象,它是类加载过来的,而对方法区进行垃圾回收,就相当于“类卸载”,这里的规则比较特殊,我们不用考虑。而栈区和程序计数器是跟进程绑定在一起的,在进程结束的时候,相应地,栈区里面的变量和程序计数器就会随之自动释放内存空间了。
在上述几个区中,堆占据的内存空间就是最大的,本来就是占据了一个程序的大部分内存。
堆内存中,可以划分为:
垃圾回收机制主要回收的就是 完全不再使用的内存。对于一半在使用,一半不再使用的内存,是不回收的,因为回收的成本比较大,当然实现起来也比较麻烦。
因此,Java中的垃圾回收,是以“对象”为基本单位的,一个对象,要么被回收,要么不被回收,不会出现一个对象被回收一半的情况。
垃圾回收的基本思想:先找出垃圾,再回收垃圾。因此,就要确保该某个对象不再被使用,就认为是垃圾。如果要是把正在使用的对象回收了,就会造成很严重的后果了。
如:一个游戏服务器,提供服务,其中有一个功能,玩家查询自己的账户余额。查询的时候肯定是需要把查询的结果保存到一个对象中,当用户尝试获取到这个结果的时候,结果对象正常来说会包含结果数据,但此时被回收了,此时查询的结果就是一个错误的结果。
因此相比于回收少了,回收多了(回收错了)是一个更严重的问题,对于GC 来说,级别垃圾的原则,宁可放过也不要乱回收。
如何找垃圾也可以称为(如何标记垃圾?/ 如何判定垃圾?) 。抛开Java来说,单纯GC 的话,判定垃圾有两种典型的方案。
a)引用计数
b)可达性分析
先谈谈 引用计数:
引用计数,就是通过一个变量来保存当前的这个对象,被几个引用来指向。一个对象就会内置一个计数器记录它被几个变量所指向。
如:此时new Test() 这个对象就被三个变量所指向,因此里面的计数器就为3.
Test a = new Test();
Test b = a ;
func(a);
void func(Test t) {
...
}
但是引用计数有个致命的问题。当出现循环引用时:如:
class Test {
Test t = null;
}
Test t1 = new Test();//1
Test t2 = new Test();//2
t1.t = t2;//3
t2.t = t2;//4
t1=null;//3
t2=null;//2
当代码运行完t2=null 的时候,按引用计数的情况来说,new Test() 里面的计数器为2,但是此时内存是不再使用的,它不被回收就会导致内存泄漏了。
因此,引用计数的优缺点:
优点:规则简单,实现方便,比较高效(程序运行的效率高)。
缺点:
1.空间利用率比较低(比较浪费空间,尤其是针对大量的小对象)。本来引用的次数就不多,而且还内置了计数器就比较浪费空间了(每一个int占4个字节)。
2.存在循环利用导致判定是否是垃圾出现了错误,从而无法回收。
因此在Java中没有使用引用计数去判定垃圾,而是第二种方式——可达性分析。
从一组初始位置出发,向下进行深度遍历,把所有能够访问到的对象都标记成“可达”,对应地,没有访问到(不能访问到) 的对象就没有标记,没有标记的就是垃圾。
如:
有:
class TreeNode {
char val;
TreeNode left;
TreeNode right;
}
TreeNode root = ...;
假设root 是一个方法中的局部变量,当前栈帧中的局部变量,也是进行可达性分析的一个初始位置,从此处就往下进行遍历。
默认情况下,整棵树都是可达的,都不是垃圾,但是如果有root.right.right=null
,则f这个结点就不可达了,就成了垃圾。如果有root.right=null
,此时c和f结点都不可达了,就都是垃圾了。
JVM中采取的方案是:在JVM 中就存在一个/一组线程,来周期性地,进行上述遍历的过程,不断地找出这些不可达的对象,由JVM进行回收。
可达性分析的初始位置有:
1.栈上的局部变量表中的引用。
2.常量池里面的引用指向的对象。
3.方法区中,引用类型的静态成员变量。
基于上述过程,就完成了对垃圾的标记。和引用计数相比,可达性分析,确实更麻烦,同时实现可达性分析的遍历过程开销是比较大的。但是带来的好处是解决了引用指针的两个缺点:内存上不需要消耗太多的空间,也没有循环引用的问题。
不管是引用计数还是可达性分析,我们都可以发现,内存是否需要回收 是看 当前的对象是否有引用来指向。是在通过引用来决定对象的生死。
垃圾回收中的经典算法/策略:
a)标记-回收
b)复制算法
c)标记-整理
比如说:白色是正在使用的对象,灰色是已经被释放的空间。
虽然此处可以释放掉不再使用的内存空间,但是引入了一个问题——内存碎片。我们发现,空闲的内存和正在使用的内存,是交替出现的。
此时如果是申请一小块内存,那没什么问题。但如果是申请一大块连续的内存,此时可能就会分配失败。很多时候,申请的内存,是一块连续的空间(new byte[]),由于内存碎片的存在,整个空闲的内存有100M,此时申请50M的内存,仍然可能会分配失败。
内存碎片的问题,如果一直累计下去,就会导致:空闲的内存其实挺多的,但是不能够去使用,就很难受了。并且该问题在频繁地“申请释放” 的场景中更加常见。
它是为了解决 标记-清除 的内存碎片问题的。把内存分为两部分。
开始:
此时假设1、3要被回收,那么就剩下了2,4了。就将2,4的内存复制到右遍的内存区域中。此时再回收掉左边的一整个内存区域。内存区域一次只用一个部分。
使用复制算法,就能够解决 标记-清除 内存碎片问题。
复制算法的缺点:
1.可用的内存空间,只有一半。
2.如果要回收的对象比较少,而剩下的对象比较多,复制内存的开销就很大了。
因此复制算法,适用于:对象会被快速回收,并且整体的内存不大的场景下。
能够解决复制算法的内存空间利用率的问题。它类似于顺序表的“删除”的搬运操作。
初始:假设此时要回收2,4,6 的内存空间。
就将3往2搬,因为4是需要回收的,它不动。5往第三个位置搬,6是需要回收的,不动。7往第四个位置搬,8往第五个位置搬。搬到最后6没有被覆盖,那么就回收6 。
最终结果:
这样的操作,能够有效避免内存碎片,同时也能提高内存利用率。
缺点:在搬运的过程中,是一个很大的开销,这个开销可能比复制算法里面的开销更大。
实际实现的垃圾回收算法,要能够结合上面的三种方式,取长补短。就有了分代算法。
它把内存中的对象分成了几种情况,每种情况下,采用不同的回收算法。
根据“年龄”去进行划分。年龄是如何来的?是根据GC 的次数来的,每次经历一个扫描周期,就认为“长了一岁”。在JVM中,垃圾回收扫描(可达性分析)是周期性地进行的。因此就根据不同的年龄,就采用不同的垃圾回收算法来处理了。
划分结构:
分代回收的过程:
1.一个新的对象,诞生于伊甸区。
2.如果活到一岁的对象(对象经历了一轮 GC 还没死),就拷贝到 生存区。
生存区的内存大小比较小,那么空间小能放下这么多对象吗?
答:根据经验规律,伊甸区的对象,绝大部分都是活不过一岁的,只有少数对象能够来到生存区,对象大部分都是“朝生夕死”的。注意:是大部分!!!
3.在生存区中,对象也要经历若干轮GC,每一轮GC 逃过的对象,都通过 复制算法 拷贝到另外的生存区里。这里面的对象来回拷贝,每一轮都会淘汰掉一批对象。
4.在生存区中,熬过一定轮次的GC 之后,这个对象如果还没有被回收的话,JVM就认为,这个对象未来能够更持久地存在下去。于是就将这样的对象拷贝到老年代了。
5.进入老年代的对象,JVM都认为是属于能够持久存在的对象。这些对象也需要使用GC 来扫描。但是扫描的频次就大大地降低了。老年代这里通常使用的是标记-整理算法。
特殊地,如果一个对象的内存特别大,它会直接放入老年代。因为如果把它放入到新生代,如果经过一轮GC没有被淘汰,就放到生存区中。在生存区中拷贝来拷贝去的开销会比较大,甚至有的对象的内存太大在生存区可能放不下,因此直接放入老年代更合适。
垃圾回收器,属于JVM 中GC 机制的具体实现。这些具体实现中,就应用到了上述的一些垃圾回收算法。
我们真正需要了解的有两个垃圾回收器。
1.CMS 。最主要的特点,是尽可能地降低STW ,使用标记-回收,先进行一个初步的标记(很快,会出现STW),接下来和业务线程并发的进行 深入的标记(不会STW),再进行一个重新的标记(很快,但是会STW),主要是对之前的标记进行简单地修正,最后进行回收。
2.G1 。最主要的特点,是将内存划分成了更多的小区域(不像上面所说的新生代和老年代),以小区域单位进行GC 。
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/ZJRUIII/article/details/124432286
内容来源于网络,如有侵权,请联系作者删除!