写了几年的Java代码的人,大多数也难以真正说清楚,一行代码是怎么被操作系统执行的,我也说不清楚。这次在极客时间的每日一课中,看到了《结合操作系统,如何理解一行Java代码是怎么运行的》 作者董鹏 的一篇文章,非常优秀,忍不住整理下,分享给大家。
要想了解一行代码最终是怎么运行的,可以跟踪代码的整个生命中期。一行Java代码,首先被编译为字节码,被JVM虚拟机通过类加载器进行加载,然后这里面还有些安全校验,校验通过后,通过解释器进行解释成机器码,然后分配内存等资源,最后执行将操作结果写好回内存。
用图标识:
这里面只是粗略的步骤,有兴趣的可以看看《深入理解Java虚拟机》里面讲解的更详尽些。
一 编译
为什么需要编译,因为Java是一名高级语言,不能直接运行在硬件上,它生来就是为了跨平台的,通过JVM虚拟机来屏蔽底层硬件的差异,Java的编译就是将Java的源代码,编译为JVM虚拟机可以识别的字节码。
一个Hello world程序编译为字节码如下:
中间的就是Java编译出来的字节码,JVM加载字节码之后,加载之后通过解释器解析成汇编指令,
最终再转换成机器可以识别的机器指令。
JVM解释器是一种软件,我记得倒是原来有出现过直接执行Java 字节码的硬件。JVM解释器现在普遍是一种软件的话,那就可以将相同的一份Java字节码解释成不同的汇编指令,在通过硬件直接翻译成机器指令
对于部分的热点代码,为提升Java的性能,JVM的解释器会直接将字节码编译为机器指令,并通过codecache缓存起来,codecache是属于堆外内存。如果系统的内存不够,codecache 存不下来了,JVM就不会即时编译了,这样Java的应用的执行速度会变慢,一般来说由于JVM的即时编译的存在,Java的运行速度初期会比较慢,后面会越来越快。
二. 代码的执行准备
代码编译成机器指令后,就需要执行了,执行需要一些环境,比如指令存在什么地方,cpu从什么位置取指令,如何取指令:
图中:指令寄存器IP,指向待执行指令的地址;CPU的控制单元,将内存中的指令取出来,然后装载到指令寄存器IR中,这些加载来的指令还是二进制码串,需要通过指令译码器进行译码,如果需要取数据,则需要从内存中取数据,调用运算单元进行计算。
由于CPU和内存的速度相差太大,所以CPU不直接访问内存,而是通过多级缓存分级存取:
这里面的Cache缓存,又分为L1,L2,L3级别缓存,每个缓存的大小依次增大,访问速度越来越慢。如果CPU去取数据的时候,需要先判断主存地址是否在Cache中,如果在Cache中取数据给CPU,如果不在就从内存中取所在块的数据,然后在Cache中的一个空闲行中存入进去。
CPU如果去主存读的时候,并不是一次只拿一个值,而是根据数据访问的局部性原理,一般来说一个值被访问,这个值的物理地址周围的值同样可能被访问,所以一次存储一个缓存行,这样下次去取这个值的下一个值,比如数组的下一个元素的时候,直接从缓存中获取即可,而不用再从内存中取数据。
这样有好处,也会有坏处,比如如果这个缓存行中的一个数据发生了变动,那么整个缓存行就会失效,需要从新从内存取数据,所以一些高性能的程序需要解决这个问题,比如Disruptor高性能队列,就用如下的代码方式定义:
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding
cursor 这个变量,无论怎么取,一个缓存行中,只会有一个cursor变量有效,就不会因为其他变量失效而影响这个变量了。
三. 代码执行
代码保存到内存中后,各种寄存器也准备到位,那么什么时候执行到我们的代码?CPU是周期性地从内存中取指令,译码指令,最终执行,周而复始。
为了支持多任务,CPU将执行时间这个资源划分成时间片,每个程序执行一段时间,或执行结束,或等待资源(比如等待IO),或时间片到了,CPU就会将正在执行的进程挂起,重新选择一个准备执行的进程进行执行。
CPU因为等待IO资源情况,也会主动放弃CPU,这时候会切换到另一任务执行。所以对于IO密集型的情况,多线程的效果要好,如果是CPU密集型的应用,多线程的效果要差的多。
四. 内存分配
JVM启动的时候,就会在操作系统中产生一个进程,多个进程共享整个机器的物理内存。每个内存都有自己的虚拟内存空间。如果我们启动多个JVM进程打印一个Object对象的hashcode时候,会发现这些hashcode值一样的,这些hashcode值默认为Object对象的地址,也就是多个JVM进程返回Object对象地址是一样的,说明这些JVM进程都有相同的内存空间。
每个Java进程都独占一个独立的虚拟内存空间,简化内存管理,而且可以防止进程对其他进程内存空间的破坏。
有了虚拟内存,最终肯定要申请物理内存,每个进程在申请内存的时候,操作系统负责物理内存和虚拟内存的映射,虚拟内存的地址可以超过物理内存地址,超出的就会发生数据的溢出,溢出的数据保存在磁盘上。
这些映射关系在操作系统中对应着页表:
页表可以看成一个大数组,数组每一项对应一个内存页,每一项保存虚拟内存和物理内存或磁盘的页的映射关系。页表可以存在主存中,也可以存储在缓存区,我们将存储在高速缓存区的页表叫做TLAB,在linux中可以通过指令:
getconf PAGE_SIZE
来获取的内存的页面大小,一般为4096即 4KB。存在缓存TLAB中的页表访问显然更快,为了让TLAB存储更多页表数据,可以通过增大内存页的办法,像DPDK就要求设置大页内存,比如设置1G内存的大页。
五. JAVA代码的执行抽象
Java代码的执行的时候,无非是读取某个对象属性的值,然后通过一系列计算,将值再写入到某个属性中。我们获取一个对象之后,如何读取和设置它的属性那,可以参考Java中的反射的实现:
首先,我们持有对象的引用后,就可以获取到这个对象的存储的虚拟地址,然后我们可以获取对象的属性相对于对象地址的偏移量,然后再查询页表,就获得存储属性的物理地址了,根据这个属性的类型,知道应该读取的内存的长度,就可以把这些属性读取出来了。写入也是如此。
属性相对于对象的偏移量在加载Class的时候就确定好了,它是和Class绑定的。如果一个对象只有一个属性,如果不压缩的话,对象头占128个字节,那么属性的偏移量就是128。
如果一个对象有多个属性,那么Java还会进行内存对齐,整个对象所占字节数对齐为8字节的倍数,这样做有两个好处:
1.提升内存访问性能。
2.保证一个属性的值只在一个缓存行中,如下图:
六. 总结
我们首先要理解Java代码是怎么一步步变成机器指令的,理解其中的解释和即时编译。再次理解CPU的执行逻辑和分时间片调度;最后我们要理解进程的虚拟内存和物理内存是如何映射的,对象是如何访问属性的。
七. 说明
本文中的主要图片和文字整理自:https://time.geekbang.org/dailylesson/detail/100028498
文章。中间夹杂些我个人的理解。