美文网首页Android开发
Java 虚拟机简介

Java 虚拟机简介

作者: 熠闲 | 来源:发表于2020-04-16 14:42 被阅读0次

###(未完成)###

    Java的运行原理虽然在我们开发人员中,不一定会进行干涉。但是了解其运行原理,则能更高效地避免或定位到相对应的Bug。本文主要通过以下几个方面进行阐述:

    1、Java虚拟机的数据区域组成与简介

    2、对象的创建与查找

    3、垃圾回收机制(GC)与内存分配管理

    4、线程与内存模型

    希望借由这几个方面能够有比较详细的阐述,使得更好地理解Java虚拟机的运行机制。

1、Java虚拟机的数据区域组成与简介

    我们知道每一个Java应用运行后,系统都会创建一个Java虚拟机对整个程序进行运转,每个Java的虚拟机运行时,主要分成5个数据区:PC(程序计数器)、虚拟机栈(VM Stack)、本地方法区栈(Native Method Stack)、堆(Heap)、方法区(Method Area)。从众所周知,每个应用内很可能会存在多个线程,而这5个数据区,前三者是线程所独有的(即每个线程都会各自拥有,不进行线程间共享)。但是后两者(堆、方法区)则是线程间共有的。我们看下图:

JVM数据区域简图—图1 JVM数据区域简图—图2

    从上图图1我们可知虚拟机的数据区及其各自负责的一些数据,图二则更能生动地展示多线程情况下,数据区各自的位置与独立性。在这里,我们简单地解释一下各个数据区的责任作用:

    1、PC(程序计数器)

    一块很小的内存空间,存储了下一条需要执行的字节码指令地址。比如分支、跳转、循环等都需要依赖其计数器才能完成。它是属于线程私有的,但是多线程并发的实现是通过线程轮换并分配执行时间完成的,所以在JVM中,某一时间内是只有一条指令在执行的。而它也是唯一一个在Java规范中,OutOfMememory未曾定义发生的区域(即不会在该区域发生OutOfMememory异常)。

    2、虚拟机栈(VM Stack)

    这个数据区主要对应了Java对象方法,主要存储方法的信息,局部变量,方法出口等。它是由一个个栈帧所组成的,在每次调用Java方法的时候创建一个栈帧,然后放入虚拟机栈中,采取的是后入先出(LIFO)的方式,即最新调用的方法优先执行。它是属于线程所私有,整个虚拟机栈作用也造成其生命周期跟线程的生命周期是相同的。 (ps:这里的局部变量表中存储局部变量值(基本数据类型),局部变量(实例对象)引用。)   

    由于后进先出的机制,虚拟机栈顶端的栈(被称作当前活动栈帧),是该虚拟机栈中唯一活跃(执行)的栈帧。由此我们可以知道,一个栈帧从入栈到出栈,对应的是一个方法的整个过程,其记录的信息自然也是整个方法所需要和产生的信息。具体有:

栈帧构成讲解图

    这里说一下栈帧里的局部变量表,它包含的主要是:编译期间就知道的8种基本数据类型、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)

    3、本地方法栈

    又称本地方法区,跟虚拟机栈的区别在于:虚拟机栈服务于Java对象方法,而本地方法栈则服务于本地方法(Native)。

    也因此无论是虚拟机栈,或者本地方法区都存在这两种异常的可能:

    (1)StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度;

    (2)OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

    4、堆(Heap)

    几乎所有的对象实例(需要new出来的,基本数据类型除外的所有对象类型)、数组内容存放区域。因其变化性,对于一般情况,对堆所分配的内存是整个虚拟机的数据区中最大的一块。也因此是垃圾回收(GC)主要运作区域,OOM最容易发生的区域。

    我们知道,堆是所有线程所共享的,也因此内部会对线程分配出私有的数据缓冲区(TLAB)。对于每个数据缓冲区来说,它所分配的内存区域不一定在物理条件上是完整的一块内存区域,只要在逻辑上是完整的一块区域即可。

    5、方法区(Method Area)

    属于线程共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。其组成为:

方法区组成表

2、对象的创建与查找

    在这一块主要了解一下,整个数据的创建于管理流程,对于后面的垃圾回收、内存相关的问题打一下基础。对象的创建,最常见的就是通过new指令来获取。创建对象的流程,大致上分为两个部分:初始化、实例化。

