既然要写java,那么jvm的运行机制还是得略知一二的。一方面搭建运行环境与程序设计需要,另一方面也能帮助理解一些绕的人头疼的脑经转转弯型的面试题。周志明老师在书中介绍了内存管理机制、虚拟机执行子系统、编译和代码优化以及并发方面的内容,而我这里只是将写在OneNote上内存管理机制以及类加载和执行相关的内容搬运过来。优化以及并发等年后再填坑吧。
内存管理机制
内存划分和异常
内存划分为:程序计数器PCR、虚拟机栈VM Stack、本地方法栈Native Method Stack、堆Heap、方法区Method Area(堆的一个逻辑部分)。其中除PCR没有任何OutOfMemoryError(OOM)外,Stack有OOM和StackOverflowError(SOF),其他只有OOM Error。
Stack和PCR是线程私有的,每个方法创建一个栈帧用于存放局部变量表、操作数栈、动态链接、方法出口等信息,当请求的栈帧总占用空间超过栈的大小的或者请求的栈深超过上限时候报SOF,当线程多到JVM发现没有空间给下一个线程分配stack的时候报OOM。
以上划分,在不同的JVM实现中有不同。以Hot Spot为例,本地方法栈和虚拟机栈被合二为一;使用老年代实现方法区;运行时常量池属于方法区。(这里需要注意,关于使用永久代实现方法区官方已有Native Memory实现的规划,JDK1.7的Hot Spot也把字符串常量池从老年代移出,日后有时间这里需要重新查阅资料整理一下。目前单纯就是复述书中的条目)
GC和内存分配策略
要明确,GC只发生在堆中。而凡被问到GC,无非就是要考虑三件事,谁要被回收,什么时候回收,如何回收。
首先解决谁要被回收的问题,两个基本思路:1.引用计数器;2.可达性分析。引用计数器记住一个反例,引用计数器不能处理多个没有任何入口的垃圾对象内部相互引用的情况。可达性分析即从GC Roots向下搜索,当目标可以被找到,存在引用链就被称为可达的。那么不可达的对象就是需要回收的垃圾。可以作为GC Roots的对象有,栈中引用的对象,方法区中静态引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象。
接下来对引用做个拓展,将引用分为四类:
强引用——存在即不可回收;
软引用——在即将OOM时可以列入GC列表;
弱引用——只生存到下一次GC;
虚引用——最弱,不会影响对象生存时间,只用于对象被GC时提供一个系统通知。到此,就基本解决谁要被回收的问题。不可达的、弱引用的、虚引用的、可能还有软引用的会被丢进回收队列。
然后要解决的就是什么时候回收。简单说来就是内存不足或者显式的调用了GC的时候。当然具体细节涉及到回收策略,需要和如何回收整合到一起讲。
介绍四种回收策略:
- 标记清除 标记待回收对象所在的内存空间,然后清除,缺点是低效和碎片化
- 复制 将被管理的内存空间划分成两部分,平时只使用一部分,当GC发生时,将不需要回收的对象拷贝到另一部分,然后抹除刚才是用的这一部分,缺点是存在空间浪费
- 标记整理 标记待回收对象所在内存空间,然后让活着的对象向一段移动,并清理掉边界之外的内存,比起标记清除没了碎片化的问题,但缺点还是低效
- 分代 将堆按照对象生存周期划分区域,然后对不同区域选择不同的回收策略。
商用虚拟机采用的回收器各不相同,但是基本回收策略就是以上四种。一般在宏观上,使用分代策略将堆空间分成新生代和老年代(或者叫永久代),然后新生代一般划为一块较大的Eden区和两块较小的Survivor区。新生代触发的GC一般都是使用复制算法的MirrorGC,耗时短影响小,而老年代触发GC则是整个堆进行标记整理的FullGC,对正常业务影响大。
那么JVM如何分配对象到什么区域上呢?有五条原则:
对象优先在Eden分配。由于对象大多朝生夕死,GC频繁,一般情况下,新对象都会优先在Eden进行分配,空间不够时触发新生代的MirrorGC。
大对象直接进入老年代。即需要大量连续空间的对象会直接进入老年代,由参数-XX:PretenureSizeThreshold控制。这么做是为了避免在Eden区及两个survivor区之间发生大量的内存复制。
长期存活对象进入老年代。新生代中的对象每熬过一次MirrorGC,‘年龄’+1,当到达阀值在下一次GC就会被安置到老年代,通过参数-XX:MaxTenuringThreshold控制阀值。
动态对象年龄判定。如果在Survivor中相同年龄所有对象大小的总和大于survivor空间的一半,年龄不小于该年龄的对象就会在下一次GC时被安置到老年代,无视存活年限阀值。
空间分配担保。由于Survivor比Eden要小,所以如果Eden中存活的对象大于Survivor的空间时,就会将超过部分暂时写入老年代作为复制时暂存地。即为老年代为MirrorGC做空间担保。当然也有可能存在老年代空间担保失败的情况,这时就会触发FullGC。这里可以推断出,新生代一定要比老年代小,而且要小很多,不然担保成功率必定非常低。
讲这么多,在我看来,其实就是需要我们在写代码的时候注意不要写一些体积庞大还朝生夕死的对象出来,这会频繁触发老年代FullGC。此外,一但对象不再使用,应立即将引用置空,方便垃圾收集齐标记垃圾对象。当然,对于运维人员,不同的场景或者要求高吞吐或者要求高延时,这样就会产生使用不同的垃圾收集器组合和参数调优,以及开发人员在对象大小方面的优化问题。不过这暂时不在我的讨论范围之内,只要知道有这么一会儿事儿就行了。
类加载
相较于内存管理和GC这种对开发人员透明的功能,类文件结构和类加载机制对代码编写的影响却是实打实看得见的。相信诸位在准备面试题的时候都会遇到类似父类和子类静态块还有构造函数谁先调用谁后调用谁不调用这样的脑经急转弯型的面试题。在看类加载机制之前,我一直是晕的。还有比如诸位不知有没有想过为啥有人会在公众号的面试经验中写到“表达式只能是整数类型,即必须是int,char或者枚举类型数据。不能是boolean或浮点型,甚至其他类型的整数数据(byte,short及long)”这样的话,难道byte、short、long真的不行么?String不行么,为什么?了解下类文件里相关字节码指令,然后用javap分析一下字节码就知道能不能用和为什么了。
类文件结构
类文件内容细碎,拿出来说篇幅过场,这里还是只记录我关心的一些写约定的缘由。
java中如果定义了超过64KB英文字符的变量或者方法名,将无法编译。因为class文件中的方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,而该常量最大只能到65535。(好像没啥用)
switch-case语句到底支持哪些类型?以java8为例,switch-case除了不支持两种浮点类型、布尔类型和Long类型外,byte、short、int、char、String、Enum都可以使用。对于switch-case语句,编译成字节码文件后,相关的执行指令是tableswitch和lookupswitch。这两个指令只支持int类型的操作,而byte、short、char编译器会在编译期就将其拓展为相应的int类型,因此可以直接使用。Enum则使用了一个转换步骤,将case中的每个选项映射到一个从1开始的int序列中去,然后执行tableswitch构建跳转表。至于String,虚拟机先对String求了hashcode,然后使用对应的hashcode作为参数执行lookupswitch指令构建跳转表。此外,除case的条件只能是上述类型外,作为传给switch的参数,还可以是对应的封装类型,编译器会自动调用Value方法将其转换为基本类型然后拓展成int类型构建跳转表。多说无益,这里上几个class文件的反汇编结果:
//switch(exp) exp为封装类型的情况
public void testswitch(Short s){
switch (s){
case 1: break;
case 2: break;
case 3: break;
}
}
public void testswitch(java.lang.Short);
Code: 0: aload_1
1: invokevirtual #16 // Method java/lang/Short.shortValue:()S
4: tableswitch { // 1 to 3
1: 32
2: 35
3: 38
default: 38
}
32: goto 38
35: goto 38
38: return
}
//switch(exp) exp为String的情况
public void testswitch(String str){
switch (str){
case "beijing": break;
case "shanghai": break;
case "guangzhou": break;
}
}
public void testswitch(java.lang.String);
Code:
0: aload_1
1: dup
2: astore_2
3: invokevirtual #16 // Method java/lang/String.hashCode:()I
6: lookupswitch { // 3
-747385589: 40
-297258560: 52
-227176258: 64
default: 73
}
40: aload_2
41: ldc #22 // String shanghai
43: invokevirtual #24 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
46: ifne 73
49: goto 73
52: aload_2
53: ldc #28 // String guangzhou
55: invokevirtual #24 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
58: ifne 73
61: goto 73
64: aload_2
65: ldc #30 // String beijing
67: invokevirtual #24 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
70: ifne 73
73: return
}
不知大家有没有注意到,switch的跳转表有两种指令:tableswitch和lookupswitch。编译器编译的时候,会根据case条件的不同翻译成tableswitch或者lookupswitch, lookupswitch在实现上会逐条比较case语句中的值,如果匹配,则跳转到相应的语句,tableswitch则适用于case中的值比较连续,譬如case为1,2, 3这种,tableswitch会存储最小值(如1)和最大值(如3),比较时会先判断是否在这个范围内,如果在这个范围内,则直接根据偏移量就可以 定位到时间目标语句,否则跳到default语句,tableswitch可以理解为是符合某种特征行为的lookupswitch的高效实现。那么再深挖一点,如果case既不离散又不连续咋办?jvm会给较为连续的case插值,构成一段连续的值之后再使用tableswitch。所以这里也牵扯出一条关于提高switch-case语句执行效率的原则:如果可以,请直接使用byte、short、int作为分支条件的数据类型,条件应当是连续的一段数字,不要设置成不连续的,如果认为case后接幻数语意不明,可以使用静态常量替代。因为使用枚举类、string都会带来额外的转换和比较开销。
类加载与执行机制
双亲委派模型:
优先找父类的加载器完成请求,如果无法完成加载,再调用本身的加载器进行类加载。如果要自定义加载器,应当重写到findClass()方法中。此外还因该注意到,即使同一个类同一个虚拟机,只要使用不同的加载器加载,这两个类必定不相等。这个结果会影响到equals()、isAssignableForm()、isInstance()、instanceof等判定结果。
类加载流程:
装载(Loading)→验证(verification)→准备(Preparation)→解析(Resoluation)→初始化(Initialization)→使用(Using)→卸载(Unloading)。脑经急转弯第一类,什么时候会对类进行初始化?有且只有五种场景会触发类初始化:
- 遇到new/getstatic/putstatic/invokestatic这四条字节码指令时。即使用new关键字实例化对象时(对应new字节码),读取或设置一个类的静态字段时(对应getstatic和putstatic,另外注意,静态常量在编译期会将结果放入常量池,此时其他对该字段的引用都会被优化器直接指向常量池中存放的结果,不会触发读取),调用一个类的静态方法的时(对应invokestatic)
- 使用反射对类进行调用的时候
- 当发现父类还没有初始化时(特殊的,接口初始化时并不要求父接口的所有方法都完成初始化)
- 虚拟机启动时,优先初始化包含main()的主类
- 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getstatic/REF_putstatic/REF_invokestatic的方法句柄,并且这个句柄对应的类没有初始化,则先触发该类的初始化
除过以上五种情况,其他所有引用类的方式都不会触发初始化。比如子类调用父类的静态字段时候,由于子类没有new一个实例,而对静态字段的定义又是在父类中完成的,getstatic会指向父类,所以子类不会初始化而父类会。
class Super{
static { System.out.println("SuperClass initialize"); }
public static int i = 123;
}
class Sub extends Super{
static { System.out.println("SubClass initialize"); }
}
public class InitializationTest{
public static void main(String[] args) {
System.out.println(Sub.i);//这里控制台只会输出 "SuperClass initialize"和123
}
}
再比如我们定义数组时,作为成员的类在以上五种情况之外并不会被初始化。即类似Class[] c = new Class[5]这样的声明数组的形式,不会触发Class的初始化。
还有个例子就是介绍场景时提到的,对静态常量的调用由于会被优化器直接指向常量池中存放的结果,所以也不会触发初始化。
public class Constant{
static { System.out.println(" Constant initialize"); }
public static final String GREETTING = "Hello";
}
public class InitializationTest{
public static void main(String[] args) {
System.out.println(Constant.GREETTING);//这里 Constant.GREETTING在编译期结束时已经被优化器替换为对常量池中Hello的直接引用,换句话说 InitializationTest和Constant这两个类已经没关系了,所以不会触发Constant类的初始化
}
}
局部变量不存在准备阶段:
局部变量不像类变量有准备阶段,所以不会被jvm自动赋予初值,使用局部变量时,应当注意声明的同时赋予其初值。IDE会帮我们检查这个问题,编译器也会拒绝编译。
分派:
面试时脑筋急转弯第二类,面对重写和重载,jvm到底会给我们的对象分派什么方法?
- 静态分派,这里主要解决重载分派问题。这里借用书中的代码
public class DispatchTest {
public void sayHello(Human human) { System.out.println("hello,guy"); }
public void sayHello(Man man) { System.out.println("hello,sir"); }
public void sayHello(Woman woman) { System.out.println("hello,lady"); }
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
DispatchTest dt = new DispatchTest();
dt.sayHello(man);
dt.sayHello(woman);
}
}
abstract class Human{ }
class Man extends Human{ }
class Woman extends Human{ }
结果当然是两行都显示"hello,guy"。借用书中对静态类型和实际类型的划分,我们将Human man = new Man()这句中Human称为静态类型,Man称为实际类型。在编译字节码的时候,编译器会按照静态类型去匹配方法中传入参数要求的类型,因为静态类型是编译期可知的。
当然,在分派的时候难免会出现可以有多种选择的情况,所以编译器对此有个优先级,对于基本类型按照自动类型提升的远近,越近的优先度越高。比如sayHi()方法有分别接受byte、short、int、long,那么当静态类型是byte时,编译器会按照从左至右的顺序使用找到的第一个方法。此外如果没有接受基本类型的版本则会将传入的参数提升为封装类型,如果封装类型也没有会找接受Serializable类型的方法,如果还失败就会寻找父类型,按照继承关系从下往上的顺序,如果这也没找到就找找有没有变长类型的。为了防止有变态出这样的题目,给个例子:
import java.io.Serializable;
public class OverloadTest {
public void sayHi(byte b) {System.out.println("Hi! byte");}//第一顺位
public void sayHi(short s) {System.out.println("Hi! short");} //第二顺位
public void sayHi(int i) {System.out.println("Hi! int");} //第三顺位
public void sayHi(long s) {System.out.println("Hi! long");} //第四顺位
public void sayHi(Byte b) {System.out.println("Hi! Byte");} //第五顺位
public void sayHi(Serializable s) {System.out.println("Hi! Serializable");} //第六顺位
public void sayHi(Object o) {System.out.println("Hi! Object");} //第七顺位
public void sayHi(byte... s) {System.out.println("Hi! bytes");} //第八顺位
public static void main(String[] args) {
OverloadTest ot = new OverloadTest();
ot.sayHi((byte)1);
}
}
- 动态分派,这里要解决重写的调用问题。继续看个常见面试题
public class DispatchTest {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHi();//"hi,sir"
woman.sayHi();//"hi,lady"
man = new Woman();
man.sayHi();//"hi,lady"
}
}
abstract class Human{
protected abstract void sayHi();
}
class Man extends Human{
@Override
public void sayHi() { System.out.println("hi,sir");}
}
class Woman extends Human{
@Override
public void sayHi() {System.out.println("hi,lady");}
}
相信大家这种题绝对不会错,不过这里还是要讲下原理,看下javap反汇编的结果:
public class testGit.DispatchTest {
public testGit.DispatchTest();
Code:
0: aload_0
1: invokespecial #8 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #16 // class testGit/Man
3: dup
4: invokespecial #18 // Method testGit/Man."":()V
7: astore_1
8: new #19 // class testGit/Woman
11: dup
12: invokespecial #21 // Method testGit/Woman."":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method testGit/Human.sayHi:()V
20: aload_2
21: invokevirtual #22 // Method testGit/Human.sayHi:()V
24: new #19 // class testGit/Woman
27: dup
28: invokespecial #21 // Method testGit/Woman."":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method testGit/Human.sayHi:()V
36: return
}
字节码invokevirtual起关键作用,原文在书第254页.这里简单说下:invokevirtual会去寻找对象的实际类型,然后寻找与常量中描述符和简单名称都相同的方法,找到就进行访问权限校验,通过后返回这个方法的直接引用,查找结束,不通过就返回IllegalAccessError。实际类型里没找到就按照继承关系由下到上一次对各个父类进行上述操作。还是没找到就报AbstractMethodError。
至此,复习结束,后面的等过年时有空继续学习。如有问题欢迎留言