JVM开荒-2

上回说到 .java —> .class
接着讲解.class字节码文件是如何在JVM中存放的
JVM在为类实例或成员变量分配内存是如何分配的
先来了解一下JVM内存结构

JVM的内存结构

Java虚拟机的内存结构并不是官方说法
《Java虚拟机使用规范》中 [运行时数据区]才是术语,但是大家通常使用JVM内存结构

根据 《Java虚拟机使用规范》中的说法,Java虚拟机的内存结构可以分为公有和私有两部分。
共有

所有线程都共享的部分
  • Java堆
  • 方法区
  • 常量池

私有

每个线程的私有数据
  • PC寄存器
  • java虚拟机栈
  • 本地方法栈

共有部分

  • Java堆
  • 方法区
  • 常量池

Java堆
java堆是从JVM划分出来的一块区域,专门用于Java实例对象的内存分配
几乎所有的实例对象都会在这里进行内存分配。(小对象会在栈上分配)

Java堆根据对象存活时间的不同,Java堆还被封为年轻代、年老代两个区域,年轻代还被进一步 分为Eden 区、From Survivor 0、To Survivor 1 区。如下图所示。
在这里插入图片描述

当有对象需要分配时,一个对象永远优先被分配在年轻代Eden区,
等到Eden区域内存不够时,启动垃圾回收。

垃圾回收:
Eden区中没有被引用的对象的内存就会被回收,
一些存货时间较长的对象会进入到老年代

在JVM中有一个名为:XX:MaxTenuringThreshold 的参数专门用来设置晋升到老年代所需要经历的 GC 次数,
即在年轻代的对象经过了指定次数的 GC 后,将在下次 GC 时进入老年代

JVM堆区域划分的原因

对象有存活时间长短的,普遍是正态分布
如果混在一起,势必导致内存不够,垃圾回收频繁

垃圾回收也不用对全部对象进行扫描,扫描老对象属于浪费时间

另外一个值得我们思考的问题是:为什么默认的虚拟机配置,Eden:from :to = 8:1:1 呢?

其实这是 IBM 公司根据大量统计得出的结果。根据 IBM 公司对对象存活时间的统计,他们发现 80% 的对象存活时间都很短。于是他们将 Eden 区设置为年轻代的 80%,这样可以减少内存空间的浪费,提高内存空间利用率

方法区
存储Java类字节码数据的一块区域
存储了每一个类的结构信息

  • 运行时常量池
  • 字段
  • 方法数据
  • 构造方法
    可以看到常量池是放在方法区当中的,但<Java虚拟机规范>将常量池和方法区放在同一个等级上

方法区在不同版本的VM中有不同的表现形式

  • JDK1.7 HotSpot虚拟机中,方法区称为永久代(Permanent Space)
  • JDK1.8 MetaSpace

私有部分

Java 堆以及方法区的数据是共享的,但是有一些部分则是线程私有的。线程私有部分可以分为:

  • PC 寄存器
  • Java 虚拟机栈
  • 本地方法栈三大部分

PC 寄存器
Program Counter寄存器,指的是保存线程当前正在执行的方法
如果这个方法不是native方法,
那么PC寄存器就保存java虚拟机正在执行的字节码指令地址
是native方法,保存undefined

任意时刻JVM一个线程指只会执行一个方法的代码,称为当前方法地址存放在pc寄存器中

当JVM使用其他语言(C)来实现指令集解释器时,也会使用到本地方法栈
若JVM不支持native方法,并且自己也不以来传统栈的话 可以无需支持本地方法栈

ava 虚拟机的内存结构是学习虚拟机所必须掌握的地方,其中以 Java 堆的内存模型最为重要,因为线上问题很多时候都是 Java 堆出现问题。因此掌握 Java 堆的划分以及常用参数的调整最为关键。

除了上述所说的六大部分之外,其实在 Java 中还有直接内存、栈帧等数据结构。但因为直接内存、栈帧的使用场景还比较少,所以这里并不做介绍,以免让初学者一时间混淆。

学到这里,一个 Java 文件就加载到内存中了,并且 Java 类信息就会存储在我们的方法区中。如果创建对象,那么对象数据就会存放在 Java 堆中。如果调用方法,就会用到 PC 寄存器、Java 虚拟机栈、本地方法栈等结构。那么面对如此之多的 Java 类,JVM 是如何决定这些类的加载顺序,又是如此控制它们的加载的呢?
在这里插入图片描述

JVM类加载机制

JVM可以将字节码读取进内存,从而进行解析 运行等一系列动作,这个过程称为JVM类加载机制
JVM执行.class字节码过程分为七个阶段:

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

先看题目

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的岁数:" + Son.factor);  //入口
    }
}

