浅谈 JVM 的内存划分、类加载、垃圾回收机制

一、JVM内存划分

JVM(Java Virtual Machine) ,Java虚拟机。

1.1、JVM为什么要进行内存划分?

java程序,是一个名字为 java 的进程,这个进程就是我们所说的 “JVM”。JVM(进程)会先从OS(操作系统)处申请一大块内存空间,在这个基础上再把这个内存空间划分成几个小的内存区域,每个区域都有自己的功能作用。

在这里插入图片描述
那么JVM里的内存区域大概划分主要为:(jdk 1.7 是这样的)
在这里插入图片描述
jdk1.8 之后,就把之前的方法区换成了元数据区。之前方法区是在 JVM 从OS 中申请到的一大块内存上划分内存区域,而元数据区,用的是本地内存 (JVM内部的C++代码里弄的内存)

面试中,一般关于JVM内存划分的问题,直接按照 jdk1.7 版本的划分方式回答。

有的面试官会通过简答题的形式让你详谈关于JVM的内存划分、内加载、垃圾回收机制是怎么一回事,有的面试官会通过代码题的形式考察JVM的知识点。

比如类似这样:

public class Test{
	private int a = 90;
	private static int b = 100;
	public static void main(String[] args){
		Test t = new Test();
	} 
}

请问:
1、当前代码中的 t 是在内存中的哪个区域? —> 栈区
2、a 是在内存中的哪个区域? —> 堆区
3、b 是在内存中的哪个区域? —> 方法区

其实大多数同学会认为,t 是一个引用变量,所以 t 应该是在堆区,包括我自己也是常常有这个误区。

但是其实变量在哪个部分,和变量类型无关,和变量的形态有关。局部变量存在栈区,成员变量存在堆区,静态的存在方法区。

二、JVM类加载

类加载,是一个很复杂的事情。

2.1、什么是类加载?

java程序在运行之前需要先编译(将 .java文件变成 .class文件(二进制字节码文件))

java程序运行时,java进程(即 JVM)就会读取对应的 .class 文件,并且解析内容,在内存中构造出类对象,并进行初始化…

类对象:描述了这个类是什么样的,类里包含哪些属性,这些属性的名字、类型,被什么修饰词修饰;类里包含哪些方法,这些方法的名字、类型是什么;还描述了这些类继承自哪个父类,实现了哪些接口。因此类对象也是创建实例的具体依据。

类加载就是:将类从文件加载到内存中。

类对象是类加载之后的结果。

2.2、类加载大体过程

1、加载
      java进程找到.class文件,从.class文件中读取内容并解析。
2、连接
       (2.1)、验证
      检查当前解析的.class文件其内容格式是否符合要求。
       (2.2)、准备
       给类里的静态变量分配内存空间,int类型就是分配4个字节的内存空间,同时这些空间初始情况全是0.
       (2.3)、解析
      初始化字符串常量。会把符号引用(占位符)替换成直接引用(内存地址)
       .class 文件里包含很多字符串常量,比如 String x = “helloworld!” 就是一个字符串常量。在类加载之前,”helloworld!“ 这个字符串是没有分配内存空间的(类加载之后才有内存空间),没有内存空间,x 就无法保存这个字符串常量的真实地址,只能显示用一个占位符标记一下这块地方是字符串常量 x 的地址,等到真正给 x 分配内存之后,就可以用真正的地址替代之前的占位符。

3、初始化
      针对类进行初始化,初始化静态成员,初始化静态代码块,并加载父类…

2.3、何时触发类加载?

使用到一个类的时候,就会触发类加载(类并不一定是程序一启动就加载,是第一次使用时才加载(懒汉模式))

那怎么样才算是使用到一个类呢??
1)、创建这个类的实例时
2)、使用了类的静态方法或静态属性
3)、使用了类的子类(加载子类会触发加载父类)

2.4、双亲委派模型[!面试高频问题]

类加载的重要环节其实是:解析.class文件,校验.class文件,构造.class对象。所以相对来说双亲委派没那么重要。但是奇怪的是,面试中考察双亲委派却比较多。

2.4.1、类加载器

JVM加载类,是由 类加载器(class loader) 这样的模块负责的。

JVM自带了多个类加载器(包括程序员也可以自己实现类加载器):
1)、bootstrap ClassLoader
负责加载标准库中的类。
2)、Extension ClassLoader
负责加载 JVM 扩展的库里的类(即语言规范中未记录的,但 JVM 实现了的)
3)、Application ClassLoader
负责加载开发人员开发的项目中自己自定义的类

