java虚拟机 时间_Java虚拟机

Java虚拟机

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

1 运行时数据区域

JVM在执行Java程序的过程中会它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,创建以及销毁时间,有的数据区域随着程序的启动而存在,有些则依赖用户线程的启动和结束而创建和销毁。JVM将所管理的内存区域划分为如下几个运行时数据区域,如下图所示:

1.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

每条线程都有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储,也就是线程私有的内存。

1.2 Java虚拟机栈

与线程计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,他的生命周期和线程相同,Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、int、short、long、float、double)、对象引用和返回类型。

1.3 本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈发挥的作用是非常相似的,他们之间的区别是虚拟机栈为虚拟机执行Java方法服务,而本地方法则为虚拟机使用到的Native方法服务。

1.4 Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。该区域唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

1.5 方法区

方法区(Method Area)与Java堆一样,也是被所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

1.6 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期产生的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

2 HotSpot虚拟机对象

2.1 对象创建过程

1)加载检查

当虚拟机遇到new指令时,首先是检查这个指令的参数是否能在常量池中定位到一个类的引用符号,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有需要先进行响应的类加载过程。

2)分配内存空间

对象所需的内存大小是类加载完成后便可以确定,为对象分配空间的任务就是把一块确定大小的内存从Java堆中划分出来。假设Java堆中的内存是绝对规整的,所用过的内存都放在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅把指针向空闲那边挪动一段与对象大小相同的距离,这种分配方式成为指针碰撞(Bump the Pointer)。如果Java堆内存不表示规整的,与使用的内存和空闲空间的内存是相互交错的,那就没办法进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中取出一块足够大的空间划分给对象实例,并更新列表的记录,这种分配方式称为空闲列表(Free List)。选择哪种方式取决于Java堆内存是否规整,而Java堆内存是否规整又取决于采用的垃圾收集器是否带有压缩整理功能决定。

2.2对象的内存布局

对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对象填充(Padding)。

对象头包含两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁等。另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

对齐填充并不是必然存在的,也没有特别的含义,仅仅起到占位符的作用。

2.3 对象的访问定位

建立对象是为了使用,Java程序需要通过栈上的引用数据来操作堆上的具体对象。访问对象的方式有两种,句柄和直接指针。

使用句柄的话,Java堆将划分一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如下图所示:

如果使用直接指针,那么Java对象的布局就必须考虑如何放置访问类型数据的相关信息,而引用中存储的直接就是对象地址。如图所示:

两种访问方式各有好处,使用句柄访问最大好处就是引用中存储的是稳定的句柄地址,在对象被移动时只会改变句柄汇总实例指针,引用不需要改;使用直接指针访问最大的好处是速度快。

3垃圾回收

垃圾回收就是回收“已死”的对象。需要考虑三个问题,如何判断对象已死,什么时候回收,以及如何回收。

3.1 判断已死对象

目前判断对象已死有两种算法:引用计数法和可达性分析算法。

引用计数法:给对象中添加一个引用计数器,当有一个地方引用它时,计数器就加1;引用失效时,计数器就减1;当引用计数器为0时,对应的对象就是已死对象。这种算法效率高,但也存在问题,当两个对象相互引用时,他们也就无法被回收了。

可达性分析算法:基本思路就是通过一系列的被称为“GC Roots”的对象作为起始点,从这个节点开始向下搜索,搜索走过的路线为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即已死。如图所示,对象5、6、7虽热相互关联,但是他们到GC Roots是不可达的,所以他们被判为已死。

以下几种对象可作为GC Roots

虚拟机栈(栈帧中的本地变量表)中的引用对象;

方法区中类静态属性引用的对象;

方法区中常量引用的对象;

本地方法栈中引用的对象。

3.2 回收算法

1)标记-清除法

标记-清除(Mark-Sweep)算法,如同名字一样,算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法有两个不足之处:一个是效率问题,标记和清除两个过程效率都不高;另外是空间问题,标记清除后会产生大量不连续的内存碎片。

2)复制算法

复制(Copying)算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用了,就将还存活着的对象复制到另外一块上,然后再把已使用的内存空间一次性清理掉。这样是解决了标记-清除算法的效率和内存碎片化的问题,但内存使用一半,闲置一半,把内存缩小为原来一半了,代价太大。

