1. 前言
从学习Java的第一天开始,到如今工作当中,想必大家都耳闻目染了各种Java的优点。其中肯定少不了:Java有虚拟机,java是跨平台的,一次编译到处运行。在相当长的一段时间里对此观点都只是一个很模糊的概念,对自己写的代码也有一种吃不透的感觉。犹如一只拦路的大老虎,望而生畏,止步不前。一番思量,一日不解决掉,对技术难以有更深层次的理解,只好硬着头皮上。
2. 不能跨平台的原因是怎样造成的?
2.1 机器语言和汇编
计算机只认识0和1
这句话大家都听说过。的确,正所谓大道至简,0和1足以撑起整个互联网世界。在早期编程中,都是编写一条条0和1组成的指令
来开发,要自己处理每一块数据的存储分配和输入输出。可想而知,满屏的0和1,程序容易出错且可读性很差。
使用 0和1
组成的机器指令来编程,太过于繁琐,单单只是记住0和1组成的指令
就令人头大。完全可以用一种简易的方式代替记忆,例如做加法运算,而这个加
的操作在机器码中可能是一个 010010
固定的指令,完全可以用 add
这个单词来代替记忆,简化了编程过程,这就是汇编语言。汇编语言的特点是用符号代替了机器指令代码,而且符号与指令代码一一对应,基本保留了机器语言的灵活性。而再将add指令
转为010010机器码
的程序便是汇编语言编译器。
2.2 硬件关系
组装过电脑的朋友都知道,组装一台电脑需要购买:CPU、内存条、硬盘,主板等以及各种外设。对程序而言,一开始存储在硬盘当中,即便计算机断电,下次重启程序依旧存在。CPU 是一个复杂的计算机部件,它内部又包含很多小零件,如下图所示:
图片摘自C语言中文网
内存对于 CPU 来仅仅是一个存放指令和数据的地方,并不能在内存中完成计算功能。例如要计算 a = b + c,必须将 a、b、c 都读取到 CPU 内部才能进行加法运算,寄存器是存储 CPU 执行所需数据的区域,是 CPU 不可或缺的一部分,所有程序都只能通过操作寄存器,达到控制 CPU 目的,完成计算任务。
2.2 芯片架构
arm
、X86
两种芯片架构广泛应用在 PC 机和移动端嵌入式设备中。前者由arm公司设计,后者由Intel、amd共同设计,双方交叉授权使用。arm
是精简指令集架构(RSIC),功耗较低,性能随之也降了下来。x86
是复杂指令集架构(CISC),功耗较高,性能强。arm架构
的寄存器 比 x86架构
的多不少。寄存器和指令集加架构本身的差异性,也是造成不能跨平台的原因。近几十年来,硬件的性能一直都在飞速发展,CPU架构 也经历了几次较大的改变。 x86架构
从最早的 16 位到 32 位再到现在的 64 位架构。arm架构
也从 v1 发展到了如今的 v8的64位架构。一般新的架构都会向前兼容几个版本,保证旧架构上的老代码,能够在新架构上运行。但这样做,却无法发挥出新架构硬件的性能,无疑是对资源的浪费。在开发中如果涉及到底层库的使用,则需要考虑兼容不同架构的CPU。例如在使用百度地图SDK时,会下载不同CPU架构的so文件,还有 X86 架构的,就是为了兼容不同CPU架构的手机。
Android可以通过adb命令来查看cpu信息1、adb shell 2、cat /proc/cpuinfo
2.3 C语言为什么不能夸平台?
通常认为 C 语言是编译型语言。在编译阶段,编译器直接将源码
编译为 对应CPU架构和操作系统上的可执行文件
。
如下图所示 c 语言代码编译为的汇编代码:
#include
int main() {
printf("Hello World");
return 0;
}
Windows 部分汇编指令:
ubuntu 部分汇编指令:
虽然读不太懂汇编指令,比较了一下差异还是不小的。C 语言更多的是偏向底层开发,只要编译器足够强大,支持对应平台的编译,或者对应平台提供有C 编译器
(C 语言的编译器也是众多语言中最多的)。程序就能在对应平台执行,也许 C 语言从来就没有想过要跨平台。
代码与平台有关性,是不能跨平台的原因。
3. JVM是如何做到跨平台的
讲了这么多不能夸平台的原因,再来理解Java是如何做到跨平台就容易得多了。JVM 在编译阶段,只将 .java
的源码,编译为和平台无关的 .class
字节码文件。不同 CPU 架构和操作系统上都会编译为相同的 calss 文件(最多只是 JDK 版本不同,有些许差异,jdk 都会向前兼容几个版本)。再由不同平台上的自行实现JVM。我们只需要搭建相应平台的运行环境即可,便可做到任意平台开发编译,到处运行。
JVM 在真机基础之上模拟了一套自己的架构,有自己的指令集、内存管理等。在使用 Eclipse 追溯源码时,常常会遇到只有 class 文件,而没有源码出现下面的页面:
图中红色框内的便是字节码指令,运行时通过逐条解释执行,这也是以前 Java 被指性能底下的诟点。的确,解释执行的性能确实是和 C 编译目标代码比不了,但是在
JDK1.2
时就支持 JIT
及时编译器。程序运行期间,分析热点(经常调用)函数,编译为本地代码缓存起来,以后直接执行本地代码。虽然性能还是和编译型的语言有一定的差异,但 Java 凭借其语言特性以及各种成熟的 Web 解决方案,这点性能差显得不那么重要,完全能够接受。JIT 编译代码如下:
有些JVM是采用纯JIT编译方式实现的,内部没解释器,例如JRockit、Maxine VM和Jikes RVM ---RednaxelaFX
4.JVM内存结构
内存作为程序运行中的临时存储介质,本质上不进行任何的区域划分,为了能够合理有效的使用回收内存,才将内存划分出更多的区域。平时听得较多的就是堆栈内存,堆栈是一种数据结构,也是一种概念模型。不同的语言有自己的实现方式,通常在 Oop
编程中,栈存放函数执行时所需的局部变量,函数执行完即释放,堆内存存储对象。
操作系统内存布局
Windows 上栈内存由系统回收,堆内存由程序员自行回收。因为栈上内存不可控,JVM 只能在操作系统的堆内存上开辟自己的空间。
JVM运行时内存结构
JVM堆
所有类实例和数组都从堆中分配
,官方JVMS8规范文档 的确是这样描述的 The heap is the run-time data area from which memory for all class instances and arrays is allocated
。有一个很常见情况下,函数执行中产生的对象在堆中分配,函数执行结束,不再引用的对象,已经没有存在的必要了。这些对象在堆中等待下一次GC,而大多对象朝生即死,生命周期极短,等待GC这段时间,也是对资源的浪费。在JDK1.5
时JVM
提供支持逃逸分析技术,通过分析对象作用域,实现了栈上分配、标量替换、同步消除优化等技术。通过函数传递对象,称之为方法逃逸。将对象赋值给其他线程变量,称之为线程逃逸:
标量替换
不可再分解的基础数据类型称之为标量,例如Java中的八大基础类型和引用类型。反之、如果某个对象还可继续分解,则该对象属于聚合量,Java类就是典型的聚合量。标量替换则是将对象的成员变量分解成原始数据类型,代替对象在栈中分配。
栈上分配
JDK1.8默认开启逃逸分析,确定对象不会再被外部引用,通过标量替换将对象分解在栈中分配,栈中的对象随着栈帧的出栈而销毁,大大的减少了堆内存的占用和GC的压力。
public class Main {
public static void main(String[] args) throws Exception {
for(int i = 0 ; i < 1000000;i++){
Child child = new Child();
child.setAge(1);
}
System.out.println("阻塞...");
System.in.read();
}
public static class Child{
private int age;
private String name;
//省略get/set方法
}
}
开启逃逸分析(1.8默认开启)
C:\Program Files\Java\jdk1.8.0_91\bin>jps -l
17456 sun.tools.jps.Jps
19680 linked.Main
7608
C:\Program Files\Java\jdk1.8.0_91\bin>jmap -histo 19680
num #instances #bytes class name
----------------------------------------------
1: 220734 5297616 linked.Main$Child
2: 437 1763680 [I
3: 3099 449536 [C
4: 2392 57408 java.lang.String
5: 488 55696 java.lang.Class
6: 97 41776 [B
7: 835 33400 java.util.TreeMap$Entry
关闭逃逸分析:
C:\Program Files\Java\jdk1.8.0_91\bin>jps -l
2436 sun.tools.jps.Jps
16536 linked.Main
7608
C:\Program Files\Java\jdk1.8.0_91\bin>jmap -histo 16536
num #instances #bytes class name
----------------------------------------------
1: 1000000 24000000 linked.Main$Child
2: 451 1873120 [I
3: 3099 449536 [C
4: 2392 57408 java.lang.String
5: 488 55696 java.lang.Class
6: 97 41776 [B
7: 835 33400 java.util.TreeMap$Entry
可以看到,关闭逃逸分析总共使用堆内存 22M ,开启逃逸分析只使用了 5M 左右。节约了不少堆内存空间,减少了 GC 压力。
开启逃逸-XX:+DoEscapeAnalysis -XX:+PrintGC
关闭逃逸-XX:-DoEscapeAnalysis -XX:+PrintGC
同步消除
如果逃逸分析确认对象的作用范围不会超过当前线程,则消除对变量的同步措施。
JVM栈
JVM栈 是方法执行所需的数据结构,每个线程都拥有一个JVM栈,随着线程的创建而创建,随着线程的销毁而销毁。JVM栈 以栈帧的单元,存放局部变量、操作数栈、动态链接、方法返回信息。具体可以参考
方法区/元数据区
方法区中存放已被虚拟机加载的类信息,并且每个类只会存在一份,作为使用该类的入口。我们所编写的代码类,经过javac
编译器,编译存储为 class 文件,在使用该类时(创建类的实例,调用了类静态方法类等),如果该类还未加载,会先将该 class 字节流从磁盘或者其他途径方式,加载存储到方法区当中,并且创建该类的 class对象 供以后访问使用。
运行时常量池
运行时常量池作为方法区的一部分,为每一个类都维护一个常量池,存放着编译时已知的字面量和各种符号引用。具体可见参考第二章
PC寄存器
每个JVM线程都有自己的PC(程序计数器)寄存器。在任何时候,每个JVM线程都在执行单个方法的代码,如果执行的不是native方法,则pc寄存器包含当前正在执行的Java字节码指令的地址。如果当前执行的native方法,则PC寄存器的值undefined。
本地方法栈
支持 native
方法调用,随着线程的创建来分配本地方法栈。
参考:
深入理解Java虚拟机一书
RednaxelaFX
keycoding