JVM(一)jvm运行结构简介

一、JVM和Java体系结构

Java:跨平台语言,一处编译多处运行

Jvm:跨语言的平台。(只关注符合规则的字节码文件,可以使用多种语言编写转化为符合规则的字节码文件就能被jvm解释运行)

如下图: 

Jvm是运行在操作系统之上,和硬件没有直接的交互:

 虚拟机在软件层面上分为程序虚拟机(jvm等)和系统虚拟机(vm、安卓模拟器)

Java虚拟机:执行jvm二进制字节码(只要符合jvm字节码规范的即可)(未必由java语言编译而来)的虚拟计算机。用于装载字节码到内部、解释/编译对应平台(操作系统)的机器指令。java虚拟机中执行的指令称为字节码指令。

而我们常说的jdk中包含jre,而jre又包含jvm。

jvm中的编译器分为前端编译器(将.java文件编译为.class文件)和后端编译器(存在jvm中的执行引擎中,也就是其中的jit即时编译器)

这里,我们可以对jvm结构图做进一步的细化:

 其中解释器、jit编译器(编译成机器指令,编译后会缓存起来以便后面使用)、垃圾回收器都是属于执行引擎。

类加载器、字节码校验器属于类加载子系统。

Java编译器输入的指令流基于两种架构,一种是基于栈的指令架构(基本都用这个),另一种则是基于寄存器的指令架构。两者特点:(hotspot vm 就是基于栈的指令架构)

栈式架构可以理解为每执行一条指令就将其指令做入栈的操作,执行完就做出栈的操作。

所谓的一、二、三地址、零地址,可以理解为就是我们要操作一个数例如2,如果是零地址则表示我们不需要给操作数2分配地址再获取2进行操作,而是直接拿操作数2入栈做操作。而一地址等就是要给操作数做地址分配的操作。

栈式的一个指令为8字节、寄存器的是16个字节。所以相对于同一个代码,前者指令数多,后者指令数少。

栈式结构:容易实现、跨平台性好(和硬件依赖度不高、可移植性更好)、指令集小、指令多、执行性能低于寄存器。

JVM的生命周期

  1. Jvm的启动:通过引导类加载器(bootstrap classloader)创建一个初始类(initial class)来完成的。(父类先于子类加载)
  2. Jvm的执行:即一个java进程。
  3. Jvm的退出:结束java进程。可以正常退出、程序发生异常退出、操作系统出现异常退出、某线程调用Runtime类的halt方法或者System类的exit方法(且java安全管理器也允许这次exit或者halt操作)、JNI卸载java虚拟机。

(Runtime对象:运行时数据区对应的环境对象,单例对象(饿汉式))

解释器和即时编译器是可以单独存在执行引擎中(即要将字节码文件转换为机器码,可以用解释器,也可以只用jit即时编译器(效率较高)。只是hotspot结合两者共同进行使用(大部分都是两者结合使用),当然也有一些只存在其一的虚拟机(例如sun  classic  vm就只有解释器))(具体说明后面分析)

(单独使用jit(jit执行效率比解释器快),会使得程序启动时间长。单独使用解释器(解释器响应时间比jit快),会使得执行效率慢)。所以一般两者搭配使用。

Hotspot:服务端、桌面应用、嵌入式等。三大热门虚拟机之一。

JRockit:专注于服务端,速度最快的jvm。三大热门虚拟机之一。

J9:服务端、桌面应用、嵌入式等。三大热门虚拟机之一。

到这里我们可以总结上面的内容得出一张图:

接下来我们会围绕类加载子系统、运行时数据区、执行引擎进行详细的分析。 

二、类加载子系统

上面对于jvm运行结构做了个大概的流程说明,这里我们先对于类加载子系统进行详细的分析

类加载子系统中的主要组成看下面的图:

 可以从图中看出类加载器的运行主要分为三个大的阶段,分别为加载、链接、初始化。

类加载器子系统会加载class文件(class文件在文件开头有特定文件标识),类加载器只负责class文件的加载,不会判断这些class文件是否能运行(运行由执行引擎决定)

加载完后会将class文件中的类信息、常量池信息存放到运行时数据区中的方法区中(可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射))。

一、加载阶段

1、将class文件的类转换为二进制字节流,通过类的全限定名定义区别其二进制字节流。

2、将字节流转换为方法区的数据结构

3、在内存中生成一个代表该类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。

二、链接阶段

1、验证:确保之前加载的信息符合当前虚拟机的规范,主要分为文件格式验证、元数据验证、字节码验证、符号引用验证

2、准备:为类变量(注意是类变量不是实例变量)分配内存并设置默认的初始值(即零,后面初始化阶段再进行真正值的赋予),这路不包含final static修饰的(编译时期就已经分配了)(类变量是分配到方法区的,而实例变量会随着对象分配到堆中)

3、解析:将常量池的符号引用转换为直接引用,符号引用就是一组符号来描述所引用的目标(而非直接指向目标地址),而直接引用则是直接指向目标地址的指针。

