了解 JVM 中几个相关问题 — JVM 内存布局、类加载机制、垃圾回收

13 篇文章 0 订阅

JVM 其实本质上就是一个 Java 进程,JVM 启动之后就会从操作系统申请到一大块内存,在程序启动时,JVM 这个 java 进程就会对它申请到的这块内存空间划分多个区域,每个区域都有自己的功能。

JVM 内存区域划分

在这里插入图片描述

1. 堆

堆中存放的时程序 new 出来的对象

2. 方法区

方法区中存放的是 类对象

一个 .java 程序启动时,就会生成一个 .class 文件,JVM 会将这个 .class 进行加载,加载到内存中 就变成了类对象

  • 类对象里都有什么?
  1. 包含了这个类的各种属性的名字,类型,访问权限
  2. 包含了这个类的各种方法的名字,参数类型,返回值类型,访问权限,以及方法的实现的二进制代码
  3. 包含了这个类的 static 成员

程序计数器

程序计数器是内存中最小的一个部分,它里面存放的只是一个内存地址,就是程序接下来要执行的指令地址。

栈中存放程序中的 局部变量

成员变量 & 局部变量 & 静态成员变量

在这里插入图片描述
在这里插入图片描述

虚拟机栈和本地方法栈的区别

本地方法栈: 指的是给 JVM 内部的方法(使用 C++ 实现的方法)去使用的
虚拟机栈:给上层的 Java 代码来使用的。

在 JDK1.8 之后这两块区域已经没有了本质区分

Java 中的类加载

类加载其实是 JVM 中的一个非常核心的流程,他所做的事情就是把 .class 文件,转换成 JVM 中的类对象。

在这里插入图片描述
当源代码编译成可执行程序后再加载成类对象之后,才能开始执行

对于类加载来说总共分为以下几个步骤:

  1. 加载
  2. 连接
    1. 验证
    2. 准备
    3. 解析
  3. 初始化

在这里插入图片描述

类加载中的“双亲委派模型”

进行类加载,其中一个非常重要的环节,就是根据这个类的名字:例如“java.lang.String”,找到对应的 .class 文件,在 JVM 中,有三个类加载器(三个特殊的对象)来负责进行这里的找文件操作。

背景:
在这里插入图片描述

源码

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查请求类是否被加载过了
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父类加载器抛出 ClassNotFoundException,说明父类加载器无法完成加载请求
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // 在父类加载器无法加载时,再调用本身的 findClass 方法进行类加载
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // 记录统级信息
                    ...
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类而是把这个请求委派给父类加载器去完成每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载

垃圾回收(GC)

我们首先要知道,垃圾回收,回收的是 “内存”, 前面讲到说 JVM 其实就是一个 Java 进程,一个进程是会持有很多的硬件资源的(CPU,内存,硬盘,带宽资源…),但是系统的内存总量是一定的,程序在使用内存的时候,必须先申请才能使用,内存要同时给很多个进程来使用,所以当前进程在使用完之后,就需要进行释放。对于 Java 来说,代码中的任何地方都可以申请内存,然后由 JVM 统一进行释放,具体来说,就是在 JVM 内部,专门持有一组负责垃圾回收的线程来进行这样的工作。

垃圾回收要回收的内存是哪些?

  • 日常讨论的 “垃圾回收” 主要就是指 “堆上内存的回收”;因为在 Java堆中存放着几乎所有的对象实例,程序员从代码编写的角度来看,内存的申请时机,是非常明确的。但是内存的释放时机,很多时候不太明确
  • 栈上的内存是和“具体的线程” 绑定在一起的会随着线程启动创建,线程代码块执行结束,内存就自动释放了

回收堆上的内存,具体是回收什么?

在堆上,主要的内存使用就是 new 出了很多对象。
此时针对堆上的对象,也分为三种:

  1. 完全要使用
  2. 完全不适用
  3. 一半要使用,一半不使用

GC 中回收内存,其实就是回收这些 完全不使用 的对象。

垃圾回收基本思想

  1. 先找出要回收的垃圾对象
  2. 再去回收垃圾对象的内存空间

死亡对象判断算法

1. 引用计数算法

给对象增加一个引用计数器,每当有一个地方引用它时,计数器就 +1;当引用失效时,计数器就 -1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。

