Java内存分配
在解释这个问题之前,我想简单的记录一下Java虚拟机对内存的分配管理。
简单的说,Java运行时内存区域,就由上面几部分构成。青绿色标记的,是每个线程私有的内存区域,其他的为线程共享的内存区域。我们先简单的依次说明每个部分是用来存什么的,最后再用一个简单的例子,将各个部分结合起来简单介绍其内存分配的基本过程。
首先,程序计数器(pc)。这个东西对于很多开发者来说,再熟悉不过了,尽管不同领域的pc,具体用法上存在一些小小的差异,但总的来说,pc是用来记录程序运行到哪里了,下一步又该执行哪一步操作。pc占据的内存是线程级的,即随线程的创建而产生,随线程的销毁而销毁(被回收)。
其次JVM栈和本地方法栈。这两个栈在存储结构上,基本相同,以至于很多的JVM产商,将二者合而为一。JVM栈,顾名思义,是用来存储Java方法运行过程中使用的栈数据,本地方法栈就是用来存储本地方法执行过程中的栈数据。栈中存储的数据,是一种被称为“栈帧”的东西。栈帧主要包括:局部变量表和操作数栈。栈帧的入栈和出栈,分别意味着一个方法的执行与结束。
接着,我们来看看方法区。方法区主要是用来存类型数据的,与类型相关的东西,比如常量,静态变量,编译后的代码等,基本都存储在这一区域。而因为“无用类”的判断条件非常苛刻(有三点,第一,该类无可达对象,第二,该类的ClassLoader已被回收,第三,该类的Class对象无引用),这个区域存储的内容很难会被回收,所以你可能会在很多地方看到“永久代”一词,其实说的主要也就是这个方法区。方法区中,有个特殊的区域,被划分(逻辑划分,不一定为物理划分)出来,即“运行时常量池”。运行时常量池,保存着字面量,符号引用等。方法区是线程共享的,随JVM启动而创建,JVM退出而销毁。
最后,是这个堆。堆,在很多领域也有用到。在Java中,堆,是用来存储对象的相关内容,包括对象的对象头和实例数据(数组对象还有一个数组的长度)。不同的JVM实现,对象可能还包括类型指针(指向对象所属的类型信息,存在方法区中)和占位符(虚拟机实现可能需要内存对齐)等。
一个简单的例子
public void test (int result, int num) { TestClassB classB = new TestClassB; classB.methodB; } public class TestClassB { public void methodB(result, num) { int finalResutl = result + num; ...... } }
现在假设线程A在执行test方法,并已经执行到TestClassB classB = new TestClassB。首先,会去判断类TestClassB有没有被加载到方法区中,如果没有,先加载类(类的加载过程不详细说明,有空可以写篇Java类加载过程的博客)入方法区;然后因为执行的是new操作,需要创建一个对象,这时候需要在堆上申请内存(内存分配有很多方案,需要考虑多线程下的线程安全问题等诸多因素,不详细阐述),用于存放对象的相关数据(对象头,实例数据,类型指针,占位符等);再然后为TestClassB的成员赋“零值”(不同类型的数据,零值不同,基本数据类型int的零值为0,引用类型的零值为null,等);最后,设置对象头。这样对于JVM来说,对象就创建成功了(后面就是执行类的构造方法了,那是属于Java语言层面的创建对象的过程)。
上面总提及一个叫做“对象头”的东西,这个东西跟对象本身没有什么关系,存储的是对象的运行时数据,包括对象的hashcode,对象的锁状态,对象持有的锁等等。比如对象的hashcode,用于指定对象的唯一性,在GC和对象定位等过程中都会用到。
接着,pc加一(此处加一,表示的是加上一个JVM指令的位数,表示的是下一个指令的内存地址),执行下一步:classB.methodB;这是一个方法调用。正如上面所说,方法的执行和结束,意味着方法栈中,栈帧的进栈和出栈。
对象在堆中存放,然而,对象的操作,方法的执行,就进入了“栈”。调用methodB时,methodB栈帧进栈,栈帧包含局部变量表和操作数栈。因为这个地方的methodB不是类方法,所以,局部变量表的第一个变量为调用该方法的类,即classB(this)。操作数栈用于进行当前数据操作,操作结果出操作数栈,并保存进局部变量表。
例子就这样简单的结束了,总的来说,就是类进入方法区,创建的对象在堆中,方法执行的时候,在方法栈中。
下面,我们来看这个有意思的Java面试题。
当一个对象被当作参数传递到一个方法后,此方法可改变这个对象的属性,并可返回变化后的结果,那么这里到底是值传递还是引用传递?
网上的标配答案:是值传递。Java语言的方法调用只支持参数的值传递。当一个对象实例作为一个参数被传递到方法中时,参数的值就是对该对象的引用。对象的属性可以在被调用过程中被改变,但对对象引用的改变是不会影响到调用者的。
其实这确实是一个很无聊的问题,本来也没有太当回事,但是一来,这个问题下面的追问者很多,我查了下知乎,对这个问题的提问者和回答人也很多;二来,答案不够准确,或者说是,没讲到点子上,有人甚至拿《Java核心卷》里的三句话作为答案。
《Java核心卷》对这种问题有如下三句话的描述:
1.一个方法不能修改一个基本数据类型的参数
2.一个方法可以改变一个对象参数的状态
3.一个方法不能让对象参数引用一个新的对象
无可厚非,这三句话总结的很经典,但是这只是简单的说出了结论,原因呢?就用这三句话解释这个问题,给初学者带来的感觉,只是,哦,原来Java还有这么一个定理(限制)。那么一个个由JVM规范导致的结果,都成了需要死记硬背的“定理”。
public class Program { public static void swap(String x, String y) { String temp = x; x = y; y = temp; } public static void main (String[] args) { String a = "testa"; String b = "testb"; swap (a, b); } }
我们接着看这段代码,将它还原到内存中。
图中“0x”开头的是十六进制的内存地址,随便举的例子。在main方法调用swap方法的时候,只是将main的局部变量表中的a和b的值(指向运行时常量池的地址)拷贝到swap的局部变量表中的x和y,在swap的局部变量表中进行的换值操作,并未对main局部变量表起作用,所以,在swap退出前,x的值是“testb”, y的值是“testa”,x与y的值互换了,但a与b的值并没有因此而改变。当然,swap退出之后,相应的局部变量表会被回收,也就没有所谓的x和y了。
这是这个问题所真正涉及的知识点,我很认同知乎上那位朋友的话,没有必要非得分出个所谓的“值传递”和“引用传递”。
这边我顺便提一点在C中,是怎么做到交换上面例子中a和b这两个值的。
在C中有一个很神奇的东西,名字叫“指针”。可以很简单的认为,它就是地址。那么“指针的指针”,就是“地址的地址”。上面以“0x”开头的数据,就是内存地址,如果将这个地址赋值给一个C中的变量,那么这个变量就称为指针变量。那么我们完全可以通过指针,透过中间变量,直接操作a和b中存储的内容(此处说的是地址),甚至是直接操作到“testa”和“testb”。
评论亮了:
传值还是传引用,深入理解java虚拟机中有这个解释,方法的参数是局部变量,存储在栈中,包括8种基本类型和引用类型,是方法调用之前调用者按参数逆序入栈,然后拷贝给被调用者。可见基本类型和引用类型是等价的,都是传值,只不过引用类型代表的含义不是自身,而是堆中的对象类型。也可以用指针来理解,但没有指针也不至于含糊吧!
备注:
Java虚拟机规范没有强制性约束在什么时候开始类加载过程
类什么时候被加载/类加载时机:
第一:生成该类对象的时候,会加载该类及该类的所有父类;
第二:访问该类的静态成员的时候;
第三:class.forName("类名");