关于JVM的一些理解

简介目录:

  1. Java与JVM
  2. JVM内存划分
  3. 类加载过程
  4. 类初始化(时机与顺序)

Java与JVM

我们都知道Java源文件可以通过编译器编译成字节码文件,然后放到虚拟机上执行。但Java虚拟机其实有很多种,比如我们最常见的Hotspot,以及TaoJVM,OpenJdk。
那为什么我们的编译出的字节码文件,既可以在Hotspot运行,也能在TaoJVM上运行呢?这是因为我们有一个「Java虚拟机规范」,这个规范只会要求你要实现哪些功能,但不会去规定你怎样实现功能(这一点和我们的「接口」非常相似)。同样,我们的Java源代码,其实也就是遵循了「Java语言规范」的文件。
而我们的编译器,就需要同时考虑这两种规范,从而变成源代码和虚拟机之间沟通的桥梁。

JVM内存划分

下图是基于jdk1.8画的JVM运行时数据区

下面来各个块功能及作用的简单介绍:

方法区

方法区用来存储加载的类信息,常量,静态变量,JIT编译后的代码等数据。可以归纳为以下两个方面:

  • 类信息
  1. 类的全限定类名
  2. 每个类父类的全限定类名
  3. 该类的类型(接口或类)
  4. 该类的访问修饰符
  5. 直接父接口的全限定名的有序列表
  • 已装载的类的详细信息
  1. 字段信息:存放类中声明的每一个字段的基本信息,包括字段名称,类型,修饰符
  2. 方法信息:存放类中声明的每一个方法的基本信息,包括方法名,返回值类型,参数类型,修饰符,异常,方法的字节码
  3. 静态变量
  4. 到类和类加载器的引用

堆内存

存放对象的实例,几乎所有的对象和数组都在这里存放。那么对象的实例,到底是放了些什么东西呢?可以来看下图:
关于JVM的一些理解_第1张图片
其中运行时数据包括:哈希码,GC分代年龄,锁状态等。类型指针就是指明对象的类型,确定这是哪个类的实例。

常量池

关于常量池,运行时常量池和字符串常量池的具体描述,可以参考下面两篇博客:
常量池,运行时常量池和字符串常量池1
常量池,运行时常量池和字符串常量池2
关于字符串常量池,可以看我的这一篇,通俗易懂,代码极少:
关于字符串常量池的一些理解

虚拟机栈

虚拟机栈是线程私有的,每一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

关于虚拟机栈的具体内容,可以参考:「Java虚拟机」栈帧、局部变量表、操作数栈

本地方法栈

本地方法栈和虚拟机栈的作用是相同的,只不过虚拟机栈执行的是java方法,本地方法栈执行的是Native方法。
java方法就是开发人员写的java代码,Native方法就是一个java调用非java代码的接口。

程序计数器

程序计数器中存放的是当前线程所执行的字节码的行号。jvm工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

类加载过程

类加载就是把二进制文件加载到内存中的一个过程。
具体的过程如下:

  • 加载
    虚拟机需要完成的事情:
    (1) 通过一个类的全限定名来获取定义此类的二进制字节流。
    (2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    (3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(这里的Class对象也是在堆中被创建的,而不是方法区)
  • 连接
    • 验证
      验证的目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机自身的安全。
      其分为4个步骤:文件格式验证,元数据验证,字节码验证,符号引用验证。其中文件格式验证是直接对字节流进行操作的,其余3项是在方法区中进行的。
    • 准备
      此阶段是正式为类变量分配内存并设置类变量初始值的阶段。其是在方法区中进行分配的。有两个注意点:
      (1)此时只是对类变量(被static修饰的变量)进行内存分配,而不是对象变量。给对象分配内存是在对象实例化时,随着对象一起分配到java堆中。
      (2)如果一个类变量没有被final修饰,则其初始值是数据类型的零值。比如int类型的是0,boolean类型的是false。
    • 解析
      解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。可对类或接口、字段、类方法、接口方法等进行解析。
      符号引用:就是包含类的信息,方法名,方法参数等信息的字符串,它供实际使用时在该类的方法表中找到对应的方法。
      直接引用:就是偏移量,通过偏移量可以直接在该类的内存区域中找到方法字节码的起始位置。
      符号引用是告诉你此方法的一些特征,你需要通过这些特征去寻找对应的方法。直接引用就是直接告诉你此方法在哪。
      也就是说,在初始化之前,已经对该类的类变量分配内存并赋默认值,对类常量分配内存并赋值。
      (上面这个属于个人理解,如有不对,劳请指正,感激不尽!)
  • 初始化
    此阶段用于初始化类变量和其它资源,是执行类构造器< clinit >()方法的过程,此时才是真正开始执行类中定义的java程序代码。具体的初始化在下一节中讨论:

类初始化

我们从类初始化的时机和顺序这两个方面来讨论类的初始化

类初始化的时机

JVM虚拟机规范中,严格规定了有且只有五种情况,类会进行初始化操作

  • 使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。

  • 通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。

  • 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。

  • 当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类。

  • 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

可以大概总结为:用到一个类(或其子类)的静态变量,静态方法和构造方法时,被反射调用时,是执行入口时等。
我们还要特别注意,在以下这几种情况中,类是不会被初始化的:

  • 调用类常量
    调用某个类的静态常量并不会导致该类的初始化——因为常量在编译时就已经加载到常量池中了。
  • 子类调用父类的静态属性
    子类如果去调用或者设置父类的静态属性,仍是是初始化父类,而子类本身并不会被初始化。
    如果调用类的静态变量,那也只会初始化静态属性和静态块。如:
class Father {
	public static int a = 1;
	static {System.out.println("父类静态代码块");}
	{System.out.println("父类普通代码块");}
	public Father() {System.out.println("父类构造方法");}
}
public class Test {
	public static void main(String[] args) {
		System.out.println(Son.a);
		/*输出结果为:
			父类静态代码块
			1
		*/
		//如果改成System.out.println(Father.a);结果也是一样的
	}
}
  • 通过数组定义类引用
    比如如下代码:
public class E {
    public static void main(String[] args) {
        System.out.println("ha");
        F[] fs = new F[2];
        System.out.println("hei");
    }
}
class F {
    static {
        System.out.println("静态块");
    }

    public F() {
        System.out.println("F构造方法");
    }
}

执行结果如下图,可以看到,F并没有被初始化
关于JVM的一些理解_第2张图片

类初始化的顺序

顺序如下图:在括号中的代表其执行先后顺序是依据其在程序中位置的先后。

我们还可以看一个比较特殊的情况:当一个类去新建一个自身实例,并赋值给该类的静态变量,那么加载过程是怎么样的?比如:

public static A a = new A();

具体的题目可以参考这篇博客末尾的题目,写得很不错:https://www.cnblogs.com/wxw7blog/p/7349180.html

你可能感兴趣的:(Java基础)