走进jvm

java源代码是怎么被机器识别并执行的呢?答案是java虚拟机,即java virtual machine.

1、字节码(也称中间码,bytecode)

一、java所有的指令有200个左右,一个字节(8位)可以存储256种不同的指令信息,一个这样的字节称为字节码(bytecode).在代码的执行过程种,jvm将字节码解释执行,屏蔽了对底层操作系统的依赖;jvm也可以将字节码编译执行,如果是热点代码,会通过JIT(Just in time)动态地翻译为机器码,提高执行效率.

二、字节码指令

  1. 加载或存储指令

    1)、将局部变量加载到操作栈种.(如 ILOAD 将int类型的局部变量压入栈 和 ALOAD将对象引用的局部变量压入栈)等
    2)、从操作栈顶存储到局部变量表.如ISTORE、ASTORE等.
    3)、将常量加载到操作栈顶,这是极为高频使用的指令.如ICONST、BIPUSH、SIPUSH、LDC等.
  2. 运算指令
    对两个操作栈帧上的值进行运算,并把结果写入操作栈顶,如IADD、IMUL等
  3. 类型转换指令
    显示转换两种不同的数值类型.如I2L、D2F等
  4. 对象创建与访问指令
    1)、创建对象指令.如NEW、NEWARRAY等.
    2)、访问属性指令.如GETFIELD、PUTFIELD 、GETSTATIC等.
    3)、检查实例类型指令.如INSTANCEOF、 CHECKCAST等.
  5. 操作栈管理指令
    1)、出栈操作.如POP即一个元素,POP2即两个元素.
    2)、复制栈顶元素并压入栈.如DUP.
  6. 方法调用与返回指令
    常见指令如下:
    (1)、INVOKEVIRTUAL指令:调用对象的实例方法.
    (2)、INVOKESPECIAL指令:调用实例初始化方法、私有方法、父类方法等.
    (3)、INVOKESTATIC指令:调用类静态方法.
    (4)、RETURN指令:返回VOID类型.
     
  7. 同步指令
    JVM使用方法结构种的ACC_SYNCHRONIZED标志同步方法,指令集中有MONTORENTER和MONITOREXIT支持synchonized语义.

     

三、字节码生成过程

我们编写好的.java文件是源代码文件,并不能交给机器直接执行,需要将其编译成为字节码甚至机器码文件.那么静态编译器如何把源码转化称字节码的呢?如图所示:

词法解析是通过空格分隔出单词、操作符、控制符等信息,将其形成token信息流,传递给语法解析器;在语法解析时,把词法解析得到的token信息流按照java语法规则组装称一颗语法树.在语义分析阶段,需要检查关键字的使用是否合理、类型是否匹配、作用域是否正确等;当语义分析完成之后,即可生产字节码.

字节码必须通过类加载过程加载到jvm环境后,才可以执行.执行有三种模式:第一,解释执行;第二,JIT编译执行;第三,JIT编译与解释混合执行(主流jvm默认执行模式).混合执行模式的优势在于解释器在启动时先执行.省去编译时间.JIT的作用是将java字节码动态地编译成可以直接发送给处理器指令执行的机器码.

2、类加载过程

在冯.诺依曼定义的计算机模型中,任何程序都需要加载到内存才能与cpu进行交流.字节码.class文件同样需要加载到内存中,才可以实例化类.在加载类时,使用的是parent delegation model,译为双亲委派模型.

java的类加载器是一个运行时核心基础设施模块,如上图,主要是在启动之初进行类的Load、Link、Init,即加载、链接、初始化.

一、加载步骤

第一步,Load阶段读取类文件产生二进制流,并转化为特定的数据结构,初步校验cafe babe魔法数、常量池、文件长度、是否有父类等,然后创建对应类的java.lang.Class实例.

第二步,Link阶段包括验证、准备、解析三个步骤.验证阶段是更详细的校验,比如final是否合规、类型是否正确、静态变量是否合理等;准备阶段是为静态变量分配内存,并设定默认值,解析类和方法确保类与类之间的相互引用的正确性,完成内存结构布局.

第三步,Init阶段执行类构造器<clinit> (class init)方法,如果赋值运算是通过其他类的静态方法来完成的,那么会马上解析出另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值.

二、类加载器的层次

 

1)、bootstrap classloader

2)、platform classload(extension classloader)

3)、application classload

3、内存布局

一、Heap(堆区)

Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用.通常情况下,它占用的空间是所有内存区域最大的,但如果无节制地创建大量对象,也容易消耗所有的空间.

Heap大小可以通过如下参数设定:

-Xms256M -Xmx1024M 

ms是memory start 简称    mx是memory max简称

注意:在通常情况下,服务器在运行过程中,堆空间不断的扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,jvm的Xms和Xmx设置成一样大小,避免在GC后调整堆大小时带来的额外压力.

堆分成两大块:新生代和老年代.对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象.

新生代=1个Eden区+2个Survivor区.绝大部分对象在Eden区生成,当Eden区装填满的时候,会触发Young Garbage Collection,即YGC.

依然存活的对象会被移送到Survivor区.如果YGC移送的对象代Survivor区容量的上限,则直接交给类老年代.

每个对象都有一个计数器,每当YGC都会+1. -XX:MaxTenuringThreshold参数配置计数器的值到达某个阀值的时候,对象会从新生代晋升为老年代.

如果Survivor区无法放下,或者超大对象的阀值超过类上限,则尝试在老年代中进行分配;如果老年代也无法放下,则会触发Full Garbage Collection,即FGC.如果依然放不下,则抛出OOM.堆内存出现OOM的概率是所有内存耗尽异常中最高的.出错时的堆内信息堆解决问题非常有帮助,所以给jvm设置运行参数 -XX:+HeapDumpOnOutOfMemoryError,让jvm遇到oom异常时能输出堆内信息,特别是对相隔数月才出现的oom异常尤为重要.