三、初始化阶段

指向类构造方法<clint>(),该方法作用是javac编译器自动收集类的的所有类变量的赋值+静态代码。注意<clint>()这个方法不同于类的构造器<init>()。子类的<clint>()方法执行前必须等待父类的<clint>()执行完。


讲完类加载子系统的大概流程,下面需要讲讲类加载器的分类、双亲委派机制

一、类加载器的分类

jvm支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)、自定义类加载器(User-Defined ClassLoader)

java虚拟机规范中,将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。

1、启动类加载器(引导类加载器,Bootstrap ClassLoader)

使用C/C++实现,嵌套于JVM内部,用来加载java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),且出于安全考虑,只加载包名为java、javax、sun等开头的类。

不继承于ClassLoader类,没有父加载器。

是扩展类加载器、应用程序类加载器的父类加载器。

2、扩展类加载器(Extension ClassLoader)

java语言实现,派生于ClassLoader类,加载java.ext.dirs系统属性指定的目录类库,或加载jre/lib/ext目录的类库。

3、应用程序类加载器(系统类加载器,ApplicationLoader)

java实现,派生于ClassLoader类,加载classpath系统属性指定的目录。

该类加载器是程序中默认的类加载器,一般来说,java应用的类都是由他来加载的。

可以通过ClassLoader#getSystemClassLoader()方法来获取该类加载器。

4、用户自定义类加载器

用户定制自己的类加载器

为什么要自定义类加载器:隔离加载类、修改类加载方式、扩展加载源、防止源码泄露。

自定类加载器步骤:

1)、通过继承抽象类java.lang.ClassLoader,jdk1.2之前一般重写loadClass方法,不过jdk1.2之后不推荐这样而是将自定义的类加载逻辑写到findClass()方法中

2)、如果自定义类加载器时,没有太复杂的需求,可以直接继承URLClassLoader类,这样就可以不要去编写findClass方法+获取字节码流方法,这样可以使得编写更加简单。

可以看到URLClassLoader已经有实现了的findClass方法了 

 

 二、双亲委派机制

java虚拟机对class文件采用的是按需加载的方式,即需要该类时才会对它的class文件加载到内存中生成class对象,且其加载模式采用双亲委派机制。

 双亲委派机制的优点:可以避免类的重复加载;保护程序安全,防止核心API被随意篡改。

现在有jdk自带的java.lang.String类,如果我们自己建一个java.lang.String,那我们在new String()的时候还是会用到jdk自带的String,因为我们自定义的java.lang.String是没有被加载到的,只加载了jdk自带的String。

沙箱安全机制:
java中的安全模型(沙箱机制)_改变ing-CSDN博客_沙箱安全机制

在JVM中看两个class对象是否是相同的看两个条件:
1)类的完整类名是否一致

2)、各自的类加载器对象ClassLoader是否一致

三、运行时数据区

结构、线程

 

 运行时数据区随着虚拟机启动而被创建,随着虚拟机的退出而销毁。

 每个JVM中只有一个Runtime实例(运行时环境对象,可以类比上图的运行时数据区)

Hotspot JVM中每个线程都对应操作系统的本地线程。java线程创建则对应操作系统的本地线程同时创建,销毁也同时回收。

造Hotspot JVM中主要有下面几个线程:

一、程序计数器

用来存储指令相关信息。

 作用:pc寄存器(程序计数器)用来存储指向下一条指令的地址,即将要执行的指令代码。然后由执行引擎区读取下一条指令去执行。

该区域是很小的空间,运行速度也很快。每个线程都会对应一个程序计数器,是线程私有的,生命周期和线程的生命周期一致。每个线程的程序不仅会存储下一条指令,而且会存储当前方法的指令地址(会有线程切换,存储当前方法状态可以防止线程切换后恢复执行时执行状态丢失)

分支、循环、跳转、异常处理等都需要依赖这个程序计数器完成。字节码解释器工作时也是根据这个计数器来获取下一条需要执行的字节码指令。

该区域是唯一没有OutOfMemoryError内存溢出的情况。

可以从上图中看到这个方法最后会转换为右边的指令,程序技术器就是存储下个执行指令的地址 。

 

二、栈

概述

java的指令是基于栈来涉及的,优点是跨平台、指令集小、编译容易实现,缺点是性能下降、实现同样的功能需要更多的指令。

栈是运行时的单位,而堆是存储的单位。栈是线程私有的,生命周期和线程一致。其作用主要是保存方法的局部变量、部分结果、方法的调用和返回。

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

java栈的大小是可以固定或者动态改变的。当设置为固定大小时,如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量则会抛出StackOverFlowError异常。

如果设置动态扩展,如果在尝试扩展时无法申请到内存,此时就会抛出OutOfMemoryError异常。

例如我们递归调用方法就会产生栈溢出异常。

 我们可以通过-Xss 来设置线程的最大栈空间。

