欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  Java

探寻Java内存分配

程序员文章站 2022-04-09 14:13:45
...
引子

这两天有个同事抓耳挠腮地纠结:Java到底是值传递还是引用传递。百思不得其姐,他将这个问题抛给大家一起讨论。于是,有的人说传值,有的人说传引用;不管哪方都觉得自己的理解是正确无误的。我觉得:要回答这个问题不妨先搁置这个问题,先往这个问题的上游走走——Java内存分配。一提到内存分配,我想不少人的脑海里都会浮现一句话:引用放在栈里,对象放在堆里,栈指向堆。嗯哼,这句话听上去没有错;但是我们继续追问一下:这个栈是什么栈?是龙门客栈么?非也!它其实是Java虚拟机栈。呃,到了此处,好学的童鞋忍不住要追问了:啥是Java虚拟机栈呢?不急,我们一起来瞅瞅。


JVM的生命周期

我们知道:每个Java程序都运行于在Java虚拟机上;也就是说:一个运行时的Java虚拟机负责运行一个Java程序。当启动一个Java程序时,一个虚拟机实例也就随之诞生了;当该程序执行完毕后这个虚拟机实例也就随之消亡。例如:在一台计算机上同时运行五个Java程序,那么系统将提供五个Java虚拟机实例;每个Java程序独自运行于它自己所对应的Java虚拟机实例中。

Java虚拟机中有两种线程,即:守护线程与非守护线程。守护线程通常由虚拟机自身使用,比如执行垃圾收集的线程。非守护线程,通常指的是我们自己的线程。当程序中所有的非守护线程都终止时,虚拟机实例将自动退出。


JVM运行时数据区

既然Java虚拟机负责执行Java程序,那我们就先来看看Java虚拟机体系结构,请参见下图:


探寻Java内存分配

在这里可以看到:class文件由类加载器载入JVM中运行。此处,我们重点关注蓝色线框中JVM的Runtime Data Areas(运行时数据区),它表示JVM在运行期间对内存空间的划分和分配。在该数据区内分为以下几个主要区域:Method Area(方法区),Heap(堆),Java Stacks(Java 栈),Program Counter Register(程序计数器),Native Method Stack(本地方法栈),现对各区域的主要作用及其特点作如下详细介绍。

Method Area(方法区)

Method Area(方法区)是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError(OOM)异常。为进一步了解Method Area(方法区),我们来看在该区域内包含了哪些具体组成部分。

(1) 运行时常量池

Class文件中除了有类的版本、字段、方法、接口等描述等与类紧密相关的信息之外,还有一个常量池用于存放编译期生成的各种字面量和符号引用;该常量池将在类加载后被存放到方法区的运行时常量池中。换句话说:在运行时常量池中存放了该类使用的常量的一个有序集合,它在java程序的动态连接中起着非常重要的作用。在该集合中包括直接常量(string,integer和,floating point等)和对其他类型、字段和方法的符号引用。外界可通过索引访问运行时常量池中的数据项,这一点和访问数组非常类似。当然,运行时常量池是方法区的一部分,它也会受到方法区内存的限制,当运行时常量池无法再申请到内存时也会抛出OutOfMemoryError(OOM)异常。

(2) 类型信息

在该部分中包括:

  • 类型的完全限定名

  • 类型的直接超类的全限定名

  • 类型是类类型还是接口类型

  • 类型的访问修饰符(public、abstract、final等)

  • 直接超接口的全限定名的有序列表

(3) 字段信息

字段信息用于描述该类中声明的所有字段(局部变量除外),它包含以下具体信息:

  • 字段名

  • 字段类型

  • 字段的修饰符

  • 字段的顺序

(4) 方法信息

方法信息用于描述该类中声明的所有方法,它包含以下具体信息:

  • 方法名

  • 方法的返回类型

  • 方法输入参数的个数,类型,顺序

  • 方法的修饰符

  • 操作数栈

  • 在帧栈中的局部变量区的大小

(5) 类变量

该部分用于存放类中static修饰的变量。

(6) 指向类加载器的引用

