Bootstrap

深入理解JVM

JavaEE初阶34、35

一、JVM内存区域划分

1、java进程是资源分配的基本单位

  一个用java写的程序跑起来,就得到了一个java进程,java进程=JVM+上面运行的字节码指令。

  java的进程是资源分配的基本单位。让我们举一个生活中的例子,一个办公团队,想要组建一个小公司,因此老板租了一块区域。接下来,老板就需要将这块区域划分为几个区域,使其能进行正常的业务工作,于是,这块区域被划分为:老板办公室、员工工作区、前台、茶水区……java进程就可以类比为拥有这么一块区域,进程将内存区域划分为不同的部分,以保证程序可以正常运行!

2、四个不同的内存区域

  java进程将内存区域划分为四个部分,分别是程序计数器、堆、栈、元数据区。

1)程序计数器

  程序计数器占比较小的空间,它的作用是保存下一条要执行指令的地址。这里的“下一条要执行的指令”是java的字节码。

2)堆

  堆是JVM上最大的空间,我们new的对象都在堆上。

3)栈

  栈保存了函数中的局部变量、函数的形参、函数之间的调用关系等信息。

4)元数据区

  元数据区以前也称为“方法区”,它主要保存java程序中的指令(指令都是包含在类的方法中)、代码中涉及到的关于类的信息、还有类的static属性

  一个java进程中,元数据区和堆是只有一份的。程序计数器和栈则可能有多份(当一个java进程有多个线程的时候,每个线程都有自己的程序计数器和栈)

3、一道题目

  有以下代码,请问代码中的a、b、c、d、e、f分别存储在内存中的哪块区域。

class Test{
  private int a;
  private Test b=new Test();
  private static int c;
  private static Test d=new Test();
  
  public staic void main(String[] args){
    int e=10;
    Test f=new Test();
  }
}

  一个变量存储在哪个内存区域,和变量的形态有关。

1)局部变量-->栈

2)类成员变量-->堆

3)静态成员变量-->元数据区 

a:变量a是类的成员变量,存储在堆区

b:变量b是一个引用类型的类成员变量,存储在堆区,它存储的是Test()的地址,new 的Test()对象则存储在堆区。

c:变量c是全局静态变量,存储在元数据区

d:变量d是一个引用类型的全局静态变量,存储在元数据区,new 的Test()对象则存储在堆区

e:变量e是局部变量,存储在栈中

f: 变量f是一个引用类型的局部变量,存储在栈中,new 的Test()对象则存储在堆区

二、JVM类加载过程

1、什么是类对象

  我们写一个java程序,得到一个.java文件;然后经过javac编译,得到.class文件(存在硬盘中)。

  当运行Java进程的时候,JVM就需要“读取” .class 中的内容,并且执行里面的指令。其中,读取.class 的操作,就是“类加载”,类加载是把类涉及到的字节码,从硬盘读取到内存中的元数据区。也就是把这个.class中的指令转变为一个类对象。

  加载一个.class 文件,也就会创建对应的类对象,类对象里面包含了.class文件中的各种信息。比如:

类的名字是啥

类里有哪些属性,每个属性的名字是啥、每个属性的类型是啥、是用publice修饰还是private修饰

类里有哪些方法、每个方法的名字是啥、参数是啥、是用publice修饰还是private修饰

继承的父类是啥

实现的接口有哪些  

 2、类加载的具体步骤

1)加载:

把.class 文件找到,代码中先见到类的名字,然后进一步找到.class文件,打开并读取文件的内容

2)验证:

验证我们读到的.class 文件的数据,是否正确、是否合法?

3)准备:

准备过程也就是分配内存空间,根据类对象读取到的内容,确定出类对象需要的内存空间,申请这样的内存空间,并且把内存空间的所有内容,都初始化为0。

  比如下面的代码,此时将这个代码进行类加载,到达准备阶段时,此时的a被初始化为0,还并不会被设置为10。

class Test{
  public static int a=10;
}

4)解析:

 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

或许你会很疑惑,什么是符号引用?什么是直接引用?让我来详细介绍一下。

  字符串常量池里面的字符串其实是存储在.class文件中(存在硬盘中),它是没有地址的,因为我们通常谈到的地址,是属于内存的概念。但是在类加载之前,数据还没有加载到内存中,所有的字符串是存储在硬盘上的,因此没有地址!

  直接引用,这个引用是用来保存一个地址,通过这个地址,我们就可以找到这个地址存储的值!

   总而言之:存在内存中的数据,才有地址;存在硬盘中的数据,没有地址。

   那么,字符串常量池中的字符串存储在硬盘中,如何表示究竟在硬盘中的哪个方位呢?

  我们可以使用“偏移量"的概念来描述字符串在文件中所处的位置,这种方式叫做”符号引用”。

  等到进行类加载的时候,就会把hello这个字符串加载到内存中,此时hello这个字符串就有了地址,此时,就可以使用直接引用的方式找到hello这个字符串。

  在没有进行类加载之前,.class 文件有很多指令使用到hello字符串,此时这些指令找到hello使用的是符号引用。我们的解析阶段就是,在进行类加载后,hello这个字符串加载到内存中,有了地址,此时的hello可以进行直接引用查找之后,Java虚拟机将常量池内的符号引用替换为直接引用的过程。