每个线程对应一个栈,栈中的数据都是一个栈帧的格式存在,线程上的每个方法对应一个栈帧,栈帧是内存区块,维系着方法执行过程中的各种数据信息。

可以看成一个大的栈(java栈)中包含有多个小的栈(栈帧)。

 线程之间的栈帧是不互相引用的,即线程A的栈帧不能引用线程B的栈帧,只能进行栈帧的切换(栈帧进出虚拟机栈)。如果当前方法A调用了其他方法B,在方法B返回之际会传回其执行结果给前一个调用的方法A栈帧。

java方法有两种返回的方式,一种是正常返回,使用return指令;一种是异常抛出。不管哪种方式都会导致栈帧被弹出。

 

 一、局部变量表

也称局部变量数组或本地变量表。实际上就是一个数组,用来存储方法参数、方法内的局部变量。

局部变量表的容量大小是在编译期就要确定下来的。在执行方法时,虚拟机通过局部变量表完成参数值到参数变量列表的传递过程。存放的参数值从局部变量数组的index 0 开始,局部变量表中基本的存储单位是slot(变量槽)。

局部变量表可以存储8种基本数据类型、引用类型等等,32位内的类型占用一个slot,64位占两个slot。

在JVM中会堆变量表的每一个slot分配一个访问索引,然后根据这个访问索引去访问指定的变量。

 栈帧中的局部变量表中的槽位是可以重用的,即过期的变量其槽位可以被其他变量使用。

变量表会有两次初始化的时机:一是在准备阶段,此时会堆变量设置为零;第二次是初始化阶段,此时会根据真实的值进行赋值。而要注意的是类变量表和局部变量表是不一样的,局部变量表必须要显示的初始化,否则无法使用(所以说方法中的局部变量要进行赋值)

二、操作数栈

也称表达式栈,在方法的执行过程中,根据字节码指令,往栈中写入或提取数据(出入栈)

 注意用来保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

操作数栈的最大深度在编译器就定义好了,操作数栈简单点讲就是通过出入栈执行指令(例如对a、b进行出入栈,然后进行求和操作,最后将结果暂时存到操作数栈中)。

如果被调用的方法带有返回值时,其返回直会被压入当前栈帧的操作数栈中,并更新pc寄存器的下一条指令。

(前面我们说过java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈)

我们可以通过一个例子来看看操作数栈是怎么运行的?

 

 

 

 

 栈顶缓存技术(Top-of-Stack-cashing):

由于多个指令可能涉及多次出入栈,导致内存多次读写操作,为了提高效率,jvm将栈顶元素全部缓冲在物理cpu的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

三、动态链接

java源文件被编译到字节码文件中时,所以的变量和方法引用都是符号引用的方式保存在class文件的常量池中。而动态链接的作用就是将这些符号引用转换为调用方法的直接引用(指向运行时常量池的方法引用,即方法区)。

 方法的调用:

jvm中将符号引用转换为直接引用于方法绑定的机制有关:

1)、静态链接:当字节码文件被转载进jvm时,如果被调用的方法在编译器可知且运行期保存不变,此时调用方法的符号引用就会转换为直接引用。

2)、动态链接:编译器无法确定,只能在运行期间将符号引用转换为直接引用。

两种机制对应的绑定(绑定就是符号引用转换为直接引用的过程)

1)、早期绑定:对应静态链接

2)、晚期绑定:对应动态链接

java中 存在虚方法和非虚方法,如果方法编译器确定且运行时不变,则称为非虚方法,静态方法、私有方法、final方法、实例构造、父类方法都是非虚方法,其他都是虚方法。

 

 方法重写:先找子类实现,不行就往上找。

 虚方法表:存放虚方法,使用索引进行代替查找,每个类都有一个虚方法表,表中存放着虚方法的实际入口,在链接阶段虚方法表会被创建并初始化。(每次动态分派的过程需要重写在类的方法元数据中查找方法,为了提高性能,所以就建立一个虚方法表以便查找)

四、方法返回地址

一个方法的结束,会有两种方式:一是正常执行完成,二是异常退出。

正常执行完的情况下此时这个方法返回地址存放的就是调用该方法的程序计数器的值(即存放下一条指令的地址),异常退出的清理返回地址就会根据异常表来确定(此时就不是由栈帧来保存相应信息)(正常退出会给上层调用者返回值,异常退出不会返回结果)

本质上,方法的结束就是当前栈帧出栈的过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈的操作数栈、设置程序计数器的值等。

 

 五、附加信息

在栈帧中,其实还存在一些附加信息, 例如对程序调试提供支持的信息

三、本地方法栈

 本地方法:native 方法,是c、c++实现的接口函数,效率高,可以提供给多种语言调用。

 本地方法栈:和java虚拟机栈类似,不过本地方法栈管理的是本地方法。线程私有的,允许固定大小和可动态扩展。

 

四、堆

一、概述