类由类加载器加载,JVM会在方法区保留指向该类加载器的引用。

(7) 指向Class实例的引用

在类被加载器加载的过程中,虚拟机会创建一个代表该类的Class对象,与此同时JVM会在方法区保留指向该Class的引用。

Program Counter Register(程序计数器)

Program Counter Register(程序计数器)在Runtime Data Areas(运行时数据区)只占据非常小的内存空间,它用于存储下一条即将执行的字节码指令的地址。

Java Stacks(Java 栈)

Java Stacks(Java 栈)亦称为虚拟机栈(VM Stack),也就是我们通常说的栈。它用于描述的Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。Java Stacks(Java 栈)的生命周期与线程相同;当一个线程执行完毕那么该栈亦被清空。

Native Method Stack(本地方法栈)

Native Method Stack(本地方法栈)与Java Stacks(Java 栈)非常类似,它用于存储调用本地方法(C/C++)所涉及到的局部变量表、操作栈等信息。

Heap(堆)

Heap(堆)在虚拟机启动时创建,用于存放对象实例,几乎所有的对象实例都在这里分配内存。所以,Heap(堆)是Java 虚拟机所管理的内存中最大的一块,也是垃圾回收器管理的重点区域。

小结

在此对JVM运行时数据区做一个小结:

  • Method Area(方法区)和Heap(堆)是被所有线程共享的内存区域。

  • Java Stacks(Java 栈)和Program Counter Register(程序计数器)以及Native Method Stack(本地方法栈)是各线程私有的内存区域。

  • 创建一个对象,该对象的引用存放于Java Stacks(Java 栈)中,真正的对象实例存放于Heap(堆)中。这也是大家常说的:栈指向堆。

  • 除了刚才提到的JVM运行时数据区所涉及到的内存以外,我们还需要关注直接内存(Direct Memory)。请注意:直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError(OOM)异常出现。比如,在使用NIO时它可以使用Native 函数库直接分配堆外内存,然后通过存储在Java 堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。类似的操作,可避免了在Java 堆和Native 堆中来回复制数据,从而提高性能。


Java调用方法时的参数传递机制

在调用Java方法传递参数的时候,到底是传值还是传引用呢?面对众多的争论,我们还是来瞅瞅代码,毕竟代码不会说谎。我们先来看一个非常简单的例子:交换两个int类型的数据,代码如下:

package cn.com;/**
 */public class TestMemory {

    public static void main(String[] args) {
        TestMemory testMemory=new TestMemory();        
        int number1=9527;        
        int number2=1314;
        System.out.println("main方法中,数据交换前:number1="+number1+" , number2="+number2);
        testMemory.swapData(number1, number2);
        System.out.println("main方法中,数据交换后:number1="+number1+" , number2="+number2);
    }    private void swapData(int a,int b) {
        System.out.println("swapData方法中,数据交换前:a="+a+" , b="+b);        
        int temp=a;
        a=b;
        b=temp;
        System.out.println("swapData方法中,数据交换后:a="+a+" , b="+b);
    }

}

我们在main方法中声明的两个变量number1=9527 , number2=1314;然后将这两个数作为参数传递给了方法swapData(int a,int b),并在该方法内交换数据。至于代码本身无需再过多的解释了;不过,请思考输出的结果是什么?在您考虑之后,请参见如下打印信息:

main方法中,数据交换前:number1=9527 , number2=1314
swapData方法中,数据交换前:a=9527 , b=1314
swapData方法中,数据交换后:a=1314 , b=9527
main方法中,数据交换后:number1=9527 , number2=1314

嗯哼,这和你想的一样么?为什么会是这样呢?还记得刚才讨论Java Stacks(Java 栈)时说的么:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。结合示例的代码:main( )方法在一个栈帧中,swapData( )在另外一个栈帧中;两者彼此独立互不干扰。在main( )中调用swapData( )传入参数时它的本质是:将实际参数值的副本(复制品)传入其它方法内而参数本身不会受到任何影响。也就是说,这number1和number2这两个变量仍然存在于main( )方法所对应的栈帧中,但number1和number2这两个变量的副本(即int a和int b)存在于swapData( )方法所对应的栈帧中。故,在swapData( )中交换数据,对于main( )是没有任何影响的。这就是Java中调用方法时的传值机制——值传递。