5)初始化:

初始化阶段一般会进行下面几个操作:

  1、针对类对象做最终的初始化操作,比如在准备阶段中,a被设置为0,此时到了初始化阶段,才真正把a初始化为10。

class Test{
  public static int a=10;
}

2、执行类中的静态代码块

3、针对父类也进行加载,如果当前加载的类还有父类,且这个父类还未被加载过,此时在这个环节也会触发针对父类的加载。

3、双亲委派模型

  双亲委派模型是类加载的阶段1涉及到的一个环节,也就是找文件环节。

  找文件操作依靠的是JVM自带是三个类加载器:

 1)BootstrapClassLoader:负责加载标准库的类

 2)  ExtensionClassLoader:负责加载扩展库的类

 3)  ApplicationClassLoader:负责加载第三方库的类(也就是我们自己写的类)

  这三个类加载器逻辑上是有父子级关系的,我们可以这样类比:BootstrapClassLoader是爷爷,ExtensionClassLoader是爸爸,ApplicationClassLoader是儿子。

  当执行找目录的过程中,这三个类加载器会分别获得一个目录,用于它们来查找文件,爷爷目录可以查找到标志库的类、爸爸目录可以查找到扩展库的类、儿子目录可以查找到第三方库的类。

  首先,JVM进行找文件操作,此时将任务发送到儿子加载器,儿子加载器第一时间不是立即执行找文件操作,而是先将这个任务发送到父亲加载器,让父亲加载器先找一下这个文件;

  父亲加载器接收到任务,也不是第一时间立即执行找文件操作,而是先将这个任务发送到爷爷加载器,让爷爷加载器先找一下这个文件;

  爷爷加载器接收到任务,根据其目录,开始执行找文件操作!分为两种情况:找到该文件,进行类加载操作;未找到该文件,将任务再派送回父亲加载器

 如果父亲加载器收到爷爷加载器派发回的找文件任务,父亲加载器再执行找文件操作,也分为两种情况,找到则进行类加载;未找到则将任务派回给儿子加载器

  如果儿子加载器收到父亲加载器派发回的找文件任务,儿子加载器再执行找文件操作,找到则进行类加载,未找到则报错

  上述逻辑的目的,是为了确保标志库中的类是优先级最高的,扩展库其次,第三方库最低。也就是说,我们平时写代码,可能会出现类名和标准库或者扩展库的类名一致,此时JVM要保证标志库的类优先加载,扩展库的类其次,我们自己写的类最低。 

三、垃圾回收机制(GC)

1、内存泄漏

  在C/C++中,malloc/new一个对象,都需要手动释放内存free/delete,如果不释放内存,就会造成内存泄漏。针对这一情况,各大语言的解决策略是不同的:

1)C语言针对内存泄漏,采取摆烂态度,对内存泄漏这种问题并没有提出什么有效的解决方案。

2)C++针对内存泄漏,给出了一个“智能指针”,但其实效果也一般。

3)Java针对内存泄漏,则给出来更系统更完整的解决方案,这就是我们要介绍的“垃圾回收机制”,也就是GC。程序员写代码,可以放心大胆地new对象,JVM会自动识别,哪些new完的对象再也不用了,就会把这样的对象自动释放掉。

2、回收机制的回收主战场

JVM中有好几个内存区域,那么GC主要负责回收哪里呢?

1)程序计数器和栈是跟随线程的,因此不需要GC,线程结束,内存就释放了

2)元数据区主要存储类对象数据,一个程序里面要加载的类,都是有上限的,不会出现无限增长的情况,因此也不需要GC

3)堆主要存储new的对象,是GC的主战场

  GC回收是以对象为维度的,以下是GC回收的逻辑:

3、GC具体是怎样回收的

GC回收分为两个步骤:

1)先找出来谁是垃圾(垃圾==不再使用的对象,也就是说,不再有引用指向这个对象)

2)释放垃圾的内存空间

识别垃圾方案1:引用计数

给每个对象分配一个计数器,衡量有多少个引用指向这个对象。

每次增加一个引用,计数器+1;

每次减少一个引用,计数器-1;

当计数器减少为0,此时对象就是垃圾了;

  结合代码举个例子:

   a)Test a=new Test() :此时new Test()这个对象存储在堆中,这个对象里面有一个计数器,此时只有a一个引用指向Test(),因此计数器=1;

  b)Test b=a :增加一个引用指向Test(),因此计数器=2;

  c)a=null:少了一个引用指向Test(),此时计数器=1;

  d)b=null:少了一个引用指向Test(),此时计数器=0,GC回收掉Test();

  上述方案其实存在两个问题,因此JVM并没有采纳这个方案。

