JVM详解 --- JVM内存模型与对象创建过程

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

注意:JVM内存模型/结构/区域是Java虚拟机规范,不同的虚拟机实现会各有不同,但是一般会遵守规范。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。区分Java内存模型

ps:Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。

总结:

  • JVM内存结构:JVM规范,和Java虚拟机的运行时区域有关,不同区域具有不同的功能。
  • Java内存模型:一种抽象模型,和Java的并发编程有关(多线程通信),主要涉及多线程通过共享内存进行通信有关的线程安全问题(原子性、可见性与有序性)。
  • Java对象模型:和Java对象在虚拟机中的表现形式有关,Java对象在JVM中的存储也是有一定的结构的。描述的是Java对象自身的存储模型。

1.JVM内存区域/结构

写在前:JDK包括JRE和编译器,JRE包括JVM和核心类库。

JVM可以分为3大部分:Class loader(类加载子系统)、Execution engine(执行引擎)和Runtime data area(运行时数据区)。

  • Class loader(类装载子系统):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
  • Execution engine(执行引擎):执行字节码或者本地方法,主要的执行技术有: 解释,即时编译,自适应优化,芯片级直接执行其中解释属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式 。
  • Runtime data area(运行时数据区):JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域(即JVM的内存),这些数据区域可以分为两个部分:一部分是线程共享的,一部分则是线程私有的。其中,线程共享的数据区包括方法区和堆,线程私有的数据区包括虚拟机栈、本地方法栈和程序计数器。

Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。

ps:自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

如下图所示:红色标出jvm

JVM组成

大致流程:

  • 编译器将Java代码转化为字节码
  • 类加载器(ClassLoader)加载字节码文件并解析,将解析的类型信息放在运行时数据区(Runtime data area)的方法区内,
  • 但是字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行
  • 这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

2.Java程序执行流程

java源文件到最后运行起来,主要会经过两个阶段:编译和解释。其中,第二阶段在java虚拟机上执行的:

  • 编写Java源代码(后缀.java),编辑器编译(javac命令)生成字节码文件(后缀.class)
  • 类加载子系统将字节码文件加载到JVM内存空间,由JVM(解释器,即java命令)运行字节码文件(执行引擎找到入口方法main(),执行其中的字节码指令)
java程序执行流程

解释类加载:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。类加载的最终结果是在堆中创建class对象。

3.JVM运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分,其中线程私有:程序计数器、虚拟机栈、本地方法栈;线程共享:堆、方法区(JDK1.8之后数据被分在堆和元空间中(直接内存,不由jvm管理))。

运行时数据区

(1)程序计数器(Program Counter Register)

在多线程情况下,当线程数超过CPU数量或CPU内核数量时,线程之间就要根据 时间片轮询抢夺CPU时间资源。也就是说,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为保证线程切换后能够恢复到正确的执行位置(另一个作用是字节码解释器通过改变程序计数器,从而实现流程控制),每条线程都需要一个独立的记录正在执行的虚拟机字节码指令的地址,即当前线程执行字节码的行号指示器(需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址)。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

(2) Java 虚拟机栈(Java Virtual Machine Stacks)

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用、方法出口等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

java虚拟机栈

可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:

java -Xss2M HackTheJava

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError异常。

拓展:局部变量表中的基本类型和对象的引用类型是存储在局部变量槽(Slot),64位的long和double类型会占两个槽,其余占用一个,局部变量表所需的空间在编译期间完成,方法运行期间无法改变大小。

(3)本地方法栈(Native Method Stack)

本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

本地方法栈与 Java 虚拟机栈类似,区别在于:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。它们之间的只不过是本地方法栈为本地方法服务(保存native方法进入区域的地址,不受JVM限制)。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

ps:为了保证线程中的局部变量不被别的线程访问,虚拟机栈和本地方法栈都是线程私有的。

(4)Java 堆(Java Heap)

Java 虚拟机所管理的内存中最大的一块,线程共享,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)

现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法,目的是更好的回收内存。可以将堆分成两块:

  • 新生代(Young Generation)
  • 老年代(Old Generation)

拓展:所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区TLAB,提升对象分配时的效率。

堆不需要连续内存,并且可以动态增加其内存,增加失败最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M HackTheJava