一个jvm实例中只存在一个堆内存,堆在jvm启动时被创建,其大小也是这个时候就确定的。

堆内存是线程共享的,基本的对象实例都是在这里分配内存。

栈的栈帧存放着对象的引用(可以理解为存放的就是指针),这个引用就指向堆中的真实的数组或者对象。

在方法结束后,堆中的对象不会马上被清除,会在后面被垃圾回收。堆是gc(垃圾回收器)执行垃圾回收的重点区域。

可以从图中看到,java栈存的是指针,最终指向堆中的对象实例,然后再由对象实例 去方法去获取相应的类、方法信息。

 

可以从上面这个图中可以看到,在堆空间中存在年轻代(年轻代又分Eden、s0、s1区)、老年代 ,且不管是7的永久代还8的元空间,实际上就是方法区。


设置堆大小及各个区的大小分配

-Xms:表示堆的起始内存。-Xmx:表示堆的最大内存。

一旦超过-Xmx则会抛出OutOfMemoryError。

通常情况下会将这两个参数配置为相同的值,这样垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能。

默认情况下:初始内存大小:物理电脑内存大小/64;最大内存大小:物理电脑内存大小/4


二、年轻代、老年代

 

  在jvm堆内存分布中,主要分代有年轻代、老年代(永久代一般指方法区)

在创建对象时一般会创建两种对象:

1)、存活时间短:一般时类中的变量对象.   

2)、存活时间长:一般是创建静态变量/大对象/多次gc不被回收的对象(线程共享)

为了针对这个现象,JVM的堆内存采用了分代的模型,存活时间短的对象一般在年轻代,经过多次小gc的一般放在老年代。原因如下:

(1)默认区域大小比例1:2

因为年轻代的对象数目多、且存活时间短,所以需要经常去回收这个区域的无用对象。所以在对年轻代和老年代进行划分的时候是1:4进行划分的,这样年轻代因为内存比较小就会经常触发MinorGC,从而达到经常对年轻代进行回收对象的效果。

而老年代是存放那些在年轻代gc多次还没有被回收的对象,这些对象的存活时间长,如果经常回收这些数据每次只能回收少量对象,这样效率就会低下,所以将老年代的内存分多些就可以多放一些对象,这样相当于减少了FullGC的次数了

(2)使用不同算法进行回收

年轻代要经常进行回收,而老年代需要回收的比较少,所以对不同的年代采用不同的回收算法。(当然也有年轻代和老年代都shi'y)

上面对于堆的年代分类进行简单说明,至于各个年代使用的是什么算法,具体的信息下面说明


永久代:永久代不属于堆内存,一般指的是方法区(存放类信息、静态常量,常量池)。那方法区什么时候进行垃圾回收?(JVM垃圾回收也针对方法区)
——首先该类的所有实例对象都已经从Java堆内存里被回收,其次加载这个类的ClassLoader已经被回收,最后,对该类的Class对象没有任何引用(1、对应类加载器被回收 2、对应的实例对象在堆中被回收 3、class对象没有被引用)


三、对象怎么从年轻代到老年代的?

一般的对象放置都是先放进新生代,然后经过15MinorGC后还未被回收的对象会被放进老年代,只有少部分特殊的对象(特别大的超大对象)会不经过年轻代直接进入老年代中

下面是堆中对于年轻代和老年代的划分:

新生区

分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的类都是在伊甸区被new出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存 0区。若幸存 0区也满了,再对该区进行垃圾回收,然后移动到1区。此后eden区来的对象存到1区,当1区满后则进行gc然后将对象放到0区,依次循环到达某个次数(默认15次)时则会将仍然存活的对象移动到老年区。(复制算法)

老年区

新生区经过多次GC仍然存活的对象移动到老年区。若老年区也满了,那么这个时候将产生MajorGC(FullGC),进行老年区的内存清理。若老年区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”


讲到这里,先不急着讲年轻代、老年代的gc过程,回收算法、垃圾回收器等知识,我们先来上手实践一下怎么配置JVM参数。看看怎么给整个堆、方法区、栈、堆中各个年代的内存大小进行配置。

三、配置JVM参数

在JVM内存分配中,有几个参数是比较核心的,如下所示。

-Xms:Java堆内存的大小 

 -Xmx:Java堆内存的最大大小 

  -Xmn:Java堆内存中的新生代大小,扣除新生代剩下的就是老年代的内存大小了 

  -XX:PermSize:永久代大小   

 -XX:MaxPermSize:永久代最大大小 

  -Xss:每个线程的栈内存大小

这些参数的设置对应下面JVM内存大小分布

1、-Xms和-Xmx,分别用于设置Java堆内存的刚开始的大小,以及允许扩张到的最大大小。对于这对参数,通常来说,都会设置为完全一样的大小

2、-Xmn,这个参数也是很常见的,他用来设置Java堆内存中的新生代的大小,然后扣除新生代大小之后的剩余内存就是给老年代的内存大小。

3、-XX:PermSize和-XX:MaxPermSize,分别限定了永久代大小和永久代的最大大小,通常这两个数值也是设置为一样的。如果是JDK 1.8以后的版本,那么这俩参数被替换为了-XX:MetaspaceSize和-XX:MaxMetaspaceSize

4、-Xss,这个参数限定了每个线程的栈内存大小


知道了JVM参数的大概意思,那么现实中我们怎么配置JVM的启动参数?

1、开发阶段:idea中

然后应用启动即可。

2、部署阶段

可以使用java -Xms512M -Xmx512M -Xmn256M -Xss1M -XX:PermSize=128M -XX:MaxPermSize=128M -jar App.jar 进行部署。

(自己看了下公司的微服务部署:java -Xmx1g -jar ***.jar   java -Xmx512m -jar ***.jar)

获取JVM默认的参数设置

然后启动JVM,就会打印相应的参数信息


上面对于对象的分配做了一个简单的说明,下面我们来讲讲Minor GC、Major GC、Full GC?
JVM中大部分都是对新生代进行回收,整个垃圾收集又分为两大类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)。

