JVM 运行时数据区域

时间:2022-02-17 10:04:06

C语言的阴影

还记得刚进大学的时候,以为这个世界上最难学的不过C语言了。尽管后来陆续学了很多的更难的课程,尽管慢慢掌握了计算机的很多原理之后,回头来看C语言,似乎没那么难理解,可当年初学C语言时的“阴影”,这么多年来,一直没有散去。

我经常还能想到几年前,懒散的趴在逸夫教学楼F1教室最后一排的座位上,听兰书敏老师讲着“戏院”(C语言)的场景。兰老师问到:“你们怎么都不吭声?到底是哪里听不懂?”老师,学生当时真是哪哪儿都没听懂啊。

 

身在Java,心在C(Java大神勿喷,C对我来说,真是一种情怀)

没想到,工作一年多的时间里,用的最多的语言不是对我影响最大的C,而是大学毕业之后现学现卖的Java。所以我对C和Java都算有一点了解。

 

一条有意思的Java面试题

前几天在搜索一个问题的解决方案时,偶然看到一个Java面试题,觉得网上绝大多数解释,有些浮于表面。而真神们又不屑于解释这些无聊的问题,所以觉得有必要站在一个“双修(残废)”者的角度,谈谈这个问题。

 

Java内存分配

在解释这个问题之前,我想简单的记录一下Java虚拟机对内存的分配管理。

网上有很多关于Java内存管理的讲解,但不知道为什么,大多数作者并没有系统的讲解,有些过于散碎。

JVM 运行时数据区域

我们先来看看这张图(我不会画图,画的太丑,各位受累受累了)。简单的说,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;
        ......
    }
}

//author: Feng_zhulin
//http://cnblogs.com/zhulin-jun

现在假设线程A在执行test方法,并已经执行到TestClassB classB = new TestClassB()。首先,会去判断类TestClassB有没有被加载到方法区中,如果没有,先加载类(类的加载过程不详细说明,有空可以写篇Java类加载过程的博客)入方法区;然后因为执行的是new操作,需要创建一个对象,这时候需要在堆上申请内存(内存分配有很多方案,需要考虑多线程下的线程安全问题等诸多因素,不详细阐述),用于存放对象的相关数据(对象头,实例数据,类型指针,占位符等);再然后为TestClassB的成员赋“零值”(不同类型的数据,零值不同,基本数据类型int的零值为0,引用类型的零值为null,等);最后,设置对象头。这样对于JVM来说,对象就创建成功了(后面就是执行类的构造方法了,那是属于Java语言层面的创建对象的过程)。

上面总提及一个叫做“对象头”的东西,这个东西跟对象本身没有什么关系,存储的是对象的运行时数据,包括对象的hashcode,对象的锁状态,对象持有的锁等等。比如对象的hashcode,用于指定对象的唯一性,在GC和对象定位等过程中都会用到。

 

接着,pc加一(此处加一,表示的是加上一个JVM指令的位数,表示的是下一个指令的内存地址),执行下一步:classB.methodB();这是一个方法调用。正如上面所说,方法的执行和结束,意味着方法栈中,栈帧的进栈和出栈。

 

JVM 运行时数据区域

好滴好滴,又到看图的时候了(捂脸,我不仅不会画图,还没有好用的画图工具,求推荐mac的良心画图工具,如果不是免费的,我只接受有破解版的)。对象在堆中存放,然而,对象的操作,方法的执行,就进入了“栈”。调用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);
        }
}
//author: Feng_zhulin
//http://cnblogs.com/zhulin-jun

我们接着看这段代码,将它还原到内存中。

JVM 运行时数据区域

图中“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”。

C语言因指针而美丽,却也因指针而复杂。Java解决了C中内存需要开发者自己管理的问题,也去除了指针的概念,让程序出错的概率大幅度降低,却也因为没有指针,在我这种装了两个半桶浆糊的人眼中,很多地方变的不可思议的臃肿和麻烦。