嗯哼,刚才这个例子是关于基本类型的参数传递。Java对于引用类型的参数传递一样采用了值传递的方式。我们在刚才的示例中稍加改造。首先,我们创建一个类,该类有两个变量number1和number2,请看代码:

package cn.com;/**
 */public class DataObject {

    private int number1;    
    private int number2;    
    public int getNumber1() {        
    return number1;
    }    public void setNumber1(int number1) {        
            this.number1 = number1;
    }    public int getNumber2() {       
            return number2;
    }    public void setNumber2(int number2) {        
            this.number2 = number2;
    }

}

好了,现在我们来测试交换DataObject类对象中的两个数据:

package cn.com;/**
  */public class TestMemory {

    public static void main(String[] args) {
        TestMemory testMemory=new TestMemory();
        DataObject dataObject=new DataObject();
        dataObject.setNumber1(9527);
        dataObject.setNumber2(1314);
        System.out.println("main方法中,数据交换前:number1="+dataObject.getNumber1()+" , number2="+dataObject.getNumber2());
        testMemory.swapData(dataObject);
        System.out.println("main方法中,数据交换后:number1="+dataObject.getNumber1()+" , number2="+dataObject.getNumber2());
    }    private void swapData(DataObject dataObject) {
        System.out.println("swapData方法中,数据交换前:number1="+dataObject.getNumber1()+" , number2="+dataObject.getNumber2());        
        int temp=dataObject.getNumber1();
        dataObject.setNumber1(dataObject.getNumber2());
        dataObject.setNumber2(temp);
        System.out.println("swapData方法中,数据交换后:number1="+dataObject.getNumber1()+" , number2="+dataObject.getNumber2());

    }

}

简单地描述一下代码:在main( )中定义一个DataObject类的对象并为其number1和number2赋值;然后调用swapData(DataObject dataObject)方法,在该方法中交换数据。请思考输出的结果是什么?在您考虑之后,请参见如下打印信息:

main方法中,数据交换前:number1=9527 , number2=1314
swapData方法中,数据交换前:number1=9527 , number2=1314
swapData方法中,数据交换后:number1=1314 , number2=9527
main方法中,数据交换后:number1=1314 , number2=9527

嗯哼,为什么是这样呢?我们通过DataObject dataObject=new DataObject();创建一个对象;该对象的引用dataObject存放于栈中,而该对象的真正的实例存放于堆中。在main( )中调用swapData( )方法传入dataObject作为参数时仍然传递的是值,只不过稍微特殊点的是:该值指向了堆中的实例对象。好了,再结合栈帧来梳理一遍:main( )方法存在于与之对应的栈帧中,在该栈帧中有一个变量dataObject它指向了堆内存中的真正的实例对象。swapData( )收到main( )传递过来的变量dataObject时将其存放于其本身对应的栈帧中,但是该变量依然指向堆内存中的真正的实例对象。也就是说:main( )方法中的dataObject和swapData( )方法中的dataObject指向了堆中的同一个实例对象!所以,在swapData( )中交换了数据之后,在main( )会体现交换后的变化。在此,我们可以进一步的验证:在该swapData( )方法的最后一行添加一句代码dataObject=null ;我们发现打印信息并没有任何变化。因为这句代码仅仅使得swapData( )所对应的栈帧中的dataObject不再指向堆内存中的实例对象但不会影响main( )所对应的栈帧中的dataObject依然指向堆内存中的实例对象。

通过这两个示例,我们进一步验证了:Java中调用方法时的传递机制——值传递。当然,有的人说:基础类型传值,对象类型传引用。其实,这也没有什么错,只不过是表述方式不同罢了;只要明白其中的道理就行。如果,有些童鞋非纠缠着个别字眼不放,那我只好说:PHP是世界上最好的语言。

以上就是探寻Java内存分配的详细内容,更多请关注其它相关文章!