1、部分收集

1)、新生代收集:Minor GC,又称Young GC。

2)、老年代收集:Major GC,又称Old GC。

目前只有CMS会有单独对老年代的收集行为,要注意Major GC和Full GC不同,一个老年代回收,一个是整堆回收。

3)、混合收集:Mixed GC。收集整个年轻代+部分老年代。

目前只有G1 GC有这种行为。

2、整堆收集(Full GC)

收集整个java堆和方法区的垃圾收集。

————————————————————

新生代GC(Minor GC/Young GC)的触发机制:年轻代的Eden空间不足。Minor GC会引发STW,暂停其他用户的线程,等到垃圾回收结束,用户线程才恢复运行。

老年代GC(Major GC/Old GC)触发机制:如果老年代空间不足时,先尝试触发Young GC,如果之后空间还不足,则触发Major GC。Major GC速度比Young GC慢10倍以上,STW实际更长。如果Major GC后内存还不足,则报OOM。

Full GC触发机制:


为什么要对堆进行分代?

————不同对象的生命周期不同,大部分的对象是临时对象、生命周期比较短。分代收集对于可以对“朝生夕死”的对象先进行回收,空出大量的空间。这样就不会出现全堆扫描,最后导致性能下降。


为对象分配内存:Tlab

什么是tlab?
————从内存模型的角度看,Eden区中JVM为每个线程分配了一个私有缓冲区域。多线程同时分配内存时,使用tlab可以避免一些非线程安全问题,还可以提升内存分配的吞吐量,因此将这种内存分配方式称为——快速分配策略。

 

 对象分配过程:

 可以从图中看出,分配对象内存时,每个线程有个tlab缓冲区,tlab是线程私有的,不存在线程安全问题,如果tlab缓冲区申请不到则会走我们之前的堆内存分配流程,此时需要自己利用锁机制去保证线程安全的问题。


对于一些参数设置的总结:

 

 


堆是分配对象内存的唯一选择?

随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术会使得所有对象分配到堆上逐渐变得不那么绝对了。

在java虚拟机中,有一个普遍的常识就是对象是在堆中进行分配内存的,但是有种特殊的情况,就是如果经过逃逸分析后发现,一个对象没有逃逸出方法的话,那么就可能被优化为栈上分配内存,这样就不需要进行垃圾回收了(这也是最常见的堆外存储技术)

(例如基于OpenJDK深度定制的TaoBaoVM,其中的GCIH技术实现off-heap,将生命周期长的java对象从堆往堆外内存移动,此时垃圾回收器就不会去管理GCIH内部的java对象,从而降低GC的回收频率和提升GC的回收效率的目的)

如果想要将堆上的对象分配到栈,需要使用逃逸分析技术。逃逸分析技术是一种可以减少java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

可以通过逃逸分析算法,hotspot编译器可以分析出一个对象的引用范围从而决定这个对象是否分配到堆上。

逃逸分析就是分析对象动态作用率:

1)、当一个对象在方法中被定义,且该对象只在方法内部使用(此时只有这个栈帧使用,所有不存在线程安全问题),则认为没有发生逃逸。

2)、对象在方法中被定义后,之后被外部方法引用(例如作为调用参数传递到其他地方中),则认为发生逃逸。
例如:

此时v对象实例是没有发生逃逸的对象,则可以将其的内存分配到栈上,随着方法执行结束,栈空间就被移除。

 有时我们也可以通过修改代码达到方法不逃逸:

 jdk6之后,hotspot就默认开启了逃逸分析。

所以在开发中可以使用局部变量的,就不要使用在方法外定义。(注意如果是逃逸方法,其对象可能是栈上分配,而不是一定)

使用了逃逸分析后,编译器会对代码做如下优化:

(这里的分离对象或标量替换中的说明,存储到cpu寄存器是相对所有语言来说,如果单对java语言就是存储在栈空间)

 JIT编译器在编译期间会根据逃逸分析的结果,发现如果一个对象在没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,执行完成后栈空间回收,局部对象也会被回收,这样就不会进行垃圾回收了。

常见的栈上分配的常见:分别是给成员变量赋值、方法返回值、实例引用传递。


逃逸分析不仅仅能够应用在栈上分配,还在锁消除、标量替换上有重要作用

(1)代码优化值同步省略(锁消除):
————在动态编译同步块时,JIT编译器可以借助逃逸分析来判断这个同步块是否只能被一个线程访问,是则会取消对这部分代码的同步(也就是锁消除)

 (2)代码优化之标量替换

标量:指一个不能分解为更小数据的数据。java中的原始数据类型就是标量。

聚合量:和标量相反,可以分解的数据叫做聚合量。java中的对象就是聚合量(因为可以分解为标量或者其他聚合量)

标量替换:在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问(没有方法逃逸),那么经过JIT优化后,就会把这个对象拆解为多个有若干标量的成员变量来替换,这个过程就是标量替换。

例如下面的代码:

 

 此时标量替换后就只使用了栈空间存储x、y两个局部变量于局部变量表中,不会用到堆空间(所以说这也为栈上分配提供了一个很好的基础)

 

 


逃逸分析并不成熟:

五、方法区

这里我们需要讲的就是线程共享的元空间,也就是方法区。

对于上面的一句代码,我们就可以看到,person这个指针是存在栈中的,然后栈指向堆中真正的对象实例 (堆中存放的是对象实例数据+对象类型指针),然后堆根据对象类型指针指向方法区的对象类型数据。(例如现在Person类有int age;String name;此时age =10,name = haha这些实例数据(10、haha)存在堆中,而int age、String name这些类信息数据就存在方法区区中)。


方法区和java堆一样,是线程共享的。JVM启动时被创建,方法区的大小是可以固定或者是可扩展的。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区内存溢出,虚拟机会抛出OutOfMemoryError。

 1.7的永久代、1.8的元空间是对于hotspot来说的,J9、JRockit等没有元空间的概念。元空间和永久代的最大区别:元空间不在虚拟机上设置的内存中,而是使用本地内存。

永久代、元空间两者并不是名字变了,内部结构也调整了。


设置方法区大小:


我们可以看看下面的例子:

不断的创建代理类,使得方法区内存溢出。

 


如何解决OOM?


方法区的内部结构:

方法区存储的内容:用于存储已被虚拟机加载的类型信息、常量、静态常量、即使编译器编译后的代码缓存等。(静态变量、字符串常量池在1.7及之后物理上被移到堆,逻辑上还是属于方法区

 1、类型信息

2、 域信息

 3、方法信息

4、 静态变量

 全局变量:static final

被声明为final的类变量在编译的时候就被分配了。

 方法区内部包含了运行时常量池(之前我们说过字节码文件中内部包含了常量池),要弄清楚方法区,需要理解Class文件,因为Class文件的信息大部分都是加载到方法区的。相对的想要弄清楚方法区的运行时常量池,需要先理解Class文件中的常量池。

1)、Class文件中的常量池

 一个java文件编译成字节码文件,而java中的字节码需要数据支持。通常这些数据会存到常量池。

常量池中有什么?

可以看到都是些数值、字符串值、一些符号引用。

 小结:常量池可以看出一张表,虚拟机可以根据这张表找到需要的字面量(数值、字符串),根据其中的引用去查找类、方法、参数等其他类型信息。

2)、方法区中的运行时常量池


 方法区的演进: 

可以看出,在1.7之后(永久代),字符串常量池、静态变量就不存在方法区中了,而是保存在堆中。1.8之后(元空间),方法区从jvm内存转移到本地内存中存储。

 


为什么永久代要被元空间替换?(jvm内存转为本地内存)

 StringTable(字符串常量池)为什么要调整?(1.7将静态变量和字符串常量池放到堆)


方法区的垃圾回收:

 

 


运行时数据区常见面试题:

 

六、对象的实例化、内存布局、访问定位

一、对象的实例化

二、对象的内存布局(堆中对象)

 

 三、对象的访问定位

 

 

 

 可以看出hotspot中默认的就是直接指针进行对象的访问定位。即栈指针指向堆中的对象实例,然后堆中的对象类型指针,根据这个类型指针指向方法区的对象类型数据。

七、直接内存

直接内存不是虚拟机运行时数据区的一部分,是直接向系统申请的内存区间。

来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存(本地内存),通常访问直接内存的速度会优于java堆(即读写性能高)

 

 

也就是所谓的零拷贝。

 直接内存的概述:

元数据区、直接内存都是本地内存

八、执行引擎

 虚拟机的执行引擎是基于软件自行实现的,因此可以不受物理条件制约的定制指令集域执行引擎的结构体系,能勾执行那些不被硬件直接支持的指令集格式。

