粗谈Java虚拟机1_开山篇

1. 前言

 从学习Java的第一天开始,到如今工作当中,想必大家都耳闻目染了各种Java的优点。其中肯定少不了:Java有虚拟机,java是跨平台的,一次编译到处运行。在相当长的一段时间里对此观点都只是一个很模糊的概念,对自己写的代码也有一种吃不透的感觉。犹如一只拦路的大老虎,望而生畏,止步不前。一番思量,一日不解决掉,对技术难以有更深层次的理解,只好硬着头皮上。


2. 不能跨平台的原因是怎样造成的?

2.1 机器语言和汇编

计算机只认识0和1 这句话大家都听说过。的确,正所谓大道至简,0和1足以撑起整个互联网世界。在早期编程中,都是编写一条条0和1组成的指令来开发,要自己处理每一块数据的存储分配和输入输出。可想而知,满屏的0和1,程序容易出错且可读性很差。

case1.jpg

 使用 0和1 组成的机器指令来编程,太过于繁琐,单单只是记住0和1组成的指令就令人头大。完全可以用一种简易的方式代替记忆,例如做加法运算,而这个的操作在机器码中可能是一个 010010 固定的指令,完全可以用 add 这个单词来代替记忆,简化了编程过程,这就是汇编语言。汇编语言的特点是用符号代替了机器指令代码,而且符号与指令代码一一对应,基本保留了机器语言的灵活性。而再将add指令转为010010机器码的程序便是汇编语言编译器。

2.2 硬件关系

 组装过电脑的朋友都知道,组装一台电脑需要购买:CPU、内存条、硬盘,主板等以及各种外设。对程序而言,一开始存储在硬盘当中,即便计算机断电,下次重启程序依旧存在。CPU 是一个复杂的计算机部件,它内部又包含很多小零件,如下图所示:


118274690_1_20171206084852264.jpg

     图片摘自C语言中文网
 内存对于 CPU 来仅仅是一个存放指令和数据的地方,并不能在内存中完成计算功能。例如要计算 a = b + c,必须将 a、b、c 都读取到 CPU 内部才能进行加法运算,寄存器是存储 CPU 执行所需数据的区域,是 CPU 不可或缺的一部分,所有程序都只能通过操作寄存器,达到控制 CPU 目的,完成计算任务。

2.2 芯片架构

armX86两种芯片架构广泛应用在 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架构的手机。

cpu_so.png

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 部分汇编指令:


微信截图_20190723174501.png

ubuntu 部分汇编指令:


微信截图_20190724170811.png

虽然读不太懂汇编指令,比较了一下差异还是不小的。C 语言更多的是偏向底层开发,只要编译器足够强大,支持对应平台的编译,或者对应平台提供有C 编译器(C 语言的编译器也是众多语言中最多的)。程序就能在对应平台执行,也许 C 语言从来就没有想过要跨平台。

代码与平台有关性,是不能跨平台的原因。

3. JVM是如何做到跨平台的

 讲了这么多不能夸平台的原因,再来理解Java是如何做到跨平台就容易得多了。JVM 在编译阶段,只将 .java的源码,编译为和平台无关的 .class 字节码文件。不同 CPU 架构和操作系统上都会编译为相同的 calss 文件(最多只是 JDK 版本不同,有些许差异,jdk 都会向前兼容几个版本)。再由不同平台上的自行实现JVM。我们只需要搭建相应平台的运行环境即可,便可做到任意平台开发编译,到处运行。

未命名文件.png

 JVM 在真机基础之上模拟了一套自己的架构,有自己的指令集、内存管理等。在使用 Eclipse 追溯源码时,常常会遇到只有 class 文件,而没有源码出现下面的页面:
微信截图_20181120131556.png

 图中红色框内的便是字节码指令,运行时通过逐条解释执行,这也是以前 Java 被指性能底下的诟点。的确,解释执行的性能确实是和 C 编译目标代码比不了,但是在 JDK1.2 时就支持 JIT 及时编译器。程序运行期间,分析热点(经常调用)函数,编译为本地代码缓存起来,以后直接执行本地代码。虽然性能还是和编译型的语言有一定的差异,但 Java 凭借其语言特性以及各种成熟的 Web 解决方案,这点性能差显得不那么重要,完全能够接受。JIT 编译代码如下:
微信截图_20181202221710.png

有些JVM是采用纯JIT编译方式实现的,内部没解释器,例如JRockit、Maxine VM和Jikes RVM ---RednaxelaFX

4.JVM内存结构

 内存作为程序运行中的临时存储介质,本质上不进行任何的区域划分,为了能够合理有效的使用回收内存,才将内存划分出更多的区域。平时听得较多的就是堆栈内存,堆栈是一种数据结构,也是一种概念模型。不同的语言有自己的实现方式,通常在 Oop编程中,栈存放函数执行时所需的局部变量,函数执行完即释放,堆内存存储对象。

操作系统内存布局
微信图片_20190730142730.png

 Windows 上栈内存由系统回收,堆内存由程序员自行回收。因为栈上内存不可控,JVM 只能在操作系统的堆内存上开辟自己的空间。

JVM运行时内存结构
微信图片_20190730145126.png
JVM堆

所有类实例和数组都从堆中分配,官方JVMS8规范文档 的确是这样描述的 The heap is the run-time data area from which memory for all class instances and arrays is allocated 。有一个很常见情况下,函数执行中产生的对象在堆中分配,函数执行结束,不再引用的对象,已经没有存在的必要了。这些对象在堆中等待下一次GC,而大多对象朝生即死,生命周期极短,等待GC这段时间,也是对资源的浪费。在JDK1.5JVM提供支持逃逸分析技术,通过分析对象作用域,实现了栈上分配、标量替换、同步消除优化等技术。通过函数传递对象,称之为方法逃逸。将对象赋值给其他线程变量,称之为线程逃逸:

标量替换

 不可再分解的基础数据类型称之为标量,例如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对象 供以后访问使用。

微信图片_20190730152335.png

运行时常量池

 运行时常量池作为方法区的一部分,为每一个类都维护一个常量池,存放着编译时已知的字面量和各种符号引用。具体可见参考第二章

PC寄存器

 每个JVM线程都有自己的PC(程序计数器)寄存器。在任何时候,每个JVM线程都在执行单个方法的代码,如果执行的不是native方法,则pc寄存器包含当前正在执行的Java字节码指令的地址。如果当前执行的native方法,则PC寄存器的值undefined。

本地方法栈

 支持 native 方法调用,随着线程的创建来分配本地方法栈。


参考:
深入理解Java虚拟机一书

RednaxelaFX

keycoding

你可能感兴趣的:(粗谈Java虚拟机1_开山篇)