Java内存模型和类加载过程

参考文章:

http://gityuan.com/2016/01/09/java-memory/ (推荐牛人的博客)

https://www.guru99.com/java-stack-heap.html

https://www.zhihu.com/question/21539353

https://www.cnblogs.com/lewis0077/p/5143268.html

深入Java虚拟机

Java虚拟机在执行java程序的过程中会把其所管理的内存区域划分为若干个不同的数据区域。 详情如下图所示:

程序计数器

程序计数器是一块较小的空间,可以看成当前线程所执行的字节码的行号指示器,在虚拟机的概念模型中,字节码解释器工作通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能需要依赖这个计数器完成。

由于虚拟机的多线程是通过线程轮流切换分配处理器执行时间的方式来实现的,所以任何一个时刻,一个处理器都会执行一条线程中的指令。因此,为了切换线程能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各个线程之间互不影响,独立存储,是一个线程私有的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行Natvie方法,这个计数器值则为空。

这个内存区域是唯一一个在Java虚拟机规范中没有规定任何Out Of Memory Erorr情况的区域。

Java栈

Java栈,即Java虚拟机栈是线程私有的,他的生命周期和线程相同。Java栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.

局部变量表:存放了编译器可知的各种基本数据类型,对象引用类型(reference类型,不同于对象本身,他可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象句柄或者其他于此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

局部变量表的所需内存空间在编译期间完成分配,当进入一个方法时候,这个方法所需要分配的局部变量空间是完全确定的,方法运行时不会改变局部变量表的大小。

Java栈是为虚拟机执行Java方法(即字节码)服务。

本地方法栈

本地方法栈执行的是Native方法服务

Java堆

Java堆是被所有线程共享的一块内存区域。 堆的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 Java堆也是垃圾收集器管理的主要区域,即"GC堆"。

Java堆可以出于物理上不连续的内存空间中,逻辑上连续即可。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。

存储内容:类中的静态常量、类中定义为final类型的常量、类中的Field信息、类中的方法信息

运行时常量池 是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于Class常量池来说,更具有动态性,Java语言不要求常量一定只有在编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入常量池,如String.intern()方法。

关于各个部分的联系,可以查看下图(虽然这张图讲的是GC Root的,但是我觉得对于理解JMM挺有用的):

图片来源,侵删


类加载的过程

这里首先总结一下对象的创建过程:

  1. 虚拟机在遇到一条new指令的时候,首先会去检查这个指令的参数是否能在常量池(运行时常量池)中定位到一个类的符号引用,并去检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,则执行类加载过程。
  2. 通过类加载检查后,虚拟机将为新生的对象分配内存。对象所需的内存大小在类加载完成后便可以完全确定。然后虚拟机对对象进行一些必要的设置(如对象是哪个类的实例,对象的哈希码,对象的GC分代年龄等等)
  3. 上述工作完成之后,从虚拟机视角来看,一个新的对象已经产生,但从Java程序视角来看的话,对象创建刚刚开始,<init>方法还没有执行,所有的字段都为零。所以一般而言,执行完new指令过后,会接着执行<init>方法,把对象按照我们的意愿进行初始化,这样才算一个真正可用的对象完全产生出来。
类加载过程

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这一个过程为类加载机制。

类从被加载到虚拟机内存开始,到卸载出内存为止,他的整个生命周期包括如下阶段:

加载,验证,准备,初始化和卸载阶段的顺序是确定的,而解析阶段则不一定了,他在某些情况下可在初始化完成后在开始,这是为了支持Java语言的运行时绑定

关于什么时候开始加载的过程,Java虚拟机规范中没有进行强制约束,可由虚拟机自由把握。但是对于初始化阶段,虚拟机严格规定了有且只有5种情况必须立即对类进行"初始化"(加载,验证,准备自然需要在此之前开始)。

  • 遇到new,getstatic,putstatic,invokestatic四条字节码指令时候,如果类没有初始化,则先触发初始化操作,生成该四条指令的最常见情况是:
  1. 使用new关键字实例化对象。
  2. 读取一个类的静态字段时候。
  3. 获取一个类的静态字段时候。
  4. 调用一个类的静态方法的时候。
  • 使用反射调用时候,如果类没有初始化,则先触发初始化操作。
  • 初始化一个类时候,如果发现他的父类还没初始化,则先触发父类的初始化。
  • 虚拟机启动时候,用户需要指定一个要执行的主类(main()函数那个类),触发初始化。
  • 当时用动态语言支持时候,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokestatic的方法的句柄,并且这个方法句柄对应的类没有初始化,则触发初始化。

加载

加载阶段,虚拟机需要完成三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将字节流所代表的静态存储结构转换为方法区的运行时结构数据。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

在加载阶段可以使用系统提供的引导类加载器来完成,也可以由用户自定的类加载器完成(如Android的插件化技术)。

详细可参考IBM这篇文章

引导类加载器(bootstrap class loader):它用来加载 Java 的核心库,是用原生代码来实现的,并不继承自 java.lang.ClassLoader。

扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区里,方法区中的数据存储格式由虚拟机自行定义。然后在内存中实例化一个java.lang.Class对象,这个对象作为程序访问方法区中这些类型数据的外部接口。

加载阶段和连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始了,但这些夹在加载阶段之中进行的动作,仍属于连接阶段的内容。

验证

验证是连接的第一步,目的是为了确保Class文件字节流中包含的信息符合虚拟机要求。

验证阶段包含4个阶段的检验动作:

  • 文件格式验证:保证输入的字节流能够正确解析并且存储在方法区之内,格式上符号描述一个Java类型信息的要求。这一阶段基于二进制字节流进行,验证通过后,会将字节流存入内存中的方法区中,后面的验证基于方法区的存储结构验证,不会再直接操作字节流。

(如验证是否以魔数开头,主次版本号是否在虚拟机处理范围之内等等)

  • 元数据验证:这一阶段主要对字节码的描述信息进行语义分析,保证其描述的信息符合Java语言规范的要求。

如这个类是否有父类?

这个类的父类是否继承了不被允许继承的类(final关键字)

...

  • 字节码验证:该阶段主要目的是通过数据流和控制流分析,确定语义是否合法,符合逻辑。 第二阶段对元数据验证完毕后,这个阶段对类的方法体进行校验分析。
  • 符号引用验证:这个阶段发生在虚拟机将符号引用转化成直接引用的时候,**这个转化发生在连接的第三阶段,解析阶段发生。**当前阶段的验证可以看做对类自身以外的信息进行匹配性校验,通常会校验如下内容:

符号引用中通过字符串描述的全限定名是否能找到对应的类。

再指定类中是否存在符合方法字段描述符以及简单名称所描述的方法和字段。

符号引用中的类,字段,方法的访问性(private,protected,public,default)是否可以被当前类访问.。

准备

准备阶段是正式将类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

需要注意的是,这个时候进行内存分配得仅仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量会在对象实例化时候随着对象一起分配在Java堆中。其次,上述所说的设置类变量初始值是指的0值,假设类变量定义如下:

public static final num=3;

则num在准备阶段初始化的值为0而不是3,因为这个时候尚未开始执行任何Java方法,而把num指定为3的putstatic指令是在程序被编译后,存放于类构造器<clinit>()方法中的,所以num=3的动作在初始化阶段才会执行。

But,如果上述代码加了一个final字段,那就不一样了:

public static final final num=3;

这时候Javac将会为num生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue的设置将num赋值为3。

public class Test {
    private static int num=3;
    private static final int finalnum=3;

    public static void main(String[] args) {

    }
}
//使用javap反编译查看Test.class,省略无关部分
//javap -v -p Test.class
 private static int num;
   descriptor: I
   flags: ACC_PRIVATE, ACC_STATIC

 private static final int finalnum;
   descriptor: I
   flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
   ConstantValue: int 3

这里顺便记录一下<init>和<clinit>的区别,可以看这个

  • <init> is the (or one of the) constructor(s) for the instance, and non-static field initialization.

  • <clinit> are the static initialization blocks for the class, and static field initialization.


class X {

   static Log log = LogFactory.getLog(); // <clinit>

   private int x = 1;   // <init>

   X(){
      // <init>
   }

   static {
      // <clinit>
   }

}

解析

解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程,在上面的符号引用验证中提到过。

  • 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时候能够无歧义的定位到目标即可。
  • 直接引用:直接引用是直接指向目标的指针,相对偏移量或者一个间接定位到目标的句柄。

符号引用替换成直接引用的过程有如下几种情况(详细内容见深入理解Java虚拟机P223):

  • 类或者接口的解析
  • 字段解析
  • 类方法解析
  • 接口方法解析

上述阶段通过了,则说明解析的工作就完成了。

初始化

初始化工作是类加载过程的最后一步,前面介绍的类加载过程中,除了在加载阶段用户可以通过自定义ClassLoader参与外,其余的皆有虚拟机主导完成。到初始化阶段,才真正执行类定义中的Java代码。

init是对象构造器方法,也就是说在程序执行 new 一个对象调用该对象类的 constructor 方法时才会执行init方法

在准备阶段,系统已经默认给类变量(被static修饰的变量)赋值过一次初始值(由系统决定)了,初始化阶段就是执行程序猿赋值的过程。或者说初始化阶段是执行<clinit>方法的过程。

clinit方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。 编译器收集的顺序是由语句在源文件中出现的顺序决定静态语句块只能访问到定义在静态语句块之前>的变量,定义在他之后的变量,只能赋值,不能访问[1]

clinit方法不需要显示的调用父类构造器,虚拟机会保证在子类的clinit方法执行之前,父类的>clinit已经执行完毕。第一个执行clinit方法的肯定是java.lang.Object。

由于父类的clinit方法先执行,也就意味着父类中定义的静态语句块优于子类变量赋值操作。[2]

clinit方法对类或者接口来说不是必须的,如果一个类没有静态语句块,也没有对static变量赋值的操作,那么编译器可以不为这个类生成clinit方法。

接口中也会有clinit方法,唯一与类中执行不同的是,执行接口clinit方法不需要先执行父接口的>clinit方法,只有当父接口中的变量使用时候,父接口才会初始化。

虚拟机保证一个类的clinit方法只会执行一次。

[1]的验证:

 static {
        num=2323;//可以赋值
        System.out.print(num+"");//IDE会提示不能访问
    }
 private static int num=3;

//----------------------------------------下面情况通过
 private static int num=3;
 static {
        num=2323;
        System.out.print(num+"");
    }
  

[2]的验证:


    public static void main(String[] args) {
        Child child = new Child();
        System.out.print(child.K+"");
    }

    static class Parent{
        public static int lll=23;
        static {
            lll=666;
        }
    }
    static class Child extends Parent{
        public static int K=lll;
    }
	//输出666

刚好整理一下static在代码中的执行顺序

如果类还没有被加载:

  1. 先执行父类的静态代码块和静态变量初始化,并且静态代码块和静态变量的执行顺序只跟代码中出现的顺序有关。

  2. 执行子类的静态代码块和静态变量初始化。

  3. 执行父类的实例变量初始化

  4. 执行父类的构造函数

  5. 执行子类的实例变量初始化

  6. 执行子类的构造函数

如果类已经被加载:

  • 则静态代码块和静态变量就不用重复执行,再创建类对象时,只执行与实例相关的变量初始化和构造方法。

.class文件是二进制字节流形式

随着虚拟机的不断发展,很多程序语言开始选择与操作系统和机器指令集无关的格式作为编译后的存储格式(Class文件),从而实现”Write Once, Run Anywhere”。 Java设计之初,考虑后期能让Java虚拟机运行其他语言,目前有越来越多的其他语言都可以直接需要在Java虚拟机,虚拟机只能识别Class文件,至于是由何种语言编译而来的,虚拟机并不关心。

类卸载的过程及触发条件

在类使用完之后,满足下面的情形,会被卸载:

  1. 该类在堆中的所有实例都已被回收,即在堆中不存在该类的实例对象。

  2. 加载该类的classLoader已经被回收。

  3. 该类对应的Class对象没有任何地方可以被引用,通过反射访问不到该Class对象。

如果类满足卸载条件,JVM就在GC的时候,对类进行卸载,即在方法区清除类的信息。

转载于:https://my.oschina.net/u/3863980/blog/1839508

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值