Java虚拟机
2025-05-20 17:17:18 6 举报
AI智能生成
Java虚拟机(JVM)是运行Java字节码的规范,具体实现为各种虚拟机。它是一个抽象计算机,具有自己的指令集和存储区。JVM的核心内容是执行字节码、管理内存和执行垃圾收集。文件类型涉及为JVM设计的编译输出,如.class文件,包含Java类的字节码表示。JVM的修饰语通常描述其功能和特性,如跨平台(一次编写,到处运行)、多线程支持、自动内存管理等。JVM的设计目标是在不同操作系统上提供一致的执行环境,并确保Java应用程序的安全性和可靠性。
作者其他创作
大纲/内容
Java虚拟机概述
从机器语言到Java
当年——在60多年前,程序员是这样干活的:
写一段程序,将其打在纸带活卡片上,1打孔,0不打孔,然后将指代或卡片输入计算机,那时候的程序都是只用0和1携程,注意,是只用0和1.程序员用于变成的IDE就是剪刀和胶水,只这两板斧,就能闯天下。
如果你聚精会神地盯着这个纸带上面的孔,你就会头晕,这哪里是程序,分明是个千疮百孔的纸带。下面是使用数字0和1所绘制而成的特殊"图画"
第一幅:
1010 0001 0000 0001 0000 0000
0000 0011 0000 0101 0000 0010
第二幅:
1010 0001 0000 0001 0000 0000
0000 0011 0000 1001 0000 0010
现在,请比较两幅图,找出其中的差异。这段机器代码是在执行一个简单的数学题目:1 +2
在那个年代,如果你编写了一个程序,2000行,恰好不小心把其中某一行的某个0写成了1,那是一件多么不幸的事,代码写错了,可这对于现代程序员而言,那都不是事,大不了断电调试。但在那个IDE简陋到只有剪刀和胶水的年代,你只能对着2000行的代码,仔仔细细,一行一行地排查问题。
于是,大家纷纷要求改变这种反人类的工作,而改变的思路就是,既然机器码这么难以阅读、理解和排错,那就用助记符把。于是人们开始用助记符来编写程序,编写完了,先用人脑来执行一边,确保没有问题后,再将助记符手工转换成机器码,制作成孔带。Grace Hopper觉得这样仍然很麻烦,就开发除了A-0 sytem,其能够自动将助记符转换成机器码。当时的助记符就是所谓的汇编。
写一段程序,将其打在纸带活卡片上,1打孔,0不打孔,然后将指代或卡片输入计算机,那时候的程序都是只用0和1携程,注意,是只用0和1.程序员用于变成的IDE就是剪刀和胶水,只这两板斧,就能闯天下。
如果你聚精会神地盯着这个纸带上面的孔,你就会头晕,这哪里是程序,分明是个千疮百孔的纸带。下面是使用数字0和1所绘制而成的特殊"图画"
第一幅:
1010 0001 0000 0001 0000 0000
0000 0011 0000 0101 0000 0010
第二幅:
1010 0001 0000 0001 0000 0000
0000 0011 0000 1001 0000 0010
现在,请比较两幅图,找出其中的差异。这段机器代码是在执行一个简单的数学题目:1 +2
在那个年代,如果你编写了一个程序,2000行,恰好不小心把其中某一行的某个0写成了1,那是一件多么不幸的事,代码写错了,可这对于现代程序员而言,那都不是事,大不了断电调试。但在那个IDE简陋到只有剪刀和胶水的年代,你只能对着2000行的代码,仔仔细细,一行一行地排查问题。
于是,大家纷纷要求改变这种反人类的工作,而改变的思路就是,既然机器码这么难以阅读、理解和排错,那就用助记符把。于是人们开始用助记符来编写程序,编写完了,先用人脑来执行一边,确保没有问题后,再将助记符手工转换成机器码,制作成孔带。Grace Hopper觉得这样仍然很麻烦,就开发除了A-0 sytem,其能够自动将助记符转换成机器码。当时的助记符就是所谓的汇编。
后来人们纷纷在主流硬件平台上基于汇编进行程序开发。汇编这东西,小巧精悍。后来,再后来呢?后来的事大家都知道了,前辈们先后开发出了Fortran、B、C、C++、Delphi、VB、PHP等各种高级语言。高级语言的出现,再一次解放了程序员的生产力,原本用汇编需要写20行的程序,高级语言一行代码就搞定,极大地提高了生产效率。同时,人们还开发出配套的IDE(integrated Development Evironment, 集成开发环境),使得调试程序和试错成为易如反掌的事,变成主教变得简单、有趣,从苦力活一跃成为一门艺术活。并且高级语言的出现,为人类编写大型程序提供了坚实的基础,如果没有这些高级语言,很难想象现在会不会出现操作系统,以及基于操作系统的游戏、网络、电商各种应用。
对于这段历史,如果真要去逐一追根溯源,写成的书用"汗牛充栋"来形容一点都不过分,不过所幸早有聪明的前辈把这段历史总结成了一句话:机器生汇编,汇编生B,B生C,C生万物。
时间一晃四五十年过去了,我们穿越到20世纪90年代初。那一年,整个程序界依然呈现百家争鸣、百花齐放的一派欣欣向荣的景象,各种变成语言各霸一方。那一年,地球东半球的人们普遍处于"通信基本靠吼,取暖基本靠抖"的生活水平,而西半球的人们已经开始大量使用电视机、电话、闹钟、烤面包机等现代化生活电器。这些家用电器五花八门,由不同的公司制造和生产。由于大家技术各不相同,大家需要针对不同的硬件进行程序开发。那一年,有个年轻的博士具有非常前瞻的商业阳光,看准了家电智能化的发展方向,便暗自下定决心,一定要运用自己所学,来实现家电智能化,开发出一种能够运行于各种不同硬件平台的编程语言,这样大家就不需要关注不同硬件平台的细节差异,只需将精力完全用在应用程序开发上面,这就能再次解放生产力,促进社会飞速发展。这个人就是詹爷,江湖人称"Java之父"——詹姆斯 高斯林
可是,要开发出这样一种能够横跨各种异构平台的编程语言,谈何容易?
如今,20多年过去了,伊人仍在,而江湖,早已不是那个江湖。当初詹爷一心想捣鼓出个"write once, run anywhere"的编程语言来一统江湖,可是江湖局势突变,家居智能化的发展并不明朗,反而是互联网异军突起。然而,Java语言却阴差阳错地在互联网领域大展身手,乘着"互联网"这朵青云扶摇直上。到如今,Java语言已经稳坐互联网开发第一宝座很多年。近几年兴起地大数据、分布式开发,很多中间价框架和平台也都直接基于Java开发,Java语言在新地领域日渐焕发出绚烂夺目的光彩。这恐怕连詹爷自己都没想到。真是世事无常。然而,戏剧性的事情还不仅如此。在詹爷开发出Java语言的20年之后,一群志同道合者捣鼓出了一个手机操作系统——Android,其使用Dalvik虚拟机(虽然现在虚拟机被谷歌公司升级了,不再叫Dalvik了),但是语法规范完全遵循Java语言。经过谷歌公司的大力推广,Android如今占据手机市场的半壁江上,并且当下随着手机与智能家居的融合愈加神话,从某种意义上说,这反而是的詹爷当年的理想得以实现。赚了一大圈,终于又赚了回来。此路不可谓不曲折。
詹爷功力深厚,使用独家武功秘籍打败江湖无敌手,坐拥半壁江上(Java所占据的应用领域一直位列前茅)
对于这段历史,如果真要去逐一追根溯源,写成的书用"汗牛充栋"来形容一点都不过分,不过所幸早有聪明的前辈把这段历史总结成了一句话:机器生汇编,汇编生B,B生C,C生万物。
时间一晃四五十年过去了,我们穿越到20世纪90年代初。那一年,整个程序界依然呈现百家争鸣、百花齐放的一派欣欣向荣的景象,各种变成语言各霸一方。那一年,地球东半球的人们普遍处于"通信基本靠吼,取暖基本靠抖"的生活水平,而西半球的人们已经开始大量使用电视机、电话、闹钟、烤面包机等现代化生活电器。这些家用电器五花八门,由不同的公司制造和生产。由于大家技术各不相同,大家需要针对不同的硬件进行程序开发。那一年,有个年轻的博士具有非常前瞻的商业阳光,看准了家电智能化的发展方向,便暗自下定决心,一定要运用自己所学,来实现家电智能化,开发出一种能够运行于各种不同硬件平台的编程语言,这样大家就不需要关注不同硬件平台的细节差异,只需将精力完全用在应用程序开发上面,这就能再次解放生产力,促进社会飞速发展。这个人就是詹爷,江湖人称"Java之父"——詹姆斯 高斯林
可是,要开发出这样一种能够横跨各种异构平台的编程语言,谈何容易?
如今,20多年过去了,伊人仍在,而江湖,早已不是那个江湖。当初詹爷一心想捣鼓出个"write once, run anywhere"的编程语言来一统江湖,可是江湖局势突变,家居智能化的发展并不明朗,反而是互联网异军突起。然而,Java语言却阴差阳错地在互联网领域大展身手,乘着"互联网"这朵青云扶摇直上。到如今,Java语言已经稳坐互联网开发第一宝座很多年。近几年兴起地大数据、分布式开发,很多中间价框架和平台也都直接基于Java开发,Java语言在新地领域日渐焕发出绚烂夺目的光彩。这恐怕连詹爷自己都没想到。真是世事无常。然而,戏剧性的事情还不仅如此。在詹爷开发出Java语言的20年之后,一群志同道合者捣鼓出了一个手机操作系统——Android,其使用Dalvik虚拟机(虽然现在虚拟机被谷歌公司升级了,不再叫Dalvik了),但是语法规范完全遵循Java语言。经过谷歌公司的大力推广,Android如今占据手机市场的半壁江上,并且当下随着手机与智能家居的融合愈加神话,从某种意义上说,这反而是的詹爷当年的理想得以实现。赚了一大圈,终于又赚了回来。此路不可谓不曲折。
詹爷功力深厚,使用独家武功秘籍打败江湖无敌手,坐拥半壁江上(Java所占据的应用领域一直位列前茅)
兼容的选择
詹爷想一统江湖,希望开发出一款编程语言,能够兼容所有的硬件平台和操作系统,但是怎么实现呢?这是一个问题。
在当时,要实现兼容性,并不难。很多变成语言都具备这种能力,例如C语言。问题的关键在于,怎样才能在开发层面实现真正的平台无关性?
我们先看看C语言是如何实现兼容性的。C语言实现系统兼容性的思路很简单,那就是通过在不同的硬件平台和操作系统上开发各自特定的编译器,从而将相同的C语言源代码翻译为底层平台相关的硬件指令。虽然这种思路很棒,但是仍然有明显的缺点,当涉及系统调用时,开发这仍然要关注具体底层系统的API.
例如,在C语言中创建一个线程, 如果你时在Linux平台上开发,那么C语言必须要这样写:如右图所示。
可以看到,在Linux平台上,开发者需要知道Linux平台所提供的创建线程的接口是pthread_create();而在Windows平台上,开发者需要知道Windows平台所提供的创建线程的接口是CreateThread().另外,在Linux和Windows平台上,C程序需要引用不同的头文件,并且所调用的创建线程的两种API的入参和返回值也不相同。也就是说,开发者需要在开发时就考虑和了解各种平台的差异性。
在当时,要实现兼容性,并不难。很多变成语言都具备这种能力,例如C语言。问题的关键在于,怎样才能在开发层面实现真正的平台无关性?
我们先看看C语言是如何实现兼容性的。C语言实现系统兼容性的思路很简单,那就是通过在不同的硬件平台和操作系统上开发各自特定的编译器,从而将相同的C语言源代码翻译为底层平台相关的硬件指令。虽然这种思路很棒,但是仍然有明显的缺点,当涉及系统调用时,开发这仍然要关注具体底层系统的API.
例如,在C语言中创建一个线程, 如果你时在Linux平台上开发,那么C语言必须要这样写:如右图所示。
可以看到,在Linux平台上,开发者需要知道Linux平台所提供的创建线程的接口是pthread_create();而在Windows平台上,开发者需要知道Windows平台所提供的创建线程的接口是CreateThread().另外,在Linux和Windows平台上,C程序需要引用不同的头文件,并且所调用的创建线程的两种API的入参和返回值也不相同。也就是说,开发者需要在开发时就考虑和了解各种平台的差异性。
Linux系统
Windows系统
由此可见,如果开发者想要使用C语言来开发一款既兼容Linux平台又兼容Windows平台的程序,不仅需要熟悉C语言本身的API,也要熟练掌握Linux和Windows上的相关API.这带来的直接后果是,开发者为了开发一个特定平台上的功能,不得不先花费巨大的精力去熟悉该平台的特性和API.可能很多人对这种额外付出的成本没有概念,这里通过一组数据来对比一下,使大家对此有一种感性认识:
拿C语言距离,200多个API,要达到入门级水平,一定很快,可以在分分钟内编写出第一个demo,并在屏幕上成功打印出一行"Hello World!".但是要精通,事情就不简单了,你不仅要熟知这些API的一般用法,还需要通过大量实践,熟知里面可能存在的各种坑。更需要通过参与实际的商业项目开发,切实体会怎样才能安全、高效地使用这些API,使你的商业程序既能拥有高性能,又能一直稳定可靠地运行下去。如果你是一个像詹爷那样拥有远大理想和崇高追求的程序员,那么你一定会追求做一个合格的架构师,你一定会基于C语言的API另外封装出自己的一套API,使你的程序拥有良好的灵活性和扩展性。除了C语言的API,还得另外熟悉C语言的各种语法、编译规则,利用这些规则你才能构建出一种合理的软件架构。
这么一套东西整下来,再聪明的人,最快也需要三四年的时间,才能达到高级程序员的水平。由此可见,要想精通某一个平台或者编程语言,是一件很费时费力的事。詹爷当年也是这么想的。人的一生是有限的,我们应该把有限的生命,投入到无限的应用开发上去,而不是浪费在无限的、层出不穷的底层细节上。詹爷觉得开车只要学会怎样启动,怎样刹车,怎样踩油门和离合器就可以了,没必要让驾驶者关注是啥发动机,不管是涡轮增压还是自然吸气,开车的人都不需要关注,更不需要知道怎样才能操控好,那是专业车手的事。
詹爷认为,简单的,才是最美的。一个应用开发者,只需要关注功能实现本身,不应该去关注底层的细节,这样才能将生产效率提上去。否定别人总是一件很容易的事情,但要成就自己,却很难。既然通过编译器来实现兼容性,是如此地低效和费电,那应该怎样做,才能既不费电,又能实现兼容性呢?
这真是一个让人头疼的问题,既然铁了心要解决这个问题,那就让我们再次好好分析分析问题,看看问题的焦点在哪里。现在,让我们看看所面临的问题,一共有两个问题:
1.现有的技术能够实现兼容性,但是成本太高,效率太低,太费电
2.无法在开发层面做到平台无关性
我们所要达成的目标有两个:
1.实现兼容性
2.开发者不需要关注底层平台的异构性,就能实现兼容性
请注意我们的目标,目前两个目标里,其实兼容性已经实现了,问题的焦点就在于第2点,我们要做到让开发者对底层细节差异无感,能够写出可以兼容所有底层平台的程序。还是举上面创建线程的例子。假设有这么一种编程语言,开发者编写了一条指令,说我要创建一个线程。当编写好的程序运行在Linux平台上时,这条指令就自动被转换成调用pthread_create()接口;当程序运行在Windows平台上使,这条指令就自动被转换程调用CreateThtread()接口。这样开发者就不需要关注不同底层平台上的API了,只需要知道有这么一条指令就可以了。
拿C语言距离,200多个API,要达到入门级水平,一定很快,可以在分分钟内编写出第一个demo,并在屏幕上成功打印出一行"Hello World!".但是要精通,事情就不简单了,你不仅要熟知这些API的一般用法,还需要通过大量实践,熟知里面可能存在的各种坑。更需要通过参与实际的商业项目开发,切实体会怎样才能安全、高效地使用这些API,使你的商业程序既能拥有高性能,又能一直稳定可靠地运行下去。如果你是一个像詹爷那样拥有远大理想和崇高追求的程序员,那么你一定会追求做一个合格的架构师,你一定会基于C语言的API另外封装出自己的一套API,使你的程序拥有良好的灵活性和扩展性。除了C语言的API,还得另外熟悉C语言的各种语法、编译规则,利用这些规则你才能构建出一种合理的软件架构。
这么一套东西整下来,再聪明的人,最快也需要三四年的时间,才能达到高级程序员的水平。由此可见,要想精通某一个平台或者编程语言,是一件很费时费力的事。詹爷当年也是这么想的。人的一生是有限的,我们应该把有限的生命,投入到无限的应用开发上去,而不是浪费在无限的、层出不穷的底层细节上。詹爷觉得开车只要学会怎样启动,怎样刹车,怎样踩油门和离合器就可以了,没必要让驾驶者关注是啥发动机,不管是涡轮增压还是自然吸气,开车的人都不需要关注,更不需要知道怎样才能操控好,那是专业车手的事。
詹爷认为,简单的,才是最美的。一个应用开发者,只需要关注功能实现本身,不应该去关注底层的细节,这样才能将生产效率提上去。否定别人总是一件很容易的事情,但要成就自己,却很难。既然通过编译器来实现兼容性,是如此地低效和费电,那应该怎样做,才能既不费电,又能实现兼容性呢?
这真是一个让人头疼的问题,既然铁了心要解决这个问题,那就让我们再次好好分析分析问题,看看问题的焦点在哪里。现在,让我们看看所面临的问题,一共有两个问题:
1.现有的技术能够实现兼容性,但是成本太高,效率太低,太费电
2.无法在开发层面做到平台无关性
我们所要达成的目标有两个:
1.实现兼容性
2.开发者不需要关注底层平台的异构性,就能实现兼容性
请注意我们的目标,目前两个目标里,其实兼容性已经实现了,问题的焦点就在于第2点,我们要做到让开发者对底层细节差异无感,能够写出可以兼容所有底层平台的程序。还是举上面创建线程的例子。假设有这么一种编程语言,开发者编写了一条指令,说我要创建一个线程。当编写好的程序运行在Linux平台上时,这条指令就自动被转换成调用pthread_create()接口;当程序运行在Windows平台上使,这条指令就自动被转换程调用CreateThtread()接口。这样开发者就不需要关注不同底层平台上的API了,只需要知道有这么一条指令就可以了。
于是,中间语言(IL)就产生了。虽然从来没有和詹爷面对面交谈过,当年,詹爷也一定这么想过。而事实上,詹爷就是这么干的。詹爷定义了字节码规范,字节码就是中间语言指令。同事,詹爷开发了虚拟机,由虚拟机负责将字节码转换成不同平台上的特定API调用。由此,我们的目标终于得以达成,开发者终于不需要关注底层硬件和操作系统层面的细节,一切都由虚拟机解决,开发者只需要熟知Java语言规范和API即可实现特定功能开发,这极大地提高了生产效率,也大大降低了编程地难度和门槛。
上面我们简单回顾了下当时Java语言产生的背景、面临的问题以及解决办法,我们知道,Java语言从一开始就与其他语言的定位不同。现在我们知道,一款编程语言要实现兼容性,至少有两种办法:一种是通过编译器实现兼容,一种是通过中间语言实现兼容。关于这两者的区别,詹爷自己用4个单词做了精辟的总结,那就是:write once, run anywhere! 这后一句,run anywhere,自然就是指兼容性,无论是编译器还是中间语言,都能实现run anywhere。区别就在前一句: write once.使用编译器实现兼容性时,不能实现write once.例如上文所举的使用C语言创建线程的例子,如果你的程序一开始是为Linux编写的,那么当你想把程序迁移到Windows平台上时,你必须得修改程序,把里面涉及线程创建的代码全部改成Windows的接口。当你的程序很大,涉及大量的系统调用时,程序中必将有很多地方都需要改写,这种改写的工作量不可谓不打,伴随而来的是高风险。你不仅要精通Linux API也要精通Windows API,否则很容易一不小心中招、踩坑,为程序埋下隐患.
而使用中间语言,就用不着考虑这些想想都令人头大的问题了,你只需要写好程序,实现程序的逻辑。程序写好后,无论你想部署到Linux平台上,还是想部署到Windows平台上,都不用修改哪怕一句代码!所有的兼容性的工作都由虚拟机帮你做好了。这种生产效率的提高绝对是革命性的!Java语言虽然没有直接走上一条康庄达到,但却在很多领域都大放异彩。Java的出现,大大缩短了商业软件的开发周期,极大地提高了IT业地生产效率,极大地解放了程序员地创造力。
上面我们简单回顾了下当时Java语言产生的背景、面临的问题以及解决办法,我们知道,Java语言从一开始就与其他语言的定位不同。现在我们知道,一款编程语言要实现兼容性,至少有两种办法:一种是通过编译器实现兼容,一种是通过中间语言实现兼容。关于这两者的区别,詹爷自己用4个单词做了精辟的总结,那就是:write once, run anywhere! 这后一句,run anywhere,自然就是指兼容性,无论是编译器还是中间语言,都能实现run anywhere。区别就在前一句: write once.使用编译器实现兼容性时,不能实现write once.例如上文所举的使用C语言创建线程的例子,如果你的程序一开始是为Linux编写的,那么当你想把程序迁移到Windows平台上时,你必须得修改程序,把里面涉及线程创建的代码全部改成Windows的接口。当你的程序很大,涉及大量的系统调用时,程序中必将有很多地方都需要改写,这种改写的工作量不可谓不打,伴随而来的是高风险。你不仅要精通Linux API也要精通Windows API,否则很容易一不小心中招、踩坑,为程序埋下隐患.
而使用中间语言,就用不着考虑这些想想都令人头大的问题了,你只需要写好程序,实现程序的逻辑。程序写好后,无论你想部署到Linux平台上,还是想部署到Windows平台上,都不用修改哪怕一句代码!所有的兼容性的工作都由虚拟机帮你做好了。这种生产效率的提高绝对是革命性的!Java语言虽然没有直接走上一条康庄达到,但却在很多领域都大放异彩。Java的出现,大大缩短了商业软件的开发周期,极大地提高了IT业地生产效率,极大地解放了程序员地创造力。
总结
一款编程语言兼容底层系统地方式大抵上分为两种。
1.通过编译器实现兼容
例如C、C++等编程语言,既能运行于Linux操作系统,也能运行于Windows操作系统;既能运行于x86平台,也能运行于AMD平台。这种能力并不是编程语言本身所具备地,而是由编译器所赋予。针对不同地硬件平台和操作系统,开发特定地编译器,编译器能够将同样一段C/C++程序翻译程于目标平台匹配地机器指令,从而实现编程语言的兼容性。但是通过编译器实现兼容性时,如果涉及系统调用,往往都需要修改程序,调用特定系统的特定API,否则程序迁移到薪的平台上之后,无法运行
2.通过中间语言实现兼容
Java、C#等语言,都属于这种兼容方式。Java/C#程序被编译后,生成中间语言(ML),中间语言指令由虚拟机负责解释和运行。虚拟机在运行期将中间语言实时翻译成与特定底层平台匹配的机器指令并运行。无论程序最终运行在哪种底层平台上,源代码被编译生成的中间语言指令都是相同的,中间语言的兼容性由虚拟机负责完成。
通过编译器实现兼容性,由于源代码被直接编译程了本地机器指令,因此其执行效率非常高。而这正是中间语言的软肋。Java语言刚问世那几年,就一直因为其性能低下而被嗤之以鼻。但是随着Java语言版本的不断更新,随着大家对改善其性能所作出的持之以恒的努力,如今Java性能已经相当高,甚至币C/C++程序性能还要高。这是因为Java虚拟机内部对寄存器进行了大量手工优化,在某些场景下,人工优化自然会比C/C++编译器所做的机器优化效果要好很多
为什么不开发编译器的原因?
1.平台无关性
Java的设计理念就是"一次编写,到处运行(write Once, Run Anywhere)"通过编译成字节码,Java程序可以在任何安装Java虚拟机的平台上运行,无需位每个目标平台重新编译
2.j
一款编程语言兼容底层系统地方式大抵上分为两种。
1.通过编译器实现兼容
例如C、C++等编程语言,既能运行于Linux操作系统,也能运行于Windows操作系统;既能运行于x86平台,也能运行于AMD平台。这种能力并不是编程语言本身所具备地,而是由编译器所赋予。针对不同地硬件平台和操作系统,开发特定地编译器,编译器能够将同样一段C/C++程序翻译程于目标平台匹配地机器指令,从而实现编程语言的兼容性。但是通过编译器实现兼容性时,如果涉及系统调用,往往都需要修改程序,调用特定系统的特定API,否则程序迁移到薪的平台上之后,无法运行
2.通过中间语言实现兼容
Java、C#等语言,都属于这种兼容方式。Java/C#程序被编译后,生成中间语言(ML),中间语言指令由虚拟机负责解释和运行。虚拟机在运行期将中间语言实时翻译成与特定底层平台匹配的机器指令并运行。无论程序最终运行在哪种底层平台上,源代码被编译生成的中间语言指令都是相同的,中间语言的兼容性由虚拟机负责完成。
通过编译器实现兼容性,由于源代码被直接编译程了本地机器指令,因此其执行效率非常高。而这正是中间语言的软肋。Java语言刚问世那几年,就一直因为其性能低下而被嗤之以鼻。但是随着Java语言版本的不断更新,随着大家对改善其性能所作出的持之以恒的努力,如今Java性能已经相当高,甚至币C/C++程序性能还要高。这是因为Java虚拟机内部对寄存器进行了大量手工优化,在某些场景下,人工优化自然会比C/C++编译器所做的机器优化效果要好很多
为什么不开发编译器的原因?
1.平台无关性
Java的设计理念就是"一次编写,到处运行(write Once, Run Anywhere)"通过编译成字节码,Java程序可以在任何安装Java虚拟机的平台上运行,无需位每个目标平台重新编译
2.j
中间语言翻译
前面讨论了既能实现兼容性,又能自动处理底层系统调用的又快又好的办法,那就是使用中间语言。可是CPU是不认中间语言的,它无法直接执行中间语言。为了使中间语言能够被CPU执行,虚拟机必须将其翻译成对应机器上的机器指令。
于是接下来的一个课题就是,怎样将中间语言翻译成对应的机器指令并得以执行。注意,虽然这是一个课题,但却包含两个问题,一个问题是怎样把中间语言翻译成对应的机器指令,另一个问题是翻译完了还要能够执行。
将中间语言翻译成对应的本地机器指令:可以使用C语言为每一个Java字节码指令写一个对应的实现函数,也可以直接为中间语言生成对应的本地机器码并通过JMP方式跳转到机器码来执行字节码指令
于是接下来的一个课题就是,怎样将中间语言翻译成对应的机器指令并得以执行。注意,虽然这是一个课题,但却包含两个问题,一个问题是怎样把中间语言翻译成对应的机器指令,另一个问题是翻译完了还要能够执行。
将中间语言翻译成对应的本地机器指令:可以使用C语言为每一个Java字节码指令写一个对应的实现函数,也可以直接为中间语言生成对应的本地机器码并通过JMP方式跳转到机器码来执行字节码指令
从中间语言翻译到机器码
"机器生汇编,汇编生B,B生成C,C生万物"。虽然现代的程序员们,手里拿着Java、C#等这些高端武器,沐浴在高级语言所带来的满满的幸福感中,分分钟就能编写出一个强大的程序,不需要拥有太多专业知识,仅仅依靠先进的武器,就能发出强大的威力。可是,这些高级语言的背后,却是虚拟机在辛辛苦苦、勤勤恳恳地干活,实现"万物到C,C到汇编,汇编到机器"地逆转换,这里地"万物",自然也包括Java的中间语言——字节码指令。
将字节码指令翻译到对应的机器码,一种可行的方法是,使用C程序,将字节码的每一条指令,都逐行逐行地解释成C程序。当执行字节码地程序——JVM(Java虚拟机)程序本身编译以后,字节码指令所对应的C程序被一起编译成本地机器码,于是虚拟机在解释字节码指令时,自然就会执行对应的C程序所对应的机器码。
"机器生汇编,汇编生B,B生成C,C生万物"。虽然现代的程序员们,手里拿着Java、C#等这些高端武器,沐浴在高级语言所带来的满满的幸福感中,分分钟就能编写出一个强大的程序,不需要拥有太多专业知识,仅仅依靠先进的武器,就能发出强大的威力。可是,这些高级语言的背后,却是虚拟机在辛辛苦苦、勤勤恳恳地干活,实现"万物到C,C到汇编,汇编到机器"地逆转换,这里地"万物",自然也包括Java的中间语言——字节码指令。
将字节码指令翻译到对应的机器码,一种可行的方法是,使用C程序,将字节码的每一条指令,都逐行逐行地解释成C程序。当执行字节码地程序——JVM(Java虚拟机)程序本身编译以后,字节码指令所对应的C程序被一起编译成本地机器码,于是虚拟机在解释字节码指令时,自然就会执行对应的C程序所对应的机器码。
通过C程序翻译
计算两数之和。首先,假设使用C程序编码。假设我们发明了某种中间语言,该中间语言定义了一条指令来实现两个正整数相加,这条指令的助记符是iadd,其对应的数字唯一编号是0x01,同事假定我们开发了一款虚拟机来解释这条指令,那么解释程序将变成如下这样:
计算两数之和。首先,假设使用C程序编码。假设我们发明了某种中间语言,该中间语言定义了一条指令来实现两个正整数相加,这条指令的助记符是iadd,其对应的数字唯一编号是0x01,同事假定我们开发了一款虚拟机来解释这条指令,那么解释程序将变成如下这样:
这段示例程序中的run()函数,可以看作是执行引擎。执行引擎接受操作数和指令编码,判断指令编码是否是iadd,如果是iadd指令,便对入栈的两个操作参数执行加法运算,并返回结果。是不是很简单?第一代JVM的执行引擎就是这样么简单。虽然通过C程序对中间语言进行解释,程序简单明了,逻辑清晰易懂,然而这种方式却有一个比较大的缺陷——效率低下。所以第一代Java虚拟机被广为诟病和吐槽,因为效率实在是太低了。既然使用C这种高级语言还是效率低,那就直接翻译成机器码吧,这样一定可以很快了吧
直接翻译为机器码
将中间语言直接翻译为机器码,办法由很多。追根到底,我们还是利用了CPU执行代码的原理。要让CPU执行一段代码,只需将CS:IP段寄存器只想到代码段入口处即可。这里首先解释下什么是CS与IP.这是物理CPU内部的两个寄存器。对于一台物理机器而言,这两个寄存器是最重要的寄存器,因为CPU在取指令时便完全依靠这两个寄存器。CS寄存器保存段地址,IP保存偏移地址。CS和IP这两个寄存器的值能够唯一确定内存中的一个地址,CPU在执行机器指令之前,便通过这两个寄存器定位到目标内存地址,并将该位置处的机器指令取出来进行运算。函数跳转的本质其实便是修改CS和IP这两个寄存器的内容,十七指向到目标函数所在的内存首地址,这样CPU便能执行目标函数了。Java虚拟机要想让物理CPU直接执行Java程序所对应的目标机器码,也得修改这两个寄存器才能实现。
修改CS:IP段寄存器的办法有很多,既可以使用汇编直接修改,也可以在高级语言中通过语法糖的形式修改,C语言中就有这样的语法糖(也许交语法规则更加恰当一些,但是某种特定的写法最终都由编译器来进行转换,到了机器码层面,已经没有这些规则了,因此将其称为语法糖也未尝不可)。
C语言中提供了一种办法,可以将CS:IP段寄存器直接指向到某个入口地址,这种办法就是定义函数指针(注:不是指针型函数,这两个概念相差很大,完全不同)
在上面的示例中,我们先定义了一个函数指针fun,接着将其指向int run(int, int)函数入口,当run()函数被操作系统加载后,其机器码指令将被保存到代码段中,fun指针就只想该代码段的首地址。最后,通过int r = fun(a,b) 执行fun,由于fun指向run()函数入口,因此系统最终执行的实际上是run()函数。
在Linux上运行这段程序,最终屏幕上将正常输出"r = 8"这行文字,其实,fun=(void*)run这句代码之所以得以正常运行,完全是因为编译器提供了这种语法支持,因此我们可以将其视为C语言的语法糖。这句代码被编译后,实际上相当于修改了CS:IP段寄存器的指向,因此当CPU运行到这里后,由于CS:IP指向到了run()函数首地址,因此程序就跳转到了run()函数。
上面的示例所要实现的功能跟前面的示例一样,都是实现对两个正整数求和。本示例也同样使用了C语言所提供的语法糖,通过fun=(void*)code这样的方式达到间接修改CS:IP段寄存器的指向的目的。但是本例的不同之处在于,在上例中,fun指针是直接指向了int run(int,int)函数,而本例中,fun指针却是指向了一个char数组首地址。char数组中保存了一串数字,这些数字其实是x86平台上的一串机器指令,因此CPU可以直接将其当作指令来执行。这是一个特别神奇的神器!很多大牛都用它实现了很多匪夷所思的功能。集中起来而言,就是大家用它实现了各种动态功能,各种在运行期动态修改程序走向的功能。
詹爷早就看好了这件神器,并且是此中高手,在JVM的后续版本中,正是这件神器挽救了Java语言,使其性能得到了突飞猛进的提高,在某种成都上快赶上甚至超越C和C++语言的速度了。
将中间语言直接翻译为机器码,办法由很多。追根到底,我们还是利用了CPU执行代码的原理。要让CPU执行一段代码,只需将CS:IP段寄存器只想到代码段入口处即可。这里首先解释下什么是CS与IP.这是物理CPU内部的两个寄存器。对于一台物理机器而言,这两个寄存器是最重要的寄存器,因为CPU在取指令时便完全依靠这两个寄存器。CS寄存器保存段地址,IP保存偏移地址。CS和IP这两个寄存器的值能够唯一确定内存中的一个地址,CPU在执行机器指令之前,便通过这两个寄存器定位到目标内存地址,并将该位置处的机器指令取出来进行运算。函数跳转的本质其实便是修改CS和IP这两个寄存器的内容,十七指向到目标函数所在的内存首地址,这样CPU便能执行目标函数了。Java虚拟机要想让物理CPU直接执行Java程序所对应的目标机器码,也得修改这两个寄存器才能实现。
修改CS:IP段寄存器的办法有很多,既可以使用汇编直接修改,也可以在高级语言中通过语法糖的形式修改,C语言中就有这样的语法糖(也许交语法规则更加恰当一些,但是某种特定的写法最终都由编译器来进行转换,到了机器码层面,已经没有这些规则了,因此将其称为语法糖也未尝不可)。
C语言中提供了一种办法,可以将CS:IP段寄存器直接指向到某个入口地址,这种办法就是定义函数指针(注:不是指针型函数,这两个概念相差很大,完全不同)
在上面的示例中,我们先定义了一个函数指针fun,接着将其指向int run(int, int)函数入口,当run()函数被操作系统加载后,其机器码指令将被保存到代码段中,fun指针就只想该代码段的首地址。最后,通过int r = fun(a,b) 执行fun,由于fun指向run()函数入口,因此系统最终执行的实际上是run()函数。
在Linux上运行这段程序,最终屏幕上将正常输出"r = 8"这行文字,其实,fun=(void*)run这句代码之所以得以正常运行,完全是因为编译器提供了这种语法支持,因此我们可以将其视为C语言的语法糖。这句代码被编译后,实际上相当于修改了CS:IP段寄存器的指向,因此当CPU运行到这里后,由于CS:IP指向到了run()函数首地址,因此程序就跳转到了run()函数。
上面的示例所要实现的功能跟前面的示例一样,都是实现对两个正整数求和。本示例也同样使用了C语言所提供的语法糖,通过fun=(void*)code这样的方式达到间接修改CS:IP段寄存器的指向的目的。但是本例的不同之处在于,在上例中,fun指针是直接指向了int run(int,int)函数,而本例中,fun指针却是指向了一个char数组首地址。char数组中保存了一串数字,这些数字其实是x86平台上的一串机器指令,因此CPU可以直接将其当作指令来执行。这是一个特别神奇的神器!很多大牛都用它实现了很多匪夷所思的功能。集中起来而言,就是大家用它实现了各种动态功能,各种在运行期动态修改程序走向的功能。
詹爷早就看好了这件神器,并且是此中高手,在JVM的后续版本中,正是这件神器挽救了Java语言,使其性能得到了突飞猛进的提高,在某种成都上快赶上甚至超越C和C++语言的速度了。
我们还没有实现由中间语言直接翻译为机器码的伟大目标呢,聪明的你一定早就想到了办法,对,果然与你想的一样,我们既然都能在C语言中直接动态执行机器码了,注意,是动态。我们只要将中间语言指令直接翻译为机器码,然后让CS:IP直接指向这段机器码,问题不就解决了吗。
是的,事情的确就是这么简单!现代的JVM的确就是这么干的。不过,在JVM里面又不完全是这么干的,但这不是很重要,重要的是,我们已经有办法、有能力完成将中间语言直接翻译成机器码并动态执行的目标
是的,事情的确就是这么简单!现代的JVM的确就是这么干的。不过,在JVM里面又不完全是这么干的,但这不是很重要,重要的是,我们已经有办法、有能力完成将中间语言直接翻译成机器码并动态执行的目标
本地编译
虽然将中间语言直接翻译为机器码并直接运行,其效率相比使用C语言来解释执行已经提高了很多,但是,由于中间语言有自己的一套内存管理和代码执行方式,因此,实现同样的功能,虽然使用中间语言只需要写几行代码,但是翻译后的机器码,比直接编写机器码,还要多出很多指令。指令数量增多,意味着在同样的硬件平台上,执行时间成本必然增加,因此其运行效率仍然不够高。即使与同样属于高级语言的C语言相比,它们实现相同的功能,C语言编译后所生成的机器码,也比中间语言直接翻译成的机器码,在数量上要精简很多。例如,使用C语言对两个正整数求和。示例程序如图:
可以看到,add()函数总共仅包含7条指令(保存edx/ecx现场不算),即可完成求和运算,如果去掉push %rbp等入栈、出战的辅助性指令,只需要下面3条机器指令即可完成:
=> 0x00007ff7b42415ea <+10>:mov 0x10(%rbp),%edx
0x00007ff7b42415ed <+13>:mov 0x18(%rbp),%eax
0x00007ff7b42415f0 <+16>:add %edx,%eax
虽然将中间语言直接翻译为机器码并直接运行,其效率相比使用C语言来解释执行已经提高了很多,但是,由于中间语言有自己的一套内存管理和代码执行方式,因此,实现同样的功能,虽然使用中间语言只需要写几行代码,但是翻译后的机器码,比直接编写机器码,还要多出很多指令。指令数量增多,意味着在同样的硬件平台上,执行时间成本必然增加,因此其运行效率仍然不够高。即使与同样属于高级语言的C语言相比,它们实现相同的功能,C语言编译后所生成的机器码,也比中间语言直接翻译成的机器码,在数量上要精简很多。例如,使用C语言对两个正整数求和。示例程序如图:
可以看到,add()函数总共仅包含7条指令(保存edx/ecx现场不算),即可完成求和运算,如果去掉push %rbp等入栈、出战的辅助性指令,只需要下面3条机器指令即可完成:
=> 0x00007ff7b42415ea <+10>:mov 0x10(%rbp),%edx
0x00007ff7b42415ed <+13>:mov 0x18(%rbp),%eax
0x00007ff7b42415f0 <+16>:add %edx,%eax
而如果使用中间语言来执行求和运算,翻译后得到的机器码指令将会多出几个数量级。以Java为例。首先,编写Java源程序,编译之后生成的字节码如图。
而每一条字节码指令最终都会对应一大堆机器指令,机器指令的数量远超C语言编译后的机器指令数量。由此可见,中间语言由于其本身不能直接被CPU执行,为了能够被CPU执行,中间语言在完成同样一个功能时,需要准备更多便于自我管理的上下文环境,最后才能执行目标机器指令。准备上下文环境最终也是依靠机器码去实现,因此中间语言最终便生成了更多机器码,当然执行效率就降低了。
为了能够进一步提升性能,JVM提供了一种机制,能够将中间语言(字节码)直接编译为本地机器指令。可能聪明的你马上会想到这样一个问题,既然中间语言在运行期能够被逐个直接翻译成机器码,那么在编译器不也能嘛?例如对于Java源代码,可以现在本地编译成字节码,再将字节码诸葛替换为机器指令,这样最终不就生成了可直接被CPU执行的、由机器码组成的程序了嘛?
这个思路的确可以一试,例如安卓和部分JVM所实现的AOT(Ahead Of Time)特性便是这方面的尝试,但是这种方式并没有减少机器指令的数量级问题。
事实上,JVM的大牛们在JIT(即时编译)、内存分配等方面倾注了大量心血,想出了很多天马行空而又切实可行的好主意,能够对热点代码进行大幅度指令优化,将Java程序的执行效率大幅提升。正是由于JVM可以在运行期基于上下文链路进行各种优化,因此优化后的指令质量比C/C++编译出的指令质量更高,因此才会有部分Java程序性能反而超过C/C++程序的现象。如果离开了这些动态变化,Java程序的执行效率是无论如何也提升不上去的
而每一条字节码指令最终都会对应一大堆机器指令,机器指令的数量远超C语言编译后的机器指令数量。由此可见,中间语言由于其本身不能直接被CPU执行,为了能够被CPU执行,中间语言在完成同样一个功能时,需要准备更多便于自我管理的上下文环境,最后才能执行目标机器指令。准备上下文环境最终也是依靠机器码去实现,因此中间语言最终便生成了更多机器码,当然执行效率就降低了。
为了能够进一步提升性能,JVM提供了一种机制,能够将中间语言(字节码)直接编译为本地机器指令。可能聪明的你马上会想到这样一个问题,既然中间语言在运行期能够被逐个直接翻译成机器码,那么在编译器不也能嘛?例如对于Java源代码,可以现在本地编译成字节码,再将字节码诸葛替换为机器指令,这样最终不就生成了可直接被CPU执行的、由机器码组成的程序了嘛?
这个思路的确可以一试,例如安卓和部分JVM所实现的AOT(Ahead Of Time)特性便是这方面的尝试,但是这种方式并没有减少机器指令的数量级问题。
事实上,JVM的大牛们在JIT(即时编译)、内存分配等方面倾注了大量心血,想出了很多天马行空而又切实可行的好主意,能够对热点代码进行大幅度指令优化,将Java程序的执行效率大幅提升。正是由于JVM可以在运行期基于上下文链路进行各种优化,因此优化后的指令质量比C/C++编译出的指令质量更高,因此才会有部分Java程序性能反而超过C/C++程序的现象。如果离开了这些动态变化,Java程序的执行效率是无论如何也提升不上去的
神奇的指令
Java语言想要一统江湖,兼容各种平台,又要实现"write once, run anywhere"的伟大梦想,只有依靠"中间语言"这一条通道了。思路是有了,但是具体如何执行,或者说如何才能实现呢?中间语言究竟长啥样,谁也不知道。
前面讲过,在使用C语言或者汇编或C++进行底层开发时,必须熟悉硬件平台本身所提供的指令,或者熟悉底层软件平台所提供的API.在不同的平台上实现同样一种功能,需要调用不同的底层接口或指令。同事,编译器也直接依赖于平台,大家需要为不同的平台开发不同的编译器。詹爷觉得这种方式简直太low了,他觉得一款好的编程语言应该是这样的,不管程序员在哪种软硬件平台上开发,如果要实现同样一个功能,他只需要调用同一个接口,它不需要感知这个平台与其他平台之间有什么差异。至于把同一个接口翻译成对应的机器的指令这事,就交给虚拟机来做吧,程序员不要再关注这事了。接口同意了,就相当于两个不同的机器说的是同一种语言,这样来实现跨平台就会轻而易举。从统一接口这个角度看,中间语言应该是你知、我知、所有的开发者都知道,但是唯独底层的物理CPU不知道的一种语言。这样看来,Java语言本身起步就符合中间语言的标准?可是别忘了,中间语言还得有一个必须什么都懂的人,这个人就是Java虚拟机,虚拟机就是全世界广大程序员免费而又无比专业的贴身翻译,它必须负责将这种中间语言精确地翻译成机器平台的机器指令。可是很遗憾,虚拟机这位贴身翻译读不懂Java程序。所以Java语言不是中间语言。为啥虚拟机读不懂Java程序呢?这还得从语言的人性化谈起。高级语言之所以高级,就在于其语法和表达规范遵循人类的思维习惯,但是这不符合机器的思维,即使虚拟机也不行。尤其是Java语言,由于比其他语言更加面向对象,其字面含义带有更加彻底的人类主观色彩,但是机器就不能理解了,机器只认得内存、对战,其他一概不认,所以Java虚拟机读不懂Java语言倒也情有可原。不过也并非所有的虚拟机都不懂面向对象的语言,JavaScript执行引擎就是个例外——JS脚本不需要编译就能被JS引擎直接执行,虽然这么玩也行,但是JS的执行引擎不跨平台,这与Java语言的远大理想相去甚远,道不同不相为谋。
既然Java语言本身不能作为中间语言,可是中间语言又是理想的实现跨平台的技术方向,这可怎么办呢?解决办法很简单,为虚拟机这位贴身翻译再配一个翻译,负责将Java程序翻译成理想中的中间语言不就行了吗?再开发个编译器,通过编译器将Java语言翻译成中间语言,然后再交给虚拟机,其再将中间语言翻译成对应机器平台上的指令。
前面讲过,在使用C语言或者汇编或C++进行底层开发时,必须熟悉硬件平台本身所提供的指令,或者熟悉底层软件平台所提供的API.在不同的平台上实现同样一种功能,需要调用不同的底层接口或指令。同事,编译器也直接依赖于平台,大家需要为不同的平台开发不同的编译器。詹爷觉得这种方式简直太low了,他觉得一款好的编程语言应该是这样的,不管程序员在哪种软硬件平台上开发,如果要实现同样一个功能,他只需要调用同一个接口,它不需要感知这个平台与其他平台之间有什么差异。至于把同一个接口翻译成对应的机器的指令这事,就交给虚拟机来做吧,程序员不要再关注这事了。接口同意了,就相当于两个不同的机器说的是同一种语言,这样来实现跨平台就会轻而易举。从统一接口这个角度看,中间语言应该是你知、我知、所有的开发者都知道,但是唯独底层的物理CPU不知道的一种语言。这样看来,Java语言本身起步就符合中间语言的标准?可是别忘了,中间语言还得有一个必须什么都懂的人,这个人就是Java虚拟机,虚拟机就是全世界广大程序员免费而又无比专业的贴身翻译,它必须负责将这种中间语言精确地翻译成机器平台的机器指令。可是很遗憾,虚拟机这位贴身翻译读不懂Java程序。所以Java语言不是中间语言。为啥虚拟机读不懂Java程序呢?这还得从语言的人性化谈起。高级语言之所以高级,就在于其语法和表达规范遵循人类的思维习惯,但是这不符合机器的思维,即使虚拟机也不行。尤其是Java语言,由于比其他语言更加面向对象,其字面含义带有更加彻底的人类主观色彩,但是机器就不能理解了,机器只认得内存、对战,其他一概不认,所以Java虚拟机读不懂Java语言倒也情有可原。不过也并非所有的虚拟机都不懂面向对象的语言,JavaScript执行引擎就是个例外——JS脚本不需要编译就能被JS引擎直接执行,虽然这么玩也行,但是JS的执行引擎不跨平台,这与Java语言的远大理想相去甚远,道不同不相为谋。
既然Java语言本身不能作为中间语言,可是中间语言又是理想的实现跨平台的技术方向,这可怎么办呢?解决办法很简单,为虚拟机这位贴身翻译再配一个翻译,负责将Java程序翻译成理想中的中间语言不就行了吗?再开发个编译器,通过编译器将Java语言翻译成中间语言,然后再交给虚拟机,其再将中间语言翻译成对应机器平台上的指令。
解决了中间语言怎么实现的问题之后,詹爷要做的第一件重要的事就是,决定中间语言长啥样,最终的结果大家都知道了,这个所谓的中间语言就是Java字节码指令集,没错,这就是中间语言。虽然Java语言是面向对象的,符合人类思维习惯的,但是Java指令却是刻板的,不知啥是对象,只知道压栈,读写局部变量表,调用目标方法,等等。詹爷定义的通用指令集如下:
iconst_0, 将自然数0压入操作数栈
iconst_1,将自然数1压入操作数栈
iload_0, 将索引为0的局部变量表的int型压入操作数栈
istore_1,将操作数栈顶的int型数据写入索引为1的局部变量表中
//.....
在JVM源代码中,定义了Java语言的全部指令集。很多人一定还不知道,Java的所有指令都使用8位二进制描述,因此,Java的指令总数不超过255个。有了 这套通用的指令集,一统江湖就有希望了。不过这看似简单,实则不简单。表面看来,一统才区区200多个指令,但这正式作者深厚功力的体现。大家都知道,指令集一般是计算机硬件才有的东西,而作者却在软件层面定义了一套同样的东西。但是软件本身不具备执行程序的能力,软件最终还得依靠硬件指令才能完成逻辑计算。因此,一套好的软件指令必须不能超出硬件指令所能表达的计算能力,同时又要对硬件指令进行高度抽象与概括。换言之,如果你定义了一套与硬件指令集完全一模一样的软件指令集,那大家还用你干嘛,不如直接用硬件指令得了。所以,詹爷所设计的指令集必定有其独到之处。
iconst_0, 将自然数0压入操作数栈
iconst_1,将自然数1压入操作数栈
iload_0, 将索引为0的局部变量表的int型压入操作数栈
istore_1,将操作数栈顶的int型数据写入索引为1的局部变量表中
//.....
在JVM源代码中,定义了Java语言的全部指令集。很多人一定还不知道,Java的所有指令都使用8位二进制描述,因此,Java的指令总数不超过255个。有了 这套通用的指令集,一统江湖就有希望了。不过这看似简单,实则不简单。表面看来,一统才区区200多个指令,但这正式作者深厚功力的体现。大家都知道,指令集一般是计算机硬件才有的东西,而作者却在软件层面定义了一套同样的东西。但是软件本身不具备执行程序的能力,软件最终还得依靠硬件指令才能完成逻辑计算。因此,一套好的软件指令必须不能超出硬件指令所能表达的计算能力,同时又要对硬件指令进行高度抽象与概括。换言之,如果你定义了一套与硬件指令集完全一模一样的软件指令集,那大家还用你干嘛,不如直接用硬件指令得了。所以,詹爷所设计的指令集必定有其独到之处。
常见汇编指令。
由于我们主要讲解Java虚拟机执行引擎的内部实现机制,而Java虚拟机的执行引擎有太多地方直接使用了机器码实现,因此很多地方都不可避免地回接触到汇编指令。不过汇编指令并没有想象中的那么可怕,下面简单介绍5个常见的汇编指令,大部分机器指令集都支持一下5类计算:
1.数据传送指令
这些指令主要在寄存器与内存、寄存器与输入/输出端口之间传送数据,例如:
// 将自然数1传送到eax寄存器
mov 1, %eax
// 将栈顶数据弹出至eax寄存器
pop %eax
(注:由于机器指令是二进制,写出来谁也看不懂,因此这里使用汇编指令代替,下面也全部这样表达,汇编本来就是机器指令的助记符)
可以这么说,数据传送指令几乎是任何硬件系统都必须支持的指令
2.算数运算指令
包括算数基本四则运算、浮点运算、数学运算(正弦、反弦等)。例如:
// 将自然数3与eax寄存器中的数累加,并将结果存储金eax中
add 3, %eax
// 对ebx寄存器中的数增1
inc %ebx
3.逻辑运算指令
与、或、非、左移、右移等指令,都属于逻辑运算指令。例如
// 将eax中的数左移1个二进位
shl %eax,1
// 对al寄存器中的数和操作数进行与操作
and al, 00111011B
4.串指令
连续空间非陪,连续空间取值,传送等,都要使用船只零。很多高级编程语言都支持字符串运算,如果硬件没有船只零,不敢想象计算机的世界会变成什么样
5.程序转移指令
if-else判断,for循环,while循环,函数调用等,都需要依靠程序转移指令,否则程序无法跳转。没有这些指令,程序不能模块化,无法被分隔成一个一个方法,更无法通过循环来解决很多重要的问题。常见的程序转移指令包括jmp跳转、loop循环、ret等。
在研究JVM源码的过程中,肯定绕不过汇编这一道坎
由于我们主要讲解Java虚拟机执行引擎的内部实现机制,而Java虚拟机的执行引擎有太多地方直接使用了机器码实现,因此很多地方都不可避免地回接触到汇编指令。不过汇编指令并没有想象中的那么可怕,下面简单介绍5个常见的汇编指令,大部分机器指令集都支持一下5类计算:
1.数据传送指令
这些指令主要在寄存器与内存、寄存器与输入/输出端口之间传送数据,例如:
// 将自然数1传送到eax寄存器
mov 1, %eax
// 将栈顶数据弹出至eax寄存器
pop %eax
(注:由于机器指令是二进制,写出来谁也看不懂,因此这里使用汇编指令代替,下面也全部这样表达,汇编本来就是机器指令的助记符)
可以这么说,数据传送指令几乎是任何硬件系统都必须支持的指令
2.算数运算指令
包括算数基本四则运算、浮点运算、数学运算(正弦、反弦等)。例如:
// 将自然数3与eax寄存器中的数累加,并将结果存储金eax中
add 3, %eax
// 对ebx寄存器中的数增1
inc %ebx
3.逻辑运算指令
与、或、非、左移、右移等指令,都属于逻辑运算指令。例如
// 将eax中的数左移1个二进位
shl %eax,1
// 对al寄存器中的数和操作数进行与操作
and al, 00111011B
4.串指令
连续空间非陪,连续空间取值,传送等,都要使用船只零。很多高级编程语言都支持字符串运算,如果硬件没有船只零,不敢想象计算机的世界会变成什么样
5.程序转移指令
if-else判断,for循环,while循环,函数调用等,都需要依靠程序转移指令,否则程序无法跳转。没有这些指令,程序不能模块化,无法被分隔成一个一个方法,更无法通过循环来解决很多重要的问题。常见的程序转移指令包括jmp跳转、loop循环、ret等。
在研究JVM源码的过程中,肯定绕不过汇编这一道坎
JVM指令。
詹爷的指令集比上述硬件指令集更加丰富,这是因为Java是面向对象的编程语言,自然要有一套支持类型操作的特殊指令。总体而言,詹爷涉及的指令集分为一下几部分
1.数据交换指令
对JVM稍有了解的人都知道,JVM内存分配分为操作数栈、局部变量表、Java堆、常量池、方法区/既然有这些内存区域,那么必须要有指令支持数据在这些内存区域之间的传送和交换。例如,当你在Java方法中访问一个静态变量时,那么其运算过程必然伴随JVM将数据从常量池传送到操作数栈的指令调用。这与硬件指令不同。以x86为例,一个在硬件上直接执行的程序,其内存一般氛围寄存器、数据段、堆栈、常量区、代码段,CPU为了完成运算,必然要涉及将数据从这些内存区域传送到寄存器的指令调用。
JVM执行逻辑运算的主战场是操作数栈(iinc指令除外,该指令可以直接对局部变量进行运算),注意,是操作数栈,不管你把数据放在堆栈中,还是放在常量池中,你要执行运算,最终JVM都会将数据传送到操作数栈中,而硬件执行逻辑运算的主战场是寄存器,不管你把数据放在数据段中,还是代码段,最终CPU都会将数据传送到寄存器中。逻辑u那算完成后,再把结果转移出去。
JVM标准提供了丰富的数据交换指令,例如iload、istore、lload、lstore、fload、fstore、dload、dstore、ldc、bipush等指令,詹爷用这些指令来实现操作数栈和局部变量表之间的数据交换.JVM规范还提供了像getfield和putfield这样的指令,詹爷用这些指令来实现Java堆中的对象的字段和操作数栈之间的数据交换。JVM规范还提供了像getstatic和putstatic这样的指令,詹爷用这些指令来实现类中的字段和操作数栈之间的数据交换。JVM规范还提供了像baload、bastore、caloadhe castore这样的指令,詹爷用这些指令来实现JVM堆中的数组和操作数栈之间的数据交换。
至此,你应该能够想到,JVM并不是随随便便就将内存分成了操作数栈、局部变量表、Java堆、常量池、方法去这几个区域的,人家都是有专门的指令在后面默默支撑的。或者说,既然把这些内存区域划分出来,就必须有相应的指令去管理
2.函数调用指令
函数调用指令集可以归入到"程序转移指令集"中,也可以单独拿出来说事。由于Java中的函数类型比较丰富,因此必然要支持更多的函数调用方式。詹爷涉及了多个函数调用指令,例如,invokevirtual、invokeinterface、invokespecial、invokestatic和return等。这比硬件所支持的函数调用指令集要丰富一些。以x86为例,x86中主要使用call指令和ret指令来保存现场和恢复现场,这往往会伴随CPU物理寄存器入栈和出战。
JVM没有物理寄存器,所以用操作数栈和PC寄存器来替代。JVM保存现场和恢复现场的解决方案是向Java堆栈中压入一个战阵,函数返回的时候从Java堆栈中弹出一个栈帧。JVM调用函数的时候,不能像CPU硬件那样,直接跳转到对应的代码段。这是因为Java函数的代码并没有被存放到代码段中,而是被放在了一个code缓存中,每一个Java函数的代码在这个code缓存中都会有一个索引位置,最终JVM会跳转到这个索引位置处执行Java函数调用。同时,Java的函数一定是被封装在类中的,因此JVM在执行函数调用时,还需要通过类寻址等等一系列运算,最终才能定位到这个入口。
3.运算指令集
JVM和运算相关的指令集主要有算术运算、位运算、比较运算、逻辑运算等,JVM还未各种基本类型的运算提供不同的操作码;x86也有算术运算、逻辑运算、位运算、比较运算,但是所有的操作都是直接针对寄存器的二进制数进行的,不区分数据类型。JVM规范中常见的运算指令包括Iadd(对两个int型数据求和)、isub(对两个int型数据做减法)、fadd(对两个float浮点数求和)、ddiv(两个double双精度数据相除)等。
4.控制转移指令
与硬件CPU一样,JVM规范也提供了常见的控制转移指令,例如switch分支选择指令、if-else条件判断、do-while循环、foreach循环、for循环、return返回、break中断循环、continue继续循环
5.对象创建与类型转换指令
作为一门面向对象的语言,JVM规范自然要提供一套创建对象的指令。在Java语法层面使用关键字new可以实例化一个对象,而对应的字节码指令也是new
詹爷的指令集比上述硬件指令集更加丰富,这是因为Java是面向对象的编程语言,自然要有一套支持类型操作的特殊指令。总体而言,詹爷涉及的指令集分为一下几部分
1.数据交换指令
对JVM稍有了解的人都知道,JVM内存分配分为操作数栈、局部变量表、Java堆、常量池、方法区/既然有这些内存区域,那么必须要有指令支持数据在这些内存区域之间的传送和交换。例如,当你在Java方法中访问一个静态变量时,那么其运算过程必然伴随JVM将数据从常量池传送到操作数栈的指令调用。这与硬件指令不同。以x86为例,一个在硬件上直接执行的程序,其内存一般氛围寄存器、数据段、堆栈、常量区、代码段,CPU为了完成运算,必然要涉及将数据从这些内存区域传送到寄存器的指令调用。
JVM执行逻辑运算的主战场是操作数栈(iinc指令除外,该指令可以直接对局部变量进行运算),注意,是操作数栈,不管你把数据放在堆栈中,还是放在常量池中,你要执行运算,最终JVM都会将数据传送到操作数栈中,而硬件执行逻辑运算的主战场是寄存器,不管你把数据放在数据段中,还是代码段,最终CPU都会将数据传送到寄存器中。逻辑u那算完成后,再把结果转移出去。
JVM标准提供了丰富的数据交换指令,例如iload、istore、lload、lstore、fload、fstore、dload、dstore、ldc、bipush等指令,詹爷用这些指令来实现操作数栈和局部变量表之间的数据交换.JVM规范还提供了像getfield和putfield这样的指令,詹爷用这些指令来实现Java堆中的对象的字段和操作数栈之间的数据交换。JVM规范还提供了像getstatic和putstatic这样的指令,詹爷用这些指令来实现类中的字段和操作数栈之间的数据交换。JVM规范还提供了像baload、bastore、caloadhe castore这样的指令,詹爷用这些指令来实现JVM堆中的数组和操作数栈之间的数据交换。
至此,你应该能够想到,JVM并不是随随便便就将内存分成了操作数栈、局部变量表、Java堆、常量池、方法去这几个区域的,人家都是有专门的指令在后面默默支撑的。或者说,既然把这些内存区域划分出来,就必须有相应的指令去管理
2.函数调用指令
函数调用指令集可以归入到"程序转移指令集"中,也可以单独拿出来说事。由于Java中的函数类型比较丰富,因此必然要支持更多的函数调用方式。詹爷涉及了多个函数调用指令,例如,invokevirtual、invokeinterface、invokespecial、invokestatic和return等。这比硬件所支持的函数调用指令集要丰富一些。以x86为例,x86中主要使用call指令和ret指令来保存现场和恢复现场,这往往会伴随CPU物理寄存器入栈和出战。
JVM没有物理寄存器,所以用操作数栈和PC寄存器来替代。JVM保存现场和恢复现场的解决方案是向Java堆栈中压入一个战阵,函数返回的时候从Java堆栈中弹出一个栈帧。JVM调用函数的时候,不能像CPU硬件那样,直接跳转到对应的代码段。这是因为Java函数的代码并没有被存放到代码段中,而是被放在了一个code缓存中,每一个Java函数的代码在这个code缓存中都会有一个索引位置,最终JVM会跳转到这个索引位置处执行Java函数调用。同时,Java的函数一定是被封装在类中的,因此JVM在执行函数调用时,还需要通过类寻址等等一系列运算,最终才能定位到这个入口。
3.运算指令集
JVM和运算相关的指令集主要有算术运算、位运算、比较运算、逻辑运算等,JVM还未各种基本类型的运算提供不同的操作码;x86也有算术运算、逻辑运算、位运算、比较运算,但是所有的操作都是直接针对寄存器的二进制数进行的,不区分数据类型。JVM规范中常见的运算指令包括Iadd(对两个int型数据求和)、isub(对两个int型数据做减法)、fadd(对两个float浮点数求和)、ddiv(两个double双精度数据相除)等。
4.控制转移指令
与硬件CPU一样,JVM规范也提供了常见的控制转移指令,例如switch分支选择指令、if-else条件判断、do-while循环、foreach循环、for循环、return返回、break中断循环、continue继续循环
5.对象创建与类型转换指令
作为一门面向对象的语言,JVM规范自然要提供一套创建对象的指令。在Java语法层面使用关键字new可以实例化一个对象,而对应的字节码指令也是new
JVM规范还提供了"窄化类型转换"指令,与"窄化类型转换"指令相对的是"宽化类型转换"指令,只不过后者是JVM内部天生支持的,不需要另外使用指令。
除了以上这些指令,JVM规范还提供了很多其他物理CPU所没有的指令,例如,抛出异常的指令,用于线程同步的指令,等等。
字节码指令是中间语言的一种实现手短,虽然肯定还有其他技术路径。字节码指令能够完成Java语言的各种功能,能够压栈和出战,能够读写局部变量表,能够调用方法,也能够创建对象示例。而最关键的一点是,它是跨平台的。这就是他很神奇的根本原因
除了以上这些指令,JVM规范还提供了很多其他物理CPU所没有的指令,例如,抛出异常的指令,用于线程同步的指令,等等。
字节码指令是中间语言的一种实现手短,虽然肯定还有其他技术路径。字节码指令能够完成Java语言的各种功能,能够压栈和出战,能够读写局部变量表,能够调用方法,也能够创建对象示例。而最关键的一点是,它是跨平台的。这就是他很神奇的根本原因
总结。
Java语言索要解决的是如何能够不关注底层技术细节就能实现兼容性,詹爷给出的答案是使用中间语言,通过中间语言来实现跨平台兼容的目标。由于中间语言并不是本地机器指令,机器CPU无法直接识别,因此中间语言并不能直接由物理CPU运行,那怎么办呢?很简单,使用虚拟机来解释中间语言,讲中间语言翻译成对应的本地机器指令。
将中间语言翻译成本地机器码的方式有很多种,例如使用C/C++语言位每一个Java字节码指令写一个对应的实现函数。但是这种方式太低效了。而解决低效的一种机制就是直接将Java字节码指令翻译成本地机器指令,运行期直接由Java虚拟机调用对应的机器指令来执行,这种调用的机制主要就是依靠CPU所提供的call和jump指令。
中间语言长啥样?外表长得确实不够漂亮,很多人看了一眼就不愿意继续看第二眼,但是它很有内涵,能量足够大,能够跨平台。他就是Java字节码指令集。该指令集就是中间语言,这个指令集是对硬件CPU指令集的抽象与再加工,能够满足Java开发的一切需要
Java语言索要解决的是如何能够不关注底层技术细节就能实现兼容性,詹爷给出的答案是使用中间语言,通过中间语言来实现跨平台兼容的目标。由于中间语言并不是本地机器指令,机器CPU无法直接识别,因此中间语言并不能直接由物理CPU运行,那怎么办呢?很简单,使用虚拟机来解释中间语言,讲中间语言翻译成对应的本地机器指令。
将中间语言翻译成本地机器码的方式有很多种,例如使用C/C++语言位每一个Java字节码指令写一个对应的实现函数。但是这种方式太低效了。而解决低效的一种机制就是直接将Java字节码指令翻译成本地机器指令,运行期直接由Java虚拟机调用对应的机器指令来执行,这种调用的机制主要就是依靠CPU所提供的call和jump指令。
中间语言长啥样?外表长得确实不够漂亮,很多人看了一眼就不愿意继续看第二眼,但是它很有内涵,能量足够大,能够跨平台。他就是Java字节码指令集。该指令集就是中间语言,这个指令集是对硬件CPU指令集的抽象与再加工,能够满足Java开发的一切需要
Java执行引擎工作原理:方法调用
JVM作为一款虚拟机,必然要涉及计算机核心的3大功能
1.方法调用
方法作为程序组成的基本单元,作为原子指令的初步封装,计算机必须能够支持方法的调用。同样,Java语言的原子指令是字节码,Java方法是对字节码的封装,因此JVM必须支持Java方法的调用
2.取指
这里的取指,是指取出指令。方法是对原子指令的封装,计算机进入方法后,最终需要逐条取出这些指令并逐条执行。Java方法也不例外,因此JVM进入Java方法后,也要能够模拟硬件CPU,能够从Java方法中逐条取出字节码指令
3.运算
计算机取出指令后,就要根据指令进行相应的逻辑运算,实现指令的功能。JVM作为虚拟机,也要具备对Java字节码的运算能力
1.方法调用
方法作为程序组成的基本单元,作为原子指令的初步封装,计算机必须能够支持方法的调用。同样,Java语言的原子指令是字节码,Java方法是对字节码的封装,因此JVM必须支持Java方法的调用
2.取指
这里的取指,是指取出指令。方法是对原子指令的封装,计算机进入方法后,最终需要逐条取出这些指令并逐条执行。Java方法也不例外,因此JVM进入Java方法后,也要能够模拟硬件CPU,能够从Java方法中逐条取出字节码指令
3.运算
计算机取出指令后,就要根据指令进行相应的逻辑运算,实现指令的功能。JVM作为虚拟机,也要具备对Java字节码的运算能力
方法调用
到目前位置。人类发明出了若干种编程语言,有的编程语言没有类概念,有的编程语言面向过程,但不管是哪种编程语言,至少都会包含函数的概念。通过函数将一个大的程序拆分成体积小、功能明确的一个个简短的函数,从而将一个复杂的大型问题分解成若干个简单的小问题,由繁到简。虽然函数并不总是大型软件模块化的手短,但一定是模块化得以实现的基础。否则随便开发个稍微难一点的功能,一写就是几千、几万行代码,估计没几个人能看懂,更没接个人耐心看。
同理,Java程序最基本的组成单位是类,而Java类也是由一个个的函数组成,在这一点上,Java也玩不出什么花样。有的编程语言由真实的物理机器运行,有的程序运行于虚拟机之上。既然所有的编程语言都是由函数组成,那么运行由这些编程语言所开发出来的程序的机器就必须能够执行函数调用,不管是物理机器还是虚拟机器。JVM作为一款虚拟机,要想具备执行一个完整的Java程序的能力,就必定得具备单个Java函数的能力。而要具备执行Java函数的能力,首先必须得能执行函数调用。经过前面的讨论我们知道,詹爷当前为了能够让Java这门编程语言兼容各种平台,最终使用了一个大招——在运行时将Java字节码指令动态翻译成本地机器指令,从而既能获得兼容性,又能获取很高的运行效率。因此,JVM实际上最后调用的并不是真正的Java函数,而是其对应的一堆机器指令。那么JVM究竟是怎么做到直接调用机器指令的呢?要研究清楚这个问题,必定要先弄清楚真是的物理机器是如何调用机器指令的
同理,Java程序最基本的组成单位是类,而Java类也是由一个个的函数组成,在这一点上,Java也玩不出什么花样。有的编程语言由真实的物理机器运行,有的程序运行于虚拟机之上。既然所有的编程语言都是由函数组成,那么运行由这些编程语言所开发出来的程序的机器就必须能够执行函数调用,不管是物理机器还是虚拟机器。JVM作为一款虚拟机,要想具备执行一个完整的Java程序的能力,就必定得具备单个Java函数的能力。而要具备执行Java函数的能力,首先必须得能执行函数调用。经过前面的讨论我们知道,詹爷当前为了能够让Java这门编程语言兼容各种平台,最终使用了一个大招——在运行时将Java字节码指令动态翻译成本地机器指令,从而既能获得兼容性,又能获取很高的运行效率。因此,JVM实际上最后调用的并不是真正的Java函数,而是其对应的一堆机器指令。那么JVM究竟是怎么做到直接调用机器指令的呢?要研究清楚这个问题,必定要先弄清楚真是的物理机器是如何调用机器指令的
真实的机器调用。
下面我们通过一个汇编程序讲解一些真实的机器调用原理。想研究JVM的执行引擎原理,汇编这道坎必须得过,别无他法。
真实的机器指令调用机制涉及的知识比较多,例如,现场保存、堆栈分配、参数传递,等等,我们不需要知道那么专业的基础只是,只需要了解大体的原理,了解了这些原理,再理解JVM的函数调用就会变得简单了。这段汇编想要实现的功能很简单,就是对两个证书进行求和。分析一段程序,一般首先看该程序由哪些模块组成,分析汇编程序也是一样。这段汇编程序在代码段中定义了两个标号,一个是main标号,一个add标号。汇编语言中的标号类似于C语言中函数的概念,那么这段汇编程序中就定义了两个函数,一个是main()函数,一个是add()函数。
下面我们通过一个汇编程序讲解一些真实的机器调用原理。想研究JVM的执行引擎原理,汇编这道坎必须得过,别无他法。
真实的机器指令调用机制涉及的知识比较多,例如,现场保存、堆栈分配、参数传递,等等,我们不需要知道那么专业的基础只是,只需要了解大体的原理,了解了这些原理,再理解JVM的函数调用就会变得简单了。这段汇编想要实现的功能很简单,就是对两个证书进行求和。分析一段程序,一般首先看该程序由哪些模块组成,分析汇编程序也是一样。这段汇编程序在代码段中定义了两个标号,一个是main标号,一个add标号。汇编语言中的标号类似于C语言中函数的概念,那么这段汇编程序中就定义了两个函数,一个是main()函数,一个是add()函数。
main()函数详解
看过main()函数的代码著时候,我们知道main()函数一共包含5步:保存调用栈基地址,初始化数据,压栈,函数调用和返回。下面分别分析这5步过程。
1.保存栈基并分配新栈
首先看第一步,main函数从下面两条指令开始执行:
pushl %ebp
movl %esp, %ebp
pushl %ebp就是保存调用者的栈基地址。调用者是谁?谁能调用一个程序的主函数?当然是操作系统了,movl %esp, %ebp将调用者的栈基地址指向栈顶。这两句是所有函数调用时都必定会执行的指令。add()函数的开头也是这两条指令。
执行完上面两条指令,main()函数接下来执行下面这条指令:
subl $32, %esp
这条指令是干嘛用的?大家整条在讲分配栈空间,分配占空间,这条指令就是干这事的。所以对于物理机器而言,分配堆栈空间非常容易,就一句话的事。这条指令中的subl表示减,指令中的%esp表示当前栈顶。整条指令的含义是:将当前栈顶减去32字节的长度。为什么是减,而不是加呢?这是因为在Linux平台上,栈是向下增长的,从内存的高地址往低地址方向增长,因此每次调用一个新的函数时,需要位新的函数分配栈空间,新韩淑的栈顶相对于调用者函数的栈顶,内存地址一定是低位方向,因此新函数的栈顶总是通过对调用者函数的栈顶做减法而计算出来的。
执行了这条指令后,main()函数就有了自己的方法栈了,栈空间大小是32字节,一个字节包含8个二进制位,如果一个int类型的证书包含4字节的话,那么main()函数的方法栈一共可以容下8个int类型的数据,如图所示。。
main()函数执行完上面3条指令,便完成了调用者栈基地址的保存和资深栈空间的分配。
看过main()函数的代码著时候,我们知道main()函数一共包含5步:保存调用栈基地址,初始化数据,压栈,函数调用和返回。下面分别分析这5步过程。
1.保存栈基并分配新栈
首先看第一步,main函数从下面两条指令开始执行:
pushl %ebp
movl %esp, %ebp
pushl %ebp就是保存调用者的栈基地址。调用者是谁?谁能调用一个程序的主函数?当然是操作系统了,movl %esp, %ebp将调用者的栈基地址指向栈顶。这两句是所有函数调用时都必定会执行的指令。add()函数的开头也是这两条指令。
执行完上面两条指令,main()函数接下来执行下面这条指令:
subl $32, %esp
这条指令是干嘛用的?大家整条在讲分配栈空间,分配占空间,这条指令就是干这事的。所以对于物理机器而言,分配堆栈空间非常容易,就一句话的事。这条指令中的subl表示减,指令中的%esp表示当前栈顶。整条指令的含义是:将当前栈顶减去32字节的长度。为什么是减,而不是加呢?这是因为在Linux平台上,栈是向下增长的,从内存的高地址往低地址方向增长,因此每次调用一个新的函数时,需要位新的函数分配栈空间,新韩淑的栈顶相对于调用者函数的栈顶,内存地址一定是低位方向,因此新函数的栈顶总是通过对调用者函数的栈顶做减法而计算出来的。
执行了这条指令后,main()函数就有了自己的方法栈了,栈空间大小是32字节,一个字节包含8个二进制位,如果一个int类型的证书包含4字节的话,那么main()函数的方法栈一共可以容下8个int类型的数据,如图所示。。
main()函数执行完上面3条指令,便完成了调用者栈基地址的保存和资深栈空间的分配。
注:由于main()的方法栈刚刚分配,还没有往里面加任何东西,因此此时的栈时空的,什么都没有。同时,系统为main()函数一共分配了32个字节,在64位平台上,一个int型数据占4个字节,因此这里将main()方法栈以4字节位单位进行划分,一共划分为8块,每一块代表4个字节大小的空间
2.初始化数据
main()函数接下来执行下面两条指令:
movl $5, 20(%esp)
movl $3, 24(%esp)
这两条指令的含义是:分别将5和3这两个证书保存到main()栈中,其中20(%esp)表示当前栈顶(即esp寄存器当前所指向的内存地址)网上移动20字节位置,数据5就被保存在这里。由于main()函数的栈空间一共有32字节那么大,因此从main()方法栈的栈顶往上移动20字节后的位置,依然在main()的方法栈内。同理,整数3被保存到了main()函数栈顶往上便宜24字节处的位置。由于一个整数占用4字节,因此5和3被分别保存到main()方法栈顶往上偏移5个整数和6个整数的位置。 内存位置如图所示,如果我们将main()函数的栈顶位置标记为(%esp),整个main()方法栈空间的32字节,按照每4字节为单元进行划分,同时按照其相对于栈顶位置的偏移量来标记main()的方法栈,那么main()函数的方法栈可以如图来标记。
这种将方法栈内存位置按照栈顶偏移量进行标记的方法,是整个计算机的理论基础,直接影响了编程语言的模块划分方法。如果没有这种方法,编译器很难在编译器就确定各个变量的位置,更不用谈各种基于编译原理的高级应用了。理解了相对定位方法后,我们再来看5和3这两个数据在main()方法栈中的位置,如图所示
main()函数接下来执行下面两条指令:
movl $5, 20(%esp)
movl $3, 24(%esp)
这两条指令的含义是:分别将5和3这两个证书保存到main()栈中,其中20(%esp)表示当前栈顶(即esp寄存器当前所指向的内存地址)网上移动20字节位置,数据5就被保存在这里。由于main()函数的栈空间一共有32字节那么大,因此从main()方法栈的栈顶往上移动20字节后的位置,依然在main()的方法栈内。同理,整数3被保存到了main()函数栈顶往上便宜24字节处的位置。由于一个整数占用4字节,因此5和3被分别保存到main()方法栈顶往上偏移5个整数和6个整数的位置。 内存位置如图所示,如果我们将main()函数的栈顶位置标记为(%esp),整个main()方法栈空间的32字节,按照每4字节为单元进行划分,同时按照其相对于栈顶位置的偏移量来标记main()的方法栈,那么main()函数的方法栈可以如图来标记。
这种将方法栈内存位置按照栈顶偏移量进行标记的方法,是整个计算机的理论基础,直接影响了编程语言的模块划分方法。如果没有这种方法,编译器很难在编译器就确定各个变量的位置,更不用谈各种基于编译原理的高级应用了。理解了相对定位方法后,我们再来看5和3这两个数据在main()方法栈中的位置,如图所示
有人可能会问为什么5和3被分配在中间的位置,上边和下边哪都不接。其实栈底那个位置即28(%esp)是留给调用add()函数的返回值的
3.压栈
接着main()函数开始执行下面4条指令:
movl 24(%esp), %eax
movl %eax, 4(%esp)
movl 20(%esp), %eax
movl %eax, (%esp)
这4条指令主要作用是"压栈"。前两条指令是吧数据3压栈,后两条指令是把数据5压栈。
先看movl 24(%esp), %eax这条指令,该指令将24(%esp)处的内存之传送到eax寄存器中,24(%esp)处的内存之就是刚刚第2步中保存的整数3.接着movl %eax, 4(%esp)这条指令又将eax寄存器中的值传送到了4(%esp)这个地方。如果将这两条指令连着看,你就会发现这里进行的数据传送的路径是x->y,y->z,最终的效果是整数3被从24(%esp)这个相对于栈顶的偏移位置,转移到了4(%esp)这个偏移位置。
同样的道理,后面两条指令的最终效果是整数5被从20(%esp)这个相对于栈顶的偏移位置,转移到了(%esp)这个偏移位置。(%esp)这个位置就是栈顶。也许有人会问为什么步直接从x点转移到z点呢,因为CPU不支持将数据从一个内存位置直接传送到另一个内存位置,若要想实现这个效果,必须使用寄存器进行中转。以移动数据3为例,数据3最终被从24(%esp)这个内存位置移动到了4(%esp)这个内存位置,CPU先将3从24(%esp)移到了eax寄存器,再将3从eax寄存器移到了4(%esp)这个内存位置。CPU无法直接执行下面的这条指令:
movl 24(%esp), 4(%esp)
只因为24(%esp)和4(%esp)都代表的是内存位置,因此CPU无法直接完成内存之间的数据传送
这四条指令执行下来,main()函数的方法栈内存布局如图所示。一般而言,往栈顶传送数据的行为叫做"压栈",这里先后将3和5都放到了栈顶处,因此这都是压栈。main()函数为何要压栈呢?那是因为main()函数即将要进行函数调用了。真实的物理机器,在发起函数调用之前,必定要进行压栈。压栈的目的是为了传参。main()函数在这里压栈的两个数据,将会被add()函数读取到。
接着main()函数开始执行下面4条指令:
movl 24(%esp), %eax
movl %eax, 4(%esp)
movl 20(%esp), %eax
movl %eax, (%esp)
这4条指令主要作用是"压栈"。前两条指令是吧数据3压栈,后两条指令是把数据5压栈。
先看movl 24(%esp), %eax这条指令,该指令将24(%esp)处的内存之传送到eax寄存器中,24(%esp)处的内存之就是刚刚第2步中保存的整数3.接着movl %eax, 4(%esp)这条指令又将eax寄存器中的值传送到了4(%esp)这个地方。如果将这两条指令连着看,你就会发现这里进行的数据传送的路径是x->y,y->z,最终的效果是整数3被从24(%esp)这个相对于栈顶的偏移位置,转移到了4(%esp)这个偏移位置。
同样的道理,后面两条指令的最终效果是整数5被从20(%esp)这个相对于栈顶的偏移位置,转移到了(%esp)这个偏移位置。(%esp)这个位置就是栈顶。也许有人会问为什么步直接从x点转移到z点呢,因为CPU不支持将数据从一个内存位置直接传送到另一个内存位置,若要想实现这个效果,必须使用寄存器进行中转。以移动数据3为例,数据3最终被从24(%esp)这个内存位置移动到了4(%esp)这个内存位置,CPU先将3从24(%esp)移到了eax寄存器,再将3从eax寄存器移到了4(%esp)这个内存位置。CPU无法直接执行下面的这条指令:
movl 24(%esp), 4(%esp)
只因为24(%esp)和4(%esp)都代表的是内存位置,因此CPU无法直接完成内存之间的数据传送
这四条指令执行下来,main()函数的方法栈内存布局如图所示。一般而言,往栈顶传送数据的行为叫做"压栈",这里先后将3和5都放到了栈顶处,因此这都是压栈。main()函数为何要压栈呢?那是因为main()函数即将要进行函数调用了。真实的物理机器,在发起函数调用之前,必定要进行压栈。压栈的目的是为了传参。main()函数在这里压栈的两个数据,将会被add()函数读取到。
4.函数调用
压完了栈。main()函数重要开始进行函数调用了。对于物理机器而言,函数调用特别简单,就一条指令:
call add
但是看着简单,背后却有一套机制在支撑。add()函数执行完,会将计算的结果保存到eax寄存器中。main()函数要取得add()函数返回值,便直接从eax寄存器中拿即可。因此,执行完call add函数调用指令后,main()函数接着调用下面的指令:
movl %eax, 28(%esp)
通过这条指令,main()函数终于完成了求和计算,并拿到了计算结果。此时,main()函数的方法栈内存布局如图所示。
到了这里,你会发现其实这样的内存布局还是挺美的,什么叫美?历史上无论是科学大牛还是艺术巨匠都告诉我们:对称的,才是美的。你看现在的main()方法栈的内存布局图,就挺对称,上面的数据都挨着栈底,下面的数据都挨着栈顶。之所以会这样,全是编译器的功劳,编译器会将一个方法内的局部变量分配在靠近栈底的位置,而将传递的参数分配在靠近栈顶的位置
压完了栈。main()函数重要开始进行函数调用了。对于物理机器而言,函数调用特别简单,就一条指令:
call add
但是看着简单,背后却有一套机制在支撑。add()函数执行完,会将计算的结果保存到eax寄存器中。main()函数要取得add()函数返回值,便直接从eax寄存器中拿即可。因此,执行完call add函数调用指令后,main()函数接着调用下面的指令:
movl %eax, 28(%esp)
通过这条指令,main()函数终于完成了求和计算,并拿到了计算结果。此时,main()函数的方法栈内存布局如图所示。
到了这里,你会发现其实这样的内存布局还是挺美的,什么叫美?历史上无论是科学大牛还是艺术巨匠都告诉我们:对称的,才是美的。你看现在的main()方法栈的内存布局图,就挺对称,上面的数据都挨着栈底,下面的数据都挨着栈顶。之所以会这样,全是编译器的功劳,编译器会将一个方法内的局部变量分配在靠近栈底的位置,而将传递的参数分配在靠近栈顶的位置
5.返回
函数返回很简单,将返回值保存到eax寄存器中,然后执行两条例行返回指令便大功告成。main()函数的返回指令就是下面这3条
movl $0, %eax
leave
ret
到此为止,我们完整地分析了main()函数地执行过程以及栈内存地布局演变。在分析地过程中,顺带着了解了main()函数为了调用add()函数而作的准备。在本示例中,main()函数为了调用add()函数,主要将入参进行了"压栈"操作,这样在add()函数内部才能取到参数并进行求和运算。但是,除了压栈外,其实系统还要做一部分工作,才能完成最终的方法调用,下面通过分析add()函数的执行过程,来了解系统工作的机制
函数返回很简单,将返回值保存到eax寄存器中,然后执行两条例行返回指令便大功告成。main()函数的返回指令就是下面这3条
movl $0, %eax
leave
ret
到此为止,我们完整地分析了main()函数地执行过程以及栈内存地布局演变。在分析地过程中,顺带着了解了main()函数为了调用add()函数而作的准备。在本示例中,main()函数为了调用add()函数,主要将入参进行了"压栈"操作,这样在add()函数内部才能取到参数并进行求和运算。但是,除了压栈外,其实系统还要做一部分工作,才能完成最终的方法调用,下面通过分析add()函数的执行过程,来了解系统工作的机制
add()函数详解
在分析add()函数之前,我们先看下add()函数的整体逻辑
可以看到,add()函数总体上分为4步: 保存调用者栈基地址,读取入参,执行运算,返回。
1.保存调用者栈基地址
add()函数也是以下面两条指令开始
pushl %ebp
movl %esp, %ebp
刚才在分析main()函数时就讲过,这两条指令主要时保存调用者栈基地址。物理机器在执行函数调用时,被调用者总是要保存调用栈基地址。这是因为esp和ebp这两个寄存器接下来要指向被调用者的栈基地址和栈顶,这两个寄存器原本保存的是调用者的栈基地址和栈顶地址,现在即将被修改,如果不保存起来,那么当被调用者函数执行完成,程序流返回到调用者流程中时,物理机器将无法恢复调用者的栈基和栈顶,从而导致程序无法继续执行下去。
add()函数的第3条指令是
subl $16, %esp
这条指令大家应该很熟悉,就是分配栈空间。为谁分配?当然是为当前函数add().分配16个字节的空间,而不是16个二进制位。现在,让我们看看方法栈空间的内存布局。由于add()函数的方法栈是在调用方main()函数的方法栈空间基础上往下增长的,并且add()方法栈与main()方法栈连在一起,因此现在我们同时看main()和add()两个函数的方法栈,如图所示
在分析add()函数之前,我们先看下add()函数的整体逻辑
可以看到,add()函数总体上分为4步: 保存调用者栈基地址,读取入参,执行运算,返回。
1.保存调用者栈基地址
add()函数也是以下面两条指令开始
pushl %ebp
movl %esp, %ebp
刚才在分析main()函数时就讲过,这两条指令主要时保存调用者栈基地址。物理机器在执行函数调用时,被调用者总是要保存调用栈基地址。这是因为esp和ebp这两个寄存器接下来要指向被调用者的栈基地址和栈顶,这两个寄存器原本保存的是调用者的栈基地址和栈顶地址,现在即将被修改,如果不保存起来,那么当被调用者函数执行完成,程序流返回到调用者流程中时,物理机器将无法恢复调用者的栈基和栈顶,从而导致程序无法继续执行下去。
add()函数的第3条指令是
subl $16, %esp
这条指令大家应该很熟悉,就是分配栈空间。为谁分配?当然是为当前函数add().分配16个字节的空间,而不是16个二进制位。现在,让我们看看方法栈空间的内存布局。由于add()函数的方法栈是在调用方main()函数的方法栈空间基础上往下增长的,并且add()方法栈与main()方法栈连在一起,因此现在我们同时看main()和add()两个函数的方法栈,如图所示
现在这张栈内存布局图同时包含main()函数和add()函数的方法栈。main()方法栈大小为32字节,add()方法栈大小为16字节。但是奇怪的是,在main()和add()方法栈的中间,竟然多了8个字节的空间。这8个字节的空间是从哪里冒出来的呢?答案很简单,在main()函数执行call add指令时,物理机器自动往栈顶压了一个数值——eip.前面讲过CPU所执行的指令位置由CS:IP这2个段寄存器共同决定,这里的eip就是IP寄存器.物理机器为何要将这个寄存器的值入栈呢?这主要是为了让main()函数执行完call调用回来之后,能够继续处理main()函数中接下来的指令。我们看main()函数执行完call add指令的上下文,如果没有call add指令,即如果main()函数不调用add()函数,那么main()函数执行完call add这条指令的后面一条指令movl %eax, 28(%esp),同时,main()函数在执行movl %eax, 28(%esp)这条指令之前,eip寄存器需要先指向这条指令,这样CPU才能读取到这条指令并执行。但是现在,main()函数在执行这条指令之前,先调用了add()函数,那么eip就会指向add()函数里面的指令内存位置(这一步是物理机器自动执行的),那么当add()函数执行完其最后一条指令之后,物理机器怎么知道接下来要把eip寄存器指向哪里,或者说物理机器怎么知道接下来要执行哪里的指令呢?所谓的函数只是人类进行的模块划分,物理机器可不知道有这些东东,物理机器更不会自动记忆当前函数被哪个函数调用,更不会在执行完当前函数后,自动跳转到当前函数的调用者函数中去执行,所以,执行完一个函数,我们不去修改eip寄存器的值,那么物理机器根本就不知道接下来应该怎么做。基于这样的原因,在执行函数调用时,CPU设计者在里面加了一个功能,即在物理机器执行call指令时,自动将当前eip寄存器入栈,而当被调用者执行完之后,物理机器再自动将eip出战,这样,执行完被调用函数之后,物理机器会接着执行调用者的后续指令。
刚刚解释了main()和add()函数中间多出来的8字节中的eip,eip占4字节,因此还有4字节的空间。看图可以知道,这剩下的4字节空间存放着ebp的值。这个值是在执行add()函数时入栈的。我们看,add()函数的开头第一条指令就是pushl %ebp,这里显式执行了push入栈操作。
经过上面对add()函数的初步分析,我们可以得出以下结论:
1.物理机器执行call函数调用时,机器会自动将eip入栈
2.物理机器执行函数调用时,被调用方需要手动将ebp入栈
add()函数执行完开头的3条指令后,机器开始进入add()函数域,接下来开始执行add()函数里面真正的逻辑运算
刚刚解释了main()和add()函数中间多出来的8字节中的eip,eip占4字节,因此还有4字节的空间。看图可以知道,这剩下的4字节空间存放着ebp的值。这个值是在执行add()函数时入栈的。我们看,add()函数的开头第一条指令就是pushl %ebp,这里显式执行了push入栈操作。
经过上面对add()函数的初步分析,我们可以得出以下结论:
1.物理机器执行call函数调用时,机器会自动将eip入栈
2.物理机器执行函数调用时,被调用方需要手动将ebp入栈
add()函数执行完开头的3条指令后,机器开始进入add()函数域,接下来开始执行add()函数里面真正的逻辑运算
2.读取入参
add()函数的第4和第5这两条指令为读取入参指令,先看指令:
movl 12(%ebp), %eax
movl 8(%ebp), %edx
第一条指令是movl 12(%ebp), %eax,这条指令中使用了2个寄存器:ebp和eax,其中ebp寄存器的用途与esp一样专一,只用于标识栈底位置。对于12(%ebp)表示从ebp寄存器所指向的内存地址往高地址方向偏移12字节。由于在add()函数的一开始执行了movl %esp, %ebp指令,因此此时ebp寄存器已经指向了原来main()函数的栈顶。第一条指令合起来的意思就是,从add()函数栈底向上偏移了12字节的位置取出数据(占4字节),将该数据传送给eax寄存器。
同理,第二条指令的意思是,从add()函数栈底向上偏移8字节的位置取出数据(占4字节),将该数据传送给eax寄存器。通过这两条指令,add()函数成功从main()函数中获取到了两个入参。为什么要从这两个位置读取入参呢?这是因为main()函数在把两个入参压栈后,执行call函数调用指令,由于系统会继续将eip和esp压栈,这两个数据共占8字节,因此导致add()栈顶与main()函数中压栈的两个参数之间隔着8字节的距离,因此add()函数要分别从这两个位置获取入参。
z之前在main()方法中执行参数压栈指令时,当时压栈的两个位置分别是(%esp)和4(%esp),而现在这两个位置的标记变成8(%ebp)和12(%ebp),由此可见,随着堆栈寄存器ebp和esp所指向位置的变化,方法栈中同一个内存位置的偏移量也随之改变。同时,对于压栈的入参,既可以从通过相对于调用者函数的栈顶的偏移量来相对定位,也可以通过相对于被调用者函数的栈底的偏移量来相对定位。当然,如果你愿意,你也可以通过相对于调用者函数的栈顶偏移位置来相对定位。总之,对方法栈内存的定位手段是灵活的,可以选择不同的参考系,不同的定位基准决定了不同的偏移量和定位方法。但是对于被调用者函数的方法栈内的数据,却不能以调用者函数为基准通过偏移量获取,因为此时被调用函数尚未分配方法栈空间,根本取不到数据,甚至会取到错误的数据。下面我们在add()指令中将会遇到不同的相对定位方式
add()函数的第4和第5这两条指令为读取入参指令,先看指令:
movl 12(%ebp), %eax
movl 8(%ebp), %edx
第一条指令是movl 12(%ebp), %eax,这条指令中使用了2个寄存器:ebp和eax,其中ebp寄存器的用途与esp一样专一,只用于标识栈底位置。对于12(%ebp)表示从ebp寄存器所指向的内存地址往高地址方向偏移12字节。由于在add()函数的一开始执行了movl %esp, %ebp指令,因此此时ebp寄存器已经指向了原来main()函数的栈顶。第一条指令合起来的意思就是,从add()函数栈底向上偏移了12字节的位置取出数据(占4字节),将该数据传送给eax寄存器。
同理,第二条指令的意思是,从add()函数栈底向上偏移8字节的位置取出数据(占4字节),将该数据传送给eax寄存器。通过这两条指令,add()函数成功从main()函数中获取到了两个入参。为什么要从这两个位置读取入参呢?这是因为main()函数在把两个入参压栈后,执行call函数调用指令,由于系统会继续将eip和esp压栈,这两个数据共占8字节,因此导致add()栈顶与main()函数中压栈的两个参数之间隔着8字节的距离,因此add()函数要分别从这两个位置获取入参。
z之前在main()方法中执行参数压栈指令时,当时压栈的两个位置分别是(%esp)和4(%esp),而现在这两个位置的标记变成8(%ebp)和12(%ebp),由此可见,随着堆栈寄存器ebp和esp所指向位置的变化,方法栈中同一个内存位置的偏移量也随之改变。同时,对于压栈的入参,既可以从通过相对于调用者函数的栈顶的偏移量来相对定位,也可以通过相对于被调用者函数的栈底的偏移量来相对定位。当然,如果你愿意,你也可以通过相对于调用者函数的栈顶偏移位置来相对定位。总之,对方法栈内存的定位手段是灵活的,可以选择不同的参考系,不同的定位基准决定了不同的偏移量和定位方法。但是对于被调用者函数的方法栈内的数据,却不能以调用者函数为基准通过偏移量获取,因为此时被调用函数尚未分配方法栈空间,根本取不到数据,甚至会取到错误的数据。下面我们在add()指令中将会遇到不同的相对定位方式
3.执行运算
add()函数主要功能是求和,而求和运算时物理机器的最基本的功能之一,因此物理机器提供了一条指令专门用于求和::add.add()函数中求和的指令如下:
addl %edx, %eax
这条指令的含义时,将edx寄存器中的值与eax寄存器中的值相加,相加结果保存到eax寄存器中。在add()函数执行本指令之前,已经通过读取入参指令将main()函数所传递过来的两个参数分别读取到了eax和edx这两个寄存器中,因此对这两个寄存器执行求和操作,就相当于对main()函数传递过来的2个入参执行求和。执行完求和运算,add()函数接着执行movl %eax, -4(%ebp)这条指令。这条指令的作用是把eax寄存器中的值转移到栈基地址往下偏移4个字节的位置。这个位置是哪里呢?其实就是add()函数的方法栈内的第一个位置。add()函数执行到现在,虽然分配了16字节的空间,但是一直还未使用过。此时eax寄存器中存放的是什么数据呢?就是刚刚add求和指令的结果。由此可知,add()函数将求和的结果保存在了其方法栈的第一个位置。此时整体堆栈内存布局如图所示。这里我们可以看出,对一个具体的方法栈中的某个位置而言,我们既可以通过ebp(栈底)进行相对定位,也可以通过esp(栈顶)进行相对定位。例如这里,对于add()函数方法栈的第一个位置,既可以通过-4(%ebp)相对定位,也可以通过12(%esp)相对定位。esp或ebp前面的正负符号表示相对基准位置是向上偏移还是向下偏移
add()函数主要功能是求和,而求和运算时物理机器的最基本的功能之一,因此物理机器提供了一条指令专门用于求和::add.add()函数中求和的指令如下:
addl %edx, %eax
这条指令的含义时,将edx寄存器中的值与eax寄存器中的值相加,相加结果保存到eax寄存器中。在add()函数执行本指令之前,已经通过读取入参指令将main()函数所传递过来的两个参数分别读取到了eax和edx这两个寄存器中,因此对这两个寄存器执行求和操作,就相当于对main()函数传递过来的2个入参执行求和。执行完求和运算,add()函数接着执行movl %eax, -4(%ebp)这条指令。这条指令的作用是把eax寄存器中的值转移到栈基地址往下偏移4个字节的位置。这个位置是哪里呢?其实就是add()函数的方法栈内的第一个位置。add()函数执行到现在,虽然分配了16字节的空间,但是一直还未使用过。此时eax寄存器中存放的是什么数据呢?就是刚刚add求和指令的结果。由此可知,add()函数将求和的结果保存在了其方法栈的第一个位置。此时整体堆栈内存布局如图所示。这里我们可以看出,对一个具体的方法栈中的某个位置而言,我们既可以通过ebp(栈底)进行相对定位,也可以通过esp(栈顶)进行相对定位。例如这里,对于add()函数方法栈的第一个位置,既可以通过-4(%ebp)相对定位,也可以通过12(%esp)相对定位。esp或ebp前面的正负符号表示相对基准位置是向上偏移还是向下偏移
4.返回
执行完求和运算后,add()函数的使命便完成了,接下来该返回了,函数返回的一般逻辑是,如果有返回值,就把返回值放在eax寄存器中,然后执行leave和ret指令。如果没有返回值,则直接执行leave和ret指令。leave和ret指令其实封装了好几个命令,如果看到其他汇编函数返回时执行的不是这两条命令,也不要感到太意外。指令是死的,人是活的。有的人喜欢使用封装好的指令,而有的人则喜欢使用最原始的指令。
至此,add()函数全部执行结束,程序流程有返回到了main()函数中。程序流为何能够返回到main()函数中呢?物理机器要想执行某个函数中的指令,必须先把ip段寄存器指向那个函数。ip指向哪里,物理机器就执行到哪里。在add()函数返回的leave指令中,其实顺带着执行了下面这两条指令:
mov %ebp, %esp
pop %eip
在main()函数调用add()函数时,物理机器执行了push %eip指令,将main()函数中add()指令的下一条指令的内存地址入栈。现在add()函数执行完了,再把这个地址弹出来(出栈),保存到eip寄存器中,这样eip寄存器就又指向main()函数中add指令的下一条指令了。当然在弹出eip之前,得先通过
mov %ebp, %esp指令将栈顶指向栈基位置,这样栈顶位置就是eip.
通过上面对一段简单的汇编程序的分析,我们知道了真实的物理机器调用函数的本质原理,总体而言,物理机器在调用函数时,需要进行下面一系列操作:
(1)参数入参。有几个参数就把几个参数入栈。此时入的是哪家的栈?是调用者自己的栈。不过不同的物理机器,入栈顺序是不同的,有的是顺序入栈,有的是逆序。
(2)代码指针(eip)入栈,这样等被调用函数执行完之后,物理机器可以再回来继续执行原来的函数指令
(3)调用函数的栈基地址入栈。还是为物理机器从被调用者返回调用方做准备。如果不入栈保存起来,那么等到调用方从被调用方返回来的时候,物理机器就不知道调用方的方法栈在哪里了
(4)为被调用方分配方法栈空间。每一个函数都有自己的栈空间,如同地球上的每一个人都有自己的小窝
通过对这段汇编程序的分析可知,物理机器在执行程序时,将程序划分成若干函数,每个函数都对应有一段机器码。一段程序的机器码都放在一块连续的内存中,这块内存叫做代码段。物理机器为每一个函数分配一个方法栈,方法栈与代码段在地址上没有任何关系,并且只有当物理机器执行到某个函数时,才会为其分配方法栈,否则就不会分配。函数通过资深的机器指令遥控其对应的方法栈,可以往里面放入数值,也可以将数值移动到其他地方,也可以从里面读取数据,也可以从调用者的方法栈里取值,通过一条条指令和一个个栈,物理机器得以运行完一整个程序。
知道了物理机器调用函数的这些秘密,我们再分析JVM的函数调用机制就不会感到高深莫测了,其实JVM也是将Java函数所对应的机器指令专门存储在内存的一块区域上,同时为每一个Java函数分配了方法栈。但是在真正了解JVM的函数调用机制之前,还需要先看看同样是高级语言的C语言时如何执行函数调用的
执行完求和运算后,add()函数的使命便完成了,接下来该返回了,函数返回的一般逻辑是,如果有返回值,就把返回值放在eax寄存器中,然后执行leave和ret指令。如果没有返回值,则直接执行leave和ret指令。leave和ret指令其实封装了好几个命令,如果看到其他汇编函数返回时执行的不是这两条命令,也不要感到太意外。指令是死的,人是活的。有的人喜欢使用封装好的指令,而有的人则喜欢使用最原始的指令。
至此,add()函数全部执行结束,程序流程有返回到了main()函数中。程序流为何能够返回到main()函数中呢?物理机器要想执行某个函数中的指令,必须先把ip段寄存器指向那个函数。ip指向哪里,物理机器就执行到哪里。在add()函数返回的leave指令中,其实顺带着执行了下面这两条指令:
mov %ebp, %esp
pop %eip
在main()函数调用add()函数时,物理机器执行了push %eip指令,将main()函数中add()指令的下一条指令的内存地址入栈。现在add()函数执行完了,再把这个地址弹出来(出栈),保存到eip寄存器中,这样eip寄存器就又指向main()函数中add指令的下一条指令了。当然在弹出eip之前,得先通过
mov %ebp, %esp指令将栈顶指向栈基位置,这样栈顶位置就是eip.
通过上面对一段简单的汇编程序的分析,我们知道了真实的物理机器调用函数的本质原理,总体而言,物理机器在调用函数时,需要进行下面一系列操作:
(1)参数入参。有几个参数就把几个参数入栈。此时入的是哪家的栈?是调用者自己的栈。不过不同的物理机器,入栈顺序是不同的,有的是顺序入栈,有的是逆序。
(2)代码指针(eip)入栈,这样等被调用函数执行完之后,物理机器可以再回来继续执行原来的函数指令
(3)调用函数的栈基地址入栈。还是为物理机器从被调用者返回调用方做准备。如果不入栈保存起来,那么等到调用方从被调用方返回来的时候,物理机器就不知道调用方的方法栈在哪里了
(4)为被调用方分配方法栈空间。每一个函数都有自己的栈空间,如同地球上的每一个人都有自己的小窝
通过对这段汇编程序的分析可知,物理机器在执行程序时,将程序划分成若干函数,每个函数都对应有一段机器码。一段程序的机器码都放在一块连续的内存中,这块内存叫做代码段。物理机器为每一个函数分配一个方法栈,方法栈与代码段在地址上没有任何关系,并且只有当物理机器执行到某个函数时,才会为其分配方法栈,否则就不会分配。函数通过资深的机器指令遥控其对应的方法栈,可以往里面放入数值,也可以将数值移动到其他地方,也可以从里面读取数据,也可以从调用者的方法栈里取值,通过一条条指令和一个个栈,物理机器得以运行完一整个程序。
知道了物理机器调用函数的这些秘密,我们再分析JVM的函数调用机制就不会感到高深莫测了,其实JVM也是将Java函数所对应的机器指令专门存储在内存的一块区域上,同时为每一个Java函数分配了方法栈。但是在真正了解JVM的函数调用机制之前,还需要先看看同样是高级语言的C语言时如何执行函数调用的
C语言的函数调用
讲完物理机器函数调用的原理后,接下来我们一起看看C语言时如何实现函数调用的。毕竟JVM是混合C和C++开发而成的,JVM执行引擎最关键的一点就在于实现了由C语言动态执行机器指令,这正是JVM与机器指令的边界所在。
C语言属于静态编译型语言,C语言开发的程序被编译后,直接生成二进制代码,而这些二进制代码正是由一条条机器指令组成,因此可直接被物理机器执行。所以,C程序中的函数调用,本质上还是依靠物理机器所提供的call指令来完成
1.一个简单的C程序
首先来看一个简单的例子
本例十分简单,add()函数主要完成两个整数的累加并返回累加结果,main()函数调用add()函数并返回0.
仔细观察这段汇编程序,你会发现这段汇编程序有两个标号,分别是main和add.而巧合的是,源程序C程序中恰好包含了main()函数和add()函数。其实,这不是巧合,而是编译器处理的结果。编译器在编译C程序时,会将C程序中的函数名处理成汇编程序中的标号。
事实上,这段汇编程序中的main和add正式汇编程序的两个代码段。汇编程序由一个一个代码段组成,如同C程序由一个一个函数组成一样。有了标号,汇编程序就能执行函数调用,即执行call指令。可以看到,在这段汇编程序中,main()代码段所对应的汇编代码中,有一条calladd指令,这正是汇编语言中的函数调用指令。因此,C程序中的main()函数调用add()函数,在汇编程序中就转换成了calladd。这就是C程序函数调用的秘密所在。继续仔细分析这段汇编程序,我们会发现无论是main代码段,还是add代码段,一开始都包含下面3条指令:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
不过add代码段一开始的第3条指令与main代码段的第3条指令有所不同,add代码段的第3条指令是subl $16, %esp,该指令其实与main代码段中的第3条指令andl $-16, %esp所表达的含义是相同的,那就是分配堆栈空间。这3条指令的作用是,保存调用者栈基地址,并为新方法分配方法栈,这几乎成为汇编程序进行方法调用的标准定式。不过并非所有的汇编中的方法都会按照这样的顺序来执行,并且这3条指令也并不一定紧靠在一起,在本例中之所以会如此一致,那都是编译器处理后的结果。但是编译器是死的,而人是活的,在JVM中,很多汇编程序直接由人工编写,并非依靠编译器,因此会有所不同。不过总体而言,汇编程序在进入方法时,这3条指令是不会少的,这是物理机器实现方法调用的核心算法。在本例中,当main()函数执行到call add这条指令之前,物理机器会为main()函数分配方法栈,堆栈空间是16字节。main()函数的方法栈内存布局如图所示
讲完物理机器函数调用的原理后,接下来我们一起看看C语言时如何实现函数调用的。毕竟JVM是混合C和C++开发而成的,JVM执行引擎最关键的一点就在于实现了由C语言动态执行机器指令,这正是JVM与机器指令的边界所在。
C语言属于静态编译型语言,C语言开发的程序被编译后,直接生成二进制代码,而这些二进制代码正是由一条条机器指令组成,因此可直接被物理机器执行。所以,C程序中的函数调用,本质上还是依靠物理机器所提供的call指令来完成
1.一个简单的C程序
首先来看一个简单的例子
本例十分简单,add()函数主要完成两个整数的累加并返回累加结果,main()函数调用add()函数并返回0.
仔细观察这段汇编程序,你会发现这段汇编程序有两个标号,分别是main和add.而巧合的是,源程序C程序中恰好包含了main()函数和add()函数。其实,这不是巧合,而是编译器处理的结果。编译器在编译C程序时,会将C程序中的函数名处理成汇编程序中的标号。
事实上,这段汇编程序中的main和add正式汇编程序的两个代码段。汇编程序由一个一个代码段组成,如同C程序由一个一个函数组成一样。有了标号,汇编程序就能执行函数调用,即执行call指令。可以看到,在这段汇编程序中,main()代码段所对应的汇编代码中,有一条calladd指令,这正是汇编语言中的函数调用指令。因此,C程序中的main()函数调用add()函数,在汇编程序中就转换成了calladd。这就是C程序函数调用的秘密所在。继续仔细分析这段汇编程序,我们会发现无论是main代码段,还是add代码段,一开始都包含下面3条指令:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
不过add代码段一开始的第3条指令与main代码段的第3条指令有所不同,add代码段的第3条指令是subl $16, %esp,该指令其实与main代码段中的第3条指令andl $-16, %esp所表达的含义是相同的,那就是分配堆栈空间。这3条指令的作用是,保存调用者栈基地址,并为新方法分配方法栈,这几乎成为汇编程序进行方法调用的标准定式。不过并非所有的汇编中的方法都会按照这样的顺序来执行,并且这3条指令也并不一定紧靠在一起,在本例中之所以会如此一致,那都是编译器处理后的结果。但是编译器是死的,而人是活的,在JVM中,很多汇编程序直接由人工编写,并非依靠编译器,因此会有所不同。不过总体而言,汇编程序在进入方法时,这3条指令是不会少的,这是物理机器实现方法调用的核心算法。在本例中,当main()函数执行到call add这条指令之前,物理机器会为main()函数分配方法栈,堆栈空间是16字节。main()函数的方法栈内存布局如图所示
将这段C程序编译成汇编程序,如图所示
main()函数的方法栈内存布局如图所示。此时main()方法栈是空的,啥都没有
当物理机器执行完add()函数的最后一条指令movl -4(%ebp), %eax时,堆栈内存布局如图所示。。
可以发现,物理机器将eip和ebp两个寄存器压入main()函数的栈顶,同时为add()函数分配了16字节的堆栈空间。由于add()函数源程序的int z = 1+2被编译器自动算出了结果3,因此编译器直接将立即数3分配到了add()函数的方法栈中第一个位置,也即最靠近add()函数方法栈栈底的位置,分配的指令就是汇编程序中add代码段中的:
movl $3, -4(%ebp)
当程序执行完add()函数,又回到了main()函数中后,物理机器会回收add()函数的堆栈空间,同时eip和ebp这两个寄存器出栈,于是堆栈中又剩下了main()函数的内存。main()函数会从eax寄存器中读取add()函数的返回值(函数返回值会被暂存在eax寄存器中,这是约定),并将其传送到main()函数方法栈的第一个位置,main()函数中的movl %eax,12(%esp)指令执行的正正是这种数据传送。此时,堆栈内存布局如图所示
可以发现,物理机器将eip和ebp两个寄存器压入main()函数的栈顶,同时为add()函数分配了16字节的堆栈空间。由于add()函数源程序的int z = 1+2被编译器自动算出了结果3,因此编译器直接将立即数3分配到了add()函数的方法栈中第一个位置,也即最靠近add()函数方法栈栈底的位置,分配的指令就是汇编程序中add代码段中的:
movl $3, -4(%ebp)
当程序执行完add()函数,又回到了main()函数中后,物理机器会回收add()函数的堆栈空间,同时eip和ebp这两个寄存器出栈,于是堆栈中又剩下了main()函数的内存。main()函数会从eax寄存器中读取add()函数的返回值(函数返回值会被暂存在eax寄存器中,这是约定),并将其传送到main()函数方法栈的第一个位置,main()函数中的movl %eax,12(%esp)指令执行的正正是这种数据传送。此时,堆栈内存布局如图所示
main()函数完成add()调用之后的堆栈布局。。可能有人会问,main()函数其实只需要分配4字节的堆栈空间,用于保存计算结果3,可为何编译器为其分配了16字节的空间呢?这是因为这也是一种约定,就是内存对齐。在32位和64位机器上,堆栈内存都是按照16字节对齐的,多了不退,少了一定会不起。之所以有这种约定,道理很简单,就是为了能够对内存进行快速定位、快速整理回收。
2.带入参的C程序如图所示。
这段汇编程序中包含两个标段main和add,main可以向add传入两个整型参数,add负责对两个入参进行求和并将累加结果返回给main.
前面已经详细分析过这段汇编的逻辑以及执行过程中的堆栈内存布局及其变化。这里我们主要研究时C程序的函数调用机制。
有参数传递场景下的C程序函数的调用机制,主要包括以下3点:
(1)压栈
如同前文分析,本例中的main()函数在调用add()函数之前,会将两个入参压栈,压入栈之后,add()就可以取得这两个入参。压入了谁的栈?当然是调用者的堆栈,即main()函数
(2)参数传递顺序
对于Linux平台而言,调用者函数向被调用函数传递参数时,采用逆向书匈奴压栈,即最后一个参数第一个压栈,而第一个参数最后压栈
这种压栈顺序决定了被调用函数对入参进行寻址的方式和顺序
(3)读取入参
main()函数将入参压栈后,作为被调用者的add()函数怎么获取入参呢?前文分析过,是通过add()函数的栈基地址ebp的相对地址,从main()函数中读取入参。最后一个入参位置在8(%ebp),倒数第二个入参位置在12(%ebp),依次类推
之所以要从相对ebp寄存器往上第8个存储位置开始寻址,是因为调用者与被调用者函数堆栈之间隔着eip和ebp这两个寄存器值。
以上3点应当牢记,这对后面分析JVM的执行引擎机制大有裨益
这段汇编程序中包含两个标段main和add,main可以向add传入两个整型参数,add负责对两个入参进行求和并将累加结果返回给main.
前面已经详细分析过这段汇编的逻辑以及执行过程中的堆栈内存布局及其变化。这里我们主要研究时C程序的函数调用机制。
有参数传递场景下的C程序函数的调用机制,主要包括以下3点:
(1)压栈
如同前文分析,本例中的main()函数在调用add()函数之前,会将两个入参压栈,压入栈之后,add()就可以取得这两个入参。压入了谁的栈?当然是调用者的堆栈,即main()函数
(2)参数传递顺序
对于Linux平台而言,调用者函数向被调用函数传递参数时,采用逆向书匈奴压栈,即最后一个参数第一个压栈,而第一个参数最后压栈
这种压栈顺序决定了被调用函数对入参进行寻址的方式和顺序
(3)读取入参
main()函数将入参压栈后,作为被调用者的add()函数怎么获取入参呢?前文分析过,是通过add()函数的栈基地址ebp的相对地址,从main()函数中读取入参。最后一个入参位置在8(%ebp),倒数第二个入参位置在12(%ebp),依次类推
之所以要从相对ebp寄存器往上第8个存储位置开始寻址,是因为调用者与被调用者函数堆栈之间隔着eip和ebp这两个寄存器值。
以上3点应当牢记,这对后面分析JVM的执行引擎机制大有裨益
编译之后的汇编指令
总体而言,我们通过汇编程序和C语言程序演示了真实的物理机器执行函数调用时的原理,如果不把这些内容弄清楚,则在研究JVM执行引擎时会村部男性。在真实的物理机器上执行函数调用时主要包含以下几个步骤:
(1)保存调用者栈基地址(即当前栈基地址入栈),当前IP寄存器入栈(即调用者中的下一条指令地址入栈)
(2)调用函数时,在x86平台上,参数从右到左依次入栈
(3)一个方法所分配的栈空间大小,取决于该方法内部的局部变量空间、为被调用者传递的入参大小
(4)被调用者在接收入参时,从8(%ebp)处开始,往上逐个获取每一个入参参数
(5)被调用者将返回结果保存在eax寄存器中,调用者从寄存器中获取返回值
(1)保存调用者栈基地址(即当前栈基地址入栈),当前IP寄存器入栈(即调用者中的下一条指令地址入栈)
(2)调用函数时,在x86平台上,参数从右到左依次入栈
(3)一个方法所分配的栈空间大小,取决于该方法内部的局部变量空间、为被调用者传递的入参大小
(4)被调用者在接收入参时,从8(%ebp)处开始,往上逐个获取每一个入参参数
(5)被调用者将返回结果保存在eax寄存器中,调用者从寄存器中获取返回值
JVM的函数调用机制
前面我们一起学习了物理机器的函数调用机制和C语言的函数调用机制,本质上还是这两者的函数调用地址是相同的,因为C语言是静态编译型语言,编译后就变成了能够直接被物理机器执行的二进制代码,所以C程序中的函数调用最终还是直接依赖物理机器的函数调用指令。现在如果让我们来开发类似JVM这样的一款解释性虚拟机,首先要解决的问题就是函数调用,你会怎么左?
JVM是用C和C++语言编写的一款软件,当JVM执行Java函数时,实际上是执行了一段汇编代码,换言之,这中间一定存在一个边界,在边界处,边界的一边是C程序,边界的另一边是机器指令,C语言要能够直接执行机器指令。例如,如果你编写了下面一段非常简单的Java代码
JVM是用C和C++语言编写的一款软件,当JVM执行Java函数时,实际上是执行了一段汇编代码,换言之,这中间一定存在一个边界,在边界处,边界的一边是C程序,边界的另一边是机器指令,C语言要能够直接执行机器指令。例如,如果你编写了下面一段非常简单的Java代码
这段Java代码很简单,main()方法调用add()方法,add()方法对两个入参进行相加和并返回累加结果。
执行javap -verbose命令,分析这段Java代码编译成的字节码内容,得到如图所示
执行javap -verbose命令,分析这段Java代码编译成的字节码内容,得到如图所示
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*XX.add -XX:CompileCommand=compileonly,*XX.add
现在我们在JVM上运行这个Java字节码文件,在运行时打印JVM所执行的机器指令(需要为JVM安装HSDIS插件),让JVM以模板解释器来解释运行这段字节码。其实iload_0、iload_1和iadd在JVM执行的时候,实际上最终执行了其对应的一大串机器码。实际上JVM会将其200戈多字节码指令所对应的机器码全部打印出来.这里我们先不关心这些机器指令到底代表什么逻辑,只要知道JVM在调用Java程序时,最终其实执行的是机器指令就可以了。
现在我们在JVM上运行这个Java字节码文件,在运行时打印JVM所执行的机器指令(需要为JVM安装HSDIS插件),让JVM以模板解释器来解释运行这段字节码。其实iload_0、iload_1和iadd在JVM执行的时候,实际上最终执行了其对应的一大串机器码。实际上JVM会将其200戈多字节码指令所对应的机器码全部打印出来.这里我们先不关心这些机器指令到底代表什么逻辑,只要知道JVM在调用Java程序时,最终其实执行的是机器指令就可以了。
经过对以上这段示例程序的分析可知,在JVM内部一定存在一个"边界",边界外面是C程序,边界里面则直接跳转到机器码,换言之,C程序可以直接调用汇编指令。当然,也许有高手会说,JVM一开始压根儿不是模板解释器,而是纯粹的字节码解释器,其会将字节码指令逐条翻译成C程序。这种说法是完全正确的,但是由于效率实在太低,地道为C++程序员们所不齿,所以JVM没发布几个版本就将默认的字节码解释器换成模板解释器了,JVM执行Java函数时直接执行机器指令。但是这里并不会纠结于这些细节,这里主要以启发式的方法来剖析JVM内部的运行机制,注重的是主要的思路逻辑,剖析JVM为什么会这样左,为什么会使用这样的技术和框架。任何事情都有其必然的道理,如果你对JVM能够做到"知其然",那么已经相当牛了;但是如果你能够JVM做到知其所以然,那么你将达到超脱的境界。你可以任意定制自己的JVM了,整个底层世界完全由你操控,这是一件想想就激动的事儿。
C语言有一个秘密武器,而这种秘密武器就是使C语言跨越IT界几十年历史长河却依然屹立不倒并一直非常流行的根本和基础,那就是指针。当然,这也是JVM得以最终成功的关键因素和关键技术。
可以说,任何底层编程开发,只要祭出"指针"这门神功或者武器,就没有C语言搞不定的事儿(因为能够访问内存,几乎能做机器指令所做的大部分事儿),所以它一直很流行,各种底层软件、驱动程序、操作系统、关系数据库、办公软件、手机操作系统,等等,几乎都是用它开发而成。同时,相比于汇编,C语言在语法上更加人性化,更易于被人类接受和理解,因此C语言广泛取代了汇编语言,只有在对性能要求非常苛刻的一些领域才需要祭出汇编这种最原始、古朴,当然威力也是最强大的神器,例如视频软件的内存访问或者操作系统底层的原子同步。JVM内部的原子操作也基本采用汇编指令来实现
大部分做C程序开发的同学对C语言的指针变量都不陌生,例如int*、char*等等,可以将char*理解成一个字符串的开始地址,也可以将其理解成一个二维字符数组的首地址。同理,既可以将int*理解成一个整型变量的内存地址,也可以将其理解成一个二位整型数组的首地址。
而C语言的指针其实还有一种更重要的用法——函数指针。通过函数指针,C语言可以将一个变量直接指向一个函数的首地址。C语言被编译时,C函数将被直接编译成机器指令,而这个函数指针将直接指向这段机器指令的首地址。于是聪明的人类便想出了一种方法,在源代码编译阶段就定义好一段机器指令,然后直接将一个C函数指针指向这段机器指令的首地址,从而间接实现C语言直接调用机器指令的目的,其实,C语言还有一种办法可以调用汇编指令,那就是内联汇编的方式,在Linux操作系统内核中就有大量的这种用法,但是这种用法与JVM所要实现的目标稍微有点不同,JVM要实现直接由C语言调用机器指令,而内联汇编的方式只实现了这一目标的一半,内联汇编的方式只能实现由C语言直接调用汇编指令,注意汇编指令与机器指令之间还有很大差距。
下面我们来看一个由C语言直接调用机器指令的示例,如图所示。在本示例中,定义了一个全局数组code,code数组里保存的若干字符就是机器指令,这些机器指令能够对入参执行自增操作。在main()函数中通过int(*fun)(int)定义了一个指针函数fun,接着通过fun = (void*)code 将该指针函数指向一个内存地址,就是code数组的首地址,最后通过result = fun(7)就能调用这个指针函数了。当这段C程序被编译后,fun()实际上就指向了code数组的内存首地址。当物理机器加载这段编译后的程序,当执行到result=fun(7)这条指令时,就会将CS:IP段寄存器指向code首地址,从而将code数组所在的这一连续内存区域当作代码段来执行。
这就是JVM内部能够直接由C程序调用和执行机器指令的奥秘所在。在JVM内部也有这么一个函数指针,就是call_stub,这个函数指针正式JVM内部C语言与机器指令的分水岭,JVM在调用这个函数之前,主要执行C程序(其实还是C程序编译后的机器指令),而JVM通过这个函数指针调用目标函数之后,就直接进入了机器指令的领域。call_stub函数指针在JVM内部有着举足轻重的意义,在Java程序的执行过程中,会有很多地方涉及直接从JVM内部调用Java函数,例如执行Java程序主函数,或者类加载。
C语言有一个秘密武器,而这种秘密武器就是使C语言跨越IT界几十年历史长河却依然屹立不倒并一直非常流行的根本和基础,那就是指针。当然,这也是JVM得以最终成功的关键因素和关键技术。
可以说,任何底层编程开发,只要祭出"指针"这门神功或者武器,就没有C语言搞不定的事儿(因为能够访问内存,几乎能做机器指令所做的大部分事儿),所以它一直很流行,各种底层软件、驱动程序、操作系统、关系数据库、办公软件、手机操作系统,等等,几乎都是用它开发而成。同时,相比于汇编,C语言在语法上更加人性化,更易于被人类接受和理解,因此C语言广泛取代了汇编语言,只有在对性能要求非常苛刻的一些领域才需要祭出汇编这种最原始、古朴,当然威力也是最强大的神器,例如视频软件的内存访问或者操作系统底层的原子同步。JVM内部的原子操作也基本采用汇编指令来实现
大部分做C程序开发的同学对C语言的指针变量都不陌生,例如int*、char*等等,可以将char*理解成一个字符串的开始地址,也可以将其理解成一个二维字符数组的首地址。同理,既可以将int*理解成一个整型变量的内存地址,也可以将其理解成一个二位整型数组的首地址。
而C语言的指针其实还有一种更重要的用法——函数指针。通过函数指针,C语言可以将一个变量直接指向一个函数的首地址。C语言被编译时,C函数将被直接编译成机器指令,而这个函数指针将直接指向这段机器指令的首地址。于是聪明的人类便想出了一种方法,在源代码编译阶段就定义好一段机器指令,然后直接将一个C函数指针指向这段机器指令的首地址,从而间接实现C语言直接调用机器指令的目的,其实,C语言还有一种办法可以调用汇编指令,那就是内联汇编的方式,在Linux操作系统内核中就有大量的这种用法,但是这种用法与JVM所要实现的目标稍微有点不同,JVM要实现直接由C语言调用机器指令,而内联汇编的方式只实现了这一目标的一半,内联汇编的方式只能实现由C语言直接调用汇编指令,注意汇编指令与机器指令之间还有很大差距。
下面我们来看一个由C语言直接调用机器指令的示例,如图所示。在本示例中,定义了一个全局数组code,code数组里保存的若干字符就是机器指令,这些机器指令能够对入参执行自增操作。在main()函数中通过int(*fun)(int)定义了一个指针函数fun,接着通过fun = (void*)code 将该指针函数指向一个内存地址,就是code数组的首地址,最后通过result = fun(7)就能调用这个指针函数了。当这段C程序被编译后,fun()实际上就指向了code数组的内存首地址。当物理机器加载这段编译后的程序,当执行到result=fun(7)这条指令时,就会将CS:IP段寄存器指向code首地址,从而将code数组所在的这一连续内存区域当作代码段来执行。
这就是JVM内部能够直接由C程序调用和执行机器指令的奥秘所在。在JVM内部也有这么一个函数指针,就是call_stub,这个函数指针正式JVM内部C语言与机器指令的分水岭,JVM在调用这个函数之前,主要执行C程序(其实还是C程序编译后的机器指令),而JVM通过这个函数指针调用目标函数之后,就直接进入了机器指令的领域。call_stub函数指针在JVM内部有着举足轻重的意义,在Java程序的执行过程中,会有很多地方涉及直接从JVM内部调用Java函数,例如执行Java程序主函数,或者类加载。
call_stub函数指针原型定义在stubRoutines.hpp文件中
这段代码里出现了一个宏,CAST_TO_FN_PTR.在JVM中,凡是出现函数名大写的情况,基本都是宏,这基本也是C/C++语言的一种规范。
CAST_TO_FN_PTR宏定义在globalDefinitions.hpp文件中,定义如下:
将call_stub()函数按照这个宏进行替换和展开(C程序中的宏会在预编译阶段被替换)最终得到如下函数定义:
static CallStub call_stub() {
return (CallStub)(castable_address(_call_stub_entry));
}
现在这种形式有代表了什么含义呢?为了理解这个问题,我们就需要对C程序钟的函数指针有生牛乳的了解
CAST_TO_FN_PTR宏定义在globalDefinitions.hpp文件中,定义如下:
将call_stub()函数按照这个宏进行替换和展开(C程序中的宏会在预编译阶段被替换)最终得到如下函数定义:
static CallStub call_stub() {
return (CallStub)(castable_address(_call_stub_entry));
}
现在这种形式有代表了什么含义呢?为了理解这个问题,我们就需要对C程序钟的函数指针有生牛乳的了解
不过在讲解之前,先对前面做一个简单的总结:Java字节码指令直接对应一段特定逻辑的本地机器码,而JVM在解释执行Java字节码指令时,会直接调用字节码指令所对应的本地机器吗。JVM是使用C/C++编写而成的,因此JVM要直接执行本地机器码,便意味着必须要能够从C/C++程序钟直接进入机器指令。这种技术实现的关键便是C语言所提供的一种高级功能——函数指针。通过函数指针能够直接由C程序出发一段机器指令。
在JVM内部,call_stub便是实现C程序调用字节码指令的第一步——例如Java主函数的调用。在JVM执行Java主函数所对应的第一条字节码指令之前,必须经过call_stub函数指针进入对应的例程,然后在目标例程钟出发对Java主函数第一条字节码指令的调用
在JVM内部,call_stub便是实现C程序调用字节码指令的第一步——例如Java主函数的调用。在JVM执行Java主函数所对应的第一条字节码指令之前,必须经过call_stub函数指针进入对应的例程,然后在目标例程钟出发对Java主函数第一条字节码指令的调用
函数指针
在正式揭秘call_stub()之前,有必要讲一下C语言钟的函数指针概念,只有明白了什么是函数指针,才能真正看懂call_stub().函数指针是C/C++语言里一种高级的变量和应用。
1.函数指针与指针函数
在C程序钟,有两种概念容易混淆,那就是函数指针与指针函数。例如下面定义了一个指针函数:
int *fun(int a, int b);
而下面定义了一个函数指针:
// 声明一个函数指针fun
void (*fun)(int a, int b);
// 声明一个函数原型add
void add(int x ,int y);
// 为函数指针赋值: 将add()函数首地址赋值给fun指针
fun = add;
仔细观察指针函数和函数指针的声明用法,可以发现这两者的一个主要区别:
如果函数名称前面的指针符号*没有被括号包括,,则所定义的就是指针函数;如果被括号包含,则所定义的就是函数指针。
# 在int *fun(int a, int b)这行声明中,fun前面的指针符号*没有包含在括号内,所以这行代码就是在定义一个指针函数
# 在void (*fun)(int a, int b)这行声明中,fun前面的指针符号*被包含在括号内,所以这行代码就是在声明一个函数指针
这就是指针函数与函数指针两者之间在形式上的差别。指针函数与函数指针在形式上只有微小的差别,但是在内容和含义上差别就大了。通俗地讲,指针函数与函数指针在内容上的含义分别如下:
# 指针函数声明的是一个函数,与一半的函数声明并无多大区别,唯一有区别的就是指针函数的返回类型是一个指针,而一般的函数声明所返回的则是普通变量类型
# 函数指针声明的是一个指针,只不过这个指针与一般的指针不同,一般的指针指向一个变量的内存地址,而函数指针则指向一个函数的首地址。
所以,在int *fun(int a, int b)这行声明中,fun实际上就i是一个普通的函数,这个函数包含两个入参,类型都是int。同时这个函数返回一个int*类型的指针。
在void(*fun)(int a,int b)这行声明中,fun实际上是一个指针变量,注意,是变量不是函数。这行声明指出,fun指针指向了一个函数。所指向的这个函数必须包含两个入参,类型都是int,同时,fun指针所指向的函数必须有一个void类型的返回值
1.函数指针与指针函数
在C程序钟,有两种概念容易混淆,那就是函数指针与指针函数。例如下面定义了一个指针函数:
int *fun(int a, int b);
而下面定义了一个函数指针:
// 声明一个函数指针fun
void (*fun)(int a, int b);
// 声明一个函数原型add
void add(int x ,int y);
// 为函数指针赋值: 将add()函数首地址赋值给fun指针
fun = add;
仔细观察指针函数和函数指针的声明用法,可以发现这两者的一个主要区别:
如果函数名称前面的指针符号*没有被括号包括,,则所定义的就是指针函数;如果被括号包含,则所定义的就是函数指针。
# 在int *fun(int a, int b)这行声明中,fun前面的指针符号*没有包含在括号内,所以这行代码就是在定义一个指针函数
# 在void (*fun)(int a, int b)这行声明中,fun前面的指针符号*被包含在括号内,所以这行代码就是在声明一个函数指针
这就是指针函数与函数指针两者之间在形式上的差别。指针函数与函数指针在形式上只有微小的差别,但是在内容和含义上差别就大了。通俗地讲,指针函数与函数指针在内容上的含义分别如下:
# 指针函数声明的是一个函数,与一半的函数声明并无多大区别,唯一有区别的就是指针函数的返回类型是一个指针,而一般的函数声明所返回的则是普通变量类型
# 函数指针声明的是一个指针,只不过这个指针与一般的指针不同,一般的指针指向一个变量的内存地址,而函数指针则指向一个函数的首地址。
所以,在int *fun(int a, int b)这行声明中,fun实际上就i是一个普通的函数,这个函数包含两个入参,类型都是int。同时这个函数返回一个int*类型的指针。
在void(*fun)(int a,int b)这行声明中,fun实际上是一个指针变量,注意,是变量不是函数。这行声明指出,fun指针指向了一个函数。所指向的这个函数必须包含两个入参,类型都是int,同时,fun指针所指向的函数必须有一个void类型的返回值
下面分别给出指针函数和函数指针的一个示例:
指针函数如上。本程序声明了一个指针函数add(),包含两个int类型的入参,同时返回int*类型的指针,在main()主函数调用时,使用与add()返回值类型相同的指针类型int* c变量来接收add()函数所返回的值,并最终打印内存地址
指针函数如上。本程序声明了一个指针函数add(),包含两个int类型的入参,同时返回int*类型的指针,在main()主函数调用时,使用与add()返回值类型相同的指针类型int* c变量来接收add()函数所返回的值,并最终打印内存地址
函数指针.
在本例中,定义了一个函数指针addPointer,并将其指向了函数add(),然后就可以通过int c = addPointer(a,b)这样的形式,,像调用普通函数一样,通过函数指针来调用其所指向的函数。由于addPointer指针指向了函数add(),因此程序最终实际上调用的就是add()函数。
在本例中,定义了一个函数指针addPointer,并将其指向了函数add(),然后就可以通过int c = addPointer(a,b)这样的形式,,像调用普通函数一样,通过函数指针来调用其所指向的函数。由于addPointer指针指向了函数add(),因此程序最终实际上调用的就是add()函数。
2.函数指针的两种定义方式
函数指针通常可以通过两种方式进行声明
# 方式一:直接声明
return_type (*func_pointer)(data_type arg1, data_type arg2, ...., data_type argn);
(注意:在定义函数指针时,指针运算符*一定要使用括号括起来,否则就不是定义函数指针了,而是变成定义指针函数了)
使用这种方式定义的函数指针就相当于直接定义了一个变量,可以直接对该变量进行赋值,函数指针与普通变量一样,可以在函数外面声明,作为全局变量,也可以声明在函数内部作为局部变量。例如上面再main()函数外面所声明的addPointer这一函数指针,其实也可以声明再main()函数内部。将上面的例子改成如图所示。在本例中,addPointer函数指针被声明在了main()函数内部,此时它就变成了局部变量。这也是函数指针与指针函数的区别之一,指针函数毕竟本质上仍然是一个函数,而函数是不可以被声明在其他函数内部的
函数指针通常可以通过两种方式进行声明
# 方式一:直接声明
return_type (*func_pointer)(data_type arg1, data_type arg2, ...., data_type argn);
(注意:在定义函数指针时,指针运算符*一定要使用括号括起来,否则就不是定义函数指针了,而是变成定义指针函数了)
使用这种方式定义的函数指针就相当于直接定义了一个变量,可以直接对该变量进行赋值,函数指针与普通变量一样,可以在函数外面声明,作为全局变量,也可以声明在函数内部作为局部变量。例如上面再main()函数外面所声明的addPointer这一函数指针,其实也可以声明再main()函数内部。将上面的例子改成如图所示。在本例中,addPointer函数指针被声明在了main()函数内部,此时它就变成了局部变量。这也是函数指针与指针函数的区别之一,指针函数毕竟本质上仍然是一个函数,而函数是不可以被声明在其他函数内部的
# 方式二: 通过类型声明
函数指针还有一种声明的方式,那就是先定义一个类型,然后通过所定义的类型去声明函数指针。
typedef (*func_pointer) (data_type arg1, data_type arg2,..., data_type argn);
(注意:这里不是在声明函数指针,而是定义了一种函数指针的类型。这种类型的类型名就是func_pointer)
类型声明的方式和直接声明的方式相比,主要区别在于,通过类型声明的方式定义的仅仅是一个类型,由这个类型无法直接去初始化函数指针,因为类型不是变量。例如,在这里定义了func_pointer这个类型后,并不能对齐进行初始化并将其指向某个函数。要想使用这种类型,就必须使用类型名去声明一个函数指针变量,然后才能为所声明的函数指针变量赋值。
还是拿刚才的addPointer举例,说明如何通过类型定义的方式来声明函数指针.
在本例中,先通过typedef(*addPointerType)(int a, int b)定义了一种类型,然后再声明了一个变量addPointer,变量addPointer就属于addPointerType这种类型。其实,addPointerType就类似于C程序中的int、char等基本类型,只不过int、char等基本类型是内建的,是C程序本来就支持的类型,而addPointerType则是开发者自定义的一种类型。
如果使用Java程序来距离,addPointerType好比是Java开发者自定义的一个Java类,定义好Java类后,就可以声明属于这种Java类型的变量,并对变量进行初始化。
在C语言中,通过类型定义的方式来声明函数指针,是一种比较普遍的做法。JVM中的call_stub函数指针便是通过这种方式来声明的。
函数指针还有一种声明的方式,那就是先定义一个类型,然后通过所定义的类型去声明函数指针。
typedef (*func_pointer) (data_type arg1, data_type arg2,..., data_type argn);
(注意:这里不是在声明函数指针,而是定义了一种函数指针的类型。这种类型的类型名就是func_pointer)
类型声明的方式和直接声明的方式相比,主要区别在于,通过类型声明的方式定义的仅仅是一个类型,由这个类型无法直接去初始化函数指针,因为类型不是变量。例如,在这里定义了func_pointer这个类型后,并不能对齐进行初始化并将其指向某个函数。要想使用这种类型,就必须使用类型名去声明一个函数指针变量,然后才能为所声明的函数指针变量赋值。
还是拿刚才的addPointer举例,说明如何通过类型定义的方式来声明函数指针.
在本例中,先通过typedef(*addPointerType)(int a, int b)定义了一种类型,然后再声明了一个变量addPointer,变量addPointer就属于addPointerType这种类型。其实,addPointerType就类似于C程序中的int、char等基本类型,只不过int、char等基本类型是内建的,是C程序本来就支持的类型,而addPointerType则是开发者自定义的一种类型。
如果使用Java程序来距离,addPointerType好比是Java开发者自定义的一个Java类,定义好Java类后,就可以声明属于这种Java类型的变量,并对变量进行初始化。
在C语言中,通过类型定义的方式来声明函数指针,是一种比较普遍的做法。JVM中的call_stub函数指针便是通过这种方式来声明的。
3.函数指针的两种调用方式
函数指针有两个基本调用方式。假设已经声明并定义一个函数指针funcPointer,则可以通过如下两种格式来调用:
# (funcPointer)(参数列表)
# funcPointer(参数列表)
第二种格式,看起来好像funcPointer就是一个普通的函数名,如果不看其定义方式,根本看不出来这到底是一个指针还是函数。
第一种格式看起来比较股改。但是有相当一部分人喜欢这么使用,之所以使用这种格式,是因为这种格式可以让人知道这是在通过指针而非函数进行函数调用
在JVM中,我们即将分析的call_stub函数便是通过(*addPointer)(a,b)这种格式进行调用。总体而言,函数指针作为C语言中的高级引用,是实现C语言动态扩展能力的关键技术之一,如同Java中的反射与类动态加载技术。
函数指针通常有两种定义方式,一种是像定义普通变量一样定义,一种是通过typedef关键字进行类型声明。函数指针本质上是一种指针,不是函数,但是由于这种指针指向的并不是某个变量的内存地址,而是某个函数的内存首地址,因此C语言允许像调用函数一样调用函数指针。要注意的是,普通的指针是不能被当成函数去调用的,这便是函数指针的奇特之处——与普通的指针变量在概念上完全一直但是能够被当成函数进行调用,而与函数在调用上具有相同的方式但是在概念上却有巨大的差异。
函数指针通常有两种调用方法,在JVM内部,使用了其中一种比较奇特的调用方式。如果不了解这种奇特的调用方式,很难理解JVM内部的实现机制
函数指针有两个基本调用方式。假设已经声明并定义一个函数指针funcPointer,则可以通过如下两种格式来调用:
# (funcPointer)(参数列表)
# funcPointer(参数列表)
第二种格式,看起来好像funcPointer就是一个普通的函数名,如果不看其定义方式,根本看不出来这到底是一个指针还是函数。
第一种格式看起来比较股改。但是有相当一部分人喜欢这么使用,之所以使用这种格式,是因为这种格式可以让人知道这是在通过指针而非函数进行函数调用
在JVM中,我们即将分析的call_stub函数便是通过(*addPointer)(a,b)这种格式进行调用。总体而言,函数指针作为C语言中的高级引用,是实现C语言动态扩展能力的关键技术之一,如同Java中的反射与类动态加载技术。
函数指针通常有两种定义方式,一种是像定义普通变量一样定义,一种是通过typedef关键字进行类型声明。函数指针本质上是一种指针,不是函数,但是由于这种指针指向的并不是某个变量的内存地址,而是某个函数的内存首地址,因此C语言允许像调用函数一样调用函数指针。要注意的是,普通的指针是不能被当成函数去调用的,这便是函数指针的奇特之处——与普通的指针变量在概念上完全一直但是能够被当成函数进行调用,而与函数在调用上具有相同的方式但是在概念上却有巨大的差异。
函数指针通常有两种调用方法,在JVM内部,使用了其中一种比较奇特的调用方式。如果不了解这种奇特的调用方式,很难理解JVM内部的实现机制
CallStub函数指针定义
现在我们将目光聚焦到JVM的call_stub指针上,这种指针其实就是函数指针。call_stub函数指针在JVM内部具有举足轻重的作用,例如Java程序主函数的调用链路就必须经过call_stub函数指针的执行,后面的类加载机制中也会经过该函数指针。因此,对于JVM执行引擎而言,该函数指针无比重要,没有这个指针的存在,JVM执行引擎便无从谈起
前面讲过,将下面这句代码:
return CAST_TO_FN_PTR(CallStub, _call_stub_entry)
进行宏替换后,得到下面这行展开式:
return (CallStub)(castable_address(_call_stub_entry));
这里的CallStub其实就是一种自定义的类型,先不管CallStub究竟是怎样的一种类型,为了将问题简化,可以将其想象成最简单的一种类型,例如int,使用int这种基本类型替换CallStub,就得到下面的替换式:
return (int)(castable_address(_call_stub_entry));
这么以替换,这种形式立刻就变成司空见惯的形式,castable_address(_call_stub_entry)返回了一个结果类型,JVM又将这种类型转换成了int类型。
现在一起看看CallStub究竟是怎样的一种类型。CallStub定义在/src/share/vm/runtime/stubRoutines.hpp文件中。由该定义可知,CallStub是这样的一种函数指针类型:其指向的函数,返回值类型是void,并且有8个入参。到这里,call_stub()函数调用的方式基本理顺了。但是,前面在描述函数指针时曾提到函数指针的两种调用方式,而在call_stub()里似乎没有看到任何一种格式的调用。
前面讲过,将下面这句代码:
return CAST_TO_FN_PTR(CallStub, _call_stub_entry)
进行宏替换后,得到下面这行展开式:
return (CallStub)(castable_address(_call_stub_entry));
这里的CallStub其实就是一种自定义的类型,先不管CallStub究竟是怎样的一种类型,为了将问题简化,可以将其想象成最简单的一种类型,例如int,使用int这种基本类型替换CallStub,就得到下面的替换式:
return (int)(castable_address(_call_stub_entry));
这么以替换,这种形式立刻就变成司空见惯的形式,castable_address(_call_stub_entry)返回了一个结果类型,JVM又将这种类型转换成了int类型。
现在一起看看CallStub究竟是怎样的一种类型。CallStub定义在/src/share/vm/runtime/stubRoutines.hpp文件中。由该定义可知,CallStub是这样的一种函数指针类型:其指向的函数,返回值类型是void,并且有8个入参。到这里,call_stub()函数调用的方式基本理顺了。但是,前面在描述函数指针时曾提到函数指针的两种调用方式,而在call_stub()里似乎没有看到任何一种格式的调用。
我们先看看JVM内部时如何调用call_stub()的。JVM在javaCalls::call_helper()中执行了call_stub()函数调用,其调用的代码如图所示。JVM内部调用了call_stub(),并且传入了8个参数,可是call_stub()函数原型却是没有入参的,这是怎么回事呢?
(注意:在call_stub的函数原型生命力,并没有入参,而JVM在调用call_stub()函数时却传入了8个参数)
其实,这里JVM隐式地调用了函数指针,call_stub()函数最终返回的是一个函数指针的实例变量(C语言中没有实例的概念,这里借用下Java的实例概念,意在强调这是一个初始化的变量).虽然call_stub的原型函数里只有return (CallStub)(castable_address(_call_stub_entry))这一行代码,可是这行代码所蕴含的逻辑却十分丰富,编译器编译后,这行代码会被转换为以下的形式:
(注意:在call_stub的函数原型生命力,并没有入参,而JVM在调用call_stub()函数时却传入了8个参数)
其实,这里JVM隐式地调用了函数指针,call_stub()函数最终返回的是一个函数指针的实例变量(C语言中没有实例的概念,这里借用下Java的实例概念,意在强调这是一个初始化的变量).虽然call_stub的原型函数里只有return (CallStub)(castable_address(_call_stub_entry))这一行代码,可是这行代码所蕴含的逻辑却十分丰富,编译器编译后,这行代码会被转换为以下的形式:
CallStub函数原型定义
高级语言之所以高级,就是人类往往能够只使用简短的几行代码(甚至一行)就能表达出十分丰富的含义,一切含义的表达皆由编译器在幕后默默支撑。当然,编译器并不会真的将C源程序转换成上面这段代码,只不过编译器会自动生成中间变量,最终所表达出来的逻辑就与上面这段代码相同。
在这段代码里,我们发现,其实在C程序里通过typedef所自定义的类型,也可以参与类型强制转换的计算。在本例中,castable_address(_call_stub_entry)返回的其实是address这种自定义类型,而编译器最终将其转换成了CallStub这种自定义的类型。
call_stub()最终返回了一个CallStub类型的函数指针变量,调用者其实就可以像调用普通函数那样来调用这种函数指针变量。只不过JVM并没有显式地调用函数指针,而是隐式地进行了调用。但是物理机器是不会理解这种隐式调用地,最终还是要靠编译器来实现隐式调用到显式调用的转变。编译器处理后的显式调用可以用下面这段逻辑表达,如图所示。
这样易分解,JVM实现call_stub()的机制就非常清晰了(其实现在再说调用call_stub(),大家都知道这种说法是错误的,因为实际上JVM调用的是call_stub()函数所返回的函数指针变量)。由于JVM在使用typedef定义CallStub类型时,就规定这种函数指针类型有8个入参,因此JVM最终在调用这种类型的函数指针时,也必须传入8个类型完全相同的参数
在这段代码里,我们发现,其实在C程序里通过typedef所自定义的类型,也可以参与类型强制转换的计算。在本例中,castable_address(_call_stub_entry)返回的其实是address这种自定义类型,而编译器最终将其转换成了CallStub这种自定义的类型。
call_stub()最终返回了一个CallStub类型的函数指针变量,调用者其实就可以像调用普通函数那样来调用这种函数指针变量。只不过JVM并没有显式地调用函数指针,而是隐式地进行了调用。但是物理机器是不会理解这种隐式调用地,最终还是要靠编译器来实现隐式调用到显式调用的转变。编译器处理后的显式调用可以用下面这段逻辑表达,如图所示。
这样易分解,JVM实现call_stub()的机制就非常清晰了(其实现在再说调用call_stub(),大家都知道这种说法是错误的,因为实际上JVM调用的是call_stub()函数所返回的函数指针变量)。由于JVM在使用typedef定义CallStub类型时,就规定这种函数指针类型有8个入参,因此JVM最终在调用这种类型的函数指针时,也必须传入8个类型完全相同的参数
1.castable_address()
前面在说函数指针的声明时谈到,定义了一种函数指针的类型后,可以去声明属于这种类型的一个函数指针变量,并可以初始化这个变量,使其指向某个函数。(注意:上面对函数指针的初始化,都是将其指向某个函数,而实际上,由于函数指针也是一种指针,而指针的特点就是可以指向内存的任意地址,既可以指向函数的首地址,也可以指向某个变量的首地址,说白了,其实指针里无非就是存储了一个值而已。所以,函数指针也是既能指向函数首地址,也能指向某个变量首地址,或者指向任何你想指向的内存地址)
而call_stub()函数内部其实就是让函数指针指向了某个内存地址。
call_stub()宏函数展开后的逻辑是:
return (CallStub)(castable_address(_call_stub_entry));
这里的castable_address是一个函数,定义在globalDefintions.hpp文件中,其定义如下:
inline address_word castable_address(address x) {
return address_word(x);
}
这里的address_word也是一种自定义类型,顾名思义,它表示的是一种地址类型。该类型在globalDefinitions.hpp中定义,定义如下:
typedef unitptr_t address_word;
由此可知,address_word类型其实是uintptr_t,而后一种类型也是JVM自定义的类型,但是这种类型是平台相关的,所以在JVM内部有3处定义了这种类型,分别是:
# globalDefinitions_gcc.hpp
# globalDefinitions_sparcWorks.hpp
# globalDefinitions_visCPP.hpp
在特定的平台上编译JVM时,编译器会自动根据平台类型,编译不同的hpp头文件。这里所列出的3种头文件,只看名字就能知道,分别对应的是Linux、Macintosh和Windows这3种主流的操作系统。这里之所以列出3种文件,是让大家感受一下,如果使用C/C++语言编程,程序员必须考虑平台架构的异构性,并且必须在代码种处理平台的兼容性。当年詹爷也是受够了这种麻烦,所以才会搞出个Java跨平台的编程语言出来。所以Java程序员是幸福的。
在Linux平台上,uintptr_t类型的定义如下(globalDefinitions_gcc.hpp文件中):
typedef unsigned int uintptr_t;
根据这个定义可知,uintptr_t在Linux平台上的类型原型是unsigned int,这是C语言的基本类型之一,即无符号整数类型,于是,终于得知address_word这种类型的是什么。让我们再把目光转回到call_stub()函数,将address_word类型进行替换,得到如下代码:
static CallStub call_stub() {
// 下面的代码原本是这一句: return (CallStub)(castable_address(_call_stub_entry));
return (CallStub)(unsigned int (_call_stub_entry));
}
前面在说函数指针的声明时谈到,定义了一种函数指针的类型后,可以去声明属于这种类型的一个函数指针变量,并可以初始化这个变量,使其指向某个函数。(注意:上面对函数指针的初始化,都是将其指向某个函数,而实际上,由于函数指针也是一种指针,而指针的特点就是可以指向内存的任意地址,既可以指向函数的首地址,也可以指向某个变量的首地址,说白了,其实指针里无非就是存储了一个值而已。所以,函数指针也是既能指向函数首地址,也能指向某个变量首地址,或者指向任何你想指向的内存地址)
而call_stub()函数内部其实就是让函数指针指向了某个内存地址。
call_stub()宏函数展开后的逻辑是:
return (CallStub)(castable_address(_call_stub_entry));
这里的castable_address是一个函数,定义在globalDefintions.hpp文件中,其定义如下:
inline address_word castable_address(address x) {
return address_word(x);
}
这里的address_word也是一种自定义类型,顾名思义,它表示的是一种地址类型。该类型在globalDefinitions.hpp中定义,定义如下:
typedef unitptr_t address_word;
由此可知,address_word类型其实是uintptr_t,而后一种类型也是JVM自定义的类型,但是这种类型是平台相关的,所以在JVM内部有3处定义了这种类型,分别是:
# globalDefinitions_gcc.hpp
# globalDefinitions_sparcWorks.hpp
# globalDefinitions_visCPP.hpp
在特定的平台上编译JVM时,编译器会自动根据平台类型,编译不同的hpp头文件。这里所列出的3种头文件,只看名字就能知道,分别对应的是Linux、Macintosh和Windows这3种主流的操作系统。这里之所以列出3种文件,是让大家感受一下,如果使用C/C++语言编程,程序员必须考虑平台架构的异构性,并且必须在代码种处理平台的兼容性。当年詹爷也是受够了这种麻烦,所以才会搞出个Java跨平台的编程语言出来。所以Java程序员是幸福的。
在Linux平台上,uintptr_t类型的定义如下(globalDefinitions_gcc.hpp文件中):
typedef unsigned int uintptr_t;
根据这个定义可知,uintptr_t在Linux平台上的类型原型是unsigned int,这是C语言的基本类型之一,即无符号整数类型,于是,终于得知address_word这种类型的是什么。让我们再把目光转回到call_stub()函数,将address_word类型进行替换,得到如下代码:
static CallStub call_stub() {
// 下面的代码原本是这一句: return (CallStub)(castable_address(_call_stub_entry));
return (CallStub)(unsigned int (_call_stub_entry));
}
到了这一步,call_stub()函数的逻辑基本全部还原出来了,call_stub()函数的逻辑总结起来包含下面两步:
# 第一步,将_call_stub_entry变量转换为unsigned int这一基本类型
# 第二步,将_call_stub_entry所转换后的unsigned int这一基本类型再转换成CallStub这一自定义类型,该类型是函数指针类型
那么_call_stub_entry是啥?是啥类型?该类型定义在StubRoutines.hpp中,定义如下:
static address _call_stub_entry;
由此可知,_call_stub_entry本身就是addresss类型,而该类型的原型是unsigned int.再次回顾下JVM调用Java函数的过程:
# JVM先调用call_stub()函数,该函数将_call_stub_entry这一unsigned int 类型变量转换成CallStub自定义的类型,该类型是函数指针
# JVM将call_stub()所返回的函数指针当成函数进行调用
在这个过程中,似乎还有件事儿不明确,那就是函数指针的指向。在call_stub()里是直接返回了_call_stub_entry这一基本类型的变量,然后直接就被转换成CallStub这一自定义的函数指针类型,接着JVM直接就调用该函数指针了,,自始至终并没有看到函数指针被指向了哪个函数。而在千问讲解函数指针时提到,要调用函数指针,必须将其指向某个函数。
其实,JVM在初始化的过程中,便将_call_stub_entry这一变量指向了某个内存地址,否则JVM肯定无法直接调用。在x86 32位Linux平台上,JVM在初始化过程中,存在这样一条链路。如图所示。
这条链路从JVM的main()函数开始,调用到init_globals()这个全局数据初始化模块,最后再调用到StubRoutines这个例程生成模块,最终在stubGenerator_x86_64.cpp:generate_initial()函数中会执行代码,对_call_stub_entry这个变量进行初始化
# 第一步,将_call_stub_entry变量转换为unsigned int这一基本类型
# 第二步,将_call_stub_entry所转换后的unsigned int这一基本类型再转换成CallStub这一自定义类型,该类型是函数指针类型
那么_call_stub_entry是啥?是啥类型?该类型定义在StubRoutines.hpp中,定义如下:
static address _call_stub_entry;
由此可知,_call_stub_entry本身就是addresss类型,而该类型的原型是unsigned int.再次回顾下JVM调用Java函数的过程:
# JVM先调用call_stub()函数,该函数将_call_stub_entry这一unsigned int 类型变量转换成CallStub自定义的类型,该类型是函数指针
# JVM将call_stub()所返回的函数指针当成函数进行调用
在这个过程中,似乎还有件事儿不明确,那就是函数指针的指向。在call_stub()里是直接返回了_call_stub_entry这一基本类型的变量,然后直接就被转换成CallStub这一自定义的函数指针类型,接着JVM直接就调用该函数指针了,,自始至终并没有看到函数指针被指向了哪个函数。而在千问讲解函数指针时提到,要调用函数指针,必须将其指向某个函数。
其实,JVM在初始化的过程中,便将_call_stub_entry这一变量指向了某个内存地址,否则JVM肯定无法直接调用。在x86 32位Linux平台上,JVM在初始化过程中,存在这样一条链路。如图所示。
这条链路从JVM的main()函数开始,调用到init_globals()这个全局数据初始化模块,最后再调用到StubRoutines这个例程生成模块,最终在stubGenerator_x86_64.cpp:generate_initial()函数中会执行代码,对_call_stub_entry这个变量进行初始化
_call_stub_entry这个变量的初始化位置。。
最终,JVM调用generate_call_stub(StubRoutines::_call_stub_return_address)函数返回一个值赋值给_call_stub_entry。
generate_call_stub(StubRoutines::_call_stub_return_address)里面的逻辑十分重要,可以说是JVM最核心的功能。不过要分析清楚这个最核心的功能模块,得先弄清楚call_stub的入参
最终,JVM调用generate_call_stub(StubRoutines::_call_stub_return_address)函数返回一个值赋值给_call_stub_entry。
generate_call_stub(StubRoutines::_call_stub_return_address)里面的逻辑十分重要,可以说是JVM最核心的功能。不过要分析清楚这个最核心的功能模块,得先弄清楚call_stub的入参
2.CallStub()入参
准确地说,CallStub并不是函数,"CallStub入参"不够严谨,但是CallStub是一个函数指针,最终JVM也是通过这一函数指针调用其所指向的函数的,所以将其说成是函数也不为过。为了简单期间,统一说成是"CallStub()入参".
在javaCalls.cpp::call_helper()函数中,JVM是这样调用CallStub()函数的,如图所示,JVM一共传入了8个参数,这8个参数的含义如下:
# link: 顾名思义,这是一个"连接器",注意,不是"链接器"
# result_val_address: 函数返回值地址
# result_type: 函数返回类型
# method(): JVM内部所表示的Java方法对象
# entry_point: JVM调用Java方法的例程入口。JVM内部的每一段例程都是在JVM启动过程中预先生成好的一段机器指令。要调用Java方法,都必须经过本例程,即需要先执行这段机器指令,然后才能跳转到Java方法字节码所对应的机器指令去执行
# parameters(): Java方法的入参集合
# size_of_parameters(): Java方法的入参数量
# CHECK: 当前线程对象
这些参数每一个都非常重要。要想深入了解这些参数背后的意义,必须要等到研究完Java的内存模型之后。这里先简单介绍5个参数:
准确地说,CallStub并不是函数,"CallStub入参"不够严谨,但是CallStub是一个函数指针,最终JVM也是通过这一函数指针调用其所指向的函数的,所以将其说成是函数也不为过。为了简单期间,统一说成是"CallStub()入参".
在javaCalls.cpp::call_helper()函数中,JVM是这样调用CallStub()函数的,如图所示,JVM一共传入了8个参数,这8个参数的含义如下:
# link: 顾名思义,这是一个"连接器",注意,不是"链接器"
# result_val_address: 函数返回值地址
# result_type: 函数返回类型
# method(): JVM内部所表示的Java方法对象
# entry_point: JVM调用Java方法的例程入口。JVM内部的每一段例程都是在JVM启动过程中预先生成好的一段机器指令。要调用Java方法,都必须经过本例程,即需要先执行这段机器指令,然后才能跳转到Java方法字节码所对应的机器指令去执行
# parameters(): Java方法的入参集合
# size_of_parameters(): Java方法的入参数量
# CHECK: 当前线程对象
这些参数每一个都非常重要。要想深入了解这些参数背后的意义,必须要等到研究完Java的内存模型之后。这里先简单介绍5个参数:
1.连接器link
连接器link的作用,从其名称也可猜测一二,就是起到连接、桥梁的作用.连接谁呢?这要从link的类型定义来说。连接器link所属类型是JavaCallWrapper,该类型定义在javaCalls.cpp文件中,定义如图所示
这是一个C++类型,这个类型里面包含这样几个变量:
# _thread, 当前Java函数所在线程
# _handles, 本地调用句柄
# _callee_method, 调用者方法对象
# _receiver, 被调用者(非静态Java方法)
# _anchor, Java线程堆栈对象
# _result, Java方法所返回的值
通过这些变量可知,link其实在Java函数的调用者与被调用者之间搭建了一座桥梁,通过这座桥梁,我们可以实现堆栈追踪,可以得到整个方法的调用链路。
在Java函数调用时,link指针将被保存到当前方法的堆栈中。(注意: JVM内部有一个linker,是链接器,与link是不同的两个对象)
连接器link的作用,从其名称也可猜测一二,就是起到连接、桥梁的作用.连接谁呢?这要从link的类型定义来说。连接器link所属类型是JavaCallWrapper,该类型定义在javaCalls.cpp文件中,定义如图所示
这是一个C++类型,这个类型里面包含这样几个变量:
# _thread, 当前Java函数所在线程
# _handles, 本地调用句柄
# _callee_method, 调用者方法对象
# _receiver, 被调用者(非静态Java方法)
# _anchor, Java线程堆栈对象
# _result, Java方法所返回的值
通过这些变量可知,link其实在Java函数的调用者与被调用者之间搭建了一座桥梁,通过这座桥梁,我们可以实现堆栈追踪,可以得到整个方法的调用链路。
在Java函数调用时,link指针将被保存到当前方法的堆栈中。(注意: JVM内部有一个linker,是链接器,与link是不同的两个对象)
2.method()
method()是当前Java方法在JVM内部的表示对象。每一个Java方法在被JVM加载时,JVM都会在内部为这个Java方法建立函数模型,说白了就是保存一份Java方法的全部原始描述信息。JVM为Java方法所建立的模型中至少包含以下信息:
# Java函数的名称、所属的Java类
# Java函数的入参信息,包括入参类型、入参参数名、入参数量、入参顺序等
# Java函数编译后的zzi解码信息,包括对应的字节码指令、所占用的总字节数等
# Java函数的注解信息
# Java函数的继承信息
# Java函数的返回信息
JVM在调用CallStub()函数指针时,将method()对象传递进去,最终就是为了从method()对象中获取到Java函数编译后的字节码信息,JVM拿到字节码信息之后,就能对字节码进行解释执行了
注:大家通过Java反射对象不仅能够获取一个Java类的元信息,也能够获取Java类中函数的全部原始信息,之所以能够获取,就是因为JVM在内部为每一个Java类、每一个Java方法都建立了内存模型,保存Java类和Java方法的全部信息,因此在运行期通过反射只需要访问这个内存模型就能得到这些信息,这是Java语言与C++(两种都是面向对象的编程语言)的最大不同。C++程序由于编译后直接变成了二进制机器指令,已经擦除了所有的面向对象信息,因此C++程序在运行期是获取不到C++类和函数的原始信息的(当然著名的RTTI能够为C++提供类型反射的能力)
method()是当前Java方法在JVM内部的表示对象。每一个Java方法在被JVM加载时,JVM都会在内部为这个Java方法建立函数模型,说白了就是保存一份Java方法的全部原始描述信息。JVM为Java方法所建立的模型中至少包含以下信息:
# Java函数的名称、所属的Java类
# Java函数的入参信息,包括入参类型、入参参数名、入参数量、入参顺序等
# Java函数编译后的zzi解码信息,包括对应的字节码指令、所占用的总字节数等
# Java函数的注解信息
# Java函数的继承信息
# Java函数的返回信息
JVM在调用CallStub()函数指针时,将method()对象传递进去,最终就是为了从method()对象中获取到Java函数编译后的字节码信息,JVM拿到字节码信息之后,就能对字节码进行解释执行了
注:大家通过Java反射对象不仅能够获取一个Java类的元信息,也能够获取Java类中函数的全部原始信息,之所以能够获取,就是因为JVM在内部为每一个Java类、每一个Java方法都建立了内存模型,保存Java类和Java方法的全部信息,因此在运行期通过反射只需要访问这个内存模型就能得到这些信息,这是Java语言与C++(两种都是面向对象的编程语言)的最大不同。C++程序由于编译后直接变成了二进制机器指令,已经擦除了所有的面向对象信息,因此C++程序在运行期是获取不到C++类和函数的原始信息的(当然著名的RTTI能够为C++提供类型反射的能力)
3.entry_point
entry_point是继_call_stub_entry这一JVM最核心例程之后的又一个最主要的例程入口。前面对_call_stub_entry进行了简单的分析,JVM每次从JVM内部调用Java函数时(相对于通过字节码指令调用目标Java函数),必定调用CallStub函数指针,而该函数指针的值就是_call_stub_entry.JVM通过_call_stub_entry所指向的函数地址,最终调用到Java函数。
在JVM通过_call_stub_entry所指向的函数调用Java函数之前,必须要先经过entry_point例程。事实上,在entry_point例程里面会真正从method()(这是Java函数在JVM内部的模型)对象上拿到Java函数编译后的字节码,JVM通过entry_point可以得到Java函数所对应的第一个字节码指令,然后开始调用Java函数。
这里先简单绘制一下JVM经过_call_stub_entry,到entry_point,再到Java程序main()主函数的路线图。如图所示。
对于该图大家一定充满了疑惑,一方面时函数指针的调用方式实在比较特殊,如果没有这方面的开发经验,简直有点颠覆大家对函数调用的一般性认识;另一方面, _call_stub_entry和entry_point这两个例程的概念也实在时云里雾里。这两个例程其实就是两段机器指令,JVM提前写好了很多例程,例如函数进入、函数调用、函数返回、异常处理、静态函数调用、本地函数调用,等等,这些例程都是直接使用机器指令携程。直接使用机器指令编写而成的这些例程,能够最大程度地提高程序运行的效率。当然,JVM中定义的200多字节码指令,每一条字节码指令也有一个例程,也全部直接使用机器指令写成。
不过任何复杂的事,其实都是可以用简单的道理说明白的,如果说JVM中的所谓例程的概念比较玄乎,那么对于广大Java程序员来说,有一个名词一定是耳熟能详的,那就是——工具类。我们就将例程想象成一个个工具类即可,个人认为这两者之间完全具有可类比性。从所要实现的功能来看,无论是例程,还是工具类,最终的目的无非就是实现某一个特定的逻辑而已。两者所不同的只是,例程是直接用机器指令写的,而Java工具类则是使用Java语言写的。(岩哥来说,例程也不是直接用机器指令写的,而是用C语言在运行期动态生成的)。
_call_stub_entry和entry_point这两个例程到此先分析到这里,后面再对其逻辑深入,
entry_point是继_call_stub_entry这一JVM最核心例程之后的又一个最主要的例程入口。前面对_call_stub_entry进行了简单的分析,JVM每次从JVM内部调用Java函数时(相对于通过字节码指令调用目标Java函数),必定调用CallStub函数指针,而该函数指针的值就是_call_stub_entry.JVM通过_call_stub_entry所指向的函数地址,最终调用到Java函数。
在JVM通过_call_stub_entry所指向的函数调用Java函数之前,必须要先经过entry_point例程。事实上,在entry_point例程里面会真正从method()(这是Java函数在JVM内部的模型)对象上拿到Java函数编译后的字节码,JVM通过entry_point可以得到Java函数所对应的第一个字节码指令,然后开始调用Java函数。
这里先简单绘制一下JVM经过_call_stub_entry,到entry_point,再到Java程序main()主函数的路线图。如图所示。
对于该图大家一定充满了疑惑,一方面时函数指针的调用方式实在比较特殊,如果没有这方面的开发经验,简直有点颠覆大家对函数调用的一般性认识;另一方面, _call_stub_entry和entry_point这两个例程的概念也实在时云里雾里。这两个例程其实就是两段机器指令,JVM提前写好了很多例程,例如函数进入、函数调用、函数返回、异常处理、静态函数调用、本地函数调用,等等,这些例程都是直接使用机器指令携程。直接使用机器指令编写而成的这些例程,能够最大程度地提高程序运行的效率。当然,JVM中定义的200多字节码指令,每一条字节码指令也有一个例程,也全部直接使用机器指令写成。
不过任何复杂的事,其实都是可以用简单的道理说明白的,如果说JVM中的所谓例程的概念比较玄乎,那么对于广大Java程序员来说,有一个名词一定是耳熟能详的,那就是——工具类。我们就将例程想象成一个个工具类即可,个人认为这两者之间完全具有可类比性。从所要实现的功能来看,无论是例程,还是工具类,最终的目的无非就是实现某一个特定的逻辑而已。两者所不同的只是,例程是直接用机器指令写的,而Java工具类则是使用Java语言写的。(岩哥来说,例程也不是直接用机器指令写的,而是用C语言在运行期动态生成的)。
_call_stub_entry和entry_point这两个例程到此先分析到这里,后面再对其逻辑深入,
4.parameters()
这个参数看名字就能猜出其含义,事实上也的确如你所猜,这个参数就是描述Java函数入参信息的。
在JVM真正调用Java函数的第一个字节码指令之前,JVM会在CallStub()函数中解析Java函数的入参,解析后,JVM会为Java函数分配堆栈,并将Java函数的入参逐个入栈。这样,JVM就为高层次的编程语言建立了方法栈模型。相比于C/C++/Delphi等编译型语言,Java这门解释型语言最大的不同就是,不直接在物理机器上运行,而是运行在虚拟机上,所以不能直接使用物理机器的方法栈,必须在虚拟机层面为每一个Java函数都建立堆栈模型,这种堆栈模型中的局部变量都是Java语言的变量类型。所以,JVM在正式调用Java函数之前,必须要能够获取到Java语言层面上的参数类型、参数对象、参数顺序等信息,维其如此,JVM才能正确调用并执行Java函数。
众所周知,Java语言是一门面向对象的语言,在内存模型上,Java类的示例对象分配在堆中,而堆栈中只是保存了引用。所谓引用,在JVM内部,实际上就是一个指针。JVM为何要这样分配内存呢?其实并不是詹爷多此一举,而是因为必须这么处理。导致JVM被逼做出这中内存分析模型策略的原因是,堆栈空间通常都比较小,放不下那么大的对象。在物理机器上直接跑的程序,堆栈空间一般最大为64MB(当然可以调大),而Java堆栈一般为1MB,或者8MB,或者再设置得大点,但是不可能达到注入500MB甚至1GB这样的规模。如果每一个函数都设置这么大的堆栈空间,那么函数的调用深度稍微一大,整个物理机器的内存就会溢出。正因如此,JVM只能将类对象示例保存到堆中。否则,如果我们定义一个很大的字符串传给某个函数,如果整个字符串信息都保存在堆栈中,那么JVM很容易就会被各种大字符串攻击
这个参数看名字就能猜出其含义,事实上也的确如你所猜,这个参数就是描述Java函数入参信息的。
在JVM真正调用Java函数的第一个字节码指令之前,JVM会在CallStub()函数中解析Java函数的入参,解析后,JVM会为Java函数分配堆栈,并将Java函数的入参逐个入栈。这样,JVM就为高层次的编程语言建立了方法栈模型。相比于C/C++/Delphi等编译型语言,Java这门解释型语言最大的不同就是,不直接在物理机器上运行,而是运行在虚拟机上,所以不能直接使用物理机器的方法栈,必须在虚拟机层面为每一个Java函数都建立堆栈模型,这种堆栈模型中的局部变量都是Java语言的变量类型。所以,JVM在正式调用Java函数之前,必须要能够获取到Java语言层面上的参数类型、参数对象、参数顺序等信息,维其如此,JVM才能正确调用并执行Java函数。
众所周知,Java语言是一门面向对象的语言,在内存模型上,Java类的示例对象分配在堆中,而堆栈中只是保存了引用。所谓引用,在JVM内部,实际上就是一个指针。JVM为何要这样分配内存呢?其实并不是詹爷多此一举,而是因为必须这么处理。导致JVM被逼做出这中内存分析模型策略的原因是,堆栈空间通常都比较小,放不下那么大的对象。在物理机器上直接跑的程序,堆栈空间一般最大为64MB(当然可以调大),而Java堆栈一般为1MB,或者8MB,或者再设置得大点,但是不可能达到注入500MB甚至1GB这样的规模。如果每一个函数都设置这么大的堆栈空间,那么函数的调用深度稍微一大,整个物理机器的内存就会溢出。正因如此,JVM只能将类对象示例保存到堆中。否则,如果我们定义一个很大的字符串传给某个函数,如果整个字符串信息都保存在堆栈中,那么JVM很容易就会被各种大字符串攻击
5.size_of_parameter()
这个入参也是见其名知其意,其含义就是参数数量,即Java函数的入参个数。刚才讲到另一个入参,parameters(),这个参数保存了Java函数的所有入参信息,但是paramters()里面其实是使用指针建立起来的数组模型,JVM在后面调用Java函数时,直接通过parameters()内部的指针,无法得知结束位置,因此这里需要将Java函数的入参数量传递进去,JVM在Java函数分配堆栈空间时,会根据这个值,计算出Java堆栈空间的大小。
这个入参也是见其名知其意,其含义就是参数数量,即Java函数的入参个数。刚才讲到另一个入参,parameters(),这个参数保存了Java函数的所有入参信息,但是paramters()里面其实是使用指针建立起来的数组模型,JVM在后面调用Java函数时,直接通过parameters()内部的指针,无法得知结束位置,因此这里需要将Java函数的入参数量传递进去,JVM在Java函数分配堆栈空间时,会根据这个值,计算出Java堆栈空间的大小。
总体而言,CallStub例程是JVM内部举足轻重的一个功能,而JVM在调用CallStub函数指针时,可谓是层层包装,让人一眼看不出究竟这里通过抽丝剥茧和场景还原,剖析了CallStub这种函数指针的调用机制,描述了CallStub函数指针的定义、调用和入参.
_call_stub_entry例程
接下来就可以分析_call_stub_entry这个例程所指向的一段机器指令的逻辑了。由于这段机器指令牵扯的东西太多(例如汇编基础、Java函数入参、Java堆栈模型等),因此前面才会花那么多篇幅进行相关技术的介绍。
前面在讲JVM调用CallStub()时,提到JVM先调用call_stub()函数,返回_call_stub_entry,然后将_call_stub_entry强制转换为CallStub这种自定义的函数指针类型,最终JVM调用这一函数指针,实现C程序的世界转入机器指令的世界,完成分水岭的跨越。不过这里有一个最核心的问题,就是,既然_call_stub_entry最终被转化成了函数指针类型,那么其必定指向了某个函数入口,这样JVM才能将这个函数指针当成函数一样进行调用。那么_call_stub_entry最终究竟指向哪里了呢?这就有必要分析_call_stub_entry例程了。
前面在分析castable_address()函数时提到,JVM中存在这样一条链路,对_call_stub_entry所代表的例程进行了初始化,这条链路是java.c:main()->......->stubGenerator_x86_64.cpp: generate_initial() 在这条链路的最末端,stubgenerator_x86_64.cpp:generate_initial()函数对_call_stub_entry变量进行了初始化,如下所示:
StubRoutines::_call_stub_entry = generate_call_stub(StubRoutines::_call_stub_return_address);
generate_call_stub()函数返回值赋值了_call_stub_entry变量,generate_call_stub()函数顾名思义,就是产生_call_stub_entry变量所指向的一个函数首地址。generate_call_stub()函数的定义如图所示
前面在讲JVM调用CallStub()时,提到JVM先调用call_stub()函数,返回_call_stub_entry,然后将_call_stub_entry强制转换为CallStub这种自定义的函数指针类型,最终JVM调用这一函数指针,实现C程序的世界转入机器指令的世界,完成分水岭的跨越。不过这里有一个最核心的问题,就是,既然_call_stub_entry最终被转化成了函数指针类型,那么其必定指向了某个函数入口,这样JVM才能将这个函数指针当成函数一样进行调用。那么_call_stub_entry最终究竟指向哪里了呢?这就有必要分析_call_stub_entry例程了。
前面在分析castable_address()函数时提到,JVM中存在这样一条链路,对_call_stub_entry所代表的例程进行了初始化,这条链路是java.c:main()->......->stubGenerator_x86_64.cpp: generate_initial() 在这条链路的最末端,stubgenerator_x86_64.cpp:generate_initial()函数对_call_stub_entry变量进行了初始化,如下所示:
StubRoutines::_call_stub_entry = generate_call_stub(StubRoutines::_call_stub_return_address);
generate_call_stub()函数返回值赋值了_call_stub_entry变量,generate_call_stub()函数顾名思义,就是产生_call_stub_entry变量所指向的一个函数首地址。generate_call_stub()函数的定义如图所示
1.pc()函数
首先看第一行代码:
address start = __ pc();
这行代码保存当前例程所对应的一段机器码的起始位置。pc()函数定义如下:
address pc() const { return code_section()->end(); }
CodeSection* code_section() const { return _code_section; }
该函数返回code_section的末尾。是一个address类型的变量。在JVM启动过程中,JVM会生成很多例程(即一段固定的机器指令,能够实现一种特定的功能逻辑),例如函数调用、字节码例程、异常处理、函数返回等。每一个例程,一开始都有这么一行代码(即address start = __ pc()),代码完全相同。事实上,JVM的所有例程都在一段连续的内存中,我们可以将这段内存想象成一根直线,当JVM刚启动时,这跟线长度为0,没有生成任何例程。第一个例程生成时,__ pc()返回0,因为此时是从这根直线的零点位置开始。假设第一个例程占20字节,则当JVM生成第二个例程时,第二个例程执行start = __pc()时,将返回20,则当JVM生成第二个例程时,第二个例程执行start == __pc()时,将返回20(如果将第一个位置标记为0,则第二个位置为20;否则从1开始标记,则第二个位置为21),因为第一个例程占用20字节。如图所示.(注: JVM的例程都写入JVM的堆内存中,在JVM初始化时,会初始化一个容量足够大的堆内存,例程会写入堆中靠近起始的位置,当Java程序开始运行后,JVM将Java类对象示例陆续写入堆内存中)
JVM中每一个例程都有一个对应的generate()函数(具体的函数名不同,但是基本都以genertae_开头),假设第一个例程占20个字节码,则当第一个例程所对应的generate()函数执行完成后,它的结束位置会自动累加到20,于是当JVM生成第二个例程时,pc()就会返回20,
首先看第一行代码:
address start = __ pc();
这行代码保存当前例程所对应的一段机器码的起始位置。pc()函数定义如下:
address pc() const { return code_section()->end(); }
CodeSection* code_section() const { return _code_section; }
该函数返回code_section的末尾。是一个address类型的变量。在JVM启动过程中,JVM会生成很多例程(即一段固定的机器指令,能够实现一种特定的功能逻辑),例如函数调用、字节码例程、异常处理、函数返回等。每一个例程,一开始都有这么一行代码(即address start = __ pc()),代码完全相同。事实上,JVM的所有例程都在一段连续的内存中,我们可以将这段内存想象成一根直线,当JVM刚启动时,这跟线长度为0,没有生成任何例程。第一个例程生成时,__ pc()返回0,因为此时是从这根直线的零点位置开始。假设第一个例程占20字节,则当JVM生成第二个例程时,第二个例程执行start = __pc()时,将返回20,则当JVM生成第二个例程时,第二个例程执行start == __pc()时,将返回20(如果将第一个位置标记为0,则第二个位置为20;否则从1开始标记,则第二个位置为21),因为第一个例程占用20字节。如图所示.(注: JVM的例程都写入JVM的堆内存中,在JVM初始化时,会初始化一个容量足够大的堆内存,例程会写入堆中靠近起始的位置,当Java程序开始运行后,JVM将Java类对象示例陆续写入堆内存中)
JVM中每一个例程都有一个对应的generate()函数(具体的函数名不同,但是基本都以genertae_开头),假设第一个例程占20个字节码,则当第一个例程所对应的generate()函数执行完成后,它的结束位置会自动累加到20,于是当JVM生成第二个例程时,pc()就会返回20,
通过这两张图可知,JVM每生成一个例程,就会将例程起始位置增加,每一个例程都会占用JVM堆内存的一块连续区域,相邻例程之间的内存区域相连(即内存位置时靠在一起的),所有的例程最后连成一块连续的区域。而事实上,JVM内部的确是这样来划分内存的
注意:在每一个例程所对应的generate()函数内部,例如这里的generate_all_stub()函数,都在函数的开始处执行address start = __pc(),然后再函数的最后执行return start.这是因为它的结束位置其实是偏移量,当JVM要生成一个新的例程时,end偏移量指向上一个例程的最后字节的位置,而上一个例程的最后一个字节位置,正式下一个例程的起始位置,所以再每一个例程所对应的generate()函数的开始,线保存这个起始位置,当generate()函数的主体逻辑执行完成,end结束位置会不断地自增,,直到到达当前例程的最后一个字节的位置。如果不在generate()函数开始处就保存这个偏移量,那么最后返回的这个值将变成当前例程的末端位置,而不是起始位置,这样,最终JVM通过CallStub这个函数指针(或者其他例程所对应的函数指针)来执行这段动态生成的机器指令,就会位置不对而报错
2.定义入参
generate_all_stub()接下来执行下面一段代码:
// same as in generate_catch_exception()!
const Address rsp_after_call(rbp, rsp_after_call_off * wordSize);
const Address mxcsr_save(rbp, mxcsr_off * wordSize);
const Address rdi_save(rbp, rdi_off * wordSize);
const Address rsi_save(rbp, rsi_off * wordSize);
const Address rbx_save(rbp, rbx_off * wordSize);
//.....
在讲解入参之前,我们先回顾下在前面分析机器调用时的调用者和被调用者堆栈模型。当时是以main()和add()举例说明的,main()是调用者函数caller,add()是被调用函数callee,当物理机器由caller执行到callee时,堆栈模型的变化如图所示。。由图可知,一个函数的堆栈空间大体上可以分为3部分:
# 堆栈变量区
保存方法的局部变量,或者对数据的地址引用(指针)
如果一个方法中并没有局部变量,则编译器不会为该方法分配堆栈变量区
# 入参区域
如果当前方法调用了其他方法,并且给其他方法传递了参数,那么这些入参会保存在调用者的堆栈中,这就是所谓的"压栈".至少在x86平台上,入参区域相对于方法的堆栈变量区,在内存上位于低位置,即堆栈变量区在高地址方向,而入参区域则在低地址方向。x86在分配堆栈空间时,本来就是按照由高高地址向低地址的方向分配的
# ip和bp区
ip和bp,一个时代码段寄存器,一个是堆栈栈基寄存器。这两个寄存器,一个用于恢复调用者方法的代码位置,一个用于恢复调用方法的堆栈位置,是完成物理机器函数调用机制的最主要的两个寄存器。
generate_all_stub()接下来执行下面一段代码:
// same as in generate_catch_exception()!
const Address rsp_after_call(rbp, rsp_after_call_off * wordSize);
const Address mxcsr_save(rbp, mxcsr_off * wordSize);
const Address rdi_save(rbp, rdi_off * wordSize);
const Address rsi_save(rbp, rsi_off * wordSize);
const Address rbx_save(rbp, rbx_off * wordSize);
//.....
在讲解入参之前,我们先回顾下在前面分析机器调用时的调用者和被调用者堆栈模型。当时是以main()和add()举例说明的,main()是调用者函数caller,add()是被调用函数callee,当物理机器由caller执行到callee时,堆栈模型的变化如图所示。。由图可知,一个函数的堆栈空间大体上可以分为3部分:
# 堆栈变量区
保存方法的局部变量,或者对数据的地址引用(指针)
如果一个方法中并没有局部变量,则编译器不会为该方法分配堆栈变量区
# 入参区域
如果当前方法调用了其他方法,并且给其他方法传递了参数,那么这些入参会保存在调用者的堆栈中,这就是所谓的"压栈".至少在x86平台上,入参区域相对于方法的堆栈变量区,在内存上位于低位置,即堆栈变量区在高地址方向,而入参区域则在低地址方向。x86在分配堆栈空间时,本来就是按照由高高地址向低地址的方向分配的
# ip和bp区
ip和bp,一个时代码段寄存器,一个是堆栈栈基寄存器。这两个寄存器,一个用于恢复调用者方法的代码位置,一个用于恢复调用方法的堆栈位置,是完成物理机器函数调用机制的最主要的两个寄存器。
(ip和bp区) 函数堆栈空间的一般布局如图所示。
其次,物理机器在执行函数调用时,存在一定的空间浪费。入参往往同时使当前方法的局部变量,编译器会将局部变量分配在局部变量去与,而在入参时,
会将局部变量再次复制一份放到压栈区域,同一份数据被分配了两次堆栈空间,起始依靠编译器的智能性,完全可以将堆栈中的入参区域去掉。但是话又说回来,正因为编译器规定了入参空间分配的原则,并使入参按照从左到右或从右到左的顺序压栈,这种规范不仅仅让被调用函数在访问入参时享受到了极大的便利,也让JVM设计者在涉及Java函数调用机制时,能够基于这一规范,随心所欲地发挥,很难想象,如果缺少了这一规范,JVM包括很多其他虚拟机,在设计函数调用机制时,会是怎样的一种场景,大家会相处多少招式来实现这一复杂的逻辑?
其次,物理机器在执行函数调用时,存在一定的空间浪费。入参往往同时使当前方法的局部变量,编译器会将局部变量分配在局部变量去与,而在入参时,
会将局部变量再次复制一份放到压栈区域,同一份数据被分配了两次堆栈空间,起始依靠编译器的智能性,完全可以将堆栈中的入参区域去掉。但是话又说回来,正因为编译器规定了入参空间分配的原则,并使入参按照从左到右或从右到左的顺序压栈,这种规范不仅仅让被调用函数在访问入参时享受到了极大的便利,也让JVM设计者在涉及Java函数调用机制时,能够基于这一规范,随心所欲地发挥,很难想象,如果缺少了这一规范,JVM包括很多其他虚拟机,在设计函数调用机制时,会是怎样的一种场景,大家会相处多少招式来实现这一复杂的逻辑?
基于这一规范,在被调用者方法中,访问入参,就变得有规律可循。假设有如下一个C程序,当该程序运行时,物理机器首先为main()函数分配局部变量,此时main()函数对战内存布局如图所示。。
main()函数堆栈布局
(注:细心的读者会发现这个图有问题,因为当CPU运行main()函数指令时,根本不会为add()函数分配堆栈空间,因此这里将add()函数的堆栈空间画上去是错误的,这里将其添加上去,主要是为了让大家能够总览调用者与被调用者之间的堆栈布局,从而建立这种印象)
(注:细心的读者会发现这个图有问题,因为当CPU运行main()函数指令时,根本不会为add()函数分配堆栈空间,因此这里将add()函数的堆栈空间画上去是错误的,这里将其添加上去,主要是为了让大家能够总览调用者与被调用者之间的堆栈布局,从而建立这种印象)
main()函数开始调用add()函数之前,main()函数会将入参压栈,注意:参数被压倒main()函数的堆栈中,此时整体堆栈空间布局如图所示。。
(注意:在x86平台上,入参以你想顺序压栈,因此8先压栈,6后压栈)
(注意:在x86平台上,入参以你想顺序压栈,因此8先压栈,6后压栈)
完成入参压栈后,接着物理机器将ip和bp这两个寄存器入栈,此时堆栈内存布局如图所示。
到了这一步,完成了ip和bp寄存器的入栈,物理机器就开始为被调用函数add()分配堆栈空间了,add()函数的栈底与ebp寄存器相邻。在x86平台上,堆栈由高内存地址往低内存地址方向分配,因此add()函数的栈底相对于ebp寄存器而言,处于低内存位置。前面讲过,物理机器对堆栈内存寻址的方式为相对偏移,可以通过相对栈底bp或栈顶sp的位置来绝对定位堆栈内存位置。当相对sp(栈顶)进行寻址时,如果对位于sp下方(低内存地址方向)的堆栈内存进行寻址,则使用-m(%sp)这样的方式,其含义是sp寄存器的值减去m字节,m是个自然数,例如-3(%sp).反之,如果对位于sp上方(高内存地址方向)的堆栈内存进行寻址,则使用m(%sp)这样的方式,其含义是sp寄存器的值加上m字节,例如3(%sp).相对于bp(栈底)进行相对寻址,也是同样的道理。
到了这一步,完成了ip和bp寄存器的入栈,物理机器就开始为被调用函数add()分配堆栈空间了,add()函数的栈底与ebp寄存器相邻。在x86平台上,堆栈由高内存地址往低内存地址方向分配,因此add()函数的栈底相对于ebp寄存器而言,处于低内存位置。前面讲过,物理机器对堆栈内存寻址的方式为相对偏移,可以通过相对栈底bp或栈顶sp的位置来绝对定位堆栈内存位置。当相对sp(栈顶)进行寻址时,如果对位于sp下方(低内存地址方向)的堆栈内存进行寻址,则使用-m(%sp)这样的方式,其含义是sp寄存器的值减去m字节,m是个自然数,例如-3(%sp).反之,如果对位于sp上方(高内存地址方向)的堆栈内存进行寻址,则使用m(%sp)这样的方式,其含义是sp寄存器的值加上m字节,例如3(%sp).相对于bp(栈底)进行相对寻址,也是同样的道理。
接着上面的例子,当物理机器执行到add()函数时,add()函数要执行int z = x + y 这样的代码,就必须读取入参。此时物理机器的bp指向add()函数的栈底,因此可以通过add()函数堆栈栈底进行相对寻址,读取两个入参x和y.那么x和y相对add()函数栈底的偏移量是多少呢?
我们来进行看图说话,如图所示,ebp相对于add()栈底的偏移位置是0,因此可以标记为(%ebp)。eip相对于add()栈底的偏移位置是4字节(32位平台,ebp寄存器占了4字节的空间),同时,eip相对于add()函数栈底,位于高内存地址方向,因此标记为4(%ebp).
同样,第一个入参x相对于add()栈底的偏移位置是8字节(32位平台, ebp和eip这2个寄存器各占去4字节,因此一共占用了8字节内存空间),因此x的位置可以标记为8(%ebp).第二个入参y相对于add()栈底的偏移位置是12字节(前面ebp、eip、x这3个数据各占4字节,因此一共占去12字节),所以y的位置还可以标记为-12(%ebp)。
我们来进行看图说话,如图所示,ebp相对于add()栈底的偏移位置是0,因此可以标记为(%ebp)。eip相对于add()栈底的偏移位置是4字节(32位平台,ebp寄存器占了4字节的空间),同时,eip相对于add()函数栈底,位于高内存地址方向,因此标记为4(%ebp).
同样,第一个入参x相对于add()栈底的偏移位置是8字节(32位平台, ebp和eip这2个寄存器各占去4字节,因此一共占用了8字节内存空间),因此x的位置可以标记为8(%ebp).第二个入参y相对于add()栈底的偏移位置是12字节(前面ebp、eip、x这3个数据各占4字节,因此一共占去12字节),所以y的位置还可以标记为-12(%ebp)。
现在,假设main()函数在调用add()时,不仅仅包含2个入参,而是包含3个,那么我们可以根据上面的堆栈图,可以推导出这3个入参的内存定位:
# 第1个入参为8(%ebp)
# 第2个入参为12(%ebp)
# 第3个入参为16(%ebp)
根据这种推导关系,使用数学归纳法,可以得到这样的入参寻址工时:
P(n) = (n+1) * 4(%ebp)
在该公式中,n表示第n个入参(按从左至右的顺序),P(n)表示第n个入参的位置。有了这个工时,我们在理解JVM调用CallStub函数指针时,就会理解JVM的入参定义。那么现在就让我们把目光再次订个到generate_call_stub()这个函数中。由于CallStub函数指最终指向generate_call_stub()这个函数所返回的一段机器指令,因此generate_call_stub()中生成的机器指令其实就是被调用函数对应的机器码,可以将其理解成CallStub()的函数体。
JVM在javaCalls.cpp::call_helper()函数中执行CallStub调用,在调用时,传递了如下8个参数:
# (address)&link,连接器
# result_val_address, 返回地址
# result_type,返回类型
# method(), Java方法的内部对象
# entry_point, Java方法调用入口例程
# args->parameters(),Java方法的入参
# args->size_of_parameters(),Java方法的入参数量
# CHECK: 当前线程
按照公式Pn = (n+1) * 4(%ebp),计算这8个参数相对于被调用函数CallStub()栈底的位置,计算后的位置如表所示:
# 第1个入参为8(%ebp)
# 第2个入参为12(%ebp)
# 第3个入参为16(%ebp)
根据这种推导关系,使用数学归纳法,可以得到这样的入参寻址工时:
P(n) = (n+1) * 4(%ebp)
在该公式中,n表示第n个入参(按从左至右的顺序),P(n)表示第n个入参的位置。有了这个工时,我们在理解JVM调用CallStub函数指针时,就会理解JVM的入参定义。那么现在就让我们把目光再次订个到generate_call_stub()这个函数中。由于CallStub函数指最终指向generate_call_stub()这个函数所返回的一段机器指令,因此generate_call_stub()中生成的机器指令其实就是被调用函数对应的机器码,可以将其理解成CallStub()的函数体。
JVM在javaCalls.cpp::call_helper()函数中执行CallStub调用,在调用时,传递了如下8个参数:
# (address)&link,连接器
# result_val_address, 返回地址
# result_type,返回类型
# method(), Java方法的内部对象
# entry_point, Java方法调用入口例程
# args->parameters(),Java方法的入参
# args->size_of_parameters(),Java方法的入参数量
# CHECK: 当前线程
按照公式Pn = (n+1) * 4(%ebp),计算这8个参数相对于被调用函数CallStub()栈底的位置,计算后的位置如表所示:
当JVM进入到CallStub这个函数指针所代表的函数的堆栈中后,调用者javaCalls::call_helper与被调用者CallStub()之间的堆栈内存布局如图所示。
在上图中,这8个入参相对于ebp的偏移量都是以字节为单位进行计算的,在32位平台上一个指针占32位,4字节,如果我们将偏移量以4字节为一个单位进行标记,而不是以1字节为计量单位,那么这8个入参的偏移位置的值会缩小4倍,看起来就不会那么大。例如28(%ebp)就会被标记为7N(%ebp),这里N为常量4.以4字节为单位对这8个入参的偏移位置进行重新标记
在上图中,这8个入参相对于ebp的偏移量都是以字节为单位进行计算的,在32位平台上一个指针占32位,4字节,如果我们将偏移量以4字节为一个单位进行标记,而不是以1字节为计量单位,那么这8个入参的偏移位置的值会缩小4倍,看起来就不会那么大。例如28(%ebp)就会被标记为7N(%ebp),这里N为常量4.以4字节为单位对这8个入参的偏移位置进行重新标记
对CallStub()的8个入参的ebp偏移位置按4字节为单位进行标记
诸如3(%ebp)这种标记方法,属于汇编语言,而JVM毕竟是使用C/C++写成的,于是JVM大神对汇编语法进行了抽象,使用C++类来表示一个堆栈位置。汇编语言通过寄存器和偏移量唯一定位一个堆栈内存,那么我们就可以在C++类中将寄存器和偏移量分别抽象成两个变量,这个C++类可以定义成如下这种格式.该程序里有一个Register类,先不用关心其具体的数据结构,只需知道它能够表示一个物理寄存器就够了。使用C++类对汇编堆栈寻址进行抽象后,便可以直接用该类进行堆栈寻址了,假设要对8(%ebp)进行寻址,可以这样写:
Address position (rbp, 8);
这就声明了一个C++类对象position,最终可以通过该对象,还原出汇编指令8(%ebp).
Address position (rbp, 8);
这就声明了一个C++类对象position,最终可以通过该对象,还原出汇编指令8(%ebp).
基于上面的C++类,我们可以这样对CallStub()的8个入参的偏移位置进行标记,如表所示
现在,让我们再回到stubGenerate_x86_64.cpp文件中的generate_call_stub()函数开始的那十几行的类声明。这里的parameter_size代表的堆栈位置是8 * wordSize(%rbp),其标记方法是:
const Address parameters (rbp, parameters_off * wordSize);
这正好与上面C++类标记法是一致的,在上文标记为Address position(rbp, 8N);
事实上,JVM里面所定义的Address类与我们在上文自定义的Address类并无本质区别,无非是额外多了几个变量而已。同时,JVM里定义了常量wordSize,其声明如下
const int wordSize = sizeof(char*);
在32位平台上,sizeof(char*)将返回4,在64位平台上,sizeof(char*)返回8.由于char*是一个指针类型,而指针能够指向物理机器内存的任何一个地址,因此,在N位平台上,指针的宽度必须也至少是N位,这样指针才能寻址到内存任何一个位置。假设内存总大小是64比特,那么指针宽度只需6位,即可寻址到内存任何位置,2的6次方正好等于64。JVM作为一款能够兼容大部分主流操作系统的虚拟机,兼容指针长度是其基本功
const Address parameters (rbp, parameters_off * wordSize);
这正好与上面C++类标记法是一致的,在上文标记为Address position(rbp, 8N);
事实上,JVM里面所定义的Address类与我们在上文自定义的Address类并无本质区别,无非是额外多了几个变量而已。同时,JVM里定义了常量wordSize,其声明如下
const int wordSize = sizeof(char*);
在32位平台上,sizeof(char*)将返回4,在64位平台上,sizeof(char*)返回8.由于char*是一个指针类型,而指针能够指向物理机器内存的任何一个地址,因此,在N位平台上,指针的宽度必须也至少是N位,这样指针才能寻址到内存任何一个位置。假设内存总大小是64比特,那么指针宽度只需6位,即可寻址到内存任何位置,2的6次方正好等于64。JVM作为一款能够兼容大部分主流操作系统的虚拟机,兼容指针长度是其基本功
还是回到generate_call_stub()函数一开始的变量定义,要注意,我们看不到一个类似于
const Address linke(rbp, 2 * wordSize)这样的定义,这是因为这个函数中并没有用到这个入参。同时,还会看到如下定义(这里我们看32位机器上的代码,因为看起来更为直观)。。mxcsr_save、save_rbx、saved_rsi、saved_rdi这4个位置相对于rbp的偏移量是负数,这很容易理解,说明这4个参数的位置在CallStub()函数的堆栈内部,,而不是位于调用函数javaCalls::call_helper()堆栈内,所以相对于rbp的偏移量才会是负数。这4个变量用于保存调用者的信息。讲完了generate_call_stub()函数开始的那十几行的类声明,接下来开始真正进入CallStub例程的逻辑分析
const Address linke(rbp, 2 * wordSize)这样的定义,这是因为这个函数中并没有用到这个入参。同时,还会看到如下定义(这里我们看32位机器上的代码,因为看起来更为直观)。。mxcsr_save、save_rbx、saved_rsi、saved_rdi这4个位置相对于rbp的偏移量是负数,这很容易理解,说明这4个参数的位置在CallStub()函数的堆栈内部,,而不是位于调用函数javaCalls::call_helper()堆栈内,所以相对于rbp的偏移量才会是负数。这4个变量用于保存调用者的信息。讲完了generate_call_stub()函数开始的那十几行的类声明,接下来开始真正进入CallStub例程的逻辑分析
3.CallStub:保存调用者堆栈
generate_call_stub()函数的逻辑部分从下面这行代码开始:
__ enter();
这行代码在不同的硬件平台上,对应不同的机器指令。在x86平台上,其函数定义在macroAssembler_x86.cpp文件中,定义如下:
void MacroAssembler::enter() {
push(rbp);
mov(rbp, rsp);
}
这两条指令最终会在JVM运行期被翻译为如下所示的对应的机器指令:
push %bp
mov %sp, %bp
如果你认真地看过前面的描述,或者熟悉汇编指令,那么你对这两条指令一定不陌生。在x86平台上,物理机器调用任何一个函数之前,都会执行这两条指令,push %ebp指令的含义是保存调用者函数的栈基地址,mov %sp, %bp指令的含义是重新指定栈基地址。由于即将开始新的函数,因此需要将栈基指向调用者函数的栈顶位置,调用者函数的栈顶位置就是被调用函数的栈基位置(严格来说,这句话是错误的,因为调用者与被调用者函数之间还隔着两个寄存器:ip和bp)。执行enter()之前,bp和sp这两个寄存器指向位置如图所示左半部分,此时堆栈空间属于调用者函数javaCalls::call_helper()。执行enter()之后,这两个寄存器将指向新的函数,如图所示右半部分,此时堆栈空间属于被调用者函数CallStub():
generate_call_stub()函数的逻辑部分从下面这行代码开始:
__ enter();
这行代码在不同的硬件平台上,对应不同的机器指令。在x86平台上,其函数定义在macroAssembler_x86.cpp文件中,定义如下:
void MacroAssembler::enter() {
push(rbp);
mov(rbp, rsp);
}
这两条指令最终会在JVM运行期被翻译为如下所示的对应的机器指令:
push %bp
mov %sp, %bp
如果你认真地看过前面的描述,或者熟悉汇编指令,那么你对这两条指令一定不陌生。在x86平台上,物理机器调用任何一个函数之前,都会执行这两条指令,push %ebp指令的含义是保存调用者函数的栈基地址,mov %sp, %bp指令的含义是重新指定栈基地址。由于即将开始新的函数,因此需要将栈基指向调用者函数的栈顶位置,调用者函数的栈顶位置就是被调用函数的栈基位置(严格来说,这句话是错误的,因为调用者与被调用者函数之间还隔着两个寄存器:ip和bp)。执行enter()之前,bp和sp这两个寄存器指向位置如图所示左半部分,此时堆栈空间属于调用者函数javaCalls::call_helper()。执行enter()之后,这两个寄存器将指向新的函数,如图所示右半部分,此时堆栈空间属于被调用者函数CallStub():
4.CallStub: 动态分配堆栈
应该说,JVM之所以能够在物理机器上分配Java语言变量类型的堆栈,完全得益于机器级别对堆栈空间分配的指令支持。JVM充分利用了这一点,通过重新分配堆栈空间,从而为JVM调用Java函数奠定基石。大家正常写一段C程序,编译时,编译器会根据被调用函数中的变量声明,自动计算出被调用函数需要多大的堆栈空间。而Java程序由于无法直接被编译为机器指令,因此Java编译器无法直接计算一个Java函数需要多大的堆栈空间(其实如果真的要做的话,也是可以做到的),但是如果仅仅解决Java函数的堆栈空间的自动计算,还是无法实现Java函数的调用,因为Java有自己的变量类型,这些变量类型不像C语言的变量,Java的变量类型并不能直接被编译成物理机器所识别的数据类型,而C语言变量最终完全被编译成物理机器所识别的类型。所以,即使Java程序编译器能够自动计算出Java函数所需要的堆栈空间大小,物理机器也无法对这段堆栈内存进行读写,,因为物理机器完全不识别Java的变量类型。当然,从技术层面上,对于强类型的Java语言,是完全可以做到直接将Java语言编译为机器指令的,JVM的JIT编译器就是这种实现方式,不过由于将Java程序直接编译为机器指令,既要实现高级语言到原始语言的转换,还要能够确保Java程序的逻辑在编译后保持完全一直,这才是最大的挑战。所以JIT提供了多种选项,有些选项不进行任何优化就编译,有些选项则可以对Java程序进行激进式的编译。不经过优化的编译与解释型运行机制基本无异,还不如不编译,,因为这种编译机制所编译出来的机器指令,与直接进行解释执行所动态生成的机器指令的数量、质量基本相差不打。但是如果进行激进式的编译,可能会破坏原本Java的程序逻辑。
Java发明之初,原本是想用于智能家居领域,没想到"无心插柳柳成荫",Java在中间件和分布式领域大放异彩,这是由于中间件和分布式领域主要关注网络通信和数据处理,开发者不用再分散精力去关心底层实现,同时中间件需要能够兼容各种底层硬件平台,这些正好都是Java天生所具备的特性。也正是由于这一点,在分布式领域,JVM无需过多关注编译质量问题,毕竟在分布式领域,大家关注的点不再是性能,而是大数据的处理能力,架构的稳定性与伸缩性等方面(这并不是说写Java程序就不用再关注内存、不用关注程序性能了,事实上很多中间件反而十分追求卓越的性能,这里主要说明,Java程序员并不像底层开发者那样对性能和内存十分关注,底层程序员可能对一个字节的内存消耗都很在意,而Java程序员很少会在意一两个字节的占用,毕竟JVM的堆内存都是以GB为单位计算的,并且Java立案随便声明一个简单类型的变量都会占用超出一个字节的内存)。但是再移动操作系统领域,个人更倾向于一种能够直接将Java程序(或类似Java的编程语言所写出来的程序)编译成本地物理机器指令的机制,或者一种能够直接运行JVM字节码指令的CPU(有公司在做这方面的研究),从而完全消除虚拟机以来,这样既能确保上商业项目对效率的极致追求,又能保证Java程序在本地运行时对性能的苛刻需求。
应该说,JVM之所以能够在物理机器上分配Java语言变量类型的堆栈,完全得益于机器级别对堆栈空间分配的指令支持。JVM充分利用了这一点,通过重新分配堆栈空间,从而为JVM调用Java函数奠定基石。大家正常写一段C程序,编译时,编译器会根据被调用函数中的变量声明,自动计算出被调用函数需要多大的堆栈空间。而Java程序由于无法直接被编译为机器指令,因此Java编译器无法直接计算一个Java函数需要多大的堆栈空间(其实如果真的要做的话,也是可以做到的),但是如果仅仅解决Java函数的堆栈空间的自动计算,还是无法实现Java函数的调用,因为Java有自己的变量类型,这些变量类型不像C语言的变量,Java的变量类型并不能直接被编译成物理机器所识别的数据类型,而C语言变量最终完全被编译成物理机器所识别的类型。所以,即使Java程序编译器能够自动计算出Java函数所需要的堆栈空间大小,物理机器也无法对这段堆栈内存进行读写,,因为物理机器完全不识别Java的变量类型。当然,从技术层面上,对于强类型的Java语言,是完全可以做到直接将Java语言编译为机器指令的,JVM的JIT编译器就是这种实现方式,不过由于将Java程序直接编译为机器指令,既要实现高级语言到原始语言的转换,还要能够确保Java程序的逻辑在编译后保持完全一直,这才是最大的挑战。所以JIT提供了多种选项,有些选项不进行任何优化就编译,有些选项则可以对Java程序进行激进式的编译。不经过优化的编译与解释型运行机制基本无异,还不如不编译,,因为这种编译机制所编译出来的机器指令,与直接进行解释执行所动态生成的机器指令的数量、质量基本相差不打。但是如果进行激进式的编译,可能会破坏原本Java的程序逻辑。
Java发明之初,原本是想用于智能家居领域,没想到"无心插柳柳成荫",Java在中间件和分布式领域大放异彩,这是由于中间件和分布式领域主要关注网络通信和数据处理,开发者不用再分散精力去关心底层实现,同时中间件需要能够兼容各种底层硬件平台,这些正好都是Java天生所具备的特性。也正是由于这一点,在分布式领域,JVM无需过多关注编译质量问题,毕竟在分布式领域,大家关注的点不再是性能,而是大数据的处理能力,架构的稳定性与伸缩性等方面(这并不是说写Java程序就不用再关注内存、不用关注程序性能了,事实上很多中间件反而十分追求卓越的性能,这里主要说明,Java程序员并不像底层开发者那样对性能和内存十分关注,底层程序员可能对一个字节的内存消耗都很在意,而Java程序员很少会在意一两个字节的占用,毕竟JVM的堆内存都是以GB为单位计算的,并且Java立案随便声明一个简单类型的变量都会占用超出一个字节的内存)。但是再移动操作系统领域,个人更倾向于一种能够直接将Java程序(或类似Java的编程语言所写出来的程序)编译成本地物理机器指令的机制,或者一种能够直接运行JVM字节码指令的CPU(有公司在做这方面的研究),从而完全消除虚拟机以来,这样既能确保上商业项目对效率的极致追求,又能保证Java程序在本地运行时对性能的苛刻需求。
上面仅是一家之言,相信大家定有不同观点。回归主体,JVM为了能够调用Java函数,需要在运行期知道一个Java函数的入参大小,然后动态计算出所需要的堆栈空间。这就是JVM能够调用Java函数的核心机制。这里的关键问题是,CallStub()作为被javaCalls::call_helper()调用的函数,JVM通过javaCalls::call_helper()最终调用到Java函数,JVM作为一款使用C/C++编写而成的程序,被编译后,C/C++编译器自然会计算出javaCalls::call_helper()传递给CallStub()的入参的空间大小,但是C/C++编译器并不会因此就自动计算出Java函数的入参数量及所需内存空间的大小,因为C/C++编译器并不识别Java程序,并且JVM被编译期间,JVM尚未加载任何Java程序,因此JVM对Java程序完全无感。等到JVM程序运行起来后,JVM会加载Java程序,并通过javaCalls::call_helper()调用Java主函数。JVM在执行Java函数调用时,仍然沿用了物理机器所使用的"堆栈"这一算法数据结构,并没有发明新的轮子,因此JVM仍然需要为Java被调用函数分配堆栈内存,保存被调用函数的局部变量以及相关上下文数据。因此,JVM必然需要在运行期动态计算Java被调用函数的空间大小,并动态为其分配堆栈空间。而物理机器提供了这种动态分配堆栈空间的能力。
由于物理机器不能识别Java程序,也不能直接执行Java程序,因此JVM必然要通过自己作为一座桥梁连接到Java程序,并让Java被调用的函数的堆栈能够"寄生"在JVM的某个函数的堆栈空间中,否则物理机器不会自动为Java方法分配堆栈。前面讲过,JVM选择CallStub这一函数指针作为JVM内部的C/C++程序与Java程序的分水岭,或者桥梁,通过这座桥梁,当JVM启动后,执行完JVM自身的一系列指令后,能够跳转到执行Java程序经翻译后所对应的二进制机器指令,CallStub能够实现机器逻辑指令上的联接,同时,JVM会调用Java的入口主函数main(),并将main()主函数的入参传递进去。因此,在分水岭之后,JVM需要为主函数分配堆栈空间,以在主函数中读取入参数据。那么Java函数所需要的堆栈空间分配在哪里呢?答案是明显的,既然CallStub()作为分水岭的函数,很自然地,JVM将Java函数堆栈空间"寄生"在了CallStub()函数堆栈中。当然,从技术实现的手段而言,JVM并非一定要选择"寄生"这种方式,JVM完全可以另外定义一种算法结构来支持Java函数的调用机制,但是JVM并没有这么做
由于物理机器不能识别Java程序,也不能直接执行Java程序,因此JVM必然要通过自己作为一座桥梁连接到Java程序,并让Java被调用的函数的堆栈能够"寄生"在JVM的某个函数的堆栈空间中,否则物理机器不会自动为Java方法分配堆栈。前面讲过,JVM选择CallStub这一函数指针作为JVM内部的C/C++程序与Java程序的分水岭,或者桥梁,通过这座桥梁,当JVM启动后,执行完JVM自身的一系列指令后,能够跳转到执行Java程序经翻译后所对应的二进制机器指令,CallStub能够实现机器逻辑指令上的联接,同时,JVM会调用Java的入口主函数main(),并将main()主函数的入参传递进去。因此,在分水岭之后,JVM需要为主函数分配堆栈空间,以在主函数中读取入参数据。那么Java函数所需要的堆栈空间分配在哪里呢?答案是明显的,既然CallStub()作为分水岭的函数,很自然地,JVM将Java函数堆栈空间"寄生"在了CallStub()函数堆栈中。当然,从技术实现的手段而言,JVM并非一定要选择"寄生"这种方式,JVM完全可以另外定义一种算法结构来支持Java函数的调用机制,但是JVM并没有这么做
那么接下来的问题就变成了,如何才能实现"寄生"?这就需要依靠物理机器提供的指令,对CallStub()堆栈进行扩展。物理机器提供了扩展堆栈空间的简单指令,如下(下述指令基于x86平台)
sub operand, %sp
operand是一个自然数,例如8、16或者其他数值。这条指令表示将堆栈向下扩展一定的空间,如果你写了一个C/C++程序,C/C++编译器会自动计算一个函数所需要的堆栈大小
sub operand, %sp
operand是一个自然数,例如8、16或者其他数值。这条指令表示将堆栈向下扩展一定的空间,如果你写了一个C/C++程序,C/C++编译器会自动计算一个函数所需要的堆栈大小
Clion的GDB调试输出的内容
将上面程序编译为汇编程序,得到如图所示。
对于被调用函数add(),其内部只声明了一个局部变量z,对于入参x和y,在所对应的汇编程序中,并没有发现编译器为其分配堆栈空间,编译器将使用ax和dx这两个寄存器分别保存这2个入参。因此,add()函数只需要为变量z分配堆栈空间,大小为32字节。由于编译器会自动对齐,因此最终编译器为add()函数分配了16字节的堆栈,分配的方式如下:
subl $16, %esp
执行这段汇编程序后,最终系统打印出正确的结果值:8
对于被调用函数add(),其内部只声明了一个局部变量z,对于入参x和y,在所对应的汇编程序中,并没有发现编译器为其分配堆栈空间,编译器将使用ax和dx这两个寄存器分别保存这2个入参。因此,add()函数只需要为变量z分配堆栈空间,大小为32字节。由于编译器会自动对齐,因此最终编译器为add()函数分配了16字节的堆栈,分配的方式如下:
subl $16, %esp
执行这段汇编程序后,最终系统打印出正确的结果值:8
虽然C/C++编译器会自动计算堆栈大小,但是可以人工对计算的结果值进行修改。我们可以将上面这段汇编程序中add代码段的堆栈空间变为64,修改后的程序如图所示。
注意:仙子啊add标段中subl $16,%esp变成了subl $64, %esp,这表示为add分配64字节空间。运行修改后的汇编程序,会发现程序依然输出了正确的结果值:8
注意:仙子啊add标段中subl $16,%esp变成了subl $64, %esp,这表示为add分配64字节空间。运行修改后的汇编程序,会发现程序依然输出了正确的结果值:8
这就是JVM实现堆栈"寄生"的机制。扩展别人的堆栈,存储自己所需要的数据。CallStub()作为JVM内部C/C++与Java程序的分水岭,CallStub()调用者javaCalls::call_helper()并没有直接将Java函数的入参传递给CallStub(),因为这个调用者并不直接是Java函数自己,因此在JVM的编译阶段,并没有将Java函数压栈。在上面C程序中,main()函数调用了add()函数,add()函数的2个入参x和y,在main()函数中完成了压栈,这2个入参实际被保存在了main()这个调用函数堆栈中。同理,JVM要调用Java主函数(由于JVM第一次调用Java函数从Java程序的主函数main()开始,因此这里以main()为例)main(),众所周知,Java主函数main()包含一个字符串数组入参,因此Java主函数的声明格式一定如下:
public static void main(String[] args) {
// ........
}
Java主函数一定包含一个String[]类型的入参。既然JVM会调用这个方法,那么按照C/C++程序的函数调用机制,JVM中调用Java程序主函数main()的函数(为了表述方便,我们假设JVM中调用Java主函数main()的函数名为xxx(),必然要对Java主函数main()的入参进行压栈,JVM会将args参数保存在xxx()的堆栈中,这样Java的主函数main()内部才能访问入参数据)。
但是,由于JVM在编译期间对Jaa程序完全"无感",JVM在编译时,压根儿就不知道加载的是什么样的Java程序,也不知道Java的主函数的入参数据是什么,因此C/C++编译器在编译JVM时,对于调用Java主函数main()的xxx()函数,并不会将Java主函数main()所需的入参String[] args数据压栈到xxx()函数中,xxx()函数中根本就没有args数据.那么问题来了,当JVM执行完自己的一系列指令后,最终开始调用Java的主函数main()时,Java主函数main()所需的args入参信息保存在哪里,从哪里获取,这些信息又是什么呢?
一切奥秘都在CallStub这个函数指针中所指向的函数中,即上文一直在讲述的stubGenerator_x86_64:generate_call_stub()函数。为了讲述方便,千问一直直接使用CallStub()来指代generate_call_stub()函数,实际上JVM内部并不存在CallStub()这个函数,CallStub仅仅是一个函数指针。但是为了讲述方便,下文继续使用CallStub()这一假想中的函数。。
前面说了很多次,CallStub()是JVM内部C/C++程序与Java程序之间的分水岭和桥梁,分水岭的其中一个重要作用就是能够将Java程序被调用的函数的入参分配到堆栈中,这样在Java函数中才能对Java类型的入参进行寻址。其实,刚才所假想的在JVM内部调用Java程序主函数main()的xxx()函数,就是CallStub()函数。刚才讲到,既然CallStub()函数调用了Java程序的主函数main(),那么在CallStub()函数中必然要将Java程序主函数main()所需要的入参信息String[] args进行压栈,否则Java主函数main()内部无法对入参进行寻址。但是在JVM编译期间,C/C++根本就不知道Java程序的任何信息,更无从谈起将Java主函数入参压栈。这个问题如何解决呢?那就是使用动态分配堆栈的方式,或者"寄生".CallStub()函数所对应的机器指令是在JVM启动过程中动态生成的,而非编译期间生成,在CallStub()内部需要知道被调用的Java函数的入参数量,并依此计算入参所需空间大小,最终将其压栈,这样当JVM在执行Java函数时,在被调用的Java函数中就能对入参进行寻址。
public static void main(String[] args) {
// ........
}
Java主函数一定包含一个String[]类型的入参。既然JVM会调用这个方法,那么按照C/C++程序的函数调用机制,JVM中调用Java程序主函数main()的函数(为了表述方便,我们假设JVM中调用Java主函数main()的函数名为xxx(),必然要对Java主函数main()的入参进行压栈,JVM会将args参数保存在xxx()的堆栈中,这样Java的主函数main()内部才能访问入参数据)。
但是,由于JVM在编译期间对Jaa程序完全"无感",JVM在编译时,压根儿就不知道加载的是什么样的Java程序,也不知道Java的主函数的入参数据是什么,因此C/C++编译器在编译JVM时,对于调用Java主函数main()的xxx()函数,并不会将Java主函数main()所需的入参String[] args数据压栈到xxx()函数中,xxx()函数中根本就没有args数据.那么问题来了,当JVM执行完自己的一系列指令后,最终开始调用Java的主函数main()时,Java主函数main()所需的args入参信息保存在哪里,从哪里获取,这些信息又是什么呢?
一切奥秘都在CallStub这个函数指针中所指向的函数中,即上文一直在讲述的stubGenerator_x86_64:generate_call_stub()函数。为了讲述方便,千问一直直接使用CallStub()来指代generate_call_stub()函数,实际上JVM内部并不存在CallStub()这个函数,CallStub仅仅是一个函数指针。但是为了讲述方便,下文继续使用CallStub()这一假想中的函数。。
前面说了很多次,CallStub()是JVM内部C/C++程序与Java程序之间的分水岭和桥梁,分水岭的其中一个重要作用就是能够将Java程序被调用的函数的入参分配到堆栈中,这样在Java函数中才能对Java类型的入参进行寻址。其实,刚才所假想的在JVM内部调用Java程序主函数main()的xxx()函数,就是CallStub()函数。刚才讲到,既然CallStub()函数调用了Java程序的主函数main(),那么在CallStub()函数中必然要将Java程序主函数main()所需要的入参信息String[] args进行压栈,否则Java主函数main()内部无法对入参进行寻址。但是在JVM编译期间,C/C++根本就不知道Java程序的任何信息,更无从谈起将Java主函数入参压栈。这个问题如何解决呢?那就是使用动态分配堆栈的方式,或者"寄生".CallStub()函数所对应的机器指令是在JVM启动过程中动态生成的,而非编译期间生成,在CallStub()内部需要知道被调用的Java函数的入参数量,并依此计算入参所需空间大小,最终将其压栈,这样当JVM在执行Java函数时,在被调用的Java函数中就能对入参进行寻址。
在CallStub()函数(即stubGenerator_x86_32: generate_call_stub())中,通过如下指令计算出被调用Java函数的入参数量,并保存调用者数据现场。。
这段代码中的加粗部分,就是JVM在对被调用的Java函数的入参进行计算.parameter_size是在generate_call_stub()函数开始处定义的堆栈变量,其定义如下:
const Address parameter_size (rbp, 8 * wordSize);
这段代码中的加粗部分,就是JVM在对被调用的Java函数的入参进行计算.parameter_size是在generate_call_stub()函数开始处定义的堆栈变量,其定义如下:
const Address parameter_size (rbp, 8 * wordSize);
这个变量指向bp栈基往高地址偏移8个字长的位置,一个字长占4字节(32位平台),因此实际相对bp的偏移量为32字节。此时bp指向CallStub()函数的栈底,parameter_size指向位置如图所示。
javaCalls::call_helper()在调用Java函数之前,会读取Java函数的入参大小,Java函数的入参大小由Java编译器在编译期间计算出来,因此JVM在执行Java函数之前,可以将其直接读取出来。JVM得到Java函数所需要的入参数量后,便可以直接计算出入参压栈所需要的堆栈空间。
也许有人会疑惑,不同的入参,数据类型不尽相同,其所占内存大小肯定也不同,那么仅仅根据入参数量,如何就能确定全部入参所需要的内存空间呢?例如在C程序中,int类型的入参和char类型的入参其所占的内存大小一定不同,如果要计算全部入参空间大小,必须知道每一种数据类型所占的内存大小,然后进行累加求和。更何况Java语言的数据类型比C语言更加丰富,不同数据类型所占用的空间大小更加不同。
其实,众所周知,Java是一门面向对象的编程语言,这一点不仅表现在Java的语义上,同时JVM在内存模型上也体现了这一原则。在Java语义上,定义任何类型的变量(除了Java的基本数据类型),都需要进行实例化,从而从语法层面上实现面向对象的宗旨。而JVM在内存中,也为每一个Java类型和对象创建了一个内存模型,这是Java能够在运行期获取Java类型的描述信息的根本前提。由于内存模型也遵循了面向对象的原则,因此实际上在JVM内部,对Java类型示例的访问全部通过指针来实现,访问Java类型示例的成员变量和方法时,亦基于指针偏移量获取到对应的内存数据。
javaCalls::call_helper()在调用Java函数之前,会读取Java函数的入参大小,Java函数的入参大小由Java编译器在编译期间计算出来,因此JVM在执行Java函数之前,可以将其直接读取出来。JVM得到Java函数所需要的入参数量后,便可以直接计算出入参压栈所需要的堆栈空间。
也许有人会疑惑,不同的入参,数据类型不尽相同,其所占内存大小肯定也不同,那么仅仅根据入参数量,如何就能确定全部入参所需要的内存空间呢?例如在C程序中,int类型的入参和char类型的入参其所占的内存大小一定不同,如果要计算全部入参空间大小,必须知道每一种数据类型所占的内存大小,然后进行累加求和。更何况Java语言的数据类型比C语言更加丰富,不同数据类型所占用的空间大小更加不同。
其实,众所周知,Java是一门面向对象的编程语言,这一点不仅表现在Java的语义上,同时JVM在内存模型上也体现了这一原则。在Java语义上,定义任何类型的变量(除了Java的基本数据类型),都需要进行实例化,从而从语法层面上实现面向对象的宗旨。而JVM在内存中,也为每一个Java类型和对象创建了一个内存模型,这是Java能够在运行期获取Java类型的描述信息的根本前提。由于内存模型也遵循了面向对象的原则,因此实际上在JVM内部,对Java类型示例的访问全部通过指针来实现,访问Java类型示例的成员变量和方法时,亦基于指针偏移量获取到对应的内存数据。
例如,下面的一段Java示例程序.
在JVM执行Animal dog = new Animal(30, 1)时,会在JVM堆中为dog实例对象分配一段连续的内存空间,并根据构造函数所传入的数据对这段内存空间进行初始化,注意,这里时连续的内存空间。在JVM执行System.out.println("dos'age is :" + dog.getAge()); 访问dog.age成员变量时,实际上JVM将Java程序中的dog处理成了一个指针,通过该指针,JVM可以找到堆中所分配的dog实例数据。Animal类型包含weight和age这两个类成员变量,并且类型都是Integer,因此dog在堆中的内存区域中持有对这两个对象的指针的引用,最终JVM通过Integer的指针获取到最终的age的值。
在dog实例所占用的连续的堆内存中,weight和age这两个实例对象的指针相对于dog指针,具有不同的偏移量,偏移量不是在JVM运行期动态计算的,而是在Java程序的编译器,由编译器自动计算出来,JVM最终通过指向dog实例的指针+偏移量,对Java类型的成员变量进行寻址,并对其进行赋值或取值操作。这就是Java语言面向对象的实现机制。后面会专门讲具体的原理,这里旨在说明一点,JVM在内存上见了一套Java面向对象的标准模型,在JVM内部,一切对Java对象实例及其成员变量和成员方法的访问,最终皆通过指针得以寻址。同理,JVM在传递Java函数参数时,所传递的也只不过是Java入参对象实例的指针而已。简而言之,传递的是指针。
在JVM执行Animal dog = new Animal(30, 1)时,会在JVM堆中为dog实例对象分配一段连续的内存空间,并根据构造函数所传入的数据对这段内存空间进行初始化,注意,这里时连续的内存空间。在JVM执行System.out.println("dos'age is :" + dog.getAge()); 访问dog.age成员变量时,实际上JVM将Java程序中的dog处理成了一个指针,通过该指针,JVM可以找到堆中所分配的dog实例数据。Animal类型包含weight和age这两个类成员变量,并且类型都是Integer,因此dog在堆中的内存区域中持有对这两个对象的指针的引用,最终JVM通过Integer的指针获取到最终的age的值。
在dog实例所占用的连续的堆内存中,weight和age这两个实例对象的指针相对于dog指针,具有不同的偏移量,偏移量不是在JVM运行期动态计算的,而是在Java程序的编译器,由编译器自动计算出来,JVM最终通过指向dog实例的指针+偏移量,对Java类型的成员变量进行寻址,并对其进行赋值或取值操作。这就是Java语言面向对象的实现机制。后面会专门讲具体的原理,这里旨在说明一点,JVM在内存上见了一套Java面向对象的标准模型,在JVM内部,一切对Java对象实例及其成员变量和成员方法的访问,最终皆通过指针得以寻址。同理,JVM在传递Java函数参数时,所传递的也只不过是Java入参对象实例的指针而已。简而言之,传递的是指针。
正因为Java函数传参,实际所传递的只是指针,而在物理机器层面,不管何种数据类型的指针,其宽度都是相同的,指针的宽度仅与物理机器的数据总线宽度有关,而与具体某种编程语言中的具体数据类型无关。例如,在C语言中,不管是char*类型的指针,还是int*类型的指针,还是某个自定义的结构体类型的指针,这些指针的宽度完全相同。在32位平台上,指针宽度一定是32位;在64位平台上,指针宽度一定是64位。因此JVM在Java函数计算入参所需要的堆栈空间时,只需要入参的数量即可。JVM计算入参堆栈空间的指令如下:
__ movptr(rcx, parameter_size); // parameter counter
__ shlptr(rcx, Interpreter::logStackElementSize); // convert parameter count to bytes
这两行代码最终会生成如下机器指令:
movl 0x20(%ebp), %ecx
shl $0x2, %ecx
movl 0x20(%ebp). %ecx这条指令的含义是,讲ebp栈基地址往高地址方向偏移32位处的数据(也即parameterSize变量的值)传送到ecx寄存器中。注意:0x20是十六进制的写法,换算成十进制就是32。ecx是CPU中的一个普通寄存器,可以被用于保存临时变量。
接着执行shl $0x2, %ecx这条指令,这条指令的含义是,讲ecx寄存器中的值左移2位。对于二进制数据,左移N位,换算成十进制,就是将所对应的十进制数乘以2的N次方,因此左移2位就表示将ecx寄存器中的数值乘以4.为何要乘以4?因为在32位平台上,每一个入参指针都占用32位内存,4字节。0x20(5ebp)处的数据是parameter_size,每一个parameter指针占用4字节,因此最重要将其乘以4.
shl $0x2, %ecx所对应的C代码是 __ shlptr(rcx, Interpreter::logStackElementSize); logStackElementSize定义在globalDefinitions.hpp文件中,定义如下:
#ifdef _LP64
const int LogBytesPerWord = 3;
#else
const int LogBytesPerWord = 2;
#endif
logStackElementSize兼容了32位和64位平台,如果是64位平台,值是3,否则就是2,最终的效果是,如果在64位平台上,就将Java函数入参数量左移3位,相当于乘以8,这表示每一个入参都占用8字节堆栈空间。同理,如果在32位平台上,就将Java函数入参数量左移2位,相当于乘以4.
CallStub()(即stubGenerator_x86_32:generate_call_stub()函数)执行完__ movptr(rcx, parameter_size)和__ shlptr(rcx, Interpreter::logStackElementSize)之后,就计算出即将被调用的Java函数入参所需要的堆栈空间。但是CallStub()还要保存调用者的数据段现场,这些用于保存调用者所执行到的java程序所对应的机器指令的基址和变址,因此CallStub()接着会执行下面这行代码:
__ addptr(rcx, locals_count_in_bytes);
这主要用于保存rdi、rsi、rbx、mxcsr这4个寄存器的值。这行代码最终会被翻译成下面的机器指令:
add $0x10, %ecx
在32位平台上,由于这4个寄存器各占4字节的内存,因此需要再将ecx寄存器加上16,最终JVM位为将被调用的Java函数所分配的堆栈空间会再增加16字节大小。基址和编制用于Java字节码取指,一个Java函数对应若干条字节码指令,而一条JVM字节码指令由若干机器指令组成(准确地说,是转换为若干机器指令),物理机器能够自动取指,但是无法对JVM字节码进行自动取指,因此对JVM字节码的取指基址需要由JVM自己去实现。后面会对此进行详细分析。
__ movptr(rcx, parameter_size); // parameter counter
__ shlptr(rcx, Interpreter::logStackElementSize); // convert parameter count to bytes
这两行代码最终会生成如下机器指令:
movl 0x20(%ebp), %ecx
shl $0x2, %ecx
movl 0x20(%ebp). %ecx这条指令的含义是,讲ebp栈基地址往高地址方向偏移32位处的数据(也即parameterSize变量的值)传送到ecx寄存器中。注意:0x20是十六进制的写法,换算成十进制就是32。ecx是CPU中的一个普通寄存器,可以被用于保存临时变量。
接着执行shl $0x2, %ecx这条指令,这条指令的含义是,讲ecx寄存器中的值左移2位。对于二进制数据,左移N位,换算成十进制,就是将所对应的十进制数乘以2的N次方,因此左移2位就表示将ecx寄存器中的数值乘以4.为何要乘以4?因为在32位平台上,每一个入参指针都占用32位内存,4字节。0x20(5ebp)处的数据是parameter_size,每一个parameter指针占用4字节,因此最重要将其乘以4.
shl $0x2, %ecx所对应的C代码是 __ shlptr(rcx, Interpreter::logStackElementSize); logStackElementSize定义在globalDefinitions.hpp文件中,定义如下:
#ifdef _LP64
const int LogBytesPerWord = 3;
#else
const int LogBytesPerWord = 2;
#endif
logStackElementSize兼容了32位和64位平台,如果是64位平台,值是3,否则就是2,最终的效果是,如果在64位平台上,就将Java函数入参数量左移3位,相当于乘以8,这表示每一个入参都占用8字节堆栈空间。同理,如果在32位平台上,就将Java函数入参数量左移2位,相当于乘以4.
CallStub()(即stubGenerator_x86_32:generate_call_stub()函数)执行完__ movptr(rcx, parameter_size)和__ shlptr(rcx, Interpreter::logStackElementSize)之后,就计算出即将被调用的Java函数入参所需要的堆栈空间。但是CallStub()还要保存调用者的数据段现场,这些用于保存调用者所执行到的java程序所对应的机器指令的基址和变址,因此CallStub()接着会执行下面这行代码:
__ addptr(rcx, locals_count_in_bytes);
这主要用于保存rdi、rsi、rbx、mxcsr这4个寄存器的值。这行代码最终会被翻译成下面的机器指令:
add $0x10, %ecx
在32位平台上,由于这4个寄存器各占4字节的内存,因此需要再将ecx寄存器加上16,最终JVM位为将被调用的Java函数所分配的堆栈空间会再增加16字节大小。基址和编制用于Java字节码取指,一个Java函数对应若干条字节码指令,而一条JVM字节码指令由若干机器指令组成(准确地说,是转换为若干机器指令),物理机器能够自动取指,但是无法对JVM字节码进行自动取指,因此对JVM字节码的取指基址需要由JVM自己去实现。后面会对此进行详细分析。
上面的指令旨在计算出将被调用的Java函数入参所占用的堆栈空间,可以看到,最终所占用的空间大小为:
Java入参数量 x 4 + 4 x 4
4 x 4就是最后rdi、rsi、rbx、mxcsr这4个寄存器所占用的堆栈空间大小。完成了Java函数入参空间计算后,接下来就需要执行最主要的一步:动态分配堆栈内存,这一步很简单,直接执行sub operand, %esp即可实现,operand就表示刚才计算出来的堆栈大小,在CallStub()(即stubGenerator_x86_32: generate_call_stub()函数)中使用下面的C代码实现:
__ stubptr(rsp, rcx);
这行代码最终会生成下面这条机器指令:
sub %ecx, %esp
在这条指令之前,JVM所计算处的堆栈空间大小保存在ecx寄存器中,因此这里直接将esp减去ecx寄存器的值,就完成堆栈空间的分配。为了加速内存寻址和回收,物理机器在分配堆栈空间时都会进行内存对齐,JVM也保留了这一原则,因此完成堆栈内存分配后,接着CallStub()执行下面这行代码:
__ andptr(rsp, -(StackAlignmentInBytes)); // Align stack
其最终队以ing的机器指令如下:
and $0xfffffff0, %esp
堆栈按照16位对齐,相当于减去后4位的值。如果前面位堆栈空间分配的字节数不够16的整数倍,这里就会减小esp寄存器的值,使其按16位对齐。至此,JVM完成了动态堆栈内存分配,这是JVM最具里程碑意义的时间!这一关迈过去之后,JVM终于跨越C/C++程序与Java程序之间的桥梁,翻越中间的分水岭,纵身一跃,开始要进入Java程序的领域"地界"中了。
Java入参数量 x 4 + 4 x 4
4 x 4就是最后rdi、rsi、rbx、mxcsr这4个寄存器所占用的堆栈空间大小。完成了Java函数入参空间计算后,接下来就需要执行最主要的一步:动态分配堆栈内存,这一步很简单,直接执行sub operand, %esp即可实现,operand就表示刚才计算出来的堆栈大小,在CallStub()(即stubGenerator_x86_32: generate_call_stub()函数)中使用下面的C代码实现:
__ stubptr(rsp, rcx);
这行代码最终会生成下面这条机器指令:
sub %ecx, %esp
在这条指令之前,JVM所计算处的堆栈空间大小保存在ecx寄存器中,因此这里直接将esp减去ecx寄存器的值,就完成堆栈空间的分配。为了加速内存寻址和回收,物理机器在分配堆栈空间时都会进行内存对齐,JVM也保留了这一原则,因此完成堆栈内存分配后,接着CallStub()执行下面这行代码:
__ andptr(rsp, -(StackAlignmentInBytes)); // Align stack
其最终队以ing的机器指令如下:
and $0xfffffff0, %esp
堆栈按照16位对齐,相当于减去后4位的值。如果前面位堆栈空间分配的字节数不够16的整数倍,这里就会减小esp寄存器的值,使其按16位对齐。至此,JVM完成了动态堆栈内存分配,这是JVM最具里程碑意义的时间!这一关迈过去之后,JVM终于跨越C/C++程序与Java程序之间的桥梁,翻越中间的分水岭,纵身一跃,开始要进入Java程序的领域"地界"中了。
5.CallStub: 调用者保存
JVM为即将被调用的Java方法分配了堆栈空间,调用者是CallStub()所指向的函数,其实就是stubGenerator_x86_32:generate_call_stub().接下来JVM就要将CPU的控制权转交给被调用的Java方法,但是在转交之前,调用者需要保存自己的寄存器数据,这些寄存器主要包括:edi、esi、edx.
懂汇编的朋友都知道,内存中一切数据都是二进制,不管是"真的"数据,还是机器指令,都是数据。如果不加以区分,你可以将一个机器指令当作一串普通的数字,也可以将一个数据看成是一个特定的机器指令。计算机区分内存中的一块数据到底是机器指令还是普通的数据,主要取决于CS:IP寄存器,被CS:IP寄存器所指的内存数据就是机器指令,会被CPU执行,否则就是数据,CPU可以对其进行数据传送。
每次在执行函数调用时,CS:IP寄存器会从当前调用者函数的机器指令处跳转到被调用函数,这样CPU才能执行被调用的函数。但是当被调用函数执行完了之后,CPU需要跳转到调用者函数中继续执行调用者函数的机器指令,换言之,需要恢复CS:IP的值,使之重新指向调用者函数中执行被调用函数的下一条指令。如何恢复呢?前文讲过,每次发生函数调用时,机器会将调用者函数的CS:IP压入栈中,而在函数调用结束后,再次将调用者函数的CS:IP从栈中弹出来进行恢复。
物理机器通过CS:IP来区分一个内存中的数据到底是真实地数据还是机器指令,而对于数据,物理机器一般会用edi和esi分别保存目的偏移地址和源偏移地址。例如在字符串复制时,一段优化的汇编代码中会同时使用edi和esi,分别指向目标字符串索引位置和源字符串索引位置。
而在JVM中,edi和esi却被赋予了更多神圣的职责,例如在Java函数调用过程中,esi会用于Java字节码寻址。每当JVM开始执行Java函数的某个字节码指令时,JVM会首先将esi寄存器指向目标字节码指令的偏移地址,然后JVM跳转到该字节码所对应的第一个机器指令开始执行。
所以,edi和esi在JVM中也是调用者函数紧密关联的寄存器,是调用者函数的私有数据。ebx是一个通用的寄存器,但是也经常被用来作为一段数据的基地址,例如使用汇编对一个一维数组的成员元素进行寻址,可以将ebx定位到这个一维数组的起始地址,然后使用一个变址定位到数组中的某个元素。在JVM中,ebx便被赋予了这种非常实际的作用,在执行Java函数调用时,ebx会用来存放Java函数中即将被执行的字节码指令的及地址,然后通过jmp指令跳转到该字节码位置进行字节码解释执行。因此ebx也会与edi、esi一样,与调用者函数息息相关。也是调用者函数的私有数据。
JVM为即将被调用的Java方法分配了堆栈空间,调用者是CallStub()所指向的函数,其实就是stubGenerator_x86_32:generate_call_stub().接下来JVM就要将CPU的控制权转交给被调用的Java方法,但是在转交之前,调用者需要保存自己的寄存器数据,这些寄存器主要包括:edi、esi、edx.
懂汇编的朋友都知道,内存中一切数据都是二进制,不管是"真的"数据,还是机器指令,都是数据。如果不加以区分,你可以将一个机器指令当作一串普通的数字,也可以将一个数据看成是一个特定的机器指令。计算机区分内存中的一块数据到底是机器指令还是普通的数据,主要取决于CS:IP寄存器,被CS:IP寄存器所指的内存数据就是机器指令,会被CPU执行,否则就是数据,CPU可以对其进行数据传送。
每次在执行函数调用时,CS:IP寄存器会从当前调用者函数的机器指令处跳转到被调用函数,这样CPU才能执行被调用的函数。但是当被调用函数执行完了之后,CPU需要跳转到调用者函数中继续执行调用者函数的机器指令,换言之,需要恢复CS:IP的值,使之重新指向调用者函数中执行被调用函数的下一条指令。如何恢复呢?前文讲过,每次发生函数调用时,机器会将调用者函数的CS:IP压入栈中,而在函数调用结束后,再次将调用者函数的CS:IP从栈中弹出来进行恢复。
物理机器通过CS:IP来区分一个内存中的数据到底是真实地数据还是机器指令,而对于数据,物理机器一般会用edi和esi分别保存目的偏移地址和源偏移地址。例如在字符串复制时,一段优化的汇编代码中会同时使用edi和esi,分别指向目标字符串索引位置和源字符串索引位置。
而在JVM中,edi和esi却被赋予了更多神圣的职责,例如在Java函数调用过程中,esi会用于Java字节码寻址。每当JVM开始执行Java函数的某个字节码指令时,JVM会首先将esi寄存器指向目标字节码指令的偏移地址,然后JVM跳转到该字节码所对应的第一个机器指令开始执行。
所以,edi和esi在JVM中也是调用者函数紧密关联的寄存器,是调用者函数的私有数据。ebx是一个通用的寄存器,但是也经常被用来作为一段数据的基地址,例如使用汇编对一个一维数组的成员元素进行寻址,可以将ebx定位到这个一维数组的起始地址,然后使用一个变址定位到数组中的某个元素。在JVM中,ebx便被赋予了这种非常实际的作用,在执行Java函数调用时,ebx会用来存放Java函数中即将被执行的字节码指令的及地址,然后通过jmp指令跳转到该字节码位置进行字节码解释执行。因此ebx也会与edi、esi一样,与调用者函数息息相关。也是调用者函数的私有数据。
既然esi、edi、ebx都属于调用者函数的私有数据,因此在发生函数调用之前,调用者函数必须将这些数据保存起来,因为在被调用者函数中,这些数据也会被调用函数所使用,其中的数据也会被被调用函数修改,这样,当被调用函数执行完毕,程序流重新回到调用者函数中时,如果调用函数之前没有保存这些数据,这些数据就无法恢复,从而使程序发生异常。
esi、edi和ebx的保存并不是必须的,例如随便写一段C程序然后编译,编译器往往并不会保存这些数据,因为很多编译器只会使用有限的寄存器保存函数特有的数据,而不会是应用esi、edi和ebx这3类寄存器。
保存的方式有很多种,可以将其保存到应用程序的堆中,也可以保存到栈中。由于esi、edi、ebx可以被看做使调用者函数的私有数据,因此JVM直接将其保存到了被调用者函数的堆栈中。注意,使保存到了被调用函数的堆栈中,而不是调用函数的堆栈中。关于这一点,只是一种约定俗称的做法,起始保存到调用者函数的堆栈中也是可以的。无论保存到谁的堆栈中,只要在被调用函数执行完之后系统能够恢复调用者函数的这些私有数据即可。
保存esi、edi、ebx很简单,如下:
// save rdi, rsi, & rbx, according to C calling conventions
// 保存rdi
__ movptr(saved_rdi, rdi);
// 保存rsi
__ movptr(saved_rsi, rsi);
// rbx
__ movptr(saved_rbx, rbx);
这几条模板最终会生成如下机器指令(使用汇编助记符表示)
mov %edi, -0x4(%ebp)
mov %esi, -0x8(%ebp)
movl %ebx,-0xc(%ebp)
这3条指令之后,堆栈布局如图所示.注意,JVM还保存了mxcsr寄存器,这属于Intel的SSE技术,该议题比较高级.以上过程有一个专门的术语,叫做"现场保存",当调用者函数的现场全部保存完之后,CPU的控制权马上就要移交给被调用者函数了
esi、edi和ebx的保存并不是必须的,例如随便写一段C程序然后编译,编译器往往并不会保存这些数据,因为很多编译器只会使用有限的寄存器保存函数特有的数据,而不会是应用esi、edi和ebx这3类寄存器。
保存的方式有很多种,可以将其保存到应用程序的堆中,也可以保存到栈中。由于esi、edi、ebx可以被看做使调用者函数的私有数据,因此JVM直接将其保存到了被调用者函数的堆栈中。注意,使保存到了被调用函数的堆栈中,而不是调用函数的堆栈中。关于这一点,只是一种约定俗称的做法,起始保存到调用者函数的堆栈中也是可以的。无论保存到谁的堆栈中,只要在被调用函数执行完之后系统能够恢复调用者函数的这些私有数据即可。
保存esi、edi、ebx很简单,如下:
// save rdi, rsi, & rbx, according to C calling conventions
// 保存rdi
__ movptr(saved_rdi, rdi);
// 保存rsi
__ movptr(saved_rsi, rsi);
// rbx
__ movptr(saved_rbx, rbx);
这几条模板最终会生成如下机器指令(使用汇编助记符表示)
mov %edi, -0x4(%ebp)
mov %esi, -0x8(%ebp)
movl %ebx,-0xc(%ebp)
这3条指令之后,堆栈布局如图所示.注意,JVM还保存了mxcsr寄存器,这属于Intel的SSE技术,该议题比较高级.以上过程有一个专门的术语,叫做"现场保存",当调用者函数的现场全部保存完之后,CPU的控制权马上就要移交给被调用者函数了
6.CallStub: 参数压栈
前面在分析"动态分配堆栈"时,分析出CallStub函数指针为即将调用的的函数分配的堆栈空间大小为:
Java函数入参数量 x 4 + 4 x 4
4x4就是最后的rdi、rsi、rbx、mxcsr这4个寄存器所占用的堆栈空间大小.CallStub为被调用者函数所分配的堆栈空间大小完全取决于Java函数的入参数量,为了分析内存空间分布情况,这里假设即将被调用的Java函数包含3个参数,则执行完前面两步(分别时动态分配堆栈和保存调用者现场)之后,堆栈实际布局如图所示.
CallStub为调用者分配的堆栈空间还剩余3个数据需要填充,接下来JVM要做的就是将即将被调用的Java函数的入参复制到这剩余的堆栈空间里去。
既然要进行数据复制,CallStub至少要知道两点:
# 即将被调用的Java函数的入参数量有多少
# 即将被复制的Java函数的参数集合在哪
这两个要素在进入本流程之前,全部已经计算得到,并且作为CallStub的参数传递给了CallStub所指向的函数。在上图中,28(%ebp)代表的堆栈位置保存的便是Java函数的入参数量,而32(%ebp)所代表的堆栈位置保存的便是Java函数的第一个入参。
由于不同的Java函数的入参数量是不同的,因此CallStub使用了循环进行处理,而这种循环时直接基于机器指令的。在机器层面进行循环,一个约定俗称的做法时将循环次数暂存到ecx寄存器。因此CallStub必然要先将Java函数的入参数量传送到ecx寄存器中。同时,由于Java函数的多个入参在内存中实际上是一个队列,是一个"串",而对串的寻址通常使用基址+变址的偏移寻址指令,因此在CallStub中将以edx寄存器存储基址,以ecx存储变址。
前面在分析"动态分配堆栈"时,分析出CallStub函数指针为即将调用的的函数分配的堆栈空间大小为:
Java函数入参数量 x 4 + 4 x 4
4x4就是最后的rdi、rsi、rbx、mxcsr这4个寄存器所占用的堆栈空间大小.CallStub为被调用者函数所分配的堆栈空间大小完全取决于Java函数的入参数量,为了分析内存空间分布情况,这里假设即将被调用的Java函数包含3个参数,则执行完前面两步(分别时动态分配堆栈和保存调用者现场)之后,堆栈实际布局如图所示.
CallStub为调用者分配的堆栈空间还剩余3个数据需要填充,接下来JVM要做的就是将即将被调用的Java函数的入参复制到这剩余的堆栈空间里去。
既然要进行数据复制,CallStub至少要知道两点:
# 即将被调用的Java函数的入参数量有多少
# 即将被复制的Java函数的参数集合在哪
这两个要素在进入本流程之前,全部已经计算得到,并且作为CallStub的参数传递给了CallStub所指向的函数。在上图中,28(%ebp)代表的堆栈位置保存的便是Java函数的入参数量,而32(%ebp)所代表的堆栈位置保存的便是Java函数的第一个入参。
由于不同的Java函数的入参数量是不同的,因此CallStub使用了循环进行处理,而这种循环时直接基于机器指令的。在机器层面进行循环,一个约定俗称的做法时将循环次数暂存到ecx寄存器。因此CallStub必然要先将Java函数的入参数量传送到ecx寄存器中。同时,由于Java函数的多个入参在内存中实际上是一个队列,是一个"串",而对串的寻址通常使用基址+变址的偏移寻址指令,因此在CallStub中将以edx寄存器存储基址,以ecx存储变址。
对于Java函数的入参队列而言,所谓基址,实际上就是第一个入参的内存地址。由于Java语言是面向对象的。因此其入参队列在JVM里实际上是指针的队列。每一个指针指向不同的入参实例。指针的宽度都是相同的,因此只要知道第一个入参的位置,便可以知道其后续所有入参的位置,只需要基于第一个入参位置进行偏移即可。假设第一个入参位置记为P1,则其后面第N个入参的位置是:
P1 + (N - 1) * 4
根据这个很简单的原理,CallStub只要将P1作为基址,将N作为变址就能对Java函数的全部入参进行寻址。所以CallStub将Java函数的参数进行入栈的第一步就是分别获取基址和变址.如图所示。
P1 + (N - 1) * 4
根据这个很简单的原理,CallStub只要将P1作为基址,将N作为变址就能对Java函数的全部入参进行寻址。所以CallStub将Java函数的参数进行入栈的第一步就是分别获取基址和变址.如图所示。
这段模板在JVM启动工程中会生成如下机器指令(使用汇编助记符给出):
此时物理寄存器(注意,不是逻辑寄存器)中所保存的重要信息如下所示:
edx: parameters首地址
ecx: Java函数入参数量
此时物理寄存器(注意,不是逻辑寄存器)中所保存的重要信息如下所示:
edx: parameters首地址
ecx: Java函数入参数量
一切准备i就绪,开始循环将Java函数参数压栈如图所示,这段模板最终会生成下面的机器指令(使用汇编助记符表示):
mov-0x4(%edx, %ecx,4), %eax
mov%eax, (%esp, %ebx, 4)
inc%ebx
dec%ecx
jne0x370b696
在机器层面进行循环一般有两种方式:一种是使用loop指令;另一种则使用跳转。很明显这里使用了跳转.
熟悉汇编的小伙伴可能看出来这段汇编中对循环因此ecx做的是减法(dec %ecx,表示减去1),而常见的循环中一般是做加法,一般使用inc %ecx.这主要是因为CallStub对Java函数的入参采取的逆向便利,也就是从后往前遍历参数,并将读取到的入参传送到堆栈中。上面这段太"底层"了,还是以举例子的方式来说明。
mov-0x4(%edx, %ecx,4), %eax
mov%eax, (%esp, %ebx, 4)
inc%ebx
dec%ecx
jne0x370b696
在机器层面进行循环一般有两种方式:一种是使用loop指令;另一种则使用跳转。很明显这里使用了跳转.
熟悉汇编的小伙伴可能看出来这段汇编中对循环因此ecx做的是减法(dec %ecx,表示减去1),而常见的循环中一般是做加法,一般使用inc %ecx.这主要是因为CallStub对Java函数的入参采取的逆向便利,也就是从后往前遍历参数,并将读取到的入参传送到堆栈中。上面这段太"底层"了,还是以举例子的方式来说明。
假设被调用的Java函数包含3个入参,分别使用arg1、arg2和arg3表示,则CallStub指针所指向的函数的parameters入参指向Java函数实际存储Java函数3个入参的内存区域的首地址。此时堆栈空间布局如图所示。
当第一轮循环完成之后,Java函数的第三个入参被压栈,如图所示
第二轮压栈后,堆栈空间布局如图所示
第三轮压栈后,堆栈空间布局如图所示。至此,Java函数的3个入参全部被压入栈中。离Java函数的调用越来越近了
7.CallStub:调用entry_point例程
前面经过调用者框架战阵保存(栈基)、堆栈动态扩展、现场保存、Java函数参数压栈这一系列的逻辑处理,JVM终于为Java函数的调用演完前周,一切就绪,就等吹响进攻的冲锋号。而负责吹响冲锋号的,就是entry_point例程。
关于到底啥是entry_point例程,为何要取这么一个古里古怪的名字,放到后文再解释。这里只需要知道这是一个与CallStub一样的例程即可。
在JVM调用CallStub所指向的函数时,已经将entry_point例程的首地址传递给CallStub函数了,作为其第5个入参。entry_point起始也是一个指向函数的指针,对于CPU而言,只要能够拿到函数的入口地址,就能执行函数调用。调用的指令很简单,就是call.我们来看CallStub时如何执行entry_point调用的。
这段模板代码最终会被翻译成下面这段机器指令(使用汇编助记符表示):
// 将method首地址传送给ebx 寄存器
mov0x14(%ebp), %ebx
// 将entry_point传送给eax寄存器
mov0x18(%ebp), %eax
// 将当前栈顶保存到esi寄存器中
mov%esp, %esi
// 调用entry_point
call*%eax
这段机器指令很简单,主要将Java函数所对应的method对象的首地址保存到ebx寄存器中,同时将CallStub栈顶保存到esi寄存器中。到这里为止,貌似CallStub函数入参的一半都已经被传送到相关寄存器中保存起来了,那么,其他入参的去向如何呢?
前面经过调用者框架战阵保存(栈基)、堆栈动态扩展、现场保存、Java函数参数压栈这一系列的逻辑处理,JVM终于为Java函数的调用演完前周,一切就绪,就等吹响进攻的冲锋号。而负责吹响冲锋号的,就是entry_point例程。
关于到底啥是entry_point例程,为何要取这么一个古里古怪的名字,放到后文再解释。这里只需要知道这是一个与CallStub一样的例程即可。
在JVM调用CallStub所指向的函数时,已经将entry_point例程的首地址传递给CallStub函数了,作为其第5个入参。entry_point起始也是一个指向函数的指针,对于CPU而言,只要能够拿到函数的入口地址,就能执行函数调用。调用的指令很简单,就是call.我们来看CallStub时如何执行entry_point调用的。
这段模板代码最终会被翻译成下面这段机器指令(使用汇编助记符表示):
// 将method首地址传送给ebx 寄存器
mov0x14(%ebp), %ebx
// 将entry_point传送给eax寄存器
mov0x18(%ebp), %eax
// 将当前栈顶保存到esi寄存器中
mov%esp, %esi
// 调用entry_point
call*%eax
这段机器指令很简单,主要将Java函数所对应的method对象的首地址保存到ebx寄存器中,同时将CallStub栈顶保存到esi寄存器中。到这里为止,貌似CallStub函数入参的一半都已经被传送到相关寄存器中保存起来了,那么,其他入参的去向如何呢?
CallStub中的method、entry_point、parameters和size_of_parameters这4个入参被从堆栈中传送到寄存器中。你不禁要问这样一个问题,为何这几个入参需要从堆栈转移到寄存器中呢?如果一直基于ebp寄存器偏移量去寻址不是很好吗?
这主要是因为这个参数在即将被调用的entry_point例程中会被使用,而从CallStub例程"跳转"到entry_point例程时使用的是call指令,而call指令一半都会使用"套路",会出"组合拳",配合call指令一起使用的是push %ebp指令,该指令会将调用函数的栈帧保存起来。配合call指令的另一个"组合拳"自然就是move %esp, %ebp,其将被调用函数的栈帧指向调用函数的栈顶。这套组合拳打出去之后,调用函数的栈帧指针ebp就会改变,因此从CallStub例程进入entry_point例程之后,要想再基于ebp进行变址寻址,就无法获取到method、parameters等参数,因此CallStub只能将这4个参数先复制到寄存器中暂存起来,在entry_point例程中通过读取这几个寄存器便可恢复这几个参数数据。
不过又有一个问题,为何CallStub的另外4个参数不用通过寄存器临时存储起来呢?这是因为另外4个参数在entry_point例程中不会被使用到,entry_point例程不关心这几个数据,只有CallStub例程关心。CallStub调用完entry_point例程再回到CallStub例程后,ebp又重新指向CallStub例程的栈帧,因此通过ebp栈帧进行变址寻址,依然能够获取到这4个参数。
这主要是因为这个参数在即将被调用的entry_point例程中会被使用,而从CallStub例程"跳转"到entry_point例程时使用的是call指令,而call指令一半都会使用"套路",会出"组合拳",配合call指令一起使用的是push %ebp指令,该指令会将调用函数的栈帧保存起来。配合call指令的另一个"组合拳"自然就是move %esp, %ebp,其将被调用函数的栈帧指向调用函数的栈顶。这套组合拳打出去之后,调用函数的栈帧指针ebp就会改变,因此从CallStub例程进入entry_point例程之后,要想再基于ebp进行变址寻址,就无法获取到method、parameters等参数,因此CallStub只能将这4个参数先复制到寄存器中暂存起来,在entry_point例程中通过读取这几个寄存器便可恢复这几个参数数据。
不过又有一个问题,为何CallStub的另外4个参数不用通过寄存器临时存储起来呢?这是因为另外4个参数在entry_point例程中不会被使用到,entry_point例程不关心这几个数据,只有CallStub例程关心。CallStub调用完entry_point例程再回到CallStub例程后,ebp又重新指向CallStub例程的栈帧,因此通过ebp栈帧进行变址寻址,依然能够获取到这4个参数。
在CallStub执行call %eax指令之前,物理寄存器(注: 不是逻辑寄存器)中所保存的重要信息如表所示。
此时eax寄存器已经指向了entry_point例程入口,因此CallStub只需执行call %eax指针便可以直接跳转到entry_point例程,去执行entry_point例程。
在entry_point,还会经过一段"铺垫"性的逻辑处理,并最终寻找到Java函数的第一个字节码并从第一个字节码开始执行。
在前面的叙述中,多次提到CallStub的一个入参——method对象,该对象代表即将被调用的Java函数,通过该对象可以寻址到Java函数所对应的第一个字节码指令,那么这个对象到底是啥。在entry_point中究竟如何使用呢?
要理清楚这些问题,不得不先研究清楚JVM的另一个重要的主题——内存模型。将Java的内存模型理清楚之后,method对象的结构、Java类的内存结构等概念都会被逐一破解,这也是理解entry_point例程所必须掌握的基础
此时eax寄存器已经指向了entry_point例程入口,因此CallStub只需执行call %eax指针便可以直接跳转到entry_point例程,去执行entry_point例程。
在entry_point,还会经过一段"铺垫"性的逻辑处理,并最终寻找到Java函数的第一个字节码并从第一个字节码开始执行。
在前面的叙述中,多次提到CallStub的一个入参——method对象,该对象代表即将被调用的Java函数,通过该对象可以寻址到Java函数所对应的第一个字节码指令,那么这个对象到底是啥。在entry_point中究竟如何使用呢?
要理清楚这些问题,不得不先研究清楚JVM的另一个重要的主题——内存模型。将Java的内存模型理清楚之后,method对象的结构、Java类的内存结构等概念都会被逐一破解,这也是理解entry_point例程所必须掌握的基础
8.CallStub:获取返回值
CallStub执行entry_point例程调用时,使用的是call指令,而非jmp,因此最终entry_point执行完毕之后,CPU的控制权还是会回到CallStub,继续执行entry_point例程调用之后的指令。调用完entry_point例程之后,会有返回值,CallStub会获取返回值并继续处理。qqianm详细描述了物理机器执行call调用的原理,简而言之就是,物理CPU执行call调用时,会将ip压入栈中,ip一种专门的段寄存器,通常与cs段寄存器一起用于指向下一条即将被CPU执行的指令地址,这样当被调用函数执行完成之后,CPU只需要从栈顶取出ip便能重新定位到调用函数的下一条指令地址,继续执行调用函数中的逻辑。
当CallStub执行完call *%eax这条指令后,堆栈内存的内存布局如图所示(假设目标Java函数u包含3个入参)
CallStub执行entry_point例程调用时,使用的是call指令,而非jmp,因此最终entry_point执行完毕之后,CPU的控制权还是会回到CallStub,继续执行entry_point例程调用之后的指令。调用完entry_point例程之后,会有返回值,CallStub会获取返回值并继续处理。qqianm详细描述了物理机器执行call调用的原理,简而言之就是,物理CPU执行call调用时,会将ip压入栈中,ip一种专门的段寄存器,通常与cs段寄存器一起用于指向下一条即将被CPU执行的指令地址,这样当被调用函数执行完成之后,CPU只需要从栈顶取出ip便能重新定位到调用函数的下一条指令地址,继续执行调用函数中的逻辑。
当CallStub执行完call *%eax这条指令后,堆栈内存的内存布局如图所示(假设目标Java函数u包含3个入参)
在JVM内部,调用函数被压栈的ip寄存器值有一个专门的称谓——return address,即返回地址。注意,返回地址与"返回值"是两个不同的概念,返回值是指被调用函数所返回的结果值,而返回地址则是指调用函数执行被调用函数所对应指令的下一条指令的内存。如图所示的这种堆栈内存布局中,最下面的eip在entry_point中的描述统一编程"return address"。因此在后面绘制的堆栈内存布局图中,这个存储单元的名称便统一改成"retuern address"。在后续流程中,JVM为了让被调用函数䣌入参、局部变量、固定帧以及操作数栈在内存上相连,会不断移动eip在堆栈中的位置,我们只需要知道,在后面描述的return address就是紧跟在call *%eax后面的那条指令的地址
CallStub中接下来的两条指令如下:
mov 0xc(%ebp), %edi // result
mov 0x10(%ebp), %esi // result_type
这2条指令分别读取被调用函数所返回的值result和数据类型result_type。如图所示的内存布局图就知道了,0xc(%ebp)和0xc10(%ebp)这2个对炸那位置所保存的正式resultAddress与result_type.JVM将2个值分别存储进edi和esi这2个寄存器中,调用方在获取被调用函数的返回值与返回类型时,也会从这2个寄存器中读取,这种完全是基于约定的技术实现方式
CallStub中接下来的两条指令如下:
mov 0xc(%ebp), %edi // result
mov 0x10(%ebp), %esi // result_type
这2条指令分别读取被调用函数所返回的值result和数据类型result_type。如图所示的内存布局图就知道了,0xc(%ebp)和0xc10(%ebp)这2个对炸那位置所保存的正式resultAddress与result_type.JVM将2个值分别存储进edi和esi这2个寄存器中,调用方在获取被调用函数的返回值与返回类型时,也会从这2个寄存器中读取,这种完全是基于约定的技术实现方式
9.CallStub:汇编指令总览
所谓例程,就是一段预先写好的函数,JVM通过例程函数在启动过程中生成机器指令,当执行Java函数调用时,JVM直接跳转到例程所生成的这段机器指令去执行。CallStub例程最终生成的机器指令如下(使用汇编助记符表示,运行平台时Linux x86)
// 保存调用者栈帧
push %ebp
mov %esp,%ebp
// 动态分配堆栈
mov 0x20(%ebp),%ecx
shl $0x2, %ecx
add $0x10, %ecx
sub %ecx, %esp
and $0xfffffff0, %esp
// 保存edi、esi、ebx这3个寄存器
mov %edi, -0x4(%ebp)
mov %esi, -0x8(%ebp)
mov %ebx, -0xc(%ebp)
// 保存mxcsr寄存器的值,属于SSE,在VS中的寄存器窗口右击,然后选择SSE就可以看到了
stmxcsr -0x10(%ebp)
// 循环遍历Java函数入参,并传送到CallStub堆栈中
mov 0x20(%ebp), %ecx
test %ecx, %ecx // parameter_size是0,直接跳过参数处理
je 0xb370b68b
mov 0x1c(%ebp), %edx // 对应parameters
xor %ebx, %ebx // 把%ebx设为0
// 开始循环
mov -0x4(%edx, %ecx, 4), %eax
mov %eax, (%esp, %ebx, 4)
inc %ebx
dec %ecx
jne 0xb370b696
// 开始entry_point例程调用
mov 0x14(%ebp), %ebx // 对应method
mov 0x18(%ebp), %eax, // 对应entry_point
mov %esp, %esi
call *%eax
// call_stub_return_address:
mov 0xc(%ebp), %edi // result
mov 0x10(%ebp), %esi // result_type
所谓例程,就是一段预先写好的函数,JVM通过例程函数在启动过程中生成机器指令,当执行Java函数调用时,JVM直接跳转到例程所生成的这段机器指令去执行。CallStub例程最终生成的机器指令如下(使用汇编助记符表示,运行平台时Linux x86)
// 保存调用者栈帧
push %ebp
mov %esp,%ebp
// 动态分配堆栈
mov 0x20(%ebp),%ecx
shl $0x2, %ecx
add $0x10, %ecx
sub %ecx, %esp
and $0xfffffff0, %esp
// 保存edi、esi、ebx这3个寄存器
mov %edi, -0x4(%ebp)
mov %esi, -0x8(%ebp)
mov %ebx, -0xc(%ebp)
// 保存mxcsr寄存器的值,属于SSE,在VS中的寄存器窗口右击,然后选择SSE就可以看到了
stmxcsr -0x10(%ebp)
// 循环遍历Java函数入参,并传送到CallStub堆栈中
mov 0x20(%ebp), %ecx
test %ecx, %ecx // parameter_size是0,直接跳过参数处理
je 0xb370b68b
mov 0x1c(%ebp), %edx // 对应parameters
xor %ebx, %ebx // 把%ebx设为0
// 开始循环
mov -0x4(%edx, %ecx, 4), %eax
mov %eax, (%esp, %ebx, 4)
inc %ebx
dec %ecx
jne 0xb370b696
// 开始entry_point例程调用
mov 0x14(%ebp), %ebx // 对应method
mov 0x18(%ebp), %eax, // 对应entry_point
mov %esp, %esi
call *%eax
// call_stub_return_address:
mov 0xc(%ebp), %edi // result
mov 0x10(%ebp), %esi // result_type
call_stub例程的分析先告一段落。到目前位置,各位仍然没有看到JVM内部如何从C/CC++程序完成Java函数䣌调用,因为这部分逻辑需要在后面讲解完entry_point例程后才能完全揭开它的真相,但是大家至少知道JVM是如何将Java函数入参压入到机器层面意义上的方法堆栈之中的。起始,这部分压入的参数,便形成了Java方法堆栈中"局部变量表"的一部分。
总之,想要研究清楚JVM执行引擎的内部实现基址,call_stub这个例程是无论如何也绕不过去的,该例程对JVM执行引擎的实现也起到至关重要的桥梁作用,让程序流从JVM的世界直接穿越进入Java的世界。
事实上,在JVM规范中,也提供了函数调用的好几种字节码指令,例如在调用Java静态方法和类成员方法时,或者native方法时,所生成的字节码函数调用指令是不同的,其中部分函数调用的指令在执行时,最终也会经过call_stub这个例程,因此弄懂call_stub例程的实现基址,对于研究JVM规范中的其他函数调用字节码指令的执行原理大有好处
总之,想要研究清楚JVM执行引擎的内部实现基址,call_stub这个例程是无论如何也绕不过去的,该例程对JVM执行引擎的实现也起到至关重要的桥梁作用,让程序流从JVM的世界直接穿越进入Java的世界。
事实上,在JVM规范中,也提供了函数调用的好几种字节码指令,例如在调用Java静态方法和类成员方法时,或者native方法时,所生成的字节码函数调用指令是不同的,其中部分函数调用的指令在执行时,最终也会经过call_stub这个例程,因此弄懂call_stub例程的实现基址,对于研究JVM规范中的其他函数调用字节码指令的执行原理大有好处
Java数据结构与面向对象
我们知道,算法和数据结构是激素那几的基础,而Java虚拟机的执行引擎框架仍然以此为基础。如同物理CPU能够识别字节码和存储单元,C语言比那一起能够识别使用C定义的结构体一样,JVM执行引擎同样能够识别Java语言中的基础数据结构——类型,所以Java语言里的数据结构对于JVM执行引擎而言非常重要。
从Java算法到数据结构。
如果非要用一句简单的花来概括何谓"编程",那么下面的这句话虽然不一定能够反应"编程"的全貌,但至少能够从一个侧面描述编程的本质:
"编程就是使用合适的算法处理特定的数据结构".同理,也可以使用下面这句话来概括什么是"程序":
"程序就是算法与数据结构的有机结合".
(注:这里所说的算法是一种广义的概念,凡是能够完成有特定逻辑的一组指令的集合都可以称为算法。使用C#完成一个绚丽的PC版视窗是一种算法,使用C++完成一个基于epoll的IO框架是一种算法,使用VB为Excel写一个宏脚本是一种算法,使用Java完成了一个网页数据加载也是一种算法。这里的算法不局限于那些特定的用于解决数学问题的技术逻辑。)
算法是指令驱动的,一条条指令按照一定顺序执行,彼此写作,最终实现某种特定的逻辑,便完成了特定的算法。Java程序的算法由Java字节码指令所驱动。而数据结构往往会作为算法的输入、输出和中间产出,即使输出的是一个简单的整型数字,也是一种数据结构。
本来涉及算法是一件挺快乐的事儿,当使用JavScript完成了一个能够绘制柱状统计图的小组件的时候,内心一定是激动无比的,或者当使用汇编完成了一个哈希表涉及的时候,会觉得自己特厉害。但是当算法遇上了不同的操作系统,或者不同的硬件平台,一件原本快乐的事情便硬生生地变成一件痛苦的事儿。例如,你想开发一款高并发、高性能的网络通信组件,或者一种分布式的调度器,如果选择的不是Java或者Python,而是C、C++等语言,那么多线程的处理、网络接口的调用等与平台相关的算法一定会让入伙的热情降到冰点以下.
Java之所以伟大就在于,它借助于JVM虚拟机和中间语言字节码,完成了指令的跨平台兼容,从而使得Java算法能够兼容大部分主流平台。这直接导致算法又变成了一项有趣的活儿,开发者终于又可以集中精力编写算法这一件事上面,再也不用担心自己的算法是否兼容其他平台,也不用关注底层不同的实现。换而言之,没有兼容不了的平台,只有写不出的程序。
当年詹爷实现Java "write once, run anywhere"的终极武器是"Java字节码"这种中间语言(ML)技术。这条技术路线的选择有其偶然性,但是更多的却是一种必然性。虽然可以选择的技术实现方式有很多种,但是总体思路是被限制死的。总而言之,在木匾计算机硬件执行架构的限制下,可选的技术路径只有那么几条,总结起来只有以下3条。
如果非要用一句简单的花来概括何谓"编程",那么下面的这句话虽然不一定能够反应"编程"的全貌,但至少能够从一个侧面描述编程的本质:
"编程就是使用合适的算法处理特定的数据结构".同理,也可以使用下面这句话来概括什么是"程序":
"程序就是算法与数据结构的有机结合".
(注:这里所说的算法是一种广义的概念,凡是能够完成有特定逻辑的一组指令的集合都可以称为算法。使用C#完成一个绚丽的PC版视窗是一种算法,使用C++完成一个基于epoll的IO框架是一种算法,使用VB为Excel写一个宏脚本是一种算法,使用Java完成了一个网页数据加载也是一种算法。这里的算法不局限于那些特定的用于解决数学问题的技术逻辑。)
算法是指令驱动的,一条条指令按照一定顺序执行,彼此写作,最终实现某种特定的逻辑,便完成了特定的算法。Java程序的算法由Java字节码指令所驱动。而数据结构往往会作为算法的输入、输出和中间产出,即使输出的是一个简单的整型数字,也是一种数据结构。
本来涉及算法是一件挺快乐的事儿,当使用JavScript完成了一个能够绘制柱状统计图的小组件的时候,内心一定是激动无比的,或者当使用汇编完成了一个哈希表涉及的时候,会觉得自己特厉害。但是当算法遇上了不同的操作系统,或者不同的硬件平台,一件原本快乐的事情便硬生生地变成一件痛苦的事儿。例如,你想开发一款高并发、高性能的网络通信组件,或者一种分布式的调度器,如果选择的不是Java或者Python,而是C、C++等语言,那么多线程的处理、网络接口的调用等与平台相关的算法一定会让入伙的热情降到冰点以下.
Java之所以伟大就在于,它借助于JVM虚拟机和中间语言字节码,完成了指令的跨平台兼容,从而使得Java算法能够兼容大部分主流平台。这直接导致算法又变成了一项有趣的活儿,开发者终于又可以集中精力编写算法这一件事上面,再也不用担心自己的算法是否兼容其他平台,也不用关注底层不同的实现。换而言之,没有兼容不了的平台,只有写不出的程序。
当年詹爷实现Java "write once, run anywhere"的终极武器是"Java字节码"这种中间语言(ML)技术。这条技术路线的选择有其偶然性,但是更多的却是一种必然性。虽然可以选择的技术实现方式有很多种,但是总体思路是被限制死的。总而言之,在木匾计算机硬件执行架构的限制下,可选的技术路径只有那么几条,总结起来只有以下3条。
1.直接编写目标硬件平台的机器码指令
这种方式最直接,也最有效,目标硬件平台所支持的指令该是什么就是什么,没有任何二义性,不会产生混淆,清清楚楚,明明白白。不过一般只有最底层的软件程序才需要使用这种方式,同时早期的软件程序也采用这种方式,毕竟那时候的编程语言还没有如今这么智能化
这种方式最直接,也最有效,目标硬件平台所支持的指令该是什么就是什么,没有任何二义性,不会产生混淆,清清楚楚,明明白白。不过一般只有最底层的软件程序才需要使用这种方式,同时早期的软件程序也采用这种方式,毕竟那时候的编程语言还没有如今这么智能化
2.使用高级语言编程,通过编译器实现兼容
可以这么说,如果在马路上拿起一块小石头随便往大街上人过去,砸中的十有八九就是这种机制的高级语言,例如C、C++、Delphi等都是如此。这种机制要求比较高,不仅要求在不同的目标平台上安装对应的编译器,还需要软件程序本身针对不同的目标平台调用不同的底层API。从源代码,到编译器,到打包,再到编译后的目标文件乃至可执行程序,全部都是目标平台相关的,没有一个是可以只"write once"就能实现"run anywhere"的。需要多个不同平台版本的编译器,源程序也是如此,因此开发者比较痛苦。。
比如C++在Windows上创建线程的API方式是
CreateThread(NULL, 0, ThreadProcessFunc, NULL, 0, NULL)
在Linux上创建线程的API是:
pthread_create(&ntid, NULL, thr_fn, NULL);
在Windows上的编译器是不认识pthread_create这个API的,反之Linux也不认识CreateThread。
可以这么说,如果在马路上拿起一块小石头随便往大街上人过去,砸中的十有八九就是这种机制的高级语言,例如C、C++、Delphi等都是如此。这种机制要求比较高,不仅要求在不同的目标平台上安装对应的编译器,还需要软件程序本身针对不同的目标平台调用不同的底层API。从源代码,到编译器,到打包,再到编译后的目标文件乃至可执行程序,全部都是目标平台相关的,没有一个是可以只"write once"就能实现"run anywhere"的。需要多个不同平台版本的编译器,源程序也是如此,因此开发者比较痛苦。。
比如C++在Windows上创建线程的API方式是
CreateThread(NULL, 0, ThreadProcessFunc, NULL, 0, NULL)
在Linux上创建线程的API是:
pthread_create(&ntid, NULL, thr_fn, NULL);
在Windows上的编译器是不认识pthread_create这个API的,反之Linux也不认识CreateThread。
3.通过虚拟机实现兼容性
这种方式就是以Java为代表的技术路线。使用这种机制实现兼容性的成本相当低,开发者只需要开发一次源代码,只需要在某一种硬件平台上编译通过,然后便可以在任何目标平台上运行(说任何平台确实夸张了,事实上JVM也并没有通吃全部的平台,但是基本实现了极大主流硬件平台和操作系统的兼容性)。开发者所需要做的仅仅是在不同的硬件平台上部署不同的虚拟机而已。这种成本相诸如C++/C之类的高级编程语言动不动就要针对不同目标平台而修改底层API,已经几乎可以忽略不计了。
这种方式就是以Java为代表的技术路线。使用这种机制实现兼容性的成本相当低,开发者只需要开发一次源代码,只需要在某一种硬件平台上编译通过,然后便可以在任何目标平台上运行(说任何平台确实夸张了,事实上JVM也并没有通吃全部的平台,但是基本实现了极大主流硬件平台和操作系统的兼容性)。开发者所需要做的仅仅是在不同的硬件平台上部署不同的虚拟机而已。这种成本相诸如C++/C之类的高级编程语言动不动就要针对不同目标平台而修改底层API,已经几乎可以忽略不计了。
当然,除了这3中主要的方式,还有其他各种于此类似的实现方案,例如,Python作为一门脚本语言,不需要编译便可以直接运行,但是其在本质上还是与JVM虚拟机类似,因为Python的编译过程由解释器代劳,解释器将python编译为中间语言(类似Java的字节码)并基于中间语言实现跨屏天,在这一点上,与JVM完全保持一致。。
比较这3种方式,可以发现,实现兼容性的思路基本是从原始笨拙向着高度智能的方向发展,最终达到了不需要为了兼容性而额外写多份源代码的目标,即"write once"."write once"这一目标的实现,带来的回报或者红利是十分丰厚的。首当其冲的红利就是开发者再也不用感知底层API了,省去了这么个麻烦且痛苦的过程,直接使得类似Java这样非常高级的编程语言的学习门槛大幅度降低,甚至近乎于零,虽然可能会导致开发者在底层实现原理领域存在盲区,不利于技术成长,但是对于商业项目而言,屏蔽了底层实现的高级编程语言的开发效率会成倍地加快。相比之下,在宏观层面上有这么一门近乎智能的编程语言,的确加速了信息化的进程,至于底层硬件的那些东西就交给社会大分工去调节吧,总会有一部分会专注于那一块像原始森林一样深厚广袤的领域。
"write once"所馈赠的另一个回报——数据结构不再直接依赖于物理机器。程序是算法与数据结构的有机结合体,不管是算法还是数据结构,最终都需要被物理机器所理解。任何一门编程语言,构成算法基础的指令都会被还原成机器指令,只有物理机器才有能力执行各种各样的算法指令。虽然广义的数据结构是一种抽象的概念,甚至很多时候人们为了演算某种算法,会将抽象的数据结构具象化,使用形象化的语言符号去描述这种抽象的数据结构,但是在工程实践中,数据结构必然需要由一种具体的编程语言去实现。这种实现的背后,仍然是物理机器在支撑,仍然离不开机器指令,简单到定义一个字节变量,复杂到定义一个复合型的结构体,其实都是需要CPU首先能够识别对应的机器指令然后才能运行实现
比较这3种方式,可以发现,实现兼容性的思路基本是从原始笨拙向着高度智能的方向发展,最终达到了不需要为了兼容性而额外写多份源代码的目标,即"write once"."write once"这一目标的实现,带来的回报或者红利是十分丰厚的。首当其冲的红利就是开发者再也不用感知底层API了,省去了这么个麻烦且痛苦的过程,直接使得类似Java这样非常高级的编程语言的学习门槛大幅度降低,甚至近乎于零,虽然可能会导致开发者在底层实现原理领域存在盲区,不利于技术成长,但是对于商业项目而言,屏蔽了底层实现的高级编程语言的开发效率会成倍地加快。相比之下,在宏观层面上有这么一门近乎智能的编程语言,的确加速了信息化的进程,至于底层硬件的那些东西就交给社会大分工去调节吧,总会有一部分会专注于那一块像原始森林一样深厚广袤的领域。
"write once"所馈赠的另一个回报——数据结构不再直接依赖于物理机器。程序是算法与数据结构的有机结合体,不管是算法还是数据结构,最终都需要被物理机器所理解。任何一门编程语言,构成算法基础的指令都会被还原成机器指令,只有物理机器才有能力执行各种各样的算法指令。虽然广义的数据结构是一种抽象的概念,甚至很多时候人们为了演算某种算法,会将抽象的数据结构具象化,使用形象化的语言符号去描述这种抽象的数据结构,但是在工程实践中,数据结构必然需要由一种具体的编程语言去实现。这种实现的背后,仍然是物理机器在支撑,仍然离不开机器指令,简单到定义一个字节变量,复杂到定义一个复合型的结构体,其实都是需要CPU首先能够识别对应的机器指令然后才能运行实现
詹爷在编程领域算得上是一个比较有品味的人,当年设计Java时,对这种全新的编程语言期望很高,不仅要求能够实现跨平台兼容,还要求融入当时非常时髦的设计思想——面向对象。Java语言里面除了接口和枚举,其他的数据结构都是类型(事实上对于JVM而言,解耦和枚举也是类型),或者反过来说,Java中的类型都是一种专门的数据结构,整个Java程序都由数据结构组成,Java算法也由数据结构的动作所驱动,所以数据结构可以说是Java的核心。
詹爷为了实现算法"write once, run anywhere"的伟大目标,最终选择了使用字节码中间语言这条技术了路线,通过Java字节码来统一描述算法。在"字节码"这条技术的康庄大道上,詹爷一溜烟跑到头,将字节码的思想贯彻得非常彻底,不仅将程序算法"字节码化",连同数据结构一起被"字节码"化。这种"字节码"化的数据结构,由于以"面向对象"为设计宗旨,因此在面向用户(即开发者)的一端是十分人性化的,同时由于被"字节码"化,因此在面向机器的那端,是十分不可理喻的,因为字节码化的数据结构不认识机器、机器也不认识它,这边实现了数据结构对物理机器的去依赖。
总而言之,詹爷使出了浑身解数,不仅让算法变成一种充满趣味的活儿,解除了算法对物理机器指令的依赖,让天下没有难以兼容的平台,而且还让数据结构也脱离对底层硬件平台的依赖,使广大开发者受益匪浅,可以像定义抽象的数据结构那样,随心所欲任意组装所需要的结构体。算法与数据结构对物理硬件的双重去依赖,使得Java程序具有简单易学的特点,并最终保证了Java程序跨平台兼容的彻底性。
詹爷让数据结构脱离底层机器约束的思路其实并不复杂,例如这个Java类,是一个描述iPhone 6s手机参数的数据结构
詹爷为了实现算法"write once, run anywhere"的伟大目标,最终选择了使用字节码中间语言这条技术了路线,通过Java字节码来统一描述算法。在"字节码"这条技术的康庄大道上,詹爷一溜烟跑到头,将字节码的思想贯彻得非常彻底,不仅将程序算法"字节码化",连同数据结构一起被"字节码"化。这种"字节码"化的数据结构,由于以"面向对象"为设计宗旨,因此在面向用户(即开发者)的一端是十分人性化的,同时由于被"字节码"化,因此在面向机器的那端,是十分不可理喻的,因为字节码化的数据结构不认识机器、机器也不认识它,这边实现了数据结构对物理机器的去依赖。
总而言之,詹爷使出了浑身解数,不仅让算法变成一种充满趣味的活儿,解除了算法对物理机器指令的依赖,让天下没有难以兼容的平台,而且还让数据结构也脱离对底层硬件平台的依赖,使广大开发者受益匪浅,可以像定义抽象的数据结构那样,随心所欲任意组装所需要的结构体。算法与数据结构对物理硬件的双重去依赖,使得Java程序具有简单易学的特点,并最终保证了Java程序跨平台兼容的彻底性。
詹爷让数据结构脱离底层机器约束的思路其实并不复杂,例如这个Java类,是一个描述iPhone 6s手机参数的数据结构
编译这个可以被看成使一个纯粹的"数据结构"的Java类,得到的字节码格式的"数据结构信息"如图所示。。
编译后所得到的这样一种使用字节码进行格式化的数据结构,你很难轻易地将其与具体的机器指令关联起来,而事实上这些"字节码"化的指令也的确无法被物理机器直接识别,这便实现了Java数据结构对物理机器的去依赖。不过这种字节码格式终归仍然需要被物理机器理解并执行,这就需要将字节码最终转换为最底层的机器指令。
詹爷使尽了浑身解数折腾出Java这么一款面向对象的语言,从算法与数据结构的角度也是具有重要意义的。其巨大作用便在于,让程序员可以专注于业务逻辑,而不需要将很多精力浪费在各种底层平台的接口兼容上。这对于商业项目而言,无疑具有无比巨大的实实在在的价值.
编译后所得到的这样一种使用字节码进行格式化的数据结构,你很难轻易地将其与具体的机器指令关联起来,而事实上这些"字节码"化的指令也的确无法被物理机器直接识别,这便实现了Java数据结构对物理机器的去依赖。不过这种字节码格式终归仍然需要被物理机器理解并执行,这就需要将字节码最终转换为最底层的机器指令。
詹爷使尽了浑身解数折腾出Java这么一款面向对象的语言,从算法与数据结构的角度也是具有重要意义的。其巨大作用便在于,让程序员可以专注于业务逻辑,而不需要将很多精力浪费在各种底层平台的接口兼容上。这对于商业项目而言,无疑具有无比巨大的实实在在的价值.
数据类型简史。
虽然Java的类型信息通过字节码进行了格式化,但是这种编译后的格式化了的数据类型并不能直接被物理机器识别,没有哪台物理机器能够在读取了Java字节码文件内容之后就能直接在内存中构建出相应的结构体。Java的这种数据结构实现机制相比于同时代的其他编程语言很独特。
数据结构与物理机器之间有着千丝万缕的联系。程序算法告诉物理机器应该怎么做,而数据结构则告诉物理机器拿什么去做。而事实上物理机器的大部分指令也的确同时包含了"怎么做"和"做什么"这两部分。例如下面这段汇编程序:
sub $32, %esp
movl $138, -28(%ebp)
movl $16, -8(%ebp)
在这段汇编程序中,出现了2个熟悉的身影——sub和mov这两个指令分别表示减和传送。这两个指令都是典型的带有"立即数"的机器指令,指令本身告诉物理机器要"怎么做",而跟在指令后面的立即数则告诉物理机器"做什么"。例如对于subl $32, %esp这条指令而言,sub是物理机器指令,告诉机器要开始做减法运算了,减多少呢?后面的立即数$32就是答案。对谁做减法运算呢?再后面的%esp寄存器就是答案。
在这个例子中,立即数32和寄存器的esp都可以认为是一种数据类型,只不过这是最简单的数据类型,简单到它就是它"自己",而不包含任何其他元素或成员,这种情况下,数据类型已经直接退化成了数据。一般而言,所谓数据结构,至少也应该是符合结构,但是在物理机器层面,已经"符合"这种概念了,CPU只能操作位或者存储单元,哪怕再稍微复杂一点点都不能支持。
在这里,有一个关键的点需要明确,那就是数据结构与数据类型的关系。简单地说,数据结构的实现需要依赖数据类型的支持,例如使用C语言定义一个单向链表,往往需要多种数据类型的参与才能实现,任何一种编程语言,简单到最原始的机器和汇编,复杂到现代的高级语言,不管哪一种,其实都能够实现任何一种复杂的数据结构,道理很简单,任何编程语言所定义的任意复杂的数据结构最终都要依靠机器指令才能实现,这本身便说明机器指令讷讷感狗实现任意复杂的数据结构。区别在于实现的难易程度,编程语言所能支持的数据类型越多,则实现复杂的数据结构的成本往往越小。由于机器指令和汇编语言不支持多样化和复杂的数据类型,因此给程序设计带来了诸多困扰,软件设计虽然也能够使用基本数据类型通过在内存中建立映射关系从而实现结构化的内存空间布局以支撑对应的算法逻辑,但是仍然无法使用一种高级和直观的数据试图去形象化地表现复杂地对象。例如,构建一种简单地结构体来描述iPhone 6S信息,仅仅使用汇编这种并不支持任何数据类型的编程语言也能够实现,但是其在语法层面并不能够提供简单的支持,只能写成如图所示这种格式
可以看出,这种方式毫无结构可言,如果不是编写程序的人特意说明这段程序是在定义一个关于iPhone6S的数据结构,读者压根儿就看不出来
虽然Java的类型信息通过字节码进行了格式化,但是这种编译后的格式化了的数据类型并不能直接被物理机器识别,没有哪台物理机器能够在读取了Java字节码文件内容之后就能直接在内存中构建出相应的结构体。Java的这种数据结构实现机制相比于同时代的其他编程语言很独特。
数据结构与物理机器之间有着千丝万缕的联系。程序算法告诉物理机器应该怎么做,而数据结构则告诉物理机器拿什么去做。而事实上物理机器的大部分指令也的确同时包含了"怎么做"和"做什么"这两部分。例如下面这段汇编程序:
sub $32, %esp
movl $138, -28(%ebp)
movl $16, -8(%ebp)
在这段汇编程序中,出现了2个熟悉的身影——sub和mov这两个指令分别表示减和传送。这两个指令都是典型的带有"立即数"的机器指令,指令本身告诉物理机器要"怎么做",而跟在指令后面的立即数则告诉物理机器"做什么"。例如对于subl $32, %esp这条指令而言,sub是物理机器指令,告诉机器要开始做减法运算了,减多少呢?后面的立即数$32就是答案。对谁做减法运算呢?再后面的%esp寄存器就是答案。
在这个例子中,立即数32和寄存器的esp都可以认为是一种数据类型,只不过这是最简单的数据类型,简单到它就是它"自己",而不包含任何其他元素或成员,这种情况下,数据类型已经直接退化成了数据。一般而言,所谓数据结构,至少也应该是符合结构,但是在物理机器层面,已经"符合"这种概念了,CPU只能操作位或者存储单元,哪怕再稍微复杂一点点都不能支持。
在这里,有一个关键的点需要明确,那就是数据结构与数据类型的关系。简单地说,数据结构的实现需要依赖数据类型的支持,例如使用C语言定义一个单向链表,往往需要多种数据类型的参与才能实现,任何一种编程语言,简单到最原始的机器和汇编,复杂到现代的高级语言,不管哪一种,其实都能够实现任何一种复杂的数据结构,道理很简单,任何编程语言所定义的任意复杂的数据结构最终都要依靠机器指令才能实现,这本身便说明机器指令讷讷感狗实现任意复杂的数据结构。区别在于实现的难易程度,编程语言所能支持的数据类型越多,则实现复杂的数据结构的成本往往越小。由于机器指令和汇编语言不支持多样化和复杂的数据类型,因此给程序设计带来了诸多困扰,软件设计虽然也能够使用基本数据类型通过在内存中建立映射关系从而实现结构化的内存空间布局以支撑对应的算法逻辑,但是仍然无法使用一种高级和直观的数据试图去形象化地表现复杂地对象。例如,构建一种简单地结构体来描述iPhone 6S信息,仅仅使用汇编这种并不支持任何数据类型的编程语言也能够实现,但是其在语法层面并不能够提供简单的支持,只能写成如图所示这种格式
可以看出,这种方式毫无结构可言,如果不是编写程序的人特意说明这段程序是在定义一个关于iPhone6S的数据结构,读者压根儿就看不出来
在计算机技术问世之初,其就像太阳一样,散发万丈光芒,继18世纪末19世纪初伟大的工业革命变革之后,再一次照亮了全世界,给人类带来了希望和信心。可是,当人们意识到无法随心所欲地定义清晰明了地数据结构问题之后,发现整个天空都暗了。其时软件领域的天空上漂浮着一朵乌云,这朵乌云叫做"数据结构化"。如果这朵乌云始终不被驱散,那么整个软件界都会处于一片黑暗中,找不着出路。
这个问题不解决,虽然各种各样降低时间复杂度和提升性能的算法依然会陆续登上历史舞台,但是没有一种直观的数据结构的视图化编程方式,一切都按照机器的"意志"行事,那么所谓的"数据结构"便只是一种仅仅存在于观念和意识中的概念,看不见摸不着写不出读不懂,始终不能落地,就像一个如幽灵般捉摸不定的量子。因此算法的发明速度也会迟缓,而离开了算法的软件技术,也必定举步维艰得不到真正的发展。可以说,软件在短短几十年里得到迅速的发展,与高级编程语言在语法层面提供的自定义数据类型的能力是息息相关的,相关的佐证俯拾皆是,并且这种相关性在Java语言里尤其明显,例如Java直接内建了HashMap、List、Set、Stack等高级数据结构类型,这些类型在大部分Java工程中被大量使用,而在汇编或C程序中,开发者往往要自己实现这些高级的数据结构与算法,虽然可以使用第三方包,但是很多第三方包的作者并不能保证程序的健壮性和兼容性。
所以现代高级编程语言的发展历程是伴随着对数据类型的日益强大的支持的过程。回到IT历史发展的早期阶段,虽然伟大的先辈们从算法层面对汇编进行了抽象,逐渐发展出了高级编程语言,使开发者不用直面机器指令或者寄存器之类的硬件,到那时在数据结构层面一直还没有诞生出这种意识,一切算法仍然围绕着二进制位、存储单元(字节)、字、双子等最原始的数据类型。那时候所谓的二进制位、字节甚至都不能算是数据类型,因为当时根本就没有这个概念。
最早的语言仅支持少量的数据结构,如Fortran 90之前通常用数组来模拟链表及二叉树,而所谓数组,其实汇编里面也能实现,本质上而言并不属于高级编程语言所独有,因为直接使用机器指令也能模拟初一个类似数组的内存空间。至于COBOL、ADA和PL/I之类的早期编程语言,也没有好到哪里去,在数据类型的概念上并没有产生质的飞跃。及至到了ALGOL,终于在数据结构方面迈出了历史性的异步。ALGOL开创性地引入了用户自定义类型的概念,注意,是自定义类型,虽然ALGOL仅提供少数的基本类型以及少量灵活的结构定义操作符,却允许开发者自主设计一种数据结构。显然,这是数据类型发展过程中最重要的进步。到了1967念,Simula 67首次提出"类型"的概念,把数据和被允许施与数据上的操作结合为一个统一体,从而成为现代"抽象数据类型"的开端及第一个"面向对象语言"。
通过以上历史进程可见,数据类型并非天生就有,而是在无数前辈们的努力下逐渐提出来的一种概念。从一开始完全没有数据类型的概念,到数据类型概念的萌芽,再到能够自定义数据类型,最终到"抽象数据类型",其中对数据类型的每一次认识上的加深,都对软件领域历史的发展产生革命性的作用,每一次都极大地提升了编程语言对客观世界的抽象和描述能力,编程语言变得越来越易用,越来月只能,越来越"人性化"。。
到C语言之父Richard发明出C语言的时候,人们已经可以轻轻松松地定义和实现数据类型了,已经能够描述非常复杂的客观事物了,例如,同样是为了iPhone 6S定义一个结构,使用C语言的"结构体"可以这样写
这个问题不解决,虽然各种各样降低时间复杂度和提升性能的算法依然会陆续登上历史舞台,但是没有一种直观的数据结构的视图化编程方式,一切都按照机器的"意志"行事,那么所谓的"数据结构"便只是一种仅仅存在于观念和意识中的概念,看不见摸不着写不出读不懂,始终不能落地,就像一个如幽灵般捉摸不定的量子。因此算法的发明速度也会迟缓,而离开了算法的软件技术,也必定举步维艰得不到真正的发展。可以说,软件在短短几十年里得到迅速的发展,与高级编程语言在语法层面提供的自定义数据类型的能力是息息相关的,相关的佐证俯拾皆是,并且这种相关性在Java语言里尤其明显,例如Java直接内建了HashMap、List、Set、Stack等高级数据结构类型,这些类型在大部分Java工程中被大量使用,而在汇编或C程序中,开发者往往要自己实现这些高级的数据结构与算法,虽然可以使用第三方包,但是很多第三方包的作者并不能保证程序的健壮性和兼容性。
所以现代高级编程语言的发展历程是伴随着对数据类型的日益强大的支持的过程。回到IT历史发展的早期阶段,虽然伟大的先辈们从算法层面对汇编进行了抽象,逐渐发展出了高级编程语言,使开发者不用直面机器指令或者寄存器之类的硬件,到那时在数据结构层面一直还没有诞生出这种意识,一切算法仍然围绕着二进制位、存储单元(字节)、字、双子等最原始的数据类型。那时候所谓的二进制位、字节甚至都不能算是数据类型,因为当时根本就没有这个概念。
最早的语言仅支持少量的数据结构,如Fortran 90之前通常用数组来模拟链表及二叉树,而所谓数组,其实汇编里面也能实现,本质上而言并不属于高级编程语言所独有,因为直接使用机器指令也能模拟初一个类似数组的内存空间。至于COBOL、ADA和PL/I之类的早期编程语言,也没有好到哪里去,在数据类型的概念上并没有产生质的飞跃。及至到了ALGOL,终于在数据结构方面迈出了历史性的异步。ALGOL开创性地引入了用户自定义类型的概念,注意,是自定义类型,虽然ALGOL仅提供少数的基本类型以及少量灵活的结构定义操作符,却允许开发者自主设计一种数据结构。显然,这是数据类型发展过程中最重要的进步。到了1967念,Simula 67首次提出"类型"的概念,把数据和被允许施与数据上的操作结合为一个统一体,从而成为现代"抽象数据类型"的开端及第一个"面向对象语言"。
通过以上历史进程可见,数据类型并非天生就有,而是在无数前辈们的努力下逐渐提出来的一种概念。从一开始完全没有数据类型的概念,到数据类型概念的萌芽,再到能够自定义数据类型,最终到"抽象数据类型",其中对数据类型的每一次认识上的加深,都对软件领域历史的发展产生革命性的作用,每一次都极大地提升了编程语言对客观世界的抽象和描述能力,编程语言变得越来越易用,越来月只能,越来越"人性化"。。
到C语言之父Richard发明出C语言的时候,人们已经可以轻轻松松地定义和实现数据类型了,已经能够描述非常复杂的客观事物了,例如,同样是为了iPhone 6S定义一个结构,使用C语言的"结构体"可以这样写
C语言为了实现"数据结构"的可视化,定义了"结构体"这种类型,通过结构体,开发者可以将任意类型的基本数据组合到一起,形成复杂的数据结构。不仅如此,C语言的结构体还能嵌套使用,结构体里面包含结构体,从而可以定义初多维度的深度树形结构。例如,将上面的结构体改造以下,编程嵌套结构体。
在本例中,iPhone6S变成了一个嵌套的结构体,基于磁力,可以定义任意复杂的结构体,由于C语言中有了"结构体",世界上几乎没有它描述不了的事物,从此数据结构有了合适的落脚点。
除了C语言,其他算得上"高级"的编程语言,也几乎都能够从语法层面支持"数据结构"的概念,开发者按照给定的格式去定义数据结构,机器一定能够正确识别。例如在VB中可以使用下面这种方式去定义数据结构
type student
id as String
name as String
age as int
end tpye
其他常见的编程语言诸如Python、Perl、C++、PHP等都支持自定义数据结构。前面讲过Java中的数据结构被字节码格式化了,最终的结果是Java的数据结构解除了对物理机器的依赖,但是C语言中的数据结构对物理机器是有依赖的,这种依赖在C语言源代码被编译后便出现了。
在本例中,iPhone6S变成了一个嵌套的结构体,基于磁力,可以定义任意复杂的结构体,由于C语言中有了"结构体",世界上几乎没有它描述不了的事物,从此数据结构有了合适的落脚点。
除了C语言,其他算得上"高级"的编程语言,也几乎都能够从语法层面支持"数据结构"的概念,开发者按照给定的格式去定义数据结构,机器一定能够正确识别。例如在VB中可以使用下面这种方式去定义数据结构
type student
id as String
name as String
age as int
end tpye
其他常见的编程语言诸如Python、Perl、C++、PHP等都支持自定义数据结构。前面讲过Java中的数据结构被字节码格式化了,最终的结果是Java的数据结构解除了对物理机器的依赖,但是C语言中的数据结构对物理机器是有依赖的,这种依赖在C语言源代码被编译后便出现了。
在Linux平台上,上面那段iphone6s.c的C语言数据结构被编译后,会直接变成如图所示的汇编程序
// 这是CLion Windows平台查看的汇编指令
push %rbp
mov %rsp,%rbp
sub $0x60,%rsp
call 0x7ff7816d1727 <__main>
movl $0x8a,-0x2c(%rbp)
movl $0x43,-0x28(%rbp)
movl $0x7,-0x24(%rbp)
movl $0x8f,-0x40(%rbp)
movl $0x2,-0x3c(%rbp)
movl $0x10,-0x38(%rbp)
movl $0x4b0,-0x34(%rbp)
mov -0x2c(%rbp),%rax
mov %rax,-0x20(%rbp)
mov -0x24(%rbp),%eax
mov %eax,-0x18(%rbp)
mov -0x40(%rbp),%rax
mov -0x38(%rbp),%rdx
mov %rax,-0x14(%rbp)
mov %rdx,-0xc(%rbp)
mov $0x0,%eax
add $0x60,%rsp
pop %rbp
ret
// 这是CLion Windows平台查看的汇编指令
push %rbp
mov %rsp,%rbp
sub $0x60,%rsp
call 0x7ff7816d1727 <__main>
movl $0x8a,-0x2c(%rbp)
movl $0x43,-0x28(%rbp)
movl $0x7,-0x24(%rbp)
movl $0x8f,-0x40(%rbp)
movl $0x2,-0x3c(%rbp)
movl $0x10,-0x38(%rbp)
movl $0x4b0,-0x34(%rbp)
mov -0x2c(%rbp),%rax
mov %rax,-0x20(%rbp)
mov -0x24(%rbp),%eax
mov %eax,-0x18(%rbp)
mov -0x40(%rbp),%rax
mov -0x38(%rbp),%rdx
mov %rax,-0x14(%rbp)
mov %rdx,-0xc(%rbp)
mov $0x0,%eax
add $0x60,%rsp
pop %rbp
ret
可以看到,C语言中的数据结构被编译后,直接被转换成了对应平台上的机器指令,因此物理机器是可以直接识别并运行的。虽然被转换后,原本C语言中结构体数据的类型信息被彻底抹去,被彻底大会最原始的类型,变得不可理解,如果直接阅读编译后的汇编代码,你肯定不知道原来这段程序是一个数据结构体。C语言通过编译器,将人类可理解的结构体这种具象化的高级概念,转换成了底层非结构化的低级概念,这种转换是自动的,是编译器智能分析的结果。虽然在今天看来,这种自动化并不一定算得上"智能化",因为当今"智能化"这个词已经有了更加复杂的含义,但是在当年那种编译原理尚未完全成熟的年代,能够做到这种自动转换,已属不易。
如果说C语言中的数据结构依赖于物理机器,倒不如说依赖于特定平台上的特定编译器更加合适,而事实上也的确如此,不同硬件平台上的编译器各不相同,这些不同的编译器将同一个C语言程序编译成了各自对应的底层指令。
C语言在编译期实现了数据结构的解释,编译器实现了对C程序中的数据类型的识别、解释,并最终翻译成了机器指令可以识别的数据类型。操作系统加载编译后的二进制程序执行,在内存中构建出与源程序中所定义的完全一致的数据结构,这给人一种错觉,,以为物理机器能够认识C程序中所定义的各种复杂的结构类型。Java语言则与此完全不同,Java编译器虽然在编译时也能够准确分析出Java的类型信息,但是编译后的Java字节码却并不能直接由物理机器执行,因此Java语言的类型信息并不完全在编译期维护,而是推迟到了运行期。原理与之相似的编程语言包括同为面向对象编程语言的SmallTalk、JavaScript、C#等。
总而言之,高级编程语言分别从算法和数据类型这两个层面对早期编程语言进行改进,改进的最终结果是: 在算法层面,早期编程语言需要使用几十甚至几百行指令才能实现的逻辑,现代高级编程语言往往只需要一两行代码便可完成,而在数据类型层面,现代高级编程语言往往直接将各种常见的结构类型集成到SDK中提供给开发者使用,并在此基础上允许开发者重写或自定义。而Java在这两方面都达到了一定的历史高度甚至巅峰
如果说C语言中的数据结构依赖于物理机器,倒不如说依赖于特定平台上的特定编译器更加合适,而事实上也的确如此,不同硬件平台上的编译器各不相同,这些不同的编译器将同一个C语言程序编译成了各自对应的底层指令。
C语言在编译期实现了数据结构的解释,编译器实现了对C程序中的数据类型的识别、解释,并最终翻译成了机器指令可以识别的数据类型。操作系统加载编译后的二进制程序执行,在内存中构建出与源程序中所定义的完全一致的数据结构,这给人一种错觉,,以为物理机器能够认识C程序中所定义的各种复杂的结构类型。Java语言则与此完全不同,Java编译器虽然在编译时也能够准确分析出Java的类型信息,但是编译后的Java字节码却并不能直接由物理机器执行,因此Java语言的类型信息并不完全在编译期维护,而是推迟到了运行期。原理与之相似的编程语言包括同为面向对象编程语言的SmallTalk、JavaScript、C#等。
总而言之,高级编程语言分别从算法和数据类型这两个层面对早期编程语言进行改进,改进的最终结果是: 在算法层面,早期编程语言需要使用几十甚至几百行指令才能实现的逻辑,现代高级编程语言往往只需要一两行代码便可完成,而在数据类型层面,现代高级编程语言往往直接将各种常见的结构类型集成到SDK中提供给开发者使用,并在此基础上允许开发者重写或自定义。而Java在这两方面都达到了一定的历史高度甚至巅峰
Java数据结构之偶然性。
Java丰富的数据类型和面向对象的特性,为广大软件开发者能够自由描述各种复杂的客观事物打开方便之门。不过相比于C/C++等面向对象的语言,或者虽然面向对象但是不够彻底的语言,Java是实实在在地将"面向对象"贯彻到底地语言。这种贯彻所带来的结果就是,如果不将整型、字符型、浮点型等基本数据类型划分为数据结构的一种,那么Java语言中的数据结构就只能退化成一种——类型。不管一种被描述的事物多么简单,也不管其如何复杂,在Java语言中,事物的一切属性都必须被"打包"为Java类型,Java类型已经成为语法层面的强制约束。而在其他语言中,可能还存在诸如结构体之类的概念,在Java中统统不存在(Java中允许定义枚举类型可能已经很开恩了),尤其在C/C++语言中,甚至允许专门为函数指针定义一种特别的类型。
相比于JVM执行引擎在技术上狭窄的选择面,Java的这种数据结构的技术实现其实带有一定的历史偶然性
Java丰富的数据类型和面向对象的特性,为广大软件开发者能够自由描述各种复杂的客观事物打开方便之门。不过相比于C/C++等面向对象的语言,或者虽然面向对象但是不够彻底的语言,Java是实实在在地将"面向对象"贯彻到底地语言。这种贯彻所带来的结果就是,如果不将整型、字符型、浮点型等基本数据类型划分为数据结构的一种,那么Java语言中的数据结构就只能退化成一种——类型。不管一种被描述的事物多么简单,也不管其如何复杂,在Java语言中,事物的一切属性都必须被"打包"为Java类型,Java类型已经成为语法层面的强制约束。而在其他语言中,可能还存在诸如结构体之类的概念,在Java中统统不存在(Java中允许定义枚举类型可能已经很开恩了),尤其在C/C++语言中,甚至允许专门为函数指针定义一种特别的类型。
相比于JVM执行引擎在技术上狭窄的选择面,Java的这种数据结构的技术实现其实带有一定的历史偶然性
1.JVM执行引擎的必然选择
在讨论Java的面向对象思想之前,先让我们再次回顾下JVM的执行引擎。
詹爷当初创立Java门派,便立志于改变智能家电程序的开发方式,希望能够开发出一门"write once, run anywhere"的语言。因此,Java语言天生便口含"兼容"的金钥匙,具体如何兼容?假设用C语言开发一款程序,为了实现兼容性,我们需要做到表中所列事情。。
詹爷当年为了实现这种能够带来巨大效益的编程语言在如何执行程序的问题上花费了无数心血,以其深厚的功力,以其对机器语言、汇编、操作系统的深刻理解,最终终于实现了JVM执行引擎。前面花了大量篇幅讲解詹爷为什么最终会选择字节码的方式作为中间语言,以及为什么最终会选择将字节码实时翻译成汇编程序这种技术路径。通过千问的分析可知,JVM做出这种技术上的选择,时综合考虑了各种技术方案的优势和弊端、特点和特性,最后所得出的最优解。
一句话,JVM选择这种技术路径时必然的,即使换了另一个大师级的人物来开发,最终也一定会设计成现在JVM的样子。只是可能CallStub这种分水岭不同,或者给出的诸如entry_point这样的例程不同,或者采用的JIT即时编译器的编译算法不同。但是在现有的物理硬件设备不变的前提下,基本不可能翻腾出别的浪花。。这种必然性是各种技术、各种路径碰撞的必然结果。
在讨论Java的面向对象思想之前,先让我们再次回顾下JVM的执行引擎。
詹爷当初创立Java门派,便立志于改变智能家电程序的开发方式,希望能够开发出一门"write once, run anywhere"的语言。因此,Java语言天生便口含"兼容"的金钥匙,具体如何兼容?假设用C语言开发一款程序,为了实现兼容性,我们需要做到表中所列事情。。
詹爷当年为了实现这种能够带来巨大效益的编程语言在如何执行程序的问题上花费了无数心血,以其深厚的功力,以其对机器语言、汇编、操作系统的深刻理解,最终终于实现了JVM执行引擎。前面花了大量篇幅讲解詹爷为什么最终会选择字节码的方式作为中间语言,以及为什么最终会选择将字节码实时翻译成汇编程序这种技术路径。通过千问的分析可知,JVM做出这种技术上的选择,时综合考虑了各种技术方案的优势和弊端、特点和特性,最后所得出的最优解。
一句话,JVM选择这种技术路径时必然的,即使换了另一个大师级的人物来开发,最终也一定会设计成现在JVM的样子。只是可能CallStub这种分水岭不同,或者给出的诸如entry_point这样的例程不同,或者采用的JIT即时编译器的编译算法不同。但是在现有的物理硬件设备不变的前提下,基本不可能翻腾出别的浪花。。这种必然性是各种技术、各种路径碰撞的必然结果。
而如果选择使用Java开发一款软件,同样在开发、编译、打包和运行期,所需要做的兼容性措施如表所示.对于软件开发,生命周期的绝大部分时间都花费在开发上,而Java程序只需要编写一次,就能在"任何平台上运行"。,由此带来的效率提升不可谓不显著。Java除了在开发商保持一致性,编译和打包、运行都保持了高度的兼容性和独立性,开发者所要做的只是针对不同的平台安装适当的JVM和JDK.
2.Java面向对象之技术偶然性
如果说JVM为了兼容各种异构硬件和平台而做出这种技术抉择是一种必然,那么Java选择面向对象的编程方式和内存管理模型(数据结构总是与内存管理机制联系在一起)便具有一定的偶然性和随机性了。对于给定的执行引擎,可以使用若干种编程方式来使用这种执行引擎。所以,Java的面向对象机制与JVM的执行引擎并不是强绑定的关系。Java程序经编译后的字节码文件,既可以使用HotSpot执行,也可以使用JRockit解释,或者被IBM JVM运行。而随着JVM越来越强大、稳定和高效,JVM本身也成为一种平台性产品,诸多编程语言可以被编译成JVM字节码文件,因此,对于同样一个Java程序编译后生成的字节码中间文件,你可以选择使用不同的执行引擎来运行,而同一款JVM执行引擎,可以解释Java、PHP等源码被编译后生成的字节码。
所以,当年詹爷选择使用编译解释的方式来执行Java程序,但是其实Java语言原本可以不是面向对象的,它可以选择面向过程,或者面向函数,或者其他,但是,在那个年代,面向对象的编程方式如日中天,如火如荼,C++、Java、VB、C#,还有一些脚本语言,例如,JavaScript、Python、Perl、PHP、Delphi等也是面向对象的。
退一万步讲,即使Java语言因为追潮流,选择了面向对象的编程方式,也完全可以不选择现在的内存模型和垃圾回收机制。但是由于,Java要求能够在运行时通过反射获取到类型信息,因此最终Java"被迫"选择了现在的这种内存模型。
C++/Delphi等编程语言也具有面向对象的特性,但是到了运行期已经完全消除了类型概念,并且也无法在运行期"反射"到类型的成员变量、方法等信息,但是Java可以。但是Java为什么可以做到在运行期动态反射到类型的成员变量和方法信息呢?说白了也很简单,在JVM加载Java类的时候,就将Java类的类元信息(类元信息即变量和方法)保存到内存中,这样在运行期直接读取目标内存种的数据便能获取到相应的信息。这种类元信息,其实就是一种打包好的数据结构模板,并且在运行期可以被识别。而相比于C++/Delphi等同样是面向对象的语言,类型模板信息早在编译期便被完全擦除,而Java语言在编译后仍然保留了类型的内部结构信息,并且类型结构信息被带到了运行期。
不过话说回来,能够做到在运行期动态反射出类型结构信息,并不能成为编程语言选择拥有面向对象特征的必然理由,更不能因此就非要实现自动内存管理(自动垃圾回收),例如C++这种面向对象的编程语言通过RTTI(运行时类型识别)也能够做到运行期识别类型,但是C++的内存仍然需要开发者自己去释放。从这个角度看,Java的自动内存管理机制就显得并不是必须的。然而Java选择具备运行时类型识别的特性本身便从一个十分隐晦的层面制约了Java必须选择成为一门面向对象的编程语言,为何?类型本身就是一种"闭包"的技术手短,只有先从语法层面实现"闭包",才能实现"对象"的概念,否则,何来的属性、成员变量、类方法一说?类型是实现将若干属性和动作打包成为一个整体对象进行统一识别的策略。如果Java像C++那样,类型不作为属性和方法封装的唯一手段,开发者可以随心所欲地在类地外面定义变量和函数,那么对于这部分数据的"运行时识别"必然是一个难题,可能需要通过类似namespace或者filename这样的机制去实现动态反射了,但是这种反射想想都让人头大
因此,从运行期动态反射的角度看,Java语言选择成为一门彻底的面向对象语言绝对是偶然种的必然。。相对来说,Java的自动内存管理(垃圾回收)机制就带有一种随机性。不过,这也不一定正确。。当一门编程语言实现了完全的闭包语法策略(使用类型包装的可以认为是闭包的一种),便自然而然具备了自动内存管理的技术基础,或者说实现自动内存管理更加容易。所以闭包便成为很多具备自动内存回收特性的编程语言的语法基础,例如GO语言、Python、JavaScript等,虽然大家具体实现闭包的手段不同,但是殊途同归,都是为了能够让虚拟机在自动回收内存时尽量简单。
总体而言,Java的面向对象和自动内存管理的特性实现,因为要实现运行时类型识别的目标,因此在偶然中带有必然性,而必然种又孕育出偶然性因素
如果说JVM为了兼容各种异构硬件和平台而做出这种技术抉择是一种必然,那么Java选择面向对象的编程方式和内存管理模型(数据结构总是与内存管理机制联系在一起)便具有一定的偶然性和随机性了。对于给定的执行引擎,可以使用若干种编程方式来使用这种执行引擎。所以,Java的面向对象机制与JVM的执行引擎并不是强绑定的关系。Java程序经编译后的字节码文件,既可以使用HotSpot执行,也可以使用JRockit解释,或者被IBM JVM运行。而随着JVM越来越强大、稳定和高效,JVM本身也成为一种平台性产品,诸多编程语言可以被编译成JVM字节码文件,因此,对于同样一个Java程序编译后生成的字节码中间文件,你可以选择使用不同的执行引擎来运行,而同一款JVM执行引擎,可以解释Java、PHP等源码被编译后生成的字节码。
所以,当年詹爷选择使用编译解释的方式来执行Java程序,但是其实Java语言原本可以不是面向对象的,它可以选择面向过程,或者面向函数,或者其他,但是,在那个年代,面向对象的编程方式如日中天,如火如荼,C++、Java、VB、C#,还有一些脚本语言,例如,JavaScript、Python、Perl、PHP、Delphi等也是面向对象的。
退一万步讲,即使Java语言因为追潮流,选择了面向对象的编程方式,也完全可以不选择现在的内存模型和垃圾回收机制。但是由于,Java要求能够在运行时通过反射获取到类型信息,因此最终Java"被迫"选择了现在的这种内存模型。
C++/Delphi等编程语言也具有面向对象的特性,但是到了运行期已经完全消除了类型概念,并且也无法在运行期"反射"到类型的成员变量、方法等信息,但是Java可以。但是Java为什么可以做到在运行期动态反射到类型的成员变量和方法信息呢?说白了也很简单,在JVM加载Java类的时候,就将Java类的类元信息(类元信息即变量和方法)保存到内存中,这样在运行期直接读取目标内存种的数据便能获取到相应的信息。这种类元信息,其实就是一种打包好的数据结构模板,并且在运行期可以被识别。而相比于C++/Delphi等同样是面向对象的语言,类型模板信息早在编译期便被完全擦除,而Java语言在编译后仍然保留了类型的内部结构信息,并且类型结构信息被带到了运行期。
不过话说回来,能够做到在运行期动态反射出类型结构信息,并不能成为编程语言选择拥有面向对象特征的必然理由,更不能因此就非要实现自动内存管理(自动垃圾回收),例如C++这种面向对象的编程语言通过RTTI(运行时类型识别)也能够做到运行期识别类型,但是C++的内存仍然需要开发者自己去释放。从这个角度看,Java的自动内存管理机制就显得并不是必须的。然而Java选择具备运行时类型识别的特性本身便从一个十分隐晦的层面制约了Java必须选择成为一门面向对象的编程语言,为何?类型本身就是一种"闭包"的技术手短,只有先从语法层面实现"闭包",才能实现"对象"的概念,否则,何来的属性、成员变量、类方法一说?类型是实现将若干属性和动作打包成为一个整体对象进行统一识别的策略。如果Java像C++那样,类型不作为属性和方法封装的唯一手段,开发者可以随心所欲地在类地外面定义变量和函数,那么对于这部分数据的"运行时识别"必然是一个难题,可能需要通过类似namespace或者filename这样的机制去实现动态反射了,但是这种反射想想都让人头大
因此,从运行期动态反射的角度看,Java语言选择成为一门彻底的面向对象语言绝对是偶然种的必然。。相对来说,Java的自动内存管理(垃圾回收)机制就带有一种随机性。不过,这也不一定正确。。当一门编程语言实现了完全的闭包语法策略(使用类型包装的可以认为是闭包的一种),便自然而然具备了自动内存管理的技术基础,或者说实现自动内存管理更加容易。所以闭包便成为很多具备自动内存回收特性的编程语言的语法基础,例如GO语言、Python、JavaScript等,虽然大家具体实现闭包的手段不同,但是殊途同归,都是为了能够让虚拟机在自动回收内存时尽量简单。
总体而言,Java的面向对象和自动内存管理的特性实现,因为要实现运行时类型识别的目标,因此在偶然中带有必然性,而必然种又孕育出偶然性因素
Java类型识别。
生活在现代世界的Java程序员时快乐的,Java程序员编写的任何类,Java虚拟机都能够在运行期识别出来,这实在是让人激动.那么Java虚拟机时如何做到这一点的呢?一切奥秘都隐藏在Java源程序被编译后生成的字节码文件中。Java类在编译期生成的字节码有其特定的组织规律,Java虚拟机在加载类时,对编译期生成的字节码信息按照固定的格式进行解析,一步一步解析出字节码中所存储的类型结构信息,从而在运行期完全还原出原始的Java类的全部结构。
生活在现代世界的Java程序员时快乐的,Java程序员编写的任何类,Java虚拟机都能够在运行期识别出来,这实在是让人激动.那么Java虚拟机时如何做到这一点的呢?一切奥秘都隐藏在Java源程序被编译后生成的字节码文件中。Java类在编译期生成的字节码有其特定的组织规律,Java虚拟机在加载类时,对编译期生成的字节码信息按照固定的格式进行解析,一步一步解析出字节码中所存储的类型结构信息,从而在运行期完全还原出原始的Java类的全部结构。
class字节码概述。
每一个Java类被编译后会生成一个对应的.class字节码文件,要想研究JVM加载Java类的原理,首先必须熟练掌握Java类被编译成的.class格式文件结构。下面将从几个方面来描述字节码的组成格式。
1.class文件构成基础
在class字节码文件中,数据都是以二进制流的形式存储。这些字节流之间都严格按照规定的顺序排列,字节之间不存在任何空隙,对于超过8位的数据,将按照Big-Endian(大端)的顺序存储,即高位字节存储在低的地址上面,而低位字节存储在高地址上面。其实这也是class文件跨平台的关键,因为PowerPC架构的处理器采用Big-Endian的存储顺序,而x86系列的处理器则采用Little-Endian(小端)的存储顺序,因此为了class文件在各种异构处理器架构下能够保持统一的存储顺序,虚拟机必须设置统一的存储规范。
2.class文件的10个组成结构
class字节码文件是采用类似C语言的结构体来存储数据,主要有两类数据项:无符号数和表。无符号数用来表述数字、索引引用以及字符串等,在.class文件中主要使用无符号数包括u1、u2、u4和u8,分别代表1字节、2字节、4字节和8字节的无符号数。而表是由多个无符号数以及其他的表组成的符合结构。
一个class字节码文件主要由以下10部分组成:
# MagicNumber
# Version
# Constant_pool
# Access_flag
# This_class
# Super_class
# Interfaces
# Fields
# Methods
# Attributes
这些数据的类型和长度都是不同的,用一个数据结构可以表示如图所示:
要注意的是,在JVM内部其实并没有定义这样的一种数据结构,这里使用类似C语言中的结构体方式来表示Java字节码文件的结构,完全是为了展示不同属性的数据类型和长度
每一个Java类被编译后会生成一个对应的.class字节码文件,要想研究JVM加载Java类的原理,首先必须熟练掌握Java类被编译成的.class格式文件结构。下面将从几个方面来描述字节码的组成格式。
1.class文件构成基础
在class字节码文件中,数据都是以二进制流的形式存储。这些字节流之间都严格按照规定的顺序排列,字节之间不存在任何空隙,对于超过8位的数据,将按照Big-Endian(大端)的顺序存储,即高位字节存储在低的地址上面,而低位字节存储在高地址上面。其实这也是class文件跨平台的关键,因为PowerPC架构的处理器采用Big-Endian的存储顺序,而x86系列的处理器则采用Little-Endian(小端)的存储顺序,因此为了class文件在各种异构处理器架构下能够保持统一的存储顺序,虚拟机必须设置统一的存储规范。
2.class文件的10个组成结构
class字节码文件是采用类似C语言的结构体来存储数据,主要有两类数据项:无符号数和表。无符号数用来表述数字、索引引用以及字符串等,在.class文件中主要使用无符号数包括u1、u2、u4和u8,分别代表1字节、2字节、4字节和8字节的无符号数。而表是由多个无符号数以及其他的表组成的符合结构。
一个class字节码文件主要由以下10部分组成:
# MagicNumber
# Version
# Constant_pool
# Access_flag
# This_class
# Super_class
# Interfaces
# Fields
# Methods
# Attributes
这些数据的类型和长度都是不同的,用一个数据结构可以表示如图所示:
要注意的是,在JVM内部其实并没有定义这样的一种数据结构,这里使用类似C语言中的结构体方式来表示Java字节码文件的结构,完全是为了展示不同属性的数据类型和长度
3.class文件中的各组成字段简单说明
# MagicNumber
MagicNumber是用来标志class文件的,位于每一个Java class文件的最前面4个字节,值固定位0xCAFEBABE。注意,这个是十六进制数值,,并非字符串"CAFEBABE",其对应的二进制数是11001010 11111110 10111010 10111110 B,一共占32位比特即4字节。虚拟机加载class文件时会先检查这4字节,如果不是0xCAFEBABE,则虚拟机拒绝加载该文件,这样就可以防止加载非class文件而造成虚拟机崩溃。
# Version
Version字段由2个长度都为2字节的字段组成,分别是Major Version和Minor Version,代表当前class文件的主版本号和次版本号。随着Java技术的不断发展,Java class会增加一些新的内容来支持Java语言的特性。同时,不同的虚拟机支持的Java class文件的版本范围是不同的,所以在加载class文件之前可以先看看该class文件是否在当前虚拟机的支持范围之内,避免加载不支持的class文件。高版本的JVM可以加载低版本的class,但反之就不行。
目前已发布的Version包括:1.1(45)、1.2(46)、1.3(47)、1.4(48)、1.5(49)、1.6(50)、1.7(51)、1.8(52).对于JDK1.6编译出来的class file,其版本号是0x00000032(查看class file的第5~8这4字节),转换位十进制数就是50.如果使用低版本的JDK编译Java程序,然后使用高版本的JRE执行class file能够顺利执行,反之,如果使用高版本的JDK编译Java程序,而使用低版本的JRE执行class file,则JVM会抛出类似于"java.lang.UnsupportedClassVersionError: Unsupported major.minor version 50"这样的异常。当出现这种异常信息时,首先查看JVM的版本,进入/bin/目录下执行java -version命令即可。其次是查看class的Version(即编译该Java类的JDK版本),查看方法是,使用二进制文件编辑器打开对应的class文件,根据其开头第4~8个字节的二进制值,换算出对应的十进制数,即可得到其版本号。
# 常量池(Constant_pool)
常量池信息从class文件的第9个字节开始。首先是2字节(即第9和第10两个字节)的长度字段constant_pool_count,表明常量池包含了多少个常量。接下来的二进制信息描述[constant_pool_count-1]个常量,常量池里放的是字面常量和符号引用。字面常量主要包含文本串以及被声明为final的常量。符号引用包含类和接口的全限定名、字段的名称和描述符、方法的名称和描述符,因为Java语言在编译的时候没有连接这一步,所有的引用都是运行时动态加载的,所以就需要把这些引用的信息保存在class文件里。字面常量根据具体的类型分成字符串、整型、长整型、浮点型、双精度浮点型这几种基本类型。符号引用保存的是引用的全局限定名,所以保存的字符串
# MagicNumber
MagicNumber是用来标志class文件的,位于每一个Java class文件的最前面4个字节,值固定位0xCAFEBABE。注意,这个是十六进制数值,,并非字符串"CAFEBABE",其对应的二进制数是11001010 11111110 10111010 10111110 B,一共占32位比特即4字节。虚拟机加载class文件时会先检查这4字节,如果不是0xCAFEBABE,则虚拟机拒绝加载该文件,这样就可以防止加载非class文件而造成虚拟机崩溃。
# Version
Version字段由2个长度都为2字节的字段组成,分别是Major Version和Minor Version,代表当前class文件的主版本号和次版本号。随着Java技术的不断发展,Java class会增加一些新的内容来支持Java语言的特性。同时,不同的虚拟机支持的Java class文件的版本范围是不同的,所以在加载class文件之前可以先看看该class文件是否在当前虚拟机的支持范围之内,避免加载不支持的class文件。高版本的JVM可以加载低版本的class,但反之就不行。
目前已发布的Version包括:1.1(45)、1.2(46)、1.3(47)、1.4(48)、1.5(49)、1.6(50)、1.7(51)、1.8(52).对于JDK1.6编译出来的class file,其版本号是0x00000032(查看class file的第5~8这4字节),转换位十进制数就是50.如果使用低版本的JDK编译Java程序,然后使用高版本的JRE执行class file能够顺利执行,反之,如果使用高版本的JDK编译Java程序,而使用低版本的JRE执行class file,则JVM会抛出类似于"java.lang.UnsupportedClassVersionError: Unsupported major.minor version 50"这样的异常。当出现这种异常信息时,首先查看JVM的版本,进入/bin/目录下执行java -version命令即可。其次是查看class的Version(即编译该Java类的JDK版本),查看方法是,使用二进制文件编辑器打开对应的class文件,根据其开头第4~8个字节的二进制值,换算出对应的十进制数,即可得到其版本号。
# 常量池(Constant_pool)
常量池信息从class文件的第9个字节开始。首先是2字节(即第9和第10两个字节)的长度字段constant_pool_count,表明常量池包含了多少个常量。接下来的二进制信息描述[constant_pool_count-1]个常量,常量池里放的是字面常量和符号引用。字面常量主要包含文本串以及被声明为final的常量。符号引用包含类和接口的全限定名、字段的名称和描述符、方法的名称和描述符,因为Java语言在编译的时候没有连接这一步,所有的引用都是运行时动态加载的,所以就需要把这些引用的信息保存在class文件里。字面常量根据具体的类型分成字符串、整型、长整型、浮点型、双精度浮点型这几种基本类型。符号引用保存的是引用的全局限定名,所以保存的字符串
# Access_flag
主要保存当前类的访问权限
# This_class
主要保存当前类的全局限定名在常量池里的索引
# Super_class
主要保存当前类的父类的全局限定名在常量池里的索引
#Interfaces
主要保存当前类实现的接口列表,包含两部分内容:interface_count和interfaces[interfaces_count]
## interfaces_count指的是当前类实现的接口数目
## interfaces[]是包含interfaces_count个接口的全局限定名的索引的数组
# Fields
主要保存当前类的成员列表,包含两部分的内容: fields_count和fields[fields_count].
## fields_count是类变量和实例变量的字段的数量综合
## fields[]是包含字段详细信息的列表
# Methods
主要保存当前类的方法列表,包含两部分的内容: methods_count和methods[methods_count]
## methods_count是该类或者接口显式定义的方法的数量
## method[] 是包含方法信息的一个详细列表
# Attributes
主要保存当前类attributes列表,包含两部分内容:attributes_count和attributes[attributes_count]
这些属性在字节码文件中的具体存储方式,下面会通过一个示例来讲解,毕竟概念还是挺抽象的
主要保存当前类的访问权限
# This_class
主要保存当前类的全局限定名在常量池里的索引
# Super_class
主要保存当前类的父类的全局限定名在常量池里的索引
#Interfaces
主要保存当前类实现的接口列表,包含两部分内容:interface_count和interfaces[interfaces_count]
## interfaces_count指的是当前类实现的接口数目
## interfaces[]是包含interfaces_count个接口的全局限定名的索引的数组
# Fields
主要保存当前类的成员列表,包含两部分的内容: fields_count和fields[fields_count].
## fields_count是类变量和实例变量的字段的数量综合
## fields[]是包含字段详细信息的列表
# Methods
主要保存当前类的方法列表,包含两部分的内容: methods_count和methods[methods_count]
## methods_count是该类或者接口显式定义的方法的数量
## method[] 是包含方法信息的一个详细列表
# Attributes
主要保存当前类attributes列表,包含两部分内容:attributes_count和attributes[attributes_count]
这些属性在字节码文件中的具体存储方式,下面会通过一个示例来讲解,毕竟概念还是挺抽象的
魔数与JVM内部的int类型。
由于魔数在字节码文件中占4字节,并且其数据值固定不变,一直都是0xCAFEBABE,因此JVM内部使用u4这种自定义的数据类型存放魔数。u4这种数据类型的定义如下:
// globalDefinitions.hpp
typedef juint u4;
juint也是自定义类型(注意,不是junit),但是这种类型的平台相关的,在Linux平台上,juint定义如下:
// globalDefinitions_gcc.hpp
typedef uint32_t juint;
uint32_t 仍然是自定义类型,定义如下:
// globalDefinitions_gcc.hpp
#ifndef _UINT32_T
#define _UINT32_T
typedef unsigned int uint32_t;
#endif // _UINT32_T
由此可知,uint32_t最终所代表的类型是unsigned int,在32位或64位系统平台上,unsigned int都占4字节,正好能够存放下魔数信息。所以,JVM内部的u4数据类型能够存放4字节,而JVM内部除了u4,还定义了另外3种常用的数据类型:
# u1,该数据类型占1字节,在Linux平台上所代表的C语言类型是unsigned char
# u2,该数据类型占2字节,在Linux平台上所代表的C语言类型是unsigned short
# u8, 该数据类型占8字节,在Linux平台上所代表的C语言类型是unsigned long
这里额外对uint32_t之类的数据类型做一个补充说明,按照POSIX标准,一般整型对应的*_t类型为:
# 1字节 ,uint8_t
# 2字节,uint16_t
# 4字节,uint32_t
# 8字节,uint64_t
这4种基本数据类型在遵循C99标准的C语言种进行了内置定义,因此开发者可以直接使用。这些数据类型都有一个特点,那就是以_t结尾,这其实表示这些数据类型并不是什么新的类型,它们只是使用typedef为类型起的别名而已。原理虽然很简单,但是作用却很大,比如C种没有bool,于是在一个软件种,一些程序员使用int,一些程序员用short,会比较混乱,最好就是用一个typedef来定义,如:
typedef char bool;
uint8_t之类的意义也正是如此,是为了让代码能够被更好地维护,并且大家都使用同一套标准。
由于魔数在字节码文件中占4字节,并且其数据值固定不变,一直都是0xCAFEBABE,因此JVM内部使用u4这种自定义的数据类型存放魔数。u4这种数据类型的定义如下:
// globalDefinitions.hpp
typedef juint u4;
juint也是自定义类型(注意,不是junit),但是这种类型的平台相关的,在Linux平台上,juint定义如下:
// globalDefinitions_gcc.hpp
typedef uint32_t juint;
uint32_t 仍然是自定义类型,定义如下:
// globalDefinitions_gcc.hpp
#ifndef _UINT32_T
#define _UINT32_T
typedef unsigned int uint32_t;
#endif // _UINT32_T
由此可知,uint32_t最终所代表的类型是unsigned int,在32位或64位系统平台上,unsigned int都占4字节,正好能够存放下魔数信息。所以,JVM内部的u4数据类型能够存放4字节,而JVM内部除了u4,还定义了另外3种常用的数据类型:
# u1,该数据类型占1字节,在Linux平台上所代表的C语言类型是unsigned char
# u2,该数据类型占2字节,在Linux平台上所代表的C语言类型是unsigned short
# u8, 该数据类型占8字节,在Linux平台上所代表的C语言类型是unsigned long
这里额外对uint32_t之类的数据类型做一个补充说明,按照POSIX标准,一般整型对应的*_t类型为:
# 1字节 ,uint8_t
# 2字节,uint16_t
# 4字节,uint32_t
# 8字节,uint64_t
这4种基本数据类型在遵循C99标准的C语言种进行了内置定义,因此开发者可以直接使用。这些数据类型都有一个特点,那就是以_t结尾,这其实表示这些数据类型并不是什么新的类型,它们只是使用typedef为类型起的别名而已。原理虽然很简单,但是作用却很大,比如C种没有bool,于是在一个软件种,一些程序员使用int,一些程序员用short,会比较混乱,最好就是用一个typedef来定义,如:
typedef char bool;
uint8_t之类的意义也正是如此,是为了让代码能够被更好地维护,并且大家都使用同一套标准。
常量池与JVM内部对象模型。
常量池是Java字节码文件种比较重要的概念,是整个Java类的核心所在,因为常量池中记录了一个Java类的所有成员变量、成员方法和静态变量与静态方法、构造函数等全部信息,包括变量名、方法名、访问标识、类型信息等。
JVM内部定义了一个C++类型constantPoolOop来记录解析后的常量池信息(这里默认HotSpot1.6版本的源码作为研究对象)。constantPoolOop其实是别名,其原始的类型是constantPoolOopDesc.在oopsHierachy.hpp中进行了这两种类型
typedef class constantPoolOopDesc* cosntantPoolOop;
而事实上,JVM内部为了在运行期描述Java类的类型信息和内部结构,定义了很多以Desc结尾的oop类,详细的定义见oopsHierarchy.hpp文件。这个源码中所定义的类,便用于实现Java的面向对象特性,很重要。既然提到了oop的概念,就不得不提JVM内部对Java对象的表示模型,这个模型便是著名的"oop-klass"模型。
常量池是Java字节码文件种比较重要的概念,是整个Java类的核心所在,因为常量池中记录了一个Java类的所有成员变量、成员方法和静态变量与静态方法、构造函数等全部信息,包括变量名、方法名、访问标识、类型信息等。
JVM内部定义了一个C++类型constantPoolOop来记录解析后的常量池信息(这里默认HotSpot1.6版本的源码作为研究对象)。constantPoolOop其实是别名,其原始的类型是constantPoolOopDesc.在oopsHierachy.hpp中进行了这两种类型
typedef class constantPoolOopDesc* cosntantPoolOop;
而事实上,JVM内部为了在运行期描述Java类的类型信息和内部结构,定义了很多以Desc结尾的oop类,详细的定义见oopsHierarchy.hpp文件。这个源码中所定义的类,便用于实现Java的面向对象特性,很重要。既然提到了oop的概念,就不得不提JVM内部对Java对象的表示模型,这个模型便是著名的"oop-klass"模型。
1.oop-klass模型。
HotSpot虚拟机在内部使用两组类来表示Java的类和对象。
# oop(ordinary object pointer),用来描述对象实例信息
# klass,用来描述Java类,是虚拟机内部Java类型结构的对等体
JVM内部定义了各种oop-klass,在JVM看来,不仅Java类是对象,Java方法也是对象,字节码常量池也是对象,一切皆是对象。JVM使用不同的oop-klass模型来表示各种不同的对象,而在技术落地时,这些不同的模型就使用不同的oop类和klass类来表示。由于JVM使用C/C++编写,因此这些oop和klass类便是各种不同的C++类。对于Java类型与实例对象,JVM使用instanceOop和instanceKlass这两个C++类来表示,这2个类后面会逐步分析到。
typedef class oopDesc*oop;
typedef class instanceOopDesc*instanceOop;
typedef class methodOopDesc*methodOop;
typedef class constMethodOopDesc*constMethodOop;
typedef class methodDataOopDesc*methodDataOop;
typedef class arrayOopDesc*arrayOop;
typedef class objArrayOopDesc*objArrayOop;
typedef class typeArrayOopDesc*typeArrayOop;
typedef class constantPoolOopDesc*constangtPoolOop;
typedef class constantPoolCacheOopDesc*cosntantPoolCacheOop;
typedef class klassOopDesc* klassOop;
typedef class markOopDesc*markOop;
typedef class compiledICHolderOopDEsc*compiledICHolderOop;
也许时为了简化变量名,JVM统一将最后的Desc去掉,全部处理成以Oop结尾的类型名。例如对于Java类中所定义的方法,JVM使用methodOop去描述Java方法的全部信息;对于Java类中所定义的引用对象变量,JVM则使用objArrayOop来保存这个引用变量的"全息"信息。
HotSpot虚拟机在内部使用两组类来表示Java的类和对象。
# oop(ordinary object pointer),用来描述对象实例信息
# klass,用来描述Java类,是虚拟机内部Java类型结构的对等体
JVM内部定义了各种oop-klass,在JVM看来,不仅Java类是对象,Java方法也是对象,字节码常量池也是对象,一切皆是对象。JVM使用不同的oop-klass模型来表示各种不同的对象,而在技术落地时,这些不同的模型就使用不同的oop类和klass类来表示。由于JVM使用C/C++编写,因此这些oop和klass类便是各种不同的C++类。对于Java类型与实例对象,JVM使用instanceOop和instanceKlass这两个C++类来表示,这2个类后面会逐步分析到。
typedef class oopDesc*oop;
typedef class instanceOopDesc*instanceOop;
typedef class methodOopDesc*methodOop;
typedef class constMethodOopDesc*constMethodOop;
typedef class methodDataOopDesc*methodDataOop;
typedef class arrayOopDesc*arrayOop;
typedef class objArrayOopDesc*objArrayOop;
typedef class typeArrayOopDesc*typeArrayOop;
typedef class constantPoolOopDesc*constangtPoolOop;
typedef class constantPoolCacheOopDesc*cosntantPoolCacheOop;
typedef class klassOopDesc* klassOop;
typedef class markOopDesc*markOop;
typedef class compiledICHolderOopDEsc*compiledICHolderOop;
也许时为了简化变量名,JVM统一将最后的Desc去掉,全部处理成以Oop结尾的类型名。例如对于Java类中所定义的方法,JVM使用methodOop去描述Java方法的全部信息;对于Java类中所定义的引用对象变量,JVM则使用objArrayOop来保存这个引用变量的"全息"信息。
HotSpot使用klass来描述Java的类型信息。HotSpot定义了如下几种类型信息:
class Klass;
class instanceKlass;
class instanceMirrorKlass;
class instanceRefKlass;
class methodKlass;
class constMethodKlass;
class methodDataKlass;
class klassKlass;
class instanceKlassKlass;
class arrayKlassKlass;
class objArrayKlassKlass;
class typeArrayKlassKlass;
class arrayKlass;
class objArrayKlass;
class typeArrayKlass;
class constantPoolKlass;
class constantPoolCacheKlass;
class compiledICHolderKlass;
总管以上oop和klass体系的定义,可以发现,无论是Oop还是klass,基本都被划分为来分别描述instance、method、constantMethod、methodData、array、objArray、typeArray、constantPool、constantPoolCache、klass、compiledICHolder,这几种模型中的每一种都由一个对应的xxxOopDesc和对应的xxxKlass.通俗而言,这几种模型,便足以勾画Java程序的全部:数据、方法、类型、数组和实例。
class Klass;
class instanceKlass;
class instanceMirrorKlass;
class instanceRefKlass;
class methodKlass;
class constMethodKlass;
class methodDataKlass;
class klassKlass;
class instanceKlassKlass;
class arrayKlassKlass;
class objArrayKlassKlass;
class typeArrayKlassKlass;
class arrayKlass;
class objArrayKlass;
class typeArrayKlass;
class constantPoolKlass;
class constantPoolCacheKlass;
class compiledICHolderKlass;
总管以上oop和klass体系的定义,可以发现,无论是Oop还是klass,基本都被划分为来分别描述instance、method、constantMethod、methodData、array、objArray、typeArray、constantPool、constantPoolCache、klass、compiledICHolder,这几种模型中的每一种都由一个对应的xxxOopDesc和对应的xxxKlass.通俗而言,这几种模型,便足以勾画Java程序的全部:数据、方法、类型、数组和实例。
那么oop到底是啥,其存在的意义究竟是什么?其名称已经说得很清楚,就是普通对象指针。指针指向哪里?指向klass类实例,直接这么说可能比较难以理解,举个例子,若Java程序中定义了一个类ClassA,同时程序中有如下代码:
ClassA a = new ClassA();
当HotSpot执行到这里时,会先将ClassA这个类型加载到perm区(也叫方法区),然后在HotSpot对中为其实例对象a开辟一块内存空间,存放实例数据。在JVM加载ClassA到perm区时,JVM就会创建一个instanceKlass,instanceKlass中保存了ClassA这个Java类中所定义得一切信息,包括变量、方法、父类、接口、构造函数、属性等,所以instanceKlass就是ClassA这个Java类类型结构的对等体。而instanceOop这个"普通对象指针"对象中包含了一个指针,该指针就指向klassInstance这个实例。在JVM实例化ClassA时,JVM又会创建一个instanceOop,instanceOop便是ClassA对象实例a在内存中的对等体,主要存储ClassA实例对象的成员变量。其中instanceOop中有一个指针指向instanceKlass,通过这个指针,JVM便可以在运行期获取这个类实例对线的类元信息。
ClassA a = new ClassA();
当HotSpot执行到这里时,会先将ClassA这个类型加载到perm区(也叫方法区),然后在HotSpot对中为其实例对象a开辟一块内存空间,存放实例数据。在JVM加载ClassA到perm区时,JVM就会创建一个instanceKlass,instanceKlass中保存了ClassA这个Java类中所定义得一切信息,包括变量、方法、父类、接口、构造函数、属性等,所以instanceKlass就是ClassA这个Java类类型结构的对等体。而instanceOop这个"普通对象指针"对象中包含了一个指针,该指针就指向klassInstance这个实例。在JVM实例化ClassA时,JVM又会创建一个instanceOop,instanceOop便是ClassA对象实例a在内存中的对等体,主要存储ClassA实例对象的成员变量。其中instanceOop中有一个指针指向instanceKlass,通过这个指针,JVM便可以在运行期获取这个类实例对线的类元信息。
2.oopDesc
既然讲到了oop,就不得不提JVM中所有oop对象的老祖宗——oopDesc类。上述列表的所有oopDesc,诸如instanceOopDesc、constantPoolOopDesc、klassOopDesc等,在C++的继承体系中,最终全都来自顶级的父类——oopDesc(JDK8中已经没有oopDesc,换成了别的名字,换成了别的名字,但是换汤不换药,内部结构并没有什么太大的变化)。Java的面向对象和运行期反射的能力,便是由oopDesc予以体现和支撑,但是看看其结构,会发现简单得似乎与其所具备的能力有点不相称:
抛开友元类VMStructs,以及用于内存屏障的_bs,oopDesc类中只剩下了2个成员变量(友元类不算成员变量):_mark和_metadata.其中_metadata是联合结构体,里面包含两个元素,分别是Klass与narrowOop,前者是款指针,后者是压缩指针。宽指针和窄指针主要用于JVM是否对Java class进行压缩,如果使用了压缩技术,自然可以节省出一定的宝贵内存空间。
oopDesc的这2个成员变量的作用很简单,_mark顾名思义,似乎是一种标记,而事实上也的确如此,Java类在整个生命周期中,会涉及到线程状态、并发所、GC分代信息等内部标识,这些标识全都打在_mark变量上。而_metadata顾名思义也很简单,用于标识元数据。每一个Java类都会包含一定的变量、方法、父类、所实现的接口等信息,这些均可称为Java类的"元数据",其实可以更加通俗点,所谓的元数据就是在前面反复讲的数据结构。Java类的结构信息在编译期被编译为字节码格式,JVM则在运行期进一步解析字节码格式,从字节码二进制流中还原出一个Java在源码期间所定义的全部数据结构信息,JVM需要将解析出来的结果保存到内存中,以便在运行期进行各种操作,例如反射,而_metadata便起到指针的作用,指向Java类的数据结构被解析后所保存的内存位置。
既然讲到了oop,就不得不提JVM中所有oop对象的老祖宗——oopDesc类。上述列表的所有oopDesc,诸如instanceOopDesc、constantPoolOopDesc、klassOopDesc等,在C++的继承体系中,最终全都来自顶级的父类——oopDesc(JDK8中已经没有oopDesc,换成了别的名字,换成了别的名字,但是换汤不换药,内部结构并没有什么太大的变化)。Java的面向对象和运行期反射的能力,便是由oopDesc予以体现和支撑,但是看看其结构,会发现简单得似乎与其所具备的能力有点不相称:
抛开友元类VMStructs,以及用于内存屏障的_bs,oopDesc类中只剩下了2个成员变量(友元类不算成员变量):_mark和_metadata.其中_metadata是联合结构体,里面包含两个元素,分别是Klass与narrowOop,前者是款指针,后者是压缩指针。宽指针和窄指针主要用于JVM是否对Java class进行压缩,如果使用了压缩技术,自然可以节省出一定的宝贵内存空间。
oopDesc的这2个成员变量的作用很简单,_mark顾名思义,似乎是一种标记,而事实上也的确如此,Java类在整个生命周期中,会涉及到线程状态、并发所、GC分代信息等内部标识,这些标识全都打在_mark变量上。而_metadata顾名思义也很简单,用于标识元数据。每一个Java类都会包含一定的变量、方法、父类、所实现的接口等信息,这些均可称为Java类的"元数据",其实可以更加通俗点,所谓的元数据就是在前面反复讲的数据结构。Java类的结构信息在编译期被编译为字节码格式,JVM则在运行期进一步解析字节码格式,从字节码二进制流中还原出一个Java在源码期间所定义的全部数据结构信息,JVM需要将解析出来的结果保存到内存中,以便在运行期进行各种操作,例如反射,而_metadata便起到指针的作用,指向Java类的数据结构被解析后所保存的内存位置。
仍然以前面所举的实例化ClassA这个自定义Java类的例子进行说明。当JVM完成ClassA类型的实例化之后,会为该Java类创建对应的oop-klass模型,oop对应的类是instanceOop,klass对应的类是instanceKlass.instanceOop内部会有一个指针指向instanceKlass,其实这个指针便是oopDesc中所定义的_metadata.klass是Java类型的对等体,而Java类型,便是Java编程语言中用于描述客观事物的数据结构,而数据结构包含一个客观事物的全部属性和行为,所以叫做"类元"信息,这便是_metadata的本意。_metadata的作用如图所示。
JVM内部一切都是对象,而描述这些对象的共同祖先就是oopDesc,但是oopDesc的结构却异常简单。让我们回到常量池对象。常量池这种对象虽然并不是由程序员在源代码中定义的,但是在Java类被编译后,其成为编译后的字节码中最核心的对象,对于这种重量级的"人物",JVM当然也有对应的类型区描述它。JVM使用constantPoolOop这种类型来保存常量池的信息,但是constantPoolOop内部其实还是借助于typeArrayOop这种类型才可以对常量池进行描述。constantPoolOop的结构定义在constantPoolOop.hpp中
JVM内部一切都是对象,而描述这些对象的共同祖先就是oopDesc,但是oopDesc的结构却异常简单。让我们回到常量池对象。常量池这种对象虽然并不是由程序员在源代码中定义的,但是在Java类被编译后,其成为编译后的字节码中最核心的对象,对于这种重量级的"人物",JVM当然也有对应的类型区描述它。JVM使用constantPoolOop这种类型来保存常量池的信息,但是constantPoolOop内部其实还是借助于typeArrayOop这种类型才可以对常量池进行描述。constantPoolOop的结构定义在constantPoolOop.hpp中
3.Java结构与其他编程语言结构的比较。
当C、C++和Delphi等编程语言被编译成二进制程序后,原来所定义的高级数据结构都不复存在了,当Windows/Linux等操作系统(宿主机)加载这些二进制程序时,是不会加载这些语言中所定义的高级数据结构的,宿主机压根儿不就不知道原来定义了哪些数据结构、哪些类,所有的数据结构都被转换为对特定内存段的偏移地址。例如C中的结构体,被编译后不复存在,汇编和机器语言中没有与之对应的数据结构的概念,CPU更不知道何为结构体。C++和Delphi中的类概念被编译后也不复存在,所谓的类最终编程内存首地址。而JVM虚拟机在加载字节码程序时,会记录字节码中所定义的所有类型的原始信息(元数据),JVM知道程序中包含了哪些类,以及每个类中所关联的字段、方法、父类等信息。这是JVM虚拟机与操作系统最大的区别所在。
正式因为JVM需要保存字节码中的类元信息,所以JVM最终就自然而然地演化出了OOP-KLASS这种二分模型,KLASS用于保存类元信息,而OOP用于表示JVM所创建的类实例对象。KLASS信息被保存在PERM永久区,而OOP则被分配在HEAP堆区。同时JVM为了支持反射等技术,必须在OOP中保存一个指针,用于指向其所属的类型KLASS,这样Java开发者便能够基于反射技术,在Java程序运行期获取Java的类型信息,例如反射Java类的类型、父类、字段、方法等信息,而这是其他很多语言所不具备的能力。也正因为JVM的这种能力,让程序员能够非常方便地开发出具有运行时动态特性的程序,例如可以根据类型来涉及更为抽象和优雅的工厂模式,例如可以在运行期动态创建一个新的类型并实例化,同时执行其方法(ASM字节码编程技术).由于这些特性,使得Java语言成为了互联网时代的各种中间件、各种框架实现的首选语言,进而有力地促进了大数据时代地架构发展。虽然Java由于天然地缺陷,在运行性能、内存占用等方面无法与本地语言相媲美,但是在互联网工业时代,生产效率是第一生产力,对于一门编程语言,没有什么能够比易学、易用又能够迅速开发出一款不错地工业产品更具有吸引力。尤其在移动互联网时代,尤其在移动互联网时代,整个人类社会要的是能够在短时间内生产出足够多样化的,能够满足各类日常需求的软件产品,而随着硬件越来越廉价,JVM在性能和空间两方面的缺点得以弥补。可以说,JVM的成功不是偶然的,它是整个人类社会发展的必然结果,是IT行业自我革命、自我快速发展的必然结果,JVM是人类对IT生产力提升的迫切需求与软件编程门高这一对矛盾相互促进而演化出的栈折中方案,虽然JVM不够完美,但已然足够。
当C、C++和Delphi等编程语言被编译成二进制程序后,原来所定义的高级数据结构都不复存在了,当Windows/Linux等操作系统(宿主机)加载这些二进制程序时,是不会加载这些语言中所定义的高级数据结构的,宿主机压根儿不就不知道原来定义了哪些数据结构、哪些类,所有的数据结构都被转换为对特定内存段的偏移地址。例如C中的结构体,被编译后不复存在,汇编和机器语言中没有与之对应的数据结构的概念,CPU更不知道何为结构体。C++和Delphi中的类概念被编译后也不复存在,所谓的类最终编程内存首地址。而JVM虚拟机在加载字节码程序时,会记录字节码中所定义的所有类型的原始信息(元数据),JVM知道程序中包含了哪些类,以及每个类中所关联的字段、方法、父类等信息。这是JVM虚拟机与操作系统最大的区别所在。
正式因为JVM需要保存字节码中的类元信息,所以JVM最终就自然而然地演化出了OOP-KLASS这种二分模型,KLASS用于保存类元信息,而OOP用于表示JVM所创建的类实例对象。KLASS信息被保存在PERM永久区,而OOP则被分配在HEAP堆区。同时JVM为了支持反射等技术,必须在OOP中保存一个指针,用于指向其所属的类型KLASS,这样Java开发者便能够基于反射技术,在Java程序运行期获取Java的类型信息,例如反射Java类的类型、父类、字段、方法等信息,而这是其他很多语言所不具备的能力。也正因为JVM的这种能力,让程序员能够非常方便地开发出具有运行时动态特性的程序,例如可以根据类型来涉及更为抽象和优雅的工厂模式,例如可以在运行期动态创建一个新的类型并实例化,同时执行其方法(ASM字节码编程技术).由于这些特性,使得Java语言成为了互联网时代的各种中间件、各种框架实现的首选语言,进而有力地促进了大数据时代地架构发展。虽然Java由于天然地缺陷,在运行性能、内存占用等方面无法与本地语言相媲美,但是在互联网工业时代,生产效率是第一生产力,对于一门编程语言,没有什么能够比易学、易用又能够迅速开发出一款不错地工业产品更具有吸引力。尤其在移动互联网时代,尤其在移动互联网时代,整个人类社会要的是能够在短时间内生产出足够多样化的,能够满足各类日常需求的软件产品,而随着硬件越来越廉价,JVM在性能和空间两方面的缺点得以弥补。可以说,JVM的成功不是偶然的,它是整个人类社会发展的必然结果,是IT行业自我革命、自我快速发展的必然结果,JVM是人类对IT生产力提升的迫切需求与软件编程门高这一对矛盾相互促进而演化出的栈折中方案,虽然JVM不够完美,但已然足够。
大端与小端。
Java是跨平台的语言,并且支持丰富多样的数据类型。但是在不同的平台上,数据在寄存器、内存、磁盘上的存储格式并不相同——准确地说,是数据的顺序不同。这种不同的存储顺序衍生了计算机底层的2个概念——大端与小端。
以JVM解析Java class字节码文件中的魔数为例进行分析。魔数占4字节,并且位于字节码头部。JVM解析魔数,只需读取出字节码流开始的4字节即可。JVM读取魔数的代码如上:
// classFileParser.cpp
u4 magic = cfs->get_u4_fast();
cfs的类型是ClassFileStream,当JVM能够运行到classFileParser.cpp:: parseClassFile()时,JVM便已读入当前正在解析的Java字节码的文件流。
ClassFileStream的结构如代码所示:
这几个字段的含义都相当清晰明了。其中_current指针字段值指向Java字节码流中当前已经读取到的位置,当ClassFileStream类刚刚初始化时,_current指针指向Java字节码流的第1个字节所在的内存位置,再JVM解析字节码的后续流程中,随着解析的进行,该指针不断向前移动。由于魔数被第一个解析,因此解析之后,_current指针往前移动4字节步长,我们来看cfs->get_u4_fast()的逻辑,如代码所示。
注意,在这个方法中,由于从字节码流中读取4字节的内容,因此_current的值被增加了4,由于_current是u1*类型的指针,该指针所指向的数据类型是一个字节,因此将_current的值增加4,其实就是增加了4字节的长度.
在get_u4_fast()这个方法中,最关键的就是u4 res = Bytes::get_Java_u4(_current)这句代码,这句代码才真正执行了从字节码流中读取4字节的任务,该方法时一个CPU架构相关的接口,在x86架构上的逻辑如代码所示.
可以看到,在get_Java_u4()方法内部连续调用了2个函数,分别是get_native_u4()和swap_u4()。先看get_native_u4()方法,该方法定义如代码所示
get_native_u4()这个方法内部直接返回*(u4*)p, 按照取值运算符的运算优先级,这句表达式的运算顺序为*((u4*)p),即先将指针p转换为u4*类型的指针,接着对转换后的指针进行取值运算,同时将运算后的结果转换为u4类型。
在这里有一个很关键的问题需要注意,由于Java字节码流通常比较大,随便一个简单的Java类都会包含几百字节,稍微复杂点就需要以KB来计了,而当JVM刚开始解析魔数时,此时P指针指向字节码流的最开始的内存位置,后面还有几百字节,因此使用一个指向首地址的指针可以循环读取后面的几百字节。而每次根据一个指针所能读取到的字节数则取决于指针的类型,如果指针是char*,则通过*p可以读取1字节的内容;如果指针是int*,则通过*p可以读取4字节的内容。在JVM解析魔数时,原本入参是cfs._current变量,其类型是u1*,但是在get_native_u2()方法里被强制转换为了u4*,这相当于将指针的范围扩大,这样通过*p能够一次读取4字节内容。
而事实上,JVM内部自定义好几种基本类型,包括u1、u2、u4、u8,它们所代表的字节位数分别是1、2、4、8.每次JVM从Java字节码流中读取相应位数的字节时,只需将cfs._current指针转换为对应的u1*、u2*、u4*、u8*类型即可。但是早期的CPU,并不是随随便便就能从指定的内存位置访问任意字节长度的数据,早期的CPU是严格按照字节数对齐的要求读取的,只是从x86开始便能支持了。
从Java字节码文件中解析出魔数信息很简单,但是一旦将一个简单的问题放到一个底层的、跨平台的上下文环境中时,问题便立刻变得复杂。魔数的解析便是一个活生生的例子,其复杂性全部体现在最关键的swap_u4()函数中。该数据是跨平台的,因此需要解决兼容性,对于x86平台,该函数定义如代码所示。
这是一段内嵌了汇编的C函数,该函数的作用是进行大端和小端的转换。对于平常以应用开发为主的C和C++程序员,以及以使用Java语言为主的开发者们,可能并不了解大端和小端的概念,而在网络协议、跨平台兼容性等开发领域,则大端和小端是必须要掌握的一项基本功,
Java是跨平台的语言,并且支持丰富多样的数据类型。但是在不同的平台上,数据在寄存器、内存、磁盘上的存储格式并不相同——准确地说,是数据的顺序不同。这种不同的存储顺序衍生了计算机底层的2个概念——大端与小端。
以JVM解析Java class字节码文件中的魔数为例进行分析。魔数占4字节,并且位于字节码头部。JVM解析魔数,只需读取出字节码流开始的4字节即可。JVM读取魔数的代码如上:
// classFileParser.cpp
u4 magic = cfs->get_u4_fast();
cfs的类型是ClassFileStream,当JVM能够运行到classFileParser.cpp:: parseClassFile()时,JVM便已读入当前正在解析的Java字节码的文件流。
ClassFileStream的结构如代码所示:
这几个字段的含义都相当清晰明了。其中_current指针字段值指向Java字节码流中当前已经读取到的位置,当ClassFileStream类刚刚初始化时,_current指针指向Java字节码流的第1个字节所在的内存位置,再JVM解析字节码的后续流程中,随着解析的进行,该指针不断向前移动。由于魔数被第一个解析,因此解析之后,_current指针往前移动4字节步长,我们来看cfs->get_u4_fast()的逻辑,如代码所示。
注意,在这个方法中,由于从字节码流中读取4字节的内容,因此_current的值被增加了4,由于_current是u1*类型的指针,该指针所指向的数据类型是一个字节,因此将_current的值增加4,其实就是增加了4字节的长度.
在get_u4_fast()这个方法中,最关键的就是u4 res = Bytes::get_Java_u4(_current)这句代码,这句代码才真正执行了从字节码流中读取4字节的任务,该方法时一个CPU架构相关的接口,在x86架构上的逻辑如代码所示.
可以看到,在get_Java_u4()方法内部连续调用了2个函数,分别是get_native_u4()和swap_u4()。先看get_native_u4()方法,该方法定义如代码所示
get_native_u4()这个方法内部直接返回*(u4*)p, 按照取值运算符的运算优先级,这句表达式的运算顺序为*((u4*)p),即先将指针p转换为u4*类型的指针,接着对转换后的指针进行取值运算,同时将运算后的结果转换为u4类型。
在这里有一个很关键的问题需要注意,由于Java字节码流通常比较大,随便一个简单的Java类都会包含几百字节,稍微复杂点就需要以KB来计了,而当JVM刚开始解析魔数时,此时P指针指向字节码流的最开始的内存位置,后面还有几百字节,因此使用一个指向首地址的指针可以循环读取后面的几百字节。而每次根据一个指针所能读取到的字节数则取决于指针的类型,如果指针是char*,则通过*p可以读取1字节的内容;如果指针是int*,则通过*p可以读取4字节的内容。在JVM解析魔数时,原本入参是cfs._current变量,其类型是u1*,但是在get_native_u2()方法里被强制转换为了u4*,这相当于将指针的范围扩大,这样通过*p能够一次读取4字节内容。
而事实上,JVM内部自定义好几种基本类型,包括u1、u2、u4、u8,它们所代表的字节位数分别是1、2、4、8.每次JVM从Java字节码流中读取相应位数的字节时,只需将cfs._current指针转换为对应的u1*、u2*、u4*、u8*类型即可。但是早期的CPU,并不是随随便便就能从指定的内存位置访问任意字节长度的数据,早期的CPU是严格按照字节数对齐的要求读取的,只是从x86开始便能支持了。
从Java字节码文件中解析出魔数信息很简单,但是一旦将一个简单的问题放到一个底层的、跨平台的上下文环境中时,问题便立刻变得复杂。魔数的解析便是一个活生生的例子,其复杂性全部体现在最关键的swap_u4()函数中。该数据是跨平台的,因此需要解决兼容性,对于x86平台,该函数定义如代码所示。
这是一段内嵌了汇编的C函数,该函数的作用是进行大端和小端的转换。对于平常以应用开发为主的C和C++程序员,以及以使用Java语言为主的开发者们,可能并不了解大端和小端的概念,而在网络协议、跨平台兼容性等开发领域,则大端和小端是必须要掌握的一项基本功,
大端和小端的概念。
大端和小端的概念之所由一位网络协议开创者提出,是因为其实大部分人在实际的开发中都很少会直接和字节打交道,唯有在跨平台以及网络程序中,才会涉及到一个叫做"字节序"的问题,并且这是一个被考虑的基础性问题。字节序,顾名思义,就是指字节的顺序,通俗而言就是数值大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,通信双方交流的信息单元(比特、字节、字、双字等)的存储顺序不同,因此需要考虑双方数据的传送顺序。如果传送顺序达不成一致,通信双方将无法进行正确的编/解码从而导致通信失败。目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian和Little-Endian,翻译过来就是所谓的大端和小端.
标准的Big-Endian和Litter-Endian的定义如下:
# Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端
# Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端
# 网络字节序, TCP/IP协议中使用的字节序通常成为网络字节序,TCP/IP各层协议将字节序定义为Big-Endian.
如果之前没有接触过大端和小端,可以举个例子,以建立起对大端和小端概念的初步认识。例如一个值为0x01020304的int整型数据写入地址为0x005071的内存位置,由于该整数占4字节,因此需要连续使用4个存储单元来存储这个整数,但是存储的方式可以有两种,其中第一种策略是整数的高位字节存储在这4个连续存储单元的高位存储单元,整数的低位字节存储在低位存储单元。先解释下什么是整数的高低位,例如0x01020304,其对应的十进制是16909060,对于这个十进制数而言,左边的数字是高位,右边的数字是低位,同样,对于十六进制0x01020304而言,也是左侧的代表高位,右侧代表低位。
按照第一种方式来存储,内存布局如图所示。可以看到,在这种存储策略下,数据增长的方向与内存地址增长(栈是朝着地址减小的方向增长的,先使用高地址)的方向是相同的。这种方式不方便人类阅读,但是机器读起来却着实很"爽",因为这样的存储顺序与机器的解读顺序是一致的。这种存储策略就叫做"小端".
对应的,第二种存储策略与之相反,数据的高位字节存储在低位存储单元上,数据的低位字节存储单元上,数据的低位字节存储在高位存储单元上。还是刚才的数据和内存位置,按照现在这种策略存储后的内存如图所示。
现在这种方式,数据字节增长的防线与内存位置增长的方向是相反的,这种方式有点类似于字符串的存储顺序,并且也很符合人类的阅读。这种策略就叫做"大端"。
大端和小端的概念之所由一位网络协议开创者提出,是因为其实大部分人在实际的开发中都很少会直接和字节打交道,唯有在跨平台以及网络程序中,才会涉及到一个叫做"字节序"的问题,并且这是一个被考虑的基础性问题。字节序,顾名思义,就是指字节的顺序,通俗而言就是数值大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,通信双方交流的信息单元(比特、字节、字、双字等)的存储顺序不同,因此需要考虑双方数据的传送顺序。如果传送顺序达不成一致,通信双方将无法进行正确的编/解码从而导致通信失败。目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian和Little-Endian,翻译过来就是所谓的大端和小端.
标准的Big-Endian和Litter-Endian的定义如下:
# Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端
# Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端
# 网络字节序, TCP/IP协议中使用的字节序通常成为网络字节序,TCP/IP各层协议将字节序定义为Big-Endian.
如果之前没有接触过大端和小端,可以举个例子,以建立起对大端和小端概念的初步认识。例如一个值为0x01020304的int整型数据写入地址为0x005071的内存位置,由于该整数占4字节,因此需要连续使用4个存储单元来存储这个整数,但是存储的方式可以有两种,其中第一种策略是整数的高位字节存储在这4个连续存储单元的高位存储单元,整数的低位字节存储在低位存储单元。先解释下什么是整数的高低位,例如0x01020304,其对应的十进制是16909060,对于这个十进制数而言,左边的数字是高位,右边的数字是低位,同样,对于十六进制0x01020304而言,也是左侧的代表高位,右侧代表低位。
按照第一种方式来存储,内存布局如图所示。可以看到,在这种存储策略下,数据增长的方向与内存地址增长(栈是朝着地址减小的方向增长的,先使用高地址)的方向是相同的。这种方式不方便人类阅读,但是机器读起来却着实很"爽",因为这样的存储顺序与机器的解读顺序是一致的。这种存储策略就叫做"小端".
对应的,第二种存储策略与之相反,数据的高位字节存储在低位存储单元上,数据的低位字节存储单元上,数据的低位字节存储在高位存储单元上。还是刚才的数据和内存位置,按照现在这种策略存储后的内存如图所示。
现在这种方式,数据字节增长的防线与内存位置增长的方向是相反的,这种方式有点类似于字符串的存储顺序,并且也很符合人类的阅读。这种策略就叫做"大端"。
验证大小端的代码
大小端产生的根本原因。
有时候计算机也是挺幽默的,也会玩"时光向左,幸福向右"之类浪漫的事。但是计算机实在不是吃饱了撑的玩这种闲情雅致,内存存储顺序的向左向右,实则是由寄存器引起的。在计算机体系结构中,内存由存储单元构成,一个存储单元的长度是一个字节,即每个存储单元都对应着一个字节,能够存储8比特数据。但是在C语言和很多其他高级语言中,除了8比特的char之外,还有16比特的short型、32比特的int型与64比特的long型(int和long具体所占二进制位数要看具体的编译期和CPU平台架构)。虽然物理内存的存储单位是1字节,但是现代计算机总线线宽和寄存器的宽度往往都大于1个字节,对于位数大于8位的处理器,例如16位或者32位的处理器,寄存器的宽度都是大于1个字节的,这就造成寄存器宽度与内存存储单元宽度之间的不一致性。
在软件程序的很多操作中,都会涉及数据在内存和寄存器之间的传送,例如,如果你的C语言程序中包含int x = 26这样的代码,那么最终CPU需要先为x分配堆栈内存空间,然后将26这个立即数传入寄存器,再通过寄存器传送到x所在的内存位置。或者如果你的C语言程序中包含int y = x这样的代码,那么CPU需要先将x的值从内存读取到寄存器,再将数据从寄存器传送到y所在的内存位置。在计算机中,并不支持直接将数据在不同的内存之间传送,更不支持将数据直接从内存传送到外部设备,例如磁盘或网络端口。CPU唯一支持不同部件之间的直接数据传送只有寄存器到寄存器了。由于在高级编程语言(相对于汇编语言而言)中并不能直接操作寄存器,所有的数据传送的指令以及针对寄存器读写的指令都被封装成面向变量的编程,而变量的存储介质是内存,因此可以这么讲,高级编程语言中的所有数据传送指令都必须经过寄存器的中转。
寄存器的宽度越大,就意味着CPU传送数据的能力越强,如果寄存器只能容纳8比特,那么CPU一次指令只能传输1字节,而如果寄存器能够容纳双字节,那么一次CPU指令就能传输2字节,这无形中提升了效率。虽然寄存器的宽度可以提高,但是内存存储单元的宽度却是一直不变的,一直都只有1字节,因此对于宽度达到双字节的寄存器可以一次性从内存中读取2个连续的存储单元的值,或者一次性向2个连续的内存存储单元写入数据。
对于一个占2字节的整数,例如0x0102,数据本身是区分高字节和低字节的,靠近左侧的字节为高字节,反之则为低字节。而一个双字节的寄存器也会区分高低位,对高低位的不同定位会带来数值结果的不同。假设双字节的寄存器中从左到右所存储的字节分别是0x01和0x02,如果将寄存器的左端定位高字节端,则寄存器所存储的数值就是0x0102,对应十进制的258.而如果将寄存器的右端定位高字节端,那么寄存器所存储的0x0201,对应的十进制数是513.由此可见,以相同顺序存储的同样一段数据,如果所标定的高低位不同,则最终所代表的数值是完全不同的。由于不同厂家所生产的CPU标准不同,因此大家对于究竟将寄存器的左端还是右端定位高字节,并没有一个统一的标准,目前Intel的80x86系列芯片是唯一还在坚持使用小端的芯片,而MIPS和ARM等芯片要么全部采用大端的方式存储要么提供选项支持大端——可以在小端之间切换。
因此,大小端的问题,本质上是由寄存器引起的(当然,软件系统也能引起所谓的大小端问题,完全看认为的定义),并最终在内存的存储顺序上得以反映出来。举个例子来说明。假设在C程序中包含int x = 0x0102这样一句代码,在大端CPU架构平台上运行到这句代码时,CPU首先将立即数0x0102保存到双字节的寄存器中(假设寄存器的宽度为2字节),由于以大端的方式存储,因此寄存器中从低位到高位所存储的字节分别是0x01和0x02。接着CPU将寄存器中的数据传送到x变量所在的内存位置,传送后,x变量所在的2个连续的内存存储单元中的值从低位到高位也分别是0x01和0x02.
而对于同样的程序,如果运行在小端CPU架构平台上,则寄存器从低位到高位所存储的字节分别是0x02和0x01,将其拼接起来得到的数据是0x0201,与原来的数据0x0102的字节位正好相反,于是最终保存到内存中,在内存中从低位到高位所保存的数值是0x02和0x01.
有时候计算机也是挺幽默的,也会玩"时光向左,幸福向右"之类浪漫的事。但是计算机实在不是吃饱了撑的玩这种闲情雅致,内存存储顺序的向左向右,实则是由寄存器引起的。在计算机体系结构中,内存由存储单元构成,一个存储单元的长度是一个字节,即每个存储单元都对应着一个字节,能够存储8比特数据。但是在C语言和很多其他高级语言中,除了8比特的char之外,还有16比特的short型、32比特的int型与64比特的long型(int和long具体所占二进制位数要看具体的编译期和CPU平台架构)。虽然物理内存的存储单位是1字节,但是现代计算机总线线宽和寄存器的宽度往往都大于1个字节,对于位数大于8位的处理器,例如16位或者32位的处理器,寄存器的宽度都是大于1个字节的,这就造成寄存器宽度与内存存储单元宽度之间的不一致性。
在软件程序的很多操作中,都会涉及数据在内存和寄存器之间的传送,例如,如果你的C语言程序中包含int x = 26这样的代码,那么最终CPU需要先为x分配堆栈内存空间,然后将26这个立即数传入寄存器,再通过寄存器传送到x所在的内存位置。或者如果你的C语言程序中包含int y = x这样的代码,那么CPU需要先将x的值从内存读取到寄存器,再将数据从寄存器传送到y所在的内存位置。在计算机中,并不支持直接将数据在不同的内存之间传送,更不支持将数据直接从内存传送到外部设备,例如磁盘或网络端口。CPU唯一支持不同部件之间的直接数据传送只有寄存器到寄存器了。由于在高级编程语言(相对于汇编语言而言)中并不能直接操作寄存器,所有的数据传送的指令以及针对寄存器读写的指令都被封装成面向变量的编程,而变量的存储介质是内存,因此可以这么讲,高级编程语言中的所有数据传送指令都必须经过寄存器的中转。
寄存器的宽度越大,就意味着CPU传送数据的能力越强,如果寄存器只能容纳8比特,那么CPU一次指令只能传输1字节,而如果寄存器能够容纳双字节,那么一次CPU指令就能传输2字节,这无形中提升了效率。虽然寄存器的宽度可以提高,但是内存存储单元的宽度却是一直不变的,一直都只有1字节,因此对于宽度达到双字节的寄存器可以一次性从内存中读取2个连续的存储单元的值,或者一次性向2个连续的内存存储单元写入数据。
对于一个占2字节的整数,例如0x0102,数据本身是区分高字节和低字节的,靠近左侧的字节为高字节,反之则为低字节。而一个双字节的寄存器也会区分高低位,对高低位的不同定位会带来数值结果的不同。假设双字节的寄存器中从左到右所存储的字节分别是0x01和0x02,如果将寄存器的左端定位高字节端,则寄存器所存储的数值就是0x0102,对应十进制的258.而如果将寄存器的右端定位高字节端,那么寄存器所存储的0x0201,对应的十进制数是513.由此可见,以相同顺序存储的同样一段数据,如果所标定的高低位不同,则最终所代表的数值是完全不同的。由于不同厂家所生产的CPU标准不同,因此大家对于究竟将寄存器的左端还是右端定位高字节,并没有一个统一的标准,目前Intel的80x86系列芯片是唯一还在坚持使用小端的芯片,而MIPS和ARM等芯片要么全部采用大端的方式存储要么提供选项支持大端——可以在小端之间切换。
因此,大小端的问题,本质上是由寄存器引起的(当然,软件系统也能引起所谓的大小端问题,完全看认为的定义),并最终在内存的存储顺序上得以反映出来。举个例子来说明。假设在C程序中包含int x = 0x0102这样一句代码,在大端CPU架构平台上运行到这句代码时,CPU首先将立即数0x0102保存到双字节的寄存器中(假设寄存器的宽度为2字节),由于以大端的方式存储,因此寄存器中从低位到高位所存储的字节分别是0x01和0x02。接着CPU将寄存器中的数据传送到x变量所在的内存位置,传送后,x变量所在的2个连续的内存存储单元中的值从低位到高位也分别是0x01和0x02.
而对于同样的程序,如果运行在小端CPU架构平台上,则寄存器从低位到高位所存储的字节分别是0x02和0x01,将其拼接起来得到的数据是0x0201,与原来的数据0x0102的字节位正好相反,于是最终保存到内存中,在内存中从低位到高位所保存的数值是0x02和0x01.
大小端验证。
x86架构的CPU是属于小端CPU,大部分Windows用户都是用这种架构的CPU,我们可以通过示例程序来验证。
这段代码测试的原理很简单,由于变量x的数组类型是short,在32位平台上占2字节。其值转换为十六进制是0x0001,如果当前CPU是大端类型,则最终保存到内存后,内存首地址的哪个存储单元中所存储的值一定是0x00,这是因为大端CPU的特点就是数据的高位字节存储在低位内存单元。反之,如果x的内存首地址所存储数值是0x1,则代表当前CPU是小端。而要拿到变量x的内存首地址所在的存储单元中所存储的值,需要通过char c = *(char*)&x来获取,这句代码的含义是,首先通过&x获取变量x的内存首地址,获取的结果是一个指针类型,只是指针的类型是short*,这个指针指向的内存范围包含2个存储单元,因此需要将其转换为char*这种类型,这样最终通过*(char*)得到的结果数据类型才是char,否则就编程了short类型。
x86架构的CPU是属于小端CPU,大部分Windows用户都是用这种架构的CPU,我们可以通过示例程序来验证。
这段代码测试的原理很简单,由于变量x的数组类型是short,在32位平台上占2字节。其值转换为十六进制是0x0001,如果当前CPU是大端类型,则最终保存到内存后,内存首地址的哪个存储单元中所存储的值一定是0x00,这是因为大端CPU的特点就是数据的高位字节存储在低位内存单元。反之,如果x的内存首地址所存储数值是0x1,则代表当前CPU是小端。而要拿到变量x的内存首地址所在的存储单元中所存储的值,需要通过char c = *(char*)&x来获取,这句代码的含义是,首先通过&x获取变量x的内存首地址,获取的结果是一个指针类型,只是指针的类型是short*,这个指针指向的内存范围包含2个存储单元,因此需要将其转换为char*这种类型,这样最终通过*(char*)得到的结果数据类型才是char,否则就编程了short类型。
如果上面的测试程序还不够直观,则下面的这段程序一定能够说明问题了:
在这个例子中,通过数组cc往内存中连续写入6字节的内容,,从内存地址的低位到高位分别是0x11、0x22、0x33、0x44、0x55、0x66。通过char*cp=&cc拿到cc数组的内存首地址,这个首地址所存放的数值是0x11。接着通过short cs=*(short*)cp获取从cs内存首地址开始的连续2个存储单元的值,并将其转换为short类型。如果在大端平台上,则应该0x1122,对应的十进制数值是4386,但是很可惜并不是这个数值,而是8721,8721所对应的十六进制数正是0x2211,这说明当前CPU是小端,事实上也是的。因为是x86平台,之所以小端CPU会将0x1122这样的内存数据解读成0x2211,就是因为小端CPU认为内存中的低位代表的是高字节,而内存中的高位则代表数据的低位字节,因此最终CPU会认为这段内存中的数值是0x2211
在这个例子中,通过数组cc往内存中连续写入6字节的内容,,从内存地址的低位到高位分别是0x11、0x22、0x33、0x44、0x55、0x66。通过char*cp=&cc拿到cc数组的内存首地址,这个首地址所存放的数值是0x11。接着通过short cs=*(short*)cp获取从cs内存首地址开始的连续2个存储单元的值,并将其转换为short类型。如果在大端平台上,则应该0x1122,对应的十进制数值是4386,但是很可惜并不是这个数值,而是8721,8721所对应的十六进制数正是0x2211,这说明当前CPU是小端,事实上也是的。因为是x86平台,之所以小端CPU会将0x1122这样的内存数据解读成0x2211,就是因为小端CPU认为内存中的低位代表的是高字节,而内存中的高位则代表数据的低位字节,因此最终CPU会认为这段内存中的数值是0x2211
大端和小端产生的场景。
虽然大端小端的问题在内存、寄存器、计算机总线甚至软件中导出都存在,但是得益于整个软硬件架构的良好涉及,因此在日常编程开发中,大多数程序员都不需要去关注这个问题,在单机上,不管是往内存中写入数据还是从内存中读取数据,由于所采用的标准都是同一套,要么全部是大端模式,要么全部是小端模式,因此不会产生大小端数据转换的问题。例如对于下面这段程序:
int x = 0x0102;
int y = x;
程序运行时,先将0x0102赋值给变量x,再将x的内存值赋值给y.虽然在这期间,数据会发生多次从内存到寄存器,再从寄存器到内存的传送,但是并不会产生混乱,只要所使用的大端和小端模式相同。如果使用的是小端模式,则最终变量x所占用的4个连续的内存存储单元从低位到高位所存储的数据分别是0x01、0x00、0x00、0x00,寄存器在传送数据的过程中暂存数据时,寄存器从低位到高位所存储的数据也是0x01、0x00、0x00、0x00,而最终寄存器在变量y所在内存写入的数据在内存中的存储顺序也是0x01、0x00、0x00、0x00.当你想打印变量x或变量y的值时,虽然存储顺序与实际数据的高低位完全相反,但是CPU的逻辑运算器时清楚这种格式的,并会将其字节反转后拼装出正确的结果显示出来。
因此在单机上由同一种模式进行数据读写时,并不会产生堆数据识别的不同,这也是为什么绝大部分程序员在日常开发中都不会接触大端和小端问题的原因。
但是当关注网络传输和文件共享时,由于数据在网络的一端写入,而由网络的另一端读取,网络两段的CPU架构并不总是相同的,很可能一端使用了大端模式,而另一端使用了小端模式。在这种场景下,如果不进行大端与小端的转换,则数据必定会出现不一致。例如下面这段程序,往文件中写入int类型的数据。
在本段代码中,往文件写入了一个short类型的数据。由于一个short类型的数据占用2字节的内存空间,因此最终往文件里写入了2字节。而这2字节所对应的ASCII字符分别是A和B,因此写入文件后,使用记事本或者在Linux上使用vm或gedit打开文件时,应该看到的是字符A和B,而不是数值。上图中的环境是x86架构,而x86属于小端模式,因此当CPU执行short test = 0x4142这行代码时,最终变量test在内存中从低位到高位的存储内容便会与0x4142的字节高低位顺序相反,变成0x4241.这导致最终写入到文件中的数据顺序也是0x4241,写入后再x86平台上使用文本编辑器打开时会发现显示的内容是BA而不是AB.如果在大端CPU架构上使用文本编辑器查看时,结果仍为BA,这是因为文本编辑器是按字节逐个读取内容的,而寄存器在读取一个字节时,不会发生字节之间的乱序。
虽然大端小端的问题在内存、寄存器、计算机总线甚至软件中导出都存在,但是得益于整个软硬件架构的良好涉及,因此在日常编程开发中,大多数程序员都不需要去关注这个问题,在单机上,不管是往内存中写入数据还是从内存中读取数据,由于所采用的标准都是同一套,要么全部是大端模式,要么全部是小端模式,因此不会产生大小端数据转换的问题。例如对于下面这段程序:
int x = 0x0102;
int y = x;
程序运行时,先将0x0102赋值给变量x,再将x的内存值赋值给y.虽然在这期间,数据会发生多次从内存到寄存器,再从寄存器到内存的传送,但是并不会产生混乱,只要所使用的大端和小端模式相同。如果使用的是小端模式,则最终变量x所占用的4个连续的内存存储单元从低位到高位所存储的数据分别是0x01、0x00、0x00、0x00,寄存器在传送数据的过程中暂存数据时,寄存器从低位到高位所存储的数据也是0x01、0x00、0x00、0x00,而最终寄存器在变量y所在内存写入的数据在内存中的存储顺序也是0x01、0x00、0x00、0x00.当你想打印变量x或变量y的值时,虽然存储顺序与实际数据的高低位完全相反,但是CPU的逻辑运算器时清楚这种格式的,并会将其字节反转后拼装出正确的结果显示出来。
因此在单机上由同一种模式进行数据读写时,并不会产生堆数据识别的不同,这也是为什么绝大部分程序员在日常开发中都不会接触大端和小端问题的原因。
但是当关注网络传输和文件共享时,由于数据在网络的一端写入,而由网络的另一端读取,网络两段的CPU架构并不总是相同的,很可能一端使用了大端模式,而另一端使用了小端模式。在这种场景下,如果不进行大端与小端的转换,则数据必定会出现不一致。例如下面这段程序,往文件中写入int类型的数据。
在本段代码中,往文件写入了一个short类型的数据。由于一个short类型的数据占用2字节的内存空间,因此最终往文件里写入了2字节。而这2字节所对应的ASCII字符分别是A和B,因此写入文件后,使用记事本或者在Linux上使用vm或gedit打开文件时,应该看到的是字符A和B,而不是数值。上图中的环境是x86架构,而x86属于小端模式,因此当CPU执行short test = 0x4142这行代码时,最终变量test在内存中从低位到高位的存储内容便会与0x4142的字节高低位顺序相反,变成0x4241.这导致最终写入到文件中的数据顺序也是0x4241,写入后再x86平台上使用文本编辑器打开时会发现显示的内容是BA而不是AB.如果在大端CPU架构上使用文本编辑器查看时,结果仍为BA,这是因为文本编辑器是按字节逐个读取内容的,而寄存器在读取一个字节时,不会发生字节之间的乱序。
这段程序从刚才所写入的文件中读出1024个字节到缓冲区buf中,并强制将指针&buf转换成int*类型,最终再通过int*类型获取int值。同样在x86这种小端模式下的CPU架构平台上运行这段程序,结果打印出十进制数据16706,其对应的十六进制正好是4142,这与原本往文件里写入的数据是i完全一致的。
在本例中,由于写入和读取文件的程序都运行在小端CPU架构平台上,因此虽然写入文件后的数据高低字节位置被反转了,但是在同样的CPU架构平台上能够识别出正确的数值,但是,如果将读取文件的这段示例程序放在大端CPU架构平台上运行,则会发现这段程序最终会打印出16961,即对应十六进制0x4241.这与将上个示例程序在小端CPU平台上运行后写入文件并在大端CPU架构平台上使用文本编辑器打开后显示的字符仍然是BA不同,这是因为文本编辑器使用ASIIC字符集进行编码,文本编辑器逐字节读取磁盘文件里的内容,不会将文件里的字符合并转换成一个int类型的数据,因此不会产生字节序反转。本示例需要CPU识别多个字节合并后所代表的某种数据类型的数值,在这个过程中,由于大端CPU与小端CPU所标定的高低位相反,因此对于2字节所合并出来的同一个数据的识别也必定不同。
所以在编写网络协议或者编写跨平台的程序时,大端和小端的问题就显得尤其突出,开发者必须要清楚所谓大小端问题产生的根本原因并采取适当的措施进行解决,否则必定产生峦溪,导致数据不一致。
在本例中,由于写入和读取文件的程序都运行在小端CPU架构平台上,因此虽然写入文件后的数据高低字节位置被反转了,但是在同样的CPU架构平台上能够识别出正确的数值,但是,如果将读取文件的这段示例程序放在大端CPU架构平台上运行,则会发现这段程序最终会打印出16961,即对应十六进制0x4241.这与将上个示例程序在小端CPU平台上运行后写入文件并在大端CPU架构平台上使用文本编辑器打开后显示的字符仍然是BA不同,这是因为文本编辑器使用ASIIC字符集进行编码,文本编辑器逐字节读取磁盘文件里的内容,不会将文件里的字符合并转换成一个int类型的数据,因此不会产生字节序反转。本示例需要CPU识别多个字节合并后所代表的某种数据类型的数值,在这个过程中,由于大端CPU与小端CPU所标定的高低位相反,因此对于2字节所合并出来的同一个数据的识别也必定不同。
所以在编写网络协议或者编写跨平台的程序时,大端和小端的问题就显得尤其突出,开发者必须要清楚所谓大小端问题产生的根本原因并采取适当的措施进行解决,否则必定产生峦溪,导致数据不一致。
但是并不是所有的网络通信场景都需要考虑大小端问题,例如下面的示例。
在本示例中,写入文件的不再是int整数,而是变成了char数组,其实就是逐字节写入,依然在x86平台上运行本程序,运行后使用文本编辑器打开写入的文件,会发现编辑器里显示的内容变成"AB",而不再是BA,为什么这一次CPU没有对数据高低字节进行反转呢?其实这就涉及到一个核心的问题:究竟什么场景下才会产生大小端模式带来的字节序反转问题?
其实这一问题已经说的很清楚,要回答一个问题,首先就要明白问题产生的本质原因。大小端问题产生的根本原因是,当寄存器需要读取或写入超过一个字节长度的数据时,由于不同CPU所认定的寄存器的高低位不同,而产生了所谓大小端模式。只要不使问题产生的条件成立,那么问题自然不会产生。由于程序在处理char类型的数据时,寄存器只需要读写1字节宽度的数据,因此自然不会出现所谓的字节序反转问题,这就是本示例能够不受大小端模式影响而始终生成同样顺序的字节的原因。而在上一个示例中,我们往文件里写入的是int*类型的数据,但是字节序反转并不是发生在CPU将变量写入文件的阶段,而是发生在CPU为变量写入数值的前一阶段,即CPU将立即数读进寄存器的阶段。因为是小端CPU,因此寄存器读取到超过1个字节宽度的int类型数据是发横了字节序反转,由于在寄存器中数据的字节序被反转,因此最终写入变量内存时的字节序也是反转的,由此导致最终写入文件的字节序依然是反转的。
其实,在这个过程中,有一个重要的点值得关注,那就是无论在大端还是小端,调用系统API fwrite和fread进行读写时,似乎并不受大小端模式的影响。理解了上面的道理之后,对于这一现象就能解释了,唯一的原因就是fwrite和fread在底层也是逐字节读取和写入的,因此不会产生字节序反转
在本示例中,写入文件的不再是int整数,而是变成了char数组,其实就是逐字节写入,依然在x86平台上运行本程序,运行后使用文本编辑器打开写入的文件,会发现编辑器里显示的内容变成"AB",而不再是BA,为什么这一次CPU没有对数据高低字节进行反转呢?其实这就涉及到一个核心的问题:究竟什么场景下才会产生大小端模式带来的字节序反转问题?
其实这一问题已经说的很清楚,要回答一个问题,首先就要明白问题产生的本质原因。大小端问题产生的根本原因是,当寄存器需要读取或写入超过一个字节长度的数据时,由于不同CPU所认定的寄存器的高低位不同,而产生了所谓大小端模式。只要不使问题产生的条件成立,那么问题自然不会产生。由于程序在处理char类型的数据时,寄存器只需要读写1字节宽度的数据,因此自然不会出现所谓的字节序反转问题,这就是本示例能够不受大小端模式影响而始终生成同样顺序的字节的原因。而在上一个示例中,我们往文件里写入的是int*类型的数据,但是字节序反转并不是发生在CPU将变量写入文件的阶段,而是发生在CPU为变量写入数值的前一阶段,即CPU将立即数读进寄存器的阶段。因为是小端CPU,因此寄存器读取到超过1个字节宽度的int类型数据是发横了字节序反转,由于在寄存器中数据的字节序被反转,因此最终写入变量内存时的字节序也是反转的,由此导致最终写入文件的字节序依然是反转的。
其实,在这个过程中,有一个重要的点值得关注,那就是无论在大端还是小端,调用系统API fwrite和fread进行读写时,似乎并不受大小端模式的影响。理解了上面的道理之后,对于这一现象就能解释了,唯一的原因就是fwrite和fread在底层也是逐字节读取和写入的,因此不会产生字节序反转
如何解决字节序反转。
前面讨论了大小端的概念、产生原因及现象,那么,如果真的面临大小端的现实场景,该如何解决则是一个重中之重的问题。例如,假设有一天你需要使用C语言开发一个网络通信协议,该协议要求兼容主流平台,那就必须要处理大小端问题。
在Linux平台,可以调用bswap这个指令进行字节序反转。前文举了一个例子,定义一个包含6个元素的char数组,然后将其前两个字节合并转换成一个short类型的数据,现在我们改造这个例子,同时支持字节序反转,如代码所示。
在本例中,定义了一个函数swap_u4()用于反转字节序。在小端CPUx86架构平台上运行本程序,控制台输出。其中lls对应的十六进制是0x44332211,而lld对应的十进制则是0x11223344.由此可见lld的值被正确地还原了出来,与原始的输入值完全相等。而lld之所以能够被完整还原出来,是因为调用了字节序反转函数swap_u4();
前面讨论了大小端的概念、产生原因及现象,那么,如果真的面临大小端的现实场景,该如何解决则是一个重中之重的问题。例如,假设有一天你需要使用C语言开发一个网络通信协议,该协议要求兼容主流平台,那就必须要处理大小端问题。
在Linux平台,可以调用bswap这个指令进行字节序反转。前文举了一个例子,定义一个包含6个元素的char数组,然后将其前两个字节合并转换成一个short类型的数据,现在我们改造这个例子,同时支持字节序反转,如代码所示。
在本例中,定义了一个函数swap_u4()用于反转字节序。在小端CPUx86架构平台上运行本程序,控制台输出。其中lls对应的十六进制是0x44332211,而lld对应的十进制则是0x11223344.由此可见lld的值被正确地还原了出来,与原始的输入值完全相等。而lld之所以能够被完整还原出来,是因为调用了字节序反转函数swap_u4();
大小端问题的避免。
大小端问题的本质是由于计算机底层硬件的问题,因此显得比较复杂,但是在宏观表象上,却比较容易解释和理解,绝大多数书籍和网络博客阐述的重点往往也是表面原因和现象.但是只要遵循下面两种方式处理数据、文件、网络传输,便可以无视大小端模式:
# 在单机上使用同一种编程语言读写变量、读写文件、进行网络通信,所读到的字节序与所写入的字节序相同,反之亦成立
# 在分布式场景下,使用同一种编程语言,在同样大小端模式的不同机器上所读写的文件与网络信息,字节序相同。
例如,在网络环境中,在一台机器上写入文件,在另一台机器上读取文件,如果这两台机器都是大端模式或者都是小端模式,则只要读取和写入时均使用同样的编程语言便能保持所读与所写的字节序是相同的。这一点对于理解JVM的字节序处理很重要
大小端问题的本质是由于计算机底层硬件的问题,因此显得比较复杂,但是在宏观表象上,却比较容易解释和理解,绝大多数书籍和网络博客阐述的重点往往也是表面原因和现象.但是只要遵循下面两种方式处理数据、文件、网络传输,便可以无视大小端模式:
# 在单机上使用同一种编程语言读写变量、读写文件、进行网络通信,所读到的字节序与所写入的字节序相同,反之亦成立
# 在分布式场景下,使用同一种编程语言,在同样大小端模式的不同机器上所读写的文件与网络信息,字节序相同。
例如,在网络环境中,在一台机器上写入文件,在另一台机器上读取文件,如果这两台机器都是大端模式或者都是小端模式,则只要读取和写入时均使用同样的编程语言便能保持所读与所写的字节序是相同的。这一点对于理解JVM的字节序处理很重要
JVM对字节码文件的大小端处理。
在讲述关于Java字节码文件的大小端问题处理之前,还有一个问题需要特别说明,那就是大小端问题不仅存在于计算机硬件体系中,软件中也同样存在。当然,软件中的大小端问题多是被编译期处理了,所以在绝大多数情况下,软件开发者并不需要特别关注大小端问题,Java编译期同样在后按默默地处理了这个问题,Java所输出的字节信息全部是大端模式,这一点对于理解JVM在解析字节码信息时的处理策略至关重要。
JVM在解析魔数及Java类的其他结构信息时,均需要读取字节信息。Java源代码一般由编译期被编译为字节码文件,而Java编译期本身一般也都是使用Java语言编写而成,因此Java编译器在对Java源程序进行语法树分析并将分析结果写入字节码文件时,字节码文件中的信息存储便是大端模式,例如对于魔数,字节码的写入顺序一定是如下这样:
0xCA 0xFE 0xBA 0xBE
这种写入文件的顺序不会受计算机体系到底是大端模式还是小端模式的影响,这是Java与其他编程语言的一个重要区别,例如,如果用由C语言开发的编译器来编译Java源代码,那么这种编译器在小端机器上生成的字节码文件中的魔数的写入顺序基本会编程下面这样:
0xBE 0xBA 0xFE 0xCA
这是因为,魔数在C语言中可以使用一个int类型的变量表示,C语言在将这个int类型变量写入字节码文件之前,首先需要将魔数信息写入该变量,而由于硬件体系属于小端模式,因此从寄存器写入内存时,魔数的字节序便已经发生反转。
既然Java编译器所生成的字节码文件并不会因为计算机架构的大小端模式而受到影响,那么JVM从字节码文件中解析魔数时,为何还要处理字节序呢?在上文中讲到,在分布式环境下,要避免大小端问题,只需使网络中的各个计算机节点的大小端模式都保持一直,并且所使用的编程语言也保持一致即可。而Java字节码的读写场景对这两个条件都不符合,首先是读写问题,Java编译器一般是由Java开发的,因此字节码文件的写入可以认为是Java语言完成的,而读取字节码文件的是JVM,JVM是由C和C++混合写成的,因此Java字节码文件的写入段和读取端属于两种不同的编程语言。接着看计算机节点的大小端模式的一致性问题。由于Java语言的跨平台性,因此对于一段已经编写好的Java源代码,既可以在Windows上编译打包,也可以在Linux或者其他平台上编译打包。同样读取Java字节码的JVM可能运行于Windows平台,也可能运行于Linux平台,因此Java字节码文件可能在Windows平台写入,而在Linux平台上被读取。但是Java字节码文件的字节序并不受大小端模式的影响,这是由于Java语言本身属于大端模式,因此Java语言所写入的文件的字节序全部按照大端模式进行存储。如此看来,可能引起Java字节码文件的字节序读取不一致的是读取端的编程语言的大小端模式。JVM由C和C++编写,而C和C++的大小端模式默认情况下与计算机硬件平台的大小端模式保持一致,因此最终又完全取决于读取端所在的计算机硬件平台的大小端模式。如果读机器属于大端模式,则最终所读取到内存中的魔数信息便是0xCAFEBABE;反之,如果读机器属于小端模式,则最终所读取到的魔数信息便是0xBABEFECA.很显然如果是后者,则JVM将会校验失败,因此如果JVM运行于小端机器上,则必须对所读取出来的魔数字节序进行反转。这便是JVM最终在bytes_linux_x86.inline.hpp中引入inline u4 Bytes::swap_u4(u4 x)这类函数接口的原因。
在讲述关于Java字节码文件的大小端问题处理之前,还有一个问题需要特别说明,那就是大小端问题不仅存在于计算机硬件体系中,软件中也同样存在。当然,软件中的大小端问题多是被编译期处理了,所以在绝大多数情况下,软件开发者并不需要特别关注大小端问题,Java编译期同样在后按默默地处理了这个问题,Java所输出的字节信息全部是大端模式,这一点对于理解JVM在解析字节码信息时的处理策略至关重要。
JVM在解析魔数及Java类的其他结构信息时,均需要读取字节信息。Java源代码一般由编译期被编译为字节码文件,而Java编译期本身一般也都是使用Java语言编写而成,因此Java编译器在对Java源程序进行语法树分析并将分析结果写入字节码文件时,字节码文件中的信息存储便是大端模式,例如对于魔数,字节码的写入顺序一定是如下这样:
0xCA 0xFE 0xBA 0xBE
这种写入文件的顺序不会受计算机体系到底是大端模式还是小端模式的影响,这是Java与其他编程语言的一个重要区别,例如,如果用由C语言开发的编译器来编译Java源代码,那么这种编译器在小端机器上生成的字节码文件中的魔数的写入顺序基本会编程下面这样:
0xBE 0xBA 0xFE 0xCA
这是因为,魔数在C语言中可以使用一个int类型的变量表示,C语言在将这个int类型变量写入字节码文件之前,首先需要将魔数信息写入该变量,而由于硬件体系属于小端模式,因此从寄存器写入内存时,魔数的字节序便已经发生反转。
既然Java编译器所生成的字节码文件并不会因为计算机架构的大小端模式而受到影响,那么JVM从字节码文件中解析魔数时,为何还要处理字节序呢?在上文中讲到,在分布式环境下,要避免大小端问题,只需使网络中的各个计算机节点的大小端模式都保持一直,并且所使用的编程语言也保持一致即可。而Java字节码的读写场景对这两个条件都不符合,首先是读写问题,Java编译器一般是由Java开发的,因此字节码文件的写入可以认为是Java语言完成的,而读取字节码文件的是JVM,JVM是由C和C++混合写成的,因此Java字节码文件的写入段和读取端属于两种不同的编程语言。接着看计算机节点的大小端模式的一致性问题。由于Java语言的跨平台性,因此对于一段已经编写好的Java源代码,既可以在Windows上编译打包,也可以在Linux或者其他平台上编译打包。同样读取Java字节码的JVM可能运行于Windows平台,也可能运行于Linux平台,因此Java字节码文件可能在Windows平台写入,而在Linux平台上被读取。但是Java字节码文件的字节序并不受大小端模式的影响,这是由于Java语言本身属于大端模式,因此Java语言所写入的文件的字节序全部按照大端模式进行存储。如此看来,可能引起Java字节码文件的字节序读取不一致的是读取端的编程语言的大小端模式。JVM由C和C++编写,而C和C++的大小端模式默认情况下与计算机硬件平台的大小端模式保持一致,因此最终又完全取决于读取端所在的计算机硬件平台的大小端模式。如果读机器属于大端模式,则最终所读取到内存中的魔数信息便是0xCAFEBABE;反之,如果读机器属于小端模式,则最终所读取到的魔数信息便是0xBABEFECA.很显然如果是后者,则JVM将会校验失败,因此如果JVM运行于小端机器上,则必须对所读取出来的魔数字节序进行反转。这便是JVM最终在bytes_linux_x86.inline.hpp中引入inline u4 Bytes::swap_u4(u4 x)这类函数接口的原因。
事实上,JVM不仅在解析魔数时需要实现大小端的正确反转,而且在后续解析所有其他信息,诸如版本号、常量池、字段等时,也都是先了大小端的兼容处理。魔数占用4字节,因此JVM定义了swap_u4()这样的接口,而除魔数以外的其他字节码信息,有的占用2字节,有的占用8字节,当然,也有的仅占用1字节。对于2字节和8字节的读取,JVM同样定义了相应的转换接口,如图所示
可以看到,JVM并没有定义一个类似swap_u1()这样的转换接口,这是因为在前文分析锅,大小端问题的产生条件是读取或写入超过1字节长都的数据,而如果从文件中一字节一字节地读取,1字节码地读取并不会产生乱序问题,因此JVM并不需要针对u1类型的数据的读取进行大小端的兼容性处理。这里我们分析了大小端的概念、产生机制以及JVM内部的解决之道。通过这点更加可以看出,如果没有Java语言,大家要实现将一个程序部署到不同的硬件平台上,是一件多么辛苦的事情,仅仅是大小端,就要花费很多精力去处理
可以看到,JVM并没有定义一个类似swap_u1()这样的转换接口,这是因为在前文分析锅,大小端问题的产生条件是读取或写入超过1字节长都的数据,而如果从文件中一字节一字节地读取,1字节码地读取并不会产生乱序问题,因此JVM并不需要针对u1类型的数据的读取进行大小端的兼容性处理。这里我们分析了大小端的概念、产生机制以及JVM内部的解决之道。通过这点更加可以看出,如果没有Java语言,大家要实现将一个程序部署到不同的硬件平台上,是一件多么辛苦的事情,仅仅是大小端,就要花费很多精力去处理
Java字节码实战
字节码格式初探。
前面我们一起分析了JVM内部调用Java方法的核心技术原理,接着从宏观层面谈了Java在面向对象、数据结构上所做的技术选择。要想深刻理解JVM执行引擎的机制,就必须对JVM内部的数据结构有深入了解,而要了解JVM内部的数据结构,就必须要了解从Java源程序过渡到JVM内部数据结构的中间桥梁——Java字节码。相信绝大多数人初次接触字节码相关的知识时,都会觉得比较抽象,为了让大家能够真正熟悉并理解Java字节码文件的格式,下面将通过一个实际的Java类的字节码文件格式分析它的构成。
前面我们一起分析了JVM内部调用Java方法的核心技术原理,接着从宏观层面谈了Java在面向对象、数据结构上所做的技术选择。要想深刻理解JVM执行引擎的机制,就必须对JVM内部的数据结构有深入了解,而要了解JVM内部的数据结构,就必须要了解从Java源程序过渡到JVM内部数据结构的中间桥梁——Java字节码。相信绝大多数人初次接触字节码相关的知识时,都会觉得比较抽象,为了让大家能够真正熟悉并理解Java字节码文件的格式,下面将通过一个实际的Java类的字节码文件格式分析它的构成。
准备测试用例。
这个测试类虽然简单,然而五脏俱全,包含类变量、成员变量、字符串和类成员方法,同时包含一个入口主函数main(),并且在main()函数中实例化了Test类。为了简单起见,该类没有继承任何类,没有使用任何类库,这样有助于在分析字节码文件格式时降低难度。
这个测试类虽然简单,然而五脏俱全,包含类变量、成员变量、字符串和类成员方法,同时包含一个入口主函数main(),并且在main()函数中实例化了Test类。为了简单起见,该类没有继承任何类,没有使用任何类库,这样有助于在分析字节码文件格式时降低难度。
使用javap命令分析字节码文件。
在%JAVA_HOME%/bin目录下包含一个javap命令工具,javap命令能够分析出一个给定的Java类中的字节码信息。这里使用javap命令来分析上述用于测试的Test类。首先编译Test.java类,得到Test.class文件,进入Test.class文件所在目录,输入如下命令:
javap -verbose Test
执行javap命令后,将输出如下信息:
使用javap -verbose命令分析一个字节码文件,将会分析字节码文件的魔数、版本号、常量池、类信息、类的构造函数、类中所包含的方法信息以及类(成员)变量信息。需要注意的是,每一次执行javap命令所输出的信息内容一定是相同的,但是信息的先后顺序则不保证完全一致,例如,常量池中的元素编号每次都不保证相同。
javap -verbose会列出Java类中的全部常量池,其格式如下:
Constant pool:
#1 = Methodref #9.#26 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#27 // Test.a:I
#3 = String #28 // Hello World
#4 = Fieldref #5.#29 // Test.s:Ljava/lang/String;
#5 = Class #30 // Test
#6 = Methodref #5.#26 // Test."<init>":()V
#7 = Methodref #31.#32 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#8 = Fieldref #5.#33 // Test.si:Ljava/lang/Integer;
#9 = Class #34 // java/lang/Object
每一个const后面的#号后的数字代表了常量池中的索引,当JVM在解析类的常量池信息时,常量池项的索引与此一致
在%JAVA_HOME%/bin目录下包含一个javap命令工具,javap命令能够分析出一个给定的Java类中的字节码信息。这里使用javap命令来分析上述用于测试的Test类。首先编译Test.java类,得到Test.class文件,进入Test.class文件所在目录,输入如下命令:
javap -verbose Test
执行javap命令后,将输出如下信息:
使用javap -verbose命令分析一个字节码文件,将会分析字节码文件的魔数、版本号、常量池、类信息、类的构造函数、类中所包含的方法信息以及类(成员)变量信息。需要注意的是,每一次执行javap命令所输出的信息内容一定是相同的,但是信息的先后顺序则不保证完全一致,例如,常量池中的元素编号每次都不保证相同。
javap -verbose会列出Java类中的全部常量池,其格式如下:
Constant pool:
#1 = Methodref #9.#26 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#27 // Test.a:I
#3 = String #28 // Hello World
#4 = Fieldref #5.#29 // Test.s:Ljava/lang/String;
#5 = Class #30 // Test
#6 = Methodref #5.#26 // Test."<init>":()V
#7 = Methodref #31.#32 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#8 = Fieldref #5.#33 // Test.si:Ljava/lang/Integer;
#9 = Class #34 // java/lang/Object
每一个const后面的#号后的数字代表了常量池中的索引,当JVM在解析类的常量池信息时,常量池项的索引与此一致
常量池部分
属性描述、默认构造方法描述信息
main方法描述信息、静态代码块
查看字节码二进制。
每一行显示16ge字节,并且使用十六进制显示,因此一行显示32个数字,入宫直接看十六进制的一大串数字,肯定没有人能够知道这一大串数字究竟代表什么。因此需要一定的格式组织
每一行显示16ge字节,并且使用十六进制显示,因此一行显示32个数字,入宫直接看十六进制的一大串数字,肯定没有人能够知道这一大串数字究竟代表什么。因此需要一定的格式组织
魔数与版本。
基于上文测试用例中所使用的Test.class字节码文件,一起详细分析字节码文件的组成格式(一共10个组成部分),
在具体分析之前,下能给出一张字节码文件内容的全图,如图所示。下面会基于该图截出若干小图片,以分析字节码文件内容的格式。每次仅仅截取其中一小部分,可以根据每一行左边的行号进行定位。同时,为了使不同数据结构的前后排列布局显示得更加清晰,下面的截图会将上下几行信息截取下来,这样能够对整体字节码结构做到心中有数
基于上文测试用例中所使用的Test.class字节码文件,一起详细分析字节码文件的组成格式(一共10个组成部分),
在具体分析之前,下能给出一张字节码文件内容的全图,如图所示。下面会基于该图截出若干小图片,以分析字节码文件内容的格式。每次仅仅截取其中一小部分,可以根据每一行左边的行号进行定位。同时,为了使不同数据结构的前后排列布局显示得更加清晰,下面的截图会将上下几行信息截取下来,这样能够对整体字节码结构做到心中有数
魔数。
所有的.class字节码文件的开始4个字节都是魔数,并且其值一定是0xCAFEBABE.注意这里的CAFEBABE是指十六进制数值,并不是字符串"CAFEBABE",如图所示,如果开始4字节不是0xCAFEBABE,则JVM将会认为该文件不是.class字节码文件,并拒绝解析
所有的.class字节码文件的开始4个字节都是魔数,并且其值一定是0xCAFEBABE.注意这里的CAFEBABE是指十六进制数值,并不是字符串"CAFEBABE",如图所示,如果开始4字节不是0xCAFEBABE,则JVM将会认为该文件不是.class字节码文件,并拒绝解析
版本号。
根据字节码文件规范,魔数之后的4个字节为版本信息,前两个字节表示major version,即主版本号;后两个字节表示minor version,即次版本号。这里版本号的值为0x34,对应的十进制数是52.目前已发布的version包括1.1(45)、1.2(46)、1.3(47)、1.4(48)、1.5(49)、1.6(50)、1.7(51)、1.8(52).据此可以知道,该class文件是JDK1.8编译的,如图所示
根据字节码文件规范,魔数之后的4个字节为版本信息,前两个字节表示major version,即主版本号;后两个字节表示minor version,即次版本号。这里版本号的值为0x34,对应的十进制数是52.目前已发布的version包括1.1(45)、1.2(46)、1.3(47)、1.4(48)、1.5(49)、1.6(50)、1.7(51)、1.8(52).据此可以知道,该class文件是JDK1.8编译的,如图所示
常量池。
常量池是.class字节码文件中非常重要和核心的内容,一个Java类中绝大部分的信息都由常量池描述,尤其是Java类中定义的变量和方法,都由常量池保存。注意,对JVM有所研究的人,可能都知道JVM的内存模型中,有一块就是常量池,JVM堆区的常量池就是用于保存每一个Java类所对应的常量池的信息的,一个Java应用程序中所包含的所有Java类的常量池组成了JVM堆区中大的常量池
常量池是.class字节码文件中非常重要和核心的内容,一个Java类中绝大部分的信息都由常量池描述,尤其是Java类中定义的变量和方法,都由常量池保存。注意,对JVM有所研究的人,可能都知道JVM的内存模型中,有一块就是常量池,JVM堆区的常量池就是用于保存每一个Java类所对应的常量池的信息的,一个Java应用程序中所包含的所有Java类的常量池组成了JVM堆区中大的常量池
常量池的基本结构。
Java类所对应的常量池主要由常量池数量和常量池数组两部分组成,如图所示,常量池数量紧跟在次版本号的后面,占2字节,常量池数组则紧跟在常量池数量之后。
常量池数组,顾名思义,就是一个类似数组的结构。这个数组固化在字节码文件中,由多个元素组成。与一般数组概念不同的是,常量池数组中不同的元素的类型、结构都是不同的,长度也是不同的,但是每一种元素的第一个数据都是一个u1类型,该字节是标志位,占1个字节如图所示。JVM解析常量池时,根据这个u1类型来获取该元素的具体类型。
使用结构化的方式来描述常量池数组的编排,可以如下描述:
tag1 元素内容1 tag2 元素内容2 tag3 元素内容3 ... tagn 元素内容n
这里为了描述,在tag与元素内容之间添加了空格,但实际的class二进制文件中,这些tag与元素内容之间绝没有任何空格,也没有任何其他的多余信息,tag后面紧跟着元素内容,第一个元素内容结束了,紧接着就是第二个元素的tag信息,由此可见,整个字节码文件中的格式涉及都是非常紧凑的
Java类所对应的常量池主要由常量池数量和常量池数组两部分组成,如图所示,常量池数量紧跟在次版本号的后面,占2字节,常量池数组则紧跟在常量池数量之后。
常量池数组,顾名思义,就是一个类似数组的结构。这个数组固化在字节码文件中,由多个元素组成。与一般数组概念不同的是,常量池数组中不同的元素的类型、结构都是不同的,长度也是不同的,但是每一种元素的第一个数据都是一个u1类型,该字节是标志位,占1个字节如图所示。JVM解析常量池时,根据这个u1类型来获取该元素的具体类型。
使用结构化的方式来描述常量池数组的编排,可以如下描述:
tag1 元素内容1 tag2 元素内容2 tag3 元素内容3 ... tagn 元素内容n
这里为了描述,在tag与元素内容之间添加了空格,但实际的class二进制文件中,这些tag与元素内容之间绝没有任何空格,也没有任何其他的多余信息,tag后面紧跟着元素内容,第一个元素内容结束了,紧接着就是第二个元素的tag信息,由此可见,整个字节码文件中的格式涉及都是非常紧凑的
JVM所定义的11种常量。
常量池元素中的不同元素结构与类型都是不同的,正因如此,JVM只能定义有限的元素类型,并针对有限的类型进行专门解析。JVM一共定义了11种常量,如表所示。可以看到,类的方法信息、接口和继承信息、属性信息都是定义在NameAndType_Info中的。
常量池元素中的不同元素结构与类型都是不同的,正因如此,JVM只能定义有限的元素类型,并针对有限的类型进行专门解析。JVM一共定义了11种常量,如表所示。可以看到,类的方法信息、接口和继承信息、属性信息都是定义在NameAndType_Info中的。
常量池元素的复合结构。
常量池数组中的每一种元素的内容都是复合数据结构的,下面分别给出JVM所定义的常量池中每一种元素的具体结构,如表所示.
该表中tag值为1的常量池元素CONSTANT_Utf8_info,其组成结构分为3部分,分别是:tag、length和bytes,其中tag和length的长度分别是u1、u2,即分别占1字节和2字节。而bytes则是字符串的具体内容,其长度是length字节。在字节码文件中,该常量池元素最终所占的字节数是:
1+2+length
其他类型的常量池元素的组成结构类似
常量池数组中的每一种元素的内容都是复合数据结构的,下面分别给出JVM所定义的常量池中每一种元素的具体结构,如表所示.
该表中tag值为1的常量池元素CONSTANT_Utf8_info,其组成结构分为3部分,分别是:tag、length和bytes,其中tag和length的长度分别是u1、u2,即分别占1字节和2字节。而bytes则是字符串的具体内容,其长度是length字节。在字节码文件中,该常量池元素最终所占的字节数是:
1+2+length
其他类型的常量池元素的组成结构类似
常量池的结束位置。
相信不少人读到这里,可能潜意识回突然蹦出这么一个问题:整个字节码文件由多个部分构成,常量池数组只是其中一块,JVM在解析字节码文件时,一定需要分别读取各个部分的字节流,其中也包括常量池数组。但是常量池中有部分元素的值是bytes数组,其长度是随机变化的,那么JVM在解析时,是如何知道整个常量池的信息解析到什么位置结束呢?其实经过分析不难发现,首先,class文件给出了常量池的总数;其次,凡是碰到有bytes数组的常量池元素,class文件在常量池的每一个元素之前都会专门划分出2字节用于描述该常量池元素内容所占的字节长度,这样一来,常量池中每一个元素的长度是确定的,而常量池的总数也是确定的,JVM据此便可以从class文件中准确地计算出常量池结构体的末端位置(起始位置不用计算,是定死的,从第9个字节开始,前面8字节分别是魔数和版本号)
相信不少人读到这里,可能潜意识回突然蹦出这么一个问题:整个字节码文件由多个部分构成,常量池数组只是其中一块,JVM在解析字节码文件时,一定需要分别读取各个部分的字节流,其中也包括常量池数组。但是常量池中有部分元素的值是bytes数组,其长度是随机变化的,那么JVM在解析时,是如何知道整个常量池的信息解析到什么位置结束呢?其实经过分析不难发现,首先,class文件给出了常量池的总数;其次,凡是碰到有bytes数组的常量池元素,class文件在常量池的每一个元素之前都会专门划分出2字节用于描述该常量池元素内容所占的字节长度,这样一来,常量池中每一个元素的长度是确定的,而常量池的总数也是确定的,JVM据此便可以从class文件中准确地计算出常量池结构体的末端位置(起始位置不用计算,是定死的,从第9个字节开始,前面8字节分别是魔数和版本号)
常量池元素总数量。
前面8字节用于描述魔数和版本号,从第9字节开始的一大段字节流都用于描述常量池数组信息。其中第9和第10字节用于描述常量池元素的总数量.如图所示.第9和第10字节所保存的常量池数组大小是0x2b,换算成十进制是43,说明该字节码文件一共包含43个常量池元素。JVM规定,不使用第0个元素,因此实际上一共有42个常量池元素(后面在解析源码时,会看到源码中的确是从第1个元素开始解析的,而不是从第0个)
前面8字节用于描述魔数和版本号,从第9字节开始的一大段字节流都用于描述常量池数组信息。其中第9和第10字节用于描述常量池元素的总数量.如图所示.第9和第10字节所保存的常量池数组大小是0x2b,换算成十进制是43,说明该字节码文件一共包含43个常量池元素。JVM规定,不使用第0个元素,因此实际上一共有42个常量池元素(后面在解析源码时,会看到源码中的确是从第1个元素开始解析的,而不是从第0个)
第一个常量池元素。
常量池数量之后(即从第11字节开始),就是常量池数组。每一个常量池元素都以tag位标开始,tag位标都只占1字节长度。如图所示,第11字节对应的值是0a,对照上文所给的常量池11种元素的符合结构可知,tag位标为10代表的是CONSTANT_Methodref_info,即类中方法的符号引用,,这种类型的元素的结构组成如下:
# tag位标, 占1字节
# index, 占2字节
# index, 占2字节
既然tag位标已经占据了字节码文件的第11字节,则接下来的第12和13字节、14和15字节将合起来表示index.如图所示,这两组两个字节对应的值9和31.从11字节开始,到11字节到15字节为止,一个常量池元素就被描述完成。紧接着开始描述第二个常量池元素
常量池数量之后(即从第11字节开始),就是常量池数组。每一个常量池元素都以tag位标开始,tag位标都只占1字节长度。如图所示,第11字节对应的值是0a,对照上文所给的常量池11种元素的符合结构可知,tag位标为10代表的是CONSTANT_Methodref_info,即类中方法的符号引用,,这种类型的元素的结构组成如下:
# tag位标, 占1字节
# index, 占2字节
# index, 占2字节
既然tag位标已经占据了字节码文件的第11字节,则接下来的第12和13字节、14和15字节将合起来表示index.如图所示,这两组两个字节对应的值9和31.从11字节开始,到11字节到15字节为止,一个常量池元素就被描述完成。紧接着开始描述第二个常量池元素
第二个常量池元素。
第一个常量池元素后面紧跟着的第二个常量池元素。其第一字节是09,表示这是一个CONSTANT_Field_info类型的结构,它的组成是:
# tag位,占1字节
# index, 占2字节
# index, 占2字节。
第一个常量池元素后面紧跟着的第二个常量池元素。其第一字节是09,表示这是一个CONSTANT_Field_info类型的结构,它的组成是:
# tag位,占1字节
# index, 占2字节
# index, 占2字节。
父类常量。
由于Test.java类并没有显式击沉任何类,因此编译后处理成默认继承,即父类是java.lang.Object.
由于Test.java类并没有显式击沉任何类,因此编译后处理成默认继承,即父类是java.lang.Object.
变量型常量池元素。
变量包括成员变量、类变量(静态变量)信息。首先看累成员变量a的信息,如图所示。
图中选中的8字节,一共包含两个常量池元素信息,这两个常量池元素的类型都是字符串,因为其tta位都是1.第一个字符串常量的length是1,其值(即bytes)是0x61,正好对应UTF-8编码的字符a.第二个字符串常量的length也是1,其值是0x49,正好对应UTF-8编码的字符I.在JVM规范中,若变量的类型是I,则表示该变量的实际类型是int.这与前面对变量a的定义一致。
变量包括成员变量、类变量(静态变量)信息。首先看累成员变量a的信息,如图所示。
图中选中的8字节,一共包含两个常量池元素信息,这两个常量池元素的类型都是字符串,因为其tta位都是1.第一个字符串常量的length是1,其值(即bytes)是0x61,正好对应UTF-8编码的字符a.第二个字符串常量的length也是1,其值是0x49,正好对应UTF-8编码的字符I.在JVM规范中,若变量的类型是I,则表示该变量的实际类型是int.这与前面对变量a的定义一致。
接着看类变量si的定义,如图所示,,图中选中的27字节,一共描述了两个常量池元素,这两个常量池元素的类型也都是字符串。第一个字符串的length为2,其值是0x7369,对应utf-8编码的字符串si.第二个字符串的length为0x13,即19,其值是0x4C 6A 61 76 61 2F 6C 61 6E 67 2F 49 6E 74 65 67 65 72 3E,这一串值是ASCII字符,每两个十六进制数对应一个ASCII字符,这些数字连起来就对应一个字符串,所对应的字符串是Ljava/lang/Integer.这两个常量池元素合起来,描述了Test类中的static Integer si这样的类变量.
接着看成员变量s的定义,如图所示.图中选中的25字节,一共描述了两个常量池元素,这两个常量池元素的类型也都是字符串。第一个字符串的length为1,其值是0x73,对应UTF-8编码的字符s.第二个字符串的length为0x12,即18,其值是0x4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B,对应的UTF-8编码的字符串Ljava/lang/String;
这两个常量池元素合起来描述了Test类中的s字符串变量。
这两个常量池元素合起来描述了Test类中的s字符串变量。
访问表示与继承信息。
前面解析完了常量池的所有元素,但是别忘了,一个.class字节码文件中共有10个组成部分,常量池只不过是其中一个组成部分,因此现在需要把目光一出来,继续回到.class字节码文件后续的组成部分的分析
前面解析完了常量池的所有元素,但是别忘了,一个.class字节码文件中共有10个组成部分,常量池只不过是其中一个组成部分,因此现在需要把目光一出来,继续回到.class字节码文件后续的组成部分的分析
access_flags。
在字节码文件中,常量池数组之后紧跟着的是access_flags结构,该结构类型是u2,占2字节。access_flags代表访问标志位,该标志用于标注类或接口层次的访问信息,如当前Class是类还是接口,是否定义为public类型,是否定义为abstract类型等,,对于测试用例Test.class,其access_flags信息如图所示,由图可知,Test.class的access_flags的值为0x0021,这代表什么含义呢?根据JVm规范,access_flags的可选项值如表所示
在字节码文件中,常量池数组之后紧跟着的是access_flags结构,该结构类型是u2,占2字节。access_flags代表访问标志位,该标志用于标注类或接口层次的访问信息,如当前Class是类还是接口,是否定义为public类型,是否定义为abstract类型等,,对于测试用例Test.class,其access_flags信息如图所示,由图可知,Test.class的access_flags的值为0x0021,这代表什么含义呢?根据JVm规范,access_flags的可选项值如表所示
access_flags可选项。
由于Test.class中的access_flags=0x21,因此该类的访问标识既包含ACC_PUBLIC(0x0001)也包含ACC_SUPER(0x0020).其中,自JDK1.2以后,类被编译出来的invokespecial字节码指令是否允许使用的选项都是真,因此access_flags的值都会带有ACC_SUPER标识位
由于Test.class中的access_flags=0x21,因此该类的访问标识既包含ACC_PUBLIC(0x0001)也包含ACC_SUPER(0x0020).其中,自JDK1.2以后,类被编译出来的invokespecial字节码指令是否允许使用的选项都是真,因此access_flags的值都会带有ACC_SUPER标识位
this_class。
在字节码文件中,紧跟着access_flags访问标识之后的是this_class结构,该结构类型是u2,占2字节。this_class记录当前类的全限定名(报名+类名),其值指向常量池中对应的索引值,Test.class中的this_class信息如图所示
在字节码文件中,紧跟着access_flags访问标识之后的是this_class结构,该结构类型是u2,占2字节。this_class记录当前类的全限定名(报名+类名),其值指向常量池中对应的索引值,Test.class中的this_class信息如图所示
由图可知Test.class的this_class的值为5,说明该值对应5号常量池元素。上文使用javap -verbose命令将Test。class的所有常量池连同其编号一起打印了出来,根据打印信息可知,5号常量池元素的信息如下:
#5 = Class #35 // com/Test
#35 = Utf8 com/Test
由此可知,this_class的确是Test,类的全限定名就是com/Test
#5 = Class #35 // com/Test
#35 = Utf8 com/Test
由此可知,this_class的确是Test,类的全限定名就是com/Test
super_class。
在字节码文件中,紧跟着this_class访问标识之后的是super_class结构,该结构类型是u2,占2字节。super_class记录当前类的父类全限定名,其值指向常量池中对应的索引值。Test.class中的super_class信息如图所示。由图可知,Test.class的super_class的值为9,说明该值对应9号常量池元素。上文使用javap-verbose命令将Test.class的所有常量池连同其编号一起打印了出来,根据打印信息可知,9号常量池元素的信息如下:
#9 = Class #39 // java/lang/Object
#39 = Utf8 java/lang/Object
由于Test.class并没有显式继承任何基类,因此编译时便让其默认继承java.lang.Object。这与字节码中的super_class值是一致的。
在字节码文件中,紧跟着this_class访问标识之后的是super_class结构,该结构类型是u2,占2字节。super_class记录当前类的父类全限定名,其值指向常量池中对应的索引值。Test.class中的super_class信息如图所示。由图可知,Test.class的super_class的值为9,说明该值对应9号常量池元素。上文使用javap-verbose命令将Test.class的所有常量池连同其编号一起打印了出来,根据打印信息可知,9号常量池元素的信息如下:
#9 = Class #39 // java/lang/Object
#39 = Utf8 java/lang/Object
由于Test.class并没有显式继承任何基类,因此编译时便让其默认继承java.lang.Object。这与字节码中的super_class值是一致的。
interface。
1.interfaces_count。
在字节码文件中,紧跟着super_class访问标识之后的是interfaces_count结构,该结构类型是u2,占2字节。interfaces_count结构记录当前类所实现的接口数量。Test.class中的interfaces_count结构信息如图所示.由图可知,Test.class的interfaces_count的值为0,说明Test.class并没有实现任何接口
2.interfaces[interfaces_count]
interfaces表示接口索引集合,是一组u2类型数据的集合,该结构描述当前类实现了哪些接口,这些被实现的接口将按implements语句(如果该类本身为接口,则为extends 语句)后的接口顺序从左到右排列在接口的索引集合中。
由于Test.class的interfaces_count值为0.因此字节码文件中并没有interfaces信息。
1.interfaces_count。
在字节码文件中,紧跟着super_class访问标识之后的是interfaces_count结构,该结构类型是u2,占2字节。interfaces_count结构记录当前类所实现的接口数量。Test.class中的interfaces_count结构信息如图所示.由图可知,Test.class的interfaces_count的值为0,说明Test.class并没有实现任何接口
2.interfaces[interfaces_count]
interfaces表示接口索引集合,是一组u2类型数据的集合,该结构描述当前类实现了哪些接口,这些被实现的接口将按implements语句(如果该类本身为接口,则为extends 语句)后的接口顺序从左到右排列在接口的索引集合中。
由于Test.class的interfaces_count值为0.因此字节码文件中并没有interfaces信息。
字段信息
fields_count。
在字节码文件中,接口区之后紧接着fields_count结构。该结构类型是u2,占2字节,该值记录当前类中所定义的变量总数量,包括类成员变量和类变量(即静态变量)。Test.class中的fields_count信息如图所示。可知,Test.class类中一共包含3个变量。从Test源文件也可以看出,该类的确包含3个变量,分别是a、si和s.
在字节码文件中,接口区之后紧接着fields_count结构。该结构类型是u2,占2字节,该值记录当前类中所定义的变量总数量,包括类成员变量和类变量(即静态变量)。Test.class中的fields_count信息如图所示。可知,Test.class类中一共包含3个变量。从Test源文件也可以看出,该类的确包含3个变量,分别是a、si和s.
field_info_fields[field_count]
在字节码文件中,紧跟着fields_count之后的是fields结构,该结构长度不确定,不同的变量类型所占长度是不同的。fields记录类中所定义的各个变量的详细信息,包括变量名、变量类型、方法标识、属性等
在字节码文件中,紧跟着fields_count之后的是fields结构,该结构长度不确定,不同的变量类型所占长度是不同的。fields记录类中所定义的各个变量的详细信息,包括变量名、变量类型、方法标识、属性等
1.fields结构组成格式。
要分析fields结构信息,首先需要清楚该结构的数据组成格式,其格式如表所示.表4.3中各个组成元素的说明如下:
# access_flags,标识变量的访问标识,该值是可选的,由JVM规范规定。
# name_index,表示变量的简单名称引用,占2字节,其值指向常量池的索引
# descriptor_index,表示变啊零的类型信息引用,占2字节,其值指向常量池的所以i你
fields结构体实际上是一个数组,数组中的每一个元素的结构都表所示,即每一个元素都包含访问表示、名称索引、描述信息索引、属性数量和属性信息。其中,如果属性数量为0,则没有属性信息。由于访问标识、名称、描述信息、属性数量的字节长度是确定的,因此JVM可以在解析过程中计算出fields结构所占的全部字节数。。
要分析fields结构信息,首先需要清楚该结构的数据组成格式,其格式如表所示.表4.3中各个组成元素的说明如下:
# access_flags,标识变量的访问标识,该值是可选的,由JVM规范规定。
# name_index,表示变量的简单名称引用,占2字节,其值指向常量池的索引
# descriptor_index,表示变啊零的类型信息引用,占2字节,其值指向常量池的所以i你
fields结构体实际上是一个数组,数组中的每一个元素的结构都表所示,即每一个元素都包含访问表示、名称索引、描述信息索引、属性数量和属性信息。其中,如果属性数量为0,则没有属性信息。由于访问标识、名称、描述信息、属性数量的字节长度是确定的,因此JVM可以在解析过程中计算出fields结构所占的全部字节数。。
变量的access_flags有如表所示的可选项。
其中,ACC_PUBLIC、ACC_PRIVATE和ACC_PROTECTED这3个标志只能选择一个,接口中的字段必须有ACC_PUBLIC、ACC_STATIC和ACC_FINAL标志,class文件对此并无规定,这些都是Java语言所要求的
其中,ACC_PUBLIC、ACC_PRIVATE和ACC_PROTECTED这3个标志只能选择一个,接口中的字段必须有ACC_PUBLIC、ACC_STATIC和ACC_FINAL标志,class文件对此并无规定,这些都是Java语言所要求的
2.第1个变量a
分析玩理论,下面来看看Test.class字节码中的变量信息实际都指的是什么。第一个field紧跟在fields_count这个结构体之后,如图所示,,由图可知,第一个变量的访问标识是1,由参考变量访问标识选项表可知,1表示ACC_PUBLIC.第一个变量的名称索引是10.类型索引是11,按照上文使用javap -verbose命令所打印的字节码常量池信息可知,常量池中第10和第11个元素的信息如下:
#10 = Utf8 a
#11 = Utf8 I
由此可知,当前描述的变量是a,其数据类型是int。根据图中所示,还可以知道,变量a的属性数量是0,因为没有属性,所以字段描述结构中最后的元素attributes也就不存在。这里需要注意描述信息索引,其指向常量池中的号元素,11号元素的值是I,这里I代表什么意思呢?在JVM规范中,每个变量/字段都有描述信息,描述信息主要描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)和返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写字符表示,而对象类型则用字符L加对象全限定名表示。为了压缩字节码文件的体积(字节码文件最终也会占用服务器硬盘资源和内存资源),对于基本数据类型,JVM都仅使用一个大写字母来表示。
分析玩理论,下面来看看Test.class字节码中的变量信息实际都指的是什么。第一个field紧跟在fields_count这个结构体之后,如图所示,,由图可知,第一个变量的访问标识是1,由参考变量访问标识选项表可知,1表示ACC_PUBLIC.第一个变量的名称索引是10.类型索引是11,按照上文使用javap -verbose命令所打印的字节码常量池信息可知,常量池中第10和第11个元素的信息如下:
#10 = Utf8 a
#11 = Utf8 I
由此可知,当前描述的变量是a,其数据类型是int。根据图中所示,还可以知道,变量a的属性数量是0,因为没有属性,所以字段描述结构中最后的元素attributes也就不存在。这里需要注意描述信息索引,其指向常量池中的号元素,11号元素的值是I,这里I代表什么意思呢?在JVM规范中,每个变量/字段都有描述信息,描述信息主要描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)和返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写字符表示,而对象类型则用字符L加对象全限定名表示。为了压缩字节码文件的体积(字节码文件最终也会占用服务器硬盘资源和内存资源),对于基本数据类型,JVM都仅使用一个大写字母来表示。
如表所示是各个基本数据类型所对应的标识符。对于数组类型,每一维使用一个前置的"["字符来描述,如"int[]"将被记录为"[I","String[][]"将被记录为"[[Ljava/lang/String;"。
用描述符描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的岩哥顺序放在一组"()"之内,如方法"String getAll(int id, String name)"的描述符"(I,Ljava/lang/String;)Ljava/lang/String;"。
用描述符描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的岩哥顺序放在一组"()"之内,如方法"String getAll(int id, String name)"的描述符"(I,Ljava/lang/String;)Ljava/lang/String;"。
变量si和s。
由于变量a的属性数量是0,字节码文件中不包含attributes信息,因此第一个变量只占8字节,分别是访问标识、变量名引用、描述信息引用和属性数量。由于字节码文件标识一共包含3个变量,因此描述变量a的字节码流的后面的字节码流将继续描述另外两个变量。Test.class类的另外两个变量的字节码内容如图所示.从图中可知,这两个变量在字节码文件中也各占8字节,因为其attributes_count都是0,变量si的access_flags是8,标识这是一个带有static修饰符的变量,而变量s的acces_flags是0,表示该变量没有任何访问修饰符,对照源程序,的确是这样。
两个变量名称分别引用常量池中的12号元素和14号元素,对照上文使用javap -verbose命令打印的常量池信息,可知这两个变量名分别是si和s
#12 = Utf8 si
#13 = Utf8 Ljava/lang/Integer;
#14 = Utf8 s
#15 = Utf8 Ljava/lang/String;
两个变量描述信息分别引用常量池中的13号和15号元素,对照打印出来的常量池信息可知,其变量类型分别是Ljava/lang/Integer和Ljava/lang/String.由此也可知,对于引用类型的变量,字节码文件描述其变量类型的格式是"L+类全限定名"
由于变量a的属性数量是0,字节码文件中不包含attributes信息,因此第一个变量只占8字节,分别是访问标识、变量名引用、描述信息引用和属性数量。由于字节码文件标识一共包含3个变量,因此描述变量a的字节码流的后面的字节码流将继续描述另外两个变量。Test.class类的另外两个变量的字节码内容如图所示.从图中可知,这两个变量在字节码文件中也各占8字节,因为其attributes_count都是0,变量si的access_flags是8,标识这是一个带有static修饰符的变量,而变量s的acces_flags是0,表示该变量没有任何访问修饰符,对照源程序,的确是这样。
两个变量名称分别引用常量池中的12号元素和14号元素,对照上文使用javap -verbose命令打印的常量池信息,可知这两个变量名分别是si和s
#12 = Utf8 si
#13 = Utf8 Ljava/lang/Integer;
#14 = Utf8 s
#15 = Utf8 Ljava/lang/String;
两个变量描述信息分别引用常量池中的13号和15号元素,对照打印出来的常量池信息可知,其变量类型分别是Ljava/lang/Integer和Ljava/lang/String.由此也可知,对于引用类型的变量,字节码文件描述其变量类型的格式是"L+类全限定名"
方法信息
methods_count。
在字节码文件中,紧跟着变量描述结构fields后面的是methods_count结构,该结构类型是u2,占2字节。该结构描述类中一共包含多少个方法。Test.class文件中该结构信息如图所示.由图可知,其值为4,即Test类中一共有4个方法。可能很多人对此会有疑惑,在Test源程序中明明只定义了两个方法,为什么字节码文件中却显示有4个呢?这是因为在编译期间,编译器回自动为一个类增加void <clinit>()这样一个方法,其方法名就是"<clinit>",返回值为void.该方法的作用主要是执行类的初始化,源程序中的所有static类型的变量都会在这个方法中完成初始化,全部被static{}所包围的程序都在这个方法中执行.同时,在源代码中,并没有为Test类定义构造函数,因此编译器会自动为该类添加一个默认的构造函数。因此,字节码文件会显示Test类中一共包含4个方法。
在字节码文件中,紧跟着变量描述结构fields后面的是methods_count结构,该结构类型是u2,占2字节。该结构描述类中一共包含多少个方法。Test.class文件中该结构信息如图所示.由图可知,其值为4,即Test类中一共有4个方法。可能很多人对此会有疑惑,在Test源程序中明明只定义了两个方法,为什么字节码文件中却显示有4个呢?这是因为在编译期间,编译器回自动为一个类增加void <clinit>()这样一个方法,其方法名就是"<clinit>",返回值为void.该方法的作用主要是执行类的初始化,源程序中的所有static类型的变量都会在这个方法中完成初始化,全部被static{}所包围的程序都在这个方法中执行.同时,在源代码中,并没有为Test类定义构造函数,因此编译器会自动为该类添加一个默认的构造函数。因此,字节码文件会显示Test类中一共包含4个方法。
method_info_methods[methods_count]。
紧跟在methods_count后面的是methods结构,这是一个数组,每一个方法的全部细节都包含在里面,包括代码指令
紧跟在methods_count后面的是methods结构,这是一个数组,每一个方法的全部细节都包含在里面,包括代码指令
1.methods结构组成格式。
要分析methods结构信息,首先需要清楚该结构的数据组成格式,其格式如表所示。。由表可知,方法各个数据项的含义非常相似,仅在访问标志位和属性表集合的可选项上有略微不同。这些字段的含义与上文给出的fields结构的字段含义基本相同。
要分析methods结构信息,首先需要清楚该结构的数据组成格式,其格式如表所示。。由表可知,方法各个数据项的含义非常相似,仅在访问标志位和属性表集合的可选项上有略微不同。这些字段的含义与上文给出的fields结构的字段含义基本相同。
其中,JVM规范为access_flags规定了一组可选项值,如表所示。
由于ACC_VOLATILE标志和ACC_TRANSIENT标志不能修饰方法,所以access_flags中不包含这两项,同时增加ACC_SYNCHRONIZED标志、ACC_NATIVE标志、ACC_STRAICTFP标志和ACC_ABSTRACT标志
由于ACC_VOLATILE标志和ACC_TRANSIENT标志不能修饰方法,所以access_flags中不包含这两项,同时增加ACC_SYNCHRONIZED标志、ACC_NATIVE标志、ACC_STRAICTFP标志和ACC_ABSTRACT标志
2.第一个方法 void <init>()
上面了解了方法描述的信息结构,下面来实际看看Test.class字节码文件中的第一个方法究竟是如何描述的。紧跟在methods_count后面的就是第一个方法的信息,如图所示.由图可以看出,,字节码文件对方法的描述比较复杂,不像前面对魔数、版本号、常量池等信息的描述那么简单。但无论多么复杂的描述,总是遵循其内在的结构逻辑,只要按照JVM的规范按图索骥,总是能够分析清楚字节码所要表达的含义。按照fields的结构组成格式,前2字节描述access_flags,即访问标识,由图可知其值为0x0001,对照上面所给出的访问标识可选项值的表可知,该值标识该方法的修饰符是ACC_PUBLIC,也即这是一个public类型的方法。接下来的2字节描述是name_index,该字段描述的是方法名,其值指向常量池中对应的元素标号。它的值是0x10,指向常量池中第16号元素,根据javap-verbose命令所打印出的常量池信息可知,常量池中第16号元素是<init>,即当前所描述的方法名是"<init>",该方法是它的默认初始化方法。
接下来的2字节描述descriptor_index,该字段描述的是方法的入参和出参信息,其值指向常量池中对应的元素编号。由图可知,其值是0x11,指向常量池中第17号元素,根据打印的常量池表,可知17号元素是()V,这表示当前方法没有入参(因为是空括号),并且方法的返回值类型是void(V代表void)这里要注意,按照JVM的规范,描述符对入参将严格按照源程序中所定义的参数列表顺序,从左到右依次放入()内,如方法String getAll(int id, String name)的描述符为"(I,Ljava/lang/String;)Ljava/lang/String;"。
根据这些信息可知,字节码所描述的第一个方法是void <init>();.接下来的2字节描述方法所包含的属性的总数量attributes_count,由图可知其值为0x01,标识当前方法一共包含1个属性。该字段后面的字节码流将描述详细的属性信息。在分析字节码流中的属性信息之前,有必要了解attributes这一字段结构的组成格式。
上面了解了方法描述的信息结构,下面来实际看看Test.class字节码文件中的第一个方法究竟是如何描述的。紧跟在methods_count后面的就是第一个方法的信息,如图所示.由图可以看出,,字节码文件对方法的描述比较复杂,不像前面对魔数、版本号、常量池等信息的描述那么简单。但无论多么复杂的描述,总是遵循其内在的结构逻辑,只要按照JVM的规范按图索骥,总是能够分析清楚字节码所要表达的含义。按照fields的结构组成格式,前2字节描述access_flags,即访问标识,由图可知其值为0x0001,对照上面所给出的访问标识可选项值的表可知,该值标识该方法的修饰符是ACC_PUBLIC,也即这是一个public类型的方法。接下来的2字节描述是name_index,该字段描述的是方法名,其值指向常量池中对应的元素标号。它的值是0x10,指向常量池中第16号元素,根据javap-verbose命令所打印出的常量池信息可知,常量池中第16号元素是<init>,即当前所描述的方法名是"<init>",该方法是它的默认初始化方法。
接下来的2字节描述descriptor_index,该字段描述的是方法的入参和出参信息,其值指向常量池中对应的元素编号。由图可知,其值是0x11,指向常量池中第17号元素,根据打印的常量池表,可知17号元素是()V,这表示当前方法没有入参(因为是空括号),并且方法的返回值类型是void(V代表void)这里要注意,按照JVM的规范,描述符对入参将严格按照源程序中所定义的参数列表顺序,从左到右依次放入()内,如方法String getAll(int id, String name)的描述符为"(I,Ljava/lang/String;)Ljava/lang/String;"。
根据这些信息可知,字节码所描述的第一个方法是void <init>();.接下来的2字节描述方法所包含的属性的总数量attributes_count,由图可知其值为0x01,标识当前方法一共包含1个属性。该字段后面的字节码流将描述详细的属性信息。在分析字节码流中的属性信息之前,有必要了解attributes这一字段结构的组成格式。
3.9大属性表集合。
在class文件中,属性表、放发表中都可以包含自己的属性表集合,用于描述某些场景的专有信息。这里仅列出大概的概括信息,详细信息可以去阅读官方文档。与class文件中其他数据项对长度、顺序、格式的严格要求不同,属性表集合不要求其中包含的属性表具有严格的顺序,并且只要属性的名称不与已有的属性名称重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息。虚拟机在运行时会据略不能识别的属性。为了能正确解析class文件,虚拟机规范中预定义了虚拟机实现必须能够识别的9项属性,如表所示。
这9种属性种的每一种属性又都是一个复合结构,均有各自的表结构。这9种表结构有一个共同的特点,即均由一个u2类型的属性名称开始,可以通过这个属性名称来判断属性的类型。该u2类型的属性名称指向常量池中对应的元素。下面描述这9种属性具体的复合组成结构。
在class文件中,属性表、放发表中都可以包含自己的属性表集合,用于描述某些场景的专有信息。这里仅列出大概的概括信息,详细信息可以去阅读官方文档。与class文件中其他数据项对长度、顺序、格式的严格要求不同,属性表集合不要求其中包含的属性表具有严格的顺序,并且只要属性的名称不与已有的属性名称重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息。虚拟机在运行时会据略不能识别的属性。为了能正确解析class文件,虚拟机规范中预定义了虚拟机实现必须能够识别的9项属性,如表所示。
这9种属性种的每一种属性又都是一个复合结构,均有各自的表结构。这9种表结构有一个共同的特点,即均由一个u2类型的属性名称开始,可以通过这个属性名称来判断属性的类型。该u2类型的属性名称指向常量池中对应的元素。下面描述这9种属性具体的复合组成结构。
1.Code属性
Java程序方法体种的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性种。当然不是所有的方法都必须有这个属性(接口中的方法或抽象方法就不存在Code属性),Code属性结构如表所示。
Code属性表中相关字段的含义如下:
# max_stack,操作数栈深度最大值,在方法执行的任何时刻,操作数栈深度都不会超过这个值。虚拟机运行时根据这个值来分配栈帧的操作数栈深度
# max_locals,局部变量表所需存储空间,单位为Slot.并不是所有局部变量占用的Slot之和,当一个局部变量的声明周期结束后,其所占用的Slot将分配给其他依然存活的局部变量使用,按此方式计算出方法运行时局部变量表所需的存储空间
# code_length和code,用来存放Java源程序经编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流
每一个指令都是一个u1类型的单字节,当虚拟机读到code中的一个字节码(一个字节能标识256种指令,Java虚拟机规范定义了其中约200多个编码对应的指令)时,就可以判断出该字节码代表的指令,指令后面是否带有参数,参数该如何解释,虽然code_length占4字节,但是Java虚拟机规范限制一个方法不能超过65535条字节码指令,如果超过,Javac将拒绝编译。
Java程序方法体种的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性种。当然不是所有的方法都必须有这个属性(接口中的方法或抽象方法就不存在Code属性),Code属性结构如表所示。
Code属性表中相关字段的含义如下:
# max_stack,操作数栈深度最大值,在方法执行的任何时刻,操作数栈深度都不会超过这个值。虚拟机运行时根据这个值来分配栈帧的操作数栈深度
# max_locals,局部变量表所需存储空间,单位为Slot.并不是所有局部变量占用的Slot之和,当一个局部变量的声明周期结束后,其所占用的Slot将分配给其他依然存活的局部变量使用,按此方式计算出方法运行时局部变量表所需的存储空间
# code_length和code,用来存放Java源程序经编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流
每一个指令都是一个u1类型的单字节,当虚拟机读到code中的一个字节码(一个字节能标识256种指令,Java虚拟机规范定义了其中约200多个编码对应的指令)时,就可以判断出该字节码代表的指令,指令后面是否带有参数,参数该如何解释,虽然code_length占4字节,但是Java虚拟机规范限制一个方法不能超过65535条字节码指令,如果超过,Javac将拒绝编译。
2.ConstantValue属性
ConstantValue属性通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量(类变量)才可以使用这项属性。其结构如表所示。
可以看出,ConstantValue属性是一个定长属性,其中attribute_length的值固定为0x00000002,constsant_index为一常量池字面量类型常量索引(class文件格式的常量类型种只有与基本类型和字符串类型相对应的字面量常量,所以ConstantValue属性只支持基本类型和字符串类型).
对非static类型变量(实例变量,如int a = 123;)的赋值是在实例构造函数<int>种进行的。
对类变量(如static int a = 123;) 的赋值有两种选择,在类构造函数<clinit>方法种或使用ConstantValue属性。当前javac编译器的选择是,如果变量同时被static和final修饰(虚拟机规范只要求有ConstantValue属性的字段必须设置ACC_STATIC标志,对final关键字的要求是javac编译器自己加入的要求),并且该变量的数据类型为基本类型或字符串类型,就生成ConstantValue和属性进行初始化;否则在类构造函数<clinit>种进行初始化。
ConstantValue属性通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量(类变量)才可以使用这项属性。其结构如表所示。
可以看出,ConstantValue属性是一个定长属性,其中attribute_length的值固定为0x00000002,constsant_index为一常量池字面量类型常量索引(class文件格式的常量类型种只有与基本类型和字符串类型相对应的字面量常量,所以ConstantValue属性只支持基本类型和字符串类型).
对非static类型变量(实例变量,如int a = 123;)的赋值是在实例构造函数<int>种进行的。
对类变量(如static int a = 123;) 的赋值有两种选择,在类构造函数<clinit>方法种或使用ConstantValue属性。当前javac编译器的选择是,如果变量同时被static和final修饰(虚拟机规范只要求有ConstantValue属性的字段必须设置ACC_STATIC标志,对final关键字的要求是javac编译器自己加入的要求),并且该变量的数据类型为基本类型或字符串类型,就生成ConstantValue和属性进行初始化;否则在类构造函数<clinit>种进行初始化。
3.Exceptions属性
该属性列举出方法种可能抛出的受查异常(即方法描述时throws关键字后列出的异常)。与Code属性平级,与Code属性包含的异常表不同,其结构如表所示。
number_of_exceptions表示可能抛出number_of_exception种受查异常。exception_index_table为异常索引集合,一组u2类型exception_index的集合,每一个exception_index为一个指向常量池中一个CONSTANT_Class_info型常量的索引,代表该受查异常的类型。
该属性列举出方法种可能抛出的受查异常(即方法描述时throws关键字后列出的异常)。与Code属性平级,与Code属性包含的异常表不同,其结构如表所示。
number_of_exceptions表示可能抛出number_of_exception种受查异常。exception_index_table为异常索引集合,一组u2类型exception_index的集合,每一个exception_index为一个指向常量池中一个CONSTANT_Class_info型常量的索引,代表该受查异常的类型。
4.InnerClasses属性。
该属性用于记录内部类和宿主类之间的关系。如果一个类中定义了内部类,编译器将会为这个类与这个类包含的内部类生成InnerClasses属性,其结构如表所示。inner_classes为内部类表集合,一组内部类表类型数据的集合,number_of_classes即为集合中内部类表类型数据的个数。
该属性用于记录内部类和宿主类之间的关系。如果一个类中定义了内部类,编译器将会为这个类与这个类包含的内部类生成InnerClasses属性,其结构如表所示。inner_classes为内部类表集合,一组内部类表类型数据的集合,number_of_classes即为集合中内部类表类型数据的个数。
每一个内部类的信息都由一个inner_classes_info表来描述,inner_classes_info表结构如表所示。
inner_class_info_index和outer_class_info_index指向常量池中CONSTANT_Class_info类型常量索引,该CONSTANT_Class_info类型常量指向常量池中CONSTANT_Utf8_info类型常量,分别为内部类的全限定名和宿主类的全限定名。
inner_name_index指向常量池中CONSTANT_Utf8_info类型常量的索引,为内部类名称,如果为匿名内部类,则该值为0.
inner_name_access_flags类似于access_flags,是内部类的访问标志,该标识的可选项值与前面描述类的访问属性的可选项值一致
inner_class_info_index和outer_class_info_index指向常量池中CONSTANT_Class_info类型常量索引,该CONSTANT_Class_info类型常量指向常量池中CONSTANT_Utf8_info类型常量,分别为内部类的全限定名和宿主类的全限定名。
inner_name_index指向常量池中CONSTANT_Utf8_info类型常量的索引,为内部类名称,如果为匿名内部类,则该值为0.
inner_name_access_flags类似于access_flags,是内部类的访问标志,该标识的可选项值与前面描述类的访问属性的可选项值一致
5.LineNumberTable属性。
用于描述Java源码的行号与字节码行号之间的对应关系,非运行时必需属性,会默认生成至class文件中,可以使用javac的-g:none或-g:lines命令关闭或要求生成该项属性信息,其结构如表所示。
line_number_table是一组line_number_info类型数据的集合,其所包含的line_number_info类型数据的数量为line_number_table_length.
用于描述Java源码的行号与字节码行号之间的对应关系,非运行时必需属性,会默认生成至class文件中,可以使用javac的-g:none或-g:lines命令关闭或要求生成该项属性信息,其结构如表所示。
line_number_table是一组line_number_info类型数据的集合,其所包含的line_number_info类型数据的数量为line_number_table_length.
line_number_info属性结构如表所示。不生成该属性的最大影响是:
# 抛出异常时,堆栈将不会显示出错的行号
# 调试程序时无法按照源码设置断点。
# 抛出异常时,堆栈将不会显示出错的行号
# 调试程序时无法按照源码设置断点。
6.LocalVariableTable属性。
用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,非运行时必需属性,默认不会生成至class文件中,可以使用javac的-g:none或-g:vars命令关闭或要求生成该项属性信息,其结构如表所示。
local_variable是一组local_variable_info类型数据的集合,其所包含的local_variable_info类型数据的数量为local_variable_table_length.
用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,非运行时必需属性,默认不会生成至class文件中,可以使用javac的-g:none或-g:vars命令关闭或要求生成该项属性信息,其结构如表所示。
local_variable是一组local_variable_info类型数据的集合,其所包含的local_variable_info类型数据的数量为local_variable_table_length.
local_variable_info结构如表所示。
start_pc+length即为该局部变量在字节码中的作用域范围。不生成该属性的最大影响是:
# 当其他人引用这个方法时,所有的参数名称都将丢失,IDE可能会使用诸如arg0、arg1之类的占位符代替原有的参数名称,对代码运行无影响,会给代码的编写带来不便
# 调试时调试器无法根据参数名称从运行上下文中获取参数值
start_pc+length即为该局部变量在字节码中的作用域范围。不生成该属性的最大影响是:
# 当其他人引用这个方法时,所有的参数名称都将丢失,IDE可能会使用诸如arg0、arg1之类的占位符代替原有的参数名称,对代码运行无影响,会给代码的编写带来不便
# 调试时调试器无法根据参数名称从运行上下文中获取参数值
7.SourceFile属性。
用于记录生成这个class文件的源码文件名称,为可选项,可以使用javac的-g:none或-g:source命令关闭或要求生成该项属性信息,其结构如表所示。
可以看出,SourceFile属性是一个定长属性,sourcefile_index是指向常量池中一个CONSTANT_Utf8_info类型常量的索引,常量的值为源码文件的文件名。对大多数文件,类名和文件名是一致的,少数特殊类型除外(如内部类),此时如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错误代码所属的文件名
用于记录生成这个class文件的源码文件名称,为可选项,可以使用javac的-g:none或-g:source命令关闭或要求生成该项属性信息,其结构如表所示。
可以看出,SourceFile属性是一个定长属性,sourcefile_index是指向常量池中一个CONSTANT_Utf8_info类型常量的索引,常量的值为源码文件的文件名。对大多数文件,类名和文件名是一致的,少数特殊类型除外(如内部类),此时如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错误代码所属的文件名
8.Deprecated属性和Synthetic属性。
这两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。Deprecated属性标识某个类、字段或方法已经被程序作者定为不再推荐使用,可在代码中使用@Deprecated注解进行设置。
Synthetic属性表示该字段或方法不是由Java源码直接产生的,而是由编译器自行添加的(当然也可以设置访问标志ACC_SYNTHETIC,所有由非用户代码产生的类、方法和字段都应该至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造函数<init>和类构造函数<clinit>)。
这两项属性的结构(当然attribute_length的值必须为0x00000000)如表所示。
这两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。Deprecated属性标识某个类、字段或方法已经被程序作者定为不再推荐使用,可在代码中使用@Deprecated注解进行设置。
Synthetic属性表示该字段或方法不是由Java源码直接产生的,而是由编译器自行添加的(当然也可以设置访问标志ACC_SYNTHETIC,所有由非用户代码产生的类、方法和字段都应该至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造函数<init>和类构造函数<clinit>)。
这两项属性的结构(当然attribute_length的值必须为0x00000000)如表所示。
4.继续第一个方法。
我们有了对属性表的基本结构认识,接下来便可以继续分析前面第一个方法的字节码。Test.class字节码文件中,如图所示的两个字节一起组成了第一个方法的属性表数量attributes_count,紧跟在属性表数量后面的是attributes数组的第一个属性。。上文中提到,虽然JVM所支持9大属性,其相互之间格式相差甚远,但是都会以一个u2类型的属性名称开始,JVM根据名称便可知道当前描述的到底是这9大属性中的哪一个属性。Test.class字节码文件中,紧跟在attributes_count后面的便是这两个字节,其值是0x0012。属性表的名称索引指向常量池中对应的位置,根据前面打印出的Test.class常量池信息可知,常量池中的第0x0012(即18)号元素的值是Code,正是前面所介绍的9大属性中的Code方法表。
我们有了对属性表的基本结构认识,接下来便可以继续分析前面第一个方法的字节码。Test.class字节码文件中,如图所示的两个字节一起组成了第一个方法的属性表数量attributes_count,紧跟在属性表数量后面的是attributes数组的第一个属性。。上文中提到,虽然JVM所支持9大属性,其相互之间格式相差甚远,但是都会以一个u2类型的属性名称开始,JVM根据名称便可知道当前描述的到底是这9大属性中的哪一个属性。Test.class字节码文件中,紧跟在attributes_count后面的便是这两个字节,其值是0x0012。属性表的名称索引指向常量池中对应的位置,根据前面打印出的Test.class常量池信息可知,常量池中的第0x0012(即18)号元素的值是Code,正是前面所介绍的9大属性中的Code方法表。
根据上文所介绍的Code方法表属性的组成结构可知,紧跟在u2类型的属性名称后面的是u4类型的attribute_length,对照图可知,attribute_length的值位0x00000030,对应的十进制的48.
Code属性组成结构中紧跟在attribute_length后面的是u2类型的max_stack和u2类型的max_locals,其值分别是2和1,如图所示
紧跟在max_locals后面的是u4类型的code_length,其值是16,如图所示。
code_length之后的字节码流是code属性,code属性开始真正描述Java方法所对应的字节码指令,这是Java的精华所在。字节码指令所占的字节码长度由code_length决定,由于code_length的值是16,因此code_length后面的16字节都用于描述字节码指令.这16个字节码的值是,如图所示。
JVM是基于栈的指令集系统,其设计的指令仅占1字节,由于1字节最多只能描述256种指令,所以JVM的指令总数只有200多个。同时,JVM的指令属于一元操作数类型,其后面只有一个操作数(当然,很多指令后不跟操作数,例如return).正因如此,对于JVM指令需要区别对待,有些字节是代表指令,但是有些字节则代表数据(专业术语焦作"操作数"),而不是指令.下面来分析当前方法,从第一字节开始,第一字节一定是代表指令,而不能是数字(否则连JVM自己都不知道真正的指令到底从哪里开始)。第一字节是0x2a,,查询JVM的指令集可知,含义如下:
0x2a aload_0 将第一个引用类型本地变量推送至栈顶
0xb7 00 01
0xb7 invokespecial 调用超类构建方法, 实例初始化方法, 私有方法
invokespecial后面的两个字节是它要调用的方法。1号常量池元素是一个父类的Method的init方法
#1 = Methodref #9.#26 // java/lang/Object."<init>":()V
0x2a aload_0 将第一个引用类型本地变量推送至栈顶
0x06 iconst_3 将int型3推送至栈顶
0xb5 00 02
0xb5 putfield 为指定类的实例域赋值 后面跟一个02,表示给常量池2号元素,即变量a赋值
#2 = Fieldref #5.#27 // Test.a:I
接着又是0x2a,推送至栈顶
0x12 ldc 将int,float或String型常量值从常量池中推送至栈顶
接着便是把常量池中的Hello World推送至栈顶
#3 = String #28 // Hello World
0xb5 00 04 给常量池4号元素(即s赋值)
#4 = Fieldref #5.#29 // Test.s:Ljava/lang/String;
0xb1 return 从当前方法返回void
至此<init>方法就结束了
# 查询JVM字节码指令链接
https://blog.csdn.net/qq_33589510/article/details/105285250
0x2a aload_0 将第一个引用类型本地变量推送至栈顶
0xb7 00 01
0xb7 invokespecial 调用超类构建方法, 实例初始化方法, 私有方法
invokespecial后面的两个字节是它要调用的方法。1号常量池元素是一个父类的Method的init方法
#1 = Methodref #9.#26 // java/lang/Object."<init>":()V
0x2a aload_0 将第一个引用类型本地变量推送至栈顶
0x06 iconst_3 将int型3推送至栈顶
0xb5 00 02
0xb5 putfield 为指定类的实例域赋值 后面跟一个02,表示给常量池2号元素,即变量a赋值
#2 = Fieldref #5.#27 // Test.a:I
接着又是0x2a,推送至栈顶
0x12 ldc 将int,float或String型常量值从常量池中推送至栈顶
接着便是把常量池中的Hello World推送至栈顶
#3 = String #28 // Hello World
0xb5 00 04 给常量池4号元素(即s赋值)
#4 = Fieldref #5.#29 // Test.s:Ljava/lang/String;
0xb1 return 从当前方法返回void
至此<init>方法就结束了
# 查询JVM字节码指令链接
https://blog.csdn.net/qq_33589510/article/details/105285250
刚才是逐条分析各个指令的含义,但仅关注单个指令并不能清楚地知道程序想要干什么,因此需要将一个Java方法所对应的全部指令连起来一起看。上文使用javap -verbose命令分析class文件时,会将每一个Java方法所对应的JVM字节码打印出来,如下:
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_3
6: putfield #2 // Field a:I
9: aload_0
10: ldc #3 // String Hello World
12: putfield #4 // Field s:Ljava/lang/String;
15: return
这段指令的含义是:加载第一个引用类型变量,加载0号索引的局部变量,通常是this指针,然后调用父类的Object<init>方法,接着再次将this指针压栈,将常量的int类型3压栈,调用putfield给a属性进行赋值,同理s属性亦是如此。然后返回。
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_3
6: putfield #2 // Field a:I
9: aload_0
10: ldc #3 // String Hello World
12: putfield #4 // Field s:Ljava/lang/String;
15: return
这段指令的含义是:加载第一个引用类型变量,加载0号索引的局部变量,通常是this指针,然后调用父类的Object<init>方法,接着再次将this指针压栈,将常量的int类型3压栈,调用putfield给a属性进行赋值,同理s属性亦是如此。然后返回。
5.第一个方法中的属性表。
JVM一共支持9大属性,Code就是其中一个属性。然而Code属性中也会引用其他8类属性。Code属性结构的最后2个组成部分分别是attributes_count和attributes,分别代表所引用的属性总数和属性信息。其中。attributes_count占4字节,attributes所占字节数需要视具体情况而定。在Test.class字节码文件中,描述完第一个方法(void <init>)的Code属性后,接下来的字节流便开始描述该方法所引用的其他属性信息。开始的4字节表示attributes_count,其内容如图所示.由图可知,attributes_count的值是2,表示void <clinit>()方法中一共引用了1个其他的属性表
JVM一共支持9大属性,Code就是其中一个属性。然而Code属性中也会引用其他8类属性。Code属性结构的最后2个组成部分分别是attributes_count和attributes,分别代表所引用的属性总数和属性信息。其中。attributes_count占4字节,attributes所占字节数需要视具体情况而定。在Test.class字节码文件中,描述完第一个方法(void <init>)的Code属性后,接下来的字节流便开始描述该方法所引用的其他属性信息。开始的4字节表示attributes_count,其内容如图所示.由图可知,attributes_count的值是2,表示void <clinit>()方法中一共引用了1个其他的属性表
前面提到,JVM的9大属性表虽然结构各不相同,但是都以u2类型、占2字节的tag开头,JVM通过tag来区分当前究竟是哪一种属性。由图可知,紧跟在attributes_count后面的2字节的值s是0x0013,如图所示,,由图可知,当前属性的tag值对应十进制的19,根据上下文所打印的常量池信息可知,常量池中第19号元素信息如下:
#19 = Utf8 LineNumberTable
由此可知,当前所描述的属性是LineNumberTable,该属性用于记录源代码与字节码指令之间的行号对应关系。根据对9大属性的简介可知,描述LineNumberTable属性的字节码流结构包括:u2类型的attribute_name_index、u4类型的attribute_length、u2类型的line_number_table_length和line_number_table行号对应关系表。其中,u2类型的attribute_name_index刚才已经分析锅,即指向常量池中19号元素。紧跟在attribute_name_index后面的是u4类型的attribute_length0
#19 = Utf8 LineNumberTable
由此可知,当前所描述的属性是LineNumberTable,该属性用于记录源代码与字节码指令之间的行号对应关系。根据对9大属性的简介可知,描述LineNumberTable属性的字节码流结构包括:u2类型的attribute_name_index、u4类型的attribute_length、u2类型的line_number_table_length和line_number_table行号对应关系表。其中,u2类型的attribute_name_index刚才已经分析锅,即指向常量池中19号元素。紧跟在attribute_name_index后面的是u4类型的attribute_length0
attribute_length其值如图所示,由图可知其值是0x0000000e,对应的十进制是14,表明LineNumberTable这个属性接下来还占14字节,14字节之后的字节码流就不再属于当前属性了。
紧跟在atrribute_length结构后面的是u2类型的line_number_table_length,占2字节。该结构标记其后面的line_number_table的元素数量,其内容如图所示。
由图可知,其值是3,表明其后面的line_number_table的元素数量是3,由于attribute_length的值是14,而line_number_table_length的值占去了2字节,因此整个LineNumberTable属性还剩下最后12字节,内容如图所示
这最后12字节都用于描述line_number_table行号对应关系表结构,line_number_table是一组line_number_info类型数据的集合,即该表结构可以认为是一个标准的数组结构,其中所有元素类型都是line_number_info结构,而该结构由2个子结构构成:line_number_table_length和line_number_info,如表所示。每一个子结构各占2字节,因此line_number_table结构所占的字节数一定是4的倍数。据此可以推测。由于留给line_number_table结构的只剩下12字节,因此这个结构数组中仅包含3个line_number_info元素。
第一个line_number_info元素。如图所示,start_pc为0x0000,line_number为0x0001
第二个line_number_info元素,如图所示。start_pc为0x0004, line_number为0x0003
第三个line_number_info元素,如图所示,start_pc为0x0009,line_number为0x0005
使用javap -verbose命令所打印的常量池表中,包含LineNumberTable属性的描述,如图所示。
6.第二个方法。
访问标识、方法名称、方法描述符、属性数量.查询常量池信息,可知这是在描述main()主函数
访问标识、方法名称、方法描述符、属性数量.查询常量池信息,可知这是在描述main()主函数
属性名称、属性长度、max_stack、max_locals、代码长度
常量池解析
前文讲述了JVM执行引擎的核心机制,以及Java Class字节码文件的格式。接下来继续剖析JVM内部的源码。JVM要完成Java逻辑的执行,必须能够"读懂"Java字节码文件。而Java字节码文件从总体上而言,其实主要包含三部分:常量池、字段信息和方法信息。其中常量池存储了字段和方法的相关符号信息,因此对常量池的解读便成为解读Java字节码文件的基础。常量池是Java字节码文件的核心,因此也是JVM解析字节码的重头戏。要注意的是,这里所说的常量池并不等同于JVM内存模型中的常量区,这里的常量池仅仅是文件中的一堆字节码而已。但是字节码文件中的常量池与JVM内存区的常量区之间却有着千丝万缕的联系,因为JVM最终会将字节码文件中的常量池信息进行解析,并存储到JVM内存模型中的常量区。字节码文件中的常量池是Java编译器对Java源代码进行语法解析后的产物,知识这种初步解析产生的结果比较粗糙。里面包含了各种引用,信息不够直观。而JVM根据字节码文件中的常量池信息再进行二次解析,这种解析目标清晰,直奔重点,会还原出所有常量池元素的全部信息,让内存与我们所编写的Java源代码保持一致。
(这里以JDK6为例,JDK8会有些许不一样)
在字节码文件中,用于描述常量池结构的字节码流所在的块区紧跟在魔数和版本号之后,因此JVM在解析完魔数与版本号之后,因此JVM在解析完魔数与版本号后,接着便开始解析常量池。JVM解析Java类字节码文件的接口是ClassFileParser::parseClassFile(),该接口内部解析的总体步骤如图所示
(这里以JDK6为例,JDK8会有些许不一样)
在字节码文件中,用于描述常量池结构的字节码流所在的块区紧跟在魔数和版本号之后,因此JVM在解析完魔数与版本号之后,因此JVM在解析完魔数与版本号后,接着便开始解析常量池。JVM解析Java类字节码文件的接口是ClassFileParser::parseClassFile(),该接口内部解析的总体步骤如图所示
JVM中对字节码常量池信息的解析主要链路如图所示。由图可以看出,JVM对常量池的解析主要分为两步:第一步是为常量池分配内存,第二步是解析常量池信息。
常量池内存分配。
JVM想要解析常量池信息,就必须先划出一块内存,将常量池的结构信息加载进来,然后次啊能进行进一步分析。内存空间的划分主要考虑两点:一是分配多大的内存;二是分配在哪里。JVM使用一个专门的C++类constantPoolOop来保存常量池的信息,而该类里面实际保存数据的是属于typeArrayOop类型的_tags示例对象。typeArrayOop类继承于oopDesc顶级结构,oopDesc里面出了标记和元数据,就啥都没有了。而令人绝望的是,typeArrayOop并没有在oopDesc的基础上多增加一些字段,换言之,typeArrayOop这种类型也是仅仅只有标记和元数据。这就带来一个问题,typeArrayOop究竟如何对复杂的常量池信息进行描述的呢?其所带来的关联问题就是,JVM怎样根据typeArrayOop区分配到合适大小的内存呢?对于天生就适合领域建模的Java语言而言,如果要描述某类对象,一般会为其建立对应的数据结构模型。以学生为例,如果使用Java来建模,则至少会是这个样子如图所示
JVM想要解析常量池信息,就必须先划出一块内存,将常量池的结构信息加载进来,然后次啊能进行进一步分析。内存空间的划分主要考虑两点:一是分配多大的内存;二是分配在哪里。JVM使用一个专门的C++类constantPoolOop来保存常量池的信息,而该类里面实际保存数据的是属于typeArrayOop类型的_tags示例对象。typeArrayOop类继承于oopDesc顶级结构,oopDesc里面出了标记和元数据,就啥都没有了。而令人绝望的是,typeArrayOop并没有在oopDesc的基础上多增加一些字段,换言之,typeArrayOop这种类型也是仅仅只有标记和元数据。这就带来一个问题,typeArrayOop究竟如何对复杂的常量池信息进行描述的呢?其所带来的关联问题就是,JVM怎样根据typeArrayOop区分配到合适大小的内存呢?对于天生就适合领域建模的Java语言而言,如果要描述某类对象,一般会为其建立对应的数据结构模型。以学生为例,如果使用Java来建模,则至少会是这个样子如图所示
可以看出,Java语言所定义的这种类型能够对"学生"这种"事物"进行精确描述,同时,数据结构一旦定义好,,则意味着其所占用的内存空间也已经确定。可是如果将学生类型替换成常量池,并且使用typeArrayOop这种类型来描述,则其所描述的学生类型是这样的:
很显然,直接根据typeArrayOop这种类型,根本就看不出其所描述的对象究竟包含哪些属性,更无法据此计算出其所描述的事物应该占用多大内存。看来这个类真的如同其名字一样,只描述数组信息。而事实上,常量池也的确类似数组,不同的Java类所生成的常量池信息是不同的,因此也无法使用一种预先定义好的数据模型去描述。但是常量池又不是严格意义上的数组,因为其每种元素成员的类型并不相同。那么typeArrayOop如何描述常量池所需内存空间和常量池结构信息呢?
很显然,直接根据typeArrayOop这种类型,根本就看不出其所描述的对象究竟包含哪些属性,更无法据此计算出其所描述的事物应该占用多大内存。看来这个类真的如同其名字一样,只描述数组信息。而事实上,常量池也的确类似数组,不同的Java类所生成的常量池信息是不同的,因此也无法使用一种预先定义好的数据模型去描述。但是常量池又不是严格意义上的数组,因为其每种元素成员的类型并不相同。那么typeArrayOop如何描述常量池所需内存空间和常量池结构信息呢?
常量池内存分配总体链路.
在ClassFileParser::parse_constant_pool()函数中,通过下面这样代码实现常量池内存分配:
oopFactory::new_constantPool()的第一个入参是length,其在ClassFileParser::parse_cosntant_pool()函数中实现了对常量池大小的解析。length值代表当前字节码文件的常量池中一共包含多少个常量池元素,该数值由Java编译器在编译期间通过分析计算得出,,最终将其保存在字节码文件中,所以JVM在解析常量池时可以直接拿来使用,这里的length便是JVM直接从字节码文件中读取出来的。在前面对Java字节码文件结构的分析中,讲到了常量池的大小的计算,不过要注意的是,length并不表示常量池需要占用多大的内存空间,而是代表一共有多少个常量池元素。有了这个值,JVM就能对常量池中的常量池元素进行逐个遍历处理。
在ClassFileParser::parse_constant_pool()函数中,通过下面这样代码实现常量池内存分配:
oopFactory::new_constantPool()的第一个入参是length,其在ClassFileParser::parse_cosntant_pool()函数中实现了对常量池大小的解析。length值代表当前字节码文件的常量池中一共包含多少个常量池元素,该数值由Java编译器在编译期间通过分析计算得出,,最终将其保存在字节码文件中,所以JVM在解析常量池时可以直接拿来使用,这里的length便是JVM直接从字节码文件中读取出来的。在前面对Java字节码文件结构的分析中,讲到了常量池的大小的计算,不过要注意的是,length并不表示常量池需要占用多大的内存空间,而是代表一共有多少个常量池元素。有了这个值,JVM就能对常量池中的常量池元素进行逐个遍历处理。
oopFactory::new_constantPool链路比较长,下面先给出总体调用路径。如图所示,由图可知,常量池内存分配的链路比较长,下面先对这条链路所涉及的类型进行一个简单的说明:
# oopFactory.顾名思义,就是oop的工厂类。工厂类涉及模式主要负责生产专门的对象,与具体的编程语言无关。前文讲过,Java语言是一门面向对象的语言,所有一切皆对象,这种面向对象的特性不仅在语法层面贯彻得很彻底,而且在JVM内部实现层面也得到彻底得实现。在JVM内部,常量池、字段、符号、方法等一切都被对象包装起来,所有这一切对象在内存中都通过oop这种指针进行跟踪(指向),JVM根据oop所指向得实际内存位置便可获取到对象的具体信息。而在JVM内部,所有这些对象的内存分配、对象创建与初始化(清零)工作都通过oopFactory这个入口得以统一实现。常量池也不例外。
# constantPoolKlass.从类型对象。对于每一种对象,JVM内部都通过oop指针指向到某个内存位置,这个内存位置往往便是与该oop相对应的klass类型。klass用于描述JVM内部一个具体对象的结构信息,例如,一个Java类中包含哪些字段、哪些方法、哪些常量。同理,常量池在JVM内部也被表示为一种对象,虽然开发者并不能在源程序中通过Java程序来表示它。不同的Java类被编译后所生成的字节码文件中的常量池大小、元素顺序和结构等都不相同,因此JVM内部必须要预留一段内存区块来描述常量池的结构信息,这便是constantPoolKlass的意义所在。尤其是最重要的与源码层面的Java类相关联的相关klass对象
# collectedHeap.闻声辨人,顾名思义,这表示JVM内部的堆内存区,可被垃圾收集器回收并反复利用。其代表JVM内部广义的堆内存区域,在JDK6时代,这个内存区域会包含用于分配Java类对象示例的堆内存区、常量池区和perm区(方法区).在JVM内部,除了堆栈变量之外的一切内存分配,都需要经过本区域,如果JVM内部的一个实例对象不在这一区域申请内存,如果不算栈上分配,那么只能跑到JVM堆外内存去申请了。
# oopFactory.顾名思义,就是oop的工厂类。工厂类涉及模式主要负责生产专门的对象,与具体的编程语言无关。前文讲过,Java语言是一门面向对象的语言,所有一切皆对象,这种面向对象的特性不仅在语法层面贯彻得很彻底,而且在JVM内部实现层面也得到彻底得实现。在JVM内部,常量池、字段、符号、方法等一切都被对象包装起来,所有这一切对象在内存中都通过oop这种指针进行跟踪(指向),JVM根据oop所指向得实际内存位置便可获取到对象的具体信息。而在JVM内部,所有这些对象的内存分配、对象创建与初始化(清零)工作都通过oopFactory这个入口得以统一实现。常量池也不例外。
# constantPoolKlass.从类型对象。对于每一种对象,JVM内部都通过oop指针指向到某个内存位置,这个内存位置往往便是与该oop相对应的klass类型。klass用于描述JVM内部一个具体对象的结构信息,例如,一个Java类中包含哪些字段、哪些方法、哪些常量。同理,常量池在JVM内部也被表示为一种对象,虽然开发者并不能在源程序中通过Java程序来表示它。不同的Java类被编译后所生成的字节码文件中的常量池大小、元素顺序和结构等都不相同,因此JVM内部必须要预留一段内存区块来描述常量池的结构信息,这便是constantPoolKlass的意义所在。尤其是最重要的与源码层面的Java类相关联的相关klass对象
# collectedHeap.闻声辨人,顾名思义,这表示JVM内部的堆内存区,可被垃圾收集器回收并反复利用。其代表JVM内部广义的堆内存区域,在JDK6时代,这个内存区域会包含用于分配Java类对象示例的堆内存区、常量池区和perm区(方法区).在JVM内部,除了堆栈变量之外的一切内存分配,都需要经过本区域,如果JVM内部的一个实例对象不在这一区域申请内存,如果不算栈上分配,那么只能跑到JVM堆外内存去申请了。
# psPermGen.这个对象标识perm区内存,这主要是JDK6时代的产物。Java类的字节码信息会保存到这块区域。但是到了JDK8时代,perm区的概念被metaSpace概念取代。perm是指内存的永久保存区域,这一部分用于存放Class和Meta的信息,Class在被加载的时候被放入permGen space区域,它和存放Java类实例对象的堆内存区域不同,如果Java程序加载了太多Java类,就很可能出现PermGen Space错误,在Web服务器堆JSP进行预编译的时候,或者在使用Spring框架的时候,这种错误出现的频率非常高。而在metaspace时代。官方的解释是:这块区域属于"本地内存"区域。所谓"本地"的概念,其实就是指操作系统,是相对于JVM虚拟机的内存而言的。由于JVM直接向操作系统申请内存存储Java类的元信息,因此默认秦广下,类元数据空间的申请只受可用的本地内存限制(容量取决于是32位还是64位操作系统的可用虚拟内存大小)。这种内存分配策略所带来的好处是,JVM不会由于为permGen Space指定的内存太小而导致类加载太多造成perm区内存耗尽,JVM不会再报出类似"java.lang.OutOfMemoryError: PermGen space"这样的错误了,理论上只要物理机器尚有可用的虚拟内存,JVM便能够加载新的类元数据。。而在perm时代,往往会面临着这样的问题:如果为perm区所指定的内存区域太大,则又会造成一定的内存浪费。但是再metaSpace时代,仍然应该设置MaxMetaspaceSize这个参数,当JVM所加载的类元信息所占内存空间达到MaxMetaspaceSize参数的设定值时,将会触发对于僵死的类及类加载器的垃圾回收。除了内存分配的位置被改变之外,metaSpace相对于permSpace时代,在性能上也做了一点调整,具体表现是,在FullGC期间,Metadata到Metadatapointers之间不需要扫描了,虽然这只需要几纳秒的时间,但几纳秒的时间也是时间。
虽然metaSpace相比permSpace有重大改进,但是从JVM角度而言,并没有本质上的改变,JVM的类结构在运行期的描述机制并没有改变,JVM仍然从字节码文件中解析出常量池、字段、方法等类元信息,然后保存到内存中某个位置。因此,整体上的研究仍然以JDK6为主,对JDK8不做过多深究。而事实上,如果对JDK6深入研究过,回过头来再看JDK8,已经没有什么技术上的障碍或者挑战了。况且,JDK8所做的优化努力虽然非常值得肯定,但是JDK6并不会因此就可以被贬抑得一无是处,并因此得到可以完全舍弃的结论。虽然permSpace有其缺点,但是metaSpace也并非只有优点,如果你的Java程序存在内存泄露,导致类加载器不停地重复加载类,不停地扩展metaspace的空间,仍然会导致机器内存不足甚至引发系统崩溃。这些都是自动内存管理的通病,在自动化和稳定性之间,总得舍弃一个,或者两者各自舍弃一点以达到某种均衡
虽然metaSpace相比permSpace有重大改进,但是从JVM角度而言,并没有本质上的改变,JVM的类结构在运行期的描述机制并没有改变,JVM仍然从字节码文件中解析出常量池、字段、方法等类元信息,然后保存到内存中某个位置。因此,整体上的研究仍然以JDK6为主,对JDK8不做过多深究。而事实上,如果对JDK6深入研究过,回过头来再看JDK8,已经没有什么技术上的障碍或者挑战了。况且,JDK8所做的优化努力虽然非常值得肯定,但是JDK6并不会因此就可以被贬抑得一无是处,并因此得到可以完全舍弃的结论。虽然permSpace有其缺点,但是metaSpace也并非只有优点,如果你的Java程序存在内存泄露,导致类加载器不停地重复加载类,不停地扩展metaspace的空间,仍然会导致机器内存不足甚至引发系统崩溃。这些都是自动内存管理的通病,在自动化和稳定性之间,总得舍弃一个,或者两者各自舍弃一点以达到某种均衡
虽然常量池内存分配的链路很长,但是从宏观层面来看,常量池内存分配大体上可以分为下面3个步骤。
# 1.在堆区分配内存空间
这一步最终在psOldGen.hpp中通过object_space()->allocate(word_size)实现。
# 2.初始化对象
主要通过在collectedHeap.inline.hpp中调用init_obj()进行对象初始化。所谓的对象初始化,其实仅仅是清零.
# 3.初始化oop
在collectedHeap.inline.hpp中调用post_allocation_install_obj_klass()完成oop初始化并赋值。JVM内部通过oop-klass来描述一个Java类.一个Java类的实例数据会被存放在堆中,而为了支持运行期反射、虚函数分发等高级操作,Java类实例指针oop会保存一个指针,用于指向Java类的类描述对象,类描述对象中保存一个Java类中所包含的全部成员变量和全部方法信息。本步骤便是为这一目标而设计的
这三步所对应的链路部分,如图所示。分别对应从上至下的3个被框定的流程。
执行完上面这3步,一个常量池对象便完成了内存分配和部分初始化,但是此时常量池对象时空对象,其内存区还没有填充具体的值,parseClassFile()函数下一步会做这个事情。
这里额外插一句,Java字节码中的一切皆对象,无论时常量池、成员变量、方法还是数组等,在JVM虚拟机内部都被识别为"对象",而为类对象分配内存空间的操作都封装在oopFactory中。oopFactory为各种JVM内建对象分配内存空间并初始化类型实例的机制还是一样的。都是先获取对应的klass类描述对象,然后为oop分配内存空间。JVM为一个Java类分配内存空间的机制。与JVM为一个常量池分配内存空间的机制在本质上是一样的。
# 1.在堆区分配内存空间
这一步最终在psOldGen.hpp中通过object_space()->allocate(word_size)实现。
# 2.初始化对象
主要通过在collectedHeap.inline.hpp中调用init_obj()进行对象初始化。所谓的对象初始化,其实仅仅是清零.
# 3.初始化oop
在collectedHeap.inline.hpp中调用post_allocation_install_obj_klass()完成oop初始化并赋值。JVM内部通过oop-klass来描述一个Java类.一个Java类的实例数据会被存放在堆中,而为了支持运行期反射、虚函数分发等高级操作,Java类实例指针oop会保存一个指针,用于指向Java类的类描述对象,类描述对象中保存一个Java类中所包含的全部成员变量和全部方法信息。本步骤便是为这一目标而设计的
这三步所对应的链路部分,如图所示。分别对应从上至下的3个被框定的流程。
执行完上面这3步,一个常量池对象便完成了内存分配和部分初始化,但是此时常量池对象时空对象,其内存区还没有填充具体的值,parseClassFile()函数下一步会做这个事情。
这里额外插一句,Java字节码中的一切皆对象,无论时常量池、成员变量、方法还是数组等,在JVM虚拟机内部都被识别为"对象",而为类对象分配内存空间的操作都封装在oopFactory中。oopFactory为各种JVM内建对象分配内存空间并初始化类型实例的机制还是一样的。都是先获取对应的klass类描述对象,然后为oop分配内存空间。JVM为一个Java类分配内存空间的机制。与JVM为一个常量池分配内存空间的机制在本质上是一样的。
内存分配。
从oopFactory::new_constantPool()调用开始一直到mutableSpace::allocate(),这个过程可以认为是第一个阶段,即为constantPool申请内存。前面讲过,内存申请主要关注两点:一是应该申请多大的内存;二是在哪里申请内存。在如图所示的链路中,从一开始便知道要申请多大的内存,内存的大小就是length变量所代表的值。因此,常量池有多少个常量池元素,最终便会分配多大的内存,至于为何是这样,后面再分析,下面先分析JVM为constantPool申请内存的机制。这条链路所涉及的接口调用及其所在的文件位置如图所示。
从oopFactory::new_constantPool()调用开始一直到mutableSpace::allocate(),这个过程可以认为是第一个阶段,即为constantPool申请内存。前面讲过,内存申请主要关注两点:一是应该申请多大的内存;二是在哪里申请内存。在如图所示的链路中,从一开始便知道要申请多大的内存,内存的大小就是length变量所代表的值。因此,常量池有多少个常量池元素,最终便会分配多大的内存,至于为何是这样,后面再分析,下面先分析JVM为constantPool申请内存的机制。这条链路所涉及的接口调用及其所在的文件位置如图所示。
内存申请最终通过object_space()->allocate()实现,其上游一系列链路主要实现方法的逐级调用,为最后调用allocate()作铺垫。allocate()函数定义在mutableSpace.cpp中,定义如下.在该函数中,通过HeapWord* new_top = obj+size,将permSpace内存区域的top指针往高地址方向移动了size大小的字节数,完成当前被加载的类所对应的常量池的内存分配。由于JVM的堆区在JVM初始化过程中便已完成了指定大小的空间分配,因此这里并没有真正向操作系统申请内存分配,仅仅从JVM已申请的堆内存中划拨一块空间用于存储常量池结构信息。注意,这里先执行HeapWord*obj= top(),再执行HeapWord* new_top = obj +size,而最终返沪ide仍然是obj指针,该指针指向的是原来的堆的最顶端,这样调用方通过指针可以还原从原堆顶到当前堆顶之间的内存空间,将其强制转换为常量池对象。
JVM再启动过程中完成permSpace的内存申请和初始化,但是这块区域一开始是空的,没有任何数据。随着不断有常量池信息写入到这块区域,top()指针也会不断地往高内存地址方向移动,而每次新写入一个对象,该对象的内存首地址便是写入之前top()指针的位置,这一点需要留心,否则在进行指针类型强制转换时会失败
JVM再启动过程中完成permSpace的内存申请和初始化,但是这块区域一开始是空的,没有任何数据。随着不断有常量池信息写入到这块区域,top()指针也会不断地往高内存地址方向移动,而每次新写入一个对象,该对象的内存首地址便是写入之前top()指针的位置,这一点需要留心,否则在进行指针类型强制转换时会失败
1.constantPool的内存有多大
为一个Java类创建其对应的常量池,需要在JVM堆区为常量池先申请一块连续的内存空间。所申请的内存空间的大小取决于一个Java类在编译时所确定的常量池大小,更直白地说,就是取决于你所定义的类的大小。在上面所描绘的常量池初始化调用链路中,可以看到size这个变量一直从来南路前端被透传到最末端,这个size变量便是JVM为当前常量池所计算出来的内存大小。在常量池初始化链路中会第哦啊用constantPoolKlass::allocate()方法,该方法会调用constantPoolOopDesc::object_size(length)方法来获取常量池大小,该方法的原型如图所示。
object_size()的实现逻辑比较简单,仅仅是将header_size与length相加后再进行内存对齐。header_size顾名思义就是对象头大小,对象头的概念后面再分析,在这里只需要知道header_size()返回的是constantPoolOopDesc类型的大小,而length则为Java类在编译期间由编译器所计算出来的常量池大小。align_object_size()函数的作用是实现内存对齐,这样便于在GC进行工作时能够高效回收垃圾,虽然这会造成一定的空间消费。
在32位操作系统上,HeapWordSize大小为4,为一个指针型变量的长度。因此在32位平台上,sizeof(constantPoolOopDesc)返回40.
关于sizeof()函数这里做一点说明。当计算C++类时,该函数返回的是其所有变量的大小加上虚函数指针的大小。若在类中定义了普通的函数,无论是公有的还是私有,也无论是静态还是非静态,都不会计算其大小。下面举例来说明
为一个Java类创建其对应的常量池,需要在JVM堆区为常量池先申请一块连续的内存空间。所申请的内存空间的大小取决于一个Java类在编译时所确定的常量池大小,更直白地说,就是取决于你所定义的类的大小。在上面所描绘的常量池初始化调用链路中,可以看到size这个变量一直从来南路前端被透传到最末端,这个size变量便是JVM为当前常量池所计算出来的内存大小。在常量池初始化链路中会第哦啊用constantPoolKlass::allocate()方法,该方法会调用constantPoolOopDesc::object_size(length)方法来获取常量池大小,该方法的原型如图所示。
object_size()的实现逻辑比较简单,仅仅是将header_size与length相加后再进行内存对齐。header_size顾名思义就是对象头大小,对象头的概念后面再分析,在这里只需要知道header_size()返回的是constantPoolOopDesc类型的大小,而length则为Java类在编译期间由编译器所计算出来的常量池大小。align_object_size()函数的作用是实现内存对齐,这样便于在GC进行工作时能够高效回收垃圾,虽然这会造成一定的空间消费。
在32位操作系统上,HeapWordSize大小为4,为一个指针型变量的长度。因此在32位平台上,sizeof(constantPoolOopDesc)返回40.
关于sizeof()函数这里做一点说明。当计算C++类时,该函数返回的是其所有变量的大小加上虚函数指针的大小。若在类中定义了普通的函数,无论是公有的还是私有,也无论是静态还是非静态,都不会计算其大小。下面举例来说明
在64位平台上运行该程序,最终打印出来的值是8,因为A类中只包含一个指针类型变量,而64位平台上一个指针的数据宽度是8,所以最终打印出来是8.
根据这个演示程序,可以推算sizeof(constantPoolOopDesc)返回值的大小。constantPoolOopDesc本身包含8个字段,如图所示。由于constantPoolOopDesc继承自oopDesc父类,因此constantPoolOopDesc类还会包含来自父类的2个成员变量:
volatile markOop _mark;
union _metadata {
wideKlassOop _klass;
narrowOop _compressed_klass;
} _metadata;
注意,markOop的类型原型是markOopDesc*指针类型,因此_mark的类型是指针,在32位平台上栈4字节的内存。_metadata是联合体,联合体内部是指针类型,因此也占4字节。
虽然oopDesc类内部包含static BarrierSet* bs这样一个变量,但是这是静态类型的变量,在JVM启动之初其会被操作系统直接分配到JVM程序的数据段内存区,在JVM位Java程序分配内存时,不会为_bs这样的全局变量在JVM堆内存或者permSpace内存区另外分配空间。
如此看来,constantPoolOopDesc最终实际包含10个字段,因此在32位平台上sizeof(constantPoolOopDesc)将返回40.可以使用GDB在32位平台上断点调试,在断点时直接打印表达式sizeof(constantPoolOopDesc)的值进行验证。
volatile markOop _mark;
union _metadata {
wideKlassOop _klass;
narrowOop _compressed_klass;
} _metadata;
注意,markOop的类型原型是markOopDesc*指针类型,因此_mark的类型是指针,在32位平台上栈4字节的内存。_metadata是联合体,联合体内部是指针类型,因此也占4字节。
虽然oopDesc类内部包含static BarrierSet* bs这样一个变量,但是这是静态类型的变量,在JVM启动之初其会被操作系统直接分配到JVM程序的数据段内存区,在JVM位Java程序分配内存时,不会为_bs这样的全局变量在JVM堆内存或者permSpace内存区另外分配空间。
如此看来,constantPoolOopDesc最终实际包含10个字段,因此在32位平台上sizeof(constantPoolOopDesc)将返回40.可以使用GDB在32位平台上断点调试,在断点时直接打印表达式sizeof(constantPoolOopDesc)的值进行验证。
搞清楚了sizeof(constantPoolOopDesc),还需要研究下HeapWordSize,因为通过header_size()计算constantPoolOopDesc类大小时使用到了HeapWordSize(header_size())的计算公式是sizeof(constantPoolOopDesc)/HeapWordSize.HeapWordSize定义如图所示:
可以看到,HeapWordSize是HeapWord类的大小,而该类只包含一个char*指针型变量,因此在32位平台上,sizeof(HeapWordSize)返回4,即其大小是4字节大小。如果在64位平台上,sizeof(HeapWordSize)返回8,即其大小是8字节大小。所以,HeapWord的大小其实就是一个指针的大小,不同平台的指针所占的内存大小是不同的。同理,header_size()函数返回的也是当前平台上的指针大小。
可以看到,HeapWordSize是HeapWord类的大小,而该类只包含一个char*指针型变量,因此在32位平台上,sizeof(HeapWordSize)返回4,即其大小是4字节大小。如果在64位平台上,sizeof(HeapWordSize)返回8,即其大小是8字节大小。所以,HeapWord的大小其实就是一个指针的大小,不同平台的指针所占的内存大小是不同的。同理,header_size()函数返回的也是当前平台上的指针大小。
至此,header_size()函数的作用已经十分清楚了,sizeof(constantPoolOopDesc)返回constantPoolOopDesc类型本身所占内存的总字节数,而HeapWordSize则返回对应平台上的一个指针宽度,在32位平台上,一个指针宽度为4字节,即双子(双字占4字节),因此header_size()计算出的结果表示constantPoolOopDesc这个类型实例在内存中所占用的双字数。
理解了上面所讲的header_size()函数的含义之后,再回过头来看object_size(int length) {return align_object_size(header_size() + length);}这函数,便很容易理解该函数的思路了,其实object_size()函数最终返回的值代表constantPoolOopDesc类型本身的大小加上常量池的大小length,constantPoolKlass::allocate()便根据这个结果,从JVM的perm区所申请所需的内存空间大小,最终,constantPoolKlass::allocate()从JVM堆内存中所申请的内存空间大小包含如图所示的两部分:
理解了上面所讲的header_size()函数的含义之后,再回过头来看object_size(int length) {return align_object_size(header_size() + length);}这函数,便很容易理解该函数的思路了,其实object_size()函数最终返回的值代表constantPoolOopDesc类型本身的大小加上常量池的大小length,constantPoolKlass::allocate()便根据这个结果,从JVM的perm区所申请所需的内存空间大小,最终,constantPoolKlass::allocate()从JVM堆内存中所申请的内存空间大小包含如图所示的两部分:
代码总是枯燥的,因此我们还是以前文所举的Iphone6s.java为例,该类定义如下:
使用十六进制编辑器打开编译后生成的字节码文件,得到常量池的长度length,如图所示。。图中选中的字节便代表常量池的元素数量。所示的常量池元素数量为0x26,换算成十进制为38.由此可知Iphone6s.class字节码文件中的常量池一共包含38个元素。
使用javap -verbose命令进行分析的结果如图所示:
javap命令所分析出的结果一共有37个常量池元素,之所以比字节码文件中的少一个,是因为JVM会保留第0号常量池位置,因此仅从第1个开始计算。如此便能保持一致。
如果JVM当前正在解析Iphone6s.class字节码文件的常量池信息,那么按照上面的分析,最终JVM会从permSpace中划分出(40+38)*4字节的内存大小(32位平台)。现在问题就来了,为什么JVM常量池对象分配这么大的内存呢?要解答这个疑问,需要知道JVM是如何解析字节码的常量池信息的。我们先来讨论一个重要的问题:常量池的内存布局
javap命令所分析出的结果一共有37个常量池元素,之所以比字节码文件中的少一个,是因为JVM会保留第0号常量池位置,因此仅从第1个开始计算。如此便能保持一致。
如果JVM当前正在解析Iphone6s.class字节码文件的常量池信息,那么按照上面的分析,最终JVM会从permSpace中划分出(40+38)*4字节的内存大小(32位平台)。现在问题就来了,为什么JVM常量池对象分配这么大的内存呢?要解答这个疑问,需要知道JVM是如何解析字节码的常量池信息的。我们先来讨论一个重要的问题:常量池的内存布局
2.内存空间布局。
在为constantPoolOop常量池对象分配内存时,需要分析JVM为常量池所申请的内存空间布局的模型。刚才提到,JVM为constantPoolOop实例对象所分配的内存空间大小是(headSize+length)个指针宽度(或者双字宽度)。JVM为常量池对象申请的内存位于perm区,perm区本身是一片连续的内存区域,而JVM为常量池申请内存时也是整片区域连续划分,因此每一个constantPoolOop对象实例在perm区中都是连续分布的,不会存在碎片化。最终申请号的内存空间布局如图所示。
注:该图假设JVM运行于Linux32位平台上,因此指针宽度为4字节,并且常量池分配方向从低地址内存往高地址内存向。该图看起来虽然简单,但是在JVM内部,几乎所有的对象都是这种布局。总体而言,JVM内存为对象分配内存时,会先分配对象头,然后分配对象的实例数据,不管字段对象还是方法对象,抑或是数组,莫不如是,再强调一遍,JVM内部为对象实例分配内存空间的模型都是"对象头+实例数据"的结构
在为constantPoolOop常量池对象分配内存时,需要分析JVM为常量池所申请的内存空间布局的模型。刚才提到,JVM为constantPoolOop实例对象所分配的内存空间大小是(headSize+length)个指针宽度(或者双字宽度)。JVM为常量池对象申请的内存位于perm区,perm区本身是一片连续的内存区域,而JVM为常量池申请内存时也是整片区域连续划分,因此每一个constantPoolOop对象实例在perm区中都是连续分布的,不会存在碎片化。最终申请号的内存空间布局如图所示。
注:该图假设JVM运行于Linux32位平台上,因此指针宽度为4字节,并且常量池分配方向从低地址内存往高地址内存向。该图看起来虽然简单,但是在JVM内部,几乎所有的对象都是这种布局。总体而言,JVM内存为对象分配内存时,会先分配对象头,然后分配对象的实例数据,不管字段对象还是方法对象,抑或是数组,莫不如是,再强调一遍,JVM内部为对象实例分配内存空间的模型都是"对象头+实例数据"的结构
用前面模型对上图所示的constantPoolOop的内存布局进行简化,得到如图所示的布局。对于常量池而言,其对象头就i是constantPoolOop对象本身,而实例数据究竟放啥暂时还不知道,不过你已经知道了实例数据所占空间的大小既然等于Java字节码文件中所有常量池元素所占空间的大小,那么可以大胆猜测,实例数据应该就是保存Java字节码文件中的常量池元素的某些特殊信息,但是常量池信息往往比其数量要大得多,因此紧跟在constantPoolOop之后的这一块实例数据区不可能用于存储常量池的全部信息。事实上,这一块的存储机制是比较复杂的,目前只需要知道常量池的内存布局总体上也是由对象头和实例数据这两块组成的。
有句话是怎么说的?知其然,更要知其所以然。虽然如此浓墨重彩地深入详细地分析区区一个常量池的内存模型是很必要的,但是这里不打算仅仅讲解what和how,后面会分析为何JVM要将常量池分配在perm区(JDK1.6),同事需要考虑一个最核心的问题:JVM为何要设计常量池这样一种机制。这两个问题是why,研究what和how通常仍然属于技术的范畴,而研究why则意味着超脱技术而上升到哲学的高度,虽然这里所谓的哲学也许被称为"技术哲学"要显得更加合理。作为技术爱好者,知其然固然好,能够做到知其所以然更是难能可贵,但是一旦做到了,所得到的回报往往会超出想象。以JVM为例,虽然绝大多数开发者也许一辈子都没有开发虚拟机的机会,从这个角度而言,如果单纯为了研究JVM而研究JVM,则一定是在浪费生命和时间。但是如果能研究JVM种种技术设计的背后原因,了解了JVM的各个模块之所以这样设计的道理,这样就能超脱JVM本身,不被局限于这一个领域之内,而能达到"天高任鸟飞,海阔凭鱼跃"之境界
初始化内存。
JVM为Java类所对应的常量池分配好内存空间后,接着需要执行这段内存空间的初始化。所谓的初始化其实就是清零操作。由于在申请内存空间时执行了内存对齐,同时由于JVM堆区会反复加载新类和擦除旧类,因此如果不执行清零,则会影响后续对Java类的解析。
在CollectedHeap::common_permanent_mem_allocate_init()中调用init_obj(obj,size),在init_obj(obj,size)中调用Copy::fill_to_aligned_words(obj + hs, size -hs),在x86 Linux平台上,该函数最终调用pd_fill_to_words()函数,此函数声明如图所示。在pd_fill_to_words()函数中,会将指定内存区的内存数据全部清空为零值,也即该函数的第3个入参value值。由于在CollectedHeap::init_obj()中调用Copy::fill_to_aligned_words(obj+hs, size -hs)函数,而Copy::fill_to_aligned_words()函数实际有3个入参,声明如下:
JVM为Java类所对应的常量池分配好内存空间后,接着需要执行这段内存空间的初始化。所谓的初始化其实就是清零操作。由于在申请内存空间时执行了内存对齐,同时由于JVM堆区会反复加载新类和擦除旧类,因此如果不执行清零,则会影响后续对Java类的解析。
在CollectedHeap::common_permanent_mem_allocate_init()中调用init_obj(obj,size),在init_obj(obj,size)中调用Copy::fill_to_aligned_words(obj + hs, size -hs),在x86 Linux平台上,该函数最终调用pd_fill_to_words()函数,此函数声明如图所示。在pd_fill_to_words()函数中,会将指定内存区的内存数据全部清空为零值,也即该函数的第3个入参value值。由于在CollectedHeap::init_obj()中调用Copy::fill_to_aligned_words(obj+hs, size -hs)函数,而Copy::fill_to_aligned_words()函数实际有3个入参,声明如下:
注意,该函数第3个参数默认为0,因此在执行pd_fill_to_words()函数时,指定的内存区会全部被抹为零值
oop-klass模型。
现在,JVM已经为constantPoolOop分配好内存并进行清零,接下来会进行非常重要的一步:填充内存。JVM为constantPoolOop申请内存,随后会解析Java字节码中的常量池信息,并将解析的中间结果暂存到所申请的内存中去。但是在分析constantPoolOop的解析国臣之前,必须要先弄清楚一个概念——oop-klass一分为二的内存模型。前面讲过,JVM为常量池所分配的内存的布局包含两部分,分别是对象头和实例数据。当年JVM使用了一种特殊的模型来表示,,这种模型就是oop-klass。因此,不把这种特殊的模型研究清楚,很难理解JVM为对象头分配内存的机制,接下来的源码阅读也一定会很吃力。。
事实上,即使弄懂了JVM的类模板表示机制,这部分源代码阅读起来仍然十分吃力。吃力的一个重要原因是指针以及基于指针的各种数据类型之间的强制转换。可能当年詹爷深受指针之苦,因此才下定决心要创造出一个没有指针的编程世界。为了达到消灭"指针"的目的,JVM本身的类模型变得格外复杂,这给源码解读带来相当大的困难。并且这种复杂性和基本的类模型从JVM发布以来一直没有经过大的变更,即使从JDK6到JDK8.也仅仅是对JVM内部的几种具体的类型进行了重组和去繁就简,并没有进行根本上的变革。因此如果对JDK6的类模型研究通透,则对JDK8的类模型自然会触类旁通,一看就懂。不过最为关键的是,只要Java语言本身对外所提供的功能特性不发生巨大变化,则JVM内部的类模型也不会发生巨大的之变,这与掐灭描述JVM执行引擎的技术选择一样,都有其技术上的必然性,虽然真正实现上的细节可能会千差万别并会得到不断改进和优化,但是主要的算法于策略不会变化,一切都是由技术本身所决定。
Java是面向对象的编程语言,这种面向对象不仅体现在语法层面,也不仅仅体现在外在的Java类层面。JVM在内部使用C++类去描述Java类的面向对象特性,JVM的奇特之处就在于,连同这些内部描述的类也被设计成面向对象机制,毫不夸张地说,JVM内部的面向对象机制比外在的Java类语法层面的面向对象机制实现得更加彻底和更加纯粹,Java语言所表现出来得面向对象特性与JVM内部的面向对象相比,简直有点小儿科。在JVM内部,不仅用于描述Java类的C++对象被赋予相比于C++语言本身所具备的面向对象特性更深一层的对象机制,而且其还用于描述相对于Java类外在对象而言属于JVM内部不能由开发者控制的类型。JVM内部用于描述Java字节码文件中的常量池类型不能被Java开发者访问和操作,但是即使是这种内部类,虽然本身使用C++这种面向对象的语言描述,但是仍然被表示成oop-klass这种二分模型。
现在,JVM已经为constantPoolOop分配好内存并进行清零,接下来会进行非常重要的一步:填充内存。JVM为constantPoolOop申请内存,随后会解析Java字节码中的常量池信息,并将解析的中间结果暂存到所申请的内存中去。但是在分析constantPoolOop的解析国臣之前,必须要先弄清楚一个概念——oop-klass一分为二的内存模型。前面讲过,JVM为常量池所分配的内存的布局包含两部分,分别是对象头和实例数据。当年JVM使用了一种特殊的模型来表示,,这种模型就是oop-klass。因此,不把这种特殊的模型研究清楚,很难理解JVM为对象头分配内存的机制,接下来的源码阅读也一定会很吃力。。
事实上,即使弄懂了JVM的类模板表示机制,这部分源代码阅读起来仍然十分吃力。吃力的一个重要原因是指针以及基于指针的各种数据类型之间的强制转换。可能当年詹爷深受指针之苦,因此才下定决心要创造出一个没有指针的编程世界。为了达到消灭"指针"的目的,JVM本身的类模型变得格外复杂,这给源码解读带来相当大的困难。并且这种复杂性和基本的类模型从JVM发布以来一直没有经过大的变更,即使从JDK6到JDK8.也仅仅是对JVM内部的几种具体的类型进行了重组和去繁就简,并没有进行根本上的变革。因此如果对JDK6的类模型研究通透,则对JDK8的类模型自然会触类旁通,一看就懂。不过最为关键的是,只要Java语言本身对外所提供的功能特性不发生巨大变化,则JVM内部的类模型也不会发生巨大的之变,这与掐灭描述JVM执行引擎的技术选择一样,都有其技术上的必然性,虽然真正实现上的细节可能会千差万别并会得到不断改进和优化,但是主要的算法于策略不会变化,一切都是由技术本身所决定。
Java是面向对象的编程语言,这种面向对象不仅体现在语法层面,也不仅仅体现在外在的Java类层面。JVM在内部使用C++类去描述Java类的面向对象特性,JVM的奇特之处就在于,连同这些内部描述的类也被设计成面向对象机制,毫不夸张地说,JVM内部的面向对象机制比外在的Java类语法层面的面向对象机制实现得更加彻底和更加纯粹,Java语言所表现出来得面向对象特性与JVM内部的面向对象相比,简直有点小儿科。在JVM内部,不仅用于描述Java类的C++对象被赋予相比于C++语言本身所具备的面向对象特性更深一层的对象机制,而且其还用于描述相对于Java类外在对象而言属于JVM内部不能由开发者控制的类型。JVM内部用于描述Java字节码文件中的常量池类型不能被Java开发者访问和操作,但是即使是这种内部类,虽然本身使用C++这种面向对象的语言描述,但是仍然被表示成oop-klass这种二分模型。
两模型三维度。
前面说过,JVM内部基于oop-klass模型描述一个Java类,将一个Java类一拆为二分别描述,第一个模型是oop,第二个模型是klass.所谓oop,并不是object-oriented programming(面向对象编程),而是ordinary object pointer(普通对象指针),它用来表示对象的实例信息。看起来像个指针,而实际上对象实例数据都藏在指针所指向的内存首地址后面的一片内存区域中。而klass则包含元数据和方法信息,用来描述Java类或者JVM内部自带的C++类型信息。其实,klass便是前文一直在讲的数据结构,Java类的继承信息、成员变量、静态变量、成员方法、构造函数等信息都在klass中保存,JVM据此便可以在运行期反射出Java类的全部结构信息。当然,JVM本身所定义的用于描述Java类的C++类也是用klass去描述,这相当于使用另一种面向对象的机制去描述C++类这种本身便是面向对象的数据。
JVM使用oop-klass这种一分为二的模型描述一个Java类,虽然模型只有两种,但是其实从3个不同的维度对一个Java类进行了描述。侧重于描述Java类的示例数据的第一种模型oop主要为Java类生成一张“实例数据视图”,从数据维度描述一个Java类实例对象中各个属性在运行期的值。而第二种模型klass则又分别从两个维度去描述一个Java类,第一个维度是Java类的"元信息试图",另一个维度则是虚函数列表,或者叫做方法分发规则。元信息视图为JVM在运行期呈现Java类的"全息"数据结构信息,这是JVM在运行期得以动态反射出类信息的基础。如图所示,盖度描述了JVM内部对Java类"两模型三维度"的映射
前面说过,JVM内部基于oop-klass模型描述一个Java类,将一个Java类一拆为二分别描述,第一个模型是oop,第二个模型是klass.所谓oop,并不是object-oriented programming(面向对象编程),而是ordinary object pointer(普通对象指针),它用来表示对象的实例信息。看起来像个指针,而实际上对象实例数据都藏在指针所指向的内存首地址后面的一片内存区域中。而klass则包含元数据和方法信息,用来描述Java类或者JVM内部自带的C++类型信息。其实,klass便是前文一直在讲的数据结构,Java类的继承信息、成员变量、静态变量、成员方法、构造函数等信息都在klass中保存,JVM据此便可以在运行期反射出Java类的全部结构信息。当然,JVM本身所定义的用于描述Java类的C++类也是用klass去描述,这相当于使用另一种面向对象的机制去描述C++类这种本身便是面向对象的数据。
JVM使用oop-klass这种一分为二的模型描述一个Java类,虽然模型只有两种,但是其实从3个不同的维度对一个Java类进行了描述。侧重于描述Java类的示例数据的第一种模型oop主要为Java类生成一张“实例数据视图”,从数据维度描述一个Java类实例对象中各个属性在运行期的值。而第二种模型klass则又分别从两个维度去描述一个Java类,第一个维度是Java类的"元信息试图",另一个维度则是虚函数列表,或者叫做方法分发规则。元信息视图为JVM在运行期呈现Java类的"全息"数据结构信息,这是JVM在运行期得以动态反射出类信息的基础。如图所示,盖度描述了JVM内部对Java类"两模型三维度"的映射
在Java编程语言中,并没有"虚函数":的概念,具体来说就是你不能在Java源代码中使用"virtual"这个关键字去修饰一个Java方法。而有过C++编程经验的小伙伴都知道,C++实现面向对象多态性的关键字就是virtual.这本来与Java毫无关系,毕竟是两种不同的语言,谁也无权强制要求别人为了支持堕胎就一定要通过"virtual"这个关键字。但是不巧的是,JVM在内部使用C++类所定义的一套对象机制去表达Java类的面向对象机制,这一表达顺手就把Java类的多态机制也包含在内了,毕竟Java号称是比C++更加纯粹的面向对象的语言,结果总不能连个多态都不支持吧。但是詹爷非但不走寻常路,而且海拔正常的路径给堵住了不让走,就是不让Java支持virtual的概念。但是这样就会带来一个问题,Java类最终被表达成了JVM内部的C++类,并且Java类方法的调用最终要通过对应的C++类,但是Java语言是面向对象的,多态性是其基本特性,这意味着JVM内部的C++类要能够支持Java语言的多态性,可是Java方法并不支持使用virtual这个关键字来修饰,这样问题就来了,C++层面怎样才能知道Java类中的哪个方法是虚函数,哪个方法不是虚函数呢?换言之,当面对一个多重继承的Java类体系时,JVM内部的C++类怎么才能将这种多态性表达出来呢?詹爷的做法很简单粗暴,那就是将Java类的所有函数都视为是"virtual"的,这样Java类中的每个方法都可以直接被其子类、子子类覆盖而不需要增加任何关键字作为修饰符。正因为如此,Java类中的每个方法都可以晚绑定,只不过对于一些确定的调用,在编译器便能实现早绑定。正因为JVM将Java类中的每一个函数都视为虚函数,所以最终在JVM内部的C++层面,就必须维护一套函数分发表。
体系总览。
在JVM内部定义了3种结构去描述一种类型:oop、klass和handle类。这一,这3种数据结构不仅能够描述外在的Java类,也能够描述JVM内在的C++类型对象。klass主要描述Java类和JVM内部C++类型的元信息和虚函数,这些元信息的实际值就保存在oop里面。oop中保存已给指针指向klass,这样在运行期JVM便能够知道每一个实例的数据结构和实际类型。handle是对oop的行为的封装,在访问Java类时一定是通过handle内部指针得到oop实例的,再通过oop就能拿到klass,如此handle最终便能操纵oop的行为了(注意,如果是调用JVM内部C++类型所对应的oop的函数,则不需要通过handle来中转,直接通过oop拿到指定的klass便能实现)。klass不仅包含自己所固有的行为接口,而且也能够操作Java类的函数.由于Java函数在JVM内部都被表示成虚函数,因此handle模型其实就是Java类行为的表达。先上一张图说明这种三角关系(如图所示)。。
这种三角关系最终在数据结构中得以落地,在handles.hpp文件中定义了Handle类的基本结构:
// handles.hpp
class Handle VALUE_OBJ_CLASS_SPEC {
private:
oop* _handle;
//....
};
在JVM内部定义了3种结构去描述一种类型:oop、klass和handle类。这一,这3种数据结构不仅能够描述外在的Java类,也能够描述JVM内在的C++类型对象。klass主要描述Java类和JVM内部C++类型的元信息和虚函数,这些元信息的实际值就保存在oop里面。oop中保存已给指针指向klass,这样在运行期JVM便能够知道每一个实例的数据结构和实际类型。handle是对oop的行为的封装,在访问Java类时一定是通过handle内部指针得到oop实例的,再通过oop就能拿到klass,如此handle最终便能操纵oop的行为了(注意,如果是调用JVM内部C++类型所对应的oop的函数,则不需要通过handle来中转,直接通过oop拿到指定的klass便能实现)。klass不仅包含自己所固有的行为接口,而且也能够操作Java类的函数.由于Java函数在JVM内部都被表示成虚函数,因此handle模型其实就是Java类行为的表达。先上一张图说明这种三角关系(如图所示)。。
这种三角关系最终在数据结构中得以落地,在handles.hpp文件中定义了Handle类的基本结构:
// handles.hpp
class Handle VALUE_OBJ_CLASS_SPEC {
private:
oop* _handle;
//....
};
可以看到,Handle类内部只有一个成员变量_handle,该变量类型是oop*,因此该变量最终指向的就是一个oop的首地址。换言之,只要能够拿到Handle对象,便能据此得到其所指向的oop对线实例,而通过oop对象实例又能进一步获取其所关联的klass实例,而获取到klass对象失利后,便能实现对oop对象方法的调用。因此,虽然从表面上看,handle体系貌似是对oop的一种封装,但是实际上其醉翁之意在于最终的klass体系。oop一半由对象头、对象转悠属性和数据体这3部分构成。其一般如图所示。。例如JVM内部对象constantPool常量池对象,其oop对象是constantPoolOOp,该对象的结构与上面的模型完全符合。上面这张oop模型图的中间布局时oop类型的专有属性区,JVM内部定义了若干oop类型,每一种oop类型都有自己特有的数据结构,oop的专有属性区便是用于存放各个oop所特有的数据结构的地方
oop体系。
虽然前面我们已经接触过constantPoolOop,也讲了很多oop的东西,但是oop究竟是啥?为什么要有这种模型?我们依然迷惑。
所谓oop就是ordinary object pointer,也即普通对象指针。但是究竟什么才是普通对象指针呢?要搞清楚何谓oop,要问2个问题:
1.HotSpot里的oop指啥
HotSpot里的oop其实就是GC所托管的指针,每一个oop都是一种xxxOopDesc*类型的指针。所有的oopDesc及其子类(除神器的markOopDesc外)的实例都由GC所管理,这才是最最重要的,是oop区分HotSpot里所使用的其他指针类型的地方。
2.对象指针之前为何要冠以"普通"二字
对象指针从本质上而言就是一个指针,指向xxxOopDesc的指针也是普通得不能再普通的指针,可是为何在HotSpot领域还要加一个"普通'来修饰?要回答这个问题,需要追溯到OOP(这里的OOP是指面向对象编程)的鼻祖——SmallTalk语言。
SmallTalk语言里的独享也由GC来管理,但是SmallTalk里面的一些简单的值类型对象都会使用所谓的"直接对象"的机制来实现,例如SmallTalk里面的整数类型。所谓"直接对象"(immediate object)就是并不在GC堆上分配对象实例,而是直接将实例内容存在对象指针里的对象,这样的指针也叫做"带标记的指针(tagged pointer)"。
这一点倒是与markOopDesc类型如出一辙,因为markOopDesc也是将整数值直接存储在指针里面,这个指针实际上并无"指向"内存的功能。
所以在SmallTalk的运行期,每当拿到一个对象指针时,都得先校验这个对象指针是一个直接对象还是一个真的指针?如果是真的指针,他就是一个"普通"的对象指针了。这样对象指针有了"普通"与"不普通"之分。
所以,在HotSpot里面,oop就是一个真的指针,而markOop则是一个看起来像指针但实际上是藏在指针里的对象(数据)。这也正是markOop实例不受GC托管的原因,因为只要出了函数作用域,指针变量就会被从堆栈上释放掉了,不需要垃圾回收了
虽然前面我们已经接触过constantPoolOop,也讲了很多oop的东西,但是oop究竟是啥?为什么要有这种模型?我们依然迷惑。
所谓oop就是ordinary object pointer,也即普通对象指针。但是究竟什么才是普通对象指针呢?要搞清楚何谓oop,要问2个问题:
1.HotSpot里的oop指啥
HotSpot里的oop其实就是GC所托管的指针,每一个oop都是一种xxxOopDesc*类型的指针。所有的oopDesc及其子类(除神器的markOopDesc外)的实例都由GC所管理,这才是最最重要的,是oop区分HotSpot里所使用的其他指针类型的地方。
2.对象指针之前为何要冠以"普通"二字
对象指针从本质上而言就是一个指针,指向xxxOopDesc的指针也是普通得不能再普通的指针,可是为何在HotSpot领域还要加一个"普通'来修饰?要回答这个问题,需要追溯到OOP(这里的OOP是指面向对象编程)的鼻祖——SmallTalk语言。
SmallTalk语言里的独享也由GC来管理,但是SmallTalk里面的一些简单的值类型对象都会使用所谓的"直接对象"的机制来实现,例如SmallTalk里面的整数类型。所谓"直接对象"(immediate object)就是并不在GC堆上分配对象实例,而是直接将实例内容存在对象指针里的对象,这样的指针也叫做"带标记的指针(tagged pointer)"。
这一点倒是与markOopDesc类型如出一辙,因为markOopDesc也是将整数值直接存储在指针里面,这个指针实际上并无"指向"内存的功能。
所以在SmallTalk的运行期,每当拿到一个对象指针时,都得先校验这个对象指针是一个直接对象还是一个真的指针?如果是真的指针,他就是一个"普通"的对象指针了。这样对象指针有了"普通"与"不普通"之分。
所以,在HotSpot里面,oop就是一个真的指针,而markOop则是一个看起来像指针但实际上是藏在指针里的对象(数据)。这也正是markOop实例不受GC托管的原因,因为只要出了函数作用域,指针变量就会被从堆栈上释放掉了,不需要垃圾回收了
JVM内部并不是oop一个人在战斗,而是整个一套继承体系在运作。在oopsHierarchy.hpp中定义了这个体系家族的所有成员。oopsHierarchy.hpp文件如图所示
这些不同的oop类型能够分别描述不同的对象,具体作用见如表所示。
面对这十几种不同的oop大可不必惊慌,事实上只需要关注两种最常用的即可:constantPoolOOp和instanceOop.其中,constantPoolOop前面已经描述过。instanceOop后面再分析。作为Java程序的解释器和虚拟运行介质,JVM将Java实例映射成instanceOop,这注定instanceOop是一个无法跳过的坎,当然也注定其过程一定很精彩。
虽然oop类型比较多,但是只要深入研究一番constantPoolOop和instanceOop这两个最重要的oop,其他的oop自会触类旁通。在这里有必要再次堆JDK的版本问题做个说明。可能很多JVM发烧友都知道,JDK8中的oop与JDK6相比变化很大,整个继承体系都发生了变化,但是本质上并没有发生多大变化。JDK8中仍然有描述constantPool常量池的oop,仍然有描述Java类实例对象的oop,JDK8并没有抛弃Java字节码文件中的常量池,没有对Java字节码文件结构进行大刀阔斧的调整(事实上这已经称为Java的一种标准,想改也改不了),并没有对描述Java类的oop-klass这种二分模型进行根本上的改变。只要这些机制或标准不发生彻底的变化,那么相应的实现机制便注定不可能有本质上的不同。因此JDK8虽然修改了继承关系,对一些oop进行了删减,但是在本质上与JDK6仍然保持一致。正是由于这种原因,对JDK6的研究并不会变得过时,更不会徒劳无功。所以,我们基本以JDK6源码为主,分析JVM的内存模型。如果对JDK8情有独钟,也完全不用担心辛辛苦苦做的研究会白费掉。还有一点需要注意,由于oopdesc不同的子类类型名称都以OopDesc结尾,因此为了简化,JVM为其取了别名,统一以oop结尾
面对这十几种不同的oop大可不必惊慌,事实上只需要关注两种最常用的即可:constantPoolOOp和instanceOop.其中,constantPoolOop前面已经描述过。instanceOop后面再分析。作为Java程序的解释器和虚拟运行介质,JVM将Java实例映射成instanceOop,这注定instanceOop是一个无法跳过的坎,当然也注定其过程一定很精彩。
虽然oop类型比较多,但是只要深入研究一番constantPoolOop和instanceOop这两个最重要的oop,其他的oop自会触类旁通。在这里有必要再次堆JDK的版本问题做个说明。可能很多JVM发烧友都知道,JDK8中的oop与JDK6相比变化很大,整个继承体系都发生了变化,但是本质上并没有发生多大变化。JDK8中仍然有描述constantPool常量池的oop,仍然有描述Java类实例对象的oop,JDK8并没有抛弃Java字节码文件中的常量池,没有对Java字节码文件结构进行大刀阔斧的调整(事实上这已经称为Java的一种标准,想改也改不了),并没有对描述Java类的oop-klass这种二分模型进行根本上的改变。只要这些机制或标准不发生彻底的变化,那么相应的实现机制便注定不可能有本质上的不同。因此JDK8虽然修改了继承关系,对一些oop进行了删减,但是在本质上与JDK6仍然保持一致。正是由于这种原因,对JDK6的研究并不会变得过时,更不会徒劳无功。所以,我们基本以JDK6源码为主,分析JVM的内存模型。如果对JDK8情有独钟,也完全不用担心辛辛苦苦做的研究会白费掉。还有一点需要注意,由于oopdesc不同的子类类型名称都以OopDesc结尾,因此为了简化,JVM为其取了别名,统一以oop结尾
klass体系。
oop的描述先告一段落,再来看看klass部分。按照JVM的官方解释,klass主要提供下面2种能力:
# klass提供一个与Java类对等的C++类型描述
# klass提供虚拟机内部的函数分发机制
其实这种说法与上文所说的2种维度的含义是相同的。klass分别从类结构和类行为这两方面取描述一个Java类(当然也包含JVm内部非开放的C++类)。
与oop相同,在JVM内部也不是klass一个人在战斗,而是一个家族。klass。klass家族体系如图所示。
klass家族一看就比oop家族要人丁兴旺,让人望而生畏。但是不要紧,只要深入研究了其中两个最重要的klass,其他的便都会是浮云。在klass家族里,除去与constantPoolOop相对应的constantPoolKlass之外,最重要的莫过于instanceKlass和klassKlass了。这两个在后面会介绍,其中instanceKlass,顾名思义,其实就是专门用于描述Java类的。名字取得好就这点好处,往往只需看个名字便能知其八分。因为很多事情的真相其实就隐藏在变量名称或者类型的名称里面,无需太多解释。klass家族的基类是Klass类,而Klass其实并不是顶级父类,Klass继承了一个名叫Klass_vtbl的类,后者才是整个klass家族的元老。当然顾名思义,后者貌似是一个描述vtbl(即虚函数表)的类,而事实上该类的确当此大任。
oop的描述先告一段落,再来看看klass部分。按照JVM的官方解释,klass主要提供下面2种能力:
# klass提供一个与Java类对等的C++类型描述
# klass提供虚拟机内部的函数分发机制
其实这种说法与上文所说的2种维度的含义是相同的。klass分别从类结构和类行为这两方面取描述一个Java类(当然也包含JVm内部非开放的C++类)。
与oop相同,在JVM内部也不是klass一个人在战斗,而是一个家族。klass。klass家族体系如图所示。
klass家族一看就比oop家族要人丁兴旺,让人望而生畏。但是不要紧,只要深入研究了其中两个最重要的klass,其他的便都会是浮云。在klass家族里,除去与constantPoolOop相对应的constantPoolKlass之外,最重要的莫过于instanceKlass和klassKlass了。这两个在后面会介绍,其中instanceKlass,顾名思义,其实就是专门用于描述Java类的。名字取得好就这点好处,往往只需看个名字便能知其八分。因为很多事情的真相其实就隐藏在变量名称或者类型的名称里面,无需太多解释。klass家族的基类是Klass类,而Klass其实并不是顶级父类,Klass继承了一个名叫Klass_vtbl的类,后者才是整个klass家族的元老。当然顾名思义,后者貌似是一个描述vtbl(即虚函数表)的类,而事实上该类的确当此大任。
klass家族的各个成员的定位是不同的,具体如表所示(由于JDK6中的很多klass在JDK8中已经被删减了,因此这里仅挑重要的几个进行描述)。由于JDK8并没有对JDK6进行本质上的模型变革,因此JDK6中的这些重要的klass模型依然能够在JDK8中寻找到,出了klassKlass这个特殊的类型。
既然klass能够描述Java类的数据结构(即元数据),那么一起来看看klass类里面究竟有些什么。基类Klass的定义如下
该类包含的字段比较多,相关字段含义或作用如下表示
handle体系。
前面讲过,handle封装了oop,由于通过oop可以拿到klass,而klass是对Java类型数据结构和方法的描述,因此handle间接封装了klass.JVM内部使用一个table来存储oop指针。如果说oop是对普通对象的直接引用,那么handle就是对普通对象的一种间接引用,中间隔了一层。但是JVM内部为何要使用这种间接引用呢?答案是,这完全是为GC考虑。具体表现在2哥地方:
# 通过handle,能够让GC知道其内部代码都有哪些地方持有GC所管理的对象的引用,这需要扫描handle所对应的table,这样JVM便无需关注其内部到底哪些地方持有对普通对象的引用
# 在GC过程中,如果发生了对象移动(例如从新生代移到了老年代),那么JVM的内部引用无须跟着更改为被移动对象的新地址,JVM只需要更改handle table里对应的指针即可。
当然实际的handle作为对Java类方法的访问的包装,远不止上面所描述的这么简单。这里涉及Java类的类继承和接口继承的话题,在C++领域,类的继承和多态性最终通过vptr(虚函数表)来实现,在klass内部,记录了每一个类的vptr信息。具体而言分为两部分来描述.
# 1.vtable虚函数表
vtable中存放Java类中非静态和非private的方法入口,JVM调用Java类的方法(非静态和非private)时,最终会访问vtable,找到对应的方法入口
# 2.itable接口函数表
itable中存放Java类所实现的接口类方法。同样,JVM调用接口方法时,最终会访问itable,找到对应的接口方法入口。
不过要注意,vtable和itable里面存放的并不是Java类和接口方法的直接入口,而是指向了Method对象入口,JVM会通过Method最终拿到真正的Java类方法入口,得到方法所对应的字节码/二进制机器码并执行。当然,对于被JIT进行动态编译后的方法,JVM最终拿到的是其对应的被编译后的本地方法的入口。
与oop和klass一样,在JVM内部,handle也不是一个人在战斗,而是有一个庞大的家族,在handles.hpp文件中定义了handle体系的家族:
前面讲过,handle封装了oop,由于通过oop可以拿到klass,而klass是对Java类型数据结构和方法的描述,因此handle间接封装了klass.JVM内部使用一个table来存储oop指针。如果说oop是对普通对象的直接引用,那么handle就是对普通对象的一种间接引用,中间隔了一层。但是JVM内部为何要使用这种间接引用呢?答案是,这完全是为GC考虑。具体表现在2哥地方:
# 通过handle,能够让GC知道其内部代码都有哪些地方持有GC所管理的对象的引用,这需要扫描handle所对应的table,这样JVM便无需关注其内部到底哪些地方持有对普通对象的引用
# 在GC过程中,如果发生了对象移动(例如从新生代移到了老年代),那么JVM的内部引用无须跟着更改为被移动对象的新地址,JVM只需要更改handle table里对应的指针即可。
当然实际的handle作为对Java类方法的访问的包装,远不止上面所描述的这么简单。这里涉及Java类的类继承和接口继承的话题,在C++领域,类的继承和多态性最终通过vptr(虚函数表)来实现,在klass内部,记录了每一个类的vptr信息。具体而言分为两部分来描述.
# 1.vtable虚函数表
vtable中存放Java类中非静态和非private的方法入口,JVM调用Java类的方法(非静态和非private)时,最终会访问vtable,找到对应的方法入口
# 2.itable接口函数表
itable中存放Java类所实现的接口类方法。同样,JVM调用接口方法时,最终会访问itable,找到对应的接口方法入口。
不过要注意,vtable和itable里面存放的并不是Java类和接口方法的直接入口,而是指向了Method对象入口,JVM会通过Method最终拿到真正的Java类方法入口,得到方法所对应的字节码/二进制机器码并执行。当然,对于被JIT进行动态编译后的方法,JVM最终拿到的是其对应的被编译后的本地方法的入口。
与oop和klass一样,在JVM内部,handle也不是一个人在战斗,而是有一个庞大的家族,在handles.hpp文件中定义了handle体系的家族:
oop家族
klass家族
可以看到,在handles.hpp中,通过2个宏分别批量声明了oop和klass家族的各个类所对应的handle类型。在编译器,宏被替换后,便出现了如下handle体系
这里有个问题,前面不是一直说handle是对oop的直接封装和对klass的间接封装吗?为什么这里却分别给oop和klass定义了两套不同的handle体系呢?这给人的感觉好像是,封装oop的handle和封装klass的handle并不是同一个handle,既然不是同一个handle,那么通过封装oop的handle还怎么去得到所对应的klass信息呢?其实这正是JVM内部常常容易使人迷惑的地方。在JVM中,使用oop-klass这种一分为二的模型去描述Java类以及JVM内部的特殊类群体,为此JVM内部特定义了各种oop和klass类型。但是对于每一个oop其实都是一个C++类型,也即klass;而对于每一个klass所对应的class,在JVM内部又都会被封装成oop.JVM在具体描述一个类型时,会使用oop去存储这个类型的实例数据,并使用klass去存储这个类型的元数据和虚方法表。而当一个类型完成其生命周期后,JVM会触发GC去回收,在回收时,既要回收一个类实例所对应的实例数据oop,也要回收其所对应的元数据和虚方法表(当然,两者并不是同时回收,一个是堆区的垃圾回收,一个是永久区的垃圾回收)。为了让GC技能回收oop也能回收klass,因此oop本身被封装成了oop,而klass也被封装成oop。而JVM内部恰好将描述类实例的oop全都定义成类名以oop结尾的类,并将描述类结构和方法信息的klass全都定义成类名以klass结尾的类。而JVM内部描述类信息的模型恰巧也叫做oop-klass,与类名存在重合,这就导致了很多人的疑惑,这些疑惑完全是因为叫法上的重合而产生。
因此为了进一步解开疑惑,我们不妨换个叫法,不再将JVM内部描述类信息的模型叫做oop-klass。而是叫做data-meeta模型。然后将JVM内部的oop体系的类名全都改成以Data结尾,例如,methodData、instanceData、cosntantPoolData等,同时将klass体系的类名也全都改成以Metda结尾,例如,methodMeta、instanceMeta、constantPoolMeta等。JVM在进行GC时,既要回收Data类实例,也要回收Meta类实例,为了让GC便于回收,因此对每一个Data类和每一个Meta类,JVM在内部都将其封装成了oop模型。对于Data类,其内存布局时前面为oop对象头,后面紧跟实例数据;而对Meta类,其内存布局时前面为oop对象头,后面紧跟实例数据和虚方法表。封装成oop之后,再进一步使用handle来封装,于是便有利于GC内存回收。
在这种新的模型中,不管是Data类还是Meta类,都是一种普通的C++类型,只不过它们从不同的角度对Java类进行了描述,不管时Data类还是Meta类,当其所在的JVM的内存区域爆满后,都会触发GC,为了方便回收,因此就需要将其封装成oop.
在这种新的模型中,不管是Data类还是Meta类,都是一种普通的C++类型,只不过它们从不同的角度对Java类进行了描述,不管时Data类还是Meta类,当其所在的JVM的内存区域爆满后,都会触发GC,为了方便回收,因此就需要将其封装成oop.
oop、klass、handle的相互转换。
oop、klass和handle三者之间是可以相互转换的。
oop、klass和handle三者之间是可以相互转换的。
1.从oop和klass到handle
handle主要用于封装oop和klass,因此往往在声明handle类实例的时候,直接将oop或者klass传递进去,便完成了这种封装。同时,当JVM执行Java类的方法时,最终也是通过handle拿到对应的oop和klass.而为了支持高效快速的调用,JVM重载了类方法访问操作符->。oop类型所对应的handle的基类是Handle,Handle有如下几种构造函数:
handle主要用于封装oop和klass,因此往往在声明handle类实例的时候,直接将oop或者klass传递进去,便完成了这种封装。同时,当JVM执行Java类的方法时,最终也是通过handle拿到对应的oop和klass.而为了支持高效快速的调用,JVM重载了类方法访问操作符->。oop类型所对应的handle的基类是Handle,Handle有如下几种构造函数:
这个构造函数接受op类型的入参,并将其保存到当前线程在堆区所申请的handleArea表中。注意,由于oop仅仅是一种指针,因此表中存储的实际上也是指针。
除了这种带有入参的构造函数,还有默认构造函数
oop和klass被handle封装之后,JVM内部大部分对oop和klass的函数调用都要经过Handle类。而从Handle类到oop或者klass,都必须经过至少依次中间过渡性的寻址,因此为了减少寻址次数,Handle重载了操作符->:
这里定义了oop operate ->() 操作符重载函数,返回non_null_obj(),而后者则直接返回oop类型的*_handle指针,因此如果JVM想要调用oop的某个函数,可以直接通过handle.例如,在常量池类型constantPoolOopDesc中定义了void field_at_put(int which, int class_index, int name_and_type_index)函数,该函数将解析除的常量池元素保存进constantPoolOop对象头后面的数据实例区中,
这里定义了oop operate ->() 操作符重载函数,返回non_null_obj(),而后者则直接返回oop类型的*_handle指针,因此如果JVM想要调用oop的某个函数,可以直接通过handle.例如,在常量池类型constantPoolOopDesc中定义了void field_at_put(int which, int class_index, int name_and_type_index)函数,该函数将解析除的常量池元素保存进constantPoolOop对象头后面的数据实例区中,
在解析常量池的过程中,JVM并没有直接通过constantPoolOop指针来调用这个函数,而是通过constantPoolHandle.这里执行了cp->field_at_put()函数,而cp则是constantPoolHandle类型
klass体系的Handle类全都继承于KlasHandle这个基类,,与oop体系类似,KlassHandle中也定义了多种构造函数用来实现对klass或oop的封装,,并且重载了operate ->()函数实现从handle直接调用klass的函数。JVM创建了Java类所对应的类模型时便使用了这种方式。
在该函数中,先通过InstanceKlass::allocate_instance_klass()创建了一个InstanceKlass*类型的_klass变量,接着通过instanceKlassHandle this_klass (THREAD, _klass)将oop封装到了instanceKlassHandle中,接下来便通过this_klass指针来直接调用instanceKlass中的各种函数。
在该函数中,先通过InstanceKlass::allocate_instance_klass()创建了一个InstanceKlass*类型的_klass变量,接着通过instanceKlassHandle this_klass (THREAD, _klass)将oop封装到了instanceKlassHandle中,接下来便通过this_klass指针来直接调用instanceKlass中的各种函数。
2.klass与oop的相互转换。
为了便于GC回收,每一种klass实例最终都要被封装成对应的oop,具体操作时,先分配对应的oop实例,接着将klass实例分配到oop对象头的后面,从而实现oop+klass这种内存布局结构。对于任何一种给定的oop和其对应的klass,oop对象首地址到其对应的klass对象首地址的距离都是固定的,因此只要得到了oop对象首地址,便能通过偏移固定的距离得到klass对象的首地址。反之,得到klass对象的首地址后,也能通过偏移固定的距离得到oop对象的首地址。通过内存偏移,便能实现oop和klass的相互转换。对于每一种oop,都提供了klass__part()这样的函数,通过本函数可以直接由oop得到对应的klass实例。例如klassOop便提供了这种函数:
// klassOop.hpp
Klass* klass_part() const {
return (Klass*)((address) this + klass_part_offset_in_bytes());
}
由于klass在内存上相对于oop实例位于高地址方向,因此从oop转换到klass只需要增加oop的首地址。同理,如果将klass转换为oop,则只需要对klass首地址做减法。例如
// klass.hpp
klassOop as_klassOop() ocnst {
return (klassOop)(((char*)this) - sizeof(klassOopDesc));
}
下面还是以前面所举的学生类Student为例,假设在某个Java方法中连续实例化了3个Student类,那么最终在JVM内存中将会出现如图所示这种布局
为了便于GC回收,每一种klass实例最终都要被封装成对应的oop,具体操作时,先分配对应的oop实例,接着将klass实例分配到oop对象头的后面,从而实现oop+klass这种内存布局结构。对于任何一种给定的oop和其对应的klass,oop对象首地址到其对应的klass对象首地址的距离都是固定的,因此只要得到了oop对象首地址,便能通过偏移固定的距离得到klass对象的首地址。反之,得到klass对象的首地址后,也能通过偏移固定的距离得到oop对象的首地址。通过内存偏移,便能实现oop和klass的相互转换。对于每一种oop,都提供了klass__part()这样的函数,通过本函数可以直接由oop得到对应的klass实例。例如klassOop便提供了这种函数:
// klassOop.hpp
Klass* klass_part() const {
return (Klass*)((address) this + klass_part_offset_in_bytes());
}
由于klass在内存上相对于oop实例位于高地址方向,因此从oop转换到klass只需要增加oop的首地址。同理,如果将klass转换为oop,则只需要对klass首地址做减法。例如
// klass.hpp
klassOop as_klassOop() ocnst {
return (klassOop)(((char*)this) - sizeof(klassOopDesc));
}
下面还是以前面所举的学生类Student为例,假设在某个Java方法中连续实例化了3个Student类,那么最终在JVM内存中将会出现如图所示这种布局
常量池klass模型(1).
既然在JVM内部,每一个对象都会表示为oop-klass这种一分为二的模型,那么常量池也不同例外。在前面已经分析了JVM为constantPoolOop所分配的内存大小和内存布局,具体的内存布局如图所示。到目前位置,JVM为constantPoolOop所分配的内存区域还是空的,还没有数据。但是,在ClassFileParser::parse_constant_pool()函数中执行完oopFactory::new_constantPool()函数时,其实已经为constantPoolOop初始化好了_metadata所指向的实例。oopFactory::new_constantPool()函数的逻辑如下:
// oopFactory.cpp
constantPoolOop oopFactory::new_constantPool(int length, bool is_conc_safe, TRAPS) {
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
return ck->allocate(length, is_conc)safe, CHECK_NULL);
}
前文在分析常量池的内存分配链路时,直接从这里的第二行开始(即ck->allocate(length, is_conc_safe, CHECK_NULL)),而忽略了第一行,即
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
这行代码调用全局对象Universe的静态函数constantPoolKlassObj()来获取constantPoolKlass实例指针,Universe::constantPoolKlassObj()函数逻辑如下:
static klassOop constantPoolKlassObj() {
return _constantPoolKlassObj;
}
这个函数直接返回了全局静态变量_constantPoolKlassObj.
既然在JVM内部,每一个对象都会表示为oop-klass这种一分为二的模型,那么常量池也不同例外。在前面已经分析了JVM为constantPoolOop所分配的内存大小和内存布局,具体的内存布局如图所示。到目前位置,JVM为constantPoolOop所分配的内存区域还是空的,还没有数据。但是,在ClassFileParser::parse_constant_pool()函数中执行完oopFactory::new_constantPool()函数时,其实已经为constantPoolOop初始化好了_metadata所指向的实例。oopFactory::new_constantPool()函数的逻辑如下:
// oopFactory.cpp
constantPoolOop oopFactory::new_constantPool(int length, bool is_conc_safe, TRAPS) {
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
return ck->allocate(length, is_conc)safe, CHECK_NULL);
}
前文在分析常量池的内存分配链路时,直接从这里的第二行开始(即ck->allocate(length, is_conc_safe, CHECK_NULL)),而忽略了第一行,即
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
这行代码调用全局对象Universe的静态函数constantPoolKlassObj()来获取constantPoolKlass实例指针,Universe::constantPoolKlassObj()函数逻辑如下:
static klassOop constantPoolKlassObj() {
return _constantPoolKlassObj;
}
这个函数直接返回了全局静态变量_constantPoolKlassObj.
_constantPoolKlassObj变量在JVM启动过程中被实例化,在JVM初始化过程中,会调用如图逻辑。在这个函数中,先调用KlassHandle h_this_klass(THREAD, Universe::klassKlassObj())函数获取klassKlass实例。这个实例究竟是啥,暂且不管
在JVM初始化过程中,会执行如下逻辑:
在这个逻辑中,最终调用base_create_klass_oop()函数创建klassKlass的实例,该实例是全局性的,可以通过调用Universe::klassKlassObj()函数获取到。
至此我们知道,在JVM启动过程中,先创建了klassKlass实例,在根据该实例,创建了常量池所对应的Klass类——constantPoolKlass.因此,欲分析constantPoolKlass实例的构建机制,首先就要分析klassKlass实例的构建。下面就先从klassKlass实例的构建原理说起
在这个逻辑中,最终调用base_create_klass_oop()函数创建klassKlass的实例,该实例是全局性的,可以通过调用Universe::klassKlassObj()函数获取到。
至此我们知道,在JVM启动过程中,先创建了klassKlass实例,在根据该实例,创建了常量池所对应的Klass类——constantPoolKlass.因此,欲分析constantPoolKlass实例的构建机制,首先就要分析klassKlass实例的构建。下面就先从klassKlass实例的构建原理说起
klassKlass实例构建总链路。
先上一张klassKlass实例构建的总体链路图。
klassKlass.cpp::create_klass()函数位于非常核心的位置,该位置也是理解JVM内存内模型的关键,将这个理解透了,JVM内存模型便明白了一大半。由于这个函数实在太重要了,因此十分有必要对其进行详细讲解。如图所示的调用链路虽然相比于JVM内部其他链路已经显得很简单了,但是这些方法分布在不同的源代码文件中,阅读时需要在不同的源代码文件中反复切换,因此下面通过将函数展开并将源码贴在一起,让大家更容易理清头绪
先上一张klassKlass实例构建的总体链路图。
klassKlass.cpp::create_klass()函数位于非常核心的位置,该位置也是理解JVM内存内模型的关键,将这个理解透了,JVM内存模型便明白了一大半。由于这个函数实在太重要了,因此十分有必要对其进行详细讲解。如图所示的调用链路虽然相比于JVM内部其他链路已经显得很简单了,但是这些方法分布在不同的源代码文件中,阅读时需要在不同的源代码文件中反复切换,因此下面通过将函数展开并将源码贴在一起,让大家更容易理清头绪
上面就是整合之后的源代码,其实代码量并不是很大。
这部分代码逻辑大体上可以划分为6个步骤,再上面的这段整合好的代码中分别使用1、2、3等这样的编号进行标注,这6个步骤如图所示。。下面将从这6个方面按照程序主线依次讲解。
1.为klassOop申请内存。
从整合之后的源代码可以看出,从进入klassKlass.cpp::create_klass()之后,就开始一路调用被层层封装起来的函数,除了函数调用之外并没有其他复杂的逻辑,一直调用到CollectedHeap::permanent_obj_allocate_no_klass_install(),才终于消停,开始做正事了。函数名不小心暴露了这件正事,顾名思义,这里开始在永久区为obj对象分配内存了。这个函数的实现逻辑是:
// collectedHeap.inline.hpp
// permanent_obj_allocate_no_klass_install()函数
// 为oop申请内存
HeapWord* obj = common_permanent_mem_allocate_init(size, CHECK_NULL);
// 初始化oop标识
post_allocation_setup_no_klass_install(klass, obj, size);
这个函数主要干了两件正事:内存申请和标识初始化。申请内存调用的是common_permanent_mem_allocate_init()函数,这个函数在讲解ConstantPoolOop的内存初始化详细分析过,其主要功能就是根据传入的size在永久区划分出一块指定大小的内存区域。函数的实现不再赘述。但是size的大小需要关注。这里的size参数值的源头在klassKlass.cpp::create_klass()函数中,该函数调用base_create_klass_oop(h_this_klass, header_size(), o.vtbl_value(), CHECK_NULL)时,第2个参数时header_size()函数所返回的知,要知道size参数的知,只需要看看header_size()函数的实现即可。该函数定义在klassKlass.hpp文件中,定义如下:
static int header_size() {
return oopDesc::header_size() + sizeof(kassKlass)/HeapWordSize;
}
这个定义表明,size的构成包含两部分:oopDesc和klassKlass的类型所占内存空间。这两个类型本身的大小在编译期间便可知道。为什么会是这么大呢?别忘了主题——实例化klassKlassOop.既然要实例化klassKlassOop,就得为其分配足够的内存空间,而前面说过,JVM内部通过"两模型三维度"去描述一个对象,两个模型自然就是oop和klass.klassKlass这个类实例在JVM内部也是被描述的对象,因此JVM也将这个对象一分为二,分别使用oop和klass这两个拆分的模型来描述。
从整合之后的源代码可以看出,从进入klassKlass.cpp::create_klass()之后,就开始一路调用被层层封装起来的函数,除了函数调用之外并没有其他复杂的逻辑,一直调用到CollectedHeap::permanent_obj_allocate_no_klass_install(),才终于消停,开始做正事了。函数名不小心暴露了这件正事,顾名思义,这里开始在永久区为obj对象分配内存了。这个函数的实现逻辑是:
// collectedHeap.inline.hpp
// permanent_obj_allocate_no_klass_install()函数
// 为oop申请内存
HeapWord* obj = common_permanent_mem_allocate_init(size, CHECK_NULL);
// 初始化oop标识
post_allocation_setup_no_klass_install(klass, obj, size);
这个函数主要干了两件正事:内存申请和标识初始化。申请内存调用的是common_permanent_mem_allocate_init()函数,这个函数在讲解ConstantPoolOop的内存初始化详细分析过,其主要功能就是根据传入的size在永久区划分出一块指定大小的内存区域。函数的实现不再赘述。但是size的大小需要关注。这里的size参数值的源头在klassKlass.cpp::create_klass()函数中,该函数调用base_create_klass_oop(h_this_klass, header_size(), o.vtbl_value(), CHECK_NULL)时,第2个参数时header_size()函数所返回的知,要知道size参数的知,只需要看看header_size()函数的实现即可。该函数定义在klassKlass.hpp文件中,定义如下:
static int header_size() {
return oopDesc::header_size() + sizeof(kassKlass)/HeapWordSize;
}
这个定义表明,size的构成包含两部分:oopDesc和klassKlass的类型所占内存空间。这两个类型本身的大小在编译期间便可知道。为什么会是这么大呢?别忘了主题——实例化klassKlassOop.既然要实例化klassKlassOop,就得为其分配足够的内存空间,而前面说过,JVM内部通过"两模型三维度"去描述一个对象,两个模型自然就是oop和klass.klassKlass这个类实例在JVM内部也是被描述的对象,因此JVM也将这个对象一分为二,分别使用oop和klass这两个拆分的模型来描述。
当common_permanent_mem_allocate_init()函数执行完之后,JVM的永久区中便多了一个对象内存布局,该对象时klassKlassOop,其内存布局模型如图所示。图中的klassKlass实例区其实就是klassKlass的实例对象所在的地方,该区域中的实例数据都是klassKlass类的成员变量。虽然直到这里,并没有明确提出这块内存区域就隶属klassKlassOop这个对象,但是并不妨碍我们大胆猜测,因为JVM既然将klassKlass也看作是一个对象,那么JVM一定会将其包装成一个oop,这样才方便垃圾统一回收。
前面说过,JVM的常量池constantPoolOop的内存分配,对于常量池对象,对于constantPoolOop对象,JVM的内存模型布局如图所示。。对比下klassKlassOop和constantPoolOop这两个对象的内存布局,其对象头都是一致的,到那时紧跟在对象头后面的数据区的数据则大不相同。constantPoolOop的数据区由两部分组成:constantPoolOop自己的字段和长度等同于一个Java类编译后得到的常量池所有元素所占内存区域。而到了klassKlassOop对象,其数据区则直接编程了klassKlass类型实例。这里有一个点疑问:klassKlassOop的对象头后面直接跟了klass模型实例,klass模型实例一般用于描述对象的元数据,但是oop本身含有一个指针_metadata指向这个元数据区域,那么oop的_metadata指针指向那里,还有用吗?或者难道是直接指向其自身?
如果你还不明白,可以这样思考,在JVM内部按照两模型三维度区描述一个对象,那么如果按照这种规范来描述klassKlassOop,则klassKlassOop的内存布局应该是如图所示的样子.在该图中,klassKlassOop._metadata指向了一个专门的元数据区域,这样Java类通过对类实例进行反射便能在运行期动态获取类型的结构信息。但是很明显,真实的情况是klassKlassOop的类元信息直接跟在了oop对象头后面,oop后面跟的并不是oop本身的实例数据。既然这样那么_metadata指针最终会指向哪里呢?
2.klassOop内存清零。
为klassOop分配完内存后,接下来开始将这段内存清零。清零调用函数collectedHeap.init_obj(),之所以要清零,是因为JVM的永久区是一个可重复使用的内存区域,会被GC反复回收,因此JVM为oop刚申请的内存区域里可能还保存着原来已经被清理的对象的数据。
为klassOop分配完内存后,接下来开始将这段内存清零。清零调用函数collectedHeap.init_obj(),之所以要清零,是因为JVM的永久区是一个可重复使用的内存区域,会被GC反复回收,因此JVM为oop刚申请的内存区域里可能还保存着原来已经被清理的对象的数据。
3.初始化mark。
通过前面两个步骤,已经成功地为klassOop申请了内存并实现清零,那么接下来地逻辑自然是往内存里填充数据。oop类里面一共包含两个成员变量:_mark和_metadata,这里先填充_mark变量._mark变量地填充主要通过调用post_allocation_setup_no_klass_install()函数实现。在上面整合地源码中贴出了该函数的实现,其中主要调用了obj->set_mark(markOopDesc::prototype())函数。
这里有必要对markOopDesc这个C++类做一个简单的说明,因为该类在整个JVM的源代码中都显得十分特别,甚至可以说非常诡异。之所以说其诡异,是因为其实markOopDesc类型在JVM内部就是起个标记的作用,说白了就是一个整型数字,但是设计者偏偏将其设计成一个类,并且还继承了oopDesc类,从而给人一种好像markOop也是一个"对象"的错觉,好像这个类纯粹就是为了迎合或者遵循JVM内部面向对象的设计风格的,说它是"过度设计"貌似也没有太冤枉它。
markOopDesc的开发者倒也坦诚,在markOopDesc的注释信息里直接敞开天窗说亮话,markOopDesc类注释的开头两句赫然写着:
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
翻译:
// 注意,该标记不是一个真正的 oop,而只是一个词。
// 由于历史原因,它被放置在 oop 层次结构中。
看来作者还是很直白的,直接说出了markOopDesc并不是一个真正的oop对象指针,之所以也将其放在oop的类继承体系之中,完全是由于历史的原因。至于到底是和历史原因,大家有兴趣可以去找找这方面的资料。
总之,这个类非常容易使人困惑,如果顺着oop这套模型去分析,反而会走上"邪道",并且最终会走进死胡同。markOopDesc类出了从oopDesc类继承而来的两个成员变量,并没有自己的成员变量,只是里面定义了不少枚举。这些枚举类型本身并不占用内存空间。在为klassOop填充_mark成员变量时,调用了obj->set_mark(markOopDesc::prototype())函数,其入参是markOopDesc::prototype(),该函数的声明如下:
// Prototype mark for initialization
static markOop prototype() {
return markOop( no_hash_in_place | no_lock_in_place );
}
在prototype()函数里貌似返回了markOop的构造函数,这个构造函数的入参是一个整型变量。先从markOopDesc.hpp文件中搜索这样的构造函数,结果很遗憾搜不到。由于markOopDesc继承了oopDesc类,但是oopDesc类中也没有定义任何一个类似的构造函数。事实上,这里并不是调用了markOop的构造函数,而是C语言的内建类型直接赋初值的写法。
通过前面两个步骤,已经成功地为klassOop申请了内存并实现清零,那么接下来地逻辑自然是往内存里填充数据。oop类里面一共包含两个成员变量:_mark和_metadata,这里先填充_mark变量._mark变量地填充主要通过调用post_allocation_setup_no_klass_install()函数实现。在上面整合地源码中贴出了该函数的实现,其中主要调用了obj->set_mark(markOopDesc::prototype())函数。
这里有必要对markOopDesc这个C++类做一个简单的说明,因为该类在整个JVM的源代码中都显得十分特别,甚至可以说非常诡异。之所以说其诡异,是因为其实markOopDesc类型在JVM内部就是起个标记的作用,说白了就是一个整型数字,但是设计者偏偏将其设计成一个类,并且还继承了oopDesc类,从而给人一种好像markOop也是一个"对象"的错觉,好像这个类纯粹就是为了迎合或者遵循JVM内部面向对象的设计风格的,说它是"过度设计"貌似也没有太冤枉它。
markOopDesc的开发者倒也坦诚,在markOopDesc的注释信息里直接敞开天窗说亮话,markOopDesc类注释的开头两句赫然写着:
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
翻译:
// 注意,该标记不是一个真正的 oop,而只是一个词。
// 由于历史原因,它被放置在 oop 层次结构中。
看来作者还是很直白的,直接说出了markOopDesc并不是一个真正的oop对象指针,之所以也将其放在oop的类继承体系之中,完全是由于历史的原因。至于到底是和历史原因,大家有兴趣可以去找找这方面的资料。
总之,这个类非常容易使人困惑,如果顺着oop这套模型去分析,反而会走上"邪道",并且最终会走进死胡同。markOopDesc类出了从oopDesc类继承而来的两个成员变量,并没有自己的成员变量,只是里面定义了不少枚举。这些枚举类型本身并不占用内存空间。在为klassOop填充_mark成员变量时,调用了obj->set_mark(markOopDesc::prototype())函数,其入参是markOopDesc::prototype(),该函数的声明如下:
// Prototype mark for initialization
static markOop prototype() {
return markOop( no_hash_in_place | no_lock_in_place );
}
在prototype()函数里貌似返回了markOop的构造函数,这个构造函数的入参是一个整型变量。先从markOopDesc.hpp文件中搜索这样的构造函数,结果很遗憾搜不到。由于markOopDesc继承了oopDesc类,但是oopDesc类中也没有定义任何一个类似的构造函数。事实上,这里并不是调用了markOop的构造函数,而是C语言的内建类型直接赋初值的写法。
在C语言中,对于变量初始化,有一种快速赋初值的写法,例如:
int x = 3;
可以直接写成
int x(3);
同样,指针类型也是基本类型,自然也支持快速赋初值的写法,例如
int x = 3;
int *p = &x;
可以写成
int x = 3;
int *p(&x);
本来这种写法一般情况下是简单易懂的,即使不知道有这种写法的人看了这种写法之后,也能猜出个大概意思。但是当这种赋初值的写法与自定义类型结合起来时,就容易误导人了,例如这里的markOop(no_hash_in_place | no_lock_in_place),就容易使人误以为在调用markOop的构造函数。markOop是一种自定义的数据类型,在hierarchy.hpp中的定义是:
typedef class markOopDesc* markOop;
由此可见,markOop的实际类型是markOopDesc*指针类型,而指针类型是一种基本的数据类型,因此自然支持赋初值的写法,下面举例说明,如图所示.
本例中,先定义类型A,接着声明一种自定义的数据类型AT,AT的类型原型是A*,其实是一种指针。在test()函数中,采用赋初值的写法直接返回类型AT的变量。最终打印出来的结果是3,因为test()返回了一个指针,而指针里面所存储的值就是3.
int x = 3;
可以直接写成
int x(3);
同样,指针类型也是基本类型,自然也支持快速赋初值的写法,例如
int x = 3;
int *p = &x;
可以写成
int x = 3;
int *p(&x);
本来这种写法一般情况下是简单易懂的,即使不知道有这种写法的人看了这种写法之后,也能猜出个大概意思。但是当这种赋初值的写法与自定义类型结合起来时,就容易误导人了,例如这里的markOop(no_hash_in_place | no_lock_in_place),就容易使人误以为在调用markOop的构造函数。markOop是一种自定义的数据类型,在hierarchy.hpp中的定义是:
typedef class markOopDesc* markOop;
由此可见,markOop的实际类型是markOopDesc*指针类型,而指针类型是一种基本的数据类型,因此自然支持赋初值的写法,下面举例说明,如图所示.
本例中,先定义类型A,接着声明一种自定义的数据类型AT,AT的类型原型是A*,其实是一种指针。在test()函数中,采用赋初值的写法直接返回类型AT的变量。最终打印出来的结果是3,因为test()返回了一个指针,而指针里面所存储的值就是3.
其实C++编译器在解释test()函数时,会将语法最终展开为下面这种形式:
A* test() {
// 为指针赋初值
A* a = (A*)3;
return a;
}
这里需要注意的是,由于指针类型变量本质上存储的也是整型数据(因为内存地址一定是整数),因此可以将一个整数直接强制转换成一个指针类型。基于这里的例子,回头看markOop::prototype()函数,一样的道理,该函数最终会在编译器展开为下面这种逻辑:
static markOopDesc* prototype() {
markOopDesc* mark = (markOopDesc*)(no_hash_in_place | no_lock_in_place);
}
这样依赖,prototype()函数的逻辑就再清晰不过了,该函数最终将返回一个指针,这个指针里存储了一个整数数字。由于oop._mark成员变量用于存储JVM内部对象的哈希值,并且也没有加任何锁。哈希值相对于JVM,就类似于人的身份证之于社会,此时的这个oop对象就像一个刚出生的婴儿一样,尚依偎在母亲"JVM"的襁褓之中,甚至在这个世界上连唯一的身份证都还没有。
JVM内部每次读取oop的mark标识时,会调用markOopDesc的value()函数,该函数定义如下:
uintptr_t value() const { return (uintptr_t) this; }
value()函数直接返回mark指针的值,由此可见,markOopDesc内部其实是将this指针当作它的值来使用的,而并没有将markOop指针还原成markOopDesc对象来使用。所以,markOop虽然看起来是指针但其实并不是真的指向markOopDesc实例的指针,这正是markOopDesc这个类型神奇的地方。如果你认为oop的mark标识指向一个markOopDesc实例的指针,那么在分析klassOop时,你可能会认为JVM执行完markOopDesc::prototype()函数,初始化完klassOop._mark成员变量之后的内存布局如图所示
A* test() {
// 为指针赋初值
A* a = (A*)3;
return a;
}
这里需要注意的是,由于指针类型变量本质上存储的也是整型数据(因为内存地址一定是整数),因此可以将一个整数直接强制转换成一个指针类型。基于这里的例子,回头看markOop::prototype()函数,一样的道理,该函数最终会在编译器展开为下面这种逻辑:
static markOopDesc* prototype() {
markOopDesc* mark = (markOopDesc*)(no_hash_in_place | no_lock_in_place);
}
这样依赖,prototype()函数的逻辑就再清晰不过了,该函数最终将返回一个指针,这个指针里存储了一个整数数字。由于oop._mark成员变量用于存储JVM内部对象的哈希值,并且也没有加任何锁。哈希值相对于JVM,就类似于人的身份证之于社会,此时的这个oop对象就像一个刚出生的婴儿一样,尚依偎在母亲"JVM"的襁褓之中,甚至在这个世界上连唯一的身份证都还没有。
JVM内部每次读取oop的mark标识时,会调用markOopDesc的value()函数,该函数定义如下:
uintptr_t value() const { return (uintptr_t) this; }
value()函数直接返回mark指针的值,由此可见,markOopDesc内部其实是将this指针当作它的值来使用的,而并没有将markOop指针还原成markOopDesc对象来使用。所以,markOop虽然看起来是指针但其实并不是真的指向markOopDesc实例的指针,这正是markOopDesc这个类型神奇的地方。如果你认为oop的mark标识指向一个markOopDesc实例的指针,那么在分析klassOop时,你可能会认为JVM执行完markOopDesc::prototype()函数,初始化完klassOop._mark成员变量之后的内存布局如图所示
如果真实这样就陷入了死胡同,因为JVM内部根本就没有在任何地方构建markOopDesc实例对象。
正是由于markOop指针其实并不是一个真正的oopDesc类型,仅仅是一个指针,并且其作用仅仅用于存储JVM内部对象的哈希值、锁状态标识等信息,因此JVM执行完markOopDesc::prototype()函数之后,正确的内存布局应该如图所示。
正是由于markOop指针其实并不是一个真正的oopDesc类型,仅仅是一个指针,并且其作用仅仅用于存储JVM内部对象的哈希值、锁状态标识等信息,因此JVM执行完markOopDesc::prototype()函数之后,正确的内存布局应该如图所示。
4.初始化klassOop._metadata。
初始化完klassOop._mark标识后,接着开始初始化klassOop._metadata成员。oop的_metadata是一个联合体,当JVM启用了宽指针选项后,_metadata的类型便是wideKlassOop,而wideKlassOop仍然是一个指针类型,指向klassOopDesc类型实例。在klass.hpp的allocate_permanent()函数中调用post_new_init_klass()函数,而该函数最终调用的方法如下:
// collectedHeap.inline.hpp
void CollectedHeap::post_allocation_install_ojb_klass(KlassHandle klass, oop obj, int size) {
obj->set_klass(klass());
}
该函数通过调用obj->set_klass()来完成_metadata成员变量的赋值,该函数的入参是klass(),这并不是在调用KlassHandle的构造函数,因为在Handle类中,()操作符被重载,因此这里的klass()其实是在调用普通函数。在Handle类中有如下定义。。
初始化完klassOop._mark标识后,接着开始初始化klassOop._metadata成员。oop的_metadata是一个联合体,当JVM启用了宽指针选项后,_metadata的类型便是wideKlassOop,而wideKlassOop仍然是一个指针类型,指向klassOopDesc类型实例。在klass.hpp的allocate_permanent()函数中调用post_new_init_klass()函数,而该函数最终调用的方法如下:
// collectedHeap.inline.hpp
void CollectedHeap::post_allocation_install_ojb_klass(KlassHandle klass, oop obj, int size) {
obj->set_klass(klass());
}
该函数通过调用obj->set_klass()来完成_metadata成员变量的赋值,该函数的入参是klass(),这并不是在调用KlassHandle的构造函数,因为在Handle类中,()操作符被重载,因此这里的klass()其实是在调用普通函数。在Handle类中有如下定义。。
在Handle类里面定义了oop operator() () const 这样的操作符重载函数,很显然,在CollectedHeap::post_allocation_install_obj_klass()函数中调用obj->set_klass(klass())时,入参klass()最终调用了Handle类的obj()函数,而该函数直接返回Handle类里面的_handle变量.于是问题变成了分析_handle指针到底指向哪里,这需要从源头上分析。将目光重新回到整条链路的源头——klassKlass::create_klass()函数。CollectedHeap::post_allocation_install_obj_klass()函数中所调用的obj->set_klass(klass())的入参的klass变量便是在该函数中定义的,定义方式是:KlassHandle h_this_klass,未使用new关键字,因此这会调用Handle类的默认构造函数,而是Handle类的默认构造函数定义如下:
Handle() { _handle = NULL; }
在这个默认构造函数里面,将成员变量_handle设置成了空值。在klassKlass:;create_klass()函数中定义了KlassHandle类型变量后,便一路透传,直到传到CollectedHeap::post_allocation_install_obj_klass()函数中的obj->set_klass(klass())函数的入参,并且在整个过程中,KlassHandle类型变量都未做修改,因此其内部的_handle成员变量一直都是空值,于是在obj->set_klass(klass())中调用klass()后所返回的值便也是空值。所以,当这一步执行完之后,klassOop类实例的内存空间布局如图所示
Handle() { _handle = NULL; }
在这个默认构造函数里面,将成员变量_handle设置成了空值。在klassKlass:;create_klass()函数中定义了KlassHandle类型变量后,便一路透传,直到传到CollectedHeap::post_allocation_install_obj_klass()函数中的obj->set_klass(klass())函数的入参,并且在整个过程中,KlassHandle类型变量都未做修改,因此其内部的_handle成员变量一直都是空值,于是在obj->set_klass(klass())中调用klass()后所返回的值便也是空值。所以,当这一步执行完之后,klassOop类实例的内存空间布局如图所示
5.初始化klass。
经过前面几个步骤,klassOop对象实例已经成功完成oop对象头的初始化。由于klassOop也是一个oop,因此对象头的内存后面也会接一段数据区,并且这段数据区正是klassKlass类型实例存放地。因此完成oop对象头的初始化之后,接着便开始初始化klassKlass类型实例。
在Klass::base_create_klass_oop()函数中,执行完Klass* kl = (Klass*)vtbl.allocate_permanent(klass,size, CHECK_NULL)函数之后,便得到klass对象实例的指针,Klass::base_create_klass_oop()函数中后续的逻辑便都是在处理klass对象实例的初始化,具体代码在上面的整合代码中已经贴出,可以看到此时大部分属性都被赋为空值了。注意这里也调用了kl->set_prototype_header(markOopDesc::prototype())函数来设置对象标,这说明JVM内部虽然将klass也封装了oop,但是klass毕竟是独立的一种类型因此也需要记录线程锁等相关标识。
经过前面几个步骤,klassOop对象实例已经成功完成oop对象头的初始化。由于klassOop也是一个oop,因此对象头的内存后面也会接一段数据区,并且这段数据区正是klassKlass类型实例存放地。因此完成oop对象头的初始化之后,接着便开始初始化klassKlass类型实例。
在Klass::base_create_klass_oop()函数中,执行完Klass* kl = (Klass*)vtbl.allocate_permanent(klass,size, CHECK_NULL)函数之后,便得到klass对象实例的指针,Klass::base_create_klass_oop()函数中后续的逻辑便都是在处理klass对象实例的初始化,具体代码在上面的整合代码中已经贴出,可以看到此时大部分属性都被赋为空值了。注意这里也调用了kl->set_prototype_header(markOopDesc::prototype())函数来设置对象标,这说明JVM内部虽然将klass也封装了oop,但是klass毕竟是独立的一种类型因此也需要记录线程锁等相关标识。
6.自指。
当执行完Klass:base_create_klass_oop()函数之后,CPU回到了klassKlass::create_klass()函数之后,CPU回到了klassKlass::create_klass()函数中,开始执行k->set_klass(k)这个逻辑。这里的k是klassOop,是klassOopDesc*类型的指针。k->set_klass(k)其实是在设置klassOopDesc类型实例的_metadata成员变量,虽然在前面的步骤中,_metadata成员变量已经被设置过一回,但是当时被设置成了NULL值,这一次再次设置,则是专门为klassOop量身定做了。在本步骤中,klassOop的_metadata成员变量被设置为指向其自身,为什么要指向自己呢?这个问题暂时不回答,等后面讲完了constantPoolOop的完整内存布局后再来分析。这里先看下Klass::base_create_klass_oop()函数执行完之后的内存布局。
至此,klassOop的实例化便完成了,最终得到的klassOop的内存模型便是如图所示的样子,虽然最终只得到了oop,但是其实内存里是有两个类型实例的,一个是oop对象头实例,紧接其后的则是klass类型实例。通过oop对象头的内存位置可以得到klass类型实例的首地址,进行转换后拿到klass实例。
当执行完Klass:base_create_klass_oop()函数之后,CPU回到了klassKlass::create_klass()函数之后,CPU回到了klassKlass::create_klass()函数中,开始执行k->set_klass(k)这个逻辑。这里的k是klassOop,是klassOopDesc*类型的指针。k->set_klass(k)其实是在设置klassOopDesc类型实例的_metadata成员变量,虽然在前面的步骤中,_metadata成员变量已经被设置过一回,但是当时被设置成了NULL值,这一次再次设置,则是专门为klassOop量身定做了。在本步骤中,klassOop的_metadata成员变量被设置为指向其自身,为什么要指向自己呢?这个问题暂时不回答,等后面讲完了constantPoolOop的完整内存布局后再来分析。这里先看下Klass::base_create_klass_oop()函数执行完之后的内存布局。
至此,klassOop的实例化便完成了,最终得到的klassOop的内存模型便是如图所示的样子,虽然最终只得到了oop,但是其实内存里是有两个类型实例的,一个是oop对象头实例,紧接其后的则是klass类型实例。通过oop对象头的内存位置可以得到klass类型实例的首地址,进行转换后拿到klass实例。
常量池klass模型(2).
constantPoolKlass模型构建。
前面分析了kalssKlass对象实例构建的原理,而且提到,之所以要分析klassKlass的构建,是因为这是研究常量池constantPoolKlass构建的基础,因为在JVM启动过程中,JVM需要执行constantPoolKlass::create_klass()函数来构建constantPoolKlass实例,而在该函数中调用了KlassHandle h_this_klass(THREAD, Universe::klassKlassObj())函数来获取klassKlass所对应的handle,JVM以此为基础来构建常量池所对应的klass.
在constantPoolKlass:;create_klass()函数中执行完KlassHandle h_this_klass(THREAD, Universe::klassKlassObj())函数之后,便开始执行base_create_klass(h_this_klass, header_size(), o.vtbl_value(), CHECK_NULL)函数,由于constantPoolKlass继承自klass,因此这里的base_create_klass()函数消息最终也由klass对象接收和执行。整条链路图如图所示.此图与上文klassKlass实例构建的时序图基本相同,通过调用kalss::base_create_klass_oop()入口进入,之后分别经过下面5个步骤:
1.内存申请
2.内存清零
3.初始化_mark成员变量
4.初始化_metadata成员变量
5.初始化klass
最终返回包装好的klassOop
前面分析了kalssKlass对象实例构建的原理,而且提到,之所以要分析klassKlass的构建,是因为这是研究常量池constantPoolKlass构建的基础,因为在JVM启动过程中,JVM需要执行constantPoolKlass::create_klass()函数来构建constantPoolKlass实例,而在该函数中调用了KlassHandle h_this_klass(THREAD, Universe::klassKlassObj())函数来获取klassKlass所对应的handle,JVM以此为基础来构建常量池所对应的klass.
在constantPoolKlass:;create_klass()函数中执行完KlassHandle h_this_klass(THREAD, Universe::klassKlassObj())函数之后,便开始执行base_create_klass(h_this_klass, header_size(), o.vtbl_value(), CHECK_NULL)函数,由于constantPoolKlass继承自klass,因此这里的base_create_klass()函数消息最终也由klass对象接收和执行。整条链路图如图所示.此图与上文klassKlass实例构建的时序图基本相同,通过调用kalss::base_create_klass_oop()入口进入,之后分别经过下面5个步骤:
1.内存申请
2.内存清零
3.初始化_mark成员变量
4.初始化_metadata成员变量
5.初始化klass
最终返回包装好的klassOop
由于constantPoolKlass实例构建的过程与klassKlass实例构建的过程完全一致,因此这里不再详细解释,只需要关注最后的结果即可。很显然,JVM最终在永久区所创建的对象是constantPoolKlass,但是由于每一个klass最终都会被包装成oop,因此最终在内存中所构建的模型如图所示。
当然,为了构建这么一个模型,从一开始就要准确计算出其所占内存大小,计算出的大小将通过klass::base_create_klass_oop()函数的size入参传递给后续流程。在构建constantPoolKlass时,这个大小由constantPoolKlass::header_size()函数所确定:
static int header_size() {
return oopDesc::header_size() + sizeof(cosntantPoolKlass)/HeapWordSize;
}
由这个逻辑可知,JVM在构建cosntantPoolKlass时,所分配的内存大小为oopDesc的大小与constantPoolKlass的大小之和,即JVM将封装constantPoolKlass的oop的大小已经计算进去了。
JVM构建constantPoolKlass的逻辑与构建klassKlass的逻辑基本是一致的,但入参size不同,且在调用klass::base_create_klass()函数时所传入的KlassHandle入参也不相同,KlassHandle入参的不同将决定最终所构建出来的klassOop的_metadata参数不同。
当然,为了构建这么一个模型,从一开始就要准确计算出其所占内存大小,计算出的大小将通过klass::base_create_klass_oop()函数的size入参传递给后续流程。在构建constantPoolKlass时,这个大小由constantPoolKlass::header_size()函数所确定:
static int header_size() {
return oopDesc::header_size() + sizeof(cosntantPoolKlass)/HeapWordSize;
}
由这个逻辑可知,JVM在构建cosntantPoolKlass时,所分配的内存大小为oopDesc的大小与constantPoolKlass的大小之和,即JVM将封装constantPoolKlass的oop的大小已经计算进去了。
JVM构建constantPoolKlass的逻辑与构建klassKlass的逻辑基本是一致的,但入参size不同,且在调用klass::base_create_klass()函数时所传入的KlassHandle入参也不相同,KlassHandle入参的不同将决定最终所构建出来的klassOop的_metadata参数不同。
在klass::base_create_klass()入口后面的链路中,有一步调用了obj->set_klass(),这一步便是在设置oop的_metadata成员变量,前面分析过,在构建klassKlass时,所传入的KlassHandle其实时NULL,因此在klassKlass::create_klass()中又调用了k->set_klass(k),最终将klassOop的_metadata指向了自己。但是在构建constantPoolKlass时,所传入的KlassHandle则是封装了klassKlass的handle,因此最终所构建出来的constantPoolKlass所对应的oop的_metadata便指向前面所构建的klass.最终constantPoolKlass内存布局如图所示
constantPoolOop与klass。
刚刚分析了最终所构建出来的常量池constantPoolKlass的内存模型,其实constantPoolKlass正是constantPoolOop的类元信息,即constantPoolOop的_metadata指针应该指向constantPoolKlass对象实例。而事实上,当constantPoolOop实例在构建过程中,JVM的确进行了这一步操作,这一步便是在如图所示被椭圆所框住的一步——post_allocation_install_obj_klass()。如果对前面构建klassKlass和构建constantOopKlass的过程很熟悉的话,应该清楚这一步实际上调用了obj->set_klass()这个函数,而该函数会将_metadata指针指向其对应的klass实例。
刚刚分析了最终所构建出来的常量池constantPoolKlass的内存模型,其实constantPoolKlass正是constantPoolOop的类元信息,即constantPoolOop的_metadata指针应该指向constantPoolKlass对象实例。而事实上,当constantPoolOop实例在构建过程中,JVM的确进行了这一步操作,这一步便是在如图所示被椭圆所框住的一步——post_allocation_install_obj_klass()。如果对前面构建klassKlass和构建constantOopKlass的过程很熟悉的话,应该清楚这一步实际上调用了obj->set_klass()这个函数,而该函数会将_metadata指针指向其对应的klass实例。
JVM创建constantPoolOop的过程中执行了如下逻辑:
// oopFactory.cpp
// new_constantPool()函数定义
constantPoolOop oopFactory::new_constantPool(int length, bool is_conc_safe, TRAPS) {
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
return ck->allocate(length, is_conc_safe, CHECK_NULL);
}
大家对这段逻辑应该不会感到陌生,上文曾经进行过引用。在这段逻辑中,第一步先是获取到了随JVM启动而创建的constantPoolKlass实例指针ck,接着调用了ck->allocate()方法。ck指针最终被传递到post_allocation_install_obj_klass()函数中,因此在JVM执行obj->set_klass()方法时,constantPoolOop的_metadata指针便在此处指向了constantPoolKlass实例对象。最终所构建的constantPoolOop的内存布局如图所示.至此,constantPoolOop实例的构建便结束了。建议将这段逻辑熟记于心,因为JVM内部其他oop对象实例的构建都大同小异,明白constantPoolOopDesc的构建原理,便意味着明白了所有
// oopFactory.cpp
// new_constantPool()函数定义
constantPoolOop oopFactory::new_constantPool(int length, bool is_conc_safe, TRAPS) {
constantPoolKlass* ck = constantPoolKlass::cast(Universe::constantPoolKlassObj());
return ck->allocate(length, is_conc_safe, CHECK_NULL);
}
大家对这段逻辑应该不会感到陌生,上文曾经进行过引用。在这段逻辑中,第一步先是获取到了随JVM启动而创建的constantPoolKlass实例指针ck,接着调用了ck->allocate()方法。ck指针最终被传递到post_allocation_install_obj_klass()函数中,因此在JVM执行obj->set_klass()方法时,constantPoolOop的_metadata指针便在此处指向了constantPoolKlass实例对象。最终所构建的constantPoolOop的内存布局如图所示.至此,constantPoolOop实例的构建便结束了。建议将这段逻辑熟记于心,因为JVM内部其他oop对象实例的构建都大同小异,明白constantPoolOopDesc的构建原理,便意味着明白了所有
klassKLass终结符。
在JVM构建constantPoolOop的过程中,由于constantPoolOop本身的大小不固定,这种不确定性主要体现在"length个指针宽度"这块区域,因为不同的Java class被编译后,常量池元素的数量会不同。因此JVM需要使用constantPoolKlass来描述这些不固定的信息,这样最终GC在回收垃圾时才能准确地知道到底要回收多大内存空间。这便是constantPoolKlass的意义所在。
而constantPoolKlass的实例大小也是不确定的,因此constantPoolKlass本身也需要其他klass来描述,这便是JVM在构建cosntantPoolOop的过程中会引用klassKlass的原因。但是,如果klassKlass又去使用一个klass来描述自身,那么klass链路将会无限引用下去,因此JVM便将klassKlass作为整个引用链的终结符,到这里就结束了,并且让klassKlass自己指向自己。这便是klassKlass实例为何最终要"自指"的原因
在JVM构建constantPoolOop的过程中,由于constantPoolOop本身的大小不固定,这种不确定性主要体现在"length个指针宽度"这块区域,因为不同的Java class被编译后,常量池元素的数量会不同。因此JVM需要使用constantPoolKlass来描述这些不固定的信息,这样最终GC在回收垃圾时才能准确地知道到底要回收多大内存空间。这便是constantPoolKlass的意义所在。
而constantPoolKlass的实例大小也是不确定的,因此constantPoolKlass本身也需要其他klass来描述,这便是JVM在构建cosntantPoolOop的过程中会引用klassKlass的原因。但是,如果klassKlass又去使用一个klass来描述自身,那么klass链路将会无限引用下去,因此JVM便将klassKlass作为整个引用链的终结符,到这里就结束了,并且让klassKlass自己指向自己。这便是klassKlass实例为何最终要"自指"的原因
常量池解析。
前面详细分析了constantPoolOop的内存分配和klass模型构建,现在JVM完成了constantPoolOop全部内存布局的大手笔,接下来要做的便是往里面填充实际数据。在这里不妨回到初心,问问JVM构建constantPoolOop的意义所在。
Java类源代码被编译成字节码,字节码中使用常量池来描述Java类中的结构信息——包括Java类中的一切变量和方法。JVM加载某个类时需要解析字节码文件中的常量池信息,从字节码文件中还原出Java源代码中所定义的全部变量和方法。而JVM运行时的对象constantPoolOop便是用来保存JVM对字节码文件中的常量池信息的分析结果的。因此JVM需要先构建出constantPoolOop的实例,并分配足够的内存空间来保存字节码文件中的常量池信息。从这里开始,我们将会详细分析JVM解析和保存常量池字节码的过程
前面详细分析了constantPoolOop的内存分配和klass模型构建,现在JVM完成了constantPoolOop全部内存布局的大手笔,接下来要做的便是往里面填充实际数据。在这里不妨回到初心,问问JVM构建constantPoolOop的意义所在。
Java类源代码被编译成字节码,字节码中使用常量池来描述Java类中的结构信息——包括Java类中的一切变量和方法。JVM加载某个类时需要解析字节码文件中的常量池信息,从字节码文件中还原出Java源代码中所定义的全部变量和方法。而JVM运行时的对象constantPoolOop便是用来保存JVM对字节码文件中的常量池信息的分析结果的。因此JVM需要先构建出constantPoolOop的实例,并分配足够的内存空间来保存字节码文件中的常量池信息。从这里开始,我们将会详细分析JVM解析和保存常量池字节码的过程
constantPoolOop域初始化。
在构建constantPoolOOp的过程中,会执行constantPoolKlass::allocate()函数,该函数主要干了3件事情:
# 构建constantPoolOop对象实例
# 初始化constantPoolOop实例域变量
# 初始化tag
在此之前都在讲第一件事情,这里讲第二件事情,先看源码
// constantPoolKlass.cpp
// constantPoolKlass域变量初始化
constantPoolOop constantPoolKlass::allocate(int length, bool is_conc_safe, TRAPS) {
// ....
pool->set_length(length);
pool->set_tags(NULL);
pool->set_cache(NULL);
pool->set_operands(NULL);
pool->set_pool_holder(NULL);
pool->set_flags(0);
pool->set_orig_length(0);
pool->set_is_conc_safe(is_conc_safe);
//....
}
这部分逻辑执行完之后,constantPool的内存布局如图所示:(注意:此时constantPoolOop域的_tags是NULL,但是这个域变量对于constantPoolOop而言其实是十分重要的一个数据载体,_tags实际上将会存放字节码常量池中的全部元素的标记。因此,下一步JVM便会对_tags进行一系列处理)
在构建constantPoolOOp的过程中,会执行constantPoolKlass::allocate()函数,该函数主要干了3件事情:
# 构建constantPoolOop对象实例
# 初始化constantPoolOop实例域变量
# 初始化tag
在此之前都在讲第一件事情,这里讲第二件事情,先看源码
// constantPoolKlass.cpp
// constantPoolKlass域变量初始化
constantPoolOop constantPoolKlass::allocate(int length, bool is_conc_safe, TRAPS) {
// ....
pool->set_length(length);
pool->set_tags(NULL);
pool->set_cache(NULL);
pool->set_operands(NULL);
pool->set_pool_holder(NULL);
pool->set_flags(0);
pool->set_orig_length(0);
pool->set_is_conc_safe(is_conc_safe);
//....
}
这部分逻辑执行完之后,constantPool的内存布局如图所示:(注意:此时constantPoolOop域的_tags是NULL,但是这个域变量对于constantPoolOop而言其实是十分重要的一个数据载体,_tags实际上将会存放字节码常量池中的全部元素的标记。因此,下一步JVM便会对_tags进行一系列处理)
初始化tag。
在创建constantPoolOop的过程中,会为其_tags域申请内存空间,这段逻辑还是在constantPoolKlass::allocate()函数中实现,具体逻辑如下
在创建constantPoolOop的过程中,会为其_tags域申请内存空间,这段逻辑还是在constantPoolKlass::allocate()函数中实现,具体逻辑如下
JVM会在永久区开辟一块内存存放_tags实例对象,而这块内存的大小也是length个指针宽度,与constantPoolOop存放实例数据的长度是一致的。构建其内存布局的具体逻辑与前面构建klassKlass等对象是基本一致。完成_tags的构建之后,constantPoolOop的内存布局如图所示。
由于_tags所指向的实例对象刚刚构建,因此此时内存中没有实际数据,接下来JVM开始解析字节码文件的常量池元素,并逐个填充到这块内存区域。
由于_tags所指向的实例对象刚刚构建,因此此时内存中没有实际数据,接下来JVM开始解析字节码文件的常量池元素,并逐个填充到这块内存区域。
解析常量池元素。
前面经过"千辛万苦",终于完成了常量池oop的基本构建工作。之前花了很多篇幅讲解常量池的oop-klass这种一分为二的模型,到这里便可以告一段落。不过付出这个代价是值得的,将常量池oop的内存模型彻底研究清楚,便基本扫清了JVM内存分配的大部分障碍,之后我们可以更快速、更深入地理解源代码。接下来需要将目光重新投向JVM类加载的"舞台",研究JVM是如何一步一步将字节码信息翻译成可以被物理机器识别的动态的数据结构的。在分析之前先看一个链路图。。
在classFileParser中通过执行语句constantPoolOop constant_pool = oopFactory::new_constantPool()完成constantPoolOop的构建,前面都是在分析这部分逻辑。接下来便开始解析常量池元素,为此classFileParse专门定义了一个函数:parse_constant_pool_entries()
// classFileParser.cpp
// 常量池元素解析函数
本函数主要通过一个for循环处理所有的常量池元素,每次循环开始的时候,先执行语句u1 tag = cfs->get_u1_fast(),从字节码文件中读取占1字节宽度的字节流。之所以这样,是因为在字节码文件中的常量池元素区,每一个常量池元素起始的1字节都用于描述常量池元素类型,JVM解析常量池元素的第一步就是需要知道每个常量池元素的类型。
获取常量池元素类型之后,通过switch条件表达式,,对不同类型的常量池元素进行处理,这里以第一个case JVM_CONSTANT_Class 语句为例进行说明。首先通过u2 name_index = cfs->get_u2_fast()获取类的名称索引,接着通过cp->klass_index_at_put(index, name_index)将当前常量池元素的类型和名称索引分别保存到constantPoolOop的tag数组和数据区。这里需要注意的是,不同类型的常量池元素在字节码文件中的组成结构也不同,例如JVM_CONSTANT_Class类型的常量池元素,其组成结构是:u1宽度的类型标识+u2宽度的名称索引,因此JVM只需要先调用cfs->get_u1_fast()获取类型标识,再调用cfs->get_u2_fast()获取其索引。再如JVM_CONSTANT_Fieldref类型的常量池元素,其组成结构是:u1宽度的类型标识+u2宽度的类索引+u2宽度的名称索引,因此JVM需要先调用cfs->get_u1_fast()获取类型标识,再连续调用两次cfs->get_u2_fast()分别获取类索引和名称索引,代码的逻辑如下:
case JVM_CONSTANT_Fieldref:
{
// 获取索引
u2 class_index = cfs->get_u2_fast();
// 获取名称索引
u2 name_and_type_index = cfs->get_u2_fast();
// 保存到constantPoolOop的tag和数据区中
cp->field_at_put(index, class_index, name_and_type_index);
}
前面经过"千辛万苦",终于完成了常量池oop的基本构建工作。之前花了很多篇幅讲解常量池的oop-klass这种一分为二的模型,到这里便可以告一段落。不过付出这个代价是值得的,将常量池oop的内存模型彻底研究清楚,便基本扫清了JVM内存分配的大部分障碍,之后我们可以更快速、更深入地理解源代码。接下来需要将目光重新投向JVM类加载的"舞台",研究JVM是如何一步一步将字节码信息翻译成可以被物理机器识别的动态的数据结构的。在分析之前先看一个链路图。。
在classFileParser中通过执行语句constantPoolOop constant_pool = oopFactory::new_constantPool()完成constantPoolOop的构建,前面都是在分析这部分逻辑。接下来便开始解析常量池元素,为此classFileParse专门定义了一个函数:parse_constant_pool_entries()
// classFileParser.cpp
// 常量池元素解析函数
本函数主要通过一个for循环处理所有的常量池元素,每次循环开始的时候,先执行语句u1 tag = cfs->get_u1_fast(),从字节码文件中读取占1字节宽度的字节流。之所以这样,是因为在字节码文件中的常量池元素区,每一个常量池元素起始的1字节都用于描述常量池元素类型,JVM解析常量池元素的第一步就是需要知道每个常量池元素的类型。
获取常量池元素类型之后,通过switch条件表达式,,对不同类型的常量池元素进行处理,这里以第一个case JVM_CONSTANT_Class 语句为例进行说明。首先通过u2 name_index = cfs->get_u2_fast()获取类的名称索引,接着通过cp->klass_index_at_put(index, name_index)将当前常量池元素的类型和名称索引分别保存到constantPoolOop的tag数组和数据区。这里需要注意的是,不同类型的常量池元素在字节码文件中的组成结构也不同,例如JVM_CONSTANT_Class类型的常量池元素,其组成结构是:u1宽度的类型标识+u2宽度的名称索引,因此JVM只需要先调用cfs->get_u1_fast()获取类型标识,再调用cfs->get_u2_fast()获取其索引。再如JVM_CONSTANT_Fieldref类型的常量池元素,其组成结构是:u1宽度的类型标识+u2宽度的类索引+u2宽度的名称索引,因此JVM需要先调用cfs->get_u1_fast()获取类型标识,再连续调用两次cfs->get_u2_fast()分别获取类索引和名称索引,代码的逻辑如下:
case JVM_CONSTANT_Fieldref:
{
// 获取索引
u2 class_index = cfs->get_u2_fast();
// 获取名称索引
u2 name_and_type_index = cfs->get_u2_fast();
// 保存到constantPoolOop的tag和数据区中
cp->field_at_put(index, class_index, name_and_type_index);
}
继续刚才类型为JVM_CONSTANT_Class的常量池元素的解析,获取到名称索引后,接着执行cp->klass_index_at_put(index, name_index)将当前常量池元素信息保存到constantPoolOop中。先看看其逻辑实现方式:
// constantPoolOop.hpp
// 解析常量池元素
void klass_index_at_put(int which, int name_index) {
tag_at_put(which, JVM_CONSTANT_ClassIndex);
*int_at_addr(which) = name_index;
}
void tag_at_put(int which, jbyte t) {
tags()->byte_at_put(which, t);
}
在这个逻辑中,通过tag_at_put(which, JVM_COSNTANT_ClassIndex)将当前常量池元素的类型保存到constantPoolOop所指向的tag的对应位置的数组中,并通过*int_at_addr(which)=name_index将当前常量池元素的名称索引保存到constantPoolOop的数据区中对应的位置。姑且将tag理解为数组(事实上也是,只不过被oop对象头包住了),当前常量池元素本身在字节码文件常量区中的位置索引将决定该常量池元素最终在tag数组中的位置,也决定该常量池元素的索引值最终在constantPoolOop的数据区的位置。
// constantPoolOop.hpp
// 解析常量池元素
void klass_index_at_put(int which, int name_index) {
tag_at_put(which, JVM_CONSTANT_ClassIndex);
*int_at_addr(which) = name_index;
}
void tag_at_put(int which, jbyte t) {
tags()->byte_at_put(which, t);
}
在这个逻辑中,通过tag_at_put(which, JVM_COSNTANT_ClassIndex)将当前常量池元素的类型保存到constantPoolOop所指向的tag的对应位置的数组中,并通过*int_at_addr(which)=name_index将当前常量池元素的名称索引保存到constantPoolOop的数据区中对应的位置。姑且将tag理解为数组(事实上也是,只不过被oop对象头包住了),当前常量池元素本身在字节码文件常量区中的位置索引将决定该常量池元素最终在tag数组中的位置,也决定该常量池元素的索引值最终在constantPoolOop的数据区的位置。
1.类元素解析。
为了说明概念,还是举个例子比较好,这里再次拿前面所举的Iphone6s.java这个类,使用javap命令查看该类的结构如下:
Classfile /D:/aa-my/my-second-trial/target/classes/com/Iphone6s.class
Last modified 2025-3-19; size 514 bytes
MD5 checksum bd147dc8db490db381625ab9399dea96
Compiled from "Iphone6s.java"
public class com.Iphone6s
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #10.#28 // java/lang/Object."<init>":()V
#2 = Fieldref #9.#29 // com/Iphone6s.length:I
#3 = Fieldref #9.#30 // com/Iphone6s.width:I
#4 = Fieldref #9.#31 // com/Iphone6s.height:I
#5 = Fieldref #9.#32 // com/Iphone6s.weight:I
#6 = Fieldref #9.#33 // com/Iphone6s.ram:I
#7 = Fieldref #9.#34 // com/Iphone6s.rom:I
#8 = Fieldref #9.#35 // com/Iphone6s.pixel:I
#9 = Class #36 // com/Iphone6s
#10 = Class #37 // java/lang/Object
#11 = Utf8 length
#12 = Utf8 I
#13 = Utf8 width
#14 = Utf8 height
#15 = Utf8 weight
#16 = Utf8 ram
#17 = Utf8 rom
#18 = Utf8 pixel
#19 = Utf8 <init>
#20 = Utf8 ()V
#21 = Utf8 Code
#22 = Utf8 LineNumberTable
#23 = Utf8 LocalVariableTable
#24 = Utf8 this
#25 = Utf8 Lcom/Iphone6s;
#26 = Utf8 SourceFile
#27 = Utf8 Iphone6s.java
#28 = NameAndType #19:#20 // "<init>":()V
#29 = NameAndType #11:#12 // length:I
#30 = NameAndType #13:#12 // width:I
#31 = NameAndType #14:#12 // height:I
#32 = NameAndType #15:#12 // weight:I
#33 = NameAndType #16:#12 // ram:I
#34 = NameAndType #17:#12 // rom:I
#35 = NameAndType #18:#12 // pixel:I
#36 = Utf8 com/Iphone6s
#37 = Utf8 java/lang/Object
为了说明概念,还是举个例子比较好,这里再次拿前面所举的Iphone6s.java这个类,使用javap命令查看该类的结构如下:
Classfile /D:/aa-my/my-second-trial/target/classes/com/Iphone6s.class
Last modified 2025-3-19; size 514 bytes
MD5 checksum bd147dc8db490db381625ab9399dea96
Compiled from "Iphone6s.java"
public class com.Iphone6s
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #10.#28 // java/lang/Object."<init>":()V
#2 = Fieldref #9.#29 // com/Iphone6s.length:I
#3 = Fieldref #9.#30 // com/Iphone6s.width:I
#4 = Fieldref #9.#31 // com/Iphone6s.height:I
#5 = Fieldref #9.#32 // com/Iphone6s.weight:I
#6 = Fieldref #9.#33 // com/Iphone6s.ram:I
#7 = Fieldref #9.#34 // com/Iphone6s.rom:I
#8 = Fieldref #9.#35 // com/Iphone6s.pixel:I
#9 = Class #36 // com/Iphone6s
#10 = Class #37 // java/lang/Object
#11 = Utf8 length
#12 = Utf8 I
#13 = Utf8 width
#14 = Utf8 height
#15 = Utf8 weight
#16 = Utf8 ram
#17 = Utf8 rom
#18 = Utf8 pixel
#19 = Utf8 <init>
#20 = Utf8 ()V
#21 = Utf8 Code
#22 = Utf8 LineNumberTable
#23 = Utf8 LocalVariableTable
#24 = Utf8 this
#25 = Utf8 Lcom/Iphone6s;
#26 = Utf8 SourceFile
#27 = Utf8 Iphone6s.java
#28 = NameAndType #19:#20 // "<init>":()V
#29 = NameAndType #11:#12 // length:I
#30 = NameAndType #13:#12 // width:I
#31 = NameAndType #14:#12 // height:I
#32 = NameAndType #15:#12 // weight:I
#33 = NameAndType #16:#12 // ram:I
#34 = NameAndType #17:#12 // rom:I
#35 = NameAndType #18:#12 // pixel:I
#36 = Utf8 com/Iphone6s
#37 = Utf8 java/lang/Object
由于第一号常量池的元素是JVM_CONSTANT_Methodref,还由于其在常量区中的位置是1,因此其最终在tag和constantPoolOop数据区中的位置也是1.当JVM解析完这个常量池元素后,constantPoolOop的内存布局如图所示。
图中的#0和#1表示数组下标位置,其中在constantPoolOop的数据区的#1号位置所存储的值为10,因为当前常量池元素(第1号常量池元素)的名称索引为10。而tag的#1位置所存储的值为10,因为当前常量池元素的是JVM_CONSTANT_Methodref,该枚举值为10,这里贴出常量池元素类型枚举的定义:
enum {
JVM_CONSTANT_Utf8 = 1,
JVM_CONSTANT_Unicode, /* unused */
JVM_CONSTANT_Integer,
JVM_CONSTANT_Float,
JVM_CONSTANT_Long,
JVM_CONSTANT_Double,
JVM_CONSTANT_Class,
JVM_CONSTANT_String,
JVM_CONSTANT_Fieldref,
JVM_CONSTANT_Methodref,
JVM_CONSTANT_InterfaceMethodref,
JVM_CONSTANT_NameAndType,
JVM_CONSTANT_MethodHandle = 15, // JSR 292
JVM_CONSTANT_MethodType = 16, // JSR 292
//JVM_CONSTANT_(unused) = 17, // JSR 292 early drafts only
JVM_CONSTANT_InvokeDynamic = 18, // JSR 292
JVM_CONSTANT_ExternalMax = 18 // Last tag found in classfiles
};
enum {
JVM_CONSTANT_Utf8 = 1,
JVM_CONSTANT_Unicode, /* unused */
JVM_CONSTANT_Integer,
JVM_CONSTANT_Float,
JVM_CONSTANT_Long,
JVM_CONSTANT_Double,
JVM_CONSTANT_Class,
JVM_CONSTANT_String,
JVM_CONSTANT_Fieldref,
JVM_CONSTANT_Methodref,
JVM_CONSTANT_InterfaceMethodref,
JVM_CONSTANT_NameAndType,
JVM_CONSTANT_MethodHandle = 15, // JSR 292
JVM_CONSTANT_MethodType = 16, // JSR 292
//JVM_CONSTANT_(unused) = 17, // JSR 292 early drafts only
JVM_CONSTANT_InvokeDynamic = 18, // JSR 292
JVM_CONSTANT_ExternalMax = 18 // Last tag found in classfiles
};
2.方法元素解析。
有些常量池元素比较特殊,其类型标识后面跟了不止一种属性,方法元素就是其中一种,在方法常量池元素的占u1宽度的标识后面,跟了2个占u2宽度的属性:类索引和方法名索引。这便带来一个问题:常量池元素的类型会保存在tag数组中,而其索引会保存在constantPoolOop的数据区,但是这里有2个索引,如何在constantPoolOop的数据区的一个位置上保存2个值呢?JVM给出的方案很简单,就是将这两个索引值进行拼接,变成一个值,然后再保存。JVM解析类型为JVM_COSNTANT_Methodref的常量池元素时,会调用cp->method_at_put(index, class_index, name_and_type_index)函数,该函数的实现如下:
// constantPoolOop.hpp
// 方法元素解析
void method_at_put(int which, int class_index, int name_and_type_index) {
tag_at_put(which, JVM_CONSTANT_Methodref);
*int_at_addr(which) = ((jint)name_and_type_index << 16) | class_index;
}
看到了没?这里将name_and_type_index左移了2字节,通过位或操作符与class_index完成两个值的拼接。再这里,位或操作符|之所以能够实现两个数值的拼接,是因为这2个数值都只占2字节。还是以Iphone6s.java为例,其常量池中第1号元素是Method,其属性如下:
#1 = Methodref #10.#28 // java/lang/Object."<init>":()V
这标识其class_index是10,而方法名索引是28,当JVM将其解析完成后,constantPoolOop的内存布局。。
在constantPoolOop的数据区的第1号位置所保存的值是0x0a1C,这正是该方法所对应的类索引值10和方法名索引28这两个数值拼接后的值
有些常量池元素比较特殊,其类型标识后面跟了不止一种属性,方法元素就是其中一种,在方法常量池元素的占u1宽度的标识后面,跟了2个占u2宽度的属性:类索引和方法名索引。这便带来一个问题:常量池元素的类型会保存在tag数组中,而其索引会保存在constantPoolOop的数据区,但是这里有2个索引,如何在constantPoolOop的数据区的一个位置上保存2个值呢?JVM给出的方案很简单,就是将这两个索引值进行拼接,变成一个值,然后再保存。JVM解析类型为JVM_COSNTANT_Methodref的常量池元素时,会调用cp->method_at_put(index, class_index, name_and_type_index)函数,该函数的实现如下:
// constantPoolOop.hpp
// 方法元素解析
void method_at_put(int which, int class_index, int name_and_type_index) {
tag_at_put(which, JVM_CONSTANT_Methodref);
*int_at_addr(which) = ((jint)name_and_type_index << 16) | class_index;
}
看到了没?这里将name_and_type_index左移了2字节,通过位或操作符与class_index完成两个值的拼接。再这里,位或操作符|之所以能够实现两个数值的拼接,是因为这2个数值都只占2字节。还是以Iphone6s.java为例,其常量池中第1号元素是Method,其属性如下:
#1 = Methodref #10.#28 // java/lang/Object."<init>":()V
这标识其class_index是10,而方法名索引是28,当JVM将其解析完成后,constantPoolOop的内存布局。。
在constantPoolOop的数据区的第1号位置所保存的值是0x0a1C,这正是该方法所对应的类索引值10和方法名索引28这两个数值拼接后的值
3.字符串元数据解析。
对于常量池而言,字符串的概念比较广泛,并不单指字符串变量。类名、方法名、类型、this指针名,等等,都可以看作是字符串,最终都会被JVM当作字符串处理,存储到符号区。由于无论是tag还是constantPoolOop的数据区,一个存储位置只能存放一个指针宽度的数据,而字符串往往很大,因此JVM专门设计了一个"符号表"的内存区,tag和constantPoolOop数据区内仅保存指针指向符号区。JVM对字符串的处理如下:
以上代码给出了一个基本思路,即字节码文件中的字符串常量池元素最终都会被保存到符号表中。为了节省内存,JVM会先判断符号表中是否存在相同的字符串,如果已经存在,则不会申请内存。这就是如果你在一个类中定义了两个字符串,但是这两个字符串的值相同,最终这两个字符串变量都会指向常量池中同一个位置的原因
对于常量池而言,字符串的概念比较广泛,并不单指字符串变量。类名、方法名、类型、this指针名,等等,都可以看作是字符串,最终都会被JVM当作字符串处理,存储到符号区。由于无论是tag还是constantPoolOop的数据区,一个存储位置只能存放一个指针宽度的数据,而字符串往往很大,因此JVM专门设计了一个"符号表"的内存区,tag和constantPoolOop数据区内仅保存指针指向符号区。JVM对字符串的处理如下:
以上代码给出了一个基本思路,即字节码文件中的字符串常量池元素最终都会被保存到符号表中。为了节省内存,JVM会先判断符号表中是否存在相同的字符串,如果已经存在,则不会申请内存。这就是如果你在一个类中定义了两个字符串,但是这两个字符串的值相同,最终这两个字符串变量都会指向常量池中同一个位置的原因
类变量解析
JVM从Java类的字节码文件中解析出常量池信息后,便将Java类的变量和方法基本信息读取进内存,保存在constantPoolOop的tag和数据区。但是tag与数据区的数据的数据仍然是非结构化的数据,根据这些数据并不能直观地描述Java类结构,因此JVM需要进一步解析。下面给出类解析的总体示意图
类变量解析。
在ClassFileParser::parseClassFile()函数中,解析完常量池,父类和接口后,接着便调用parse_fields()函数解析类变量信息:如图所示。
在调用parse_fields()函数之前,先定义了一个变量fac,这里的FieldAllocationCount是一个结构体类型,声明如下:// classFileParser.cpp
// FieldAllocationCOunt结构体
struct FieldAllocationCount {
unsigned int static_oop_count;
unsigned int static_byte_count;
unsigned int static_short_count;
unsigned int static_word_count;
unsigned int static_double_count;
unsigned int nonstatic_oop_count;
unsigned int nostatic_byte_count;
unsigned int nostatic_short_count;
unsigned int nostatic_word_count;
unsigned int nostatic_double_count;
}
通过声明可知,FieldAllocationCount结构体类型的变量实例将会记录5种静态(static)类型变量的数量和5种对应的非静态类型的变量的数量,这5种变量类型分别是
# Oop,引用类型
# Byte, 字节类型
# Short, 短整型
# Word, 双字类型
# Double,浮点型
在ClassFileParser::parseClassFile()函数中,解析完常量池,父类和接口后,接着便调用parse_fields()函数解析类变量信息:如图所示。
在调用parse_fields()函数之前,先定义了一个变量fac,这里的FieldAllocationCount是一个结构体类型,声明如下:// classFileParser.cpp
// FieldAllocationCOunt结构体
struct FieldAllocationCount {
unsigned int static_oop_count;
unsigned int static_byte_count;
unsigned int static_short_count;
unsigned int static_word_count;
unsigned int static_double_count;
unsigned int nonstatic_oop_count;
unsigned int nostatic_byte_count;
unsigned int nostatic_short_count;
unsigned int nostatic_word_count;
unsigned int nostatic_double_count;
}
通过声明可知,FieldAllocationCount结构体类型的变量实例将会记录5种静态(static)类型变量的数量和5种对应的非静态类型的变量的数量,这5种变量类型分别是
# Oop,引用类型
# Byte, 字节类型
# Short, 短整型
# Word, 双字类型
# Double,浮点型
在parse_fields()函数里面,会分别统计static和非static的这5种变量的数量,后面JVM为Java类分配内存空间时,会根据这些变量的数量计算所占内存大小。。可能会有人问:Java语言所支持的实际数据类型远不止这5种啊,例如,boolean、float、int等等,为什么JVM只统计这5种呢?这是因为在JVM内部,除了引用类型,所有内置的基本类型都使用剩余的4种类型来表示,因此想知道一个Java类的域变量需要占用多少内存,只需要分别统计这几种类型的数量即可。下面一起看看JVM对Java类域变量的具体解析逻辑:如图所示。
上面对ClassFileParser::parse_fields()的主要逻辑进行了注释,通过注释可以知道,JVM解析Java类域变量的逻辑是:
# 获取Java类中的变量数量
# 读取变量的访问标识
# 读取变量名称索引
# 读取变量类型索引
# 读取变量属性
# 判断变量类型
# 统计各类型数量
上面对ClassFileParser::parse_fields()的主要逻辑进行了注释,通过注释可以知道,JVM解析Java类域变量的逻辑是:
# 获取Java类中的变量数量
# 读取变量的访问标识
# 读取变量名称索引
# 读取变量类型索引
# 读取变量属性
# 判断变量类型
# 统计各类型数量
在Java类所对应的字节码文件中,有专门的一块区域保存Java类的变量信息,字节码文件会依次描述各个变量的访问标识、名称索引、类型索引和属性。由于每个变量的访问标识、名称索引、类型索引和属性数量都占用2字节(u2),因此在这段逻辑中,只需要依次调用cfs->get_u2_fast()即可。解析完一个变量的属性后,调用fields->short_at_put()函数将属性保存到fields所代表的内存区域中。fields是在ClassFileParser::parse_fields()方法一开始就申请的内存,如下:
u2 lenght = cfs->get_u2_fast();
typeArrayOop new_fields = oopFactory::new_permanent_shortArray(length*instanceKlass::next_offset, CHECK_(nullHandle));。
如果你非常认真地研究了前面的常量池对象constantPoolOop的内存申请与分配机制,那么这里对于typeArrayOop的内存分配机制自然一看就明白,因此这里不关注其实现的具体过程。但是,有一个问题却非常值得关注:这里到底申请了多大的内存?
oopFactory::new_permanent_shortArray()函数的第一个入参决定了最终所分配的内存大小,而该入参并没有直接给出而是一个表达式:
length * instanceKlass::next_offset
length自然是指Java类中的变量数量,而instanceKlass::next_offset在instanceKlass.hpp文件中定义,是一个枚举:
由该定义可知,instanceKlas::next_offset的值为7.由此也可知,最终所申请的内存大小是7*length,由于length类型是u2,占2字节,因此所申请的内存大小实际上相当于14个变量所占用的总字节。假设Java类中一共有5个变量,则JVM需要为其申请14*5=70字节的内存空间,来存放变量的属性。
这里仍然有一点需要注意,字节码文件对Java类变量的描述维度与JVM内存中的映像并不相同,字节码文件仅仅描述了变量的访问标识、名称索引、类型索引、属性,而JVM内存映像除此之外,还描述了每一个变量的偏移量和泛型索引。在遍历解析各个变量属性的循环后面,JVM会取出每一个变量的类型进行判断,并根据类型来统计上文所提到的5大类型(静态和非静态各5种类型)的总数量
u2 lenght = cfs->get_u2_fast();
typeArrayOop new_fields = oopFactory::new_permanent_shortArray(length*instanceKlass::next_offset, CHECK_(nullHandle));。
如果你非常认真地研究了前面的常量池对象constantPoolOop的内存申请与分配机制,那么这里对于typeArrayOop的内存分配机制自然一看就明白,因此这里不关注其实现的具体过程。但是,有一个问题却非常值得关注:这里到底申请了多大的内存?
oopFactory::new_permanent_shortArray()函数的第一个入参决定了最终所分配的内存大小,而该入参并没有直接给出而是一个表达式:
length * instanceKlass::next_offset
length自然是指Java类中的变量数量,而instanceKlass::next_offset在instanceKlass.hpp文件中定义,是一个枚举:
由该定义可知,instanceKlas::next_offset的值为7.由此也可知,最终所申请的内存大小是7*length,由于length类型是u2,占2字节,因此所申请的内存大小实际上相当于14个变量所占用的总字节。假设Java类中一共有5个变量,则JVM需要为其申请14*5=70字节的内存空间,来存放变量的属性。
这里仍然有一点需要注意,字节码文件对Java类变量的描述维度与JVM内存中的映像并不相同,字节码文件仅仅描述了变量的访问标识、名称索引、类型索引、属性,而JVM内存映像除此之外,还描述了每一个变量的偏移量和泛型索引。在遍历解析各个变量属性的循环后面,JVM会取出每一个变量的类型进行判断,并根据类型来统计上文所提到的5大类型(静态和非静态各5种类型)的总数量
偏移量。
解析完字节码文件中Java类的全部域变量信息后,JVM计算出5种数据类型各自的数量,并据此计算各个变量的偏移量。
解析完字节码文件中Java类的全部域变量信息后,JVM计算出5种数据类型各自的数量,并据此计算各个变量的偏移量。
静态变量偏移量。
先看静态类型变量,其逻辑如图所示。
这段逻辑很简单,先拿到起始偏移量,接着分别根据各个静态类型变量的偏移量计算总偏移量。计算顺序是:
# static_oop
# static_double
# static_word
# static_short
# static byte
每一种"下游"数据类型的偏移量都依赖其"上游"数据类型所占的字节宽度及数量。JVM为何要记录每一个变量的偏移量呢?起始,这与静态变量的存储机制和访问机制有关。对于JDK1.6而言,静态变量存储再Java类在JVM种所对应的镜像类Mirror种,当Java代码访问静态变量时,最终JVM也是通过设置偏移量进行访问。
先看静态类型变量,其逻辑如图所示。
这段逻辑很简单,先拿到起始偏移量,接着分别根据各个静态类型变量的偏移量计算总偏移量。计算顺序是:
# static_oop
# static_double
# static_word
# static_short
# static byte
每一种"下游"数据类型的偏移量都依赖其"上游"数据类型所占的字节宽度及数量。JVM为何要记录每一个变量的偏移量呢?起始,这与静态变量的存储机制和访问机制有关。对于JDK1.6而言,静态变量存储再Java类在JVM种所对应的镜像类Mirror种,当Java代码访问静态变量时,最终JVM也是通过设置偏移量进行访问。
非静态变量偏移量。
相比于静态变量偏移量,非静态变量的偏移量计算稍显复杂。逻辑如下:
如上面代码所注释的那样,非静态类型变量的偏移量计算逻辑可以分为以下5个步骤:
1.计算非静态变量起始偏移量
2.计算nonstatic_double_offset和nonstatic_oop_offset这两种非静态类型变量的起始偏移量
3.计算剩余3种类型变量的起始偏移量:nonstatic_word_offset、nonstatic_short_offset和nonstatic_byte_offset
4.计算对齐补白空间
5.计算补白后非静态变量所需要的内存空间总大小
JVM的内存管理模型在所有编程语言种属于比较复杂的一种,而对于一个给定的Java类,其主要的内存空间便是其非静态类型的变量所占用的空间(另一部分是虚拟方法分发表),因此理解了这部分内存分配策略,JVM的内存管理模型便理解了一半,故这部分内容十分重要。下面分析非静态类型变量偏移量的计算逻辑。
相比于静态变量偏移量,非静态变量的偏移量计算稍显复杂。逻辑如下:
如上面代码所注释的那样,非静态类型变量的偏移量计算逻辑可以分为以下5个步骤:
1.计算非静态变量起始偏移量
2.计算nonstatic_double_offset和nonstatic_oop_offset这两种非静态类型变量的起始偏移量
3.计算剩余3种类型变量的起始偏移量:nonstatic_word_offset、nonstatic_short_offset和nonstatic_byte_offset
4.计算对齐补白空间
5.计算补白后非静态变量所需要的内存空间总大小
JVM的内存管理模型在所有编程语言种属于比较复杂的一种,而对于一个给定的Java类,其主要的内存空间便是其非静态类型的变量所占用的空间(另一部分是虚拟方法分发表),因此理解了这部分内存分配策略,JVM的内存管理模型便理解了一半,故这部分内容十分重要。下面分析非静态类型变量偏移量的计算逻辑。
1.计算非静态变量起始偏移量
要计算非静态变量的偏移量,首先需要知道从哪里开始偏移。多次讲到了偏移量,那么什么是偏移量呢?对于非静态类型的变量,其偏移量是相对于未来即将new出来的Java对象实例在JVM内部所对应的instanceOop对象实例首地址的偏移位置。前面描述常量池对象时提到过,在JVM内部,使用oop-klass这种一分为二的模型去描述一个对象,常量池对象本身便是这种模型。对于Java类,JVM内部同样使用这种一分为二的模型去描述,对应的oop类是instanceOopDesc,对应的klass类是instaceKlass。在oop-klass模型种,oop模型主要存储对象实例的实际数据,而klass模型则主要存储对象的结构信息和虚函数方法表。说白了就是描述类的结构和行为。当JVM加载一个Java类时,会首先构建对应的instanceKlass对象,而当new一个Java对象实例时,则会构建出对应的instanceOop种的排列顺序。instanceOop对象主要由Java类的成员变量组成,而这些成员变量在instanceOop种的排列顺序,便由各种变量类型的偏移量决定。
在HotSpot内部,任何oop对象都包含对象头,因此实际上非静态变量的偏移量要从对象头的末尾开始计算。Java类实例在堆内存中的起始偏移量如图所示.
知道了偏移量的含义,现在来看看JVM的实现。源代码里有如下逻辑:
要计算非静态变量的偏移量,首先需要知道从哪里开始偏移。多次讲到了偏移量,那么什么是偏移量呢?对于非静态类型的变量,其偏移量是相对于未来即将new出来的Java对象实例在JVM内部所对应的instanceOop对象实例首地址的偏移位置。前面描述常量池对象时提到过,在JVM内部,使用oop-klass这种一分为二的模型去描述一个对象,常量池对象本身便是这种模型。对于Java类,JVM内部同样使用这种一分为二的模型去描述,对应的oop类是instanceOopDesc,对应的klass类是instaceKlass。在oop-klass模型种,oop模型主要存储对象实例的实际数据,而klass模型则主要存储对象的结构信息和虚函数方法表。说白了就是描述类的结构和行为。当JVM加载一个Java类时,会首先构建对应的instanceKlass对象,而当new一个Java对象实例时,则会构建出对应的instanceOop种的排列顺序。instanceOop对象主要由Java类的成员变量组成,而这些成员变量在instanceOop种的排列顺序,便由各种变量类型的偏移量决定。
在HotSpot内部,任何oop对象都包含对象头,因此实际上非静态变量的偏移量要从对象头的末尾开始计算。Java类实例在堆内存中的起始偏移量如图所示.
知道了偏移量的含义,现在来看看JVM的实现。源代码里有如下逻辑:
void ClassFileParser::layout_fields(Handle class_loader,
FieldAllocationCount* fac,
ClassAnnotationCollector* parsed_annotations,
FieldLayoutInfo* info,
TRAPS) {
// Field size and offset computation
// 获取父类非静态字段大小(以字节为单位)
int nonstatic_field_size = _super_klass() == NULL ? 0 : _super_klass()->nonstatic_field_size();
// 计算非静态字段起始偏移量
first_nonstatic_field_offset = instanceOopDesc::base_offset_in_bytes() + nonstatic_field_size * heapOopSize;
// ...
}
这里首先调用instanceOopDesc::base_offset_in_bytes()方法,该方法实现如下:
// instanceOop.hpp
// 计算对象头大小
// If compressed, the offset of the fields of the instance may not be aligned.
static int base_offset_in_bytes() {
// offset computation code breaks if UseCompressedClassPointers
// only is true
return (UseCompressedOops && UseCompressedClassPointers) ?
klass_gap_offset_in_bytes() :
sizeof(instanceOopDesc);
}
如果没有启用压缩策略,则最终返回instanceOopDesc类型大小。instanceOopDesc继承自oopDesc,而oopDesc类型大小在前文已经计算出来了,是两个指针宽度。假设JVM运行在64位架构上,则这个值是16。而如果启用了压缩策略,则在64位架构上,该值为12,这是因为无论是否开启压缩策略,oop._mark作为一个指针式不会被压缩的,任何时候都会占用8字节,而oop._klass则会受压缩策略是否开启的影响,若开启压缩策略,则_klass仅会占用4字节,所以在64位架构上开启压缩策略的情况下,oop对象头总共占用12字节的内存空间。
FieldAllocationCount* fac,
ClassAnnotationCollector* parsed_annotations,
FieldLayoutInfo* info,
TRAPS) {
// Field size and offset computation
// 获取父类非静态字段大小(以字节为单位)
int nonstatic_field_size = _super_klass() == NULL ? 0 : _super_klass()->nonstatic_field_size();
// 计算非静态字段起始偏移量
first_nonstatic_field_offset = instanceOopDesc::base_offset_in_bytes() + nonstatic_field_size * heapOopSize;
// ...
}
这里首先调用instanceOopDesc::base_offset_in_bytes()方法,该方法实现如下:
// instanceOop.hpp
// 计算对象头大小
// If compressed, the offset of the fields of the instance may not be aligned.
static int base_offset_in_bytes() {
// offset computation code breaks if UseCompressedClassPointers
// only is true
return (UseCompressedOops && UseCompressedClassPointers) ?
klass_gap_offset_in_bytes() :
sizeof(instanceOopDesc);
}
如果没有启用压缩策略,则最终返回instanceOopDesc类型大小。instanceOopDesc继承自oopDesc,而oopDesc类型大小在前文已经计算出来了,是两个指针宽度。假设JVM运行在64位架构上,则这个值是16。而如果启用了压缩策略,则在64位架构上,该值为12,这是因为无论是否开启压缩策略,oop._mark作为一个指针式不会被压缩的,任何时候都会占用8字节,而oop._klass则会受压缩策略是否开启的影响,若开启压缩策略,则_klass仅会占用4字节,所以在64位架构上开启压缩策略的情况下,oop对象头总共占用12字节的内存空间。
Java类是面向对象的,继承是面向对象编程的3大特性之一,除了java.lang.Object类以外,所有的Java类都显式或隐式地继承了某个父类,而字段继承和方法继承则构成了继承的全部内涵。如果说继承是目标,那么字段在子类中的内存占用则是技术手段。子类必须将父类中所定义的非静态字段信息全部复制一遍,才能实现字段继承的目标。因此,在计算子类非静态字段的起始偏移量时,必须将父类可被继承的字段的内存大小考虑在内。具体而言,子类的非静态字段起始偏移量,在计算完oop对象头的大小后,还需要为父类的可被继承的字段预留空间。HotSpot将父类可被继承的字段的内存空间安排在子类所对应的oop对象头的后面,因此最终一个java类中非静态字段的起始偏移位置在父类被继承的字段域的末尾,如图所示。
2.内存对齐于字段重排。
在上面第一步计算出Java类字段的起始偏移量后,接下来就能基于这个起始偏移量计算出Java类中所有字段的偏移量。不过在这之前,需要先研究这样一个问题——内存对齐。因为Java类字段的偏移地址与内存对齐有脱不开的关系,JVM为了处理内存对齐,颇费了一番心思,甚至不惜将字段进行重排。
在上面第一步计算出Java类字段的起始偏移量后,接下来就能基于这个起始偏移量计算出Java类中所有字段的偏移量。不过在这之前,需要先研究这样一个问题——内存对齐。因为Java类字段的偏移地址与内存对齐有脱不开的关系,JVM为了处理内存对齐,颇费了一番心思,甚至不惜将字段进行重排。
1.什么是内存对齐?
内存对齐与数据在内存中的位置有关。如果一个变量的内存起始地址正好等于其长度的整数倍,则这种内存分配就被称作自然对齐。在32位x86平台上,基本的对齐规则如表所示:
举个例子,在32位CPU下,假设一个int整型变量的内存地址位0x00000008,那么该变量就是自然对齐的。
内存对齐与数据在内存中的位置有关。如果一个变量的内存起始地址正好等于其长度的整数倍,则这种内存分配就被称作自然对齐。在32位x86平台上,基本的对齐规则如表所示:
举个例子,在32位CPU下,假设一个int整型变量的内存地址位0x00000008,那么该变量就是自然对齐的。
2.为什么要字节对齐?
现在计算机中内存空间都是按照字节划分的,也即内存的粒度细分到存储单元,而一个存储单元恰恰包含8个比特,正好是1字节(byte),因此从理论上讲似乎对任何类型的变量访问都可以从任何地址开始。但实际情况恰恰相反,各个硬件平台在对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。例如有些架构的CPU在访问一个没有进行对齐的变量进行读写时,硬件就会报错。要验证CPU硬件平台是否支持非对齐状态的变量进行读写,只需要通过如下一个C程序进行测试:
char ch[2];
char *p = &ch[0];
int i = *(int*)p;
p = &ch[1];
i = *(int*)p;
这段程序很简单,先声明一个大小为2的char类型数组,接着将该数组的第1个元素(从1开始计数)的内存地址传递给指针p,再通过变量i从数组的第1个元素所在的内存位置开始连续读取4字节(即一个int类型的宽度)。接着再从数组的第2个元素所在的内存位置开始连续读取4字节数据。如果ch数组的首地址恰好是4字节的整数倍,则第一次读取4字节能够成功,但第二次一定会失败,因为第二次读取时,起始地址一定不是4字节的整数倍。
某些平台会支持非对齐内存位置的数据读写,硬件不会抛出异常,但是最常见的是,如果不按照其平台要求对数据进行对齐,会在存取效率上有所损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设32位系统)存放在偶地址开始的地方,那么一个读周期可以读出32比特,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32比特数据。显然在读取效率上下降很多。
例如,假设一个整型变量的内存地址不是4字节对齐,其内存位置位于0x00000002,则CPU需要进行两次内存访问才能读取到该变量的值:
# 第一次从0x00000002和0x00000003这两个连续的内存存储单元中读取一个short类型宽度的数据
# 第二次则从0x00000004和0x00000005这两个连续的内存单元中再读取一个short类型宽度的数据
将这2次读取的数据进行组合之后才能得到所要的数据。如果该变量的内存位置是0x00000003,则CPU可能需要2次甚至3次内存访问才能将该变量的值读取出来。CPU访问两次的方案是:
# 第一次从0x00000001~0x00000004这4个连续的内存单元中读取一个int类型宽度的数据
# 第二次则从0x00000005~0x00000008
将两次内存访问的结果剔除首尾多出来的字节,拼凑出目标结果。而CPU访问三次的方案是:
# 第一次从0x00000003这个内存单元上读取一个char类型宽度的数据
# 第二次从0x00000004和0x00000005这两个连续的内存单元中读取一个short类型宽度的数据
# 第三次则从0x00000006这个内存单元上读取一个char类型宽度的数据
三次都读取完成后,再将这3次读取的结果进行组合得到整型数据。如果这样的程序工作在多线程环境中,则还需要进行总线级别的原子操作,以防止出现脏数据,从而造成程序的严重错误。而如果这个整型变量的内存地址是自然对齐的,则CPU只需要一次内存访问就能读取数据,并且还是原子性的,效率和安全性无疑是最高的。所以说,由于以上两种情况,一种是程序健壮性,一种是高性能,均需要各种类型数据按照一定的规则在空间上排列,而不是顺序地一个一个地排放,从而对数据进行对齐
现在计算机中内存空间都是按照字节划分的,也即内存的粒度细分到存储单元,而一个存储单元恰恰包含8个比特,正好是1字节(byte),因此从理论上讲似乎对任何类型的变量访问都可以从任何地址开始。但实际情况恰恰相反,各个硬件平台在对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。例如有些架构的CPU在访问一个没有进行对齐的变量进行读写时,硬件就会报错。要验证CPU硬件平台是否支持非对齐状态的变量进行读写,只需要通过如下一个C程序进行测试:
char ch[2];
char *p = &ch[0];
int i = *(int*)p;
p = &ch[1];
i = *(int*)p;
这段程序很简单,先声明一个大小为2的char类型数组,接着将该数组的第1个元素(从1开始计数)的内存地址传递给指针p,再通过变量i从数组的第1个元素所在的内存位置开始连续读取4字节(即一个int类型的宽度)。接着再从数组的第2个元素所在的内存位置开始连续读取4字节数据。如果ch数组的首地址恰好是4字节的整数倍,则第一次读取4字节能够成功,但第二次一定会失败,因为第二次读取时,起始地址一定不是4字节的整数倍。
某些平台会支持非对齐内存位置的数据读写,硬件不会抛出异常,但是最常见的是,如果不按照其平台要求对数据进行对齐,会在存取效率上有所损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设32位系统)存放在偶地址开始的地方,那么一个读周期可以读出32比特,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32比特数据。显然在读取效率上下降很多。
例如,假设一个整型变量的内存地址不是4字节对齐,其内存位置位于0x00000002,则CPU需要进行两次内存访问才能读取到该变量的值:
# 第一次从0x00000002和0x00000003这两个连续的内存存储单元中读取一个short类型宽度的数据
# 第二次则从0x00000004和0x00000005这两个连续的内存单元中再读取一个short类型宽度的数据
将这2次读取的数据进行组合之后才能得到所要的数据。如果该变量的内存位置是0x00000003,则CPU可能需要2次甚至3次内存访问才能将该变量的值读取出来。CPU访问两次的方案是:
# 第一次从0x00000001~0x00000004这4个连续的内存单元中读取一个int类型宽度的数据
# 第二次则从0x00000005~0x00000008
将两次内存访问的结果剔除首尾多出来的字节,拼凑出目标结果。而CPU访问三次的方案是:
# 第一次从0x00000003这个内存单元上读取一个char类型宽度的数据
# 第二次从0x00000004和0x00000005这两个连续的内存单元中读取一个short类型宽度的数据
# 第三次则从0x00000006这个内存单元上读取一个char类型宽度的数据
三次都读取完成后,再将这3次读取的结果进行组合得到整型数据。如果这样的程序工作在多线程环境中,则还需要进行总线级别的原子操作,以防止出现脏数据,从而造成程序的严重错误。而如果这个整型变量的内存地址是自然对齐的,则CPU只需要一次内存访问就能读取数据,并且还是原子性的,效率和安全性无疑是最高的。所以说,由于以上两种情况,一种是程序健壮性,一种是高性能,均需要各种类型数据按照一定的规则在空间上排列,而不是顺序地一个一个地排放,从而对数据进行对齐
3.什么时候需要考虑对齐
无论是C/C++这样的编译性语言,还是Java/C#这样的高级语言,一般情况下都不需要开发者去考虑对齐的问题,因为编译器或者虚拟机会自动将数据进行对齐补白。不过如果你是在设计底层协议或者开发硬件驱动程序,这时候就必须要考虑对齐的事情了。下面这个简单的例子能够验证C/C++的编译器gcc对内存对齐的支持:
这段代码很简单,在test()函数里声明和定义了3种不同类型的变量,之所以要声明这几个不同类型的变量,就是为了测试gcc编译器的对齐处理能力
无论是C/C++这样的编译性语言,还是Java/C#这样的高级语言,一般情况下都不需要开发者去考虑对齐的问题,因为编译器或者虚拟机会自动将数据进行对齐补白。不过如果你是在设计底层协议或者开发硬件驱动程序,这时候就必须要考虑对齐的事情了。下面这个简单的例子能够验证C/C++的编译器gcc对内存对齐的支持:
这段代码很简单,在test()函数里声明和定义了3种不同类型的变量,之所以要声明这几个不同类型的变量,就是为了测试gcc编译器的对齐处理能力
使用gcc编译器将这段C程序编译成汇编程序,如下所示:
mov %ecx,0x10(%rbp) // 获取入参
movb $0x41,-0x1(%rbp) // 为变量c赋值
movw $0x10,-0x4(%rbp) // 为变量s赋值
movl $0x8,-0x8(%rbp) // 为变量i赋值
在这段汇编程序的test段中,中间有几条分别是test()函数中的局部变量进行赋值的指令,其中变量c的堆栈位置是-5(%rbp),因为c是字节类型,因此只占1个内存存储单元。变量c之后的局部变量是short类型的s,由于short类型占2个内存存储单元,因此按常理s的堆栈位置应该是-3(%rbp),但是编译器却将变量分配在了-8(%rbp)这个位置,其实在这里,编译器便是对变量进行了对齐处理。由于short类型的宽度是2字节,因此其内存首地址必须是2字节的整数倍,如果将变量s的偏移地址放在-3(%rbp)这个位置,很显然不符合自然对齐的原则
mov %ecx,0x10(%rbp) // 获取入参
movb $0x41,-0x1(%rbp) // 为变量c赋值
movw $0x10,-0x4(%rbp) // 为变量s赋值
movl $0x8,-0x8(%rbp) // 为变量i赋值
在这段汇编程序的test段中,中间有几条分别是test()函数中的局部变量进行赋值的指令,其中变量c的堆栈位置是-5(%rbp),因为c是字节类型,因此只占1个内存存储单元。变量c之后的局部变量是short类型的s,由于short类型占2个内存存储单元,因此按常理s的堆栈位置应该是-3(%rbp),但是编译器却将变量分配在了-8(%rbp)这个位置,其实在这里,编译器便是对变量进行了对齐处理。由于short类型的宽度是2字节,因此其内存首地址必须是2字节的整数倍,如果将变量s的偏移地址放在-3(%rbp)这个位置,很显然不符合自然对齐的原则
下面这个结构体的例子则是很经典,能够使你在不了解汇编语言的情况上,直观地观察到编译器对内存对齐处理的支持:
在这段程序中,声明了2个结构体类型A和B,注意,这两个结构体中所包含的变量数量和变量类型完全一致,唯一不一致的是变量的声明顺序。
结构体所占的内存大小,往往是其中所有变量所占内存的综合,按照这个逻辑,则本例中的两个结构体的大小应该都是一样大小。在32位机器上,以上几种数据类型的长度如下:
# Char, 1(有符号无符号都相同)
# Short, 2(有符号无符号都相同)
# Int, 4(有符号无符号都相同)
a是int类型,宽度是4字节;b是char类型,宽度是1字节;c是short类型,宽度是2字节。所以这两个结构体所占的内存大小应该是7字节。然而结果并不是这样。运行main()函数,打印的结果如下:
sizeof(structA)的值为8
sizeof(structB)的值却是12
很奇怪,两个结构体的大小竟然没有一个是7.这是因为gcc编译器对变量进行了内存对齐。对于结构体A,其变量类型的顺序是int->char->short,在内存分配时,先为int类型的变量a分配4字节内存。接着为char类型的b变量分配内存。由于char仅占1字节内存,因此其实并无内存对齐要求。所以变量b的内存可以直接跟在变量a之后。假设变量a的内存其实地址标记为4x(即4字节的整数倍),则b的内存地址为4(x+1)(为了方便描述问题,在那时忘记堆栈空间的增长方向到底时往高地址还是往低地址吧)
接着为short类型的c变量分配内存。如果不考虑内存对齐,则变量c应当位于位于变量b的后面,则变量c的内存地址应该为4(x+1)+1.但是由于short类型的数据的对齐原则是按2字节对齐,而4(x+1)+1这样的内存地址很显然不能被2整除,不符合按2字节对齐的要求,所以编译器就自作主张,将其又往后移动了一个字节,将变量c分配在4(x+1)+2的内存地址。这样一来,给人的归纳觉是char类型的变量b似乎占据了2个内存单元。其实变量b并没有占据2个存储单元,仍然只需要一个存储单元的内存空间,而其后面的另一个多出来的存储单元是用于对齐的补白空间,因此,最终struct A需要占用的内存空间总大小是8字节。其内存空间布局如图所示
在这段程序中,声明了2个结构体类型A和B,注意,这两个结构体中所包含的变量数量和变量类型完全一致,唯一不一致的是变量的声明顺序。
结构体所占的内存大小,往往是其中所有变量所占内存的综合,按照这个逻辑,则本例中的两个结构体的大小应该都是一样大小。在32位机器上,以上几种数据类型的长度如下:
# Char, 1(有符号无符号都相同)
# Short, 2(有符号无符号都相同)
# Int, 4(有符号无符号都相同)
a是int类型,宽度是4字节;b是char类型,宽度是1字节;c是short类型,宽度是2字节。所以这两个结构体所占的内存大小应该是7字节。然而结果并不是这样。运行main()函数,打印的结果如下:
sizeof(structA)的值为8
sizeof(structB)的值却是12
很奇怪,两个结构体的大小竟然没有一个是7.这是因为gcc编译器对变量进行了内存对齐。对于结构体A,其变量类型的顺序是int->char->short,在内存分配时,先为int类型的变量a分配4字节内存。接着为char类型的b变量分配内存。由于char仅占1字节内存,因此其实并无内存对齐要求。所以变量b的内存可以直接跟在变量a之后。假设变量a的内存其实地址标记为4x(即4字节的整数倍),则b的内存地址为4(x+1)(为了方便描述问题,在那时忘记堆栈空间的增长方向到底时往高地址还是往低地址吧)
接着为short类型的c变量分配内存。如果不考虑内存对齐,则变量c应当位于位于变量b的后面,则变量c的内存地址应该为4(x+1)+1.但是由于short类型的数据的对齐原则是按2字节对齐,而4(x+1)+1这样的内存地址很显然不能被2整除,不符合按2字节对齐的要求,所以编译器就自作主张,将其又往后移动了一个字节,将变量c分配在4(x+1)+2的内存地址。这样一来,给人的归纳觉是char类型的变量b似乎占据了2个内存单元。其实变量b并没有占据2个存储单元,仍然只需要一个存储单元的内存空间,而其后面的另一个多出来的存储单元是用于对齐的补白空间,因此,最终struct A需要占用的内存空间总大小是8字节。其内存空间布局如图所示
structA的内存布局如图所示
再来分析struct B。其变量类型的顺序为char->int->short。分配内存时,先为char类型的b变量分配内存空间。b变量只需要一个存储单元便足够了。接着为int类型的变量a分配内存。假设变量b的内存地址为4x(即4字节的整数倍),这理解释一下,变量b的内存地址之所以也是4字节的整数倍,这是由于编译器不仅保证各种变量、结构、复合结构的数据类型是内存对齐的,还会保证堆、堆栈的内存地址也是自然对齐的。所以无论结构体B被分配在那里,其内存起始地址一定是4的整数倍。假设变量a的内存位置紧跟在变量b的后面,则a的内存地址应该是4x+1,因为变量b只需要1个内存单元即可。但是由于内存对齐的需要,变量a需要按4字节对齐,所以肯定不能分配在4x+1这个内存位置,那怎么办呢?很简单,往后移动3个字节,补齐4字节即可。移动3字节后的内存位置是4(x+1),这下能被4整除了,所以就分配在这里了。接着为short类型的变量c分配内存。由于变量a占4字节内存空间,因此变量c的起始内存地址自然就是相对于变量a的起始地址4(x+1)再往后移动4个字节,所以变量c的起始内存地址变成了4(x+2).现在,内存布局如图所示的样子。在这种内存布局下,struct B总共占有10字节内存空间。但是在此刻,内存对齐的规则又发挥作用,具体发挥的对象就是struct B类型本身。
在编译原理中,不仅是基本的数据类型要求做到自然对齐,连结构体这样的复合结构类型也需要整体进行自然对齐。起始,数据类型的对齐并不是为了方便自己,而是为了方便别人。例如,struct B中的char类型的变量b后面补白了3字节,是为了让紧跟其后的int类型的a变量能够自然对齐。同理,struct B也需要考虑让其后面的变量能够自然对齐。由于struct B也不知道其后面会跟什么类型的变量,因此便按照默认的4字节进行对齐。这么做使得后续在处理数据对齐时的逻辑变得简单。由于编译器要将struct结构体按照4字节对齐,因此最终struct B的内存空间会被扩展到12字节,最终的内存布局如图所示
4.人工优化结构体的内存空间。
通过上面的两个例子可以看到,即使是如c/c++这样接近底层的编程语言,平时在开发中也不需要关注内存对齐的问题,因为编译器可以bb昂我们搞定一切。但是上面那2个结构体的例子却向我们透露出一个强烈的信号:虽然编译器会帮我们处理号内存对齐的问题,但是编译器并不是万能的,并不会使用除了内存对齐以外的其他优化技巧。上面例子中的A和B两个结构体所包含的成员项是完全相同的,所不同的仅仅是成员项的声明顺序不同而已,结果就造成了两者在内存空间利用率上的巨大差异。假设结构体的成员项数量增多,并且声明完全是无序的,那么内存的利用率将会更加糟糕。例如下面这个结构体:
struct A {
char b;
int a;
char b2;
int a2;
char b3;
int a3;
};
像这种结构体,将会占用24个内存单元。对于这种糟糕的内存使用率,程序员完全可以主动优化,例如,更改下结构体成员项的声明顺序,变成如下:
struct B {
char b;
char b2;
char b3;
int a;
int a2;
int a3;
};
通过上面的两个例子可以看到,即使是如c/c++这样接近底层的编程语言,平时在开发中也不需要关注内存对齐的问题,因为编译器可以bb昂我们搞定一切。但是上面那2个结构体的例子却向我们透露出一个强烈的信号:虽然编译器会帮我们处理号内存对齐的问题,但是编译器并不是万能的,并不会使用除了内存对齐以外的其他优化技巧。上面例子中的A和B两个结构体所包含的成员项是完全相同的,所不同的仅仅是成员项的声明顺序不同而已,结果就造成了两者在内存空间利用率上的巨大差异。假设结构体的成员项数量增多,并且声明完全是无序的,那么内存的利用率将会更加糟糕。例如下面这个结构体:
struct A {
char b;
int a;
char b2;
int a2;
char b3;
int a3;
};
像这种结构体,将会占用24个内存单元。对于这种糟糕的内存使用率,程序员完全可以主动优化,例如,更改下结构体成员项的声明顺序,变成如下:
struct B {
char b;
char b2;
char b3;
int a;
int a2;
int a3;
};
修改后的结构体只需要占用16个内存单元,比优化之前的结构体一下子少占用8字节的内存空间,这种内存空间的节省量相当可观。请记住这个实例,下面讲解JVM的内存分配策略时,与此会有很大关系。所以,在进行C/C++程序开发时,一个优秀的程序员还是应当主动关注内存对齐的问题。在这方面,一种可以遵循的设计技巧是:在定义结构体类型中的成员项时,按照类型大小从小到大依次声明,如此便能够尽量减少中间的填补空间。除了这总设计技巧,还有一种主动的以空间换时间的策略,显式地声明填补空间,例如对上面例子中的A结构体进行如下处理:
struct C {
int a;
char b;
char reserved; // 声明一个补白字节
short c;
};
在变量b后面多声明一个成员项,其类型也是char。这个成员对程序没有明显的意义,仅仅起到填补空间以达到字节对齐的目的。当然,即使不加这个成员项,编译器也会自动填补对齐,我们加上它只是起到显式的提醒作用。假设A结构体的结构是这样的:
struct D {
char a;
int b;
};
由于变量b是int类型,需要按4字节进行对齐,因此编译器会自动在变量a的后面填补3字节以对齐。如果你不够信赖编译器,可以自行添加3字节的补白空间,添加方式就是声明一个元素数量为3的char类型数组。修改后的A结构体如下:
struct E {
char a;
char reserved[3];
int b;
};
struct C {
int a;
char b;
char reserved; // 声明一个补白字节
short c;
};
在变量b后面多声明一个成员项,其类型也是char。这个成员对程序没有明显的意义,仅仅起到填补空间以达到字节对齐的目的。当然,即使不加这个成员项,编译器也会自动填补对齐,我们加上它只是起到显式的提醒作用。假设A结构体的结构是这样的:
struct D {
char a;
int b;
};
由于变量b是int类型,需要按4字节进行对齐,因此编译器会自动在变量a的后面填补3字节以对齐。如果你不够信赖编译器,可以自行添加3字节的补白空间,添加方式就是声明一个元素数量为3的char类型数组。修改后的A结构体如下:
struct E {
char a;
char reserved[3];
int b;
};
5.Java类字段对对齐的要求。
Java类的字段最终也是要保存到堆内存中的,也需要被CPU频繁地读写,并且Java类的变量类型最终也会映射成CPU硬件平台架构所能支持的数据类型,因此Java类字段在内存中的位置也必须满足自然对齐的要求。尤其是Java语言作为一门跨平台的编程语言,可以运行在众多的硬件平台上,更应该兼容各种异构平台对数据对齐的要求,否则万一运行在某个硬件平台上,更应该兼容各种异构平台对数据对齐的要求,否则万一运行在某个硬件平台上,而该平台上压根儿不支持非对齐内存的访问,那么Java虚拟机很可能就会可怜地因为这个低级的原因而直接崩溃。因此,JVM在设计上就必须使其内部5打类型的数据都满足内存对齐的原则。Java类中包含八大基本数据类型,每种数据类型所占用的内存空间大小总体上与C/C++中的基本数据类型的宽度相等,如表所示.除了这8中基本数据类型,还有另外一种类型,就是引用。引用数据类型所占用的内存大小与不同的硬件平台以及是否开启压缩策略有关。在32位平台上,引用数据类型占用4字节内存空间,而在64位平台上,如果开启了压缩策略,则占用4字节内存空间,否则便占用8字节内存空间。
Java类的字段最终也是要保存到堆内存中的,也需要被CPU频繁地读写,并且Java类的变量类型最终也会映射成CPU硬件平台架构所能支持的数据类型,因此Java类字段在内存中的位置也必须满足自然对齐的要求。尤其是Java语言作为一门跨平台的编程语言,可以运行在众多的硬件平台上,更应该兼容各种异构平台对数据对齐的要求,否则万一运行在某个硬件平台上,更应该兼容各种异构平台对数据对齐的要求,否则万一运行在某个硬件平台上,而该平台上压根儿不支持非对齐内存的访问,那么Java虚拟机很可能就会可怜地因为这个低级的原因而直接崩溃。因此,JVM在设计上就必须使其内部5打类型的数据都满足内存对齐的原则。Java类中包含八大基本数据类型,每种数据类型所占用的内存空间大小总体上与C/C++中的基本数据类型的宽度相等,如表所示.除了这8中基本数据类型,还有另外一种类型,就是引用。引用数据类型所占用的内存大小与不同的硬件平台以及是否开启压缩策略有关。在32位平台上,引用数据类型占用4字节内存空间,而在64位平台上,如果开启了压缩策略,则占用4字节内存空间,否则便占用8字节内存空间。
Java语言中的8种基本数据类型与引用类型对应JVM内部所定义的5大数据类型。它们的对应关系如表所示。
JVM必须确保其内部这5种基本数据类型都能够自然对齐,即确保其内存地址能够被其所占用字节宽度所整除。解决数据类型自然对齐的手段无非使内存补白,JVM自然也少不了这一手(事实上除了这法子也没别的法子了),但是JVM却技高一筹,不仅使用了补白,还祭出了另一件法宝——字段重排。
JVM必须确保其内部这5种基本数据类型都能够自然对齐,即确保其内存地址能够被其所占用字节宽度所整除。解决数据类型自然对齐的手段无非使内存补白,JVM自然也少不了这一手(事实上除了这法子也没别的法子了),但是JVM却技高一筹,不仅使用了补白,还祭出了另一件法宝——字段重排。
JVM的字段重排策略主要包括下面2点:
# 将相同类型的字段组合在一起
# 按照double->word->short->byte->oop的顺序依次分配
第一点,将相同类型的字段组合在一起,究其原因,是因为这样更能节省内存空间。还记得上面用C语言写的那个结构体的例子吗?使用C语言编写一个结构体时,结构体种成员项声明的顺序不同,则结构体的大小也随之不同。这种规律对Java字段同样使用。看下面这个简单的Java类:
class A {
byte b;
long 1;
byte b2;
int i;
}
如果没有字段重排。则JVM为了让各种类型的字段做到自然对齐,最终只能按照如图所示这种补白的方式来分配内存(省略oop对象头)。这样的内存布局需要占用24字节的内存空间。
# 将相同类型的字段组合在一起
# 按照double->word->short->byte->oop的顺序依次分配
第一点,将相同类型的字段组合在一起,究其原因,是因为这样更能节省内存空间。还记得上面用C语言写的那个结构体的例子吗?使用C语言编写一个结构体时,结构体种成员项声明的顺序不同,则结构体的大小也随之不同。这种规律对Java字段同样使用。看下面这个简单的Java类:
class A {
byte b;
long 1;
byte b2;
int i;
}
如果没有字段重排。则JVM为了让各种类型的字段做到自然对齐,最终只能按照如图所示这种补白的方式来分配内存(省略oop对象头)。这样的内存布局需要占用24字节的内存空间。
如果将相同类型的字段组合在一起进行内存分配,并且假设按照byte->long->int的顺序分配内存,则最终所分配的内存空间布局如图所示。
经过字段重排后,现在只需要补白6字节,类A总共只占20字节内存空间,比重排之前节省宝贵的4字节。但是与C语言一样,gcc编译器不仅要求对结构体里的每个成员进行对齐补白,还要求对整个结构体进行对齐,JVM也不仅需要负责将Java类里面的每个字段进行对齐,还需要对整个类进行对齐。JVM规范还要求对类的堆内存空间按8字节对齐,因此刚才对类A进行字段重排后,虽然全部字段加起来只需要20个内存单元,但是为了对整个类进行对齐,最终仍需要补白至24字节,这样依赖,反倒与没有重排之前一样了。因此,这时候JVM字段重排策略的第2点就显得十分重要了,那就是重排后字段的顺序,前面讲过,在定义C语言的结构体时,应当按照字段类型的宽度从小到大的顺序声明成员项,而JVM里面则是按照字段类型的宽度从大到小的顺序分配字段。现在按照long->int->byte的顺序对类A的字段空间重新排列,重排后的内存空间布局如图所示
经过字段重排后,现在只需要补白6字节,类A总共只占20字节内存空间,比重排之前节省宝贵的4字节。但是与C语言一样,gcc编译器不仅要求对结构体里的每个成员进行对齐补白,还要求对整个结构体进行对齐,JVM也不仅需要负责将Java类里面的每个字段进行对齐,还需要对整个类进行对齐。JVM规范还要求对类的堆内存空间按8字节对齐,因此刚才对类A进行字段重排后,虽然全部字段加起来只需要20个内存单元,但是为了对整个类进行对齐,最终仍需要补白至24字节,这样依赖,反倒与没有重排之前一样了。因此,这时候JVM字段重排策略的第2点就显得十分重要了,那就是重排后字段的顺序,前面讲过,在定义C语言的结构体时,应当按照字段类型的宽度从小到大的顺序声明成员项,而JVM里面则是按照字段类型的宽度从大到小的顺序分配字段。现在按照long->int->byte的顺序对类A的字段空间重新排列,重排后的内存空间布局如图所示
现在按照新的顺序对数据类型进行排序,不仅对各个字段进行了内存对齐,同时对整个类按8字节进行对齐(在那时没考虑oop对象头),重排后一共仅占16个内存单元,比优化之前得24字节节省了宝贵的8字节内存空间。别小看这8字节的内存空间,对于一个生产环境种的JVM,其需要加载成千上万个类实例对象,这成千上万个8字节加起来的内存空间便十分可观。通过这个例子也可以知道,JVM要按照long->int->short->byte的顺序对字段进行排列,实在时有其道理的
3.计算变量偏移量。
其实通过前面所阐述的HotSpot对字段内存分配的策略,不难推导出一种计算Java类各字段偏移量的方法。既然HotSpot将字段进行了重排,将相同类型的字段存储在一起,那么便可以先计算出其内部5大类型字段的起始偏移量。每一种类型都包含零或多个Java类字段,基于该类型的起始偏移量,便可逐个计算出该类型所对应的每一个具体的Java类字段的偏移量。事实上,HotSpot也就是这么实现的。看源码:
HotSpot提供了好几种重排顺序选项。如果allocation_style的值是0,则按照oops->longs/doubles->ints->shorts/chars->bytes的顺序为字段分配内存空间;如果allocation_style的值是1,则最先分配longs/doubles,最后分配oops.这里仅讨论后一种情况。
其实通过前面所阐述的HotSpot对字段内存分配的策略,不难推导出一种计算Java类各字段偏移量的方法。既然HotSpot将字段进行了重排,将相同类型的字段存储在一起,那么便可以先计算出其内部5大类型字段的起始偏移量。每一种类型都包含零或多个Java类字段,基于该类型的起始偏移量,便可逐个计算出该类型所对应的每一个具体的Java类字段的偏移量。事实上,HotSpot也就是这么实现的。看源码:
HotSpot提供了好几种重排顺序选项。如果allocation_style的值是0,则按照oops->longs/doubles->ints->shorts/chars->bytes的顺序为字段分配内存空间;如果allocation_style的值是1,则最先分配longs/doubles,最后分配oops.这里仅讨论后一种情况。
根据上面的源码,此时起始longs/doubles类型的起始偏移量已经计算出来了,这个偏移量就是整个Java类的起始偏移量。
分配完longs/doubles类型之后接着分配ints类型,说白了就是ints的起始偏移量。ints的起始偏移量一定位于longs内存空间的末尾,所以ints的起始偏移量的计算方法是:
longs/doubles的起始偏移量+longs的宽度*longs的数量。
ints之后的各种数据类型的起始偏移量的计算方法也类似,我们看HotSpot的实现:
next_nonstatic_word_offset = next_nonstatic_double_offset + (nonstatic_double_count * BytesPerLong);
next_nonstatic_short_offset = next_nonstatic_word_offset + (nonstatic_word_count * BytesPerInt);
next_nonstatic_byte_offset = next_nonstatic_short_offset + (nonstatic_short_count * BytesPerShort);
通过这段代码,HotSpot将ints、shorts/chars、bytes这3种字段类型的起始偏移量也计算出来。
这里要注意两点:
# 无论字段重排是哪种顺序,longs/doubles后面跟的一定是ints,而ints后面所跟的一定是shorts/chars,并且shorts/chars后面跟的一定是bytes
# 由于longs/doubles进行了对齐处理,所以其末尾的下一个内存地址一定是8字节的整数倍。对于这样的内存地址,其也一定是4字节的整数倍,所以将ints紧跟在longs/doubles字段后面,ints的字段也就天然是对齐的。同理,ints后面的shorts/chars以及shorts/chars后面的bytes字段也一定是天然对齐的。此时的内存布局如图所示:
分配完longs/doubles类型之后接着分配ints类型,说白了就是ints的起始偏移量。ints的起始偏移量一定位于longs内存空间的末尾,所以ints的起始偏移量的计算方法是:
longs/doubles的起始偏移量+longs的宽度*longs的数量。
ints之后的各种数据类型的起始偏移量的计算方法也类似,我们看HotSpot的实现:
next_nonstatic_word_offset = next_nonstatic_double_offset + (nonstatic_double_count * BytesPerLong);
next_nonstatic_short_offset = next_nonstatic_word_offset + (nonstatic_word_count * BytesPerInt);
next_nonstatic_byte_offset = next_nonstatic_short_offset + (nonstatic_short_count * BytesPerShort);
通过这段代码,HotSpot将ints、shorts/chars、bytes这3种字段类型的起始偏移量也计算出来。
这里要注意两点:
# 无论字段重排是哪种顺序,longs/doubles后面跟的一定是ints,而ints后面所跟的一定是shorts/chars,并且shorts/chars后面跟的一定是bytes
# 由于longs/doubles进行了对齐处理,所以其末尾的下一个内存地址一定是8字节的整数倍。对于这样的内存地址,其也一定是4字节的整数倍,所以将ints紧跟在longs/doubles字段后面,ints的字段也就天然是对齐的。同理,ints后面的shorts/chars以及shorts/chars后面的bytes字段也一定是天然对齐的。此时的内存布局如图所示:
完成了JVM内部5大类型数据的起始偏移量计算之后,接着就可以计算每种类型所对应的Java类种的字段的具体偏移量了。计算方法很简单,将Java类中的字段按照其所属的5大类型的起始偏移量进行顺序排列即可。看HotSpot的源码实现:
在这段逻辑里面,HotSpot对Java类中的所有字段进行遍历,并分别计算其各个字段的偏移量。以Java类中的long类型字段为例,其计算逻辑是:
case STATIC_DOUBLE:
real_offset = next_static_double_offset;
next_static_double_offset += BytesPerLong;
break;
real_offset就是当前字段的真实偏移量。假设Java类中包含2个long类型的字段,并假设HotSpot当前遍历到第一个long字段,则该long字段的偏移量就是next_nonstatic_double_offset.计算完第一个long字段的偏移量之后,HotSpot执行next_nonstatic_double_offset+=BytesPerLong,将next_nonstatic_double_offset地址往后偏移8字节,这样当HotSpot遍历到Java类中第2个long类型的字段时,通过real_offset=next_nonstatic_double_offset就能直接计算出第二个long字段的偏移量了
case STATIC_DOUBLE:
real_offset = next_static_double_offset;
next_static_double_offset += BytesPerLong;
break;
real_offset就是当前字段的真实偏移量。假设Java类中包含2个long类型的字段,并假设HotSpot当前遍历到第一个long字段,则该long字段的偏移量就是next_nonstatic_double_offset.计算完第一个long字段的偏移量之后,HotSpot执行next_nonstatic_double_offset+=BytesPerLong,将next_nonstatic_double_offset地址往后偏移8字节,这样当HotSpot遍历到Java类中第2个long类型的字段时,通过real_offset=next_nonstatic_double_offset就能直接计算出第二个long字段的偏移量了
4.gap填充。
也许有细心的小伙伴可能发现上面那段代码中有部分逻辑比较不同寻常,那就是case NONSTATIC_DOUBLE分支里的逻辑与其他case分支的逻辑都不一样,其他case分支的逻辑明显比case NON_STATIC_DOUBLE这个条件分支的逻辑复杂一些。这是为什么呢?
要解决这个问题,不得不再去关注JVM内部的oopt对象头的事儿。前面也将到过,每一个Java类在堆内存中,都是从oop头开始的,而这个oop头所占的内存空间与JVM是否开启了指针压缩策略有关,在64位平台上,如果开启了指针压缩策略,则对象头金辉占用12个内存单元。如果没有开启,则会占用16个内存单元。如果oop对象头只占用了12字节,那么其后面第一个long类型的字段的起始偏移量按照常理应该是12,但这不符合long类型数据的自然对齐原则(12不能被8整除),所以只能在oop对象头后面连续填充4个空字节,然后从第16个偏移量位置处第一个long类型的字段分配内存。这样一来就造成了从ooop对象头到第一个long类型字段之间浪费了4字节的内存空间。虽然4字节看起来微不足道,但是这对于一个高性能的虚拟机而言是绝对不能接受的。对于内存吝啬到前无古人的地步的JVM来说,即使这么一点内存也必须要充分利用起来,而利用的方式便是,将int、short、byte等宽度小于等于4字节的字段往这个内存间隙里面插入,虽然这回破坏HotSpot对不同类型重排的顺序策略:我们来看HotSpot的源码。事实上,如果一个Java类显式继承了父类,那么如果父类字段的末尾不是按8字节对齐的,则父类字段末尾与子类第一个long/double字段之间也会形成补白空袭,则这段空袭也会参与上述逻辑计算,被安插int、short、byte等宽度比较小的字段所谓
也许有细心的小伙伴可能发现上面那段代码中有部分逻辑比较不同寻常,那就是case NONSTATIC_DOUBLE分支里的逻辑与其他case分支的逻辑都不一样,其他case分支的逻辑明显比case NON_STATIC_DOUBLE这个条件分支的逻辑复杂一些。这是为什么呢?
要解决这个问题,不得不再去关注JVM内部的oopt对象头的事儿。前面也将到过,每一个Java类在堆内存中,都是从oop头开始的,而这个oop头所占的内存空间与JVM是否开启了指针压缩策略有关,在64位平台上,如果开启了指针压缩策略,则对象头金辉占用12个内存单元。如果没有开启,则会占用16个内存单元。如果oop对象头只占用了12字节,那么其后面第一个long类型的字段的起始偏移量按照常理应该是12,但这不符合long类型数据的自然对齐原则(12不能被8整除),所以只能在oop对象头后面连续填充4个空字节,然后从第16个偏移量位置处第一个long类型的字段分配内存。这样一来就造成了从ooop对象头到第一个long类型字段之间浪费了4字节的内存空间。虽然4字节看起来微不足道,但是这对于一个高性能的虚拟机而言是绝对不能接受的。对于内存吝啬到前无古人的地步的JVM来说,即使这么一点内存也必须要充分利用起来,而利用的方式便是,将int、short、byte等宽度小于等于4字节的字段往这个内存间隙里面插入,虽然这回破坏HotSpot对不同类型重排的顺序策略:我们来看HotSpot的源码。事实上,如果一个Java类显式继承了父类,那么如果父类字段的末尾不是按8字节对齐的,则父类字段末尾与子类第一个long/double字段之间也会形成补白空袭,则这段空袭也会参与上述逻辑计算,被安插int、short、byte等宽度比较小的字段所谓
Java语言与其他语言处理内存对齐的差异。
纵观HotSpot对Java类字段的堆内存分配算法,可以看出HotSpot对内存的利用几乎已经到了极致,虽然存在模仿,但几乎已经无法再被超越了。别的就不说了,就拿经典的gcc编译器来说,最多也只做到了自动将数据类型进行自然对齐,基本不需要开发者再去为这件事而烦恼,但是也仅此而已。例如前面所列举过的结构体的例子:
struct A {
int a;
char b;
short c;
};
struct A结构体由于内存对齐的需要,最终需要分配8字节的内存空间。如果将A结构体内部的成员项的顺序变动一下,变成如下:
struct B {
char b;
int a;
short c;
};
则内存占用空间立马变大。这种情况在Java中是不存在的。与struct A所对等的Java类如下:
class A {
int a;
byte b;
short c;
}
除去Java类在JVM内部所对应的oop的对象头部分的内存空间,最终类A字段在堆内存中也会占用8字节。对于Java类,不管其内部字段的声明顺序如何变化,都不会影响其内存占用,这主要得益于JVM的字段重排算法。
纵观HotSpot对Java类字段的堆内存分配算法,可以看出HotSpot对内存的利用几乎已经到了极致,虽然存在模仿,但几乎已经无法再被超越了。别的就不说了,就拿经典的gcc编译器来说,最多也只做到了自动将数据类型进行自然对齐,基本不需要开发者再去为这件事而烦恼,但是也仅此而已。例如前面所列举过的结构体的例子:
struct A {
int a;
char b;
short c;
};
struct A结构体由于内存对齐的需要,最终需要分配8字节的内存空间。如果将A结构体内部的成员项的顺序变动一下,变成如下:
struct B {
char b;
int a;
short c;
};
则内存占用空间立马变大。这种情况在Java中是不存在的。与struct A所对等的Java类如下:
class A {
int a;
byte b;
short c;
}
除去Java类在JVM内部所对应的oop的对象头部分的内存空间,最终类A字段在堆内存中也会占用8字节。对于Java类,不管其内部字段的声明顺序如何变化,都不会影响其内存占用,这主要得益于JVM的字段重排算法。
其实经典的编译器诸如gcc等,也是可以像HotSpot那样,对局部变量进行字段重排的,这在算法层面完全是可行的,只是囿于当时的算法技术而未如此优化,不过事物发展的规律总是长江后浪推前浪,短暂的几十年的历史缝隙,对算法而言可谓是悠久长远的历史场合,再过几十年,说不定会有新的算法横空出世,睥睨天下。不过不管未来怎样,至少从目前来看,JVM的这种内存分配算法几乎是已经到了无可再优化的境界。
Java字段内存分配总结。
前面浓墨重彩地详细剖析了HotSpot对Java类字段地内存分配原理,并举了若干例子。这部分内容实在是重要之极,也是理解JVM内存模型地最基础、最核心地异步。以前也对JVM地内存模型存在诸多误会,以为全是面向对象,以为面向对象的东西必然会浪费非常多的内存空间。直到将HotSpot的类字段分配模型剖析完,才发现压根儿不是那么回事,JVM对内存空间利用率的要求是非常苛刻的,如果去掉占用12字节或16字节的对象头,其内存使用效率一定超过绝大多数的编程语言了。这里再对JVM的类字段分配策略进行梳理归纳。虽然前面所分析的源码皆基于HotSpot,但是HotSpot所努力实现的目标,事实上正是JVM的规范要求。
# 规则1: 任何对象都是以8字节为粒度进行对齐的
# 规则2: 类属性按照如下优先级进行排列: 长整型和双精度型;整型和浮点型;字符和短整型;字节类型和布尔类型;最终是引用类型。这些属性都按照各自类型宽度进行对齐
# 规则3:不同类继承关系中的成员不能混合排列。首先按照规则2处理父类中的成员,接着才是子类的成员
# 规则4:当父类最后一个属性和子类第一个属性之间间隔不足4字节时,必须扩展到4字节的基本单位
# 规则5:如果子类第一个成员是一个双精度或长整型,并且父类没有用完8字节(没有显式的父类,并且JVM开启了指针压缩策略,oop对象头只占用12字节时)JVM会破坏规则2,按整型(int)、短整型(short)、字节型(byte)、引用类型(reference)的顺序向未填满的空间填充
对于规则1,没啥好说的,与C语言中 对结构体整体对齐的约束一样,JVM也需要使Java类在JVM内部的堆内存映像从整体上做到对齐,这并不是为了方便Java类自己,而是为了方便其后续的其他类的内存分配。。
对于规则2,是内存分配时最基本的要求,自然对齐,否则JVM运行在某些不支持非对齐内存访问的CPU硬件上时会因此而崩溃。。
对于规则3,其实于内存的利用率而言,并不是一个必须要遵守的原则,甚至反而因为遵守了这个原则而导致内存利用率降低。这个原则存在的目的主要是为了内存分析时方便,尤其是当一个类的继承体系比较深的时候,如果若干父类与子类的字段都混合组合在一起,那内存分析人员的情绪一定是崩溃的
对于规则4,也是为了让父类属性集合上从整体上做到对齐,从而方便其后续子类字段在处理对齐时能够尽可能地简单。HotSpot从源码级别保证了规则4的旅行:
first_nonstatic_field_offset = instanceOopDesc::base_offset_in_bytes() + nonstatic_field_size *heapOopSize;
这行代码前面讲过,意在计算Java类字段的整体起始偏移量。这个偏移量也要算上oop对相投和父类字段,而父类字段是按照heapOopSize对齐的,heapOopSize的定义如下
int heapOopSize = 0;
// Set the size of basic types here (after argument parsing but before
// stub generation).
if (UseCompressedOops) {
// Size info for oops within java objects is fixed
heapOopSize = jintSize;
LogBytesPerHeapOop = LogBytesPerInt;
LogBitsPerHeapOop = LogBitsPerInt;
BytesPerHeapOop = BytesPerInt;
BitsPerHeapOop = BitsPerInt;
} else {
heapOopSize = oopSize;
LogBytesPerHeapOop = LogBytesPerWord;
LogBitsPerHeapOop = LogBitsPerWord;
BytesPerHeapOop = BytesPerWord;
BitsPerHeapOop = BitsPerWord;
}
前面浓墨重彩地详细剖析了HotSpot对Java类字段地内存分配原理,并举了若干例子。这部分内容实在是重要之极,也是理解JVM内存模型地最基础、最核心地异步。以前也对JVM地内存模型存在诸多误会,以为全是面向对象,以为面向对象的东西必然会浪费非常多的内存空间。直到将HotSpot的类字段分配模型剖析完,才发现压根儿不是那么回事,JVM对内存空间利用率的要求是非常苛刻的,如果去掉占用12字节或16字节的对象头,其内存使用效率一定超过绝大多数的编程语言了。这里再对JVM的类字段分配策略进行梳理归纳。虽然前面所分析的源码皆基于HotSpot,但是HotSpot所努力实现的目标,事实上正是JVM的规范要求。
# 规则1: 任何对象都是以8字节为粒度进行对齐的
# 规则2: 类属性按照如下优先级进行排列: 长整型和双精度型;整型和浮点型;字符和短整型;字节类型和布尔类型;最终是引用类型。这些属性都按照各自类型宽度进行对齐
# 规则3:不同类继承关系中的成员不能混合排列。首先按照规则2处理父类中的成员,接着才是子类的成员
# 规则4:当父类最后一个属性和子类第一个属性之间间隔不足4字节时,必须扩展到4字节的基本单位
# 规则5:如果子类第一个成员是一个双精度或长整型,并且父类没有用完8字节(没有显式的父类,并且JVM开启了指针压缩策略,oop对象头只占用12字节时)JVM会破坏规则2,按整型(int)、短整型(short)、字节型(byte)、引用类型(reference)的顺序向未填满的空间填充
对于规则1,没啥好说的,与C语言中 对结构体整体对齐的约束一样,JVM也需要使Java类在JVM内部的堆内存映像从整体上做到对齐,这并不是为了方便Java类自己,而是为了方便其后续的其他类的内存分配。。
对于规则2,是内存分配时最基本的要求,自然对齐,否则JVM运行在某些不支持非对齐内存访问的CPU硬件上时会因此而崩溃。。
对于规则3,其实于内存的利用率而言,并不是一个必须要遵守的原则,甚至反而因为遵守了这个原则而导致内存利用率降低。这个原则存在的目的主要是为了内存分析时方便,尤其是当一个类的继承体系比较深的时候,如果若干父类与子类的字段都混合组合在一起,那内存分析人员的情绪一定是崩溃的
对于规则4,也是为了让父类属性集合上从整体上做到对齐,从而方便其后续子类字段在处理对齐时能够尽可能地简单。HotSpot从源码级别保证了规则4的旅行:
first_nonstatic_field_offset = instanceOopDesc::base_offset_in_bytes() + nonstatic_field_size *heapOopSize;
这行代码前面讲过,意在计算Java类字段的整体起始偏移量。这个偏移量也要算上oop对相投和父类字段,而父类字段是按照heapOopSize对齐的,heapOopSize的定义如下
int heapOopSize = 0;
// Set the size of basic types here (after argument parsing but before
// stub generation).
if (UseCompressedOops) {
// Size info for oops within java objects is fixed
heapOopSize = jintSize;
LogBytesPerHeapOop = LogBytesPerInt;
LogBitsPerHeapOop = LogBitsPerInt;
BytesPerHeapOop = BytesPerInt;
BitsPerHeapOop = BitsPerInt;
} else {
heapOopSize = oopSize;
LogBytesPerHeapOop = LogBytesPerWord;
LogBitsPerHeapOop = LogBitsPerWord;
BytesPerHeapOop = BytesPerWord;
BitsPerHeapOop = BitsPerWord;
}
如果开启了额指针压缩策略,则其大小是jintSize,而jintSize的值是4,如果没有开启指针压缩策略,则其大小是oopSIze,oopSize的值为8.父类的字段大小是nonstatic_field_size,该值的算法如图所示。在这段逻辑中,先对Java类中的byte类型字段进行补白,对齐至4字节或8字节。由于在JVM分配内存时,排在最末尾的是byte类型,而前面的long、int、short这几种类型的字段之间一定不会存在任何补白(因为long末尾后面的内存位置一定能能够int类型字段是自然对齐的,而int末尾后面的内存位置也一定能够保证short类型字段是自然对齐的),所以只要确保最末尾的byte类型的字段能够按照4字节或者8字节对齐,则整个Java所对应的堆内存也一定是按照4字节或者8字节对齐的。所以上面这段逻辑能够确保next_nonstatic_type_offset最终一定也是按照4字节或8字节对齐。这直接影响到最终所计算出来的父类的nonstatic_field_size值。在这段代码的最后一行表达式中,为了让问题简化,假设父类没有父类,所以最后一行表达式中的nonstatic_field_size一开始是0,此时最后一行表达式退化成下面这行表达式:
nonstatic_field_size = ((next_nonstatic_type_offset - first_nonstatic_field_offset)/heapOopSize);
若JVM没有开启指针压缩选项,则heapOopSize的值为8,而next_nonstatic_type_offset经过补白,已经是按照8字节对齐的。没有开启指针压缩选项,则oop对象头占用16字节,first_nonstatic_field_offset的值就是16,所以这行表达式,所有的3个变量值都是8的整数倍,所以最终计算出来的也必定是8的整数倍。若JVM开启了指针压缩选项,则heapOopSize的值为4,next_nonstatic_type_ofset经过补白,也按照4字节对齐。开启指针压缩选项后,oop对象占用12字节,则first_nonstatic_field_offset的值为12,所以这行表达式的3个变量都是4的整数倍,则最终所计算出来的结果也必定是4的整数倍。所以,,无论JVM是否开启指针压缩选项,则父类字段所需要占用的内存空间必定是4的整数倍。这句话换个说法,就是规则4
nonstatic_field_size = ((next_nonstatic_type_offset - first_nonstatic_field_offset)/heapOopSize);
若JVM没有开启指针压缩选项,则heapOopSize的值为8,而next_nonstatic_type_offset经过补白,已经是按照8字节对齐的。没有开启指针压缩选项,则oop对象头占用16字节,first_nonstatic_field_offset的值就是16,所以这行表达式,所有的3个变量值都是8的整数倍,所以最终计算出来的也必定是8的整数倍。若JVM开启了指针压缩选项,则heapOopSize的值为4,next_nonstatic_type_ofset经过补白,也按照4字节对齐。开启指针压缩选项后,oop对象占用12字节,则first_nonstatic_field_offset的值为12,所以这行表达式的3个变量都是4的整数倍,则最终所计算出来的结果也必定是4的整数倍。所以,,无论JVM是否开启指针压缩选项,则父类字段所需要占用的内存空间必定是4的整数倍。这句话换个说法,就是规则4
下面这个例子对于本规则有极强的说服力。
对于规则5,起始前面已经详细分析过HotSpot的源码实现。当开启了指针压缩选项,oop对象头只需要12字节的内存空间,如果Java类中定义了long或double类型的字段,则oop对象头与第一个long/double字段之间会有4字节的补白空间。JVM为了充分使用内存,坚决不浪费宝贵的内存空间,就按照整型(int)、短整型(short)、字节型(byte)、引用类型(reference)的顺序往这段空隙中填充。下面这个Java类可以验证这一规则
开启指针压缩选项,假设HotSpot没有填充oop对象头后面的空隙gap,则此时Father类的内存布局如图所示.最终Father类在内存中占用32字节。而HotSpot填充gap,内存布局如图所示。此时Father类在内存中仅占用24字节。
而当有父类参与时,则需要满足规则4与规则5.下面这个例子正好能够验证这种情况:父类为了满足规则4,做到按4字节对齐,所以父类的末尾补白了2个字节,这段空间硬生生地被浪费掉了。
如果在父类中再定义一个占2字节的变量,则JVM会用这个字段填充被补白的2字节空间。如下面的程序:
而即使这样,父类字段的末尾与子类的第一个long类型字段之间仍然会有4字节的补白,由于子类中包含了1个byte类型的字段,所以JVM违反了规则2,将这个字段插在了这个间隙里,现在还剩下3个补白字节。所以如果子类中继续声明了3个byte类型的字段,则JVM会将这3个字段插入到剩下的3个补白字节中,,从而使得Son类的堆内存大小依然保持不变。
而即使这样,父类字段的末尾与子类的第一个long类型字段之间仍然会有4字节的补白,由于子类中包含了1个byte类型的字段,所以JVM违反了规则2,将这个字段插在了这个间隙里,现在还剩下3个补白字节。所以如果子类中继续声明了3个byte类型的字段,则JVM会将这3个字段插入到剩下的3个补白字节中,,从而使得Son类的堆内存大小依然保持不变。
从源码看字段继承。
在Java类的继承关系与细节上,很多人可能堆有些概念存在疑问或误解。下面就从源码的角度逐一分析
在Java类的继承关系与细节上,很多人可能堆有些概念存在疑问或误解。下面就从源码的角度逐一分析
字段重排与补白。
分析问题总是免不了做实验,阅读源码使人明白细节,而实验则使人能够从结果直接验证细节。为了分析Java类的一些继承问题,需要通过做实验进行验证,但是在做这个实验之前需要做个实验来先验证字段重排与补白。之所以要验证这个课题,是因为在继承的实验中,基本通过观察父类与子类所占用的内存大小来验证字段是否被继承,而父类与子类所占用的内存大小并不严格等价于其所声明的各个成员本身所占用内存的综合,这还受到内存对齐补白的机制制约。先从一个最简单的实验用例开始,这是一个空的Java类:
public class Father {
}
这个空的Java类到底占用多大内存空间呢?由于Java编程语言并没有提供类似于C/C++语言的sizeof这种可以获取变量或结构体或类型大小的关键字,但是在JDK1.8中,可以使用如下工具类进行计算
import jdk.nashorn.internal.ir.debug.ObjectSizeCalculator;
public class Father {
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
运行main()函数后打印如下结果(在64位平台上,后面的例子都基于64位)
father's size :16
这个结果可能出乎很多人的预料。但是这个结果是十分正确的。前面讲到HotSpot内部会使用oop来表示每一个Java类的实例,Java类实例在堆内存中的布局如图所示
分析问题总是免不了做实验,阅读源码使人明白细节,而实验则使人能够从结果直接验证细节。为了分析Java类的一些继承问题,需要通过做实验进行验证,但是在做这个实验之前需要做个实验来先验证字段重排与补白。之所以要验证这个课题,是因为在继承的实验中,基本通过观察父类与子类所占用的内存大小来验证字段是否被继承,而父类与子类所占用的内存大小并不严格等价于其所声明的各个成员本身所占用内存的综合,这还受到内存对齐补白的机制制约。先从一个最简单的实验用例开始,这是一个空的Java类:
public class Father {
}
这个空的Java类到底占用多大内存空间呢?由于Java编程语言并没有提供类似于C/C++语言的sizeof这种可以获取变量或结构体或类型大小的关键字,但是在JDK1.8中,可以使用如下工具类进行计算
import jdk.nashorn.internal.ir.debug.ObjectSizeCalculator;
public class Father {
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
运行main()函数后打印如下结果(在64位平台上,后面的例子都基于64位)
father's size :16
这个结果可能出乎很多人的预料。但是这个结果是十分正确的。前面讲到HotSpot内部会使用oop来表示每一个Java类的实例,Java类实例在堆内存中的布局如图所示
为了确认是否开启压缩选项,使用如下命令查看JVM启动的参数:
-XX:+PrintCommandLineFlags
-XX:InitialHeapSize=265035648
-XX:MaxHeapSize=4240570368
-XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:-UseLargePagesIndividualAllocation
-XX:+UseParallelGC
可以看到,JVM默认开启了压缩参数-XX:+UseCompressedOops.既然使用了压缩算法,则对象头之应该占用12字节的内存空间,为何Father类却占用了16字节呢?这主要是由对齐引起的。JVM在64位平台上为Java类对象实例分配内存时,会基于8字节的整数倍进行对齐。如果内存空间不足8字节的整数倍,则会将其补白到8字节的整数倍。这就是Father这个空类占用16字节内存的原因。其内存布局如图所示
-XX:+PrintCommandLineFlags
-XX:InitialHeapSize=265035648
-XX:MaxHeapSize=4240570368
-XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:-UseLargePagesIndividualAllocation
-XX:+UseParallelGC
可以看到,JVM默认开启了压缩参数-XX:+UseCompressedOops.既然使用了压缩算法,则对象头之应该占用12字节的内存空间,为何Father类却占用了16字节呢?这主要是由对齐引起的。JVM在64位平台上为Java类对象实例分配内存时,会基于8字节的整数倍进行对齐。如果内存空间不足8字节的整数倍,则会将其补白到8字节的整数倍。这就是Father这个空类占用16字节内存的原因。其内存布局如图所示
为了验证这一点,对Father类进行改造。先增加一个字节类型的变量:
public class Father {
private byte b1;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在增加了一个字节类型的变量b1,在JVM规范中,一个字节类型的变量仅占1字节空间大小。由于Father类没有显式继承父类,因此在堆中,其对象头之后应该紧跟着b1变量。当JVM最终为对象头和b1变量分配内存空间时,由于对象头加上b1所占用的内存空间综合只有13字节,因此JVM会将其补白到16字节,所以Father类最终应该仍然占用16字节的内存空间。运行main()函数,得到结果的确是16
father's size :16.Father类的对内存布局如图所示.
public class Father {
private byte b1;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在增加了一个字节类型的变量b1,在JVM规范中,一个字节类型的变量仅占1字节空间大小。由于Father类没有显式继承父类,因此在堆中,其对象头之后应该紧跟着b1变量。当JVM最终为对象头和b1变量分配内存空间时,由于对象头加上b1所占用的内存空间综合只有13字节,因此JVM会将其补白到16字节,所以Father类最终应该仍然占用16字节的内存空间。运行main()函数,得到结果的确是16
father's size :16.Father类的对内存布局如图所示.
为了继续验证,在Father类中继续定义了3个byte类型的变量,如下:
public class Father {
private byte b1;
private byte b2;
private byte b3;
private byte b4;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
运行main()函数,得到如下结果:
father's size :16
由于现在对象头加上4个byte类型的变量的内存综合正好等于16字节,正好是8的整数倍,因此JVM不会对齐进行补白。此时的Father类的内存布局如图所示。
public class Father {
private byte b1;
private byte b2;
private byte b3;
private byte b4;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
运行main()函数,得到如下结果:
father's size :16
由于现在对象头加上4个byte类型的变量的内存综合正好等于16字节,正好是8的整数倍,因此JVM不会对齐进行补白。此时的Father类的内存布局如图所示。
接着见证奇迹的时候到了,再增加一个byte类型的字节,如下:
public class Father {
private byte b1;
private byte b2;
private byte b3;
private byte b4;
private byte b5;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在执行main()函数,得到结果如下:
father's size :24
此时的Father类的堆内存布局如图所示
public class Father {
private byte b1;
private byte b2;
private byte b3;
private byte b4;
private byte b5;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在执行main()函数,得到结果如下:
father's size :24
此时的Father类的堆内存布局如图所示
这几个例子已经能够验证Java类在内存分配时的对齐机制了。不过byte类型的变量不够通用,因此再对Father类进行微小的变动,相信大家能够准确计算出下面这个Java类所占用的内存空间大小:
public class Father {
private int i1;
private int i2;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在在Father类里面定义了2个int类型的基本类型变量,JVM标准规定,一个int类型的变量占用4字节内存大小。因此Father类需要的内存空间大小为:
12字节(对象头大小)+4字节*2(2个int类型变量)=20字节
由于JVM的内存补白机制,因此最终为其分配24字节的内存空间,后面的4字节通过补白进行对齐。其内存布局如图所示.
运行main()函数,的确打印出24.关于JVM内存对齐的机制就讲到这里,这块理清楚了,有助于接下来要讲的继承机制
public class Father {
private int i1;
private int i2;
public static void main(String[] args) {
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
}
}
现在在Father类里面定义了2个int类型的基本类型变量,JVM标准规定,一个int类型的变量占用4字节内存大小。因此Father类需要的内存空间大小为:
12字节(对象头大小)+4字节*2(2个int类型变量)=20字节
由于JVM的内存补白机制,因此最终为其分配24字节的内存空间,后面的4字节通过补白进行对齐。其内存布局如图所示.
运行main()函数,的确打印出24.关于JVM内存对齐的机制就讲到这里,这块理清楚了,有助于接下来要讲的继承机制
private字段可被继承吗
有一种说法是,凡是父类中被定义成private的字段,都是"老子"的私有财产,即便是"儿子",也及成不了。可是JVM的世界真的是如此无情吗,看下面这个例子:
public class Father {
}
class Son extends Father{
public static void main(String[] args) {
Son son = new Son();
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
System.out.println("son's size :" + ObjectSizeCalculator.getObjectSize(son));
}
}
运行main()函数,打印如下结果:
father's size :16
son's size :16
本例中的父类与子类都是空类,因此这两个对象实例在内存中起始都是只有对象头,只需要12字节的内存空间,但是为了对齐,最终JVM为其分配了16字节的内存大小。此时父类与子类的内存布局如图所示。虽然通过上面的例子可以证明,Java父类中的private字段的确被子类继承了,证明在JVM的世界里其实也是有爱的。可是很多人对一件事很是耿耿于怀,因为从编程的角度看,子类并不能访问父类中的private字段。
有一种说法是,凡是父类中被定义成private的字段,都是"老子"的私有财产,即便是"儿子",也及成不了。可是JVM的世界真的是如此无情吗,看下面这个例子:
public class Father {
}
class Son extends Father{
public static void main(String[] args) {
Son son = new Son();
Father father = new Father();
System.out.println("father's size :" + ObjectSizeCalculator.getObjectSize(father));
System.out.println("son's size :" + ObjectSizeCalculator.getObjectSize(son));
}
}
运行main()函数,打印如下结果:
father's size :16
son's size :16
本例中的父类与子类都是空类,因此这两个对象实例在内存中起始都是只有对象头,只需要12字节的内存空间,但是为了对齐,最终JVM为其分配了16字节的内存大小。此时父类与子类的内存布局如图所示。虽然通过上面的例子可以证明,Java父类中的private字段的确被子类继承了,证明在JVM的世界里其实也是有爱的。可是很多人对一件事很是耿耿于怀,因为从编程的角度看,子类并不能访问父类中的private字段。
例如下面的例子
public class Father {
private int b1;
private int b2;
}
class Son extends Father {
public Son() {
// 编译报错
this.b1;
super.b1;
}
}
在本例中,父类中定义了变量b1,并使用private关键字进行修饰。在子类Son的构造函数中,无论使用this.b1还是super.b1,均会报编译错误,前面不是证明了"老子"的私有财产是可以被儿子继承的吗?可是儿子为何却偏偏使用不了老子的私有财产呢?会不会是前面的证明有问题。
public class Father {
private int b1;
private int b2;
}
class Son extends Father {
public Son() {
// 编译报错
this.b1;
super.b1;
}
}
在本例中,父类中定义了变量b1,并使用private关键字进行修饰。在子类Son的构造函数中,无论使用this.b1还是super.b1,均会报编译错误,前面不是证明了"老子"的私有财产是可以被儿子继承的吗?可是儿子为何却偏偏使用不了老子的私有财产呢?会不会是前面的证明有问题。
为了弄清楚这个问题,下面对示例进行改造:
public class Father {
private int b1;
private int b2;
public Father() {
this.b1 = 1;
this.b2 = 2;
}
public int getB2() {
return b2;
}
public void setB2(int b2) {
this.b2 = b2;
}
public int getB1() {
return b1;
}
public void setB1(int b1) {
this.b1 = b1;
}
}
class Son extends Father {
public Son() {
System.out.println("b1 == " + this.getB1());
System.out.println("b2 == " + this.getB2());
}
public static void main(String[] args) {
Son son = new Son();
}
}
public class Father {
private int b1;
private int b2;
public Father() {
this.b1 = 1;
this.b2 = 2;
}
public int getB2() {
return b2;
}
public void setB2(int b2) {
this.b2 = b2;
}
public int getB1() {
return b1;
}
public void setB1(int b1) {
this.b1 = b1;
}
}
class Son extends Father {
public Son() {
System.out.println("b1 == " + this.getB1());
System.out.println("b2 == " + this.getB2());
}
public static void main(String[] args) {
Son son = new Son();
}
}
运行main()函数,打印如下结果:
b1 == 1
b2 == 2
通过本例可以看出,虽然子类不能字节访问父类的私有成员变量,但是却可以通过调用父类为私有成员变量所提供的公开接口访问父类的私有字段。
在main()函数中执行Son的构造函数时,首先要执行父类的构造函数,这是众所周知的。当父类的构造函数执行完成之后,父类的2个字段便有了值,所以在子类中能够将父类的构造函数,其实是在初始化子类中所继承的父类部分的字段。最终所分配的内存模型如图所示
所以,关于private关键字的继承问题,应该可以使用如下2句话加以概括:
# 老子的私有财产(特指私有成员变量)的确是被儿子继承的
# 儿子虽然能够继承老子的私有财产,但是却没有权利直接支配,除非老子给儿子开放了解耦,否则儿子不能动老子的私有财产
b1 == 1
b2 == 2
通过本例可以看出,虽然子类不能字节访问父类的私有成员变量,但是却可以通过调用父类为私有成员变量所提供的公开接口访问父类的私有字段。
在main()函数中执行Son的构造函数时,首先要执行父类的构造函数,这是众所周知的。当父类的构造函数执行完成之后,父类的2个字段便有了值,所以在子类中能够将父类的构造函数,其实是在初始化子类中所继承的父类部分的字段。最终所分配的内存模型如图所示
所以,关于private关键字的继承问题,应该可以使用如下2句话加以概括:
# 老子的私有财产(特指私有成员变量)的确是被儿子继承的
# 儿子虽然能够继承老子的私有财产,但是却没有权利直接支配,除非老子给儿子开放了解耦,否则儿子不能动老子的私有财产
使用HSDB验证字段分配与继承。
使用HSDB验证Java字段的继承:父类private字段是否继承,父类final字段是否继承,父类private/public/protected字段是否被覆盖(引用类型与基本类型)。前面讲了很多关于一个Java类字段大小、字段排序以及在继承的情况下字段覆盖的问题,并且使用工具来测试了的大小,从侧面验证了相关理论。但是JDK为大家提供了一个神器,其能够直接观察处于运行时的一个Java类真实的内存布局以及父类字段的继承与覆盖。下面就与各位道友一起,通过HSDB来直接观察Java类在运行期的内存布局。首先要有测试程序,示例如下,先定义一个父类MyClass:
public abstract class MyClass {
private Integer i = 1;
protected long plong = 12L;
protected final short s = 6;
public char c = 'A';
}
接着定义一个子类继承自MyClass
public class Test4 extends MyClass {
private long l;
private Integer i = 3;
private long plong = 18L;
public char c = 'B';
public void add(int a, int b) {
Test4 test = this;
int z = a + b;
int x = 3;
}
public static void main(String[] args) {
Test4 test = new Test4();
test.add(2, 3);
}
}
验证的过程主要使用HSDB工具,该工具为JDK自带,是一套视窗系统,能够在视窗里面通过界面或者命令行查看运行过程的若干数据,包括堆栈、堆、perm(JDK8已经没有perm区的概念)、实例数据、常量池,以及与实例所对应的JVM内部类instanceKlass等。工具的使用也很简单。
使用HSDB验证Java字段的继承:父类private字段是否继承,父类final字段是否继承,父类private/public/protected字段是否被覆盖(引用类型与基本类型)。前面讲了很多关于一个Java类字段大小、字段排序以及在继承的情况下字段覆盖的问题,并且使用工具来测试了的大小,从侧面验证了相关理论。但是JDK为大家提供了一个神器,其能够直接观察处于运行时的一个Java类真实的内存布局以及父类字段的继承与覆盖。下面就与各位道友一起,通过HSDB来直接观察Java类在运行期的内存布局。首先要有测试程序,示例如下,先定义一个父类MyClass:
public abstract class MyClass {
private Integer i = 1;
protected long plong = 12L;
protected final short s = 6;
public char c = 'A';
}
接着定义一个子类继承自MyClass
public class Test4 extends MyClass {
private long l;
private Integer i = 3;
private long plong = 18L;
public char c = 'B';
public void add(int a, int b) {
Test4 test = this;
int z = a + b;
int x = 3;
}
public static void main(String[] args) {
Test4 test = new Test4();
test.add(2, 3);
}
}
验证的过程主要使用HSDB工具,该工具为JDK自带,是一套视窗系统,能够在视窗里面通过界面或者命令行查看运行过程的若干数据,包括堆栈、堆、perm(JDK8已经没有perm区的概念)、实例数据、常量池,以及与实例所对应的JVM内部类instanceKlass等。工具的使用也很简单。
Test4类中其实仅定义了4个字段,但是其对应的JVM内部oop对象头却跟了8个字段,很显然,另外4个就是Test类的父类MyClass中的字段。父类与子类字段的划分如图所示。从图中可以看出来,父类的4个字段分别是i、plong、s和c,子类的4个字段分贝是l、i、plong和c.父类MyClass中的字段i类型是Integer,访问权限是private。而子类Test也定义了一个同名、同类型的字段i,从图中可以看出来,JVM内部在Test这个子类的实例对象中,同时为父类和子类的变量i都开辟了内存空间,由此可以得出下面的结论:
# 如果子类中定义了与父类同名的字段,并且当父类中的字段访问权限是private时,子类不会覆盖父类的字段,JVM会在内存中同时为父类和子类的该相同字段各分配一段内存空间。
同样,各位道友其实也早看出来了,父类MyClass中另外定义的2个变量——plong和c,子类Test中也都定义了一摸一样的字段名和类型,并且父类中这两个字段的访问权限分别是protected与public,但是子类依然没有覆盖。由此可以得出下面这条结论:
# 当子类中定义了与父类相同名称、相同类型的字段时,无论父类中字段的访问权限是什么,也无论父类字段是否被final修饰,子类都不会覆盖父类字段。
这一点与Java类继承概念中的方法继承大不相同,如果子类重写(override)了父类的方法,则子类会将父类的方法覆盖掉。虽然子类的字段不会覆盖父类字段,这意味着儿子会全盘接纳老子的全部财产,但是并不等于儿子就有权直接支配老子的财产。前面也举例分析过,对于父类中被定义为private的字段,这部分属于老子的私有财产,儿子不能直接访问(例如,儿子不能直接通过super.xxx来使用),除非老子开放了getXxx()这样的公共接口,否则儿子无论如何都访问不了老子的私有字段。这一点倒是与方法的继承如出一辙
# 如果子类中定义了与父类同名的字段,并且当父类中的字段访问权限是private时,子类不会覆盖父类的字段,JVM会在内存中同时为父类和子类的该相同字段各分配一段内存空间。
同样,各位道友其实也早看出来了,父类MyClass中另外定义的2个变量——plong和c,子类Test中也都定义了一摸一样的字段名和类型,并且父类中这两个字段的访问权限分别是protected与public,但是子类依然没有覆盖。由此可以得出下面这条结论:
# 当子类中定义了与父类相同名称、相同类型的字段时,无论父类中字段的访问权限是什么,也无论父类字段是否被final修饰,子类都不会覆盖父类字段。
这一点与Java类继承概念中的方法继承大不相同,如果子类重写(override)了父类的方法,则子类会将父类的方法覆盖掉。虽然子类的字段不会覆盖父类字段,这意味着儿子会全盘接纳老子的全部财产,但是并不等于儿子就有权直接支配老子的财产。前面也举例分析过,对于父类中被定义为private的字段,这部分属于老子的私有财产,儿子不能直接访问(例如,儿子不能直接通过super.xxx来使用),除非老子开放了getXxx()这样的公共接口,否则儿子无论如何都访问不了老子的私有字段。这一点倒是与方法的继承如出一辙
类成员变量的偏移量。
在上面使用inspect查看Test类实例所对应的oop的内存分布时显式了oop的内存大小为48字节,而当使用inspect窗口查看这个Test类的实例时,字段的显式顺序却与父类中各个字段所定义的顺序相同,如果按照这种顺序来分配内存,那么Test类的实例大小可能会大于48字节(考虑字段对齐的情况)。实际上,无论时inspect命令还是Inspect视窗,所显示的字段顺序都不是内存中真正分配的顺序。但是HSDB提供了其他窗口可以查看各个字段的位置,如图所示。显示出Test类中所定义的4个字段,并且显示出每个字段的偏移量。各个字段的偏移量如下(按照由小到大排列):
private long l; (offset = 32)
private java.lang.Integer i; (offset = 48)
private long plong; (offset = 40)
public char c; (offset = 28)
可以看出,偏移量最小的字段c,其偏移字段为28,而Test在JVM内部所对应的oop的对象头在64位机器上最大也仅占用16字节,很显然,这是因为在Test类自己的字段域与oop对象头之间还存在其他数据,而这些数据正是Test父类MyClass的字段域。
在上面使用inspect查看Test类实例所对应的oop的内存分布时显式了oop的内存大小为48字节,而当使用inspect窗口查看这个Test类的实例时,字段的显式顺序却与父类中各个字段所定义的顺序相同,如果按照这种顺序来分配内存,那么Test类的实例大小可能会大于48字节(考虑字段对齐的情况)。实际上,无论时inspect命令还是Inspect视窗,所显示的字段顺序都不是内存中真正分配的顺序。但是HSDB提供了其他窗口可以查看各个字段的位置,如图所示。显示出Test类中所定义的4个字段,并且显示出每个字段的偏移量。各个字段的偏移量如下(按照由小到大排列):
private long l; (offset = 32)
private java.lang.Integer i; (offset = 48)
private long plong; (offset = 40)
public char c; (offset = 28)
可以看出,偏移量最小的字段c,其偏移字段为28,而Test在JVM内部所对应的oop的对象头在64位机器上最大也仅占用16字节,很显然,这是因为在Test类自己的字段域与oop对象头之间还存在其他数据,而这些数据正是Test父类MyClass的字段域。
如图所示,显示了MyClass的全部信息。其中父类MyClass中的全部字段及各个字段的偏移量。各个字段的偏移量按照由小到大的顺序分别如下:
private java.lang.Integer i; (offset = 24)
protected long plong; (offset = 16)
protected final short s; (offset = 12)
public char c; (offset = 14)
偏移量最小的plong,其偏移量是16,这正好位于Test类在JVM内部的oop对象的对象头之后,而偏移量最大的字段是变量i,其偏移量是24。Test类在JVM内部的字段域被分配在其父类字段域之后,而父类MyClass的字段域的最后一个字段i的偏移量是24.,因此Test类的第一个字段c的偏移量是28.
private java.lang.Integer i; (offset = 24)
protected long plong; (offset = 16)
protected final short s; (offset = 12)
public char c; (offset = 14)
偏移量最小的plong,其偏移量是16,这正好位于Test类在JVM内部的oop对象的对象头之后,而偏移量最大的字段是变量i,其偏移量是24。Test类在JVM内部的字段域被分配在其父类字段域之后,而父类MyClass的字段域的最后一个字段i的偏移量是24.,因此Test类的第一个字段c的偏移量是28.
Test类所对应的oop的完整内存布局如图所示。该图显示出Test类所对应得instanceOop的完整内存布局,并且显示出各个字段所占的内存大小、起始偏移量。注意,JVM为了字段对齐,在instanceOop里面有两处做了对齐补白。各位道友可以对照如图所示内存布局,与HSDB中所显示的父类与子类中各个字段的偏移量做一比较,再次感受下JVM分配字段的机制
引用类型变量内存分配。
在上面的Test类的示例中,在Test类中定义了引用类型的类成员变量,即private Integer i = 3.同时,在main()主函数中实例化了Test类,得到实例test.变量i与main()中的局部变量test,一个是类的成员变量,一个是Java方法中的局部变量,但是两者都是引用类型。一起来围观引用类型在两种不同场景下的内存分配吧。
首先看类成员变量i。变量i既然是Test类的成员变量,其应该也在Test类实例所对应的instanceOop的字段域之中。。使用HSDB的inspect视窗查看main()主函数中所创建的Test类实例,如图所示。图中显示出Test类的成员变量I的位置和值,其值指向一个java/lang/Integer类型所对应的oop,该oop地址是i: 0x000000076bbfe138。再使用HSDB的Inspect视窗查看Integer的这个实例地址,
在上面的Test类的示例中,在Test类中定义了引用类型的类成员变量,即private Integer i = 3.同时,在main()主函数中实例化了Test类,得到实例test.变量i与main()中的局部变量test,一个是类的成员变量,一个是Java方法中的局部变量,但是两者都是引用类型。一起来围观引用类型在两种不同场景下的内存分配吧。
首先看类成员变量i。变量i既然是Test类的成员变量,其应该也在Test类实例所对应的instanceOop的字段域之中。。使用HSDB的inspect视窗查看main()主函数中所创建的Test类实例,如图所示。图中显示出Test类的成员变量I的位置和值,其值指向一个java/lang/Integer类型所对应的oop,该oop地址是i: 0x000000076bbfe138。再使用HSDB的Inspect视窗查看Integer的这个实例地址,
如图所示,由此可知,Test类中的成员变量i的实际数值被存储再java.lang.Integer类型实例再JVM内部所对应的instanceOop的字段域之中,而在Test类实例所对应的instanceOop中的成员变量i所存储的仅仅是一个指针引用。Test类的成员变量i的指针引用存储在Test类实例所对应的instanceOop中,i指针指向java.lang.Integer的实例instanceOop。无论是Test类的实例instanceOop还是java.lang.Integer类的实例instanceOop,都位于JVM的堆内存中。所以可以得出结论:
# 类的成员变量的引用(指针)和类成员变量的实例都分配在JVM的堆内存中,类的成员变量的引用分配在所在类的实例instanceOop的字段域之中。
接着看Java方法内引用类型的局部变量的内存分配位置。在main()主方法里实例化了一个Test类的对象test,test对象的内存地址是0x000000076bd30458,由于test属于main()方法的局部变量,因此在main()方法的栈帧里一定存在对这个地址的引用。
# 类的成员变量的引用(指针)和类成员变量的实例都分配在JVM的堆内存中,类的成员变量的引用分配在所在类的实例instanceOop的字段域之中。
接着看Java方法内引用类型的局部变量的内存分配位置。在main()主方法里实例化了一个Test类的对象test,test对象的内存地址是0x000000076bd30458,由于test属于main()方法的局部变量,因此在main()方法的栈帧里一定存在对这个地址的引用。
HSDB支持查看一个线程的整体堆栈内存,使用HSDB刚刚连接上Java进程时,会显示如图所示的Java Thread窗口,该窗口主要显示当前Java进程的所有线程,其中最下面可以看到main主线程,选中该线程,单机该窗口上方一排工具按钮中的第二个按钮,如图所示。
单击第二个工具按钮后,会弹出新的窗口显示该线程详细的堆栈内存,如图所示。图例显示出了当前Test进程的main主线程,由于Java的主线程最终会调用Java程序的main()主函数,因此通过该主线程就可以观察到Test类的main()主函数的栈帧。堆栈由下往上看,堆栈增长的方向也是自下而上(高地址往低地址方向)。图中分为3列,最左一侧显示堆栈内存地址,第二列显示这个内存地址上的数据,最右侧显示关键内存区域的属性。由于Test类运行于64位平台上,因此最左侧相邻行之间的地址相差8字节。由于在整个Test进程里面只有main()函数里面实例化了一次Test类,因此main()函数的堆栈必然会第一个引用Test类实例的地址,因此只需要在main()的栈帧里搜素哦test这个实例的地址。仔细观察这张main主线程的堆栈图,自下而上搜索Test类实例的地址,该地址是0x000000076bd30458.由于这个位置是第一个对Test实例对象地址的引用位置,因此该位置一定属于main()主函数的局部变量表区域。由于main()函数有一个args入参,因此该位置的下面那个位置的数据类型是ObjArray,这正是Java的数组类型在JVM内部的对象表现形式,由此更加确定方框所框住的位置一定属于main()函数的栈帧,而这一点也能够反向证明test这个局部变量的引用位于其所在方法的栈帧之中
其内存布局如图所示
Java栈帧
众所周知,如果Java程序运行出现异常,程序会打印出相应的异常堆栈,通过异常堆栈可以知道Java方法的调用链路。起始,调用链路是由一个个Java方法栈帧所组成,每一个Java方法都有一个栈帧,在这一点上,Java程序与C/C++程序并无任何区别。在这里让我们一起分析JVM内部实现Java方法栈帧的机制和技术实现。分析之前,让我们回顾下之前的逻辑,以JVM调用Java程序的main()主函数为例,讲解了CallStub例程的实现机制。在JVM内部,例程就是一个功能性函数。站在宏观的角度看,它就是一种预先设定好的逻辑。至于逻辑的具体实现方式,可以有很多种,从程序员的角度看,既可以用C语言实现,也可以用Delphi,或者其他编程语言实现。entry_point例程与CallStub例程一样,是一段使用C编写,最终生成一段对应的汇编的逻辑。
JVM调用Java程序的main()主函数,会经过CallStub例程,但是在CallStub例程里仅仅完成了Java主函数的参数传递,并没有开始执行Java程序的main()主函数的字节码指令,这是因为JVM在准备执行一个Java方法的字节码指令之前,必须先为该方法创建好对应的方法堆栈。对于Java主函数而言,在CallStub例程里会调用entry_point例程,在entry_point例程里完成主函数的栈帧创建,找到Java主函数所对应的第一个字节码指令并进入执行。在entry_point例程里会涉及JVM内部的method对象,即Java方法在JVM内部的表达形式。
JVM内部可以调用各种不同的方法类型,例如JNI本地函数,或者Java里的静态方法,或者Java类的成员方法。调用不同种类的方法,会触发不同的entry_point例程,所谓entry_point,顾名思义,就是"进入点",进入哪里?当然是目标方法啦。正因为JVM在调用目标方法之前,会先经过entry_point,并且JVM在执行目标方法的指令之前,需要先为其创建好相应的方法堆栈,因此JVM选择entry_point例程种完成方法堆栈创建。接下来会以Java主函数堆栈创建为例,讲解JVM创建Java栈帧的具体实现技术,。
JVM调用Java程序的main()主函数,会经过CallStub例程,但是在CallStub例程里仅仅完成了Java主函数的参数传递,并没有开始执行Java程序的main()主函数的字节码指令,这是因为JVM在准备执行一个Java方法的字节码指令之前,必须先为该方法创建好对应的方法堆栈。对于Java主函数而言,在CallStub例程里会调用entry_point例程,在entry_point例程里完成主函数的栈帧创建,找到Java主函数所对应的第一个字节码指令并进入执行。在entry_point例程里会涉及JVM内部的method对象,即Java方法在JVM内部的表达形式。
JVM内部可以调用各种不同的方法类型,例如JNI本地函数,或者Java里的静态方法,或者Java类的成员方法。调用不同种类的方法,会触发不同的entry_point例程,所谓entry_point,顾名思义,就是"进入点",进入哪里?当然是目标方法啦。正因为JVM在调用目标方法之前,会先经过entry_point,并且JVM在执行目标方法的指令之前,需要先为其创建好相应的方法堆栈,因此JVM选择entry_point例程种完成方法堆栈创建。接下来会以Java主函数堆栈创建为例,讲解JVM创建Java栈帧的具体实现技术,。
entry_point例程生成。
与CallStub例程一样,entry_point例程也是在JVM启动过程中被创建。事实上,JVM内部的所有例程都随着JVM的启动而创建。entry_point例程的总体创建链路如图所示。与CallStub的伟大征程一样,本链路起步于JVM的main()函数,一路走到init_globals()这个全局数据初始化模块,然后便与CallStub分道扬镳,进入TemplateInterpreter::initialize()流程。
与CallStub例程一样,entry_point例程也是在JVM启动过程中被创建。事实上,JVM内部的所有例程都随着JVM的启动而创建。entry_point例程的总体创建链路如图所示。与CallStub的伟大征程一样,本链路起步于JVM的main()函数,一路走到init_globals()这个全局数据初始化模块,然后便与CallStub分道扬镳,进入TemplateInterpreter::initialize()流程。
initialized()函数实现如下:在initialize()函数中执行了InterpreterGenerator g(_code)这行代码,创建解释器生成器的实例。在HotSpot内部,存在3种解释器,分别是字节码解释器、C++解释器和模板解释器。字节码解释器逐条解释翻译字节码指令,由于使用C/C++这种高级语言执行字节码指令逻辑,因此执行效率比较低下。模板解释器相比于字节码解释器的高级之处在于,模板解释器将字节码指令直接翻译成了对应的机器指令这种直接生成的机器指令相比于字节码解释器所对应的C/C++代码经编译后生成的机器指令,显然要高效很多,毕竟是人力纯手工精雕细琢。由于模板解释器更加高效,因此JVM默认的解释器就是模板解释器,当然可以通过启动参数指定其他解释器。对于C++解释器和模板解释器而言,都有一个对应的"解释器生成器",模板解释器对应的生成器是TemplateInterpreterGenerator.在TemplateInterpreter::initialize()函数种执行InterpreterGenerator g(_code)时,实际上是在实例化TemplateInterpreterGenerator对象。
TemplateInterpreterGenerator对象的实例化过程,伴随着其构造函数的调用,其构造函数定义如下(基于x86的32位Linux平台):
在这里调用了generate_all()函数。generate_)all()顾名思义,就是产生所有的。。所有啥呢?其实就是一款解释器运行时所需要的各种例程及入口。对于模板解释器而言,这些例程直接就是生成好的机器指令。generate_all()中就包含普通Java函数调用对应的entry_point的入口,且看generate_all()的定义:
在这里调用了generate_all()函数。generate_)all()顾名思义,就是产生所有的。。所有啥呢?其实就是一款解释器运行时所需要的各种例程及入口。对于模板解释器而言,这些例程直接就是生成好的机器指令。generate_all()中就包含普通Java函数调用对应的entry_point的入口,且看generate_all()的定义:
对于模板解释器而言本方法无疑具有里程碑式的意义,它将生成模板解释器所对应的各种模板例程的机器指令,并保存入口地址。如果一个启动的JVM代表一个高度进化的文明社会,那么这个方法一定代表着一个国家的重大基础设施建设工程,所有的高速公路、高速网络、地铁交通、机场与航线、港口与轮渡,等等都会在本阶段里完工。Java程序中的一个个对象类型如同这个社会里的一个个鲜活的人类个体,基础设施完成以后,社会里的人可以借助于高效快速的各种交通网络区完成各自的神圣使命。generate_all()函数的上半段定义了一些重要的逻辑入口,例如CodeletMark cm(_masm, "return entry points")代码段定义了return指令的入口,同时会生成其对应的机器指令。而从#define method_entry(kind)宏定义开始,则定义了一系列"方法入口",例如,zerolocals、abstract、java_lang_math_sin等。
在AbstractInterpreter中定义了JVM所支持的全部方法入口。当JVM调用Java函数时,例如Java类的构造函数、类成员方法、静态方法、虚方法等或者特定的数学函数,最终就会从不同的入口进去,在CallStub例程中进入不同的函数入口。对于正常的Java方法调用(包括Java程序主函数),其所对应的entry_point一般都是zerolocals或者zerolocals_synchronized,如果方法加了同步关键字synchronized,则其entry_point是zerolocals_synchronized。因此这里关注zerolocals方法入口,method_entry(zerolocals)生成了该方法入口。method_entry()正是在generate_all()中定义的宏,调用method_entry(zerolocals)就相当于执行了下面这个逻辑:
Interpreter::_entry_table[Integerpreter::zerolocals] = generate_method_entry(Interpreter::zerolocalsfd)
这个逻辑执行完之后了,JVM会为zerolocals生成本地机器指令,同时将这串机器指令的首地址保存到Interpreter::_entry_table数组中。
Interpreter::_entry_table[Integerpreter::zerolocals] = generate_method_entry(Interpreter::zerolocalsfd)
这个逻辑执行完之后了,JVM会为zerolocals生成本地机器指令,同时将这串机器指令的首地址保存到Interpreter::_entry_table数组中。
对于32位x86Linux平台,为zerolocals方法入口生成机器指令的generate_method_entry()函数定义如下.在generate_method_entry()函数中,判断入参的枚举类型,当入参类型是zerolocals时啥也不干,因此跳出switch条件判断分支,直接到最后一句:
InterpreterGenerator* ig_this = (InterpreterGenerator*)this;
return ig_this->generate_normal_entry(synchronized);
InterpreterGenerator* ig_this = (InterpreterGenerator*)this;
return ig_this->generate_normal_entry(synchronized);
在generate_normal_entry()函数中,终于要开始为zerolocals生成本地机器指令了。在32位 x86Linux平台上,generate_normal_entry()函数定义如下:
generate_normal_entry()函数在JVM启动过程中调动,执行完成之后,会向JVM的代码缓存区写入对应的本地机器指令。当JVM调用一个特定的Java方法时,会根据Java方法所对应的entry_point类型找到对应的函数入口,并执行这段预先生成好的机器指令。通过前面的分析可知,CallStub例程所进入的entry_point例程其实就是方法入口,并且这个方法入口并不是只有一个,而是有一批。至于到底会进入哪一个入口,其实在编译期就确定了,编译器会判断Java方法的签名(Java方法名、访问表示,是否使用synchronized锁定,是否是虚方法等),并根据Java方法的签名信息生成不同的方法调用指令。在一个Java类被JVM加载的过程中,同样会对每个Java方法进行签名信息分析,并最终确定一个Java方法的entry_point类型。下面开始具体分析generate_normal_entry()函数执行的详细过程。由于Java方法栈主要由局部变量表、帧数据和操作数这三大部分组成,因此下面的分析业主要从这几个方面进行详解
generate_normal_entry()函数在JVM启动过程中调动,执行完成之后,会向JVM的代码缓存区写入对应的本地机器指令。当JVM调用一个特定的Java方法时,会根据Java方法所对应的entry_point类型找到对应的函数入口,并执行这段预先生成好的机器指令。通过前面的分析可知,CallStub例程所进入的entry_point例程其实就是方法入口,并且这个方法入口并不是只有一个,而是有一批。至于到底会进入哪一个入口,其实在编译期就确定了,编译器会判断Java方法的签名(Java方法名、访问表示,是否使用synchronized锁定,是否是虚方法等),并根据Java方法的签名信息生成不同的方法调用指令。在一个Java类被JVM加载的过程中,同样会对每个Java方法进行签名信息分析,并最终确定一个Java方法的entry_point类型。下面开始具体分析generate_normal_entry()函数执行的详细过程。由于Java方法栈主要由局部变量表、帧数据和操作数这三大部分组成,因此下面的分析业主要从这几个方面进行详解
局部变量表创建
constMethod的内存布局。
世界是物质的,物质是运动的,运动是有规律的,规律是可以被掌握的。当JVM启动运行后,其内部的一切数据对象都处于不断运动的状态,而运动的规律,则由詹爷一手创建。相对于JVM内部的所有对象而言,詹爷就是它们的创世神。JVM是使用C/C++写成的,与其他基于C的程序一样,JVM充分基于数据结构展开其独特的算法。JVM内部变化的是对象的位置,是对象的诞生和消亡,是指向对象的指针。同样,算法也会一直变化。但是唯一千年不变的是对象所代表的数据结构,是一串结构中各个元素的相对偏移。这种相对位移便成了JVM内部物质运动的一种内在规律,连Java函数在JVM内部所对应的method数据结构表达形式也挣脱不了这种造物主所设定的命运.
作为JVM世界的造物神,自然是知道这种规律的,因为规矩是造物神定下的。在entry_point()例程中,这种规律将被用来定位Java函数所对应的字节码位置,并计算局部变量表的容量。对于JDK6,JVM内部通过偏移量为Java函数顶下了"规矩",准确地说,至少定下了3条规矩:
# method对象的constMethod指针紧跟在methodOop对象头的后面也即constMethod的偏移量是固定的。
# constMethod内部存储Java函数所对应的字节码指令的位置相对于constMethod起始位的偏移量是固定的。
# method对象内部存储Java函数的参数数量、局部变量数量的参数的偏移量是固定的
注:constMethod对象是method对象内部的一个字段。method对象是Java方法在JVm内部所对等的数据结构。
这种偏移量是由C++编译器保证的。JDK6的method及其相关属性的偏移量如图所示。
世界是物质的,物质是运动的,运动是有规律的,规律是可以被掌握的。当JVM启动运行后,其内部的一切数据对象都处于不断运动的状态,而运动的规律,则由詹爷一手创建。相对于JVM内部的所有对象而言,詹爷就是它们的创世神。JVM是使用C/C++写成的,与其他基于C的程序一样,JVM充分基于数据结构展开其独特的算法。JVM内部变化的是对象的位置,是对象的诞生和消亡,是指向对象的指针。同样,算法也会一直变化。但是唯一千年不变的是对象所代表的数据结构,是一串结构中各个元素的相对偏移。这种相对位移便成了JVM内部物质运动的一种内在规律,连Java函数在JVM内部所对应的method数据结构表达形式也挣脱不了这种造物主所设定的命运.
作为JVM世界的造物神,自然是知道这种规律的,因为规矩是造物神定下的。在entry_point()例程中,这种规律将被用来定位Java函数所对应的字节码位置,并计算局部变量表的容量。对于JDK6,JVM内部通过偏移量为Java函数顶下了"规矩",准确地说,至少定下了3条规矩:
# method对象的constMethod指针紧跟在methodOop对象头的后面也即constMethod的偏移量是固定的。
# constMethod内部存储Java函数所对应的字节码指令的位置相对于constMethod起始位的偏移量是固定的。
# method对象内部存储Java函数的参数数量、局部变量数量的参数的偏移量是固定的
注:constMethod对象是method对象内部的一个字段。method对象是Java方法在JVm内部所对等的数据结构。
这种偏移量是由C++编译器保证的。JDK6的method及其相关属性的偏移量如图所示。
首先看constMethod,其相对于methodOop的起始位置的偏移量是一个oopDesc对象头的举例,在32位x86平台,这个距离是8,即两个指针的宽度。再看Java函数的两个十分重要的属性:max_locals和size_of_parameters。在JDK6中,这两个参数被保存在methodOopDsc对象中,在32位x86平台上,methodOopDesc的成员变量、所占用内存大小(以字节为单位)、相对于mehtodOop起始的偏移量(以字节为单位)如表所示。
基于表7.1,可以知道max_locals与size_of_parameters这两个参数相对于methodOop对象首地址的偏移量分别是36与38.知道了这两个偏移量,JVm将会基于此计算四年局部变量表的大小。而到了JDK8,Oracle公司的研发人员可能觉得,对于一个给定的Java函数,其max_locals与size_of_parameters这两个参数就是恒定不变的,不可能在运行过程中被修改,因此应该将其当作只读的属性。基于这种考虑的结果便是直接导致max_locals与size_of_parameters这两个参数被从methodOopDesc对象中移到了constMethod对象中,因为constMethod对象中的属性都是只读的。
基于表7.1,可以知道max_locals与size_of_parameters这两个参数相对于methodOop对象首地址的偏移量分别是36与38.知道了这两个偏移量,JVm将会基于此计算四年局部变量表的大小。而到了JDK8,Oracle公司的研发人员可能觉得,对于一个给定的Java函数,其max_locals与size_of_parameters这两个参数就是恒定不变的,不可能在运行过程中被修改,因此应该将其当作只读的属性。基于这种考虑的结果便是直接导致max_locals与size_of_parameters这两个参数被从methodOopDesc对象中移到了constMethod对象中,因为constMethod对象中的属性都是只读的。
JDK8中,methodOop与constMethod(在JDK8中,已经不再叫methodOop和constMethod了,后面的Oop没有了,但是数据结构没有太大的变化)的内存布局如图所示。所以,在JDK8中要想从methodOop对象中读取到max_locals和size_of_parameters这两个参数,便不能再基于method的偏移量去读取了,只能基于constMethod的偏移量进行读取。
在32位x86平台上,JDK8中的constMethod类型的成员变量、所占用的内存大小(以字节为单位)、相对于constMethod起始位置的偏移量(以字节为单位),如表所示.JVM内部会基于偏移量来分配堆栈空间。
局部变量表空间计算。
如果你对JVM内存模型或者Java的字节码指令有过接触,就会明白,要研究它们怎么都绕不开一个常见的内存区域——局部变量表。局部变量表作为Java犯法堆栈(栈帧)的一部分,主要的作用就是保存Java方法内部所声明的局部变量,当然,也包括入参。成功为Java函数分配局部变量表的第一步就是正确计算出Java函数局部变量表所需的大小。
Java方法的局部变量表包含Java方法的所有入参和方法内部所声明的全部局部变量。在编译阶段,编译器准确地计算出Java方法的局部变量空间。而到了运行期,仅仅知道局部变量空间大小是不够的,JVM需要为其分配足够的堆栈空间。虽然编译期间便知道了所需要的局部变量空间大小,但是这对JVM进行堆栈空间分配并不能提供足够的信息,因为通常情况下,Java方法的入参的堆栈空间是由调用方所分配,因此被调用方并不需要再分配编译期所计算出的全部局部变量空间。对于Java方法之间的调用(是指一个Java方法调用了另一个Java方法,而非本地函数调用Java方法),调用方的操作数栈与被调用方的局部变量表往往存在重叠去,这给Java方法局部变量表的分配带来一定的挑战。
所以在运行期,JVM只需要为Java方法的局部变量分配堆栈空间,而不需要为Java函数的入参额外分配空间,因为入参的堆栈空间由调用方完成分配。很绕?有没有?事实上,这么绕是有道理的,根本原因是编译期间所获取的信息与运行期间所能获取的信息是不对称的,并且运行期间可能会有各种优化。对于绕的东西,举例来理解是一个不错的办法。下面是一个简单的Java类,包含一个很简单的方法.
如果你对JVM内存模型或者Java的字节码指令有过接触,就会明白,要研究它们怎么都绕不开一个常见的内存区域——局部变量表。局部变量表作为Java犯法堆栈(栈帧)的一部分,主要的作用就是保存Java方法内部所声明的局部变量,当然,也包括入参。成功为Java函数分配局部变量表的第一步就是正确计算出Java函数局部变量表所需的大小。
Java方法的局部变量表包含Java方法的所有入参和方法内部所声明的全部局部变量。在编译阶段,编译器准确地计算出Java方法的局部变量空间。而到了运行期,仅仅知道局部变量空间大小是不够的,JVM需要为其分配足够的堆栈空间。虽然编译期间便知道了所需要的局部变量空间大小,但是这对JVM进行堆栈空间分配并不能提供足够的信息,因为通常情况下,Java方法的入参的堆栈空间是由调用方所分配,因此被调用方并不需要再分配编译期所计算出的全部局部变量空间。对于Java方法之间的调用(是指一个Java方法调用了另一个Java方法,而非本地函数调用Java方法),调用方的操作数栈与被调用方的局部变量表往往存在重叠去,这给Java方法局部变量表的分配带来一定的挑战。
所以在运行期,JVM只需要为Java方法的局部变量分配堆栈空间,而不需要为Java函数的入参额外分配空间,因为入参的堆栈空间由调用方完成分配。很绕?有没有?事实上,这么绕是有道理的,根本原因是编译期间所获取的信息与运行期间所能获取的信息是不对称的,并且运行期间可能会有各种优化。对于绕的东西,举例来理解是一个不错的办法。下面是一个简单的Java类,包含一个很简单的方法.
add()方法的局部变量表的最大容量是4(locals=4),入参数量是3(args_size=3).入参数量之所以是3,是因为add()方法是类的成员方法,因此会有隐藏的第一个入参this.3个入参,加上add()方法内部所定义的局部变量z,构成了局部变量表的容量。注意看add()方法所对应的字节码指令,当执行z=x+y时,需要先执行两条字节码指令:
iload_1
iload_2
这两条字节码指令分别将局部变量表的第1个槽位和第2个槽位的数据推送至表达式栈栈顶(槽位起始编号从0开始)。第1和第2个槽位上所保存的数据,正是add()方法的两个入参x和y。很显然,第一个槽位上所保存的数据是this指针。更加显然的是,JVM内部的局部变量表的确包含了入参。当执行完z=x+y后,对应的最后一条字节码指令是istore_3,它将计算结果保存到局部变量表的第3个槽位。第3个槽位正是add()方法内部所定义的局部变量z,.此时的局部变量表的内部结构与索引如下:
槽位索引 对应入参/局部变量
0 this
1 x
2 y
3 z
注:在32位平台上,一个槽位占32位。
对于本例,由于x和y这两个入参在调用方调用add()方法时便已经分配完毕,因此JVM无须再为这两个入参分配堆栈空间,只需为z分配。而z所需的堆栈空间大小,则为编译期间所计算出的局部变量表的大小减去入参数量,对于本例而言,就是(4-3)=1。因此,JVM只需要为add()方法内的局部变量z分配1个变量槽。
entry_point例程里给出了这种除入参之外所需要的局部变量表的空间大小的计算方法。如图所示。最终生成的机器指令如下(使用汇编展示,基于32位x86平台,JDK6):
movzwl 0x26(%ebx), %ecx
movzwl 0x24(%ebx), %edx
sub %ecx, %edx
iload_1
iload_2
这两条字节码指令分别将局部变量表的第1个槽位和第2个槽位的数据推送至表达式栈栈顶(槽位起始编号从0开始)。第1和第2个槽位上所保存的数据,正是add()方法的两个入参x和y。很显然,第一个槽位上所保存的数据是this指针。更加显然的是,JVM内部的局部变量表的确包含了入参。当执行完z=x+y后,对应的最后一条字节码指令是istore_3,它将计算结果保存到局部变量表的第3个槽位。第3个槽位正是add()方法内部所定义的局部变量z,.此时的局部变量表的内部结构与索引如下:
槽位索引 对应入参/局部变量
0 this
1 x
2 y
3 z
注:在32位平台上,一个槽位占32位。
对于本例,由于x和y这两个入参在调用方调用add()方法时便已经分配完毕,因此JVM无须再为这两个入参分配堆栈空间,只需为z分配。而z所需的堆栈空间大小,则为编译期间所计算出的局部变量表的大小减去入参数量,对于本例而言,就是(4-3)=1。因此,JVM只需要为add()方法内的局部变量z分配1个变量槽。
entry_point例程里给出了这种除入参之外所需要的局部变量表的空间大小的计算方法。如图所示。最终生成的机器指令如下(使用汇编展示,基于32位x86平台,JDK6):
movzwl 0x26(%ebx), %ecx
movzwl 0x24(%ebx), %edx
sub %ecx, %edx
从call_stub例程进入entry_point例程之前,ebx寄存器指向Java函数所对应的method对象首地址,根据前文所分析的JDK6中method的结构可知,相对于method首地址偏移38个字节的位置保存的时max_locals参数,而偏移36个字节的位置保存的是size_of_parameters参数,38和36所对应的十六进制正是0x26和0x24.到了JDK8,由于max_locals与size_of_parameters这两个参数保存的位置发生了变化,因此其寻址方式也跟着发生变化。JDK8最终所生成的机器指令如下:
mov 0x8(%ebx), %edx
movzwl 0x22(%edx), %ecx
movzwl 0x20(%edx),%edx
sub %ecx, %edx
由于在JDK8中,max_locals与size_of_parameters这两个参数从method对象移到了constMethod对象中,因此首先执行mov 0x8(%ebx), %edx。将edx寄存器指向constMethod对象首地址。因为constMethod相对于method对象的首地址的偏移量为8字节,而程序流从call_stub例程进入entry_point例程之后,ebx寄存器指向method对象首地址,因此通过0x8(%ebx)将ebx寄存器往上移动8字节的宽度,就得到constMethod首地址,并将这个首地址保存到edx寄存器中,接着再基于constMethod的偏移量分别通过0x22(%edx)与0x20(%edx),就得到max_locals与size_of_parameters这两个参数值
mov 0x8(%ebx), %edx
movzwl 0x22(%edx), %ecx
movzwl 0x20(%edx),%edx
sub %ecx, %edx
由于在JDK8中,max_locals与size_of_parameters这两个参数从method对象移到了constMethod对象中,因此首先执行mov 0x8(%ebx), %edx。将edx寄存器指向constMethod对象首地址。因为constMethod相对于method对象的首地址的偏移量为8字节,而程序流从call_stub例程进入entry_point例程之后,ebx寄存器指向method对象首地址,因此通过0x8(%ebx)将ebx寄存器往上移动8字节的宽度,就得到constMethod首地址,并将这个首地址保存到edx寄存器中,接着再基于constMethod的偏移量分别通过0x22(%edx)与0x20(%edx),就得到max_locals与size_of_parameters这两个参数值
初始化局部变量区。
在CallStub执行call %eax指令之前,物理寄存器(注意:不是逻辑寄存器哦)中所保存的重要信息如表所示:
计算出除Java方法入参之外的参数所需要分配的堆栈空间,接着就是执行空间分配。对于绝大多数C/C++语言,包括Delphi等可以直接编译为本地二进制机器指令的语言,分配堆栈空间基本都是用如下指令:
sub operand, %esp
esp寄存器指向调用者函数的栈顶,要扩展堆栈的内存空间,只需将栈顶指针继续往下移动,移动多大空间,就能扩展多大空间。当然也不是随便想扩充多大就扩充多大,对于基于硬件的操作系统而言,往往有一个最大堆栈深度的限制;而对于基于软件的JVM虚拟机,本身也提供了这种限制,具体的限制通过参数-Xss进行控制。
在CallStub执行call %eax指令之前,物理寄存器(注意:不是逻辑寄存器哦)中所保存的重要信息如表所示:
计算出除Java方法入参之外的参数所需要分配的堆栈空间,接着就是执行空间分配。对于绝大多数C/C++语言,包括Delphi等可以直接编译为本地二进制机器指令的语言,分配堆栈空间基本都是用如下指令:
sub operand, %esp
esp寄存器指向调用者函数的栈顶,要扩展堆栈的内存空间,只需将栈顶指针继续往下移动,移动多大空间,就能扩展多大空间。当然也不是随便想扩充多大就扩充多大,对于基于硬件的操作系统而言,往往有一个最大堆栈深度的限制;而对于基于软件的JVM虚拟机,本身也提供了这种限制,具体的限制通过参数-Xss进行控制。
而在entry_point例程中,分配堆栈空间使用了另一种方式,那就是push操作。entry_point例程所对应的源码如下:
这段逻辑主要执行了3件事:
#1 将栈顶的返回地址暂存到rax寄存器中
#2 获取Java函数第一个入参在堆栈中的位置
#3 为局部变量表分配堆栈空间
在32位x86平台上,这段逻辑所生成的机器指令如下(使用汇编展示):
// get return address
pop %eax
// compute beginning of parameters(rdi)
lea -0x4(%esp, %ecx, 4), %edi // 让edi指向第一个参数在堆栈中的位置
// rdx - # of additional locals
// allocate space for locals
// explicitly initialize locals
test %edx, %edx // 测试edx是否为0,即判断Java函数中是否有局部变量
jle 0x01cbbb08 // 如果Java函数内部没有声明局部变量,则跳过堆栈空间分配
push $0x0
dec %edx
jg 0xb36d6559
下面对局部变量表的初始化逻辑进行详细分析.
这段逻辑主要执行了3件事:
#1 将栈顶的返回地址暂存到rax寄存器中
#2 获取Java函数第一个入参在堆栈中的位置
#3 为局部变量表分配堆栈空间
在32位x86平台上,这段逻辑所生成的机器指令如下(使用汇编展示):
// get return address
pop %eax
// compute beginning of parameters(rdi)
lea -0x4(%esp, %ecx, 4), %edi // 让edi指向第一个参数在堆栈中的位置
// rdx - # of additional locals
// allocate space for locals
// explicitly initialize locals
test %edx, %edx // 测试edx是否为0,即判断Java函数中是否有局部变量
jle 0x01cbbb08 // 如果Java函数内部没有声明局部变量,则跳过堆栈空间分配
push $0x0
dec %edx
jg 0xb36d6559
下面对局部变量表的初始化逻辑进行详细分析.
1.暂存返回地址
JVM控制流从call_stub例程刚进入entry_point例程时,尚未对堆栈空间进行任何操作,因此到目前为止,call_stub例程与entry_point例程的堆栈空间如图所示.可以看到,在栈顶位置保存的是目标Java函数的返回地址,注意这个所谓"返回地址"的概念,在之前分析CallStub例程时对此进行过专门描述,它是指CallStub例程中"call *%eax"这条指令所在内存区域的下一条指令地址,这个值实际上是原本CallStub例程中eip寄存器的值。因此,这里的"返回地址"并不是指目标Java函数的返回值。不过要注意的是,JVM内部也将return address叫做"return_from_java",因此下文有时根据需要写成return_from_java,大家心里知道这其实也是return address即可。不管是return address也好,还是return_from_java也罢,其实都是eip.接下来,JVM要为被调用者函数的局部变量分配堆栈空间,在分配之前,需要先将"返回地址"临时保存到rax寄存器中。但是为何要这样做呢?原因主要包括以下两方面:
# 第一,Java函数入参也是局部变量表的一部分。被调用的Java方法的入参信息,就保存在call_stub例程栈顶返回地址的上方,如图所示,return_address存储单元的上面就是目标Java方法的入参,分别是argument word1 ~ argument word n.由于接下来要为被调用的Java方法分配局部变量的堆栈空间,而入参也属于被调用方的局部变量,因此理所当然地要将这两块区域连成一片,中间不能有分隔,否则看着多别扭啊
# 第二,JVM实现操作数栈与局部变量表的服用。当一个Java方法调用了另一个Java方法时,最终调用方的栈顶时return address与操作数栈,操作数栈位于return address的上方。但是当发生Java方法调用时,调用方Java方法会将被调用方的入参复制到调用方的操作数栈,此时操作数栈便同时充当了入参的堆栈内存。这么做的原因也很简单,就是为了提高Java方法调用的效率,如果不实现操作数栈与入参堆栈的复用,那么必然会专门为入参开辟出一段内存空间,这样做,除了会消耗物理机器内存,还会额外增加许多指令用于数据复制,额外耗费非常可观的CPU计算成本,这在时间与空间上都不划算。
JVM控制流从call_stub例程刚进入entry_point例程时,尚未对堆栈空间进行任何操作,因此到目前为止,call_stub例程与entry_point例程的堆栈空间如图所示.可以看到,在栈顶位置保存的是目标Java函数的返回地址,注意这个所谓"返回地址"的概念,在之前分析CallStub例程时对此进行过专门描述,它是指CallStub例程中"call *%eax"这条指令所在内存区域的下一条指令地址,这个值实际上是原本CallStub例程中eip寄存器的值。因此,这里的"返回地址"并不是指目标Java函数的返回值。不过要注意的是,JVM内部也将return address叫做"return_from_java",因此下文有时根据需要写成return_from_java,大家心里知道这其实也是return address即可。不管是return address也好,还是return_from_java也罢,其实都是eip.接下来,JVM要为被调用者函数的局部变量分配堆栈空间,在分配之前,需要先将"返回地址"临时保存到rax寄存器中。但是为何要这样做呢?原因主要包括以下两方面:
# 第一,Java函数入参也是局部变量表的一部分。被调用的Java方法的入参信息,就保存在call_stub例程栈顶返回地址的上方,如图所示,return_address存储单元的上面就是目标Java方法的入参,分别是argument word1 ~ argument word n.由于接下来要为被调用的Java方法分配局部变量的堆栈空间,而入参也属于被调用方的局部变量,因此理所当然地要将这两块区域连成一片,中间不能有分隔,否则看着多别扭啊
# 第二,JVM实现操作数栈与局部变量表的服用。当一个Java方法调用了另一个Java方法时,最终调用方的栈顶时return address与操作数栈,操作数栈位于return address的上方。但是当发生Java方法调用时,调用方Java方法会将被调用方的入参复制到调用方的操作数栈,此时操作数栈便同时充当了入参的堆栈内存。这么做的原因也很简单,就是为了提高Java方法调用的效率,如果不实现操作数栈与入参堆栈的复用,那么必然会专门为入参开辟出一段内存空间,这样做,除了会消耗物理机器内存,还会额外增加许多指令用于数据复制,额外耗费非常可观的CPU计算成本,这在时间与空间上都不划算。
既然操作数栈被同时用作了入参的堆栈,而入参同时又是被调用方的局部变量表的一部分,因此就必然要将其内存空间连成一片。如果调用方也是Java方法,那么以上两点说的其实是一回事,那么调用方的操作数栈其实同时也是被调用方的入参堆栈。基于以上两点,入参堆栈与即将分配的局部变量之间不允许存在一个碍事的return address参数,因此JVM便将这个参数先移走。于是JVM念起那段古朴沧桑的强大咒语:pop %eax.将return address瞬间传送到eax中。
2.获取Java函数第一个入参在堆栈中的位置。
JMV念完咒语,return address瞬间消失,现在JVM从栈顶一眼看过去,第一个映入眼帘的参数变成了argument word n,也就是Java方法的最后一个入参。argument word n想跟JVM打声招呼,但是JVM并没有理睬,而是大声呼叫:
"第一个参数,给我站出来!"
含完了,但是没人理它,JVM一言不合就念咒语,那古老的咒语是:
lea -0x4(%esp,%ecx, 4), %edi
念完咒语,CPU偷偷地将第一个参数地堆栈坐标写进了edi寄存器。将这个咒语展开,等价于下面这个表达式:
(%edi) = (%esp) + (%ecx)*4 - 0x4
程序流从call_stub例程跳转到entry_point例程时,ecx寄存器中记录地是Java方法入参地数量,假设入参数量是N,则由此可知,第一个参数地位置在当前栈顶往上N字节,再往下移动4字节。这里需要注意地是,由于堆栈空间从高地址内存位置向低地址内存位置增长,所以上面表达式的前半部分的子表达式((%esp) + (%ecx) *4)所计算出来的结果实际上是Java方法第一个入参的内存位置的最高位地址,但是在大部分主流CPU架构平台上,都是将一个数据的最低位内存地址标记为该数据的内存地址,而不是最高位内存地址,因此上面表达式最后需要减去0x4,从而得到Java方法的第一个入参的最低为地址,这就是第一个入参的内存首地址。同时需要注意的是,在32位平台上,一个指针类型的数据宽度是4字节,因此这里减去的是4;但是在64位平台上,由于一个指针的数据宽度是8字节,因此在64位平台上,上面这段表达式就变成如下这样:
(%edi) = (%esp) + (%ecx) * 8 - 0x8
在该表达式中,自然数0x4变成0x8.这个经之所以这么念,是有道理的,JVM可是精打细算的主,刚才在赶走return address老兄时,所念的咒语是pop %eax.这段咒语念完,栈顶的数据被弹出,esp寄存器会自动往上移动,移动之后的堆栈内存布局如图所示
JMV念完咒语,return address瞬间消失,现在JVM从栈顶一眼看过去,第一个映入眼帘的参数变成了argument word n,也就是Java方法的最后一个入参。argument word n想跟JVM打声招呼,但是JVM并没有理睬,而是大声呼叫:
"第一个参数,给我站出来!"
含完了,但是没人理它,JVM一言不合就念咒语,那古老的咒语是:
lea -0x4(%esp,%ecx, 4), %edi
念完咒语,CPU偷偷地将第一个参数地堆栈坐标写进了edi寄存器。将这个咒语展开,等价于下面这个表达式:
(%edi) = (%esp) + (%ecx)*4 - 0x4
程序流从call_stub例程跳转到entry_point例程时,ecx寄存器中记录地是Java方法入参地数量,假设入参数量是N,则由此可知,第一个参数地位置在当前栈顶往上N字节,再往下移动4字节。这里需要注意地是,由于堆栈空间从高地址内存位置向低地址内存位置增长,所以上面表达式的前半部分的子表达式((%esp) + (%ecx) *4)所计算出来的结果实际上是Java方法第一个入参的内存位置的最高位地址,但是在大部分主流CPU架构平台上,都是将一个数据的最低位内存地址标记为该数据的内存地址,而不是最高位内存地址,因此上面表达式最后需要减去0x4,从而得到Java方法的第一个入参的最低为地址,这就是第一个入参的内存首地址。同时需要注意的是,在32位平台上,一个指针类型的数据宽度是4字节,因此这里减去的是4;但是在64位平台上,由于一个指针的数据宽度是8字节,因此在64位平台上,上面这段表达式就变成如下这样:
(%edi) = (%esp) + (%ecx) * 8 - 0x8
在该表达式中,自然数0x4变成0x8.这个经之所以这么念,是有道理的,JVM可是精打细算的主,刚才在赶走return address老兄时,所念的咒语是pop %eax.这段咒语念完,栈顶的数据被弹出,esp寄存器会自动往上移动,移动之后的堆栈内存布局如图所示
由于现在rsp寄存器指向了最后一个参数,因此i需要往上移动(n-1)*4字节的位置,找到第一个参数的堆栈地址。注意,JVM在这里使用了lea命令,而非mov.lea命令是专门用于获取内存地址的命令。在这一步之所以要将Java函数的第一个入参的位置保存下来,是因为接下来要为Java函数内部所声明的局部变量分配堆栈空间,并且要执行Java字节码指令,字节码指令往往都会涉及在操作数栈与局部变量表之间相互传送数据,而JVM要读取/写入局部变量表,必然要知道局部变量表的起始位置,否则无法定位。因此在这一步将局部变量表的起始位置保存到edi寄存器中,后续相关的字节码指令例如iload、istore等都需要用到edi寄存器,直接从edi寄存器中读取局部变量表的起始位置。更进一步说,这也是对局部变量表读取和写入时都基于索引的原因,因为JVM的确就是基于局部变量表的起始位置做偏移的,从而读取/写入局部变量表相关数据。局部变量的索引号其实就是入参或局部变量相对于局部变量表的偏移量。
Java方法的第一个入参的内存位置将作为Java方法的局部变量表的起始位置,并保存在edi寄存器中。这里其实偷偷地埋下了一个巨坑,假设一个Java方法包含3个入参,同时假设该方法没有局部变量,则该方法的局部变量表及edi寄存器所指向的位置如图所示
Java方法的第一个入参的内存位置将作为Java方法的局部变量表的起始位置,并保存在edi寄存器中。这里其实偷偷地埋下了一个巨坑,假设一个Java方法包含3个入参,同时假设该方法没有局部变量,则该方法的局部变量表及edi寄存器所指向的位置如图所示
edi寄存器所保存的正是Java方法第一个入参的内存位置,Java方法的局部变量表的slot位置也以该位置为起始点。而事实上,对于计算机内部的一段连续的数据区域,其内存首地址是整个数据区域中地址最低的那个位置,很显然,slot的起始位置并不符合这一原则。上图中,由于Java方法堆栈由内存高地址向低地址方向增长,因此Java方法的第一个入参的内存地址反而处于最高位,因此,如果按照一般逻辑,Java方法的局部变量表的slot起始位置应该如图所示才对。由于JVM将整个局部变量表的高位地址作为其起始位置,因此运行期的字节码指令操作局部变量表的slot槽位索引时,必须充分考虑到这一点,而这也是理解读写局部变量表所对应的load与store系列的字节码指令所对应的机器码的关键所在,否则将一头雾水。同时,在这一步计算入参位置时,默认将Java方法入参全部当成指针类型,因此在32位和64位平台上,Java方法的第一个入参的内存位置要么以32位计算,要么以64位计算,但是在局部变量表中,普通数据类型(例如int)与long类型所占的槽数不同,而无论在32位还是64位平台上,局部变量表中的long类型的数据宽度都要比指针类型的数据宽度要大,计算出long/double类型数据在局部变量表中的真实偏移量。
3.为局部变量表分配堆栈空间。
JVM念完两个古老的咒语之后,一切准备工作就绪,开始为Java方法内部所声明的局部变量分配堆栈空间了。这一次,JVM不走寻常路,咒语时这样念的:
test %edx, %edx // 测试edx是否为0,即判断Java函数中是否有局部变量
jle 0x01cbbb08 // 如果Java函数内部没有声明局部变量,则跳过堆栈空间分配
push $0x0 // 0xb36d6559
dec %edx
jg 0xb36d6559 // 这个地址就是上面第3行的指令地址,push $0x0
这段指令的逻辑是,先测试edx是否为0,edx寄存器中所保存的结果就是签名max_locals-size_of_parameters后得到的值。如果这个值为0,则说明被调用的Java方法内部并没有声明任何局部变量,于是JVM不会再去分配堆栈空间。能省一件事就省一件事,整天念咒语。如果max_locals-size_of_parameters不等于0,则执行一个微循环,先通过push $0x0往栈顶压入一个0,接着通过dec %edx将寄存器中的值减去1,最后再判断edx的值是否为0,如果不是0则继续跳转回push $0x0,否则循环结束。
假设Java方法内部定义了5个局部变量,则这段逻辑执行完之后,最后的堆栈内存布局如图所示(仍然假设目标Java方法包含3个入参)
通过不断push的方式,JVM完成了局部变量表的堆栈空间分配。其实,在entry_point例程中,完全可以使用"sub operand, %esp"这样的方式分配堆栈空间,但是这里偏偏没有这么做。这也是有道理的,这是因为Java方法的堆栈空间会被反复使用。对于一片指定的堆栈区域,就像个战场,战场永远是那个战场,但是却前仆后继来了一茬又一茬的军队。战争有它自己的规律,每次完成一场决战,胜利的一方都要打扫战场,清理物资。而对于同一块内存区域,这一次可能是被作为Java方法a()的堆栈,而下一次则可能变成方法b()的堆栈。与战场所不同的是,Java方法执行完毕之后,并不负责清零堆栈,因此清零的工作只能由下一次使用这块堆栈空间的Java方法去负责。这便是在entry_point例程中使用循环push 0的方式进行分配堆栈空间的原因。如果使用"sub operand, %esp"的方式去分配堆栈空间,分配完了仍然需要进行一次循环,使用"mov 0, -operand(%esp)这样的方式对堆栈空间进行清零,效率反而低下",不美
JVM念完两个古老的咒语之后,一切准备工作就绪,开始为Java方法内部所声明的局部变量分配堆栈空间了。这一次,JVM不走寻常路,咒语时这样念的:
test %edx, %edx // 测试edx是否为0,即判断Java函数中是否有局部变量
jle 0x01cbbb08 // 如果Java函数内部没有声明局部变量,则跳过堆栈空间分配
push $0x0 // 0xb36d6559
dec %edx
jg 0xb36d6559 // 这个地址就是上面第3行的指令地址,push $0x0
这段指令的逻辑是,先测试edx是否为0,edx寄存器中所保存的结果就是签名max_locals-size_of_parameters后得到的值。如果这个值为0,则说明被调用的Java方法内部并没有声明任何局部变量,于是JVM不会再去分配堆栈空间。能省一件事就省一件事,整天念咒语。如果max_locals-size_of_parameters不等于0,则执行一个微循环,先通过push $0x0往栈顶压入一个0,接着通过dec %edx将寄存器中的值减去1,最后再判断edx的值是否为0,如果不是0则继续跳转回push $0x0,否则循环结束。
假设Java方法内部定义了5个局部变量,则这段逻辑执行完之后,最后的堆栈内存布局如图所示(仍然假设目标Java方法包含3个入参)
通过不断push的方式,JVM完成了局部变量表的堆栈空间分配。其实,在entry_point例程中,完全可以使用"sub operand, %esp"这样的方式分配堆栈空间,但是这里偏偏没有这么做。这也是有道理的,这是因为Java方法的堆栈空间会被反复使用。对于一片指定的堆栈区域,就像个战场,战场永远是那个战场,但是却前仆后继来了一茬又一茬的军队。战争有它自己的规律,每次完成一场决战,胜利的一方都要打扫战场,清理物资。而对于同一块内存区域,这一次可能是被作为Java方法a()的堆栈,而下一次则可能变成方法b()的堆栈。与战场所不同的是,Java方法执行完毕之后,并不负责清零堆栈,因此清零的工作只能由下一次使用这块堆栈空间的Java方法去负责。这便是在entry_point例程中使用循环push 0的方式进行分配堆栈空间的原因。如果使用"sub operand, %esp"的方式去分配堆栈空间,分配完了仍然需要进行一次循环,使用"mov 0, -operand(%esp)这样的方式对堆栈空间进行清零,效率反而低下",不美
堆栈与栈帧。
JVM为局部变量分配完堆栈空间后,接着执行一项特殊的任务。这项特殊的任务就是构建栈帧,准确地说,是构建Java方法所对应的栈帧中的固定部分,这个固定的部分在JVM内部有一个专门的称谓——fixed frame. fixed frame不是指固定的栈帧,而是指栈帧中的固定部分,众所周知,在JVM内部,每个Java方法都对应一个栈帧,这个栈帧中会包含局部变量表、操作数栈、常量池缓存指针、返回地址等数据。
其实,对于一个Java方法的栈帧,其首尾分别是局部变量表和操作数栈,中间则是除此以外的其他重要信息。而恰恰是首尾的这两部分数据,不同的Java方法是不同的,只有除去首尾这两部分不同的部分,处于栈帧中间位置的部分,其数据结构才是固定不变的,这部分就是fixed frame
JVM为局部变量分配完堆栈空间后,接着执行一项特殊的任务。这项特殊的任务就是构建栈帧,准确地说,是构建Java方法所对应的栈帧中的固定部分,这个固定的部分在JVM内部有一个专门的称谓——fixed frame. fixed frame不是指固定的栈帧,而是指栈帧中的固定部分,众所周知,在JVM内部,每个Java方法都对应一个栈帧,这个栈帧中会包含局部变量表、操作数栈、常量池缓存指针、返回地址等数据。
其实,对于一个Java方法的栈帧,其首尾分别是局部变量表和操作数栈,中间则是除此以外的其他重要信息。而恰恰是首尾的这两部分数据,不同的Java方法是不同的,只有除去首尾这两部分不同的部分,处于栈帧中间位置的部分,其数据结构才是固定不变的,这部分就是fixed frame
栈帧是什么?
在分析Java方法的栈帧之前,先了解下到底啥是栈帧。肯定很多人早对这两个字如雷贯耳,但是却着实不知道这到底是个啥玩意儿。其实说白了,一个方法的栈帧就是这个方法所对应得堆栈。frame有时也被译作框架,不过这种译法也名副其实,对于一个特定得组件或者中间件,当你进入其首个方法入口后,则后续所有一切计算就都与这个入口函数息息相关了,而这个入口函数也的确形似整个中间组件的"门户"了,所以称其为框架,的确当之无愧,而译作帧,汉语里面一幅画称为一个帧,所以显示器每一次的刷屏就叫做一阵,电影胶卷里的一个胶片也叫做一帧。如果一个gif动画由36幅图,,就说其有36个帧。软件程序的运行过程,说白了就是一个个函数不断调用的过程,一个个函数粉墨登场,每一个函数都有堆栈,当函数运行时,函数就向其对应的堆栈空间写入数据,将原本空白的堆栈空间渲染成由01和1所组成的有特殊业务含义的数据"画面",一个个函数彷佛一个个绘画家,各自对着自己的堆栈"笔走龙蛇,挥毫泼墨"。这些函数的堆栈空间是前后相连的,看起来就像放电影一样,所以翻译者将frame译作帧,实在是充满了丰富的想象力,
当然,除了栈帧的叫法,很多人也将其叫做活动记录,这主要来源一个英文单词——activiation records。当然活动记录与堆栈的概念还有一些细微的区别,活动记录更多用于特指当前位于栈顶的堆栈,因为CPU只关心最顶端的堆栈。所以关于堆栈的叫法实在是不少,不过现在就让我们忘了这些名字或概念,而是去思考下面两个问题:
# 堆栈到底是啥
# 堆栈有什么用
要回答这两个问题,还得从机器的角度说起
对于第一个问题,答案其实很简单,函数内部会定义局部变量,这些变量最终要在内存中占用一定的内存空间。由于这些变量都位于同一个函数中,因此一个很自然的想法就i是将这些变量合起来当作一个整体,将其内存空间分配在一起,这样有利于变量空间的整体内存申请和释放,所以抛开"栈帧"本身的特定含义,完全可以将"栈帧"看作一个容器,这个容器中存放的是函数内部的局部变量。事实上,几乎所有编程语言的函数所对应的堆栈空间的确就是个容器,在容器内部会按顺序存放函数内的局部变量。所以栈帧是个容器。
在程序运行的过程中一个函数会有一个栈帧,多个函数的栈帧连起来,就变成堆栈。在《算法与数据结构》里,堆栈是一种数据结构,也是一种容器,堆栈里的元素会按照FILO(first in/ last out)的顺序增加/删除元素。对于一个运行中的程序,从主函数进入的一系列函数的栈帧按照FILO的顺序在内存中分配空间,所以程序的堆栈其实是容器的容器。。。所以栈帧是个容器,堆栈是多个栈帧连成一片后形成的大容器,是容器的容器。这样就回答了上面第一个问题,堆栈到底是啥,接下来要回答第二个问题,堆栈有什么作用,要回答这个问题,必须思考为什么要用堆栈这种大容器来保存函数的栈帧小容器?请看下面C语言的例子
int add(int , int );
int main() {
int m = 5;
int n = 3;
int z = add(m,n);
}
int add(int m, int n) {
int z = m + n;
return z;
}
这个例子很简单,add()函数用于求和。编译后,这段程序会生成对应的机器码。站在机器的角度看,要让main()函数成功调用到add()函数,需要解决下面4个主要问题:
# 当CPU从main()函数跳转到add()函数时,CPU要能够拿到add()函数的机器指令的位置,否则CPU无法执行add()函数的机器指令
# 当add()函数执行完成之后,CPU要能够重新拿到main()函数的机器指令,以便跳转回main()函数继续执行
# 当CPU从main()函数进入add()函数之后,CPU需要为add()函数里的变量分配内存空间,以便在内存中存储add()函数内部的局部变量的值。
# 当CPU从add()函数返回main()函数之前,由于add()函数已经完成其使命,其内部的局部变量失去了意义,并且外部也压根儿访问不到这些局部变量,因此CPU需要将add()函数的局部变量回收掉,以便让宝贵的内存空间能够重复被利用。
在分析Java方法的栈帧之前,先了解下到底啥是栈帧。肯定很多人早对这两个字如雷贯耳,但是却着实不知道这到底是个啥玩意儿。其实说白了,一个方法的栈帧就是这个方法所对应得堆栈。frame有时也被译作框架,不过这种译法也名副其实,对于一个特定得组件或者中间件,当你进入其首个方法入口后,则后续所有一切计算就都与这个入口函数息息相关了,而这个入口函数也的确形似整个中间组件的"门户"了,所以称其为框架,的确当之无愧,而译作帧,汉语里面一幅画称为一个帧,所以显示器每一次的刷屏就叫做一阵,电影胶卷里的一个胶片也叫做一帧。如果一个gif动画由36幅图,,就说其有36个帧。软件程序的运行过程,说白了就是一个个函数不断调用的过程,一个个函数粉墨登场,每一个函数都有堆栈,当函数运行时,函数就向其对应的堆栈空间写入数据,将原本空白的堆栈空间渲染成由01和1所组成的有特殊业务含义的数据"画面",一个个函数彷佛一个个绘画家,各自对着自己的堆栈"笔走龙蛇,挥毫泼墨"。这些函数的堆栈空间是前后相连的,看起来就像放电影一样,所以翻译者将frame译作帧,实在是充满了丰富的想象力,
当然,除了栈帧的叫法,很多人也将其叫做活动记录,这主要来源一个英文单词——activiation records。当然活动记录与堆栈的概念还有一些细微的区别,活动记录更多用于特指当前位于栈顶的堆栈,因为CPU只关心最顶端的堆栈。所以关于堆栈的叫法实在是不少,不过现在就让我们忘了这些名字或概念,而是去思考下面两个问题:
# 堆栈到底是啥
# 堆栈有什么用
要回答这两个问题,还得从机器的角度说起
对于第一个问题,答案其实很简单,函数内部会定义局部变量,这些变量最终要在内存中占用一定的内存空间。由于这些变量都位于同一个函数中,因此一个很自然的想法就i是将这些变量合起来当作一个整体,将其内存空间分配在一起,这样有利于变量空间的整体内存申请和释放,所以抛开"栈帧"本身的特定含义,完全可以将"栈帧"看作一个容器,这个容器中存放的是函数内部的局部变量。事实上,几乎所有编程语言的函数所对应的堆栈空间的确就是个容器,在容器内部会按顺序存放函数内的局部变量。所以栈帧是个容器。
在程序运行的过程中一个函数会有一个栈帧,多个函数的栈帧连起来,就变成堆栈。在《算法与数据结构》里,堆栈是一种数据结构,也是一种容器,堆栈里的元素会按照FILO(first in/ last out)的顺序增加/删除元素。对于一个运行中的程序,从主函数进入的一系列函数的栈帧按照FILO的顺序在内存中分配空间,所以程序的堆栈其实是容器的容器。。。所以栈帧是个容器,堆栈是多个栈帧连成一片后形成的大容器,是容器的容器。这样就回答了上面第一个问题,堆栈到底是啥,接下来要回答第二个问题,堆栈有什么作用,要回答这个问题,必须思考为什么要用堆栈这种大容器来保存函数的栈帧小容器?请看下面C语言的例子
int add(int , int );
int main() {
int m = 5;
int n = 3;
int z = add(m,n);
}
int add(int m, int n) {
int z = m + n;
return z;
}
这个例子很简单,add()函数用于求和。编译后,这段程序会生成对应的机器码。站在机器的角度看,要让main()函数成功调用到add()函数,需要解决下面4个主要问题:
# 当CPU从main()函数跳转到add()函数时,CPU要能够拿到add()函数的机器指令的位置,否则CPU无法执行add()函数的机器指令
# 当add()函数执行完成之后,CPU要能够重新拿到main()函数的机器指令,以便跳转回main()函数继续执行
# 当CPU从main()函数进入add()函数之后,CPU需要为add()函数里的变量分配内存空间,以便在内存中存储add()函数内部的局部变量的值。
# 当CPU从add()函数返回main()函数之前,由于add()函数已经完成其使命,其内部的局部变量失去了意义,并且外部也压根儿访问不到这些局部变量,因此CPU需要将add()函数的局部变量回收掉,以便让宝贵的内存空间能够重复被利用。
在考虑这4个问题时,让我们忘记所谓的栈帧,忘记一切,我们就是最原始的CPU设计师,在那个时代,还没有所谓堆栈的说法问世。。作为一个CPU,面前广阔无垠的内存空间可以任意驱驰,身后跟着ax、bx、cx等一众小弟,它们的名字叫做寄存器。CPU的主要任务就是读取一条机器指令,然后执行,然后再接着读取下一条指令,再执行,再读取下一条指令再执行。。。如此循环往复。上面第1个问题和第2个问题比较好解决,当加载程序时,只需将main()函数和add()函数的机器指令分别保存到内存的两个地方,并且CPU能够拿到这两个地方的内存地址,就可以了。当CPU执行完main()函数的最后一条机器指令后,能够得到add()函数的第一条机器指令,这样CPU自然而然就转去执行add()函数的机器指令。事实上在编译阶段,编译器就将main()函数和add()函数的内存地址以标号这种相对位移的方式(专业的说法叫代码段)保存在了内存中,所以CPU一定能够得到这两个地址。现在关键就是第3和第4这两个问题了。
按道理说,CPU可以将main()函数和add()函数所对应的栈帧(即堆栈空间)分配到内存中的任意两个位置,而随意分配的策略也有很多种,例如可以根据函数名进行散列而得到一个内存地址,然后从这个地址开始为函数内部的局部变量分配内存。但是这种方式即使从表面看都能发现很多问题,例如内存中的很多碎片都是游泳的,可能存放了其他程序或其他函数的机器指令,可能存放了程序的全局变量或静态变量,也可能用作其他程序的堆栈空间,因此通过随机散列的方式获取内存地址将会使事情变得十分危险,不过如果真有人愿意尝试这种危险的方案,也一定能够设计出相应的策略,假设函数堆栈真的按照这种方式去分配,最终一个程序的若干函数的堆栈空间布局就会像如图所示的这样。
按道理说,CPU可以将main()函数和add()函数所对应的栈帧(即堆栈空间)分配到内存中的任意两个位置,而随意分配的策略也有很多种,例如可以根据函数名进行散列而得到一个内存地址,然后从这个地址开始为函数内部的局部变量分配内存。但是这种方式即使从表面看都能发现很多问题,例如内存中的很多碎片都是游泳的,可能存放了其他程序或其他函数的机器指令,可能存放了程序的全局变量或静态变量,也可能用作其他程序的堆栈空间,因此通过随机散列的方式获取内存地址将会使事情变得十分危险,不过如果真有人愿意尝试这种危险的方案,也一定能够设计出相应的策略,假设函数堆栈真的按照这种方式去分配,最终一个程序的若干函数的堆栈空间布局就会像如图所示的这样。
可以看到,这种策略下的堆栈空间彼此之间使割裂的,毫无联系,因为是随即散列的嘛。这种空间分配策略最终也形成一个大容器,结构类似于Java语言中的hashmap.不过空间是否割裂,这并没有任何关系,只要能用就行。但是这种碎片化的分配策略与线性的分配策略相比,除了会造成CPU更多的计算(要进行散列计算)外,还会造成更多的存储空间浪费。这种浪费体现在CPU对每一个堆栈的首地址的记忆上。如图所示是一种线性的、顺序分配的堆栈布局图。。在这种线性顺序排列的堆栈布局中,CPU只需要知道每一个函数所需堆栈的大小,以及第一个函数的堆栈起始地址(栈底),就可以推算除后续其他函数堆栈的起始地址。事实上,对于现在成熟的计算机和各种编程语言而言,都不需要专门计算某个函数的堆栈起始地址,每一个函数的栈底地址直接等同于其调用者函数的栈顶。但是如果按照散列策略来分配堆栈空间,调用者函数的堆栈空间首末地址与被调用者函数的堆栈空间首末地址之间毫无关系,则CPU需要将每次散列得到的函数堆栈空间的起始地址专门保存起来,否则当程序流从调用者函数跳转到被调用者时,CPU无法定位到被调用者函数的堆栈空间起始地址,更不用谈为被调用者函数内部的局部变量分配空间了。而当被调用函数执行结束、程序流跳转回调用函数时,CPU需要重新定位到调用函数的堆栈空间地址。所以,调用函数的堆栈空间需要额外留出一个指针的宽度用于保存被调用函数的堆栈空间地址,而被调用函数的堆栈空间也需要额外流出一个指针的宽度用于保存调用函数的堆栈空间地址。当调用函数调用多个函数时,调用函数就可能需要留出若干空间分别保存多个被调用函数的堆栈空间地址。当然,也不一定要将这些堆栈空间的地址保存在函数的堆栈空间中,但是不管保存到哪里,都是需要消耗宝贵的内存空间的。
除了需要浪费额外的内存空间去保存函数的堆栈空间地址外,在CPU读取堆栈空间地址时也需要额外的计算。对于线性顺序布局的堆栈空间,当从调用函数进入被调用函数时,由于SP寄存器保存了调用函数的栈顶地址,因此CPU可以直接通过"mov %sp, %bp"这种纯粹寄存器数据传送的方式读取到被调用函数的栈底,在计算机内部,没有什么比纯寄存器之间的直接数据传送速度更快了。而在随机散列策略中,由于被调用函数的堆栈空间地址被保存在内存中,因此无论如何,都避免不了CPU对内存的访问,而数据在内存和寄存器之间的传送速度往往要比数据直接在寄存器之间的传送效率低百倍以上。
更进一步,随机散列策略除了时间和空间上的小,对堆栈的优化也被死死束缚。在Java虚拟机中,对堆栈有一项重要的优化策略是堆栈重叠(调用方法的操作数栈与被调用方法的局部变量表重叠),这种堆栈重叠的方式能够避免调用函数内部局部变量向被调用函数堆栈复制被调用函数的入参,从而能够减少相当可观的内存数据复制。这种现象不仅Java虚拟机中会有,其他编程语言中也会有,例如一些主流编译器的优化选项都支持这种策略。更有甚者,当在开发底层驱动器或者视频核心逻辑时,部分代码时必须直接用汇编写的,这时候就可以手动直接让堆栈重叠,例如上面的main()函数和add()函数,在Linux平台上编译后的得到的汇编程序如图所示。上面这段汇编脚本比较中规中矩,当main()函数调用add()函数传递参数时,老老实实地将add()函数的入参复制了一遍,由于add()函数包含两个入参,因此一共使用了4条指令完成两个参数的压栈
更进一步,随机散列策略除了时间和空间上的小,对堆栈的优化也被死死束缚。在Java虚拟机中,对堆栈有一项重要的优化策略是堆栈重叠(调用方法的操作数栈与被调用方法的局部变量表重叠),这种堆栈重叠的方式能够避免调用函数内部局部变量向被调用函数堆栈复制被调用函数的入参,从而能够减少相当可观的内存数据复制。这种现象不仅Java虚拟机中会有,其他编程语言中也会有,例如一些主流编译器的优化选项都支持这种策略。更有甚者,当在开发底层驱动器或者视频核心逻辑时,部分代码时必须直接用汇编写的,这时候就可以手动直接让堆栈重叠,例如上面的main()函数和add()函数,在Linux平台上编译后的得到的汇编程序如图所示。上面这段汇编脚本比较中规中矩,当main()函数调用add()函数传递参数时,老老实实地将add()函数的入参复制了一遍,由于add()函数包含两个入参,因此一共使用了4条指令完成两个参数的压栈
如果这段程序由人工编写,则可以进行比较激进的优化,省略复制参数的4条指令。在add()函数中读取入参时,直接基于add()堆栈栈底往上偏移,,进入main()函数的堆栈空间读取入参。激进优化后的汇编指令如图所示。修改之后,main()函数少了4条参数赋值的指令,同时main()函数的堆栈空间只需要分配16字节,而原来是32字节。add()函数直接从main()函数的堆栈中入参的原始内存位置处读取数据。这种优化方案所带来的性能提升还是相当可观的,这就是线性顺序内存分配所带来的好处,只要你想得到,就能做得到。而采用随机散列的方式分配函数堆栈,无法享受这些优化措施所带来的的性能提升红利。
由于线性顺序分配栈帧(堆栈空间)有这么多好处,因此称为涉及栈帧容器的上佳策略。而线性顺序存储元素成员的容器有好几种,最主要的两种容器分别是堆式容器(队列)和栈式容器,到了这里大家都知道了,堆与栈的区别就是前者是FIFO(先进先出),后者是FILO(先进后出)。由于函数调用过程是链式路径,越是最后调用的函数,其栈帧(堆栈空间)就越分配得晚;越是最后调用的函数,其栈帧空间也越早被释放,所以自然就使用栈式结构来作为栈帧的容器,这就是堆栈的由来。
由于线性顺序分配栈帧(堆栈空间)有这么多好处,因此称为涉及栈帧容器的上佳策略。而线性顺序存储元素成员的容器有好几种,最主要的两种容器分别是堆式容器(队列)和栈式容器,到了这里大家都知道了,堆与栈的区别就是前者是FIFO(先进先出),后者是FILO(先进后出)。由于函数调用过程是链式路径,越是最后调用的函数,其栈帧(堆栈空间)就越分配得晚;越是最后调用的函数,其栈帧空间也越早被释放,所以自然就使用栈式结构来作为栈帧的容器,这就是堆栈的由来。
到了这里,还有个问题没有解决,那就是操作系统一般都是从高位地址开始往下扩展堆栈空间。堆栈作为一个内存容器,并不一定非得从高位地址开始往下增长,也可以从地位地址开始往高地址内存方向增长,有部分操作系统就是这么干的。而更重要的一点,对于一个应用程序,操作系统明显地回将程序的内存空间区分为堆和栈(当然还有代码区等),堆区往高地址方向增长,栈区往低地址方向增长,这又是为何呢?换句话说,操作系统完全可以不区分所谓的堆区和栈区,大家都只是内存中的一部分空间而已,管它是啥结构,一股脑儿全部分配在内存中,也不是不可行。软件程序中的所谓堆和栈的容器就全部分配在堆内存中,也没件有啥问题。事实上,这里需要区分操作系统和应用程序的内存结构。堆与操作系统,其上面回运行若干应用程序,如果操作系统不将应用程序的堆内存和栈内存区分开来,那么内存空间就会相互"打架",并且回破坏函数栈帧在空间上的线性连续性。还是拿上面的main()函数和add()函数举例,假设main()函数的堆栈从内存为0的地址处开始分配空间(这种情况事实上式不存在的,这里仅仅为了举例方便),main()函数堆栈需要32字节的空间,因此当main()函数的堆栈空间分配完之后,下一个数据只能从第32内存单元开始分配空间。假设这是main()函数调用了alloc()函数向操作系统申请内存空间,那么操作系统就会从第32个内存单元开始分配空间(注意,这种假设的前提是操作系统没有将应用程序的内存区分为堆内存和栈内存),等分配完了alloc()函数所申请的内存空间后,main()函数开始调用add()函数,此时CPU会为add()函数分配栈空间挂件,而栈空间的起始位置就是刚才alloc()函数所申请的内存的末端位置。这样依赖,main()函数与add()函数的栈帧(堆栈空间)在内存空间上的线性顺序就被破坏了,如此依赖,调用函数与被调用函数的栈帧在空间上便处于割裂状态,这与上面所举的散列方案所产生的内存空间布局也没什么两样。所以,这就要求存储程序函数栈帧的容器——堆栈,在内存空间分布上必须是完全线性的,中间不能出现任何空间分隔。既然如此,操作系统就必须将应用程序的堆内存空间与栈内存空间完全隔离开来,将此作为操作系统治理内存的基本总之,而这也是程序函数堆栈这个容器级别的数据接哦古与软件程序中的堆栈容器的数据结构之间最大的不同点。
在软件程序中涉及的所谓堆栈容器,栈中的元素也可以是容器,但是栈中往往仅保存对应元素的容器的指针,指针所指向的容器在堆中开辟空间,这便导致各个元素所指向的容器空间之间并不是连续的线性分布,例如,下面这个程序是用C语言所编写的堆栈容器,堆栈的元素是栈帧,栈帧也是一个容器https://github.com/2over/stack-c 移步github,本实例程序中定义了两个结构体,分别是stack与frame,里面包含栈底、栈顶和堆栈大小3个成员项。frame则用于构建栈帧。运行main()函数,会打印如图所示的内容,注意看程序所打印出来的3个栈帧的内存空间,这3个地址之间没有任何联系,这是因为这3个栈帧完全是操作系统随机分配的内存空间。
在软件程序中涉及的所谓堆栈容器,栈中的元素也可以是容器,但是栈中往往仅保存对应元素的容器的指针,指针所指向的容器在堆中开辟空间,这便导致各个元素所指向的容器空间之间并不是连续的线性分布,例如,下面这个程序是用C语言所编写的堆栈容器,堆栈的元素是栈帧,栈帧也是一个容器https://github.com/2over/stack-c 移步github,本实例程序中定义了两个结构体,分别是stack与frame,里面包含栈底、栈顶和堆栈大小3个成员项。frame则用于构建栈帧。运行main()函数,会打印如图所示的内容,注意看程序所打印出来的3个栈帧的内存空间,这3个地址之间没有任何联系,这是因为这3个栈帧完全是操作系统随机分配的内存空间。
所以说,软件程序里所定义的栈帧结构,与操作系统层面的程序函数堆栈结构之间还是有差别的。本例动态创建的3个栈帧,内存布局完全是无序和随机的,它们的内存空间的结构如图所示。而程序函数堆栈中的各个栈帧之间是完全紧密顺序排列的,栈帧之间不可能出现别的数据
当然,使用软件起始也能模拟出程序函数堆栈的空间分布,将上面的程序进行改造,使之分配的栈帧彼此首尾相连,改造后的程序,在https://github.com/2over/stack-c/tree/main.改造后的程序,栈帧内存空间分配算法有所改变,完成堆栈这个大容器统一分配一整块连续的内存,然后在这块内存空间里按顺序存放栈帧。当堆栈容量不够时,通过扩容重新分配一整块连续的内存空间,然后再重新按顺序进行排列。现在运行的main()函数,会打印如图所示的内容。注意看程序所打印出来的4个栈帧的内存地址,现在这4个地址之间是有联系的,第4个栈帧的内存地址与第3个栈帧内存地址之差是0x10,正好是struct Frame这个结构体的大小。同样,第3个栈帧内存地址与第2个栈帧的内存地址只差也是0x10,第2个栈帧内存地址与第1个栈帧的内存地址只差也是0x10,由此可见栈帧之间的确是线性按顺序分配空间的,。
这种堆栈空间布局基本与程序函数调用的堆栈空间布局是一样的,布局结构体如图所示。
综上所述,程序函数调用的堆栈的成员是栈帧,"堆栈"作为一个容器,其内部存储的是栈帧元素。而栈帧又是一个小容器,存储函数内部的局部变量。函数调用堆栈与软件程序里所涉及的堆栈结构的最大不同之处在于,函数调用堆栈的成员(栈帧)之间在空间上是连续分布的,而软件程序里所设计的堆栈结构,其成员元素往往并不相连。
综上所述,程序函数调用的堆栈的成员是栈帧,"堆栈"作为一个容器,其内部存储的是栈帧元素。而栈帧又是一个小容器,存储函数内部的局部变量。函数调用堆栈与软件程序里所涉及的堆栈结构的最大不同之处在于,函数调用堆栈的成员(栈帧)之间在空间上是连续分布的,而软件程序里所设计的堆栈结构,其成员元素往往并不相连。
硬件对堆栈的支持。
为了支持应用程序函数栈帧所形成的堆栈结构,系统设计的老前辈们还从硬件上予以支持,专门设计了两个硬件寄存器——SP和BP,用于存储当前函数栈帧的栈底和栈顶。在上面使用C语言模拟程序堆栈和栈帧的示例中,在结构体stack中专门定义了两个指针base和top,分别指向堆栈的底部栈帧和顶部栈帧,注意这里不是栈顶指针和栈底指针呦!在操作系统层面,栈底和栈顶是针对栈帧而言的,例如,该示例中有4个栈帧frame,如果是真实的函数栈帧,则每一个栈帧都会有一个栈顶和栈底指针,用于标识函数堆栈的起始地址和末端地址。而使用软件实现的栈帧,由于其数据结构是固定大小的,因此通过指针持有其起始地址,自然就知道其末端地址,何况使用软件实现堆栈和栈帧时,也不需要知道末端地址,直接通过强类型转换就能读取到栈帧各个变量的值,所以大部分使用软件实现的所谓堆栈,其栈底指针和栈顶指针更多的是用来指向整个堆栈容器的底部栈帧和顶部栈帧,如图所示
为了支持应用程序函数栈帧所形成的堆栈结构,系统设计的老前辈们还从硬件上予以支持,专门设计了两个硬件寄存器——SP和BP,用于存储当前函数栈帧的栈底和栈顶。在上面使用C语言模拟程序堆栈和栈帧的示例中,在结构体stack中专门定义了两个指针base和top,分别指向堆栈的底部栈帧和顶部栈帧,注意这里不是栈顶指针和栈底指针呦!在操作系统层面,栈底和栈顶是针对栈帧而言的,例如,该示例中有4个栈帧frame,如果是真实的函数栈帧,则每一个栈帧都会有一个栈顶和栈底指针,用于标识函数堆栈的起始地址和末端地址。而使用软件实现的栈帧,由于其数据结构是固定大小的,因此通过指针持有其起始地址,自然就知道其末端地址,何况使用软件实现堆栈和栈帧时,也不需要知道末端地址,直接通过强类型转换就能读取到栈帧各个变量的值,所以大部分使用软件实现的所谓堆栈,其栈底指针和栈顶指针更多的是用来指向整个堆栈容器的底部栈帧和顶部栈帧,如图所示
对于软件实现的堆栈,只需要获取指向堆栈顶部栈帧的指针,就能实现压栈和出栈的功能。而程序函数调用时为函数所开辟的堆栈空间,由于不同的函数其内部局部变量不同,因此栈帧空间结构不同,大小不同,因此必须要分别保存函数栈帧的栈顶地址与栈底地址。如果该实例中的4个栈帧是真实地函数调用堆栈,则CPU会分别使用SP和BP这两个寄存器存储当前位于堆栈顶部的栈帧的栈顶和栈底,结构示意图如图所示。
起始如果没有SP和BP这两个硬件寄存器,系统也能完成对堆栈顶部函数栈帧的栈顶与栈底标记,但是那样势必要将栈顶与栈底地址保存到内存,而内存访问的性能与寄存器的访问性能完全不在一个档次。所以函数栈帧的空间分配起始用纯软件也能实现,只不过借助两个特定的寄存器硬件,使得这一效率更高而已
起始如果没有SP和BP这两个硬件寄存器,系统也能完成对堆栈顶部函数栈帧的栈顶与栈底标记,但是那样势必要将栈顶与栈底地址保存到内存,而内存访问的性能与寄存器的访问性能完全不在一个档次。所以函数栈帧的空间分配起始用纯软件也能实现,只不过借助两个特定的寄存器硬件,使得这一效率更高而已
当CPU完成一个函数的执行之后,程序流会跳转到当前函数的调用方,同时,CPU需要回收掉当前函数的栈帧空间,并使SP和BP这两个寄存器重新指向调用者函数的栈顶和栈底,CPU回收函数栈帧空间很简单,只需要将SP往BP的方向移动一定距离即可,但是CPU如何将SP与BP重定向至调用者函数的栈顶和栈底呢?其实也很简单,由于真实的程序函数调用堆栈的各个栈帧都是首尾相连的,被调用函数的栈底就是调用函数的栈顶,因此当CPU完成函数调用后,只需要将被调用函数的SP指向被调用函数的BP,由于被调用函数的BP恰好就是调用函数的SP,因此这就相当于CPU将SP重新指向到了调用者函数的栈顶。所以剩下的事就是CPU如何将BP也恢复到调用者函数的栈底。
在上面使用C语言模拟函数调用堆栈和栈帧的示例中,为了实现出栈后能够定位到上一个栈帧,在每一个栈帧中都存储了一个pre指针,每次压入一个新的栈帧时,新的栈帧pre指针就指向原本位于栈帧顶部的栈帧的起始地址,所以,当讲位于堆栈顶部的栈帧出栈时,只需通过读取pre指针的值,就能定位到上一个栈帧,从而将堆栈的栈顶指针top重定向至新的堆栈顶部的栈帧,其实,在本示例中(特指上面改造后的示例程序,在改造后的示例程序中,各个栈帧是线性顺序分布的,首尾相连),由于所模拟的各个栈帧的数据结构相同,因此各个栈帧所占内存空间大小也相同,所以完全可以不依赖pre指针就可以直接计算出相邻的栈帧位置。
但是在机器的函数调用堆栈层面,由于不同的函数定义完全不同,因此函数的栈帧结构也完全不同,大小更不可能相同,所以当进行出栈时,想要将BP恢复至调用者函数的栈底,如果当前函数没有保存调用者函数的栈底地址,是根本无法恢复回去的。所以,在真实的函数调用中,物理机器必定会将调用函数的栈底地址压入到被调用函数的堆栈之中,因而,使用C语言及其他很多语言编写的程序被编译后,所生成的每一个函数的机器指令必定以下面这两天条指令作为"起式":
push %bp
move %sp, %bp
第一条指令就是往被调用函数的堆栈中压入调用函数的栈底地址,这便是物理机器对程序调用堆栈的压栈与出栈的支持及算法,说起来,实在是简单至极,与使用C语言所模拟的堆栈和栈帧的示例程序中使用pre指针来恢复至上一个栈帧是同一个道理。
由此更加可以看出来,计算机程序调用的压栈与出栈机制,即使不使用BP和SP这两个硬件寄存器,也是完全可以实现的,使用纯软件算法一样可以实现,SP与BP的加入纯粹是为了提升效率
在上面使用C语言模拟函数调用堆栈和栈帧的示例中,为了实现出栈后能够定位到上一个栈帧,在每一个栈帧中都存储了一个pre指针,每次压入一个新的栈帧时,新的栈帧pre指针就指向原本位于栈帧顶部的栈帧的起始地址,所以,当讲位于堆栈顶部的栈帧出栈时,只需通过读取pre指针的值,就能定位到上一个栈帧,从而将堆栈的栈顶指针top重定向至新的堆栈顶部的栈帧,其实,在本示例中(特指上面改造后的示例程序,在改造后的示例程序中,各个栈帧是线性顺序分布的,首尾相连),由于所模拟的各个栈帧的数据结构相同,因此各个栈帧所占内存空间大小也相同,所以完全可以不依赖pre指针就可以直接计算出相邻的栈帧位置。
但是在机器的函数调用堆栈层面,由于不同的函数定义完全不同,因此函数的栈帧结构也完全不同,大小更不可能相同,所以当进行出栈时,想要将BP恢复至调用者函数的栈底,如果当前函数没有保存调用者函数的栈底地址,是根本无法恢复回去的。所以,在真实的函数调用中,物理机器必定会将调用函数的栈底地址压入到被调用函数的堆栈之中,因而,使用C语言及其他很多语言编写的程序被编译后,所生成的每一个函数的机器指令必定以下面这两天条指令作为"起式":
push %bp
move %sp, %bp
第一条指令就是往被调用函数的堆栈中压入调用函数的栈底地址,这便是物理机器对程序调用堆栈的压栈与出栈的支持及算法,说起来,实在是简单至极,与使用C语言所模拟的堆栈和栈帧的示例程序中使用pre指针来恢复至上一个栈帧是同一个道理。
由此更加可以看出来,计算机程序调用的压栈与出栈机制,即使不使用BP和SP这两个硬件寄存器,也是完全可以实现的,使用纯软件算法一样可以实现,SP与BP的加入纯粹是为了提升效率
栈帧开辟与回收。
前面讲过,操作系统为了简化内存治理,便将程序的内存空间划分成对堆和占,并且栈必须在内存空间上是线路分布的,而堆则可以随机分配。其实,操作系统强行将堆与栈分开的另一个原因,想必也是由两者的空间分布的特点不同所引起的。使用C/C++语言开发程序的一大难点便是内存管理(另一大难点自然就是多线程与并发了),内存管理的难点在于一不小心就容易造成内存泄露,而造成内存泄露的关键原因就是忘记释放已经申请的堆内存空间。对于使用malloc()接口所申请的内存空间,如果不是使用free()去登记释放,除非程序运行结束,否则这部分申请的内存是不会被自动回收的。如果malloc()接口在一个多线程/多进程环境中被调用,那么要不了多久,操作系统内存空间便会耗尽而使系统崩溃。
对于简单的程序设计,还能看出来哪些变量只申请了内存而没有释放,而对于一个大型的程序而言,经过层层封装,一不小心,便很容易将内存释放的事情遗忘掉。所以开发C/C++程序真是陷阱重重。相比于堆内存空间管理的严酷要求,栈的开辟和释放就显得十分简单。想开辟一个栈空间,只需要调用如下指令:
sub $32, %sp
调用这条指令后,直接就将SP指针往前移动32个存储单元的距离,相当简单。而当一个函数执行完后,需要释放其对应的栈帧空间,也只需要调用下面的指令:
add $32, %sp
将SP指针再移回去32组黑的距离就行了,当然,对于高级编程语言,这条指令基本是看不到的,因为直接被封装在leave指令里了。
总之,不管是为了能够高效实现栈帧的压栈与出栈而将堆与栈空间相分离,还是纯粹为了堆与栈分开治理而发现了分离后能够享受到高效的压栈与出栈算法所带来的性能提升,总之,将应用程序的堆与栈隔离开来,并且规定栈帧在堆栈空间内必须线性连续分布,而且还借助于SP于BP这两个硬件寄存器来实现高效的压栈与出栈指令,都是历史上那些伟大的软件设计师经过千锤百炼后所得出的最优方案,是精华中的精华。
前面讲过,操作系统为了简化内存治理,便将程序的内存空间划分成对堆和占,并且栈必须在内存空间上是线路分布的,而堆则可以随机分配。其实,操作系统强行将堆与栈分开的另一个原因,想必也是由两者的空间分布的特点不同所引起的。使用C/C++语言开发程序的一大难点便是内存管理(另一大难点自然就是多线程与并发了),内存管理的难点在于一不小心就容易造成内存泄露,而造成内存泄露的关键原因就是忘记释放已经申请的堆内存空间。对于使用malloc()接口所申请的内存空间,如果不是使用free()去登记释放,除非程序运行结束,否则这部分申请的内存是不会被自动回收的。如果malloc()接口在一个多线程/多进程环境中被调用,那么要不了多久,操作系统内存空间便会耗尽而使系统崩溃。
对于简单的程序设计,还能看出来哪些变量只申请了内存而没有释放,而对于一个大型的程序而言,经过层层封装,一不小心,便很容易将内存释放的事情遗忘掉。所以开发C/C++程序真是陷阱重重。相比于堆内存空间管理的严酷要求,栈的开辟和释放就显得十分简单。想开辟一个栈空间,只需要调用如下指令:
sub $32, %sp
调用这条指令后,直接就将SP指针往前移动32个存储单元的距离,相当简单。而当一个函数执行完后,需要释放其对应的栈帧空间,也只需要调用下面的指令:
add $32, %sp
将SP指针再移回去32组黑的距离就行了,当然,对于高级编程语言,这条指令基本是看不到的,因为直接被封装在leave指令里了。
总之,不管是为了能够高效实现栈帧的压栈与出栈而将堆与栈空间相分离,还是纯粹为了堆与栈分开治理而发现了分离后能够享受到高效的压栈与出栈算法所带来的性能提升,总之,将应用程序的堆与栈隔离开来,并且规定栈帧在堆栈空间内必须线性连续分布,而且还借助于SP于BP这两个硬件寄存器来实现高效的压栈与出栈指令,都是历史上那些伟大的软件设计师经过千锤百炼后所得出的最优方案,是精华中的精华。
堆栈大小与多线程。
现代操作系统都支持多进程与多线程机制,允许同一个应用程序同时运行若干进程或线程,作为虚拟操作系统的JVM自然也是支持多进程与多线程应用的。沿着上文对堆栈与栈帧认识的思路,可以继续往下推断在多进程与多线程机制下的栈空间分配机制,对于这些机制,不用看任何理论,也不用看操作系统原理相关的书,完全借助于上文所得到的结论便可分析。前面讲过,操作系统会将一个应用程序的内存空间划分成对和栈这两大部分,函数的栈帧只能在栈空间分配。但是多进程/多线程环境下,就会有一个关键的问题:在多进程或多线程的环境下,操作系统会为一个进程/线程单独划分一个栈空间,还是会让一个应用程序的所有进程/线程共同使用同一个栈空间呢?说白了就是,在多线程环境下,操作系统是否会进一步将引用程序的栈空间划分成更多的子块?
我们可以先从一个假设触发去论证。假设操作系统不将应用程序的栈空间按照线程的粒度进行细分,而是让全部线程共同使用同一个堆栈空间,那么随着一个应用程序的多个线程交替执行,该应用程序的堆栈空间布局就可能会变成如图所示的样子。(注意:为了简化问题分析,本图在栈帧之间省略了除bp以外的其他堆栈所必需的数据),,假设该应用同时运行了4个线程,并且恰好这4个线程依次执行了func1()这个函数,那么此时的堆栈内的栈帧空间布局就会如图所示。当出现这种内存布局时,就违反了操作系统关于堆栈空间布局的最核心的原则:栈帧必须是连续分布的。例如对于线程1,其func1的下一个栈帧应该是func2()(假设func1调用了func2),但是现在却变成了线程2的func1()函数的栈帧。这样不连续的内存布局无论是对新函数的栈帧分配还是函数执行结束时的栈帧回收,都会造成相当大的问题,程序的堆栈空间变得杂乱无章,各个线程的堆栈空间相互覆盖重写,世界一片大乱。
现代操作系统都支持多进程与多线程机制,允许同一个应用程序同时运行若干进程或线程,作为虚拟操作系统的JVM自然也是支持多进程与多线程应用的。沿着上文对堆栈与栈帧认识的思路,可以继续往下推断在多进程与多线程机制下的栈空间分配机制,对于这些机制,不用看任何理论,也不用看操作系统原理相关的书,完全借助于上文所得到的结论便可分析。前面讲过,操作系统会将一个应用程序的内存空间划分成对和栈这两大部分,函数的栈帧只能在栈空间分配。但是多进程/多线程环境下,就会有一个关键的问题:在多进程或多线程的环境下,操作系统会为一个进程/线程单独划分一个栈空间,还是会让一个应用程序的所有进程/线程共同使用同一个栈空间呢?说白了就是,在多线程环境下,操作系统是否会进一步将引用程序的栈空间划分成更多的子块?
我们可以先从一个假设触发去论证。假设操作系统不将应用程序的栈空间按照线程的粒度进行细分,而是让全部线程共同使用同一个堆栈空间,那么随着一个应用程序的多个线程交替执行,该应用程序的堆栈空间布局就可能会变成如图所示的样子。(注意:为了简化问题分析,本图在栈帧之间省略了除bp以外的其他堆栈所必需的数据),,假设该应用同时运行了4个线程,并且恰好这4个线程依次执行了func1()这个函数,那么此时的堆栈内的栈帧空间布局就会如图所示。当出现这种内存布局时,就违反了操作系统关于堆栈空间布局的最核心的原则:栈帧必须是连续分布的。例如对于线程1,其func1的下一个栈帧应该是func2()(假设func1调用了func2),但是现在却变成了线程2的func1()函数的栈帧。这样不连续的内存布局无论是对新函数的栈帧分配还是函数执行结束时的栈帧回收,都会造成相当大的问题,程序的堆栈空间变得杂乱无章,各个线程的堆栈空间相互覆盖重写,世界一片大乱。
所以,为了支持多线程应用,操作系统必须为应用程序的每一个线程专门划分一块连续的区域,各个线程的函数调用堆栈就在各自的内存区域内按照单一方向进行压栈或出栈。所以,真实的多线程应用程序的内存空间布局回事如图所示的情形。与一个应用程序能够运行多线程同理,一个操作系统上会运行若干应用程序,为了让每个应用程序的堆栈空间能够独自完整地遵循线性顺序分布的原则(中间不允许由割裂),其实操作系统在加载一个应用程序时,就会同时划分一片内存区域给这个应用程序作为其堆栈之用,这块内存区域与其他应用程序的不会重叠。既然操作系统为每一个应用程序划分了一块独立的、连续的内存区域,那么问题又来了,内存空间是有限的,而应用程序和线程却可以有很多,例如一个生产环境中的JVM服务器就会有两三千个线程,何况这仅仅是一个JVM进程而已,操作系统上还要运行其他若干应用程序,而每个应用程序中又会有若干线程。所以,大量的线程与有限的内存容量之间形成了一对尖锐的矛盾,换言之,线程堆栈的空间不是任意大的,一定是会有约束和限制的,否则如果随便一个线程堆栈空间都有一两百兆,那么一个2GB的内存空间也只能同时运行几个线程,,很显然这是肯定无法满足现在操作系统和大型应用程序的需求的。所以,几乎所有主流的操作系统都会有默认堆栈大小的设置。例如,在64位Linux操作系统上,默认的堆栈空间大小是1024KB.即1MB。而对于JVM而言,开发者可以自行设置Java线程堆栈的空间大小。有的小伙伴可能认为1MB的内存空间也太小了吧。例如Java程序,往往一个稍微复杂点的函数,里面就定义了若干变量,并且一个程序稍微大了一点、复杂一点,其函数调用堆栈都是很深的,1MB的堆栈空间貌似远远不够吧。
别急,1MB的堆栈空间到底够不够,算一算就知道了。以Java程序为例,由于Java是面向对象的编程语言,因此在线程堆栈上只有指向对象的指针,对象的实例并不在堆栈上(JVM为了优化而内部实现的栈上对象分配策略不算在内)。假设Java函数里全是对象,并且假设JVM没有开启指针压缩选项,那么在64位机器上一个指针占用64比特的内存空间,1MB内存空间空间可以容纳的指针对象数量是:
1* 1024*1024 * 8 / 64 = 131072
呵呵,不多,也就只能容纳13万多点儿的指针宽度!假设程序的每一个函数里都有100个局部变量(连同JVM所创建的Java栈帧持有的数据),那么1MB的堆栈空间理论上可以1万多个函数栈帧,即能够支持1万多次的函数顺序调用,专业一点就是支持的最大堆栈深度达到1万以上。
什么样复杂的程序能够达到1万以上的堆栈深度呢?所以,其实1MB的堆栈空间对于绝大多数函数而言,,已经显得十分巨大,大部分程序的线程的实际最大堆栈深度远远达不到1万,所以1MB的堆栈空间对这些线程而言显然是比较浪费的。
别急,1MB的堆栈空间到底够不够,算一算就知道了。以Java程序为例,由于Java是面向对象的编程语言,因此在线程堆栈上只有指向对象的指针,对象的实例并不在堆栈上(JVM为了优化而内部实现的栈上对象分配策略不算在内)。假设Java函数里全是对象,并且假设JVM没有开启指针压缩选项,那么在64位机器上一个指针占用64比特的内存空间,1MB内存空间空间可以容纳的指针对象数量是:
1* 1024*1024 * 8 / 64 = 131072
呵呵,不多,也就只能容纳13万多点儿的指针宽度!假设程序的每一个函数里都有100个局部变量(连同JVM所创建的Java栈帧持有的数据),那么1MB的堆栈空间理论上可以1万多个函数栈帧,即能够支持1万多次的函数顺序调用,专业一点就是支持的最大堆栈深度达到1万以上。
什么样复杂的程序能够达到1万以上的堆栈深度呢?所以,其实1MB的堆栈空间对于绝大多数函数而言,,已经显得十分巨大,大部分程序的线程的实际最大堆栈深度远远达不到1万,所以1MB的堆栈空间对这些线程而言显然是比较浪费的。
对于Java应用程序而言,可以使用下面的程序测试堆栈大小设置:
默认设置下,堆栈大小是1MB,因此Java程序的默认线程堆栈大小也是1MB。
默认设置下,堆栈大小是1MB,因此Java程序的默认线程堆栈大小也是1MB。
JVM的栈帧
JVM栈帧与大小确定。
Java虚拟机是建立在物理操作系统之上的虚拟系统,其栈帧与物理操作系统上的栈帧有所不同。物理操作系统直接基于CPU硬件执行指令,而Java虚拟机只能基于栈式指令集运行,两者不同的运行机制决定了栈帧结构的不同,但是,Java虚拟机本身其实并没有真正的执行能力,说到底最终还是不得不调用CPU硬件指令去完成程序逻辑,所以Java函数的栈帧与物理机器的栈帧之间又存在莫大的内在联系,这种联系体现在两方面:
1.堆栈的设计
Java函数的堆栈设计直接借用物理操作系统的堆栈管理思想,并无创新。Java虚拟机也将一个Java应用程序的内存空间划分成堆内存与栈内存,分别治理。另一方面,Java函数的调用链路既然也要使用"堆栈"这种容器级别的数据结构,本身就决定了Java应用程序的堆与栈必须要分别划分。
2.栈帧的设计
堆栈作为栈帧的容器,其设计思路一旦确定,栈帧的设计也必然随之被确定,逃不出"压栈"与"出栈"这种法则,否则Java函数调用链路的容器就不是堆栈这种数据结构了。既然Java函数的内部数据使用栈帧这种容器进行存储,则必然导致Java函数的栈帧也要存储Java函数内的局部变量。同时,上文也推导过,当一个操作系统决定使用"堆栈"这种容器对"栈帧"这种子容器进行线性顺序存储的策略时,由于不同函数其内部所包含的局部变量类型和数量都不同,因此栈帧大小也不同,为了支持当一个函数被执行完成后,能够让CPU重新定位到该函数的调用者函数的堆栈中去,一种最简单的算法就是在被调用函数的堆栈中保存调用函数的栈底地址,既然是最简单的算法,Java虚拟机当然也得借用这种算法。所以,Java栈帧除了需要保存Java方法内的局部变量外,还需要保存一些支持堆栈开辟于回收的上下文数据。Java栈帧里保存Java方法内部局部变量的区域叫做"局部变量表",而Java栈帧里保存上下文数据的区域叫做"JVM帧数据"。
所以,现在知道Java栈帧至少由局部变量表和JVM帧数据所组成。众所周知,Java的指令集是面向栈的,是栈式指令集。与栈式指令集相对的是寄存器指令集。其中寄存器指令集直接被CPU硬件所支持。对于基于寄存器指令集所设计的软件程序,其逻辑处理皆与寄存器紧密相关,一点都脱不开干系,例如下面的示例程序:本程序使用汇编语言定义了一个add(int,int)函数,在函数里面无论是读取入参,还是对两个整数进行求和,都需要通过寄存器方能完成
Java虚拟机是建立在物理操作系统之上的虚拟系统,其栈帧与物理操作系统上的栈帧有所不同。物理操作系统直接基于CPU硬件执行指令,而Java虚拟机只能基于栈式指令集运行,两者不同的运行机制决定了栈帧结构的不同,但是,Java虚拟机本身其实并没有真正的执行能力,说到底最终还是不得不调用CPU硬件指令去完成程序逻辑,所以Java函数的栈帧与物理机器的栈帧之间又存在莫大的内在联系,这种联系体现在两方面:
1.堆栈的设计
Java函数的堆栈设计直接借用物理操作系统的堆栈管理思想,并无创新。Java虚拟机也将一个Java应用程序的内存空间划分成堆内存与栈内存,分别治理。另一方面,Java函数的调用链路既然也要使用"堆栈"这种容器级别的数据结构,本身就决定了Java应用程序的堆与栈必须要分别划分。
2.栈帧的设计
堆栈作为栈帧的容器,其设计思路一旦确定,栈帧的设计也必然随之被确定,逃不出"压栈"与"出栈"这种法则,否则Java函数调用链路的容器就不是堆栈这种数据结构了。既然Java函数的内部数据使用栈帧这种容器进行存储,则必然导致Java函数的栈帧也要存储Java函数内的局部变量。同时,上文也推导过,当一个操作系统决定使用"堆栈"这种容器对"栈帧"这种子容器进行线性顺序存储的策略时,由于不同函数其内部所包含的局部变量类型和数量都不同,因此栈帧大小也不同,为了支持当一个函数被执行完成后,能够让CPU重新定位到该函数的调用者函数的堆栈中去,一种最简单的算法就是在被调用函数的堆栈中保存调用函数的栈底地址,既然是最简单的算法,Java虚拟机当然也得借用这种算法。所以,Java栈帧除了需要保存Java方法内的局部变量外,还需要保存一些支持堆栈开辟于回收的上下文数据。Java栈帧里保存Java方法内部局部变量的区域叫做"局部变量表",而Java栈帧里保存上下文数据的区域叫做"JVM帧数据"。
所以,现在知道Java栈帧至少由局部变量表和JVM帧数据所组成。众所周知,Java的指令集是面向栈的,是栈式指令集。与栈式指令集相对的是寄存器指令集。其中寄存器指令集直接被CPU硬件所支持。对于基于寄存器指令集所设计的软件程序,其逻辑处理皆与寄存器紧密相关,一点都脱不开干系,例如下面的示例程序:本程序使用汇编语言定义了一个add(int,int)函数,在函数里面无论是读取入参,还是对两个整数进行求和,都需要通过寄存器方能完成
但是Java语言不是这种机制,对于下面这段Java程序:编译后得到的字节码指令如下:
public static int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn
LineNumberTable:
line 5: 0
line 6: 4
大家可以看到,Java语言被编译后生成的指令,没有一条是与硬件寄存器相关的,反而大部分都是围绕栈的指令,例如,iload将数据压栈,istore将栈顶数据弹出,iadd则对栈顶的两个数据进行累加,等等。
直接基于寄存器的只能怪之所以能够操作寄存器,那是因为每个寄存器都能用来存储数据,只不过仅能存储一个数据。寄存器指令对寄存器的操作其实就是对相应寄存器中的数据进行的操作。同理,栈式指令集若想对栈中的数据进行操作,前提就是得有一部分内存能够被用作栈空间,如此,栈式指令集才能对栈中的数据进行逻辑运算。
public static int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn
LineNumberTable:
line 5: 0
line 6: 4
大家可以看到,Java语言被编译后生成的指令,没有一条是与硬件寄存器相关的,反而大部分都是围绕栈的指令,例如,iload将数据压栈,istore将栈顶数据弹出,iadd则对栈顶的两个数据进行累加,等等。
直接基于寄存器的只能怪之所以能够操作寄存器,那是因为每个寄存器都能用来存储数据,只不过仅能存储一个数据。寄存器指令对寄存器的操作其实就是对相应寄存器中的数据进行的操作。同理,栈式指令集若想对栈中的数据进行操作,前提就是得有一部分内存能够被用作栈空间,如此,栈式指令集才能对栈中的数据进行逻辑运算。
同时,栈容器一定与java函数相关联,或者说JVM必须为每一个Java函数划分一定的空间作为栈式指令集操作的对象,而本来每一个Java函数都有一个与之配套的栈帧空间,所以JVM干脆将函数的栈帧空间与这个操作栈合二为一,以免再维护一套与堆栈类似的内存空间,JVM具体的做法是将操作栈直接嵌入到Java方法的栈帧之中,作为Java方法栈帧的一部分。如此依赖,Java方法就包含了3至少3部分数据:
# Java方法的局部变量表
# Java方法堆栈调用的上下文环境数据
# Java方法的操作数栈
事实上,Java方法栈帧的结构的确与上文所推导出来的结构保持一致。一个比较详细的Java方法栈帧如图所示(从下往上看)
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入到了字节码文件中方法表的Code属性之中。因此,一个栈需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。在这一点上,Java语言与C/C++等语言一样,都是在编译期计算好所需栈帧大小。在这里需要提一下"动态链接"的概念。在C/C++语言中有动态链接库的概念,Java中也有类似的概念。每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。class文件的常量池中有大量的福豪引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接引用,这部分称为动态链接。因此要实现动态链接的关键便是在Java方法栈中持有一个指针,指向常量池,如此便能得到该Java方法的字节码指令,并根据字节码指令映射到机器指令,从而完成方法逻辑处理
# Java方法的局部变量表
# Java方法堆栈调用的上下文环境数据
# Java方法的操作数栈
事实上,Java方法栈帧的结构的确与上文所推导出来的结构保持一致。一个比较详细的Java方法栈帧如图所示(从下往上看)
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入到了字节码文件中方法表的Code属性之中。因此,一个栈需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。在这一点上,Java语言与C/C++等语言一样,都是在编译期计算好所需栈帧大小。在这里需要提一下"动态链接"的概念。在C/C++语言中有动态链接库的概念,Java中也有类似的概念。每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。class文件的常量池中有大量的福豪引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接引用,这部分称为动态链接。因此要实现动态链接的关键便是在Java方法栈中持有一个指针,指向常量池,如此便能得到该Java方法的字节码指令,并根据字节码指令映射到机器指令,从而完成方法逻辑处理
栈帧创建。
HotSpot中生成JVM栈帧的代码如下:
这个过程大体上可以分为以下几步:
#1 恢复return address
#2 创建新的栈帧
#3 将最后一个入参位置压栈
#4 计算Java方法的第一个字节码位置
#5 将methodOop压栈
#6 将ConstantPoolCache压栈
#7 将局部变量表压栈
#8 将第一条字节码指令压栈
#9 将操作数栈底地址压栈
下面开始详细分析这几个步骤。如果将这几个步骤的技术实现都研究清楚了,那么你便会真正明白Java方法栈帧创建的机制
HotSpot中生成JVM栈帧的代码如下:
这个过程大体上可以分为以下几步:
#1 恢复return address
#2 创建新的栈帧
#3 将最后一个入参位置压栈
#4 计算Java方法的第一个字节码位置
#5 将methodOop压栈
#6 将ConstantPoolCache压栈
#7 将局部变量表压栈
#8 将第一条字节码指令压栈
#9 将操作数栈底地址压栈
下面开始详细分析这几个步骤。如果将这几个步骤的技术实现都研究清楚了,那么你便会真正明白Java方法栈帧创建的机制
1.恢复return address
JVM创建栈帧的第一步是恢复return address。所谓的return address的概念,就是eip寄存器,也是JVM调用目标Java方法的call指令的下一条指令的内存地址。这一步在TemplateInterpreterGenerator:generate_fixed_frame(bool native_call)函数中所对应的代码时"--push(rax)",其对应的汇编是"push %eax".
在上面的步骤——"创建局部变量表"中,使用"push %eax"这样的指令连续压栈,将Java方法内部的局部变量压入堆栈,但是在压栈之前,先执行了"pop %eax"这条指令,将return address从栈顶弹出至eax寄存器中。现在Java方法的局部变量全部入栈,于是又将return address再次还原到堆栈中。Java方法局部变量入栈之前和入栈之后的堆栈结构变化如图所示。由于Java方法的入参和Java方法内部的局部变量共同组成了局部变量表,局部变量表作为一个整体,自然不能被分隔,不能硬生生地在中间插入一个return address,因此在对局部变量进行压栈时必然要先将returna address拿掉,待局部变量全部压栈完成之后,再将return address恢复至栈顶
JVM创建栈帧的第一步是恢复return address。所谓的return address的概念,就是eip寄存器,也是JVM调用目标Java方法的call指令的下一条指令的内存地址。这一步在TemplateInterpreterGenerator:generate_fixed_frame(bool native_call)函数中所对应的代码时"--push(rax)",其对应的汇编是"push %eax".
在上面的步骤——"创建局部变量表"中,使用"push %eax"这样的指令连续压栈,将Java方法内部的局部变量压入堆栈,但是在压栈之前,先执行了"pop %eax"这条指令,将return address从栈顶弹出至eax寄存器中。现在Java方法的局部变量全部入栈,于是又将return address再次还原到堆栈中。Java方法局部变量入栈之前和入栈之后的堆栈结构变化如图所示。由于Java方法的入参和Java方法内部的局部变量共同组成了局部变量表,局部变量表作为一个整体,自然不能被分隔,不能硬生生地在中间插入一个return address,因此在对局部变量进行压栈时必然要先将returna address拿掉,待局部变量全部压栈完成之后,再将return address恢复至栈顶
2.开辟新的栈帧。
这里所谓创建新的栈帧,是指通过硬件寄存器真正开始为被调用的Java方法分配堆栈空间。创建栈帧的第一步就是首先要清晰地标识出自己的栈底,栈底就是一个新的栈帧的领域边界。如果没有标识出栈底,则不管分配多少新的堆栈空间,都还只是在别人的栈帧领域之内。栈帧划分自己领域的方法也很简单,执行下面这2条机器指令即可:
# push %ebp
# mov %esp, %ebp
从这里开始,被调用的Java方法终于开始有了自己栈帧的"立脚之处",接下来新增的堆栈空间就都属于被调用的Java方法,而不再是"调用方法"。以后对这块区域内的数据进行访问时,都必须以新的bp(栈底指针)为基准进行偏移寻址,而不能以调用方的bp为基准进行偏移寻址。不过刚才"从这里开始,被调用的Java方法终于开始有了自己栈帧的立脚之处"这种说法却不够严谨,因为被调用的Java方法并不是从这里才开始拥有自己的栈帧空间的,在前面的步骤——创建局部变量表时,所创建的局部变量表其实正是被调用的Java方法自己的栈帧空间的一部分,所以从那时起,被调用的Java方法就拥有了自己的栈帧领域,而并不是执行了本步骤中的"push %ebp"和"%mov %esp, %ebp"这两条指令之后才开始拥有了自己的栈帧领域。这一点与C/C++等语言有巨大的差异,在C/C++中,大部分情况下,调用了这两条机器指令后,接下来会再配合调用"sub $operand, %esp"为被调用的函数分配新的栈帧,因此在C/C++中,这两条机器指令的出现,往往就意味着被调用的函数将会开辟自己的栈帧空间。而在HotSpot中,当这两条机器指令出现时,被调用的Java方法已经拥有自己的栈帧领域了。不过话说过来,既然局部变量表本身就属于被调用的Java方法的栈帧的一部分,那么HotSpot应该在创建局部变量表之前就执行"push %ebp"和"mov %esp, %ebp"这两条机器指令,为被调用的Java方法创建栈帧。不过HotSpot一反常规,没有这么做,这里面的原因倒也不复杂,主要基于下面两点考虑
这里所谓创建新的栈帧,是指通过硬件寄存器真正开始为被调用的Java方法分配堆栈空间。创建栈帧的第一步就是首先要清晰地标识出自己的栈底,栈底就是一个新的栈帧的领域边界。如果没有标识出栈底,则不管分配多少新的堆栈空间,都还只是在别人的栈帧领域之内。栈帧划分自己领域的方法也很简单,执行下面这2条机器指令即可:
# push %ebp
# mov %esp, %ebp
从这里开始,被调用的Java方法终于开始有了自己栈帧的"立脚之处",接下来新增的堆栈空间就都属于被调用的Java方法,而不再是"调用方法"。以后对这块区域内的数据进行访问时,都必须以新的bp(栈底指针)为基准进行偏移寻址,而不能以调用方的bp为基准进行偏移寻址。不过刚才"从这里开始,被调用的Java方法终于开始有了自己栈帧的立脚之处"这种说法却不够严谨,因为被调用的Java方法并不是从这里才开始拥有自己的栈帧空间的,在前面的步骤——创建局部变量表时,所创建的局部变量表其实正是被调用的Java方法自己的栈帧空间的一部分,所以从那时起,被调用的Java方法就拥有了自己的栈帧领域,而并不是执行了本步骤中的"push %ebp"和"%mov %esp, %ebp"这两条指令之后才开始拥有了自己的栈帧领域。这一点与C/C++等语言有巨大的差异,在C/C++中,大部分情况下,调用了这两条机器指令后,接下来会再配合调用"sub $operand, %esp"为被调用的函数分配新的栈帧,因此在C/C++中,这两条机器指令的出现,往往就意味着被调用的函数将会开辟自己的栈帧空间。而在HotSpot中,当这两条机器指令出现时,被调用的Java方法已经拥有自己的栈帧领域了。不过话说过来,既然局部变量表本身就属于被调用的Java方法的栈帧的一部分,那么HotSpot应该在创建局部变量表之前就执行"push %ebp"和"mov %esp, %ebp"这两条机器指令,为被调用的Java方法创建栈帧。不过HotSpot一反常规,没有这么做,这里面的原因倒也不复杂,主要基于下面两点考虑
1.局部变量表的整体性
在本步骤之前有一个步骤——"创建局部变量表",这个步骤被安排在entry_point例程里执行,但是严格来说,这个步骤并不能叫做"创建局部变量表"。而只能叫做将Java方法局部变量压栈,这是因为局部变量表不仅仅包含Java方法的局部变量,而且包含Java方法的入参,如图所示。Java函数入栈逻辑在entry_point例程的调用方——CallStub例程中执行,而Java函数局部变量入栈逻辑则在entry_point例程中完成,虽然Java函数入参的堆栈区域与Java函数局部变量所在的堆栈区域分属两个不同的栈帧(前者属于CallStub的栈帧,而后者按理说属于entry_point的栈帧),但是对于被调用的Java函数而言,不管是其方法入参还是方法内部的局部变量,都隶属于局部变量表,当Java程序执行Java字节码指令读写Java方法的局部变量表时,将Java函数的第一个入参所在的堆栈位置作为偏移机制对局部变量进行变址寻址。而调用函数的栈底基址ebp很明显并不属于被调用的Java方法的局部变量表的一员,自然不能被字节码读写,因此HotSpot并不能再对局部变量进行压栈之前执行"push %ebp"和"mov %esp, %ebp"这两条机器指令,否则局部变量表就不完整了。这与前面步骤先将return address出栈弹出到rsa、等到Java方法的局部变量全部入栈之后再恢复至栈顶时同一个道理
在本步骤之前有一个步骤——"创建局部变量表",这个步骤被安排在entry_point例程里执行,但是严格来说,这个步骤并不能叫做"创建局部变量表"。而只能叫做将Java方法局部变量压栈,这是因为局部变量表不仅仅包含Java方法的局部变量,而且包含Java方法的入参,如图所示。Java函数入栈逻辑在entry_point例程的调用方——CallStub例程中执行,而Java函数局部变量入栈逻辑则在entry_point例程中完成,虽然Java函数入参的堆栈区域与Java函数局部变量所在的堆栈区域分属两个不同的栈帧(前者属于CallStub的栈帧,而后者按理说属于entry_point的栈帧),但是对于被调用的Java函数而言,不管是其方法入参还是方法内部的局部变量,都隶属于局部变量表,当Java程序执行Java字节码指令读写Java方法的局部变量表时,将Java函数的第一个入参所在的堆栈位置作为偏移机制对局部变量进行变址寻址。而调用函数的栈底基址ebp很明显并不属于被调用的Java方法的局部变量表的一员,自然不能被字节码读写,因此HotSpot并不能再对局部变量进行压栈之前执行"push %ebp"和"mov %esp, %ebp"这两条机器指令,否则局部变量表就不完整了。这与前面步骤先将return address出栈弹出到rsa、等到Java方法的局部变量全部入栈之后再恢复至栈顶时同一个道理
2.Java栈帧帧数据相对寻址
在这一步才执行"push %ebp"和"mov %esp, %ebp"这两条机器指令的另一个重要原因是,为了对Java方法栈帧内部的帧数据(fixed frame)进行相对寻址方便。Java方法的栈帧主要由3部分组成: 局部变量表、帧数据和操作数栈。其中只有帧数据的这部分数据的长度是固定不变的,任何Java方法的这部分内容所占用的堆栈内存空间大小相等,因此在HotSpot内部,这部分数据被称作"fixed frame".而局部变量表与操作数栈的大小则随着Java方法的不同而变化,并没有固定的大小。如图所示.在左图中,bp是HotSpot实际所压栈的位置,位于局部变量表栈顶,而由图中的bp则位于局部变量表内部、Java方法入参顶部、局部变量区域底部。示意图中的frame1、frame2、frame3则是Java方法栈帧中的fixed frame部分的3个示例数据。在HotSpot执行Java方法的过程中,经常需要读取堆栈中的fixed frame部分的数据,而读取的方式便是基于bp进行变址寻址。以读取frame1这个数据为例,在左图中,frame1与bp相邻,因此HotSpot要读取frame 1,只需将bp减4即可(假设一个数据占用4字节空间)。同理,读取frame2和frame3只需要将bp减去8和12.但是对于右图,由于bp与frame1之间隔了一个局部变量区域,而不同的Java方法,其局部变量的数量相差很大,因此要通过bp去寻址frame1,每次都得计算出Java方法的局部变量表所占的内存综合,这将十分消耗Java虚拟机的性能。即使将每个java方法的局部变量表所占的内存空间存储起来也于事无补,那样的话仍然需要读取内存。不过虽然bp被放在了被调用的Java方法的局部变量区域的后面,但是局部变量仍然应当被看作是被调用的Java方法的栈帧空间的一部分,毕竟Java方法的字节码在访问自己的局部变量表时,时将局部变量表当作自己堆栈空间的一部分,而不是别人的堆栈空间。
"push %ebp"和"mov %esp, %ebp"这两条机器指令执行完成之后的堆栈内存空间布局如图所示
在这一步才执行"push %ebp"和"mov %esp, %ebp"这两条机器指令的另一个重要原因是,为了对Java方法栈帧内部的帧数据(fixed frame)进行相对寻址方便。Java方法的栈帧主要由3部分组成: 局部变量表、帧数据和操作数栈。其中只有帧数据的这部分数据的长度是固定不变的,任何Java方法的这部分内容所占用的堆栈内存空间大小相等,因此在HotSpot内部,这部分数据被称作"fixed frame".而局部变量表与操作数栈的大小则随着Java方法的不同而变化,并没有固定的大小。如图所示.在左图中,bp是HotSpot实际所压栈的位置,位于局部变量表栈顶,而由图中的bp则位于局部变量表内部、Java方法入参顶部、局部变量区域底部。示意图中的frame1、frame2、frame3则是Java方法栈帧中的fixed frame部分的3个示例数据。在HotSpot执行Java方法的过程中,经常需要读取堆栈中的fixed frame部分的数据,而读取的方式便是基于bp进行变址寻址。以读取frame1这个数据为例,在左图中,frame1与bp相邻,因此HotSpot要读取frame 1,只需将bp减4即可(假设一个数据占用4字节空间)。同理,读取frame2和frame3只需要将bp减去8和12.但是对于右图,由于bp与frame1之间隔了一个局部变量区域,而不同的Java方法,其局部变量的数量相差很大,因此要通过bp去寻址frame1,每次都得计算出Java方法的局部变量表所占的内存综合,这将十分消耗Java虚拟机的性能。即使将每个java方法的局部变量表所占的内存空间存储起来也于事无补,那样的话仍然需要读取内存。不过虽然bp被放在了被调用的Java方法的局部变量区域的后面,但是局部变量仍然应当被看作是被调用的Java方法的栈帧空间的一部分,毕竟Java方法的字节码在访问自己的局部变量表时,时将局部变量表当作自己堆栈空间的一部分,而不是别人的堆栈空间。
"push %ebp"和"mov %esp, %ebp"这两条机器指令执行完成之后的堆栈内存空间布局如图所示
3.将最后一个入参位置压栈
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行__push(rsi)指令,其对应的机器指令是"push %rsi".在前面分析CallStub()函数时,当时rsi寄存器例保存了Java方法最后一个入参在堆栈中的位置,所以执行"push %rsi"之后,Java方法最后一个入参的位置就被压入Java的方法栈中。entry_point例程由CallStub例程调用,也可能会由其他例程调用,例如Java方法调用Java方法时,就会由invoke_virtual或者invoke_sepcial等例程调用。当entry_point例程由CallStub例程调用时,CallStub例程流程进入entry_point时,其栈顶元素正好就是被调用的Java方法的最后一个入参,所以这个位置就是CallStub例程所对应的函数的栈顶。同样,当entry_point由其他例程进入时,被调用的Java方法的最后一个入参也是调用方例程的栈顶。所以,这最后一个入参自然就是调用方函数的栈顶,再往前,就进入了被调用方的堆栈空间,因此最后一个参数的位置自然称为调用方与被调用方的栈帧空间的分水岭。在执行本指令之前,HotSpot通过"push %ebp"和"mov %esp, %ebp"这两条机器指令将调用方的栈底指针ebp压入堆栈中,同时又将调用方的栈顶指针esp的值复制给ebp,作为被调用的Java方法的栈底,因此被调用的Java方法的栈底实际上便是用调用方esp栈顶指针,此时esp所指向的位置如图所示。
此时esp实际上指向了Java方法栈帧"fixed frame"的底部,这个位置已经超出了Java方法局部变量区域,这对于Java方法的运行倒没有任何影响,但是等Java运行完成,即将要退出时,就会引起很大的问题。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行__push(rsi)指令,其对应的机器指令是"push %rsi".在前面分析CallStub()函数时,当时rsi寄存器例保存了Java方法最后一个入参在堆栈中的位置,所以执行"push %rsi"之后,Java方法最后一个入参的位置就被压入Java的方法栈中。entry_point例程由CallStub例程调用,也可能会由其他例程调用,例如Java方法调用Java方法时,就会由invoke_virtual或者invoke_sepcial等例程调用。当entry_point例程由CallStub例程调用时,CallStub例程流程进入entry_point时,其栈顶元素正好就是被调用的Java方法的最后一个入参,所以这个位置就是CallStub例程所对应的函数的栈顶。同样,当entry_point由其他例程进入时,被调用的Java方法的最后一个入参也是调用方例程的栈顶。所以,这最后一个入参自然就是调用方函数的栈顶,再往前,就进入了被调用方的堆栈空间,因此最后一个参数的位置自然称为调用方与被调用方的栈帧空间的分水岭。在执行本指令之前,HotSpot通过"push %ebp"和"mov %esp, %ebp"这两条机器指令将调用方的栈底指针ebp压入堆栈中,同时又将调用方的栈顶指针esp的值复制给ebp,作为被调用的Java方法的栈底,因此被调用的Java方法的栈底实际上便是用调用方esp栈顶指针,此时esp所指向的位置如图所示。
此时esp实际上指向了Java方法栈帧"fixed frame"的底部,这个位置已经超出了Java方法局部变量区域,这对于Java方法的运行倒没有任何影响,但是等Java运行完成,即将要退出时,就会引起很大的问题。
当Java程序运行完成时,HotSpot必须回收其对应的堆栈空间。对于操作系统而言,堆栈空间的回收别无他法,全部依赖esp寄存器值的修改。对于C/C++等本地编译性的语言,ebp指针指向被调用函数栈帧的栈底,因此回收被调用函数的栈帧空间时,只需要将esp恢复至被i调用函数的ebp指针即可。Java方法栈帧由局部变量表、、帧数据和操作数栈这三部分组成,其中局部变量表又一分为二,一部分为Java方法入参区域,一部分则为Java方法局部变量表区域,前者属于调用方的栈帧空间,后者才属于被调用一方的栈帧空间,因此当被调用的Java方法执行完毕后,HotSpot需要回收的堆栈空间实际上仅包含帧数据、操作数栈及局部变量表中的局部变量所在的区域。如图所示。。在上一步,执行完"push %ebp"和"mov %esp, %ebp"这两条机器指令后,调用方函数的栈顶指针esp被复制给了被调用的Java方法的栈底指针ebp,但是esp指向的位置并不是Java局部变量中入参区域与局部变量去与的分界线之初,因此当Java方法执行完成之后,如果HotSpot以Java方法的栈底指针ebp来确定回收的堆栈空间,就会出现异常,因为Java方法局部变量表中的局部变量区域所占的堆栈内存空间不在回收之列。因此为了能够正确回收被调用的Java方法的栈帧空间,HotSpot必须记录Java方法的调用方的栈顶位置,回收堆栈空间时,就以此来确定回收范围。这便是本步骤——将Java方法入参的最后一个参数在堆栈中的位置进行压栈的意义所在。
事实上,如果将Java字节码中的return指令展开成机器指令,也的确会发现其中的奥妙。假设一个方法的返回值类型是int,则该Java方法的最后一条字节码指令必定是ireturn,ireturn指令展开后的机器码如下(使用汇编助记符展示)
事实上,如果将Java字节码中的return指令展开成机器指令,也的确会发现其中的奥妙。假设一个方法的返回值类型是int,则该Java方法的最后一条字节码指令必定是ireturn,ireturn指令展开后的机器码如下(使用汇编助记符展示)
观察其中加粗的两行指令"mov -0x4(%ebp), %ebx"和"mov %ebx, %esp",第一条指令将ebp所指向的堆栈内存位置的相邻位置(低地址方向)的数据取出来,传送给ebp;接着第二条指令便将ebp的值再度传送给esp,HotSpot正是通过这第2条指令彻底回收了当前Java方法的栈帧,-0x4(%ebp)位置上的数据其实正是本步骤中所压栈的Java方法的最后一个入参在堆栈中的位置,也即调用方的栈顶。
本步骤执行完成之后的堆栈内存布局
4.计算Java方法的第一个字节码位置
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行下面两行代码:
# __ movptr(rsi, Address(rbx,Method::const_offset())); // get ConstMethod*
# __ lea(rsi, Address(rsi,ConstMethod::codes_offset())); // get codebase
这两行代码所对应的机器码是:
# mov 0x8(%ebx), %esi。ebx指向method,0x8(%ebx)指向ConstMehtod
# lea 0x30(%esi), %esi. 在这一步让%esi指向codebase,也就是method的第一个字节码的位置
这一段不难理解,在进入entry_point例程之前,ebx寄存器中所保存的就是Java方法在JVM内部对应的methodOop对象首地址。0x8(%ebx)恰好指向constMethodOop,constMethodOop对象在常量池解析阶段生成,该对象主要保存Java方法中的只读信息,例如异常信息表、Java方法注解信息、方法名、Java方法的字节码指令等。
methodOop的内存结构如下(JDK6, 32位x86平台):
methodOop
+0: header
+4: klass
+8: constMethodOop
+12: constants
+16: methodData
// ...
由此可见,相对methodOop首地址偏移8字节的位置处所保存的正是指向constMethodOop对象的指针,因此HotSpot执行"mov 0x8(%ebx), %esi"这条指令之后,esi寄存器中所存储的就是constMethodOop对象的内存地址了。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行下面两行代码:
# __ movptr(rsi, Address(rbx,Method::const_offset())); // get ConstMethod*
# __ lea(rsi, Address(rsi,ConstMethod::codes_offset())); // get codebase
这两行代码所对应的机器码是:
# mov 0x8(%ebx), %esi。ebx指向method,0x8(%ebx)指向ConstMehtod
# lea 0x30(%esi), %esi. 在这一步让%esi指向codebase,也就是method的第一个字节码的位置
这一段不难理解,在进入entry_point例程之前,ebx寄存器中所保存的就是Java方法在JVM内部对应的methodOop对象首地址。0x8(%ebx)恰好指向constMethodOop,constMethodOop对象在常量池解析阶段生成,该对象主要保存Java方法中的只读信息,例如异常信息表、Java方法注解信息、方法名、Java方法的字节码指令等。
methodOop的内存结构如下(JDK6, 32位x86平台):
methodOop
+0: header
+4: klass
+8: constMethodOop
+12: constants
+16: methodData
// ...
由此可见,相对methodOop首地址偏移8字节的位置处所保存的正是指向constMethodOop对象的指针,因此HotSpot执行"mov 0x8(%ebx), %esi"这条指令之后,esi寄存器中所存储的就是constMethodOop对象的内存地址了。
这里为了描述方便,简单贴出constMethodOop的布局结构与个字段偏移量(JDK6, 32位x86平台)
constMethodOop
+0: header
+4: klass
+8: fingerprint
+16: is_conc_safe
+20: method
+24: stackmap_data
+28: exception_table
+32: constMethod_size
+36: interpreter_kind
+37: flags
+38: code_size
+40: name_index
+42: signature_index
+44: method_idnum
+46: generic_signature_index
+48: byte codes
注意,这个结构中,is_conc_safe是bool类型,仅占用1字节。但是其后面紧跟的字段是methodOop指针类型,其宽度是4,因此需要按4字节对齐,也因此is_conc_safe后有3字节的补位。前面在分析HotSpot解析Java类的方法时,会将Java类方法的字节码指令保存到对应的constMethodOop对象实例的末尾。constMethodOop类型的最后一个字段时generic_signature_index,类型是u2,其相对于constMethodOop对象首地址的偏移量,而Java方法的字节码指令就分配在该字段的后面,因此Java方法的字节码的第一条指令的位置相对于constMethodOop就是48.因此,,本步骤会通过"lea 0x30(%esi), %esi"指令将Java方法的第一条字节码指令的内存位置传送给esi寄存器。在entry_point例程的最后,会通过call指令去开始真正执行Java方法的字节码指令
constMethodOop
+0: header
+4: klass
+8: fingerprint
+16: is_conc_safe
+20: method
+24: stackmap_data
+28: exception_table
+32: constMethod_size
+36: interpreter_kind
+37: flags
+38: code_size
+40: name_index
+42: signature_index
+44: method_idnum
+46: generic_signature_index
+48: byte codes
注意,这个结构中,is_conc_safe是bool类型,仅占用1字节。但是其后面紧跟的字段是methodOop指针类型,其宽度是4,因此需要按4字节对齐,也因此is_conc_safe后有3字节的补位。前面在分析HotSpot解析Java类的方法时,会将Java类方法的字节码指令保存到对应的constMethodOop对象实例的末尾。constMethodOop类型的最后一个字段时generic_signature_index,类型是u2,其相对于constMethodOop对象首地址的偏移量,而Java方法的字节码指令就分配在该字段的后面,因此Java方法的字节码的第一条指令的位置相对于constMethodOop就是48.因此,,本步骤会通过"lea 0x30(%esi), %esi"指令将Java方法的第一条字节码指令的内存位置传送给esi寄存器。在entry_point例程的最后,会通过call指令去开始真正执行Java方法的字节码指令
5.将methodOop压栈
TemplateInterpreterGenerator::generate_fixed_frame(bool native_cal)函数接着执行下面的代码:
# __ push(rbx); // save Method*
其对应的机器指令是
push %rbx
rbx寄存器指向Java方法在JVM内部所对应的methodOop对象首地址,因此这一步的目的便是将methodOop对象的首地址压入栈中。在HotSpot调用Java方法的过程中,将通过这个地址读取到Java方法的全部信息,例如进行多态运行期方法绑定时需要定位到callee从而决定到底调用继承体系中的哪一个对象的方法
TemplateInterpreterGenerator::generate_fixed_frame(bool native_cal)函数接着执行下面的代码:
# __ push(rbx); // save Method*
其对应的机器指令是
push %rbx
rbx寄存器指向Java方法在JVM内部所对应的methodOop对象首地址,因此这一步的目的便是将methodOop对象的首地址压入栈中。在HotSpot调用Java方法的过程中,将通过这个地址读取到Java方法的全部信息,例如进行多态运行期方法绑定时需要定位到callee从而决定到底调用继承体系中的哪一个对象的方法
6.将ConstantPoolCache压栈
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行下面几行代码
__ movptr(rdx, Address(rdx, ConstMethod::constants_offset()));
__ movptr(rdx, Address(rdx, ConstantPool::cache_offset_in_bytes()));
__ push(rdx); // set constant pool cache
其对应的机器指令是:
# mov 0xc(%ebx), %edx --ConstMethod*
# mov 0xc(%edx), %edx --ConstantPoolCache*
# push %edx
在32位x86Linux平台上,相对于methodOop首地址偏移量为0xc(即十进制12)的字段是constantPoolOop指针,该指针指向Java类所对应的内存常量池首地址,HotSpot通过"mov 0xc(%ebx), %edx"指令将constantPoolOop首地址传送给edx寄存器。而constantPoolOop的内存结构布局如图所示:
constantPoolOop:
+0: header
+4: klass
+8: tags
+12: constantPoolCacheOop
+16: _pool_holder
// ...
相对于constantPoolOop首地址偏移量为0xc的字段是constantPoolCacheOop,HotSpot通过"mov 0xc(%edx), %edx"指令将constantPoolCacheOop首地址传送给edx寄存器,接着执行"push %edx"指令将constantPoolCacheOop首地址压入Java方法栈中,由此可知,对于同一个Java类文件中的所有Java方法,每一个Java方法的栈帧中都必须持有指向该Java类解析后所生成的常量池缓存对象的地址。常量池缓存中的内容皆是直接引用,不必像常量池那样,存放的都是索引号。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行下面几行代码
__ movptr(rdx, Address(rdx, ConstMethod::constants_offset()));
__ movptr(rdx, Address(rdx, ConstantPool::cache_offset_in_bytes()));
__ push(rdx); // set constant pool cache
其对应的机器指令是:
# mov 0xc(%ebx), %edx --ConstMethod*
# mov 0xc(%edx), %edx --ConstantPoolCache*
# push %edx
在32位x86Linux平台上,相对于methodOop首地址偏移量为0xc(即十进制12)的字段是constantPoolOop指针,该指针指向Java类所对应的内存常量池首地址,HotSpot通过"mov 0xc(%ebx), %edx"指令将constantPoolOop首地址传送给edx寄存器。而constantPoolOop的内存结构布局如图所示:
constantPoolOop:
+0: header
+4: klass
+8: tags
+12: constantPoolCacheOop
+16: _pool_holder
// ...
相对于constantPoolOop首地址偏移量为0xc的字段是constantPoolCacheOop,HotSpot通过"mov 0xc(%edx), %edx"指令将constantPoolCacheOop首地址传送给edx寄存器,接着执行"push %edx"指令将constantPoolCacheOop首地址压入Java方法栈中,由此可知,对于同一个Java类文件中的所有Java方法,每一个Java方法的栈帧中都必须持有指向该Java类解析后所生成的常量池缓存对象的地址。常量池缓存中的内容皆是直接引用,不必像常量池那样,存放的都是索引号。
到了这一步,堆栈内存布局如图所示.注意,在靠近栈顶的位置多出了2个0,这两个位置为保留字段或者占位符,在Java方法运行过程中将会用于存储运行期的部分结果。这里需要说明的是,这种堆栈内存布局图绘制到现在已经变得很大、很复杂了,但是最终仍然保留了全部信息。因为浓墨重彩地描述了Java方法调用的入口及栈帧创建的整个过程,从JVM内部的CallStub例程开始,一直到现在。主线一直未变,这张图就是主线。
7.将局部变量表压栈
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行__push(rdi)指令,其对应的机器指令是push %edi.在前面初始化Java方法局部变量区域时,通过不断地循环执行"push $0x0"指令为Java方法内的局部变量开辟堆栈空间,而在循环之前,将Java方法的第一个入参在堆栈中的位置传递给了edi寄存器,并且中间并没有被修改,因此此时edi寄存器仍然指向Java方法的第一个入参在堆栈中的位置。这一步对于Java方法而言具有十分重要的意义,因为Java方法栈帧部分的局部变量表的起始位置,其实就是Java方法的第一个入参在堆栈中的位置,因此Java栈帧必须要记录下这个位置,作为Java方法的局部变量表的第一个槽位,不然在Java方法执行的过程中,Java字节码无法读写局部变量表。举一个十分简单的例子,
public class A {
public static void doSomeThing() {
int a = 3;
int b = 81;
int c = a + b - 9;
}
}
使用javap -verbose命令查看doSomeThing()方法所对应的字节码如下:
public static void doSomeThing();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: iconst_3
1: istore_0
2: bipush 81
4: istore_1
5: iload_0
6: iload_1
7: iadd
8: bipush 9
10: isub
11: istore_2
12: return
LineNumberTable:
line 6: 0
line 7: 2
line 8: 5
line 9: 12
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数接着执行__push(rdi)指令,其对应的机器指令是push %edi.在前面初始化Java方法局部变量区域时,通过不断地循环执行"push $0x0"指令为Java方法内的局部变量开辟堆栈空间,而在循环之前,将Java方法的第一个入参在堆栈中的位置传递给了edi寄存器,并且中间并没有被修改,因此此时edi寄存器仍然指向Java方法的第一个入参在堆栈中的位置。这一步对于Java方法而言具有十分重要的意义,因为Java方法栈帧部分的局部变量表的起始位置,其实就是Java方法的第一个入参在堆栈中的位置,因此Java栈帧必须要记录下这个位置,作为Java方法的局部变量表的第一个槽位,不然在Java方法执行的过程中,Java字节码无法读写局部变量表。举一个十分简单的例子,
public class A {
public static void doSomeThing() {
int a = 3;
int b = 81;
int c = a + b - 9;
}
}
使用javap -verbose命令查看doSomeThing()方法所对应的字节码如下:
public static void doSomeThing();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: iconst_3
1: istore_0
2: bipush 81
4: istore_1
5: iload_0
6: iload_1
7: iadd
8: bipush 9
10: isub
11: istore_2
12: return
LineNumberTable:
line 6: 0
line 7: 2
line 8: 5
line 9: 12
当HotSpot执行源代码中的int a = 3时,实际上执行的时iconst_3和istore_0这两条字节码指令,其中iconst_3字节码所对应的汇编指令如下:
mov $0x3, %eax // 将操作数3推送至栈顶缓存(缓存在eax寄存器中)
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f106a0(, %ebx, 4)
接着在istore_0字节码指令中理应读取栈顶缓存的值,并将其保存到doSomeThing()方法的局部变量的第一个槽位上(因为变量a是doSomeThing()方法内的第一个局部变量)。来看istore_0所对应的汇编指令:
mov %eax, (%edi) // 这里引用了edi寄存器
movzbl 0x1(%esi),%ebx
inc %esi
jmp *-0x48f0f2a0(, %ebx, 4)
在istore_0字节码所对应的汇编指令中,执行"mov %eax,(%edi)"指令,将栈顶缓存的值(即eax寄存器中所保存的值)传送给edi寄存器所指向的内存位置。而edi寄存器正指向Java变量表的第一个槽位,而该槽位其实也是Java方法的第一个入参所在的堆栈内存位置。只不过本示例中的doSomeThing()方法没有入参(静态方法,连隐藏的this指针也没有),因此edi寄存器的位置只能是Java方法内的第一个局部变量的堆栈位置
mov $0x3, %eax // 将操作数3推送至栈顶缓存(缓存在eax寄存器中)
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f106a0(, %ebx, 4)
接着在istore_0字节码指令中理应读取栈顶缓存的值,并将其保存到doSomeThing()方法的局部变量的第一个槽位上(因为变量a是doSomeThing()方法内的第一个局部变量)。来看istore_0所对应的汇编指令:
mov %eax, (%edi) // 这里引用了edi寄存器
movzbl 0x1(%esi),%ebx
inc %esi
jmp *-0x48f0f2a0(, %ebx, 4)
在istore_0字节码所对应的汇编指令中,执行"mov %eax,(%edi)"指令,将栈顶缓存的值(即eax寄存器中所保存的值)传送给edi寄存器所指向的内存位置。而edi寄存器正指向Java变量表的第一个槽位,而该槽位其实也是Java方法的第一个入参所在的堆栈内存位置。只不过本示例中的doSomeThing()方法没有入参(静态方法,连隐藏的this指针也没有),因此edi寄存器的位置只能是Java方法内的第一个局部变量的堆栈位置
在本例中,doSomeThing()函数的第二句源代码是int b = 81,变量b是doSomeThing()方法内的第2个局部变量,那么在32位x86平台上,其位置应该是在doSomeThing()方法的局部变量表的第2个槽位,而edi寄存器指向第1个槽位,因此第2个槽位相对于第1个槽位的偏移量是-0x4,使用edi标识应该是-0x4(%edi).看看int b = 81所对应的字节码指令是不是这么处理的就知道了,int b = 81对应两条字节码指令:
# bipush 81
# istore_1
其中bipush也会将81这个操作数推送至栈顶缓存,保存在eax寄存器中。istore_1字节码指令的功能便是将栈顶缓存的内容保存到Java方法局部变量表的第2个槽位上,一起围观其对应的机器指令:
mov %eax, -0x4(%edi)
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f0f2a0(, %ebx, 4)
这条字节码指令果然引用了-0x4(%edi)这个位置,通过edi寄存器直接定位到Java方法的第2个槽位。总之,在Java方法运行期间,对Java方法入参和内部局部变量的读写就全靠edi寄存器了,这么一个重要的数据当然要保存到Java方法的栈帧里去了。edi在HotSpot内部也被叫做locals pointer,即局部变量表的指针
# bipush 81
# istore_1
其中bipush也会将81这个操作数推送至栈顶缓存,保存在eax寄存器中。istore_1字节码指令的功能便是将栈顶缓存的内容保存到Java方法局部变量表的第2个槽位上,一起围观其对应的机器指令:
mov %eax, -0x4(%edi)
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f0f2a0(, %ebx, 4)
这条字节码指令果然引用了-0x4(%edi)这个位置,通过edi寄存器直接定位到Java方法的第2个槽位。总之,在Java方法运行期间,对Java方法入参和内部局部变量的读写就全靠edi寄存器了,这么一个重要的数据当然要保存到Java方法的栈帧里去了。edi在HotSpot内部也被叫做locals pointer,即局部变量表的指针
8.将第一条字节码指令压栈。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数紧接着执行的源代码是:
if (native_call) {
__ push(0); // no bcp
} else {
__ push(rsi); // set bcp
}
由于JVM既可以加载Java类,也可以加载C/C++/Delphi等程序库,因此HotSpot需要通过native_call判断被调用的方法是否是Java方法。如果是Java方法,就需要将Java方法所对应的第一个字节码指令的位置入栈,这个位置在前面的步骤中已经计算好,并被保存在rsi寄存器中,因此只需要执行"push %rsi"指令即可,反之,如果被调用的方法不是Java方法,那么就是本地方法。C/C++/Delphi等程序库都会被JVM当作本地方法调用。所谓本地方法,也即直接本地编译型方法,例如C语言程序,编译后直接生成能够被CPU识别的二进制机器指令。所以本地方法不存在所谓"Java字节码指令"一说,因此也就无须将rsi寄存器压入栈中。rsi所指向的位置在HotSpot内部有一个专门的称呼——bcp,其含义是byte code pointer,即指向Java方法字节码指令的指针。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数紧接着执行的源代码是:
if (native_call) {
__ push(0); // no bcp
} else {
__ push(rsi); // set bcp
}
由于JVM既可以加载Java类,也可以加载C/C++/Delphi等程序库,因此HotSpot需要通过native_call判断被调用的方法是否是Java方法。如果是Java方法,就需要将Java方法所对应的第一个字节码指令的位置入栈,这个位置在前面的步骤中已经计算好,并被保存在rsi寄存器中,因此只需要执行"push %rsi"指令即可,反之,如果被调用的方法不是Java方法,那么就是本地方法。C/C++/Delphi等程序库都会被JVM当作本地方法调用。所谓本地方法,也即直接本地编译型方法,例如C语言程序,编译后直接生成能够被CPU识别的二进制机器指令。所以本地方法不存在所谓"Java字节码指令"一说,因此也就无须将rsi寄存器压入栈中。rsi所指向的位置在HotSpot内部有一个专门的称呼——bcp,其含义是byte code pointer,即指向Java方法字节码指令的指针。
9.将操作数栈栈底地址压栈。
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数所执行的最后两句源代码如下:
__ push(0); // reserve word for pointer to expression stack bottom
__ movptr(Address(rsp, 0), rsp); // set expression stack bottom
其对应的机器指令如下:
push $0x0
mov %esp, (%esp)
第一条机制指令往栈顶压入一个零值,接着通过第二条机器指令将当前esp寄存器的值覆盖为刚刚压入的零值。
在前面步骤中执行各种push操作,每一次push完之后,物理CPU会自动将esp寄存器的值更新为当前最新的栈顶位置,因此到了本步执行的时候,esp寄存器本来就指向了当前堆栈的栈顶,而这里又通过"mov %esp, (%esp)"指令将esp的值覆盖为刚刚压入栈顶的零值,因此此时栈顶所保存的值就是其自己的内存位置。此时堆栈内存布局结构如图所示.
TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数所执行的最后两句源代码如下:
__ push(0); // reserve word for pointer to expression stack bottom
__ movptr(Address(rsp, 0), rsp); // set expression stack bottom
其对应的机器指令如下:
push $0x0
mov %esp, (%esp)
第一条机制指令往栈顶压入一个零值,接着通过第二条机器指令将当前esp寄存器的值覆盖为刚刚压入的零值。
在前面步骤中执行各种push操作,每一次push完之后,物理CPU会自动将esp寄存器的值更新为当前最新的栈顶位置,因此到了本步执行的时候,esp寄存器本来就指向了当前堆栈的栈顶,而这里又通过"mov %esp, (%esp)"指令将esp的值覆盖为刚刚压入栈顶的零值,因此此时栈顶所保存的值就是其自己的内存位置。此时堆栈内存布局结构如图所示.
在这一步,之所以要将esp的值存储起来,是因为截止到本步骤,Java方法栈帧的"fixed frame"部分就创建完成了,Java方法栈帧接下来的部分就是操作数栈。虽然操作数栈也属于Java方法栈帧的一部分吗,但是在Java方法的运行过程中,Java字节码指令所面向的栈,实际上便是这个操作数栈,而不是整个Java方法栈帧。Java字节码进行压栈和出栈,会基于操作数栈的栈底位置进行变址寻址。由于Java方法的fixed frame接下来相邻的部分就是操作数栈,因此fixed frame的栈顶位置其实就是操作数栈的栈底,HotSpot将该位置记录下来,用作操作数栈的栈底,在HotSpot内部,将该位置叫做"expression stack bottom",即表达式栈栈底。在这一步之所以要将esp寄存器的值存储起来的另一个原因是,Java字节码的压栈和出栈指令,最终都会映射成物理机器码的push和pop指令,而这两条指令都会自动修改esp的值,而当HotSpot执行到这一步时,esp恰好指向了fixed frame的顶部,因此HotSpot将其保存起来,一方面时为了能够定位到fixed frame的顶部位置,另一方面也是方便了Java字节码指令的压栈和出栈的执行
10.Java栈帧详细结构。
分析完TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)这个函数,现在回头再看看Java方法的栈帧组成,自然是如明镜一般。前文所属,Java方法栈帧由3大部分组成,分别是局部变量表、帧数据和操作数栈,现在已经知道了局部变量表与帧数据的分配机制及内存组成,仅剩下操作数栈。不过操作数栈相比于局部变量表,确实更加变化不定,并且与被被调用的Java方法息息相关,紧密相邻。这里先总结下Java方法栈帧中固定的部分,如图所示。Java栈帧与C/C++的栈帧自然是大不相同,Java栈帧错综复杂,并且结构也是怪异到了极点,不像C/C++等编程语言的栈帧那般简单明了。这些怪异点主要体现在下面几点.
分析完TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)这个函数,现在回头再看看Java方法的栈帧组成,自然是如明镜一般。前文所属,Java方法栈帧由3大部分组成,分别是局部变量表、帧数据和操作数栈,现在已经知道了局部变量表与帧数据的分配机制及内存组成,仅剩下操作数栈。不过操作数栈相比于局部变量表,确实更加变化不定,并且与被被调用的Java方法息息相关,紧密相邻。这里先总结下Java方法栈帧中固定的部分,如图所示。Java栈帧与C/C++的栈帧自然是大不相同,Java栈帧错综复杂,并且结构也是怪异到了极点,不像C/C++等编程语言的栈帧那般简单明了。这些怪异点主要体现在下面几点.
1.栈底与局部变量底部不是同一位置。
Java方法的局部变量表包含两部分,分别是Java方法入参区域和Java内的局部变量区域。但是Java方法入参区域并不属于Java方法的栈帧,,而是属于其调用方的栈帧的一部分。因此,Java方法栈由局部变量表、操作数栈和帧数据(fixed frame)组成,这一说法并不够严谨,因为Java方法仅包含了局部变量表的一部分区域。由于Java方法的入参区域与内部局部变量区域分别属于两个不同的方法,因此当被调用的函数执行完成,需要回收其堆栈空间时,必须标记其真实的栈底位置,因此HotSpot只能在fixed frame中保存Java方法的最后一个入参的堆栈内存位置,这个位置恰好就是调用方的栈顶,也是被调用的java方法的栈底位置。HotSpot通过这个位置来标记所需要回收的堆栈内存范围。
虽然Java方法的入参区域与内部局部变量去与分别属于两个不同的方法,但是对于被调用的一方而言,这两个区域又必须在物理上连成一片,这样才能形成一个整体,作为一个完整的局部变量表,方便被调用的Java方法的字节码指令进行读写,因此HotSpot只能将调用方的栈底指针bp保存到fixed frame之中,这虽然未尝不可,但是毕竟与C/C++之类的编程语言有很大差异,在程序逻辑上相差甚远
Java方法的局部变量表包含两部分,分别是Java方法入参区域和Java内的局部变量区域。但是Java方法入参区域并不属于Java方法的栈帧,,而是属于其调用方的栈帧的一部分。因此,Java方法栈由局部变量表、操作数栈和帧数据(fixed frame)组成,这一说法并不够严谨,因为Java方法仅包含了局部变量表的一部分区域。由于Java方法的入参区域与内部局部变量区域分别属于两个不同的方法,因此当被调用的函数执行完成,需要回收其堆栈空间时,必须标记其真实的栈底位置,因此HotSpot只能在fixed frame中保存Java方法的最后一个入参的堆栈内存位置,这个位置恰好就是调用方的栈顶,也是被调用的java方法的栈底位置。HotSpot通过这个位置来标记所需要回收的堆栈内存范围。
虽然Java方法的入参区域与内部局部变量去与分别属于两个不同的方法,但是对于被调用的一方而言,这两个区域又必须在物理上连成一片,这样才能形成一个整体,作为一个完整的局部变量表,方便被调用的Java方法的字节码指令进行读写,因此HotSpot只能将调用方的栈底指针bp保存到fixed frame之中,这虽然未尝不可,但是毕竟与C/C++之类的编程语言有很大差异,在程序逻辑上相差甚远
2.Java栈帧需要额外保存若干数据
Java的fixed frame部分的数据其实与Java的源程序指令没有四号关系,但是fixed frame中却保存了大量的运行时数据,这一点也与其他编程语言差别较大。例如C/C++,编译后的栈帧几乎全部用于保存函数的入参和局部变量,除了寥寥几个寄存器上下文的现场保存,几乎就没有其他额外的数据。Java栈帧的这种特性所带来的结果就是运行同样的逻辑,其堆栈空间需要占用更多的内存。
这也难怪,毕竟JVM仅仅只是使用软件模拟的虚拟系统而已,由于硬件资源有限,因此只能使用软件的方式来保存上下文了。例如Java方法的fixed frame中需要保存bcp,而在C/C++等编程语言中不需要保存这么一个指向指令的指针,自有那硬件寄存器(ip段寄存器)自动存储。再如Java栈帧中的caller sp,在C/C++语言中更加不需要使用宝贵的堆栈内存去保存,也有那硬件寄存器去存储(bp)。
当然,虽然Java方法栈帧的结构十分怪异,但是却自有道理,,这由JVM自身的执行引擎和内存模型所决定。JVM为其太女生所秉持的崇高理想而设计的各种巧妙机制,最终决定Java的方法栈只能长成这个样子。
Java的fixed frame部分的数据其实与Java的源程序指令没有四号关系,但是fixed frame中却保存了大量的运行时数据,这一点也与其他编程语言差别较大。例如C/C++,编译后的栈帧几乎全部用于保存函数的入参和局部变量,除了寥寥几个寄存器上下文的现场保存,几乎就没有其他额外的数据。Java栈帧的这种特性所带来的结果就是运行同样的逻辑,其堆栈空间需要占用更多的内存。
这也难怪,毕竟JVM仅仅只是使用软件模拟的虚拟系统而已,由于硬件资源有限,因此只能使用软件的方式来保存上下文了。例如Java方法的fixed frame中需要保存bcp,而在C/C++等编程语言中不需要保存这么一个指向指令的指针,自有那硬件寄存器(ip段寄存器)自动存储。再如Java栈帧中的caller sp,在C/C++语言中更加不需要使用宝贵的堆栈内存去保存,也有那硬件寄存器去存储(bp)。
当然,虽然Java方法栈帧的结构十分怪异,但是却自有道理,,这由JVM自身的执行引擎和内存模型所决定。JVM为其太女生所秉持的崇高理想而设计的各种巧妙机制,最终决定Java的方法栈只能长成这个样子。
11.创建fixed frame的全部脚本。
HotSpot调用TemplateInterpreterGenerator::generate_fixed_frame(bool native_cal)函数创建fixed frame,这个函数与其他例程一样,在JVM启动之初就会全部转换为机器指令,HotSpot只需直接调用机器指令即可完成Java帧数据的创建。这部分所对应的机器指令(32位x86平台)
当TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数执行完之后(是指其所生成的机器指令执行完成,该函数本身会在JVM启动之初被调用执行,但是只会生成对应的机器指令,并不会直接执行),物理寄存器又有一番变化,最终各个寄存器中所存储的结果如表所示:
寄存器名——指向
edx——constantPoolCache
ecx——Java函数入参数量
ebx——指向Java函数,即Java函数所对应的methodOop对象
esp——Java方法栈帧的fixed frame顶部
ebp——Java方法调用方的栈底
esi——bcp
edi——locals pointer
eax——return address
其中,尤其要关注esi和edi这两个寄存器,接下来HotSpot执行Java字节码指令时,字节码对Java方法的局部变量表的读写主要就依靠edi来进行相对寻址,而HotSpot调用字节码指令的前提是得先定位到首个字节码再内存中的位置,这个位置就存储在esi寄存器中
HotSpot调用TemplateInterpreterGenerator::generate_fixed_frame(bool native_cal)函数创建fixed frame,这个函数与其他例程一样,在JVM启动之初就会全部转换为机器指令,HotSpot只需直接调用机器指令即可完成Java帧数据的创建。这部分所对应的机器指令(32位x86平台)
当TemplateInterpreterGenerator::generate_fixed_frame(bool native_call)函数执行完之后(是指其所生成的机器指令执行完成,该函数本身会在JVM启动之初被调用执行,但是只会生成对应的机器指令,并不会直接执行),物理寄存器又有一番变化,最终各个寄存器中所存储的结果如表所示:
寄存器名——指向
edx——constantPoolCache
ecx——Java函数入参数量
ebx——指向Java函数,即Java函数所对应的methodOop对象
esp——Java方法栈帧的fixed frame顶部
ebp——Java方法调用方的栈底
esi——bcp
edi——locals pointer
eax——return address
其中,尤其要关注esi和edi这两个寄存器,接下来HotSpot执行Java字节码指令时,字节码对Java方法的局部变量表的读写主要就依靠edi来进行相对寻址,而HotSpot调用字节码指令的前提是得先定位到首个字节码再内存中的位置,这个位置就存储在esi寄存器中
局部变量表。
前面详细分析了局部变量表的创建过程及组成,其实说白了,局部变量表就是JVM为Java方法内的变量在堆栈上所分配的一块连续的内存空间而已,这块连续的内存空间用于存储Java方法的入参数据和局部变量
前面详细分析了局部变量表的创建过程及组成,其实说白了,局部变量表就是JVM为Java方法内的变量在堆栈上所分配的一块连续的内存空间而已,这块连续的内存空间用于存储Java方法的入参数据和局部变量
1.局部变量表的基本单位
在JVM内哦不,局部变量表按照"槽位"(slot)为基本单位进行划分。那么一个槽位slot的单位是多大呢?JVM规范中给出了槽位单位的长度。JVM规范规定一个slot槽位应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,其中reference是对象的引用,可以查到对象在Java堆中的实例的起始地址索引和方法区中的对象数据类型。returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址,起始说白了就是一个指针类型的数据。。
但是JVM的这一对于slot长度的规范让人十分迷惑,因为在JVM内部,一个boolean类型与一个float类型所占的内存空间大小肯定是不同的,而JVM规范则规定一个slot槽位能够存放这两种数据类型的任意一种数据,那么slot到底多大?这是一个比较复杂的问题
在JVM内哦不,局部变量表按照"槽位"(slot)为基本单位进行划分。那么一个槽位slot的单位是多大呢?JVM规范中给出了槽位单位的长度。JVM规范规定一个slot槽位应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,其中reference是对象的引用,可以查到对象在Java堆中的实例的起始地址索引和方法区中的对象数据类型。returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址,起始说白了就是一个指针类型的数据。。
但是JVM的这一对于slot长度的规范让人十分迷惑,因为在JVM内部,一个boolean类型与一个float类型所占的内存空间大小肯定是不同的,而JVM规范则规定一个slot槽位能够存放这两种数据类型的任意一种数据,那么slot到底多大?这是一个比较复杂的问题
2.局部变量表的读写与线程安全性
Java程序员无法直接编写Java代码对局部变量表进行读写,毕竟这不是Java这种语言级别的数据结构概念,这种结构只能通过字节码指令进行访问和写入,并由JVM在运行期进行动态读写。事实上,局部变量表作为堆栈的一部分,起始也决定了其只能由机器指令或者JVM这种能够创建和销毁堆栈的虚拟机器访问。字节码指令而不能由Java程序员去编写,它由编译器生成。但涉及对局部变量表的访问时,编译器会生成load和store这样的指令,分别对局部变量表进行读和写。例如下面这个简单的例子。如图所示。编译这个类,并使用javap命令分析编译后的字节码文件,得到该示例中的add(int, int)方法的字节码指令.
对于add()方法中int sum = x + y这样的源代码,既涉及对两个入参x和y的读取,也涉及对sum局部变量的写入,编译器将其翻译成iload_0、iload_1、iadd和istore_2这4条字节码指令。由于变量x和y是add()方法的入参,因此其slot索引号分别是0和1,所以iload_0和iload_1分别表示从局部变量表中读取变量x和变量y。
当执行iadd指令完成x和y的求和运算之后,求和结果被存储到变量sum中。由于sum是add()方法中的第3个局部变量,因此其在局部变量表的索引号为2,所以执行完iadd指令后,便通过istore_2指令将求和结果存储到sum变量中。
局部变量表的大小(或谓深度)由编译器直接在编译期间计算出来,因此Java方法的入参和局部变量的slot索引号在编译期间便确定下来,所谓slot索引号,说白了其实就是变量以方法栈帧中的某个点作为基准位置进行偏移,终究是堆栈空间的一部分,运行时无法修改,在这一点上,Java语言与其他众多语言都保持一致,虽然其他语言中未必就有局部变量表的概念,但是在堆栈上一定为其分配了空间。不过在C/C++语言中,可以通过嵌入汇编脚本或者直接调用机器指令而动态扩展/收缩栈帧空间,改变栈顶和栈底位置,"世界尽在掌握中"。所涉虽远,然而JVM却是个中高手,在执行引擎中导出可见这类逻辑,如前面所分析执行引擎中的CallStub等例程都使用这种逻辑。
当运行期完成上述int sum = x + y这段逻辑的字节码指令之后,会执行最后一条指令ireturn,结束该方法的调用。ireturn指令所对应的机器指令会将JVM为该方法所申请的栈帧空间销毁掉,由于局部变量表就包含在栈帧之中因此栈帧都被销毁之后,局部变量表自然也被销毁,至此,局部变量表完成其使命。
至于iload和istore系列的字节码指令究竟如何完成局部变量表的读和写,这里先熟悉下即可.总体而言,局部变量表的声明周期包括以下几个环节:
#1 在编译期间,编译器通过文法、语义解析,计算出一个Java方法所需的局部变量表大小,并写入Java class字节码文件的方法属性的code属性表中
#2 在JVM加载Java类的时候,会解析Java class字节码文件中的方法信息,并解析出局部变量表的大小,如此便将局部变量表的大小这个数据从文件系统加载到内存中
#3 当JVM准备调用Java方法时,会为该方法创建栈帧,而栈帧中就包含局部变量表所需的空间。局部变量表的创建过程前面已经分析过。这一步局部变量表终于横空出世,Java方法的入参和内部的局部变量表们终于有了安身立命的场所,大家按照先来后到的顺序按序分。每个局部变量都有自己的slot索引。
#4 当JVM具体执行Java方法时,便调用Java的iload和istore系列指令对栈帧中的局部变量表的空间进行不断读取和写入。由于有slot索引号,所以并不会产生错误。
#5 当JVM执行完Java方法后,Java方法的栈帧空间会被销毁,由于局部变量表就包含在栈帧空间内部,因此连同局部变量表一起被销毁
由于局部变量表简历在堆栈空间上,因此是线程私有数据,所以JVM对齐所进行的一切操作都不用考虑并发安全问题
Java程序员无法直接编写Java代码对局部变量表进行读写,毕竟这不是Java这种语言级别的数据结构概念,这种结构只能通过字节码指令进行访问和写入,并由JVM在运行期进行动态读写。事实上,局部变量表作为堆栈的一部分,起始也决定了其只能由机器指令或者JVM这种能够创建和销毁堆栈的虚拟机器访问。字节码指令而不能由Java程序员去编写,它由编译器生成。但涉及对局部变量表的访问时,编译器会生成load和store这样的指令,分别对局部变量表进行读和写。例如下面这个简单的例子。如图所示。编译这个类,并使用javap命令分析编译后的字节码文件,得到该示例中的add(int, int)方法的字节码指令.
对于add()方法中int sum = x + y这样的源代码,既涉及对两个入参x和y的读取,也涉及对sum局部变量的写入,编译器将其翻译成iload_0、iload_1、iadd和istore_2这4条字节码指令。由于变量x和y是add()方法的入参,因此其slot索引号分别是0和1,所以iload_0和iload_1分别表示从局部变量表中读取变量x和变量y。
当执行iadd指令完成x和y的求和运算之后,求和结果被存储到变量sum中。由于sum是add()方法中的第3个局部变量,因此其在局部变量表的索引号为2,所以执行完iadd指令后,便通过istore_2指令将求和结果存储到sum变量中。
局部变量表的大小(或谓深度)由编译器直接在编译期间计算出来,因此Java方法的入参和局部变量的slot索引号在编译期间便确定下来,所谓slot索引号,说白了其实就是变量以方法栈帧中的某个点作为基准位置进行偏移,终究是堆栈空间的一部分,运行时无法修改,在这一点上,Java语言与其他众多语言都保持一致,虽然其他语言中未必就有局部变量表的概念,但是在堆栈上一定为其分配了空间。不过在C/C++语言中,可以通过嵌入汇编脚本或者直接调用机器指令而动态扩展/收缩栈帧空间,改变栈顶和栈底位置,"世界尽在掌握中"。所涉虽远,然而JVM却是个中高手,在执行引擎中导出可见这类逻辑,如前面所分析执行引擎中的CallStub等例程都使用这种逻辑。
当运行期完成上述int sum = x + y这段逻辑的字节码指令之后,会执行最后一条指令ireturn,结束该方法的调用。ireturn指令所对应的机器指令会将JVM为该方法所申请的栈帧空间销毁掉,由于局部变量表就包含在栈帧之中因此栈帧都被销毁之后,局部变量表自然也被销毁,至此,局部变量表完成其使命。
至于iload和istore系列的字节码指令究竟如何完成局部变量表的读和写,这里先熟悉下即可.总体而言,局部变量表的声明周期包括以下几个环节:
#1 在编译期间,编译器通过文法、语义解析,计算出一个Java方法所需的局部变量表大小,并写入Java class字节码文件的方法属性的code属性表中
#2 在JVM加载Java类的时候,会解析Java class字节码文件中的方法信息,并解析出局部变量表的大小,如此便将局部变量表的大小这个数据从文件系统加载到内存中
#3 当JVM准备调用Java方法时,会为该方法创建栈帧,而栈帧中就包含局部变量表所需的空间。局部变量表的创建过程前面已经分析过。这一步局部变量表终于横空出世,Java方法的入参和内部的局部变量表们终于有了安身立命的场所,大家按照先来后到的顺序按序分。每个局部变量都有自己的slot索引。
#4 当JVM具体执行Java方法时,便调用Java的iload和istore系列指令对栈帧中的局部变量表的空间进行不断读取和写入。由于有slot索引号,所以并不会产生错误。
#5 当JVM执行完Java方法后,Java方法的栈帧空间会被销毁,由于局部变量表就包含在栈帧空间内部,因此连同局部变量表一起被销毁
由于局部变量表简历在堆栈空间上,因此是线程私有数据,所以JVM对齐所进行的一切操作都不用考虑并发安全问题
3.slot大小
虚拟机通过slot索引号来统一区分局部变量表,Java方法里的入参和局部变量一人一个索引号。但是局部变量有大有小,这个大小由数据类型所决定。JVM规范规定一个slot槽位应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。但是这些不同类型的数据所需的内存空间是不同的,而slot的基本单位是确定不变的,JVM是如何保证这一点的呢?换言之,slot的基本单位到底有多大,或者说一个槽位所占的内存空间到底是多大?除了这一疑问,还有另外一个更大的疑问,JVM规范回顶一个slot能够存放一个reference,但是同事又说如果是long型则需要2个slot来存放,问题是,在64位平台上,如果不开启指针压缩功能,则一个引用类型的变量所占的内存空间与long类型的变量应该是一样大小,都是64位,但是JVM的这一规范着实让人十分不解,一个slot对两种数据宽度完全一样的数据的要求不同,那么这个slot的基本长度究竟是多大?到底是按照long的标准还是按照reference这个引用类型的标准?这个答案不知道,不过可以通过编写示例程序进行测试,因为Java类在编译时便能确定其局部变量表的大小,并能确定各个变量的索引号,通过索引号便能知道每一种变量类型究竟占多大空间了。示例程序如下:
本示例程序的add()是类成员方法,因此在编译期实际上会生成3个入参,其中第一个入参是隐式的this指针引用。add()方法包含两个入参,其类型都是long,同时add()方法内部的变量也是long类型。由于add()的第一个隐式入参this指针式一个引用类型reference,而add()方法内部的局部变量都是long类型,因此通过add()方法所对应的字节码指令就能知道引用类型和long类型的变量究竟占多大空间。javap命令作的输出结果显示,add()方法的locals=7,这表示add()方法的局部变量表包含7个slot槽位,这很好理解,由于add()方法包含1个this引用类型的入参和3个long类型的局部变量吧,一个引用入参占1个槽位,而每个long类型的数据占用2个slot,这与JVM的规范是一致的。接着观察add()方法的指令进行进一步确认。第1条字节码指令是lload_1,这表示读取add()方法的第一个入参x,其实此时的add()方法第0个入参是隐式的this指针,this指针的slot索引是0,而第一个入参x的slot索引为1,这的确表示this指针仅占用1个slot.add()方法的第2条字节码指令是lload_3,该指令读取add()方法的第2个入参y,注意此时lload后面的操作数变成3,表明第1个入参x占用了2个slot,否则如果只占用1个slot,则入参y的读取指令应该为lload_2.
剩下的字节码指令各位自行推敲,总之无论从locals与args_size这些参数值,还是从字节码指令看,验证结果都与JVM的规范是完全一致的。不过这更加深了疑惑,在64位平台上,引用类型reference的变量所占的内存空间应该与long类型的数据是一致的,但是为什么它们所占的slot槽位数不同,这里面究竟有何秘密?
虚拟机通过slot索引号来统一区分局部变量表,Java方法里的入参和局部变量一人一个索引号。但是局部变量有大有小,这个大小由数据类型所决定。JVM规范规定一个slot槽位应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。但是这些不同类型的数据所需的内存空间是不同的,而slot的基本单位是确定不变的,JVM是如何保证这一点的呢?换言之,slot的基本单位到底有多大,或者说一个槽位所占的内存空间到底是多大?除了这一疑问,还有另外一个更大的疑问,JVM规范回顶一个slot能够存放一个reference,但是同事又说如果是long型则需要2个slot来存放,问题是,在64位平台上,如果不开启指针压缩功能,则一个引用类型的变量所占的内存空间与long类型的变量应该是一样大小,都是64位,但是JVM的这一规范着实让人十分不解,一个slot对两种数据宽度完全一样的数据的要求不同,那么这个slot的基本长度究竟是多大?到底是按照long的标准还是按照reference这个引用类型的标准?这个答案不知道,不过可以通过编写示例程序进行测试,因为Java类在编译时便能确定其局部变量表的大小,并能确定各个变量的索引号,通过索引号便能知道每一种变量类型究竟占多大空间了。示例程序如下:
本示例程序的add()是类成员方法,因此在编译期实际上会生成3个入参,其中第一个入参是隐式的this指针引用。add()方法包含两个入参,其类型都是long,同时add()方法内部的变量也是long类型。由于add()的第一个隐式入参this指针式一个引用类型reference,而add()方法内部的局部变量都是long类型,因此通过add()方法所对应的字节码指令就能知道引用类型和long类型的变量究竟占多大空间。javap命令作的输出结果显示,add()方法的locals=7,这表示add()方法的局部变量表包含7个slot槽位,这很好理解,由于add()方法包含1个this引用类型的入参和3个long类型的局部变量吧,一个引用入参占1个槽位,而每个long类型的数据占用2个slot,这与JVM的规范是一致的。接着观察add()方法的指令进行进一步确认。第1条字节码指令是lload_1,这表示读取add()方法的第一个入参x,其实此时的add()方法第0个入参是隐式的this指针,this指针的slot索引是0,而第一个入参x的slot索引为1,这的确表示this指针仅占用1个slot.add()方法的第2条字节码指令是lload_3,该指令读取add()方法的第2个入参y,注意此时lload后面的操作数变成3,表明第1个入参x占用了2个slot,否则如果只占用1个slot,则入参y的读取指令应该为lload_2.
剩下的字节码指令各位自行推敲,总之无论从locals与args_size这些参数值,还是从字节码指令看,验证结果都与JVM的规范是完全一致的。不过这更加深了疑惑,在64位平台上,引用类型reference的变量所占的内存空间应该与long类型的数据是一致的,但是为什么它们所占的slot槽位数不同,这里面究竟有何秘密?
JVM规范并没有给出答案,不过可以换个思路进行验证,JVM将局部变量表的每个slot进行了索引编号,Java方法的每个入参和每个局部变量都有一个唯一的索引号,JVM在运行期会将这个索引号转换为机器指令中内存数据传送时的偏移量。可以通过偏移量观察到每个数据类型所占据的真实的内存空间大小。JVM在创建Java方法栈帧时,将局部变量表的起始位置保存到edi寄存器中,并且局部变量表的所谓起始位置其实是Java方法第一个入参的内存位置。JVM准备调用一个Java方法之前,会将Java方法的N个入参进行压栈,复制到局部变量表中,但是此时JVM并未关注这些入参的实际类型,而是将其统一当成指针类型进行处理,因此在32位平台上,这第一个入参在堆栈的内存位置,其实是按照其数据宽度为4字节进行计算的,这里就存在一个问题:如果Java方法的第一个入参类型是long,则最终存储到edi寄存器中的所谓第一个入参的内存位置,便不能代表真实的第一个入参的内存位置,如下面的这个例子
public class A {
public static long add(long x, long y, long z) {
long sum = x + y;
return sum;
}
}
使用javap -verbose命令查看编译后的字节码文件
public static long add(long, long, long);
descriptor: (JJJ)J
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=8, args_size=3
0: lload_0
1: lload_2
2: ladd
3: lstore 6
5: lload 6
7: lreturn
LineNumberTable:
line 6: 0
line 7: 5
}
该示例中的add(long, long, long)是个静态方法,因此没有隐式的this入参,其第一个入参就是声明中的long x.JVM准备调用该方法之前,会初始化该方法的局部变量表,初始化的思路总体上分为两步(32位平台):
#1. 先申请N个指针宽度的堆栈空间,N为Java方法入参数量。对于本例而言,由于包含3个入参,同时在32位平台上,因此JVM先分配12字节内存空间。
#2. 接着申请(maxLocals- sizeOfParameters)个指针宽度的堆栈空间。对于本例而言,使用javap命令查看编译后的字节码文件可知,其maxLocals=8, sizeOfParameters=3,因此JVM会接着申请5个指针宽度的内存空间,在32位平台上,该内存一共包含20字节
在第一步中,JVM会顺便计算第一个入参的内存位置,并将其保存到edi寄存器中。但是这里所谓的第一个入参的内存位置,并非是该入参的真正的内存位置,因为此时仅仅是先申请堆栈空间并全部初始化位0,尚未运行Java字节码指令将真正的局部变量存储进来。此时所谓第一个入参的位置,其实是JVM将各个入参当作指针看待时的位置。这里是一个关键点。
public class A {
public static long add(long x, long y, long z) {
long sum = x + y;
return sum;
}
}
使用javap -verbose命令查看编译后的字节码文件
public static long add(long, long, long);
descriptor: (JJJ)J
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=8, args_size=3
0: lload_0
1: lload_2
2: ladd
3: lstore 6
5: lload 6
7: lreturn
LineNumberTable:
line 6: 0
line 7: 5
}
该示例中的add(long, long, long)是个静态方法,因此没有隐式的this入参,其第一个入参就是声明中的long x.JVM准备调用该方法之前,会初始化该方法的局部变量表,初始化的思路总体上分为两步(32位平台):
#1. 先申请N个指针宽度的堆栈空间,N为Java方法入参数量。对于本例而言,由于包含3个入参,同时在32位平台上,因此JVM先分配12字节内存空间。
#2. 接着申请(maxLocals- sizeOfParameters)个指针宽度的堆栈空间。对于本例而言,使用javap命令查看编译后的字节码文件可知,其maxLocals=8, sizeOfParameters=3,因此JVM会接着申请5个指针宽度的内存空间,在32位平台上,该内存一共包含20字节
在第一步中,JVM会顺便计算第一个入参的内存位置,并将其保存到edi寄存器中。但是这里所谓的第一个入参的内存位置,并非是该入参的真正的内存位置,因为此时仅仅是先申请堆栈空间并全部初始化位0,尚未运行Java字节码指令将真正的局部变量存储进来。此时所谓第一个入参的位置,其实是JVM将各个入参当作指针看待时的位置。这里是一个关键点。
当上面的这两步都执行完之后,JVM便为add(long, long)方法分配好局部变量表的空间,其内存布局如图所示。
但是add(long,long,long)方法的第一个入参类型其实是long,long类型的数据需要栈基8字节内存空间,因此该入参的真实内存起始位置应该是如图所示入参2所在的内存位置。如图给出一个很明显的信息:edi寄存器所存储的位置,并非是局部变量表中第一个变量的实际内存位置,这种特性直接影响到Java字节码指令中读写局部变量表的load和store系列指令所对应的机器码层面的处理,因为load和store系列指令所对应的机器码在读写局部变量表时,实际上将edi寄存器所指的位置当作基准偏移位置,,而现在add(long, long, long)方法的第一个long类型的入参的实际内存位置与edi寄存器这个基准位置不同,最终反映到机器码层面必定要基于edi进行偏移。读取局部变量表中第一个long类型的数据的字节码指令是lload_0,且看该指令在32位平台上所生成的机器指令:
mov -0x4(%edi), %eax
mov (%edi),%edx
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f102a0(, %ebx, 4)
注意第一条机器码指令是"mov -0x4(%edi), %eax"机器码果然从edi寄存器所指的下一个4字节的位置开始读取局部变量表中的long类型的数据,并将其保存到eax寄存器中。由于在32位平台上,一次mov指令最大只能传送4字节数据,因此这里对于long类型的数据连续使用了2条mov指令,连续读取了2个4字节数据并分别保存到eax和edx寄存器中。这里起始是使用了栈顶缓存技术。总之由此可以验证,edi寄存器中所存储的的确不是第一个入参的真实内存起始位置,对于这一点,还有一个验证点,如果Java方法的第一个入参类型是int,在32位平台上,Java中的一个int类型数据占4字节内存空间,这与指针类型的数据宽度是一致的。那么如果Java方法的第一个入参若果真是int类型,则edi寄存器所指向的位置与该入参的真实内存位置相同。若Java中读取局部变量表第一个数据且类型是int的字节字节码指令是iload_0,则该字节码指令所对应的机器指令应该如下:
mov (%edi), %eax
查看JVM在32位平台上字节码指令所对应的汇编指令,iload_0指令如下:
mov (%edi), %eax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f106a0(, %ebx, 4)
结果果然与预测中的完全一致! 所以如果Java方法的第一个入参类型是int类型,则edi寄存器所指的内存位置便与第一个入参的真实内存位置保持一致。
mov -0x4(%edi), %eax
mov (%edi),%edx
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f102a0(, %ebx, 4)
注意第一条机器码指令是"mov -0x4(%edi), %eax"机器码果然从edi寄存器所指的下一个4字节的位置开始读取局部变量表中的long类型的数据,并将其保存到eax寄存器中。由于在32位平台上,一次mov指令最大只能传送4字节数据,因此这里对于long类型的数据连续使用了2条mov指令,连续读取了2个4字节数据并分别保存到eax和edx寄存器中。这里起始是使用了栈顶缓存技术。总之由此可以验证,edi寄存器中所存储的的确不是第一个入参的真实内存起始位置,对于这一点,还有一个验证点,如果Java方法的第一个入参类型是int,在32位平台上,Java中的一个int类型数据占4字节内存空间,这与指针类型的数据宽度是一致的。那么如果Java方法的第一个入参若果真是int类型,则edi寄存器所指向的位置与该入参的真实内存位置相同。若Java中读取局部变量表第一个数据且类型是int的字节字节码指令是iload_0,则该字节码指令所对应的机器指令应该如下:
mov (%edi), %eax
查看JVM在32位平台上字节码指令所对应的汇编指令,iload_0指令如下:
mov (%edi), %eax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x48f106a0(, %ebx, 4)
结果果然与预测中的完全一致! 所以如果Java方法的第一个入参类型是int类型,则edi寄存器所指的内存位置便与第一个入参的真实内存位置保持一致。
搞清楚了这一层的原理,接着便可以验证JVM规范中所说的一个slot究竟栈多大内存空间的问题了。在32位平台上,一个slot能够存放一个int类型,也能存放一个reference类型,因此如果一个Java方法的第1个和第2个入参的类型都是int类型,则读取局部变量表中这2个入参的字节码指令分别是iload_0和iload_1,如果要验证32位平台上一个slot槽位究竟占多大内存空间,只需要分析iload_0和iload_1这2条字节码指令所对应的机器指令中,分别相对于edi寄存器做多大的偏移量即可。刚刚展示了iload_0字节码指令所对应的机器指令,那么在32位平台上,iload_1字节码指令所对应的机器指令如下:
mov -0x4(%edi), %eax
movzbl 0x1(%esi), %ebx
inc *%esi
jmp *-0x48f106a0(, %ebx, 4)
可以看到,最终机器指令从局部局部变量表中读取第二个且数据类型是int类型的变量时,使用了"mov -0x4(%edi), %eax"这条机器指令,这表示第二个int类型的变量在局部变量表中的起始位置相对于edi所指的位置偏移了-0x4字节,而这个偏移距离正好是32位平台上,一个指针类型的数据宽度也是4字节,而Java内部的reference引用类型的数据起始就是一个指针,因此32位平台上的1个slot槽位也能容纳下一个reference类型的数据。
按照这种思路,可以顺便验证下32位平台上一个long类型的数据是否的确需要两个slot槽位。按照同样的思路,验证最让人疑惑的64位平台上的slot大小。由于JVM规范规定一个slot能够容纳一个int类型的数据,并无32位与64位平台之分,因此即使在64位平台上,一个int类型的数据仍然只需要1个slot即可。要验证64位平台上1个slot大小,只需要看iload_0和iload_1这两条字节码指令所对应的机器码相对于edi寄存器的偏移量,这个偏移量便是slot的大小。在64位平台上,iload_0这条字节码指令所对应的机器码如下:
mov (%edi), %eax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
由第一条机器码可知,iload_0指令读取局部变量表第一个且类型是int的变量时,相对于edi寄存器的偏移量是0.
64位平台上,iload_1字节码指令所对应的机器码如下:
mov -0x8(%edi), %eax
movzbl 0x1(%esi),%ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
由第一条机器码可知,iload_0指令读取局部变量表第一个且类型是int的变量时,相对于edi寄存器的偏移量是-0x8.而上面iload_0指令相对于edi的偏移量是0,由此可知iload_0与iload_1这两条字节码指令所读取的数据的内存位置的相对偏移量为8字节,而这正是1个slot的大小。
mov -0x4(%edi), %eax
movzbl 0x1(%esi), %ebx
inc *%esi
jmp *-0x48f106a0(, %ebx, 4)
可以看到,最终机器指令从局部局部变量表中读取第二个且数据类型是int类型的变量时,使用了"mov -0x4(%edi), %eax"这条机器指令,这表示第二个int类型的变量在局部变量表中的起始位置相对于edi所指的位置偏移了-0x4字节,而这个偏移距离正好是32位平台上,一个指针类型的数据宽度也是4字节,而Java内部的reference引用类型的数据起始就是一个指针,因此32位平台上的1个slot槽位也能容纳下一个reference类型的数据。
按照这种思路,可以顺便验证下32位平台上一个long类型的数据是否的确需要两个slot槽位。按照同样的思路,验证最让人疑惑的64位平台上的slot大小。由于JVM规范规定一个slot能够容纳一个int类型的数据,并无32位与64位平台之分,因此即使在64位平台上,一个int类型的数据仍然只需要1个slot即可。要验证64位平台上1个slot大小,只需要看iload_0和iload_1这两条字节码指令所对应的机器码相对于edi寄存器的偏移量,这个偏移量便是slot的大小。在64位平台上,iload_0这条字节码指令所对应的机器码如下:
mov (%edi), %eax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
由第一条机器码可知,iload_0指令读取局部变量表第一个且类型是int的变量时,相对于edi寄存器的偏移量是0.
64位平台上,iload_1字节码指令所对应的机器码如下:
mov -0x8(%edi), %eax
movzbl 0x1(%esi),%ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
由第一条机器码可知,iload_0指令读取局部变量表第一个且类型是int的变量时,相对于edi寄存器的偏移量是-0x8.而上面iload_0指令相对于edi的偏移量是0,由此可知iload_0与iload_1这两条字节码指令所读取的数据的内存位置的相对偏移量为8字节,而这正是1个slot的大小。
在进一步验证64位平台上的slot大小之前,有必要弄清楚在64位平台上edi寄存器所指的位置。当JVM准备执行一个Java方法时,先为其创建局部变量表并初始化为0.还是以上面的Calculator.add(long x, long y, long z)方法为例,该方法的局部变量表创建完成之后,其内存布局及edi寄存器所指位置如图所示。。
64位平台与32位平台最大的不同便在于,JVM创建局部变量表时,JVM仍然将每一个入参当作一个指针类型的数据处理,但是一个指针在64位平台上占8字节,因此edi寄存器所指的位置,其后面起始能够容纳一个long类型的数据(即入参1的大小)。那么如果Java方法的第一个入参类型是long,则lload_0字节码指令所对应的机器码应该直接从edi寄存器所指的位置处开始读取数据,那么来看下64位平台上,lload_0这条字节码指令所对应的机器码,如下所示:
mov -0x8(%edi), %rax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
关注第一条机器指令"mov -0x8(%edi), %rax",这个结果令人吃惊,与预测的完全不同,lload_0竟然不是从edi寄存器所指的位置开始读取long类型的数据,而是以edi所指位置位基准又向低地址方向偏移了8字节。由此可见,在64位平台上,一个long类型的数据在局部变量表中似乎占据了16字节的大小。不过这与JVM规范倒是完全相符,因为刚才验证过在64位平台上,一个slot的宽度是8字节,而JVM规范有规定存储一个long类型的数据需要2个slot,而slot的宽度就是16字节。由此,谜底终于揭开了,JVM的规范一点没错,无论是在32位平台还是64位平台上都会一样生效。同时,这也解释了64位平台上的reference引用类型数据所占的slot数量为1的原因,因为虽然在64位平台上,一个引用类型本质上是一个指针类型,其所需内存大小按理应该与一个long类型的数据所需的内存大小相同,但是在JVM的局部变量表中,这两种类型的数据所需的slot数是不同的,这是由早期的JVM规范所规定的,所以到了64位平台上的JVM,只能将一个slot实现为占据8字节大小的内存区域,如此才让一个slot能容纳一个引用类型的数据。不过在JVM内部,long类型的变量也只有在局部变量表中才会占据16字节,而在堆内存中,该类型的数据该占据多大的内存空间,还是占据多大的内存空间。例如,如果一个Java类的成员变量类型是long类型,则该变量会被分配在堆内存中,在堆内存中,该变量就只占8字节。从这个角度来看,64位平台上的JVM的局部变量表对内存存在一定的空间浪费。
64位平台与32位平台最大的不同便在于,JVM创建局部变量表时,JVM仍然将每一个入参当作一个指针类型的数据处理,但是一个指针在64位平台上占8字节,因此edi寄存器所指的位置,其后面起始能够容纳一个long类型的数据(即入参1的大小)。那么如果Java方法的第一个入参类型是long,则lload_0字节码指令所对应的机器码应该直接从edi寄存器所指的位置处开始读取数据,那么来看下64位平台上,lload_0这条字节码指令所对应的机器码,如下所示:
mov -0x8(%edi), %rax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
关注第一条机器指令"mov -0x8(%edi), %rax",这个结果令人吃惊,与预测的完全不同,lload_0竟然不是从edi寄存器所指的位置开始读取long类型的数据,而是以edi所指位置位基准又向低地址方向偏移了8字节。由此可见,在64位平台上,一个long类型的数据在局部变量表中似乎占据了16字节的大小。不过这与JVM规范倒是完全相符,因为刚才验证过在64位平台上,一个slot的宽度是8字节,而JVM规范有规定存储一个long类型的数据需要2个slot,而slot的宽度就是16字节。由此,谜底终于揭开了,JVM的规范一点没错,无论是在32位平台还是64位平台上都会一样生效。同时,这也解释了64位平台上的reference引用类型数据所占的slot数量为1的原因,因为虽然在64位平台上,一个引用类型本质上是一个指针类型,其所需内存大小按理应该与一个long类型的数据所需的内存大小相同,但是在JVM的局部变量表中,这两种类型的数据所需的slot数是不同的,这是由早期的JVM规范所规定的,所以到了64位平台上的JVM,只能将一个slot实现为占据8字节大小的内存区域,如此才让一个slot能容纳一个引用类型的数据。不过在JVM内部,long类型的变量也只有在局部变量表中才会占据16字节,而在堆内存中,该类型的数据该占据多大的内存空间,还是占据多大的内存空间。例如,如果一个Java类的成员变量类型是long类型,则该变量会被分配在堆内存中,在堆内存中,该变量就只占8字节。从这个角度来看,64位平台上的JVM的局部变量表对内存存在一定的空间浪费。
这里有个问题,可能有些人认为既然可以使用iload_0与iload-1这两条字节码指令所对应的机器码相对于edi寄存器的偏移量之差来确定slot的大小,那么也应该能使用lload_0与lload_1这两条字节码指令来确定。例如,上面给出了lload_0字节码相对于edi的偏移量为-0x8,而lload_1字节码指令在64位平台上所对应的机器码如下:
mov -0x10(%edi), %rax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
这条字节码指令从相对于edi偏移量为-0x10的位置开始读取局部变量。由于lload_0从相对于edi偏移量为-0x8的位置开始读取,这两个偏移量之差为0x8,即8字节,由此得出long类型的数据在局部变量表中只需占据8字节大小的结论。很显然,这个结论是错误的,其根本原因在于,如果一个Java方法编译后能够产生lload_1这样的字节码指令,则说明该方法的局部变量表的第二个变量类型是long,并且其索引号是1,那么便说明Java方法的局部变量表中的第一个变量只占1个slot槽位,因此第一个变量类型一定是int、reference等而理性,而绝不可能是long类型,否则第二个变量的读取指令就应该是lload_2.如果第一个变量类型是int,则其读取指令是iload_0,因此需要比较iload_0与lload_1这两条字节码指令所读取的相对于edi寄存器所指内存的偏移量之差,由此才能确定局部变量表中一个long类型的数据所占据的slot槽数,很显然,iload_0字节码指令从相对于edi偏移量为0的位置开始读取局部变量,而lload_1从相对于edi偏移量为-0x10的位置开始读取局部变量,这两者的偏移量之差为0x10,即16字节,因此也能验证出一个long类型的数据在局部变量表中占据16字节的内存大小。这个结果与上述的验证结果完全一致。
mov -0x10(%edi), %rax
movzbl 0x1(%esi), %ebx
inc %esi
jmp *-0x103ceb100(, %ebx, 8)
这条字节码指令从相对于edi偏移量为-0x10的位置开始读取局部变量。由于lload_0从相对于edi偏移量为-0x8的位置开始读取,这两个偏移量之差为0x8,即8字节,由此得出long类型的数据在局部变量表中只需占据8字节大小的结论。很显然,这个结论是错误的,其根本原因在于,如果一个Java方法编译后能够产生lload_1这样的字节码指令,则说明该方法的局部变量表的第二个变量类型是long,并且其索引号是1,那么便说明Java方法的局部变量表中的第一个变量只占1个slot槽位,因此第一个变量类型一定是int、reference等而理性,而绝不可能是long类型,否则第二个变量的读取指令就应该是lload_2.如果第一个变量类型是int,则其读取指令是iload_0,因此需要比较iload_0与lload_1这两条字节码指令所读取的相对于edi寄存器所指内存的偏移量之差,由此才能确定局部变量表中一个long类型的数据所占据的slot槽数,很显然,iload_0字节码指令从相对于edi偏移量为0的位置开始读取局部变量,而lload_1从相对于edi偏移量为-0x10的位置开始读取局部变量,这两者的偏移量之差为0x10,即16字节,因此也能验证出一个long类型的数据在局部变量表中占据16字节的内存大小。这个结果与上述的验证结果完全一致。
至此,关于JVM内部局部变量表中的slot到底是多大的问题便验证完毕。总结如下:
# 32位平台上,一个slot大小为4字节
# 64位平台上,一个slot大小为8字节
# 64位平台上,long类型数据在局部变量表中占据16字节的内存空间,但是在堆内存中该占据多大内存空间还是占据多大
不过根据JVM规范,还有一个疑问,那就是一个slot能够容纳一个int、reference等类型的数据,还能容纳一个short、char等类型的数据。由于char、short所需内存空间,小于int与reference类型所需的内存空间,那么对于这类数窄数据,JVM如何在局部变量表中进行存取呢?请看如图所示。从javap命令的分析结果可以看出,对于char和short类型的数据,无论是读还是写,所使用的指令全都是iload与istore系列的指令,因此在JVM内部,对于数据宽度小于int类型的数据类型,仍然将其处理成int类型的数据,并使用基于int类型数据的读写指令对于char、short等类型的数据进行读写。
# 32位平台上,一个slot大小为4字节
# 64位平台上,一个slot大小为8字节
# 64位平台上,long类型数据在局部变量表中占据16字节的内存空间,但是在堆内存中该占据多大内存空间还是占据多大
不过根据JVM规范,还有一个疑问,那就是一个slot能够容纳一个int、reference等类型的数据,还能容纳一个short、char等类型的数据。由于char、short所需内存空间,小于int与reference类型所需的内存空间,那么对于这类数窄数据,JVM如何在局部变量表中进行存取呢?请看如图所示。从javap命令的分析结果可以看出,对于char和short类型的数据,无论是读还是写,所使用的指令全都是iload与istore系列的指令,因此在JVM内部,对于数据宽度小于int类型的数据类型,仍然将其处理成int类型的数据,并使用基于int类型数据的读写指令对于char、short等类型的数据进行读写。
栈帧深度与slot复用。。
通常一个Java程序会包含多个线程,每个线程都会包含若干方法栈帧,这些若干方法栈帧就组成了线程的堆栈空间。线程堆栈空间(stack space)不会无限制地增长,而是会受到约束,直接由操作系统加载的软件程序的线程堆栈空间大小会受到操作系统层面所设置的栈空间大小限制。
正是因为堆栈空间大小会受到限制,所以当一个线程中所调用的方法太深时,导致JVM所分配的栈帧太多,就有可能耗尽stack space,从而抛出stackOverflow异常。所以合理地设置默认堆栈空间大小是一门学问。在JVM中,可以通过XSS来设置默认地堆栈空间大小。前面分析过,Java方法栈帧由三大部分组成:局部变量表、固定帧和操作数栈。固定帧的大小是固定不变的,无论所调用的是何种Java方法。因此Java方法帧的大小取决于局部变量表和操作数栈的大小,而由于调用者方法的操作数栈会作为被调用方法的局部变量表的一部分(栈帧重叠),因此可以认为操作数栈属于被调用者方法的栈帧的一部分,由此看来,一个Java方法的栈帧大小主要取决于局部变量表的大小,而Java方法的局部变量表主要由两部分组成,一个是Java方法入参;二是Java方法局部变量。因此,Java方法的栈帧大小最终取决于Java方法的入参和局部变量的大小。为何要分析这个问题呢?实在是因为这与线程堆栈的合理运用存在着馍大的关系。当JVM设定默认的堆栈空间大小后,一个Java线程所能调用的最大方法深度便直接取决于Java方法局部变量表的大小。如果Java方法的局部变量表所占的空间大,则Java线程所能调用的最大方法深度便会变小。例如下面这个示例:
//...
1577
1578
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:636)
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
// ...
可见,当递归调用1578此之后,线程的堆栈空间终于被耗尽。当堆栈空间被耗尽后,JVM便会抛出java.lang.StackOverflowError异常。
通常一个Java程序会包含多个线程,每个线程都会包含若干方法栈帧,这些若干方法栈帧就组成了线程的堆栈空间。线程堆栈空间(stack space)不会无限制地增长,而是会受到约束,直接由操作系统加载的软件程序的线程堆栈空间大小会受到操作系统层面所设置的栈空间大小限制。
正是因为堆栈空间大小会受到限制,所以当一个线程中所调用的方法太深时,导致JVM所分配的栈帧太多,就有可能耗尽stack space,从而抛出stackOverflow异常。所以合理地设置默认堆栈空间大小是一门学问。在JVM中,可以通过XSS来设置默认地堆栈空间大小。前面分析过,Java方法栈帧由三大部分组成:局部变量表、固定帧和操作数栈。固定帧的大小是固定不变的,无论所调用的是何种Java方法。因此Java方法帧的大小取决于局部变量表和操作数栈的大小,而由于调用者方法的操作数栈会作为被调用方法的局部变量表的一部分(栈帧重叠),因此可以认为操作数栈属于被调用者方法的栈帧的一部分,由此看来,一个Java方法的栈帧大小主要取决于局部变量表的大小,而Java方法的局部变量表主要由两部分组成,一个是Java方法入参;二是Java方法局部变量。因此,Java方法的栈帧大小最终取决于Java方法的入参和局部变量的大小。为何要分析这个问题呢?实在是因为这与线程堆栈的合理运用存在着馍大的关系。当JVM设定默认的堆栈空间大小后,一个Java线程所能调用的最大方法深度便直接取决于Java方法局部变量表的大小。如果Java方法的局部变量表所占的空间大,则Java线程所能调用的最大方法深度便会变小。例如下面这个示例:
//...
1577
1578
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:636)
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
// ...
可见,当递归调用1578此之后,线程的堆栈空间终于被耗尽。当堆栈空间被耗尽后,JVM便会抛出java.lang.StackOverflowError异常。
接着对上面的示例程序稍作修改,主要修改test(long)方法,修改后的方法如下:
public static void test(long a) {
long b = a + 3;
long c = b - a + b * 5;
long d = a & 6;
System.out.println(a++);
test(a);
}
修改后的test(long)方法内部另外声明了两个局部变量,并且都是long类型的,仍然将JVM的XSS参数设置为200KB,运行修改后的程序,输出结果如上
// ...
1167
1168
1169
1170
1171
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
// ...
现在可以看到,test(long)函数仅递归调用了1171此便耗尽了线程的堆栈空间。由此可见,当Java方法的局部变量表增大后,的确会减少方法的调用深度。各位可以继续在test(long)方法内部定义更多的局部变量并测试所能递归的最大次数。
由于局部变量表的大小直接影响到一个线程所能调用的方法深度,因此在声明方法局部变量时,应该尽可能使slot能够服用。所谓slot复用,便是让方法内部的变量能够占用局部变量表中的同一个槽位,这样便能减小局部变量表的大小,从而提高一个线程能调用的最大方法深度
public static void test(long a) {
long b = a + 3;
long c = b - a + b * 5;
long d = a & 6;
System.out.println(a++);
test(a);
}
修改后的test(long)方法内部另外声明了两个局部变量,并且都是long类型的,仍然将JVM的XSS参数设置为200KB,运行修改后的程序,输出结果如上
// ...
1167
1168
1169
1170
1171
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
// ...
现在可以看到,test(long)函数仅递归调用了1171此便耗尽了线程的堆栈空间。由此可见,当Java方法的局部变量表增大后,的确会减少方法的调用深度。各位可以继续在test(long)方法内部定义更多的局部变量并测试所能递归的最大次数。
由于局部变量表的大小直接影响到一个线程所能调用的方法深度,因此在声明方法局部变量时,应该尽可能使slot能够服用。所谓slot复用,便是让方法内部的变量能够占用局部变量表中的同一个槽位,这样便能减小局部变量表的大小,从而提高一个线程能调用的最大方法深度
仍然以上面修改后的test(long)方法作为示例,该方法内部包含3个局部变量,分别是b、c和d.但是仔细观察可以发现,从源程序的d变量声明开始,一直到方法结束,a和b变量都没有再被使用,因此可以使用花括号{}将a和b变量的声明语句括起来,如下:
public static void test(long a) {
{
long b = a + 3;
long c = b - a + b * 5;
}
long d = a & 6;
System.out.println(a++);
test(a);
}
现在仍然将JVM的XSS设置为200KB,运行该示例程序,输出结果如下:
// ...
1215
1216
1217
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:636)
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
从输出结果可以看出,test(long)方法被调用的最大次数相比刚才的1171此增大到1217此,由此可见增加花括号的方式的确有效。这是因为使用花括号将前面两个变量括起来之后,Java编译器便会认为其作用域不会超出花括号的范围,因此在出了花括号的范围之后,JVM便会清空这两个变量所占用的slot槽位,空出来给test(long)方法内部后续的变量复用。
public static void test(long a) {
{
long b = a + 3;
long c = b - a + b * 5;
}
long d = a & 6;
System.out.println(a++);
test(a);
}
现在仍然将JVM的XSS设置为200KB,运行该示例程序,输出结果如下:
// ...
1215
1216
1217
Exception in thread "Thread-0" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:636)
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
从输出结果可以看出,test(long)方法被调用的最大次数相比刚才的1171此增大到1217此,由此可见增加花括号的方式的确有效。这是因为使用花括号将前面两个变量括起来之后,Java编译器便会认为其作用域不会超出花括号的范围,因此在出了花括号的范围之后,JVM便会清空这两个变量所占用的slot槽位,空出来给test(long)方法内部后续的变量复用。
最大操作数栈与操作数栈复用。
与Java方法堆栈息息相关的一个重要参数是max stack,即最大操作数栈。Java虚拟机的指令集基于栈,所有的计算逻辑都需要通过栈来完成,这个栈便是"操作数栈",JVM内部也叫"表达式栈"。操作数栈的大小由Java编译器再编译期计算,但是编译期只能计算出一个"最大"的栈深度,这是因为操作数栈与slot槽一样,也可以实现复用。而之所以要实现复用,是为了节省宝贵的内存空间。最大操作数栈被作为Java class字节码文件内部Code属性区的一部分,Java源文件被编译后,使用javap -v命令可以查看每一个Java方法的最大操作数栈大小,例如下面这个例子,如图所示。
打印结果中的"stack=1"表示本程序的main()主函数的最大操作数栈空间只需要1个即可,stack的数据宽度与slot槽位宽度保持一致,因此这里也可以说,main()主函数的最大操作数栈空间为1个槽位。在本示例程序中main主函数的字节码指令中包含iconst_0,该指令会将自然数1推送至操作数栈栈顶,因此main()主函数需要1个槽位大小的操作数栈空间:
与Java方法堆栈息息相关的一个重要参数是max stack,即最大操作数栈。Java虚拟机的指令集基于栈,所有的计算逻辑都需要通过栈来完成,这个栈便是"操作数栈",JVM内部也叫"表达式栈"。操作数栈的大小由Java编译器再编译期计算,但是编译期只能计算出一个"最大"的栈深度,这是因为操作数栈与slot槽一样,也可以实现复用。而之所以要实现复用,是为了节省宝贵的内存空间。最大操作数栈被作为Java class字节码文件内部Code属性区的一部分,Java源文件被编译后,使用javap -v命令可以查看每一个Java方法的最大操作数栈大小,例如下面这个例子,如图所示。
打印结果中的"stack=1"表示本程序的main()主函数的最大操作数栈空间只需要1个即可,stack的数据宽度与slot槽位宽度保持一致,因此这里也可以说,main()主函数的最大操作数栈空间为1个槽位。在本示例程序中main主函数的字节码指令中包含iconst_0,该指令会将自然数1推送至操作数栈栈顶,因此main()主函数需要1个槽位大小的操作数栈空间:
修改上面的示例程序,变成如下:
现在main()主函数中定义了两个变量a和b,使用javap命令分析后得知main()主函数的最大操作数栈仍然是1,这是因为当JVM执行main()函数中的"int a = 1"这条指令时需要将自然数1推送至栈顶,但是当执行完之后,栈顶的自然数1会从栈顶被传送至局部变量表中,因此当执行"int b = 1"时,JVM便可以复用栈顶的1个空间。
现在main()主函数中定义了两个变量a和b,使用javap命令分析后得知main()主函数的最大操作数栈仍然是1,这是因为当JVM执行main()函数中的"int a = 1"这条指令时需要将自然数1推送至栈顶,但是当执行完之后,栈顶的自然数1会从栈顶被传送至局部变量表中,因此当执行"int b = 1"时,JVM便可以复用栈顶的1个空间。
接着看下面这个示例。本示例与上面的示例相比,在main()函数里面多了一步——调用add()方法。
可以看到,现在Stack的值为2,为何会是2呢?这主要因为main()主函数调用了add()方法。由于add()方法包含2个入参,因此当main()函数调用add()方法时,需要将add()方法所需的两个实参推送至main()方法的操作数栈顶。JVM在执行Java方法调用时,实现了"堆栈重叠"技术,因此这两个栈顶实参将被当作add()方法局部变量表的一部分。在本示例中,如果没有操作数栈复用技术,则main()函数的最大操作数栈一定为4,但是由于"int b = 1"指令复用了"int a = 1"指令的操作数栈空间,而"add(a,b)"指令又复用了"int b = 1"指令的操作数栈空间,因此最终main()函数只需要分配2个槽位大小的操作数栈空间,便能满足全部指令的逻辑计算。由此可以明白Java方法的操作数栈之"最大"的含义——这个"最大"实则时在内存复用的基础上,从一个Java方法所有指令中选出一个需要占用最多操作数栈空间的指令,以该指令所需的操作数栈空间作为一个Java方法的"最大"操作数栈空间
可以看到,现在Stack的值为2,为何会是2呢?这主要因为main()主函数调用了add()方法。由于add()方法包含2个入参,因此当main()函数调用add()方法时,需要将add()方法所需的两个实参推送至main()方法的操作数栈顶。JVM在执行Java方法调用时,实现了"堆栈重叠"技术,因此这两个栈顶实参将被当作add()方法局部变量表的一部分。在本示例中,如果没有操作数栈复用技术,则main()函数的最大操作数栈一定为4,但是由于"int b = 1"指令复用了"int a = 1"指令的操作数栈空间,而"add(a,b)"指令又复用了"int b = 1"指令的操作数栈空间,因此最终main()函数只需要分配2个槽位大小的操作数栈空间,便能满足全部指令的逻辑计算。由此可以明白Java方法的操作数栈之"最大"的含义——这个"最大"实则时在内存复用的基础上,从一个Java方法所有指令中选出一个需要占用最多操作数栈空间的指令,以该指令所需的操作数栈空间作为一个Java方法的"最大"操作数栈空间
类方法解析
前面分析了HotSpot解析常量池和类变量的详细过程,接下来将谈谈类方法的解析。作为一门面向对象的语言,每一个Java类都有属性和行为这两个基本要素,而Java又作为一门解释性的语言,由JVM虚拟机负责解释执行。JVM执行的正是Java类的"行为"——Java方法,而执行之前,必须要先对方法进行解释,毕竟JVM虚拟机没有真正的运算能力,最终必须依靠物理CPU完成字节码的运算。
Java方法的解析大体上可以分为3道工序:
#1 在Java类源代码编译期间,编译器负责将Java类源代码翻译为对应的字节码指令,同时完成的工作还有Java方法局部变量表的计算,以及最大操作数栈的计算。
#2 在JVM运行期间,JVM加载类型,调用classFileParser::parseClassFIle()函数对Java class字节码文件进行解析,在这一步将会完成Java方法的分析、字节码指令存放、父类与接口类方法继承与重载等一系列逻辑
#3 在调用系统加载器System Class Loader(SCL)对应用程序的Java类进行加载的过程中,完成方法符号链接、验证,最重要的是完成vtable与itable的构建,从而支持在JVM运行期的方法动态绑定(也叫晚绑定)。当然,Java技术体系不仅提供了SCL,还提供了其他类加载用于加载Java类,开发者也可以自定义类加载器来加载
经过这3道层层推进的工序,最终才能完成Java类方法的解析工作,等这一切都完成后,JVM才能在运行期通过invoke_virtual等字节码指令,完成Java方法的调用和执行。这里我们主要讲述Java类方法解析的第2道工序,该工序主要在classFileParser::parseClassFile()函数中完成。classFileParser::parseClassFile()函数主要完成Java类class字节码文件的解析,其中不仅仅包含Java方法的解析,还包含其他步骤
Java方法的解析大体上可以分为3道工序:
#1 在Java类源代码编译期间,编译器负责将Java类源代码翻译为对应的字节码指令,同时完成的工作还有Java方法局部变量表的计算,以及最大操作数栈的计算。
#2 在JVM运行期间,JVM加载类型,调用classFileParser::parseClassFIle()函数对Java class字节码文件进行解析,在这一步将会完成Java方法的分析、字节码指令存放、父类与接口类方法继承与重载等一系列逻辑
#3 在调用系统加载器System Class Loader(SCL)对应用程序的Java类进行加载的过程中,完成方法符号链接、验证,最重要的是完成vtable与itable的构建,从而支持在JVM运行期的方法动态绑定(也叫晚绑定)。当然,Java技术体系不仅提供了SCL,还提供了其他类加载用于加载Java类,开发者也可以自定义类加载器来加载
经过这3道层层推进的工序,最终才能完成Java类方法的解析工作,等这一切都完成后,JVM才能在运行期通过invoke_virtual等字节码指令,完成Java方法的调用和执行。这里我们主要讲述Java类方法解析的第2道工序,该工序主要在classFileParser::parseClassFile()函数中完成。classFileParser::parseClassFile()函数主要完成Java类class字节码文件的解析,其中不仅仅包含Java方法的解析,还包含其他步骤
clasFileParser:parseClassFile()函数通过调用ClassFileParser::parse_methods()函数完成Java类方法解析的第2道工序.ClassFileParser::parse_methods()函数主要逻辑如下(仅保留主要逻辑):
这段逻辑很简单,首先从Java class文件流中读取当前Java类中所定义的全部方法数量,接着循环遍历Java类中的每一个方法,调用ClassFileParser::parse_method()函数对Java方法进行逐个解析。
这段逻辑很简单,首先从Java class文件流中读取当前Java类中所定义的全部方法数量,接着循环遍历Java类中的每一个方法,调用ClassFileParser::parse_method()函数对Java方法进行逐个解析。
ClassFileParser::parse_method()函数所包含的逻辑颇为复杂,毕竟这是Java类的核心所在,不过虽然复杂,却是条缕分明,层次清晰,总体看来,奇冤吗骨架如下:
方法签名解析与校验。
JavaClass字节码文件中的方法属性部分的解析,从方法的flags标识的索引(指该标识在Java class内部常量池的索引号)开始,flags标识占用2字节长度,并且其后连续跟了4字节,分别时方法名称索引和方法描述索引,方法名称索引和描述索引各占2字节长度。Java方法的签名信息主要3部分组成:
# 方法的标识,public、private、static、final、synchronized、native等
# 方法的名称
# 方法的描述,描述方法的返回值类型和入参信息,例如()V标识无入参的void类型方法
这3种信息共同组成Java方法的签名信息。由于每种信息中所存储的都是指向常量池的索引号,因此只需要2字节,Java方法签名信息总共需要6字节长度。
JavaClass字节码文件中的方法属性部分的解析,从方法的flags标识的索引(指该标识在Java class内部常量池的索引号)开始,flags标识占用2字节长度,并且其后连续跟了4字节,分别时方法名称索引和方法描述索引,方法名称索引和描述索引各占2字节长度。Java方法的签名信息主要3部分组成:
# 方法的标识,public、private、static、final、synchronized、native等
# 方法的名称
# 方法的描述,描述方法的返回值类型和入参信息,例如()V标识无入参的void类型方法
这3种信息共同组成Java方法的签名信息。由于每种信息中所存储的都是指向常量池的索引号,因此只需要2字节,Java方法签名信息总共需要6字节长度。
code属性解析
Java方法的Code属性解析都集中在ClassFileParser::parse_method()函数的while循环下的if(method_attribute_name == vmSymbols::tag_code()){}块中。Java方法的code属性主要包含属性的总长度、最大栈深度、局部变量表数量、字节码指令,除此以外,code属性本身是一个复合属性,其下面还包含几个子属性,例如行号表、局部变量表等。Java方法的code属性起始于属性名称的常量池索引号,索引号之后所跟的是属性长度,所以在ClassFileParser::parse_method()中主要解析Java方法几大属性的while循环中,首先便是执行u2 method_attribute_name_index = cfs->get_u2_fast()和u4 method_attribute_length = cfs->get_u4_fast()来分别获取当前属性的索引号和总长度。Java方法的极大属性的索引号的数据宽度为2字节,总数据宽度为4字节。
紧跟在code属性的总长度后面的是3个属性:max_stack、max_locals和code_length所占用的数据宽度不同,HotSpot对此进行了区分,逻辑如下:
// Stack size, locals size, and code size
if (_major_version == 45 && _minor_version <= 2) {
cfs->guarantee_more(4, CHECK_(nullHandle));
max_stack = cfs->get_u1_fast();
max_locals = cfs->get_u1_fast();
code_length = cfs->get_u2_fast();
} else {
cfs->guarantee_more(8, CHECK_(nullHandle));
max_stack = cfs->get_u2_fast();
max_locals = cfs->get_u2_fast();
code_length = cfs->get_u4_fast();
}
在Java方法的code属性中,紧跟在code_length之后的就是Java源码所对应的字节码了,这部分指令最终会被从Java class字节码文件复制道内存中,具体而言是复制到Java方法在JVM内部所对应的mehtodOop对象的内存区域。由于当前阶段仍在Java方法属性的解析阶段,尚未创建methodOop()对象,因此在这一步不会进行复制。但是HotSpot却通过code_start=cfs->get_u1_buffer()将字节码的第一条指令在Java class字节码文件中的位置记录下来,保存到code_start变量中,在前面解析出的code_length最终将会在后续创建methodOop时作为参数传递进去,最终HotSpot将依据code_start和code_length这两个数据确定从Java class字节码文件中要复制的字节码指令区域。
注意:HotSpot调用cfs->get_u1_buffer()函数来获取第一条字节码指令在字节码文件中的位置,而非该位置处的值,在HotSpot内部,获取字节码文件某个位置的值,通常调用诸如get_u1_fast()这样的方法
Java方法的Code属性解析都集中在ClassFileParser::parse_method()函数的while循环下的if(method_attribute_name == vmSymbols::tag_code()){}块中。Java方法的code属性主要包含属性的总长度、最大栈深度、局部变量表数量、字节码指令,除此以外,code属性本身是一个复合属性,其下面还包含几个子属性,例如行号表、局部变量表等。Java方法的code属性起始于属性名称的常量池索引号,索引号之后所跟的是属性长度,所以在ClassFileParser::parse_method()中主要解析Java方法几大属性的while循环中,首先便是执行u2 method_attribute_name_index = cfs->get_u2_fast()和u4 method_attribute_length = cfs->get_u4_fast()来分别获取当前属性的索引号和总长度。Java方法的极大属性的索引号的数据宽度为2字节,总数据宽度为4字节。
紧跟在code属性的总长度后面的是3个属性:max_stack、max_locals和code_length所占用的数据宽度不同,HotSpot对此进行了区分,逻辑如下:
// Stack size, locals size, and code size
if (_major_version == 45 && _minor_version <= 2) {
cfs->guarantee_more(4, CHECK_(nullHandle));
max_stack = cfs->get_u1_fast();
max_locals = cfs->get_u1_fast();
code_length = cfs->get_u2_fast();
} else {
cfs->guarantee_more(8, CHECK_(nullHandle));
max_stack = cfs->get_u2_fast();
max_locals = cfs->get_u2_fast();
code_length = cfs->get_u4_fast();
}
在Java方法的code属性中,紧跟在code_length之后的就是Java源码所对应的字节码了,这部分指令最终会被从Java class字节码文件复制道内存中,具体而言是复制到Java方法在JVM内部所对应的mehtodOop对象的内存区域。由于当前阶段仍在Java方法属性的解析阶段,尚未创建methodOop()对象,因此在这一步不会进行复制。但是HotSpot却通过code_start=cfs->get_u1_buffer()将字节码的第一条指令在Java class字节码文件中的位置记录下来,保存到code_start变量中,在前面解析出的code_length最终将会在后续创建methodOop时作为参数传递进去,最终HotSpot将依据code_start和code_length这两个数据确定从Java class字节码文件中要复制的字节码指令区域。
注意:HotSpot调用cfs->get_u1_buffer()函数来获取第一条字节码指令在字节码文件中的位置,而非该位置处的值,在HotSpot内部,获取字节码文件某个位置的值,通常调用诸如get_u1_fast()这样的方法
LVT & LVTT
在Java方法的属性中,有一种属性是局部变量表——LocalVariableTable,在JVM内部简写为LVT.LocalVariableTable属性用于描述Java方法栈帧中局部变量表中的变量与Java源代码定义的变量之间的关系,这种关系并非运行时必须,所以默认情况下不会生成到class文件中。。若想生成到class字节码文件中,则可以通过在javac命令中使用-g:vars选项生成这项信息。如果Java class字节码文件中没有生成局部变量表,则在调试Java程序时,无法看到源码中所定义的参数名称,IDE可能使用arg0、arg1占位符替代原来的参数,这对程序运行没有任何影响,但会影响使用体验。
在Java class字节码文件中,LocalVariableTable属性表的结构如表所示。
注:表中的local_variable_info类型是一种特殊的复合数据结构,该复合结构用于描述Java方法栈帧与源代码中局部变量的关联,其结构如图所示。
这5种属性的含义如下:
# start_pc,标识当前局部变量的声明周期开始的字节码偏移量
# length, 标识当前局部变量的作用范围覆盖长度,和start_pc一起就表示局部变量表在字节码中的作用范围
# name_index,当前局部变量的名称所对应的常量池的索引号
# index, 当前局部变量在栈帧局部变量中slot的位置,如果数据类型是long或double(64位),slot的位置位index和index+1
在Java方法的属性中,有一种属性是局部变量表——LocalVariableTable,在JVM内部简写为LVT.LocalVariableTable属性用于描述Java方法栈帧中局部变量表中的变量与Java源代码定义的变量之间的关系,这种关系并非运行时必须,所以默认情况下不会生成到class文件中。。若想生成到class字节码文件中,则可以通过在javac命令中使用-g:vars选项生成这项信息。如果Java class字节码文件中没有生成局部变量表,则在调试Java程序时,无法看到源码中所定义的参数名称,IDE可能使用arg0、arg1占位符替代原来的参数,这对程序运行没有任何影响,但会影响使用体验。
在Java class字节码文件中,LocalVariableTable属性表的结构如表所示。
注:表中的local_variable_info类型是一种特殊的复合数据结构,该复合结构用于描述Java方法栈帧与源代码中局部变量的关联,其结构如图所示。
这5种属性的含义如下:
# start_pc,标识当前局部变量的声明周期开始的字节码偏移量
# length, 标识当前局部变量的作用范围覆盖长度,和start_pc一起就表示局部变量表在字节码中的作用范围
# name_index,当前局部变量的名称所对应的常量池的索引号
# index, 当前局部变量在栈帧局部变量中slot的位置,如果数据类型是long或double(64位),slot的位置位index和index+1
研究局部变量表对于Java应用程序开发者而言没有具体的意义,但是这关乎Java程序调试的一些工程实现策略,也就是说,这种策略不仅在Java中有应用,在其他编程语言中也有类似实现。在调试过程中,如何让IDE在面对编译后的毫无意义的栈帧占位符的北京下,能够位开发者呈现出这些占位符所对应的源码中的原始变量名,是所有编程语言都需要具备的能力。既然HotSpot开放源代码,不妨顺道研究一番。
HotSpot的局部变量表的分析逻辑在Java编译器中实现,Java编译器会对Java源码进行语法解析,分析Java方法的栈帧结构,并据此分析各个变量的作用域。分析的结果被以特定的组织结构存储在Java class字节码文件中。在HotSpot加载某个Java类时,会按照这种特地你个的组织结构还原出编译期所分析的结果,从而在调试期间能够据此显示出局部变量的原始名称。在HotSpot中,局部变量表还原的逻辑封装在classFileParser.cpp::parse_localvariable_table()函数中,如下所示。这段逻辑很简单,通过cfs->get_*_fast()函数从Java class字节码文件中读取连续的数据,分别获取局部变量表的总长度、start_pc、length、name_index等数据。不过这段逻辑仅仅是将这些数据读取出来,进行了简单的校验便结束。
HotSpot的局部变量表的分析逻辑在Java编译器中实现,Java编译器会对Java源码进行语法解析,分析Java方法的栈帧结构,并据此分析各个变量的作用域。分析的结果被以特定的组织结构存储在Java class字节码文件中。在HotSpot加载某个Java类时,会按照这种特地你个的组织结构还原出编译期所分析的结果,从而在调试期间能够据此显示出局部变量的原始名称。在HotSpot中,局部变量表还原的逻辑封装在classFileParser.cpp::parse_localvariable_table()函数中,如下所示。这段逻辑很简单,通过cfs->get_*_fast()函数从Java class字节码文件中读取连续的数据,分别获取局部变量表的总长度、start_pc、length、name_index等数据。不过这段逻辑仅仅是将这些数据读取出来,进行了简单的校验便结束。
真正的逻辑在classFileParser.cpp::parse_method()函数中(1.8在copy_localvariable_table()函数)。。这段逻辑将局部变量表从Java class字节码文件复制到Java方法在JVM内部所对应的constMethodOop这个内部对象的内存区域,这个对象的内存布局结构后面会介绍。
在JVM运行期,在方法中打上断点,当JVM运行到断点处会执行中断而暂停,此时JVM能够通过栈帧上指向methodOop的指针定位到对应的constMethodOop,由于局部变量表就保存在constMethodOop的内存区域的末尾位置,因此JVM能够基于constMethodOop进一步获取当前Java方法所有的局部变量表,从而在IDE中将方法入参和局部变量以原始的变量名显示出来。在编译java类时,是否启用生成局部变量表并放到字节码文件的选项,不仅会影响IDE调试时的体验,还会影响Java类库使用的体验,在提供class文件给第三方使用时(不包含Java类源码),如果没有开启-g:vars选项,不生成局部变量表,则第三方将不会看到Java方法入参的原始名称.例如下面这个类:
使用javac命令进行编译,不带-g:vars选项,打开编译后生成的class文件,内容如图所示。可以看到,无论是add()方法还是main()方法,其内部的变量名都变成了以var开头的名字,这是IDE自己的明明。IDE根据字节码指令进行逆向编译时,只能分析出java方法包含几个入参和几个局部变量,但是由于字节码文件中没有存储变量名,因此IDE无法还原出源码中真实的变量名。注意观察IDE自动生成的这几个var变量名,2个入参被命名为var1和var2,3个局部变量被分别命名为var3、var4和var6.中间貌似有不连贯的地方,缺失了var5.IDE为何要跳过var5直接将最后一个变量命名为var6呢?不难猜测,IDE对变量进行自动命名的规则是var拼接上入参或变量所对应的slot索引号。由于add()是Java类成员方法,因此其第一个入参是隐藏的this,所以其源码中显式声明的两个入参才分别被命名为var1和var2.而add()方法中的变量y的类型是long, JVM规范规定long类型的数据在slot中占用2个槽位,所以最后一个变量x的slot索引号自然就变成了6.再观察main()方法,由于这是一个静态方法,所以并不包含this这个隐藏的入参,所以main()方法中显式声明的第一个入参String[]被IDE自动命名成var0.这里并不是要大家去研究Java逆向编译工程原理。只要要大家明白,处处留心皆学问。
使用javac命令进行编译,不带-g:vars选项,打开编译后生成的class文件,内容如图所示。可以看到,无论是add()方法还是main()方法,其内部的变量名都变成了以var开头的名字,这是IDE自己的明明。IDE根据字节码指令进行逆向编译时,只能分析出java方法包含几个入参和几个局部变量,但是由于字节码文件中没有存储变量名,因此IDE无法还原出源码中真实的变量名。注意观察IDE自动生成的这几个var变量名,2个入参被命名为var1和var2,3个局部变量被分别命名为var3、var4和var6.中间貌似有不连贯的地方,缺失了var5.IDE为何要跳过var5直接将最后一个变量命名为var6呢?不难猜测,IDE对变量进行自动命名的规则是var拼接上入参或变量所对应的slot索引号。由于add()是Java类成员方法,因此其第一个入参是隐藏的this,所以其源码中显式声明的两个入参才分别被命名为var1和var2.而add()方法中的变量y的类型是long, JVM规范规定long类型的数据在slot中占用2个槽位,所以最后一个变量x的slot索引号自然就变成了6.再观察main()方法,由于这是一个静态方法,所以并不包含this这个隐藏的入参,所以main()方法中显式声明的第一个入参String[]被IDE自动命名成var0.这里并不是要大家去研究Java逆向编译工程原理。只要要大家明白,处处留心皆学问。
而带上-g:vars选项进行javac编译后,使用IDE打开编译后的class文件,所看到的方法内部的变量名与Java源码中的变量名完全一致。之所以会这样,是因为使用-g:vars选项进行编译时,Java方法内部的变量名称也会在字节码文件中的常量池中存储,这是必须的,因为局部变量表中并没有直接存储变量名,而是将变量名存储到常量池并引用常量池的索引号。下面举例对局部变量表加以说明,Java示例便是上面所举的Test类,以Test.add()方法作为分析对象。使用-g:vars选项对Test类进行编译,并使用javap命令分析字节码文件,得到add()方法的分析结果如下
先看局部变量表中的this、aaa和bbb这3个入参,Start都是0,Length都是16.这里的0表示这3个参数的作用域从第1个字节码指令就开始生效,length为16是因为add()方法字节码指令的总长度为16,对于Java方法的入参,在方法内部任何地方都能引用到,所以其作用域的长度自然等于整个字节码指令的总长度。接着看变量z,其start为4,length为12.在add()方法中,变量z的声明与初始化被合二为一了,其声明语句为int z = aaa + bbb,其对应的字节码指令包含4条,分别是列表中bci(byte code index,字节码指令偏移量)等于0、1、2、3的4条指令。在这4条指令之后的所有字节码指令都能引用,所以变量z的作用域就从4开始,而其作用范围自然是后续所有字节码指令的长度,为(16-4)=12。同样的机制,变量y的声明语句的字节码指令到bci=8的位置结束,bci=8的指令是istore4,一共占2字节长度,所以下一条字节码指令的bci为10,这正是变量y的作用域开始的位置,所以变量y的start=10.JDK1.5引入泛型之后,为LocalVariableTable属性添加了一个姐妹属性;LocalVariableTypeTable.在JVM内部,这个属性简称为LVTT,该属性的结构和LocalVariableTable相似,仅仅把记录的字段描述符的descriptor_index替换成字段特征签名(signature)。
先看局部变量表中的this、aaa和bbb这3个入参,Start都是0,Length都是16.这里的0表示这3个参数的作用域从第1个字节码指令就开始生效,length为16是因为add()方法字节码指令的总长度为16,对于Java方法的入参,在方法内部任何地方都能引用到,所以其作用域的长度自然等于整个字节码指令的总长度。接着看变量z,其start为4,length为12.在add()方法中,变量z的声明与初始化被合二为一了,其声明语句为int z = aaa + bbb,其对应的字节码指令包含4条,分别是列表中bci(byte code index,字节码指令偏移量)等于0、1、2、3的4条指令。在这4条指令之后的所有字节码指令都能引用,所以变量z的作用域就从4开始,而其作用范围自然是后续所有字节码指令的长度,为(16-4)=12。同样的机制,变量y的声明语句的字节码指令到bci=8的位置结束,bci=8的指令是istore4,一共占2字节长度,所以下一条字节码指令的bci为10,这正是变量y的作用域开始的位置,所以变量y的start=10.JDK1.5引入泛型之后,为LocalVariableTable属性添加了一个姐妹属性;LocalVariableTypeTable.在JVM内部,这个属性简称为LVTT,该属性的结构和LocalVariableTable相似,仅仅把记录的字段描述符的descriptor_index替换成字段特征签名(signature)。
创建methodOop。
完成对Java方法的各项属性解析之后,HotSpot开始在内存中创建一个与Java方法对等的内部对象——methodOop.methodOop包含Java方法的一切信息,例如方法名、返回值类型、入参、字节码指令、栈深、局部变量表、行号灯。其实一言以蔽之,HotSpot通过methodOop,将Java class字节码文件中的方法信息存储到了内存中,并且这片内存区域是结构化的,使得可以在JVM运行期方便地访问Java方法的各种属性信息。
ClassFileParser::parse_method()函数中通过调用oopFactory::new_method()函数完成methodOop对象的创建,
oopFactory::new_method()函数里面实际上创建了两个对象,分别是methodOop和constMethodOop。其中methodOop通过调用(methodKlass*)->allocate()函数创建,而constMethodOop通过调用oopFactory::new_constMethod()函数创建。这里需要说明一下,constMethod和methodOop的关系,无论是在JDK6还是最新的JDK8中,都保留了这两个对象。这两个对象的作用不同,methodOop主要存储Java方法的名称、签名、访问标识、解释入口灯信息,而constMethodOop则用于存储方法的字节码指令、行号表、异常表等信息。
对于JDK6和JDK8而言,这两者有微小的变化。在JDK6中,Java方法的最大栈深度和局部变量表数量存储在methodOop中,而到了JDK8,这两个数据则被存储到了constMethodOop中,这种变化仅影响到HotSpot为Java方法创建栈帧时读取这两个变量的逻辑。
完成对Java方法的各项属性解析之后,HotSpot开始在内存中创建一个与Java方法对等的内部对象——methodOop.methodOop包含Java方法的一切信息,例如方法名、返回值类型、入参、字节码指令、栈深、局部变量表、行号灯。其实一言以蔽之,HotSpot通过methodOop,将Java class字节码文件中的方法信息存储到了内存中,并且这片内存区域是结构化的,使得可以在JVM运行期方便地访问Java方法的各种属性信息。
ClassFileParser::parse_method()函数中通过调用oopFactory::new_method()函数完成methodOop对象的创建,
oopFactory::new_method()函数里面实际上创建了两个对象,分别是methodOop和constMethodOop。其中methodOop通过调用(methodKlass*)->allocate()函数创建,而constMethodOop通过调用oopFactory::new_constMethod()函数创建。这里需要说明一下,constMethod和methodOop的关系,无论是在JDK6还是最新的JDK8中,都保留了这两个对象。这两个对象的作用不同,methodOop主要存储Java方法的名称、签名、访问标识、解释入口灯信息,而constMethodOop则用于存储方法的字节码指令、行号表、异常表等信息。
对于JDK6和JDK8而言,这两者有微小的变化。在JDK6中,Java方法的最大栈深度和局部变量表数量存储在methodOop中,而到了JDK8,这两个数据则被存储到了constMethodOop中,这种变化仅影响到HotSpot为Java方法创建栈帧时读取这两个变量的逻辑。
创建methodOop的逻辑如下:
前面详细分析过常量池对象constantPoolOop的创建过程,在classFileParser::parseClassFile()方法中调用oopFactory::new_constantPool()函数创建常量池对象,后者调用constantPoolKlass::allocate()函数完成常量池的创建。仔细比较constantPoolKlass::allocate()函数与这里的methodKlass::allocate()函数,会发现两者逻辑基本一致,都是先求取methodOop的大小size,接着调用CollectedHeap::permanent_obj_allocate()函数在perm区(对于JDK8而言则在metaSpace区)为所创建的对象分配内存。最后在调用一系列的setter()方法初始化所创建的对象
前面详细分析过常量池对象constantPoolOop的创建过程,在classFileParser::parseClassFile()方法中调用oopFactory::new_constantPool()函数创建常量池对象,后者调用constantPoolKlass::allocate()函数完成常量池的创建。仔细比较constantPoolKlass::allocate()函数与这里的methodKlass::allocate()函数,会发现两者逻辑基本一致,都是先求取methodOop的大小size,接着调用CollectedHeap::permanent_obj_allocate()函数在perm区(对于JDK8而言则在metaSpace区)为所创建的对象分配内存。最后在调用一系列的setter()方法初始化所创建的对象
创建methodOop的详细过程不再赘述,与constantPoolOop的创建基本一致.最终创建出来的methodOop对象的内存布局如图所示(基于JDK6的内存模型)。虽然方法对象methodOop与常量池constMethodOop的创建机制基本相同,但是两者的数据结构仍然有所不同。methodOop的内存结构与其类型定义的结构保持一致,而常量池constantPoolOop实例对象的内存的末尾还跟着常量池的元素数据。HotSpot内部并没有为常量池的元素数组专门定义一种数据结构,这是因为不同Java类编译后所得到的常量池数组大小不同,并且各个元素成员的内存也完全不同,无法抽象成专门的数据结构,所以HotSpot只能将其分配到constantPoolOop实例对象的内存的末尾区域。但是methodOop与constantPoolOop一样,类型本身的字段并不足以保存Java方法的全部信息,例如Java方法的字节码指令、行号表等信息,在methodOop类型中并没有专门的字段存储这些信息,所以按理说methodOop也应该像constMethodOop那样,将这些信息分配到methodOop实例对象的末尾区域,但是methodOop并没有这么做。这是因为HotSpot为此专门另外定义二零一种数据结构——constMethodOop,HotSpot将Java方法的字节码指令及行号表等信息分配到了constMethodOop实例对象的内存的末尾区域。
在classFileParser::parseClassFile()函数调用oopFactory::new_method()函数创建java方法对象的过程中,后者调用oopFactory::new_constMethod()函数完成constMethodOop的创建。在oopFactory::new_constMethod()函数中实际是调用constMethodKlass:;allocate()函数完成constMethodOop的创建。constMethodKlass:;allocate()函数与constantPoolKlass::allocate()函数的机制也基本一致。最终所创建的constMethodOop的内存结构如图所示.
当oopFactory::new_constMethod()函数调用constMethodKlass::allocate()函数时,constMethodKlass::allocate()函数的入参如下:
constMethodOop constMethodKlass::alloate(
int byte_code_size,
int compressed_line_number_size,
int localvariable_table_length,
int check_exceptions_length,
bool is_conc_safe, TRAPS)
可以看到,constMethodKlass::allocate()函数入参包含字节码大小、行号表大小、局部变量表大小等信息,在constMethodKlass::allocate()函数内部所计算出的内存大小,时constMethodOop本身的大小与字节码大小、行号表大小等的总和,因为HotSpot将字节码指令、行号表、局部变量表等信息分配在constMethodOop对象实例的内存的末尾区域,所以在创建constMethodOop对象时,就将末尾区域所需要的内存提前申请和分配好。后续HotSpot会执行逻辑,将字节码指令、行号表、异常表等数据从Java class字节码文件中复制到constMethodOop末尾的这段内存中
当oopFactory::new_constMethod()函数调用constMethodKlass::allocate()函数时,constMethodKlass::allocate()函数的入参如下:
constMethodOop constMethodKlass::alloate(
int byte_code_size,
int compressed_line_number_size,
int localvariable_table_length,
int check_exceptions_length,
bool is_conc_safe, TRAPS)
可以看到,constMethodKlass::allocate()函数入参包含字节码大小、行号表大小、局部变量表大小等信息,在constMethodKlass::allocate()函数内部所计算出的内存大小,时constMethodOop本身的大小与字节码大小、行号表大小等的总和,因为HotSpot将字节码指令、行号表、局部变量表等信息分配在constMethodOop对象实例的内存的末尾区域,所以在创建constMethodOop对象时,就将末尾区域所需要的内存提前申请和分配好。后续HotSpot会执行逻辑,将字节码指令、行号表、异常表等数据从Java class字节码文件中复制到constMethodOop末尾的这段内存中
Java方法属性复制。
与Java方法相对应的JVM内部的methodOop对象创建完成之后,HotSpot需要将Java方法的几大属性数据复制进所创建的对象之中,这几大属性是指code(主要是字节码指令)、行号表、异常表、局部变量表等。前面分析过,HotSpot在创建methodOop时,顺便创建了constMethodOop对象实例,Java方法的属性信息被分配在constMethodOop对象实例的内存区域的末尾位置。HotSpot在创建constMethodOop实例对象时,将字节码指令、行号表、异常表、局部变量表等属性所需的空间大小计算在内,已经预先为这些属性申请好内存空间,所以接下来需要做的事就是将这些属性信息从Java class字节码文件中复制到申请的内存中。这段逻辑位于ClassFileParser::parse_method()函数中,如下:
// classFileParser.cpp
// 复制Java方法属性的逻辑
// ....
// Copy byte codes
m->set_code(code_start);
// Copy line number table
if (linenumber_table != NULL) {
memcpy(m->compressed_linenumber_table(),
linenumber_table->buffer(), linenumber_table_length);
}
// Copy exception table
if (exception_table_length > 0) {
int size =
exception_table_length * sizeof(ExceptionTableElement) / sizeof(u2);
copy_u2_with_conversion((u2*) m->exception_table_start(),
exception_table_start, size);
}
// ...
这段逻辑大同小异,都是j将前面步骤已经解析好的属性信息复制到对应的内存位置,其中局部变量表的复制逻辑已经在前面分析LVT时已经进行过描述。这里重点关注字节码指令的处理。字节码指令的处理主要时调用m->set_code(address code_start)函数,入参code_start在前面流程中已经解析处理,其值时当前Java方法的第一条字节码指令在读入内存中的字节码文件流中的内存位置。methodOop:;set_code(address)函数逻辑如下:
// 复制Java方法字节码指令
// byte codes
void set_code(address code) { return constMethod()->set_code(code); }
本函数里面调用了constMethod()->set_code(address)函数,该函数逻辑如下:
// byte codes
void set_code(address code) {
if (code_size() > 0) {
memcpy(code_base(), code, code_size());
}
}
由此可见,constMethodOop::set_code(address)函数最终调用了memcpy()函数,memcpy()函数有3个形式入参,分别表示目的内存首地址、复制源内存首地址、复制长度。在复制Java方法的字节码时,第2个入参code已经包含明确的值,,就是当前Java方法的第一条字节码指令在读入内存中的字节码文件流中的内存位置。而constMethodOop::set_code(address)函数第一个入参是code_base()函数的返回值,该函数逻辑如下:
address code_base() const { return (address) (this+1); }
address是指针类型,因此this+1表示constMethodOop实例对象的末尾位置的下一位,这正是Java方法字节码指令的存放位置。到此为止,Java方法字节码指令复制的实现逻辑便清楚了,字节码指令最终从Java class字节码文件中复制到了constMethodOOp对象实例的内存的末尾位置。在Java程序运行期,HotSpot将根据所调用的目标函数,找到该目标Java方法在内存中所对应的methodOop对象实例,并根据methodOop对象实例找到对应的constMethodOop,最终基于constMethodOop定位到目标Java方法所对应的字节码指令,并将首个字节码指令的内存位置保存到目标Java方法的栈帧中,HotSpot通过JMP硬件指令跳转到这个位置开始执行Java方法所对应的字节码指令从而完成Java方法逻辑。
与Java方法相对应的JVM内部的methodOop对象创建完成之后,HotSpot需要将Java方法的几大属性数据复制进所创建的对象之中,这几大属性是指code(主要是字节码指令)、行号表、异常表、局部变量表等。前面分析过,HotSpot在创建methodOop时,顺便创建了constMethodOop对象实例,Java方法的属性信息被分配在constMethodOop对象实例的内存区域的末尾位置。HotSpot在创建constMethodOop实例对象时,将字节码指令、行号表、异常表、局部变量表等属性所需的空间大小计算在内,已经预先为这些属性申请好内存空间,所以接下来需要做的事就是将这些属性信息从Java class字节码文件中复制到申请的内存中。这段逻辑位于ClassFileParser::parse_method()函数中,如下:
// classFileParser.cpp
// 复制Java方法属性的逻辑
// ....
// Copy byte codes
m->set_code(code_start);
// Copy line number table
if (linenumber_table != NULL) {
memcpy(m->compressed_linenumber_table(),
linenumber_table->buffer(), linenumber_table_length);
}
// Copy exception table
if (exception_table_length > 0) {
int size =
exception_table_length * sizeof(ExceptionTableElement) / sizeof(u2);
copy_u2_with_conversion((u2*) m->exception_table_start(),
exception_table_start, size);
}
// ...
这段逻辑大同小异,都是j将前面步骤已经解析好的属性信息复制到对应的内存位置,其中局部变量表的复制逻辑已经在前面分析LVT时已经进行过描述。这里重点关注字节码指令的处理。字节码指令的处理主要时调用m->set_code(address code_start)函数,入参code_start在前面流程中已经解析处理,其值时当前Java方法的第一条字节码指令在读入内存中的字节码文件流中的内存位置。methodOop:;set_code(address)函数逻辑如下:
// 复制Java方法字节码指令
// byte codes
void set_code(address code) { return constMethod()->set_code(code); }
本函数里面调用了constMethod()->set_code(address)函数,该函数逻辑如下:
// byte codes
void set_code(address code) {
if (code_size() > 0) {
memcpy(code_base(), code, code_size());
}
}
由此可见,constMethodOop::set_code(address)函数最终调用了memcpy()函数,memcpy()函数有3个形式入参,分别表示目的内存首地址、复制源内存首地址、复制长度。在复制Java方法的字节码时,第2个入参code已经包含明确的值,,就是当前Java方法的第一条字节码指令在读入内存中的字节码文件流中的内存位置。而constMethodOop::set_code(address)函数第一个入参是code_base()函数的返回值,该函数逻辑如下:
address code_base() const { return (address) (this+1); }
address是指针类型,因此this+1表示constMethodOop实例对象的末尾位置的下一位,这正是Java方法字节码指令的存放位置。到此为止,Java方法字节码指令复制的实现逻辑便清楚了,字节码指令最终从Java class字节码文件中复制到了constMethodOOp对象实例的内存的末尾位置。在Java程序运行期,HotSpot将根据所调用的目标函数,找到该目标Java方法在内存中所对应的methodOop对象实例,并根据methodOop对象实例找到对应的constMethodOop,最终基于constMethodOop定位到目标Java方法所对应的字节码指令,并将首个字节码指令的内存位置保存到目标Java方法的栈帧中,HotSpot通过JMP硬件指令跳转到这个位置开始执行Java方法所对应的字节码指令从而完成Java方法逻辑。
<clinit>与<init>。
1.初识<clinit>与<init>
在Java中,有两种特殊的方法,分别是<clinit>和<init>。这两个方法并非由Java开发者所定义,而是由Java编译器自动生成。当Java类中存在用staticx修饰的静态类型字段,或者存在使用static{}块包裹的逻辑时,编译器会自动生成<clinit>方法。而当Java类定义了构造函数,或者其非static类成员变量被赋予了初始值时,编译器会自动生成<init>方法。看下面Java示例。如图所示。
通过javap命令可以看到,Test类一共包含4个方法,其中main()和add()方法在Test类中被显式定义,然而另外2个方法却并未在Test类中进行过声明,分别时public Test()和static{},其实这2个方法便是由编译器自动生成的。由于Test类中有一个被static修饰的成员变量,因此编译器自动生成了static{}这样的方法,同时由于Test类的成员变量i在声明时就被赋予了初值,因此编译器自动生成了public Test()这样的构造函数。
其实这里的public Test()和static{}这两个方法,方法名分别是<init>和<clinit>,使用javap命令解析Java class文件时,在常量池中便有这两个方法的名称,索引号,如下:
#13 = Utf8 <init>
#21 = Utf8 <clinit>
只是javap命令并没有像main()和add()方法那样直接将<init>和<clinit>这2个方法的名称显示出来,而是以public Test()和static{}这样的形式加以显示
在Java中,有两种特殊的方法,分别是<clinit>和<init>。这两个方法并非由Java开发者所定义,而是由Java编译器自动生成。当Java类中存在用staticx修饰的静态类型字段,或者存在使用static{}块包裹的逻辑时,编译器会自动生成<clinit>方法。而当Java类定义了构造函数,或者其非static类成员变量被赋予了初始值时,编译器会自动生成<init>方法。看下面Java示例。如图所示。
通过javap命令可以看到,Test类一共包含4个方法,其中main()和add()方法在Test类中被显式定义,然而另外2个方法却并未在Test类中进行过声明,分别时public Test()和static{},其实这2个方法便是由编译器自动生成的。由于Test类中有一个被static修饰的成员变量,因此编译器自动生成了static{}这样的方法,同时由于Test类的成员变量i在声明时就被赋予了初值,因此编译器自动生成了public Test()这样的构造函数。
其实这里的public Test()和static{}这两个方法,方法名分别是<init>和<clinit>,使用javap命令解析Java class文件时,在常量池中便有这两个方法的名称,索引号,如下:
#13 = Utf8 <init>
#21 = Utf8 <clinit>
只是javap命令并没有像main()和add()方法那样直接将<init>和<clinit>这2个方法的名称显示出来,而是以public Test()和static{}这样的形式加以显示
2.使用HSDB查看<clinit>和<init>
通过HSDB这个神器,可以直观地显示出<init>和<clinit>这2个方法。启动HSDB并使其连接上Test进程,接着单机HSDB的Tool->Class browser工具按钮,选择Test类,就能查看Test类中的全部方法,如图所示。Test类中的确存在名为<init>和<clinit>的两个方法,而这两个方法正对应与用javap命令所显示出来的public Test()和static{} 方法
通过HSDB这个神器,可以直观地显示出<init>和<clinit>这2个方法。启动HSDB并使其连接上Test进程,接着单机HSDB的Tool->Class browser工具按钮,选择Test类,就能查看Test类中的全部方法,如图所示。Test类中的确存在名为<init>和<clinit>的两个方法,而这两个方法正对应与用javap命令所显示出来的public Test()和static{} 方法
3.<init>详解
在上面的Test类的例子中,并没有在Test类中显式定义构造函数,但是编译器还是默认生成了一个构造函数,那么如果开发者显式定义一个无参的构造函数,编译器会如何处理呢?且举一例进行说明,将上述Test类示例稍微做下修改,添加一个无参的构造函数,修改后的Test类如下:本次为Test类显式定义了一个无参的构造函数,这种改动主要影响<init>()方法。使用javap命令分析Test.class文件时,只需要观察public Test()这部分的字节码指令的变化。在上面这段字节码指令中,能够看到如下指令:
5: iconst_3
6: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: putfield #3 // Field i:Ljava/lang/Integer;
12: bipush 8
14: istore_1
其中bci等于5、6、9的字节码指令对应的Java源码是Test类的成员变量i的定义和赋值:private Integer i = 3;接着bci等于12和14的字节码指令对应的Java源码是Test构造函数中的short s = 8.由此可见,即使人为地定义了默认的无参构造函数,编译器仍然会修改构造函数的字节码指令,将Java类成员变量的初始化指令都插入到构造函数<init>()方法的字节码指令中。这其字节码指令一定与Java源码保持一致,编译器不会随便往里面加入其他逻辑。
在上面的Test类的例子中,并没有在Test类中显式定义构造函数,但是编译器还是默认生成了一个构造函数,那么如果开发者显式定义一个无参的构造函数,编译器会如何处理呢?且举一例进行说明,将上述Test类示例稍微做下修改,添加一个无参的构造函数,修改后的Test类如下:本次为Test类显式定义了一个无参的构造函数,这种改动主要影响<init>()方法。使用javap命令分析Test.class文件时,只需要观察public Test()这部分的字节码指令的变化。在上面这段字节码指令中,能够看到如下指令:
5: iconst_3
6: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: putfield #3 // Field i:Ljava/lang/Integer;
12: bipush 8
14: istore_1
其中bci等于5、6、9的字节码指令对应的Java源码是Test类的成员变量i的定义和赋值:private Integer i = 3;接着bci等于12和14的字节码指令对应的Java源码是Test构造函数中的short s = 8.由此可见,即使人为地定义了默认的无参构造函数,编译器仍然会修改构造函数的字节码指令,将Java类成员变量的初始化指令都插入到构造函数<init>()方法的字节码指令中。这其字节码指令一定与Java源码保持一致,编译器不会随便往里面加入其他逻辑。
前面分析过,当Java类的成员变量存在初始化行为时,其初始化的逻辑就会被嵌入到<init>()方法中,其实,还有一种情况也会如此,那就是在Java类中使用{}包裹的代码逻辑,也会被嵌入到<init>()方法中。修改Test类,如下.可以看出,上面有一大段字节码指令在处理对应的Test类源码中被{}包裹的块逻辑中的System.out.println("i=" + i);
现在再对Test类进行微调,刚才显式定义了一个无参的默认构造函数,现在则定义含入参的非默认的构造函数,同时将刚才无参的默认构造函数删除,修改后的Test类如下。可以看到,现在得到了public Test(int)方法及其字节码指令,其字节码指令中的bci等于5、6、9的这3条字节码指令仍然对应Test类中的private Integer i = 3这一条源代码,然后bci等于12、14的这两条字节码指令对应的Test(int)这个入参的构造函数中的x=12这一句源代码。由此可见,当为Java 类定义非默认的构造函数时,Java编译器仍然会"擅自"修改构造函数的逻辑,将Java类成员的初始化逻辑所对应的字节码指令插入到构造函数所对应的字节码指令中。
到这里可以看出来,<init>()方法还是有点意思的,看不见的背后,其实编译器为我们做了很多工作。不过<init>()方法还有更多值得玩味地特性。刚才分别为Test类显式定义了一个无参的默认构造函数和一个含入参的构造函数,现在看看如果为一个Java类同时定义两个乃至多个构造函数,编译器如何处理。修改Test类,变成如下。可以看到,现在Test类包含2个显式定义的构造函数,编译后使用javap命令分析Test.class文件,显式如图所示。仔细观察使用javap命令得到的输出中的Test()和Test(int)这两个方法的字节码,会发现这两个方法都包含bci等于5、6、9的这3条字节码指令,并且这3条字节码指令的含义相同,都对应Test类中的private Integer i = 3这一句源代码。而这两个方法中,在bci为9之后的字节码指令则不再相同,因为两个构造函数内部的逻辑不同。由此可见,当为java类定义多个构造函数时,编译器会将类成员变量的初始化逻辑的字节码指令编排到每一个构造函数的字节码指令中,而道理其实很简单,因为不管是通过new Test()去实例化Test类,还是通过new Test(10)去实例化Test类,所实例化的Test类的成员变量i都应该具有初始值3,所以编译器必须将类成员变量的初始化逻辑原封不动地复制到每一个构造函数里去。
现在转换下视角,从继承的角度看<init>()方法。让Test类继承一个父类MyClass,MyClass定义如下:
public abstract class MyClass {
protected long plong = 12L;
}
修改Test类,使其继承MyClass.为了节省篇幅,这里补贴出Test类。由于Test类继承了MyClass类,因此Test类便理所当然地继承了MyClass类中的成员变量plong。当实例化Test类时,JVM除了要完成Test类的成员变量赋初值逻辑,也要完成继承自MyClass父类的成员变量的赋初值逻辑。因此Java编译器必然要将MyClass父类成员变量的初始化逻辑也嵌入到Test类的全部构造函数之中。基于这种理论猜测来做验证——编译Test类,并使用javap命令分析Test.class字节码文件,javap命令的输出如下。观察javap命令的输出,在public Test()和public Test(int)这2个方法中,bci=1的指令都是:
invokespecial #1; // Method MyClass. "<init>":()V
可见,在Test类的两个构造函数中自动调用了父类的默认构造函数。Java编译器将MyClass父类的成员变量的初始化逻辑封装到了MyClass父类自己的默认构造函数中,并通过将调用父类默认构造函数的逻辑嵌入到子类的各个构造函数中,从而在子类构造函数中完成父类成员变量的初始化逻辑。关于MyClass.<init>()方法,大家可以自行使用javap命令分析MyClass.<init>()方法的字节码指令,验证该方法有无嵌入MyClass成员变量的初始化逻辑。
public abstract class MyClass {
protected long plong = 12L;
}
修改Test类,使其继承MyClass.为了节省篇幅,这里补贴出Test类。由于Test类继承了MyClass类,因此Test类便理所当然地继承了MyClass类中的成员变量plong。当实例化Test类时,JVM除了要完成Test类的成员变量赋初值逻辑,也要完成继承自MyClass父类的成员变量的赋初值逻辑。因此Java编译器必然要将MyClass父类成员变量的初始化逻辑也嵌入到Test类的全部构造函数之中。基于这种理论猜测来做验证——编译Test类,并使用javap命令分析Test.class字节码文件,javap命令的输出如下。观察javap命令的输出,在public Test()和public Test(int)这2个方法中,bci=1的指令都是:
invokespecial #1; // Method MyClass. "<init>":()V
可见,在Test类的两个构造函数中自动调用了父类的默认构造函数。Java编译器将MyClass父类的成员变量的初始化逻辑封装到了MyClass父类自己的默认构造函数中,并通过将调用父类默认构造函数的逻辑嵌入到子类的各个构造函数中,从而在子类构造函数中完成父类成员变量的初始化逻辑。关于MyClass.<init>()方法,大家可以自行使用javap命令分析MyClass.<init>()方法的字节码指令,验证该方法有无嵌入MyClass成员变量的初始化逻辑。
到了这里,如果父类有多个构造函数,那么编译器会让子类的构造函数调用父类的哪个构造函数呢?这里直接通过试验进行验证。修改上述的父类。注意,上述类文件中为MyClass类定义了一个默认的无参构造函数,另一个则是含入参的非默认构造函数。Test类还是继承MyClass类。编译Test类并使用javap 命令分析Test.class字节码文件,输出如图所示(且看子类Test的两个构造函数到底会调用父类的哪个构造函数)。javap命令的输出显示,Test的两个构造函数中被嵌入了调用MyClass.<init>()这个默认的构造函数的逻辑。前面举了多个例子,全方位分析了Java类的<init>()方法(即构造函数)的生成规则,这些规则可以总结为如下几点:
# 无论一个Java类有无定义构造函数,编译器都会自动生成一个默认的构造函数<init>().可以使用javap命令或者HSDB来验证
# <init>()方法主要完成Java类的成员变量的初始化逻辑,同时会执行Java类中被{}包裹的块逻辑。如果Java类中的成员变量没有被赋初值,则在<init>()方法中不会对其进行初始化。
# 如果为Java类显式定义了多个构造函数,无论是否时默认的无参构造函数,Java编译器都会将Java类成员变量的初始化逻辑嵌入到每一个构造函数中,并且嵌入的位置在各构造函数自身逻辑之前
可以将上述6点总结成一句话:
一个类的各个构造函数的处理逻辑是,调用父类默认构造函数,完成自身成员变量的初始化逻辑和被{}包裹的块逻辑,调用各构造自身逻辑
# 无论一个Java类有无定义构造函数,编译器都会自动生成一个默认的构造函数<init>().可以使用javap命令或者HSDB来验证
# <init>()方法主要完成Java类的成员变量的初始化逻辑,同时会执行Java类中被{}包裹的块逻辑。如果Java类中的成员变量没有被赋初值,则在<init>()方法中不会对其进行初始化。
# 如果为Java类显式定义了多个构造函数,无论是否时默认的无参构造函数,Java编译器都会将Java类成员变量的初始化逻辑嵌入到每一个构造函数中,并且嵌入的位置在各构造函数自身逻辑之前
可以将上述6点总结成一句话:
一个类的各个构造函数的处理逻辑是,调用父类默认构造函数,完成自身成员变量的初始化逻辑和被{}包裹的块逻辑,调用各构造自身逻辑
4.<clinit>详解
前面详细分析了<init>()方法,而Java编译器除了会自动生成<init>()方法,还会自动生成<clinit>()方法。前面分析过,当Java类中存在static字段,或者被static{}包裹的代码逻辑时,就会自动生成<clinit>()方法。关于<clinit>()方法也有很多有趣的特性,,先看Test类。
前面详细分析了<init>()方法,而Java编译器除了会自动生成<init>()方法,还会自动生成<clinit>()方法。前面分析过,当Java类中存在static字段,或者被static{}包裹的代码逻辑时,就会自动生成<clinit>()方法。关于<clinit>()方法也有很多有趣的特性,,先看Test类。
再看MyClass类
可以看到MyClass的static{}方法中使用了invokestatic字节码指令完成变量i的初始化。Test类的static{}方法中分别完成了成员变量a和局部变量y的初始化。由此可见,<clinit>()方法不具有继承性,原理其实也很简单,因为<clinit>()方法时在类加载过程中被调用,而父类与子类说分别加载的,当父类加载完之后,父类中的static成员变量初始化和被static{}所包裹的块逻辑已经执行完成,没必要在子类加载时再执行一次,所以子类只需完成自身static成员变量初始化以及被static{}所包裹的块逻辑即可
可以看到MyClass的static{}方法中使用了invokestatic字节码指令完成变量i的初始化。Test类的static{}方法中分别完成了成员变量a和局部变量y的初始化。由此可见,<clinit>()方法不具有继承性,原理其实也很简单,因为<clinit>()方法时在类加载过程中被调用,而父类与子类说分别加载的,当父类加载完之后,父类中的static成员变量初始化和被static{}所包裹的块逻辑已经执行完成,没必要在子类加载时再执行一次,所以子类只需完成自身static成员变量初始化以及被static{}所包裹的块逻辑即可
5.init与clinit执行顺序。
<clinit>()方法在Java类第一次被iJVM加载时调用,而<init>()方法则在Java类被实例化时调用。由于类的加载一定位于类实例化之前被触发。并且每一次实例化Java类都会调用<init>()方法,而<clinit>()仅在第一次加载时被调用,以后再加载时不会重复调用,图中定义了MyClass和Test两个类,Test类继承自MyClass类。并且Test和MyClass类中都包含{}块逻辑和static{}逻辑。运行Test类的main()方法,输出如下:
father.static{} ...i=30
son.static{} ... a=155
father. {}... plong =13
father.constructor() ...plong=67
son.{}... i=2
son.{}... a=155
根据该输出顺序可知,JVM先加载了父类MyClass,调用了父类的static{}块逻辑,因此首先输出"father.static{}...i=30",接着又加载了子类Test,因此调用了子类的{}块逻辑,因此接着输出"son.static{} ... a=155"。接着实例化Test类,前面说过,类的构造函数的执行顺序是"调用父类的默认构造函数->执行类成员变量初始化逻辑和被{}包裹的块逻辑->执行构造函数自身逻辑"。由于Test类继承了MyClass类,因此Test的构造函数先执行父类的构造函数。而父类MyClass中包含被{}包裹的块逻辑,因此父类构造函数先执行块逻辑,再执行自身逻辑,所以接下来的输出便是按照这种顺序执行结果。
<clinit>()方法在Java类第一次被iJVM加载时调用,而<init>()方法则在Java类被实例化时调用。由于类的加载一定位于类实例化之前被触发。并且每一次实例化Java类都会调用<init>()方法,而<clinit>()仅在第一次加载时被调用,以后再加载时不会重复调用,图中定义了MyClass和Test两个类,Test类继承自MyClass类。并且Test和MyClass类中都包含{}块逻辑和static{}逻辑。运行Test类的main()方法,输出如下:
father.static{} ...i=30
son.static{} ... a=155
father. {}... plong =13
father.constructor() ...plong=67
son.{}... i=2
son.{}... a=155
根据该输出顺序可知,JVM先加载了父类MyClass,调用了父类的static{}块逻辑,因此首先输出"father.static{}...i=30",接着又加载了子类Test,因此调用了子类的{}块逻辑,因此接着输出"son.static{} ... a=155"。接着实例化Test类,前面说过,类的构造函数的执行顺序是"调用父类的默认构造函数->执行类成员变量初始化逻辑和被{}包裹的块逻辑->执行构造函数自身逻辑"。由于Test类继承了MyClass类,因此Test的构造函数先执行父类的构造函数。而父类MyClass中包含被{}包裹的块逻辑,因此父类构造函数先执行块逻辑,再执行自身逻辑,所以接下来的输出便是按照这种顺序执行结果。
6.{}和static{}的作用域。
在Java类源码中可以使用{}和static{}来包裹块逻辑,前面也有多个例子进行了演示,不过这两者的作用域却是各不相同的。被{}所包裹的块,能够访问Java类的非静态成员变量和静态成员变量,但是其内部所定义的变量不能被外部所访问,这一点与Java类的普通函数(非static)的作用域完全相同。而在编译阶段,就连{}块中的局部变量也与非static方法的局部变量一样,被组织成局部变量表。且看下面例子.
其中bci等于4、5、7、10的这4条字节码指令的作用是完成Test类成员变量i的初始化。。而从bci=13的字节码指令开始,执行Test类中被{}包裹的逻辑。注意bci=14的字节码指令是istore_1,这表示将数字1赋值给{}块中的a变量,这正暗示出Java编译器将{}块中的变量a处理成了局部变量表中的第2各局部变量(即第2个slot)。同理,bci=16和bci=20的两条字节码指令istore_2和istore_3也表明了编译器将{}块中的第2和第3个局部变量b和c分别处理成了局部变量表中第3和第4个变量。既然Java编译器将{}块中的局部变量处理成了局部变量表,而局部变量表是函数相关的,但是{}块并不是一个函数,那么问题来了,这里的局部变量表是谁的呢?很显然,由于Java编译器将{}块中的逻辑嵌入到了<init>()构造函数中,因此这里的局部变量表自然就是Java类的构造函数了。
在Java类源码中可以使用{}和static{}来包裹块逻辑,前面也有多个例子进行了演示,不过这两者的作用域却是各不相同的。被{}所包裹的块,能够访问Java类的非静态成员变量和静态成员变量,但是其内部所定义的变量不能被外部所访问,这一点与Java类的普通函数(非static)的作用域完全相同。而在编译阶段,就连{}块中的局部变量也与非static方法的局部变量一样,被组织成局部变量表。且看下面例子.
其中bci等于4、5、7、10的这4条字节码指令的作用是完成Test类成员变量i的初始化。。而从bci=13的字节码指令开始,执行Test类中被{}包裹的逻辑。注意bci=14的字节码指令是istore_1,这表示将数字1赋值给{}块中的a变量,这正暗示出Java编译器将{}块中的变量a处理成了局部变量表中的第2各局部变量(即第2个slot)。同理,bci=16和bci=20的两条字节码指令istore_2和istore_3也表明了编译器将{}块中的第2和第3个局部变量b和c分别处理成了局部变量表中第3和第4个变量。既然Java编译器将{}块中的局部变量处理成了局部变量表,而局部变量表是函数相关的,但是{}块并不是一个函数,那么问题来了,这里的局部变量表是谁的呢?很显然,由于Java编译器将{}块中的逻辑嵌入到了<init>()构造函数中,因此这里的局部变量表自然就是Java类的构造函数了。
不过当一个Java类有多个{}块逻辑时,问题将变得更加有趣。将上述Test类稍加改造如下:
现在Test类中有两个{}块逻辑,Java编译器会将{}块逻辑中的局部变量处理成构造函数的局部变量表,现在有两个块逻辑,那么一个关键的问题就随之出现了:这两个{}块逻辑中的变量在局部变量表中的编号会是怎样的?是否相关。通过分析Test.class字节码文件,Test类中的2个{}块逻辑代码所对应的字节码指令都被嵌入到了构造函数之中。注意观察这2个{}块逻辑中的局部变量在局部变量表中的编号(slot索引),都是从1开始,也即第一个{}块中的a变量的slot索引是1,而第二个{}块逻辑中的x变量的slot索引也是1.相应地,这2个块逻辑中的后续变量的slot索引编号都从1开始递增。由此可见,当Java类中存在多个{}块逻辑时,Java编译器并不是简单地将其合并到构造函数之中,各个块的局部变量的slot索引之间并不存在任何关联,,唯一存在关联的就是各个{}块逻辑中的第一个局部变量的slot索引号相同。其实道理也很简单,由于Java类中的{}块就相当于一个独立的普通的Java函数,因此多个{}块中的局部变量之间彼此没有任何联系,相互不能引用,所以即使Java编译器,所以即使Java编译器将多个{}块逻辑都统一嵌入到构造函数之中,但是仍然保留了各个{}块逻辑的独立性,{}块中的局部变量既不能被其他{}块引用,更不会被构造函数引用到,因此当{}块逻辑执行完了之后,其局部变量表立即就被回收,被回收的局部变量表空间便被其他{}块的局部变量表所复用(其实并没有回收的动作,被复用就是被回收)。
现在Test类中有两个{}块逻辑,Java编译器会将{}块逻辑中的局部变量处理成构造函数的局部变量表,现在有两个块逻辑,那么一个关键的问题就随之出现了:这两个{}块逻辑中的变量在局部变量表中的编号会是怎样的?是否相关。通过分析Test.class字节码文件,Test类中的2个{}块逻辑代码所对应的字节码指令都被嵌入到了构造函数之中。注意观察这2个{}块逻辑中的局部变量在局部变量表中的编号(slot索引),都是从1开始,也即第一个{}块中的a变量的slot索引是1,而第二个{}块逻辑中的x变量的slot索引也是1.相应地,这2个块逻辑中的后续变量的slot索引编号都从1开始递增。由此可见,当Java类中存在多个{}块逻辑时,Java编译器并不是简单地将其合并到构造函数之中,各个块的局部变量的slot索引之间并不存在任何关联,,唯一存在关联的就是各个{}块逻辑中的第一个局部变量的slot索引号相同。其实道理也很简单,由于Java类中的{}块就相当于一个独立的普通的Java函数,因此多个{}块中的局部变量之间彼此没有任何联系,相互不能引用,所以即使Java编译器,所以即使Java编译器将多个{}块逻辑都统一嵌入到构造函数之中,但是仍然保留了各个{}块逻辑的独立性,{}块中的局部变量既不能被其他{}块引用,更不会被构造函数引用到,因此当{}块逻辑执行完了之后,其局部变量表立即就被回收,被回收的局部变量表空间便被其他{}块的局部变量表所复用(其实并没有回收的动作,被复用就是被回收)。
接下来再分析static{},既然{}块可以被当作一个普通的Java方法处理,那么static{}也自然可以被当作一个static修饰的Java静态方法处理,而事实上JVM也的确这么规定的,所唯一不同的只是static{}块逻辑是在Java类被加载时就执行,这是与其他Java类中静态方法的最大不同。既然static{}可以被当作一个Java静态方法处理,那么这个块的作用域也就十分明了了:
# 仅能访问Java类中的静态成员变量,而不能访问非静态成员变量
# 仅能访问Java类中的静态成员变量,而不能访问非静态成员变量
7.<init>与<clinit>的访问标识。
<init>()方法包括其一系列的重载(即Java类中所定义的非默认构造函数),直接被编译器处理成public类型,这从前面一系列Java示例程序的javap命令的输出中可以看出来。同时由于是构造函数,本身就没有返回类型。而对于<clinit>()方法,在HotSpot源码中的classFileParser.cpp::parse_method()方法中存在如下逻辑
AccessFlags access_flags;
if (name == vmSymbols::class_initializer_name()) {
// We ignore the other access flags for a valid class initializer.
// (JVM Spec 2nd ed., chapter 4.6)
if (_major_version < 51) { // backward compatibility
flags = JVM_ACC_STATIC;
} else if ((flags & JVM_ACC_STATIC) == JVM_ACC_STATIC) {
flags &= JVM_ACC_STATIC | JVM_ACC_STRICT;
}
} else {
verify_legal_method_modifiers(flags, is_interface, name, CHECK_(nullHandle));
}
这段代码中的vmSymbols::class_initializer_name()就返回<clinit>.通过这段源码可知,当HotSpot所解析的当前Java方法名是<clinit>时,就直接设置其访问标识是ACC_STATIC
<init>()方法包括其一系列的重载(即Java类中所定义的非默认构造函数),直接被编译器处理成public类型,这从前面一系列Java示例程序的javap命令的输出中可以看出来。同时由于是构造函数,本身就没有返回类型。而对于<clinit>()方法,在HotSpot源码中的classFileParser.cpp::parse_method()方法中存在如下逻辑
AccessFlags access_flags;
if (name == vmSymbols::class_initializer_name()) {
// We ignore the other access flags for a valid class initializer.
// (JVM Spec 2nd ed., chapter 4.6)
if (_major_version < 51) { // backward compatibility
flags = JVM_ACC_STATIC;
} else if ((flags & JVM_ACC_STATIC) == JVM_ACC_STATIC) {
flags &= JVM_ACC_STATIC | JVM_ACC_STRICT;
}
} else {
verify_legal_method_modifiers(flags, is_interface, name, CHECK_(nullHandle));
}
这段代码中的vmSymbols::class_initializer_name()就返回<clinit>.通过这段源码可知,当HotSpot所解析的当前Java方法名是<clinit>时,就直接设置其访问标识是ACC_STATIC
查看运行时字节码指令。
Java方法经过编译后就生成了字节码指令,在运行期字节码指令会被加载到JVM内存中,使用HSDB可以观察运行期的字节码指令,相信各位对此非常感兴趣。先准备一个示例程序:
字节码指令被分配在constMethodOop对象的内存区域的末尾,因此只要在HSDB中额能够查到add()方法所对应的constMethodOop所在的内存位置,就能基于此定位到字节码指令的内存位置,并进一步查看内存中的字节码指令长成啥样。。在HSDB中单击工具栏的Tools->Class Browser按钮,搜索Test类,可以看到该类的所有方法,如图所示。。图中显示出Test.add()方法,HSDB显示出该方法的签名和地址,其地址是0x000000001cce30b8
Java方法经过编译后就生成了字节码指令,在运行期字节码指令会被加载到JVM内存中,使用HSDB可以观察运行期的字节码指令,相信各位对此非常感兴趣。先准备一个示例程序:
字节码指令被分配在constMethodOop对象的内存区域的末尾,因此只要在HSDB中额能够查到add()方法所对应的constMethodOop所在的内存位置,就能基于此定位到字节码指令的内存位置,并进一步查看内存中的字节码指令长成啥样。。在HSDB中单击工具栏的Tools->Class Browser按钮,搜索Test类,可以看到该类的所有方法,如图所示。。图中显示出Test.add()方法,HSDB显示出该方法的签名和地址,其地址是0x000000001cce30b8
接着单击HSDB工具栏的Tools->Insepector按钮,输入Test.add()方法的地址并回车。Inspector工具将分析Test.add()方法在JVM内部所对应的MethodOop对象实例的内存结构以及各个字段的内存地址,其中就包括我们关心的constMethodOop字段的内存地址,如图所示。由于MethodOop类型包含constMethodOop的引用,因此在图中可以看到constMethod字段的内存地址为 0x000000001cce3028。这个地址正是Test.add()方法在JVM内部所对应的constMethodOop对象实例的内存地址,而Test.add()方法的字节码指令就跟在constMethodOop对象实例的内存后面。HSDB没有直接给出字节码指令的查看工具,因此只能通过计算字节码指令的其实内存地址并使用Mem工具去查看字节码指令。字节码指令的起始内存地址的计算很简单,只需要使用constMethodOop的内存首地址加上该对象实例所占内存空间的大小,就得到字节码指令的内存首地址。
在64位平台上,并且开启-XX:-UseCompressedOops选项的前提下,constMethodOop的所有指针类型的字段都占用8字节的内存空间,constMethodOop对象内各个字段的偏移量如表所示(基于JDK8)。由表可知,JDK8的constMethodOop在64位平台上共占用44字节的内存空间(关闭指针压缩功能),所以字节码指令的内存首地址很显然就在这44字节的后面。但是事实上并不是这样的,前面在分析Java类字段解析时提到内存对齐,不仅Java类的各个字段会进行内存对齐,C++类也会内存对齐,并且整个C++类也需要内存对齐。C++类对齐的其中一条规则就是整个类型实例所占的内存空间必须是类型中宽度最大的字段所占内存的整数倍。由于constMethodOop中包含指针,因此该类实例对象所占的内存空间必须是8字节的整数倍,因此最终constMethodOop所占的内存大小应该是48字节。constMethodOop的大小计算出来之后,便可以计算字节码指令的内存起始地址,计算公式很简单:
0x000000001cce3028+ 0x30= 0x1cce3058。(48对应的十六进制是0x30)
计算出字节码指令的起始地址后,可以使用HSDB所提供的mem工具查看这个地址处的内容。
mem 0x1cce3058 2
0x000000001cce3058: 0x060436601c1b4edc
0x000000001cce3060: 0x0019291106b10536
上面这个mem命令的最后加了一个选项2,表示从0x1cce3058这个内存位置开始,连续输出两行内容,,在64位平台上,一行显示128字节内容。。
JVM的每个字节码指令都占1字节长度,并且Test.add()方法里的局部变量的值也都小于255,因此每个变量的值也都只占1字节的长度,这些变量将作为带参数的字节码指令的操作数,例如istore 4,istore指令后面紧跟的4,在内存中仅使用1字节来表示,并且作为字节码指令的操作数,其内存位置与字节码指令所在的内存相邻,这一点很好理解。现在来分析mem 0x1cce3058 2 命令的输出结果。输出结果一共2行,每行按照高位到低位的顺序显示,但是字节码指令在内存中的分配顺序实际上是从低位到高位,即Java方法的第一条字节码指令所在的内存地址,一定是在最低位,而Java方法的最后一条字节码指令,则一定在最高位。所以这里首先要将mem的输出结果的顺序进行逆转,将其按照从低位到高位的顺序重新排列,这样才符合人类的阅读习惯。对于Test.add()方法,其每个字节码指令及操作数皆使用1字节表示,因此在对mem输出结果的顺序进行逆转时,也以1字节为单位进行逆转,内存重排后的内容如下:
0x000000001cce3058: dc 4e 1b 1c 60 36 04 06
0x000000001cce3060: 36 05 b1 06 11 29 19 00
内存重排后,先来看Test.add()方法所对应的字节码指令的十六进制数字。使用javap命令分析编译后的Test.class文件,得到如下结果
public void add(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: aload_0
1: astore_3
2: iload_1
3: iload_2
4: iadd
5: istore 4
7: iconst_3
8: istore 5
10: return
0x000000001cce3028+ 0x30= 0x1cce3058。(48对应的十六进制是0x30)
计算出字节码指令的起始地址后,可以使用HSDB所提供的mem工具查看这个地址处的内容。
mem 0x1cce3058 2
0x000000001cce3058: 0x060436601c1b4edc
0x000000001cce3060: 0x0019291106b10536
上面这个mem命令的最后加了一个选项2,表示从0x1cce3058这个内存位置开始,连续输出两行内容,,在64位平台上,一行显示128字节内容。。
JVM的每个字节码指令都占1字节长度,并且Test.add()方法里的局部变量的值也都小于255,因此每个变量的值也都只占1字节的长度,这些变量将作为带参数的字节码指令的操作数,例如istore 4,istore指令后面紧跟的4,在内存中仅使用1字节来表示,并且作为字节码指令的操作数,其内存位置与字节码指令所在的内存相邻,这一点很好理解。现在来分析mem 0x1cce3058 2 命令的输出结果。输出结果一共2行,每行按照高位到低位的顺序显示,但是字节码指令在内存中的分配顺序实际上是从低位到高位,即Java方法的第一条字节码指令所在的内存地址,一定是在最低位,而Java方法的最后一条字节码指令,则一定在最高位。所以这里首先要将mem的输出结果的顺序进行逆转,将其按照从低位到高位的顺序重新排列,这样才符合人类的阅读习惯。对于Test.add()方法,其每个字节码指令及操作数皆使用1字节表示,因此在对mem输出结果的顺序进行逆转时,也以1字节为单位进行逆转,内存重排后的内容如下:
0x000000001cce3058: dc 4e 1b 1c 60 36 04 06
0x000000001cce3060: 36 05 b1 06 11 29 19 00
内存重排后,先来看Test.add()方法所对应的字节码指令的十六进制数字。使用javap命令分析编译后的Test.class文件,得到如下结果
public void add(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: aload_0
1: astore_3
2: iload_1
3: iload_2
4: iadd
5: istore 4
7: iconst_3
8: istore 5
10: return
使用javap命令只能得到字节码指令的名称,但是内存里实际存储的是字节码的十六进制编码。对照指令集的十六进制编码表,将上述字节码指令名称及操作数逐个翻译成十六进制编码,翻译后的结果如下:
public void add(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: aload_0 2a
1: astore_3 4e
2: iload_1 1b
3: iload_2 1c
4: iadd 60
5: istore 4 36 04
7: iconst_3 06
8: istore 5 36 05
10: return b1
Test.add()方法第一条字节码指令是aload_0,其十六进制编码是2a, 该字节码指令的内存地址就是add()方法的字节码指令在constMethodOop之后的首地址,因此其内存地址就是前面分析得到的0x1cce3058,也即上面使用mem命令所观察的内存地址。以alod_0指令的十六进制编码为开始,将add()方法的字节码指令的十六进制编码为字节为单位顺序编排,如下:
0x000000001cce3058: 2a 4e 1b 1c 60 36 04 06
0x000000001cce3060: 36 05 b1
将这个结果与上面使用mem命令所查看到的内存内容进行比较,从第一个字节到add()方法所对应的最后一条字节码指令的十六进制编码b1注意比较,会发现除了第一个字节码不同之外,其余的都是完全相同的。字节码指令存储的奥秘到此便完全揭晓。
不过仍然有一个疑问,为何第一个字节码的内容不一样呢?这是因为为了使用HSDB观察Test程序的运行时内存结构,使用了JDB进行调试,并且就在Test.add()方法内部打上了断点,所以JVM将add()方法的入口地址改写成断点指令的。等add()方法从中断回复之后,第一条字节码指令就会恢复正常。
其实对于字节码指令的内存位置的计算,除了上面所述的通过constMethodOop内存首地址加上该对象实例所占的内存大小的方法之外,还有一个更简单的办法,那就是直接查看堆栈.Java的每一个方法都会在JVM的内存中被分配一个栈帧空间,并且栈帧中会保存一个指针指向Java方法的字节码指令的内存首地址,而HSDB可以观察到运行期的Java方法的堆栈内容,因此可以使用这种方式直接定位到Java方法的字节码指令所在的内存地址。
public void add(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: aload_0 2a
1: astore_3 4e
2: iload_1 1b
3: iload_2 1c
4: iadd 60
5: istore 4 36 04
7: iconst_3 06
8: istore 5 36 05
10: return b1
Test.add()方法第一条字节码指令是aload_0,其十六进制编码是2a, 该字节码指令的内存地址就是add()方法的字节码指令在constMethodOop之后的首地址,因此其内存地址就是前面分析得到的0x1cce3058,也即上面使用mem命令所观察的内存地址。以alod_0指令的十六进制编码为开始,将add()方法的字节码指令的十六进制编码为字节为单位顺序编排,如下:
0x000000001cce3058: 2a 4e 1b 1c 60 36 04 06
0x000000001cce3060: 36 05 b1
将这个结果与上面使用mem命令所查看到的内存内容进行比较,从第一个字节到add()方法所对应的最后一条字节码指令的十六进制编码b1注意比较,会发现除了第一个字节码不同之外,其余的都是完全相同的。字节码指令存储的奥秘到此便完全揭晓。
不过仍然有一个疑问,为何第一个字节码的内容不一样呢?这是因为为了使用HSDB观察Test程序的运行时内存结构,使用了JDB进行调试,并且就在Test.add()方法内部打上了断点,所以JVM将add()方法的入口地址改写成断点指令的。等add()方法从中断回复之后,第一条字节码指令就会恢复正常。
其实对于字节码指令的内存位置的计算,除了上面所述的通过constMethodOop内存首地址加上该对象实例所占的内存大小的方法之外,还有一个更简单的办法,那就是直接查看堆栈.Java的每一个方法都会在JVM的内存中被分配一个栈帧空间,并且栈帧中会保存一个指针指向Java方法的字节码指令的内存首地址,而HSDB可以观察到运行期的Java方法的堆栈内容,因此可以使用这种方式直接定位到Java方法的字节码指令所在的内存地址。
vtable.
多态。
java是一门面向对象的编程语言,面向对象的一大特色便是多态。多态的具体体现便是在运行期能够根据对象实例的不同而执行不同的接口方法,换成业界对多态的标准定义便是:允许不同类的对象对同一消息做出响应,即统一消息可以根据发送对象的不同而采用多种不同的行为方式(发送消息就是函数调用)。。多态是面向对象编程的特性,而这种特性并不仅仅是喊句口号就算的。而是必须使用特定的机制或技术去实现。实现多态的技术称为动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。在Java中,动态绑定也叫"晚绑定",这是因为在Java还有一类绑定是在编译期间便能确定,所以所谓的晚绑定的概念,是相对于编译期绑定而言的。
面向对象编程语言之所以要实现多态这一特性,最主要的目的就是为了消除类型之间的耦合关系,通俗地讲就是解耦。从计算机软件易产生,"解耦"便是一切计算机程序所要重点考虑的原则之一。其实何止是软件,计算机硬件之间也是以解耦为主要原则的,这类例子举不胜举,例如内存插槽、IO接口之类,都是实现解耦的手段。
解耦的最大好处在于,一旦系统发生了变化,能够将变化降低到最小,仅变化新增的部件,而对于已经存在的部件,则尽量保持不变。所以一个优秀的系统设计师总是想办法设计拥有良好兼容性和扩展性的架构,而面向对象语言的多态性,则是从语言特性上直接实现对象的解耦,这极大地提升了面向对象编程语言构建一套高内聚、低耦合系统地能力。
由于多态通过"动态绑定"的方式得以实现,而绑定通俗一点讲就是让不同的对象对同一个函数进行调用,或者反过来讲就是将同一个函数与不同的对象绑定起来,所以多态性得以实现的一个大前提就是,编程语言必须是面向对象的,否则哪来的函数与对象相互绑定一说呢?同时,函数与对象相互绑定,,意味着函数也属于对象的一部分吗,这便具备了封装的特性。因为有了封装,才有了对象。有了对象才能叫做面向对象编程。同时,一个函数能够绑定多个不同的对象,意味着多个不同的对象都具有相同的行为,这是继承的含义。因此,面向对象编程语言的三大特性——封装、继承与多态,其中前两个特性"封装"与"继承"其实就是为了第三个特性"多态"而准备的,或者说"封装"与"继承"成全了"多态",为"多态"做了嫁衣。。下面是一个简单的动态绑定的示例程序:
本示例程序中定义了抽象类Animal,同时定义了2个子类Dog和Cat,这2个子类都重写了基类中的say()方法。在main()方法中,将animial实例引用分别指向Dog和Cat的实例,并分别调用run(Animal)方法。在本示例中,当Animal.run(Animal)方法中执行animal.say()时,因为编译期并不知道animal这个引用到底指向哪个实例对象,所以编译期无法进行绑定,必须等到运行期才能确切知道最终调用哪个子类的say()方法,这便是动态绑定,也即晚绑定,这是Java语言以及绝大多数面向对象语言的动态机制最直接的体现。
java是一门面向对象的编程语言,面向对象的一大特色便是多态。多态的具体体现便是在运行期能够根据对象实例的不同而执行不同的接口方法,换成业界对多态的标准定义便是:允许不同类的对象对同一消息做出响应,即统一消息可以根据发送对象的不同而采用多种不同的行为方式(发送消息就是函数调用)。。多态是面向对象编程的特性,而这种特性并不仅仅是喊句口号就算的。而是必须使用特定的机制或技术去实现。实现多态的技术称为动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。在Java中,动态绑定也叫"晚绑定",这是因为在Java还有一类绑定是在编译期间便能确定,所以所谓的晚绑定的概念,是相对于编译期绑定而言的。
面向对象编程语言之所以要实现多态这一特性,最主要的目的就是为了消除类型之间的耦合关系,通俗地讲就是解耦。从计算机软件易产生,"解耦"便是一切计算机程序所要重点考虑的原则之一。其实何止是软件,计算机硬件之间也是以解耦为主要原则的,这类例子举不胜举,例如内存插槽、IO接口之类,都是实现解耦的手段。
解耦的最大好处在于,一旦系统发生了变化,能够将变化降低到最小,仅变化新增的部件,而对于已经存在的部件,则尽量保持不变。所以一个优秀的系统设计师总是想办法设计拥有良好兼容性和扩展性的架构,而面向对象语言的多态性,则是从语言特性上直接实现对象的解耦,这极大地提升了面向对象编程语言构建一套高内聚、低耦合系统地能力。
由于多态通过"动态绑定"的方式得以实现,而绑定通俗一点讲就是让不同的对象对同一个函数进行调用,或者反过来讲就是将同一个函数与不同的对象绑定起来,所以多态性得以实现的一个大前提就是,编程语言必须是面向对象的,否则哪来的函数与对象相互绑定一说呢?同时,函数与对象相互绑定,,意味着函数也属于对象的一部分吗,这便具备了封装的特性。因为有了封装,才有了对象。有了对象才能叫做面向对象编程。同时,一个函数能够绑定多个不同的对象,意味着多个不同的对象都具有相同的行为,这是继承的含义。因此,面向对象编程语言的三大特性——封装、继承与多态,其中前两个特性"封装"与"继承"其实就是为了第三个特性"多态"而准备的,或者说"封装"与"继承"成全了"多态",为"多态"做了嫁衣。。下面是一个简单的动态绑定的示例程序:
本示例程序中定义了抽象类Animal,同时定义了2个子类Dog和Cat,这2个子类都重写了基类中的say()方法。在main()方法中,将animial实例引用分别指向Dog和Cat的实例,并分别调用run(Animal)方法。在本示例中,当Animal.run(Animal)方法中执行animal.say()时,因为编译期并不知道animal这个引用到底指向哪个实例对象,所以编译期无法进行绑定,必须等到运行期才能确切知道最终调用哪个子类的say()方法,这便是动态绑定,也即晚绑定,这是Java语言以及绝大多数面向对象语言的动态机制最直接的体现。
C++中的多态与vtable。
JVM实现晚绑定的机制基于vtable,即virtual table,也即虚方法表。JVM通过虚方法表在运行期动态确定所调用的目标类的目标方法。在分析JVM的vtable概念之前,先一起品味C++中虚方法表的实现机制,这两者有很紧密的联系。。有如下C++类:
这个C++示例很简单,类中包含一个short类型的变量和一个run()方法,在main()函数中打印3个信息:CPLUS类型宽度、其示例的内存首地址和其变量x的内存地址。编译并执行,输出结果如下。
由于CPLUS类中仅包含1个short类型的变量,因此该类型的数据宽度自然是2。另外注意观察结果中的cplus实例和其变量x的内存地址,两者是相等的。
JVM实现晚绑定的机制基于vtable,即virtual table,也即虚方法表。JVM通过虚方法表在运行期动态确定所调用的目标类的目标方法。在分析JVM的vtable概念之前,先一起品味C++中虚方法表的实现机制,这两者有很紧密的联系。。有如下C++类:
这个C++示例很简单,类中包含一个short类型的变量和一个run()方法,在main()函数中打印3个信息:CPLUS类型宽度、其示例的内存首地址和其变量x的内存地址。编译并执行,输出结果如下。
由于CPLUS类中仅包含1个short类型的变量,因此该类型的数据宽度自然是2。另外注意观察结果中的cplus实例和其变量x的内存地址,两者是相等的。
现在将C++类中的run()方法修改一下,变成虚方法,修改后的程序如下:输出如图所示
注意看,现在sizeof(CPLUS)的值变成16了,并且cplus实例和其变量x的内存地址也不再相等了。这是咋回事呢?这是因为当C++类中出现虚方法时,表示该方法拥有多态性,此时会根据类型指针所指向的实际对象而在运行期调用不同的方法,这与Java中的多态在语义上是完全一致的。
C++为了实现多态,就在C++类实例对象中嵌入虚函数表vtable,通过虚函数表来实现运行期的方法分派。C++。C++中所谓虚函数表,其实就是一个普通的表,表中存储的是方法指针,方法指针会指向目标方法的内存地址。所以虚函数表就是一堆指针的集合而已。对于大部分C++编译器而言,其实现虚函数表的机制都大同小异,都会将虚函数表分配在C++对象实例的起始位置,当C++类中出现虚函数表时,其内存分配就是先分配虚函数表,在分配类中的字段空间。
注意看,现在sizeof(CPLUS)的值变成16了,并且cplus实例和其变量x的内存地址也不再相等了。这是咋回事呢?这是因为当C++类中出现虚方法时,表示该方法拥有多态性,此时会根据类型指针所指向的实际对象而在运行期调用不同的方法,这与Java中的多态在语义上是完全一致的。
C++为了实现多态,就在C++类实例对象中嵌入虚函数表vtable,通过虚函数表来实现运行期的方法分派。C++。C++中所谓虚函数表,其实就是一个普通的表,表中存储的是方法指针,方法指针会指向目标方法的内存地址。所以虚函数表就是一堆指针的集合而已。对于大部分C++编译器而言,其实现虚函数表的机制都大同小异,都会将虚函数表分配在C++对象实例的起始位置,当C++类中出现虚函数表时,其内存分配就是先分配虚函数表,在分配类中的字段空间。
以本示例程序而言,CPLUS的实例对象cplus的实际内存结构如图所示。由于CPLUS类中仅包含一个虚函数,因此虚函数表中只有一个指针。注意,在cplus实例的末尾有一段补白空间,这是因为C++编译器会对类型做对齐处理,整个C++类实例对象所占的内存空间必须是其中宽度最大的字段所占内存空间的整数倍,而CPLUS类中由于嵌入了虚函数表,表中元素是指针类型,在64位平台上,1个指针占8字节内存空间,因此CPLUS类实例对象所占的内存空间就是16字节,这就是上面运行示例程序后输出结果中的sizeof(CPLUS)的值变成16的原因所在。。同时,字段x被安排在虚函数表之后,因此x的内存地址也不再与cplus示例对象的内存首地址相等,并且根据上述程序运行结果可见,这两者的内存地址相差8字节,这正好是一个指针的宽度
Java中的多态实现机制。
Java的多态机制并没有跳出这个全,也采用了vtable来实现动态绑定。Java类在JVM内部对应的对象是instanceKlassOop(JDK8中是instanceKlass).在JVM加载Java类的过程中,JVM会动态解析Java类的方法及其对父类方法的重写,进而构建出一个vtable,并将vtable分配到instanceKlassOop内存区的末尾,从而支持运行期的方法动态绑定。。JVM的vtable机制与C++的vtable机制之间最大之不同在于,C++的vtable在编译期间便由编译器完成分析和模型构建,而JVM的vtable则在JVM运行期、Java类被加载时进行动态构建。其实也可以认为JVM在运行期做了C++编译器在静态编译期所作的事情。关于C++编译器在编译期间构建vtable的机制,不同的编译器具体的实现方式不尽相同,但基本都没跳出这个框框。
JVM在第一次加载Java类时会调用classFileParser.cpp::parseClassFile()函数对Java class字节码进行解析,上文刚刚讲过,在parseClassFile()函数中会调用parse_method()函数解析Java类中的方法,parse_methods()函数执行完之后,会继续调用klassVtable::compute_vtable_size_and_num_mirandas()函数,计算当前Java类的vtable大小。下面便一起来围观下该方法实现的主要逻辑:如图所示
#1. 获取父类vtable的大小,并将当前类的vtable的大小设置为父类vtable的大小
#2. 循环遍历当前Java类的每一个方法,调用needs_new_vtable_entry()函数进行判断,如果判断的结果是true,则将vtable的大小增1
Java的多态机制并没有跳出这个全,也采用了vtable来实现动态绑定。Java类在JVM内部对应的对象是instanceKlassOop(JDK8中是instanceKlass).在JVM加载Java类的过程中,JVM会动态解析Java类的方法及其对父类方法的重写,进而构建出一个vtable,并将vtable分配到instanceKlassOop内存区的末尾,从而支持运行期的方法动态绑定。。JVM的vtable机制与C++的vtable机制之间最大之不同在于,C++的vtable在编译期间便由编译器完成分析和模型构建,而JVM的vtable则在JVM运行期、Java类被加载时进行动态构建。其实也可以认为JVM在运行期做了C++编译器在静态编译期所作的事情。关于C++编译器在编译期间构建vtable的机制,不同的编译器具体的实现方式不尽相同,但基本都没跳出这个框框。
JVM在第一次加载Java类时会调用classFileParser.cpp::parseClassFile()函数对Java class字节码进行解析,上文刚刚讲过,在parseClassFile()函数中会调用parse_method()函数解析Java类中的方法,parse_methods()函数执行完之后,会继续调用klassVtable::compute_vtable_size_and_num_mirandas()函数,计算当前Java类的vtable大小。下面便一起来围观下该方法实现的主要逻辑:如图所示
#1. 获取父类vtable的大小,并将当前类的vtable的大小设置为父类vtable的大小
#2. 循环遍历当前Java类的每一个方法,调用needs_new_vtable_entry()函数进行判断,如果判断的结果是true,则将vtable的大小增1
关于父类vtable的大小需要从Java类的顶级父类java.lang.Object开始算起,这个一会儿再说,现在重点看第2步——needs_new_vtable_entry()函数的实现逻辑。如图所示。在分析这段逻辑之前,有必要稍微解释一下vtable的机制。Java类在运行期进行动态绑定的方法,一定会被声明为public或者protected的,并且没有static和final修饰,且Java类上也没有final修饰。道理很简单,阐述如下:
# 如果一个Java方法被static修饰,则压根儿不会参与到整个Java类的继承体系中,所以静态的Java方法不会参与到运行期的动态绑定机制。所谓的动态绑定,是指将Java类实例与Java方法搭配,而静态方法的调用压根儿不需要经过类实例,只需要有类型名即可
# 如果一个Java方法被private修饰,则外部根部无法调用该方法,只能被类内部的其他方法所调用,因此也不会参与到运行期的动态绑定
# 如果一个Java方法被final修饰,则其子类无法重写该方法,则该方法仅为Java类所固有,不会出现多态性
# 如果一个Java类被final修饰,则该Java类中的所有非静态方法都会隐式地被final修饰,参考第3条,则Java类中地所有非静态方法都不会被子类重写,因此都不会出现多态性,不会发生运行期动态绑定
只有毛南族以上4哥条件的Java方法,才有可能参与到运行期的动态绑定,而满足了以上4个条件的Java方法,其一定只被public或者protected关键字修饰。而满足了这4个条件的java方法只是有可能参与动态绑定,这是因为仅仅满足了这4个条件还不够,还得有别的条件,其余的条件包括以下:
# 父类中必须包含名称相同的Java方法,这里所谓的父类,并不一定是Java类的直接父类,也可能是间接的父类。例如A类继承了B类,B类继承了C类,则A类间接继承了C类
# 父类中名称相同的Java类,其签名也必须完全一致
# 如果一个Java方法被static修饰,则压根儿不会参与到整个Java类的继承体系中,所以静态的Java方法不会参与到运行期的动态绑定机制。所谓的动态绑定,是指将Java类实例与Java方法搭配,而静态方法的调用压根儿不需要经过类实例,只需要有类型名即可
# 如果一个Java方法被private修饰,则外部根部无法调用该方法,只能被类内部的其他方法所调用,因此也不会参与到运行期的动态绑定
# 如果一个Java方法被final修饰,则其子类无法重写该方法,则该方法仅为Java类所固有,不会出现多态性
# 如果一个Java类被final修饰,则该Java类中的所有非静态方法都会隐式地被final修饰,参考第3条,则Java类中地所有非静态方法都不会被子类重写,因此都不会出现多态性,不会发生运行期动态绑定
只有毛南族以上4哥条件的Java方法,才有可能参与到运行期的动态绑定,而满足了以上4个条件的Java方法,其一定只被public或者protected关键字修饰。而满足了这4个条件的java方法只是有可能参与动态绑定,这是因为仅仅满足了这4个条件还不够,还得有别的条件,其余的条件包括以下:
# 父类中必须包含名称相同的Java方法,这里所谓的父类,并不一定是Java类的直接父类,也可能是间接的父类。例如A类继承了B类,B类继承了C类,则A类间接继承了C类
# 父类中名称相同的Java类,其签名也必须完全一致
以上两点其实换言之,就是Java类中的方法必须重写了父类的Java方法,这样的方法才会参与到运行期动态绑定。而这其实正是多态的含义:父类与子类中包含一个完全一样的行为(即Java方法的名称和签名完全相同),在运行期这一行为将根据不同的条件与具体的类型对象相绑定(父类或子类实例)。
而父类与子类都同时拥有的相同的行为,从继承的角度看,就是子类对父类的重写,并且重写的前提是,这一行为不能是private的,不能是static的,不能是final的,否则父类的行为无法被子类继承,所谓的重写更无从谈起了。所以上面这段代码的逻辑就是在进行这一系列的判断,只有最终满足条件的,才会返回true.
在klassVtable.cpp::compute_vtable_size_and_num_mirandas()函数中,如果needs_new_vtable_entry()函数返回true,将会对vtable_length增加1.仙子啊我们描述JVM内部vtable的实现机制。每一个Java类在JVM内部都有一个对应的instanceKlassOop,vtable就被分配在这个oop内存区域的后面。vtable表中的每一个位置存放一个指针,指向Java方法在内存中所对应的methodOop的内存首地址。如果一个Java类继承了父类,则该Java类就会直接继承父类的vtable.若该Java类中声明了一个非private、非final、非static的方法,若该方法是对父类方法的重写,则JVM会更新父类vtable表中指向父类被重写的方法的指针,使其指向子类中该方法的内存地址。若该方法并不是对父类方法的重写,则JVM会向该Java类的vtable中插入一个新的指针元素,使其指向该方法的内存位置。
而父类与子类都同时拥有的相同的行为,从继承的角度看,就是子类对父类的重写,并且重写的前提是,这一行为不能是private的,不能是static的,不能是final的,否则父类的行为无法被子类继承,所谓的重写更无从谈起了。所以上面这段代码的逻辑就是在进行这一系列的判断,只有最终满足条件的,才会返回true.
在klassVtable.cpp::compute_vtable_size_and_num_mirandas()函数中,如果needs_new_vtable_entry()函数返回true,将会对vtable_length增加1.仙子啊我们描述JVM内部vtable的实现机制。每一个Java类在JVM内部都有一个对应的instanceKlassOop,vtable就被分配在这个oop内存区域的后面。vtable表中的每一个位置存放一个指针,指向Java方法在内存中所对应的methodOop的内存首地址。如果一个Java类继承了父类,则该Java类就会直接继承父类的vtable.若该Java类中声明了一个非private、非final、非static的方法,若该方法是对父类方法的重写,则JVM会更新父类vtable表中指向父类被重写的方法的指针,使其指向子类中该方法的内存地址。若该方法并不是对父类方法的重写,则JVM会向该Java类的vtable中插入一个新的指针元素,使其指向该方法的内存位置。
相信很多人对这段文字看得有些晕,还是举个例子,假设下面有一个超类和子类。子类B继承于类A,并且重谢了类A的void print()方法。由于类B和类A中的print()方法名称相同,签名也完全相同,并且都没有private、static、final这3个关键字修饰,因此该方法将会在运行期进行动态绑定。当HotSpot在运行期加载类A时,其vtable中将会有一个指针元素指向其void print()方法在HotSpot内部的内存首地址。当HotSpot加载类B时,首先类B完全继承其父类A的vtable,因此类B便也有一个vtable,并且vtable里有一个指针指向类A的print()方法的内存地址。HotSpot遍历类B的所有方法,并发现print()方法是public的,并且没有被static、final修饰,于是HotSpot去搜索其父类中名称相同、签名也相同的方法(即前面的klassVtalbe.cpp::needs_new_vtable_entry()函数),结果发现父类中存在一个完全一样的方法,于是HotSpot机会将类B的vtable中原本指向类A的print()方法的内存地址的指针值修改成指向类B自己的print()方法所在的内存地址。
而当HotSpot解析类B的newFun()方法时,由于该方法并没有在父类中出现,并且也是public的,同时没有static和final修饰,满足vtable的条件,于是HotSPot将类B原本继承于A的vtable的长度增1,并将新增的vtable的指针元素指向newFun()方法在内存中的位置。
而当HotSpot解析类B的newFun()方法时,由于该方法并没有在父类中出现,并且也是public的,同时没有static和final修饰,满足vtable的条件,于是HotSPot将类B原本继承于A的vtable的长度增1,并将新增的vtable的指针元素指向newFun()方法在内存中的位置。
在classFileParser.cpp::parseClassFile()函数中,执行完klassVtable.cpp::compute_vtable_size_and_num_mirandas()函数后,会得到当前Java类的vtable的大小,虚函数表的大小被保存在classFileParser.cpp::parseClassFile()函数的局部变量vtable_size中,,该变量值将在后续创建Java类所对应的instanceKlassOop对象时被保存到该对象中的_vtable_length字段中,而该字段可以通过HSDB进行查看,并进而验证HotSpot计算虚函数表大小的逻辑。
写个测试类调用示例中的类A或B中的方法,并设置断点,让程序运行到断点处中断,然后使用HSDB连上测试程序,单击HSDB工具栏上的Tools-》Class Browser按钮,就会看到类A和类B.如图所示。复制图中类A的地址,单机HSDB工具栏中的Tools->Inspector按钮,将地址复制进去,可以查看类A在JVM内部所对应的instanceKlassOop的内部结构,其中就有类A所对应的vtable虚函数表的大小,如图所示
写个测试类调用示例中的类A或B中的方法,并设置断点,让程序运行到断点处中断,然后使用HSDB连上测试程序,单击HSDB工具栏上的Tools-》Class Browser按钮,就会看到类A和类B.如图所示。复制图中类A的地址,单机HSDB工具栏中的Tools->Inspector按钮,将地址复制进去,可以查看类A在JVM内部所对应的instanceKlassOop的内部结构,其中就有类A所对应的vtable虚函数表的大小,如图所示
类A的vtable大小为6。这里有个疑问,类A中之定义了一个方法,按理说其vtable大小应该是1,可是为何HSDB里却显示6呢?其实,在Java语言中,所有Java类隐式继承了顶级父类java.lang.Object,类A的vtable的另外5个方法指针元素便指向java.lang.Object中的5个方法,java.lang.Object中一共定义了如下12个方法。
# protected void finalize()
# public final void wait(long, int)
# public final native void wait(long)
# public fianl void wait()
# public boolean equals(java.lang.Object)
# public java.lang.String toString()
# public native int hashCode()
# public final native javga.lang.Class getClass()[signature() Ljava.lang.Class<*>;]
# protected natvie java.lang.Object clone()
# private static static native void registerNatives()
# public final native void notify()
# public final native void notifyAll()
而其中只有如下5个方法可以被子类重写,因此java.lang.Object类的vtable大小为5,这5个方法如下:
# protected void finalize()
# public boolean equals(java.lang.Object)
# public java.lang.String toSTring()
# public native int hashCode()
# protected native java.lang.Object clone()
这5个方法都是public或者proteced的,并且没有用static和final修饰,而java.lang.Object中的其他方法要么被final修饰,要么被static修饰,因此都不能被子类继承,所以其方法指针不会被JVM添加到vtable虚函数表中,因此,并不是Java类中的所有方法都会被放入vtable中。使用同样的方式可以看到类B的vtable大小是7,具体过程不再赘述。
# protected void finalize()
# public final void wait(long, int)
# public final native void wait(long)
# public fianl void wait()
# public boolean equals(java.lang.Object)
# public java.lang.String toString()
# public native int hashCode()
# public final native javga.lang.Class getClass()[signature() Ljava.lang.Class<*>;]
# protected natvie java.lang.Object clone()
# private static static native void registerNatives()
# public final native void notify()
# public final native void notifyAll()
而其中只有如下5个方法可以被子类重写,因此java.lang.Object类的vtable大小为5,这5个方法如下:
# protected void finalize()
# public boolean equals(java.lang.Object)
# public java.lang.String toSTring()
# public native int hashCode()
# protected native java.lang.Object clone()
这5个方法都是public或者proteced的,并且没有用static和final修饰,而java.lang.Object中的其他方法要么被final修饰,要么被static修饰,因此都不能被子类继承,所以其方法指针不会被JVM添加到vtable虚函数表中,因此,并不是Java类中的所有方法都会被放入vtable中。使用同样的方式可以看到类B的vtable大小是7,具体过程不再赘述。
vtable与invokevirtual指令。
一开始讲JVM内部执行字节码指令的原理时曾经分析锅,JVM通过调用CallStub例程开始调用Java程序的主函数main(),在CallStub例程内部最终调用了zero_locals这个例程,而在zero_locals例程中最终跳转到Java程序的main()函数的第一条字节码指令并开始执行Java程序,那么在Java内部,当一个Java方法调用了另一个Java方法时,是如何实现Java方法的调用的?这得从Java的字节码指令开始说起。Java的字节码指令中方法的调用实现分为4中指令:
# invokevirtual,为最常见的情况,包含virtual dispatch(虚方法分发)机制
# invokespecial, 调用private和构造方法,绕过了virtual dispatch
# invokeinterface, 其实现与invokevirtual类似
# invokestatic,调用静态方法
其中最复杂的要属invokevirtual指令,它涉及多态的特性,凡是Java类中需要在运行期动态绑定的方法调用,都通过invokevirtual指令,该指令将实现方法的分发,因此vtable与该指令之间有莫大的联系,而事实上,在HotSpot执行invokevirtual指令的过程中,最终会读取被调用类的vtable虚函数表,并据此决定真实的目标调用方法。JVM内部实现virtual dispatch机制时,会首先从receiver(被调用方法的对象)的类的实现中查找对应的方法,如果没找到,则去父类查找,直到找到方法并实现调用,而不是依赖于引用的类型。这里看一个例子。
一开始讲JVM内部执行字节码指令的原理时曾经分析锅,JVM通过调用CallStub例程开始调用Java程序的主函数main(),在CallStub例程内部最终调用了zero_locals这个例程,而在zero_locals例程中最终跳转到Java程序的main()函数的第一条字节码指令并开始执行Java程序,那么在Java内部,当一个Java方法调用了另一个Java方法时,是如何实现Java方法的调用的?这得从Java的字节码指令开始说起。Java的字节码指令中方法的调用实现分为4中指令:
# invokevirtual,为最常见的情况,包含virtual dispatch(虚方法分发)机制
# invokespecial, 调用private和构造方法,绕过了virtual dispatch
# invokeinterface, 其实现与invokevirtual类似
# invokestatic,调用静态方法
其中最复杂的要属invokevirtual指令,它涉及多态的特性,凡是Java类中需要在运行期动态绑定的方法调用,都通过invokevirtual指令,该指令将实现方法的分发,因此vtable与该指令之间有莫大的联系,而事实上,在HotSpot执行invokevirtual指令的过程中,最终会读取被调用类的vtable虚函数表,并据此决定真实的目标调用方法。JVM内部实现virtual dispatch机制时,会首先从receiver(被调用方法的对象)的类的实现中查找对应的方法,如果没找到,则去父类查找,直到找到方法并实现调用,而不是依赖于引用的类型。这里看一个例子。
编译这段程序,并使用javap命令分别分析Test.main()函数中的3个方法调用的字节码指令,以及类B.print()方法中调用newFun()和privateFun()时所使用的字节码指令,首先看B类的print()方法调用newFun()和privateFun()方法时的字节码指令,javap分析的结果如下:
public void print();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String B.print()
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #6 // Method newFun:()V
12: aload_0
13: invokespecial #7 // Method privateFun:()V
16: return
LineNumberTable:
line 25: 0
line 26: 8
line 27: 12
line 28: 16
由javap分析的结果可知,在B.print()方法中调用newFun()和privateFun()方法时所使用的字节码指令分别是invokevirtual和invokespecial,为何在类B内部调用自己的这两个方法所使用的指令竟然还有所不同呢?这是因为newFun()是public的,并且没有static和final修饰,因此这个方法是可以被继承的,并且是可以被类重写的。而编译器在编译期间并不知道类B有没有子类,因此这里只能使用invokevirtual指令去调用newFun()方法,从而使newFun()方法支持在运行期进行动态绑定。虽然编译器在编译期间可以分析整个工程以确定类B到底有无子类,但是别忘了JVM可是能够支持在运行期动态创建新的类型(例如,使用cglib)的,编译器根本无法得知在运行期会不会突然冒出个类去继承B并重写类B的newFun()方法。而privateFun()方法则不一样,其为类B的私有方法,就算有子类继承于类B,也无法重写该方法,因此该方法不需要参与动态绑定,在编译期间便能直接确定其调用者,所以其对应的字节码指令是invokespecial.与B.print()方法中调用newFun()方法使用invokevirtual指令同样的道理,在Test类的main()主函数中调用a.print()、b.print()和b.newFun()这3个方法是,所对应的字节码指令也都是invokevirtual,这是因为这3个方法都是可以被子类所重写,,所以编译器在编译期间无法确定其真实的调用方到底是谁,只能通过invokevirtual指令在运行期进行动态绑定
public void print();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String B.print()
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #6 // Method newFun:()V
12: aload_0
13: invokespecial #7 // Method privateFun:()V
16: return
LineNumberTable:
line 25: 0
line 26: 8
line 27: 12
line 28: 16
由javap分析的结果可知,在B.print()方法中调用newFun()和privateFun()方法时所使用的字节码指令分别是invokevirtual和invokespecial,为何在类B内部调用自己的这两个方法所使用的指令竟然还有所不同呢?这是因为newFun()是public的,并且没有static和final修饰,因此这个方法是可以被继承的,并且是可以被类重写的。而编译器在编译期间并不知道类B有没有子类,因此这里只能使用invokevirtual指令去调用newFun()方法,从而使newFun()方法支持在运行期进行动态绑定。虽然编译器在编译期间可以分析整个工程以确定类B到底有无子类,但是别忘了JVM可是能够支持在运行期动态创建新的类型(例如,使用cglib)的,编译器根本无法得知在运行期会不会突然冒出个类去继承B并重写类B的newFun()方法。而privateFun()方法则不一样,其为类B的私有方法,就算有子类继承于类B,也无法重写该方法,因此该方法不需要参与动态绑定,在编译期间便能直接确定其调用者,所以其对应的字节码指令是invokespecial.与B.print()方法中调用newFun()方法使用invokevirtual指令同样的道理,在Test类的main()主函数中调用a.print()、b.print()和b.newFun()这3个方法是,所对应的字节码指令也都是invokevirtual,这是因为这3个方法都是可以被子类所重写,,所以编译器在编译期间无法确定其真实的调用方到底是谁,只能通过invokevirtual指令在运行期进行动态绑定
HSDB查看运行时vtable.
在HotSpot中,Java类在JVM内部所对应的类型是instanceKlassOop(JDK8中的类型名是instanceKlass),vtable便分配在instanceKlass对象实例的内存末尾。instanceKlass对象实例在内存中所占大小是0x1b8字节,换算成十进制是440.根据这个特点,可以使用HSDB获取到Java类所对应的instanceKlass在内存中的首地址,然后加上0x1b8,就得到vtable的内粗泥底质,如此便可以查看这个内存位置上的vtable成员数据。还是以前面的class A作为示例程序,类A中仅包含1个Java方法,因此类A的vtable长度一共是6,另外5个事超类java.lang.Object中的5个方法。使用JDB基于JDK8调试(关闭JDK的指针压缩功能选项),并运行至断点处使程序暂停,然后使用HSDB连接上测试程序,打开HSDB的Tools->Class Browser功能,就能看到类A在JVM内部所对应的instanceKlass对象实例的内存地址,如图所示
在HotSpot中,Java类在JVM内部所对应的类型是instanceKlassOop(JDK8中的类型名是instanceKlass),vtable便分配在instanceKlass对象实例的内存末尾。instanceKlass对象实例在内存中所占大小是0x1b8字节,换算成十进制是440.根据这个特点,可以使用HSDB获取到Java类所对应的instanceKlass在内存中的首地址,然后加上0x1b8,就得到vtable的内粗泥底质,如此便可以查看这个内存位置上的vtable成员数据。还是以前面的class A作为示例程序,类A中仅包含1个Java方法,因此类A的vtable长度一共是6,另外5个事超类java.lang.Object中的5个方法。使用JDB基于JDK8调试(关闭JDK的指针压缩功能选项),并运行至断点处使程序暂停,然后使用HSDB连接上测试程序,打开HSDB的Tools->Class Browser功能,就能看到类A在JVM内部所对应的instanceKlass对象实例的内存地址,如图所示
由图可知,类A在JVM内部所对应的instanceKlass的内存首地址是0x000000001d063138。由于vtable被分配在instanceKlass的末尾位置,因此vtable的内存首地址是:
0x000000001cb43138+0x1b8=0x1CB432F0.这里的0x1b8是instanceKlass对象实例所占的内存空间大小。得到vtable内存地址后,便可以使用HSDB的mem工具来查看这个地址处的内存数据。单击HSDB工具栏上的Windows->Console按钮,打开HSDB的终端控制台,按回车键,然后输入以下命令,就可以查看从vtable内存首地址开始的连续6个双字内容,如图所示
mem 0x1CB432F0 6
0x000000001cb432f0: 0x000000001c741b10
0x000000001cb432f8: 0x000000001c7415e8
0x000000001cb43300: 0x000000001c741740
0x000000001cb43308: 0x000000001c741540
0x000000001cb43310: 0x000000001c741678
0x000000001cb43318: 0x000000001cb43028
在64位平台上,一个指针占8字节,而vtable里的每一个成员元素都是一个指针,,因此这里mem所输出的6行,正好是类A的vtable里的6个方法指针,每一个指针指向1个方法在内存中的位置。类A的vtable总长度是6,其中前面5个是基类java.lang.Object中的5个方法的指针。在HSDB中可以查看Java方法在JVM内部所对应的method对象实例的内存地址,单击HSDB工具栏上的Tools->Class Browser按钮,选择某个类,HSDB会显示这个类中的所有方法及其内存地址。如图所示查看java.lang.Object中的方法的内存地址。
图中标识出了java.lang.Object中5个可被继承的Java方法的内存地址。既然类A的vtable的5个成员指针指向java.lang.Object中的这5个方法,则vtable中的前5个指针的值必定就是java.lang.Object类中这5个方法的内存地址。比较上面mem命令的输出结果与该图中的这5个地址的值,果然发现mem命令所输出结果的前5行与java.lang.Object中的这5个方法的内存地址是一一对应的。这一方名证明vtable数据的确是被分配在instanceKlass对象实例的内存区域的后面,另一方面也说明vtable中所存储的的确是指向方法内存的指针。上面mem命令所输出的第6行的指针,一定就是指向类A自己的方法的内存地址。查看类A的方法的内存地址,比较图中的方法print()的内存地址与上述mem命令所输出结果的第6行数据,会发现两者完全相等
0x000000001cb43138+0x1b8=0x1CB432F0.这里的0x1b8是instanceKlass对象实例所占的内存空间大小。得到vtable内存地址后,便可以使用HSDB的mem工具来查看这个地址处的内存数据。单击HSDB工具栏上的Windows->Console按钮,打开HSDB的终端控制台,按回车键,然后输入以下命令,就可以查看从vtable内存首地址开始的连续6个双字内容,如图所示
mem 0x1CB432F0 6
0x000000001cb432f0: 0x000000001c741b10
0x000000001cb432f8: 0x000000001c7415e8
0x000000001cb43300: 0x000000001c741740
0x000000001cb43308: 0x000000001c741540
0x000000001cb43310: 0x000000001c741678
0x000000001cb43318: 0x000000001cb43028
在64位平台上,一个指针占8字节,而vtable里的每一个成员元素都是一个指针,,因此这里mem所输出的6行,正好是类A的vtable里的6个方法指针,每一个指针指向1个方法在内存中的位置。类A的vtable总长度是6,其中前面5个是基类java.lang.Object中的5个方法的指针。在HSDB中可以查看Java方法在JVM内部所对应的method对象实例的内存地址,单击HSDB工具栏上的Tools->Class Browser按钮,选择某个类,HSDB会显示这个类中的所有方法及其内存地址。如图所示查看java.lang.Object中的方法的内存地址。
图中标识出了java.lang.Object中5个可被继承的Java方法的内存地址。既然类A的vtable的5个成员指针指向java.lang.Object中的这5个方法,则vtable中的前5个指针的值必定就是java.lang.Object类中这5个方法的内存地址。比较上面mem命令的输出结果与该图中的这5个地址的值,果然发现mem命令所输出结果的前5行与java.lang.Object中的这5个方法的内存地址是一一对应的。这一方名证明vtable数据的确是被分配在instanceKlass对象实例的内存区域的后面,另一方面也说明vtable中所存储的的确是指向方法内存的指针。上面mem命令所输出的第6行的指针,一定就是指向类A自己的方法的内存地址。查看类A的方法的内存地址,比较图中的方法print()的内存地址与上述mem命令所输出结果的第6行数据,会发现两者完全相等
miranda方法。
在Java中,有这么一类方法被称为miranda方法,所谓miranda方法,在JVM内部并没有专门的定义,因为它并不是JVM规范里的一部分。根据HotSpot里的文档描述,在早期的虚拟机里有一个bug,那就是JVM在遍历解析Java类的方法时,仅仅会便利Java类及其所有父类的方法,但是并不会去查找Java类所实现的接口interface里的方法,这回导致这样一种结果:如果Java类没有实现接口里的方法,则接口里的方法将不会被虚拟机查到,为了解决这个问题,编译器引入了一个相反的办法,那就是在编译期王Java类中插入接口里所定义的方法,这些方法就是所谓的miranda方法。但是这种解决办法也是有问题的,因为miranda方法并不是Java规范的一部分,所以从某种意义上说,这其实是另一种bug.按照上面所描述的,首先有一个疑问:JVM规定,对于接口里所定义的接口方法,Java类必须全部实现这些接口类,那么哪里还会出现什么bug呢?但是有一种情况允许java类可以不实现接口方法,只需要在类名前面加上abstract修饰符,如下例所示:
MyClass类继承了IA接口,但是并没有实现接口方法test(),这种情况也是能够编译通过的。但是在MyClass的构造函数里,可以调用test()这个接口方法。如果编译器没有miranda机制,则在MyClass类的构造函数里调用test()接口方法时,肯定会编译报错,因为这个方法只存在于接口类中,而不存在MyClass的任何父类中。事实上,miranda方法中的"Miranda"一词是有典故的,这个典故来源于法庭宣判。miranda其实是一个法律术语,即"米兰达原则",简单来说,米兰达原则(Miranda Rule)要求警察告诉被拘捕的犯罪嫌疑人,它可以对警察保持缄默,他有权要求有律师在场。总之,,JVM借鉴了法律上的这一充满人道主义关怀的原则,将其运用于接口类的方法实现中。如果一个Java类无法提供接口类方法的实现,那么编译器将会为其提供一个方法实现,这个方法就叫做miranda方法。这如同法律审判一样,如果一个犯罪嫌疑人没有能力请一名律师辩护,那么法庭将为其提供一个。从这个角度来重新审视程序,会发现程序设计原来和生活则是相同的,创意来源于生活,设计来源于生活,一切都离不开生活。
软件程序使用符号作为程序世界的规则,但是依然存在不遵循规则的现象,在程序的世界里,当出现不遵循规则的现象时,设计者总是倾向于来弥补缺陷,使得程序的世界尽量完美,少一些"悲剧",这何尝不是每一个人所梦想并为之奋斗的社会愿景.
在Java类中掉哟弄个miranda方法时,实现的时动态绑定策略,而非早绑定。还是以上面的MyClass类为例,在其构造函数中调用test()方法时,生成的汇编指令是invokevirtual,如下:
public com.MyClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: invokevirtual #2 // Method test:()V
8: return
在Java中,有这么一类方法被称为miranda方法,所谓miranda方法,在JVM内部并没有专门的定义,因为它并不是JVM规范里的一部分。根据HotSpot里的文档描述,在早期的虚拟机里有一个bug,那就是JVM在遍历解析Java类的方法时,仅仅会便利Java类及其所有父类的方法,但是并不会去查找Java类所实现的接口interface里的方法,这回导致这样一种结果:如果Java类没有实现接口里的方法,则接口里的方法将不会被虚拟机查到,为了解决这个问题,编译器引入了一个相反的办法,那就是在编译期王Java类中插入接口里所定义的方法,这些方法就是所谓的miranda方法。但是这种解决办法也是有问题的,因为miranda方法并不是Java规范的一部分,所以从某种意义上说,这其实是另一种bug.按照上面所描述的,首先有一个疑问:JVM规定,对于接口里所定义的接口方法,Java类必须全部实现这些接口类,那么哪里还会出现什么bug呢?但是有一种情况允许java类可以不实现接口方法,只需要在类名前面加上abstract修饰符,如下例所示:
MyClass类继承了IA接口,但是并没有实现接口方法test(),这种情况也是能够编译通过的。但是在MyClass的构造函数里,可以调用test()这个接口方法。如果编译器没有miranda机制,则在MyClass类的构造函数里调用test()接口方法时,肯定会编译报错,因为这个方法只存在于接口类中,而不存在MyClass的任何父类中。事实上,miranda方法中的"Miranda"一词是有典故的,这个典故来源于法庭宣判。miranda其实是一个法律术语,即"米兰达原则",简单来说,米兰达原则(Miranda Rule)要求警察告诉被拘捕的犯罪嫌疑人,它可以对警察保持缄默,他有权要求有律师在场。总之,,JVM借鉴了法律上的这一充满人道主义关怀的原则,将其运用于接口类的方法实现中。如果一个Java类无法提供接口类方法的实现,那么编译器将会为其提供一个方法实现,这个方法就叫做miranda方法。这如同法律审判一样,如果一个犯罪嫌疑人没有能力请一名律师辩护,那么法庭将为其提供一个。从这个角度来重新审视程序,会发现程序设计原来和生活则是相同的,创意来源于生活,设计来源于生活,一切都离不开生活。
软件程序使用符号作为程序世界的规则,但是依然存在不遵循规则的现象,在程序的世界里,当出现不遵循规则的现象时,设计者总是倾向于来弥补缺陷,使得程序的世界尽量完美,少一些"悲剧",这何尝不是每一个人所梦想并为之奋斗的社会愿景.
在Java类中掉哟弄个miranda方法时,实现的时动态绑定策略,而非早绑定。还是以上面的MyClass类为例,在其构造函数中调用test()方法时,生成的汇编指令是invokevirtual,如下:
public com.MyClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: invokevirtual #2 // Method test:()V
8: return
既然miranda方法被视为"晚绑定"机制,那按照上面所讲的vtable的原理,miranda方法将会被加入到vtable中,而HotSpot内部的确也是这么处理的。在classFileParser::parseClassFile()函数中调用klassVtable.cpp::compute_vtable_size_and_num_mirandas()函数计算vtable长度时,其实已经包含了对miranda方法的处理,如图所示。HotSpot先计算当前类的所有miranda方法的总数,并将其增加到vtable的长度变量里。而miranda方法的总数的计算逻辑就是搜索当前类所实现的所有接口类,以及各个接口所继承的父类接口类中的方法(别忘了接口类是可以继承接口类的),如果这些接口类方法并没有在当前类中实现,则会被当作miranda方法。
在klassVtable.cpp::compute_vtable_size_and_num_miranda()函数中调用klassVtable::get_num_mirandas()函数,而后者调用klassVtable:;get_mirandas()函数完成所有miranda方法的搜索和判断。在该函数中分别对当前Java类的全部接口类进行遍历,而在遍历每一个接口类时,又对该接口类所继承的父类接口类进行遍历,如此一层一层往上搜索全部miranda方法。
在klassVtable.cpp::compute_vtable_size_and_num_miranda()函数中调用klassVtable::get_num_mirandas()函数,而后者调用klassVtable:;get_mirandas()函数完成所有miranda方法的搜索和判断。在该函数中分别对当前Java类的全部接口类进行遍历,而在遍历每一个接口类时,又对该接口类所继承的父类接口类进行遍历,如此一层一层往上搜索全部miranda方法。
miranda方法其实并不是JVM的一个标准规范,对于接口方法,Java类中还会保存一种内部结构——itable,顾名思义,接口接口方法表。itable主要用于接口类方法的分发,其机制与vtable类似,都会在运行期进行方法的动态绑定
vtable特点总结。
前面对vtable进行了比较全名的研究和验证,这里再次总结下其特点:
# vtable分配在instanceKlassOop对象实例的内存末尾
# 所谓vtable,可以看作是一个数组,数组中的每一项成员元素都是一个指针,指针指向Java方法在JVM内部所对应的method实例对象的内存首地址
# vtable是Java实现面向对象的多态性的机制,如果一个Java方法可以被继承和重写,则最终通过invokevirtual字节码指令完成Java方法的动态绑定和分发。事实上,很多面向对象的语言都基于vtable机制去实现多态性,例如C++
# Java子类会继承父类的vtable
# Java中所有类都继承自java.lang.Object,java.lang.Object中有5个虚方法(可被继承和重写)
void finalize()
boolean equals(Object)
String toString()
int hashCode()
Object clone()
因此,如果一个Java类中不声明任何方法,则其vtable的长度默认为5
# Java类中不是每一个Java方法的内存地址都会保存到vtable表中。只有当Java子类中声明的Java方法是public或者protected的,且没有final、static修饰,并且Java子类中的方法并非对父类方法的重写时,JVM才会在vtable表中为该方法增加一个引用
# 如果Java子类某个方法重写了父类方法,则子类的vtable中原本对父类方法的指针引用会被替换为对子类的方法引用
前面对vtable进行了比较全名的研究和验证,这里再次总结下其特点:
# vtable分配在instanceKlassOop对象实例的内存末尾
# 所谓vtable,可以看作是一个数组,数组中的每一项成员元素都是一个指针,指针指向Java方法在JVM内部所对应的method实例对象的内存首地址
# vtable是Java实现面向对象的多态性的机制,如果一个Java方法可以被继承和重写,则最终通过invokevirtual字节码指令完成Java方法的动态绑定和分发。事实上,很多面向对象的语言都基于vtable机制去实现多态性,例如C++
# Java子类会继承父类的vtable
# Java中所有类都继承自java.lang.Object,java.lang.Object中有5个虚方法(可被继承和重写)
void finalize()
boolean equals(Object)
String toString()
int hashCode()
Object clone()
因此,如果一个Java类中不声明任何方法,则其vtable的长度默认为5
# Java类中不是每一个Java方法的内存地址都会保存到vtable表中。只有当Java子类中声明的Java方法是public或者protected的,且没有final、static修饰,并且Java子类中的方法并非对父类方法的重写时,JVM才会在vtable表中为该方法增加一个引用
# 如果Java子类某个方法重写了父类方法,则子类的vtable中原本对父类方法的指针引用会被替换为对子类的方法引用
vtable机制逻辑验证。
前面izhi在说Jaava中实现多态是通过vtable这个机制,但是vtable到底是如何实现多态机制的呢?这在JVM内部颇为复杂,这里先对其进行逻辑上的推理,其实多态实现的机制也很简单,就是通过vtable。举个例子:如图所示。
在本示例中,有父类Animal和子类Dog,子类Dog重写了父类的say()方法。在main()主函数中,将animal引用分别指向2个不同的示例,并调用run(Animal)方法。运行这个而程序,结果也如预测的那样,如下:
I'm a dog
I'm animal
前面izhi在说Jaava中实现多态是通过vtable这个机制,但是vtable到底是如何实现多态机制的呢?这在JVM内部颇为复杂,这里先对其进行逻辑上的推理,其实多态实现的机制也很简单,就是通过vtable。举个例子:如图所示。
在本示例中,有父类Animal和子类Dog,子类Dog重写了父类的say()方法。在main()主函数中,将animal引用分别指向2个不同的示例,并调用run(Animal)方法。运行这个而程序,结果也如预测的那样,如下:
I'm a dog
I'm animal
这样的输出结果充分演绎了面向对象所具有的多态性,而多态得以实现的奥秘,其实就隐藏在vtable中。根据前面所说的vtable的构成原理,类Animal的vtable的长度应该为6,除了所继承的java.lang.Object中的5个虚方法(即可被重写的方法)外,其自身仅包含1个虚方法。并且其vtable中的第6个指针元素指向say()方法在JVM内部所对应的method示例对象的内存地址。同理,子类Dog的vtable的长度也应该等于6,因为前面分析过,子类会完全继承父类的vtable,并且如果子类重写了父类的方法,则JVM会将子类vtable中原本指向父类方法的指针成员修改成重新指向子类的方法。类Animal和子类Dog的vtable结构分别如图所示。
在JVM运行期,会根据对象引用所执行的实际的实例调用实例的方法。不过这首先得从编译器说起。上面示例中Animal.run(Animal)方法所对应的字节码指令如下(使用javap命令显示)
public static void run(com.Animal);
descriptor: (Lcom/Animal;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #10 // Method say:()V
4: return
Animal.run(Animal)方法的字节码指令主要的逻辑包含两步,第一步是aload_0,第二步是invokevirtual。字节码指令aload_0标识从第0个slot位置加载Java引用对象。由于Animal.run(Animal)方法是一个static静态方法,其入参并没有隐式的this指针,所以slot中的第一个局部变量就是Animal.run(Animal)的第一个入参animal引用对象。
在JVM运行期,会根据对象引用所执行的实际的实例调用实例的方法。不过这首先得从编译器说起。上面示例中Animal.run(Animal)方法所对应的字节码指令如下(使用javap命令显示)
public static void run(com.Animal);
descriptor: (Lcom/Animal;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #10 // Method say:()V
4: return
Animal.run(Animal)方法的字节码指令主要的逻辑包含两步,第一步是aload_0,第二步是invokevirtual。字节码指令aload_0标识从第0个slot位置加载Java引用对象。由于Animal.run(Animal)方法是一个static静态方法,其入参并没有隐式的this指针,所以slot中的第一个局部变量就是Animal.run(Animal)的第一个入参animal引用对象。
接着第二步执行invokevirtual指令,Java多态的秘密就隐藏在该指令后面所跟的操作数operand中。invokevirtual指令后面的操作数是常量池的索引值,该值为10,javap命令显示索引为10的常量池所代表的字符串是Method say:()V,这表示invokevirtual在运行期调用的方法是void say().在运行期,JVM将首先确定被调用的方法所属的Java类实例对象,JVM会读取被调用的方法的堆栈,并获取堆栈中的局部变量表的第0个slot位置的数据,该数据一定是指向被调用的方法所属的Java类实例,原因很简单,凡是所对应的字节码指令为invokevirtual的Java方法,其必定是Java类的成员方法,而非静态方法,而Java类的成员方法的第一个入参一定是隐式的this指针,该指针就指向Java类的对象实例。同时,Java类的第一个入参一定位于局部变量表的第0个位置,因此JVM可以从被invokevirtual所调用的方法的局部变量表中读取到this指针,从而得知被调用的Java类实例到底是哪一个。
拿本示例来说,当animal引用变量指向new Dog()对象实例时,则say()方法的局部变量表第一个入参便指向new Dog()这个对象实例。同理,当animal引用变量指向new Animal()对象实例时,则say()方法的局部变量表的第一个入参便指向new Animal()实例对象。
JVM获取到被invokevirtual指令所调用的方法所属的实际的类对象时,接着便能够通过对象获取到其对应的vtable方法分发表。vtable表中保存当前类的每一个方法的指针。JVM会遍历vtable中的每一个指针成员,并根据指针读取到其对应的method对象,判断invokevirtual指令所调用的方法名称和签名与vtable表中指针所指向的方法的名称和签名是否一致,如果方法名称和签名完全一致,则算是找到了invokevirtual所实际调用的目标方法,于是JVM定位到目标方法的第一条字节码指令并开始执行。如此便完成了方法在运行期的动态分发和执行。
还是以本示例说明,当animal引用变量指向new Dog()对象实例时,JVM会遍历Dog类所对应的vtable表,并搜索其中名称为"say"且签名为void()方法的,很显然,Dog类中存在该方法,于是JVM最终执行Dog类的say()方法。同理,当animal引用变量指向new Animal()对象实例时,JVM最终执行的就是Animal类中的say(0方法。事实上,C++的虚方法分发的原理与此完全类似,所不同的是C++的vtable由编译器在编译期间完成构建,而Java类的vtable则由JVM在运行期进行构建
拿本示例来说,当animal引用变量指向new Dog()对象实例时,则say()方法的局部变量表第一个入参便指向new Dog()这个对象实例。同理,当animal引用变量指向new Animal()对象实例时,则say()方法的局部变量表的第一个入参便指向new Animal()实例对象。
JVM获取到被invokevirtual指令所调用的方法所属的实际的类对象时,接着便能够通过对象获取到其对应的vtable方法分发表。vtable表中保存当前类的每一个方法的指针。JVM会遍历vtable中的每一个指针成员,并根据指针读取到其对应的method对象,判断invokevirtual指令所调用的方法名称和签名与vtable表中指针所指向的方法的名称和签名是否一致,如果方法名称和签名完全一致,则算是找到了invokevirtual所实际调用的目标方法,于是JVM定位到目标方法的第一条字节码指令并开始执行。如此便完成了方法在运行期的动态分发和执行。
还是以本示例说明,当animal引用变量指向new Dog()对象实例时,JVM会遍历Dog类所对应的vtable表,并搜索其中名称为"say"且签名为void()方法的,很显然,Dog类中存在该方法,于是JVM最终执行Dog类的say()方法。同理,当animal引用变量指向new Animal()对象实例时,JVM最终执行的就是Animal类中的say(0方法。事实上,C++的虚方法分发的原理与此完全类似,所不同的是C++的vtable由编译器在编译期间完成构建,而Java类的vtable则由JVM在运行期进行构建
执行引擎
可以这么说,在JVM内部,最精华的部分便是执行引擎(当然,GC也绝对是精华)。几乎每一款JVM都在执行引擎上面下足了功夫,从最原始抵消的字节码解释器,到模板解释器,再到JIT即时编译期,,许多大神提出了各种优化策略和理论来提升Java程序的执行速度。有些公司出品的JVM直接将Java程序翻译成本地机器码,而在安卓体系中,也使用了AOT技术来提升运行时效率。
JVM的执行引擎本身是一个相当复杂深奥的模型,对于从来没有涉及过底层的广大程序员而言,向理解它十分苦难。这里力图使用比较浅显的语言来将JVM执行引擎实现的技术细节清楚地描述出来,使广大道友不仅仅局限于理论研究,而是能够真正一窥其具体的技术内幕,不仅能够闻其道,而且能够知其术,做到知行合一。往往越是高深高妙的理论,如果仅仅研究理论,往往容易让人迷糊,而配合着源码看,便能真正理解这些深奥的理论。在描述Java执行引擎时,将试图对照物理及其CPU的执行机制,例如指令集、取指机制、程序计数器登,从物理及其CPU执行的角度看Java的执行引擎,通过这样的对比,希望能够让各位道友不要陷入JVM那复杂深奥的引擎模型里面,而是能够站在一个较高的角度看待问题。
JVM执行引擎毕竟只是虚拟系统,本身并不具备真正的运算能力,其内部其实仍然需要依靠物理CPU才能完成运算功能,而物理CPU仅识别二进制机器指令,JVM执行引擎既然需要依赖物理CPU,就必然需要将字节码指令最终转换为二进制机器指令,因此后面在分析JVM执行引擎的过程中,将不可避免地涉及汇编语言。重点分析JVM内部的具体技术实现,而非主要讲理论。
JVM的执行引擎本身是一个相当复杂深奥的模型,对于从来没有涉及过底层的广大程序员而言,向理解它十分苦难。这里力图使用比较浅显的语言来将JVM执行引擎实现的技术细节清楚地描述出来,使广大道友不仅仅局限于理论研究,而是能够真正一窥其具体的技术内幕,不仅能够闻其道,而且能够知其术,做到知行合一。往往越是高深高妙的理论,如果仅仅研究理论,往往容易让人迷糊,而配合着源码看,便能真正理解这些深奥的理论。在描述Java执行引擎时,将试图对照物理及其CPU的执行机制,例如指令集、取指机制、程序计数器登,从物理及其CPU执行的角度看Java的执行引擎,通过这样的对比,希望能够让各位道友不要陷入JVM那复杂深奥的引擎模型里面,而是能够站在一个较高的角度看待问题。
JVM执行引擎毕竟只是虚拟系统,本身并不具备真正的运算能力,其内部其实仍然需要依靠物理CPU才能完成运算功能,而物理CPU仅识别二进制机器指令,JVM执行引擎既然需要依赖物理CPU,就必然需要将字节码指令最终转换为二进制机器指令,因此后面在分析JVM执行引擎的过程中,将不可避免地涉及汇编语言。重点分析JVM内部的具体技术实现,而非主要讲理论。
执行引擎概述。
所谓执行引擎,就是一个运算器,能够识别所输入的指令,并根据输入的指令执行一套特定的逻辑,最终输出特定的结果。其实,相比于JVM这类虚拟机而言,物理实体机器也是有其特定的执行引擎的,物理机器的执行引擎便是CPU(中央计算单元)。CPU能够识别机器指令,并根据机器指令完成特定的运算。
物理CPU执行指令的流程是这样的:
#1.取指。CPU的控制器从内存读取一条指令并放入指令寄存器。物理机器指令一般由操作码和操作数组成,当然并不是所有的操作码都会有操作数。例如mov ax 1这条机器指令,其中mov ax就是操作码,而1就是操作数,在Intel处理器上,这条指令所对应的十六进制数是0xB8 01。
#2.译码。指令寄存器中的指令经过译码,确定该指令应进行何种操作(由操作码决定),操作数在哪里(由操作数决定)
#3.执行。分两个阶段,"取操作数"和"进行运算"
#4.取下一条指令。修正指令计数器(亦称程序计数器),计算下一条指令的地址,并重新进入取指、译码和执行的循环
只要操作系统一启动,CPU便会一直循环往复地执行上述流程,当机器啥事也不干的时候,会进入"空转"的状态,但是并不会停止。这种机制说白了与汽车发动机一样,只要汽车一启动,发动机就会一直转下去,没挂党委的时候也会保持空转状态,如果发动机不转了汽车就熄火了。类似的机制还有恩多。例如视窗系统会有一个静默线程一直保持无限的while循环,当收到外部消息(例如鼠标点击)时就对消息进行处理,如果一直没有收到消息就一直循环下去,以此来确保视窗程序一直运行下去。Web服务器也是一样,会有一个线程一直保持循环,如果接收到客户端请求就启动新的线程/进程处理。
JVM既然作为虚拟机,自然也得有这么一套虚拟的CPU执行机制,按照"取指->译码->执行->取下一条指令"这一流程循环往复地执行下去。不过JVM不像真正的操作系统那样,当没事可干的时候让CPU保持空转,如果JVM所运行的Java程序执行完了,Java程序的声明周期会终止,而JVM虚拟机本身也会退出。所以,如果想让JVM一直保持空转,只能在Java程序里的某个线程中一直保持空循环。Tomcat这款Web应用服务器程序便是这种机制,否则一旦没有外部http请求过来,Tomcat程序及其宿主JVM虚拟机都会寿终正寝。类似的,Hadoop、Spark之类的分布式系统亦都有类似的机制。正是因为JVM没有空转机制,因此JVM一旦启动,处理完自身的初始化逻辑,便会进入Java程序,执行Java的字节码指令。前面讲过,JVM进入Java程序之前,会先确定Java程序的main()函数及其所在的类,加载Java主类并执行main()主函数,在JVM调用Java的main()主函数的链路上,会经过CallStub例程和zerolocals例程,在zerolocals例程中,JVM会为Java main()主函数创建栈帧,创建完栈帧,最终JVM会调用如下逻辑
// templateInterpreter_x86_32.cpp
// JVM调用Java字节码指令
address InterpreterGenerator::generate_normal_entry(bool synchronized) {
// ...
address entry_point = __ pc();
// ....
// 创建栈帧
generate_fixed_frame(false);
// ....
// 跳转到目标Java方法的第一条字节码指令,并执行其对应的机器指令
__ dispatch_next(vtos);
// ...
return entry_point;
}
所谓执行引擎,就是一个运算器,能够识别所输入的指令,并根据输入的指令执行一套特定的逻辑,最终输出特定的结果。其实,相比于JVM这类虚拟机而言,物理实体机器也是有其特定的执行引擎的,物理机器的执行引擎便是CPU(中央计算单元)。CPU能够识别机器指令,并根据机器指令完成特定的运算。
物理CPU执行指令的流程是这样的:
#1.取指。CPU的控制器从内存读取一条指令并放入指令寄存器。物理机器指令一般由操作码和操作数组成,当然并不是所有的操作码都会有操作数。例如mov ax 1这条机器指令,其中mov ax就是操作码,而1就是操作数,在Intel处理器上,这条指令所对应的十六进制数是0xB8 01。
#2.译码。指令寄存器中的指令经过译码,确定该指令应进行何种操作(由操作码决定),操作数在哪里(由操作数决定)
#3.执行。分两个阶段,"取操作数"和"进行运算"
#4.取下一条指令。修正指令计数器(亦称程序计数器),计算下一条指令的地址,并重新进入取指、译码和执行的循环
只要操作系统一启动,CPU便会一直循环往复地执行上述流程,当机器啥事也不干的时候,会进入"空转"的状态,但是并不会停止。这种机制说白了与汽车发动机一样,只要汽车一启动,发动机就会一直转下去,没挂党委的时候也会保持空转状态,如果发动机不转了汽车就熄火了。类似的机制还有恩多。例如视窗系统会有一个静默线程一直保持无限的while循环,当收到外部消息(例如鼠标点击)时就对消息进行处理,如果一直没有收到消息就一直循环下去,以此来确保视窗程序一直运行下去。Web服务器也是一样,会有一个线程一直保持循环,如果接收到客户端请求就启动新的线程/进程处理。
JVM既然作为虚拟机,自然也得有这么一套虚拟的CPU执行机制,按照"取指->译码->执行->取下一条指令"这一流程循环往复地执行下去。不过JVM不像真正的操作系统那样,当没事可干的时候让CPU保持空转,如果JVM所运行的Java程序执行完了,Java程序的声明周期会终止,而JVM虚拟机本身也会退出。所以,如果想让JVM一直保持空转,只能在Java程序里的某个线程中一直保持空循环。Tomcat这款Web应用服务器程序便是这种机制,否则一旦没有外部http请求过来,Tomcat程序及其宿主JVM虚拟机都会寿终正寝。类似的,Hadoop、Spark之类的分布式系统亦都有类似的机制。正是因为JVM没有空转机制,因此JVM一旦启动,处理完自身的初始化逻辑,便会进入Java程序,执行Java的字节码指令。前面讲过,JVM进入Java程序之前,会先确定Java程序的main()函数及其所在的类,加载Java主类并执行main()主函数,在JVM调用Java的main()主函数的链路上,会经过CallStub例程和zerolocals例程,在zerolocals例程中,JVM会为Java main()主函数创建栈帧,创建完栈帧,最终JVM会调用如下逻辑
// templateInterpreter_x86_32.cpp
// JVM调用Java字节码指令
address InterpreterGenerator::generate_normal_entry(bool synchronized) {
// ...
address entry_point = __ pc();
// ....
// 创建栈帧
generate_fixed_frame(false);
// ....
// 跳转到目标Java方法的第一条字节码指令,并执行其对应的机器指令
__ dispatch_next(vtos);
// ...
return entry_point;
}
InterpreterGenerator::generate_normal_entry(bool synchronized)函数在前面分析过Java方法的栈帧时,,已经分析了其一部分逻辑,其中详细地分析了这个函数中的generate_fixed_frame()函数,Java方法栈帧的创建便是通过该函数实现的。当JVM调用Java主函数main()时,便是为main()主函数创建栈帧,创建完栈帧,接着又有一系列逻辑处理(例如,方法校验、调用技术等)最后会执行这个函数中的__ dispatch_next(vtos)函数(准确地说,函数前面的__是一个宏)。从这个函数开始,JVM将读取到Java主函数的第一条字节码指令,并执行第一条字节码指令所对应的机器指令,并由此进入轰轰烈烈的Java程序的世界中去,__dispatch_next(vtos)函数是一个平台相关的函数,在32位x86平台上,其对应的实现如下:
void InterpreterMacroAssembler::dispatch_next(TosState state, int step) {
// load next bytecode (load before advancing rsi to prevent AGI)
load_unsigned_byte(rbx, Address(rsi, step));
// advance rsi
increment(rsi, step);
dispatch_base(state, Interpreter::dispatch_table(state));
}
其实,dispatch_next()函数是JVM内部非常核心的一个函数,该函数的主要功能就是进行"取指".JVM虚拟机与真实的物理机器执行指令的流程完全一样,都是循环往复地执行"取指->译码->取指"的过程。
void InterpreterMacroAssembler::dispatch_next(TosState state, int step) {
// load next bytecode (load before advancing rsi to prevent AGI)
load_unsigned_byte(rbx, Address(rsi, step));
// advance rsi
increment(rsi, step);
dispatch_base(state, Interpreter::dispatch_table(state));
}
其实,dispatch_next()函数是JVM内部非常核心的一个函数,该函数的主要功能就是进行"取指".JVM虚拟机与真实的物理机器执行指令的流程完全一样,都是循环往复地执行"取指->译码->取指"的过程。
取指。
在研究JVM的"取指"机制之前,先了解下物理机器级别的取指方式。对于直接运行在物理机器上的软件程序,其经过编译后直接形成二进制的物理机器指令编码。当操作系统加载这个软件程序时,会在内存中为该程序创建如图所示的数据。在一个基于段式内存管理的架构中,当操作系统将程序加载进内存之后,会将程序编译后的二进制代码指令存储到一个专门的区域——代码段.操作系统执行程序的过程,就是将代码段中的指令取出来逐个执行的过程。另外操作系统会将程序中的静态字段存储到数据段中,并为程序初始化堆栈空间。这部分内容超出了范围,
操作系统会将软件程序的二进制机器码指令全部读进代码段中,当操作系统开始执行该软件程序时,CPU便会读取代码段中的第一条机器指令,然后进入译码->执行->取下一条指令->译码->执行的循环,直到执行完该程序。
CPU在取指时,先从程序的代码段中读取出操作码,在译码阶段,译码逻辑会判断该操作码,并从代码段中读取其所对应操作数。例如,假设软件程序的二进制机器指令中有一条指令是mov ax,3,则mov ax是一个操作码,其操作数是3.在取指时,CPU首先读入mov ax这条操作码,译码器识别出该指令后面会跟着一个32位的数字(假设在32位x86平台上),于是译码器会接着从代码段内存区域读取跟随在mov ax这条指令后面的操作数3,如此完成整条指令的读取,接着交给运算器执行。当然,事实上CPU取指、译码和执行的机制时非常复杂的。这里将其简化,只是为了说明CPU的工作原理。
在研究JVM的"取指"机制之前,先了解下物理机器级别的取指方式。对于直接运行在物理机器上的软件程序,其经过编译后直接形成二进制的物理机器指令编码。当操作系统加载这个软件程序时,会在内存中为该程序创建如图所示的数据。在一个基于段式内存管理的架构中,当操作系统将程序加载进内存之后,会将程序编译后的二进制代码指令存储到一个专门的区域——代码段.操作系统执行程序的过程,就是将代码段中的指令取出来逐个执行的过程。另外操作系统会将程序中的静态字段存储到数据段中,并为程序初始化堆栈空间。这部分内容超出了范围,
操作系统会将软件程序的二进制机器码指令全部读进代码段中,当操作系统开始执行该软件程序时,CPU便会读取代码段中的第一条机器指令,然后进入译码->执行->取下一条指令->译码->执行的循环,直到执行完该程序。
CPU在取指时,先从程序的代码段中读取出操作码,在译码阶段,译码逻辑会判断该操作码,并从代码段中读取其所对应操作数。例如,假设软件程序的二进制机器指令中有一条指令是mov ax,3,则mov ax是一个操作码,其操作数是3.在取指时,CPU首先读入mov ax这条操作码,译码器识别出该指令后面会跟着一个32位的数字(假设在32位x86平台上),于是译码器会接着从代码段内存区域读取跟随在mov ax这条指令后面的操作数3,如此完成整条指令的读取,接着交给运算器执行。当然,事实上CPU取指、译码和执行的机制时非常复杂的。这里将其简化,只是为了说明CPU的工作原理。
通过这个物理机器取指和译码的过程可知,物理机器要完成软件程序的运行,得具备2个条件:
# 内存中要存储软件程序编译后的指令
# CPU要能够识别出代码段中的指令和操作数
对于第一点,操作系统运行软件之前,会将其源代码所对应的机器指令全部读进内存区。Java程序启动后,JVM也会将Java源代码所对应的字节码指令全部读进JVM内存中。而对于第二点,在我们的想象中,物理机器内部似乎要维护一张大而全的指令集表,每次CPU读入一个机器指令时,去扫描这张指令表,从而识别出识别出所读取到的机器码,并进一步判断该操作码后面是否有操作数。而事实上,物理机器内部这张所谓的指令集表并非仅仅是内存中的数据那么简单,物理机器"内置"的指令集其实是硬件结构,更具体地说是数字电路.这些数字电路被集成进CPU内部,只要向CPU传递一个指令(0和1的组合),CPU就会一句其预先设定好的电路进行解码(高低电平),然后操作对应的寄存器或者某些电路去读取该指令操作码后面的操作数。同时,另一些电路则会触发读取当前机器指令的下一条指令,如此一来,CPU便能完成"取指->译码->执行->继续取指"的循环了,这便是CPU识别并执行机器指令的原理了。
在这一点上,JVM基本也继承了这一思想(事实上想不继承都难,除非能够提供出一种完全异于冯·诺伊曼体系结构的数字计算机)。JVM作为一款虚拟机,有其自己的一套指令集,,这套指令集必定能够被JVM的虚拟运算器所识别,但是JVM并没有真正的硬件译码电路来识别JVM的这套指令,因此只能使用软件模拟。这种软件模拟的结果就是JVM需要使用软件的方式在内存中维护一套指令集,否则JVM无法识别Java方法所对应的指令操作码。这套指令集包含在下面的代码文件中,如图所示。
这个枚举中定义了JVM的全部指令集,比如熟悉的iload、iconst_0之类的,都包含在内。由于这些指令操作码定义在C++的枚举类中,因此在操作系统加载JVM时便会将这些指令集读进内存之中,这便是Java虚拟机的执行引擎赖以运行的基础。在JVM运行期。Java字节码的译码系统完全依赖于这套"软指令集"
# 内存中要存储软件程序编译后的指令
# CPU要能够识别出代码段中的指令和操作数
对于第一点,操作系统运行软件之前,会将其源代码所对应的机器指令全部读进内存区。Java程序启动后,JVM也会将Java源代码所对应的字节码指令全部读进JVM内存中。而对于第二点,在我们的想象中,物理机器内部似乎要维护一张大而全的指令集表,每次CPU读入一个机器指令时,去扫描这张指令表,从而识别出识别出所读取到的机器码,并进一步判断该操作码后面是否有操作数。而事实上,物理机器内部这张所谓的指令集表并非仅仅是内存中的数据那么简单,物理机器"内置"的指令集其实是硬件结构,更具体地说是数字电路.这些数字电路被集成进CPU内部,只要向CPU传递一个指令(0和1的组合),CPU就会一句其预先设定好的电路进行解码(高低电平),然后操作对应的寄存器或者某些电路去读取该指令操作码后面的操作数。同时,另一些电路则会触发读取当前机器指令的下一条指令,如此一来,CPU便能完成"取指->译码->执行->继续取指"的循环了,这便是CPU识别并执行机器指令的原理了。
在这一点上,JVM基本也继承了这一思想(事实上想不继承都难,除非能够提供出一种完全异于冯·诺伊曼体系结构的数字计算机)。JVM作为一款虚拟机,有其自己的一套指令集,,这套指令集必定能够被JVM的虚拟运算器所识别,但是JVM并没有真正的硬件译码电路来识别JVM的这套指令,因此只能使用软件模拟。这种软件模拟的结果就是JVM需要使用软件的方式在内存中维护一套指令集,否则JVM无法识别Java方法所对应的指令操作码。这套指令集包含在下面的代码文件中,如图所示。
这个枚举中定义了JVM的全部指令集,比如熟悉的iload、iconst_0之类的,都包含在内。由于这些指令操作码定义在C++的枚举类中,因此在操作系统加载JVM时便会将这些指令集读进内存之中,这便是Java虚拟机的执行引擎赖以运行的基础。在JVM运行期。Java字节码的译码系统完全依赖于这套"软指令集"
指令长度。
既然JVM内部定义了这么一套指令集,是否就能完成执行引擎的功能呢?很显然,答案时不能的。仅有这么一套指令集,JVM无法据此执行字节码指令。别说执行字节码指令,便连取指的功能都完成不了,为何?物理机器CPU在读取机器指令时,会先读取指令中的操作码,CPU会识别出操作码并据此判断操作码后面是否跟随操作数。CPU只有知道一个操作码后面是否跟随操作数,以及所跟随的操作数的大小,才能计算出下一条的位置,从而完成"取指"的功能。举个例子,例如一个C程序编译后包含下面3条机器指令:
mov ax, 1
mov ax, 2
mov ax, 3
这3条指令所对应的十六进制机器码如下:
0xB8 01
0xB8 02
0xB8 03
当CPU执行到第一条指令时,先读取0xB8这个操作码(这个操作码标识mov ax),CPU的译码电路"翻译"出这个操作码的位置,第二条指令操作码的位置相对于第一条指令操作码的位置再往前移动32位,这是因为程序的机器码再内存中是连续存储的,于是CPU便驱动其内部的相关电路跑去内存中读出mov ax后面所跟随的操作数。同时,当CPU执行完第一条指令后,便能接着取下一条指令中的操作码,完成继续取指。
需要注意的是,对于计算机而言,无论操作码还是操作数,在内存中都只是一串0和1的组合而已(准确地说是一串高低电平),因此如果直接将一个数组交给CPU,CPU是无法知道这个数字所代表的到底是操作码还是操作数,例如mov ax这个操作码所对应的十六进制编码是0xB8(Intel CPU),但是可能某个操作码后面所跟随的操作数也是0xB8,因此如果直接将0xB8交给CPU,CPU是无法区分的。所以,当CPU在执行一段程序时,一定时先读取程序中的第一条指令的操作码并进行译码,计算该操作码后面是否跟随操作数以及操作数的数据宽度,,如此CPU才能计算出下一条指令的起始位置并读取下一条指令中的操作码,然后继续译码,继续计算下一条指令的起始位置。。。。如此循环往复,这便是"取指"的关键所在。所以CPU要能够正确完成取指,不仅仅需要识别出操作码本身,还得知道操作码后面所跟随的操作数。由于JVM完全继承了这一设计思想,因此也必须规定出每一条字节码指令后面所跟随的操作数及操作数的大小。。很显然,上面再bytecodes.hpp中所定义的一套指令集并无这种规范,所以JVM必然再别的地方定义了这种规范,如图所示
既然JVM内部定义了这么一套指令集,是否就能完成执行引擎的功能呢?很显然,答案时不能的。仅有这么一套指令集,JVM无法据此执行字节码指令。别说执行字节码指令,便连取指的功能都完成不了,为何?物理机器CPU在读取机器指令时,会先读取指令中的操作码,CPU会识别出操作码并据此判断操作码后面是否跟随操作数。CPU只有知道一个操作码后面是否跟随操作数,以及所跟随的操作数的大小,才能计算出下一条的位置,从而完成"取指"的功能。举个例子,例如一个C程序编译后包含下面3条机器指令:
mov ax, 1
mov ax, 2
mov ax, 3
这3条指令所对应的十六进制机器码如下:
0xB8 01
0xB8 02
0xB8 03
当CPU执行到第一条指令时,先读取0xB8这个操作码(这个操作码标识mov ax),CPU的译码电路"翻译"出这个操作码的位置,第二条指令操作码的位置相对于第一条指令操作码的位置再往前移动32位,这是因为程序的机器码再内存中是连续存储的,于是CPU便驱动其内部的相关电路跑去内存中读出mov ax后面所跟随的操作数。同时,当CPU执行完第一条指令后,便能接着取下一条指令中的操作码,完成继续取指。
需要注意的是,对于计算机而言,无论操作码还是操作数,在内存中都只是一串0和1的组合而已(准确地说是一串高低电平),因此如果直接将一个数组交给CPU,CPU是无法知道这个数字所代表的到底是操作码还是操作数,例如mov ax这个操作码所对应的十六进制编码是0xB8(Intel CPU),但是可能某个操作码后面所跟随的操作数也是0xB8,因此如果直接将0xB8交给CPU,CPU是无法区分的。所以,当CPU在执行一段程序时,一定时先读取程序中的第一条指令的操作码并进行译码,计算该操作码后面是否跟随操作数以及操作数的数据宽度,,如此CPU才能计算出下一条指令的起始位置并读取下一条指令中的操作码,然后继续译码,继续计算下一条指令的起始位置。。。。如此循环往复,这便是"取指"的关键所在。所以CPU要能够正确完成取指,不仅仅需要识别出操作码本身,还得知道操作码后面所跟随的操作数。由于JVM完全继承了这一设计思想,因此也必须规定出每一条字节码指令后面所跟随的操作数及操作数的大小。。很显然,上面再bytecodes.hpp中所定义的一套指令集并无这种规范,所以JVM必然再别的地方定义了这种规范,如图所示
在该初始化函数中,JVM定义了每个bytecode的名字(字符串)、format、字节码的返回结果result类型等。可是纵观这张表,并没有哪里明确指出每个字节码指令后面是否跟随操作数及操作数的宽度。其实秘密就藏在format这一列中,这一列记录了每一个字节码指令的总长度。例如_iconst_0这个字节码的format是b,则标识该字节码指令,包括操作码和操作数,其总长度一共只有1,由此可以推测,该字节码指令是没有操作数的。。这里所谓的"长度为1",是指1字节,因为Java的每个字节码指令都仅占1字节,这也是为何Java字节码指令数量少于256个的原因。
再如bipush这条指令对应的format="bc",则标识bipush这个操作码后面会跟一个宽度为1字节的操作数。同理,sipush对应的format="bcc",则表示sipush指令后面会跟一个宽度为2字节的操作数。这很好理解,因为bipush指令表示将一个1字节的数据推送至操作数栈顶,因此该指令后面的操作数仅占1字节,而sipush表示将一个short类型的操作数推送至栈顶,而一个short类型的数据占2字节。既然推送一个short类型的数据至栈顶的字节码指令是sipush,那么推送一个int类型的数据至栈顶的字节码指令是什么呢?直接看上面的字节码指令表是看不出来的,不过可以通过试验来验证:
从分析结果可以看出,编译器使用iconst_3这条字节码指令将自然数3推送至栈顶。在刚才Bytecodes::initialize()函数中所定义的字节码指令格式表中找到iconst_3,可以看到其format="b"。这说明iconst_3指令的总长度为1,同时说明该操作码后面并没有操作数跟随。
在这里可以看到,自然数3虽然在源代码中被定义为整型,但是编译器直接使用一个特殊的字节码指令将其推送至栈顶,并没有使用操作码+操作数的方式来推送。JVM如此设计的原因在于减小Java class的体积,字节码文件的体积减小了,其加载到内存后所占的内存空间也会降低,而JVM所牺牲的仅仅是在内存中多定义了一个字节码指令,以及为此多写了一段译码逻辑而已。这种牺牲是值得的,并且是非常划算的,毕竟字节码指令只定义了一次,但是JVM会加载成千上万个Java字节码文件,如果每一个字节码文件中都包含一个int a = 3这样的逻辑,并且假设JVM使用操作码+操作数的方式来进行译码,那么每一条int a = 3这样的逻辑所对应的字节码,相比于iconst_3这套指令而言,除了字节码指令本身,还会额外多出来一个操作数,而一个操作数至少占占1字节,虽然这一点内存空间不算啥,但是当JVM加载了成千上万个java类时,这些多出来的内存空间累加起来就非常可观了。
再如bipush这条指令对应的format="bc",则标识bipush这个操作码后面会跟一个宽度为1字节的操作数。同理,sipush对应的format="bcc",则表示sipush指令后面会跟一个宽度为2字节的操作数。这很好理解,因为bipush指令表示将一个1字节的数据推送至操作数栈顶,因此该指令后面的操作数仅占1字节,而sipush表示将一个short类型的操作数推送至栈顶,而一个short类型的数据占2字节。既然推送一个short类型的数据至栈顶的字节码指令是sipush,那么推送一个int类型的数据至栈顶的字节码指令是什么呢?直接看上面的字节码指令表是看不出来的,不过可以通过试验来验证:
从分析结果可以看出,编译器使用iconst_3这条字节码指令将自然数3推送至栈顶。在刚才Bytecodes::initialize()函数中所定义的字节码指令格式表中找到iconst_3,可以看到其format="b"。这说明iconst_3指令的总长度为1,同时说明该操作码后面并没有操作数跟随。
在这里可以看到,自然数3虽然在源代码中被定义为整型,但是编译器直接使用一个特殊的字节码指令将其推送至栈顶,并没有使用操作码+操作数的方式来推送。JVM如此设计的原因在于减小Java class的体积,字节码文件的体积减小了,其加载到内存后所占的内存空间也会降低,而JVM所牺牲的仅仅是在内存中多定义了一个字节码指令,以及为此多写了一段译码逻辑而已。这种牺牲是值得的,并且是非常划算的,毕竟字节码指令只定义了一次,但是JVM会加载成千上万个Java字节码文件,如果每一个字节码文件中都包含一个int a = 3这样的逻辑,并且假设JVM使用操作码+操作数的方式来进行译码,那么每一条int a = 3这样的逻辑所对应的字节码,相比于iconst_3这套指令而言,除了字节码指令本身,还会额外多出来一个操作数,而一个操作数至少占占1字节,虽然这一点内存空间不算啥,但是当JVM加载了成千上万个java类时,这些多出来的内存空间累加起来就非常可观了。
事实上,JVM为了节省空间,专门定义了iconst_0~iconst_5这6条字节码指令,当将自然数0~5推送至栈顶时,便会分别生成这6条指令。其实这也是一种权衡,事实上JVM可以为每一个仅占1字节的数字分别定义一个特殊的字节码指令和译码逻辑,但是这样依赖,指令和译码逻辑反而显得太臃肿,反而不美。如果推送的整数大于5,JVM会如何处理呢?很简单,修改上面的Test类的tdd()方法中的a变量初始值,将其改成6看看,修改后编译Java类,并使用javap命令分析字节码文件,分析结果如图所示。
可以看到,现在推送至栈顶的指令变成了bipush 6了。现在的字节码指令格式终于变成了"操作码+操作数"这种范式。。前面已经分析过,bipush字节码指令的format="bc",其长度为2,表示bipush操作码后面会跟随一个只占1字节码宽度的操作数。那么如果要将一个宽度超过1字节整数推送至栈顶呢?很简单
可以看到,现在推送至栈顶的指令变成了bipush 6了。现在的字节码指令格式终于变成了"操作码+操作数"这种范式。。前面已经分析过,bipush字节码指令的format="bc",其长度为2,表示bipush操作码后面会跟随一个只占1字节码宽度的操作数。那么如果要将一个宽度超过1字节整数推送至栈顶呢?很简单
很简单,继续实验,将上面测试用例Test.tdd()方法中的变量a初始值改成300,编译后的字节码指令如图所示:可以看到,现在推送至栈顶的指令变成了sipush,该指令其format="bcc",表示该指令后面会跟随1个占2字节宽度的操作数。事实上,如果将上面的int a = 3改成char a = 3或者short a = 3,最终所生成的字节码指令也是iconst_3,同样地,若将int a = 300改成short a = 300,所生成的字节码指令与int a= 300所生成的一样,都是sipush.
由此可以知道,其实JVM体系对内存空间的使用标准非常岩哥,从编译期便开始进行优化,能使用1个操作码完成的事,就绝不使用由1个操作码+1个占1字节宽度的操作数所组成的指令去完成l能够使用由1个操作码+1个1字节宽度的操作数所组成的指令去完成的事,也绝不使用由1个操作码+1个占2字节宽度的操作数所组成。我们接着往下分析,如果要推送的整数比较大,超过了2字节的宽度,编译器会生成什么样的指令呢?这也是上面未验证完的问题。2字节所能表示的最大无符号整数是65535,那么如果在Test.tdd()方法中这样定义变量a;
int a = 65539;
所生成的字节码指令会是什么呢?编译Test类并使用javap命令分析,输出如图所示。。这一次的指令终于有了很大的变化,变成了ldc #6.所谓ldc,全称是load constant,意思是从常量池中加载(JVM内部大部分名称都起得很直观明了,让人能够顾名思义,即使是缩写也是如此,但ldc是个例外,乍一看,真猜不出啥意思)。而其后面所跟随的操作数#6,其实正是65539这个数字在字节码文件中的常量池中的索引号。由此可见,当一个int型整数的宽度超过2字节时,Java编译器便会将其直接编译进字节码文件的常量池中,而常量池中的数据在被JVM加载之后,会保存进JVM的常量区内。如果一个整数被保存进JVM的常量区之中,当其他Java class字节码文件中也使用了同样的整数为变量赋值时,则JVM不会重复将该整数写入常量区。JVM通过这种方式避免大数据(虽然只占4字节)的内存重复占用。
int a = 65539;
所生成的字节码指令会是什么呢?编译Test类并使用javap命令分析,输出如图所示。。这一次的指令终于有了很大的变化,变成了ldc #6.所谓ldc,全称是load constant,意思是从常量池中加载(JVM内部大部分名称都起得很直观明了,让人能够顾名思义,即使是缩写也是如此,但ldc是个例外,乍一看,真猜不出啥意思)。而其后面所跟随的操作数#6,其实正是65539这个数字在字节码文件中的常量池中的索引号。由此可见,当一个int型整数的宽度超过2字节时,Java编译器便会将其直接编译进字节码文件的常量池中,而常量池中的数据在被JVM加载之后,会保存进JVM的常量区内。如果一个整数被保存进JVM的常量区之中,当其他Java class字节码文件中也使用了同样的整数为变量赋值时,则JVM不会重复将该整数写入常量区。JVM通过这种方式避免大数据(虽然只占4字节)的内存重复占用。
事实上,只要为整型变量赋值的自然数超过32767,Java编译器便会使用ldc字节码指令编译源代码,而非sipush,这是因为如果存在负数,第一位将会用于表示符号。同理,只要为整型变量赋值的自然数超过127,Java编译器也会生成sipush字节码,而非bipush,这同样是因为第一位需要用于表示符号,看下面示例。注意看int a= 5和int a2 = 6所生成字节码指令分别是iconst_5和bipush 6.这是因为JVM规范只提供了iconst_0~iconst_5这6个特殊的字节码指令,当超过6时,并没有提供类似于iconst_6这样的字节码指令。而int b = 127和int b2 = 128所生成的字节码也不同,分别是bipush 127和sipush 128, int c=32767 和int c2=32768所生成的字节码也不同,分别是sipush 32767 和ladc #2,这是因为有一个二进制位需要用作区分正负数,因此8个二进制位最大只能表示到127,16个二进制数最大只能表示到32767.
继续回到ldc这个字节码指令。既然ldc指令后面所跟随的内容是常量池的索引,而非真正的操作数,那么来看看该指令在JVM内部的格式定义,如下:
def(_ldc , "ldc" , "bk" , NULL , T_ILLEGAL, 1, true );
其format=bk,长度是2位,这意味着ldc指令后面只能跟随一个宽度为1字节的操作数,不过聪明的你可能会马上想到,既然ldc指令后面所跟随的操作数是常量池的索引,并且这个操作数只能占1字节的宽度,但是1字节所能代表的最大数是256,那么如果常量池很大,其中的元素超过256个,那么ldc这个指令岂不是会存在问题了码?
继续回到ldc这个字节码指令。既然ldc指令后面所跟随的内容是常量池的索引,而非真正的操作数,那么来看看该指令在JVM内部的格式定义,如下:
def(_ldc , "ldc" , "bk" , NULL , T_ILLEGAL, 1, true );
其format=bk,长度是2位,这意味着ldc指令后面只能跟随一个宽度为1字节的操作数,不过聪明的你可能会马上想到,既然ldc指令后面所跟随的操作数是常量池的索引,并且这个操作数只能占1字节的宽度,但是1字节所能代表的最大数是256,那么如果常量池很大,其中的元素超过256个,那么ldc这个指令岂不是会存在问题了码?
为了分析问题下面,下面再次改造tdd()方法,改造后的程序如下:
由于Test.tdd()方法内部定义超过了256个整型变量,值都大于32767,并且彼此不同
由于Test.tdd()方法内部定义超过了256个整型变量,值都大于32767,并且彼此不同
从javap命令的输出结果可以看到,常量池中的元素索引号已经超过256.观察tdd()方法的字节码指令,可以看到当使用自然数33022为tdd()方法内的局部变量赋值时所使用的字节码指令是ldc #255,而当使用33023为tdd(方法内的局部变量赋值时所使用的字节码指令就变成了ldc_w,并且此后的局部变量的赋值指令都变成了ldc_w.显然,当从常量池中索引号大于255的地方取值时,JVM使用了ldc_w指令。该指令的含义是:将int、flot或String型常量值从常量池中推送至栈顶(宽索引)。看看bytecodes.cpp中如何定义该指令格式:
def(_ldc_w , "ldc_w" , "bkk" , NULL , T_ILLEGAL, 1, true );
其format=bkk,总长度变成了3,说明ldc_w指令后面可以跟随一个2字节宽的操作数据。如此一来就通了,对于将常量池中索引号大于255的常量池推送至栈顶,JVM就使用ldc_w指令,反之就使用ldc指令。道理其实很简单,这是在尽量压缩字节码的体积。使用十六进制编辑器打开上面更改过的Test类的字节码文件,可以查找到ldc #255和ldc_w#256这两条字节码指令的内容
def(_ldc_w , "ldc_w" , "bkk" , NULL , T_ILLEGAL, 1, true );
其format=bkk,总长度变成了3,说明ldc_w指令后面可以跟随一个2字节宽的操作数据。如此一来就通了,对于将常量池中索引号大于255的常量池推送至栈顶,JVM就使用ldc_w指令,反之就使用ldc指令。道理其实很简单,这是在尽量压缩字节码的体积。使用十六进制编辑器打开上面更改过的Test类的字节码文件,可以查找到ldc #255和ldc_w#256这两条字节码指令的内容
由图可以看到,ldc #255指令在字节码文件中的内容是0x12ff,这是因为ldc指令的十六进制编码是0x12,而255所对应的十六进制数是0xff.同理,ldc_w指令所对应的十六进制编码是0x13.256所对应的十六进制数是0x0100,所以ldc_w #256指令的十六进制内容是0x13 0100。所以在字节码文件中,ldc整条指令,操作码连同操作数,一共只占2字节;而ldc_w与bytecodes.cpp中所定义的format一致,一共只占3字节。由此可见,JVM为了尽可能地减小字节码文件的体积,真所谓无所不用其极,玄之又玄,众妙之门!而这种努力是非常值得的,随便一个Java Web程序的war包中都包含成千上万个Java class字节码文件,字节码文件的体积得以减少,则在网络传输(例如,远程部署)时将提升速度,并且JVM将它们读进内存后所占用内存空间也会减少。虽然一个字节码文件的体积的减少量并不是很明显,但是宏观上的效应就很可观了。
纵观bytecodes.cpp::initialize()函数中所定义的全部字节码指令的格式,会看到绝大多数字节码指令的format所包含的字符数都是1位,少部分为2位和3位,而超过3位的则更少,寥寥无几。因此JVM的字节码指令集在设计上非常紧凑和简洁。
纵观bytecodes.cpp::initialize()函数中所定义的全部字节码指令的格式,会看到绝大多数字节码指令的format所包含的字符数都是1位,少部分为2位和3位,而超过3位的则更少,寥寥无几。因此JVM的字节码指令集在设计上非常紧凑和简洁。
上面讲了这么多,除了可以知道对于int型变量的自然数赋值指令包含iconst、bipush、sipush、ldc和ldc_w这五种外,最主要的是明白了JVM内部对字节码的指令宽度是有严格定义的,而字节码指令的宽度对于JVM虚拟机完成取指是至关重要的,JVM只有知道了每一个字节码指令所占的宽度,才能完成"取指->译码->执行->取指"这种循环,将程序一直执行下去。
Bytecodes::initialize()函数会在JVM启动期间被调用,该函数执行完成之后,各个字节码指令所占的内存宽度便会被JVM所记录,JVM在运行期执行Java程序时会不断地读取该函数所维护地表,计算每个字节码指令的长度
Bytecodes::initialize()函数会在JVM启动期间被调用,该函数执行完成之后,各个字节码指令所占的内存宽度便会被JVM所记录,JVM在运行期执行Java程序时会不断地读取该函数所维护地表,计算每个字节码指令的长度
JVM的两级取指机制。
前面分析了执行引擎取指的关键一步——计算每一个指令的总长度。无论是物理CPU,还是JVM的软件模拟的执行引擎,其内在的核心机制都是类似的。在HotSpot内部也存在CPU内部类似的译码器,HotSpot里面通常叫做"解释器"。HotSpot提供了好几种解释器,例如字节码解释器bytecodeInterpreter、模板解释器templateInterpreter等。如果HotSpot以模板解释器来执行字节码指令(事实上这也是默认的方式),则所有的字节码指令都会通过TemplateInterpreterGenerator::generate_and_dispatch()这个函数来生成对应的机器指令。在该函数中实现了指令跳转(即取下一条字节码指令)的逻辑。该函数的实现如下。该函数会在JVM启动期间被调用,用于生成固定的取指逻辑。需要注意的是,JVM会为每一个字节码指令都生成一个特定的取指逻辑,这是因为不同的字节码其指令宽度不同,因此取指逻辑也肯定不统一。该函数其实主要干了两件事:
#1 为Java字节码指令生成对应的汇编指令
#2. 实现字节码指令跳转,即"取指"
这两件事对于templateInterpreter模板解释器而言,是核心中的核心。通过该函数也可以知道。HotSpot内部在生成"取指"逻辑的同时,也会为字节码指令生成对应的本地机器码。或者换而言之,HotSpot在为每一个字节码指令生成其机器逻辑指令时,会同时为该字节码指令生成其取指逻辑(取下一条指令)。该函数的第一个入参是Template*类型的指针,Template便是解释器为每个java字节码指令所定义的汇编模板,在本函数中通过调用t->generate(_masm)来生成字节码指令的机器码,该逻辑会在下文中详细解释,这里重点关注TemplateInterpreterGenerator::generate_and_dispatch()函数中所调用的__dispatch_epilog(tos_out,step)这行代码。这行代码便是在生成跳转(取指)逻辑。该函数的第二个入参是step,step是Java字节码指令的"步长"或所占的数据宽度,其单位是"字节"或8位。在TemplateInterpreterGenerator::generate_and_dispatch()函数中,通过step = t->is_wide() ? Bytecodes::wide_length_for(t->bytecode()): Bytecodes::length_for(t->bytecode())来计算出字节码指令的步长,而计算的逻辑正与前面所描述的一致,即根据Bytecodes::initialize()函数中为每个字节码指令所定义的format字段来计算,format包含几个字符,则字节码的步长便是几。
前面分析了执行引擎取指的关键一步——计算每一个指令的总长度。无论是物理CPU,还是JVM的软件模拟的执行引擎,其内在的核心机制都是类似的。在HotSpot内部也存在CPU内部类似的译码器,HotSpot里面通常叫做"解释器"。HotSpot提供了好几种解释器,例如字节码解释器bytecodeInterpreter、模板解释器templateInterpreter等。如果HotSpot以模板解释器来执行字节码指令(事实上这也是默认的方式),则所有的字节码指令都会通过TemplateInterpreterGenerator::generate_and_dispatch()这个函数来生成对应的机器指令。在该函数中实现了指令跳转(即取下一条字节码指令)的逻辑。该函数的实现如下。该函数会在JVM启动期间被调用,用于生成固定的取指逻辑。需要注意的是,JVM会为每一个字节码指令都生成一个特定的取指逻辑,这是因为不同的字节码其指令宽度不同,因此取指逻辑也肯定不统一。该函数其实主要干了两件事:
#1 为Java字节码指令生成对应的汇编指令
#2. 实现字节码指令跳转,即"取指"
这两件事对于templateInterpreter模板解释器而言,是核心中的核心。通过该函数也可以知道。HotSpot内部在生成"取指"逻辑的同时,也会为字节码指令生成对应的本地机器码。或者换而言之,HotSpot在为每一个字节码指令生成其机器逻辑指令时,会同时为该字节码指令生成其取指逻辑(取下一条指令)。该函数的第一个入参是Template*类型的指针,Template便是解释器为每个java字节码指令所定义的汇编模板,在本函数中通过调用t->generate(_masm)来生成字节码指令的机器码,该逻辑会在下文中详细解释,这里重点关注TemplateInterpreterGenerator::generate_and_dispatch()函数中所调用的__dispatch_epilog(tos_out,step)这行代码。这行代码便是在生成跳转(取指)逻辑。该函数的第二个入参是step,step是Java字节码指令的"步长"或所占的数据宽度,其单位是"字节"或8位。在TemplateInterpreterGenerator::generate_and_dispatch()函数中,通过step = t->is_wide() ? Bytecodes::wide_length_for(t->bytecode()): Bytecodes::length_for(t->bytecode())来计算出字节码指令的步长,而计算的逻辑正与前面所描述的一致,即根据Bytecodes::initialize()函数中为每个字节码指令所定义的format字段来计算,format包含几个字符,则字节码的步长便是几。
在32位x86平台上,__dispatch_epilog(tos_out, step)函数的实现逻辑如下(由于其内部设计汇编,因此一定是CPU平台相关的):
在InterpreterMacroAssembler::dispatch_epilog()函数中调用dispatch_next()函数,而后者则通过"三部曲"完成两级取指逻辑。所谓两级取指逻辑,第一级是获取字节码指令,,当前字节码指令执行完成后,JVM必须能够自动获取到其下一条字节码指令,这样才能循环往复地执行下去,而第二级取指逻辑则是取字节码指令所对应地本地机器指令,当前字节码指令对那个地机器码执行完成之后,JVM必须要能够跳转到下一条字节码指令所对应的机器码。。
在InterpreterMacroAssembler::dispatch_epilog()函数中调用dispatch_next()函数,而后者则通过"三部曲"完成两级取指逻辑。所谓两级取指逻辑,第一级是获取字节码指令,,当前字节码指令执行完成后,JVM必须能够自动获取到其下一条字节码指令,这样才能循环往复地执行下去,而第二级取指逻辑则是取字节码指令所对应地本地机器指令,当前字节码指令对那个地机器码执行完成之后,JVM必须要能够跳转到下一条字节码指令所对应的机器码。。
InterpreterMacroAssembler::InterpreterMacroAssembler()函数中的这三部曲分别是:
// load next bytecode (load before advancing rsi to prevent AGI)
load_unsigned_byte(rbx, Address(rsi, step));
// advance rsi
increment(rsi, step);
dispatch_base(state, Interpreter::dispatch_table(state));
这三条指令在不同的CPU平台上会生成不同的取指机器指令,在32位x86平台上生成如下3条机器指令:
movzbl 0x1(%esi), %ebx
inc %esi
jmp *_dispatch_table(,%ebx, state)
这3条机器指令中的第一条和第三条用于对本地机器码取指和跳转,其逻辑先不必理会,第二条inc %esi则用于取字节码指令,该指令由InterpreterMacroAssembler::InterpreterMacroAssembler()函数中的incrment(rsi, step)代码所生成,increment(rsi, step)函数便是JVM模板解释器用于取指的核心逻辑,该函数的功能是计算下一个即将执行的Java字节码指令的内存位置,而计算公式很简单:
下一个字节码指令的内存位置 = 当前字节码的位置 + 当前字节码指令所占的内存大小(以字节计)
这种公式很好理解,对于一个Java方法,当JVM将其加载进内存后,会将该Java方法所对应的全部字节码指令放到一块内存区域中,这些字节码指令彼此相邻,在内存里线性按序存储,所以相邻的2条字节码所对应的内存地址的偏移量便是前面一条字节码指令所占的内存大小。事实上物理机器指令的存储方式也是这样的,因此物理CPU在取指时也遵循同样的逻辑。该函数就像汽车中的发动机曲轴,通过它,JVM才能沿着人们所编写好的Java逻辑一直往下运行下去。而在物理机器层面,CPU也具有取指功能,只不过CPU的取指功能是实实在在的硬件电路实现,而JVM仅仅是软件模拟。
increment(rsi, step)函数的第一个入参是rsi寄存器,该寄存器总是指向当前字节码指令所在的内存位置,第2个入参是step,step是Java字节码指令的步长(即字节码指令所占的内存大小)。increment(rsi, step)函数最终生成的机器指令类似于rsi = rsi + step,表示将当前字节码指令所在的内存位置加上字节码指令的步长,从而得到当前字节码指令的下一条字节码指令的内存位置,这便完成了JVM取指的逻辑。step的计算方式是在模板表中通过format这个字段指定每个字节码指令的步长,并在TempalteInterpreterGenerator::generate_and_dispatch()函数中实现步长计算的逻辑,逻辑很简单,就是获取format字符串的字符数,例如对于iload_0指令,该字节码指令的format="b",其步长便是1字节。
// load next bytecode (load before advancing rsi to prevent AGI)
load_unsigned_byte(rbx, Address(rsi, step));
// advance rsi
increment(rsi, step);
dispatch_base(state, Interpreter::dispatch_table(state));
这三条指令在不同的CPU平台上会生成不同的取指机器指令,在32位x86平台上生成如下3条机器指令:
movzbl 0x1(%esi), %ebx
inc %esi
jmp *_dispatch_table(,%ebx, state)
这3条机器指令中的第一条和第三条用于对本地机器码取指和跳转,其逻辑先不必理会,第二条inc %esi则用于取字节码指令,该指令由InterpreterMacroAssembler::InterpreterMacroAssembler()函数中的incrment(rsi, step)代码所生成,increment(rsi, step)函数便是JVM模板解释器用于取指的核心逻辑,该函数的功能是计算下一个即将执行的Java字节码指令的内存位置,而计算公式很简单:
下一个字节码指令的内存位置 = 当前字节码的位置 + 当前字节码指令所占的内存大小(以字节计)
这种公式很好理解,对于一个Java方法,当JVM将其加载进内存后,会将该Java方法所对应的全部字节码指令放到一块内存区域中,这些字节码指令彼此相邻,在内存里线性按序存储,所以相邻的2条字节码所对应的内存地址的偏移量便是前面一条字节码指令所占的内存大小。事实上物理机器指令的存储方式也是这样的,因此物理CPU在取指时也遵循同样的逻辑。该函数就像汽车中的发动机曲轴,通过它,JVM才能沿着人们所编写好的Java逻辑一直往下运行下去。而在物理机器层面,CPU也具有取指功能,只不过CPU的取指功能是实实在在的硬件电路实现,而JVM仅仅是软件模拟。
increment(rsi, step)函数的第一个入参是rsi寄存器,该寄存器总是指向当前字节码指令所在的内存位置,第2个入参是step,step是Java字节码指令的步长(即字节码指令所占的内存大小)。increment(rsi, step)函数最终生成的机器指令类似于rsi = rsi + step,表示将当前字节码指令所在的内存位置加上字节码指令的步长,从而得到当前字节码指令的下一条字节码指令的内存位置,这便完成了JVM取指的逻辑。step的计算方式是在模板表中通过format这个字段指定每个字节码指令的步长,并在TempalteInterpreterGenerator::generate_and_dispatch()函数中实现步长计算的逻辑,逻辑很简单,就是获取format字符串的字符数,例如对于iload_0指令,该字节码指令的format="b",其步长便是1字节。
由于不同的Java字节码指令的步长是不同的,因此最终所生成的本地机器码也有所不同,如果字节码指令的步长是1(只有操作码而没有操作数),则生成的本地机器指令便是inc %esi,该指令表示对esi增加1字节(因为一个步长为1的Java字节码指令所占的内存空间为1字节);而如果字节码指令的步长超过1,则生成的本地机器指令便是add $operand, %esi,该指令表示对esi进行累加,例如字节码的步长为2,则对应的指令是add $0x2, %esi,表示对esi寄存器增加2字节。例如,对于iload_1这样的字节码指令,该指令没有操作数,因此步长为1,那么iload_1字节码的下一个字节码的内存位置相对于iload_1偏移量是1字节,所以生成的机器指令是inc %esi,其将esi寄存器(该寄存器指向当前字节码指令的内存位置)的值加1,便得到iload_1这条指令的下一条指令所在的内存位置。
可能聪明的你会想到这样一个问题:rsi寄存器总是指向当前字节码指令所在的内存位置,这一句恐怕不对吧,例如,当JVM在运行Java程序main()主函数所对应的第一条字节码指令时,这个时候根本就不存在上一条字节码指令,那么rsi寄存器指向哪里呢?
事实上,在JVM调用Java的main()主函数时,会先调用generate_fixed_frame(false)函数来创建栈帧,而在这个过程里面,JVM便会有一个逻辑获取Java的main()主函数在JVM内部的第一条字节码指令,并将esi寄存器指向第一条字节码的位置。这个逻辑的具体实现在前面分析Java函数栈帧的地方说过,不再赘述,同时关于Java方法在JVM内部的映射以及Java方法的字节码指令在JVM内部的存储,也在前面的Java方法的地方分析过。
正因为在JVM调用Java的main()主函数之前会先执行generate_fixed_frame(false)来创建栈帧。并在这个过程中将esi寄存器的值指向main()函数的第一条字节码指令,所以接着调用dispatch_next()函数时才能根据rsi进行偏移,顺利完成取指。
到了这里,终于可以与本章开始所讲的InterpreterGenerator::generate_normal_entry()函数接上了,在JVM进入Java世界之前,会先找到Java的main()主函数并调用,而调用Java主函数时,最终流程会进入InterpreterGenerator::generate_normal_entry()这个函数的逻辑中去。而在InterpreterGenerator::generate_normal_entry()函数中,除了会调用generate_fixed_frame()函数为Java的main()主函数创建栈帧之外(注意,在创建栈帧的过程中,JVM会将rsi寄存器指向main()主函数的第一条字节码指令的内存地址),还将调用dispatch_next()函数执行Java的main()主函数的第一条字节码指令。在32位x86平台上,在InterpreterGenerator::generate_normal_entry()函数中所调用的dispatch_next()函数便是在定义interp_masm_x86_32.cpp中的dispatch_next()这个函数。
可能聪明的你会想到这样一个问题:rsi寄存器总是指向当前字节码指令所在的内存位置,这一句恐怕不对吧,例如,当JVM在运行Java程序main()主函数所对应的第一条字节码指令时,这个时候根本就不存在上一条字节码指令,那么rsi寄存器指向哪里呢?
事实上,在JVM调用Java的main()主函数时,会先调用generate_fixed_frame(false)函数来创建栈帧,而在这个过程里面,JVM便会有一个逻辑获取Java的main()主函数在JVM内部的第一条字节码指令,并将esi寄存器指向第一条字节码的位置。这个逻辑的具体实现在前面分析Java函数栈帧的地方说过,不再赘述,同时关于Java方法在JVM内部的映射以及Java方法的字节码指令在JVM内部的存储,也在前面的Java方法的地方分析过。
正因为在JVM调用Java的main()主函数之前会先执行generate_fixed_frame(false)来创建栈帧。并在这个过程中将esi寄存器的值指向main()函数的第一条字节码指令,所以接着调用dispatch_next()函数时才能根据rsi进行偏移,顺利完成取指。
到了这里,终于可以与本章开始所讲的InterpreterGenerator::generate_normal_entry()函数接上了,在JVM进入Java世界之前,会先找到Java的main()主函数并调用,而调用Java主函数时,最终流程会进入InterpreterGenerator::generate_normal_entry()这个函数的逻辑中去。而在InterpreterGenerator::generate_normal_entry()函数中,除了会调用generate_fixed_frame()函数为Java的main()主函数创建栈帧之外(注意,在创建栈帧的过程中,JVM会将rsi寄存器指向main()主函数的第一条字节码指令的内存地址),还将调用dispatch_next()函数执行Java的main()主函数的第一条字节码指令。在32位x86平台上,在InterpreterGenerator::generate_normal_entry()函数中所调用的dispatch_next()函数便是在定义interp_masm_x86_32.cpp中的dispatch_next()这个函数。
当JVM第一次调用Java的main()主函数时,rsi指向Java的main()主函数的第一条字节码指令在内存中的位置,但是从InterpreterGenerator::generate_normal_entry()函数中调用dispatch_next()函数时,仅传入了第一个参数tosState,而第二个参数step并没有传递,因此step默认为0,而当步长为0时,JVM最终不会生成类似inc %esi这样的机器指令,即此时不会"取下一条指令",因为JVM总得先执行第一条指令。只有第一条字节码指令执行完成之后,JVM才能通过esi来获取下一条字节码指令所在的内存位置,,从而挨个执行Java方法的全部字节码指令。
JVM内部其实存在两级取指逻辑,第一级取字节码指令,第二级取字节码指令对应的本地机器码。第二级的取指逻辑是通过InterpreterMacroAssembler::InterpreterMacroAssembler()函数中的第一和第三条指令完成的。这两条指令最终所生成的本地机器指令如下(32位x86平台)
movzbl step(%esi), %ebx
jmp *_dispatch_table(, %ebx, state)
第一条指令movzbl step(%esi), %ebx取下一条字节码指令,并将其存储到ebx寄存器中,接着通过第二条指令跳转到下一条字节码指令所对应的本地机器码。注意,第二条指令中的_dispatch_table便是JVM内部所维护的跳转表,跳转表中记录了每个JVM字节码所对应的本地机器码实现,JVM通过跳转表,完成第二级取指逻辑。在JVM取到某条字节码指令时,跳转到其对应的本地机器码并执行。
JVM内部其实存在两级取指逻辑,第一级取字节码指令,第二级取字节码指令对应的本地机器码。第二级的取指逻辑是通过InterpreterMacroAssembler::InterpreterMacroAssembler()函数中的第一和第三条指令完成的。这两条指令最终所生成的本地机器指令如下(32位x86平台)
movzbl step(%esi), %ebx
jmp *_dispatch_table(, %ebx, state)
第一条指令movzbl step(%esi), %ebx取下一条字节码指令,并将其存储到ebx寄存器中,接着通过第二条指令跳转到下一条字节码指令所对应的本地机器码。注意,第二条指令中的_dispatch_table便是JVM内部所维护的跳转表,跳转表中记录了每个JVM字节码所对应的本地机器码实现,JVM通过跳转表,完成第二级取指逻辑。在JVM取到某条字节码指令时,跳转到其对应的本地机器码并执行。
取指指令放在哪儿。
如果HotSpot以模板解释器来执行字节码指令(事实上这也是默认的方式),则所有的字节码指令都会通过TempalteInterpreterGenerator::generate_and_dispatch()这个函数生成对应的机器指令,TemplateInterpreterGenerator::generate_and_dispatch()函数主要完成两件事:
#1. 为当前字节码指令生成其对应的本地机器码
#2. 为当前字节码指令生成其对应的取指逻辑(取下一条指令)
这两件事分别通过t->generate(_masm)和__dispatch_epilog(tos_out, step)来完成,TempalteInterpreterGenerator::generate_and_dispatch()函数先调用t->generate(_masm)为当前字节码指令生成其对应的本地机器码,具体生成逻辑后面再解释,而__dispatch_epilog(tos_out,step)的逻辑前面刚刚分析过,通过三部曲完成两级取指.t->generate(_masm)和__dispatch_epilog(tos_out, step)这两个函数会分别向JVM内部的代码缓冲区中写入对应的本地机器指令,由此可知,字节码的取指逻辑其实时被写入到每一个字节码指令所对应的本地机器码所在内存的后面区域,其原因很简单,因为不同的字节码指令的步长不同,因此所生成的对rsi寄存器进行累加的逻辑也不同,所以不同字节码指令的取指逻辑肯定也不同。事实上,这还与栈顶缓存有关。使用HSDIS工具可以查看JVM模板解释器在运行期所生成的全部字节码指令的本地机器码。
其实,JVM虚拟机的这种取指逻辑安排与硬件CPU的取指逻辑是完全一致的。只不过CPU是通过数字电路来完成自动取指功能。当CPU读取到当前机器指令时,CPU内部的数字电路便会触发取指电路去工作,取指电路会根据当前所取指令的内存地址偏移当前指令的长度,从而计算出下一条指令的内存位置。通过这种机制物理CPU便能够周而复始地一直执行下去,直到最后一条指令。
如果HotSpot以模板解释器来执行字节码指令(事实上这也是默认的方式),则所有的字节码指令都会通过TempalteInterpreterGenerator::generate_and_dispatch()这个函数生成对应的机器指令,TemplateInterpreterGenerator::generate_and_dispatch()函数主要完成两件事:
#1. 为当前字节码指令生成其对应的本地机器码
#2. 为当前字节码指令生成其对应的取指逻辑(取下一条指令)
这两件事分别通过t->generate(_masm)和__dispatch_epilog(tos_out, step)来完成,TempalteInterpreterGenerator::generate_and_dispatch()函数先调用t->generate(_masm)为当前字节码指令生成其对应的本地机器码,具体生成逻辑后面再解释,而__dispatch_epilog(tos_out,step)的逻辑前面刚刚分析过,通过三部曲完成两级取指.t->generate(_masm)和__dispatch_epilog(tos_out, step)这两个函数会分别向JVM内部的代码缓冲区中写入对应的本地机器指令,由此可知,字节码的取指逻辑其实时被写入到每一个字节码指令所对应的本地机器码所在内存的后面区域,其原因很简单,因为不同的字节码指令的步长不同,因此所生成的对rsi寄存器进行累加的逻辑也不同,所以不同字节码指令的取指逻辑肯定也不同。事实上,这还与栈顶缓存有关。使用HSDIS工具可以查看JVM模板解释器在运行期所生成的全部字节码指令的本地机器码。
其实,JVM虚拟机的这种取指逻辑安排与硬件CPU的取指逻辑是完全一致的。只不过CPU是通过数字电路来完成自动取指功能。当CPU读取到当前机器指令时,CPU内部的数字电路便会触发取指电路去工作,取指电路会根据当前所取指令的内存地址偏移当前指令的长度,从而计算出下一条指令的内存位置。通过这种机制物理CPU便能够周而复始地一直执行下去,直到最后一条指令。
程序计数器在哪里。
经常可以在各种书籍上看到JVM在执行字节码指令时,会有一个pc计数器(program counter)指向当前所执行地指令,当前指令执行完成之后,PC回自动指向下一条字节码指令,程序计数器是保证软件 程序能够连续执行下去的关键技术之一。物理CPU中专门有一个寄存器用于存放PC,当计算机上的某个软件程序开始运行之前,操作系统回将该软件程序加载至内存中(数据段、代码段)加载之后,操作系统便会执行一件非常重要的事情——将该软件程序的第一条机器指令在内存中的地址送入程序计数器,操作系统会从该地址读取指令,并开始执行,由此开始操作系统将CPU的控制权交给软件程序。当执行指令时,处理器将自动修改PC的内容,每执行一条指令,PC便会增加一个量,这个量等于指令所含的字节数,这样PC所指向的内存位置总是将要执行的下一条指令的地址。由于大多数指令都是按顺序来执行的,所以PC通常都是加1.
JVM内部的程序计数器的原理也与之相同,其实经过前面对JVM取指技术实现的分析可知,JVM内部所谓PC计数器,其实就是esi寄存器(x86平台)。当JVM开始执行Java程序的main()主函数时,PC(esi寄存器)便会指向Java程序的main()的main()主函数第一条字节码指令的内存位置,接着JVM每执行完一条字节码指令便会对PC执行一定的增量,从而让PC总是指向即将要执行的字节码指令,如此便能让Java程序连续执行下去。
知其然,并知其所以然。JVM为何要用宝贵的寄存器资源作为程序计数器呢?道理很简单,就是因为CPU读写寄存器的速度是最快的(相对于内存和磁盘)。由于JVM一旦跑起来之后,所作的事情就是不断执行"取指->译码->执行"这一循环往复的任务,因此取指在JVM内部可谓是最频繁的事情,如此多的数据读写,如果性能低下,必然影响到JVM的整体执行效率,因此JVM便选择一个寄存器作为程序计数器。另一方面,JVM的指令集是面向栈的,而面向栈的指令集并不直接依赖于寄存器,因此JVM也有更多的资源可以直接基于寄存器。
经常可以在各种书籍上看到JVM在执行字节码指令时,会有一个pc计数器(program counter)指向当前所执行地指令,当前指令执行完成之后,PC回自动指向下一条字节码指令,程序计数器是保证软件 程序能够连续执行下去的关键技术之一。物理CPU中专门有一个寄存器用于存放PC,当计算机上的某个软件程序开始运行之前,操作系统回将该软件程序加载至内存中(数据段、代码段)加载之后,操作系统便会执行一件非常重要的事情——将该软件程序的第一条机器指令在内存中的地址送入程序计数器,操作系统会从该地址读取指令,并开始执行,由此开始操作系统将CPU的控制权交给软件程序。当执行指令时,处理器将自动修改PC的内容,每执行一条指令,PC便会增加一个量,这个量等于指令所含的字节数,这样PC所指向的内存位置总是将要执行的下一条指令的地址。由于大多数指令都是按顺序来执行的,所以PC通常都是加1.
JVM内部的程序计数器的原理也与之相同,其实经过前面对JVM取指技术实现的分析可知,JVM内部所谓PC计数器,其实就是esi寄存器(x86平台)。当JVM开始执行Java程序的main()主函数时,PC(esi寄存器)便会指向Java程序的main()的main()主函数第一条字节码指令的内存位置,接着JVM每执行完一条字节码指令便会对PC执行一定的增量,从而让PC总是指向即将要执行的字节码指令,如此便能让Java程序连续执行下去。
知其然,并知其所以然。JVM为何要用宝贵的寄存器资源作为程序计数器呢?道理很简单,就是因为CPU读写寄存器的速度是最快的(相对于内存和磁盘)。由于JVM一旦跑起来之后,所作的事情就是不断执行"取指->译码->执行"这一循环往复的任务,因此取指在JVM内部可谓是最频繁的事情,如此多的数据读写,如果性能低下,必然影响到JVM的整体执行效率,因此JVM便选择一个寄存器作为程序计数器。另一方面,JVM的指令集是面向栈的,而面向栈的指令集并不直接依赖于寄存器,因此JVM也有更多的资源可以直接基于寄存器。
译码。
对于执行引擎而言,取指只是第一步,执行才是终极目标,不过从取指到执行中间,还有一个步骤:译码。之所以要译码,道理很简单,JVM内部定义了两百多个字节码指令,不同字节码指令的实现机制都是不同的,因此JVM取出字节码指令后,需要将其翻译称不同的逻辑,然后才能执行
对于执行引擎而言,取指只是第一步,执行才是终极目标,不过从取指到执行中间,还有一个步骤:译码。之所以要译码,道理很简单,JVM内部定义了两百多个字节码指令,不同字节码指令的实现机制都是不同的,因此JVM取出字节码指令后,需要将其翻译称不同的逻辑,然后才能执行
模板表。
对于物理CPU而言,译码逻辑直接固化在硬件数字电路中,当CPU读取到特定的物理机器指令时,回触发所固化的特定数字电路,这种触发机制其实便是译码逻辑。如果CPU对一条机器指令无动于衷,那么CPU便无法完成译码,更无法执行指令,严重的则直接当即。JVM是虚拟的机器,没有专门的硬件译码电路,因此仅能软件模拟。如果JVM以模板解释器来解释字节码,则这种模板定义如下所示:
在该函数中,通过def()函数对每一个字节码指令进行定义,定义了什么呢?def()函数一共有9个入参,第1个入参是字节码指令编码,而第8个入参则是该指令所对应的汇编指令生成器,这种生成器在JVM内部被称作generator.其实这很好理解,因为对于模板解释器而言,每一个Java字节码指令最终都会生成对应的一串本地机器码,JVM在运行期直接执行这些机器码,因此,对于模板解释器,JVM为每一个字节码都专门配备了一个生成器,其实所谓生成器,说白了就是一个函数而已。
例如,对于将int型局部变量从局部变量表推送至操作数栈栈顶,JVM中一共设计了多种iload字节码指令系列,例如iload_0、iload_1等,而这些字节码指令所对应的generator都是iload()函数。iload()函数是CPU平台架构相关的,并且在templateTable.hpp中定义了2个重载函数,如下:
static void iload();
static void iload(int n);
如果字节码指令是iload 6、iload 18之类的,其对应的generator生成器便是iload()。如果字节码指令是iload_0、iload_1之类的,则对应的generator生成器是iload(int n)。与将自然数推送至操作数栈栈顶的的机制一样,从局部变量表加载数据到操作数栈栈顶,也并非都是用同一个字节码指令。对于int型局部变量,当其slot索引号小于等于3时,,使用iload_0、iload_1、iload_2或者iload_3这样的字节码指令。而当slot索引号超过3时,便会使用iload slot_idx这样的字节码指令。iload_0、iload_1、iload_2或者iload_3这样的字节码指令只占1字节内存空间,因为没有操作数,而iload slot_idx这样的指令回占用2字节空间,操作码和操作数各占1字节,,由此可知JVM中之所以设计iload_0、iload_1、iload_2或者iload_3这样的字节码指令,其目的仍然时为了给字节码文件瘦身。iload_0、iload_1、iload_2或者iload_3字节码指令所对应的generator是TemplateTable::iload(int n)函数,其定义如下(在32位x86平台):
void TemplateTable::iload(int n) {
transition(vtos, itos);
__ movl(rax, iaddress(n));
}
在该函数中,通过__ movl(rax, iaddress(n))这条指令将Java方法栈帧的局部变量表中指定索引号的变量传送至操作数栈栈顶。关于该函数的实现机制,后面再进行分析,在这里需要关心的一个问题是:对于在TemplateTable::initialize()函数中通过def()函数所dinginess的各种字节码指令的模板,JVM是如何进行保存的,以便在运行期能够据此进行模板编译?
对于物理CPU而言,译码逻辑直接固化在硬件数字电路中,当CPU读取到特定的物理机器指令时,回触发所固化的特定数字电路,这种触发机制其实便是译码逻辑。如果CPU对一条机器指令无动于衷,那么CPU便无法完成译码,更无法执行指令,严重的则直接当即。JVM是虚拟的机器,没有专门的硬件译码电路,因此仅能软件模拟。如果JVM以模板解释器来解释字节码,则这种模板定义如下所示:
在该函数中,通过def()函数对每一个字节码指令进行定义,定义了什么呢?def()函数一共有9个入参,第1个入参是字节码指令编码,而第8个入参则是该指令所对应的汇编指令生成器,这种生成器在JVM内部被称作generator.其实这很好理解,因为对于模板解释器而言,每一个Java字节码指令最终都会生成对应的一串本地机器码,JVM在运行期直接执行这些机器码,因此,对于模板解释器,JVM为每一个字节码都专门配备了一个生成器,其实所谓生成器,说白了就是一个函数而已。
例如,对于将int型局部变量从局部变量表推送至操作数栈栈顶,JVM中一共设计了多种iload字节码指令系列,例如iload_0、iload_1等,而这些字节码指令所对应的generator都是iload()函数。iload()函数是CPU平台架构相关的,并且在templateTable.hpp中定义了2个重载函数,如下:
static void iload();
static void iload(int n);
如果字节码指令是iload 6、iload 18之类的,其对应的generator生成器便是iload()。如果字节码指令是iload_0、iload_1之类的,则对应的generator生成器是iload(int n)。与将自然数推送至操作数栈栈顶的的机制一样,从局部变量表加载数据到操作数栈栈顶,也并非都是用同一个字节码指令。对于int型局部变量,当其slot索引号小于等于3时,,使用iload_0、iload_1、iload_2或者iload_3这样的字节码指令。而当slot索引号超过3时,便会使用iload slot_idx这样的字节码指令。iload_0、iload_1、iload_2或者iload_3这样的字节码指令只占1字节内存空间,因为没有操作数,而iload slot_idx这样的指令回占用2字节空间,操作码和操作数各占1字节,,由此可知JVM中之所以设计iload_0、iload_1、iload_2或者iload_3这样的字节码指令,其目的仍然时为了给字节码文件瘦身。iload_0、iload_1、iload_2或者iload_3字节码指令所对应的generator是TemplateTable::iload(int n)函数,其定义如下(在32位x86平台):
void TemplateTable::iload(int n) {
transition(vtos, itos);
__ movl(rax, iaddress(n));
}
在该函数中,通过__ movl(rax, iaddress(n))这条指令将Java方法栈帧的局部变量表中指定索引号的变量传送至操作数栈栈顶。关于该函数的实现机制,后面再进行分析,在这里需要关心的一个问题是:对于在TemplateTable::initialize()函数中通过def()函数所dinginess的各种字节码指令的模板,JVM是如何进行保存的,以便在运行期能够据此进行模板编译?
这个秘密就藏在def()函数中,在TemplateTable::initialize()函数中定义了每一个字节码指令的机器码生成器,def函数实现如下:
在该函数中,先通过Template* t = is_wide ? template_for_wide(code) : template_for(code)从模板表中取出当前定义的字节码指令模板,接着通过t->initialize(flags, in, out, gen, arg)对字节码指令模板进行初始化,如此便将初始化好的字节码指令模板保存到模板表中。
在该函数中,先通过Template* t = is_wide ? template_for_wide(code) : template_for(code)从模板表中取出当前定义的字节码指令模板,接着通过t->initialize(flags, in, out, gen, arg)对字节码指令模板进行初始化,如此便将初始化好的字节码指令模板保存到模板表中。
模板表是什么?其实在TemplateTable::initialize()函数中定义的字节码指令及其生成器便保存在模板表中,其定义如下:
在TemplateTable类中定义了_template_table数组,该数组即是模板表,模板表记录了每个字节码指令的汇编生成器(即函数)、参数及其他相关信息。该数组的元素类型是Template,而数组的大小初始化大小就是Java字节码指令的数量,道理很简单,因为每一个Java字节码指令对应一个模板。同时TemplateTable类中定义了模板表的访问接口template_for(Bytecode::Code)函数,其可以根据字节码指令的编号查询对应的模板,该接口在TemplateTable::def()函数中被调用,用于读取字节码指令在模板表中所对应的模板。注意,无论是模板表_template_table还是模板表的访问接口函数,都是静态的(使用static修饰),因此在操作系统加载TemplateTable类时便会完成模板表的初始化,只不过这个时候的模板表里的元素都是控制,模板尚未构建。因此,在TemplateTable::def()函数中才需要先从模板表中取出当前模板,并进行初始化。如此依赖,等到TemplateTable::initialize()函数执行完成,则字节码指令的模板表也完成构建。那么模板在啥时候构建呢?或者换言之,TemplateTable::initialize()函数在啥时候被调用呢?
在TemplateTable类中定义了_template_table数组,该数组即是模板表,模板表记录了每个字节码指令的汇编生成器(即函数)、参数及其他相关信息。该数组的元素类型是Template,而数组的大小初始化大小就是Java字节码指令的数量,道理很简单,因为每一个Java字节码指令对应一个模板。同时TemplateTable类中定义了模板表的访问接口template_for(Bytecode::Code)函数,其可以根据字节码指令的编号查询对应的模板,该接口在TemplateTable::def()函数中被调用,用于读取字节码指令在模板表中所对应的模板。注意,无论是模板表_template_table还是模板表的访问接口函数,都是静态的(使用static修饰),因此在操作系统加载TemplateTable类时便会完成模板表的初始化,只不过这个时候的模板表里的元素都是控制,模板尚未构建。因此,在TemplateTable::def()函数中才需要先从模板表中取出当前模板,并进行初始化。如此依赖,等到TemplateTable::initialize()函数执行完成,则字节码指令的模板表也完成构建。那么模板在啥时候构建呢?或者换言之,TemplateTable::initialize()函数在啥时候被调用呢?
其实可以很容易猜到,模板表时运行期执行Java字节码指令时时刻都会使用到的基础数据,因此一定在JVM启动期间进行构建。模板表构建的整体链路如下:
这条链路时本地调试HotSpot源码时的调用链路,本地调试编译好的调试版HotSpot时会从java.c这个main()主函数所在的文件进入,然后一路调用下来。HotSpot启动时进入java.c::main()主函数,main()主函数由操作系统调用。接着在LoadJavaVM(0中调用JNI_CreateJavaVM()接口开始创建JVM虚拟机,
需要注意的是,JDK1.6版本中,在java_md.c::LoadJavaVM()过程中有两处都会调用JNI_CreateJavaVM()接口。
jboolean LoadJavaVM(const char* jvmpath, InvocationFunctions: *ifn) {
#ifdef GAMMA
/* JVM is directly linked with gamma launcher; no dlopen() */
ifn->CreateJavaVM = JNI_CreateJavaVM;
ifn->GetDefaultJavaVMInitArgs = JNI_GetDefaultJavaVMInitArgs;
return JNI_TRUE;
#else
// ...
ifn->CreateJavaVM = (CreateJavaVM_t)dlsym(libvm, "JNI_CreateJavaVM");
// ...
#endif /*ifndef GAMMA */
}
在该接口中,通过判断是否定义GAMMA宏,分别使用两种方式调用JNI_CreateJavaVM(),如果设置过GAMMA宏则直接调用该接口,否则便通过动态链接库进行调用。当在本地编译HotSpot源代码并生成调试版的HotSpot时,为了调试跟踪方便,HotSpot提供了gamma启动器,通过gamma启动器能够直接在本地调试HotSpot源代码。所谓gamma启动器,其实就是上面链路中的入口java.c::main()函数,而对于Java用户而言,启动Java程序时所调用的命令是%JAVA_HOMNE%\bin\java命令中启动HotSpot虚拟机,如果通过Java命令启动虚拟机,则本地一定需要先行安装JDK,否则Java程序无法启动。安装JDK之后,在Linux上会生成libjvm.so动态链接库,在Windows上会生成jvm.dll,因此如果是从Java命令启动HotSpot虚拟机,因此最终回调用Interpreter::initialize()接口对解释器进行初始化。
这条链路时本地调试HotSpot源码时的调用链路,本地调试编译好的调试版HotSpot时会从java.c这个main()主函数所在的文件进入,然后一路调用下来。HotSpot启动时进入java.c::main()主函数,main()主函数由操作系统调用。接着在LoadJavaVM(0中调用JNI_CreateJavaVM()接口开始创建JVM虚拟机,
需要注意的是,JDK1.6版本中,在java_md.c::LoadJavaVM()过程中有两处都会调用JNI_CreateJavaVM()接口。
jboolean LoadJavaVM(const char* jvmpath, InvocationFunctions: *ifn) {
#ifdef GAMMA
/* JVM is directly linked with gamma launcher; no dlopen() */
ifn->CreateJavaVM = JNI_CreateJavaVM;
ifn->GetDefaultJavaVMInitArgs = JNI_GetDefaultJavaVMInitArgs;
return JNI_TRUE;
#else
// ...
ifn->CreateJavaVM = (CreateJavaVM_t)dlsym(libvm, "JNI_CreateJavaVM");
// ...
#endif /*ifndef GAMMA */
}
在该接口中,通过判断是否定义GAMMA宏,分别使用两种方式调用JNI_CreateJavaVM(),如果设置过GAMMA宏则直接调用该接口,否则便通过动态链接库进行调用。当在本地编译HotSpot源代码并生成调试版的HotSpot时,为了调试跟踪方便,HotSpot提供了gamma启动器,通过gamma启动器能够直接在本地调试HotSpot源代码。所谓gamma启动器,其实就是上面链路中的入口java.c::main()函数,而对于Java用户而言,启动Java程序时所调用的命令是%JAVA_HOMNE%\bin\java命令中启动HotSpot虚拟机,如果通过Java命令启动虚拟机,则本地一定需要先行安装JDK,否则Java程序无法启动。安装JDK之后,在Linux上会生成libjvm.so动态链接库,在Windows上会生成jvm.dll,因此如果是从Java命令启动HotSpot虚拟机,因此最终回调用Interpreter::initialize()接口对解释器进行初始化。
在interpreter.cpp::interpreter_init()中调用Interpreter::initialize()时,回根据系统所设置的参数而调用对应的解释器,看Interpreter类的声明
class Interpreter: public CC_INTERP_ONLY(CppInterpreter) NOT_CC_INTERP(TemplateInterpreter) {
Interpreter类继承时通过CC_INTERP_ONLY(CppInterpreter)宏和NOT_CC_INTERP(TemplateInterpreter)宏来判断继承哪一个解释器,在HotSpot中,默认使用模板解释器,因此Interpreter类实际继承的是TemplateInterpreter这个模板解释器。在interpreter.cpp::interpreter_init()函数中调用Interpreter::initialize()函数时,实际调用的是TemplateInterpreter::initialize()这个函数,该函数声明如下:
在模板解释器的初始化逻辑中,主要初始化了抽象解释器AbstractInterpreter、模板表TemplateTable、CodeCache的Stub队列StubQueue和解释器生成器InterpreterGenerator.在这里只需要知道,在模板解释器的初始化链路中,调用到了TemplateTable::initialize()这个接口进行模板表的构建,因此在JVM启动过程中,解释器初始化完成,模板表也就构建完成,每一个字节码指令都与其对应的生成器函数一一进行关联
class Interpreter: public CC_INTERP_ONLY(CppInterpreter) NOT_CC_INTERP(TemplateInterpreter) {
Interpreter类继承时通过CC_INTERP_ONLY(CppInterpreter)宏和NOT_CC_INTERP(TemplateInterpreter)宏来判断继承哪一个解释器,在HotSpot中,默认使用模板解释器,因此Interpreter类实际继承的是TemplateInterpreter这个模板解释器。在interpreter.cpp::interpreter_init()函数中调用Interpreter::initialize()函数时,实际调用的是TemplateInterpreter::initialize()这个函数,该函数声明如下:
在模板解释器的初始化逻辑中,主要初始化了抽象解释器AbstractInterpreter、模板表TemplateTable、CodeCache的Stub队列StubQueue和解释器生成器InterpreterGenerator.在这里只需要知道,在模板解释器的初始化链路中,调用到了TemplateTable::initialize()这个接口进行模板表的构建,因此在JVM启动过程中,解释器初始化完成,模板表也就构建完成,每一个字节码指令都与其对应的生成器函数一一进行关联
汇编器。
对于模板计时器,每一个字节码指令都会关联一个生成器函数,用于生成字节码指令的本地机器码。例如iload_1字节码指令所对应的函数时TemplateTable::iload(int n),在改善书中要调用 __movl(rax, iaddress(n))函数生成对应的机器指令,所生成的机器指令是mov reg, operand,表示将操作数(立即数传送至指定的寄存器中)。在x86平台上,该函数回调用如下接口:
该函数属于Assembler类,Assembler类是JVM内部为模板解释器所定义的汇编器,当JVM使用模板解释器来解释执行字节码指令时,便会通过汇编器来为每一个字节码指令生成对应的本地机器码。在Assembler::movl()函数中调用emit系列的接口生成本地机器码,emit系列的接口则定义在AbstractAssembler类中,该类顾名思义是"抽象汇编器"。emit()接口将机器码写入特定的内存位置,JVM在运行期解释字节码指令时,会跳转到特定的内存位置执行机器码。
每个字节码指令都会关联一个生成器函数,而生成器函数会调用汇编器生成机器码,但是HotSPot源码中并没有在生成器函数中看到直接调用汇编器的地方。仍以iload_1字节码为例,其所对应的生成器函数TemplateTable::iload(int n)仅仅只是调用了__ movl(rax, iaddress(n)),并没有调用类似assembler.mov(rax, iaddress(n))的函数,然而movl(rax, iaddress(n))函数的确定义在assembler_x86.cpp这个汇编器类中(x86平台),这究竟是如何关联的呢?秘密就隐藏在
__ movl(rax, iaddress(n))这句指令的前缀"__"里面,这是两个连续的下划线。事实上这是一个宏,在x86平台上,这个宏的定义如下:
// tempalteTable_x86_32.cpp
// 模板表生成器函数中调用的汇编器宏
#ifndef CC_INTERP
#define __ _masm->
因此,在模板表中可以通过添加__前缀直接调用汇编器中的函数,而不用添加类名。再以iload_1字节码指令为例,在x86平台上其对应的生成器函数是iload(int n),该函数调用如下函数生成本地机器码:
__ movl(rax, iaddress(n));
在HotSpot源码编译的预处理阶段,这句代码会被进行宏替换,替换之后的代码变成如下:
_masm->mov(rax, iaddress(n));
如此ilai,模板表便与汇编器关联上了,模板表的确是通过调用汇编器接口完成本地机器码指令的生成。不过你可能会向问,templateTable_x86_32.cpp中的_masm这个变量定义在哪里?啥时候初始化?这是一个很好的问题。对于TemplateTable类,其_masm变量定义如下:
// templateTable.hpp
// 模板表中汇编器变量定义
static InterpreterMacroAssembler* _masm; // the assembler used when generating templates
对于模板计时器,每一个字节码指令都会关联一个生成器函数,用于生成字节码指令的本地机器码。例如iload_1字节码指令所对应的函数时TemplateTable::iload(int n),在改善书中要调用 __movl(rax, iaddress(n))函数生成对应的机器指令,所生成的机器指令是mov reg, operand,表示将操作数(立即数传送至指定的寄存器中)。在x86平台上,该函数回调用如下接口:
该函数属于Assembler类,Assembler类是JVM内部为模板解释器所定义的汇编器,当JVM使用模板解释器来解释执行字节码指令时,便会通过汇编器来为每一个字节码指令生成对应的本地机器码。在Assembler::movl()函数中调用emit系列的接口生成本地机器码,emit系列的接口则定义在AbstractAssembler类中,该类顾名思义是"抽象汇编器"。emit()接口将机器码写入特定的内存位置,JVM在运行期解释字节码指令时,会跳转到特定的内存位置执行机器码。
每个字节码指令都会关联一个生成器函数,而生成器函数会调用汇编器生成机器码,但是HotSPot源码中并没有在生成器函数中看到直接调用汇编器的地方。仍以iload_1字节码为例,其所对应的生成器函数TemplateTable::iload(int n)仅仅只是调用了__ movl(rax, iaddress(n)),并没有调用类似assembler.mov(rax, iaddress(n))的函数,然而movl(rax, iaddress(n))函数的确定义在assembler_x86.cpp这个汇编器类中(x86平台),这究竟是如何关联的呢?秘密就隐藏在
__ movl(rax, iaddress(n))这句指令的前缀"__"里面,这是两个连续的下划线。事实上这是一个宏,在x86平台上,这个宏的定义如下:
// tempalteTable_x86_32.cpp
// 模板表生成器函数中调用的汇编器宏
#ifndef CC_INTERP
#define __ _masm->
因此,在模板表中可以通过添加__前缀直接调用汇编器中的函数,而不用添加类名。再以iload_1字节码指令为例,在x86平台上其对应的生成器函数是iload(int n),该函数调用如下函数生成本地机器码:
__ movl(rax, iaddress(n));
在HotSpot源码编译的预处理阶段,这句代码会被进行宏替换,替换之后的代码变成如下:
_masm->mov(rax, iaddress(n));
如此ilai,模板表便与汇编器关联上了,模板表的确是通过调用汇编器接口完成本地机器码指令的生成。不过你可能会向问,templateTable_x86_32.cpp中的_masm这个变量定义在哪里?啥时候初始化?这是一个很好的问题。对于TemplateTable类,其_masm变量定义如下:
// templateTable.hpp
// 模板表中汇编器变量定义
static InterpreterMacroAssembler* _masm; // the assembler used when generating templates
注意:TemplateTable模板表中的汇编器变量类型是静态的。汇编器变量在JVM启动期间,JVM调用字节码指令所对应的生成器函数时会对其进行赋值。在这里,不得不再次提起JVM的取指逻辑,前面在分析取指逻辑时对其进行过详细分析。在JVM启动期间,JVM会为所有字节码指令生成取指逻辑HotSpot通过TemplateInterpreterGenerator::generate_and_dispatch()接口来生成取指逻辑,前面分析过,HotSpot为每一个字节码指令生成取指逻辑的同时(其实是指取下一条字节码指令),会为该字节码指令生成器本身所对应的机器逻辑指令。在TemplateInterpreterGenerator::generate_and_dispatch()接口中,会先调用t->generate(_masm)函数,为当前字节码指令生成字节码本身的机器逻辑,接着才会调用_dispatch_epilog(tos_out, step)函数为该字节码指令生成其对应的取指逻辑(取下一条字节码指令)。注意观察,在调用t->generate(_masm)时,为其传递了一个_masm指针,这个指针也是汇编器,其实模板表的静态变量_masm函数便是在JVM调用t->generate(_masm)函数进行了赋值,看->generate(_masm)函数定义:
// templateTable.cpp
// 模板解释器的汇编器赋值
void Template::generate(InterpreterMacroAssembler* masm) {
// parameter passing
TemplateTable::_desc = this;
TemplateTable::_masm = masm;
// code generation
_gen(_arg);
masm->flush();
}
// templateTable.cpp
// 模板解释器的汇编器赋值
void Template::generate(InterpreterMacroAssembler* masm) {
// parameter passing
TemplateTable::_desc = this;
TemplateTable::_masm = masm;
// code generation
_gen(_arg);
masm->flush();
}
在调用该函数时会将汇编器作为参数传递进来,而在该函数中调用_gen(_arg)函数时,由于_gen是各个字节码指令所对应的本地机器码生成函数,这些生成函数都是TemplateTable类的静态函数,因此JVM在调用这些函数时,这些生成器函数会调用汇编器的接口生成本地机器码,生成器函数所调用的汇编器实际上便是Template::generate()函数所传入的汇编器。这里有点绕,还是以iload_1字节码指令为例来理一理思路,首先,JVM启动期间调用TemplateTable类的initialize()接口将每一个字节码指令与其生成器函数进行关联,例如iload_1字节码指令所关联的生成器函数就是TemplateTable::iload(int i),但是在这个阶段,TemplateTable::iload(int i)并不会被调用。接着,JVM启动期间会调用模板解释器(templateInterpreter)为每个字节码指令生成该字节码指令的本地机器码指令,同时会为该字节码生成对应的取指的本地机器码指令(取下一条字节码指令),这两个逻辑都封装在TemplateInterpreterGenerator::generate_and_dispatch()接口中,在该接口中调用t->generate(_masm)函数来为当前字节码指令生成机器指令,注意,模板表TemplateTable::_masm静态变量便在这个函数内部完成初始化,在t->generate(_masm)函数内部执行TemplateTable::_masm = masm将外部所传递进来的汇编器示例传递进TemplateTable模板表内部。t->generate(_masm)内部调用_gen(_arg)函数为字节码指令生成本地机器码。由于iload_1字节码指令对应的_gen是TemplateTable::iload(int i)函数,因此JVM会调用TemplateTable::iload(int i)函数。在TempalteTable::iload(int i)函数内部调用 __ movl(rax, iaddress(n))生成本地机器码。注意,这里的前缀"__"是两条下划线,这是一个宏,前面分析过,这个宏会在预处理阶段被替换成_masm->,因此JVM实际上调用的是TemplateTable::_masm->movl(rax, iaddress(n))函数。由于刚才在执行t->generate(_masm)函数时,JVM根据该函数所传入的汇编器_masm对TemplateTable::_masm变量进行了初始化,因此JVM所调用的便是传递给t->generate(_masm)函数的汇编器的movl(接口),其主题链路如下:
#1. jvm启动
...调用TemplateTable::initialize()
#2. TemplateTable::initialize()
#3. TemplateTable::def()
iload_1字节码指令的_gen生成器映射为TemplateTable::iload(int i)函数
#4.TemplateInterpreterGenerator::generate_and_dispatch()
#5. t->generate(_masm)
#6. _gen(_arg)
#7. __ movl(rax, iaddress(n))
这里实际调用的是TemplateTable::_masm->movl()
对照上面的文字看这条链路,思路应该会清晰很多。至此,关于TemplateTable模板表中的汇编器_masm什么时候被初始化的问题理出了头绪,既然TemplateTable模板表中的汇编器是在JVM调用t->generate(_masm)函数时被初始化,那么这里所传入的_masm汇编器又是啥呢?其实也很简单,轴线确定这里的_masm指针所声明的累。由于t->generate(_masm)函数在TemplateInterpreterGenerator::generate_and_dispatch()函数中被调用,因此可以确定TemplateInterpreterGenerator::generate_and_dispatch()函数里的_masm变量是TemplateInterpreterGenerator类中的变量,TemplateInterpreterGenerator是模板解释器生成器,所谓解释器生成器,是专门负责为模板解释器将字节码指令翻译生成本地机器码的类型。HotSpot内部有好几种解释器,其他解释器也有自己专门的生成器,负责将字节码指令解释为特定的逻辑。TemplateInterpreterGenerator继承自AbstractInterpreterGenerator类,AbstractInterpreterGenerator中便定义了一个汇编器指针
class AbstractInterpreterGenerator: public StackObj {
protected:
InterpreterMacroAssembler* _masm;
#1. jvm启动
...调用TemplateTable::initialize()
#2. TemplateTable::initialize()
#3. TemplateTable::def()
iload_1字节码指令的_gen生成器映射为TemplateTable::iload(int i)函数
#4.TemplateInterpreterGenerator::generate_and_dispatch()
#5. t->generate(_masm)
#6. _gen(_arg)
#7. __ movl(rax, iaddress(n))
这里实际调用的是TemplateTable::_masm->movl()
对照上面的文字看这条链路,思路应该会清晰很多。至此,关于TemplateTable模板表中的汇编器_masm什么时候被初始化的问题理出了头绪,既然TemplateTable模板表中的汇编器是在JVM调用t->generate(_masm)函数时被初始化,那么这里所传入的_masm汇编器又是啥呢?其实也很简单,轴线确定这里的_masm指针所声明的累。由于t->generate(_masm)函数在TemplateInterpreterGenerator::generate_and_dispatch()函数中被调用,因此可以确定TemplateInterpreterGenerator::generate_and_dispatch()函数里的_masm变量是TemplateInterpreterGenerator类中的变量,TemplateInterpreterGenerator是模板解释器生成器,所谓解释器生成器,是专门负责为模板解释器将字节码指令翻译生成本地机器码的类型。HotSpot内部有好几种解释器,其他解释器也有自己专门的生成器,负责将字节码指令解释为特定的逻辑。TemplateInterpreterGenerator继承自AbstractInterpreterGenerator类,AbstractInterpreterGenerator中便定义了一个汇编器指针
class AbstractInterpreterGenerator: public StackObj {
protected:
InterpreterMacroAssembler* _masm;
注意,这个类名是AbstractInterpreterGenerator,顾名思义,该类是"抽象解释器生成器",是解释器生成器的顶级父类。既然模板解释器生成器TemplateInterpreterGenerator继承自这个基类,那么TemplateInterpreterGenerator自然也继承了汇编器指针_masm,所以在TemplateInterpreterGenerator::generate_and_dispatch()函数中JVM才可以将汇编器指针直接传入Template::generate()函数中。至此,关于汇编器初始化的问题,便演化成TemplateInterpreterGenerator中的汇编器啥时候初始化的问题。其实,汇编器初始化的逻辑隐藏在CodeletMark类的构造函数中,该类的定义如下:
纵观整个HotSpot源码,只有在CodeletMark类的构造函数里对汇编器进行了实例化。CodeletMark其实是一个包装器,能够自动回收所分配的代码空间以及所实例化的汇编器。那么CodeletMark类与templateInterpreterGenerator类中的汇编器有啥关系呢?说到这里,就不得不先分析一下HotSpot字节码指令生成本地机器码的流程了。默认情况下HotSpot会启用模板解释器来解释执行字节码指令,在JVM启动期间,JVM会依次调用模板解释器的生成器为各个字节码指令生成对应的本地机器指令及各个字节码指令的取指逻辑,这个逻辑主要封装在TemplateInterpreterGenerator::generate_and_dispatch()这个函数中。该函数调用的整体链路如下:
1.java.c:: main() 调用LoadJavaVM()
2. java_md.c:: loadJavaVM() 调用ifn->CreateJavaVM= (CreateJavaVM_t)dlsym(libjvm, "JNI_CreateJavaVM")
3.jni.cpp:: _JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_CreateJavaVM(JavaVM **vm, void **penv, void *args)
调用result = Threads::create_vm((JavaVMInitArgs*) args, &can_try_again);
4.thread.cpp:: Thread::create_vm() 调用Init_globals()
5.init.cpp:: init_globals()调用interpreter_init()
6.interpreter.cpp::interpreter_init()调用Interpreter::initialize()
7.templateInterpreter.cpp:: TemplateInterpreter::initialize();
8.templateTable.cpp:: TemplateTable::initialize() 初始化模板表
9.templateInterpreter_x86_32.cpp:: InterpreterGenerator(_code)解释器生成器构造函数
templateInterpreter_x86_32.cpp:: TemplateInterpreterGenerator::generate_all()
10.templateInterpreter.cpp::TemplateInterpreterGenerator::set_entry_points_for_all_bytes()
11.templateInterpreter.cpp::TemplateInterpreterGenerator::set_entry_points()
12.templateInterpreter.cpp::TemplateInterpreterGenerator::set_short_entry_points()
13.templateInterpreter.cpp::TemplateInterpreterGenerator::generate_and_dispatch()
纵观整个HotSpot源码,只有在CodeletMark类的构造函数里对汇编器进行了实例化。CodeletMark其实是一个包装器,能够自动回收所分配的代码空间以及所实例化的汇编器。那么CodeletMark类与templateInterpreterGenerator类中的汇编器有啥关系呢?说到这里,就不得不先分析一下HotSpot字节码指令生成本地机器码的流程了。默认情况下HotSpot会启用模板解释器来解释执行字节码指令,在JVM启动期间,JVM会依次调用模板解释器的生成器为各个字节码指令生成对应的本地机器指令及各个字节码指令的取指逻辑,这个逻辑主要封装在TemplateInterpreterGenerator::generate_and_dispatch()这个函数中。该函数调用的整体链路如下:
1.java.c:: main() 调用LoadJavaVM()
2. java_md.c:: loadJavaVM() 调用ifn->CreateJavaVM= (CreateJavaVM_t)dlsym(libjvm, "JNI_CreateJavaVM")
3.jni.cpp:: _JNI_IMPORT_OR_EXPORT_ jint JNICALL JNI_CreateJavaVM(JavaVM **vm, void **penv, void *args)
调用result = Threads::create_vm((JavaVMInitArgs*) args, &can_try_again);
4.thread.cpp:: Thread::create_vm() 调用Init_globals()
5.init.cpp:: init_globals()调用interpreter_init()
6.interpreter.cpp::interpreter_init()调用Interpreter::initialize()
7.templateInterpreter.cpp:: TemplateInterpreter::initialize();
8.templateTable.cpp:: TemplateTable::initialize() 初始化模板表
9.templateInterpreter_x86_32.cpp:: InterpreterGenerator(_code)解释器生成器构造函数
templateInterpreter_x86_32.cpp:: TemplateInterpreterGenerator::generate_all()
10.templateInterpreter.cpp::TemplateInterpreterGenerator::set_entry_points_for_all_bytes()
11.templateInterpreter.cpp::TemplateInterpreterGenerator::set_entry_points()
12.templateInterpreter.cpp::TemplateInterpreterGenerator::set_short_entry_points()
13.templateInterpreter.cpp::TemplateInterpreterGenerator::generate_and_dispatch()
TemplateInterpreterGenerator::set_entry_points_for_all_bytes()函数的逻辑很好理解,遍历每一个字节码,,然后调用TemplateInterpreterGenerator::set_entry_points()函数为每个字节码指令设置入口点。关于入口点的概念,后面会分析,,这里将目光聚焦于TemplateInterpreterGenerator::set_entry_points()函数,该函数一开始便通过CodeletMark cm(_masm, Bytecodes::name(code), code)实例化了一个CodeletMark类对象,这里就是关键点。实例化CodeletMark时,向器构造函数传递了指针变量_masm,该指针便是TemplateInterpreterGenerator类从抽象解释器生成器AbstractInterpreterGenereator中继承而来的私有成员变量,该指针指向汇编器的内存首地址。在通过Codelet cm(_masm, Bytecodes::name(code), code)chuangjianCodeletMark类型实例对象时,TemplateInterpreterGenerator类中的私有成员变量_masm尚未初始化,但是前面分析到,在CodeletMark的构造函数中会实例化一个汇编器,并将外部所传入的汇编器指针指向这个所创建的汇编器实例对象。如此依赖,当TemplateInterpreterGenerator::set_entry_points()函数执行完CodeletMark cm(_masm, Bytecodes::name(code), code)之后,TemplateInterpreterGenerator类的私有成员变量_masm便完成初始化。
当TemplateInterpreterGenerator类的私有成员变量_masm完成初始化之后,则当调用到上面的链路图中的最后链路TemplateInterpreterGenerator类的私有成员变量_masm完成初始化之后,则当调用到上面的链路图中的最后链路TemplateInterpreterGenerator::generate_and_dispatch()时,JVM便将_masm汇编器指针通过t->generate(_masm)调用传递给了TemplateTable::_masm这个静态字段,最终在TemplateTable模板表中调用相关函数为字节码指令生成本地机器码时,实际所调用的便是TemplateTable::_masm这个静态字段所指向的汇编器实例的接口,那么,汇编器到底是什么呢?到底是怎么将字节码指令翻译成对应的机器码呢?
在CodeletMark的构造函数中,直接将_masm实例化称InterpreterMacroAssembler这个汇编器类型。事实上,在HotSpot内部,汇编器总共包含4个继承层次,如图所示(32位x86平台)
这4层汇编器所对应的文件分别如下:
# AbstractAssembler, assembler.hpp
# Assembler, assembler_x86.hpp
# MarcroAssembler, assembler_x86.hpp
# InterpreterMacroAssembler, interp_masm_x86_32.hpp
当TemplateInterpreterGenerator类的私有成员变量_masm完成初始化之后,则当调用到上面的链路图中的最后链路TemplateInterpreterGenerator类的私有成员变量_masm完成初始化之后,则当调用到上面的链路图中的最后链路TemplateInterpreterGenerator::generate_and_dispatch()时,JVM便将_masm汇编器指针通过t->generate(_masm)调用传递给了TemplateTable::_masm这个静态字段,最终在TemplateTable模板表中调用相关函数为字节码指令生成本地机器码时,实际所调用的便是TemplateTable::_masm这个静态字段所指向的汇编器实例的接口,那么,汇编器到底是什么呢?到底是怎么将字节码指令翻译成对应的机器码呢?
在CodeletMark的构造函数中,直接将_masm实例化称InterpreterMacroAssembler这个汇编器类型。事实上,在HotSpot内部,汇编器总共包含4个继承层次,如图所示(32位x86平台)
这4层汇编器所对应的文件分别如下:
# AbstractAssembler, assembler.hpp
# Assembler, assembler_x86.hpp
# MarcroAssembler, assembler_x86.hpp
# InterpreterMacroAssembler, interp_masm_x86_32.hpp
顶级汇编器是AbstractAssembler,故名思意,就是抽象汇编器,但是该抽象汇编器其实一点都不抽象,它定义了最核心的功能和数据结构,如下:
每一个汇编器都会往内存中写入一段指令,因此JVM会为每一个汇编器分配一个内存首地址,汇编器就从该首地址开始写入指令或数据。因此在这个抽象汇编器中定义了_code_begin、code_pos等字段,用于记录首地址及当前写入的位置。同时,抽象汇编器提供了写入本地机器指令的就扣,就是emit()系列的函数,可以写入一个8位、16位或者其他宽度的数据/指令,抽象汇编器的子类都依赖于这些核心功能生成机器指令。而在汇编器的继承体系中,继承层次越深的汇编器,其处理的业务越抽象和复杂,也与Java的字节码指令越靠近。同时,除了最顶级的抽象汇编器,其子类皆与具体的硬件平台相关,这很好理解,因为机器码本身就是硬件相关的,对于Assembler和MacroAssembler,在各平台相关的源码中都有定义,例如在assembler_sparc.hpp中也定义了这两个类。如果想深入理解HotSpot的解释执行原理,就必须对这几个汇编器子类有深入理解
每一个汇编器都会往内存中写入一段指令,因此JVM会为每一个汇编器分配一个内存首地址,汇编器就从该首地址开始写入指令或数据。因此在这个抽象汇编器中定义了_code_begin、code_pos等字段,用于记录首地址及当前写入的位置。同时,抽象汇编器提供了写入本地机器指令的就扣,就是emit()系列的函数,可以写入一个8位、16位或者其他宽度的数据/指令,抽象汇编器的子类都依赖于这些核心功能生成机器指令。而在汇编器的继承体系中,继承层次越深的汇编器,其处理的业务越抽象和复杂,也与Java的字节码指令越靠近。同时,除了最顶级的抽象汇编器,其子类皆与具体的硬件平台相关,这很好理解,因为机器码本身就是硬件相关的,对于Assembler和MacroAssembler,在各平台相关的源码中都有定义,例如在assembler_sparc.hpp中也定义了这两个类。如果想深入理解HotSpot的解释执行原理,就必须对这几个汇编器子类有深入理解
汇编。
HotSpot内部定义了4个c层次的汇编器,顶级的抽象汇编器提供了往缓冲区写入本地机器码指令和数据的接口,并记录所写入的其实地址与当前地址。例如,emit_int8()接口提供往指令缓冲区写入一个字节码宽度的指令/数据的能力,其实现如下:
void emit_int8( int8_t x) { code_section()->emit_int8( x); }
该函数在_code_section所指向的内存位置处写入一个输入的字节指令或数据,写入后,将_code_section指针再往前移动1字节,等待写入下一个机器指令。hotspot默认使用模板解释器,JVM在启动期间会初始化模板解释器,为每一个字节码指令在内存中写入其对应的机器指令片段,JVM在运行期便能够对字节码指令进行解释,当执行某一条字节码指令时,会读取其对应的机器指令并执行,从而完成Java逻辑运算。
位于汇编器继承体系第二层的是Assembler汇编器。Assembler其实是对物理机器指令的抽象,或者说软件封装。例如,在x86平台上,Assembler类中的接口如下:
// assembler_x86.hpp
// x86平台上的Assembler定义
class Assembler : public AbstractAssembler {
// ....
void decl(Register dst);
void decl(Address dst);
void decq(Register dst);
void decq(Address dst);
void incl(Register dst);
void incl(Address dst);
void incq(Register dst);
void incq(Address dst);
void lea(Register dst, Address src);
void mov(Register dst, Register src);
void pusha();
void popa();
void pushf();
void popf();
void push(int32_t imm32);
void push(Register src);
void pop(Register dst);
HotSpot内部定义了4个c层次的汇编器,顶级的抽象汇编器提供了往缓冲区写入本地机器码指令和数据的接口,并记录所写入的其实地址与当前地址。例如,emit_int8()接口提供往指令缓冲区写入一个字节码宽度的指令/数据的能力,其实现如下:
void emit_int8( int8_t x) { code_section()->emit_int8( x); }
该函数在_code_section所指向的内存位置处写入一个输入的字节指令或数据,写入后,将_code_section指针再往前移动1字节,等待写入下一个机器指令。hotspot默认使用模板解释器,JVM在启动期间会初始化模板解释器,为每一个字节码指令在内存中写入其对应的机器指令片段,JVM在运行期便能够对字节码指令进行解释,当执行某一条字节码指令时,会读取其对应的机器指令并执行,从而完成Java逻辑运算。
位于汇编器继承体系第二层的是Assembler汇编器。Assembler其实是对物理机器指令的抽象,或者说软件封装。例如,在x86平台上,Assembler类中的接口如下:
// assembler_x86.hpp
// x86平台上的Assembler定义
class Assembler : public AbstractAssembler {
// ....
void decl(Register dst);
void decl(Address dst);
void decq(Register dst);
void decq(Address dst);
void incl(Register dst);
void incl(Address dst);
void incq(Register dst);
void incq(Address dst);
void lea(Register dst, Address src);
void mov(Register dst, Register src);
void pusha();
void popa();
void pushf();
void popf();
void push(int32_t imm32);
void push(Register src);
void pop(Register dst);
如果你熟悉汇编指令,应该对上面Assembler类中的这些dec()、inc()和lea()和mov()等接口感到非常亲切。例如x86平台提供的压栈指令中有一种指令格式是push reg,于是Assembler类中便提供了一个接口push(Register src),这表示将一个硬件寄存器的值压入栈中。另一种压栈指令s是push operand,这表示将一个立即数压入栈中,于是Assembler类中便提供了一个接口push(int32_t imm32).Assembler类中的这些接口基本是对物理机器指令的"纯净"模拟,即最终所生成出来的机器码与原生的机器指令是完全一直的,HotSpot并不会往里面加入别的"杂项".例如以压栈指令为例,在x86平台上,将一个立即数压栈的接口实现如下:
void Assembler::push(int32_t imm32) {
// in 64bits we push 64bits onto the stack but only
// take a 32bit immediate
emit_int8(0x68);
emit_int32(imm32);
}
push()函数里面调用emit()系列的接口往指令缓存区先写入0x68这个字节,再将imm32这个32位宽的立即数写入指令缓存区。emit()系列的接口就是顶级抽象汇编器所提供的核心接口。0x68是Intel处理器所提供的push机器指令的十六进制编码,例如push 2,则对应的机器码0x68 02,因此,Assembler::push(int32_t imm32)这个接口最终会向指令缓存区中写入下面这条机器码:
push imm32
这与实际的机器指令是一致的。因此,在HotSpot内部,调用Assembler::push(int32_t imm32)接口,可以完全等同于调用物理机器的push operand这个机器指令。所以Assembler类基本就是对物理机器指令的完全抽象,并且是"纯净版"的软抽象。在汇编器继承体系中,到了Assembler汇编器的下一层,便是MacroAssembler汇编器,这一层的汇编器仍然基于机器指令进行抽象,但是不再是"纯净版"的抽象和模拟,而是被打上了深深的Java烙印,很多指令能够直接为Java数据所用。看看MacroAssembler在x86平台上的定义:
class MacroAssembler: public Assembler {
// ....
// C++ bool manipulation
// bool类型数据传送
void movbool(Register dst, Address src);
void movbool(Address dst, bool boolconst);
void movbool(Address dst, Register src);
void testbool(Register dst);
// oop manipulations
// 加载/存储Java类的元数据klass
void load_klass(Register dst, Register src);
void store_klass(Register dst, Register src);
// 从JVM堆内存中加载Java对象实例
void load_heap_oop(Register dst, Address src);
void load_heap_oop_not_null(Register dst, Address src);
void store_heap_oop(Address dst, Register src);
注意观察MacroAssembler类所提供的接口,可以发现,也有类似物理机器级别的mov、add等指令,但是MacroAssembler类更近一步,延伸出movbool这种传送布尔值的接口,同时能够从JVM堆内存中读取Java类实例对象。再如,在物理机器级别,求和指令是add oprd1, oprd2.但是CPU堆add后面的两个操作数有要求,oprd1和oprd2均为寄存器是允许的,一个为寄存器而另一个为存储器也是允许的,但不允许两个都是存储器操作数,即不允许两个操作数都来自内存,或者一个来自内存一个来自立即数。但是在MacroAssembler汇编器中提供了接口void addptr(Address dst, int32_t src),该接口想想实现的目标是将一个存储器操作数和一个立即数累加求和,由于物理机器不支持这种指令,因此MacroAssemlber只能对这个接口进行复杂处理
void Assembler::push(int32_t imm32) {
// in 64bits we push 64bits onto the stack but only
// take a 32bit immediate
emit_int8(0x68);
emit_int32(imm32);
}
push()函数里面调用emit()系列的接口往指令缓存区先写入0x68这个字节,再将imm32这个32位宽的立即数写入指令缓存区。emit()系列的接口就是顶级抽象汇编器所提供的核心接口。0x68是Intel处理器所提供的push机器指令的十六进制编码,例如push 2,则对应的机器码0x68 02,因此,Assembler::push(int32_t imm32)这个接口最终会向指令缓存区中写入下面这条机器码:
push imm32
这与实际的机器指令是一致的。因此,在HotSpot内部,调用Assembler::push(int32_t imm32)接口,可以完全等同于调用物理机器的push operand这个机器指令。所以Assembler类基本就是对物理机器指令的完全抽象,并且是"纯净版"的软抽象。在汇编器继承体系中,到了Assembler汇编器的下一层,便是MacroAssembler汇编器,这一层的汇编器仍然基于机器指令进行抽象,但是不再是"纯净版"的抽象和模拟,而是被打上了深深的Java烙印,很多指令能够直接为Java数据所用。看看MacroAssembler在x86平台上的定义:
class MacroAssembler: public Assembler {
// ....
// C++ bool manipulation
// bool类型数据传送
void movbool(Register dst, Address src);
void movbool(Address dst, bool boolconst);
void movbool(Address dst, Register src);
void testbool(Register dst);
// oop manipulations
// 加载/存储Java类的元数据klass
void load_klass(Register dst, Register src);
void store_klass(Register dst, Register src);
// 从JVM堆内存中加载Java对象实例
void load_heap_oop(Register dst, Address src);
void load_heap_oop_not_null(Register dst, Address src);
void store_heap_oop(Address dst, Register src);
注意观察MacroAssembler类所提供的接口,可以发现,也有类似物理机器级别的mov、add等指令,但是MacroAssembler类更近一步,延伸出movbool这种传送布尔值的接口,同时能够从JVM堆内存中读取Java类实例对象。再如,在物理机器级别,求和指令是add oprd1, oprd2.但是CPU堆add后面的两个操作数有要求,oprd1和oprd2均为寄存器是允许的,一个为寄存器而另一个为存储器也是允许的,但不允许两个都是存储器操作数,即不允许两个操作数都来自内存,或者一个来自内存一个来自立即数。但是在MacroAssembler汇编器中提供了接口void addptr(Address dst, int32_t src),该接口想想实现的目标是将一个存储器操作数和一个立即数累加求和,由于物理机器不支持这种指令,因此MacroAssemlber只能对这个接口进行复杂处理
void Assembler::addl(Address dst, int32_t imm32) {
InstructionMark im(this);
prefix(dst);
emit_arith_operand(0x81, rax, dst, imm32);
}
void Assembler::emit_arith_operand(int op1, Register rm, Address adr, int32_t imm32) {
assert((op1 & 0x01) == 1, "should be 32bit operation");
assert((op1 & 0x02) == 0, "sign-extension bit should not be set");
if (is8bit(imm32)) {
emit_int8(op1 | 0x02); // set sign bit
emit_operand(rm, adr, 1);
emit_int8(imm32 & 0xFF);
} else {
emit_int8(op1);
emit_operand(rm, adr, 4);
emit_int32(imm32);
}
}
例如,在32位x86平台上,该接口最终调用如下函数才能实现累加。
可以看到,对于这种物理机器不支持的指令,JVM内部需要生成多条机器指令去处理,才能完成MacroAssembler汇编器中所定义的一个接口功能。因此,MacroAssembler汇编器可以被看作对物理机器指令的组合封装(也可以认为是对其父类汇编器Assembler的组合封装),同时MacroAssembler能够支持Java内部数据对象级别的机器指令原语操作,从而为JVM内部的解释器完成特定逻辑提供必要的支撑。总之,汇编器模块应该代表了整个虚拟机中最精华的部分,而能否将精华全部吸收,完全就看个人的能力和修为。不过对于并非从事JVM开发的道友而言,倒没必要面面俱到,只要领悟了其思想便可。
在汇编的继承体系中,MacroAssembler汇编器的下一层是InterpreterMacroAssembler汇编器,顾名思义,这是解释器级别的汇编器,其直接为解释器提供相关汇编接口,
InstructionMark im(this);
prefix(dst);
emit_arith_operand(0x81, rax, dst, imm32);
}
void Assembler::emit_arith_operand(int op1, Register rm, Address adr, int32_t imm32) {
assert((op1 & 0x01) == 1, "should be 32bit operation");
assert((op1 & 0x02) == 0, "sign-extension bit should not be set");
if (is8bit(imm32)) {
emit_int8(op1 | 0x02); // set sign bit
emit_operand(rm, adr, 1);
emit_int8(imm32 & 0xFF);
} else {
emit_int8(op1);
emit_operand(rm, adr, 4);
emit_int32(imm32);
}
}
例如,在32位x86平台上,该接口最终调用如下函数才能实现累加。
可以看到,对于这种物理机器不支持的指令,JVM内部需要生成多条机器指令去处理,才能完成MacroAssembler汇编器中所定义的一个接口功能。因此,MacroAssembler汇编器可以被看作对物理机器指令的组合封装(也可以认为是对其父类汇编器Assembler的组合封装),同时MacroAssembler能够支持Java内部数据对象级别的机器指令原语操作,从而为JVM内部的解释器完成特定逻辑提供必要的支撑。总之,汇编器模块应该代表了整个虚拟机中最精华的部分,而能否将精华全部吸收,完全就看个人的能力和修为。不过对于并非从事JVM开发的道友而言,倒没必要面面俱到,只要领悟了其思想便可。
在汇编的继承体系中,MacroAssembler汇编器的下一层是InterpreterMacroAssembler汇编器,顾名思义,这是解释器级别的汇编器,其直接为解释器提供相关汇编接口,
// interp_masm_x86_32.cpp
public:
InterpreterMacroAssembler(CodeBuffer* code) : MacroAssembler(code), _locals_register(rdi), _bcp_register(rsi) {}
// Helpers for runtime call arguments/results
// 运行时获取参数或相关结果
void get_method(Register reg) { movptr(reg, Address(rbp, frame::interpreter_frame_method_offset * wordSize)); }
void get_const(Register reg) { get_method(reg); movptr(reg, Address(reg, Method::const_offset())); }
void get_constant_pool(Register reg) { get_const(reg); movptr(reg, Address(reg, ConstMethod::constants_offset())); }
void get_constant_pool_cache(Register reg) { get_constant_pool(reg); movptr(reg, Address(reg, ConstantPool::cache_offset_in_bytes())); }
void get_cpool_and_tags(Register cpool, Register tags) { get_constant_pool(cpool); movptr(tags, Address(cpool, ConstantPool::tags_offset_in_bytes()));
}
void get_unsigned_2_byte_index_at_bcp(Register reg, int bcp_offset);
void get_cache_and_index_at_bcp(Register cache, Register index, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_and_index_and_bytecode_at_bcp(Register cache, Register index, Register bytecode, int byte_no, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_entry_pointer_at_bcp(Register cache, Register tmp, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_index_at_bcp(Register index, int bcp_offset, size_t index_size = sizeof(u2));
void get_method_counters(Register method, Register mcs, Label& skip);
// load cpool->resolved_references(index);
void load_resolved_reference_at_index(Register result, Register index);
// Expression stack
// 操作数相关指令
void f2ieee(); // truncate ftos to 32bits
void d2ieee(); // truncate dtos to 64bits
void pop_ptr(Register r = rax);
void pop_i(Register r = rax);
void pop_l(Register lo = rax, Register hi = rdx);
void pop_f();
// Dispatching
// 取指相关指令
void dispatch_prolog(TosState state, int step = 0);
void dispatch_epilog(TosState state, int step = 0);
void dispatch_only(TosState state); // dispatch via rbx, (assume rbx, is loaded already)
void dispatch_only_normal(TosState state); // dispatch normal table via rbx, (assume rbx, is loaded already)
void dispatch_only_noverify(TosState state);
void dispatch_next(TosState state, int step = 0); // load rbx, from [esi + step] and dispatch via rbx,
void dispatch_via (TosState state, address* table); // load rbx, from [esi] and dispatch via rbx, and table
// jump to an invoked target
void prepare_to_jump_from_interpreted();
这个类中的接口分类比较明确,主要分为获取运行时参数相关的指令、操作数栈相关的指令、取指相关的指令等。事实上,还有性能监控的指令。由于HotSpot是一款基于栈式指令集的虚拟机,因此在InterpreterMacroAssembler类中可以看到各种pop和push指令接口,例如将不同类型的数据压栈的指令包括push_ptr()、push_i()和push_l()等。同时,对于任何一个执行引擎而言,取指指令都是必须支持的(物理机器事实上没有软件取指指令,直接通过硬件数字电路触发),因此InterpreterMacroAssembler类内部定义了各种取指相关的接口,这些接口统一以dispatch为前缀。其实,在JVM内部,取指也可以叫做分发,dispatch的直接含义,这有其特殊的技术原因。同时JVM内部再调用方法之前会创建栈帧,再动态绑定时进行运行期连接,这些操作都需要解释器能够在运行期读取各种参数。例如,Java方法在JVM内部所对应的method对象实例、Java class字节码文件常量池在Jvm内存中的映像、字节码所对应的入口点缓存等,所以在InterpreterMacroAssembler类中提供了get_method()、get_cosntant_pool()等接口。以上这些核心的接口,将支撑起JVM内部解释器的运行期的各种调用,使得解释器能够站在JVM虚拟机这个层面或这个高度来看待问题,或者说能够以高度抽象的思维来思考问题,而不仅仅局限于物理机器指令的各种原子化的琐碎的指令逻辑上。其实这本质上也是一种面向对象的抽象思维,一层一层地抽象,抽象通过软件函数的封装得以体现,函数所封装的不仅仅是函数,是能力。从机器到汇编,从汇编到B语言,从B到C语言,从C到C++,从C++到Java,其实就是在一路抽象,一路封装,汇编是机器指令的简单符号代替,而C语言中的一个接口便封装了无数汇编能力,一个Java接口其实也封装了若干C和机器指令的特性。从这个角度去分析应用系统,也不难看出应用系统要分层的原因了。
public:
InterpreterMacroAssembler(CodeBuffer* code) : MacroAssembler(code), _locals_register(rdi), _bcp_register(rsi) {}
// Helpers for runtime call arguments/results
// 运行时获取参数或相关结果
void get_method(Register reg) { movptr(reg, Address(rbp, frame::interpreter_frame_method_offset * wordSize)); }
void get_const(Register reg) { get_method(reg); movptr(reg, Address(reg, Method::const_offset())); }
void get_constant_pool(Register reg) { get_const(reg); movptr(reg, Address(reg, ConstMethod::constants_offset())); }
void get_constant_pool_cache(Register reg) { get_constant_pool(reg); movptr(reg, Address(reg, ConstantPool::cache_offset_in_bytes())); }
void get_cpool_and_tags(Register cpool, Register tags) { get_constant_pool(cpool); movptr(tags, Address(cpool, ConstantPool::tags_offset_in_bytes()));
}
void get_unsigned_2_byte_index_at_bcp(Register reg, int bcp_offset);
void get_cache_and_index_at_bcp(Register cache, Register index, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_and_index_and_bytecode_at_bcp(Register cache, Register index, Register bytecode, int byte_no, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_entry_pointer_at_bcp(Register cache, Register tmp, int bcp_offset, size_t index_size = sizeof(u2));
void get_cache_index_at_bcp(Register index, int bcp_offset, size_t index_size = sizeof(u2));
void get_method_counters(Register method, Register mcs, Label& skip);
// load cpool->resolved_references(index);
void load_resolved_reference_at_index(Register result, Register index);
// Expression stack
// 操作数相关指令
void f2ieee(); // truncate ftos to 32bits
void d2ieee(); // truncate dtos to 64bits
void pop_ptr(Register r = rax);
void pop_i(Register r = rax);
void pop_l(Register lo = rax, Register hi = rdx);
void pop_f();
// Dispatching
// 取指相关指令
void dispatch_prolog(TosState state, int step = 0);
void dispatch_epilog(TosState state, int step = 0);
void dispatch_only(TosState state); // dispatch via rbx, (assume rbx, is loaded already)
void dispatch_only_normal(TosState state); // dispatch normal table via rbx, (assume rbx, is loaded already)
void dispatch_only_noverify(TosState state);
void dispatch_next(TosState state, int step = 0); // load rbx, from [esi + step] and dispatch via rbx,
void dispatch_via (TosState state, address* table); // load rbx, from [esi] and dispatch via rbx, and table
// jump to an invoked target
void prepare_to_jump_from_interpreted();
这个类中的接口分类比较明确,主要分为获取运行时参数相关的指令、操作数栈相关的指令、取指相关的指令等。事实上,还有性能监控的指令。由于HotSpot是一款基于栈式指令集的虚拟机,因此在InterpreterMacroAssembler类中可以看到各种pop和push指令接口,例如将不同类型的数据压栈的指令包括push_ptr()、push_i()和push_l()等。同时,对于任何一个执行引擎而言,取指指令都是必须支持的(物理机器事实上没有软件取指指令,直接通过硬件数字电路触发),因此InterpreterMacroAssembler类内部定义了各种取指相关的接口,这些接口统一以dispatch为前缀。其实,在JVM内部,取指也可以叫做分发,dispatch的直接含义,这有其特殊的技术原因。同时JVM内部再调用方法之前会创建栈帧,再动态绑定时进行运行期连接,这些操作都需要解释器能够在运行期读取各种参数。例如,Java方法在JVM内部所对应的method对象实例、Java class字节码文件常量池在Jvm内存中的映像、字节码所对应的入口点缓存等,所以在InterpreterMacroAssembler类中提供了get_method()、get_cosntant_pool()等接口。以上这些核心的接口,将支撑起JVM内部解释器的运行期的各种调用,使得解释器能够站在JVM虚拟机这个层面或这个高度来看待问题,或者说能够以高度抽象的思维来思考问题,而不仅仅局限于物理机器指令的各种原子化的琐碎的指令逻辑上。其实这本质上也是一种面向对象的抽象思维,一层一层地抽象,抽象通过软件函数的封装得以体现,函数所封装的不仅仅是函数,是能力。从机器到汇编,从汇编到B语言,从B到C语言,从C到C++,从C++到Java,其实就是在一路抽象,一路封装,汇编是机器指令的简单符号代替,而C语言中的一个接口便封装了无数汇编能力,一个Java接口其实也封装了若干C和机器指令的特性。从这个角度去分析应用系统,也不难看出应用系统要分层的原因了。
在理解了这4层汇编器之后,再回头看Java的字节码指令。在这4层汇编器里,似乎并没有看到对Java字节码指令的汇编。事实上,字节码指令所对应的汇编生成器都在TemplateTable模板表中,模板表为每个Java字节码指令提供了本地机器码生成器接口(或者解释入口),在生成器接口中调用汇编器生成字节码指令的本地机器逻辑。仍以iload_1这条字节码指令为例,其对应的生成器接口是TemplateTable::iload(int i),该接口仅通过调用__ movl(rax, iaddress(n))来生成机器码,这条指令会在预处理阶段将宏替换为_masm->movl(rax, iaddress(n)),_masm便是汇编器,movl(rax, iaddress(n))函数实现最终会生成如下机器码:
mov -0x4(%edi), %eax
z在前面分析Java方法堆栈的时候说过,当JVM调用一个Java函数之前会为其创建栈帧空间,被调用Java方法的局部变量表也会同时被创建,创建完栈帧之后,JVM便会将edi寄存器指向局部变量表的第0个slot位置。因此上面的这条机器指令的含义是:取相对于局部变量表第0个slot偏移量为4字节位置处(即第一个int型局部变量)的变量,将其值传送到eax寄存器中。如果局部变量表的slot索引号大于3,则将该变量推送至操作数栈栈顶的字节码指令便是iload,而不是iload_0、iload_1、iload_2及iload_3.例如下面这个Java示例程序.在Test类中定义了一个静态方法add()其包含3个入参,方法内部包含两个局部变量。由于是静态方法,因此没隐式的this入参,所以3个入参的slot索引号分别是0、1、2,而内部的两个局部变量的slot索引号则分别是3和4.add()方法最终要将第2个局部变量sum2的值返回出去,因此必然会涉及到一次iload操作。同时由于sum2在局部变量表中的slot索引号是4,大于3,因此将其推送至操作数栈栈顶的字节码指令一定是iload 4,而add()方法的入参和其内部局部变量的入栈指令则分别是iload_0、iload_1、iload_2、iload_3。javap的输出结果如下。从输出结果可以看出,编译器实际所生成的load系列指令与所推论的完全一致。
mov -0x4(%edi), %eax
z在前面分析Java方法堆栈的时候说过,当JVM调用一个Java函数之前会为其创建栈帧空间,被调用Java方法的局部变量表也会同时被创建,创建完栈帧之后,JVM便会将edi寄存器指向局部变量表的第0个slot位置。因此上面的这条机器指令的含义是:取相对于局部变量表第0个slot偏移量为4字节位置处(即第一个int型局部变量)的变量,将其值传送到eax寄存器中。如果局部变量表的slot索引号大于3,则将该变量推送至操作数栈栈顶的字节码指令便是iload,而不是iload_0、iload_1、iload_2及iload_3.例如下面这个Java示例程序.在Test类中定义了一个静态方法add()其包含3个入参,方法内部包含两个局部变量。由于是静态方法,因此没隐式的this入参,所以3个入参的slot索引号分别是0、1、2,而内部的两个局部变量的slot索引号则分别是3和4.add()方法最终要将第2个局部变量sum2的值返回出去,因此必然会涉及到一次iload操作。同时由于sum2在局部变量表中的slot索引号是4,大于3,因此将其推送至操作数栈栈顶的字节码指令一定是iload 4,而add()方法的入参和其内部局部变量的入栈指令则分别是iload_0、iload_1、iload_2、iload_3。javap的输出结果如下。从输出结果可以看出,编译器实际所生成的load系列指令与所推论的完全一致。
对于iload 4这样的字节码指令,在模板表TemplateTable中为其所绑定的本地机器生成器函数是TemplateTable::iload(),其定义如下:
忽略该函数中的if (RewriteFrequentPairs) {} 分支,该函数的主要逻辑便只剩下locals_index(rbx)和__ movl(rax, iaddress(rbx)),其中locals_index(rbx)顾名思义就是将局部变量的slot索引号读取到rbx寄存器中,而__ movl(rax, iaddress(rbx))则将指定slot索引号的局部变量读取到rax寄存器中。最终,所生成的TemplateTable::iload()的本地机器逻辑如下:
--取字节码指令的操作数
--esi寄存器指向当前字节码指令的起始地址
--假设字节码指令是iload 6.则这条机器码将读取iload这个字节码指令所在内存位置的下一个字节,下一个字节的值是操作数4
movzbl 0x1(%esi), %ebx
--求补操作
neg %ebx
--edi指向局部变量表的第0个slot位置
--上一步将iload 4的操作数4读进了ebx寄存器中,因此这里对第0个slot偏移6个位置,将该位置的局部变量值读进eax寄存器中
mov (%edi, %ebx, 4), %eax
如果对汇编语法很熟悉,则很容易读懂这几行汇编的逻辑,这便是iload字节码指令的机器实现逻辑。
忽略该函数中的if (RewriteFrequentPairs) {} 分支,该函数的主要逻辑便只剩下locals_index(rbx)和__ movl(rax, iaddress(rbx)),其中locals_index(rbx)顾名思义就是将局部变量的slot索引号读取到rbx寄存器中,而__ movl(rax, iaddress(rbx))则将指定slot索引号的局部变量读取到rax寄存器中。最终,所生成的TemplateTable::iload()的本地机器逻辑如下:
--取字节码指令的操作数
--esi寄存器指向当前字节码指令的起始地址
--假设字节码指令是iload 6.则这条机器码将读取iload这个字节码指令所在内存位置的下一个字节,下一个字节的值是操作数4
movzbl 0x1(%esi), %ebx
--求补操作
neg %ebx
--edi指向局部变量表的第0个slot位置
--上一步将iload 4的操作数4读进了ebx寄存器中,因此这里对第0个slot偏移6个位置,将该位置的局部变量值读进eax寄存器中
mov (%edi, %ebx, 4), %eax
如果对汇编语法很熟悉,则很容易读懂这几行汇编的逻辑,这便是iload字节码指令的机器实现逻辑。
上面分别演示了iload_1和iload_2这2条字节码指令的本地及其实现,虽然不能因此而言尽JJVM执行引擎的全部细节,但是足以管中窥豹,把握JVM执行引擎的整体脉络,一通而百通。而事实上,HotSpot为了提升性能而设计了模板解释器这种直接生成机器指令的机制,但是在一开始可没有这么高大上,一开始的解释器真的只是解释器,并不是通过模板解释器将字节码指令直接翻译成对应的机器码去执行,而是直接使用C语言逻辑去解释字节码指令。在HotSpot中至今仍然保留着最原始的字节码解释器,其对应的文件bytecodeInterpreter.cpp
从这里可以看到,字节码解释器在解释iload指令时,调用SET_STACK_SLOT(LOCALS_SLOT(pc[1]), 0);函数将局部变量推送至操作数栈栈顶。该函数其实是一个宏,同时其里面的入参函数LOCALS_SLOT也是一个宏,这2个宏的定义如下(x86平台):
// 入参pc[1]是iload指令后面的操作数,该操作数即为局部变量的slot索引号
// 该宏返回指定slot索引号的局部变量值
#define LOCALS_SLOT(offset) ((intptr_t*)&locals[-(offset)])
// 将操作数栈栈顶指定位置的存储单元复制为value
#define SET_STACK_SLOT(value, offset) (*(intptr_t*)&topOfStack[-(offset)] = *(intptr_t*)(value))
通过这2个宏定义可以看出,使用C语言所实现的iload字节码指令的逻辑,与模板解释器中直接使用本地机器指令所实现的逻辑是一致的。解释iload字节码指令时,都是先从iload指令中解析出跟随在该操作码之后的操作数,该操作数便是局部变量的slot索引号,解释西根据该索引号取出局部变量的值,最终将该数值传送到操作数栈栈顶。
由于字节码解释器使用C/C++逻辑来解释字节码指令,使用C/C++的解释逻辑在静态编译阶段所生成的机器指令,肯定要比模板解释器中人工生成的机器指令繁琐和冗长,因此解释效率相当低,所以如今的JVM不再使用它了,但是源码仍然保留。
从这里可以看到,字节码解释器在解释iload指令时,调用SET_STACK_SLOT(LOCALS_SLOT(pc[1]), 0);函数将局部变量推送至操作数栈栈顶。该函数其实是一个宏,同时其里面的入参函数LOCALS_SLOT也是一个宏,这2个宏的定义如下(x86平台):
// 入参pc[1]是iload指令后面的操作数,该操作数即为局部变量的slot索引号
// 该宏返回指定slot索引号的局部变量值
#define LOCALS_SLOT(offset) ((intptr_t*)&locals[-(offset)])
// 将操作数栈栈顶指定位置的存储单元复制为value
#define SET_STACK_SLOT(value, offset) (*(intptr_t*)&topOfStack[-(offset)] = *(intptr_t*)(value))
通过这2个宏定义可以看出,使用C语言所实现的iload字节码指令的逻辑,与模板解释器中直接使用本地机器指令所实现的逻辑是一致的。解释iload字节码指令时,都是先从iload指令中解析出跟随在该操作码之后的操作数,该操作数便是局部变量的slot索引号,解释西根据该索引号取出局部变量的值,最终将该数值传送到操作数栈栈顶。
由于字节码解释器使用C/C++逻辑来解释字节码指令,使用C/C++的解释逻辑在静态编译阶段所生成的机器指令,肯定要比模板解释器中人工生成的机器指令繁琐和冗长,因此解释效率相当低,所以如今的JVM不再使用它了,但是源码仍然保留。
栈顶缓存。
前面分析过,当JVM使用模板解释器运行字节码指令时,最后生成的iload_1这条指令的本地机器码如下(32位x86平台):
mov -0x4(%edi), %eax
HotSpot提供了工具HSDIS可以使用该工具打印模板解释器为各个字节码指令所生成的本地机器码,可以据此验证实际生成的机器码是否与源码中所写的一致。HotSpot在运行期所打印的iload_1的本地机器逻辑如图所示(32位x86平台)
根据TemplateTable::iload(int n)这个iload_1字节码指令的生成器函数接口的代码逻辑,分析出只会生成mov -0x4(%edi), %eax这一条机器指令,可是JVM工具所打印出来的机器逻辑竟然有这么多,其中包含了mov -0x4(%edi), %eax这条指令,很奇怪吧!难道之前的分析存在问题?
前面分析过,当JVM使用模板解释器运行字节码指令时,最后生成的iload_1这条指令的本地机器码如下(32位x86平台):
mov -0x4(%edi), %eax
HotSpot提供了工具HSDIS可以使用该工具打印模板解释器为各个字节码指令所生成的本地机器码,可以据此验证实际生成的机器码是否与源码中所写的一致。HotSpot在运行期所打印的iload_1的本地机器逻辑如图所示(32位x86平台)
根据TemplateTable::iload(int n)这个iload_1字节码指令的生成器函数接口的代码逻辑,分析出只会生成mov -0x4(%edi), %eax这一条机器指令,可是JVM工具所打印出来的机器逻辑竟然有这么多,其中包含了mov -0x4(%edi), %eax这条指令,很奇怪吧!难道之前的分析存在问题?
为了进一步确认,可以在看看iload这条字节码指令。前面分析过,模板解释器为该字节码指令所生成的本地机器逻辑如下(32位x86平台):
movzbl 0x1(%esi), %ebx
neg %ebx
mov (%edi, %ebx, 4), %eax
z再看看JVM工具在运行时所生成的指令,可以看到,iload指令也存在同样的问题.
可以看到,iload指令也存在同样的问题,实际所生成的本地机器逻辑与模板表中的生成器接口所设计的逻辑不一致。弹道这里面真的存在什么问题不成?
事实上,模板表中所定义的生成器接口中所设计的逻辑没有错,JVM工具打印出来的本地机器逻辑也没有错,所有的问题都与一个关键的优化措施有关,这个优化措施便是"栈顶缓存"。可能绝大多数人第一眼看到栈顶缓存,脑子里都会打上一个大大的问号,这是什么东西,栈顶就栈顶,为何还要加个缓存?缓存加在哪里?有什么好处?
movzbl 0x1(%esi), %ebx
neg %ebx
mov (%edi, %ebx, 4), %eax
z再看看JVM工具在运行时所生成的指令,可以看到,iload指令也存在同样的问题.
可以看到,iload指令也存在同样的问题,实际所生成的本地机器逻辑与模板表中的生成器接口所设计的逻辑不一致。弹道这里面真的存在什么问题不成?
事实上,模板表中所定义的生成器接口中所设计的逻辑没有错,JVM工具打印出来的本地机器逻辑也没有错,所有的问题都与一个关键的优化措施有关,这个优化措施便是"栈顶缓存"。可能绝大多数人第一眼看到栈顶缓存,脑子里都会打上一个大大的问号,这是什么东西,栈顶就栈顶,为何还要加个缓存?缓存加在哪里?有什么好处?
在讲述JVM的栈顶缓存概念之前,先讲一讲Java开发中的缓存。有过Java开发及优化经验的基本都用过缓存技术,例如查询DB之前,通常会先查询缓存,如果缓存中没有,再去查询DB.下面给出一段伪代码用于表述这种逻辑:
相信这一段示例程序对于大部分Java道友而言,都不会陌生。在Java程序中使用缓存,可以减轻DB负担,从而提升应用程序的响应速度。这种缓存可以是本地缓存,也可以是远程的缓存集群。。但是不管是本地缓存还是远程的缓存中心,数据应该都是存放在内存单元中,肯定不可能存放在磁盘中,否则还不如直接查询DB.JVM中的所谓栈顶缓存,与之完全不同。栈顶缓存的数据通过寄存器来暂存,并非内存。对于CPU而言,一方面,就读取速度而言,CPU从寄存器中读取速度最快,其次是内存,再次是磁盘。CPU从寄存器中读取数据的速度往往比内存中读取要快上好几个数量级(例如百倍),这种熟读方面的差异非常大,毕竟相差百倍以上,,这是任何一个优秀的系统设计师都不能避免的问题。另一方面,CPU在执行运算时,例如做加法运算,并不能直接对两个内存中的数据直接进行求和,要么将两个数据全部从内存读取到寄存器中然后对两个寄存器进行求和,要么将一个数据读取进寄存器e而另一个保留在内存中,这种规则本身没有问题,问题的关键在于,寄存器的数量是相当稀少的,一个CPU能够集成几十个寄存器便已经是奢侈至极,比起现代计算机的内存动辄就是8GB、16GB甚至32GB的巨大空间,这真的是沧海一粟。由于寄存器的数量很稀少,因此CPU往往在空间和效率上不能两全。例如要对两个数据进行求和,最快的方式当然是CPU直接对内存中的两个数据累加,但是CPU并不支持这种运算,因此在对两个数据进行求和之前,只能将其中一个数据先读取进寄存器,或者将两个数据全部读进寄存器然后才能进行累加。这中间必然存在效率上的牺牲,既然对内存数据进行运算这么慢,那将数据全部放进寄存器不就行了吗?这种方式并不可行,寄存器的数量是极其稀少的,因此编译器在将高级语言编译为机器语言时,会将数据加载进内存,而非全部加载进寄存器。
相信这一段示例程序对于大部分Java道友而言,都不会陌生。在Java程序中使用缓存,可以减轻DB负担,从而提升应用程序的响应速度。这种缓存可以是本地缓存,也可以是远程的缓存集群。。但是不管是本地缓存还是远程的缓存中心,数据应该都是存放在内存单元中,肯定不可能存放在磁盘中,否则还不如直接查询DB.JVM中的所谓栈顶缓存,与之完全不同。栈顶缓存的数据通过寄存器来暂存,并非内存。对于CPU而言,一方面,就读取速度而言,CPU从寄存器中读取速度最快,其次是内存,再次是磁盘。CPU从寄存器中读取数据的速度往往比内存中读取要快上好几个数量级(例如百倍),这种熟读方面的差异非常大,毕竟相差百倍以上,,这是任何一个优秀的系统设计师都不能避免的问题。另一方面,CPU在执行运算时,例如做加法运算,并不能直接对两个内存中的数据直接进行求和,要么将两个数据全部从内存读取到寄存器中然后对两个寄存器进行求和,要么将一个数据读取进寄存器e而另一个保留在内存中,这种规则本身没有问题,问题的关键在于,寄存器的数量是相当稀少的,一个CPU能够集成几十个寄存器便已经是奢侈至极,比起现代计算机的内存动辄就是8GB、16GB甚至32GB的巨大空间,这真的是沧海一粟。由于寄存器的数量很稀少,因此CPU往往在空间和效率上不能两全。例如要对两个数据进行求和,最快的方式当然是CPU直接对内存中的两个数据累加,但是CPU并不支持这种运算,因此在对两个数据进行求和之前,只能将其中一个数据先读取进寄存器,或者将两个数据全部读进寄存器然后才能进行累加。这中间必然存在效率上的牺牲,既然对内存数据进行运算这么慢,那将数据全部放进寄存器不就行了吗?这种方式并不可行,寄存器的数量是极其稀少的,因此编译器在将高级语言编译为机器语言时,会将数据加载进内存,而非全部加载进寄存器。
JVM的栈顶缓存正是针对CPU这种在时间和空间上不能两全的遗憾e而进行的改进措施,当然,这种改进措施蕴含了计算机运行机制的精华,也是JVM执行引擎的精华,由于CPU无法同时兼顾时间与空间,而JVM追求的则是性能,因此只能舍弃空间,这种取舍的结果便是,模板解释器在执行操作数栈操作时,如果按照常规的思路,肯定会将数据直接压入栈顶,栈顶其实也是内存存储单元,但是JVM并没有走寻常路,为了追求性能,JVM在执行操作数栈相关操作时,会优先将数据传送到寄存器,而非真正的栈顶。在后续流程中,CPU执行运算时,便无需将数据再从栈顶传送到寄存器,因为数据本来就缓存在寄存器中,这便节省了一次内存读写,从而提升了JVM虚拟机运算指令的执行效率。这便是栈顶缓存。这种机制与Java应用开发中使用缓存中间件的思路类似,只不过对于栈顶缓存而言,缓存介质时硬件寄存器,而非内存单元。
由于寄存器的数量十分有限,多的也就几十个,因此JVM并不是每次都能将数据存进寄存器,所以在将数据存进寄存器(即栈顶缓存)之前,必须先判断寄存器中是否有数据,如果有数据,得先想办法将数据移走,然后才能将当前操作的数据存进去。另外,Java内建的数据类型很丰富,因此栈顶缓存的数据类型也是五花八门,什么都有,JVM在将这些数据移走时必须考虑真实的数据类型,对待不同的数据类型处理逻辑也是不同的,所以JVM在解释一个字节码指令时,需要包含处理栈顶不同类型数据的逻辑,这便是前面使用JVM工具打印运行时所输出的iload_1和iload_2这2个字节码指令的本地机器码时代码比想象中要多得多的原因,这一点其实与Java应用开发的缓存策略类似,在Java应用开发中,如果要查询满足某种条件的数据,则需要考虑缓存中是否有数据,如果有,就从读取缓存的入口进入获取结果集,否则就从读取DB的入口进入获取结果集。很多应用为了进一步提升性能,会考虑两级缓存——本地缓存和远程缓存,本地缓存通常缓存热点数据,将热点数据加载到本地内存,从而避免访问远程缓存中心时的网络开销,从而在命中率超过80%的情况下,应用的整体性能将会提升。因此,在Java应用开发中要访问数据,通常也会有很多"入口点",如果本地一级缓存中有数据,就从中取数,否则就判断远程缓存中是否有,若有,则从远程缓存中取数,否则就只能查询DB.这些查询一级缓存才能和二级缓存的前置逻辑,便类似于Java字节码指令处理的前置逻辑,都是处理缓存。
由于寄存器的数量十分有限,多的也就几十个,因此JVM并不是每次都能将数据存进寄存器,所以在将数据存进寄存器(即栈顶缓存)之前,必须先判断寄存器中是否有数据,如果有数据,得先想办法将数据移走,然后才能将当前操作的数据存进去。另外,Java内建的数据类型很丰富,因此栈顶缓存的数据类型也是五花八门,什么都有,JVM在将这些数据移走时必须考虑真实的数据类型,对待不同的数据类型处理逻辑也是不同的,所以JVM在解释一个字节码指令时,需要包含处理栈顶不同类型数据的逻辑,这便是前面使用JVM工具打印运行时所输出的iload_1和iload_2这2个字节码指令的本地机器码时代码比想象中要多得多的原因,这一点其实与Java应用开发的缓存策略类似,在Java应用开发中,如果要查询满足某种条件的数据,则需要考虑缓存中是否有数据,如果有,就从读取缓存的入口进入获取结果集,否则就从读取DB的入口进入获取结果集。很多应用为了进一步提升性能,会考虑两级缓存——本地缓存和远程缓存,本地缓存通常缓存热点数据,将热点数据加载到本地内存,从而避免访问远程缓存中心时的网络开销,从而在命中率超过80%的情况下,应用的整体性能将会提升。因此,在Java应用开发中要访问数据,通常也会有很多"入口点",如果本地一级缓存中有数据,就从中取数,否则就判断远程缓存中是否有,若有,则从远程缓存中取数,否则就只能查询DB.这些查询一级缓存才能和二级缓存的前置逻辑,便类似于Java字节码指令处理的前置逻辑,都是处理缓存。
以iload_1这条字节码指令为例,当栈顶缓存为空时,则JVM会直接将slot索引号为1的局部变量传送到寄存器ax中(x86平台),并不需要先将栈顶数据移出去,因此JVM在运行期会直接从如下入口进入:
而当栈顶缓存不为空时,例如栈顶缓存此时已经存储了一个int型的数据,则JVM在执行iload_1字节码指令时,需要先将栈顶缓存中的数据移到其本来应该存储的内存位置,然后再将slot索引号为1的局部变量传送到栈顶缓存(即寄存器)中,此时iload_1的进入点如下:
这一次的入口点变成了push %eax,其作用是将eax寄存器(即栈顶缓存)中的数据先推送至操作数栈栈顶。
这一次的入口点变成了push %eax,其作用是将eax寄存器(即栈顶缓存)中的数据先推送至操作数栈栈顶。
那么iload_1在什么情况下,在执行之前,栈顶缓存是空的呢?可以看下面这个例子
观察这一段字节码指令,在执行iload_1之前,先执行了bipush 8 和istore_2,这2条字节码指令将完成为变量z赋值的逻辑。istore_2字节码指令执行完成之后,由于栈顶并没有数据等待操作,用不着将数据缓存在寄存器中,因此此时寄存器中没有数据。在这种情况下,当执行iload_1指令时,JVM便会直接从iload_1的本地机器码mov -0x4(%edi), %eax开始执行
观察这一段字节码指令,在执行iload_1之前,先执行了bipush 8 和istore_2,这2条字节码指令将完成为变量z赋值的逻辑。istore_2字节码指令执行完成之后,由于栈顶并没有数据等待操作,用不着将数据缓存在寄存器中,因此此时寄存器中没有数据。在这种情况下,当执行iload_1指令时,JVM便会直接从iload_1的本地机器码mov -0x4(%edi), %eax开始执行
修改上面的Test类,改成如图所示。注意观察,此时在执行iload_1指令之前,JVM必须先执行iload_0这条指令。iload_0指令执行完之后,由于使用了栈顶缓存策略,因此JVM会将slot索引号为0的局部变量直接传送到寄存器指针(即栈顶缓存)中,而不是直接将该局部变量推送至栈顶,如此一来,栈顶缓存(即寄存器)中便有数据了,并且是int类型的。接着JVM开始执行iload_1这条指令,由于栈顶缓存中已经有数据,因此JVM必须先将iload_0所加载的数据从栈顶缓存中移走,所以JVM执行iload_1指令时就必须从push %eax这条机器码开始执行,该指令会将iload_0指令加载到栈顶缓存的数据推送至操作数栈栈顶。
栈式指令集
Java的字节码指令都是面向栈的,面向栈的指令集往往有一个缺点:不需要指定操作数,专业术语就是"零地址"指令。例如,在Java中对两个int类型的数据求和,其对应的字节码指令是iadd.乍一看,这种指令很是让人感觉无厘头,不是说好的对两个数求和的吗?书呢?这种指令的写法与通常意义上我们所见到的假发表达式格式相去甚远,因此有点让人莫名其妙。在数学中,对两个数求和,司空见惯的一种表达式肯定是下面这种:
x = y + z
这种表达式非常简单易明了,随便一个人一看都知道是在对两个数求和。不仅计算器讲究封装的艺术,数学也讲究封装,而事实上,数学里的封装要算得上计算机封装的老祖宗了,现代计算机的诞生其实是数学概念抽象的结果,抽象便是封装。将上面这个数学表达式抽象成一个数学函数,变成:
add(x, y, z)
计算机本来就是用于处理数字信号的,对于这种函数,某些CPU天生能支持,例如,ARM处理器。CPU在处理这种数学函数时,会通过约定的指令格式来完成。例如,上面这个函数翻译成计算器指令,可以如下:
add, x, y, z
这便是物理计算机支持的硬件指令,这种指令能够对操作码后面的两个数求和,并将累加结果保存到操作码后面的第一个操作数中。将该指令的表达形式进行抽象,可以得到下面这种一般意义上的指令格式:
op dest, src1, src2
这种指令格式的含义是: 对op操作码后面的两个操作数src1和src2进行某种操作,并将操作结果保存到op操作码后面的第一个操作数dest中,有了这种通用的表达式,计算机便能够支持各种数学运算,例如,对两个数执行减法运算,指令如下:
sub dest, src1, src2
其实,类似"op dest, src1, src2"这种格式的指令,在计算机中叫做"三地址"指令,所谓三地址指令,其实就是指c操作码op的后面跟了3个操作数。由于在计算机内部,数据都存储在内存或寄存器中,因此op操作码后面的操作数通常都是指某个内存单元编号或者某个特定的寄存器,所以叫做"三地址"。
不过有些CPU设计者认为三地址指令太长,需要简化。例如,x = y + z这种表达式可以简化成y+=z,这种表达式仍然能够对两个数进行求和,并将求和结果保存到其中一个数字之中,对y+=z表达式重新编排,可以写成下面这种格式:
+=y, z
乍一看,这种格式很奇怪,但是如果将其使用数学函数来表达,则可以抽象成下面这种函数:
add(y,z)
这样的数学函数表达式大家都懂,如果让计算机指令来支持这种数学函数,则可以写成下面这种格式:
add y, z
x86系列的处理器便能够直接执行这种指令对两个数进行求和。若使用C语言编写程序计算x = y + z,则在x86平台上编译后便会生成类似上面这条指令的机器码。将该指令进行抽象可以得到下面这种通用的指令格式:
op dest, src
这种指令的含义是: 对op操作码后面的两个操作数进行某种运算,并将运算结果保存在dest第一个操作数中,这种格式的指令使用专业术语叫做"二地址"指令,意思很明确,操作码后面跟了2个操作数。相比于三地址指令,二地址指令所需要的内存k空间变小了,并且整个指令也更加精简。
Java的字节码指令都是面向栈的,面向栈的指令集往往有一个缺点:不需要指定操作数,专业术语就是"零地址"指令。例如,在Java中对两个int类型的数据求和,其对应的字节码指令是iadd.乍一看,这种指令很是让人感觉无厘头,不是说好的对两个数求和的吗?书呢?这种指令的写法与通常意义上我们所见到的假发表达式格式相去甚远,因此有点让人莫名其妙。在数学中,对两个数求和,司空见惯的一种表达式肯定是下面这种:
x = y + z
这种表达式非常简单易明了,随便一个人一看都知道是在对两个数求和。不仅计算器讲究封装的艺术,数学也讲究封装,而事实上,数学里的封装要算得上计算机封装的老祖宗了,现代计算机的诞生其实是数学概念抽象的结果,抽象便是封装。将上面这个数学表达式抽象成一个数学函数,变成:
add(x, y, z)
计算机本来就是用于处理数字信号的,对于这种函数,某些CPU天生能支持,例如,ARM处理器。CPU在处理这种数学函数时,会通过约定的指令格式来完成。例如,上面这个函数翻译成计算器指令,可以如下:
add, x, y, z
这便是物理计算机支持的硬件指令,这种指令能够对操作码后面的两个数求和,并将累加结果保存到操作码后面的第一个操作数中。将该指令的表达形式进行抽象,可以得到下面这种一般意义上的指令格式:
op dest, src1, src2
这种指令格式的含义是: 对op操作码后面的两个操作数src1和src2进行某种操作,并将操作结果保存到op操作码后面的第一个操作数dest中,有了这种通用的表达式,计算机便能够支持各种数学运算,例如,对两个数执行减法运算,指令如下:
sub dest, src1, src2
其实,类似"op dest, src1, src2"这种格式的指令,在计算机中叫做"三地址"指令,所谓三地址指令,其实就是指c操作码op的后面跟了3个操作数。由于在计算机内部,数据都存储在内存或寄存器中,因此op操作码后面的操作数通常都是指某个内存单元编号或者某个特定的寄存器,所以叫做"三地址"。
不过有些CPU设计者认为三地址指令太长,需要简化。例如,x = y + z这种表达式可以简化成y+=z,这种表达式仍然能够对两个数进行求和,并将求和结果保存到其中一个数字之中,对y+=z表达式重新编排,可以写成下面这种格式:
+=y, z
乍一看,这种格式很奇怪,但是如果将其使用数学函数来表达,则可以抽象成下面这种函数:
add(y,z)
这样的数学函数表达式大家都懂,如果让计算机指令来支持这种数学函数,则可以写成下面这种格式:
add y, z
x86系列的处理器便能够直接执行这种指令对两个数进行求和。若使用C语言编写程序计算x = y + z,则在x86平台上编译后便会生成类似上面这条指令的机器码。将该指令进行抽象可以得到下面这种通用的指令格式:
op dest, src
这种指令的含义是: 对op操作码后面的两个操作数进行某种运算,并将运算结果保存在dest第一个操作数中,这种格式的指令使用专业术语叫做"二地址"指令,意思很明确,操作码后面跟了2个操作数。相比于三地址指令,二地址指令所需要的内存k空间变小了,并且整个指令也更加精简。
上面这两种指令格式,无论是三地址格式还是二地址,就i指令格式本身而言,普罗大众都比较容易根据指令格式而明白其功能,并且这种格式也符合数学领域中的函数抽象,相比于Java中的iadd这种"零地址"的指令格式,更加容易被人接受。其实零地址这种格式的指令会让人产生慌乱,慌乱的原因是:不知道这种指令对谁执行累加。而无论是e二元地址还是三元地址的指令,至少所操作的目标数据都包含在指令中。
事实上,大凡设计成"零地址"格式的指令集,通常都是面向栈的指令集,既然是面向栈的,则指令所操作的元数据和目标数据,便默认存放于栈上。而上面二元地址和三元地址的指令集,更多的是基于寄存器的架构,所操作的数据直接位于寄存器中(当然也有部分位于内存单元中)。之所以二元和三元地址指令集大多数直接面向寄存器,是因为这种多元地址指令后面所跟随的操作数可以直接被指定为目标寄存器,而CPU读取寄存器的性能要远远高于内存,因此能够直接基于寄存器运算的指令,没必要设计成面向栈式操作,否则反而不美。而零地址指令往往不能设计成面向寄存器操作,或者说设计难度很大,因为不同的硬件平台,其所继承的寄存器数量、内部标识、指令格式并不相同,而零地址指令由于并不直接包含操作数,因此无法明确指定所操作的数据到底位于哪个寄存器中,所以不能很容易地设计成面向寄存器操作,只能设计成基于栈操作,因为不管何种计算器,栈的概念都是被支持的,并且语义也是一致的。t同时由于这种特性,因此寄存器式指令集一般是硬件平台相关的,而栈式指令集则能跨平台。例如,在x86平台上执行两个数据相加,可以直接在指令中所操作的元数据位于哪个寄存器,以及结果数据位于哪个寄存器,例如下面这条指令:
add %ax, %bx
这条求和指令直接指定源数据分别位于ax和bx这两个通用寄存器中,并且操作结果在bx寄存器中(AT&T语法)。而如果使用栈式指令进行求和,例如Java中的iadd指令,假设该指令是基于寄存器的,那么问题来了,iadd本身就是一条完整的指令,并不包含任何操作数,那么其所操作的源数据到底在哪两个寄存器中呢?ax ? bx? 不知道,32位的x86CPU有8个32位通用寄存器,而SUN的SPARC处理器则有24个通用急促请你,寄存器是如此之多,而iadd指令又无法指定所操作的源数据位于那里,因此如果硬生生将零地址指令设计成面向寄存器的,难度可想而知。
进一步说,即使规定默认的寄存器地址而将基于栈的指令集设计成面向寄存器的架构,其灵活性也会大打折扣。例如,在86平台上能够支持直接对一个内存单元和一个寄存器求和,例如:
add (%ax), %bx
该指令将ax寄存器所指的内存地址的数据与bx寄存器进行累加,并将累加结果保存进bx寄存器(AT&T语法)。对于这种灵活的求和指令,很显然,零地址的指令集根本支持不了。JVM由于刚"出道"时便以"跨平台"作为最大的特性大力宣传,因此其指令集便选择了零地址的格式,而零地址的指令集又只能选择基于栈的架构,实在是有其深刻的技术制约因素。由于指令集是面向栈的,因此JVM的执行引擎内部也只能紧紧围绕栈来实现,前面所说的栈顶缓存便是针对堆栈而进行的一项优化措施,其优化d的思路还是优先使用寄存器。需要注意的是,这里所言的栈是指"求值栈",而不是指系统调用栈(system call stack)。有些虚拟机把求值栈实现在系统调用栈上,但两者概念上不是一个东西。
事实上,大凡设计成"零地址"格式的指令集,通常都是面向栈的指令集,既然是面向栈的,则指令所操作的元数据和目标数据,便默认存放于栈上。而上面二元地址和三元地址的指令集,更多的是基于寄存器的架构,所操作的数据直接位于寄存器中(当然也有部分位于内存单元中)。之所以二元和三元地址指令集大多数直接面向寄存器,是因为这种多元地址指令后面所跟随的操作数可以直接被指定为目标寄存器,而CPU读取寄存器的性能要远远高于内存,因此能够直接基于寄存器运算的指令,没必要设计成面向栈式操作,否则反而不美。而零地址指令往往不能设计成面向寄存器操作,或者说设计难度很大,因为不同的硬件平台,其所继承的寄存器数量、内部标识、指令格式并不相同,而零地址指令由于并不直接包含操作数,因此无法明确指定所操作的数据到底位于哪个寄存器中,所以不能很容易地设计成面向寄存器操作,只能设计成基于栈操作,因为不管何种计算器,栈的概念都是被支持的,并且语义也是一致的。t同时由于这种特性,因此寄存器式指令集一般是硬件平台相关的,而栈式指令集则能跨平台。例如,在x86平台上执行两个数据相加,可以直接在指令中所操作的元数据位于哪个寄存器,以及结果数据位于哪个寄存器,例如下面这条指令:
add %ax, %bx
这条求和指令直接指定源数据分别位于ax和bx这两个通用寄存器中,并且操作结果在bx寄存器中(AT&T语法)。而如果使用栈式指令进行求和,例如Java中的iadd指令,假设该指令是基于寄存器的,那么问题来了,iadd本身就是一条完整的指令,并不包含任何操作数,那么其所操作的源数据到底在哪两个寄存器中呢?ax ? bx? 不知道,32位的x86CPU有8个32位通用寄存器,而SUN的SPARC处理器则有24个通用急促请你,寄存器是如此之多,而iadd指令又无法指定所操作的源数据位于那里,因此如果硬生生将零地址指令设计成面向寄存器的,难度可想而知。
进一步说,即使规定默认的寄存器地址而将基于栈的指令集设计成面向寄存器的架构,其灵活性也会大打折扣。例如,在86平台上能够支持直接对一个内存单元和一个寄存器求和,例如:
add (%ax), %bx
该指令将ax寄存器所指的内存地址的数据与bx寄存器进行累加,并将累加结果保存进bx寄存器(AT&T语法)。对于这种灵活的求和指令,很显然,零地址的指令集根本支持不了。JVM由于刚"出道"时便以"跨平台"作为最大的特性大力宣传,因此其指令集便选择了零地址的格式,而零地址的指令集又只能选择基于栈的架构,实在是有其深刻的技术制约因素。由于指令集是面向栈的,因此JVM的执行引擎内部也只能紧紧围绕栈来实现,前面所说的栈顶缓存便是针对堆栈而进行的一项优化措施,其优化d的思路还是优先使用寄存器。需要注意的是,这里所言的栈是指"求值栈",而不是指系统调用栈(system call stack)。有些虚拟机把求值栈实现在系统调用栈上,但两者概念上不是一个东西。
栈式指令集既然是零地址格式,那就意味着其操作的源数据与目的数据皆位于求值栈栈顶。既然所操作的数据位于栈顶,那么在操作之前,必然会有指令将数据传送到栈顶,所以使用一条寄存器式指令便能实现的逻辑,往往需要多条栈式指令方能实现。例如,在JVM执行iadd指令进行求和之前,必定会有iload系列或者iconst或者bipush等将数据推送至栈顶的前置指令。例如下面这个示例。
观察这段字节码指令,在iadd指令之前,存在两条前置指令iload_1和iload_2,这两条指令分别将slot索引号为1和2的int类型的数据推送至操作数栈栈顶,这两个推送的数据其实便是接下来iadd指令的源操作数。那么iadd指令执行完成之后,所求取的结果s数据存储在那里呢?这得先从字节码指令执行的原理说起。JVM在启动阶段会为所有字节码指令生成本地机器码指令。而本地机器码指令大多数是基于寄存器的。c从这个角度来看,JVM的栈式指令集其实只能算作是"伪指令"。因为一方面,JVM并不具备真正具有运算能力的硬件数字电路,另一方面,JVM的字节码指令最终仍然需要基于寄存器架构的本地机器指令才能执行。对于iadd指令而言,使用HSDIS工具在运行期打印该指令的本地指令如下(32位x86平台):
pop %eax
pop %edx
add %edx, %eax
movzbl 0x(%esi), %ebx
inc %esi
jmp *-0x48f106a0( , %ebx, 4)
先执行pop %eax和pop %edx指令将栈顶的两个数据分别弹出至eax和edx这两个寄存器中,接着执行add %edx,%eax指令对这两个数据进行求和,并将求和结果在存储进eax寄存器中,其实这里仍然使用了栈顶缓存策略——优先将数据存储进寄存器。但是事实上,按照JVM内部对iadd指令的定义,该指令执行完成之后,数据应该会被存储进栈顶——因为整个JVM的执行引擎和指令集都是面向栈操作的。
观察这段字节码指令,在iadd指令之前,存在两条前置指令iload_1和iload_2,这两条指令分别将slot索引号为1和2的int类型的数据推送至操作数栈栈顶,这两个推送的数据其实便是接下来iadd指令的源操作数。那么iadd指令执行完成之后,所求取的结果s数据存储在那里呢?这得先从字节码指令执行的原理说起。JVM在启动阶段会为所有字节码指令生成本地机器码指令。而本地机器码指令大多数是基于寄存器的。c从这个角度来看,JVM的栈式指令集其实只能算作是"伪指令"。因为一方面,JVM并不具备真正具有运算能力的硬件数字电路,另一方面,JVM的字节码指令最终仍然需要基于寄存器架构的本地机器指令才能执行。对于iadd指令而言,使用HSDIS工具在运行期打印该指令的本地指令如下(32位x86平台):
pop %eax
pop %edx
add %edx, %eax
movzbl 0x(%esi), %ebx
inc %esi
jmp *-0x48f106a0( , %ebx, 4)
先执行pop %eax和pop %edx指令将栈顶的两个数据分别弹出至eax和edx这两个寄存器中,接着执行add %edx,%eax指令对这两个数据进行求和,并将求和结果在存储进eax寄存器中,其实这里仍然使用了栈顶缓存策略——优先将数据存储进寄存器。但是事实上,按照JVM内部对iadd指令的定义,该指令执行完成之后,数据应该会被存储进栈顶——因为整个JVM的执行引擎和指令集都是面向栈操作的。
前面分析了JVM的指令集为何事面向栈的,以及为何被设计成零地址格式和栈式指令集的实现方式。总体而言,虚拟机选择栈式指令集有利有弊,好处大抵有如下几个:
# 编译器更加容易设计和实现。因为不用考虑y一堆寄存器了,只需要关注栈顶即可
# 指令集非常精简小巧。因为大部分指令都是零地址格式,一个字节就能完成某种操作,所占内存空间也小
# 跨平台。虽然不同的硬件平台,其寄存器和机器级别的指令格式不同,但是JVM不管这些,只关注栈,栈总是所有硬件平台都要支持的一种内存结构,或者说即使没有栈,只要有内存即可
但是得到这些好处的同时,也带来了如下硬伤:
# 实现同样的功能,需要更多的指令。以求和为例,基于寄存器的指令集只需要执行add dest, src这样的指令便能完成求和,而栈式指令集则需要先load再add,需要多条指令。所以虽然单个指令变小了,但是完成一个功能需要更多的指令,总体上其实并没有精简太多,甚至局部反而更加繁琐
# 性能下降。这是由于栈式指令集需要更多的指令才能完成某个功能,并且完成同一个功能所对应的本地机器码比原本使用纯粹的基于寄存器指令更多,CPU需要更多的时钟周期,需要制造更多电子脉冲。
JVM之所以s使用栈式指令集,是为了跨平台,但是同样使用Java语法规范的Android技术体系,却直接使用了寄存器式指令集。安卓4之前,安卓使用Dalvik虚拟机来解释执行安卓字节码文件,虽然HotSpot与Dalvik同样都是虚拟机,但是为何Dalvik没有使用栈式指令集呢?问题就出在跨平台上。安卓刚出道时,所针对的就是ARM系列处理器,该处理器有16个32位通用寄存器,而Dalvik里面则涉及了16个虚拟寄存器,再运行期,Dalvik会将虚拟寄存器架构的优势。所以安卓系统一开始仅仅是借用了Java跨平台的语法,但是并没有为其打造一颗跨平台的"心脏",所以安卓并不能直接移植到其他异构的手机平台上。
由于JVM的性能比较低,因此大神们想尽了各种办法来优化性能,,各种奇思妙想层出不穷,各路大招层见迭出,所用技能让人叹为观止!HotSpot内部解释器从最原始的字节码解释(以C语言函数逻辑解释执行字节码)升级到模板解释器(直接生成本地机器码),解决了原始解释器效率低下的突出问题(很严重,严重到被C/C++程序员嘲笑)。接着加入了JIT编译器,其能够再运行期针对热点代码进行即时编译,JIT使用了多种优化策略使得编译出来的代码指令更高效,例如将代码内联减少字节码的跳转次数,能够加快热点代码的运行效率。HotSpot还针对客户端和服务端分别开发了C1和C2两层编译优化功能,C1和C2属于动态自适应编译器,,其中C1编译器仅做了简单优化,这主要是因为客户端的JVM虚拟机追求快速启动、快速相应,如果编译时间过长,反而影响客户端体验,而C2则会做深层次的优化,这种编译器编译出来的代码质量较高,但是因此需要较长的编译时间成本,所以仅适合用于服务端的JVM,JIT、C1、C2这些优化技术太过于底层,并且需要很深厚的编译原理内功。
# 编译器更加容易设计和实现。因为不用考虑y一堆寄存器了,只需要关注栈顶即可
# 指令集非常精简小巧。因为大部分指令都是零地址格式,一个字节就能完成某种操作,所占内存空间也小
# 跨平台。虽然不同的硬件平台,其寄存器和机器级别的指令格式不同,但是JVM不管这些,只关注栈,栈总是所有硬件平台都要支持的一种内存结构,或者说即使没有栈,只要有内存即可
但是得到这些好处的同时,也带来了如下硬伤:
# 实现同样的功能,需要更多的指令。以求和为例,基于寄存器的指令集只需要执行add dest, src这样的指令便能完成求和,而栈式指令集则需要先load再add,需要多条指令。所以虽然单个指令变小了,但是完成一个功能需要更多的指令,总体上其实并没有精简太多,甚至局部反而更加繁琐
# 性能下降。这是由于栈式指令集需要更多的指令才能完成某个功能,并且完成同一个功能所对应的本地机器码比原本使用纯粹的基于寄存器指令更多,CPU需要更多的时钟周期,需要制造更多电子脉冲。
JVM之所以s使用栈式指令集,是为了跨平台,但是同样使用Java语法规范的Android技术体系,却直接使用了寄存器式指令集。安卓4之前,安卓使用Dalvik虚拟机来解释执行安卓字节码文件,虽然HotSpot与Dalvik同样都是虚拟机,但是为何Dalvik没有使用栈式指令集呢?问题就出在跨平台上。安卓刚出道时,所针对的就是ARM系列处理器,该处理器有16个32位通用寄存器,而Dalvik里面则涉及了16个虚拟寄存器,再运行期,Dalvik会将虚拟寄存器架构的优势。所以安卓系统一开始仅仅是借用了Java跨平台的语法,但是并没有为其打造一颗跨平台的"心脏",所以安卓并不能直接移植到其他异构的手机平台上。
由于JVM的性能比较低,因此大神们想尽了各种办法来优化性能,,各种奇思妙想层出不穷,各路大招层见迭出,所用技能让人叹为观止!HotSpot内部解释器从最原始的字节码解释(以C语言函数逻辑解释执行字节码)升级到模板解释器(直接生成本地机器码),解决了原始解释器效率低下的突出问题(很严重,严重到被C/C++程序员嘲笑)。接着加入了JIT编译器,其能够再运行期针对热点代码进行即时编译,JIT使用了多种优化策略使得编译出来的代码指令更高效,例如将代码内联减少字节码的跳转次数,能够加快热点代码的运行效率。HotSpot还针对客户端和服务端分别开发了C1和C2两层编译优化功能,C1和C2属于动态自适应编译器,,其中C1编译器仅做了简单优化,这主要是因为客户端的JVM虚拟机追求快速启动、快速相应,如果编译时间过长,反而影响客户端体验,而C2则会做深层次的优化,这种编译器编译出来的代码质量较高,但是因此需要较长的编译时间成本,所以仅适合用于服务端的JVM,JIT、C1、C2这些优化技术太过于底层,并且需要很深厚的编译原理内功。
但是对于JIT等优化技术也不能过于迷信,使用JIT编译出来的代码指令的执行效率并不一定就比直接解释执行的效率高,甚至如果一个方法并不是经常被调用,那么可能执行一次JIT编译的时间成本都要高于直接解释执行一次该方法的时间。。但是很多时候使用JIT编译所获得的代码质量,却比使用C/C++代码完成同样的Java逻辑的代码质量还要高,这才是JIT迷人的地方。这里举一个例子来比较使用模板解释器和使用JIT、C1和C2编译器所生成的本地代码,Java示例如下:
该示例的main()主函数中使用for循环重复调用calc(int,int)方法,并且循环了10万次。之所以要循环这么多次,是为了让JVM能够"侦查"到calc(int,int)方法是一个热点代码。对于热点代码,HotSpot会调用JIT即时编译器将其编译成高质量的本地代码。同时,在calc(int, int)方法里面进行了比较复杂的运算,之所以设计得比较复杂,是为了接下来要比较C1和C2这两款编译器所生成得本地机器指令得质量,如果算法简单,JVM可能压根就不开启C2分层优化编译器,这样便无法比较。
该示例的main()主函数中使用for循环重复调用calc(int,int)方法,并且循环了10万次。之所以要循环这么多次,是为了让JVM能够"侦查"到calc(int,int)方法是一个热点代码。对于热点代码,HotSpot会调用JIT即时编译器将其编译成高质量的本地代码。同时,在calc(int, int)方法里面进行了比较复杂的运算,之所以设计得比较复杂,是为了接下来要比较C1和C2这两款编译器所生成得本地机器指令得质量,如果算法简单,JVM可能压根就不开启C2分层优化编译器,这样便无法比较。
使用HSDIS插件(或JITWatch)可以查看运行后JIT所生成的本地汇编指令,所生成的本地汇编代码分为C1版和C2版(即C1编译器和C2编译器分别生成的本地指令),其中C1版如下。观察上面C1编译器所生成的机器指令,HHSDIS工具给出Test.calc()方法入参的载体,这两个入参的载体分别是:
# parm0: rdx = int
# parm1: r8 = int
这表示Test.calc(int i, int j)的入参i的值将被从main()主函数传递给rdx寄存器,而入参j的值将被传递给r8寄存器。C1编译器所生成的核心算法指令从地址0x000001c1e9c4eadb开始,到地址0x000001b869d0dc3e结束,一共包含6条机器指令,如下:
0x000001c1e9c4eadb: mov %rdx,%rax
0x000001c1e9c4eade: add $0x211,%eax
0x000001c1e9c4eae4: sub %r8d,%eax
0x000001c1e9c4eae7: imul %edx,%eax
0x000001c1e9c4eaea: sub $0x5,%eax
0x000001c1e9c4eaed: imul %r8d,%eax
这6条机器指令完成Test.calc(int,int)方法内部的算法逻辑,懂汇编的道友一看就会明白这6条指令的含义,的确与Test.calc(int,int)方法的逻辑完全保持一致。这6条指令之前有11条机器指令完成相关的准备工作,这6条指令执行完成之后,再执行3条指令便会执行retq指令而结束。
# parm0: rdx = int
# parm1: r8 = int
这表示Test.calc(int i, int j)的入参i的值将被从main()主函数传递给rdx寄存器,而入参j的值将被传递给r8寄存器。C1编译器所生成的核心算法指令从地址0x000001c1e9c4eadb开始,到地址0x000001b869d0dc3e结束,一共包含6条机器指令,如下:
0x000001c1e9c4eadb: mov %rdx,%rax
0x000001c1e9c4eade: add $0x211,%eax
0x000001c1e9c4eae4: sub %r8d,%eax
0x000001c1e9c4eae7: imul %edx,%eax
0x000001c1e9c4eaea: sub $0x5,%eax
0x000001c1e9c4eaed: imul %r8d,%eax
这6条机器指令完成Test.calc(int,int)方法内部的算法逻辑,懂汇编的道友一看就会明白这6条指令的含义,的确与Test.calc(int,int)方法的逻辑完全保持一致。这6条指令之前有11条机器指令完成相关的准备工作,这6条指令执行完成之后,再执行3条指令便会执行retq指令而结束。
再看看C2版的汇编指令,C2版的指令数量总体上比C1版的要少很多,但是主要的算法逻辑并没有精简,依然由6条机器码指令指令完成,如下:
0x000001eded0a9b0c: mov %edx,%eax
0x000001eded0a9b0e: sub %r8d,%eax
0x000001eded0a9b11: add $0x211,%eax
0x000001eded0a9b17: imul %edx,%eax
0x000001eded0a9b1a: add $0xfffffffb,%eax
0x000001eded0a9b1d: imul %r8d,%eax
这6条指令也用于完成Test.calc(int, int)方法的算法逻辑,但是这6条指令与上面C1版的6条指令有所不同,调整了部分顺序,并且使用了补码操作代替减法运算,可以算作部分优化。但是由于C2版的机器指令远远少于C1版本的机器指令数量,因此C2编译器的编译质量更高。但是无论是C1编译器还是C2编译器,其编译后的本地机器码整体质量大多(不能绝对)比直接使用JVM内置的模板解释器所生成的本地机器码的质量要高很多,而影响JVM内置的模板解释器效率的一个核心因素便是字节码指令的分发,字节码指令的分发需要对应的jmp机器指令才能完成,有几条字节码指令就会有几次jmp,这种跳转一方面使得模板解释器生成的本地机器码数量增多,另一方面也降低了执行效率
0x000001eded0a9b0c: mov %edx,%eax
0x000001eded0a9b0e: sub %r8d,%eax
0x000001eded0a9b11: add $0x211,%eax
0x000001eded0a9b17: imul %edx,%eax
0x000001eded0a9b1a: add $0xfffffffb,%eax
0x000001eded0a9b1d: imul %r8d,%eax
这6条指令也用于完成Test.calc(int, int)方法的算法逻辑,但是这6条指令与上面C1版的6条指令有所不同,调整了部分顺序,并且使用了补码操作代替减法运算,可以算作部分优化。但是由于C2版的机器指令远远少于C1版本的机器指令数量,因此C2编译器的编译质量更高。但是无论是C1编译器还是C2编译器,其编译后的本地机器码整体质量大多(不能绝对)比直接使用JVM内置的模板解释器所生成的本地机器码的质量要高很多,而影响JVM内置的模板解释器效率的一个核心因素便是字节码指令的分发,字节码指令的分发需要对应的jmp机器指令才能完成,有几条字节码指令就会有几次jmp,这种跳转一方面使得模板解释器生成的本地机器码数量增多,另一方面也降低了执行效率
使用javap命令输出Test.calc(int, int)方法所对应的字节码指令如下:
通过输出结果可知,Test.calc(int,int)方法一共对应24条字节码指令,假设每条字节码指令仅对应4条本地机器码(4条机器码指令是最少的了,仅仅dispatch_next分发就需要占用3条机器码),那么24条字节码指令至少也会生成96条本地机器码,这种级别的数量相比于上面C1和C2编译器所生成的本地机器码数量而言,无疑是巨大的。由此可见,C1和C2这种分层自适应编译器所带来的性能提升当真不是说着玩的,而是真刀真枪的,能够将性能提升几个数量级。
通过输出结果可知,Test.calc(int,int)方法一共对应24条字节码指令,假设每条字节码指令仅对应4条本地机器码(4条机器码指令是最少的了,仅仅dispatch_next分发就需要占用3条机器码),那么24条字节码指令至少也会生成96条本地机器码,这种级别的数量相比于上面C1和C2编译器所生成的本地机器码数量而言,无疑是巨大的。由此可见,C1和C2这种分层自适应编译器所带来的性能提升当真不是说着玩的,而是真刀真枪的,能够将性能提升几个数量级。
除了这些优化技术,HotSpot还是用一些内存分配、并发控制方面的优化技术,其中比较突出的是"逃逸分析"。所谓"逃逸分析"是指当一个Java对象被定义后,可能会被外部方法引用,例如被当作参数传递到其他方法中,这称为"方法逃逸";也可能被其他线程访问,这个称为"线程逃逸"。若能证明一个Java对象不会逃逸到其他方法中,则在位该对象分配内存空间时,可以直接进行"栈上分配",即直接将Java对象实例分配在栈中,而非堆内存,这种分配策略所带来的一个直接好处便是不需要通过GC来回收对象实例,当Java方法调用完成,方法栈被回收时,Java对象实例也跟着被销毁。其实这种分配方式在C/C++中都有对应的实现,在C语言中,如果想在栈上直接分配一个结构体(姑且将C语言中的结构体看作一个对象,其实无论结构体还是类型,本质上都可以认为是一种复合的数据结构),可以直接通过struct A a(假设A是一种自定义的结构体)这样的方式来声明;而如果想在堆中分配结构体实例,则需要通过malloc()函数来实现。在C++中要实现栈上分配和堆上分配就更简单了,就看你创建对象实例时是否使用new关键字了。在HotSpot中实现了这种"栈上分配"技术,当确定一个Java类不会逃逸到其他方法中,并且该Java类结构比较简单(可以直接拆分成标量,也即最原始的基本类型)时,则HotSpot会将Java对象实例直接分配在当前线程所关联的高速缓存中,这种优化策略有一个专门的术语——标量替换。
与方法逃逸类似,如果能够证明一个Java对象不会逃逸到其他线程,则该对象便不会存在多线程锁的竞争,那么方法上的同步措施便会消除。很显然,消除了同步锁之后,代码的执行效率会更高。
JVM中所使用的这些优化技术,每一个都像盛开在阳光下的鲜花,赏之不尽
而在于Java关联紧密的另一个世界——安卓虚拟机,也是先了JIT技术,但是谷歌的技术大神们觉得这样仍然不够,研究出一个AOT(ahead of time)技术。其实所谓AOT,就是提前编译,或者叫做静态编译,这种技术是相对于JIT而言的。AOT是在Java程序运行之前就提前编译好,直接编译成本地相关的机器指令。对于JVM而言,如果纯粹使用解释器解释执行,则每次执行一个方法都需要将字节码指令翻译成对应的机器指令,Java方法调用几次,则这种工作便会重复做几次;而如果开启JIT,则每次Java程序重新启动运行后,都要进行一次这种编译,这种工作还是重复的。而AOT的思想则是,在编译阶段就把这些工作做完,直接将Java程序翻译成对应的本地机器指令,这样就不用在运行期重复去做这些事情了。正是由于AOT的出现,安卓原生的Dalvik虚拟机便在安卓5时代被抛弃了,谷歌转而使用ART虚拟机,所谓ART,其实就是AOT的运行时环境,专门负责运行AOT后的指令
与方法逃逸类似,如果能够证明一个Java对象不会逃逸到其他线程,则该对象便不会存在多线程锁的竞争,那么方法上的同步措施便会消除。很显然,消除了同步锁之后,代码的执行效率会更高。
JVM中所使用的这些优化技术,每一个都像盛开在阳光下的鲜花,赏之不尽
而在于Java关联紧密的另一个世界——安卓虚拟机,也是先了JIT技术,但是谷歌的技术大神们觉得这样仍然不够,研究出一个AOT(ahead of time)技术。其实所谓AOT,就是提前编译,或者叫做静态编译,这种技术是相对于JIT而言的。AOT是在Java程序运行之前就提前编译好,直接编译成本地相关的机器指令。对于JVM而言,如果纯粹使用解释器解释执行,则每次执行一个方法都需要将字节码指令翻译成对应的机器指令,Java方法调用几次,则这种工作便会重复做几次;而如果开启JIT,则每次Java程序重新启动运行后,都要进行一次这种编译,这种工作还是重复的。而AOT的思想则是,在编译阶段就把这些工作做完,直接将Java程序翻译成对应的本地机器指令,这样就不用在运行期重复去做这些事情了。正是由于AOT的出现,安卓原生的Dalvik虚拟机便在安卓5时代被抛弃了,谷歌转而使用ART虚拟机,所谓ART,其实就是AOT的运行时环境,专门负责运行AOT后的指令
操作数栈在哪里
JVM的指令集架构是面向栈的,所设计的大部分字节码指令也都是紧紧围绕栈进行操作,例如前面提到的iadd指令,该地址是零地址指令,指令中并没有显式标记其操作的元数据和目标数据究竟位于哪里,之所以没有显式标记,是因为无论是源数据还是目标数据,其实都位于栈中。讲了半天,这个栈到底在哪里呢?长啥样?大部分书籍中并没有直接的答案。其实这里所谓的栈,是JVM内部所实现的一个求值栈,这个求值栈也叫做操作数栈或者表达式栈,JVM内部将其称为expression stack。当JVM准备执行一个Java方法时,会先为其创建一个栈帧,栈帧主要包含三大部分,分别时局部变量表、固定帧和操作数栈,其详细结构如图所示。
Java方法栈帧的局部变量表、固定帧和操作数栈按照内存从高位向低位顺序增长(x86平台),不过在JVM开始执行Java方法的第一条字节码指令之前,操作数栈其实并没有被创建,JVM仅仅执行到创建Java方法栈帧的最后一步——将当前线程栈栈顶位置压入当前栈顶位置,也就是上图所示的最底部操作数栈的上一个存储单元。由于JVM在创建Java方法栈帧时,将methodOop、constantPoolCache、bcp(字节码指针)等压入固定帧(fixed frame)中所使用的指令都是push,因此当执行完之后,物理机器的esp指针——栈顶指针,其实就指向当前线程栈的栈顶,这个位置便是图中的最底部操作数栈的上一个存储单元。Java方法所对应的操作数栈便从这个位置开始。。因此在JVM内部将该位置叫做expression stack bottom,即表达式栈栈底。至此,JVM便为Java字节码的执行准备好一切,就等着执行指令。假设Java方法的字节码指令中有iload_1这条指令,则该指令最终会被翻译成本地机器指令push %eax,该指令会将Java方法栈帧的局部变量表中slot索引号为1的局部变量压入栈顶——从操作数栈底部开始压入。
JVM的指令集架构是面向栈的,所设计的大部分字节码指令也都是紧紧围绕栈进行操作,例如前面提到的iadd指令,该地址是零地址指令,指令中并没有显式标记其操作的元数据和目标数据究竟位于哪里,之所以没有显式标记,是因为无论是源数据还是目标数据,其实都位于栈中。讲了半天,这个栈到底在哪里呢?长啥样?大部分书籍中并没有直接的答案。其实这里所谓的栈,是JVM内部所实现的一个求值栈,这个求值栈也叫做操作数栈或者表达式栈,JVM内部将其称为expression stack。当JVM准备执行一个Java方法时,会先为其创建一个栈帧,栈帧主要包含三大部分,分别时局部变量表、固定帧和操作数栈,其详细结构如图所示。
Java方法栈帧的局部变量表、固定帧和操作数栈按照内存从高位向低位顺序增长(x86平台),不过在JVM开始执行Java方法的第一条字节码指令之前,操作数栈其实并没有被创建,JVM仅仅执行到创建Java方法栈帧的最后一步——将当前线程栈栈顶位置压入当前栈顶位置,也就是上图所示的最底部操作数栈的上一个存储单元。由于JVM在创建Java方法栈帧时,将methodOop、constantPoolCache、bcp(字节码指针)等压入固定帧(fixed frame)中所使用的指令都是push,因此当执行完之后,物理机器的esp指针——栈顶指针,其实就指向当前线程栈的栈顶,这个位置便是图中的最底部操作数栈的上一个存储单元。Java方法所对应的操作数栈便从这个位置开始。。因此在JVM内部将该位置叫做expression stack bottom,即表达式栈栈底。至此,JVM便为Java字节码的执行准备好一切,就等着执行指令。假设Java方法的字节码指令中有iload_1这条指令,则该指令最终会被翻译成本地机器指令push %eax,该指令会将Java方法栈帧的局部变量表中slot索引号为1的局部变量压入栈顶——从操作数栈底部开始压入。
下面举例分析,该示例程序很简单,在main()主函数中堆两个int类型的数求和。编译该类,并使用javap命令分析编译后的字节码文件,输出分析结果如图所示。分析结果显示locals=4,表示main()主函数的局部变量表的slot编号最大为4,同时args_size=1,表明一共只有1个入参。
当JVM准备调用main()主函数的第一个字节码指令时,main()方法的栈帧结果如图所示。注意观察,局部变量表一共包含4哥slot,并且此时并没有表达式栈,或者说此时的表达式栈空间为零。当JVM执行完a和b这两个局部变量赋值的字节码指令后,接着便开始执行int sum = a + b这句代码,该代码由于涉及求和运算,因此一定会通过表达式栈来完成。
这句代码从iload_1这条字节码指令开始执行,iload_1表示将slot索引号为1的局部变量传送到表达式栈,slot=1的局部变量正是变量a,iload_1执行后的栈帧结构如图所示。注意此时表达式栈中压入了一个int型数据,即局部变量表a的值被压进来。而事实上,该图是错误的,iload_1这条指令执行后,JVM并不会将a变量的值直接传送到表达式栈中,别忘了,JVM内部使用了一项重要的优化技术——栈顶缓存。因此实际的情况时,执行完iload_1这条指令之后,局部变量a的值被传送到了eax寄存器中(x86平台)。这里仅仅是为了y演示表达式栈的原理机制,,绘制了这张图,这里可以暂时忘记栈顶缓存技术的存在
接着JVM执行iload_2,该字节码指令将slot编号为2的局部变量的值加载到表达式栈中,而对于Test.main()方法而言,slot=2的局部变量是变量b.该指令执行完之后,main()的方法栈结构如图所示.至此,表达式栈中已经压入了两个int型数据,这就为接下来要执行的iadd指令准备好了源数据——源数据的确是位于栈顶的。
上面通过一个简单的示例演示了Java方法的表达式栈的创建机制,由此可见,Java方法的表达式栈其实就位于Java方法的栈帧之中。知其然,更要知其所以然,方为大山,JVM为何要将表达式站放在Java方法的栈帧中?其实答案很简单,这种方式在技术实现上很简单,假设不这么实现,而是将表达式栈存放在内存中其他位置,那么JVM得另外维护一套求值栈管理机制了,比较麻烦,使用这种实现方式,当创建Java方法栈时,表达式栈的底部位置便已确定;而当Java方法执行完成之后方法栈被销毁时,表达式栈也会跟着一起被销毁,JVM无须额外设计一套复杂的机制来管理
上面通过一个简单的示例演示了Java方法的表达式栈的创建机制,由此可见,Java方法的表达式栈其实就位于Java方法的栈帧之中。知其然,更要知其所以然,方为大山,JVM为何要将表达式站放在Java方法的栈帧中?其实答案很简单,这种方式在技术实现上很简单,假设不这么实现,而是将表达式栈存放在内存中其他位置,那么JVM得另外维护一套求值栈管理机制了,比较麻烦,使用这种实现方式,当创建Java方法栈时,表达式栈的底部位置便已确定;而当Java方法执行完成之后方法栈被销毁时,表达式栈也会跟着一起被销毁,JVM无须额外设计一套复杂的机制来管理
栈帧重叠。
w无论JVM的指令集是基于栈还是寄存器,其方法调用所基于的数据结构完全相同,,都是基于堆栈。JVM在准备调用一个Java方法之前,会先为其创建栈帧,随着执行引擎堆字节码的执行,JVM会动态地读写Java方法的操作数栈。。在概念模型中,存在直接调用关系的两个Java方法的栈帧在堆栈空间上是彼此线性串联的,并且彼此都拥有完整的栈帧结构。但是大多数虚拟机的实现都会进行一些优化,其中一项很成熟的优化技术便是栈帧重叠。所谓栈帧重叠,就是使两个相邻的栈帧出现一部分重叠,让前一个栈帧的操作数栈于后一个栈帧的局部变量表区域部分重叠在一起,这样在进行方法调用时就能公用这部分堆栈空间,并且无须进行额外的参数复制。如图所示。
w无论JVM的指令集是基于栈还是寄存器,其方法调用所基于的数据结构完全相同,,都是基于堆栈。JVM在准备调用一个Java方法之前,会先为其创建栈帧,随着执行引擎堆字节码的执行,JVM会动态地读写Java方法的操作数栈。。在概念模型中,存在直接调用关系的两个Java方法的栈帧在堆栈空间上是彼此线性串联的,并且彼此都拥有完整的栈帧结构。但是大多数虚拟机的实现都会进行一些优化,其中一项很成熟的优化技术便是栈帧重叠。所谓栈帧重叠,就是使两个相邻的栈帧出现一部分重叠,让前一个栈帧的操作数栈于后一个栈帧的局部变量表区域部分重叠在一起,这样在进行方法调用时就能公用这部分堆栈空间,并且无须进行额外的参数复制。如图所示。
栈帧重叠是需要在技术上实现的。HotSpot的实现方式最终还是通过java字节码,往深了说,最终仍是由机器指令来完成。下面通过举例加以说明。
该示例很简单,在主函数main()里面调用Test对象实例的add()方法。对于堆栈重叠技术,HSDB依然带着神器的光环,它能够带领我们一起领略传说中的堆栈重叠。使用JDB启动Test程序,并在add()方法的第2行上打上断点,然后就让程序一直处于暂停状态。接着使用JPS查看Test进程的进程号,使用HSDB连接上这个进程,
该示例很简单,在主函数main()里面调用Test对象实例的add()方法。对于堆栈重叠技术,HSDB依然带着神器的光环,它能够带领我们一起领略传说中的堆栈重叠。使用JDB启动Test程序,并在add()方法的第2行上打上断点,然后就让程序一直处于暂停状态。接着使用JPS查看Test进程的进程号,使用HSDB连接上这个进程,
接下来,我们查看main主线程的堆栈情况,可以看到Test类的实例地址为0x000000076bd20bb8,接着通过该地址在上面的堆栈图中确认了main()主函数的栈帧起始位置,并据此顺藤摸瓜进而找到main()主函数的固定帧以及调用add()方法时的操作数栈,main()方法的栈帧分配情况如图所示。
main()主函数调用add()方法(当前Test程序由于在add()方法的第2行被打上断点,因此处于暂停状态,但是此时JVM流程已经进入了add()方法,因此JVM为add()方法分配了栈帧),从而main()主函数的栈帧再往上就进入了add()方法的栈帧空间。
add()方法的栈帧也是起始于局部变量表,而局部变量表由add(0方法的入参区域与内部局部变量区域这两部分构成。那么如何确定add()方法的栈帧起始位置呢?很简单,这里还是要看Test类实例的地址。add()方法的第一个入参是this,this指针存储的正是Test实例的地址0x000000076bd20bb8,因此add()方法的局部变量表的第一个slot的值就是0x000000076bd20bb8。而add()方法的第一个入参之间隔了a和b这两个入参,也就是两个slot的举例,反应再图中,就i是隔了两行。这个特点正好可以利用来确定add()方法栈帧的起始位置,只要在图中,从main()主函数的固定帧区域的顶部w往上寻找,找到两处值都是0x000000076bd20bb8并且又隔了两行的地方,就是add()方法的栈帧的起始位置。
main()主函数调用add()方法(当前Test程序由于在add()方法的第2行被打上断点,因此处于暂停状态,但是此时JVM流程已经进入了add()方法,因此JVM为add()方法分配了栈帧),从而main()主函数的栈帧再往上就进入了add()方法的栈帧空间。
add()方法的栈帧也是起始于局部变量表,而局部变量表由add(0方法的入参区域与内部局部变量区域这两部分构成。那么如何确定add()方法的栈帧起始位置呢?很简单,这里还是要看Test类实例的地址。add()方法的第一个入参是this,this指针存储的正是Test实例的地址0x000000076bd20bb8,因此add()方法的局部变量表的第一个slot的值就是0x000000076bd20bb8。而add()方法的第一个入参之间隔了a和b这两个入参,也就是两个slot的举例,反应再图中,就i是隔了两行。这个特点正好可以利用来确定add()方法栈帧的起始位置,只要在图中,从main()主函数的固定帧区域的顶部w往上寻找,找到两处值都是0x000000076bd20bb8并且又隔了两行的地方,就是add()方法的栈帧的起始位置。
从图中很容易找到这个位置,如图所示,图中两个向右箭头所指的值正好是Test实例地址0x000000076bd20bb8,并且这两个d地址之间正好隔了2行,这2行分别是add()方法的入参a和b,图中两个箭头所指的内存地址分别是0x000000000333f4c0和0x000000000333f4a8.由此可以确定,0x000000000333f4a8这个地址就i是add()方法的局部变量的第一个slot所在位置,也是add()方法的第一个入参this指针所在位置,因此add()方法的局部变量表就是从该位置开始。
由于add()方法内部还定义了3个变量,因此add()方法的局部变量表一共有6个成员(3个入参加上3个局部变量,这里少了一个变量),反应在图中,由于此时Test进程在add()方法的第2行被打上了断点,因此add()方法的z和x这两个局部变量尚未被赋值(由于加的是while(true), 所以这里z被赋值为5,x没有)
而在main()主函数调用add()方法时,由于add()方法有3个入参,因此main()主函数需要向操作数栈中赋值这3个入参的值,这3个入参共同组成了运行时的操作数栈(亦称表达式栈),而表达式栈的位置也位于main()方法的固定帧的上面。而现在,main()主函数栈帧的固定帧的上面同时也是add()方法的局部变量表的区域,因此main()方法的表达式栈空间与add()方法的局部变量表空间重叠起来了,如图所示,绿色方框和红色放款所框住的这3行就是main()方法和add()方法的栈帧重叠的部分,由此可以知道,栈帧重叠,所重叠的部分也只是操作数栈部分,不涉及被调用方法内部的局部变量区域。并且重叠的空间大小,随着当前方法所调用的方法的入参区域大小的不同而不同,并没有固定的大小。
entry_point例程机器指令
执行引擎实战。
一个简单的例子。
根据javap命令所打印出的doSomeThing()方法的信息可知,doSomeTHing()方法的操作数栈最大深度为2(stack=2),局部变量表大小为3(locals=3).同时,该方法经过编译后,一共有12t条字节码指令。下面先分析z这12条字节码指令的运行过程
根据javap命令所打印出的doSomeThing()方法的信息可知,doSomeTHing()方法的操作数栈最大深度为2(stack=2),局部变量表大小为3(locals=3).同时,该方法经过编译后,一共有12t条字节码指令。下面先分析z这12条字节码指令的运行过程
字节码运行过程分析。
doSomeThing()方法开始运行之前,JVM便已经分配好局部变量表和操作数栈(也叫表达式栈,expression stack)具体创建的过程在前面解释过,对于本例,doSomeThing()方法的栈帧准备之后的局部变量表与操作数栈内存布局如图所示。。左侧显示字节码,随着程序的运行,会标示当前运行到的字节码指令。右侧则显示程序运行过程中的局部变量表和表达式站的内存数据,同时标注当前程序计数器。值得注意的是,图中左侧分成两列,第一列是当前字节码相对于基址的偏移量,第二列则是具体的字节码。其中第4个字节码指令istore_1的偏移量是4,而不是3,这是因为第3条字节码指令bipush 81占用了2字节的宽度,bipush 占用1个,立即数81也占用1个。同理,第9条字节码指令isub的偏移量是10,而不是9,也是因为其上一条指令bipush 9占用了2字节宽度。所谓程序计数器,就是指示当前字节码指令相对于Java方法的字节码区域的起始位置的偏移量所在的堆栈内存位置。对于x86的32位平台而言,使用esi寄存器保存当前字节码指令的内存地址,当前字节码指令运行结束之后,由当前字节码指令自己增加esi的值,从而将esi指向下一条字节码指令的内存地址。这一点与CPU硬件层面的程序计数器的工作原理类似,只不过CPU是纯数字电路驱动,不需要软件程序自己实现计数的逻辑,而JVM则由软件逻辑进行控制,但是基于JVM这一虚拟机器的上层的Java引用程序与基于物理CPU之上的软件程序一样,也不需要感知代码指令的偏移。
虽然程序计数器所代表的硬件寄存器中实际所保存的是目标字节码指令的内存地址,但是为了理解简单,大家都不约而同地将其简单理解为指向下一条指令相对于第一条指令的偏移位置,简化理解,即程序计数器指向的是指令的相对偏移量。另外,在32位机器上,图中局部变量表和表达式栈的每一个小方框代表4字节,32位比特的内存存储单元,在32位平台上JVM的一个slot槽位正好占据32位存储空间
doSomeThing()方法开始运行之前,JVM便已经分配好局部变量表和操作数栈(也叫表达式栈,expression stack)具体创建的过程在前面解释过,对于本例,doSomeThing()方法的栈帧准备之后的局部变量表与操作数栈内存布局如图所示。。左侧显示字节码,随着程序的运行,会标示当前运行到的字节码指令。右侧则显示程序运行过程中的局部变量表和表达式站的内存数据,同时标注当前程序计数器。值得注意的是,图中左侧分成两列,第一列是当前字节码相对于基址的偏移量,第二列则是具体的字节码。其中第4个字节码指令istore_1的偏移量是4,而不是3,这是因为第3条字节码指令bipush 81占用了2字节的宽度,bipush 占用1个,立即数81也占用1个。同理,第9条字节码指令isub的偏移量是10,而不是9,也是因为其上一条指令bipush 9占用了2字节宽度。所谓程序计数器,就是指示当前字节码指令相对于Java方法的字节码区域的起始位置的偏移量所在的堆栈内存位置。对于x86的32位平台而言,使用esi寄存器保存当前字节码指令的内存地址,当前字节码指令运行结束之后,由当前字节码指令自己增加esi的值,从而将esi指向下一条字节码指令的内存地址。这一点与CPU硬件层面的程序计数器的工作原理类似,只不过CPU是纯数字电路驱动,不需要软件程序自己实现计数的逻辑,而JVM则由软件逻辑进行控制,但是基于JVM这一虚拟机器的上层的Java引用程序与基于物理CPU之上的软件程序一样,也不需要感知代码指令的偏移。
虽然程序计数器所代表的硬件寄存器中实际所保存的是目标字节码指令的内存地址,但是为了理解简单,大家都不约而同地将其简单理解为指向下一条指令相对于第一条指令的偏移位置,简化理解,即程序计数器指向的是指令的相对偏移量。另外,在32位机器上,图中局部变量表和表达式栈的每一个小方框代表4字节,32位比特的内存存储单元,在32位平台上JVM的一个slot槽位正好占据32位存储空间
1.执行iconst_3指令。
iconst_3指令的作用是将操作数3推送至栈顶(表达式栈的栈顶)。执行之后的内存布局如图所示。。此时操作数栈栈顶的值变成3。根据JVM解释器的运行机制,JVM会先执行字节码指令所对应的逻辑,执行完之后才会将程序计数器更新为下一条字节码指令所在的位置,但是在更新之前,程序计数器仍然指向当前刚刚执行完的字节码指令。下面为了描述方便,会统一表述为:当执行完当前字节码指令时,程序计数器指向当前字节码指令所在的位置。
iconst_3指令的作用是将操作数3推送至栈顶(表达式栈的栈顶)。执行之后的内存布局如图所示。。此时操作数栈栈顶的值变成3。根据JVM解释器的运行机制,JVM会先执行字节码指令所对应的逻辑,执行完之后才会将程序计数器更新为下一条字节码指令所在的位置,但是在更新之前,程序计数器仍然指向当前刚刚执行完的字节码指令。下面为了描述方便,会统一表述为:当执行完当前字节码指令时,程序计数器指向当前字节码指令所在的位置。
2.执行istore_0指令
istore指令将表达式栈栈顶的数据弹出来,并传送到局部变量表中指定的位置,该位置由紧跟在istore指令后面的数字指定。当istore_0指令执行之后,数字3从表达式栈栈顶被弹出,并保存到局部变量表的第0哥槽位(slot),此时程序计数器的值更新为1,堆栈内存布局如图所示
istore指令将表达式栈栈顶的数据弹出来,并传送到局部变量表中指定的位置,该位置由紧跟在istore指令后面的数字指定。当istore_0指令执行之后,数字3从表达式栈栈顶被弹出,并保存到局部变量表的第0哥槽位(slot),此时程序计数器的值更新为1,堆栈内存布局如图所示
3.执行bipush指令
bipush指令将单字节的常量(-128~127)推送至栈顶,该指令后面紧跟一个单字节的操作数。当bipush 81指令执行之后,数字81被推送至操作数栈栈顶,此时程序计数器的值更新为2,堆栈内存布局如图所示
bipush指令将单字节的常量(-128~127)推送至栈顶,该指令后面紧跟一个单字节的操作数。当bipush 81指令执行之后,数字81被推送至操作数栈栈顶,此时程序计数器的值更新为2,堆栈内存布局如图所示
4.执行istore_1指令
istore_1指令与前面的istore_0指令类似,都是讲栈顶数据写入局部变量表,不过写入的位置是第1个slot.本指令执行完之后,程序计数器更新为4,此时堆栈内存布局如图所示
istore_1指令与前面的istore_0指令类似,都是讲栈顶数据写入局部变量表,不过写入的位置是第1个slot.本指令执行完之后,程序计数器更新为4,此时堆栈内存布局如图所示
5.执行iload_0与iload_1指令
iload系列指令的作用是将局部变量表中的数据推送至栈顶,iload_0与iload_1指令的作用分别是将局部变量表中slot索引号为0和1的两个数据推送至栈顶,在此过程中,程序计数器的值会分别更新为5和6,当执行完iload_1指令后,堆栈内存布局如图所示
iload系列指令的作用是将局部变量表中的数据推送至栈顶,iload_0与iload_1指令的作用分别是将局部变量表中slot索引号为0和1的两个数据推送至栈顶,在此过程中,程序计数器的值会分别更新为5和6,当执行完iload_1指令后,堆栈内存布局如图所示
6.执行iadd指令
iadd指令的作用是对栈顶两个intl类型的数据求和,完成求和之后,让栈顶元素出栈,并将累加结果压入操作数栈栈顶。本指令执行完之后,程序计数器会更新为7,同时堆栈内存布局如图所示
注意:求和之后JVM会让操作数栈栈顶原本用于求和的两个数据出栈,所以完成求和之后,栈顶只保留求和结果。
完成iadd指令后,后续的几条指令是完成减法运算,其中仍会涉及将数据推送至栈顶,执行减法运算,再从栈顶将计算结果写入局部变量表的重复过程,相关指令的流程机制与前面相同,这里仅贴出后续指令执行过程中的堆栈变化图
本示例的字节码指令便全部执行完成。本示例程序虽然简单,但是也具有一定的典型性,这种典型性主要体现在字节码指令上,例如,局部变量表的读写、操作数压栈、数学运算等,绝大多数程序逻辑都离不开这几种最通用的字节码指令。在JVM内部,每一种解释器(例如字节码解释器、模板解释器)都给出了Java字节码指令对应的实现,或用C语言写成,或用机器指令完成,其中字节码解释器的实现最为直观,基本能够看懂C语言的道友都能看懂;而模板解释器虽然也是用C语言解释字节码逻辑,但是C语言仅负责在运行时生成对应的本地机器指令,在运行期实际上是通过动态生成的机器指令去完成字节码指令的逻辑。不过话说回来,字节码解释器使用C语言直接解释字节码,虽然编译时会被编译器解释成对应的本地机器码,不过这种由编译器生成的机器指令相比于模板解释器中由人工生成的机器指令,在质量上要逊色很多,这便是字节码解释器从JVM很早的版本便弃用的原因。不过正是由于模板解释器所生成的本地机器指令只有在运行期才能看到,在编译期无法直接看到,因此必须使用工具,其中一种工具便是HSDIS,使用该工具能够在运行期打印出每一个字节码指令所对应的本地机器指令
iadd指令的作用是对栈顶两个intl类型的数据求和,完成求和之后,让栈顶元素出栈,并将累加结果压入操作数栈栈顶。本指令执行完之后,程序计数器会更新为7,同时堆栈内存布局如图所示
注意:求和之后JVM会让操作数栈栈顶原本用于求和的两个数据出栈,所以完成求和之后,栈顶只保留求和结果。
完成iadd指令后,后续的几条指令是完成减法运算,其中仍会涉及将数据推送至栈顶,执行减法运算,再从栈顶将计算结果写入局部变量表的重复过程,相关指令的流程机制与前面相同,这里仅贴出后续指令执行过程中的堆栈变化图
本示例的字节码指令便全部执行完成。本示例程序虽然简单,但是也具有一定的典型性,这种典型性主要体现在字节码指令上,例如,局部变量表的读写、操作数压栈、数学运算等,绝大多数程序逻辑都离不开这几种最通用的字节码指令。在JVM内部,每一种解释器(例如字节码解释器、模板解释器)都给出了Java字节码指令对应的实现,或用C语言写成,或用机器指令完成,其中字节码解释器的实现最为直观,基本能够看懂C语言的道友都能看懂;而模板解释器虽然也是用C语言解释字节码逻辑,但是C语言仅负责在运行时生成对应的本地机器指令,在运行期实际上是通过动态生成的机器指令去完成字节码指令的逻辑。不过话说回来,字节码解释器使用C语言直接解释字节码,虽然编译时会被编译器解释成对应的本地机器码,不过这种由编译器生成的机器指令相比于模板解释器中由人工生成的机器指令,在质量上要逊色很多,这便是字节码解释器从JVM很早的版本便弃用的原因。不过正是由于模板解释器所生成的本地机器指令只有在运行期才能看到,在编译期无法直接看到,因此必须使用工具,其中一种工具便是HSDIS,使用该工具能够在运行期打印出每一个字节码指令所对应的本地机器指令
字节码指令实现。
z在前面分析栈顶缓存机制时,提到模板解释器所生成的本地机器指令与栈顶缓存有很大的关系,几乎大部分字节码指令的本地实现都使用了栈顶缓存这种优化技术。这里将要分析的iconst_3、istore_0和iadd等指令也都使用了栈顶缓存技术。t同时,在描述字节码指令的本地实现机制之前,有一点需要说明,当JVM开始调用一个Java方法之前,会为该Java方法创建好栈帧,Java方法栈帧主要包含3大块,分别时局部变量表、固定帧和操作数栈。当JVM为即将调用的Java方法准备好栈帧之后,在x86平台上,JVM会以esi寄存器作为程序计数器,并会将该寄存器指向局部变量表的第0个slot的内存位置,同时JVM会将sp这种栈顶寄存器指向Java方法的操作数栈栈顶,因此,在JVM执行目标Java方法所对应的字节码指令时,字节码指令所对应的本地机器码指令push与pop,实际上便是在操作Java方法的操作数栈栈顶,所以下面说说的压栈和出栈实际上是指对Java方法操作数栈的压栈和出栈
z在前面分析栈顶缓存机制时,提到模板解释器所生成的本地机器指令与栈顶缓存有很大的关系,几乎大部分字节码指令的本地实现都使用了栈顶缓存这种优化技术。这里将要分析的iconst_3、istore_0和iadd等指令也都使用了栈顶缓存技术。t同时,在描述字节码指令的本地实现机制之前,有一点需要说明,当JVM开始调用一个Java方法之前,会为该Java方法创建好栈帧,Java方法栈帧主要包含3大块,分别时局部变量表、固定帧和操作数栈。当JVM为即将调用的Java方法准备好栈帧之后,在x86平台上,JVM会以esi寄存器作为程序计数器,并会将该寄存器指向局部变量表的第0个slot的内存位置,同时JVM会将sp这种栈顶寄存器指向Java方法的操作数栈栈顶,因此,在JVM执行目标Java方法所对应的字节码指令时,字节码指令所对应的本地机器码指令push与pop,实际上便是在操作Java方法的操作数栈栈顶,所以下面说说的压栈和出栈实际上是指对Java方法操作数栈的压栈和出栈
iconst_3.
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
iconst_3指令对应的本地机器码貌似很多,但是对于本示例程程序而言,由于iconst_3指令是方法d的第一条字节码指令,在其之前并没有其他字节码指令会向栈顶h缓存数据,因此此时栈顶状态为空,所以iconst_3指令便从上面这段本地机器指令的无缓存入口点进入,即下面这条机器指令:
mov $0x3, %eax
该指令驱动物理CPUj将操作数3传送到eax寄存器中,JVM完成这条传送指令之后,便直接开始取指,准备执行下一条字节码指令。这中间s似乎存在一个问题,不是说好的,iconst_3指令会将数字3压入操作数栈栈顶的吗,为何这里仅仅看到机器指令将其传送到寄存器中,反倒没栈顶啥事儿了?答案很简单,这里仍然在旅行栈顶缓存策略,iconst_3指令并没有真的将数据入栈,而是先临时存放在eax这个缓冲器中。
那么iconst_3指令到底啥时候才会将数字3入栈呢?这需要看具体的场景,具体来说需要看其后面的那条字节码指令是啥,如果其后面的那条指令仍然是进行压栈操作,例如又来一条iconst系列的指令,或者来一条iload系列的指令,或者sipush、bipush、ldc等指令,由于这些指令也会使用栈顶缓存策略,而对于一个给定的CPU硬件平台,栈顶缓存只能有一个寄存器,所以JVM为了给这些后续的指令腾出栈顶缓存空间,只能对iconst_3自治领中所隐含的操作数3执行真正压栈操作。但是如果iconst_3指令后面的那条字节码指令不是压栈操作,而是运算指令,例如iadd、isub等,或者写局部变量表操作,例如istore系列的指令,则JVM永远不会对iconst_3执行真正的压栈操作,数字3最多只能到达用于栈顶缓存的寄存器之中,而不会被传送到栈顶,这是为了减少几次内存读写操作,从而提升运算速度。
对于本示例程序,由于iconst_3指令后面的那条指令是istore_0,该指令不会继续进行压栈操作,因此实际上JVM在运行本测试程序时,并不会将数字3压入栈顶,从这个角度而言,前面绘制的内存堆栈布局图是错误的,不过前面的重点是分析字节码的字面含义,并不考虑栈顶缓存这种优化技术,因此基于字节码字面含义而绘制的堆栈布局图并没有问题。事实上,,JVM规范也只是定义了一套字节码指令集,至于各种JVM虚拟机内部究竟如何实现,则没有相应的规范,所以通常对字节码指令的理解也只能是基于其字面含义
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
iconst_3指令对应的本地机器码貌似很多,但是对于本示例程程序而言,由于iconst_3指令是方法d的第一条字节码指令,在其之前并没有其他字节码指令会向栈顶h缓存数据,因此此时栈顶状态为空,所以iconst_3指令便从上面这段本地机器指令的无缓存入口点进入,即下面这条机器指令:
mov $0x3, %eax
该指令驱动物理CPUj将操作数3传送到eax寄存器中,JVM完成这条传送指令之后,便直接开始取指,准备执行下一条字节码指令。这中间s似乎存在一个问题,不是说好的,iconst_3指令会将数字3压入操作数栈栈顶的吗,为何这里仅仅看到机器指令将其传送到寄存器中,反倒没栈顶啥事儿了?答案很简单,这里仍然在旅行栈顶缓存策略,iconst_3指令并没有真的将数据入栈,而是先临时存放在eax这个缓冲器中。
那么iconst_3指令到底啥时候才会将数字3入栈呢?这需要看具体的场景,具体来说需要看其后面的那条字节码指令是啥,如果其后面的那条指令仍然是进行压栈操作,例如又来一条iconst系列的指令,或者来一条iload系列的指令,或者sipush、bipush、ldc等指令,由于这些指令也会使用栈顶缓存策略,而对于一个给定的CPU硬件平台,栈顶缓存只能有一个寄存器,所以JVM为了给这些后续的指令腾出栈顶缓存空间,只能对iconst_3自治领中所隐含的操作数3执行真正压栈操作。但是如果iconst_3指令后面的那条字节码指令不是压栈操作,而是运算指令,例如iadd、isub等,或者写局部变量表操作,例如istore系列的指令,则JVM永远不会对iconst_3执行真正的压栈操作,数字3最多只能到达用于栈顶缓存的寄存器之中,而不会被传送到栈顶,这是为了减少几次内存读写操作,从而提升运算速度。
对于本示例程序,由于iconst_3指令后面的那条指令是istore_0,该指令不会继续进行压栈操作,因此实际上JVM在运行本测试程序时,并不会将数字3压入栈顶,从这个角度而言,前面绘制的内存堆栈布局图是错误的,不过前面的重点是分析字节码的字面含义,并不考虑栈顶缓存这种优化技术,因此基于字节码字面含义而绘制的堆栈布局图并没有问题。事实上,,JVM规范也只是定义了一套字节码指令集,至于各种JVM虚拟机内部究竟如何实现,则没有相应的规范,所以通常对字节码指令的理解也只能是基于其字面含义
istore_0
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
本示例程序所对应的第二条字节码指令是istore_0,该指令的字面含义是将栈顶数据写入局部变量表中索引号为0的slot槽位中。同样,本字节码指令依然使用了栈顶缓存技术,其对应的第一条机器指令是mov (%rsp), %eax,这条机器指令的含义是将栈顶数据传送至eax寄存器中。
在JVM执行字节码跳转时,会判断栈顶缓存状态,当栈顶缓存为空时,则会执行mov系列的机器指令以将栈顶数据传送至缓存的寄存器之中。这是一个通用的逻辑。。在x86平台上,用作栈顶缓存的寄存器是eax,在JVM执行istore系列的字节码指令之前,如果eax寄存器中已经有数据,则JVM不再执行mov (%rsp), %eax这条机器指令,而是直接从该指令的下面指令开始mov %eax,(%r14)执行。这条指令将eax寄存器中的数据传送到(%r14)所指向的内存位置,
前面分析过,edi寄存器指向Java方法栈帧的局部变量表第0个slot位置,因此这条指令上在解释执行Java字节码指令istore的字面含义——将栈顶数据写入局部变量表。(我这里是r14寄存器)
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
本示例程序所对应的第二条字节码指令是istore_0,该指令的字面含义是将栈顶数据写入局部变量表中索引号为0的slot槽位中。同样,本字节码指令依然使用了栈顶缓存技术,其对应的第一条机器指令是mov (%rsp), %eax,这条机器指令的含义是将栈顶数据传送至eax寄存器中。
在JVM执行字节码跳转时,会判断栈顶缓存状态,当栈顶缓存为空时,则会执行mov系列的机器指令以将栈顶数据传送至缓存的寄存器之中。这是一个通用的逻辑。。在x86平台上,用作栈顶缓存的寄存器是eax,在JVM执行istore系列的字节码指令之前,如果eax寄存器中已经有数据,则JVM不再执行mov (%rsp), %eax这条机器指令,而是直接从该指令的下面指令开始mov %eax,(%r14)执行。这条指令将eax寄存器中的数据传送到(%r14)所指向的内存位置,
前面分析过,edi寄存器指向Java方法栈帧的局部变量表第0个slot位置,因此这条指令上在解释执行Java字节码指令istore的字面含义——将栈顶数据写入局部变量表。(我这里是r14寄存器)
iadd
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
iadd指令的作用是对Java方法栈栈顶的两个int型数据执行求和运算。由于JVM本身不具备数学运算的能力,z最终仍然要依靠物理CPU才能完成,而在x64平台上,物理机器执行求和运算的一种方式便是直接对两个寄存器中的数据进行累加,例如:
add %edx,%eax
JVM执行求和逻辑时,也会将Java方法栈栈顶的两个数据传送到edx和eax这两个寄存器中,这样才能触发CPU硬件的求和指令,从而完成真正的求和运算。
iadd指令同样履行了栈顶缓存策略,如果缓存寄存器eax中没有数据,则会从上面第一条机器指令mov (%rsp),%eax开始执行,将Java方法栈栈顶的第一个int型数据弹出至eax寄存器中,接着执行上面的第二条机器指令mov (%rsp),%edx,将Java方法栈栈顶的第二个int型数据弹出至edx寄存器中。而如果缓存器eax总已经有数据,例如iadd指令的上一条指令是iload系列或者iconst系列的指令等,这些指令会将操作数缓存至eax寄存器中,因此在执行iadd指令时,会直接从上面第二条机器指令开始执行,第一条机器指令不需要执行。如此依赖,原本需要连续执行两次pop/mov指令次啊能将栈顶的两个数据弹出至寄存器中,现在只需要执行一次pop指令。CPU在执行pop/mov指令时需要将数据从内存传送至寄存器中,而读写内存的效率相比于读写寄存器时非常低的,因此JVM每节省一次内存读写,性能便能提高不少。
总体而言,JVM虽然有一个专门的执行引擎模块,能够执行常规的若干指令,但是毕竟计算机的运算能力只能依靠硬件赋予,因此JVM字节码指令最终都需要转换为对应的硬件CPU指令。前面栈式了在x64平台上几种常见的字节码指令的解释原理,其他字节码指令的解释原理也都大同小异,都是基于Java方法操作数栈和局部变量表而翻译对应的机器逻辑
在64位x64平台上,使用HSDIS工具打印该指令对应的本地机器逻辑如下:
iadd指令的作用是对Java方法栈栈顶的两个int型数据执行求和运算。由于JVM本身不具备数学运算的能力,z最终仍然要依靠物理CPU才能完成,而在x64平台上,物理机器执行求和运算的一种方式便是直接对两个寄存器中的数据进行累加,例如:
add %edx,%eax
JVM执行求和逻辑时,也会将Java方法栈栈顶的两个数据传送到edx和eax这两个寄存器中,这样才能触发CPU硬件的求和指令,从而完成真正的求和运算。
iadd指令同样履行了栈顶缓存策略,如果缓存寄存器eax中没有数据,则会从上面第一条机器指令mov (%rsp),%eax开始执行,将Java方法栈栈顶的第一个int型数据弹出至eax寄存器中,接着执行上面的第二条机器指令mov (%rsp),%edx,将Java方法栈栈顶的第二个int型数据弹出至edx寄存器中。而如果缓存器eax总已经有数据,例如iadd指令的上一条指令是iload系列或者iconst系列的指令等,这些指令会将操作数缓存至eax寄存器中,因此在执行iadd指令时,会直接从上面第二条机器指令开始执行,第一条机器指令不需要执行。如此依赖,原本需要连续执行两次pop/mov指令次啊能将栈顶的两个数据弹出至寄存器中,现在只需要执行一次pop指令。CPU在执行pop/mov指令时需要将数据从内存传送至寄存器中,而读写内存的效率相比于读写寄存器时非常低的,因此JVM每节省一次内存读写,性能便能提高不少。
总体而言,JVM虽然有一个专门的执行引擎模块,能够执行常规的若干指令,但是毕竟计算机的运算能力只能依靠硬件赋予,因此JVM字节码指令最终都需要转换为对应的硬件CPU指令。前面栈式了在x64平台上几种常见的字节码指令的解释原理,其他字节码指令的解释原理也都大同小异,都是基于Java方法操作数栈和局部变量表而翻译对应的机器逻辑
总结。
JVM最核心的技术便是执行引擎,最难的也是执行引擎。要想透彻理解JVM的执行引擎,就必须先理解物理计算机CPU执行运算的机制,前面详细描述了物理CPU进行取指、译码、运算的原理,并从这个点触发,逐步深入分析JVM的执行引擎的运行机制。相比于物理CPU的取指机制,JVM的取指机制显得更加复杂。从整体效果来看,JVM取指机制其实糅合了物理CPU的取指机制和JVM本身的字节码取指机制。由于一个Java方法对应若干字节码指令,因此每当JVM执行一条字节码指令后,便需要执行一次"取指"。而每一条字节码指令有对应多条机器指令,因此在JVM执行一条目标字节码指令时,需要同时处理本地机器指令的跳转。
JVM在执行字节码指令时,综合使用了若干技巧,这些技巧可以节省CPU资源,提高程序性能。这些技巧包括栈顶缓存、堆栈重叠及JIT等,其中JIT术语非常高级的技术主题,同时也是一个永恒的话题,毕竟既想拥有Java简单易学的语法特性(最重要的是不需要管理内存),邮箱尽可能地提升程序执行效率,不是一件容易的事。
JVM最核心的技术便是执行引擎,最难的也是执行引擎。要想透彻理解JVM的执行引擎,就必须先理解物理计算机CPU执行运算的机制,前面详细描述了物理CPU进行取指、译码、运算的原理,并从这个点触发,逐步深入分析JVM的执行引擎的运行机制。相比于物理CPU的取指机制,JVM的取指机制显得更加复杂。从整体效果来看,JVM取指机制其实糅合了物理CPU的取指机制和JVM本身的字节码取指机制。由于一个Java方法对应若干字节码指令,因此每当JVM执行一条字节码指令后,便需要执行一次"取指"。而每一条字节码指令有对应多条机器指令,因此在JVM执行一条目标字节码指令时,需要同时处理本地机器指令的跳转。
JVM在执行字节码指令时,综合使用了若干技巧,这些技巧可以节省CPU资源,提高程序性能。这些技巧包括栈顶缓存、堆栈重叠及JIT等,其中JIT术语非常高级的技术主题,同时也是一个永恒的话题,毕竟既想拥有Java简单易学的语法特性(最重要的是不需要管理内存),邮箱尽可能地提升程序执行效率,不是一件容易的事。
类的声明周期
Java类的生命周期是一个绕不开的话题,w我们将从源代码的角度来分析Java类声明周期的技术实现解析解。前面描述的Java执行引擎,更是于类的声明周期息息相关——Java执行引擎直接负责和管理Java类声明周期的大部分阶段,包括类的加载、初始化、创建与方法调用
类的生命周期概述。
Java程序的所有数据结构和算法都封装在类型之中,这也是面向对象编程语言的一大特色。当JVM执行一个Java类所封装的算法之前,首先要做的一件事便是字节码文件解析,字节码文件解析包含3哥主要的过程——常量池解析、Java类字段解析及Java方法解析。通过类字段解析,JVM能够分析出Java类所封装的数据结构;通过方法解析,JVM能够分析出Java类所封装的算法逻辑。而无论是数据结构还是方法信息,很多与字符串或者大数据(是指占二进位比较多的大数)相关的信息都封装于常量池中,所以JVM想要解析字段和方法信息,必先解析常量池。前面描述了字节码文件解析的技术实现细节,当常量池、字段和方法信息全部被解析完,则字节码文件的精华便已经被完全消化吸收。但是,这几个过程其实仅仅属于Java类"加载"过程中的一个环节,这对于一个Java类的整个"漫长"的生命周期而言,仅仅是个开始。在字节码文件的精华被吸收之后还需要经过一系列的"二次"消化处理,方能被JVM在运行期"随心所欲"地调用。
按照JVM规范,一个Java文件从被加载到被卸载的整个生命g过程,总共要经历5个阶段:加载->链接(验证+准备+解析)->初始化(使用前的准备)->使用->卸载。其中第二个阶段"链接",对应3个阶段:验证、准备和解析,因此很多人也说Java类的声明周期一共包含7个阶段。
前面说的常量池解析、Java字段和方法的解析,其实都属于加载阶段的一部分。所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。字节码相关的工具类库,例如asm、cglib等,都利用了这一机制,在运行期动态修改静态声明的Java类所对应的字节码内容,从而在运行期直接改掉Java类的定义,甚至直接在运行期创建一个全新的Java类。
Java程序的所有数据结构和算法都封装在类型之中,这也是面向对象编程语言的一大特色。当JVM执行一个Java类所封装的算法之前,首先要做的一件事便是字节码文件解析,字节码文件解析包含3哥主要的过程——常量池解析、Java类字段解析及Java方法解析。通过类字段解析,JVM能够分析出Java类所封装的数据结构;通过方法解析,JVM能够分析出Java类所封装的算法逻辑。而无论是数据结构还是方法信息,很多与字符串或者大数据(是指占二进位比较多的大数)相关的信息都封装于常量池中,所以JVM想要解析字段和方法信息,必先解析常量池。前面描述了字节码文件解析的技术实现细节,当常量池、字段和方法信息全部被解析完,则字节码文件的精华便已经被完全消化吸收。但是,这几个过程其实仅仅属于Java类"加载"过程中的一个环节,这对于一个Java类的整个"漫长"的生命周期而言,仅仅是个开始。在字节码文件的精华被吸收之后还需要经过一系列的"二次"消化处理,方能被JVM在运行期"随心所欲"地调用。
按照JVM规范,一个Java文件从被加载到被卸载的整个生命g过程,总共要经历5个阶段:加载->链接(验证+准备+解析)->初始化(使用前的准备)->使用->卸载。其中第二个阶段"链接",对应3个阶段:验证、准备和解析,因此很多人也说Java类的声明周期一共包含7个阶段。
前面说的常量池解析、Java字段和方法的解析,其实都属于加载阶段的一部分。所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。字节码相关的工具类库,例如asm、cglib等,都利用了这一机制,在运行期动态修改静态声明的Java类所对应的字节码内容,从而在运行期直接改掉Java类的定义,甚至直接在运行期创建一个全新的Java类。
Java类是写给人类看的,而JVM内存中的类模板快照则是写给机器看的,物理机器无法直接执行Java类的源代码,所以需要通过类加载这样一个过程将字节码格式的Java类转换成机器能够识别的内存类模板快照。
JVM完成Java类加载之后,接着便开始进行链接。所谓链接,虽然与编译原理中的链接不是同一件事,然而本质上是相同的。总体而言,链接的主要作用是将字节码指令中对常量池中的索引转换为直接引用。链接包含3个步骤:验证、准备和解析。其实在类加载阶段(也即类的声明周期的第一个阶段),JVM会对字节码文件进行验证,只不过该阶段的验证着重于字节码文件格式本身,与链接阶段的验证侧重点不同。在链接阶段,着重于由字节码信息出发进行反向验证,例如,根据字节码文件中的类名是否能够找到对应的类模板。这些都验证无误之后,JVM才能放心地加载当前类,也才能放心地将字节码指令中对常量池索引号的引用重写为直接引用。关于链接的具体技术实现,是在是太重要了,因为在JVM执行字节码指令的过程中,会依赖于重写机制,例如invokevirtual指令。重写本身比较简单,但是与方法调用联系到一起,就比较复杂了。
在链接阶段,在正式使用Java类之前的最后一道工序便是"初始化"。这里所谓的初始化,并非指对l类进行实例化,而是指执行类的<clinit>()方法。Java类的实例化,对应的乃是Java类声明周期中的"使用"阶段。类的<clinit>()方法在前面分析JVM解析Java类方法时详细分析过。总体而言,当Java类中包含static修饰的静态字段,或者有使用static{}块包裹的代码块时,编译后便会在字节码文件中包含一个名为<clinit>()的方法,JVM在初始化阶段便会调用该方法。需要说明一点,该方法仅能由Java编译器生成并由JVM调用,程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。等JVM完成类的初始化之后,便"万事俱备,只欠东风",就等着开发者使用了。使用方式多种多样,其中最常见的一种方式是通过new关键字来实例化一个Java类。当然,除了通过new关键字使用Java类,还可以有多种方式,例如下面这个例子:
该示例使用Class.forName(String)接口加载一个类,并通过Class.newInstance()接口实例化一个类。从广义上说,类的加载也可以对应类生命周期的7个阶段的前5个阶段,即加载、验证、准备、解析和初始化。当类加载之后,JVM内部会为Java类创建一个对等的类模板,类模板在JDK6时代被存储在所谓的perm取,而到了JDK8时代,则被存储在所谓的metaSpace区。无论存储在哪里,当存储区即将被打爆而这个类又不再使用时,JVM的GC便有可能将其回收,即释放内存。而当实例化一个Java类之后,JVM内部则会为Java类实例对象创建一个对等的实例对象,该实例对象所存储的区域与具体的GC策略紧密关联,有可能在新生代,也可能在老年代,当然,更可能在栈上(栈上分配)。当类被使用完毕之后,JVM必须销毁实例对象,否则JVM内存区早晚会被打爆。JVM对类模板的销毁和类实例对象的销毁,都是卸载。
总体而言,Java类的生命周期如图所示。虽然Java类的声明周期包含7个阶段,然而这7个阶段到底做了些什么事情,内部实现机制如何,我们仍然一无所知,如果不了解这些内部机制,则即时知道这7个阶段,怕是也没啥用处
JVM完成Java类加载之后,接着便开始进行链接。所谓链接,虽然与编译原理中的链接不是同一件事,然而本质上是相同的。总体而言,链接的主要作用是将字节码指令中对常量池中的索引转换为直接引用。链接包含3个步骤:验证、准备和解析。其实在类加载阶段(也即类的声明周期的第一个阶段),JVM会对字节码文件进行验证,只不过该阶段的验证着重于字节码文件格式本身,与链接阶段的验证侧重点不同。在链接阶段,着重于由字节码信息出发进行反向验证,例如,根据字节码文件中的类名是否能够找到对应的类模板。这些都验证无误之后,JVM才能放心地加载当前类,也才能放心地将字节码指令中对常量池索引号的引用重写为直接引用。关于链接的具体技术实现,是在是太重要了,因为在JVM执行字节码指令的过程中,会依赖于重写机制,例如invokevirtual指令。重写本身比较简单,但是与方法调用联系到一起,就比较复杂了。
在链接阶段,在正式使用Java类之前的最后一道工序便是"初始化"。这里所谓的初始化,并非指对l类进行实例化,而是指执行类的<clinit>()方法。Java类的实例化,对应的乃是Java类声明周期中的"使用"阶段。类的<clinit>()方法在前面分析JVM解析Java类方法时详细分析过。总体而言,当Java类中包含static修饰的静态字段,或者有使用static{}块包裹的代码块时,编译后便会在字节码文件中包含一个名为<clinit>()的方法,JVM在初始化阶段便会调用该方法。需要说明一点,该方法仅能由Java编译器生成并由JVM调用,程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。等JVM完成类的初始化之后,便"万事俱备,只欠东风",就等着开发者使用了。使用方式多种多样,其中最常见的一种方式是通过new关键字来实例化一个Java类。当然,除了通过new关键字使用Java类,还可以有多种方式,例如下面这个例子:
该示例使用Class.forName(String)接口加载一个类,并通过Class.newInstance()接口实例化一个类。从广义上说,类的加载也可以对应类生命周期的7个阶段的前5个阶段,即加载、验证、准备、解析和初始化。当类加载之后,JVM内部会为Java类创建一个对等的类模板,类模板在JDK6时代被存储在所谓的perm取,而到了JDK8时代,则被存储在所谓的metaSpace区。无论存储在哪里,当存储区即将被打爆而这个类又不再使用时,JVM的GC便有可能将其回收,即释放内存。而当实例化一个Java类之后,JVM内部则会为Java类实例对象创建一个对等的实例对象,该实例对象所存储的区域与具体的GC策略紧密关联,有可能在新生代,也可能在老年代,当然,更可能在栈上(栈上分配)。当类被使用完毕之后,JVM必须销毁实例对象,否则JVM内存区早晚会被打爆。JVM对类模板的销毁和类实例对象的销毁,都是卸载。
总体而言,Java类的生命周期如图所示。虽然Java类的声明周期包含7个阶段,然而这7个阶段到底做了些什么事情,内部实现机制如何,我们仍然一无所知,如果不了解这些内部机制,则即时知道这7个阶段,怕是也没啥用处
类加载。
前面已经描述过JVM对字节码文件的精华部分的解析过程,当字节码文件解析完成之后,JVM便会在内部创建一个与Java类对等的类模板对象,说白了该对象其实是C++类的实例。每一个Java类模板,最终在JVM内部都会有一个KlassOop与之对等,Java类中的字段、方法及常量池等都会保存到klassOop实例对象中。要注意这个实例对象并非Java类的实例对象,其仅仅用于表示Java类型本身,或者Java类的定义,与Java类实例对象对等的JVM内部对象是instanceOop实例。
下面就从Java类模板对象——instanceKlass的创建开始分析。
前面描述过Java字节码文件的常量池解析、字段解析与方法解析,这三部分内容的解析便是Java字节码文件的精华所在。这三部分内容的解析都位于ClassFileParser::parseClassFile()函数中,对应的接口分别如下:
# parse_constant_pool(), 解析常量池
# parse_fileds(), 解析Java类字段
# parse_methods(), 解析Java类方法
这3个接口的源代码在前面详细解读过,当这3个接口执行完成之后,Java字节码文件的精华便被分析完了,至此JVM便对Java类中所定义的一切数据结构和算法"了如指掌",为了巩固胜利成果,JVM需要将这些好不容易辛辛苦苦解析出来的结果保存起来。这些计息的结果会存储到klassOop这个内部对象实例中,可以将g该对象看作Java类在JVM内部完全对等的一个镜像——只不过Java类是写给人类看的,而内部镜像klassOop则是写给机器读的。当成功保存解析结果之后,则Java类的声明周期的第一个阶段——加载,便大功告成。不看过程看结果,类加载阶段其实就是为了这一目标而来——在JVM内部创建一个与Java类结构对等的数据对象。
HotSpot仍然在ClassFileParser::parseClassFile()函数中完成klassOop的创建,其主要源码如下:
前面已经描述过JVM对字节码文件的精华部分的解析过程,当字节码文件解析完成之后,JVM便会在内部创建一个与Java类对等的类模板对象,说白了该对象其实是C++类的实例。每一个Java类模板,最终在JVM内部都会有一个KlassOop与之对等,Java类中的字段、方法及常量池等都会保存到klassOop实例对象中。要注意这个实例对象并非Java类的实例对象,其仅仅用于表示Java类型本身,或者Java类的定义,与Java类实例对象对等的JVM内部对象是instanceOop实例。
下面就从Java类模板对象——instanceKlass的创建开始分析。
前面描述过Java字节码文件的常量池解析、字段解析与方法解析,这三部分内容的解析便是Java字节码文件的精华所在。这三部分内容的解析都位于ClassFileParser::parseClassFile()函数中,对应的接口分别如下:
# parse_constant_pool(), 解析常量池
# parse_fileds(), 解析Java类字段
# parse_methods(), 解析Java类方法
这3个接口的源代码在前面详细解读过,当这3个接口执行完成之后,Java字节码文件的精华便被分析完了,至此JVM便对Java类中所定义的一切数据结构和算法"了如指掌",为了巩固胜利成果,JVM需要将这些好不容易辛辛苦苦解析出来的结果保存起来。这些计息的结果会存储到klassOop这个内部对象实例中,可以将g该对象看作Java类在JVM内部完全对等的一个镜像——只不过Java类是写给人类看的,而内部镜像klassOop则是写给机器读的。当成功保存解析结果之后,则Java类的声明周期的第一个阶段——加载,便大功告成。不看过程看结果,类加载阶段其实就是为了这一目标而来——在JVM内部创建一个与Java类结构对等的数据对象。
HotSpot仍然在ClassFileParser::parseClassFile()函数中完成klassOop的创建,其主要源码如下:
在这段源码中,将常量池解析、Java类字段解析、Java方法解析的调用同时贴了出来,这样方便把握字节码文件解析的主脉络。。在创建klassOop时,首先通过调用oopFactory::new_instanceKlass()接口在内存中构建一个instanceKlass对象实例,该接口的机制与前面JVM构建常量池对象实例的逻辑类似,与常量池的构建、方法对象methodOop的构建一样,构建实例对象绕不开的一个问题是,所创建的对象占多大的内存空间?只需要看下在调用oopFactory::new_instanceKlass()【JDK8中是InstanceKlass::allocate_instance_klass】接口时所传入的参数,上面的源码包含了该接口d调用的部分,从上面的源码可知,z在调用该接口时传入了vtable_size、itable_size、static_field_size和total_oop_map_count这4个与大小有关的数据,之所以要传入这4个数据,是因为它们与klassOop在内存中的实际布局是有关系的。oopFactory::new_instanceKlass()接口所构建的对象类型是instanceKlass,该类型继承自Klass类,其内部结构如下:
instanceKlass // 类结构
## Klass // 结构部分
#### jint _layout_helper //布局类型
#### juint _super_check_offset
#### Symbol* _name // 类名
#### klassOop _secondary_super_cache
// ...
#### jint _biased_lock_revocation_count
## instanceKlass // 结构部分
#### klassOop _array_klasses
#### objArrayOop _methods // Java类中定义的方法信息
#### typeArrayOop _fields // Java类中定义的字段信息
#### oop _class_loader // 类加载器
#### typeArrayOOp _inner_classes // 内部类
#### int _nonstatic_field_size // 非静态字段的大小
#### int _static_field_size // 静态字段大小
#### int _vtable_len // 虚方法表长度
// ...
vlatile u2 _idnum_allocated_count
instanceKlass // 类结构
## Klass // 结构部分
#### jint _layout_helper //布局类型
#### juint _super_check_offset
#### Symbol* _name // 类名
#### klassOop _secondary_super_cache
// ...
#### jint _biased_lock_revocation_count
## instanceKlass // 结构部分
#### klassOop _array_klasses
#### objArrayOop _methods // Java类中定义的方法信息
#### typeArrayOop _fields // Java类中定义的字段信息
#### oop _class_loader // 类加载器
#### typeArrayOOp _inner_classes // 内部类
#### int _nonstatic_field_size // 非静态字段的大小
#### int _static_field_size // 静态字段大小
#### int _vtable_len // 虚方法表长度
// ...
vlatile u2 _idnum_allocated_count
从instanceKlass的结构可以看到,其内部定义了若干字段,这些字段足以存储Java类规范所支持的一切信息,例如字段、方法、内部类等,因为instanceKlass要作为Java类在JVM内部对等的结构体,所以能够兼容Java类中的所有元素是其唯一的设计目标。但是JVM在创建instanceKlass对象时,为其所申请的内存空间却超过了instanceKlass类型本身所需的内存大小,这是因为JVM需要在instanceKlass内存空间的末尾再预留出足够的空间,存储虚方法表vtable、接口表itable及Java类中的引用类型表oopMap.存储x虚方法表vtable,itable与oopMap也是各有其作用。不过在调用oopFactory::new_instanceKlass()接口创建instanceKlass对象实例时,还传入了static_field_size这个数据,其表示Java类中所定义的静态字段所占内存的大小。不过静态字段在不同的JDK版本中存放的位置不同,在JDK6中,静态字段会被分配到instanceKlass实例对象所申请的内存空间中,而在JDK7和8中,静态字段将会被分配到与instanceKlass对等的镜相类——java.lang.Class实例中。由于在JDK6中,静态字段信息也会存放在instanceKlass对象的预留内存空间中,因此最终JVM为instanceKlass申请的内存空间大小实际上是instanceKlass类型本身所占的内存大小与vtable、itable、static fields及oopMap的大小之和,这种逻辑在代码中得到了体现,JDK8中的allocate_instance_klass接口的实现逻辑如图所示。
与为常量池和方法对象申请内存的实现逻辑一样,在为instanceKLass申请内存空间时,oopFactory也是先计算所要申请的内存大小,然后调用相应的接口进行申请。在这段代码中可以看到,所要申请的预留内存空间大小size的计算逻辑是vtable、itable、static fields及oopmap之和,因此最终静态字段一定在这个预留空间中。
与为常量池和方法对象申请内存的实现逻辑一样,在为instanceKLass申请内存空间时,oopFactory也是先计算所要申请的内存大小,然后调用相应的接口进行申请。在这段代码中可以看到,所要申请的预留内存空间大小size的计算逻辑是vtable、itable、static fields及oopmap之和,因此最终静态字段一定在这个预留空间中。
在JDK6中,JVM在instanceKlass的内存空间末尾预留出足够的空间,存放虚方法表vtable、接口表itable、静态字段信息表及Java类中的引用类型表oopMap,这4张表存放的顺序依次是vtable、itable、static fields和oopMap,这是由于JVM的源码所规定的,源码如图所示
在instanceKlass.hpp文件中定义了3种接口,分别用于获取vtable、itable和oopMap这3种数据在内存种的地址,通过上面这段逻辑分析可知,这3部分数据的存储顺序依次是vtable、itable和oopMap。JVM调用oopFactory::new_instanceKlass()接口所创建的数据结构如图所示。
图中的这个内存数据结构,便是Java类加载的最终产物,也是Java类在内存种的对等体。JVM根据这个数据结构,能够获取Java类种所定义的一切元素,仔细观察图种所示,会发现其中有一项数据是c++ vtbl pointer,顾名思义,这便是C++类型对象种的虚方法表指针。由于HotSpot内部的klass都继承自基类Klass,而Klass类又继承自Klass_vtbl,但是Klass_vtbl中却有一个虚方法unused_initial_virtual()。如果C++类中也包含虚方法,则编译器会在C++实例对象头部插入虚方法表的指针,其道理与Java的虚方法表一样,都是为了实现多态,不过在JDK6里面让所有的klass类型都继承自klass_vtbl,是为了更好地管理vtable,方便方法区的内存回收,但是道到了JDK8就没有这玩意儿了,因为所有的klass都继承了Metadata类
图中的这个内存数据结构,便是Java类加载的最终产物,也是Java类在内存种的对等体。JVM根据这个数据结构,能够获取Java类种所定义的一切元素,仔细观察图种所示,会发现其中有一项数据是c++ vtbl pointer,顾名思义,这便是C++类型对象种的虚方法表指针。由于HotSpot内部的klass都继承自基类Klass,而Klass类又继承自Klass_vtbl,但是Klass_vtbl中却有一个虚方法unused_initial_virtual()。如果C++类中也包含虚方法,则编译器会在C++实例对象头部插入虚方法表的指针,其道理与Java的虚方法表一样,都是为了实现多态,不过在JDK6里面让所有的klass类型都继承自klass_vtbl,是为了更好地管理vtable,方便方法区的内存回收,但是道到了JDK8就没有这玩意儿了,因为所有的klass都继承了Metadata类
类加载——镜相类与静态字段。。
类加载的最终结果便是在JVM的方法区创建一个与Java类对等的instanceKlass实例对象,但是在JVM创建完instanceKlass之后,又创建了与之对等的另一个镜相类——java.lang.Class.在JDK6中,创建镜相类的逻辑被包含在instanceKlassKlass::allocate_instance_klass()函数中,在该函数的末尾执行java_lang_Class::create_mirror()调用,该接口的实现逻辑如下。通过观察这段源码可知,所谓的mirror镜像类,其实也是instanceKlass的一个实例对象,SystemDictionary::Class_klass()返回的便是java_lang_Class类型,因此instsanceMirrorKlass::cast(SystemDictionary::Class_klass())->allocate_instance(k, CHECK_0)这行代码就是用来创建java.lang.Class()这个Java类型在JVM内部对等的instanceKlass实例的。接着通过k->set_java_mirror,而不是InstanceKlass。
类加载的最终结果便是在JVM的方法区创建一个与Java类对等的instanceKlass实例对象,但是在JVM创建完instanceKlass之后,又创建了与之对等的另一个镜相类——java.lang.Class.在JDK6中,创建镜相类的逻辑被包含在instanceKlassKlass::allocate_instance_klass()函数中,在该函数的末尾执行java_lang_Class::create_mirror()调用,该接口的实现逻辑如下。通过观察这段源码可知,所谓的mirror镜像类,其实也是instanceKlass的一个实例对象,SystemDictionary::Class_klass()返回的便是java_lang_Class类型,因此instsanceMirrorKlass::cast(SystemDictionary::Class_klass())->allocate_instance(k, CHECK_0)这行代码就是用来创建java.lang.Class()这个Java类型在JVM内部对等的instanceKlass实例的。接着通过k->set_java_mirror,而不是InstanceKlass。
事实上,JDK类库中所提供的反射等工具类,其实都基于java.lang.CLass这个内部镜像实现。例如下面这个Java程序。
该示例Java类很简单,Test类中包含2个公开的字段和一个公开的方法,在main()方法中通过java.lang.Class.forName(String)接口反射获取Test类型,反射之后通过java.lang.Class.getFields()接口获取Test类中所包含的全部公开字段数组,并遍历字段数组,打印出字段名。运行该程序,输出如图所示。打印结果显示Test类中一共包含2个公开字段,与定义的完全一致。在这里,重点研究的是,java.lang.Class.getFields()接口究竟如何知道Test类中有两个公开的字段。源码面前无秘密
该示例Java类很简单,Test类中包含2个公开的字段和一个公开的方法,在main()方法中通过java.lang.Class.forName(String)接口反射获取Test类型,反射之后通过java.lang.Class.getFields()接口获取Test类中所包含的全部公开字段数组,并遍历字段数组,打印出字段名。运行该程序,输出如图所示。打印结果显示Test类中一共包含2个公开字段,与定义的完全一致。在这里,重点研究的是,java.lang.Class.getFields()接口究竟如何知道Test类中有两个公开的字段。源码面前无秘密
首先看java.lang.Class.getFields()接口,该接口最终会调用java.lang.Class.getDeclaredFields0(boolean publicOnly)接口,该接口是一个native接口,其最终调用的接口位于HotSpot内部的函数中,该函数如下:
上面这个JVM_GetClassDeclaredFields()函数的第2个入参ofClass便是java.lang.Class类型实例。同时,在执行上面这个JVM_GetClassDeclaredFields()函数调用时,说明其前面的一个步骤——Class klass = Class.forName("Test")已经执行完了,此时在JVM内部的klass实例,实际上是Test类型在JVM内部的全部信息,所以在JVM_GetClassDeclaredFields()函数中能够获取Test类中的全部字段。这便是Java反射的原理。通过本示例也可以知道,Java的反射是离不开java.lang.Class这个镜像类的。如果思维再放得开阔一点,可以这样认为,即时JVM内部没有安排jjava.lang.Class这么一个媒介作为面向对象反射的基础,那么JVM也必然要定义另外类,假设这个类就叫做Reflection,这个类能够直接被Java程序开发者使用,那么Reflection这个类也必然需要在JVM内部与所要反射的目标Java类对应的instanceKlass之间建立联系,能够让Java开发者通过这个Reflection类反射出目标Java类的字段、方法等全部信息。从这个意义上而言,java.lang.Class并非是偶然有的,而是必然,是Java这种面向对象的语言与虚拟机实现机制这两种规范下的必然技术实现,如果非要说有巧合的话,那便是恰好叫了"java.lang.Class"这个类名
既然java.lang.Class是一个必然的存在,所以每次JVM在内部为Java类创建一个对等的instanceKlass时,都要再创建一个对应的Class镜像类,作为反射的基础。
上面这个JVM_GetClassDeclaredFields()函数的第2个入参ofClass便是java.lang.Class类型实例。同时,在执行上面这个JVM_GetClassDeclaredFields()函数调用时,说明其前面的一个步骤——Class klass = Class.forName("Test")已经执行完了,此时在JVM内部的klass实例,实际上是Test类型在JVM内部的全部信息,所以在JVM_GetClassDeclaredFields()函数中能够获取Test类中的全部字段。这便是Java反射的原理。通过本示例也可以知道,Java的反射是离不开java.lang.Class这个镜像类的。如果思维再放得开阔一点,可以这样认为,即时JVM内部没有安排jjava.lang.Class这么一个媒介作为面向对象反射的基础,那么JVM也必然要定义另外类,假设这个类就叫做Reflection,这个类能够直接被Java程序开发者使用,那么Reflection这个类也必然需要在JVM内部与所要反射的目标Java类对应的instanceKlass之间建立联系,能够让Java开发者通过这个Reflection类反射出目标Java类的字段、方法等全部信息。从这个意义上而言,java.lang.Class并非是偶然有的,而是必然,是Java这种面向对象的语言与虚拟机实现机制这两种规范下的必然技术实现,如果非要说有巧合的话,那便是恰好叫了"java.lang.Class"这个类名
既然java.lang.Class是一个必然的存在,所以每次JVM在内部为Java类创建一个对等的instanceKlass时,都要再创建一个对应的Class镜像类,作为反射的基础。
在JDK6中,静态字段会存储在instanceKlass的预留空间里,在JVM为instanceKlass申请内存空间时已经为静态字段预留了空间,而在创建完instanceKlass之后,JVM在ClassFileParser::parseClassFile()函数中调用this_klass->do_local_static_fields(&initialize_static_field, CHECK_(nullHandle))【JDK8中是InstanceKlass::cast(k())->do_local_static_fields(&initialize_static_field, mirror, CHECK);】对这部分内存空间进行初始化,do_local_static_fields()函数的实现如下。这段逻辑遍历Java类中d的全部静态字段并逐个将其塞进instanceKlass的预留空间中。在这段逻辑中,需要注意,instanceKlass::do_local_static_fields(void f(fieldDescriptor*, TRAPS), TRAPS)函数的第一个入参是函数指针,instanceKlass::do_local_static_fields(void f(fieldDescriptor*, TRAPS), TRAPS)内部调用了instanceKlass::do_local_static_fields_impl(instanceKlassHandle this_oop, vpod f(fieldDescriptor* fd, TRPAS), TRAPS),而在后者内部则通过函数指针f调用其指向的函数,那么指针f指向哪个函数呢?
在ClassFileParser::parseClassFile()函数中调用instanceKlass::do_local_static_fields(void f(fieldDescriptor*, TRAPS), TRAPS)时,所传入的函数指针是&initialize_static_field,所以该指针指向的如下。。在该函数中,通过调用h_k()->**_field_put【JDK8中是mirror()->**_field_put】系列接口,将不同类型的静态字段存储到instanceKlass对象实例的预留内存空间中,如此便完成了Java类中静态字段的存储。而在JDK8中,静态字段不再存储于instanceKlass预留空间,而是转移到instanceKlass的镜像类——java.lang.Class的预留空间里去,因此在JDK8中的源码中,上面的这个initialize_static_field()函数定义到javaClasses.cpp中了。同时,创建mirror镜像类的接口也不再在java_lang_Class::create_mirror()函数中调用,而是在ClassFileParser::parseClassFile()函数中调用。虽然调用的地方不同了,但是函数实现的内部机制并没有从根本上发生变化,因此从这一点上看,JDK6和JDK8并没有做很大的变更。JDK8之所以要将静态字段从instanceKlass迁移到mirror中,也不是没有道理。毕竟静态字段并非Java类的成员变量,,如果从数据结构这个角度看,静态字段不能算作Java类这个数据结构的一部分,因此JDK8将静态字段转移到mirror中。从反射的角度看,静态字段放在mirror中是合理的。毕竟在进行反射时,需要给出Java类中所定义的全部字段,无论字段是不是静态类型。例如,将上面的Test类做个修改,在里面增加一个static类型的公开字段,则最终的打印结果会包含该字段。
综上所述,对于JDK6而言,类加载阶段所产出的最终结果便是如图所示的这两个实例对象。。在JDK6中,由于mirror也是一个instanceKlass,因此其包含了instanceKlass所包含的一切字段
Java主类加载机制
在前面详细分析了常量池解析、字段解析、方法解析、instanceKlass创建以及镜像类的创建。之所以要逐个详细分析,一方面是因为JJVM使用C/C++编写而成,而C/C++语言本身就比Java语言更具难度,相信只要不是直接从事JVM开发的道友,阅读起来都会比较吃力,里面有太多的内存分配、回收、指针、类型转换的内容。另一方面是因为JVM作为虚拟机,里卖弄涉及的计算机基础知识多而杂,几乎覆盖了方方面面,其实现也复杂,然而其过程也精彩,所以虽然阅读的过程痛苦,但是结果却是快乐的,理解了原理之后再次面对Java程序,会有一种"一览众山小"的感觉。
总结一下类加载的整体过程,虚拟机在得到一个Java class文件流之后,接下来要完成的主要步骤如下:
1.读取魔数与版本号
2.解析常量池,parse_constant_pool()
3.解析字段信息,parse_fields()
4.解析方法,parse_method()
5.创建与Java类对等的内部对象instanceKlass,new_instanceKlass()
6.创建Java镜像类,create_mirror()
以上便是一个Java类加载的核心流程。了解了类加载的核心流程之后,也许你会想,Java类的加载到底何时才会被触发呢?Java类加载的触发条件比较多,其中比较特殊的便是Java程序中包含main()主函数的累——这种类一般也被称作Java程序的主类。Java主类的加载由JVM自动触发——JVM执行完自身的若干初始化逻辑之后,第一个加载的便是Java程序的主类。总体上而言,Java主类加载的链路如下:
在前面详细分析了常量池解析、字段解析、方法解析、instanceKlass创建以及镜像类的创建。之所以要逐个详细分析,一方面是因为JJVM使用C/C++编写而成,而C/C++语言本身就比Java语言更具难度,相信只要不是直接从事JVM开发的道友,阅读起来都会比较吃力,里面有太多的内存分配、回收、指针、类型转换的内容。另一方面是因为JVM作为虚拟机,里卖弄涉及的计算机基础知识多而杂,几乎覆盖了方方面面,其实现也复杂,然而其过程也精彩,所以虽然阅读的过程痛苦,但是结果却是快乐的,理解了原理之后再次面对Java程序,会有一种"一览众山小"的感觉。
总结一下类加载的整体过程,虚拟机在得到一个Java class文件流之后,接下来要完成的主要步骤如下:
1.读取魔数与版本号
2.解析常量池,parse_constant_pool()
3.解析字段信息,parse_fields()
4.解析方法,parse_method()
5.创建与Java类对等的内部对象instanceKlass,new_instanceKlass()
6.创建Java镜像类,create_mirror()
以上便是一个Java类加载的核心流程。了解了类加载的核心流程之后,也许你会想,Java类的加载到底何时才会被触发呢?Java类加载的触发条件比较多,其中比较特殊的便是Java程序中包含main()主函数的累——这种类一般也被称作Java程序的主类。Java主类的加载由JVM自动触发——JVM执行完自身的若干初始化逻辑之后,第一个加载的便是Java程序的主类。总体上而言,Java主类加载的链路如下:
Java程序main主类加载的调用链路(JDK6)
1.java.c::JavaMain():执行mainClass = LoadClass(env, classname);
2.java.c::LoadClass(): 执行cls = (*env)->FindClass(env, buf)来寻找主类
注意:
JDK8中,则是在Java层进行了主类的加载
NULL_CHECK0(mid = (*env)->GetStaticMethodID(env, cls,
"checkAndLoadMain",
"(ZILjava/lang/String;)Ljava/lang/Class;"));
str = NewPlatformString(env, name);
CHECK_JNI_RETURN_0(
result = (*env)->CallStaticObjectMethod(
env, cls, mid, USE_STDERR, mode, str));
注意:
JDK8中,则是在Java层进行了主类的加载
NULL_CHECK0(mid = (*env)->GetStaticMethodID(env, cls,
"checkAndLoadMain",
"(ZILjava/lang/String;)Ljava/lang/Class;"));
str = NewPlatformString(env, name);
CHECK_JNI_RETURN_0(
result = (*env)->CallStaticObjectMethod(
env, cls, mid, USE_STDERR, mode, str));
3.jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)): 执行loader = Handle(THREAD, SystemDictionary::java_system_loader());
4.jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)): 执行result = find_class_from_class_loader(env, sym, true, loader,
protection_domain, true, thread);加载主类
4.jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)): 执行result = find_class_from_class_loader(env, sym, true, loader,
protection_domain, true, thread);加载主类
5.jvm.cpp::find_class_from_class_loader():执行Klass* klass = SystemDictionary::resolve_or_fail(name, loader, protection_domain, throwError != 0, CHECK_NULL);
6.SystemDictionary::resovle_or_fail()
7.SystemDictionary::resolve_or_null()
8.SystemDictionary::resolve_instance_class_or_null(): 执行k = load_instance_class(name, class_loader, THREAD);
9.SystemDictionary::load_instance_class()->JavaClass::call_virtual()
10.java.lang.ClassLoader.loadClass(String)
11.sun.misc.AppClassLoader.loadClass(String, boolean)
12.java.lang.ClassLoader.loadClass(String, boolean)
13.java.net.URLClassLoader.findClass(final String)
14.java.net.URLClassLoader.defineClass(String, Resource)
15.java.lang.ClassLoader.defineClass(String, java.io.ByteBuffer, ProtectionDomain)
16.native java.lang.ClassLoader.defineClass0()->ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()
result = JVM_DefineClassWithSource(env, utfName, loader, body, length, pd, utfSource);
result = JVM_DefineClassWithSource(env, utfName, loader, body, length, pd, utfSource);
17.jvm.cpp::JVM_DefineClassWithSource()
18.jvm.cpp::jvm_define_class_common()
19.SystemDictionary.cpp::resolve_from_stream()
20.ClassFileParser.cpp::parseClassFile() P632
调用链路的核心逻辑如下
(1) JVM启动后,操作系统会调用java.c::main()主函数,从而进入JVM的世界。java.c::main()方法调用java.c::JavaMain()方法,java.c::JavaMain()方法主要执行JVM的初始化逻辑,初始化完毕之后,便会搜索Java程序的main()主函数所在的类,也即"主类",找到主类的类名之后,便会调用mainClass = LoadClass(env, classname)对主类进行加载
(2) LoadClass(env, class)方法是java.c::LoadClass()方法,而后者执行cls = (*env)->FindClass(env, buf)来寻找主类
(3) (*env)->FindClass(env, buf)函数首先跳转到jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv*env, const char* name)),JNI_ENTRY是一个宏,在预编译阶段便已展开,这个宏作用的结果是:(*env)->FindClass(env, 最终会调用jni.cpp::jni_FindClass(JNIEnv *env, const char* name)函数。
jni.cpp::jni_FindClass(JNIEnv* env, const char* name)函数先调用loader = Handle(THREAD, SystemDictionary::java_system_loader())获取类加载器。Java程序主类的类加载器默认是系统加载器,该加载器s是JDK类库中定义的sun.misc.AppClassLoader,关于该加载器的细节会后面详述
jni.cpp::jni_FindClass(JNIEnv* env, const char* name)函数先调用loader = Handle(THREAD, SystemDictionary::java_system_loader())获取类加载器。Java程序主类的类加载器默认是系统加载器,该加载器s是JDK类库中定义的sun.misc.AppClassLoader,关于该加载器的细节会后面详述
JVM体系中加载器的继承关系如图所示。
由图可知,系统加载器的顶级父类是java.lang.ClassLoader,这是JDK类库所提供的核心加载器。事实上,无论Java程序内部有没有自定义类加载器,最终都会调用java.lang.ClassLoader所提供的几个native接口完成类的加载。这些接口主要b包括如下3种:
private native Class<?> defineClass0(String name, byte[] b, int off, int len, ProtectionDomain pd);
private native Class<?> defineClass1(String name, byte[] b, int off, int len, ProtectionDomain pd, String source);
private native Class<?> defineClass2(String name, java.nio.ByteBuffer b, int off, int len, ProtectionDomain pd, String source);
Java主类的加载也无法绕过这3个接口。
jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv* env, const char* name))函数内部获取到系统加载器之后,接着便开始调用find_class_from_class_loader()接口加载主类,而后者则调用SystemDictionary::resolve_or_fail()接口
由图可知,系统加载器的顶级父类是java.lang.ClassLoader,这是JDK类库所提供的核心加载器。事实上,无论Java程序内部有没有自定义类加载器,最终都会调用java.lang.ClassLoader所提供的几个native接口完成类的加载。这些接口主要b包括如下3种:
private native Class<?> defineClass0(String name, byte[] b, int off, int len, ProtectionDomain pd);
private native Class<?> defineClass1(String name, byte[] b, int off, int len, ProtectionDomain pd, String source);
private native Class<?> defineClass2(String name, java.nio.ByteBuffer b, int off, int len, ProtectionDomain pd, String source);
Java主类的加载也无法绕过这3个接口。
jni.cpp::JNI_ENTRY(jclass, jni_FindClass(JNIEnv* env, const char* name))函数内部获取到系统加载器之后,接着便开始调用find_class_from_class_loader()接口加载主类,而后者则调用SystemDictionary::resolve_or_fail()接口
(4)SystemDictionary::resolve_or_fail()接口经过一系列调用,最终调用SystemDictionary::resolve_instance_class_or_null()接口,该接口内部逻辑比较冗长,会经过层层判断,确认同一个加载器没有别的线程在加载同一个类,则最终会执行真正的加载,调用SystemDictionary::load_instance_class()接口,该接口内部执行如下调用:
JavaCalls::call_virtual()接口的主要功能是根据输入的参数,调用指定的Java类中的指定方法。该接口的第2个入参(入参从位置1开始计数)指明所调用的Java类对应的instance,第4个入参指令所调用的特定方法,第5个入参指明所调用的Java类的签名信息。当JVM执行Java程序主类加载时,向JavaCalls::call_virtual()接口传入的第2和第4个入参分别是class_loader和vmSymbols::loadClass_name(), vmSymbols::loadClass_name()返回的方法名是loadClass(),而class_loader则是前置流程中实例化好的系统加载器——AppClassLoader,在JVM内部对等的实例对象。同时,JavaCalls::call_virtual()接口的第5个参数是vmSymbols::string_class_signature(),其返回的字符串是(Ljava/lang/String;)Ljava/lang/Class,该字符串表示所调用的Java方法的入参是Ljava/lang/String,而返回值则是Ljava/lang/Class.由此可知,当JVM加载Java程序的主类时,最终会调用AppClassLoader.loadClass(String)这个方法。由此,JVM的流程便转移到了Java的世界,进入到了Java类的逻辑流之中。
JavaCalls::call_virtual()接口最终会调用JavaCalls::call()接口,JavaCalls::call()接口调用JavaCalls::call_helper(),而后者则会调用StubRoutines::call_stub()例程。总体而言,该例程在运行期对应着一段机器码,其作用是辅佐JVM执行Java类方法。这里不得不提一句,JVM作为一款虚拟机,其本身由C/C++语言写成,但是JVM是位执行Java字节码文件而生的,因此JVM内部必然有一套机制能够从C/C++程序调用Java类中的方法,这套机制便通过JavaCalls类来实现,该类中定义了各种call_*()接口,这些接口最终都要调用StubRoutines::call_stub()例程,从而辅佐JVM执行Java方法。事实上,JavaCalls::call_virtual()接口在JVM内部是一个很常用的接口,大凡涉及Java类成员方法的调用最终都会经过该接口
JavaCalls::call_virtual()接口的主要功能是根据输入的参数,调用指定的Java类中的指定方法。该接口的第2个入参(入参从位置1开始计数)指明所调用的Java类对应的instance,第4个入参指令所调用的特定方法,第5个入参指明所调用的Java类的签名信息。当JVM执行Java程序主类加载时,向JavaCalls::call_virtual()接口传入的第2和第4个入参分别是class_loader和vmSymbols::loadClass_name(), vmSymbols::loadClass_name()返回的方法名是loadClass(),而class_loader则是前置流程中实例化好的系统加载器——AppClassLoader,在JVM内部对等的实例对象。同时,JavaCalls::call_virtual()接口的第5个参数是vmSymbols::string_class_signature(),其返回的字符串是(Ljava/lang/String;)Ljava/lang/Class,该字符串表示所调用的Java方法的入参是Ljava/lang/String,而返回值则是Ljava/lang/Class.由此可知,当JVM加载Java程序的主类时,最终会调用AppClassLoader.loadClass(String)这个方法。由此,JVM的流程便转移到了Java的世界,进入到了Java类的逻辑流之中。
JavaCalls::call_virtual()接口最终会调用JavaCalls::call()接口,JavaCalls::call()接口调用JavaCalls::call_helper(),而后者则会调用StubRoutines::call_stub()例程。总体而言,该例程在运行期对应着一段机器码,其作用是辅佐JVM执行Java类方法。这里不得不提一句,JVM作为一款虚拟机,其本身由C/C++语言写成,但是JVM是位执行Java字节码文件而生的,因此JVM内部必然有一套机制能够从C/C++程序调用Java类中的方法,这套机制便通过JavaCalls类来实现,该类中定义了各种call_*()接口,这些接口最终都要调用StubRoutines::call_stub()例程,从而辅佐JVM执行Java方法。事实上,JavaCalls::call_virtual()接口在JVM内部是一个很常用的接口,大凡涉及Java类成员方法的调用最终都会经过该接口
(5) 经过上一个步骤,JVM最终会调用sun.misc.AppClassLoader.loadClass(String)接口加载Java应用程序的主类。AppClassLoader继承自java.lang.ClassLoader这个基类,java.lang.ClassLoader.loadClass(String)方法调用loadClass(String, boolean)方法,由于继承的关系,实际调用的是sun.misc.AppClassLoader.loadClass(String, boolean)方法,该方法的实现逻辑如下:
z这段代码逻辑是,先判断所加载的类名中是否包含点号".",如果包含则说明传入的一定是类的全限定名,包含了报名,则JVM调用SecurityManager模块检查包的访问权限。通过访问权限验证之后,则调用super.loadClass(name, resolve)方法。
z这段代码逻辑是,先判断所加载的类名中是否包含点号".",如果包含则说明传入的一定是类的全限定名,包含了报名,则JVM调用SecurityManager模块检查包的访问权限。通过访问权限验证之后,则调用super.loadClass(name, resolve)方法。
由于继承关系,super.loadClass(name, resolve)方法其实调用的是java.lang.ClassLoader.loadClass(String name, boolean resolve)方法,该方法的主要逻辑如下:
在java.lang.ClassLoader.loadClass(String name, boolean resolve)方法中,首先通过findLoadedClass(name)方法判断当前加载器是否加载过指定的类,如果没有加载,则判断当前加载器的parent是否为null,如果不为null,则调用parent.loadClass(name, false)方法,通过父类加载器加载指定的Java类。AppClassLoader的父类加载器是ExtClassLoader,这是扩展类加载器,用于加载JDK中指定路径下的扩展类,这种加载器不会加载Java应用程序的主类,所以程序流回进入if (this.parent != null) {}代码块,但是parent.loadClass(name, false)返回null.接着java.lang.ClassLoader.loadClass(String name, boolean resolve)方法只能通过调用this.findClass(name)来加载Java主类。
java.lang.ClassLoader.findClass(String)方法直接抛出异常,因此该类注定要由子类来实现。对于系统类加载器AppClassLoader,其继承自URLClassLoader,因此java.lang.ClassLoader.findClass(String)方法实际指向java.net.URLClassLoader.findClass(Stirng).java.net.URLClassLoader.findClass(String)方法最终调用java.lang.ClassLoader.defineClass1()这一native接口,这是一个本地接口,由本地类库实现。openjdk项目包含了JDK核心Java类库中的全部本地实现,java.lang.ClassLoader.defineClass1()所对应的本地实现是ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()通过调用java.lang.ClassLoader.defineClass1()接口,Java程序流又转移到JVM内部,因此Java类的加载最终仍然是通过JVM本地类库得以实现。
ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()调用jvm.cpp::JVM_DefineClassWithSource().jvm.cpp::JVM_DefineClassWithSource()调用jvm.cpp::jvm_define_class_common(),而后者则调用SystemDictionary.cpp::resolve_from_stream()接口来加载Java主类。在SystemDictionary.cpp::resolve_from_stream()接口中,终于开始调用ClassFileParser.cpp::parseClassFile()这个函数来解析Java主类,并最终创建Java主类在JVM内部的对等体InstanceKlass,由此完成Java主类的加载
在java.lang.ClassLoader.loadClass(String name, boolean resolve)方法中,首先通过findLoadedClass(name)方法判断当前加载器是否加载过指定的类,如果没有加载,则判断当前加载器的parent是否为null,如果不为null,则调用parent.loadClass(name, false)方法,通过父类加载器加载指定的Java类。AppClassLoader的父类加载器是ExtClassLoader,这是扩展类加载器,用于加载JDK中指定路径下的扩展类,这种加载器不会加载Java应用程序的主类,所以程序流回进入if (this.parent != null) {}代码块,但是parent.loadClass(name, false)返回null.接着java.lang.ClassLoader.loadClass(String name, boolean resolve)方法只能通过调用this.findClass(name)来加载Java主类。
java.lang.ClassLoader.findClass(String)方法直接抛出异常,因此该类注定要由子类来实现。对于系统类加载器AppClassLoader,其继承自URLClassLoader,因此java.lang.ClassLoader.findClass(String)方法实际指向java.net.URLClassLoader.findClass(Stirng).java.net.URLClassLoader.findClass(String)方法最终调用java.lang.ClassLoader.defineClass1()这一native接口,这是一个本地接口,由本地类库实现。openjdk项目包含了JDK核心Java类库中的全部本地实现,java.lang.ClassLoader.defineClass1()所对应的本地实现是ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()通过调用java.lang.ClassLoader.defineClass1()接口,Java程序流又转移到JVM内部,因此Java类的加载最终仍然是通过JVM本地类库得以实现。
ClassLoader.c::Java_java_lang_ClassLoader_defineClass1()调用jvm.cpp::JVM_DefineClassWithSource().jvm.cpp::JVM_DefineClassWithSource()调用jvm.cpp::jvm_define_class_common(),而后者则调用SystemDictionary.cpp::resolve_from_stream()接口来加载Java主类。在SystemDictionary.cpp::resolve_from_stream()接口中,终于开始调用ClassFileParser.cpp::parseClassFile()这个函数来解析Java主类,并最终创建Java主类在JVM内部的对等体InstanceKlass,由此完成Java主类的加载
类加载器的加载机制。
体现Java语言强大生命力和巨大魅力的关键因素之一便是:Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用按理举不胜举,例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布引用程序就能实现。同时,自定义的加载器能够实现应用隔离,例如Tomcat、Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡所有美好的设想。
Java应用程序自定义类加载器很简单,只需要继承java.lang.ClassLoader类,然后覆盖它的findClass(String name)方法即可,使用自定义类加载器来加载Java类的使用方式通常如下:
MyClassLoader mcl = new MyClassLoader();
Class klass = mcl.loadClass("com.***.Test");
Test t = (Test)klass.newInstance();
通过这段示例程序可以看出,使用自定义类加载器进行Java类加载时,首先需要调用加载器的loadClass()接口完成java.lang.Class类的加载,然后才能实例化所要加载的类型实例。自定义类加载器的loadClass()方法所完成的类加载,便是Java类生命周期7个阶段(加载->验证->准备->解析->初始化->使用->卸载)中的类加载阶段。
有很多自定义的类加载器会重写java.lang.ClassLoader中的很多接口,实现起来非常复杂,但是无论多么复杂的自定义类加载器,最终都会调用java.lang.ClassLoader.defineClass*()系列本地接口,最终仍然会由JVM内部的本地实现来完成实际的加载工作,在JVM内部创建与所要加载的目标Java类对等的InstanceKlass对象实例,从而完成Java类型的加载。
关于java.lang.ClassLoader.defineClass*()的本地接口实现机制,在签名分析Java应用程序主类加载时已经讲解过,该接口最终会调用ClassFileParser.cpp::parseClassFile()这个JVM内部的函数完成Java类的解析,并创建内部对应的对象实例,将Java类从字节码文件格式完全转换成内存格式
体现Java语言强大生命力和巨大魅力的关键因素之一便是:Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用按理举不胜举,例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布引用程序就能实现。同时,自定义的加载器能够实现应用隔离,例如Tomcat、Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡所有美好的设想。
Java应用程序自定义类加载器很简单,只需要继承java.lang.ClassLoader类,然后覆盖它的findClass(String name)方法即可,使用自定义类加载器来加载Java类的使用方式通常如下:
MyClassLoader mcl = new MyClassLoader();
Class klass = mcl.loadClass("com.***.Test");
Test t = (Test)klass.newInstance();
通过这段示例程序可以看出,使用自定义类加载器进行Java类加载时,首先需要调用加载器的loadClass()接口完成java.lang.Class类的加载,然后才能实例化所要加载的类型实例。自定义类加载器的loadClass()方法所完成的类加载,便是Java类生命周期7个阶段(加载->验证->准备->解析->初始化->使用->卸载)中的类加载阶段。
有很多自定义的类加载器会重写java.lang.ClassLoader中的很多接口,实现起来非常复杂,但是无论多么复杂的自定义类加载器,最终都会调用java.lang.ClassLoader.defineClass*()系列本地接口,最终仍然会由JVM内部的本地实现来完成实际的加载工作,在JVM内部创建与所要加载的目标Java类对等的InstanceKlass对象实例,从而完成Java类型的加载。
关于java.lang.ClassLoader.defineClass*()的本地接口实现机制,在签名分析Java应用程序主类加载时已经讲解过,该接口最终会调用ClassFileParser.cpp::parseClassFile()这个JVM内部的函数完成Java类的解析,并创建内部对应的对象实例,将Java类从字节码文件格式完全转换成内存格式
打破双亲委派机制-loadClass。。
loadClass的作用是负责类的加载流程(包括双亲委派),通常用于外部调用类加载器加载类
loadClass的作用是负责类的加载流程(包括双亲委派),通常用于外部调用类加载器加载类
打破双亲委派机制-findClass
findClass的作用是负责实际找字节码并转换为Class对象,默认z只在loadClass的委托失败时调用
findClass的作用是负责实际找字节码并转换为Class对象,默认z只在loadClass的委托失败时调用
反射加载机制。
除了通过自定义类加载器完成类的加载,也可以通过java.lang.Class.forName(String)反射接口完成类加载,不过该接口所完成的加载,包含了Java类生命周期7个阶段的前面5个——加载、验证、准备、解析和初始化。
java.lang.Class.forName(String)最终会调用private static native Class<?> forName0()这个本地接口完成类加载。openjdk提供了该方法的本地接口实现,如下:
java.lang.Class.forName0()的本地接口调用jvm.cpp::JVM_FindClassFromCaller()进行类加载,而后者会调用jvm.cpp::find_class_from_class_loader()接口,前面分析Java主类加载时绘制了Java主类的加载链路,Java主类加载也会经过jvm.cpp::find_class_from_class_loader()接口,因此两者的后续流程都相同,最终仍然会从JVM内部发起对java.lang.ClassLoader.loadClass(String)接口的调用,从而完成类的真正加载。由此可知,在使用反射机制加载类时,最终仍然走了java.lang.ClassLoader.loadClass(String)这个方法。
除了通过自定义类加载器完成类的加载,也可以通过java.lang.Class.forName(String)反射接口完成类加载,不过该接口所完成的加载,包含了Java类生命周期7个阶段的前面5个——加载、验证、准备、解析和初始化。
java.lang.Class.forName(String)最终会调用private static native Class<?> forName0()这个本地接口完成类加载。openjdk提供了该方法的本地接口实现,如下:
java.lang.Class.forName0()的本地接口调用jvm.cpp::JVM_FindClassFromCaller()进行类加载,而后者会调用jvm.cpp::find_class_from_class_loader()接口,前面分析Java主类加载时绘制了Java主类的加载链路,Java主类加载也会经过jvm.cpp::find_class_from_class_loader()接口,因此两者的后续流程都相同,最终仍然会从JVM内部发起对java.lang.ClassLoader.loadClass(String)接口的调用,从而完成类的真正加载。由此可知,在使用反射机制加载类时,最终仍然走了java.lang.ClassLoader.loadClass(String)这个方法。
JVM_FindClassFromCaller()方法实现逻辑
import与new指令。
在硬编码阶段,如果要想使用某个类,必须先import进来,但是纵观Java的字节码指令,以及编译后的Java class字节码文件,里面其实并没有任何关于import关键字的解释活痕迹,到了编译阶段,import关键字就这样悄无声息地消失了。
事实上,import语句仅仅是个语法糖,是为了不写那一长串地全限定名。import关键字并没有任何关联地运行时行为,更不会导致类的加载。它的存在纯粹时为了方便写代码,让大家可以把别的package的"名字"引入到当前源码文件里直接用。否则,每次在new一个Java类时,都必须在类名前面不全完整的package包名。而Java类的加载,则使用了"延迟加载"机制,仅在第一次被使用时才会发生真正的加载,而与是否使用了import关键字无关。
虽然import语句没有对应的指令,也不会导致类的加载,但是所import进来的报名则会被写入Java Class文件的常量池中,作为字符串存起来,以用于类加载时的验证和解析。至于import进来的类究竟啥时候会被加载,有好几种情况。例如,使用new关键字,或者读写类的静态变量,或者通过反射加载类。我们来看看使用new关键字对类进行实例化的时候,JVM内部是如何完成类的加载的。
在32位Intel处理器上,new指令对应的实现在templateTable_x86_32.cpp:TemplateTable::_new()函数中。使用new指令加载类时,如果一个类尚未被加载或者未被链接过,则会进入慢分配流程,而慢分配主要通过调用InterpreterRuntime::new()函数完成。interpreterRuntime::_new()函数会调用constantPool->klass_at()接口来获取new指令后面所对应的Java类模板,该接口最终调用constantPoolDesc::klass_at_impl()函数,后者的实现如下:
如果在应用程序中第一次使用某个类,且此时类尚未被加载进JVM内部,会走上面这条链路,先获取类加载器,最终调用SystemDictionary::resolve_or_fail()接口进行类加载。而Java主类的加载也会走到这个接口中,因此后续链路与Java主类加载的链路完全相同,z最终仍然会调用java.lang.ClassLoader.loadClass(String)这个接口去执行类加载。由此可知,在第一次对某个Java类使用new关键字创建其实例对象时,如果类尚未被加载过,则会进入上述流程先完成加载,再进行实例化
在硬编码阶段,如果要想使用某个类,必须先import进来,但是纵观Java的字节码指令,以及编译后的Java class字节码文件,里面其实并没有任何关于import关键字的解释活痕迹,到了编译阶段,import关键字就这样悄无声息地消失了。
事实上,import语句仅仅是个语法糖,是为了不写那一长串地全限定名。import关键字并没有任何关联地运行时行为,更不会导致类的加载。它的存在纯粹时为了方便写代码,让大家可以把别的package的"名字"引入到当前源码文件里直接用。否则,每次在new一个Java类时,都必须在类名前面不全完整的package包名。而Java类的加载,则使用了"延迟加载"机制,仅在第一次被使用时才会发生真正的加载,而与是否使用了import关键字无关。
虽然import语句没有对应的指令,也不会导致类的加载,但是所import进来的报名则会被写入Java Class文件的常量池中,作为字符串存起来,以用于类加载时的验证和解析。至于import进来的类究竟啥时候会被加载,有好几种情况。例如,使用new关键字,或者读写类的静态变量,或者通过反射加载类。我们来看看使用new关键字对类进行实例化的时候,JVM内部是如何完成类的加载的。
在32位Intel处理器上,new指令对应的实现在templateTable_x86_32.cpp:TemplateTable::_new()函数中。使用new指令加载类时,如果一个类尚未被加载或者未被链接过,则会进入慢分配流程,而慢分配主要通过调用InterpreterRuntime::new()函数完成。interpreterRuntime::_new()函数会调用constantPool->klass_at()接口来获取new指令后面所对应的Java类模板,该接口最终调用constantPoolDesc::klass_at_impl()函数,后者的实现如下:
如果在应用程序中第一次使用某个类,且此时类尚未被加载进JVM内部,会走上面这条链路,先获取类加载器,最终调用SystemDictionary::resolve_or_fail()接口进行类加载。而Java主类的加载也会走到这个接口中,因此后续链路与Java主类加载的链路完全相同,z最终仍然会调用java.lang.ClassLoader.loadClass(String)这个接口去执行类加载。由此可知,在第一次对某个Java类使用new关键字创建其实例对象时,如果类尚未被加载过,则会进入上述流程先完成加载,再进行实例化
类的初始化。
完成类的加载后,经过链接,便会进入类的初始化阶段。所谓初始化,说白了就是调用java类的<clinit>()方法。该方法时编译器在编译期间自动生成的,当Java类中出现静态字段或者包含static{}块逻辑时,所编译出来的java字节码文件中便会自动包含一个名为<clinit>的方法。该方法不能由程序员z在Java程序中调用,只能由JVM在运行期调用,这个调用的过程便是Java类的初始化。注意,<clinit>()方法并非类的构造函数。
JVM规范规定,当遇到new、getstatic、invokestatic等字节码指令或者加载Java应用程序主类或者其他一些情况时,会执行类的初始化逻辑。下面以new指令为例,说明JVM内部是如何一步一步调用<clinit>()方法的。
当使用new关键字来实例化一个Java类时,如果该Java类是第一次被使用,则必定会先执行加载->链接->初始化逻辑,然后次啊能创建类实例对象。使用new关键字时,在32位Intel处理器上,new指令对应的实现在templateTable_x86_32.cpp::TemplateTable::_new()函数中,使用new指令加载类时,如果一个类尚未被加载和解析过,则会进入慢分配流程,慢分配流程调用InterpreterRuntime::_new()函数,而InterpreterRuntime::_new()函数会调用
klass->initialize(CHECK)接口,该接口的实现在instanceKlass.cpp中,该接口内部调用instanceKlass::initialize_impl(),后者调用instanceKlass::call_class_initializer(),instanceKlass::call_class_initializer()调用instanceKlass::call_class_initializer_impl()接口,该接口的实现逻辑如图所示。这段逻辑其实比较简单,先获取Java类的<clinit>()函数所对应的method对象,接着通过JavaCalls::call()接口执行初始化方法。如此便完成类的初始化。当然,如果Java类中没有定义任何静态字段,也没有static{}逻辑块,则编译后的字节码文件中自然不会包含<clinit>()方法,则上面这段逻辑不会执行,直接跳过。
完成类的加载后,经过链接,便会进入类的初始化阶段。所谓初始化,说白了就是调用java类的<clinit>()方法。该方法时编译器在编译期间自动生成的,当Java类中出现静态字段或者包含static{}块逻辑时,所编译出来的java字节码文件中便会自动包含一个名为<clinit>的方法。该方法不能由程序员z在Java程序中调用,只能由JVM在运行期调用,这个调用的过程便是Java类的初始化。注意,<clinit>()方法并非类的构造函数。
JVM规范规定,当遇到new、getstatic、invokestatic等字节码指令或者加载Java应用程序主类或者其他一些情况时,会执行类的初始化逻辑。下面以new指令为例,说明JVM内部是如何一步一步调用<clinit>()方法的。
当使用new关键字来实例化一个Java类时,如果该Java类是第一次被使用,则必定会先执行加载->链接->初始化逻辑,然后次啊能创建类实例对象。使用new关键字时,在32位Intel处理器上,new指令对应的实现在templateTable_x86_32.cpp::TemplateTable::_new()函数中,使用new指令加载类时,如果一个类尚未被加载和解析过,则会进入慢分配流程,慢分配流程调用InterpreterRuntime::_new()函数,而InterpreterRuntime::_new()函数会调用
klass->initialize(CHECK)接口,该接口的实现在instanceKlass.cpp中,该接口内部调用instanceKlass::initialize_impl(),后者调用instanceKlass::call_class_initializer(),instanceKlass::call_class_initializer()调用instanceKlass::call_class_initializer_impl()接口,该接口的实现逻辑如图所示。这段逻辑其实比较简单,先获取Java类的<clinit>()函数所对应的method对象,接着通过JavaCalls::call()接口执行初始化方法。如此便完成类的初始化。当然,如果Java类中没有定义任何静态字段,也没有static{}逻辑块,则编译后的字节码文件中自然不会包含<clinit>()方法,则上面这段逻辑不会执行,直接跳过。
前面分析过,每一个类都有一个加载器,并且不同加载器的同一个类无法相互转换。换言之,如果先后使用2个不同的类加载器去加载同一个类,则该类必定会先后被加载两次。同理,该类的初始化逻辑会先后被执行两次。看下面这段示例。
static logic. classLoader =sun.misc.Launcher$AppClassLoader@18b4aac2
start to load Test with custom classLoader
static logic. classLoader =com.Test0513$1@5a07e868
loaded Test with custom classLoader
通过打印结果可以观察到,在自定义的类加载器准备加载测试类之前Test类的static{}逻辑便被执行过一次,这是因为Test类包含主函数,因此其属于测试程序的主类,在JVM启动完成之后便会首先加载该类,使用自定义的类加载器再次加载Test类时,由于虽然c测试类已经被加载过,但是由于这一次使用的类加载器发生了变化,因此JVM便又加载了它一次,,因此Test的static{}块逻辑再次被调用
static logic. classLoader =sun.misc.Launcher$AppClassLoader@18b4aac2
start to load Test with custom classLoader
static logic. classLoader =com.Test0513$1@5a07e868
loaded Test with custom classLoader
通过打印结果可以观察到,在自定义的类加载器准备加载测试类之前Test类的static{}逻辑便被执行过一次,这是因为Test类包含主函数,因此其属于测试程序的主类,在JVM启动完成之后便会首先加载该类,使用自定义的类加载器再次加载Test类时,由于虽然c测试类已经被加载过,但是由于这一次使用的类加载器发生了变化,因此JVM便又加载了它一次,,因此Test的static{}块逻辑再次被调用
类加载器。
要想在JVM内部创建一个与Java类完全对等的结构模型,必须经过类加载器。类加载器的好处自不必多言。那么类加载器到底是啥?与JVM内部究竟有些l联系,所谓的双亲委派到底是怎么回事,JDK的核心类库究竟是啥时候加载的......
要想在JVM内部创建一个与Java类完全对等的结构模型,必须经过类加载器。类加载器的好处自不必多言。那么类加载器到底是啥?与JVM内部究竟有些l联系,所谓的双亲委派到底是怎么回事,JDK的核心类库究竟是啥时候加载的......
类加载器的定义。
Java体系中定义了3种类加载器,分别如下:
# Bootstrap ClassLoader(引导类加载器,缩写为BCL)。加载指定的JDK核心类库,无法由Java应用程序直接引用。负责加载下述3种情况下所指定的核心类库:
## %JAVA_HOME%/jre/lib目录
## -Xbootclassspath参数所指定的目录
## 系统属性sun.boot.clas.path指定的目录种特定名称的jar包
# Extension ClassLoader(扩展类加载器,缩写为ECL)。加载扩展类,扩展JVM的类库。该加载器加载下述两种情况下所指定的类库
## %JAVA_HOME%/jre/lib/ext目录
## 系统属性java.ext.dirs所指定的目录中的所有类库
# System ClassLoader(系统类加载器,缩写为SCL)。加载Java应用程序类库,加载类库的路径由系统环境变量ClassPath、-cp或系统属性java.class.path指定
除了这3种加载器之外,Java还能支持开发者自定义加载器,自定义的加载器大大丰富了Java中间件,在若干Java框架和组件种得到极其广泛的应用。通过下面这个测试程序,可以获取运行时JVM的各类加载器所加载的类路径:
public class Test0513_1 {
public static void main(String[] args) {
System.out.println("引导类加载器加载路径: " + System.getProperty("sun.boot.class.path"));
System.out.println("扩展类加载器加载路径: " + System.getProperty("java.ext.dirs"));
System.out.println("系统类加载器加载路径: " + System.getProperty("java.class.path"));
}
}
Java体系中定义了3种类加载器,分别如下:
# Bootstrap ClassLoader(引导类加载器,缩写为BCL)。加载指定的JDK核心类库,无法由Java应用程序直接引用。负责加载下述3种情况下所指定的核心类库:
## %JAVA_HOME%/jre/lib目录
## -Xbootclassspath参数所指定的目录
## 系统属性sun.boot.clas.path指定的目录种特定名称的jar包
# Extension ClassLoader(扩展类加载器,缩写为ECL)。加载扩展类,扩展JVM的类库。该加载器加载下述两种情况下所指定的类库
## %JAVA_HOME%/jre/lib/ext目录
## 系统属性java.ext.dirs所指定的目录中的所有类库
# System ClassLoader(系统类加载器,缩写为SCL)。加载Java应用程序类库,加载类库的路径由系统环境变量ClassPath、-cp或系统属性java.class.path指定
除了这3种加载器之外,Java还能支持开发者自定义加载器,自定义的加载器大大丰富了Java中间件,在若干Java框架和组件种得到极其广泛的应用。通过下面这个测试程序,可以获取运行时JVM的各类加载器所加载的类路径:
public class Test0513_1 {
public static void main(String[] args) {
System.out.println("引导类加载器加载路径: " + System.getProperty("sun.boot.class.path"));
System.out.println("扩展类加载器加载路径: " + System.getProperty("java.ext.dirs"));
System.out.println("系统类加载器加载路径: " + System.getProperty("java.class.path"));
}
}
从使用的角度看,虽然JVM提供了多种多样的类加载器,并且开发者可以自定义若干加载器,但是站在程序的角度看,其实Java体系一共之定义了两种类加载器,一种使用C++语言定义的,另一种则使用Java语言定义。
使用C++语言定义的类加载器如下:
JVM内部使用C++定义的ClassLoader,其实这便是传说种的bootstrap class loader,即引导类加载器。该加载器内部所有的字段和函数都使用static修饰,因此该加载器并不需要实例化,当需要加载Java类时,直接调用静态函数。该加载器提供setup_bootstrap_search_path()接口用于设置加载器所要搜索的类路径,同时提供了一个最重要的方法——load_classfile(),来加载指定的Java类。
JVM内部使用C++定义的ClassLoader,其实这便是传说种的bootstrap class loader,即引导类加载器。该加载器内部所有的字段和函数都使用static修饰,因此该加载器并不需要实例化,当需要加载Java类时,直接调用静态函数。该加载器提供setup_bootstrap_search_path()接口用于设置加载器所要搜索的类路径,同时提供了一个最重要的方法——load_classfile(),来加载指定的Java类。
load_classfile()的实现逻辑如下:
z这个C++加载器的加载接口直接调用了ClassFileParser::parseClassFile()接口来解析并加载Java类,并最终在JVM内部创建一个与Java类完全对等的C++对象实例,完成类的加载。JVM内部所定义的引导类加载器的实现十分干脆纯净,不像使用Java所定义的类加载器那么绕,不过,并不是所有的Java虚拟机都会使用C++专门定义一个类加载器,有些JVM也是用Java语言来定义引导类加载器,只不过其实现仍然要依赖于JVM内部所提供的本地接口。是否使用C++来定义引导类加载器并不重要,重要的是,无论是用Java编写的加载器,还是用其他语言编写的加载器,其最终目的都是要在JVM内部创建一个与Java类完全对等的结构体。只要能够实现这个目标,技术上怎么玩都是可以的。
z这个C++加载器的加载接口直接调用了ClassFileParser::parseClassFile()接口来解析并加载Java类,并最终在JVM内部创建一个与Java类完全对等的C++对象实例,完成类的加载。JVM内部所定义的引导类加载器的实现十分干脆纯净,不像使用Java所定义的类加载器那么绕,不过,并不是所有的Java虚拟机都会使用C++专门定义一个类加载器,有些JVM也是用Java语言来定义引导类加载器,只不过其实现仍然要依赖于JVM内部所提供的本地接口。是否使用C++来定义引导类加载器并不重要,重要的是,无论是用Java编写的加载器,还是用其他语言编写的加载器,其最终目的都是要在JVM内部创建一个与Java类完全对等的结构体。只要能够实现这个目标,技术上怎么玩都是可以的。
了解了C++定义的类加载器,再看Java定义的加载器。使用Java语言定义的类加载器,便是JDK核心类库种的java.lang.ClassLoader类。在HotSpot中,除引导类加载器BCL外,其余所有的类加载器——无论是Javat体系所提供的,还是开发者自定义的,都继承自java.lang.ClassLoader,扩展类加载器与系统类加载器也不例外。扩展类加载器与系统类加载器都定义在sun.misc.Launcher类中,类名分别是ExtClassLoader和AppClassLoader,这两个类加载器都继承自URLClassLoader,而URLClassLoader则继承自java.lang.ClassLoader。实施行,java.lang.ClassLoader提供了绝大多数类加载功能,同时提供了最重要的define*系列的native解耦,扩展类加载器与系统类加载器最终也是依靠java.lang.ClassLoader的本地接口方能完成Java类的加载。所以,剋这么说,扩展类加载器和系统类加载器仅仅是张皮而已,java.lang.ClassLoader才是真正的"幕后主使"。
事实上,从JDK研发者的角度看,最初的JDK根本就没有所谓的类加载器这个概念,JDK的核心类库直接通过调用ClassFileParser::parseClassFile()解耦完成加载,而对于Java应用程序中的类库,则绕个弯子,通过调用native接口从而间接调用ClassFileParser::parseClassFile()接口完成加载。只不过为了Java applet而专门开发了类加载器。只可惜Java Applet没有生根发芽,但是类加载器倒是遍地开花。Java体系所定义的3种类加载器——引导类加载器、扩展类加载器和系统类加载器,与开发者自定义的类加载器,它们之间存在一定的关系,这种关系如图所示
事实上,从JDK研发者的角度看,最初的JDK根本就没有所谓的类加载器这个概念,JDK的核心类库直接通过调用ClassFileParser::parseClassFile()解耦完成加载,而对于Java应用程序中的类库,则绕个弯子,通过调用native接口从而间接调用ClassFileParser::parseClassFile()接口完成加载。只不过为了Java applet而专门开发了类加载器。只可惜Java Applet没有生根发芽,但是类加载器倒是遍地开花。Java体系所定义的3种类加载器——引导类加载器、扩展类加载器和系统类加载器,与开发者自定义的类加载器,它们之间存在一定的关系,这种关系如图所示
上图所示的各种类加载器之间的关系,并非Java类面向对象的三大特性之一的那种"继承"关系,毕竟引导类加载器是使用C++语言编写而成的,Java类编写的类加载器想去继承也无法继承。上图所表达的联系,仅仅是类的委托加载器机制,尤其是其中著名的双亲委派机制。虽然这几种类加载器之间没有直接的继承关系,但是k扩展类加载器、系统类加载器及用户自定义的加载器,却都继承自java.lang.ClassLoader这个基类。当JVM加载Java主类时,最终也是通过java.lang.ClassLoader.loadClass(String)这一接口完成的。委托加载关系,本质上通过java.langClassLoader.parent字段实现,该字段表示父类加载器,对于引导类加载器,并没有所谓的父加载器的概念,因为引导类加载器本身时随着JVM的启动而初始化,并且该类种的字段和方法全部是静态字段和方法,并不需要实例化便能使用。而除了引导类加载器之外的所有类加载器,由于都继承自java.lang.ClassLoader这个基类,因此便都拥有parent字段,由于该字段被final private修饰,因此子类只能通过调用java.lang.ClassLoader的构造函数才能初始化该字段。对于扩展类加载器和系统类加载器,在JVM第一次加载Java类时会被创建,并完成其父类加载器的设定。扩展类加载器和系统类加载器在sun.misc.Launcher类的构造函数中完成初始化,sun.misc.Launcher类的构造函数逻辑如下.
在Launcher类的构造函数中,首先创建了扩展类加载器,接着创建了系统类加载器。注意,扩展类加载器是在构造函数中定义的局部变量extcl,而系统类加载器则是Launcher类的成员变量loader.为何扩展类加载器不是一个类成员变量,而是一个局部变量呢?当构造函数执行完毕,这个扩展类实例不就被销毁了嘛,那么JVM还如何使用该扩展类去加载扩展包呢?其实,当Launcher类的构造函数执行完之后,扩展类加载器实例对象并不会被GC回收,因为在创建系统类加载器的时候,扩展类加载器被设置为系统类加载器的parent,因此当JVM向加载扩展类时,总是能够通过系统类加载器的parent属性获取到扩展类加载器,从而使用扩展类加载器去加载相应的类库。
在Launcher类的构造函数中,首先创建了扩展类加载器,接着创建了系统类加载器。注意,扩展类加载器是在构造函数中定义的局部变量extcl,而系统类加载器则是Launcher类的成员变量loader.为何扩展类加载器不是一个类成员变量,而是一个局部变量呢?当构造函数执行完毕,这个扩展类实例不就被销毁了嘛,那么JVM还如何使用该扩展类去加载扩展包呢?其实,当Launcher类的构造函数执行完之后,扩展类加载器实例对象并不会被GC回收,因为在创建系统类加载器的时候,扩展类加载器被设置为系统类加载器的parent,因此当JVM向加载扩展类时,总是能够通过系统类加载器的parent属性获取到扩展类加载器,从而使用扩展类加载器去加载相应的类库。
在Launcher类的构造函数中,调用ExtClassLoader.getExtClassLoader()接口创建扩展类加载器,该接口实现如下:
在ExtClassLoader.getExtClassLoader()接口中先获取扩展类加载路径,最后直接通过new ExtClassLoader(dirs)来实例化一个扩展类加载器。在ExtClassLoader(File[])构造函数中,调用super(getExtUrls(dirs), null, factory)父类构造函数来实例化一个加载器,注意,在调用父类构造函数时,所传入的第2个参数是null,并不是引导类加载器。
分析完扩展类加载器,在看系统类加载器的创建,其原理大同小异,需要注意的是,在Launcher类构造函数中实例化系统类加载器时,将刚刚创建的扩展类加载器作为入参传递给了AppClassLoader.getAppClassLoader(final ClassLoader)接口,该接口最终会将系统类加载器的parent属性设置为扩展类加载器。因此系统类加载器的父加载s是null.
既然系统类和扩展类加载器都是在Launcher类的构造函数中才得以创建,那么Launcher类是在什么时间点被实例化的呢?
JVM在启动过程中,除了会进行扩展类加载器与系统类加载器的实例化,也会进行引导类加载器的初始化。引导类加载器便是使用C++语言编写的ClassLoader类,该类提供了initialize()接口,该接口在JVM的init()初始化链路中被调用,该接口会读取引导类路径,定位到相关的jar文件,为加载核心类库做准备
在ExtClassLoader.getExtClassLoader()接口中先获取扩展类加载路径,最后直接通过new ExtClassLoader(dirs)来实例化一个扩展类加载器。在ExtClassLoader(File[])构造函数中,调用super(getExtUrls(dirs), null, factory)父类构造函数来实例化一个加载器,注意,在调用父类构造函数时,所传入的第2个参数是null,并不是引导类加载器。
分析完扩展类加载器,在看系统类加载器的创建,其原理大同小异,需要注意的是,在Launcher类构造函数中实例化系统类加载器时,将刚刚创建的扩展类加载器作为入参传递给了AppClassLoader.getAppClassLoader(final ClassLoader)接口,该接口最终会将系统类加载器的parent属性设置为扩展类加载器。因此系统类加载器的父加载s是null.
既然系统类和扩展类加载器都是在Launcher类的构造函数中才得以创建,那么Launcher类是在什么时间点被实例化的呢?
JVM在启动过程中,除了会进行扩展类加载器与系统类加载器的实例化,也会进行引导类加载器的初始化。引导类加载器便是使用C++语言编写的ClassLoader类,该类提供了initialize()接口,该接口在JVM的init()初始化链路中被调用,该接口会读取引导类路径,定位到相关的jar文件,为加载核心类库做准备
系统类加载器与扩展类加载器创建。
系统类加载器与扩展类加载器在sum.misc.Launcher的构造函数中被创建,但是sum.misc.Launcher类又在何时被创建呢?这要从Java主类的加载说起。前面讲过,JVM加载主类时,会走下面这条链路:
1.java.c::JavaMain()
2.java.c::LoadClass()
3.jni.cpp:JNI_ENTRY(jclass, jni_FindClass(JNIEnv* env, const char* name))
4.jvm.cpp::find_class_from_class_loader()
5.SystemDictionary.cpp::resolve_or_fail()
.....
6.jvm.cpp::jvm_define_class_common()
7.SystemDictionary.cpp::resolve_from_stream()
8.ClassFileParser.cpp::parseClassFile()
这条链路的第3步会进入jni_FindClass()入口,当JVM加载Java应用程序主类时,该入口最终会调用loader = Handle(THREAD, SystemDictionary::java_system_loader());来获取类加载器,sum.misc.Launcher类的实例便会在SystemDictionary::java_system_loader()链路里创建。
这里的SystemDinctionary::java_system_loader()返回SystemDictionary._java_system_loader,这便是传说中的系统类加载器.SystemDictionary._java_system_loader成员变量在JVM启动期间便通过SystemDictionary::compute_java_system_loader()函数进行初始化,该函数的实现如图所示。
该函数通过调用JavaCalls:call_static()函数来完成_java_system_loader变量的初始化。JavaCalls::call_static()函数在JVM内部也是调用频率很高的一个接口,大凡涉及Java类静态方法调用时,最终都会经过本接口调用。该接口顾名思义,主要作用是调用Java类的静态方法,该接口的第1~4个入参函数含义分别如下:
# JavaValue* result,储存调用Java类静态方法后的返回值
# KlassHandle klass,所调用的目标Java类在JVM内部的对等结构体
# Symbol* name, 所调用的目标Java类静态方法的名称
# Symbol* signature, 目标Java类静态方法的签名
众所周知,Java程序的方法一定是被封装在Java类中,所以要调用一个Java方法,必定要知道类对象、类名和方法签名,,静态方法也不例外,由此便能理解为何调用JavaCalls::call_static函数需要传递上面这几个入参。
系统类加载器与扩展类加载器在sum.misc.Launcher的构造函数中被创建,但是sum.misc.Launcher类又在何时被创建呢?这要从Java主类的加载说起。前面讲过,JVM加载主类时,会走下面这条链路:
1.java.c::JavaMain()
2.java.c::LoadClass()
3.jni.cpp:JNI_ENTRY(jclass, jni_FindClass(JNIEnv* env, const char* name))
4.jvm.cpp::find_class_from_class_loader()
5.SystemDictionary.cpp::resolve_or_fail()
.....
6.jvm.cpp::jvm_define_class_common()
7.SystemDictionary.cpp::resolve_from_stream()
8.ClassFileParser.cpp::parseClassFile()
这条链路的第3步会进入jni_FindClass()入口,当JVM加载Java应用程序主类时,该入口最终会调用loader = Handle(THREAD, SystemDictionary::java_system_loader());来获取类加载器,sum.misc.Launcher类的实例便会在SystemDictionary::java_system_loader()链路里创建。
这里的SystemDinctionary::java_system_loader()返回SystemDictionary._java_system_loader,这便是传说中的系统类加载器.SystemDictionary._java_system_loader成员变量在JVM启动期间便通过SystemDictionary::compute_java_system_loader()函数进行初始化,该函数的实现如图所示。
该函数通过调用JavaCalls:call_static()函数来完成_java_system_loader变量的初始化。JavaCalls::call_static()函数在JVM内部也是调用频率很高的一个接口,大凡涉及Java类静态方法调用时,最终都会经过本接口调用。该接口顾名思义,主要作用是调用Java类的静态方法,该接口的第1~4个入参函数含义分别如下:
# JavaValue* result,储存调用Java类静态方法后的返回值
# KlassHandle klass,所调用的目标Java类在JVM内部的对等结构体
# Symbol* name, 所调用的目标Java类静态方法的名称
# Symbol* signature, 目标Java类静态方法的签名
众所周知,Java程序的方法一定是被封装在Java类中,所以要调用一个Java方法,必定要知道类对象、类名和方法签名,,静态方法也不例外,由此便能理解为何调用JavaCalls::call_static函数需要传递上面这几个入参。
SystemDictionary::compute_java_system_loader()函数调用JavaCalls::call_static()函数时,所传入的第2个入参时KlassHandle(THREAD, WK_KLASS(ClassLoader_klasss)),其中ClassLoader_klass在vmSymbols.hpp中通过宏定义如下:
template(getSystemClassLoader_name, "getSystemClassLoader")
在JDK8中是
do_klass(ClassLoader_klass, java_lang_ClassLoader, Pre ) \
由此可知,getSystemClassLoader_name()函数返回的"getSytemClassLoader"这个字符串标识,而该标识作为JavaCalls:call_static()函数的第3个入参,该入参正是JavaCalls::call_static()函数最终要调用的目标方法.综合上面第2和第3这两个入参可知,SystemDictionary::compute_java_system_loader()将通过调用java.lang.ClassLoader类的静态方法getSystemClassLoader()函数主要通过调用java.lang.ClassLoader.initSystemClassLoader()接口来初始化系统类加载器,initSystemClassLoader()接口的主要逻辑如下:
在这个方法中,终于看到了sun.misc.Launcher类的实例化,其实例化通过调用sun.misc.Launcher.getLauncher()得以完成,该函数直接返回Launcher.launcher静态变量,而该静态变量在定义时便实例化了,如下:
private static Launcher launcher = new Launcher();
而系统类加载器和扩展类加载器都是在Launcher类的构造函数中被创建的,由此JVM便完成了系统类加载器和扩展类加载器的创建
template(getSystemClassLoader_name, "getSystemClassLoader")
在JDK8中是
do_klass(ClassLoader_klass, java_lang_ClassLoader, Pre ) \
由此可知,getSystemClassLoader_name()函数返回的"getSytemClassLoader"这个字符串标识,而该标识作为JavaCalls:call_static()函数的第3个入参,该入参正是JavaCalls::call_static()函数最终要调用的目标方法.综合上面第2和第3这两个入参可知,SystemDictionary::compute_java_system_loader()将通过调用java.lang.ClassLoader类的静态方法getSystemClassLoader()函数主要通过调用java.lang.ClassLoader.initSystemClassLoader()接口来初始化系统类加载器,initSystemClassLoader()接口的主要逻辑如下:
在这个方法中,终于看到了sun.misc.Launcher类的实例化,其实例化通过调用sun.misc.Launcher.getLauncher()得以完成,该函数直接返回Launcher.launcher静态变量,而该静态变量在定义时便实例化了,如下:
private static Launcher launcher = new Launcher();
而系统类加载器和扩展类加载器都是在Launcher类的构造函数中被创建的,由此JVM便完成了系统类加载器和扩展类加载器的创建
双亲委派机制与破坏。
凡是接触过Java类加载器的小伙伴,必定知道JVM中的双亲委派机制。该机制在java.lang.ClassLoader.loadClass(String, boolean)接口中,该接口的逻辑如下:
1.现在当前加载器的缓存中查找有无目标类,如果有,直接返回
2.判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name, false)接口进行加载
3.反之,如果当前加载器的父类加载器为空,则调用findBootStrapClassOrNull(name)接口,让引导类加载器进行加载
4.如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。该接口最终会调用java.lang.ClassLoader解耦的define*系列的native接口加载目标Java类。
双亲委派的模型就隐藏在第2和第3步中,在理解双亲委派机制之前,有一点需要先说明,那就是JVM中的所有类加载都会通过java.lang.CLassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDk的核心类库也不能例外,虽然核心类库会存在"预加载"的行为。假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心得一个类,因此一定只能由引导类加载器去加载,按照上面4步加载得逻辑,在第1步从系统类得缓存中肯定查找不到该类,于是进入第2步,由于从系统类加载器得父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。这便是双亲委派机制,这种机制保证核心类库一定是由引导类加载器进行加载,而不会被多种加载器加载,否则每个加载器都会加载一遍核心类库,世界要打乱了,同时也会存在安全隐患。
双亲委派从本质上而言,其实规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。不过,可能你会想到,如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法,抹去其中的双亲委派机制,进保留上面这4步中的第1步和第4步,那么是不是就能够加载核心类库了呢?这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类类加载器疑惑扩展类加载器,最终都必须调用java.lang.ClassLoader.defineClass(String, byte[], int, int ,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护,其实现如下:
在该接口中会判断所要加载的目标类的全限定名是否以"java."开始,如果是,,则直接抛出异常,这便是所有的自定义类加载器只能重写java.lang.Class.findClass(String)接口的原因
凡是接触过Java类加载器的小伙伴,必定知道JVM中的双亲委派机制。该机制在java.lang.ClassLoader.loadClass(String, boolean)接口中,该接口的逻辑如下:
1.现在当前加载器的缓存中查找有无目标类,如果有,直接返回
2.判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name, false)接口进行加载
3.反之,如果当前加载器的父类加载器为空,则调用findBootStrapClassOrNull(name)接口,让引导类加载器进行加载
4.如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。该接口最终会调用java.lang.ClassLoader解耦的define*系列的native接口加载目标Java类。
双亲委派的模型就隐藏在第2和第3步中,在理解双亲委派机制之前,有一点需要先说明,那就是JVM中的所有类加载都会通过java.lang.CLassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDk的核心类库也不能例外,虽然核心类库会存在"预加载"的行为。假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心得一个类,因此一定只能由引导类加载器去加载,按照上面4步加载得逻辑,在第1步从系统类得缓存中肯定查找不到该类,于是进入第2步,由于从系统类加载器得父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。这便是双亲委派机制,这种机制保证核心类库一定是由引导类加载器进行加载,而不会被多种加载器加载,否则每个加载器都会加载一遍核心类库,世界要打乱了,同时也会存在安全隐患。
双亲委派从本质上而言,其实规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。不过,可能你会想到,如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法,抹去其中的双亲委派机制,进保留上面这4步中的第1步和第4步,那么是不是就能够加载核心类库了呢?这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类类加载器疑惑扩展类加载器,最终都必须调用java.lang.ClassLoader.defineClass(String, byte[], int, int ,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护,其实现如下:
在该接口中会判断所要加载的目标类的全限定名是否以"java."开始,如果是,,则直接抛出异常,这便是所有的自定义类加载器只能重写java.lang.Class.findClass(String)接口的原因
预加载。
JVM启动期间,会先加载一部分核心类库,这部分核心类库包括:
k可以看到,平时开发中最常用的基础类库,基本都在这里面了,例如Object、Long、String、Serializable及Thread等。也正是因为这些类使用频率非常高,即使是一个非常简单的Java程序都可能用到这些类中的大部分类,因此不如预先将其加载到内存。但是同时也应该看到,放眼JDK的整个核心类库,这几个类也是凤毛麟角,这是因为预加载的类越多,则JVM的启动过程将越慢,反而不美。
核心类库的加载可以被观察到的,只需要在Java命令行上加上-XX:+TraceClassLoading选项,JVM启动时便会打印类似下面这样的跟踪类加载的日志:
[Opened D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Object from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.io.Serializable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Comparable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.String from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.AnnotatedElement from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.GenericDeclaration from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.Type from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Class from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Cloneable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.ClassLoader from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
JVM启动期间,会先加载一部分核心类库,这部分核心类库包括:
k可以看到,平时开发中最常用的基础类库,基本都在这里面了,例如Object、Long、String、Serializable及Thread等。也正是因为这些类使用频率非常高,即使是一个非常简单的Java程序都可能用到这些类中的大部分类,因此不如预先将其加载到内存。但是同时也应该看到,放眼JDK的整个核心类库,这几个类也是凤毛麟角,这是因为预加载的类越多,则JVM的启动过程将越慢,反而不美。
核心类库的加载可以被观察到的,只需要在Java命令行上加上-XX:+TraceClassLoading选项,JVM启动时便会打印类似下面这样的跟踪类加载的日志:
[Opened D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Object from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.io.Serializable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Comparable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.String from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.AnnotatedElement from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.GenericDeclaration from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.reflect.Type from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Class from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.Cloneable from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
[Loaded java.lang.ClassLoader from D:\jdk1.8.0_131(64)\jdk1.8.0_131(64)\jre\lib\rt.jar]
JDK核心类库随JVM启动而进行预加载的链路如下:
java.c::main()
java_md.c::LoadJavaVM()
jni.cpp::JNI_CreateJavaVM()
Threads::create_vm()
init.cpp::init_globals()
Universe.cpp::universe2_init()
Universe::genesis()
SystemDictionary::initialize()
SystemDictionary::initialize_preloaded_classes()
SystemDictionary::initialize_wk_klasses_until()
在SystemDictionary::initialize_wk_klasses_until()函数中,遍历WK_KLASSES_DO宏中所定义的全部加载的类,并调用SystemDictionary::initialize_wk_klass()函数对这些类进行逐个加载。SystemDictionary::initialize_wk_klass()函数实现如下:
java.c::main()
java_md.c::LoadJavaVM()
jni.cpp::JNI_CreateJavaVM()
Threads::create_vm()
init.cpp::init_globals()
Universe.cpp::universe2_init()
Universe::genesis()
SystemDictionary::initialize()
SystemDictionary::initialize_preloaded_classes()
SystemDictionary::initialize_wk_klasses_until()
在SystemDictionary::initialize_wk_klasses_until()函数中,遍历WK_KLASSES_DO宏中所定义的全部加载的类,并调用SystemDictionary::initialize_wk_klass()函数对这些类进行逐个加载。SystemDictionary::initialize_wk_klass()函数实现如下:
main.c::main()
java.c::JNI_Launch
java_md_solinux.c::LoadJavaVM()
jni.cpp::JNI_CreateVM()
thread.cpp::create_vm()
init.cpp::init_globals()
Universe.cpp::universe2_init()
Universe::genesis()
SystemDictionary::initialize()
SystemDictionary::initialize_preloaded_classes()
SystemDictionary::initialize_wk_klasses_until()
SystemDictionary::initialize_wk_klass()
在该函数中,最终调用SystemDictionary::resolve_or_fail()接口去加载核心类库,在前面分析JVM加载Java主类时,其链路也会经过这里,该函数会一路调用,最终调用到SystemDictionary::load_instance_class()函数。到了这个函数中,开始体现出"双亲委派机制"。该函数根据类加载器变量class_loader是否为null,被分成了if{}和else{}这两大分支,如果是null则进入if{}逻辑块,否则进入else{}逻辑块。在if{}逻辑块中,JVM会调用内部的引导类加载器来加载类,而这个接口最终会通过java.lang.ClassLoader.loadClass(String)接口执行类的加载,这便是jvm内部的双亲委派机制,一边是直接使用引导类加载器,一边则是使用系统类加载器,在if{}语句块中,可以看到JVM调用了ClassLoader::load_classfile()接口,这是引导类加载器的加载接口。
由于JDK核心类库的预加载直接使用了引导类加载器,不经过java.lang.ClassLoader.loadClass(String)接口,因此如果在这个接口内部打断点,是无法看到核心类库的加载过程的。正是由于JVM启动期间会对部分核心类库进行预加载,因此JVM在加载Java应用程序主类的链路中对sun.misc.Launcher类进行初始化时,才能直接获取到java.lang.ClassLoader类在JVM内部对应的实例对象,并调用其静态方法getSystemClassLoader()来初始化系统类加载器和扩展类加载器
在该函数中,最终调用SystemDictionary::resolve_or_fail()接口去加载核心类库,在前面分析JVM加载Java主类时,其链路也会经过这里,该函数会一路调用,最终调用到SystemDictionary::load_instance_class()函数。到了这个函数中,开始体现出"双亲委派机制"。该函数根据类加载器变量class_loader是否为null,被分成了if{}和else{}这两大分支,如果是null则进入if{}逻辑块,否则进入else{}逻辑块。在if{}逻辑块中,JVM会调用内部的引导类加载器来加载类,而这个接口最终会通过java.lang.ClassLoader.loadClass(String)接口执行类的加载,这便是jvm内部的双亲委派机制,一边是直接使用引导类加载器,一边则是使用系统类加载器,在if{}语句块中,可以看到JVM调用了ClassLoader::load_classfile()接口,这是引导类加载器的加载接口。
由于JDK核心类库的预加载直接使用了引导类加载器,不经过java.lang.ClassLoader.loadClass(String)接口,因此如果在这个接口内部打断点,是无法看到核心类库的加载过程的。正是由于JVM启动期间会对部分核心类库进行预加载,因此JVM在加载Java应用程序主类的链路中对sun.misc.Launcher类进行初始化时,才能直接获取到java.lang.ClassLoader类在JVM内部对应的实例对象,并调用其静态方法getSystemClassLoader()来初始化系统类加载器和扩展类加载器
引导类加载。
JVM使用双亲委派机制加载类,如果所加载的类属于JDK核心类库中所定义的类,则java.lang.ClassLoader.loadClass(String, boolean)会进入findBootstrapClassOrNull()方法,通过引导类加载核心类库。该方法最终会调用private native Class<?> findBootstrapClass(String name)这个本地接口执行核心类库加载。这个本地接口最终会调用引导类加载器执行加载。开发的JDK源码中包含这个本地接口的实现,实现逻辑如下。
在JNI实现中,调用JVM_FindClassFromBootLoader()来加载核心类库。而在JVM_FindClassFromBootoLoader()接口欧中,则直接调用SystemDictionary::resolve_or_null()函数,该函数最终也会走到SystemDictionary::load_instance_class()函数中。关于该函数,前面多次提到过,无论是JVM在启动期间预加载部分核心类库还是JVM加载Java应用程序主类,只要涉及类的加载,最终都会经过该函数。该函数内部也是用了双亲委派机制,如果class_loader为空,则直接调用引导类加载器的ClassLoader::load_classfile()接口进行类加载,否则,仍然要通过所指定的Java类加载器去加载。由于在加载核心类库时,类加载器为空,因此核心类库的加载最终都会通过调用引导类加载器接口进行加载
JVM使用双亲委派机制加载类,如果所加载的类属于JDK核心类库中所定义的类,则java.lang.ClassLoader.loadClass(String, boolean)会进入findBootstrapClassOrNull()方法,通过引导类加载核心类库。该方法最终会调用private native Class<?> findBootstrapClass(String name)这个本地接口执行核心类库加载。这个本地接口最终会调用引导类加载器执行加载。开发的JDK源码中包含这个本地接口的实现,实现逻辑如下。
在JNI实现中,调用JVM_FindClassFromBootLoader()来加载核心类库。而在JVM_FindClassFromBootoLoader()接口欧中,则直接调用SystemDictionary::resolve_or_null()函数,该函数最终也会走到SystemDictionary::load_instance_class()函数中。关于该函数,前面多次提到过,无论是JVM在启动期间预加载部分核心类库还是JVM加载Java应用程序主类,只要涉及类的加载,最终都会经过该函数。该函数内部也是用了双亲委派机制,如果class_loader为空,则直接调用引导类加载器的ClassLoader::load_classfile()接口进行类加载,否则,仍然要通过所指定的Java类加载器去加载。由于在加载核心类库时,类加载器为空,因此核心类库的加载最终都会通过调用引导类加载器接口进行加载
加载、链接与延迟加载。
public class Test0515 {
public static void main(String[] args) {
java.lang.Long ll = 3000L;
}
}
在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,调试时会发现java.lang.Long这个核心类仍然会经过该断点,这说明java.lang.Long核心类仍然通过java.lang.ClassLoader进行加载。但是JVM在启动时会预加载一部分核心类库,包括java基本类型的包装类,因此java.lang.Long这种核心类库也一定早就预加载好了,可是为何在测试程序中实例化Long类型时,仍然要再次加载一遍呢?其实,之所以会再次走加载流程,并不是因为碰到了类加载的指令,而是因为测试类所对应的常量池索引尚未转换成直接对象引用,更准确地说,是因为预加载的核心类库虽然已经完成了类的加载,但是由于尚未在应用程序中使用到,因此并未经过链接,所以在new字节码指令的流程中便走了"慢分配"流程。
众所周知,Java类的声明周期一共分为7个阶段,其中加载阶段仅仅是其中第一步,加载完成z之后需要进行链接和初始化。在链接阶段,字节码指令会被重写,将其所引用的常量池索引号转换为直接引用。例如,在实例化一个类时,编译后所生成的字节码指令如下:
new #2
new指令后面所跟随的#2表示引用常量池中索引号为2的元素,该元素一定指向某个Java类的全限定名。如果是实例化Long,则常量池2号索引指向的字符串一定如下:
class java/lang/Long
在Java类经编译后所得到的字节码文件中的原始常量池中,class java/lang/Long并不是存放于常量池的一个元素中而是分开存放好几个元素z中,例如,class本身是常量池中的一种固定的类型,而java/lang/Long在常量池中则是一种字符串类型,UTF-8格式的字符串。当JVM加载完测试类Test之后,会对其字节码进行重谢,重写后的new字节码指令,其后面所跟随的便是直接指向"java/lang/Long"这个字符串的内存地址,这个重写便是整个Java类声明周期中"链接"阶段所做的最重要的事情。其实重写字节码的过程,可以认为是做了一次缓存的优化,通过重写,避免运行期再去将多个不同的常量池元素一步步拼装出new指令实际要指向的类型。
等到JVM真正运行到new这条字节码指令时,JVM为了加快指令执行速度,又做了一次缓存。这一次缓存的是什么呢?众所周知,在Java语言中,new指令的作用很简单也很唯一,就是实例化一个类型,或者数组。当使用new实例化一个Java类型时,JVM必须要知道其所实例化的时何种类型,这个问题在Java类链接阶段便已经解决了,通过字节码指令重写,JVM知道所要实例化的Java类的全限定名,但是仅仅这样仍然不够,放眼JVM执行new指令的过程,JVM需要根据Java类的全限定名称,在内存区perm区(JDK8中是metaspace区),定位到这个Java类在内存中的对等体——instanceKlass,instanceKlass作为Java类在内存中的对等体,包含了原始Java类的一切信息,JVM只有找到了instanceKlass,才能根据这个类模板创建出Java类的实例对象。在JVM开始执行new指令所对应的实例对象创建过程之前,一定会先完成类的加载、链接和初始化,instanceKlass便在类的加载阶段完成构建,因此JVM根据JVMJava类的全限定名称一定能够定位到instanceKlass这个类模板。但是如果每次执行new指令都要根据Java类的全限定名称取定位到instanceKlass这个内存对象,未免会做重复的事情,浪费机器性能,为了避免这种情况,JVM在第一次执行new指令时,便会将定位到的instanceKlass缓存起来,这样如果程序后续需要再次实例化同样的Java类对象时,便直接从缓存中读取instanceKlass,直接据此创建Java类实例对象,从而提升效率。
public class Test0515 {
public static void main(String[] args) {
java.lang.Long ll = 3000L;
}
}
在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,调试时会发现java.lang.Long这个核心类仍然会经过该断点,这说明java.lang.Long核心类仍然通过java.lang.ClassLoader进行加载。但是JVM在启动时会预加载一部分核心类库,包括java基本类型的包装类,因此java.lang.Long这种核心类库也一定早就预加载好了,可是为何在测试程序中实例化Long类型时,仍然要再次加载一遍呢?其实,之所以会再次走加载流程,并不是因为碰到了类加载的指令,而是因为测试类所对应的常量池索引尚未转换成直接对象引用,更准确地说,是因为预加载的核心类库虽然已经完成了类的加载,但是由于尚未在应用程序中使用到,因此并未经过链接,所以在new字节码指令的流程中便走了"慢分配"流程。
众所周知,Java类的声明周期一共分为7个阶段,其中加载阶段仅仅是其中第一步,加载完成z之后需要进行链接和初始化。在链接阶段,字节码指令会被重写,将其所引用的常量池索引号转换为直接引用。例如,在实例化一个类时,编译后所生成的字节码指令如下:
new #2
new指令后面所跟随的#2表示引用常量池中索引号为2的元素,该元素一定指向某个Java类的全限定名。如果是实例化Long,则常量池2号索引指向的字符串一定如下:
class java/lang/Long
在Java类经编译后所得到的字节码文件中的原始常量池中,class java/lang/Long并不是存放于常量池的一个元素中而是分开存放好几个元素z中,例如,class本身是常量池中的一种固定的类型,而java/lang/Long在常量池中则是一种字符串类型,UTF-8格式的字符串。当JVM加载完测试类Test之后,会对其字节码进行重谢,重写后的new字节码指令,其后面所跟随的便是直接指向"java/lang/Long"这个字符串的内存地址,这个重写便是整个Java类声明周期中"链接"阶段所做的最重要的事情。其实重写字节码的过程,可以认为是做了一次缓存的优化,通过重写,避免运行期再去将多个不同的常量池元素一步步拼装出new指令实际要指向的类型。
等到JVM真正运行到new这条字节码指令时,JVM为了加快指令执行速度,又做了一次缓存。这一次缓存的是什么呢?众所周知,在Java语言中,new指令的作用很简单也很唯一,就是实例化一个类型,或者数组。当使用new实例化一个Java类型时,JVM必须要知道其所实例化的时何种类型,这个问题在Java类链接阶段便已经解决了,通过字节码指令重写,JVM知道所要实例化的Java类的全限定名,但是仅仅这样仍然不够,放眼JVM执行new指令的过程,JVM需要根据Java类的全限定名称,在内存区perm区(JDK8中是metaspace区),定位到这个Java类在内存中的对等体——instanceKlass,instanceKlass作为Java类在内存中的对等体,包含了原始Java类的一切信息,JVM只有找到了instanceKlass,才能根据这个类模板创建出Java类的实例对象。在JVM开始执行new指令所对应的实例对象创建过程之前,一定会先完成类的加载、链接和初始化,instanceKlass便在类的加载阶段完成构建,因此JVM根据JVMJava类的全限定名称一定能够定位到instanceKlass这个类模板。但是如果每次执行new指令都要根据Java类的全限定名称取定位到instanceKlass这个内存对象,未免会做重复的事情,浪费机器性能,为了避免这种情况,JVM在第一次执行new指令时,便会将定位到的instanceKlass缓存起来,这样如果程序后续需要再次实例化同样的Java类对象时,便直接从缓存中读取instanceKlass,直接据此创建Java类实例对象,从而提升效率。
前面分析过,使用new指令加载类时,如果类尚未被加载或者未被链接过,则会进入慢分配流程,从而会进入InterpreterRuntime::_new()函数,而InterpreterRuntime::_new()函数则会调用constantPool->klass_at()接口来获取new指令后面的Java类模板,该接口最终调用constantPoolOopDesc::klass_at_impl()函数,该函数的实现如下:
上面这段代码的主要思路是,先从缓存中读取Java类模板,如果读取不到,则执行类加载,加载之后,将类模板写入缓存,最终仍然从缓存中读取类模板并返回。这与Java应用程序中的缓存读写逻辑何其相似。
在该逻辑中,如果目标Java类模板尚未被解析,则JVM会调用SystemDictionary::resolve_or_fail()接口先执行类加载器。该接口最终会调用SystemDictionary::load_instance_class()函数执行类加载,对于SystemDictionary::load_instance_class()函数,,其内部也是用了一个类似于双亲委托的机制。当类加载器为null时,则直接加载核心类库,否则最终仍然会调用java.lang.ClassLoader.loadClass(String)这个java接口取执行类加载。由于在示例程序中,通过执行new指令进入了SystemDictionary::load_instance_class()函数,而Java程序的默认类加载器便是系统类加载器,因此SystemDictionary::load_isntance_class()函数最终仍然通过java.lang.ClassLoader.loadClass(String)这个Java接口去执行类加载。这是为何在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,而调试时会发现java.lang.Long这个核心类仍然会经过该断点进行加载的原因。
在该逻辑中,如果目标Java类模板尚未被解析,则JVM会调用SystemDictionary::resolve_or_fail()接口先执行类加载器。该接口最终会调用SystemDictionary::load_instance_class()函数执行类加载,对于SystemDictionary::load_instance_class()函数,,其内部也是用了一个类似于双亲委托的机制。当类加载器为null时,则直接加载核心类库,否则最终仍然会调用java.lang.ClassLoader.loadClass(String)这个java接口取执行类加载。由于在示例程序中,通过执行new指令进入了SystemDictionary::load_instance_class()函数,而Java程序的默认类加载器便是系统类加载器,因此SystemDictionary::load_isntance_class()函数最终仍然通过java.lang.ClassLoader.loadClass(String)这个Java接口去执行类加载。这是为何在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,而调试时会发现java.lang.Long这个核心类仍然会经过该断点进行加载的原因。
java.lang.Long在JVM启动期间便被预加载,因此最终通过java.lang.ClassLoader.findBootstrapClassOrNull()接口调用引导类加载器去加载核心类库时,JVM内部并未真的重新将java.langLong加载一遍,而是直接从缓存中读取。
将上面的测试示例稍微修改一下,变成如下
public class Test0515 {
public static void main(String[] args) {
java.lang.Long ll = 3000L;
java.lang.Long lll = 5113L;
}
}
在本示例程序中,连续两次实例化了Long类型,在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,调试时会发现java.lang.Long这个核心类的加载仅在执行b本示例程序的第一个Long实例化代码时才会经过该断点,而第二次实例化Long类型时则不会再经过该断点,因为第一次实例化之后,java.lang.Long这个类模板已经完成解析,所以第二次再次实例化时,JVM便会尝试走快速分配流程进行无锁分配,如果该类不满足快速分配的条件(例如类太大),虽然仍然会进入慢分配流程,但是由于已经解析过,因此慢分配阶段并不会精力类加载过程。
为了证明再对JDK核心类库中被预加载的累执行new实例化操作时,JVM没有重复加载累,在断点测试时开启-XX:+TraceClassLoading选项,查看本实例程序中的Long类型是何时被加载的。
事实上,不仅JDK核心类的加载如此,所有的Java类的实例化都是如此,很多人都说Java类的加载、链接和初始u哈并没有严格的顺序,可以混着来,其实道理便在这里。
从更广义的角度看,其实这也体现了类的延迟加载机制——一个类,只有当真正被使用时才会被加载,即使程序中import了某个类,但是如果不适用,则JVm在整个运行期都不会加载它,甚至,即使在程序中使用到了某个类,但是如果使用条件一直不符合,导致JVM一直没有进入使用的分支流程中,则JVM也自始至终都不会加载整个类,例如
if(...) {
new Test();
}
如果整个程序只有这一处地方用到了Test类,但是程序在运行期从来都没有进入过这里的if分支流程,则Test类不会被加载。不用说,延迟加载机制避免了系统无谓的开销
将上面的测试示例稍微修改一下,变成如下
public class Test0515 {
public static void main(String[] args) {
java.lang.Long ll = 3000L;
java.lang.Long lll = 5113L;
}
}
在本示例程序中,连续两次实例化了Long类型,在java.lang.ClassLoader.findBootstrapClassOrNull(String)中打上断点,调试时会发现java.lang.Long这个核心类的加载仅在执行b本示例程序的第一个Long实例化代码时才会经过该断点,而第二次实例化Long类型时则不会再经过该断点,因为第一次实例化之后,java.lang.Long这个类模板已经完成解析,所以第二次再次实例化时,JVM便会尝试走快速分配流程进行无锁分配,如果该类不满足快速分配的条件(例如类太大),虽然仍然会进入慢分配流程,但是由于已经解析过,因此慢分配阶段并不会精力类加载过程。
为了证明再对JDK核心类库中被预加载的累执行new实例化操作时,JVM没有重复加载累,在断点测试时开启-XX:+TraceClassLoading选项,查看本实例程序中的Long类型是何时被加载的。
事实上,不仅JDK核心类的加载如此,所有的Java类的实例化都是如此,很多人都说Java类的加载、链接和初始u哈并没有严格的顺序,可以混着来,其实道理便在这里。
从更广义的角度看,其实这也体现了类的延迟加载机制——一个类,只有当真正被使用时才会被加载,即使程序中import了某个类,但是如果不适用,则JVm在整个运行期都不会加载它,甚至,即使在程序中使用到了某个类,但是如果使用条件一直不符合,导致JVM一直没有进入使用的分支流程中,则JVM也自始至终都不会加载整个类,例如
if(...) {
new Test();
}
如果整个程序只有这一处地方用到了Test类,但是程序在运行期从来都没有进入过这里的if分支流程,则Test类不会被加载。不用说,延迟加载机制避免了系统无谓的开销
父加载器。
JVM默认提供3种类加载器:引导类加载器、扩展类加载器和系统类加载器。其中,系统类加载器的父加载器是扩展类加载器,而扩展类加载器y与引导类加载器的父加载器都是null。这是JVM中固化下来的关系设定。默认情况下,Java引用程序的类加载器是系统类加载器,下面这个测试程序可以验证父加载器的相关理论:
根据该结果可知,Test测试类的加载器是Launcher$AppClassLoader,而这正是系统类加载器。同理,Test测试类加载器的父加载器是Launcher$ExtClassLoader,这是扩展类加载器。通过这里的打印结果可知,扩展类的父加载器的确是null。而至于Test类的加载器为何是系统类加载器,前面从源码级别进行了分析。
JDK核心类库中的类由引导类加载器负责加载,那么如果在测试程序中加载一个核心类,然后打印其加载器,会打印出啥呢?测试程序很简单,如下:
为何这里却打印出一个空值呢?道理其实很简单,站在程序的角度看,引导类加载器与另外两种类加载器——系统类加载器和扩展类加载器,并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值。
JVM默认提供3种类加载器:引导类加载器、扩展类加载器和系统类加载器。其中,系统类加载器的父加载器是扩展类加载器,而扩展类加载器y与引导类加载器的父加载器都是null。这是JVM中固化下来的关系设定。默认情况下,Java引用程序的类加载器是系统类加载器,下面这个测试程序可以验证父加载器的相关理论:
根据该结果可知,Test测试类的加载器是Launcher$AppClassLoader,而这正是系统类加载器。同理,Test测试类加载器的父加载器是Launcher$ExtClassLoader,这是扩展类加载器。通过这里的打印结果可知,扩展类的父加载器的确是null。而至于Test类的加载器为何是系统类加载器,前面从源码级别进行了分析。
JDK核心类库中的类由引导类加载器负责加载,那么如果在测试程序中加载一个核心类,然后打印其加载器,会打印出啥呢?测试程序很简单,如下:
为何这里却打印出一个空值呢?道理其实很简单,站在程序的角度看,引导类加载器与另外两种类加载器——系统类加载器和扩展类加载器,并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值。
JVM提供自定义类加载器的接口,开发者只需继承java.lang.ClassLoader或者其子类(例如java.net.URLClassLoader)
运行该测试程序,打印结果如下:
Test.classLoader is com.Test0515_2$1@5a07e868
Test.classLoader.parentClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
Test.classLoader.parentClassLoader.parentClassLoader is sun.misc.Launcher$ExtClassLoader@3fee733d
通过打印结果可知,Test类的加载器是Test0515_2$1@5a07e868。这里的Test$1表示是main主函数中的局部变量,因为在本示例程序中,自定义的加载器被定义成局部变量。。接着看这个自定义类加载器的父加载器,是Launcher$AppClassLoader,即系统类加载器。这里比较奇怪,明明自定义的类加载器直接继承了java.lang.ClasLoader,并没有继承Launcher$AppClassLoader,但是为何自定义的类加载器的父加载器变成了系统类加载器呢?原因其实很简单,这个秘密隐藏在java.lang.ClassLoader的默认构造函数中,其默认的无参构造函数是:
protected ClassLoader() {
this(checkCreateClassLoader(), getSystemClassLoader());
}
通过这个默认构造函数可知,如果自定义的类加载器没有重写构造函数,则该默认构造函数会将getSystemClassLoader()返回的结果作为自定义类加载器的父加载器。而getSystemClassLoader()函数最终返回的便是x系统类加载器——Launcher$AppClassLoader
运行该测试程序,打印结果如下:
Test.classLoader is com.Test0515_2$1@5a07e868
Test.classLoader.parentClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
Test.classLoader.parentClassLoader.parentClassLoader is sun.misc.Launcher$ExtClassLoader@3fee733d
通过打印结果可知,Test类的加载器是Test0515_2$1@5a07e868。这里的Test$1表示是main主函数中的局部变量,因为在本示例程序中,自定义的加载器被定义成局部变量。。接着看这个自定义类加载器的父加载器,是Launcher$AppClassLoader,即系统类加载器。这里比较奇怪,明明自定义的类加载器直接继承了java.lang.ClasLoader,并没有继承Launcher$AppClassLoader,但是为何自定义的类加载器的父加载器变成了系统类加载器呢?原因其实很简单,这个秘密隐藏在java.lang.ClassLoader的默认构造函数中,其默认的无参构造函数是:
protected ClassLoader() {
this(checkCreateClassLoader(), getSystemClassLoader());
}
通过这个默认构造函数可知,如果自定义的类加载器没有重写构造函数,则该默认构造函数会将getSystemClassLoader()返回的结果作为自定义类加载器的父加载器。而getSystemClassLoader()函数最终返回的便是x系统类加载器——Launcher$AppClassLoader
加载器与类型转换。
经过上述分析可知,每个Java类都必定有一个类加载器,JDK核心类库的加载器引导类加载器,应用程序中的类加载器默认是系统类加载器,除此以外,开发者还可以为应用程序开发自定义的加载器。例如Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务上的不同应用程序。在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性,但是,如果涉及Java类型转换,则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。将上面自定义类加载器的那个示例稍微修改一下,变成如下:
这里修改了一行代码,原本在加载Test实例时,没有做类型转换,直接返回Object类型,而这里进行了类型转换。运行程序,最终会抛出下面这条异常:
Exception in thread "main" java.lang.ClassCastException: com.Test0516 cannot be cast to com.Test0516
异常信息提示得很清楚:Test类无法转换成Test类,乍一看,这个异常抛得莫名其妙,同一个类型竟然无法转换。但是实际上JVM并没有错,错的是你,在Test test = (Test)loader.loadClass("Test").newInstance()这句代码中,等号左边所声明的Test类型,由于测试程序并没有明确为其指定类加载器,因此JVM会使用系统类加载器加载Test类,而等号右边则明确使用了自定义的类加载器加载Test类型,因此d等号左边与右边的两个Test类型的加载器并不是同一个,所以程序便抛异常了。
ClassCastException这类异常在使用Spring框架的Java应用程序中比较常见,相信很多人都遇到过,救起原因,还是因为很多中间件都有自定义的类加载器,因此被内存加载器所加载的类型,无法直接转换为默认加载器加载的类型
经过上述分析可知,每个Java类都必定有一个类加载器,JDK核心类库的加载器引导类加载器,应用程序中的类加载器默认是系统类加载器,除此以外,开发者还可以为应用程序开发自定义的加载器。例如Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务上的不同应用程序。在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性,但是,如果涉及Java类型转换,则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。将上面自定义类加载器的那个示例稍微修改一下,变成如下:
这里修改了一行代码,原本在加载Test实例时,没有做类型转换,直接返回Object类型,而这里进行了类型转换。运行程序,最终会抛出下面这条异常:
Exception in thread "main" java.lang.ClassCastException: com.Test0516 cannot be cast to com.Test0516
异常信息提示得很清楚:Test类无法转换成Test类,乍一看,这个异常抛得莫名其妙,同一个类型竟然无法转换。但是实际上JVM并没有错,错的是你,在Test test = (Test)loader.loadClass("Test").newInstance()这句代码中,等号左边所声明的Test类型,由于测试程序并没有明确为其指定类加载器,因此JVM会使用系统类加载器加载Test类,而等号右边则明确使用了自定义的类加载器加载Test类型,因此d等号左边与右边的两个Test类型的加载器并不是同一个,所以程序便抛异常了。
ClassCastException这类异常在使用Spring框架的Java应用程序中比较常见,相信很多人都遇到过,救起原因,还是因为很多中间件都有自定义的类加载器,因此被内存加载器所加载的类型,无法直接转换为默认加载器加载的类型
类示例分配。
编写Java程序,使用最频繁的指令几乎就是new了,因为Java所有的数据和行为都封装在类中,想要读写数据或者触发某种行为,必须先实例化Java类。实例化Java类的方式有很多,例如,调用Java.lang.Class.newInstance()或者直接使用new指令。而事实上,new的实现机制相当精彩,对内存、线程并发控制等几乎达到了登峰造极的地步,利用了软件和硬件所能利用的一切优化手段。并且,对象实例化涉及内存分配,而内存f分配又与垃圾收集器紧密耦合在一块,所以可以这么说,这部分的计数实现是精华中的精华,内存分配z则显得相当简单——什么技术够高大上,就直接拿来使用,为了性能和内存开销用尽一切谋略。HotSpot提供了new字节码指令的机器码实现,在Intel32位平台上,其实现在templateTable_x86_32.cpp::_new()函数中,该函数中的代码都是为了在运行期生成硬件相关的机器码。好在HotSpot保留了字节码解释器的实现,并且字节码解释器中的实现逻辑与机器码逻辑基本一致,所以为了方便,可以直接参考字节码计时器的逻辑来分析实例化的机制,字节码解释器的实现如下
编写Java程序,使用最频繁的指令几乎就是new了,因为Java所有的数据和行为都封装在类中,想要读写数据或者触发某种行为,必须先实例化Java类。实例化Java类的方式有很多,例如,调用Java.lang.Class.newInstance()或者直接使用new指令。而事实上,new的实现机制相当精彩,对内存、线程并发控制等几乎达到了登峰造极的地步,利用了软件和硬件所能利用的一切优化手段。并且,对象实例化涉及内存分配,而内存f分配又与垃圾收集器紧密耦合在一块,所以可以这么说,这部分的计数实现是精华中的精华,内存分配z则显得相当简单——什么技术够高大上,就直接拿来使用,为了性能和内存开销用尽一切谋略。HotSpot提供了new字节码指令的机器码实现,在Intel32位平台上,其实现在templateTable_x86_32.cpp::_new()函数中,该函数中的代码都是为了在运行期生成硬件相关的机器码。好在HotSpot保留了字节码解释器的实现,并且字节码解释器中的实现逻辑与机器码逻辑基本一致,所以为了方便,可以直接参考字节码计时器的逻辑来分析实例化的机制,字节码解释器的实现如下
这段逻辑从宏观上分为两部分:一部分是快速分配,一部分则是慢分配。如果所要new的Java类型尚未被解析过(即使已经被加载也不算),则直接进入慢分配,这便是前面所述的JVM延迟加载的基础所在。快速分配的流程比较复杂,而慢分配则直接调用InterpreterRuntime::_new接口。前面在分析Java类加载时多次提到这个接口。
为了尽可能地加快内存分配速度,并减少并发操作带来的性能损失,JVM在分配内存时,总是有限使用快速分配策略,当快速分配失败时,才会启用慢分配策略,这便是上面这段源码的总体逻辑,这段逻辑可以概括位如下几点:
1.若Java类尚未被解析,则直接进入慢分配,不会使用快速分配策略
2.快速分配。如果没有开启栈上分配或不符合条件则会进行TLAB策略
3.快速分配。如果TLAB分配不成功,则尝试在eden区分配
4.如果eden区分配失败,则会进入慢分配流程
5.如果对象满足了直接进入老年代的条件,那就直接分配在老年代
6.快速分配,对于热点代码,如果开启逃逸分析,JVM则会执行栈上分配或标量替换等优化方案
为了尽可能地加快内存分配速度,并减少并发操作带来的性能损失,JVM在分配内存时,总是有限使用快速分配策略,当快速分配失败时,才会启用慢分配策略,这便是上面这段源码的总体逻辑,这段逻辑可以概括位如下几点:
1.若Java类尚未被解析,则直接进入慢分配,不会使用快速分配策略
2.快速分配。如果没有开启栈上分配或不符合条件则会进行TLAB策略
3.快速分配。如果TLAB分配不成功,则尝试在eden区分配
4.如果eden区分配失败,则会进入慢分配流程
5.如果对象满足了直接进入老年代的条件,那就直接分配在老年代
6.快速分配,对于热点代码,如果开启逃逸分析,JVM则会执行栈上分配或标量替换等优化方案
栈上分配与逃逸分析。
前面在分析JVM的JIT即时编译器时层提到过逃逸分析。即时编译(just-in-time compilation,JIT)是一种通过在运行时将字节码翻译成机器码,从而改善字节码编译语言性能的技术。在HotSpot中有多种实现,C1、C2和C1+C2,分别对应Client、Server和分层编译。未来究竟还有何种更加精妙的实现谁也说不准。
而所谓逃逸,是指在一个方法内部被创建的对象不仅在方法内部被引用,还在方法外部被其他变量引用,这带来的后果是:在该方法执行完毕之后,该方法中创建的对象无法被GC回收,因为对象在方法外部还被引用这,这便是逃逸的含义。JVM所进行的逃逸分析是确定方法内部所创建的对象会不会逃逸出方法体外部,如果确定不会逃逸出去,那么就能对该对象采取多种优化措施,这些优化措施主要围绕两大方面进行:内存分配和线程同步。
逃逸分析的算法主要基于连通图,通过引入来南通图来构建对象和对象引用之间的可达性关系,并在此基础上,提出一种组合数据流分析法,该算法是上下文相关和流敏感的,同时模拟了对象任意层次的嵌套关系,所以运行时间比较长和内存消耗比较大(要感知上下文和程序流),但是分析精度比较高。然而其并不能确保百分百的准确性,因为Java语言拥有许多动态特性,例如动态生成字节码、调用本地函数、反射、方法拦截等,这些语言特性导致逃逸分析的算法不能作为编译期间的静态优化措施,而只能是基于运行时的动态分析,而这正是逃逸分析为何需要感知运行时的上下问和程序流的原因。一个对象在编译期可能并没有发生Taoism,但是可能被一个AOP框架拦截,结果久不好说了,很可能在运行期救护i发生方法逃逸甚至线程逃逸。
由于逃逸分析是在运行期进行的,并且很耗费内存和CPU资源,因此JVM不可能对每一个方法的变量都进行逃逸分析,所以其只能作为JIT的一项优化措施,即只有JVM触发JIT编译时才会进行逃逸分析。这也是为何在上面的BytecodeInterpreter::run()函数源码中并没有看到丝毫与所谓栈上分配相关的实现的原因。在逃逸分析完成之后,JIT编译器会基于逃逸分析的结果,直接基于Java字节码指令,生成优化后的本地机器码指令,所生成的本地机器指令会直接将Java对线分配在栈甚至硬件寄存器中。这些优化的本地机器指令已经再也看不到Java的new字节码指令了,而这也是并不能在Java的new字节码指令的直接实现里看到栈上分配相关的实现的原因.
在该示例的main()主函数中,创建了Test类的两个示例t1和t2,其中t2并没有在主函数之外被引用,因此不会发生逃逸,而t1被传递到了foo()方法,不过由于t2没有逃逸,因此JVM认为t1也没有逃逸。JIT基于逃逸分析的结果,可以使用不同的策略,为对象实例分配内存,在不同的策略下所生成的本地机器指令自然不同。目前主要的优化技术包括标量替换和栈上分配。这两种优化技术都不会将对象实例直接分配在对上
前面在分析JVM的JIT即时编译器时层提到过逃逸分析。即时编译(just-in-time compilation,JIT)是一种通过在运行时将字节码翻译成机器码,从而改善字节码编译语言性能的技术。在HotSpot中有多种实现,C1、C2和C1+C2,分别对应Client、Server和分层编译。未来究竟还有何种更加精妙的实现谁也说不准。
而所谓逃逸,是指在一个方法内部被创建的对象不仅在方法内部被引用,还在方法外部被其他变量引用,这带来的后果是:在该方法执行完毕之后,该方法中创建的对象无法被GC回收,因为对象在方法外部还被引用这,这便是逃逸的含义。JVM所进行的逃逸分析是确定方法内部所创建的对象会不会逃逸出方法体外部,如果确定不会逃逸出去,那么就能对该对象采取多种优化措施,这些优化措施主要围绕两大方面进行:内存分配和线程同步。
逃逸分析的算法主要基于连通图,通过引入来南通图来构建对象和对象引用之间的可达性关系,并在此基础上,提出一种组合数据流分析法,该算法是上下文相关和流敏感的,同时模拟了对象任意层次的嵌套关系,所以运行时间比较长和内存消耗比较大(要感知上下文和程序流),但是分析精度比较高。然而其并不能确保百分百的准确性,因为Java语言拥有许多动态特性,例如动态生成字节码、调用本地函数、反射、方法拦截等,这些语言特性导致逃逸分析的算法不能作为编译期间的静态优化措施,而只能是基于运行时的动态分析,而这正是逃逸分析为何需要感知运行时的上下问和程序流的原因。一个对象在编译期可能并没有发生Taoism,但是可能被一个AOP框架拦截,结果久不好说了,很可能在运行期救护i发生方法逃逸甚至线程逃逸。
由于逃逸分析是在运行期进行的,并且很耗费内存和CPU资源,因此JVM不可能对每一个方法的变量都进行逃逸分析,所以其只能作为JIT的一项优化措施,即只有JVM触发JIT编译时才会进行逃逸分析。这也是为何在上面的BytecodeInterpreter::run()函数源码中并没有看到丝毫与所谓栈上分配相关的实现的原因。在逃逸分析完成之后,JIT编译器会基于逃逸分析的结果,直接基于Java字节码指令,生成优化后的本地机器码指令,所生成的本地机器指令会直接将Java对线分配在栈甚至硬件寄存器中。这些优化的本地机器指令已经再也看不到Java的new字节码指令了,而这也是并不能在Java的new字节码指令的直接实现里看到栈上分配相关的实现的原因.
在该示例的main()主函数中,创建了Test类的两个示例t1和t2,其中t2并没有在主函数之外被引用,因此不会发生逃逸,而t1被传递到了foo()方法,不过由于t2没有逃逸,因此JVM认为t1也没有逃逸。JIT基于逃逸分析的结果,可以使用不同的策略,为对象实例分配内存,在不同的策略下所生成的本地机器指令自然不同。目前主要的优化技术包括标量替换和栈上分配。这两种优化技术都不会将对象实例直接分配在对上
1.标量替换。
所谓标量,是指不可分割的量,Java中的基本数据类型和reference类型都属于标量,其中reference类型在JVM内部其实就是一个指针,因此也属于不可分割的量。如果一个数据类型可以继续分解,则称为聚合量。如果将一个对象拆散,将其成员变量恢复到基本类型以用于访问,这个过程就叫做标量替换。由此可知,标量替换不仅仅是替换那么单纯,替换后还要修改类型字段的读写指令。如果标量替换的优化比较激进,甚至可以将一个类的所有字段都打散分配到硬件寄存器中,当前前提是这个类型中包含的字段不能太多,毕竟寄存器数量是有限的
所谓标量,是指不可分割的量,Java中的基本数据类型和reference类型都属于标量,其中reference类型在JVM内部其实就是一个指针,因此也属于不可分割的量。如果一个数据类型可以继续分解,则称为聚合量。如果将一个对象拆散,将其成员变量恢复到基本类型以用于访问,这个过程就叫做标量替换。由此可知,标量替换不仅仅是替换那么单纯,替换后还要修改类型字段的读写指令。如果标量替换的优化比较激进,甚至可以将一个类的所有字段都打散分配到硬件寄存器中,当前前提是这个类型中包含的字段不能太多,毕竟寄存器数量是有限的
2.栈上分配。
如果一个类实例引用变量没有发生逃逸,则将实例对象直接分配在方法栈上。在栈上分配的对象实例,会随着Java方法执行结束后方法栈空间的回收而被回收,因此不需要GC来回收。这种方式所带来的好处是不言而喻的,毕竟GC时间越少,则JVM留给用户线程执行的时间片段久越多.不过栈上分配也有其硬伤,那就是Java类型不能太大,包含的字段不能太多,毕竟堆栈空间是有限的,容纳不下几个大型的Java类。如果不是因为有这个限制,JVM也不必建立所谓的堆内存空间。
如果一个类实例引用变量没有发生逃逸,则将实例对象直接分配在方法栈上。在栈上分配的对象实例,会随着Java方法执行结束后方法栈空间的回收而被回收,因此不需要GC来回收。这种方式所带来的好处是不言而喻的,毕竟GC时间越少,则JVM留给用户线程执行的时间片段久越多.不过栈上分配也有其硬伤,那就是Java类型不能太大,包含的字段不能太多,毕竟堆栈空间是有限的,容纳不下几个大型的Java类。如果不是因为有这个限制,JVM也不必建立所谓的堆内存空间。
逃逸分析的算法比较复杂,并且与本地机器指令有关。绝大多数开发者都不需要关心其具体实现。但是逃逸分析和基于此所进行的内存分配优化可以通过jmap-histo命令观察开启和关闭逃逸分析选项这两种情况下的实例总数来进行验证。除了这种办法也可以通过观察GC日志来间接分析,例如下面这个示例。本示例程序中通过一个50万次循环,不断创建Test类实例对象,之所以要循环,是因为只有达到一定的循环次数才能称为热点代码,才会触发JIT编译优化,也才会启用逃逸分析。使用-server -Xmx5m -Xms5m -XX:-DoEscapeAnalysis -XX:+PrintGC选项运行该测试程序,结果输出如下
[GC (Allocation Failure) 1024K->656K(5632K), 0.0009041 secs]
[GC (Allocation Failure) 1680K->918K(5632K), 0.0009729 secs]
[GC (Allocation Failure) 1942K->1022K(5632K), 0.0005166 secs]
[GC (Allocation Failure) 2046K->1094K(5632K), 0.0003730 secs]
[GC (Allocation Failure) 2118K->1142K(5632K), 0.0003213 secs]
[GC (Allocation Failure) 2166K->1198K(5632K), 0.0002725 secs]
[GC (Allocation Failure) 2222K->1028K(5632K), 0.0003567 secs]
[GC (Allocation Failure) 2052K->1028K(5632K), 0.0002203 secs]
//。。。。。
-XX:-DoEscapeAnalysis选项表示关闭逃逸分析。接着使用-server -Xmx5m -Xms5m -XX:+DoEscapeAnalysis -XX:+PrintGC选项运行该测试程序,结果输出如下:
[GC (Allocation Failure) 1024K->678K(5632K), 0.0005977 secs]
[GC (Allocation Failure) 1702K->924K(5632K), 0.0004959 secs]
[GC (Allocation Failure) 1948K->988K(5632K), 0.0005238 secs]
[GC (Allocation Failure) 2012K->1004K(5632K), 0.0004395 secs]
[GC (Allocation Failure) 2028K->1028K(5632K), 0.0003543 secs]
[GC (Allocation Failure) 2052K->1076K(5632K), 0.0003397 secs]
[GC (Allocation Failure) 2100K->1084K(5632K), 0.0003215 secs]
[GC (Allocation Failure) 2108K->948K(5632K), 0.0002350 secs]
======l
这一次使用-XX:+DoEscapeAnalysis选项开启了逃逸分析(其实该选项在JDK8中默认是开启的,因此不需要设置该选项),JVM一共只进行了8次GC回收,相比关闭逃逸分析时,次数少了很多。开启逃逸分析之后,JVM之所以仍然存在GC,是因为一开始运行时的循环次数不够,未触发JIT,因此Test类实例对象仍然分配在堆空间,而当触发JIT,JVM使用JIT编译后的本地机器指令来实现类对象实例分配时,实例对象不再分配在对上,因此不再有GC输出日志
[GC (Allocation Failure) 1024K->656K(5632K), 0.0009041 secs]
[GC (Allocation Failure) 1680K->918K(5632K), 0.0009729 secs]
[GC (Allocation Failure) 1942K->1022K(5632K), 0.0005166 secs]
[GC (Allocation Failure) 2046K->1094K(5632K), 0.0003730 secs]
[GC (Allocation Failure) 2118K->1142K(5632K), 0.0003213 secs]
[GC (Allocation Failure) 2166K->1198K(5632K), 0.0002725 secs]
[GC (Allocation Failure) 2222K->1028K(5632K), 0.0003567 secs]
[GC (Allocation Failure) 2052K->1028K(5632K), 0.0002203 secs]
//。。。。。
-XX:-DoEscapeAnalysis选项表示关闭逃逸分析。接着使用-server -Xmx5m -Xms5m -XX:+DoEscapeAnalysis -XX:+PrintGC选项运行该测试程序,结果输出如下:
[GC (Allocation Failure) 1024K->678K(5632K), 0.0005977 secs]
[GC (Allocation Failure) 1702K->924K(5632K), 0.0004959 secs]
[GC (Allocation Failure) 1948K->988K(5632K), 0.0005238 secs]
[GC (Allocation Failure) 2012K->1004K(5632K), 0.0004395 secs]
[GC (Allocation Failure) 2028K->1028K(5632K), 0.0003543 secs]
[GC (Allocation Failure) 2052K->1076K(5632K), 0.0003397 secs]
[GC (Allocation Failure) 2100K->1084K(5632K), 0.0003215 secs]
[GC (Allocation Failure) 2108K->948K(5632K), 0.0002350 secs]
======l
这一次使用-XX:+DoEscapeAnalysis选项开启了逃逸分析(其实该选项在JDK8中默认是开启的,因此不需要设置该选项),JVM一共只进行了8次GC回收,相比关闭逃逸分析时,次数少了很多。开启逃逸分析之后,JVM之所以仍然存在GC,是因为一开始运行时的循环次数不够,未触发JIT,因此Test类实例对象仍然分配在堆空间,而当触发JIT,JVM使用JIT编译后的本地机器指令来实现类对象实例分配时,实例对象不再分配在对上,因此不再有GC输出日志
TLAB.
相信很多研究JVM的道友对TLAB不会陌生,TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。
TLAB的出现能够解决直接在堆上安全分配所带来的线程同步性能消耗问题。堆内存是全局,任何线程都能够在对上申请空间,因此每次申请堆内存空间时都必须进行同步处理,对于一个生产环境下的Java Web应用程序而言,拥有一两千、两三千个线程是很常见的事情,这么多线程同时在堆上申请内存,竞争十分激烈,必然会出现线程阻塞。而TLAB则是线程私有的一款内存空间,这块空间位于eden区,由于各个线程所拥有的TLAB区域彼此不重复,因此线程在各自的TLAB内存区域申请空间,无须加锁,这样内存申请的效率便会得到极大提升。其实说白了,这就是一种典型的"空间换时间"的策略。
参数-XX:+UseTLAB可以开启TLAB,默认是开启的。TLAB的内存空间非常小,默认情况下仅占整个eden空间的1%,每个线程所能拥有的TLAB空间非常少,因此JVM必然会限制Java类对象实例的大小。如果对象实例超过超过一定的阈值,便属于大对象,JVM会将大对象直接分配到对上,不再分配在TLAB区,否则一下子就可能将TLAB将TLAB区"打爆"。在上面贴出来的BytecodeInterpreter::run()函数源码中,可以看到TLAB的逻辑,其逻辑是直接调用result = (oop) THREAD->tlab().allocate(obj_size)来完成TLAB内存分配。tlab实际上是JVM内部Thread类的一个成员变量,类型是ThtreadLocalAllocBuffer,该类内部主要通过3个字段维护TLAB区域的范围,者3个字段分别是:
# _start, TLAB区域的内存首地址
# _top, 最近一次TLAB分配内存后所指向的地址
# _end, TLAB区域的终止位置(内存对齐后的位置)
通过这3个变量,TLAB便能完成内存申请与释放。在BytecodeInterpreter::run()函数中调用tlab->allocate(size_t)来申请内存,实现如下:
这里的实现极为简单,如果TLAB剩余的空间足够容纳Java类对象实例,则重置TLAB的top属性,如此便完成内存分配。虽然TLAB分配的逻辑很简单,但是TLAB内存空间的维护稍具复杂性,需要随着运行时的执行流儿随时变化。如果TLAB剩余空间不够,则TLAB内存分配必定会失败,此时JVM便会向eden区申请内存空间。如果JVM再次申请分配一个比较小的Java对象实例,该对象大小小于TLAB区的剩余空间,则JVM会继续将小对象有限填充到TLAB区域。
如果TLAB区域都用完了,如何处理呢?别忘了,TLAB区域本身被分配在的EDEN区,属于eden区的一部分吗因此当eden也快要用完的时候,会触发GC垃圾回收,在垃圾回收期间,TLAB的内存空间会随着eden区的回收一起被回收掉,如此实现TLAB的循环利用
相信很多研究JVM的道友对TLAB不会陌生,TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。
TLAB的出现能够解决直接在堆上安全分配所带来的线程同步性能消耗问题。堆内存是全局,任何线程都能够在对上申请空间,因此每次申请堆内存空间时都必须进行同步处理,对于一个生产环境下的Java Web应用程序而言,拥有一两千、两三千个线程是很常见的事情,这么多线程同时在堆上申请内存,竞争十分激烈,必然会出现线程阻塞。而TLAB则是线程私有的一款内存空间,这块空间位于eden区,由于各个线程所拥有的TLAB区域彼此不重复,因此线程在各自的TLAB内存区域申请空间,无须加锁,这样内存申请的效率便会得到极大提升。其实说白了,这就是一种典型的"空间换时间"的策略。
参数-XX:+UseTLAB可以开启TLAB,默认是开启的。TLAB的内存空间非常小,默认情况下仅占整个eden空间的1%,每个线程所能拥有的TLAB空间非常少,因此JVM必然会限制Java类对象实例的大小。如果对象实例超过超过一定的阈值,便属于大对象,JVM会将大对象直接分配到对上,不再分配在TLAB区,否则一下子就可能将TLAB将TLAB区"打爆"。在上面贴出来的BytecodeInterpreter::run()函数源码中,可以看到TLAB的逻辑,其逻辑是直接调用result = (oop) THREAD->tlab().allocate(obj_size)来完成TLAB内存分配。tlab实际上是JVM内部Thread类的一个成员变量,类型是ThtreadLocalAllocBuffer,该类内部主要通过3个字段维护TLAB区域的范围,者3个字段分别是:
# _start, TLAB区域的内存首地址
# _top, 最近一次TLAB分配内存后所指向的地址
# _end, TLAB区域的终止位置(内存对齐后的位置)
通过这3个变量,TLAB便能完成内存申请与释放。在BytecodeInterpreter::run()函数中调用tlab->allocate(size_t)来申请内存,实现如下:
这里的实现极为简单,如果TLAB剩余的空间足够容纳Java类对象实例,则重置TLAB的top属性,如此便完成内存分配。虽然TLAB分配的逻辑很简单,但是TLAB内存空间的维护稍具复杂性,需要随着运行时的执行流儿随时变化。如果TLAB剩余空间不够,则TLAB内存分配必定会失败,此时JVM便会向eden区申请内存空间。如果JVM再次申请分配一个比较小的Java对象实例,该对象大小小于TLAB区的剩余空间,则JVM会继续将小对象有限填充到TLAB区域。
如果TLAB区域都用完了,如何处理呢?别忘了,TLAB区域本身被分配在的EDEN区,属于eden区的一部分吗因此当eden也快要用完的时候,会触发GC垃圾回收,在垃圾回收期间,TLAB的内存空间会随着eden区的回收一起被回收掉,如此实现TLAB的循环利用
指针碰撞与eden区分配。
如果JVM向TLAB申请内存失败,则会转而向Eden区申请内存。在这个过程中,使用了bump-the-pointer技术,也即指针碰撞。其逻辑实现其实比较简单
Universe::heap()返回JVM内部所使用的CollectedHeap堆对象,top_addr()指向eden区空闲块的起始地址,end_addr()指向eden区空闲块的结束地址.JVM首先通过compare_to保存eden区空闲块的起始地址,接着使用new_top保存分配内存后新的空闲块的起始地址。指针碰撞技术的关键在于CAS操作,这里通过基于CPU硬件的CAS原子指令进行空闲块的同步操作,比较_top的预期值与compare_to是否相同,若相同则表明没有其他线程操作该变量,若没有其他线程操作该变量,则会更新eden区的_top属性,并返回原来的_top作为Java类实例对象的内存首地址。这便是所谓的"指针碰撞"技术,起始就是判断预期的top与原来的top是否相等,而指针碰撞的关键就是CAS原语,JVM通过CAS避免了多线程之间的锁竞争,这是实现内存快速分配的技术保障
如果JVM向TLAB申请内存失败,则会转而向Eden区申请内存。在这个过程中,使用了bump-the-pointer技术,也即指针碰撞。其逻辑实现其实比较简单
Universe::heap()返回JVM内部所使用的CollectedHeap堆对象,top_addr()指向eden区空闲块的起始地址,end_addr()指向eden区空闲块的结束地址.JVM首先通过compare_to保存eden区空闲块的起始地址,接着使用new_top保存分配内存后新的空闲块的起始地址。指针碰撞技术的关键在于CAS操作,这里通过基于CPU硬件的CAS原子指令进行空闲块的同步操作,比较_top的预期值与compare_to是否相同,若相同则表明没有其他线程操作该变量,若没有其他线程操作该变量,则会更新eden区的_top属性,并返回原来的_top作为Java类实例对象的内存首地址。这便是所谓的"指针碰撞"技术,起始就是判断预期的top与原来的top是否相等,而指针碰撞的关键就是CAS原语,JVM通过CAS避免了多线程之间的锁竞争,这是实现内存快速分配的技术保障
清零。
前面两个快速分配——现在TLAB上分配,如果分配失败,则再向eden区申请内存空间,如果这两种分配策略中有一个成功,则Java类实例对象将会在TLAB区或者eden区占有一席之地。但是别忘了,无论是TLAB区还是eden区,都会不断地被GC,因此Java对象实例锁分配到的内存空间有可能仍残留着哪些已经被回收或者被转移到其他堆内存区域的对象的信息片段,如果是这样,则需要将这段内存空间进行清零。在BytecodeInterpreter::run()函数源码中实现了清零逻辑:
在这段逻辑中,主要关注need_zero,如果在TLAB区中成功分配内存,并且TLAB区本身已清零,则表达式!ZeroTLAB返回false,于是if(need_zero)不满足,无需清零。否则即时在TLAB区成功申请内存,最终仍需清零。如果TLAB区申请内存失败,则向eden区分配内存之前先将need_zero设置为true,这意味着只要是从eden区分配的内存,最终都需要清零。清零的方式很简单,将指定的内存区域全部设置为0即可,所有的二进制位都是0
前面两个快速分配——现在TLAB上分配,如果分配失败,则再向eden区申请内存空间,如果这两种分配策略中有一个成功,则Java类实例对象将会在TLAB区或者eden区占有一席之地。但是别忘了,无论是TLAB区还是eden区,都会不断地被GC,因此Java对象实例锁分配到的内存空间有可能仍残留着哪些已经被回收或者被转移到其他堆内存区域的对象的信息片段,如果是这样,则需要将这段内存空间进行清零。在BytecodeInterpreter::run()函数源码中实现了清零逻辑:
在这段逻辑中,主要关注need_zero,如果在TLAB区中成功分配内存,并且TLAB区本身已清零,则表达式!ZeroTLAB返回false,于是if(need_zero)不满足,无需清零。否则即时在TLAB区成功申请内存,最终仍需清零。如果TLAB区申请内存失败,则向eden区分配内存之前先将need_zero设置为true,这意味着只要是从eden区分配的内存,最终都需要清零。清零的方式很简单,将指定的内存区域全部设置为0即可,所有的二进制位都是0
偏向锁。
如果快速分配策略成功实施并完成清零,接着会设置偏向锁。所谓设置偏向锁,其实是设置对象头,即oop的mark标记,其逻辑如下:
if (UseBiasedLocking) {
result->set_mark(ik->prototype_header());
} else {
result->set_mark(markOopDesc::prototype());
}
不是说好设置偏向锁的吗?可是这里的源码看起来像是在设置prototype。其实prototype的类型便是mark,而每一个Java类实例都有一个mark标记,所谓的mark,看起来像个C++对象,但实际上在JVM内部是被当作一个指针使用的,在32位平台上,指针是一个32位的正整数,同理,64位上的指针便是一个64位的正整数。而JVM会将Java类对象的GC分代年龄、哈希码、锁标志位等信息存放到这个mark上,其实说白了就是二进制打标。其中,偏向锁的表示也会打在这个mark指针上。看上面这段源码,如果JVM开启偏向锁,则将ik->prototype_header()设置为新创建的Java类实例对象的标记,markOopDesc::prototype()其返回一个没有哈希码,没有偏向锁的标记。关于偏向锁,其概念往往与轻量级锁,重量级锁紧密关联在一起,这几种锁可以相互转化。感兴趣的可以深入研究,关于多线程同步控制是一个很复杂的话题,从硬件到软件有各种各样的解决方案,
如果快速分配策略成功实施并完成清零,接着会设置偏向锁。所谓设置偏向锁,其实是设置对象头,即oop的mark标记,其逻辑如下:
if (UseBiasedLocking) {
result->set_mark(ik->prototype_header());
} else {
result->set_mark(markOopDesc::prototype());
}
不是说好设置偏向锁的吗?可是这里的源码看起来像是在设置prototype。其实prototype的类型便是mark,而每一个Java类实例都有一个mark标记,所谓的mark,看起来像个C++对象,但实际上在JVM内部是被当作一个指针使用的,在32位平台上,指针是一个32位的正整数,同理,64位上的指针便是一个64位的正整数。而JVM会将Java类对象的GC分代年龄、哈希码、锁标志位等信息存放到这个mark上,其实说白了就是二进制打标。其中,偏向锁的表示也会打在这个mark指针上。看上面这段源码,如果JVM开启偏向锁,则将ik->prototype_header()设置为新创建的Java类实例对象的标记,markOopDesc::prototype()其返回一个没有哈希码,没有偏向锁的标记。关于偏向锁,其概念往往与轻量级锁,重量级锁紧密关联在一起,这几种锁可以相互转化。感兴趣的可以深入研究,关于多线程同步控制是一个很复杂的话题,从硬件到软件有各种各样的解决方案,
压栈与取指。
再快速分配走完偏向锁设置之后,Java类实例对象的内存空间已经分配完成,接着JVM将Java对象实例的内存首地址压入操作数栈栈顶,完成压栈之后则开始取指——读取下一条字节码指令,在BytecodeInterpreter::run()函数中实现了这种逻辑:
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
不过按道理,完成对象内存分配之后,不是应该将对象的内存首地址存储到局部变量表中对应的位置吗?为何只进行了压栈?道理很简单,Java源码中的new语句通常会被编译为几条字节码指令,其中第一条字节码指令便是new指令,上面的逻辑都是new指令的内部实现机制。完成new指令之后,JVM接着会调用Java类的构造函数,通过构造函数才真正返回一个完成原始构建的内部对象。构造函数运行之后,则会生成一条字节码指令,将对象的内存首地址存储到局部变量表中。例如下面的示例:
该示例特别简单,编译后,使用javap命令查看其字节码指令,如图所示。可以看到,源码中的new语句对应了4条字节码指令,首先执行new指令,接着执行invokespecial指令调用类的默认无参构造函数<init>(),最后在通过astore_1将构造好的实例对象内存首地址保存进局部变量表中。由此可以看出,完成new指令后,之所以不利己将对象内存地址写入局部变量表中,是因为接下来就会调用方法,而JVM每次调用Java方法之前,都必须要将入参压入操作数栈栈顶。如果执行完new指令之后就立即将对象内存地址写入局部变量表中,那么接下来调用类的构造函数时就需要再次将对象内存地址从局部变量表中读取出来并压入操作数栈栈顶,这样多了几次内存读写所以JVM干脆就在执行完new指令后,直接将内存地址压入栈顶,提升性能。
再快速分配走完偏向锁设置之后,Java类实例对象的内存空间已经分配完成,接着JVM将Java对象实例的内存首地址压入操作数栈栈顶,完成压栈之后则开始取指——读取下一条字节码指令,在BytecodeInterpreter::run()函数中实现了这种逻辑:
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
不过按道理,完成对象内存分配之后,不是应该将对象的内存首地址存储到局部变量表中对应的位置吗?为何只进行了压栈?道理很简单,Java源码中的new语句通常会被编译为几条字节码指令,其中第一条字节码指令便是new指令,上面的逻辑都是new指令的内部实现机制。完成new指令之后,JVM接着会调用Java类的构造函数,通过构造函数才真正返回一个完成原始构建的内部对象。构造函数运行之后,则会生成一条字节码指令,将对象的内存首地址存储到局部变量表中。例如下面的示例:
该示例特别简单,编译后,使用javap命令查看其字节码指令,如图所示。可以看到,源码中的new语句对应了4条字节码指令,首先执行new指令,接着执行invokespecial指令调用类的默认无参构造函数<init>(),最后在通过astore_1将构造好的实例对象内存首地址保存进局部变量表中。由此可以看出,完成new指令后,之所以不利己将对象内存地址写入局部变量表中,是因为接下来就会调用方法,而JVM每次调用Java方法之前,都必须要将入参压入操作数栈栈顶。如果执行完new指令之后就立即将对象内存地址写入局部变量表中,那么接下来调用类的构造函数时就需要再次将对象内存地址从局部变量表中读取出来并压入操作数栈栈顶,这样多了几次内存读写所以JVM干脆就在执行完new指令后,直接将内存地址压入栈顶,提升性能。
上面分析了Java类对象内存的快速分配的技术实现机制,总体而言快速分配的策略如图所示。如果快速分配失败,则最终会进入慢分配流程,慢分配流程也会首先尝试在TLAB中分配,如果分配失败,则继续尝试使用指针碰撞技术在新生代分配,这种分配是无锁的,效率仍然很高。如果仍然分配失败,则最终才会使用互斥锁,在堆区进行分配。在这个过程中如果碰到GC正在回收垃圾,则会等待GC回收完成。慢分配流程中所使用的优化手段与快速分配类似,都是有限使用无锁分配方案(TLAB或指针碰撞)。慢分配与GC在理论和源码上是紧密耦合在一起的,而GC本身是一个非常复杂的管理习题。
启动HSDB的命令
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
HSDIS插件安装
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*XX.add -XX:CompileCommand=compileonly,*XX.add
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:TieredStopAtLevel=3 -XX:CompileCommand=compileonly,*Test0429.add -XX:LogFile=D:\\javalog\\1.log
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-Xint
-XX:+PrintInterpreter
-XX:+DebugNonSafepoints
-XX:CompileCommand=compileonly,*Test0509::foo
-XX:LogFile=D:\\javalog\\1.log
-XX:+PrintAssembly
-Xint
-XX:+PrintInterpreter
-XX:+DebugNonSafepoints
-XX:CompileCommand=compileonly,*Test0509::foo
-XX:LogFile=D:\\javalog\\1.log
JITWatch安装
(查看字节码反汇编)
(查看字节码反汇编)
1.4.2版本源码启动,不会出现乱码
mvn clean compile exec:java
JVM的编译阶段是什么
JVM的编译阶段主要指的是字节码编译阶段。在Java程序的编译过程中,首先会将Java源代码(.java文件)通过Java编译器(javac)编译成字节码文件(.class).
这个字节码是与平台无关的中间代码,可以在任何支持JVM的操作系统和硬件上运行。具体来说,JVM的编译阶段包括:
1.Java源代码编译: 使用Java编译器将.java文件编译成.class字节码文件
2.字节码加载与验证:当程序运行时,JVM回家再字节码文件,并进行验证,确保代码符合JVM的要求
3.即时编译(JIT): JVM中的即时编译器(JIT Compiler)会将热点代码(经常执行的部分)编译成本地机器码,提高执行效率。这个过程通常发生在程序运行时,而不是预先完成的编译
所以,JVM的编译阶段通常是指将Java源代码编译为字节码,以及后续的JIT编译过程
这个字节码是与平台无关的中间代码,可以在任何支持JVM的操作系统和硬件上运行。具体来说,JVM的编译阶段包括:
1.Java源代码编译: 使用Java编译器将.java文件编译成.class字节码文件
2.字节码加载与验证:当程序运行时,JVM回家再字节码文件,并进行验证,确保代码符合JVM的要求
3.即时编译(JIT): JVM中的即时编译器(JIT Compiler)会将热点代码(经常执行的部分)编译成本地机器码,提高执行效率。这个过程通常发生在程序运行时,而不是预先完成的编译
所以,JVM的编译阶段通常是指将Java源代码编译为字节码,以及后续的JIT编译过程
C++在Windows/Linux的编译器叫什么
MinGW. 是Windows 系统下的一个编译环境,包含了C++ 代码编译所需的三方库、头文件等,用于完成C++ 源码的编译和链接
linux上的C++编译器叫g++
C++在Windows/Linux上的打包文件有何不同
C++的指针为什么在32位机器上占4个字节,64位占8个字节?
为什么说Java的类型信息不完全是在编译器确定的,有些是推迟到了运行期?
Java的数据结构在运行期决定,主要体现在:
1.泛型类型擦除: 运行时类型信息被擦除,具体类型在运行时决定
# Java的泛型在编译期会进行类型擦除,即泛型信息不会进入字节码,实际在运行时使用的是Object或其边界类型(如Integer)
# 这意味着,在运行时,ArrayList<Integer>和ArrayList<String>都只是ArrayList,数据结构的具体类型信息只能在运行期通过实际存储的对象来决定
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World")
运行时list只是ArrayList,但存储的内容决定了它的具体使用方式
1.泛型类型擦除: 运行时类型信息被擦除,具体类型在运行时决定
# Java的泛型在编译期会进行类型擦除,即泛型信息不会进入字节码,实际在运行时使用的是Object或其边界类型(如Integer)
# 这意味着,在运行时,ArrayList<Integer>和ArrayList<String>都只是ArrayList,数据结构的具体类型信息只能在运行期通过实际存储的对象来决定
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World")
运行时list只是ArrayList,但存储的内容决定了它的具体使用方式
2.接口与实现类的动态绑定: 运行时决定使用ArrayList还是LinkedList
# 例如,ArrayList和LinkedList都是List接口的实现类,编译时声明的List类型,在运行时具体的结构可能是ArrayList或LinkedList
# 这意味着,数据结构的具体实现是在运行期才真正决定的
List<Integer> list = new ArrayList<>(); // 编译期决定使用ArrayList
list = new LinkedList<>(); // 运行时可切换为LinkedList
集合框架的动态特性
# Java的数据结构常通过接口(如List、Map、Set)进行抽象,具体的实现类(ArrayList、HashMap、TreeSet)是在运行时绑定的
# 这使得数据结构的实际行为和性能特性在运行时才会最终确定
List<String> list = getList(); // getList() 在运行时才会决定返回ArrayList或LinkedList
# 例如,ArrayList和LinkedList都是List接口的实现类,编译时声明的List类型,在运行时具体的结构可能是ArrayList或LinkedList
# 这意味着,数据结构的具体实现是在运行期才真正决定的
List<Integer> list = new ArrayList<>(); // 编译期决定使用ArrayList
list = new LinkedList<>(); // 运行时可切换为LinkedList
集合框架的动态特性
# Java的数据结构常通过接口(如List、Map、Set)进行抽象,具体的实现类(ArrayList、HashMap、TreeSet)是在运行时绑定的
# 这使得数据结构的实际行为和性能特性在运行时才会最终确定
List<String> list = getList(); // getList() 在运行时才会决定返回ArrayList或LinkedList
3.动态内存分配: 集合类(如ArrayList)在运行时动态扩展
# Java的所有对象(包括数据结构)都是在堆内存种动态分配的,在运行时决定具体的大小、结构及存储内容
# 例如,ArrayList的容量扩展是在运行期动态调整的,而LinkedList在插入数据时会动态分配节点
List<Integer> list = new ArrayList<>(2); // 初始容量 2
list.add(1);
list.add(2);
list.add(3); // 运行时动态扩展数组
# Java的所有对象(包括数据结构)都是在堆内存种动态分配的,在运行时决定具体的大小、结构及存储内容
# 例如,ArrayList的容量扩展是在运行期动态调整的,而LinkedList在插入数据时会动态分配节点
List<Integer> list = new ArrayList<>(2); // 初始容量 2
list.add(1);
list.add(2);
list.add(3); // 运行时动态扩展数组
4.反射与动态代理: 运行时可以动态创建和操作数据结构
# Java允许在运行时通过反射创建和操作数据结构,例如:
# Class.forName("java.util.ArrayList")可以在运行时动态实例化数据结构,而不是在编译器固定
Class<?> clazz = Class.forName("java.util.ArrayList");
List<Integer> list = (List<Integer>) clazz.getDeclaredConstructor().newInstance();
这说明Java的数据结构类型并不是在编译期完全固定,而是运行期动态决定的
这使得Java既能在编译器提供类型安全,又能在运行时保持高度的灵活性和扩展性
# Java允许在运行时通过反射创建和操作数据结构,例如:
# Class.forName("java.util.ArrayList")可以在运行时动态实例化数据结构,而不是在编译器固定
Class<?> clazz = Class.forName("java.util.ArrayList");
List<Integer> list = (List<Integer>) clazz.getDeclaredConstructor().newInstance();
这说明Java的数据结构类型并不是在编译期完全固定,而是运行期动态决定的
这使得Java既能在编译器提供类型安全,又能在运行时保持高度的灵活性和扩展性
比如List<String>类型的集合,可以向其加入Integer类型的数据
https://blog.csdn.net/Cover_sky/article/details/135467049
https://blog.csdn.net/Cover_sky/article/details/135467049
为什么堆的增长方向和栈的增长方向是相反的?
那么如何理解JVM直接使用markOop存放数值不存放指针
在HotSpot JVM的对象头(Object Header)中,markOop(Mark Word)是一个关键的数据结构,它主要用于存储对象的状态信息,而不是传统意义上的指针。要理解为什么JVM直接在markOop中存放数值,而不是存放指针,我们需要深入了解markOop的作用和设计
1.什么是markOop?
在HotSpot JVM中,每个Java对象的对象头(Object Header)通常由两部分组成:
# Mark Word(markOop):存放对象的状态信息
# Klass Pointer(指向对象的类元数据)
其中,Mark Word(markOop)是一个重要的优化设计,它的大小通常是32位或64位,取决于是32bit还是64bit的机器,它用于存放:
# 对象的哈希码
# GC相关信息
# 锁信息(偏向锁、轻量级锁等)
# 分代年龄(Age、GC相关)
在HotSpot JVM中,每个Java对象的对象头(Object Header)通常由两部分组成:
# Mark Word(markOop):存放对象的状态信息
# Klass Pointer(指向对象的类元数据)
其中,Mark Word(markOop)是一个重要的优化设计,它的大小通常是32位或64位,取决于是32bit还是64bit的机器,它用于存放:
# 对象的哈希码
# GC相关信息
# 锁信息(偏向锁、轻量级锁等)
# 分代年龄(Age、GC相关)
2.为什么markOOp存放数值,而不是存放指针?
# markOop主要用于存储对象的状态,而不是指向其他对象
## markOop不是对象的普通引用,而是存储对象自身的一些元信息,比如哈希值、锁状态,而这些信息都是数值,而不是指向其他对象的指针。
## 如果markOop存放的是指针,那么就必须指向其他的元数据结构,但这样会增加额外的存储开销和访问成本
# 直接存放数值可以i提高访问效率
## 如果markOop里存的是指针,那么在访问对象的哈希码、GC信息或锁信息时,就需要额外的间接寻址,影响性能
## 直接使用数值可以减少指针解析和访问的成本,提升HotSpot JVM的执行效率
# 对象头大小的限制
## HotSpot JVM设计上希望控制对象头的大小,以节省内存
## 直接存数值可以保证markOop只占用固定的32/64位,避免额外的存储开销
## 如果存放指针,会额外占用4/8字节,导致对象头变大,不利于内存优化
# GC需要快速标记和管理对象
## 在GC过程中,JVM需要快速判断对象的状态,比如是否被锁、是否存活等
## 直接存放数值意味着标记、判断、回收都可以在对象头内部完成,而不用额外解析指针,提高了GC的效率
# markOop主要用于存储对象的状态,而不是指向其他对象
## markOop不是对象的普通引用,而是存储对象自身的一些元信息,比如哈希值、锁状态,而这些信息都是数值,而不是指向其他对象的指针。
## 如果markOop存放的是指针,那么就必须指向其他的元数据结构,但这样会增加额外的存储开销和访问成本
# 直接存放数值可以i提高访问效率
## 如果markOop里存的是指针,那么在访问对象的哈希码、GC信息或锁信息时,就需要额外的间接寻址,影响性能
## 直接使用数值可以减少指针解析和访问的成本,提升HotSpot JVM的执行效率
# 对象头大小的限制
## HotSpot JVM设计上希望控制对象头的大小,以节省内存
## 直接存数值可以保证markOop只占用固定的32/64位,避免额外的存储开销
## 如果存放指针,会额外占用4/8字节,导致对象头变大,不利于内存优化
# GC需要快速标记和管理对象
## 在GC过程中,JVM需要快速判断对象的状态,比如是否被锁、是否存活等
## 直接存放数值意味着标记、判断、回收都可以在对象头内部完成,而不用额外解析指针,提高了GC的效率
3.markOop的动态变化
markOop不是固定不变的,它在不同状态下的存储内容也不同,例如:
# 普通状态: 存放哈希码、分代年龄
# 偏向锁状态: 存放线程ID
# 轻量级锁状态: 存放指向Lock Record(锁记录)的指针
# 重量级锁状态: 存放
markOop不是固定不变的,它在不同状态下的存储内容也不同,例如:
# 普通状态: 存放哈希码、分代年龄
# 偏向锁状态: 存放线程ID
# 轻量级锁状态: 存放指向Lock Record(锁记录)的指针
# 重量级锁状态: 存放
JVM中还有不是普通对象类型的指针吗?
在JVM中,除了普通的对象引用指针(reference)指针之外,还有一些特殊类型的指针或引用,主要包括:
1.句柄指针(Handl Pointer)
早期的一些JVM使用句柄池(Handle Pool)来管理对象。对象的引用实际上是指向一个句柄,而句柄再指向实际的对象实例和它的类元数据。这种方式的优点是GC时可以只更新句柄,而不需要修改所有引用对象的指针。不过,现代JVM如HotSpot通常不使用句柄池,而是采用直接指针方式。
2.直接指针(Direct Pointer)
现代JVM如HotSpot优化后,直接使用指针引用对象,而不是通过句柄。这种方式提高了访问速度,因为省去了间接引用的开销
3.NULL指针
JVM中的对象引用可以是null.标识这个引用没有指向任何对象,null本质上是一个特殊值,不是一个真正的对象
4.oop(Ordinary Object Pointer,普通对象指针)
HotSpotJVM内部用oop来标识Java对象的引用,oop可以是直接指针,也可以是压缩后的指针(Compressed Oops)
5.Klass指针
在对象头(Object Header)中,JVM维护了一个Klass指针,指向对象的类元数据。这个Klass指针用于在运行时确定对象的类型
6.弱引用相关指针
JVM还提供了几种特殊的对象引用:
# 强引用(Strong Reference): 普通对象引用,GC不会回收
# 软引用(Soft Reference): JVM内存不足时才会回收
# 弱引用(Weak Reference):下一次GC就会回收
# 虚引用(Phantom Reference):对象即将被回收时收到通知,配合ReferenceQueue使用
7.JNI指针(Java Native Interface Pointer)
在使用JNI(Java本地接口)时,会涉及本地指针(Native Pointer),如jobject、jclass、jmethodID等,它们在C/C++代码中用于操作JVM对象。
8.线程本地存储指针(Thread Local Storage Pointer)
每个线程在JVM中会有线程本地存储(TLS),用于存储当前线程的栈、局部变量等。这个存储区域的访问通常需要指针
9.栈帧指针(Frame Pointer)
JVM方法执行时,每个线程都有一个虚拟机栈,其中每个方法调用都会创建一个栈帧。栈帧中包含局部变量表、操作数栈、方法返回地址等,方法执行时需要指针来管理栈帧的压栈和出栈
10.Metaspace主要存储类元数据,JVM会使用指针访问Metaspace中的类定义、方法区等信息
1.句柄指针(Handl Pointer)
早期的一些JVM使用句柄池(Handle Pool)来管理对象。对象的引用实际上是指向一个句柄,而句柄再指向实际的对象实例和它的类元数据。这种方式的优点是GC时可以只更新句柄,而不需要修改所有引用对象的指针。不过,现代JVM如HotSpot通常不使用句柄池,而是采用直接指针方式。
2.直接指针(Direct Pointer)
现代JVM如HotSpot优化后,直接使用指针引用对象,而不是通过句柄。这种方式提高了访问速度,因为省去了间接引用的开销
3.NULL指针
JVM中的对象引用可以是null.标识这个引用没有指向任何对象,null本质上是一个特殊值,不是一个真正的对象
4.oop(Ordinary Object Pointer,普通对象指针)
HotSpotJVM内部用oop来标识Java对象的引用,oop可以是直接指针,也可以是压缩后的指针(Compressed Oops)
5.Klass指针
在对象头(Object Header)中,JVM维护了一个Klass指针,指向对象的类元数据。这个Klass指针用于在运行时确定对象的类型
6.弱引用相关指针
JVM还提供了几种特殊的对象引用:
# 强引用(Strong Reference): 普通对象引用,GC不会回收
# 软引用(Soft Reference): JVM内存不足时才会回收
# 弱引用(Weak Reference):下一次GC就会回收
# 虚引用(Phantom Reference):对象即将被回收时收到通知,配合ReferenceQueue使用
7.JNI指针(Java Native Interface Pointer)
在使用JNI(Java本地接口)时,会涉及本地指针(Native Pointer),如jobject、jclass、jmethodID等,它们在C/C++代码中用于操作JVM对象。
8.线程本地存储指针(Thread Local Storage Pointer)
每个线程在JVM中会有线程本地存储(TLS),用于存储当前线程的栈、局部变量等。这个存储区域的访问通常需要指针
9.栈帧指针(Frame Pointer)
JVM方法执行时,每个线程都有一个虚拟机栈,其中每个方法调用都会创建一个栈帧。栈帧中包含局部变量表、操作数栈、方法返回地址等,方法执行时需要指针来管理栈帧的压栈和出栈
10.Metaspace主要存储类元数据,JVM会使用指针访问Metaspace中的类定义、方法区等信息
C++解释器、模板解释器和JIT即时编译器的关系是什么
解释执行。
包括C++解释器和模板解释器。解释执行并不是每次执行字节码时动态把它编译成机器码,而是根据字节码的类型,转到对应的机器码区执行,即一个派发(switch)的过程。而C++解释器派发到的是由字节码对应的C++代码所编译成的机器码,模板解释器派发到的字节码对应的汇编模板所生成的机器码。由于C++代码是由编译器编译成机器码,比较荣誉,所以执行速度慢,而模板解释器的汇编模板是由汇编代码专门编写的,执行效率高。所以解释执行速度较慢,并不是每次将字节码动态编码成机器码的原因,这是错误的,而是对于每个字节码都派发到对应的机器码上执行,而不是从上到下的顺序执行机器码,多了很多判断、跳转的指令,所以效率较低。
包括C++解释器和模板解释器。解释执行并不是每次执行字节码时动态把它编译成机器码,而是根据字节码的类型,转到对应的机器码区执行,即一个派发(switch)的过程。而C++解释器派发到的是由字节码对应的C++代码所编译成的机器码,模板解释器派发到的字节码对应的汇编模板所生成的机器码。由于C++代码是由编译器编译成机器码,比较荣誉,所以执行速度慢,而模板解释器的汇编模板是由汇编代码专门编写的,执行效率高。所以解释执行速度较慢,并不是每次将字节码动态编码成机器码的原因,这是错误的,而是对于每个字节码都派发到对应的机器码上执行,而不是从上到下的顺序执行机器码,多了很多判断、跳转的指令,所以效率较低。
编译执行。
JIT对于热点代码,编译成运行效率高的机器码。这里与模板解释器的区别在于JIT针对的是代码段生成机器码,而模板解释器是针对每个字节码指令生成机器码,以及JIT是动态生成的,模板解释器是在JVM启动时就把字节码对应的汇编模板转换为机器码。某种意义上模板解释器也属于JIT的范畴。当JIT把整段代码直接编译成机器码时,在执行时就可以自上而下的获取执行机器码,而不用对每条字节码指令跳转到对应的机器码上,执行效率获得提升
JIT对于热点代码,编译成运行效率高的机器码。这里与模板解释器的区别在于JIT针对的是代码段生成机器码,而模板解释器是针对每个字节码指令生成机器码,以及JIT是动态生成的,模板解释器是在JVM启动时就把字节码对应的汇编模板转换为机器码。某种意义上模板解释器也属于JIT的范畴。当JIT把整段代码直接编译成机器码时,在执行时就可以自上而下的获取执行机器码,而不用对每条字节码指令跳转到对应的机器码上,执行效率获得提升
C++解释器
模板解释器
JIT编译执行
TLAB的清零时机
0 条评论
下一页