文章目录
JVM(java虚拟机)
JVM隔离了各种操作系统与硬件平台的差异,为字节码的执行提供了统一的运行环境并可以执行字节码(如果将class文件比作可执行文件,那么字节码就相当于cpu指令,JVM就可以被看做是操作系统)。JVM并非只有一种,有多家公司都发布了自己的虚拟机软件,他们皆可以用来运行字节码,其中最出名的是HotSpot虚拟机。当虚拟机运行时就像是运行在宿主机上的一个普通进程。
Java编译有三种形式,前端编译、即时编译(JIT编译)、静态提前编译(AOT编译)。前端编译是将源文件编译为字节码class文件;即时编译是在运行时将字节码编译为本地机器码;静态提前编译是直接将源文件编译成本地机器码。相较静态提前编译,即时编译需要在运行时额外占用cpu资源,但运行时能够获得更多的信息,所以即时编译的优化效果比较好,因此大多数的优化都发生在即时编译。HotSpot运行时一般是即时编译执行热点代码(虚拟机参数-XX:CompileThreshold=N表示当某个方法被执行N次时会被认为是热点代码,热点代码只会编译一次并存储,以后直接使用),解释执行不常用的代码。
前端编译将源文件编译为符合字节码规范的,可在虚拟机上执行class文件。这里的源文件不仅指符合java规范的java文件,也可能是符合其他语言规范的文件,如Clojure、JRuby、Groovy等,只是需要针对不同的语言使用不同的前端编译器,如果有合适的编译器,我们甚至可以把C语言编译成字节码让JVM去执行。可见Java规范与JVM规范是两个不同的范畴,如果我们对字节码工程足够熟悉,甚至可以直接编辑出JVM可识别的class文件。
JVM运行时内存区域
Java与C++之间有一堵有内存动态分配与垃圾回收计算所围成的高墙,墙外面的人想进去,强里面的人想出来。
每一个进程都有自己的进程空间,32位系统的进程空间大小为232Byte,64位系统的进程空间大小为264Byte,进程中指令的地址和数据存储的地址都是进程空间中的地址,每一个进程空间都映射到一段物理内存,每一个进程中的地址都通过MMU访问到实际内存对应的空间。
每一个进程空间都分为内核空间和用户空间,所有进程的内核空间都映射到操作系统内核(所以内核空间可以用作进程间通信的桥梁),用户空间负责保存本进程运行的代码和数据,一般被分为代码段、数据段、堆和栈,但实际上分不分段以及怎么分段都无所谓,分段只是为了使程序更加优美,比如某个指令需要访问一个地址,该地址可以处于任何区域。
通过java命令启动一个虚拟机进程,其进程空间与普通进程的区域划分如上图。如图可见JVM进程空间的代码区和数据区实际上是JVM作为一个普通进程的代码区和数据区,不涉及到字节码(java代码)。普通进程创建对象会向内核申请在堆区分配空间(需要显式释放),而JVM进程会在启动时一次向内核申请在堆区分配足够的空间(在JVM进程结束时由JVM程序显式释放),并且这部分空间以后将由JVM进程自己管理,所以Java需要创建对象时向JVM进程申请在该区域的部分空间,此处申请不需再经过内核,而该部分内存的释放也不需要显式释放(实际上并未释放给操作系统,而是释放给JVM),由虚拟机自己实现GC,可见JVM的堆实际上使用的是池化技术。
JVM启动后会向内核申请一部分进程空间由JVM管理,这部分空间在JVM进程结束后释放,JVM把管理的内存被划分为若干个不同的数据区域,程序计算器、虚拟机栈、本地方法栈、堆与方法区,其中程序计算器、虚拟机栈、本地方法栈是线程私有的,每一个线程都有一个,堆和方法区则是所有线程共享的。在内存分配时可能会出现内存溢出OOM(OutOfMemory)异常。
程序计数器
程序计数器可以看作是当前线程所执行的字节码的行号指示器(当执行native方法时,其值为undefined),每一个线程都有一个程序计数器(N个线程的程序计数器占用的内存大小为 N * 程序计数器区大小),该区域是唯一个一个不会出现内存溢出的区域。
虚拟机栈
虚拟机栈也是线程私有的,也就是每一个线程都有一个自己的栈,在创建线程时就分配了其初始化栈大小的空间,通过-Xss设置栈大小(N个线程的虚拟机栈占用的内存大小为 N * Xss的值),栈是由许多栈帧组成,栈帧对应的是一个方法,当在线程中发生一次方法调用时就会有新的栈帧入栈,当方法返回时就会有栈帧出栈。每一个栈帧包含了局部变量表、操作数栈、动态链接、方法出口等信息,在编译程序代码时,需要多大的局部变量表,多深的操作数栈都已经确定并保存在方法表的Code属性中,因此进入一个方法时,其栈帧大小就已经分配不会再更改。栈会抛出StackOverflowError(栈大小Xss无法容纳当前线程新压入的栈帧时,比如无法结束的递归)和OutOfMemoryError(当新创建线程申请该线程的栈空间时,进程空间或物理内存不足)异常。
局部变量表存放了编译期可知的各种基本数据类型和对象引用的局部变量,局部变量表所需的内存空间在编译期间确定,当生成一个栈帧时,局部变量表的大小已经定死,之后不会改变。成员变量的基本类型和对象引用存放在堆里,因为一个堆中的对象占用内存的主要部分就是其成员变量(基本变量保存数据,引用对象指向堆中的另一个对象)。局部变量单位为Slot(4个字节),long和double占用两个Slot,boolean、byte、char、short、int、float、reference和returnAddress占用1个Slot,reference为引用类型,returnAddress是早期提供给jsr、jsr_w和ret指令使用的跳转地址,但现在已经用异常处理表替换了这几条指令,所以returnAddress也开始灭绝了。
操作数栈既然是栈那也是通过出栈和入栈来处理,它是当前方法中运算过程中的临时变量的存储地。
// 编译前
public void func() {
int a = 5 + 10;
int b = a + 3;
b = b + 6;
}
// 反编译后代码如下,在进入方法后就在局部变量表里面分配了两个空间索引为1和2分别对应变量a和b
bipush 15 // 将15压入操作数栈(编译过程中5+10合并成15) ,此时操作数栈为15
istore_1 // 从操作数栈弹出栈顶15存储到局部变量表索引为1的空间,此时操作数栈为空
iload_1 // 取出局部变量表中访问索引为1的空间并重新压入栈顶,此时操作数栈为15
iconst_3 // 将数值3压入操作数的栈顶,此时操作数栈为15、3
iadd // 将栈顶的前两个弹出并进行加法运算后将结果重新压入栈顶,此时操作数栈为18
istore_2 // 从栈顶弹出18并压入局部变量表访问索引为2的空间,此时操作数栈为空
iload_2 // 将局部变量表中访问索引为2的Slot重新压入栈顶,此时操作数栈为18
bipush 6 // 将6压入操作数栈,此时操作数栈为18、6
iadd // 将栈顶的前两个弹出并进行加法运算后将结果重新压入栈顶,此时操作数栈为24
istore_2 // 从栈顶弹出24并压入局部变量表访问索引为2的空间,此时操作数栈为空
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接。
方法返回地址,当调用一个方法时会在当前线程的栈顶创建一个代表被调用方法的栈帧,而调用方法当前指令的位置会被保存在被调用方法的栈帧中,当被调用方法执行完成后,它的栈帧被弹出,调用方法的栈帧重新作为栈顶,当前线程的PC寄存器回到调用方法的原指令的下一条指令。
本地方法栈
本地方法栈( Native Method Stack) 与虚拟机栈所发挥的作用是非常相似的, 它们之间的区别不过是虚拟机栈为虚拟机执行Java方法( 也就是字节码) 服务, 而本地方法栈则为虚拟机使用到的Native方法服务。设置本地方法栈大小的参数为-Xoss,在HotSpot中不区分本地方法栈与虚拟机栈,本地方法栈的栈帧在虚拟机栈中和普通的栈帧无异,所以在HotSpot中设置-Xoss参数没有任何意义。
堆
堆是JVM所管理的内存中最大的一块,几乎所有的对象实例都在这里分配内存,它是所有线程都共享的内存区域。在JVM启动时就向内核申请了初始化大小的堆空间(-Xmx参数设置,堆空间不一定是进程空间中连续的地址),当堆不够用时(GC后仍不够用)堆会自动进行扩展,当堆的内存不够用并且已经无法扩展时(通过-Xms参数设置了扩展上限或者物理内存不够用,或者进程空间不够用),会抛出OutOfMemoryError异常(附带异常信息Java heap space),为了避免堆扩展过程的开销,经常把-Xmx与-Xms设置为相同值。
堆可分为新生代和老年代,新生代进一步被划分为Eden空间和两个Survivor空间,堆的进一步划分只是为了更好地回收内存,或者更快地分配内存。通过-Xmn制定新生代大小,剩下的为老年代,-XX: SurvivorRatio=N意味着Eden与一个Survivor的比例为N。
在HotSpot中,一个对象在内存中的布局分为三部分,对象头、实例数据与对齐填充。实例数据是对象真正存储的有效信息,也就是在程序代码中所定义的各种类型的非静态成员变量(包括从父类继承而来的);JVM要求对象的起始地址必须是8的整数倍,如果对象头+实例数据所占用的空间不是8的整数倍,那么就需要对齐填充;对象头分为两部分(数组分为三部分),第一部分用于存储对象自身的运行时数据(如HashCode、gc分代年龄、锁状态等信息),第二部分是类型指针,第三部分只有数组才有,为数组长度。
方法区
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java虚拟机规范把方法区描述为堆的一个逻辑部分。方法区超过该值会抛出OOM异常。
class常量池、运行时常量池和String常量池。class文件中的常量池保存着字面值和符号引用,对字节码的执行提供支持;运行时常量池位于方法区,是class文件加载到内存中的表现形式,为字节码的执行提供支持,每一个类都对应一个运行时常量池;String常量池实际上是一个表(也被叫着StringTable),表中保存着许多String类型实例的地址,在整个虚拟机中只有一个String常量池,Java6前String常量池被放在永久代,Java7开始String常量池被转移至堆中,Java8开始存被放在了元空间。
读写String常量池的方式只有String.intern()方法与ldc指令。ldc从运行时常量池加载某个CONSTANT_String_info常量时(代码中的String常量会产生一条操作数为CONSTANT_String_info的ldc指令),首先会到String常量池查看是否有值为CONSTANT_String_info的String实例,如果有就将该实例的引用压入栈,否则会创建一个String实例(在哪个区创建本座也没有查到资料,不过who care呢)并将其引用压入栈且放入String常量池中。String对象的intern()方法会先查询String常量池,如果存在值与该String对象相等的引用,那么返回该引用,否则会将String对象的引用放入String常量池中(在Java7之前,并不是将String对象的引用放入String常量池,而是创建一个值为该String对象值的新String对象,并将新String对象的引用放入String常量池)。示例分析如下:
void test() {
String s = new String("1"); // 1
s.intern(); // 2
String s2 = "1"; // 3
System.out.println(s == s2); // 4
String s3 = new String("1") + new String("1"); // 5
s3.intern(); // 6
String s4 = "1" + "1"; // 7 这里等同于String s4 = "11",对于两个String常量进行+操作,编译器会将其合并为一个CONSTANT_String_info常量。
System.out.println(s3 == s4); // 8
}
执行代码1时,首先创建一个值为"1"的String对象a并将其引用写入String常量池,然后再创建一个值为"1"的String对象b并将引用赋值给s;执行代码2时,去查找String常量池中值与b的值相等的对象,找到a返回;执行代码3时,去查找String常量池中值为"1"的对象,找到a并将其引用赋值给s2;执行代码4时,s与s2都指向对象a,所以两者相等。执行代码5时,创建值为"1"的String对象c以及值为"1"的String对象d,并创建值为"11"的String对象e赋值给s3;执行代码6时,去查找String常量池中值与e相等的对象,未找到,对于java7+,在String常量池中加入e的引用,对于java6-,克隆一个值与e相等的对象f并将其引用放入常量池;执行代码7,查找String常量池值为"11"的对象赋值给s4,java7+为e,java6-为f;执行代码8时,s3为e,java7+时s4为e,java6-时s4为f,所以java6-为false,java7+为true。
如果将代码6和代码7换个顺序,那么执行代码7时,会新建一个值为"11"的对象放入常量池并赋值给s4,这时候在任何java版本中都为false。依赖于String常量池来判断对象是否相同有太多不确定因素,且不说java版本问题,在某个模块中,你不能确定此时String常量池中是否已有某个字符串,因为一个项目包含了许多模块。
运行时常量池是方法区的一部分,它用来保存常量,常量可以实现共享以节约内存。 在类加载过程中会将class文件中的常量池解析到运行时常量池(class文件中的常量池包括字面值(文本字符串、基本类型、static final常量值)与符号引用(类名、方法名、字段名))。
方法区是个逻辑概念,Java1.6之前,方法区由永久代实现,Java1.7开始将方法区中的运行时常量池从永久代迁移到了堆中,Java1.8开始彻底摒弃了永久代,运行时常量池依然存放在堆中,由元空间代替永久代实现方法区其他部分。元空间与永久代最大的区别在于,元空间并不在虚拟机中,而是使用本机内存。
在1.7之前通过-XX: PermSize可设置永久代初始化空间,通过-XX: MaxPermSize可设置永久代的空间上限,永久代超过该值会抛出OOM异常(附加异常信息PermGen space)。在1.8之后由于永久代被彻底摒弃,-XX: PermSize与-XX: MaxPermSize失效,由-XX:MetaspaceSize来控制元空间大小。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,它直接向内核申请在进程空间分配内存,并不是向JVM申请空间。在一些场景中,直接内存能显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。在进程空间或者物理内存不够用时或者超过配置的最大内存时(通过-XX:MaxDirectMemorySize可以设置最大直接内存,默认值为-Xmx)也会抛出OOM异常。
GC(垃圾回收)
JVM自己在需要的时候进行GC,编程人员不必手动进行内存释放(也可以通过System.gc()手动触发GC,但不建议使用)。
对象的死活
GC首先就须要判断哪些对象已经死掉了需要被GC。标记活着对象最容易想到的就是引用计数算法,有地方引用该对象时计数就加1,引用失效计数就减1,计数不为0就意味着对象还活着,该算法效率较高,但最大的弊端就是无法解决循环引用。在主流的JVM中使用可达性分析算法。
可达性分析算法通过一系列的GC Roots作为起始点向下搜索,能够搜索到的节点便是存活的节点。GC Roots包含哪些GC Root呢?虚拟机栈与本地方法栈的栈帧的局部变量表中引用的对象,以及方法区中静态属性与常量引用的对象。
引用分为强引用、软引用、弱引用、虚引用,强度依次减弱,在可达性分析时分别对应强可达、软可达、弱可达、虚可达。
当一个对象直接通过"="赋值给一个引用时,这个对象就被强引用,虚拟机宁愿抛出OOM异常也不会回收强可达对象,如果某个成员变量特别是静态变量引用了其他对象,当以后不会使用这些对象时,可以选择把变量置为null。
软引用是使用SoftReference创建的引用,只有在无引用(以及虚可达和弱可达)的对象回收后任然内存不足时才会回收软可达对象,回收软可达对象后任然内存不足才会抛出OOM异常。软引用的特征使它很适合做缓存,如网页缓存、图片缓存。
弱引用是使用WeakReference创建的引用,在发生GC时,只要发现弱引用,不管系统堆空间是否足够,都会将对象进行回收。与软引用一样,弱引用也适合做缓存,使用频率高,比较大的对象适合软引用,使用频率低,比较小的对象适合弱引用,在无法判断使用谁时,使用WeakHashMap一般不会有问题。WeakHashMap是一个Map,不过其key对应的value对象被弱引用,value对象如果被回收了,那么WeakHashMap对应的键值对也被从Map中移除了。
虚引用是使用PhantomReference创建的引用,虚引用的对象不会对垃圾回收造成影响,它的get()方法始终返回null(也就是通过虚引用无法获取对象的句柄,这样虚引用跟无引用就没什么区别)。虚引用必须与引用队列一起使用,当虚引用的对象被回收后,该虚引用就会被放进引用队列,这样虚引用相较无引用可以起到获得通知的作用,可以将一个强引用对象额外增加一个虚引用,当该对象不需要再使用时,断开强引用,当对象回收时会获得通知。
public abstract class Reference<T> {
Reference(T referent);
Reference(T referent, ReferenceQueue<? super T> queue); // 当对象被回收后会将该引用放入注册的queue中
public T get(); // 返回对象的强引用(虚引用始终返回null),如果对象已经回收返回null
public void clear(); // 与referent解除引用关系,referent变为无引用(如果referent没有其他引用的话)
public boolean isEnqueued(); // 当前引用是否在注册的queue中
public boolean enqueue(); // 将当前引用放入注册的queue中
}
public class ReferenceQueue<T> {
public Reference<? extends T> poll(); // 如果队列有引用就返回并移除下一个引用,否则返回null
public Reference<? extends T> remove(long timeout); // poll的阻塞模式,超时返回null
public Reference<? extends T> remove(); // poll的阻塞模式
}
Reference是用来保存引用的,但它同时也是一个Java对象,如果Reference引用的对象已经被释放而引用本身无法被释放就会出现内存泄漏。可以用一个全局集合保存所有引用,同时为其配置一个引用队列作为搭档,定时根据引用队列中的引用移除全局集合中的引用。
垃圾回收器
垃圾回收常用的算法又标记-清除算法、复制算法、标记-整理算法、分代收集算法。标记-清除算法首先对所有的已死对象进行标记,然后对标记的对象进行清除,其缺点为产生大量不连续空间;复制算法将容量等分为两块,其中一块是有效的,当有效的这一块用完时,将活着的对象转移到另一块,然后将另一块设为有效,原来有效的那一块设置为无效并清理,其缺点为只有一半的空间为有效空间,并且活着对象多时复制效率较低;标记-整理算法先对所有的活着对象进行标记,然后将标记的对象移动到内存的一端,这样解决了标记-清除算法空间不连续问题;分代收集算法根据对象存活周期的不同将内存划分为几块,各块根据特点采用不同的算法。
HotSpot的堆GC在整体上使用的是分代收集算法,将堆区划分为新生代与老年代,新生代中的对象存活周期短,使用的是复制算法,老年代的对象多而且生命周期相对较长,使用的是标记算法。准确的说,新生代使用的是复制算法的变种,这是因为新时代98%的对象生命周期都非常短,新生代被进一步划分为一个Eden区域两个Survivor区(一个活跃Survivor区),向堆申请空间首先会向Eden区申请空间,若Eden区没有足够的空间时将触发新生代GC(Minor GC),新生代GC将Eden区与活跃Survivor区中的存活对象移动到非活跃Survivor区(因为此时存活的对象很少,所以Survivor区能够容纳),此时不活跃的Survivor区转为活跃区与清空的Eden区一起继续提供内存服务,因为新生代活跃对象少,Eden与Survivor的容量比例默认为8:1,也就是说这样的复制算法在有效容量上达到了9/10。当某一次触发Minor GC且Survivor也无法容纳所有活着的对象时,就会将新生代的对象全部移动到老年代中,若老年代也无法容纳这些对象,就触发老年代GC(Major GC或叫着Full GC),如果进行Full GC之后任然无法容纳这些对象就抛出OOM异常。但如果是大对象(内存超过-XX: PretenureSizeThreshold指定的值的对象,如很多byte[])就不会向Eden区申请空间而直接向老年代申请空间,因为大对象不适合复制算法,但如果大对象生命周期短,那么Full GC触发频繁也会影响效率,故而应尽量避免使用生命周期短的大对象。每一次Minor GC时新生代中的对象年龄都会加1,当年龄达到一定阈值(-XX: MaxTenuringThreshol设置阈值,默认15)就算Survivor区尚有空间也会把这个对象放入老年代,如果某个年龄的对象占了Survivor区内存的一半,那么大于或等于该年龄的对象也会晋升到老年代。
在HotSpot 1.7版本后提供了几种垃圾回收器,如下图所示:
上图中间分割线上面的垃圾回收器适合于新生代的垃圾回收,下面的垃圾回收器适合于老年代垃圾回收,只有相连的两个垃圾回收器才可以搭配使用,进行新生代和老年代的垃圾回收。根据不同的应用场景选择不同的组合进行垃圾回收。
Serial收集器是最基本、历史最悠久的收集器,它采用单线程进行回收,并且在回收时,其他工作线程都必须暂停工作(Stop The World简称STW),因为是单线程,所以适合于单核CPU,在客户端模式下是一个不错的选择。
ParNew就是Serial收集器的多线程版本,它采用多线程进行回收,在回收时,也会暂停其他工作线程,适合于多核CPU(双核情况不一定笔Serial收集器效果好),在服务端模式下是个不错的选择,使用 -XX: ParallelGCThreads控制收集线程个数,默认为CPU核心数。
Parallel Scavenge收集器也是采用多线程进行回收,在回收时,也会暂停其他工作线程。其他垃圾回收器大多关注的是停顿时间(就是每一次垃圾回收的时间),Parallel Scavenge收集器关注的却是吞吐量(就是在一段时间内工作线程运行时间占用的比例),停顿时间短有利于交互,高吞吐量适合于后台运算程序。可以用-XX: MaxGCPauseMillis控制垃圾最大停顿时间(停顿时间过小会导致GC频繁),用-XX: GCTimeRatio设置工作时间与GC时间的比例,默认99,吞度量为1%,可以根据关注点设置这两个参数之一。另外使用-XX: +UseAdaptiveSizePolicy参数就不需要设置-XX: SurvivorRatio和-XX: PretenureSizeThreshold,他会根据最大停顿时间与吞吐量设置自动调整。
Serial Old收集器是Serial收集器的老年代版本,也是工作在单线程STW模式下,使用标记-整理算法,适合于客户端模式。
Parallel Old收集器是Parallel Scavenge收集器的老年版本,它工作在多线程STW模式下,使用标记-整理算法,关注“吞吐量优先”,适合于服务端模式。
CMS收集器以最短停顿时间为目的,非常适合互联网站或B/S服务器。CMS收集器基于标记-清除算法,收集过程分为4个阶段初始标记(STW)、并发标记、重新标记(STW)、并发清除,其中耗时最长的并发标记与并发清除都可以与用户线程并发执行,GC线程个数默认为(CPU个数+3) / 4个以保证GC线程留给用户线程足够的CPU资源。CMS常用标记-清除算法会产生碎片,收集器顶不住压力时会进行碎片整理,此时也会STW。
G1收集器是一款面向服务端应用的垃圾收集器,它同时管理着老年代和新生代,是目前最主流的垃圾回收器。
GC日志与参数配置
GC日志记录了每一次GC的信息,通过-XX: +PrintGCDetails或-verbose: gc表示需要打印GC信息,-Xloggc: filePath指定日志存放地址,不同的GC收集器可能日志格式小有差别,大概如下:
33. 125: [ GC[ DefNew: 3324K-> 152K( 3712K) , 0. 0025925 secs] 3324K-> 152K( 11904K) , 0. 0031680 secs]
最开始的33.125是垃圾回收时间(虚拟机启动后的秒数);GC表示本次GC没有STW,如果本次GC采用了STW,那么这里是Full GC;DefNew表示GC发生的区域(另外有Tenured、Perm);3324K->152K(3712)表示GC前该区域内存使用容量->GC后该区域内存使用容量(该内存区域总容量),3324K->152K(11904K) 表示GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量);0.00.1680secs表示GC使用时间 。
以下为各种垃圾回收相关的配置参数:
参数 | 说明 |
---|---|
UseSerialGC | 使用 Serial + Serial Old 进行垃圾回收 |
UseParNewGC | 使用 ParNew + Serial Old 进行垃圾回收 |
UseConcMarkSweepGC | 使用 ParNew + CMS + Serial Old 进行垃圾回收,Serial Old作为CMS出现Concurrent Mode Failure失败后的备用收集器 |
UseParallelGC | 使用 Parallel Scavenge + Serial Old 进行垃圾回收 |
UseParallelOldGC | 使用 Parallel + Parallel Old 进行垃圾回收 |
SurvivorRatio | Eden区域与一个Survivor区域的比例,默认为8 |
PretenureSizeThreshold | 直接晋升老年代的对象大小 |
MaxTenuringThreshold | 晋升老年代的年龄 |
UseAdaptiveSizePolicy | 动态调整堆中各区域大小以及晋升老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败 |
ParallelGCThreads | 设置并行GC数 |
GCTimeRatio | 非GC时间与GC时间的比值,默认99,即允许1%的GC时间 |
MaxGCPauseMillis | GC最大停顿时间,仅仅适合Parallel Scavenge |
CMSInitiationgOccupancyFranction | CMS收集器在老年代空间被使用多少后触发垃圾回收,默认68% |
UseCMSCompactAtFullCollection | CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理 |
CMSFullGCsBeforeCompaction | CMS收集器在进行若干次垃圾回收再启动一次内存碎片整理 |
监控工具
工具 | 作用 |
---|---|
jps | 虚拟机进程状态工具 |
jstat | 虚拟机统计信息监控工具 |
jinfo | 虚拟机配置信息工具 |
jmap | 虚拟机内存映像工具 |
jhat | 虚拟机堆快照分析工具 |
jstack | 虚拟机堆栈跟踪工具 |
JConsole | 虚拟机监视与管理控制工具 |
VisualVM | 多合一故障处理工具 |
命令行工具大都可以用 cmd -help 查看命令详情。
jps是查看JVM进程状态的工具,与linux的ps命令很像。格式为 jps [options] [hostid] ,默认输出虚拟机唯一ID(以下简称VMID,在没有hostid参数时查看的是本地虚拟机,此时的VMID称作LVMID,LVMID与操作系统进程ID一致,很多其他命令都需要该命令获取的VMID作为参数)和虚拟机执行主类名称。-q 只显示ID,不和其他任何参数一起用;-m 显示传递给main函数的参数;-l 输出主类全名(jar包显示会输出jar路径);-v 输出启动时JVM配置参数(无虚拟机默认参数)。指定hostid后jps可以查看开启了RMI的远程虚拟机的进程状态,hostid格式为[protocol://][hostname][:port][/servername]。
jstat是监控虚拟机各种运行状态的的命令行工具,它可以显示本地或者远程虚拟机的类装载、内存、垃圾回收和JIT编译等运行数据。格式为jstat [option] vmid [ interval [s|ms] count],其中vmid可以是LVMID或hostid,interval [s|ms] count表示每个interval秒/毫秒(默认毫秒)查询一次,共查询count次,默认只查询一次。-class监视类装载、卸载数量、总空间以及类装载耗时;-gc查看各区总容量与已使用容量,GC时间合计等;-gccapacity与-gc基本相同,但关注堆各个区域使用到的最大、最小空间;-gcutil与-gc基本相同,但关注个空间已使用部分的占比;-gccause与-gcutil一样,但会额外输出上一次gc的原因;-gcnew关注-gc的新生代;-gcold关注-gc老年代;-gcnewcapacity关注-gccapacity的新生代;-gcoldcapacity关注-gccapacity的老年代;-gcpermcapacity关注-gccapacity的老年代;-compiler输出JIT编译过的方法、耗时等信息。
jinfo的作用是实时地查看和调整虚拟机各项参数。格式为jinfo [options] vmid。选项**-flags显示所有的虚拟机参数;-flag name** 显示指定名字的虚拟机参数值;-flag [+|-] name使名字为name的虚拟机参数起作用和不起作用;-sysprops查看系统属性信息;不加参数时包括所有虚拟机参数和系统属性信息。jps查看虚拟机的参数是显示配置或显示调用的,不包括虚拟机默认参数(默认参数需要查虚拟机手册),而jinfo命令包含了虚拟机默认参数。
jmap用来查看内存信息或生成内存映像快照。格式为jmap [options] vmid。选项 -dump:[live] format=b, file=<filename> 将堆快照保存到文件中,如果包含live只保存活着的对象; -heap显示Java堆详细信息,如哪种回收期、参数配置、分代状况等;-histo[:live]显示堆中对象统计信息,包括类、实例数量、合计容量,如果包含:live只统计活着的对象;-finalizerinfo显示等待finalizer线程执行finalize方法的对象;-permstat以classloader为统计口径显示永久代内存状况。
jhat用来分析jmap生成的堆快照。格式为jat <快照文件>。jhat内置一个微型http服务器,分析结果可以通过浏览器查看,我们一般不会在服务器上直接运行jhat来占用服务器资源,而是将快照文件下载到本地用jhat分析。
jstack用于生成虚拟机当前时刻的线程快照。线程快照就是虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照主要用来定位线程出现长时间停顿的原因,如线程死锁、死循环、请求外部资源长时间等待等。格式为jsack [option] vmid。-l 除堆栈外,显示关于锁的附加信息;-m如果调用native方法的话显示C/C++堆栈。
JConsole是一款对JVM进行监视和管理的可视化工具。JSconsole工具在JDK/bin目录下,启动后将自动搜索本机运行的所有虚拟机,可以从中选择一个虚拟机进行监控管理,也可以选择界面的远程进程来进行远程服务器的监控。选择虚拟机进入主界面后,共有6个标签页,分别为概述、内存、线程、类、VM概要与MBean。概述主要包括堆内存使用情况、线程、类和CPU使用情况4种信息曲线图,是后面几个标签页的汇总。内存监控相当于可视化的jstat命令,用于监控虚拟机内存的变化趋势。线程监控相当于可视化的jstat命令,遇到线程停顿时可以使用它进行监控分析,可以通过检测死锁来查看死锁线程。
VisualVM是目前为止随JDK发布的功能最强大的运行监视和故障处理程序,未来一段时间将会是官方主力发展的虚拟机故障处理工具。 VisualVM的性能分析功能比一些收费的商业工具(如JProfiler、YourKit)还要强大,而且它对应用程序的实际性能影响很小,可以直接应用在生产环境中,这也是很多商业工具无法与之媲美的。VisualVM工具对应JDK/bin目录下的jvisualvm命令软件。根据需要,VisualVM还可以安装多个插件,在工具标签下的插件选项中可查看可安装与已安装的插件。对于VisualVM不再做过多介绍,有了本章节知识,平时多使用自然能够熟练掌握,平时还应该在别人的博客中浏览他们分享的,在实际项目中遇到的问题以及优化方法。
虚拟机执行子系统
类文件结构
JVM执行的是class类文件(无论是通过java编译得到还是其他语言编译得到,甚至是手动编写的class文件),每一个class类文件都对应着一个类或接口的定义,然而本节所说的class文件并不一定是文件,可以是内存中的字节码序列,或者来源于网络的字节码序列,只要满足class文件格式。通过反编译javap命令加-v或-verbose选项可以很友好地查看class文件。
在class文件中有两种数据类型:无符号数和表。无符号数用u1、u2、u4、u8来代表1个字节、两个字节、4个字节和8个字节的无符号数,可以用来描述数字、索引引用、数量值、utf8编码字符串。表是由无符号数或者其他表组成的复合数据类型,习惯以"_info"结尾,整个class文件就是一个表。整个class文件结构如下:
类型 | 名称 | 数量 | 说明 |
---|---|---|---|
u4 | magic | 1 | 很多文件都通过文件开头的一个魔数来表示文件类型而非容易随意更改的扩展名,不同的文件类型魔数不同,class文件的魔数为0xCAFEBABE(咖啡宝贝) |
u2 | minor_version | 1 | 次版本号 |
u2 | major_version | 1 | 主版本号,Java 1.7.0对应的的主版本号为50,次版本号为0,十进制用50.0表示,每个版本的虚拟机可以执行该版本及低版本的字节码(可能也能执行稍微高一点版本的) |
u2 | constant_pool_count | 1 | 常量池数量 + 1(1代表一个特殊用途空间),常量池从1开始计数,因为第0个计数被用作特殊用途(某些指向常量池的索引不需要指向任何常量池时指向该空间) |
cp_info | constant_pool | constant_pool_count - 1 | 常量池(虽然绝大多数教程把这个结构叫着常量池,但从逻辑上讲叫着常量项更合适),常量池中的常量分为字面值(字符串常量、 |
u2 | access_flags | 1 | 访问标识有两个字节故可以表达16种标识,现只用8位,ACC_PUBLIC(0x0001)、ACC_FINAL(0x0010)、ACC_SUPER(0x0020)、ACC_INTERFACE(0x0200)、ACC_ABSTRACT(0x0400)、ACC_SYNTHETIC(0x1000标识此类并非由用户代码产生)、ACC_ANNOTATION(0x2000)、ACC_ENUM(0x4000) |
u2 | this_class | 1 | 当前类的类全名,指向常量池一个类型为CONSTANT_Class_info的常量 |
u2 | super_class | 1 | 父类的类全名,指向常量池一个类型为CONSTANT_Class_info的常量,没有父类(只有Object类没有父类)指向常量池特殊空间 |
u2 | interfaces_count | 1 | 实现或继承(接口可以多继承接口)的接口数量 |
u2 | interfaces | interfaces_count | 每一个实现或继承的接口都指向常量池一个类型为CONSTANT_Class_info的常量 |
u2 | fields_count | 1 | |
field_info | fields | fields_count | 成员变量(包括静态变量)、构造方法的描述,其结构后面专讲 |
u2 | methods_count | 1 | |
method_info | methods | methods_count | 成员方法(包括静态方法)的描述,其结构后面专讲 |
u2 | attributes_count | 1 | 类的额外属性个数 |
attribute_info | attributes | attributes_count | 类的额外属性 |
成员变field_info量表表示一个字段,其结构如下:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 按位标识字段的修饰符,ACC_PUBLIC(0x0001)、ACC_PRIVATE(0x0002)、ACC_PROTECTED(0x0004)、ACC_STATIC(0x0008)、ACC_FINAL(0x0010)、ACC_VOLATILE(0x0040)、ACC_TRANSIENT(0x0080)、ACC_SYNTHETIC(0x1000标识此字段并非由用户代码产生的)、ACC_ENUM(0x4000) |
u2 | name_index | 1 | 字段名,指向常量区的CONSTANT_Utf8_info常量 |
u2 | descripter_index | 1 | 字段描述,指向常量区索引CONSTANT_Fieldref_info |
u2 | attributes_count | 1 | 成员变量的额外属性数量 |
attribute_info | attruibutes | attruibutes_count | 成员变量的额外属性 |
方法表method_info表示一个方法,其结构如下:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 按位标识字段的修饰符,与字段相似 ,ACC_PUBLIC(0x0001)、ACC_PRIVATE(0x0002)、ACC_PROTECTED(0x0004)、ACC_STATIC(0x0008)、ACC_FINAL(0x0010)、ACC_SYNCHRONIZED(0x0020)、ACC_BRIDGE(0x0040编译器产生的桥接方法)、ACC_VARARGS(0x0080方法是否接受不定参数)、ACC_NATIVE(0x0100)、ACC_ABSTRACT(0x0400)、ACC_STRICTFP(0x0800)、 ACC_SYNTHETIC(0x1000标识此方法并非由用户代码产生的) |
u2 | name_index | 1 | 方法名,指向常量区的CONSTANT_Utf8_info常量 |
u2 | descripter_index | 1 | 方法描述,指向常量区索引CONSTANT_Methodref_info |
u2 | attributes_count | 1 | 方法的额外属性数量 |
attribute_info | attruibutes | attruibutes_count | 方法的额外属性 |
<init>为实例构造函数名,由编译器生成,是构建实例时调用的函数(每一个程序定义的构造函数或默认的无参构造函数都会生成一个<init>函数),其中包括的代码依次为调用super构造函数、成员变量的直接初始化(如int a = 3)与成员块的代码(这两者按在源码中的先后顺序排列)、自定义的构造函数代码。<clinit>在类加载的初始化阶段被调用,它也由编译器生成,包括非fianl的static成员变量直接初始化(如staic int a = 3)与静态块的代码(这两者按在源码中的先后顺序排列)。class文件中一定&有lt;init>方法,不一定有<clinit>方法。
常量池的项目被分为了14种类型,每一种类型都通过一个u1类型标识,不同的类型其具体结构不同,其中只有CONSTANT_Utf8_info类型的长度是不定的,具体各类型结构如下:
常量类型 | 项目 | 类型 | 描述 |
---|---|---|---|
CONSTANT_Utf8_info | tag | u1 | 标识常量类型,值为1,标志utf-8编码的字节序列 |
length | u2 | 字符串以utf-8编码后的字节长度 | |
bytes | u1 | 字符串以utf-8编码后的字节序列 | |
CONSTANT_Integer_info | tag | u1 | 标识常量类型,值为3,标识整型字面量 |
bytes | u4 | 高位在前存储int值 | |
CONSTANT_Float_info | tag | u1 | 标识常量类型,值为4,标识浮点型字面量 |
bytes | u4 | 高位在前存储float值 | |
CONSTANT_Long_info | tag | u1 | 标识常量类型,值为5,标识长整型字面量 |
bytes | u8 | 高位在前存储long值 | |
CONSTANT_Double_info | tag | u1 | 标识常量类型,值为6,标识双精度实型字面量 |
bytes | u8 | 高位在前存储double值 | |
CONSTANT_Class_info | tag | u1 | 标识常量类型,值为7,标识类或接口的元信息 |
index | u2 | 指向全限定名CONSTANT_Utf8_info常量项的索引 | |
CONSTANT_String_info | tag | u1 | 标识常量类型,值为8,标识字符串类型字面量 |
index | u2 | 指向字符串字面值CONSTANT_Utf8_info常量项的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 标识常量类型,值为9,标识字段的元信息 |
index | u2 | 指向声明该字段的类或接口描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向字段描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_Methodref_info | tag | u1 | 标识常量类型,值为10,标识类中方法的元信息 |
index | u2 | 指向声明方法的类描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向名称及类型描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_InterfaceMethodref_info | tag | u1 | 标识常量类型,值为11,标识接口中方法的元信息 |
index | u2 | 指向声明方法的接口描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向名称及类型描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_NameAndType_info | tag | u1 | 标识常量类型,值为12,标识字段或方法的名和类型 |
index | u2 | 指向该字段或方法名称(实例构造函数名都为<init>,类构造函数都为<clinit>)常量项CONSTANT_Utf8_info | |
index | u2 | 指向该字段或方法类型常量项CONSTANT_Utf8_info 字段的类型有:B、C、D、F、I、J (long)、S、Z、V(void)、L(表示对象以;结尾,如Ljava/lang/String;)、[ (数组,如 [[I 表示int[][]) ) 方法类型表示方式:"(参数列表类型)返回值类型",如 (ILjava/lang/String;Ljava/lang/Integer;J)V 代表方法void funcName(int a, String b, Integer c, long d) | |
CONSTANT_MethodHandle_info | tag | u1 | 标识常量类型,值为15,标识方法句柄 |
reference_kind | u2 | ||
reference_index | u2 | ||
CONSTANT_MethodType_info | tag | u1 | 标识常量类型,值为16,标识方法类型 |
descriptor_index | u2 | 指向CONSTANT_Utf8_info表示方法的描述符 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 标识常量类型,值为18,标志一个动态方法调用点 |
bootstrap_method_attr_index | u2 | ||
name_and_type_index | u2 |
属性表attribute_info表示一个额外用途的属性,描述某些场景专有的信息,在整个类文件的表结构中、field_info和method_info中都包含了该表结构,其具体结构如下:
名称 | 类型 | 数量 | 描述信息 |
---|---|---|---|
attribute_name_index | u2 | 1 | 属性名,指向CONSTANT_Utf8_info |
attribute_length | u4 | 1 | 属性值的长度 |
info | u1 | attribute_length | 属性值 |
如果把严格的表结构比作bean对象,那么包含属性表结构的表就像是包含了一个Map成员的bean对象,可以向map中放入任何不重名的属性并自己实现解析,attribute_info表结构让包含它的表结构能够更好地扩展,在Java7中有21项已经由虚拟机预定义的属性,下面说明其中一部分:
预定义属性名称 | 可出现位置 | 概述 | 属性结构(attribute_info的info部分) | 类型 | 细节描述 |
---|---|---|---|---|---|
Code | method_info | 存放方法的字节码指令 | max_stack | u2 | 操作数栈大小 |
max_locals | u2 | 局部变量所需存储空间 | |||
code_length | u4 | 指令长度 | |||
code | u1 | 现在所有指令都是一个字节长度 | |||
exception_table_length | u2 | ||||
exception_table | exception_info | 用excetion表而非跳转指令处理异常 | |||
attibutes_count | u2 | ||||
attibutes | attibutes_info | ||||
Exceptions | method_info | 方法可抛出的异常(方法头中的throws部分) | number_of_exceptions | u2 | |
exception_index_table | u2 | 指向异常类型CONSTANT_Class_info | |||
LineNumberTable | Code属性表 | 保存源码行号与字节码行号关系(常用于异常打印源码行号以及断点调试) | line_number_table_length | u2 | |
line_number_table | line_number_info | 包含了两个u2类型的数据项start_pc和line_number,分别对应字节码行号和源码行号 | |||
LocalVariableTable | Code属性表 | 用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系 | local_variable_table_length | u2 | |
local_variable_table | local_variable_info | 该表结构包含了局部变量的作用域范围、局部变量名称以及在局部变量表中的位置 | |||
ConstantValue | field_info | 用于虚拟机自动为静态变量赋值(只有static final修饰的基本类型或String类型变量才会生成该属性) | constantvalue_index | u2 | 指向常量区基本类型或String类型的常量 |
其他属性省略 |
异常表exception_info,当start_pc位置到end_pc位置(不包含end_pc)之间发生catch_type及其子类异常时,跳转到handler_pc位置去执行指令,finally子句会生成catch块个数 + 1个exception_info表(每一个catch的处理代码发生异常会对应一个exception_info,try中发生未被catch的异常对应一个exception_info),其具体结构如下:
名称 | 类型 | 数量 | 描述 |
---|---|---|---|
start_pc | u2 | 1 | 捕获异常的开始位置 |
end_pc | u2 | 1 | 捕获异常的结束位置 |
handler_pc | u2 | 1 | 异常发生时处理指令位置 |
catch_type | u2 | 1 | 可捕获的异常类型,指向CONSTANT_Class_info常量 |
指令介绍
JVM指令由一个字节的操作码和此操作码所需的参数(操作数)组成,对于一个明确的操作码,其对应操作数的字节长度是一定的。JVM采用面向操作数栈而非寄存器的架构,所以绝大多数指令都没有操作数,只有操作码。一条字节码指令并非一条真正的机器指令,虚拟机也没有保证一条字节码指令的原子性。
对于大部分与数据类型相关的指令,他们的操作码助记符中都有特殊字符来表明为那种数据类型服务,如i代表int,l代表long,b代表byte,c代表char,f代表float,d代表double,a代表reference。对于某组指定(如Tipush,T代表数据类型),如果每一种数据类型都需要一个指令(如bipush、sipush、iipush …),那么一个byte代表的256个指令可能会出现不够用的情况,所以对于某组指令通常会提供有限个类型的指令(如Tipush实际上只支持bipush与sipush),而对于不支持类型的指令可以在必要的时候通过特殊的指令向支持的类型进行转换。
对于带有操作数的指令(如Tload,操作数为变量位置),为了避免减少指令长度,把操作码和最常用的一些操作数列出来组成一个新的不含有操作数的指令(如iload 1是一个操作码与一个操作数组成的指令,其效果和一个无操作数的指令iload_1相同),当然这样的操作码和操作数的组合有限,否则256个指令早就被用完了。
各指令对应的助记符如下表:
指令分类 | 操作码 | 操作数 | 指令说明 |
---|---|---|---|
加载与储存指令 | iload、lload、fload、dload、aload | 操作的局部变量位置 | 获取一个局部变量并压入操作栈 |
iload_<n> 、lload_<n> 、fload_<n> 、dload_<n>、 aload_<n> | 同Tload,只是操作码自己包含了局部变量位置 | ||
istore、lstore、fstore、dstore、astore | 操作的局部变量位置 | 将栈顶元素弹出并保存到一个局部变量 | |
istore_<n> 、lstore_<n> 、fstore_<n> 、dstore_<n>、 astore_<n> | 同Tstore,只是操作码自己包含了局部变量位置 | ||
bipush、sipush | 常量值 | 将一个常量压入操作栈 | |
ldc、ldc_w、ldc2_w | 指向常量池的一个引用 | 从常量区取出常量压入操作栈,ldc与ldc_w都是从常量区取一个字长,ldc2_w取两个字长,ldc的操作数为1个字节,故只能在常量区寻址1-255的常量,ldc_w和ldc2_w的操作数为2字节 | |
aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f> 、dconst_<d> | 将一个常量压入操作栈,操作码自带常量,iconst_m1压入的-1,aconst_null压入null | ||
运算指令 | iadd、 ladd、 fadd、 dadd | 弹出栈顶两个元素相加,并将运算结果入栈 | |
isub、 lsub、 fsub、 dsub | 弹出栈顶两个元素相减,并将运算结果入栈 | ||
imul、 lmul、 fmul、 dmul | 弹出栈顶两个元素相乘,并将运算结果入栈 | ||
idiv、 ldiv、 fdiv、 ddiv | 弹出栈顶两个元素相除,并将运算结果入栈 | ||
irem、 lrem、 frem、 drem | 弹出栈顶两个元素取余,并将运算结果入栈 | ||
ineg、 lneg、 fneg、 dneg | 弹出栈顶元素取相反数,并将运算结果入栈 | ||
ishl、 ishr、 iushr、 lshl、 lshr、 lushr | 弹出栈顶两个元素进行位移运算,并将结果入栈 | ||
ior、 lor | 弹出栈顶两个元素位或,并将运算结果入栈 | ||
iand、 land | 弹出栈顶两个元素位与,并将运算结果入栈 | ||
ixor、 lxor | 弹出栈顶两个元素异或,并将运算结果入栈 | ||
iinc | 两个参数,第一个为局部变量位置,第二个为常量值 | 变量自增一个常量值 | |
dcmpg、 dcmpl、 fcmpg、 fcmpl、 lcmp | 弹出栈顶两个元素进行比较,并将比较结果入栈(比较结果可能是0、-1或1),dcmpg与dcmpl不同之处在于,当与NaN(未定义或不可表示的值)比较时,dcmpg值为1,dcmpl值为-1 | ||
类型转换指令 | i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f | 弹出栈顶某个类型的元素强转为另一类型并压入栈顶,用来进行显式类型转换操作或用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题 | |
对象创建与访问指令 | new | 对象类型,常量池CONSTANT_Class_info的引用 | 创建完成后,将对象的引用压入栈 |
newarray | 单字节操作数atype(其值为4-11的数,分别代表不同的基本类型) | 弹出栈顶元素作为数组长度,创建一个基本类型的数组并将数组引用压入栈 | |
anewarray | 指向常量池CONSTANT_Class_info引用 | 弹出栈顶元素作为数组长度,创建一个引用类型(类型由操作数决定,如果创建的是N维数组,那么其元素就是N-1维数组)的数组并将数组引用压入栈 | |
multianewarray | 两个操作数,第一个操作数为指向常量池的数组类型CONSTANT_Class_info引用,第二个操作数为可确定长度的维度 | 创建多维数组。第一个操作数为数组类型而不像anewarray为数组元素类型;第二个参数为可确定长度的维度(在栈顶中有这么多个数会弹出用于创建空间),而非数组维度,如new[2][3][4][][]的第二个操作数为3,栈顶的三个元素分别为2,3,4,这三个会依次弹出用于创建数组。最后将数组引用入栈 | |
getfield、putfield、getstatic、putstatic | 指向常量池CONSTANT_Fieldref_info的索引 | getfield弹出栈顶元素并获取其成员变量(由操作数指定)压入栈;putfield弹出栈顶两个元素,并把栈顶第二个元素的成员变量(由操作数指定)设置为栈顶元素;getstatic从操作数指定的静态域获取值并压入栈;putstatic弹出栈顶元素并将操作数指定的静态域设置为该值 | |
baload、caload、saload、iaload、laload、faload、daload、aaload | 弹出栈顶两个元素,将数组(由栈顶第二个元素指定)的第n个元素(由栈顶元素指定)入栈 | ||
bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore | 弹出栈顶三个元素,将数组(由栈顶第三个元素栈顶)的第n个元素(由栈顶第二个元素指定)的值设为栈顶元素 | ||
arraylength | 弹出栈顶的数组引用,并将数组的长度压入栈 | ||
instanceof、checkcase | 指向常量池CONSTANT_Class_info | instanceof弹出栈顶元素,检查其是否属于操作数指定的类型(结果为1或0),将结果压入栈;checkcase检查栈顶元素(不会出栈)是否属于操作数指定的类型(不属于会抛出ClassCastException异常) | |
操作数栈管理指令 | pop、pop2 | pop弹出栈顶元素,pop2弹出栈顶两个元素 | |
dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2 | dup复制栈顶元素插入栈顶;dup_x1复制栈顶第二个元素压入栈顶;dup_x2复制栈顶第三个元素压入栈顶;dup2复制栈顶两个元素压入栈顶;dup2_x1复制栈顶第二三个元素压入栈;dup2_x2复制栈顶第三四个元素压入栈 | ||
swap | 将栈顶两个元素交换位置 | ||
控制转移指令 | if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne | 新指令位置 | 弹出栈顶两个元素进行比较,如果满足条件就跳转到新指令位置继续执行 |
ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull | 新指令位置 | 弹出栈顶一个元素与0进行比较,如果满足条件就跳转到新指令位置继续执行 | |
tableswitch、lookupswitch | {匹配值1:新指令位置1, 匹配值2:新指令位置2,...[default:新指令位置n]} | 用于处理switch语句,当满足某个匹配值时跳转到相应的新指令位置。tableswitch弹出栈顶元素index并找到table的第index行的新指令位置(也就是说编译期间就已经确定了位置,效率高),而lookupswitch弹出栈顶元素key并查找对应case为key的行的新指令位置 | |
goto、goto_w | 指令位置 | 跳转到操作数指定的指令位置,goto为操作数为两字节,goto_w操作数为4字节,两者仅仅寻址能力有差别。另外有跳转指令jsr、jsr_w、ret用于异常处理,但有了异常表后,这些指令已被废弃 | |
方法调用指令 | invokevirtual | 指向常量池CONSTANT_Methodref_info | 详见执行引擎部分方法调用 |
invokeinterface | 指向常量池CONSTANT_InterfaceMethodref_info | 详见执行引擎部分方法调用 | |
invokespecial | 指向常量池CONSTANT_Methodref_info | 详见执行引擎部分方法调用 | |
invokestatic | 指向常量池CONSTANT_Methodref_info | 详见执行引擎部分方法调用 | |
invokedynamic | 指向常量池CONSTANT_InvokeDynamic_info | 详见执行引擎部分方法调用 | |
ireturn、lreturn、freturn、dreturn、areturn、return | 方法返回 | ||
异常指令 | athrow | 将栈顶的异常对象弹出并到异常表匹配 | |
同步指令 | monitorenter、monitorexit | monitorenter弹出栈顶引用对象并进入其管程(synchronized开始),monitorexit弹出栈顶引用对象并退出期管程(synchronized结束),会生成一个异常表解决synchronized块中异常需要调用monitorexit问题 |
类加载机制
虚拟机把描述类的数据从Class文件加载到内存, 并对数据进行校验、转换解析和初始
化, 最终形成可以被虚拟机直接使用的Java类型, 这就是虚拟机的类加载机制。
类加载过程
类从被加载到虚拟机内存中开始, 到卸载出内存为止, 它的整个生命周期包括: 加载 、连接(细分为验证、准备 、解析三个阶段) 、初始化、使用、卸载。从加载到初始化的过程为类加载过程。
加载过程主要有三个阶段,首先根据类全名找到定义该类的二进制字节流(字节流来源可以是网络、class文件、jar包、代码生成等,可以通过自定义加载器的loadClass方法来实现从不同源获取字节流),然后将字节流代表的静态数据结构转换为方法区的运行时数据结构,最后在内存中创建一个Class对象作为访问方法区中该类的各种数据结构的访问入口(Class对象1.6在永久代中,1.7之后在堆中)。
验证是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,该阶段可能抛出java.lang.VerifyError异常。主要验证分为四个阶段:文件格式验证(魔数、版本号、常量类型等,该次验证发生在加载过程的读取二进制流与存入方法区两者之间,无法通过文件格式验证是不会建立方法区运行时数据结构的,后面的验证都是基于方法区运行时数据结构的)、元数据验证(除了Object类必须有父类、父类是否是final、重载了final方法等,该阶段保证不存在不符合Java语言规范的元数据信息)、字节码验证(跳转指令在方法内跳转、指令操作的栈元素的类型符合指令要求等,该阶段通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件)、符号引用验证(符号引用是否可被当前类访问等,验证发生在解析阶段)。如果我们的字节码以及第三方包是没有问题的(我们通过编译器编译得到的字节码通常是没有问题的),为了提高类加载效率,可以通过-Xverify: none禁止掉验证过程。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里分配的是类变量而非实例变量的内存,即static变量。这里的初始化先初始为0值(对象0值为null,数字型0值为0,boolean的0值为false),如果该字段有ConstantValue属性,那么用该属性值初始化。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,虚拟机实现可以对第一次解析的结果进行缓存,JVM规范并不强制要求该过程发生在类加载过程,可以在运行到相应代码需要解析时再解析。
初始化的过程是执行类构造器<clinit>的过程,<clinit>方法由编译器自动收集非final修饰的static变量以及静态语句块生成。<clinit>是编译过程中生成的, 其中代码包括static变量的初始化(static变量的初始化有两种方式,非final字段放入clinit函数中在初始化阶段进行初始化,final字段使用字段的ConstantValue属性在准备阶段进行初始化)与静态块的初始化,这两者按在源码中的先后顺序排列,<clinit>不需要显式调用父类的<clinit>方法,虚拟机会确保一个类调用<clinit>方法的时候其父类的<clinit>方法已经调用完成,同一个类加载器加载的类,其<clinit>方法只会执行一次。如果一个类没有初始化过,有且仅有5种情况会触发对类的初始化:
- 遇到new、getstatic、putstatic、invokestatic时。如果是SonClass.SuperStaticField或者SonClass.SuperStaticMethod(),那么只会初始化父类(除非配置了-XX: +TraceClassLoading才会同时初始化子类)。
- 使用java.lang.reflect包的方法对类进行反射调用的时候。
- 初始化子类前必须保证其父类已经初始化。
- 虚拟机启动时,要执行的主类会被初始化。
- java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄。
类加载器
Java源代码通过编译,得到虚拟机能够识别的字节码文件,在虚拟机运行过程中需要用到某个类时,通过类加载器分析该类及其依赖类的字节码文件,并在系统中生成Class类的对象。每个java程序至少会有三个类加载器,引导类加载器BootstrapClassLoader、扩展类加载器ExtensionClassLoader和应用类加载器AppClassLoader(也叫系统类加载器),这三个名字并非其真实类名,而且开发人员可以自己定制类加载器,除了引导类加载器,所有的加载器都是ClassLoader的子类。
每个加载器都对应着一个命名空间,同一个加载器的命名空间中不能出现相同的类全名(由于双亲委派机制,有直系血缘关系的任意两个加载器中不可能出现同名的类,除非打破双亲委派机制),但不同空间可以。对于虚拟机,要判断两个类是否相同,不仅看类全名,而且看该类的加载器是否相同,也就是命名空间是否相同,其实就是看该Class对象内存地址是否相同,就算是同一个class文件,也可能被不同的加载器加载到不同的地址,当然同一个加载器加载类全名相同的类两次,实际上第二次只会寻找到就返回。在某个类中,只有加载该类的加载器及其父加载器加载的类才是可见的(可以被引用),而包可见属性指的是运行时包,也就是同一个包且由用一个加载器加载(就算是被父加载器加载的也不行)的类才能访问包可见代码。
类加载器也是Java类,因此类加载器本身也是要被类加载器加载的,显然必须有第一个类加载器不需要被加载,这个加载器就是BootstrapClassLoader,它不是java类,是JVM启动核心包含的一段代码。因为引导类加载器不是一个java类,所以很多平台的JVM在其他地方获得加载器的时候如果得到的是这个引导类加载器,那么实际得到的将会是个null,如扩展类加载器的父类是引导类加载器,那么扩展类加载器的getParent()就返回null。
类加载的过程实际上是寻找到字节码加载到内存中生成Class类对象的过程,一般寻找字节码的方式有直接根据包名对应的路径在本地文件系统寻找、从网络流中寻找、从jar等压缩包寻找和动态将源文件编译成class文件得到字节码等,开发者可以自己实现类加载器来定义寻找方式,甚至在得到序列后可以进行特定的解密和验证来得到真正的字节码,之后生成Class对象的过程开发人员一般不自己改动。
一个加载器的加载器(Object的getClassLoader()方法获得)和父加载器(ClassLoader的getClassLoader()方法获得)是没直接关系的,类加载器是Class类的属性,它表示的是加载该类的ClassLoader对象,因为加载器本身也是一个类,所以它被加载到内存形成一个Class对象的时候也有一个加载器对象,而父加载器只有ClassLoader实例才有,他是ClassLoader实例的属性,默认是应用加载器,但创建该加载器时是可以指定的,它是为了便于管理加载器以及实现加载器的双亲委托机制。加载器的加载器和父加载器都是一个加载器的实例对象,而被加载的是类对象,子加载器却是一个加载器的对象。从ClassLoader的无参构造函数可以看到,默认的父加载器是系统加载器。一个加载器的加载器和其父加载器并不一定是同一个加载器,如扩展加载器和应用加载器都是由引导类加载器加载的,但扩展加载器的父加载器是引导类加载器,而应用加载器的父加载器却是扩展类加载器。
加载器既然具有父子结构,那么就构成了一颗树,根是引导类加载器,从根开始依次为引导类加载器->扩展类加载器->系统加载器->各类自定义加载器,当然这些都是默认的关系,我们可以在调用构造函数时直接指定父加载器,从而将其挂在树的相应位置。
加载器的loadClass方法中就实现了双亲委派机制,用某个加载器去加载类,首先会使用其父加载器去加载,只有在父加载器加载失败的时候才会调用自身去加载,如果还是失败就抛出找不到类异常,这样一定会首选使用引导类加载器去加载类。委托机制的好处是提高软件系统的安全性,因为如果用户自定义的类与由父加载器可加载的可靠类全名相同,加载器不会加载到自定义的类,从而防止不可靠甚至恶意的代码代替由父加载器加载的可靠代码,而且父加载器加载的包可见代码,子加载器加载的代码就算伪造为相同的包也无法调用。loadClass函数实现了委派机制(该方法为典型的模板方法模式),一般不要去改动。
各个加载器的找寻字节码的方式如下,启动类加载器加载只会寻找jre/lib/rt.jar包里的所有class文件,扩展类加载器寻找jre/lib/ext/(System.getProperty(“java.ext.dirs”)得到的目录)目录下的所有jar包中的class文件,系统类加载器只会在classpath目录下及其目录下的jar包中寻找class文件。虚拟机并不是用java写的(也不可能用java写,否则java程序和虚拟机形成相互依赖的死锁,实际是C++写的),在虚拟机启动时(标准的java启动过程,有些框架可以自己修改虚拟机启动过程),执行其他语言编写的程序,搭建java运行环境,这会将$JAVA_HOME/jre/rt.jar下的所有类加载到系统中,构造出java运行最基本的类对象,这个构造java运行最基本的类对象的过程就是引导类加载器加载类的过程,可见引导类的概念是虚拟的,并非一个实际的类,他加载类的过程实际上是一段其他语言实现的代码构造类对象的过程,所以引导类加载器不存在父加载器,而其自身也无对应的Class对象,所以它直接加载的类通过getClassLoader方法将返回null,而且其子加载器调用getParent也只能返回null;引导加载器加载的过程中加载了一个扩展类加载器,该加载器在引导类加载器加载完后会负责加载$JAVA_HOME/jre/lib/同-Djava.ext.dirs指定目录下的jar包里的类;扩展类加载器加载的过程中加载了一个应用类加载器,该加载器负责在扩展类加载器加载完后加载classpath指定的目录中的class文件和jar文件中的类。
当某个类需要加载时,可以指定某个类加载器去加载该类,默认使用当前类加载器(引发此次加载的类被加载时使用的类加载器)。
破坏双亲委派机制-线程上下文加载器,如果某个类被某个加载器加载,而在该类中需要加载的另一个类无法通过该加载器进行加载,就需要显示指定一个类加载器进行加载,这在SPI框架(如JNDI、JDBC等)中比较常见。Java中很多SPI框架都存放在rt.jar包中,而服务方对SPI的实现作为第三方包大多放在classpath下,由于双亲委派机制的存在,SPI框架的代码都由启动类加载器加载,当SPI框架需要加载第三方包时,只能指定一个类加载器对第三方包进行加载,由于SPI框架是通用框架,无法预料第三方实现以何种方式提供(可能是jar包、也可能是网络直接序列),所以不应该硬性指定类加载器,可以通过参数的形式传入,但在java中一般通过当前线程上下文获取类加载器。通过线程的getContextClassLoader与setContextClassLoader进行线程上下文加载器的获取和设置,在新建线程时默认与父线程的类加载器相同,最初的线程默认为系统类加载器。可见,线程上下文加载器并没有破坏双亲委派机制,而是对双亲委派机制的补充,它和双亲委派机制根本就是毫无关系的两个维度。只有重写了ClassLoader的loadClass方法才可能破坏双亲委派机制。
类加载器还可以用来加载器其他资源,如图片,配置文件。
// 除了引导类加载器,所有加载器的父类。
public abstract class ClassLoader {
protected ClassLoader(ClassLoader parent); // 指定父加载器。
protected ClassLoader(); // 以getSystemClassLoader()返回的加载器作为父加载器。
public final ClassLoader getParent();
public static ClassLoader getSystemClassLoader(); // 获取应用加载器
public Class loadClass(String name) // loadClass(name, false);
protected Class loadClass(String name, boolean resolve) { // 加载类,实现了双亲委派机制,resolve加载过程中是否连接。
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // 查看类是否已经被当前加载器加载过
if (c == null) {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
if (c == null) {
c = findClass(name); // 由本加载器进行加载
}
}
// 加载过程中是否连接
if (resolve) {
resolveClass(c);
}
}
return c;
}
protected final Class findLoadedClass(String name); // 查看当前加载器是否加载过指定类
protected Class findClass(String name); // 该方法负责真正地加载类,自定义ClassLoader一般只需要实现该方法,该方法首先获字节码序列,然后直接调用defineClass方法生成Class对象
protected final Class defineClass(String name, byte[] b, int off, int len); // 将字节码序列转换为Class对象,类加载过程主要是通过该方法完成
protected final Class defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain);
protected final Class defineClass(String name, java.nio.ByteBuffer b, ProtectionDomain protectionDomain);
protected final Class findSystemClass(String name);
protected final void resolveClass(Class c); // 连接
protected URL findResource(String name); // 获取名为name的资源,由具体加载器实现
public URL getResource(String name); // 用findResource寻找资源,但实现了双亲委派机制
public InputStream getResourceAsStream(String name);
public Enumeration<URL> getResources(String name); // 获取当前加载器及其所有祖先加载器可获取的所有资源
public static URL getSystemResource(String name); // getSystemClassLoader().getResource(name)
public static InputStream getSystemResourceAsStream(String name); // getSystemClassLoader().getSystemResourceAsStream(name)
public static Enumeration<URL> getSystemResources(String name); // getSystemClassLoader().getSystemResources(name)
}
Java agent
在类加载过程中,如果需要对字节码进行处理,那么可以自定义ClassLoader来完成这项工作,但这样做存在两点问题,首先,自定义的ClassLoader作为框架层,很可能被业务层的需求所污染,其次,由于双亲委派机制的存在,无法处理父ClassLoader加载的类。JVM在类加载过程中提供了扩展接口在类加载过程中进行拦截,业务可以自定义ClassFileTransformer来完成拦截功能。JVM中应该存在一个单例的Instrumentation实例,该实例可用于ClassFileTransformer的注册,可以通过Java agent方式来获取系统中的Instrumentation实例。
public interface Instrumentation {
//注册一个Transformer,从此之后的类加载都会被Transformer拦截,Transformer可以直接对类的字节码byte[]进行修改
void addTransformer(ClassFileTransformer transformer);
//对JVM已经加载的类重新触发类加载,使用的就是上面注册的Transformer。
// retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/成员属性
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
// 获取一个对象的大小
long getObjectSize(Object objectToSize);
// 将一个jar加入到bootstrap classloader的classpath里
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
// 获取当前被JVM加载的所有类对象
Class[] getAllLoadedClasses();
}
// 字节码拦截处理
public interface ClassFileTransformer {
byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer);
}
Java agent有两种实现方式,一种方式是JVM启动时指定启动参数,另一种方式是通过Attach API,第二种方式能在不重启虚拟机的条件下完成。无论是哪种方式,首先都需要制作一个jar包,并在其manifest文件中指定入口类,如MyAgent。
// jar包中Java agent的入口类
public class MyAgent {
// 以vm参数的形式载入时需要实现该方法,同时需要在jar包的manifest中配置条目Premain-Class:package.MyAgent
public static void premain(String agentArgs, Instrumentation inst);
// 以Attach的方式载入时需要实现该方法,同时需要在jar包的manifest中配置条目Agent-Class:package.MyAgent
public static void agentmain(String agentArgs, Instrumentation inst);
}
以JVM参数形式实现Java agent,只需要在java启动命令中加入-javaagent:**.jar参数即可,这样就会在执行main函数之前执行该jar包的manifest文件中Premain-Clas条目指定类的静态方法premain(…)。
以Attach API方式实现Java agent,可以在额外的进程中完成,当目标JVM启动后(目标JVM是一个普通的JVM,没有任何额外的代码),由额外的进程发送信号给目标JVM,由目标JVM来执行MyAgent的agentmain(…)方法。额外进程一般是另一个java程序,其代码示例如下。
// VirtualMachine是与目标JVM沟通的桥梁,其定义在JDK的lib/tools.jar中,JRE中无此包,所以需要额外引入该jar包才可进行编码
// VirtualMachine.attach的参数是目标JVM的进程pid,可以通过返回的VirtualMachine与目标JVM进行通信
VirtualMachine vm = VirtualMachine.attach("1234");
try {
// 指定agent的jar包路径,发送给目标JVM,由目标JVM执行该jar包的manifest文件中Agent-Class条目指定类的静态方法agentmain(...)
vm.loadAgent(".../agent.jar");
} finally {
// 断开与目标JVM的通道
vm.detach();
}
执行引擎
JVM的执行引擎在执行class字节码时有解释执行与编译执行两种方式,Hotspot虚拟机使用两者并存的方式,编译执行热点代码,解释执行其他代码,这里主要讨论解释执行。
JVM的执行引擎是基于栈结构而非寄存器,也就是说JVM指令依赖于栈中的数据而非寄存器中的数据。基于栈解释执行的具体过程可以参看上文运行时内存结构-虚拟机栈-操作数栈处,下面会具体讨论方法调用过程。
变量的静态类型与动态类型。如果有Parent var = new Son(),那么变量var的静态类型就是其引用类型Parent,在整个生命周期中,变量的静态类型是不会改变的,变量var的动态类型就是其实例对象的类型Son,变量的动态类型有可能发生变化,如将Parent的任一子类实例赋值给var,那么var的动态类型就变成了该子类类型。Java编译器只知道变量的静态类型,而变量的动态类型只有在运行时才知道。
JVM通过方法调用指令进行方法调用,方法调用指令包括操作码(JVM提供了5种与方法调用相关的操作码,下面的)和操作数(方法调用指令的操作数都为指向CONSTANT_Methodref_info或CONSTANT_InterfaceMethodref_info的引用,它包括方法所属类、方法签名(方法名+方法类型), invokedynamic除外)。Java源文件在编译成class文件时必须确定指令即操作码与操作数,操作码稍后介绍,这里看看如何确定操作数。如Parent parent = new Son(); parent.fun(“字符串参数”);由于在编译期间只能确定静态类型,所以操作数的类为Parent;如果Parent或其父类中有方法fun(String arg)那么操作数的方法签名就是它,否则找到最匹配方法的签名,也就是从多个同名的重载方法中(可能只有一个方法)找到最匹配的方法,如果找不到匹配的就编译异常。最匹配的优先级解释如下,对于引用类型,与自己血缘关系最近的祖先进程越能匹配;对于基本类型有如下顺序,char / (byte > short) > int > long > float > double > 对应封装类型 > 对应封装类型的祖先类型,处于前面的类型可以匹配处于后面的类型,但处于前面的类型无法匹配处于后面类型的封装类型(如byte无法匹配Integer);只有在前面两者都无法满足的情况下才会考虑可变参数方法的匹配。注意,操作数指定的类的某个方法并不带表它是运行时真正被调用的方法,运行时真正被调用的方法的方法签名一定与操作码的方法签名相同,但运行时被真正被调用方法所属的类却不一定是操作码的类。
方法调用指令的核心在于如何根据方法调用指令的操作数找到被调用方法的入口地址(就是从符号引用找到直接引用的过程)。JVM针对不同的调用场景提供了5个方法调用相关的操作码。
操作码 | 操作数类型 | 栈数据 | 说明 | java源码编译 |
---|---|---|---|---|
invokestatic | CONSTANT_Methodref_info | [arg1, arg2, …] → result | 从操作数(类 + 方法签名)便能找到方法的入口地址 | 调用static方法 |
invokespecial | CONSTANT_Methodref_info | objectref, [arg1, arg2, …] → result | 从操作数(类 + 方法签名)便能找到方法的入口地址 | 调用实例构造器<init>方法、私有方法和父类方法 |
invokeinterface | CONSTANT_InterfaceMethodref_info | objectref, [arg1, arg2, …] → result | 需要根据动态类型 + 操作数(方法签名)找到方法的入口地址 | 通过接口引用调用方法 |
invokevirtual | CONSTANT_Methodref_info | objectref, [arg1, arg2, …] → result | 需要根据动态类型 + 操作数(方法签名)找到方法的入口地址 | 通过非接口引用调用非static且非special方法 |
invokedynamic | CONSTANT_InvokeDynamic_info | [arg1, arg2 …] → result | Java7开始加入的新操作码 | 通过java代码编译无法得到该指令 |
从上表可以看出,所有的方法调用指令(invokedynamic除外)最终都是根据类全名+方法签名来寻找到直接地址,方法签名都是来源于操作码,类全名可能来源于操作码,也可能来源于操作数栈。对于invokespecial,其类全名下对应的方法签名一定是存在的;对于invokestatic、invokeinterface、invokevirtual,其类全名下对应的方法签名不一定存在,如果不存在就找其父类下对应的方法签名,一直到找到为止。
在实现上,根据类全名+方法签名寻找直接引用比较耗时,一般在方法区为每一个类维护一张虚方法表vtable(接口维护一张接口方法表itable),在类加载的解析阶段将类中各个方法的符号引用与直接引用的对应关系写入表中(如果有未被覆盖的祖先类方法,也写入该表中)。
高效并发
见多线程一章