二、Metaspace(元空间)

异常早在jdk8版本中,元空间的前身Perm区已经被淘汰.在jdk7及之前的版本中,只有hotspot才有Perm区,译为永生代,它在启动时固定了大小,很难进行调优,并且FGC时会移动类元信息.在某些场景下,如果动态加载类过多,容易产生Perm区的OOM.比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态的加载很多的类,经常出现致命错误:   xxxx java.lang.OutOfMemoryError:PermGen space.

为了解决该问题,需要设定运行参数-XX:MaxPermSize=1280M,如果部署到新机器上,往往会因为jvm参数没有修改导致故障再现.jdk8使用元空间替换永久代.在jdk8及以上版本中,设定MaxPermSize参数,jvm在启动的时候并不会报错,但会提示,suport was removed in 8.0.

三、JVM Stack(虚拟机栈)

栈(stack)是一个先进后出的数据结构,像子弹的弹夹,撞针只能访问位于顶部的那一颗子弹.相对于基于寄存器的运行环境来说,jvm是基于栈结构的运行环境.栈结构移植性更好,可控性更强.jvm中的虚拟机栈是描述java方法执行的内存区域,它是线程私有的.栈中的元素用来支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程.在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧.正在执行的方法称为当前方法,栈帧是方法运行的基本结构.

在执行引擎运行时,所有指令都只能针对当前栈帧进行操作.而StackOverflowError表示请求溢出,导致内存耗尽,通常出现在递归的方法中.JVM能够横扫千军,虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵.

虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上.在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定.栈帧在整个jvm体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等.

四、Native Method Stacks(本地方法栈)

本地方法栈(Native Mothod Stack) 在jvm内存布局中,也是线程对象私有的,但是虚拟机栈“主内”,而本地方法栈“主外”.这个“内外”是针对jvm而言的,本地方法栈为native方法服务.线程开始调用本地方法时,会进入一个不再受jvm约束的世界.本地方法可以通过JNI(java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和jvm相同的能力和权限.当大量的本地方法出现时,势必会削弱jvm对系统的控制力,因为它的出错信息会比较黑盒.对于内存不足的情况,本地方法栈还是会抛出native heap OutOfMemory.

重点说一下JNI类本地方法.最著名的本地方法应该是System.currentTimeMillis(),JNI是java深度使用操作系统的特性功能,复用非java代码.但是在项目过程中,如果大量使用其他语言来实现JNI,就会丧失跨平台的特性,威胁到程序的稳定性.假如需要与本地代码交互,就可以用中间标准框架进行解耦,这样即使本地方法奔溃也不至于影响到jvm的稳定,当然,如果要求极高的执行效率、偏底层的跨进程操作等,可以考虑设计JNI调用方式.

五、Program Counter Register(程序计数寄存器)

在程序计数寄存器(PC)中,Register的命名源于CPU的寄存器,CPU只有把数据转载到寄存器才能够运行.寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令.这样必然导致经常中断或恢复,如何保证分毫不差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器.程序计数器在各个线程之间互不影响,此区域也不会发送内存溢出异常.

最后,从线程共享的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器都是线程内部私有的.

4、对象实例化

java是面向对象的静态强类型语言,声明并创建对象的代码很常见,根据某个类声明一个引用变量指向被创建的对象,并使用此引用变量操作改对象.

5、垃圾回收

java会对内存进行自动分配与回收管理,使上层业务更加安全,方便地使用内存实现现实逻辑.在不同的jvm实现及不同的回收机制中,堆内存的划分是不一样的.这里简要介绍回收(Garbage Collection ,GC).垃圾回收的主要目的是清除不用使用的对象,自动释放内存.

GC是如何判断对象是否可以被回收的呢?为了判断对象是否存活,JVM引入了GC Roots.如果一个对象与GC Roots之间没有直接或间接的引用关系,比如某个失去引用的对象,或者两个互相环岛状循环引用的对象等,判决这些对象“死缓”,是可以被回收的.什么对象可以作为GC Roots呢?比如:类静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈中引用的对象等.

有了判断对象是否存活的标准后,在了解一下垃圾回收的相关算法.最基础的为“标记-清除算法”,改算法会从每个GC Roots出发,依次标记有引用关系的对象,最后将没有被标记的对象清除.但是这种算法会带来大量的空间碎片,导致需要分配一个较大连续空间时容易触发FGC.为了解决这个问题,由提出类“标记-整理算法”,该算法类似计算机的磁盘整理,首先会从GC Roots出发标记存活的对象,然后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清除,这样就不会产生碎片的问题.“Mark-Copy”算法,为了能够并行地标记和整理将空间分为两块,每次只激活一块,垃圾回收时只需把存活的对象复制到另外一块未激活空间上,将未激活空间标记为已激活,将已激活空间标记为未激活,然后清除原空间中的对象.堆内存空间分为较大的Eden和两块较小的Survivor,每次只使用Eden和Survivor区的一块.这种情形下的“Mark-Copy”减少了内存空间的浪费.“Mark-Copy”现作为主流的YGC算法进行新生代的垃圾回收.

垃圾回收器(Garbage Collector)是实现垃圾回收算法并应用在JVM环境中的内存管理模块.当前实现的垃圾回收器有数十种,如Serial回收器、CMS回收器、G1等.

猜你喜欢

转载自blog.csdn.net/huangpeigui/article/details/84799626
JVM