研究表明,新生代的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的比例大小是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(10%+80%),只有10%的内存被闲置。当然98%的对象可回收只是一般场景下的数据,我们买办法保证每次回收都只有不超过10%的对象存活,当Survivor不够用时,需要依赖其他内存(老年代)进行分配担保。

3)标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间,就需要有额外空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用复制算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一段移动,然后直接清理掉端边界以外的内存。

4)分代收集算法

分代收集(Generational Collection)算法并没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般把内存分为新生代和老年代,然后根据各个年代的特点采用合适的收集算法。

3.3 回收时间

一般情况下,在每次分配内存空间时都会检查剩余可用空间是否足够,如果没有足够的可用空间,就会触发GC回收操作。详细请看【4 内存分配策略和回收策略】。

4内存分配和回收策略

4.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配是,虚拟机将触发一次Minor GC。

4.2大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,比如说很长的字符串、数组等。这样做的目的是避免新生代发生大量的内存复制。虚拟机提供了-XX:PretenureSizeThreshold参数,让大于这个值得对象直接进入老年代分配。

4.3 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并且经历过第一次Minor GC后仍然存活,并且能被Survior容纳的话,将被移动到Survivor空间中,并且对象年龄设置为1,之后每一次Minor GC后还存活年龄就加1,到达一定年龄(默认是15,也可以通过XX:MaxTenuringthreshold设置)后将被晋升到老年代中。

4.4 动态对象年龄判断

为了更好适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到MaxTenuringthreshold才能晋升到老年代,如果Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

4.5 空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC可以确保安全;如果不成功,则虚拟机将会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将会尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。(大部分情况下HandlePromotionFailure是打开的,避免Full GC过于频繁)。

5虚拟机类加载机制

类从被加载到虚拟机内存中开始包含7个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、和卸载(Unloading),其中前5个阶段是必须顺序完成的,如图所示:

5.1 触发类初始化

在以下几种情况下会触发类的初始化:

1)遇到new、getstatic、putstatic或invokestatic四个字节码指令;

2)使用java.lang.reflect包的方法对类进行反射调用;

3)当初始化一个类的时候,如果发现其父类未被初始化,则需要优先加载并且初始化其父类;

4)虚拟机启动时,main方法所在的类会被优先初始化

接口的初始化与类的初始化基本类似,但也有些差异。当一个接口被初始化时,并不要求其父接口全部完成了初始化,只有在使用到父接口时才会被初始化。

5.2 类加载过程

类的加载过程也就是加载、验证、准备、解析和初始化。

1)加载

加载主要完成3件事,第一,通过类的全限定名来获取定义此类的二进制字节流;第二,将字节流所代表的的静态存储结构转化为方法区的运行时数据结构;第三,在内存中生成一个代表这个累的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2)验证

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码攻击。验证包含这几个部分,文件格式验证、元数据验证、字节码验证、符号因引用验证。

3)准备

准备阶段是正式为类变量分配内存并设置类变量(注意是类变量,static修饰的变量)初始化的阶段,这些变量所使用的内存都将在方法区中进行分配。

public static int value = 123;

这里需要注意类变量value在准备阶段过后的初始值不是123,而是0,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令时程序被编译后,存放在构造器()方法中的,所以赋值为123的动作将做初始化夹断才会执行。

4)解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。这里区分一下符号引用和直接引用:符号引用(Symbolic References)是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用和虚拟机实现的内存布局无关,引用目标不一定已经加载到内存中。直接引用(Direct References)可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用和虚拟机的内存布局相关,有了直接引用,那引用的目标必定已经在内存中存在。

5)初始化

初始化阶段是执行()方法的过程,()方法是由编译器自动收集类中的所有类变量和静态代码块语句合并产生的,收集的顺序由语句在源文件中出现的顺序决定,静态语句块中只能访问定义在它之前的变量,定义在它之后的变量,它可以进行赋值,但是不能访问,如图所示

()方法与类的构造器函数不同,他不需要显示地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经被执行完毕。因此虚拟机中第一个被执行的()方法的类一定是java.lang.Object。

5.3 类加载器

实现“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块被称为“类加载器”。

类加载器虽然只用于实现类的加载动作,但它在java程序中起到的作用却远不限于类的加载阶段。对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立在虚拟机中的唯一性。通俗一点说,比较两个类是否“相等”,只有在这两个类都是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同。那这两个类就必定不相等。(这里说的“相等”包括Class对象的equals方法等)