JVM的主要作用就是装载字节码到其内部,但字节码不能直接的运行在操作系统上,此时想要java程序运行起来就需要执行引擎,执行引擎的任务就是将字节码指令解释/编译成对应平台的本地机器指令。

也就是说执行引擎就是将字节码指令解释(解释器)/编译(JIT编译器)为可以被机器识别的机器执行指令。

从图中可以看出,执行引擎 会获取程序计数器的字节码指令,然后根据指令去局部变量表等地方获取相应变量或对象,之后根据对象引用准确定位到堆中的对象实例(获取实例数据),然后再根据对象头的元数据指针定位到方法区的类型数据。

        执行引擎的输入是字节码二进制流,然后进行解析执行,最后输出执行结果。


 解释器和JIT编译器都能实现将字节码转换为本地机器语言的作用。解释器和JIT编译器可以单独使用也可以混合使用,Hotspot JVM就是两者搭配使用。


机器码、指令、汇编语言

机器码:即能被本地机器识别的指令,采用二进制编码方式表示的指令。(即0、1)

指令:由于机器码0、1的可读性太差,于是将特定的01序列简化为对应的指令,例如mov、inc等,这样可读性较好。不同的硬件平台的同一个操作对应的机器码可能不同。

指令集:不同硬件平台支持各自的指令,每个平台所支持的所有指令,称之为对应平台的指令集。如ARM指令集对应ARM架构的平台、X86指令集对应X86架构的平台。

汇编语言:指令的可读性差,汇编中用助记符代替机器指令的操作码,用地址符号或标号代替指令或者操作数的地址。不同硬件平台的汇编语言对应不同的机器语言指令集,由于计算机之认识指令码,所有汇编语言还是需要翻译成机器指令码,计算机才能识别和执行。

高级语言:比汇编语言更加接近人的语言,但仍然需要解释或编译成机器的指令码。

 字节码:

 


解释器

解释器真正意义上就是一个运行时的翻译者,将字节码文件的内容翻译成对应平台的本地机器执行。(不断的根据程序计数器的指令进行翻译)

在Java的发展史中,一共有两套解释执行器,一个是古老的字节码解释器(效率低下),一个是现在普遍使用的模板解释器。

字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率低下。而模板解释器将每一条字节码和一个模板函数相关联,模板函数中会快速的产生这个模板的机器码,这样根据模板函数的对应就能快速的翻译字节码。

 


JIT编译器(即时编译器)

上面我们知道,虽然解释器使用了模板解释器后在效率上有较高的提升,但还是不够,所有就出现了即时编译器。

解释执行模式:运行时通过解释器将字节码转为机器码执行。

编译执行模式:使用即时编译技术将方法编译成机器码后执行。(在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,所以即时编译执行的效率会高于解释执行的)

        HotSpot VM采用的就是解释器+即时编译器并存的架构。这个时候问题就来了,既然hotspot已经内置了JIT编译器,那为什么还要使用解释器来“拖累”程序的执行性能?(例如JRockit VM就只有即时编译器)

————我们需要明确的是:解释执行可以减去编译的时间,立即执行,此时启动时间短。但是解释执行的效率低,而编译执行多了一个编译过程,启动时间较长,但是其执行效率更高。所以配合两者的优点,能够达到更好的效果。(前期先用解释执行,这样启动时间不会太长,后面慢慢的用即时编译,这样会提高执行效率)(而且即时编译会将一些编译过的代码缓存起来,下次用到可以直接使用,不用编译,这样就大大的使得性能提升)(当然,如果对于启动时间没要求,可以直接使用即时编译执行)


前面说了即时编译,但即时编译会根据热点探测功能,将热点字节码编译为本地机器指令(而不是将所有代码进行即时编译)

在java中,编译期是存在多种方式的:

1)、前端编译器:将java文件编译为class文件

2)、后端运行期编译器(JIT即时编译器):将字节码转为机器码

3)、静态提前编译器(AOT编译器):直接将java文件编译成本地机器码

 前面说了即时编译器是根据热点探测将那些热点代码转化为机器码执行,那么是如何进行探测的??

——JIT编译器会根据代码被调用执行的频率来决定这些代码是否是热点代码。然后将这些热点代码做出深度优化后将其直接编译为对应平台的本地机器码。

一个被多次调用的方法,或者方法内循环次数较多的循环体都称为热点代码,而JIT编译器将其编译为本地机器指令,由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换或简称OSR。

那么一个方法要被调用多少次/循环体循环多少次才能算热点代码?
————目前hotspot vm采用的热点探测方式是基于计数器的热点探测。就是会为每个方法建立两个不同类型的计数器,一个为方法调用计数器(用于统计方法的调用次数),一个为回边计数器(用于统计循环体的循环次数)

方法调用计数器:默认的阈值在client模式为1500次,在server模式下是10000次,超过这个阈值就会触发JIT编译。可以通过-XX:CompileThreshold来设定这个值。