(5)方法区(Methed Area)

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

注意:方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。

拓展:运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种 字面量 和 符号引用。其中,字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等;而符号引用则属于编译原理方面的概念,包括以下三类常量:类和接口的全限定名、字段的名称和描述符 和 方法的名称和描述符。因为运行时常量池(Runtime Constant Pool)是方法区的一部分,那么当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。

Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。

拓展:直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

4.Java对象在虚拟机中的创建与访问定位

一个Java对象的创建过程往往包括 类初始化 和 类实例化 两个阶段。

写在前

我们知道,一个对象在可以被使用之前必须要被正确地实例化。在Java代码中,有很多行为可以引起对象的创建,最为直观的一种就是使用new关键字来调用一个类的构造函数显式地创建对象,这种方式在Java规范中被称为 : 由执行类实例创建表达式而引起的对象创建。除此之外,我们还可以使用反射机制(Class类的newInstance方法、使用Constructor类的newInstance方法)、使用Clone方法、使用反序列化等方式创建对象。

  • 使用new关键字创建对象

这是我们最常见的也是最简单的创建对象的方式,通过这种方式我们可以调用任意的构造函数(无参的和有参的)去创建对象。

  • 使用Class类的newInstance方法(反射机制)

我们也可以通过Java的反射机制使用Class类的newInstance方法来创建对象,事实上,这个newInstance方法调用无参的构造器创建对象,如:

Student student2 = (Student)Class.forName("Student类全限定名").newInstance(); 
# 或者:
Student stu = Student.class.newInstance();
  • 使用Constructor类的newInstance方法(反射机制)

java.lang.relect.Constructor类里也有一个newInstance方法可以创建对象,该方法和Class类中的newInstance方法很像,但是相比之下,Constructor类的newInstance方法更加强大些,我们可以通过这个newInstance方法调用有参数的和私有的构造函数,比如:

public class Student {

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    public static void main(String[] args) throws Exception {

        Constructor constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);
    }
}

使用newInstance方法的这两种方式创建对象使用的就是Java的反射机制,事实上Class的newInstance方法内部调用的也是Constructor的newInstance方法。

  • 无论何时我们调用一个对象的clone方法,JVM都会帮我们创建一个新的、一样的对象,特别需要说明的是,用clone方法创建对象的过程中并不会调用任何构造函数。简单而言,要想使用clone方法,我们就必须先实现Cloneable接口并实现其定义的clone方法,这也是原型模式的应用。比如:
public class Student implements Cloneable{

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        // TODO Auto-generated method stub
        return super.clone();
    }

    public static void main(String[] args) throws Exception {

        Constructor constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);
        Student stu4 = (Student) stu3.clone();
    }
}
  • 使用(反)序列化机制创建对象

当我们反序列化一个对象时,JVM会给我们创建一个单独的对象,在此过程中,JVM并不会调用任何构造函数。为了反序列化一个对象,我们需要让我们的类实现Serializable接口,比如:

public class Student implements Cloneable, Serializable {

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "Student [id=" + id + "]";
    }

    public static void main(String[] args) throws Exception {

        Constructor constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);

        // 写对象(输入输出流主要针对的内存来说的)
        ObjectOutputStream output = new ObjectOutputStream(
                new FileOutputStream("student.bin"));
        output.writeObject(stu3);
        // 注意:用完要关闭
        output.close();

        // 读对象
        ObjectInputStream input = new ObjectInputStream(new FileInputStream(
                "student.bin"));
        Student stu5 = (Student) input.readObject();
        System.out.println(stu5);
    }
}

完整示例:

public class Student implements Cloneable, Serializable {

    private int id;