问题1:计数器的引用增加了内存开销,因为我们计数器是分配到对象中的,因此每个对象因为引入计数器而需要分配更多空间。

问题2:这种方案可能会产生“循环引用”,导致无法GC回收

class Test{
  Test t=null;
}


Test a=new Test();
Test b=new Test();
a.t=b;
b.t=a;
a=null;
b=null;

  让我来具体介绍一下“循环引用”为什么会导致无法GC回收。

a)我们创建一个Test类,这个类里面有一个属性,即Test t。

b)实例化两个Test类,引用分别是a,b

c)分别设置Test对象t的值,此时各自的计数器都为2

d)将a,b置为null,此时就出现了“循环引用”,由于各自的计数器都为1,因此无法进行GC操作,但是实际上,两个Test对象都无法再获取到了,可以被视为垃圾,但是却无法GC。

识别垃圾方案2:可达性分析 (JVM采用)

  在JVM中,专门搞了一波线程,它会周期性地扫描代码中的所有对象,判定某个对象是否“可达,也就是可以被访问到,对应的,不可达的对象,就是垃圾了。

  类比生活在的例子,老师拥有学生的名单,当老师根据名单进行点名时,喊到的同学说明来教室上课了,那么没喊到的同学,说明没来上课

  JVM拥有所有对象的名单,周期性扫描时,可以被访问到的对象,说明还有引用指向它,因此还正在使用中,不用回收。无法访问到的对象,说明已经没有引用指向它,因此可以判定为垃圾,将它回收!

  可达性分析的起点,称为GC root,一个程序中,GC root不是只有一个,而是有很多个,它可以是栈上的局部变量(引用类型)、方法区中静态的成员(引用类型)、常量池引用指向的对象……把所有的GC root都遍历一遍,针对每一个尽可能往下延伸,因此特别消耗性能。


  知道了谁是垃圾,需要回收,那么该如何回收呢?

回收垃圾方案1:标记清除法

  标记清除也就是将被标记为垃圾的对象进行回收,使其空出内存空间!可是这可能会造成”内存碎片问题“。

  让我们看看下面这张图片,蓝色区域为原本是垃圾的区域,此时进行“标记清除”,将其空间进行回收之后,可是空闲出来的空间是一段不连续的内存,当后面JVM需要申请一个3M的连续空间时,是无法实现的!这就是”内存碎片问题“

回收垃圾方案2:复制算法

  复制算法的逻辑是将内存空间一分为二,同一时刻,只使用其中一半。

 如图,JVM将内存一分为二,每次只使用其中一半,将垃圾对象识别出来后,JVM将非垃圾对象复制到另一半的内存空间中,接下来另一半的内存空间全部释放!

  

 但是复制算法缺点也很明显:

1)内存空间利用率低,每次只使用了一半

2)如果存活下来的对象比较多,那么复制成本 也比较大

垃圾回收方案3:标记整理法

   标记整理法的思想标记难解释,我们以上述例子来讲解一下它的具体逻辑: 

 a)有三个垃圾对象1、3、5,需要回收,其他对象均为不需要回收的

b)将对象2复制到对象1的内存空间中

c)将对象4复制到原对象2的内存空间中

d)将对象6复制到对象3的内存空间中

e)将对象7复制到原对象4的内存空间中

f)将对象8移动复制到对象5的内存空间中

g)释放掉后面的内存空间

  但是标记整理法搬运的开销也不小

  垃圾回收方案 4:分代回收法(JVM采用)

  以上3个垃圾回收方案因为有各自的缺陷,因此都不是JVM采纳的垃圾回收方案,实际上,JVM采用的垃圾回收方案是”分代回收法“,这个方案是将上述几个方案综合在一起,取长补短而成的。

  JVM根据对象的年龄,把对象进行区分,年轻的是新生代,年老的是老年代。那么JVM是如何计算对象的年龄的呢,其实是通过前面的可达性分析计算的,由于可达性分析会周期性扫描每个对象,每经过一轮扫描并且存活的对象,年龄+1。

  分代回收将内存整个堆内存划分为几个区域:

a)伊甸区:存储新生代,采用复制算法。根据经验规律,绝大部分新对象,活不过第一轮GC,留存下来的拷贝到幸存区

  由于绝大部分新对象活不过第一轮GC,因此复制算法真正需要拷贝的对象其实不多,开销会比较小

b)幸存区:是两个相等的空间,采用复制算法,反复进行多次

c)老年区:存储老年代,采用标记整理法,如果一个对象在幸存区已经反复被拷贝多次,也没有被判定为垃圾,它的年龄不断增长,此时它就可以拷贝到老年区

d)根据经验规律,老年代中的对象,生命周期会比较长,GC的频率会比较低,因此需要整理的次数也不多

;