引用计数的优点:

  • 规则简单,实现方便,比较高效(程序运行效率高)

引用计数的缺点:

  1. 空间利用率比较低
    如果一个对象很大,在程序中出现的对象数目也不多,此时给对象增加四个字节的引用计数位没什么影响。
    如果一个对象很小,在程序中对象的数据也很多,此时引用计数就会带来不可忽视的空间开销。
  2. 存在循环引用的问题(致命伤)
    有些特殊的代码下,循环引用会导致代码的引用计数判断出现问题,从而无法回收。
    在这里插入图片描述

所以我们在 Java 中并没有使用引用计数这种方式来判定对象是否“死亡”,而是用了以下的方式:

2. 可达性分析算法

此算法的核心思想为 : 从一组初始的位置(GC Roots)出发,向下进行深度遍历,把所有能够访问到的对象都标记成“可达”(可以被访问到),对应的,不可达对象(没有标记的对象),就是“死亡”对象。

在这里插入图片描述
在Java语言中,可作为GC Roots的对象包含下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
    比如:各个线程被调用的方法中使用到的参数、局部变量等。
  2. 方法区中类静态属性引用的对象
    比如:Java类的引用类型静态变量
  3. 方法区中常量引用的对象
    比如:字符串常量池(string Table)里的引用
  4. 本地方法栈中 JNI(Native方法)引用的对象。

不管是引用计数,还是可达性分析,判定原则其实都是看当前这个对象是否有引用来指向,是通过引用来决定 “对象的生死”。

垃圾回收算法

1. 标记-清除算法

当堆中的有效内存空间被耗尽的时候,会停止整个程序(也称stop the world),然后进行两项工作, 第⼀项 是标记,第⼆项则是清除。

  • 标记:Collector从引⽤根节点开始遍历,标记所有被引⽤的对象。⼀般是在对象的Header中记录为可达对象。
  • 清除:Collector对堆内存进⾏从头到尾的线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收

在这里插入图片描述

标记清除算法的问题:

被标记的空间并不是一整块连续的空间,而是和存活对象穿插着的一小块一小块的碎片空间。如果我们将标记的空间进行删除,再次使用申请空间时,很多时候我们是需要申请一块连续存储的内存空间的,虽然内存空间足够,但是由于内存碎片,空间不连续,也会导致内存分配失败。

2. 复制算法

"复制"算法是为了解决"标记-清理"的效率问题。 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。
这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。

在这里插入图片描述

复制算法的问题:
  1. 可用的内存空间只有一半
  2. 如果要回收的对象比较少(剩下的对象比较多),复制的开销就会非常大

3. 标记整理算法

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。

针对老年代的特点,提出了一种称之为"标记-整理算法"标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存

在这里插入图片描述
这样的操作,就可以有效避免内存碎片,同时也能提高空间利用率。

标记整理算法的问题:

在这个搬运的过程中,也会是一个很大的开销,这个开销要比复制算法里面的复制对象的开销甚至更大。

4. 分代算法

分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。

当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法

如何进行分代?
哪些对象会进入新生代?哪些对象会进入老年代?

在 JVM 中,进行垃圾回收扫描(可达性分析)也是周期性的。 这个对象每次经历了一个扫描周期,就认为 “长了一岁”。

就根据这个对象的年龄,来对整个内存进行了分类,把年龄短的对象放一起,年龄长的放一起。
不同年龄的对象,就可以采取不同的垃圾回收算法来进行处理了。

在这里插入图片描述

特殊情况:如果这个对象特别大,会直接进入老年代

因为:如果这个大对象直接放在新生代,来回拷贝的开销会非常大,就会直接将这样的大对象放在老年代。

总结:一个对象的一生

一个对象的一生:我是一个普通的 Java 对象,我出生在 Eden 区,在 Eden 区我还看到和我长的很像的小兄弟,我们在 Eden
区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了 Survivor 区的 “From” 区(S0 区),自从去了
Survivor 区,我就开始漂了,有时候在 Survivor 的 “From” 区,有时候在 Survivor 的 “To” 区(S1
区),居无定所。直到我 18
岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次GC加一岁)然后被回收了。

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值