请写出最后的输出字符串。

正确答案是:

爷爷在静态代码块
爸爸在静态代码块
爸爸的岁数:25

加载

官方描述

加载阶段是类加载过程的第一个阶段。在这个阶段,
JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,
接着会为这个类在 JVM 的方法区创建
一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口。

就是把代码数据加载到内存中

验证

当JVM加载完Class字节码文件并在方法去创建对应的Class对象后,
JVM便会启动对这个字节码流的校验
校验过程大致分为:

  • JVM规范校验
JVM会对字节流进行文件格式校验,判断是否符合JVM规范
能否被当前版本的JVM处理
例如:文件是否以 0x cafe bene 开头
  • 代码逻辑校验
JVM会对代码组成的数据流和控制流进行校验
确保JVM运行该字节码文件后不会出现致命的错误
例如:一个方法要求传入int类型的参数,但是使用它的时候却传入了一个String类型的参数

当代码数据被加载到内存中时,JVM会对代码数据进行校验,看看这份代码是不是真的按照JVM规范去写

准备(重点)

完成字节码文件的校验后,JVM便会开始为类变量分配内存初始化。
分为:

内存分配对象

Java变量有 [类变量] [类成员变量] 两种
[类变量] 用static修饰的变量,其他都为 [类成员变量]
在准备阶段,JVM只会为[类变量]分配内存,而不会为 [类成员变量] 分配内存
[类成员变量] 分配内存需要等到初始化阶段才开始

// 例如下面的代码在准备阶段,只会为 factor 属性分配内存,而不会为 website 属性分配内存
public static int factor = 3;
public String website = "www.cnblogs.com/chanshuyi";

初始化的类型

在准备阶段 JVM会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予Java语言中该数据类型的零值,而不是用户代码里的初始化的值

例如: sector的值将是0 而不是3

public static int sector = 3;

但是如果一个变量是常量(static final),那么准备阶段直接赋值

public static final int number = 3;

两个语句的区别是一个有 final 关键字修饰,另外一个没有。而 final 关键字在 Java 中代表不可改变的意思,意思就是说 number 的值一旦赋值就不会在改变了。既然一旦赋值就不会再改变,那么就必须一开始就给其赋予用户想要的值,因此被 final 修饰的类变量在准备阶段就会被赋予想要的值。而没有被 final 修饰的类变量,其可能在初始化阶段或者运行阶段发生变化,所以就没有必要在准备阶段对它赋予用户想要的值。

解析

当通过准备阶段之后,JVM针对

  • 接口、
  • 字段、
  • 类接口、
  • 接口方法、
  • 方法句柄 、
  • 调用点限定符号

这七类引用进行解析。这个阶段主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用

初始化(重点)

到了初始化,Java才开始真正的执行。
JVM会根据语句的执行顺序对类对象进行初始化
一般来说以下5种情况才会初始化

  • 遇见 new \ getstitic \ putstatic \ invokestatic 这四条字节码指令
如果类没有初始化,需要先初始化
生成这4条指令的最常见的Java代码场景是:
使用new关键字实例化对象的时候
读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候
调用一个类的静态方法
  • 使用java.lang.reflect包的方法对类进行反射调用的时候 ,如果类没有进行过初始化,需要先触发初始化
  • 初始化一个类的时候,其父类必须先初始化
  • 虚拟机启动时,用户需要指定一个执行的主类(main)
  • 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化

使用

当JVM完成初始化阶段之后,
从入口方法开始执行里面的程序

卸载

code执行完,JVM开始销毁创建的Class对象,最后负责运行JVM的程序也退出内存

JVM类加载案例

public class Book {
    public static void main(String[] args)
    {
        System.out.println("Hello ShuYi.");
    }

    Book()
    {
        System.out.println("书的构造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    {
        System.out.println("书的普通代码块");
    }

    int price = 110;

    static
    {
        System.out.println("书的静态代码块");
    }

    static int amount = 112;
}

执行结果

书的静态代码块
Hello ShuYi.

思路分析
首先根据上面说到的触发初始化第四种
当虚拟机启动的时候,用户需要指定一个main
那么我们的代买当中只有一个构造方法,但实际上Java代码编译成字节码后
是没有构造方法的概念的 只有 [类初始化方法] [对象初始化方法]

[类初始化方法]
编译器会按照其顺序 收集类变量的赋值语句、静态代码块、最终组成类初始化方法
类初始化方法一般在类初始化的时候执行

例子中的类初始化方法

    static
    {
        System.out.println("书的静态代码块");
    }
    static int amount = 112;

[对象初始化方法]
编译器会按照其出现顺序,收集成员变量的赋值语句、普通代码块,最后收集构造函数的代码,最终组成对象初始化方法
对象初始化方法一般在实例化类对象的时候执行

例子中对象初始化方法:

    {
        System.out.println("书的普通代码块");
    }
    int price = 110;
    System.out.println("书的构造方法");
    System.out.println("price=" + price +",amount=" + amount);

其实上面的这个例子其实没有执行对象初始化方法。

因为我们确实没有进行 Book 类对象的实例化。如果你在 main 方法中增加 new Book() 语句,你会发现对象的初始化方法执行了!

实战分析

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的岁数:" + Son.factor);  //入口
    }
}

最终的输出结果是:

爷爷在静态代码块
爸爸在静态代码块
爸爸的岁数:25

这是因为对于静态字段,只有直接定义这个字段的类才会被初始化(执行静态代码块)

  • 首先main Son初始化(需要父类初始化)
  • static输出
  • 所有父类都初始化之后,Son类才能调用静态变量

JVM垃圾回收机制

内存总是有限度的,需要一个机制来不断地回收废弃的内存,从而实现循环利用
JVM的内存结构有《Java虚拟机规范》规定,垃圾回收机制并没有具体的规范约束
所以不同的虚拟机有不同的回收机制
以HotSpot为例

判断谁是垃圾

生活中,一个东西经常没有被用,即可判定为垃圾。
Java中也是如此,一个对象没有被引用,就应该被回收。
根据这个思想,我们会想能否用引用数来判断:

引用计数法:
	对象被引用时加一,被去引用的时候减一

存在致命的问题
	循环引用
		A -----> B    B---------->C  C------------A
		但是他们三个从来没有被其他引用
	从垃圾的引用判断,他们三个确实是不被其他对象引用,
	但是他们的应用计数不为零,存在了循环引用的问题

GC Root Tracing算法

现在JVM普遍采用此方法
此方法基本思路:
通过一系列的’GC Roots’对象作为起点,从这些节点向下搜索,不可达即为垃圾
在这里插入图片描述
再Java中,可以作为GC Roots对象的有:

  • 虚拟机栈(栈帧中的本地变量表) 中引用的对象
  • 方法区中的类静态属性引用的对象;
  • 方法区常量引用的对象
  • 本地方法栈中JNI(一般说的Native方法) 中引用的对象

【证】:那些可作为GC Roots的对象

如何进行回收

  • 标记清楚法
  • 复制算法
  • 标记压缩算法

标记清除法

分为

  • 标记阶段
  • 清除阶段

标记所有GC对象引用的对象
清除未被标记的对象

问题:
空间破碎问题,
如果空间碎片太多,则会导致内存空间不连续
虽然说大对象也可以分配在碎片中 但是效率要很低

复制算法

将原有的内存空间分为两块
每次只是用一块
在垃圾回收的时候,正在使用的内存中的存活对象复制到未使用的内存块中。
之后清除正在使用的内存块中的所有对象
最后交换两个内存块的角色
缺点:将内存空间折半,极大浪费了内存空间

标记压缩算法

标记清除算法的优化版

  • 标记阶段
    从GC Root引用集合出发去标记所有引用的对象
  • 压缩阶段
    将所有存活的对象压缩在内存空间压缩在内存一边,之后清理边界外的所有对象

三种方法对比

标记清除算法虽然会产生内存碎片,但是不需要移动太多对象,适合应用在存货对象较多的情况
复制算法虽然需要将内存空间折半,并且需要移动存活对象,但是清理后不会产生碎片 适合存活对象比较少的
标记压缩算法,标记清除算法的优化版本,减少空间碎片。

分代思想

所谓分代算法,就是根据JVM内存的不同区域,采用不同的垃圾回收方法。
新生代里采用的垃圾回收算法,
新生代的特点是存活对象少,适合采用复制算法。而复制算法的一种最简单实现便是折半内存使用
实际上我们知道,在VJM新生代划分区域中,却不是采用等分为两块内存的形式。而是:Eden区域 from区域 to区域
为什么要分成三块而不是分成两块,IBM表明:新生代98%都是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间
所以在HotSpot虚拟机中,JVM将内存划分为一块较大的Eden空间和两块较小的Survivor空间,大小占比8:1:1.
回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,清理掉Eden刚才用过的空间

这种方式将均分50%的利用率提升到了90%

分区思想

将整个堆空间划分成连续不同的小区域
每一个小区域都单独使用,独立回收
优点:

	可以控制一次回收多少个区间,可以较好地控制GC时间。
	

在这里插入图片描述

发布了154 篇原创文章 · 获赞 605 · 访问量 23万+

猜你喜欢

转载自blog.csdn.net/weixin_39381833/article/details/102171002