对象初始化流程图

    到此,初始化已经完成,接下来就是实例化了。实例化其实也有几个阶段:申请内存来存储成员变量,在申请成功之前,这些成员变量的值为默认零值。等到内存申请成功了,才将代码中设置的初始值赋加过去。实例化后获得一个对象实例,而对象实例主要有:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。    

    (1)对象头:包括了对象自身的运行时数据(GC年代标记、哈希码、锁状态标记等)+类型指针(对象所属的哪个类)

    (2)实例数据:代码中各字段内容

    (3)对齐填充:不是必然需要,主要是占位,保证对象大小是某个字节的整数倍。

    对象的内容存储是毋庸置疑的,但外部持有的引用指向的有两种可能:1、对象的实例内容;2、对象的句柄(其实就是一个指向实例内容的引用,比1类多了一层句柄而已)。前者优点是访问速度快,后者的优点是当对象需要频繁GC的时候对内存比较友好。

3、垃圾回收机制(GC)与内存分配管理

    从第一节数据区域的划分我们可以知道,PC、虚拟机栈、本地方法栈会随着线程的结束而回收,但是属于线程共有的堆和方法区则一直存在着,为了解放开发人员手动回收内存操作的负累,这也就造成了内部需要在运行时对不需要再继续存在的变量——垃圾进行回收。而垃圾回收最主要的难点就是,怎么分辨哪些对象是有用,而哪些对象是垃圾需要进行回收。因此提出了几种算法。在这之前,我们先对引用类别进行了解一下。

   3.1 引用的类型

    a、强引用:通常是new指令所返回,如果一个对象的强引用存在,就不会被GC所回收;

    b、软引用:SoftReference,平时不会对存在软引用的对象进行回收,但是当内存不足的时候(内存溢出),则会将只存在软引用的对象列入回收范围内;

    c、弱引用:WeakReference,每次GC的时候都会对弱引用进行回收,也因此每次弱引用的存活时间都是每次GC之间;

    d、虚引用:PhantomReference ,无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

   3.2 判断对象存活的算法

    a、引用计数法

    给对象添加一个引用计数器。但是难以解决循环引用问题。