    public Student() {

    }

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        // TODO Auto-generated method stub
        return super.clone();
    }

    @Override
    public String toString() {
        return "Student [id=" + id + "]";
    }

    public static void main(String[] args) throws Exception {

        System.out.println("使用new关键字创建对象:");
        Student stu1 = new Student(123);
        System.out.println(stu1);
        System.out.println("\n---------------------------\n");


        System.out.println("使用Class类的newInstance方法创建对象:");
        Student stu2 = Student.class.newInstance();    //对应类必须具有无参构造方法,且只有这一种创建方式
        System.out.println(stu2);
        System.out.println("\n---------------------------\n");

        System.out.println("使用Constructor类的newInstance方法创建对象:");
        Constructor constructor = Student.class
                .getConstructor(Integer.class);   // 调用有参构造方法
        Student stu3 = constructor.newInstance(123);   
        System.out.println(stu3);
        System.out.println("\n---------------------------\n");

        System.out.println("使用Clone方法创建对象:");
        Student stu4 = (Student) stu3.clone();
        System.out.println(stu4);
        System.out.println("\n---------------------------\n");

        System.out.println("使用(反)序列化机制创建对象:");
        // 写对象
        ObjectOutputStream output = new ObjectOutputStream(
                new FileOutputStream("student.bin"));
        output.writeObject(stu4);
        output.close();

        // 读取对象
        ObjectInputStream input = new ObjectInputStream(new FileInputStream(
                "student.bin"));
        Student stu5 = (Student) input.readObject();
        System.out.println(stu5);

    }
}
/* Output: 
        使用new关键字创建对象:
        Student [id=123]
        ---------------------------

        使用Class类的newInstance方法创建对象:
        Student [id=0]
        ---------------------------

        使用Constructor类的newInstance方法创建对象:
        Student [id=123]
        ---------------------------

        使用Clone方法创建对象:
        Student [id=123]
        ---------------------------

        使用(反)序列化机制创建对象:
        Student [id=123]
*///:~

从Java虚拟机层面看,除了使用new关键字创建对象的方式外,其他方式全部都是通过转变为invokevirtual指令直接创建对象的。

(1)Java对象的创建

当一个对象被创建时,虚拟机就会为其分配内存来存放对象自己的实例变量及其从父类继承过来的实例变量(即使这些从超类继承过来的实例变量有可能被隐藏也会被分配空间)。在为这些实例变量分配内存的同时,这些实例变量也会被赋予默认值(零值)。在内存分配完成之后,Java虚拟机就会开始对新创建的对象按照程序猿的意志进行初始化。在Java对象初始化过程中,主要涉及三种执行对象初始化的结构,分别是 实例变量初始化、实例代码块初始化 以及 构造函数初始化。

对象创建的主要流程

解释基本流程:

  • 虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。
  • 类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配。
    ps:划分内存时还需要考虑一个问题-并发,解决方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。
  • 然后内存空间初始化操作(初始0值)
  • 接着是做一些必要的对象设置(元信息、哈希码…)
  • 最后执行方法。

Step1:类加载检查

虚拟机遇到new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个引用代表的类是否已经被加载、解析和初始化过。

Step2:内存分配

在类加载检查通过后,对象所需内存的大小在类加载完成后便可完全确定,虚拟机就会为新生对象分配内存。一般来说,根据Java堆中内存是否绝对规整,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。内存的分配有两种方式:

  • 指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
  • 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定!

java对象的内存分配方式

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证内存分配的线程安全:

  • 对分配内存空间的动作进行同步处理采用CAS+失败重试的方式保证更新操作的原子性;
  • 本地线程分配缓冲区:把内存分配的动作按照线程划分在不同的空间之中进行。每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在自己的TLAB上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。比如 int 的初始化0值就是0,而一个对象的初始化0值就是 null 。

ps:采用TLAB:在TLAB分配时顺便初始化

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置(这些信息存放在对象头中)

  • 这个对象是哪个类的实例;
  • 如何才能找到类的元数据信息;
  • 对象的哈希码;
  • 对象的 GC 分代年龄等信息。

另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化(调用对象的构造方法进行初始化),这样一个真正可用的对象才算完全产生出来。

(2)对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

对象的内存布局
  • 对象头:对象头分为Mark WordClass Metadata Addresss两个部分, Mark Word存储对象的hashCode、锁信息或者分代年龄GC等标志等信息。Class Metadata Addresss存放指向类元数据的指针,JVM通过这个指针确定该对象是哪个类的实例。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

Java对象内存布局中最重要的一块应该就是对象头中的Mark Word部分了,他涉及到了hash值、锁状态、分代年龄等许多非常重要的内容下表是 HotSpot 虚拟机对象头的 Mark Word:

(3)对象访问定位