这3个 类加载器 各自负责各自的一个片区(即:各自负责自己的一组目录)

2.4.1、什么是双亲委派模型?

在这里插入图片描述

描述上述 类加载器 相互配合的工作过程,就是双亲委派模型。

那类加载器如何相互配合呢??
1)、上述3个类加载器存在如上父子关系。Bootstrap ClassLoader 是 Extension ClassLoader 的父 类加载器,Extension ClassLoader 是 Application ClassLoader 的父 类加载器。
2)、进行类加载的时候,输入的内容叫做 全限定类名,形如:java.lang.Thread
3)、加载的时候从 Application ClassLoader 开始
4)、某个类加载器开始加载的时候,不会立即扫描自己负责的路径,而是先把任务委派给父 “类加载器” 来先进行处理。
5)、找到最上面的 Bootstrap ClassLoader, 再往上,就没有父类加载器了,Bootstrap ClassLoader 只能自己手动加载了。
在这里插入图片描述
6)、如果父亲没有找到类,就交给自己的儿子,继续加载。
在这里插入图片描述
7)、如果一直找到下面的 Appliacation ClassLoader 也没找到类,就会抛出一个 “类没找到” 的异常,表示类加载失败。

按照这个顺序/规则加载类,好处是:
假设开发人员自定义的类,其全限定类名与标准库中的类名一致,此时仍然可以保证类加载可以加载到标准库的类,防止了加载错类,代码报错的问题。

三、JVM 垃圾回收机制(GC)

3.1、什么是GC

GC(垃圾回收) 用于解决 内存泄漏 的问题。程序员只需要负责申请内存,释放内存的工作,交给 JVM 来完成。JVM会自动判定当前内存是否还在使用,不再使用的话,就进行释放。

使用GC最大的问题在于引入了额外的开销:时间 + 空间 的成本。时间:STW问题(Stop The World)程序在进行GC时,反映在用户这里的问题就是产生明显的卡顿,这样的卡顿十分影响用户体验。空间:消耗了额外的CPU/内存等资源。

但GC仍然是开发中必不可少的!但由于C++一般追求的是效率,所以C++的垃圾回收不采取GC机制,而是采用智能指针的方式。其他语言,Python/Java/Go/PHP…这些都是采用GC机制回收垃圾预防内存泄漏。

3.2、GC回收哪部分内存?

在JVM内存区域划分处我们知道内存区域主要划分成4部分:栈区、堆区、方法去、程序计数器。
在这里插入图片描述
而GC主要针对堆区这部分的内存进行回收。
堆区又可以细分成这几部分:
在这里插入图片描述

我们GC时,千万不能将 正在使用的内存 回收了,一定要确保是已经不再使用的内存,才能够回收。否则回收错了,会影响程序的正确运行。

3.3、GC机制具体怎么回收垃圾?

3.3.1、先 找出垃圾(判定哪个对象是垃圾)

那怎么找出垃圾??涉及一系列复杂策略。

当一个对象再也不用了,就说明其是垃圾了。在 Java 中,对象的使用,需要凭借 引用,假设一个对象,已经没有任何引用指向它的时候,这个对象自然就无法再被使用了。

所以找出垃圾最关键的要点就是通过引用判定当前对象是否还能被使用,没有引用就视为是无法被使用。

两种典型的,判定对象是否存在引用的办法:
1、引用计数 [不是 JVM 采取的办法]
给每个对象都加上个计数器,这个计数器就表示 “当前的对象有几个引用”。
举例此处有个对象:MyTest m = new MyTest();
在这里插入图片描述
每次多一个引用指向该对象,计数器就 +1;每次少一个引用指向该对象,计数器就 -1(比如引用是一个局部变量,出了作用域,引用计数就-1;比如引用是一个成员变量,所在对象销毁了,引用计数就+1)。

当引用计数为0时,就说明当前这个对象已经无人能够使用了,此时这个对象就能够被回收了。

(我上面画的图,m、n引用不一定是局部变量,也可能是成员变量,因此他们可以和 MyTest 对象在同一条内存条上。其次,这个内存空间有可能是机器上上的整条内存空间)