循环引用问题截图

    b、可达性算法

    以'GC Roots'作为起点,将对象的引用陆续加入形成一颗树(如obj1与GC Roots相连,obj2与obj1相连)。当能够通过GC Roots到达某个节点,则该对象仍旧有用,否则视为垃圾。而可作为GC Roots的对象有这几种:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象。

    在可达性算法中,要把对象进行回收其实经过两次检查:首先将不可达的对象放入回收考虑范围(标记起来),之后再回收考虑范围的对象(被标记的对象)再进行finalize()的检查:没有覆盖finalize()方法,或者finalize() 方法已经被虚拟机调用过。将打上需要回收的标志。当然,每个对象的finalize()只会被系统调用一次,也就是说对象能够在finalize()方法中最后“拯救自己”一次,但也仅仅只有一次。

    3.3 对象回收的算法

    a、标记清除法:这个就是字面意思,将需要回收的对象进行标记,然后将标记的对象直接回收清除就行了。优点是简单,缺点就是效率不高,而且会造成内存碎片较多(内存碎片过多则可能会在下一次需要一个较大内存的对象因无法找到一个连续的内存触发新一次的GC);

    b、复制算法:将内存分成两块区域,每次只GC其中一块,然后将存活的对象复制到另一块上面去,轮流使用。由于每次GC一般都能回收大量的对象,也因此提出了比较优化的方案:将内存分成两种类型(Eden 、Survivor),3块区域(Eden :Survivor:Survivor=8:1:1),由此每一次运行时候将空闲出一块Survivor区域(约是所占内存区域的10%)备用。

    我们假设名称为E块、S1块,S2块,此次当GC的时候将存活的对象放到E块和S1块,S2块备用。当GC对E块和S1块进行清理回收的时候,将存活的对象复制放入S2块。这样E块和S1块空出来了,这时候将E块和S2块用于这次的运行产生的对象存储,S1块作为备用(Survivor依次备用)。

    优点:高效、实现也算简单,也不会产生内存碎片,适合生命周期比较短的(如新生代类)对象进行管理;

    缺点:除了增加复制的时间花销之外,还有就是必须浪费一部分内存(最初是要浪费一半的内存空间,即使是优化后也需要浪费10%的内存空间)。如果每次存活的对象(老年代类的对象)较多的时候,就加大了不小的负担。在优化方案中,当存活的对象超过10%的时候,仍旧需要通过分代算法进行补足(依赖于老生代进行分配担保,所以大对象直接进入老年代)。

    c、标记整理法:对存活的对象进行标记,然后移动到内存的一端,之后再对剩余的内存空间进行清理。优点就是每次都能够整理出比较连续完整的内存空间,缺点就是需要增加存活对象移动的成本。

     3.4 内存分配与回收策略

    由于对象的生命周期可能存在较大差距,一刀切地采用某种算法弊端就十分明显。因此引入归类的方法,将对象分代进行管理,在不同代类的对象管理方法各自采用不同的算法,其中分成:新生代(年轻代)、老生代(老年代)、永生代。

    (1)新生代:该内存区域主要用于存放新创建的对象,该区域的内存容量相对会较,垃圾回收也比较频繁。采用的算法为复制算法(8:1:1优化版)。

    (2)老生代:该内存区域主要存放JVM中生命周期较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),该区域内存相对较大,垃圾回收相对不频繁,采用的是标记整理法。由于老生代的回收速度会比较慢,所以要尽量避免老生代的垃圾回收。

    (3)永生代:该内存区域主要存放类定义、字节码和常量等很少会变更(或者说不会变更)的信息。该内存区域是由JVM在运行时根据应用程序使用的类来填充的。此外,Java SE类库和方法也存储在这里。如果JVM发现某些类不再需要,并且其他类可能需要空间,则这些类可能会被回收。

    在新生代中,如果存活的对象总量超过了内存的10%,需要老生代进行分担即:该情况下会对对象的年龄进行判断,根据年龄阈值将超过年龄的对象转移到老生代中。也因此老生代中的对象年龄不一定都是相同的,甚至一些挺“年轻的”对象也可能直接进入老生代——占据(或者说需要)大内存的对象。

    这里我们将内存分配与回收策略统计一下:

垃圾回收算法优缺点及和内存适用关系

4、线程与内存模型

    线程与进程的定义就不多说了,这里看看线程并发的安全必须依赖于以下几个方面:

    (1)原子性:提供了一种互斥访问,同一时刻只能有一个线程对它进行操作;

    (2)可见性:一个线程对主内存对修改可以及时的被其他线程观察到;

    (3)一致性:一个线程观察其他线程的指令行执行顺序,由于指令重新排序的存在,该观察结果一般杂乱无序;

    这里补充一个概念——指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化。所以在保证程序执行结果+代码顺序执行结果一致的前提下,处理器并不保证每一句代码执行顺序都会按照代码中的顺序来。比如int a=0; int b=1; a++;b=a+5;(将上述代码视为4行)中,第1、2句代码谁先执行并无任何影响,所以在处理器的时候可能会先执行第2句再执行第一句。所以指令重排可能会打乱代码的执行顺序,只要这个执行顺序不用想程序或者代码顺序的执行结果。 


线程、工作内存、主内存的三者交互关系

    上面就是线程、工作内存与主内存的基本交互,至于工作内存与主内存的操作包括lock/unlock、read、load、user、store、write等操作,具体的操作对象与目的就不多说了。这里补充一个线程并发的原则——happen - before(先行发生原则),它可以理解为:1、如果A先行发生于B,则A造成的事件影响(如修改了共享变量、调用方法等)对B是可见的;2、即使A、B存在happen - before关系,在保证执行结果或者代码顺序结果的前提下,不保证执行顺序(也就是说不一定一定要按照Happens-Before原则制定的顺序来执行,比如指令重排)

    4.1 volatile 关键字

    保证可见性不保证原子性

    4.2 synchronized 关键字

    同步块大家都比较熟悉,通过 synchronized 关键字来实现,所有加上synchronized 和 块语句,在多线程访问的时候,同一时刻只能有一个线程能够用,属于重量级的线程安全机制。synchronized 修饰的方法 或者 代码块。

相关文章

网友评论

    本文标题:Java 虚拟机简介

    本文链接:https://www.haomeiwen.com/subject/omvouctx.html