创建对象是为了使用对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。在虚拟机规范中,reference类型中只规定了一个指向对象的引用,并没有定义这个引用使用什么方式去定位、访问堆中的对象的具体位置。目前的主流对象访问方式有句柄访问直接指针访问两种。

  • 指针访问: 指向对象,代表一个对象在内存中的起始地址。
  • 句柄访问: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问

Java堆中划分出一块内存来作为句柄池reference中存储对象的句柄地址!而句柄中包含了对象实例数据对象类型数据各自的具体地址信息,具体构造如下图所示:

句柄访问方式

优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而reference本身不需要修改。

  • 直接指针访问

如果使用直接指针访问,reference 中存储的直接就是对象地址!那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

直接访问方式

优势:速度更,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

5.拓展问题

(1)堆与栈的区别

栈与堆的区别主要从物理地址、内存分配、存放内容和程序的可见度进行解释。

  • 物理地址

堆分配给对象的物理地址是不连续的,性能慢;栈使用的是数据结构中的栈,先进后出的原则,栈物理地址分配是连续的。所以性能快。

  • 内存分配

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。栈是连续的,所以分配的内存空间要在编译期就确定,大小是固定的。

ps:队列与栈,都是用来预存数据的,但两者操作方法不同。

  • 存放内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储;栈存放的是局部变量、操作数栈与返回结果。因此栈更关注的是程序方法的执行。ps:jdk1.7静态变量在方法区,静态对象放在堆,jdk1.8都在堆。

  • 程序可见度

堆对整个应用程序都是共享的、可见的,故堆是线程共享的;栈只对线程是可见的,故栈是线程私有的。它的生命周期与线程相同。

(2)一个实例变量在对象初始化的过程中会被赋值几次?

  • 我们知道,JVM在为一个对象分配完内存之后,会给每一个实例变量赋予默认值,这个时候实例变量被第一次赋值,这个赋值过程是没有办法避免的。如果我们在声明实例变量x的同时对其进行了赋值操作,那么这个时候,这个实例变量就被第二次赋值了。如果我们在实例代码块中,又对变量x做了初始化操作,那么这个时候,这个实例变量就被第三次赋值了。如果我们在构造函数中,也对变量x做了初始化操作,那么这个时候,变量x就被第四次赋值。也就是说,在Java的对象初始化过程中,一个实例变量最多可以被初始化4次。

  • 总结:(1)JVM给对象分配完内存之后会赋0,无法避免(2)声明实例变量时(3)实例代码块进行了初始化操作(4)构造函数中对变量进行初始化

(3)类的初始化过程与类的实例化过程的异同?

  • 类的初始化是指类加载过程中的初始化阶段对类变量按照程序猿的意图进行赋值的过程;
  • 类的实例化是指在类完全加载到内存中后创建对象的过程。

(4)深拷贝和浅拷贝

对象的实例是存储在堆内存中然后通过一个引用值去操作对象,由此拷贝的时候就存在两种情况了:拷贝引用和拷贝实例,这也是浅拷贝和深拷贝的区别。

  • 浅拷贝/复制(shallowCopy)只是增加了一个指针(复制了内存地址或者说引用)指向已存在的内存地址。
  • 深拷贝/复制(deepCopy)是增加了一个指针并且申请了一个新的内存保存复制的实例对象(与之前的对象完全隔离),使这个增加的指针指向这个新的内存,ps:使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误。

(5)内存泄露举例

  • 内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。

  • 但是,即使这样,Java也还是存在着内存泄漏的情况,比如长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。

(6)什么情况下会发生栈内存溢出

  • 栈是线程私有的(虚拟机栈和本地方法栈),他的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息。局部变量表又包含基本数据类型,对象引用类型

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,方法递归调用产生这种结果。

  • 如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是无法申请到足够的内存去完成扩展,或者在新建立线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将抛出一个OutOfMemory 异常。(线程启动过多)

  • 参数 -Xss 去调整JVM栈的大小

6.参考链接

https://blog.csdn.net/ThinkWon/article/details/104390752
https://gitee.com/SnailClimb/JavaGuide/blob/master/docs/java/jvm/Java
https://blog.csdn.net/justloveyou_/article/details/72466416
https://www.jianshu.com/p/e74fe532e35e
http://www.hollischuang.com/archives/2509

你可能感兴趣的:(JVM详解 --- JVM内存模型与对象创建过程)