还要记得如果一个方法被调用时,会先看看这个方法之前是否已经被JIT编译过了(JIT编译后会将其结果缓存起来),如果存在则直接使用缓存,如果不存在则计数器+1,直到超过阈值则提交代码编译请求。

热度衰减

注意:方法调用计数器统计的是一段时间内的调用次数,如果一定时间调用次数没达到阈值,则会将其数值减半。减半的过程称为热度的衰减,而这段时间称为其方法的半衰周期。

热度衰减是虚拟机在进行垃圾回收是顺便进行的,可以通过-XX:-UseCounterDecay来关闭热度衰减。也可以用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。


回边计数器:用来统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。回边数达到阈值也会提交即时编译请求。(整个过程和方法调用计数器类似)

 


 


hotspot vm中JIT的分类:在其中有两个JIT编译器,分别为Client Compiler和Server Compiler,分别简称C1编译器和C2编译器。可以通过下面的命令指定java虚拟机用哪种即时编译器:

         分层编译策略:jvm 从jdk6u25 之后,引入了分层编译。HotSpot 内置两种编译器,分别是client启动时的c1编译器和server启动时的c2编译器,c2在将代码编译成机器代码的时候需要搜集大量的统计信息以便在编译的时候进行优化,因此编译出来的代码执行效率比较高,代价是程序启动时间比较长,而且需要执行比较长的时间,才能达到最高性能;与之相反, c1的目标是使程序尽快进入编译执行的阶段,所以在编译前需要搜集的信息比c2要少,编译速度因此提高很多,但是付出的代价是编译之后的代码执行效率比较低,但尽管如此,c1编译出来的代码在性能上比解释执行的性能已经有很大的提升,所以所谓的分层编译,就是一种折中方式,在系统执行初期,执行频率比较高的代码先被c1编译器编译,以便尽快进入编译执行,然后随着时间的推移,执行频率较高的代码再被c2编译器编译,以达到最高的性能。

JDK6时期出现分层编译的概念,JDK7时在-server模式下才会开启,JDK8时分层编译默认为开启。这里的划分方式各个书中的版本不同,《深入理解Java虚拟机(第2版)》分为3层(0,1级别不变,级别2化为C2编译),《Java性能权威指南》分为5层:
- 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
- 第1层,简单C1编译代码。
- 第2层,受限的C1编译代码。
- 第3层,完全C1编译代码。
- 第4层,C2编译代码。
所有方法都是从级别0开始,多数代码第一次编译的级别是3。级别2产生的情况可能为:C2编译队列满了;C1编译器全忙;有时候有些不太重要的方法会从级别2开始编译(而不是3)。前两种情况下的级别2编译都是有大概率转到级别4编译,而最后一种情况会转为级别1。我的个人理解级别2是级别3的备胎,正常编译的话只用级别3就行了,一些特殊情况就需要备胎来进行协助了。
C1是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,放弃了许多耗时较长的全局优化手段。
C2会执行所有经典的优化动作:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排等。

 

 

 

 

九、StringTable(字符串常量池)

一、String

String的声明是final,不可修改的。String实现了Serializable接口,表示字符串是支持序列化的。并且实现了Comparable接口,表示String是可以比较大小的。

String在jdk是final char[] 用来存储字符串数据,jdk9则改为byte[](可以节约一些空间)。

也就是字符串修改、连接都是重新分配一个String对象,而不是对旧对象的修改,因为String是不可修改的。

字符串常量池中是一个固定大小的Hashtable,默认大小长度为1009.如果放进String pool的String非常多,那么就会造成hash冲突严重,从而导致链表太长,此时调用String.intern时性能会大幅下降。(-XX:StringTableSize可以设置StringTable长度)

JDK6默认长度为1009、jdk7默认长度为60013,jdk8可以设置长度,最小是1009。


字符串常量池中不会存在相同内容的常量,变量拼接的底层就是用StringBuilder,如果拼接的结果调用intern方法,则会主动将常量池中还没有的字符串对象放入池中,并返回对象地址。

拼接的字符串在编译为字节码时会被优化为一个整体的字符串:

 拼接的底层是StringBuilder实现的


intern()方法的使用

使用双引号和调用intern方法会将字符串常量池中查看是否存在,存在则直接使用常量池的字符串,若不存在则将其放入常量池中。

 

如上调用s2的intern方法会直接指向常量池的字符串,所以上是直接指向beijing这个字符串的。

 注意jdk6和jdk7之后的intern方法原理不同,jdk6调用intern方法,此时是直接将这个字符串对象复制一份放进常量池并返回这个对象的地址。而jdk7之后是将字符串对象的引用地址复制一份放进常量池中,并返回这个引用地址。所以jdk之后放的是地址,不在是字符串本身。

 

 

 


 

 intern方法的效率测试:

 

 


 字符串常量池的垃圾回收

 


G1中的String去重操作

 

 

 

 

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值