别再搞混JVM内存结构和Java内存模型(一)

概述:一直以为JVM内存结构Java内存模型是说的一个东西,只是不同的叫法,直到今天看了下别人的博客才发现其实并不是这样。
大家平时说的那些堆栈之类的叫Java内存分区(或者叫区域)或者叫JVM的内存结构的分区或者叫运行时数据区(JVM的内存结构还不止这些),而Java内存模型是一种规范,是抽象的概念,目的是解决由于多线程并发编程通过内存共享进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题,即保证内存共享的正确性(可见性、有序性、原子性)。

1.JVM内存结构

整个JVM的构成
在这里插入图片描述

1.1运行时数据区域:

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
在这里插入图片描述
接下来逐一介绍运行时数据区的各个空间

1.程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,他是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换,分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址:如果正在执行的是本地方法(Native),这个计数器值则应为空。此内存区域是唯一一个没有规定OutOfMemoryError情况的区域。

2.Java虚拟机栈
它也是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表,操作数栈,动态连接,方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,它不等同与对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址).
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小,这里说的大小是指变量槽的数量,虚拟机真正使用多大的内存空间(比如一个变量槽占用32个bit,64个bit,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
在这里插入图片描述
3.本地方法栈
本地方法栈与虚拟机栈所发挥的作用相似,区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法(Native)服务

4.Java堆
Java堆是虚拟机所管理的内存中最大的一块。它是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放实例。Java几乎所有的对象实例都在这里分配内存。堆是垃圾收集器管理的内存区域。(GC主要回收的是堆;极少出现在方法区里,主要是对常量池的回收和类型的卸载,回收的内存比较少)
所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。无论如何划分都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

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

6.运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。(Class文件的对象就是反射的原理 反射原理
运行时常量池,相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被利用的比较多的是String类的intern()方法。
当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

7.直接内存
直接内存并不是虚拟机运行时数据区的一部分,但这部分内存也经常被使用,可能导致OutOfMemoryError异常。
JDK1.4中新加入了NIO(New input/Output)类,引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据
本机直接内存的分配不会受到Java堆大小的限制,但是既然是内存,则肯定还是会受到本机总内存(包括物理内存,SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

1.2类加载

前言:代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,确实编程语言发展的一大步。
java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。

1.2.1类加载的过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载,验证,准备,解析,初始化,使用和卸载七个阶段,其中验证,准备,解析三个阶段部分统称为连接。
在这里插入图片描述
加载,连接,初始化的过程都是在程序运行期间完成的,这种策略让Java语言提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性,java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

加载,验证,准备,初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班的开始,但是解析阶段不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定),这里说的按部就班的开始只是说开始的时间是有顺序的,但是并不是按部就班的进行或者说按部就班的完成,这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用,激活另一个阶段

2.六种情况必须对类进行初始化
什么情况下需要开始类加载过程的第一个阶段"加载",JVM并没有强制规定,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,严格规定了有且只有六种情况必须立即对类进行"初始化"(而加载,验证,准备自然需要在此之前开始):

(1):遇到new,getstatic,putstatic,或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段.能够生产这四条指令的典型Java代码场景有:
使用new关键字实例化对象的时候
读取或设置一个类型的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外,子类调用父类的静态字段只会初始化父类)的时候
调用一个类型的静态方法的时候

(2)调用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化

(3)当初始化类的时候,如果发现其父类还未进行初始化,则需要先触发其父类的初始化

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

(5)当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

(6)当一个接口定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化(注意:一个接口在初始化时,并不要求其父类接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

记住这是有且只有,这六种场景中的行为称为对一个类型进行主动引用,除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。例子:

public class SuperClasss{
    static{
    sout(父类);
    //上一句是简写
    }
    public static int value=123;
}
public class SubClass extends SuperClass{
       static{
           sout(子类);
       }
}
public class NotInit{
          public static void main(String[] args){
          sout(SubClass.value);
          }
}
1.子类是可以使用父类的静态方法和静态变量的
2.子类并没有继承父类的静态方法和静态变量,只是当做全局变量可以去使用

上述代码运行后,只会输出父类,对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,取决于JVM的具体实现。

3.加载
加载阶段,java虚拟机需要完成下面三件事:
(1)通过一个类的全限定名来获取定义此类的二进制流
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(Class对象是存放在堆区的,不是方法区,这点很多人容易犯错。类的元数据(元数据并不是类的Class对象。Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。)

加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方法(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

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

4.验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合<java虚拟机规范>的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机的自身的安全。
大致四个检验动作:
(1)文件格式验证(若不符合则会抛出异常 VerifyError异常)
(2)元数据验证
(3)字节码验证
(4)符号引用验证

5.准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

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

7.初始化
根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源

1.2.2类加载器

通过一个类的全限定名来获取描述该类的二进制流这个动作在JAVA虚拟机外部实现的,实现这个动作的代码被称为类加载器

类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段.比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里说的相等,包括代表类的Class对象的equals()方法,isAssignableFrom()方法,isInstance()方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况.

双亲委派模型
从Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分,另一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全部都继承抽象类java.lang.ClassLoader.
从java开发人员角度来看,类加载器就该划分的更细致了,自JDK1.2以来,Java一直保持着三层类加载器,双亲委派的类加载机制,这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构.
针对JDK8及以前介绍什么是三层类加载器
(1)启动类加载器(Bootstrap ClassLoader)加载<java_home>\jre\lib目录,不能直接被java程序引用,加载核心库java.*由C++编写
(2)扩展类加载器(Extension Class Loader):这个类加载器是java代码编写的,它负责加载jre/lib/ext目录的类,加载扩展库javax.

(3)应用程序类加载器(Application Class Loader):由java编写,加载程序所在的目录

如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离,重载等功能.
在这里插入图片描述
此图展示了各种类加载器之间的层次关系(这些层次关系就叫"双亲委派模型"),双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器.不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码

双亲委派模型的工作流程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载

好处:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系.例如类java.lang.Object,他存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类,反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

执行引擎和Java内存模型在下一篇分析

猜你喜欢

转载自blog.csdn.net/weixin_45593271/article/details/107250637
今日推荐