引用计数的优点:
简单容易,执行效率高。
引用计数的缺点:
(1)、空间利用率低,尤其是小对象。比如当计数器是int时,对象本身只有一个int成员,本来对象里只需要存储一个int成员,只需要花费4个字节,但是加上int计数器,就需要花费8个字节。
(2)、可能会出现循环引用的情况。
举个例子:
在这里插入图片描述
就像是你有两套房子,房1的钥匙锁房2里,房2的钥匙锁房1里了,此时两套房子的门都打不开了。因此循环引用就好比死锁的循环依赖,因此Java不采用引用计数的方式判定对象当前含有几个引用,而是采用可达性分析的方式,其他语言采用引用计数的方式是因为他们采用的别的手段来预防引用计数的循环引用。

1、可达性分析 [是 JVM 采取的办法]
约定一些特定的变量,称为 “GC roots”。每隔一段时间,从 GC roots 出发,进行遍历,看看当前哪些变量是能够被访问到的。能够访问到的变量就称为 “可达”,不能够访问到的就称为 “不可达”。

GC roots 有哪些?
(1)、栈上的变量
(2)、常量池引用的对象
(3)、方法区引用类型的静态变量
每一组都有一些变量,每个变量都可以视为是起点。从这些起点出发,尽可能遍历,就能够找到所有能够访问到的对象。

在这里插入图片描述

为什么要指出是否是 JVM 采取的办法吗是因为,面试时,如果面试官问:针对垃圾回收机制判定对象的引用是否存在采用的方法有哪些??引用计数、可达性分析这两个都可以答。但是如果面试官问:针对Java的垃圾回收机制判定对象的引用是否存在采用的方法有哪些??只回答 可达性分析!

3.3.2、再 回收垃圾(释放内存)

那怎么回收垃圾??涉及一系列复杂策略。主要有4个方法:
1、标记清除
在这里插入图片描述
标记清除这种方式虽然简单便捷,不过它有一个问题就是:内存碎片:本来我们的内存空间是一大片、一整片的内存空间,现在释放了一些内存空间之后,导致整个内存空间变得十分零碎。

假设上述图片中的每个灰色区域是1K,此时整条内存就有4K空闲空间。但是由于内存是离散的,导致我们想申请2K的内存空间(连续的),也申请不了。因此内存碎片导致内存利用率低,故这是GC中回收内存的一种办法,但是由于内存碎片的问题,它并不被广泛使用。

2、复制算法
复制算法是针对标记清除方法中的内存碎片问题而引入的一种方法。

复制算法就是将一整块内存空间分成两半,使用左半侧空间时,右半侧不用;使用右半侧空间时,左半侧空间不用。但是并不是不用的那半侧空间没有效果,它会在垃圾回收的时候起到效果。
在这里插入图片描述
但是复制算法也有自己的问题:
1、空间利用率比较低(一整块内存只用一半,用一半丢一半)
2、如果一轮 GC 下来,大部分对象要保留,只有少数对象要回收,这个时候复制算法的开销就很大的了。

3、标记整理
标记整理是针对复制算法的缺点应运而生的。

标记整理类似于顺序表的删除元素,含有搬运元素的操作。
在这里插入图片描述
标记整理相对于上述复制算法来说,空间利用率高了,同时还能解决内存碎片问题,但是标记整理的搬运操作也很耗时。

因此我们需要因地制宜的选择合适的回收方式。

4、分代回收
分代回收,是将上面的3个方法综合起来,根据不同对象的特点,采取不同的回收方式。主要是根据对象年龄的特点来划分的,对象年龄是根据GC的轮次来算,GC轮次是相当于有一组线程周期性扫描线程里的所有对象,如果一个对象经历了一次GC但没有被回收,此时就表示它的年龄+1,以此类推,经理几轮GC,对象年龄就加几。

对象年龄不一样,对象所表现出的特点则不一样:如果一个对象年龄越大,它可能会活得越久。就像C语言,已经存在了50多年,我们可以合理推测,它还能继续存在50多年。

基于上述内容,就可以针对对象的年龄进行分类,把堆里的对象分成:新生代(年龄小的对象)和老年代(年龄大的对象)。新生代的对象容易被GC,老年代的对象不容易被GC。

在这里插入图片描述

分代回收还有一个特例:如果一个对象很大,那么这个对象可以直接进入老年代。一个大对象,从新生代到老年代,需要经过复制算法,但是大对象进行复制算法开销很大,因此大对象直接进入老年代;同时一个大对象,创建出来也不是为了立即销毁的,因此可以直接进入老年代。

  • 26
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值