三种类加载器,启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension ClassLoader)和应用类加载器(Application ClassLoader),启动类加载器是C++实现的,是虚拟机自身的一部分;其他加载器是Java实现的,独立于虚拟机之外,并且全部继承java.lang.ClassLoader。启动类加载器负责将放在\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。三种类加载器之间的关系如图所示:

上图展示类加载器之间的这种层次关系被称为加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动器加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。使用双亲委派模型来组织类加载器之间的关系的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,他存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派得处于模型顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,系统中将会出现多个不同的Object类,Java类型体系中最基本的行为也就无法保证。

6 java内存模型

6.1高速缓存

计算机执行运算任务并不只是处理器进行计算操作,其中免不了与内存的交互,比如读取运算数据、存储运算结果等I/O操作。由于计算机存储设备与处理器的运算速度存在几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当预算结束后再从缓存同步到内存中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好解决了处理器与内存的速度矛盾,但也带入了一个新的问题:缓存一致性(Cache Coherence)。在多核处理器系统中,每个处理器都有自己的高速缓存,而他们又共享统一主内存,如图所示,当多个处理器的运算任务都涉及同一内存区域时,将可能导致各自的缓存数据不一致,为了解决这一问题就需要处理器在访问缓存时都需要遵循一些协议,在读写时根据协议来进行操作。而即将引入的“内存模型”(Memory Model)可以理解为在特定协议下,对特定内存或高速缓存进行读写的过程抽象。

6.1 主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与编程时所说的变量有所区别,它包含了实例字段、静态字段和构成数组对象的元素,不包含局部变量与方法参数,因为后者是线程私有的。Java内存模型规定了所有变量都存储在主内存中,但每条线程都有自己的工作内存(Working Memory,可以与前面所说的处理器高速缓存类比,虚拟机为了获取更好的运算速度,可能会将工作内存优先存储于高速缓存中,因为程序运行时主要访问读写的是工作内存),线程中保存了呗线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来完成,线程、主内存、工作内存三者之间的交互关系如图所示:

6.2 内存间的交互

Java内存模型定义了8种操作来完成主内存与工作内存之间变量的传递细节,虚拟机必须保证这8种操作都是原子的、不可再分的(对于double和long类型的变量有所不同,这里不再细究,有想了解的请留言),具体操作如下:

1)lock(锁定):作用于内存变量,把一个变量标识为线程独占状态;

2)unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;

3)read(读取):作用于主内存的变量,把变量的值从主内存传输到线程的工作线程;

4)load(载入):作用于工作线程,把read操作从主内存中得到的变量值放入工作内存的变量副本中;

5)use(使用):作用于工作内存,把工作内存中的变量的值传递给执行引擎;

6)assign(赋值):作用于工作内存的变量,把从执行引擎接收到的值赋给工作内存的变量;

7)store(存储):作用于工作内存的变量,把工作内存中的变量的值传递到主内存;

8)write(写):作用于主内存变量,把store操作从工作内存中得到的变量的值放入主内内存变量中。

Java内存模型还规定了上述8中操作必须满足如下规则:

1)不允许read和load、store和write操作之一独立出现;

2)不允许线程丢弃它的最近assign操作,即进行了assign操作后必须同步到主内存中;

3)不允许线程把没有发生过任何assign操作的数据同步到主内存;

4)一个新的变量只能在主内存中产生;

5)一个变量同一时刻只能允许一个线程进行lock操作,但lock操作可以被同一线程多次执行,多次执行lock后需要进行同样次数的unlock操作变量才会被解锁;

6)如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要从新执行load或assign操作初始化变量的值;

7)不能对一个未被lock操作的变量进行unlock操作,不能unlock被其他线程lock的变量;

8)在一个变量进行Unlock之前,必须先把此变量同步回主内存中;

6.3 volatile特殊规则

关键字volatile可以说是虚拟机提供的最轻量级的同步机制。当一个变量被volatile修饰后,它将具备两种特性,第一是保证此变量对所有线程的可见性,即当一个线程修改了此变量的值,新值对于其他线程来说是可以立即知道的,而普通变量则需要通过主内存来完成。

在面试的时候,面试官经常会问一个问题:“volatile变量对所有线程是立即可见的,对volatile变量所有的写操作能立即反应到其他线程之中,换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的,这句话对不对?”。这句话的论据部分并没有错,但是并不能推出“基于volatile变量的运算在并发下是安全的”这个结论,volatile变量在各个线程中不存在一致性问题,但是java里面的操作运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值